跳轉到

工具套件與 Sandbox Host

把 agent 的工具拆成兩半:工具定義(schema / registry)留在 app 端工具執行(jail + uid/cgroup 隔離)放在 host 端,兩者之間唯一的交接物是一個不透明的 /.tools bundle 目錄。

看這篇之前:先讀 架構總覽 抓全貌。Sandbox 與檔案同步的上層脈絡見 Sandbox、FileStore 與同步;HTTP 線上契約的完整細節見 sandbox-host-wire.md;執行時「誰在何時打 host」的時間軸見 Hosted Sandbox 執行時架構

職責與邊界

這個子系統涵蓋兩個各自獨立、但靠一條 bundle 接縫銜接的部分:

App 端(工具定義)— src/workspace_app/tooling/

  • 維護 demo 部署的工具套件登錄表(packages.PACKAGES)。
  • 把每個套件原始碼 prebuild 成自帶 .venv + 可攜 cpython + launch + schemas/ 的 sandbox-droppable bundle。
  • 啟動時掃描 bundle 目錄,依 AgentConfig.allowed_tools 展開成 OpenAI Agents SDK 的 FunctionTool,其 on_invokeexec 進 sandbox。
  • 提供工具作者用的 Dispatcher(3-stage binary contract)。

Host 端(工具執行)— sandbox-host/

  • 一個獨立的 FastAPI 服務,把單一被注入的 Sandbox 透過 HTTP 暴露出去。
  • 生產後端 IsolatedProcessSandbox:每個 handle 配一組 pooled numeric uid/gid + 一個 cgroup v2,外加 chroot jail。
  • 把 prebuild 出來的 toolchain bundle 烤進自己的 image,在 jail 裡以 read-only 掛在 /.tools

不負責

  • 不負責 agent 回合的編排、cancel、SSE — 那是 API 與回合引擎 / Agent 執行時
  • 不負責 sandbox handle 的生命週期登錄(idle reap / mirror sweep 在 app 端的 InvestigationRegistry),host 只管自己 pod 內的 handle 與 idle backstop。
  • App-side 的 HttpSandbox client、wire 的 handle 編碼(pod_url + remote_id)細節屬於 Sandbox、FileStore 與同步sandbox-host-wire.md,本篇只連結不複述。

核心模組

