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 --ephemeralCodex 基础上下文 + 环境上下文 + OnWorks 重建后的完整历史 prompt无本地 session 状态、可审计、可迁移、适合多用户丢失原生 rollout 里的工具轨迹;历史要自己压缩
Codex 原生 resumeCodex 基础上下文 + 本地 rollout 还原的消息、工具调用、工具输出 + 新环境上下文/新 promptthread 状态完整,工具轨迹保真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。一次任务大致走这些步骤:

  1. 把附件和上次产物准备到真实会话工作目录。
  2. 把完整 prompt、历史摘要和交付要求拼好,写入 stdin。
  3. unshare 创建新的 user / mount / PID namespace,并在里面启动 Bash。
  4. mount 在新 mount namespace 内重新构造 /tmp、/tmp/.onworks/work、/tmp/.onworks/codex-home、$HOME 和 /proc。
  5. exec 把 Bash 替换成 Codex CLI,工作根是 /tmp/.onworks/work。
  6. Codex 调用 LLM,需要执行工具时再在内层沙盒里跑模型给出的命令。
  7. OnWorks 持续读取 stdout JSONL,把思考、命令、文件变更、最终答案和 token 用法保存到任务记录。
  8. 子进程退出后,OnWorks 从真实工作目录收集 output/ 和中间产物;namespace 随进程消失,真实目录中的交付文件保留。

对应关系可以这样记:unshare 是创建隔离环境的入口, Mount Namespace 是隔离挂载视图的内核机制, mount 是在新视图里实际调整 /tmp、bind mount 和 $HOME 的工具。OnWorks 不是“unshare 自动隐藏文件”,而是靠 unshare 得到新 namespace,再靠 mount 重排路径视图。

参数含义:

当前实现不再把 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 的语义不是“全盘不可见”,而是:

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.envPermission denieddeny 优先级高于根路径的 read;Bubblewrap 用零长度只读对象盖住原文件。
读 workspace/allowed.txtold/ 整体以只读 bind 进入,因此能读。
写 workspace/probe-write.txt成功,内容 workspace-ok演示目录被 --bind 成可写,其余根路径仍是只读。
沙盒内 /proc/self/statusSeccomp: 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 平台是否需要包装由策略决定:

运行中的 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 …

要点:

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):

网络状态bwrapseccomp 过滤器
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 -uroot / 0(user namespace 内默认映射,不代表宿主权限)
/proc/self/statusSeccomp: 2(filter 模式)、Seccomp_filters: 1;Landlock 空
PID 视图自身 pid=2、ppid=1;/proc/1/comm = codex(即内层 helper,--as-pid-1);宿主进程不可见
写 /tmp、写 workspaceOK(--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)。

简化记忆:

如果关闭 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 工作。前者适合无状态多用户调用,后者适合模型需要连续工具轨迹的长任务。