Repository navigation
Description
Describe the feature
I would like to bind each SandboxAgent run to a logical working directory inside a developer-owned live sandbox, with consistent relative-path behavior across sandbox capabilities.
The SDK currently supports sharing one live sandbox session, but every run uses the session-wide Manifest.root for filesystem path resolution:
exec_command(workdir=...)changes the directory for that shell command only.view_image("plot.png")resolves from the session workspace root.apply_patchpaths are workspace-root-relative, not relative to the shellworkdir.run_aschanges the OS identity and permissions, but not path resolution.
This behavior is documented, so I am filing this as a feature request rather than a bug.
Relevant current behavior:
exec_commandimplementsworkdiras a per-commandcdview_imageuses the session-wide workspace path policySandboxRunConfighas no run-level cwd or workspace view- The Sandbox Agents guide notes that
apply_patchpaths are relative to the workspace root rather than shellworkdir.
Production use case
Our application runs several trusted document-research agents concurrently. They share the same dependencies and do not require separate containers or security-grade isolation. Each agent only needs a reliable task-local directory:
/workspace/tasks/task-a
/workspace/tasks/task-b
We originally reused one Modal sandbox and assigned each child a task directory. This produced two related problems:
- Concurrent children sometimes chose identical workspace-root-relative filenames and overwrote or read each other's files.
- After forcing each child's shell commands into its task directory, filesystem capabilities still used the session root.
For example:
exec_command(
cmd="render input.pdf --output page.png",
workdir="tasks/task-a",
)
view_image(path="page.png")
# Looks for /workspace/page.png rather than
# /workspace/tasks/task-a/page.png.
apply_patch(path="notes.md", ...)
# Edits /workspace/notes.md rather than
# /workspace/tasks/task-a/notes.md.
We worked around this with capability-specific path-prefixing adapters, task-local temporary directories, and prompt instructions. That remained brittle because every path-bearing capability or custom tool had to implement the convention independently.
We eventually moved to one fresh Modal sandbox per child. That is reliable, but concurrent fan-out now multiplies sandbox startup overhead and active sandbox usage, and we sometimes approach our provider concurrency limits.
Desired behavior
The exact API is not important, but an illustrative shape might be:
run_a = RunConfig(
sandbox=SandboxRunConfig(
session=shared_sandbox,
cwd="tasks/task-a",
)
)
run_b = RunConfig(
sandbox=SandboxRunConfig(
session=shared_sandbox,
cwd="tasks/task-b",
)
)
await asyncio.gather(
Runner.run(agent_a, task_a, run_config=run_a),
Runner.run(agent_b, task_b, run_config=run_b),
)For run_a:
- shell commands without an explicit
workdirstart undertasks/task-a; - a relative shell
workdiris interpreted beneathtasks/task-a; view_image("page.png")readstasks/task-a/page.png;- an
apply_patchoperation onnotes.mdeditstasks/task-a/notes.md.
The scope should be bound to the run or its prepared capabilities, rather than implemented by mutating session-global cwd or path-policy state. That matters when multiple runs share the session concurrently.
Other desirable properties:
- Omitting
cwdpreserves current behavior. Manifest.root, session lifecycle, snapshots, and directBaseSandboxSessionoperations remain session-scoped.run_asand filesystem permissions remain orthogonal and continue to compose with the path scope.- Built-in workspace-backed capabilities have one consistent relative-path base.
- The design can support distinct scopes for concurrent runs sharing one developer-owned session.
Minimal model-free reproduction
Reproduction using UnixLocalSandboxClient
import asyncio
from agents.sandbox import Manifest
from agents.sandbox.capabilities.tools import (
ExecCommandArgs,
ExecCommandTool,
ViewImageArgs,
ViewImageTool,
)
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
PNG = (
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0l"
"EQVR42mP8/x8AAwMCAO+a84QAAAAASUVORK5CYII="
)
async def main() -> None:
client = UnixLocalSandboxClient()
session = await client.create(manifest=Manifest())
try:
async with session:
await session.mkdir("tasks/a", parents=True)
shell = ExecCommandTool(session=session)
view_image = ViewImageTool(session=session)
await shell.run(
ExecCommandArgs(
cmd=f"printf '%s' '{PNG}' | base64 -d > plot.png",
workdir="tasks/a",
)
)
print(await view_image.run(ViewImageArgs(path="plot.png")))
print(
type(
await view_image.run(
ViewImageArgs(path="tasks/a/plot.png")
)
).__name__
)
finally:
await client.delete(session)
asyncio.run(main())Output:
image path `plot.png` was not found
ToolOutputImage
The example uses the Unix-local provider, but our Modal integration encounters the same mismatch.
Non-goals
- This does not need to be a chroot or security boundary.
- It does not replace separate sandboxes for untrusted agents, different images, or genuine compute isolation.
- It does not require per-run mounts, users, groups, or environments.
- It does not address child-agent orchestration or result delivery.
- Deliberate access through absolute paths or
..could remain a separate policy decision; the immediate need is consistent handling of relative paths.
Activity
jacobbabula commentedon Aug 13, 2026
I traced this through the current sandbox preparation path. The clean boundary looks like a run-local path base bound to the cloned capabilities—not mutable session state, and not a second workspace root.
A focused contract would be:
cwdtoSandboxRunConfig, preserving the existing positional field order, and validate it only after the live/resumed/fresh session has been selected.workdir,view_image, and everyapply_patchpath, includingmove_to. Approval callbacks and diagnostics should see the effective target rather than an ambiguous raw spelling.cwdas convenience, not confinement:..may climb towardManifest.root, but cannot escape the existing workspace policy.Manifest.root, materialization, mounts, Skills/Memory layouts, snapshots, resume state, custom tools, and directBaseSandboxSessioncalls session-root-relative.cwd=Noneremains behaviorally unchanged.The regression suite should cover shell/image/patch consistency,
move_to, approval targets, existing absolute/traversal behavior (including Windows path forms), and two concurrent runs writing the same relative filename beneath different bases.If that boundary matches the intended API, I'm happy to put together the focused implementation. One point I'd like to confirm first: should
cwdfollow the entireRunnerinvocation across handoffs, or be selected per preparedSandboxAgent?feat(sandbox): add run-scoped working directory
Filimoa commentedon Aug 14, 2026
Implemented in #4421.
I took this through the full runtime and lifecycle path rather than treating it as a small
cwdplumbing change. The PR:exec_command,view_image, and everyapply_patchpath;cwdis omitted;Manifest.root, Skills, Memory, snapshots, direct session operations, andrun_asownership semantics intact;I also tested this beyond the unit suite: I ran a successful model-backed nested Python Skill smoke test and four concurrent configured-cwd runs against one live Modal sandbox. The Modal run used distinct task directories with shared session-owned Skill resources and confirmed that identical relative filenames stayed within their assigned task directories.
The complete repository format, lint, typecheck, and test stack passes, and the final patch went through two clean independent reviews.
cwdremains explicitly a convenience boundary rather than security confinement.I’m happy to contribute a follow-up docs PR.