路徑 角色
src/workspace_app/tooling/packages.py 套件登錄表:PACKAGES = {name → host source dir} + PREBUILT_DIR(env WORKSPACE_TOOLS_DIR,預設 <repo>/.workspace-tools)。CLI 套件的 key 必須等於 [project.scripts] console_script;venv carrier 的 key 只是 bundle 目錄名。rca-tools gitignored、source 不在就略過。
src/workspace_app/tooling/prebuild.py 把套件 source build 成自帶的 bundle。build_package(生產路徑:uv sync --frozen --no-editable + --reinstall/--refresh-package 破 cache,#64);build_package_uvrun / provision_uvrun(#63 輕量 DEBUG bundle,symlink source + uv run --project)。_LAUNCH / _PYTHON_LAUNCH 帶 AT_SECURE 動態載入器解法;_is_venv_carrier 以「無 [project.scripts]」分流;_dump_schemas 跑 3-stage contract 快取 schema;_source_hash / _should_rebuild 驅動增量重建。
src/workspace_app/tooling/registry.py App 端(在 agent-host 進程建 FunctionTool,非 sandbox-host):discover_packages(prebuilt_dir)PREBUILT_DIRlist[PackageInfo](STRICT:每個子目錄都得是完整 bundle,否則 RuntimeError;缺目錄 FileNotFoundError)。build_function_tools 展開 allowed 的 colon 語法(pkg / pkg:cmdNone=全部、[]=無)成 FunctionToolon_invoke 在 sandbox 跑 <install_dir>/launch <cmd> <args_json>_check_collisions 在跨套件命令撞名時 raise;_review_chart 接 #285 VLM 圖表自審。
src/workspace_app/tooling/dispatcher.py 工具作者用的 Dispatcher:實作 3-stage binary contract(無參數 → 列出命令 JSON;<cmd> → 印 pydantic JSON schema;<cmd> <args_json> → 驗證 + 跑 handler)。每個 @d.command 一個 pydantic Args model,同時當 LLM schema 與 runtime 驗證的單一事實來源;錯誤的子命令/參數 exit 2。
scripts/prebuild_tools.py Operator 進入點 uv run python scripts/prebuild_tools.py [--force]:iterate PACKAGES、缺 source 就跳過(rca-tools 保持可選)、build_packagePREBUILT_DIR/<name>。改任何工具 source 後必須重跑。
sample-tools/ 倉庫內含四個各自獨立的 uv workspace(自帶 pyproject + uv.lock,絕對 import only;外加 gitignored 的 rca-toolsPACKAGES 共五筆登錄)。data-fetch / csv-column-summary / sci-plot = CLI 套件(3-stage Dispatcher,自帶 pandas/matplotlib);python-stack = venv carrier(無 scripts;綁 pandas/numpy/scipy/matplotlib + office stack openpyxl/XlsxWriter/python-pptx,#252);rca-tools = gitignored 的 in-house 多命令套件。
sandbox-host/src/sandbox_host/protocol.py Host 自己的 sandbox 資料形狀 + 內部 Sandbox Protocol(11 ops)。刻意不 import workspace_app 任何東西 — 唯一的跨進程契約是 HTTP wire。image / exposed_ports 收下但 process 後端會忽略。
sandbox-host/src/sandbox_host/app.py FastAPI 殼,把單一注入的 Sandbox 暴露成 HTTP。Routes:POST/DELETE /sandboxes、檔案操作、POST /sandboxes/{rid}/exec(NDJSON 串流 {o:b64} chunk 再 {exit,out,err})。_HostController 追蹤 live handle + idle clock;create 回 advertise_url(POD_IP)+ remote_idcheck_cgroup_ready 餵 boot + /readyz/drain(PreStop)+ /healthz。錯誤映到 404 {error,detail}
sandbox-host/src/sandbox_host/isolated_process.py 生產後端:LocalProcessSandbox 子類,加上 per-handle pooled uid/gid(_UidPool)+ per-handle cgroup v2(_CgroupManagermemory.max/cpu.max/pids.max)。create 擁有 workspace(chown + chmod 700 + 預設 POSIX ACL via setfacl,讓 root 寫入的檔案仍可由 uid 寫);_exec_argv 把命令包成先 join cgroup.procssetpriv 降權。isolate=False — uid + cgroup 就是隔離,無 namespace。Host 必須以 root 跑。
sandbox-host/src/sandbox_host/local_process.py 基礎後端(host 上的 subprocess)。_JAIL_BOOTSTRAP:unprivileged user + mount-namespace chroot,workspace = /root(agent cwd / $HOME),sandbox root 是 infra;read-only bind /usr/etcSANDBOX_TOOLS_DIR/.tools/root 的 sibling,永不被 walk/sync),並在 carrier 被 provision 時把 python/python3pip/pip3 shim 到 /.tools/python-stack/launch(含 /etc/profile.d tmpfs overlay 讓 login shell PATH 保住 shim;unjailed 沒有 chroot 可蓋,改由映像檔安裝的 docker/profile.d/sandbox-jailbin.sh + SANDBOX_JAILBIN 達成同一件事)。
sandbox-host/src/sandbox_host/service.py 可測組裝根:SandboxHostSettingsbuild_sandbox(IsolatedProcessSandbox)make_host_appresolve_tools_dir(#251 接上 prebuilt /.tools,unset = 無工具,寬鬆)、advertise_url(POD_IP 或 loopback)、resolve_cgroup_root
sandbox-host/src/sandbox_host/config.py 獨立 12-factor 設定:SandboxHostSettings dataclass + load_settings(env)SANDBOX_HOST_*讀 workspace_app 的設定。
sandbox-host/src/sandbox_host/__main__.py python -m sandbox_host serve glue(排除 coverage):load_settings、boot 時 fail-loud check_cgroup_readybuild_host_app(pod_ip=POD_IP)、uvicorn serve + SIGTERM→drain + idle-reaper loop。
sandbox-host/src/sandbox_host/mock.py host 測試用的 in-memory Sandbox(取代 IsolatedProcessSandbox 注入)。
sandbox-host/Dockerfile 獨立 image。Stage 1(拋棄式)只帶 workspace_app + sample-tools 去跑 scripts/prebuild_tools.py/build/.workspace-tools。Stage 2 精簡 runtime:fastapi/uvicorn + util-linux(setpriv/unshare)+ acl(setfacl)+ make_deck(#284)toolchain(nodejs/npm + pptxgenjs、libreoffice-impress + poppler-utils、fonts-noto-cjk)烤進去(因為 host 在這 image 內 jail 且忽略 SandboxSpec.image);把不透明 bundle 目錄複製到 /opt/toolsSANDBOX_HOST_TOOLS_DIR)。

介面與接縫

接縫 種類 定義位置 實作
Sandbox Protocol(11 ops) sandbox-host/src/sandbox_host/protocol.py local_process.py:LocalProcessSandbox(基礎)、isolated_process.py:IsolatedProcessSandbox(生產)、mock.py:MockSandbox(測試)
AclRunner Callable seam(setfacl 系統二進位邊界) sandbox-host/src/sandbox_host/isolated_process.py _run_setfacl(預設 shell out)、測試注入的 spy(免 root / 免 acl 套件)
ReadinessCheck Callable seam sandbox-host/src/sandbox_host/app.py check_cgroup_ready
3-stage tool binary contract process / CLI 契約 src/workspace_app/tooling/dispatcher.py sample-tools/*/src/*/cli.py 透過 Dispatcher
FunctionTool.on_invoke(LLM tool → sandbox exec) OpenAI Agents SDK FunctionTool src/workspace_app/tooling/registry.py _to_function_tool.on_invoke

Sandbox Protocol 的 11 個方法:create / kill / exec / upload / download / walk / exists / delete / mkdir / rmdir / renameexec 接一個 OutputSinkCallable[[bytes], None]),逐 chunk 餵 stdout/stderr,同一份 bytes 也累進 ExecResult

protocol.py 是 host 對 sandbox 形狀的獨立副本 — 它故意不 import workspace_app,host 與 app 之間唯一耦合就是 HTTP wire 契約,因此 host 可以是完全自主的隔離服務。

運作方式 / 資料流

flowchart TD
  subgraph App["workspace-app (工具定義)"]
    PK[packages.PACKAGES] --> PB[prebuild.build_package]
    SRC["sample-tools/*\nDispatcher 3-stage CLI"] --> PB
    PB --> PD[("PREBUILT_DIR/&lt;pkg&gt;\n.venv+python+launch+schemas")]
    PD --> DISC[registry.discover_packages]
    DISC --> BFT["build_function_tools\nallowed_tools pkg:cmd"]
    BFT --> FT[FunctionTool.on_invoke]
  end
  FT -->|exec launch cmd args_json| CL[HttpSandbox client]
  CL -->|HTTP wire| HOST
  subgraph HOST["sandbox-host pod (工具執行)"]
    APP2["app.py FastAPI\n/sandboxes /exec NDJSON"] --> ISO["IsolatedProcessSandbox\nuid pool + cgroup v2 + setpriv"]
    ISO --> JAIL["chroot jail\nworkspace=/root\n/.tools = bundles RO"]
    PD -. baked into image .-> TOOLS["/opt/tools = /.tools/"]
    TOOLS --- JAIL
  end

三個時間軸:

PREBUILD(operator,在 host 上)scripts/prebuild_tools.py iterate PACKAGES。對每個 source,prebuild.build_packageuv venv --relocatable + uv sync --frozen --no-editable(對著套件已 commit 的 uv.lock)裝進 PREBUILT_DIR/<name>/.venv,複製可攜 cpython,寫 AT_SECURE 的 launch shell(venv carrier 則寫純 python launcher),_dump_schemas 再跑一次 launch(3-stage contract)把 commands.json + schemas/<cmd>.json 快取下來,最後寫 .built source-hash。

STARTUP(app / host 進程)registry.discover_packages(PREBUILT_DIR) 嚴格驗證每個 bundle → list[PackageInfo];依 AgentConfig.allowed_toolsbuild_function_tools 在跨套件撞名檢查後,把 pkg / pkg:cmd 選擇展開成 FunctionTool

RUNTIME(LLM 回合) — LLM 呼叫某個 FunctionToolon_invokeensure_sandbox() 取得 handle,再呼 actx.sandbox.exec([<install_dir>/launch, cmd, args_json])。HTTP 部署時 actx.sandbox 是 app 端的 HttpSandbox client → POST /sandboxes/{rid}/exec 打到 sandbox-host pod → app.py._exec_ndjson 串流輸出 → IsolatedProcessSandbox._exec_argv 把 argv 包成「join per-handle cgroup + setpriv 降到 pooled uid」,在 chroot jail 裡跑,bundle 以 read-only 掛在 /.tools/<name>。工具進程透過自己的 Dispatcher(驗證參數 → 跑 → stdout/stderr)分派,結果文字回流;若命令輸出了圖片且其 schema 接受 styleregistry._review_chart 跑 #285 的 VLM 自審迴圈(下載 render → describe → restyle → 重 exec,≤2 passes)。

關鍵不變式與眉角

STRICT discover — 半成品 bundle 一律 fail-fast

discover_packages 對每個子目錄要求 commands.json + schemas/ + 每個列出的命令都有對應 schema 檔,否則 RuntimeError;缺 PREBUILT_DIR 本身則 FileNotFoundError沒有 silent-skip。這源自一個 May-30 production-style 事故:三個半成品套件 → discover 回 [] → agent 以零工具運作、直到 LLM 回覆才被發現。想要「零套件」的部署要清空 PACKAGES,不要靠 discover no-op。

改了工具 source 一定要重跑 prebuild

增量重建是 content-hash_source_hash,不是 mtime,#64)+ uv --reinstall/--refresh-package,所以同版本的小改也會真的重建。uv 以 (name, version) 快取 wheel,沒有強制 refresh 的話同版本編輯會被靜默保留舊 wheel;舊的 mtime 檢查也漏掉沒動 mtime 的編輯。

PACKAGES key 的鐵律

CLI 套件的 key 必須等於該套件 [project.scripts] 的 console_script entry(launch wrapper 會呼 .venv/bin/<name>);venv carrier 的 key 只是 bundle 目錄名。

venv carrier 不暴露任何 FunctionTool

venv carrier = 無 [project.scripts] → prebuild 寫純 python launcher + 空的 commands.json"[]")+ 空 schemas/(讓 discover 滿意但暴露 0 個工具)。jail bootstrap 只在該 carrier 被 provision 時,才把 python/python3/python3.x shim 到 /.tools/python-stack/launch

allowed_tools 的 None vs [] 不可混淆

build_function_tools(None) = 暴露所有套件的所有命令(對齊 build_tools(None));allowed_tools=[] = 明確「不要任何套件」。未知的 pkg/cmd 名會被靜默略過(設定打錯不能讓 LLM 收 500);但同一次選擇裡兩個不同套件匯出同名命令會 raise ValueError(避免 flat name 互蓋)。

host 與 app 不共用任何 Python module

耦合只有 HTTP wire 契約(sandbox-host-wire.md)。protocol.py 是 sandbox 形狀的刻意獨立副本。

HTTP host 忽略 SandboxSpec.image / exposed_ports

沒有 container;隔離是 uid + cgroup v2(IsolatedProcessSandbox 無 namespace)。任何 sandbox 需要的 toolchain(例如 make_deck 的 node/libreoffice)必須烤進 sandbox-host IMAGE,不能靠 image 請求。

host 必須 root 且要有 delegated cgroup v2

host 要 root(setuid/chown 到外部 uid)+ 一塊被 delegate 的 cgroup v2 subtree。當 cgroup v2 不在或不可寫,check_cgroup_ready 會在 boot 與 /readyz 兩處 fail loud — 絕不在無隔離下開服

jail 裡 workspace 是 /root,工具在 sibling /.tools

provisioned tools 在 /.tools/root 的 sibling,在 workspace 之外),所以永遠不會被 walk、sync、或出現在檔案樹。read-only 工具掛載與 workspace 都保持乾淨。

