OnWorks 和 Codex 对比
本文聚焦 Codex CLI 0.162.1 的请求结构、agent loop、stdout JSONL、resume/rollout、工具、沙盒与压缩机制。工具的 Python 等价实现见 codex-tool.html。
1. Codex 发给 LLM 的 Responses API input
我用本地 mock Responses API 抓过 0.162.1 的 codex exec 请求。请求体约 39KB,顶层字段如下:
{
"model": "mock-model",
"stream": true,
"input": [...],
"tools": [...],
"tool_choice": "auto",
"parallel_tool_calls": true,
"reasoning": { "summary": "auto" },
"store": false,
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": "<thread_id>",
"client_metadata": { ... }
}
1.1 第一轮 input
实测第一轮有 4 条 input。OnWorks 的完整 prompt 是第 4 条;前两条是 Codex CLI 自己加的开发者上下文,第三条是环境上下文:
[
{ "type": "message", "role": "developer", "content": [{ "type": "input_text", "text": "You are a coding agent running in the Codex CLI..." }] },
{ "type": "message", "role": "developer", "content": [{ "type": "input_text", "text": "<skills_instructions>..." }] },
{ "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "<environment_context><cwd>/tmp/.onworks/work</cwd>...</environment_context>" }] },
{ "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "你是 onworks AI 助手......\n\n# 对话历史......\n\n# 任务要求......\n\n# 交付要求......" }] }
]
tools 实测包含 exec_command、write_stdin、request_user_input、view_image、multi_agent_v1、goal 工具和 web_search。store:false 表示多轮状态不在 provider 侧保存。
1.2 agent-message 和工具循环
模型直接输出最终回答时,Responses SSE 包含 message item 的 created/delta/done 和 response.completed。Codex 把最终 message 转成 stdout JSONL:
{"type":"item.completed","item":{"type":"agent_message","id":"item_4","text":"已按订单创建日期汇总,共生成 6 个月度合计......"}}
模型要先执行工具时,第一轮 LLM 返回类似:
{"type":"response.output_item.done","item":{
"type":"function_call",
"name":"exec_command",
"arguments":"{\"cmd\":\"python3 - <<'PY'\\n...\",\"yield_time_ms\":10000}",
"call_id":"call_1"
}}
Codex 本地执行后,把原有 input、函数调用和工具输出一起放进下一次 LLM 请求:
[
...上一轮 4 条 input...
{ "type": "function_call", "name": "exec_command", "call_id": "call_1", "arguments": "..." },
{ "type": "function_call_output", "call_id": "call_1", "output": "{\"output\":\"...\",\"exit_code\":0}" }
]
模型可以继续返回工具调用,也可以返回最终 agent_message。一次 codex exec 可能对应多次 LLM HTTP 请求,但 OnWorks 只把它视作一个任务轮次。
2. Codex 返回 stdout JSONL
Codex 在 exec --json 模式下把内部事件写入 stdout JSONL。消费端可以选择只读 stdout,而不解析本地 rollout 文件。示例事件:
{"type":"thread.started","thread_id":"01a139a8-4fcb-7e42-a4b9-c2b0e7b1c010"}
{"type":"turn.started"}
{"type":"item.started","item":{"type":"command_execution","id":"call_1","command":"python3 ..."}}
{"type":"item.completed","item":{"type":"reasoning","id":"item_1","text":"..."}}
{"type":"item.completed","item":{"type":"command_execution","id":"call_1","exit_code":0,"aggregated_output":"..."}}
{"type":"item.completed","item":{"type":"file_change","id":"call_2","changes":[{"path":"/tmp/.onworks/work/output/summary.xlsx","kind":"add"}]}}
{"type":"item.completed","item":{"type":"agent_message","id":"item_4","text":"已按订单创建日期汇总,共生成 6 个月度合计......"}}
{"type":"turn.completed","usage":{"input_tokens":8284,"cached_input_tokens":0,"output_tokens":18}}
| 事件 / 字段 | 消费端处理 |
|---|---|
item.started + command_execution | 新增 running 的「执行命令」步骤。 |
item.completed + reasoning | 保存思考步骤,最多 4,000 字符。 |
item.completed + command_execution | 用退出码和输出前 6 行更新同 id 步骤。 |
item.completed + file_change | 记录变更路径,新增「写入文件」步骤。 |
item.completed + agent_message | 把 text 记为本轮最终答案。 |
turn.completed | 读取 usage,换算并记录 token。 |
thread.started | 当前实现不消费 thread_id。 |
3. 与 codex exec resume 的上下文对比
OnWorks 第二轮仍然启动:
codex exec --json --ephemeral ... -
并重新把历史写进 stdin。它没有第一轮的 thread_id,也不会把第一轮的 function_call/function_call_output 原样放回 LLM input。
我用同一 mock 服务测试了原生 resume。第一轮 exec round1 的 LLM input 是:
[0] developer message - Codex 基础 instructions,约 16,979 字符
[1] developer message - skills instructions,约 2,301 字符
[2] user message - environment_context
[3] user message - "round1"
随后执行:
codex exec resume --json --skip-git-repo-check <thread_id> 'round2'
第二轮 LLM input 变成 7 条:
[0] developer message - Codex 基础 instructions
[1] developer message - skills instructions
[2] user message - 第一轮 environment_context
[3] user message - "round1"
[4] assistant message - "ROUND_ONE_OK"
[5] user message - 第二轮 environment_context
[6] user message - "round2"
如果第一轮有工具,原生 resume 会继续保留工具调用/输出;OnWorks 只保留业务层历史。
| 方案 | 第二轮 LLM input | 优点 | 代价 |
|---|---|---|---|
OnWorks:exec --ephemeral | Codex 基础上下文 + 环境上下文 + OnWorks 重建后的完整历史 prompt | 无本地 session 状态、可审计、可迁移、适合多用户 | 丢失原生 rollout 里的工具轨迹;历史要自己压缩 |
| Codex 原生 resume | Codex 基础上下文 + 本地 rollout 还原的消息、工具调用、工具输出 + 新环境上下文/新 prompt | thread 状态完整,工具轨迹保真 | session 绑定 CODEX_HOME/宿主机,扩容需要共享存储或 sticky 路由 |
| app-server | 长驻协议维护 thread 状态 | 适合审批、交互和复杂 UI | 集成复杂度更高 |
4. Codex 沙盒和 OnWorks 外层 unshare
OnWorks 的 Agent 进程有两层互补的隔离。外层 unshare 不属于 Codex 沙盒,它包住整个 codex exec;Codex 沙盒再包住模型请求执行的每条工具命令。
4.1 执行 Agent 任务的完整命令、路径视图和流程
真实后端工作目录:
/home/work/workspace/work_desk/backend/app/media/work/s-123
外层 unshare 后 Codex 看到的视图:
/tmp/.onworks/work ← bind mount 自真实工作目录
/tmp/.onworks/home ← bind mount 自真实 CODEX_HOME
$HOME ← tmpfs,覆盖原 HOME
/proc ← 新 PID namespace 的 /proc
这里的 s-123 只是文档示例 ID。真实的新会话由 uuid.uuid4() 生成,所以实际目录形如 /home/work/workspace/work_desk/backend/app/media/work/<uuid>。/tmp/.onworks 是 unshare namespace 内的临时视图,不是宿主 shell 里常驻的目录。
backend/app/codex.py 的包装完整命令是:
unshare \
-r -m -p -f --mount-proc \
/bin/bash -c '
set -e
mount -t tmpfs none /tmp
mkdir -p /tmp/.onworks/work /tmp/.onworks/codex-home
mount --bind /home/work/workspace/work_desk/backend/app/media/work/s-123 /tmp/.onworks/work
mount -t tmpfs none /home/work
cat > /tmp/.onworks/codex-home/config.toml <<'ONWORKS_CODEX_CONFIG'
model = "<active_model>"
model_provider = "onworks"
[model_providers.onworks]
name = "OnWorks LLM"
base_url = "https://ark.cn-beijing.volces.com/api/v3"
wire_api = "responses"
env_key = "ONWORKS_LLM_API_KEY"
[shell_environment_policy]
inherit = "core"
ignore_default_excludes = false
exclude = ["ONWORKS_LLM_API_KEY", "ONWORKS_JWT_SECRET", "ONWORKS_DATABASE_URL"]
ONWORKS_CODEX_CONFIG
cd /tmp/.onworks/work
exec env CODEX_HOME=/tmp/.onworks/codex-home \
codex exec \
--json \
--ephemeral \
--skip-git-repo-check \
-C /tmp/.onworks/work \
-s workspace-write \
-c approval_policy="never" \
-c sandbox_workspace_write.network_access=true \
-
'
这里的 - 很关键:任务 prompt 不是放成命令行参数,而是由 OnWorks 写入 codex exec 的 stdin。一次任务大致走这些步骤:
- 把附件和上次产物准备到真实会话工作目录。
- 把完整 prompt、历史摘要和交付要求拼好,写入 stdin。
unshare创建新的 user / mount / PID namespace,并在里面启动 Bash。mount在新 mount namespace 内重新构造/tmp、/tmp/.onworks/work、/tmp/.onworks/codex-home、$HOME和/proc。exec把 Bash 替换成 Codex CLI,工作根是/tmp/.onworks/work。- Codex 调用 LLM,需要执行工具时再在内层沙盒里跑模型给出的命令。
- OnWorks 持续读取 stdout JSONL,把思考、命令、文件变更、最终答案和 token 用法保存到任务记录。
- 子进程退出后,OnWorks 从真实工作目录收集
output/和中间产物;namespace 随进程消失,真实目录中的交付文件保留。
对应关系可以这样记:unshare 是创建隔离环境的入口, Mount Namespace 是隔离挂载视图的内核机制, mount 是在新视图里实际调整 /tmp、bind mount 和 $HOME 的工具。OnWorks 不是“unshare 自动隐藏文件”,而是靠 unshare 得到新 namespace,再靠 mount 重排路径视图。
参数含义:
-r:进入 user namespace,把当前 uid 映射成 namespace 内 root,才有权限执行后面的 mount。-m:mount namespace。里面的 mount 只影响这个进程树。-p -f:PID namespace;-ffork 出 namespace 的第一个进程。--mount-proc:重新挂/proc,减少看到宿主机其他进程信息的能力。mount -t tmpfs none /tmp:给一个空/tmp,避免读到宿主机临时文件。mount --bind ... /tmp/.onworks/work:把本会话任务目录挂到固定路径。mkdir -p ... /tmp/.onworks/codex-home:在同一个空/tmptmpfs 上创建一次性CODEX_HOME。OnWorks 只写入最小 model/provider 配置,不 bind 真实~/.codex;历史 sessions、日志、SQLite、插件缓存和auth.json都不可见。这个目录随 mount namespace 消失。
当前实现不再把 CODEX_HOME bind 到受限视图,而是让 CODEX_HOME 指向 namespace 内的一次性目录。模型 provider 凭据仍通过 ONWORKS_LLM_API_KEY 交给 Codex 主进程;但生成的 shell_environment_policy 会把它排除在模型触发的工具 shell 之外,同时排除 ONWORKS_JWT_SECRET 和 ONWORKS_DATABASE_URL。这不是对 .codex 内容做行级过滤,而是不再让真实 .codex 进入任务视图。
4.2 Codex 内层工具命令沙盒和两层协作
本小节的结论在 Codex CLI 0.163.0(Ubuntu 24.04 / 内核 6.8)上实测得出,并用 strace 跟踪了全部 syscall;另已对照 rust-v0.166.0 源码复核。0.162.1 的 Linux 二进制里包含相同的实现标记(自带的 bwrap 资源、--as-pid-1、use_legacy_landlock 等),机制一致。
OnWorks 传给 CLI 的是:
-s workspace-write
-c approval_policy="never"
-c sandbox_workspace_write.network_access=true
workspace-write 的语义不是“全盘不可见”,而是:
- 通常可以读宿主文件系统;
- 写入被限制到 cwd/workspace 等允许根;
- 网络默认关闭,除非显式打开;
-C /tmp/.onworks/work就是 workspace root,因此在 OnWorks 里可写根正是外层 bind 出来的任务目录。
4.2.1 从 function_call 到目标 shell 的完整链路
Linux 内层沙盒不是“执行脚本时自动套上”,而是 Codex tool loop 在 spawn 边界显式构造出来的。以 exec_command 为例,精确链路是:
模型返回 function_call(exec_command, arguments)
→ ExecCommandHandler 解析 cmd/workdir/tty/sandbox_permissions
→ 解析 workdir 为 command-cwd;没有 workdir 时使用 turn cwd
→ 选用 shell,生成 /bin/bash -lc '…' 这类 argv
→ 合并 PermissionProfile / additional_permissions
→ 生成 approval requirement
→ ToolOrchestrator 做审批和 escalation
→ SandboxManager.select_initial() 选择平台沙盒
→ SandboxManager.transform() 生成沙盒启动参数
→ UnifiedExecProcessManager 分配 process_id / session_id
→ 选择 PTY 或 pipe,并 spawn codex-linux-sandbox
→ 外层 stage 构造内层 helper argv 和 Bubblewrap argv
→ 外层 stage execv Bubblewrap(同一 PID 的进程镜像变成 bwrap)
→ Bubblewrap 建立 mount/user/PID/IPC/按需 net namespace
→ Bubblewrap 启动内层 codex-linux-sandbox
→ 内层 stage 校验 capabilities,安装 PR_SET_NO_NEW_PRIVS + Seccomp BPF
→ 内层 stage fork;子进程 execvp 目标 shell,父进程 waitpid 并转发信号
→ Codex 收集 stdout/stderr 或 PTY 输出并截断
→ 返回 function_call_output 给模型
下面用 Codex CLI rust-v0.166.0 的关键源码说明这两个 helper stage 不是两个固定程序,而是同一个 Rust 可执行文件按参数进入不同阶段。
外层 stage 先把原命令包装成内层 --apply-seccomp-then-exec 调用,再交给 Bubblewrap:
// codex-rs/linux-sandbox/src/linux_run_main.rs
let inner = build_inner_seccomp_command(InnerSeccompCommandArgs {
sandbox_policy_cwd: &sandbox_policy_cwd,
command_cwd: command_cwd.as_deref(),
permission_profile: &permission_profile,
managed_network,
proxy_route_spec,
command,
});
run_bwrap_with_proc_fallback(...);
外层 stage 启动 Bubblewrap 时用 execv 替换自身进程镜像:
// codex-rs/linux-sandbox/src/launcher.rs
unsafe {
libc::execv(program.as_ptr(), argv_ptrs.as_ptr());
}
内层 stage 看到 --apply-seccomp-then-exec 后,先收紧权限,再 fork/exec 原命令:
// codex-rs/linux-sandbox/src/linux_run_main.rs
apply_permission_profile_to_current_thread(...)?;
let command_pid = unsafe { libc::fork() };
if command_pid == 0 {
reset_forwarded_signal_handlers_to_default();
signal_mask.restore();
exec_or_panic(command);
}
let signal_forwarders = install_bwrap_signal_forwarders(command_pid);
...
let reaped_pid = unsafe { libc::waitpid(-1, &mut status, 0) };
Seccomp 的安装顺序也在源码里很直接:先 PR_SET_NO_NEW_PRIVS,后安装 BPF:
// codex-rs/linux-sandbox/src/seccomp.rs
if network_seccomp_mode.is_some()
|| (apply_landlock_fs && !file_system_sandbox_policy.has_full_disk_write_access())
{
set_no_new_privs()?;
}
if let Some(mode) = network_seccomp_mode {
install_network_seccomp_filter_on_current_thread(mode, managed_network)?;
}
这里有两个 cwd 必须分开:
| 字段 | 来源 | 作用 |
|---|---|---|
--sandbox-policy-cwd | 通常是 turn cwd,也就是 codex exec -C 指定的目录 | 解析 :workspace_roots、相对策略路径、临时目录等符号路径;OnWorks 中是 /tmp/.onworks/work。 |
--command-cwd | 模型传入的 workdir;未传时默认等于 turn cwd | 命令的逻辑工作目录,Codex 会把它作为 helper 进程的 spawn cwd;只有当该路径规范化后和原始逻辑路径不同(例如 symlink alias 会消失)时,helper 才额外加 bwrap --chdir。 |
workspace_roots | 配置、-C 和 executor 环境信息 | 把 :workspace_roots 展开成真实可写路径。 |
permission_profile | -s read-only/workspace-write/danger-full-access 加上额外权限 | 描述文件系统和网络权限,序列化后通过 --permission-profile 传给 helper。 |
Codex 不预先复制或扫描一个“沙盒目录树”。它只把声明式策略和路径上下文交给 helper;helper 再调用 policy API 算出 readable roots、writable roots、deny paths,最后翻译成 Bubblewrap 的 --ro-bind、--bind、--tmpfs 等参数。模型命令看到的是这些 mount 在相同路径上的视图,不是另一份目录拷贝。
下面是一个独立复刻实验,用来回答三个问题:Codex 怎么知道目录?谁决定调用 helper?Bubblewrap/Seccomp 是不是固定触发?先造一个测试目录,其中根允许读、演示目录允许写、secret.env 显式拒绝:
/tmp/codex-sandbox-demo-trace-20261105-163427/
├── codex-linux-sandbox -> /…/vendor/x86_64-unknown-linux-musl/bin/codex
├── secret.env # TOP_SECRET,策略里 deny
├── probe.sh # 测试读取和写入
├── workspace/
│ ├── allowed.txt # old
│ └── probe-write.txt # 沙盒内写入后的 workspace-ok
├── outside/
│ └── other.txt # 只读可读,不可写
└── artifacts/
└── bwrap-cmdline.txt # 从 /proc/<bwrap pid>/cmdline 抓到的参数
把原生 Codex 二进制用符号链接命名为 codex-linux-sandbox 很关键:Rust 二进制按 argv[0] 分派,名字叫这个名字才进入 sandbox helper 模式。helper 收到的核心 JSON 是:
{
"type": "managed",
"file_system": {
"type": "restricted",
"entries": [
{ "path": { "type": "path", "path": "/" }, "access": "read" },
{ "path": { "type": "path", "path": "/tmp/codex-sandbox-demo-trace-20261105-163427" }, "access": "write" },
{ "path": { "type": "path", "path": "/tmp/codex-sandbox-demo-trace-20261105-163427/secret.env" }, "access": "deny" }
]
},
"network": "enabled"
}
外层 stage 的实际调用等价于:
codex-linux-sandbox \
--sandbox-policy-cwd /tmp/codex-sandbox-demo-trace-20261105-163427 \
--command-cwd /tmp/codex-sandbox-demo-trace-20261105-163427 \
--permission-profile '{…上面的 JSON…}' \
-- /tmp/codex-sandbox-demo-trace-20261105-163427/probe.sh
从 /proc 抓到的进程链是:
codex-linux-sandbox … -- /tmp/…/probe.sh ← 外层 helper,只有解析和包装
└─ bwrap --as-pid-1 --new-session --die-with-parent …
└─ codex-linux-sandbox … --apply-seccomp-then-exec -- /tmp/…/probe.sh
└─ /bin/sh /tmp/…/probe.sh
helper 自己算出下面这些关键 Bubblewrap 参数。这里省略了固定的保护性 .git/.agents/.codex/.aws 遮罩:
bwrap --as-pid-1 --new-session --die-with-parent
--ro-bind / /
--dev /dev
--bind /tmp/codex-sandbox-demo-trace-20261105-163427 /tmp/codex-sandbox-demo-trace-20261105-163427
--perms 000 --ro-bind-data 3 /tmp/codex-sandbox-demo-trace-20261105-163427/secret.env
--unshare-user --unshare-pid --unshare-ipc
--proc /proc --cap-drop ALL
--argv0 codex-linux-sandbox
-- <native codex> … --apply-seccomp-then-exec -- /tmp/…/probe.sh
执行结果也和参数对应:
| 探针 | 结果 | 说明 |
|---|---|---|
pwd | /home/work/workspace/work_desk | 这个直连 helper 的测试从该目录启动,虽然传了另一个 --command-cwd,但该路径没有需要 --chdir 的规范化差异;它证明 helper 期望真正的调用方先把 spawn cwd 设置好。 |
读 secret.env | Permission denied | deny 优先级高于根路径的 read;Bubblewrap 用零长度只读对象盖住原文件。 |
读 workspace/allowed.txt | old | / 整体以只读 bind 进入,因此能读。 |
写 workspace/probe-write.txt | 成功,内容 workspace-ok | 演示目录被 --bind 成可写,其余根路径仍是只读。 |
沙盒内 /proc/self/status | Seccomp: 2,filter 数量为 1 | 由内层 stage 在执行最终命令前安装;外层 helper 和 bwrap 自身没有装这个过滤器。 |
所以答案不是“执行脚本时默认执行 codex-linux-sandbox”,而是:策略选择触发。read-only/workspace-write 会让 exec 边界主动 spawn helper;helper 按 JSON 策略计算目录视图并找到/校验 bwrap;Bubblewrap 建好 namespace 和 mount 后再次进入同一个二进制;只有内层 stage 的 --apply-seccomp-then-exec 会安装 Seccomp BPF。danger-full-access 没有这些要求时则不会走这条包装链。
Linux 平台是否需要包装由策略决定:
read-only和workspace-write:进入SandboxType::LinuxSeccomp,再构造 Bubblewrap + Seccomp。danger-full-access且没有 managed-network 要求:通常是SandboxType::None,不启动 helper,也无 Bubblewrap/Seccomp。require_escalated且策略允许:审批通过后第一次尝试通常直接绕过沙盒;若策略包含 denied reads,则仍可能保留受限沙盒。- managed-network 或 denied-read 存在时,即使原本想绕过沙盒,也必须保留隔离。
运行中的 PTY 会话由 Codex 的 Unified Exec Manager 管理。exec_command 返回的 session_id 实际是这里的 process_id,后续 write_stdin 用它定位进程、写入 stdin、轮询输出。命令结束或超时后,Codex 汇总输出、生成 exit code,再交给 model loop。
4.2.2 工具命令的执行者:arg0 自我调用的「Linux sandbox helper」
Linux 上执行模型生成的每条工具命令时,Codex 主进程不自己动手,而是把命令交给一个 helper:同一个 codex 二进制以 codex-linux-sandbox 为 argv[0] 重新执行(arg0 分发;0.166 源码里的 alias 目录是 $CODEX_HOME/tmp/arg0/linux/codex-arg0<随机>/)。CLI 用一组参数把策略传给 helper:
codex-linux-sandbox \
--sandbox-policy-cwd /tmp/.onworks/work \
--command-cwd /tmp/.onworks/work \
--permission-profile '{"type":"managed","file_system":{…},"network":"enabled"}' \
-- /bin/bash -c '…模型给的命令…'
注意 OnWorks 的特殊性:一次性 CODEX_HOME 在 /tmp tmpfs 里,CLI 会拒绝在临时目录下创建 alias 并给出 warning(OnWorks 日志里可观察到),此时 helper 退回直接执行 codex 二进制;内层 stage 的 argv[0] 由 bwrap 的 --argv0 codex-linux-sandbox 保证,行为不受影响。
permission-profile 是 -s workspace-write 等旗标翻译成的 JSON 策略。此前本文档的表述“内置 helper = namespace + seccomp 组合、Landlock 作为旧路径”并不准确:实际是 Bubblewrap(bwrap)+ seccomp BPF 两段式(Landlock 只是显式开启的特性开关,且已不再用于文件系统受限策略)。请求 x-codex-turn-metadata 里的 "sandbox":"seccomp" 只是内部枚举 SandboxType::LinuxSeccomp 的 serde 序列化名,不代表“沙盒仅有 seccomp”。
4.2.3 实测进程链(OnWorks 同款配置复刻)
在 OnWorks 文档 4.1 的同一套 unshare 包装里跑真实 codex exec(mock LLM 下发一条 exec_command),从宿主侧用 pstree 抓到的进程树:
unshare -r -m -p -f --mount-proc … ← OnWorks 外层
└─ node codex exec --json --ephemeral …
└─ codex ← Cli 主进程(rust 二进制)
├─ codex --sandbox-policy-cwd … --permission-profile … ← 外层 stage(arg0 同一个二进制)
│ └─ bwrap --as-pid-1 --new-session --ro-bind / / … ← bwrap(来自 npm 包 codex-resources/bwrap)
│ └─ codex … --apply-seccomp-then-exec … ← 内层 stage(再入同一 helper)
│ └─ /bin/bash -c '…' ← 最终命令(fork+execv)
└─ git …
要点:
- helper 两段执行:外层 stage 不装任何限制,它只负责找到 bwrap 并把命令包进去;真正的
no_new_privs+ seccomp 由内层 stage(--apply-seccomp-then-exec)在 bwrap 的 mount 视图建立好之后安装,这样 seccomp 不会提前拦截 bwrap 自己需要的 syscall(bwrap 在受限配置里可能依赖 setuid 的部署)。 - bwrap 的来源:优先用 PATH 上的系统
bwrap(且会检查该 bwrap 不在策略允许写的目录里、能否创建 user namespace),否则用 npm 包自带的codex-resources/bwrap(启动前做 SHA-256 digest 校验,不匹配则退出码 8)。实测 OnWorks 环境里走的是自带 bwrap:helper 以/proc/self/fd/N直接execv,bwrap 二进制不落盘。 - 失败无降级:该路径没有回退到 Landlock 的兜底;bwrap 不可用(如内核禁止 user namespace、WSL1)时工具命令直接失败。
4.2.4 bwrap 具体做了什么
OnWorks 配置(network_access=true)下,从 /proc/<pid>/cmdline 抓到的完整 bwrap 参数(折叠了重复的 4 组 hidden 路径):
bwrap --as-pid-1
--new-session --die-with-parent
--ro-bind / / # 整个根以只读 bind 进来(可读宿主文件系统)
--dev /dev # 只重建标准设备的 /dev
--perms 000 --tmpfs /tmp/codex-daemon-0 --remount-ro /tmp/codex-daemon-0 # 遮蔽 codex daemon 的 socket 目录
--bind /tmp /tmp # workspace-write 放行 /tmp 和 $TMPDIR(可写)
--perms 555 --tmpfs /tmp/.git --remount-ro /tmp/.git ┐
--perms 555 --tmpfs /tmp/.agents --remount-ro /tmp/.agents │ 用空只读 tmpfs 盖住
--perms 555 --tmpfs /tmp/.codex --remount-ro /tmp/.codex │ 4 个受保护的点目录
--perms 555 --tmpfs /tmp/.aws --remount-ro /tmp/.aws ┘
--ro-bind /tmp/codex-bwrap-synthetic-mount-targets-0 … # synthetic mount 登记点(供 sandbox integrity 检查)
--bind /tmp/.onworks/work /tmp/.onworks/work # 可写根:任务工作区
--perms 555 --tmpfs /tmp/.onworks/work/.git … # 工作区里同样盖掉 .git/.agents/.codex/.aws
--unshare-user # 一个 clone(CLONE_NEWUSER|NEWNS|NEWIPC|NEWPID) 一步到位
--unshare-pid
--unshare-ipc
(network_access=false 时这里还会多一个 --unshare-net;OnWorks 默认配置没有)
--proc /proc # 挂新 procfs(看到的是新 PID ns 的 /proc)
--cap-drop ALL # 清空全部 Linux capabilities
--argv0 codex-linux-sandbox -- # 内层命令仍以 arg0「codex-linux-sandbox」运行
… --apply-seccomp-then-exec -- /bin/bash -c '…'
namespace 层面:user/PID/IPC namespace 每条命令都新建;mount 内的可见性由上面的 bind/tmpfs 组合控制;net namespace 仅在网络关闭时才创建——OnWorks 当前 network_access=true,所以工具命令与宿主共享网络栈(实测 ns 与宿主一致),能正常出网。
4.2.5 seccomp 具体做了什么
内层 stage 先做两件事再 exec 最终命令(strace 实测):
capget(… ) = {effective=0, permitted=0} # 校验 bwrap --cap-drop ALL 已清空能力
prctl(PR_SET_NO_NEW_PRIVS, 1) # 禁止 setuid 提权
seccomp(SECCOMP_SET_MODE_FILTER, …) # 安装 BPF 过滤器(network=false 时实测 114 条指令)
(之后 fork;子进程 execv 最终命令,父进程 waitpid;过滤器被子进程继承)
/proc/self/status 佐证:沙盒内命令读到 Seccomp: 2、Seccomp_filters: 1(对应 OnWorks 配置下的小过滤器);bwrap 本体与外层 stage 读到 Seccomp: 0。过滤器由 Rust 侧 seccompiler 生成。
过滤器内容由网络策略决定(源码 seccomp.rs,默认动作 Allow、命中的 syscall 返回 EPERM):
| 网络状态 | bwrap | seccomp 过滤器 |
|---|---|---|
network_access=false | 加 --unshare-net | 「Restricted」:deny connect/accept/bind/listen/sendto/getsockopt/…,也 deny ptrace/process_vm_readv/process_vm_writev;socket/socketpair 仅放行 AF_UNIX |
network_access=true(OnWorks 默认) | 不加 --unshare-net | 「VM-socket-restricted」:仅 deny AF_VSOCK 系 socket 与 io_uring_*;TCP/UDP 照常放行 |
| managed 网络代理(OnWorks 未用) | 加 --unshare-net | 「ProxyRouted」:仅放行 AF_INET/AF_INET6,用于连本地协议桥 |
也就是说:OnWorks 当前配置下 seccomp 不是网络壁垒(seccomp 只挡 AF_VSOCK),网络侧的隔离实际由「网络开着」这一配置决定。
4.2.6 Landlock 现在什么时候用
Landlock 只在显式打开 features.use_legacy_landlock(helper 参数 --use-legacy-landlock)时使用,且文件系统受限策略下 helper 会直接 panic(提示必须 bubblewrap),仅对 danger-full-access 类策略有效。实测默认路径全程无 landlock_create_ruleset 调用,沙盒内 /proc/self/status 的 Landlock 字段为空。
4.2.7 OnWorks 配置下的沙盒内实测摘要
同一次复刻运行里,让 exec_command 执行一段探针脚本,沙盒内观察到的行为:
| 探针 | 结果 |
|---|---|
whoami / id -u | root / 0(user namespace 内默认映射,不代表宿主权限) |
/proc/self/status | Seccomp: 2(filter 模式)、Seccomp_filters: 1;Landlock 空 |
| PID 视图 | 自身 pid=2、ppid=1;/proc/1/comm = codex(即内层 helper,--as-pid-1);宿主进程不可见 |
写 /tmp、写 workspace | OK(--bind /tmp /tmp、--bind work) |
写 /home/work(外层 tmpfs 覆盖的宿主 HOME) | EROFS Read-only file system |
curl https://…、socket.connect(:443) | 200 / 连接成功(未进 net namespace;seccomp 只挡 VSOCK) |
对照组验证:-s workspace-write 且 network_access=false 时,bwrap 参数里出现 --unshare-net(同时 DNS 解析失败——新 net namespace 里没有 resolv.conf);对 IP 直连 connect() 立即返回 Errno 1 Operation not permitted(seccomp 过滤器)。
macOS 用 Seatbelt(profile 由 CLI 内联生成),Windows 用 restricted token/backend;danger-full-access 则不套这层命令级沙盒——helper 会原样执行命令,既无 bwrap 也无 seccomp。OnWorks 不使用 danger-full-access。
4.2.8 两层关系总结
| 层 | 控制对象 | 主要机制 | 作用 |
|---|---|---|---|
| OnWorks 外层 | 整个 codex exec 进程树 | unshare、mount/PID namespace、tmpfs、bind mount | 限制 CLI 看到哪些路径;隔离任务目录、一次性 CODEX_HOME、/tmp、/proc。 |
| Codex 内层(bwrap) | 模型生成的每条工具命令 | Bubblewrap:mount namespace 内 --ro-bind / + 可写根 bind + 挂点 tmpfs 遮蔽;user/PID/IPC namespace、--cap-drop ALL、新 /proc | 落实 read-only/workspace-write 的文件系统写根与可见性。 |
| Codex 内层(seccomp) | 每条工具命令及其子孙进程 | PR_SET_NO_NEW_PRIVS + BPF 过滤器(网络关闭时的网络类 syscall deny / VSOCK、io_uring deny) | 网络策略与防提权(macOS 用 Seatbelt,Windows 用 restricted token)。 |
简化记忆:
- 外层回答“这个 CLI 能看见哪个宿主目录”。
- 内层的 bwrap 回答“模型请求的 shell 命令能写哪里、能不能看见哪些隐藏文件”。
- 内层的 seccomp 回答“这条命令能不能建 socket / 联网 / 提权”。
如果关闭 OnWorks 外层(ONWORKS_CODEX_ISOLATE=0),bwrap 层的 workspace-write 仍然存在(写根限制不受影响),但 Codex 更容易读到宿主上的其他文件(外层的 tmpfs /tmp、一次性 CODEX_HOME 都不会再出现);OnWorks 默认要求 unshare,就是为了补上这层可见性隔离。
5. 一次用户任务:文件与沙盒实例
下面用一个具体例子说明“用户发问题、上传文件、Agent 生成文件”的完整路径。示例项目根目录是 /home/work/workspace/work_desk;用户 u-001 在会话 s-123 中上传 sales.xlsx,要求生成 summary.xlsx。
5.1 上传后的真实目录
POST /tasks 先把上传原件保存到任务目录,并把映射写进 MySQL:
/home/work/workspace/work_desk/backend/app/media/tasks/3f8a12cd-4567-4def-9abc-000000000001/
└── 9f8e7d6c.xlsx ← 用户上传的 sales.xlsx 原件
-- tasks
id = 3f8a12cd-4567-4def-9abc-000000000001
user_id = u-001
session_id = s-123
status = processing
-- task_files
task_id = 3f8a12cd-4567-4def-9abc-000000000001
filename = sales.xlsx
stored_name = 9f8e7d6c.xlsx
role = input
这个目录是“任务的原始附件/交付归档目录”。Agent 不会直接修改这里的上传原件。
5.2 启动 Agent 前复制到会话工作空间
OnWorks 会按 session_id 准备一个会话级 scratch workspace。任务 3f8a12cd 的前 8 位成为本轮附件目录名:
/home/work/workspace/work_desk/backend/app/media/work/s-123/
├── attachments/
│ └── 3f8a12cd/
│ ├── 01-sales.xlsx
│ └── 01-sales.xlsx.txt ← 后端预解析出的文本,帮助模型少花工具轮次
├── work/ ← 脚本、临时数据、图表数据等中间产物
└── output/ ← Agent 创建;用户点名要的最终文件
所以同一个会话的第二轮任务还能看到 output/summary.xlsx,可以让 Agent 继续修改它。不同轮次的附件用 task_id 前 8 位分开,避免同名冲突。无 session_id 的旧任务才会退回使用 /home/work/workspace/work_desk/backend/app/media/work/{task_id}。
5.3 Agent 进程看到的沙盒视图
OnWorks 启动 Codex 时,实际磁盘上的工作目录是 /home/work/workspace/work_desk/backend/app/media/work/s-123;Codex 看到的映射视图和启动命令见 4.1。因此在 Agent 里命令输出会是:
$ pwd
/tmp/.onworks/work
$ ls
attachments output work
$ ls attachments/3f8a12cd
01-sales.xlsx 01-sales.xlsx.txt
Codex 内层再使用 workspace-write 沙盒:-C /tmp/.onworks/work 是可写根;写入限制在这个 workspace;网络按 OnWorks 配置显式打开;审批关闭。即使内层沙盒仍允许读取宿主文件,外层 namespace 已经用空 /tmp、空 $HOME 和新 /proc 隐藏了宿主侧的大部分上下文。
5.4 Agent 写文件后的两个落点
如果 Agent 生成 Excel,并把核对用的 CSV 作为中间产物留下,会话工作空间变成:
/home/work/workspace/work_desk/backend/app/media/work/s-123/
├── attachments/3f8a12cd/
│ ├── 01-sales.xlsx
│ └── 01-sales.xlsx.txt
├── work/
│ └── summary-check.csv ← 中间产物
└── output/
└── summary.xlsx ← 交付文件
任务结束后,OnWorks 从真实路径 /home/work/workspace/work_desk/backend/app/media/work/s-123/output/summary.xlsx 收集文件,并复制回本任务目录:
/home/work/workspace/work_desk/backend/app/media/tasks/3f8a12cd-4567-4def-9abc-000000000001/
├── 9f8e7d6c.xlsx ← 上传原件,role=input
├── b71c3d90.xlsx ← summary.xlsx 的交付副本,role=output
└── 55a2f1e8.csv ← summary-check.csv 的中间副本,role=intermediate
浏览器下载或预览时,OnWorks 先查 task 是否属于当前用户,再按 task_files.stored_name 找文件。因此另一个用户即使知道 URL 中的任务 ID 或文件 ID,也拿不到这份文件。
5.5 两个用户和两个会话的隔离例子
用户 u-001 / 会话 s-123
/home/work/workspace/work_desk/backend/app/media/work/s-123/
└── output/summary.xlsx
用户 u-002 / 会话 s-987
/home/work/workspace/work_desk/backend/app/media/work/s-987/
└── output/summary.xlsx
两个文件名可以相同,但真实目录不同。HTTP 层分别用 session.user_id 和 task.user_id 校验归属;Codex 层每次只 bind mount 当前任务解析出的那个 session_id 目录,所以进程视图里不会出现另一个会话的工作空间。
5.6 一句话总结
上传原件在 /home/work/workspace/work_desk/backend/app/media/tasks/{task_id}/;Agent 操作空间在 /home/work/workspace/work_desk/backend/app/media/work/{session_id}/;交付文件先由 Agent 写到该工作空间的 output/,任务结束后再复制为 /home/work/workspace/work_desk/backend/app/media/tasks/{task_id}/ 中的输出记录。沙盒外层用 unshare 只暴露当前会话工作空间,并把 CODEX_HOME 放在同一次 mount namespace 的 /tmp/.onworks/codex-home;内层再由 Codex workspace-write 限制写路径。
6. OnWorks 多轮压缩 vs Codex 原生压缩
两者的共同目标是:历史、工具结果和当前任务加起来太长时,不要把所有原始内容都塞进模型。差异在于“谁负责压”和“压完落在哪”。下面分清单轮信息、应用层多轮历史、Codex 单次运行内的原生历史。
先给结论: OnWorks 的“单轮”不是无限塞进下一轮 prompt。当前用户输入最多 50,000 字符且整段进入本轮;历史轮次每轮最多 6,000 字符;Agent 历史窗口默认 80,000 字符,Chat 历史窗口默认 50,000 字符;工具输出不会由 OnWorks 塞进长期历史,Codex 运行内部按模型元数据截断(未知模型 fallback 是 10,000 bytes,内置模型通常是 10,000 tokens)。多轮压缩只在历史真的掉出窗口时启动,Agent 默认触发点是 20,000 字符未摘要内容。
| 维度 | OnWorks 应用层多轮 | Codex 原生运行内 |
|---|---|---|
| 对比对象 | OnWorks 在每次请求前重拼的 stdin prompt;适用于 codex exec --ephemeral。 |
Codex CLI 一个 thread / 一次运行内的 agent history;不等于 OnWorks 的跨请求时间线。 |
| 记忆分层 | 三层:原始 DB/文件层 → .compaction.json 滚动摘要层 → 模型可见 prompt 层。 |
两层:运行内原始 agent history → 压缩检查点后的新 history;可再写回 rollout。 |
| 预算单位 | 按字符预算,默认与模型 context window 解耦。 | 按 token 和 context window 预算。 |
| 单轮当前输入 | Chat/Agent 输入上限 50,000 字符;当前输入不裁剪、不丢进摘要。 | 当前 user turn 进入运行内 history;受 context window 硬上限约束。 |
| 历史单轮 | 进入下一轮前每轮裁到 6,000 字符。 | 不按“轮”固定裁剪;由整体 token 预算和压缩策略处理。 |
| 附件 | 最多 10 个、每个 25 MiB;解析文本单文件 30,000 字符、合计 120,000 字符。 | Codex 路径保留原文件;需要时用工具读原件,工具输出再按模型策略截断。 |
| 工具输出 | 不把工具输出并入长期历史;步骤 UI 保留命令 600 字符、前 6 行且每行 220 字符、推理 4,000 字符,步骤 JSON 存库 400,000 字符。 | 进入运行内 history;按模型 truncation policy 截断,未知模型 fallback 10,000 bytes,内置模型常见 10,000 tokens。 |
| 历史窗口 | Agent 80,000 字符;Chat 50,000 字符。可用 ONWORKS_AGENT_HISTORY_CHARS / ONWORKS_CHAT_HISTORY_CHARS 覆盖。 |
模型 context window;默认有效输入约 95%,自动压缩默认用窗口的 90%。 |
| 压缩触发 | 旧轮次掉出窗口后,未摘要 pending ≥ max(2,000, 预算/4) 才压缩。Agent 默认 20,000 字符;Chat 默认 12,500 字符。 |
usage 达到模型自动压缩阈值或 90% context window;也可配置显式阈值或 /compact 手动触发。 |
| 压缩方式 | 滑动窗口 + 滚动合并摘要:旧摘要 + 新掉出 pending 生成一个新摘要;最近能放下的轮次保持原文。 | 压缩 turn:local 摘要,或 provider 支持时 remote V2;构建替换 history 并保留初始上下文、关键真实用户消息和检查点。 |
| 摘要上限 | 摘要正文 4,000 字符,完整摘要块预留 4,500 字符。 | 没有 OnWorks 这种固定字符摘要上限;受压缩输入 20,000 tokens 和目标 context window 约束。 |
| 持久位置 | 原始时间线在 MySQL messages + tasks;摘要和 covered ID 在 media/work/{sessionId}/.compaction.json。 |
普通 thread 写 rollout;OnWorks 使用的 --ephemeral 主要是运行内状态,不落回应用 DB。 |
| 失败行为 | 保留旧摘要;pending 只加“早期 N 轮省略”提示,不静默假装完整。 | 压缩失败可退回 fallback prompt / 重新尝试;不会把 OnWorks 的跨请求业务时间线重写掉。 |
| 恢复语义 | 任何后端实例读 DB + 工作目录 + 摘要缓存即可重拼下一轮。 | 原生 thread 可 resume 工具轨迹;--ephemeral 请求结束后不留同样的原生 resume 状态。 |
| 适用场景 | 无状态、多用户、可迁移、以业务轮次为主的 Agent 产品。 | 需要工具轨迹保真、连续 shell/read/write 状态和原生 resume 的长任务。 |
核心差异: OnWorks 压缩的是跨请求重拼的应用层 prompt,按字符预算工作;Codex 压缩的是单次 CLI 运行内的 agent history,按 token/context window 工作。前者适合无状态多用户调用,后者适合模型需要连续工具轨迹的长任务。