Sandbox host — HTTP wire contract¶
這是 workspace app 的 HttpSandbox client
(src/workspace_app/sandbox/http_client.py)與獨立的 sandbox-host
服務(sandbox-host/)之間的 contract。兩者不共用任何 Python 模組——只共用這份 wire
API。app 在這裡定義它;host 則獨立實作它(#251)。
兩側都各自把關以確保一致:
- App 側 ——
tests/sandbox/test_http.py拿HttpSandbox去打一個 in-test 的 fake host, 該 fake host 比照這份 contract(也就是 app 對它的參照基準)。 - Host 側 ——
sandbox-host/tests/test_wire.py在行程內(in-process)驅動真正的 server, 而sandbox-host/tests/test_contract.py(integration)則透過 subprocess 走真正的 HTTP 去驅動它。
當你改動下面任何一處,兩側都要一起更新。
這裡只定義線上格式。哪一個 endpoint 在什麼時機被誰呼叫,見 Hosted Sandbox 執行時架構。
Routing¶
POST /sandboxes 打到 host 的 ClusterIP Service(會做負載平衡)。回應裡帶著被選中那個 pod
自己、可直接定址的 URL(pod_url),外加它在本機的 handle id(remote_id)。client 把這兩者
打包進一個不透明的 SandboxHandle.id({"u": pod_url, "r": remote_id} 的 base64),之後每一次
呼叫都直接連到擁有它的那個 pod——所以不管哪個 app replica 都能正確路由、無需共用狀態。
某個 pod 死掉(connection refused)時會被當成 SandboxNotFound,app 會從 FileStore 重新建立
sandbox。
Endpoints¶
| Method & path | Body / params | Success | Purpose |
|---|---|---|---|
POST /sandboxes |
{image?, env?, exposed_ports?, item_id?} |
200 {pod_url, remote_id} |
建立 |
DELETE /sandboxes/{rid} |
— | 204 |
終止 |
POST /sandboxes/{rid}/exec |
{cmd: [str], env?: {str: str}} |
200 NDJSON stream |
exec(見下) |
POST /sandboxes/{rid}/persist |
{delete: bool} |
204 |
rsync 工作目錄 → NFS 封存(#492) |
PUT /sandboxes/{rid}/file?path= |
raw octet-stream body | 204 |
上傳 |
GET /sandboxes/{rid}/file?path= |
— | 200 octet-stream |
下載 |
GET /sandboxes/{rid}/exists?path= |
— | 200 {exists: bool} |
存在性檢查 |
GET /sandboxes/{rid}/disk-usage |
— | 200 {bytes: int} |
workspace 總用量(配額) |
GET /sandboxes/{rid}/size?path= |
— | 200 {size: int\|null} |
單檔大小(配額;不存在回 null) |
POST /sandboxes/{rid}/mark-ready |
— | 204 |
標記沙盒「已完整還原、可信」(#366) |
GET /sandboxes/{rid}/ready |
— | 200 {ready: bool} |
讀 ready 狀態(#366) |
GET /sandboxes/{rid}/walk?root= |
— | 200 {entries: [{path,size,version}]} |
walk |
DELETE /sandboxes/{rid}/file?path= |
— | 204 |
刪除 |
POST /sandboxes/{rid}/mkdir |
{path} |
204 |
mkdir |
DELETE /sandboxes/{rid}/dir?path= |
— | 204 |
rmdir |
POST /sandboxes/{rid}/rename |
{src, dst} |
204 |
rename |
POST /tools/resolve |
{tools: {名稱: manifest 網址}} |
200 {tools: {名稱: {sha, version, stale, commands}}, refused: {名稱: 原因}} |
第三方工具:抓→驗→裝,並回傳要掛的 sha 與要給模型的 schema(#674) |
維運用(不屬於 sandbox 表面):GET /healthz(回
{status, version, capabilities: [str]}——能力名與行為同 commit,不會像手維護的相容性表那樣
漂移)、GET /readyz、POST /drain。
item_id + persist(#492):host 設了 SANDBOX_HOST_NFS_ROOT 時,帶 item_id 的
create 會先把 {nfs_root}/{item_id} rsync 還原進新沙盒、reown 成沙盒 uid、最後才
mark-ready(所以 create 一回來,目錄就是完整且可信的);persist 再把它 rsync 回去——
delete: true 是靜止點的對帳,false 是回合中的純追加 checkpoint,且只在 ready 為真
時執行(半還原的目錄絕不能覆蓋封存)。沒有 archive 或沒帶 item_id ⇒ 兩者都是 no-op,舊
client 因此照舊可用。
POST /tools/resolve —— 為什麼回應是「部分成功」¶
回應刻意不是全有全無:每個工具各自成功或被拒(refused 逐項給原因),
app 收到後把失敗的那支拿掉、turn 照跑。若整個請求 500,一個作者過期的 artifact
就會連帶讓同一個 workspace 裡其他所有工具消失——那是營運上最糟的失敗形狀。
回應同時帶 sha(sandbox 要掛哪一份)與 commands(要告訴模型這支工具吃什麼參數)。
兩者出自同一次 resolve,所以 app 眼中的介面與 sandbox 裡實際跑的 bundle 不可能對不上;
若 app 自己另外去讀 manifest,作者在兩次讀取之間發版就會讓模型用上一版的參數去呼叫新版工具。
stale: true 代表 artifact store 連不上、這是上一次成功解析的版本;
工具仍可用,但 app 應該讓使用者知道它不是最新的。
檔案以 raw application/octet-stream 的 body 傳遞(不是 base64-in-JSON)。
路徑都是相對於 workspace root;開頭的 / 代表 workspace root。
Readiness marker(#366):mark-ready/ready 操作的是一個放在沙盒根、workspace
外的空檔($root/{id}/.ready,跟 workspace 平輩),所以它不會出現在 walk、檔案樹或
exists,使用者也無法用同名檔偽造。app 的 mirror 只有在 ready 為真(walk 前後各驗一次)時才
傳播刪除;沙盒回收(DELETE /sandboxes/{rid})會先移除這個 marker 再 rmtree。
這裡沒有 expose_port endpoint——v1 沒有 sandbox 內網路服務的路徑。client 的
expose_port 會丟 NotImplementedError。upload_file /
download_to_file 是 client 端對 PUT/GET /file 的便利封裝,不是獨立的 endpoint。
exec 的 env:這個指令要看到的額外環境變數,由呼叫端逐次指名(#673)。
呼叫端的值最後套用,所以蓋得過 exec 路徑自己設的東西。省略即可——舊 client 不送這個欄位,
host 把「沒送」和「空的」視為同一件事。
這取代了早期把變數寫成一個沙盒內檔案、再讓工具去讀的做法:同一個 sandbox 裡 agent 和工具 共用 uid,落在磁碟上的東西兩者都讀得到。逐次指名之後,agent 自己的
exec沒有東西可繼承、 也沒有檔案可打開。
exec —— NDJSON streaming¶
回應是 application/x-ndjson,一行一個 JSON 物件:
{"o": "<base64>"}—— 一個即時輸出的 chunk(stdout+stderr 交錯),一到就送出; client 會把解碼後的 bytes 轉發到它的on_outputsink。- 最後一個 frame
{"exit": int, "out": "<base64>", "err": "<base64>"}—— exit code 加上分開的完整 stdout/stderr 緩衝區,client 據此重建ExecResult。 {"error": "<type>", "detail": "<msg>"}—— 若exec在 host 上拋了例外。此時 HTTP 狀態已經是200(stream 已開啟),所以後端錯誤是帶內(in-band)以一個 frame 傳遞;client 會重新拋出對映到的例外。- 若 stream 在最後一個
exit/errorframe 之前就結束,client 會把它當成 pod 死掉 →SandboxNotFound(任何已送達的ochunk 都保留)。
Error model¶
帶 body {"error": "<type>", "detail": "<msg>"} 的 404 會對映回 client 拋出的
例外:
SandboxNotFound—— 未知 / 已終止的 handle,或某個死掉的 pod(連線錯誤)。FileNotFoundError—— 下載 / 刪除 / rmdir / rename 時檔案不存在。
指令的非零 exit 不是錯誤——它搭著 exec 的 exit frame 一起回來。
傳輸層的兩種壞法必須分開(#492):逾時代表 pod 可達但慢(過載 / 大檔傳輸中)
⇒ client 映成 SandboxBusy,沙盒還活著,冪等的檔案/探測操作會以遞增的讀取期限 + 遞增
退避(皆有上限)重試,超過次數就大聲失敗;把「只是忙」誤判成死會再開一個沙盒 = split-brain。
其他傳輸失敗(連線被拒/重設)才是 SandboxNotFound。create(不冪等)、persist(長時間
rsync)、exec(有自己的期限)一律不重試。
Auth¶
v1 沒有:host 只在 cluster namespace 內可達 (NetworkPolicy / ClusterIP)。任何 namespace 內的 caller 都能驅動它——這是可接受的。