launcher 的 HOME 是 per-sandbox,不是共用 /tmp(#393)

launchHOME(caches + 使用者 pip install --break-system-packages--user 退路)由 exec path 以 SANDBOX_HOME 傳入:unjailed(線上 pod,無 namespace)指到 per-sandbox 的 .home(workspace 的 sibling,回收即刪),jail 明著傳 /tmp(jail 的 /tmp 是每次 exec 的 ephemeral tmpfs,隔離的)。launcher 沒收到 SANDBOX_HOME 時的 fallback 是一個私有 mktemp -d(0700),絕不是共用 /tmp — 預設不能是會出事的東西。原本寫死 HOME=/tmp 在無 jail 的 pod 上把使用者 pip --user 安裝洩漏到全 pod 共用的 /tmp/.local(每個 sandbox 的 carrier python 都 import 得到)。

sandbox 裡只有一個 python,pip 就是它

python / python3 / python3.N pip / pip3 / pip3.N 全都是指向 carrier launch 的 symlink;launcher 依「自己被叫成什麼名字」分派,pip 會轉成 python -m pip。所以 pip install X 裝進去的,正是 exec(["python", ...]) 會跑的那個直譯器。在此之前 pip 沒被 shim,會沿 PATH 掉到映像檔自己的 python——直譯器不同、HOME 也不同,於是裝好的套件落在 carrier 永遠不會讀的 .local、還是另一個 X.Y;pip 回報成功、import 失敗,中間沒有任何訊息把兩件事連起來。pip 的 shim 只在 carrier 存在時建立:沒有 carrier 時退路是 /usr/bin/python3,而指過去的 pip 會變成 python3 install X(根本不是指令),不能動的 shim 比沒有 shim 更糟

bundle 裡那個直譯器的 PEP 668 EXTERNALLY-MANAGED 標記在 prebuild 時被移除。它是 uv 發行版帶進來的,宣稱「有 OS 套件管理員管這個直譯器,請改用 venv」——對一個沒有任何 packager 追蹤、也沒有 venv 可退的自帶 bundle 來說兩句都不成立。移除它之後 pip 的預設行為就是對的:bundle 的 site-packages 在線上是 root-owned 唯讀,pip 自己退到 --user = $HOME/.local,而 launcher 早就把 HOME 指到 SANDBOX_HOME ——正好是這個直譯器的 user-site。於是 pip install X 直接可用,不需要教任何旗標,也不需要偷偷替使用者往指令列塞參數。

login shell 的 PATH guard(unjailed)

agent 常打 bash -lc "python3 …",而 workflow 的每個 node 指令都被包成 sh -lc-l 會 source /etc/profile,Debian 的 profile 會硬重設 PATH,把 exec path 排在最前面的 per-sandbox .jailbin 整個丟掉。jail 用 tmpfs 蓋 /etc/profile.d 解決;unjailed(線上就是這個)沒有 chroot 可蓋,所以 guard 是一個真的檔案 docker/profile.d/sandbox-jailbin.sh,由 sandbox-host 與 app 兩個映像檔安裝進 /etc/profile.d/,再從 SANDBOX_JAILBIN(per-exec 匯出;/etc/profile 只重設 PATH,匯出變數活得下來)讀回那個 per-sandbox 目錄。變數沒設時它必須是完全的 no-op ——PATH="$VAR:$PATH" 在變數未設時會留下開頭的空元素,那在 PATH 裡等於「當前目錄」。

sticky per-handle 路由

createadvertise_url(來自 POD_IP)+ remote_id;client 必須把兩者一起編碼,讓之後每個操作都路由回擁有該 local handle 的同一個 pod。因此 pod-split sandboxing 需要 sticky per-handle 路由,而不是共用後端。

套件源碼一律絕對 import

sample-tool 套件強制絕對 import(ruff TID252 ban-relative-imports = all);工具碼會被複製/搬移,相對 import 會壞。每個套件也必須 commit uv.lock,否則 build_package raise(reproducible bundle 的前提)。

設計決策與出處

決策 理由 出處
工具定義在 app、執行在 host、交接 = 不透明 /.tools bundle 目錄 host 不 import workspace_app 任何東西,保持精簡隔離服務;加工具只要編輯 PACKAGES + 重 prebuild,host 不需 registry metadata sandbox-host/Dockerfile 頭註 + packages.pydocs/plan-skills-and-tools.mddocs/plan-http-sandbox.md
discover_packages 嚴格 fail-fast(不 silent-skip 半成品) May-30 事故:3 個半成品套件 → discover 回 [] → agent 零工具運作至 LLM 回覆才被發現;改成啟動時就炸 registry.py:discover_packages docstring
content-hash 重建 + uv --reinstall/--refresh-package uv 以 (name,version) 快取 wheel,同版本編輯會靜默保留舊 wheel;mtime 檢查也漏編輯 prebuild.py:_should_rebuild(#64)
.built stamp 也 fold 進 launcher 模板 _drop_externally_managed 的 fingerprint _source_hash(source) 看不到 builder 碼的編輯(它烤進 bundle,不是套件 source);不 fold 就會靜默保留舊 launch/舊的「拿掉什麼」規則,讓已建好的 bundle 繼續拒絕 pip install 而程式碼讀起來像修好了(#64 同類的 stale-cache) prebuild.py:_builder_fingerprint/_build_stamp(#393)
launcher HOME 走 per-sandbox SANDBOX_HOME,fallback 是私有 mktemp 不是共用 /tmp hosted 無 jail(只有 uid+cgroup),寫死 HOME=/tmp 讓使用者 pip install --break-system-packages--user 退路落在全 pod 共用的 /tmp/.local → 跨 sandbox 洩漏;fail-safe 預設不能是會出事的 /tmp prebuild.py:_PYTHON_LAUNCH + local_process.py:_exec_argv/_HOME + isolated_process._provision(#393)
pip/pip3* 也 shim 到 carrier(launcher 依被叫的名字分派);carrier 不在時不建 pip shim 只 shim python 時,pip 沿 PATH 掉到映像檔的 python:直譯器與 HOME 都不同 → 裝在 carrier 讀不到的 .local、還是別的 X.Y,pip 成功而 import 失敗;退路 /usr/bin/python3 上的 pip 會變成 python3 install X,不能動的 shim 比沒有更糟 prebuild.py:_PYTHON_LAUNCHcase + local_process.py:_PIP_SHIM_NAMES/_JAIL_BOOTSTRAP
prebuild 移除 bundle 直譯器的 EXTERNALLY-MANAGED uv 的 CPython 帶著 PEP 668 標記,pip 因此拒絕 pip install 直到被塞 --break-system-packages;但沒有 packager 追蹤這個 bundle,也沒有 venv 可退——那是關於 uv 自己安裝位置的宣稱,複製過來就不成立了。拿掉後 pip 自己退到 --user,正好落在 launcher 已指好的 SANDBOX_HOME prebuild.py:_drop_externally_managed
unjailed 的 login-shell PATH guard 走映像檔裝的 /etc/profile.d + per-exec SANDBOX_JAILBIN bash -lc/workflow 的 sh -lc 會 source /etc/profile,Debian 硬重設 PATH 把 .jailbin 丟掉;jail 蓋 tmpfs 解決,unjailed 沒有 chroot 可蓋,而目錄是 per-sandbox 的,pod 級檔案寫不死 → 只能由變數帶進來 docker/profile.d/sandbox-jailbin.sh + local_process.py:_exec_argv
隔離 = pooled uid/gid + cgroup v2,無 namespace;image 忽略 這是在他們 pod 裡可行的模型;HTTP host 無法 honour 任意 container image,故 toolchain 烤進單一 image isolated_process.py 模組 docstring + protocol.py SandboxSpec 註
venv carrier(python-stack)取代 host site-packages / per-image 安裝 agent 的 raw exec(['python', ...]) 直接看到 pandas/numpy/scipy/matplotlib + office stack,不污染 host deps;jail 把 python shim 到 carrier launcher python-stack/pyproject.toml + local_process.py:_JAIL_BOOTSTRAP(#252)
AT_SECURE / 顯式動態載入器 launch wrapper glibc 的 AT_SECURE 在 userns chroot jail 裡會剝掉 $ORIGIN/RPATH/LD_LIBRARY_PATH,弄壞 relocatable venv;顯式呼 ld-linux 還原 prebuild.py _LAUNCH/_PYTHON_LAUNCH 註解
uv-run debug bundle(build_package_uvrun--project 不是 --directory 快速迭代、不複製 venv;--project 保住 caller cwd,讓工具的 workspace-relative 寫入落在 sandbox 而非 source 樹 prebuild.py _UVRUN_LAUNCH 註解(#63)
advertise_url(POD_IP)+remote_id sticky handle 路由 每個 handle 的後端狀態活在單一 pod,之後操作必須直接路由回去 app.py 模組 docstring + service.advertise_url
make_deck toolchain 烤進 image(node/pptxgenjs/libreoffice/poppler/CJK 字型) host 在此 image 內 jail 且忽略 SandboxSpec.image,deck deps 無法搭純 python 的 /.tools bundle sandbox-host/Dockerfile stage 2(#284)

與其他子系統的關係

  • Sandbox、FileStore 與同步 — app 端的 HttpSandbox client 是本篇 host 的對端;actx.sandbox 在 HTTP 部署時就是它。sandbox handle 的生命週期登錄(idle/mirror sweep)在那一側。
  • Agent 執行時FunctionTool.on_invoke 透過 AgentToolContextactx.sandbox / actx.ensure_sandbox / actx.on_exec_output / actx.describer)接上 agent 回合;_review_chart 用到的 VLM 自審(run_reviewsrc/workspace_app/agent/plot_review.py)也在 agent 端。
  • App 平台 — 每個 App / Preset 的 AgentConfig.allowed_tools 決定 build_function_tools 暴露哪些命令(colon 語法 pkg / pkg:cmd)。
  • 背景工作與擴展 / 部署 — sandbox-host 以獨立 k8s Deployment 跑,sticky per-handle 路由 + /drain(PreStop)+ idle-reaper 對應 pod 的 scale / rollout。
  • make_deck(app 端工具) — 其 FunctionTool 與 preflight 檢查住在 app 端(src/workspace_app/agenttooling,本篇未逐行追),但它依賴的 node/libreoffice/poppler toolchain 由 sandbox-host/Dockerfile 供應。

原始碼錨點

接手者建議的閱讀順序:

  • src/workspace_app/tooling/packages.py — 從 PACKAGES / PREBUILT_DIR 看清楚有哪些套件、慣例為何。
  • src/workspace_app/tooling/prebuild.pybuild_package_is_venv_carrier_dump_schemas_should_rebuild:bundle 怎麼被造出來。
  • src/workspace_app/tooling/registry.pydiscover_packagesbuild_function_tools_to_function_tool_review_chart:bundle 怎麼變成 LLM 工具。
  • src/workspace_app/tooling/dispatcher.pyDispatcher:工具作者寫 cli.py 時的 3-stage 契約。
  • scripts/prebuild_tools.py — operator 進入點。
  • sandbox-host/src/sandbox_host/protocol.pySandbox Protocol(11 ops)與獨立資料形狀。
  • sandbox-host/src/sandbox_host/app.pymake_host_app_exec_ndjsoncheck_cgroup_ready:HTTP 殼。
  • sandbox-host/src/sandbox_host/isolated_process.pyIsolatedProcessSandbox:uid pool + cgroup + setpriv 隔離。
  • sandbox-host/src/sandbox_host/local_process.py_JAIL_BOOTSTRAP:chroot jail 與 /.tools / python shim(檔案上半 jail bootstrap;下半的 file-op 與 exec pump/timeout 內部實作細節見原始碼)。
  • sandbox-host/src/sandbox_host/service.py / config.py / __main__.py — 組裝根、12-factor 設定、serve glue。
  • sandbox-host/Dockerfile — 兩階段 image,prebuild 交接到 /opt/tools

未在本篇完整追蹤的細節

  • make_deck 的 preflight / FunctionTool 程式碼在 app 端(sandbox-host/src 下無 make_deck 符號),本篇僅標明 toolchain 來源在 Dockerfile。
  • wire 契約的 handle 編碼(pod_url + remote_id)、retry / 路由細節見 sandbox-host-wire.md 與 app 端 HttpSandbox
  • rca-tools source 在此 worktree gitignored / 不存在,其命令集由 packages.py 註解推得,未實讀。
  • _review_chart 依賴的 actx.describer / agent.plot_review.run_review 屬 app 端,完整行為未在此追。