openai-agents-python 沙箱 Shell 工具深度解析:exec_command 与 write_stdin 的架构、参数与实战
导读
本文基于 openai-agents-python 仓库中的 Shell Tool 参考文档(该文档由 ::: agents.sandbox.capabilities.tools.shell_tool 指令自动生成,实体内容位于 shell_tool.py),深入剖析沙箱(Sandbox)能力体系中的两个核心函数工具:exec_command(执行命令)与 write_stdin(向运行中的进程写入输入)。读者读完本文后,将掌握这两个工具的全部参数语义、底层执行链路(一次性执行与 PTY 交互式执行)、输出协议格式、路径解析规则、审批机制,并能直接借助仓库中的示例与测试写出可运行、可调试的沙箱 Agent 代码。
一、Shell 工具在沙箱能力体系中的位置
1.1 能力(Capability)机制
在 openai-agents-python 中,SandboxAgent 通过"能力"(Capability)来挂载沙箱原生的工具与行为。Capability 是基类(见 capability.py),它定义了 tools()、instructions()、bind()、bind_run_as()、bind_workspace_scope() 等钩子。
内置能力包括 Filesystem(apply_patch、view_image)、Shell(本文主角)、Skills、Memory、Compaction。Capabilities.default() 默认返回 [Filesystem(), Shell(), Compaction()](见 capabilities.py),也就是说:只要使用默认配置的 SandboxAgent,Shell 能力就已经被挂载;如果你显式传入 capabilities=[...],则会替换默认列表,需要自行把想要的默认能力补上。
注意:根据 docs/sandbox/guide.md 的说明,沙箱 Agent 目前处于 beta 阶段,API、默认值和支持的能力范围可能随版本变化。
1.2 Shell 能力暴露哪些工具
Shell 能力的 tools() 方法(见 shell.py)会构建一个 ShellToolSet:
toolset = ShellToolSet(
exec_command=ExecCommandTool(
session=self.session,
user=self.run_as,
workspace_scope=self.workspace_scope,
),
write_stdin=WriteStdinTool(session=self.session)
if self.session.supports_pty()
else None,
workspace_scope=self.workspace_scope,
)
关键结论(与 test_shell_capability.py 中的测试一一对应):
exec_command总是被暴露;write_stdin仅当会话支持 PTY 时才暴露(session.supports_pty()为真);ShellToolSet.write_stdin在非 PTY 会话下为None,此时能力只返回一个工具;configure_tools回调(Shell(configure_tools=...))可以在工具构建后自定义或替换它们,例如为exec_command追加审批策略。
Shell 还会通过 instructions() 返回一段注入给模型的提示片段(_SHELL_INSTRUCTIONS),原文如下:
When using the shell:
- Use `exec_command` for shell execution.
- If available, use `write_stdin` to interact with or poll running sessions.
- To interrupt a long-running process via `write_stdin`, start it with `tty=true` and send Ctrl-C (`\u0003`).
- Prefer `rg` and `rg --files` for text/file discovery when available.
- Avoid using Python scripts just to print large file chunks.
该片段会在运行准备阶段由 runtime_agent_preparation.py 收集所有能力的指令片段,合并进"Sandbox capability instructions"小节,最终与默认沙箱提示(见 prompt.md)一起构成模型收到的完整 system prompt;能力工具则被追加到 agent.tools 之后。
二、exec_command:参数详解与执行流程
ExecCommandTool(tool_name = "exec_command")的入参模型是 ExecCommandArgs(见 shell_tool.py)。其完整参数如下:
| 参数 | 类型 | 默认值 | 约束 | 说明 |
|---|---|---|---|---|
cmd |
str |
必填 | min_length=1 |
要执行的 Shell 命令字符串。 |
workdir |
str | None |
None |
— | 可选的工作目录;不传时使用本轮运行的 cwd(SandboxRunConfig.cwd 决定的 workspace scope)。 |
shell |
str | None |
None |
— | 要启动的 Shell 二进制,例如 /bin/bash;不传时使用用户默认 Shell。 |
login |
bool |
True |
— | 是否以 -l/-i 语义运行 Shell;为 True 时对应 sh -lc。 |
tty |
bool |
False |
— | 是否分配 TTY;False 走普通管道,True 打开 PTY 以获得可交互进程。 |
yield_time_ms |
int |
10000 |
ge=0 |
等待输出多少毫秒后让出(yield),同时被换算为本次执行的超时时间。 |
max_output_tokens |
int | None |
None |
ge=1 |
返回输出的最大 token 数,超出部分会被截断。 |
2.1 工作目录解析(workdir)
_resolve_workdir_command(shell_tool.py)决定命令如何被包裹:
- 若
workdir为空 且 workspace scope 的cwd为None:命令原样执行; - 否则将
workdir锚定到 workspace scope 之下,经session.normalize_path规范化后,最终生成:cd <shlex 转义后的路径> && <cmd>
路径统一走 workspace_paths.py 的 coerce_posix_path / sandbox_path_str,反斜杠会被归一化为正斜杠(测试 test_exec_command_tool_normalizes_raw_backslashes_before_workspace_scope 验证了 src\project → src/project)。测试中可见实际效果:
- scope cwd 为
tasks/a、workdir为src/project→ 实际执行cd /workspace/tasks/a/src/project && pwd; - 绝对路径
workdir(如/mnt/shared-data)只有在通过Manifest(extra_path_grants=...)授予路径时才会被放行(见SandboxPathGrant,参考 docs/sandbox/guide.md 中关于extra_path_grants的说明)。
2.2 Shell 解析(shell / login)
_resolve_shell(shell_tool.py)的规则:
shell |
login |
结果 |
|---|---|---|
None |
True |
True(交给会话默认的 sh -lc 前缀) |
None |
False |
["sh", "-c"] |
/bin/bash 等 |
True / False |
["/bin/bash", "-lc"] / ["/bin/bash", "-c"] |
测试 test_resolve_shell_uses_plain_sh_when_login_is_false 与 test_exec_command_tool_wraps_workdir_and_uses_custom_shell 分别验证了这两种行为。
2.3 执行路径:PTY 优先,一次性执行兜底
run() 方法(shell_tool.py)的执行流程如下:
- 计算
timeout_s = yield_time_ms / 1000; - 组装
wrapped_command(含cd ... && ...前缀)与 shell 前缀; - 如果会话支持 PTY:
- 优先调用
session.pty_exec_start(wrapped_command, shell=..., tty=args.tty, user=..., yield_time_s=..., max_output_tokens=...); - 若抛出
ExecTransportError且该错误context.get("retry_safe") is True、同时tty=False,则自动降级为一次性执行(_run_one_shot_exec→session.exec),并在输出前附加提示:"PTY transport failed before the interactive session opened; fell back to one-shot exec."; - 若
tty=True或错误不可重试,则直接向上抛出(测试test_exec_command_tool_does_not_fall_back_for_tty_sessions、test_exec_command_tool_does_not_fall_back_for_non_retry_safe_transport_errors验证)。
- 优先调用
- 如果会话不支持 PTY:直接走
session.exec一次性执行。 - 超时:捕获
ExecTimeoutError/TimeoutError,输出 "Command timed out after X.XXX seconds.",exit_code置为None(测试test_exec_command_tool_formats_timeout_without_exit_code验证)。
底层 BaseSandboxSession.exec(见 base_sandbox_session.py)在 shell=True 时会把命令前缀为 sh -lc;若指定了 user,还会再前缀 sudo -u <user> --(见 _prepare_exec_command)。也就是说,SandboxAgent.run_as 会一路透传到这里的进程身份。
三、write_stdin:交互、轮询与中断
WriteStdinTool(tool_name = "write_stdin")的入参模型是 WriteStdinArgs(shell_tool.py):
| 参数 | 类型 | 默认值 | 约束 | 说明 |
|---|---|---|---|---|
session_id |
int |
必填 | — | 由 exec_command 返回的"运行中会话 ID"(PTY 进程会话标识)。 |
chars |
str |
"" |
— | 要写入 stdin 的字节;传空字符串即纯轮询,用于取回这段时间的新输出。 |
yield_time_ms |
int |
250 |
ge=0 |
写入后等待输出多少毫秒再返回。 |
max_output_tokens |
int | None |
None |
ge=1 |
输出 token 上限。 |
关键行为与限制(均有测试佐证):
- 只能在 PTY 会话上使用;对非 PTY 会话调用会抛
RuntimeError("write_stdin is not available for non-PTY sandboxes")(测试test_write_stdin_tool_rejects_non_pty_sessions)。 - 只有
tty=true启动的进程才有可写 stdin;否则返回结构化错误:"stdin is not available for this process. Start the command withtty=trueinexec_commandbefore usingwrite_stdin."(测试test_write_stdin_tool_formats_missing_stdin_error)。 session_id不存在时返回 "write_stdin failed: PTY session not found: ",exit_code=1(测试test_write_stdin_tool_formats_unknown_session_error)。- 中断长任务:按
_SHELL_INSTRUCTIONS的约定,先以tty=true启动,再用chars="\u0003"(Ctrl-C)写入即可向进程发送中断信号。
exec_command 与 write_stdin 配合的典型交互循环是:exec_command 启动交互式程序(如 Python REPL)→ 返回 Process running with session ID <id> → 模型通过 write_stdin 写入输入或轮询输出 → 进程结束返回退出码。
四、输出协议:稳定的结构化返回格式
两个工具最终都会调用 _format_response(shell_tool.py)生成统一的、对模型友好的文本块:
Chunk ID: <uuid 前 6 位十六进制>
Wall time: <秒,4 位小数> seconds
Process exited with code <exit_code> # 已结束
Process running with session ID <id> # 仍在运行(PTY 未结束)
Original token count: <N> # 仅在发生截断时出现
Output:
<命令输出(stdout 与 stderr 合并)>
细节说明:
- 输出合并:
_normalize_output将 stdout/stderr 以 UTF-8(errors="replace")解码后合并;两者都不为空时用换行连接(stdout 已以\n结尾则不重复插入)。测试test_exec_command_tool_uses_stdout_only_when_stderr_is_empty、test_exec_command_tool_uses_stderr_only_when_stdout_is_empty等覆盖了各种组合。 - Token 截断:
max_output_tokens生效时,走 token_truncation.py 的formatted_truncate_text_with_token_count(约 4 字节/token 估算),截断后会在块内附上Original token count: N,并可能在输出前加上Total output lines: N与截断标记(如…6 tok)。测试test_exec_command_tool_includes_original_token_count_when_truncating展示了max_output_tokens=2时的完整输出。 - Wall time:来自
time.perf_counter()的真实耗时,便于模型和开发者判断命令是否卡顿。
五、底层会话抽象与 PTY 资源管理
exec_command / write_stdin 并不自己实现进程管理,而是委托给绑定的 BaseSandboxSession:
exec(...):一次性执行,返回ExecResult(stdout, stderr, exit_code);pty_exec_start(...):启动 PTY(或普通管道)进程,返回PtyExecUpdate(process_id, output, exit_code, original_token_count);pty_write_stdin(...):向指定session_id写入 stdin 并收集新输出。
具体实现在 unix_local.py(UnixLocalSandboxSession):tty=True 时通过 os.openpty() + setsid/TIOCSCTTY 分配控制终端;tty=False 时用普通管道。PTY 资源管理常量定义在 pty_types.py:
| 常量 | 值 | 含义 |
|---|---|---|
PTY_YIELD_TIME_MS_MIN / MAX |
250 / 30000 | 单次收集输出的 yield 时间被钳制在 0.25s ~ 30s |
PTY_EMPTY_YIELD_TIME_MS_MIN |
5000 | 空输入轮询时 yield 至少 5s,避免空转 |
PTY_PROCESSES_MAX |
64 | 单会话最大并发 PTY 进程数,超出按 LRU 修剪 |
PTY_PROCESSES_WARNING |
60 | 达到 60 个进程时打日志告警 |
PTY_PROCESS_ID_MIN / MAX_EXCLUSIVE |
1000 / 100000 | 进程会话 ID 的随机分配区间 |
这解释了为什么 yield_time_ms 的实际生效值可能被后端钳制:例如 exec_command 默认 yield_time_ms=10000,但传入过小或过大的值都会在 clamp_pty_yield_time_ms 处被规整。
六、安全与审批:approval、run_as 与路径边界
6.1 工具级审批
ExecCommandTool 与 WriteStdinTool 构造时都接受 needs_approval(布尔值或异步回调),并通过 FunctionTool 的机制接入运行时的审批流。Shell(configure_tools=...) 是配置审批的推荐入口——因为能力在绑定会话后会被 clone() 成每轮运行的副本,测试 test_configure_tools_can_customize_shell_approvals_after_clone 展示了典型用法:
async def exec_command_needs_approval(ctx, params, call_id) -> bool:
return str(params["cmd"]).startswith("rm ")
async def write_stdin_needs_approval(ctx, params, call_id) -> bool:
return str(params["chars"]) == "\u0003" # 对 Ctrl-C 也要求审批
def configure_tools(toolset: ShellToolSet) -> None:
toolset.exec_command.needs_approval = exec_command_needs_approval
toolset.write_stdin.needs_approval = write_stdin_needs_approval
capability = Shell(configure_tools=configure_tools)
6.2 运行身份与路径边界
run_as:通过bind_run_as绑定后,所有 shell 动作以指定沙箱用户执行(底层sudo -u <user> --),配合 Manifest 中的User与Permissions实现文件级权限隔离(参考 docs/sandbox/guide.md 的 Permissions 一节)。workspace_scope:SandboxWorkspaceScope(见 workspace_paths.py)是"模型可见的相对路径基准",只改变相对路径的锚定,不改变会话的访问验证与文件系统边界。SandboxRunConfig.cwd会决定 scope,而exec_command、view_image、apply_patch的相对路径都从该 cwd 解析(见 docs/sandbox/guide.md 中关于cwd的说明)。
七、实战:让模型在沙箱中"边执行边交互"
仓库提供了两个可直接运行的示例,是理解 Shell 工具的最佳入口:
7.1 保持同一交互进程:examples/sandbox/unix_local_pty.py
unix_local_pty.py 演示了"同一个交互式 Python 进程"的用法:任务要求启动一个交互式 Python 会话,并在同一个会话里连续计算 5 + 5、再 +5。它通过 capabilities=[Shell()] 显式挂载 Shell 能力,并使用 ModelSettings(tool_choice="required") 强制模型先查看工作区:
agent = SandboxAgent(
name="Unix-local PTY Demo",
model=model,
instructions=(
"Complete the task by inspecting and interacting with the sandbox through the shell "
"capability. Keep the final answer concise. "
"Preserve process state when the task depends on it. If you start an interactive "
"program, continue using that same process instead of launching a second one."
),
default_manifest=_build_manifest(),
capabilities=[Shell()],
model_settings=ModelSettings(tool_choice="required"),
)
运行时,模型会先调用 exec_command(tty=true 启动 python),拿到 Process running with session ID ... 后,再通过若干次 write_stdin 写入表达式并轮询输出,最终保持单一进程完成全部计算——这正是 write_stdin 相比"每次新起进程"的核心价值。
7.2 自定义能力封装:examples/sandbox/misc/workspace_shell.py
workspace_shell.py 展示了一个更轻量的自定义能力 WorkspaceShellCapability:它不依赖 exec_command/write_stdin 这对工具,而是直接用 ShellTool 包裹 session.exec,并注入自己的指令片段。适合"只需要只读探查工作区"的场景,也说明 Shell 能力并非唯一选择——你可以基于 Capability 基类自由组装。
7.3 启动沙箱会话
以 basic.py 为模板,创建并运行沙箱会话的标准流程是:
from agents import ModelSettings, Runner
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
agent = SandboxAgent(
name="Sandbox Assistant",
model="gpt-5.6-sol",
instructions="Inspect the workspace before answering.",
default_manifest=Manifest(entries={...}),
capabilities=Capabilities.default(), # 包含 Shell()
model_settings=ModelSettings(tool_choice="required"),
)
client = UnixLocalSandboxClient()
sandbox = await client.create(manifest=agent.default_manifest)
async with sandbox:
result = await Runner.run(
agent,
"Summarize the sandbox project.",
run_config=RunConfig(sandbox=SandboxRunConfig(session=sandbox)),
)
Windows 上请改用
DockerSandboxClient或托管后端;本地 macOS/Linux 开发首选UnixLocalSandboxClient(参见 docs/sandbox/clients.md)。
八、行为验证:测试如何锁定契约
仓库的 test_shell_capability.py(共 879 行)对本文描述的行为做了系统验证,可作为"可执行文档"使用:
- 能力绑定:
Shell()未绑定会话时调用tools()抛ValueError;绑定后exec_command是FunctionTool;PTY 会话下同时暴露write_stdin。 - 参数与包裹逻辑:
cmd拼接cd前缀、shell/login组合、yield_time_ms→timeout换算(如 1500ms → 1.5s)。 - 输出格式:完整断言
Chunk ID、Wall time、退出码、会话 ID、Original token count与截断标记的精确文本。 - 降级与错误:PTY 启动失败且
retry_safe=True时降级一次性执行并附加提示;tty=True或不可重试时抛出;超时无退出码。 - write_stdin 边界:非 PTY 拒绝、未知会话、stdin 不可用等场景的结构化返回。
九、小结
exec_command 与 write_stdin 是 openai-agents-python 沙箱 Shell 能力的"双引擎":前者负责启动命令(一次性或 PTY 交互式),后者负责维持与运行中进程的双向通信。二者共享一套稳定的输出协议(Chunk ID / Wall time / 退出码 / 会话 ID / 截断计数),并深度接入能力的绑定、指令注入、审批与路径边界机制。理解它们,就理解了沙箱 Agent 执行任意命令、维护有状态进程、安全控制权限的核心路径。
进一步阅读:
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00