# Sandbox Handbook — full corpus Concatenated Markdown twins of every handbook page. zh-TW first, then English. Tenant API base: `{TEAMSYNC_API_BASE}/private/module/sandbox`. See `/llms.txt` for the index and reading order. # Locale: zh-TW # Sandbox 前端整合手冊 TeamSync · Sandbox Sandbox 前端整合手冊 把使用者的程式碼打包成環境、送到該部署建置、執行,再把日誌與產出物取回前端。這份手冊說明前端要怎麼串。 ## 這份手冊怎麼讀 先讀 **快速開始**,跑一次完整的 happy path:釘策展環境 **SCFG Standard**(或上傳+建置自訂映像)→ 建**部門**任務 → 發布 → 分享 → **房間啟用** → 提交執行 → 取回結果。跑過一次,後面的內容就都是查閱資料。 **概念** 是設計串接前值得先讀的部分。幾乎所有呼叫都是 **`/private/module/sandbox`**;region/profile 下拉是 `GET /public/info/sandbox/{regions,profiles}`。最常踩雷的是——**目錄任務是部門/公司擁有,不是聊天室**([任務受眾](/zh-TW/concepts/job-audience.md))、**未授權通常回 404**(錯的 owner-scope 是 **403**;`chatroom` 建立是 **422**)、**分享不是 Agent 選單**、**沒有 streaming(用輪詢)**、**兩種不同的 capability token**(上傳 15 分鐘 vs 下載 5 分鐘 — 都不是 URL)。權限是階梯,不是一律 company manager,見 [角色與權限](/zh-TW/concepts/personas.md)。 **操作流程** 是任務導向的:一頁一個你被交辦要做的功能。**API 參考** 列出端點、完整請求/回應欄位、五個 agent 工具、列舉與錯誤。**變更紀錄** 釘住手冊稽核時的後端 SHA。 [快速開始 → 從認證到第一個執行結果的完整最短路徑。](/zh-TW/get-started/index.md) [概念 → 控制面 vs 資料面、資源模型、狀態機與輪詢、內容與下載。](/zh-TW/concepts/index.md) [操作流程 → 建置環境、任務與綁定、執行與取結果、Agent 確認。](/zh-TW/flows/index.md) [API 參考 → 端點目錄、完整欄位表、agent 工具、列舉、錯誤。](/zh-TW/reference/index.md) ## 給 LLM / Agent 讀 這份手冊有機器可讀的純文字版本(與 [custom-tables-docs](https://custom-tables-docs.pages.dev/llms.txt) 同一套慣例): - [`/llms.txt`](/llms.txt) — 索引:閱讀順序、租戶 API 契約摘要、每頁 `.md` 連結。 - [`/llms-full.txt`](/llms-full.txt) — 雙語全文(先 zh-TW 再 en)。 - 每一頁還有對應的 Markdown twin,例如 [`/zh-TW/concepts/personas.md`](/zh-TW/concepts/personas.md)。 ## 內容來源 每一頁都對照後端 `origin/master` 原始碼撰寫。目前釘選見 [變更紀錄](/zh-TW/changelog/index.md)(`172a7f80bdf4dbdffdc018eb08bfa42c62bb485c`,release v5.21.0,2026-10-08)。前端端點取自 `src/routers/private/modules/sandbox/`(base `/private/module/sandbox`)加上 `GET /public/info/sandbox/*`;欄位表取自那些 router 加上 `src/schemas/sandbox.py`;狀態列舉與限制取自 `src/schemas/enums.py`、`src/components/sandbox/constants.py`、`src/components/sandbox/storage.py`。Agent 工具在 `src/components/tools/custom/sandbox/`。控制面(`/sandbox-control/*`)與 root(`/root/sandbox/*`)不對前端開放。若後端與本手冊不一致,以後端原始碼為準,並回報給我們修正。 v5.21.0 串接變更(任務的 `output_policy` 現在可以執行自訂表格 Command、runner 憑證的有效期限現在涵蓋整個 `timeout_seconds`、runner 會重試完成回報)請先看[更新紀錄](/zh-TW/changelog/index.md),再看[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md)。v5.10.11 的變更(佇列位置/預估時間、佇列 429 body、Quick Run input、依 provider 過濾的區域與地端部署)同樣在更新紀錄,以及[能力、管理查詢與執行規劃](/zh-TW/concepts/integration-contract.md)。 # 認證與 Base URL ## Base URL 幾乎所有前端 sandbox 端點都在: ```text {TEAMSYNC_API_BASE}/private/module/sandbox ``` (注意是單數 `module`。)本手冊之後的**租戶**路徑省略這段前綴,例如「`GET /settings`」= `GET {BASE}/private/module/sandbox/settings`。 **例外 — 沒有 `/private` 前綴,也不必登入:** `GET /public/info/sandbox/regions` 與 `GET /public/info/sandbox/profiles`。不要把它們掛在 `/private/module/sandbox` 底下。 ## 認證方式 帶標準 TeamSync 認證。`get_current_user` 再交給 `get_sandbox_principal` 解析行為主體。 | Header | 何時 | | --- | --- | | `Authorization: Bearer ` | 使用者登入(OAuth2 password bearer)。 | | `X-Api-Key: ` | API key。`get_current_user` 本來就吃這個 header。Sandbox 再讀一次以附上 scope/fingerprint/允許的房間。 | 兩者擇一。兩個都沒有 → 共用的 credential 例外,狀態碼是 **418**(`Could not validate credentials`,帶 `WWW-Authenticate: Bearer`),**不是 401**、也不是 sandbox 的 404。用戶端的重新登入/換 token 判斷必須認 418,只認 401 永遠不會觸發。API key 還會對照 key 的 `domains` 檢查 origin,除非 domain 清單含有單獨的 `*`。 後端用 `get_sandbox_principal` 解析出 principal: | 欄位 | 值 | | --- | --- | | `auth_method` | `jwt`(使用者登入)或 `api_key` | | `principal_type` | `user` 或 `social_media_client` | | 攜帶 | `company_id`、`role`、`api_key_scope`、`allowed_chatroom_ids`… | ### API key scope 的重要限制 **`Sandbox` scope 的 API key 是「受限 key」**,只允許:聊天室 menu、手動提交、查自己的 run(list/status/content/artifacts/download)、取消自己的 run。 受限金鑰不能用的管理面:quick-run、retry、environments(含 context 上傳)、tasks(含公司執行歷史)、granted-jobs、bindings、secrets、settings、root。要用這些,需要**使用者 JWT** 或 **`Full` scope** 的 key(`Full` 仍受租戶/角色限制)。 **狀態碼:**第一道 auth 閘門對 Quick Run、環境/任務管理、缺少 room path 或超出 key allowlist 的房間回 403(作者上傳 handle 保留明確 404)。有 room path 卻被 Sandbox route registry 排除的路徑,例如 retry、granted-jobs,回 405;受限 principal 若進到拒絕它的 Sandbox router 則回 404。這些回應不授予改走其他路徑的權限。 ## 權限(角色) 誰能變更什麼,取決於 **owner scope**、**consumer scope** 與 **聊天室管理員階梯**,不是「凡是變更都要 company manager」。 - 環境異動依 owner scope 要求公司或擁有部門的管理權;`GET`/`PUT /settings` 仍限定公司管理員(`role ≥ 3`)。 - 建立目錄任務:公司擁有 → 公司管理員;部門擁有 → 該部門管理員(或公司管理員)。**`POST /tasks` 送 `owner_scope=chatroom` 是 422。** 呼叫者不能擁有的 department/company scope 回 **403 `owner_scope_forbidden`**。 - 房間啟用、綁定、secret、quick-run 走聊天室/consumer 階梯;部門管理員不能對別的部門的室硬啟用。分享 vs 啟用見 [任務受眾](/zh-TW/concepts/job-audience.md)。 - 未授權的識別碼**通常回 404**,不洩漏資源是否存在。所以看到 404 常常是「沒權限」而不是「不存在」。 完整矩陣見 [角色與權限](/zh-TW/concepts/personas.md)。 ## Kill switch → 503 `SANDBOX_ENABLED` 預設 **true**。設成 `false` 時:所有**非 GET 的變更**回 **503 `sandbox_disabled`**;例外是路徑以 `/cancel` 或 `/download` 結尾者仍可用。GET/HEAD/OPTIONS 一律放行。 ## Idempotency-Key(提交類必帶) 以下端點**必須**帶 `Idempotency-Key` header,否則回 **422 `idempotency_key_required`**: - `POST /chatrooms/{id}/runs`(提交執行) - `POST /chatrooms/{id}/runs/{run_id}/retry` - `POST /chatrooms/{id}/quick-run` - `POST /secrets`、`/secrets/rotate`、`/secrets/revoke` 規則(`MAX_IDEMPOTENCY_KEY_CHARS = 128`):strip 後非空、≤128 字元、禁 NUL。客戶端用 UUID 很好,但伺服器不要求 UUID 語法。同一把 key 重送會回同一結果(安全重試)。每次新意圖換一把新 key。 ## 請求 body 規則 - 所有請求 body 為 `extra="forbid"`:帶了未知欄位 → **422**。 - 提交執行有先於讀 body 的大小防護:`Content-Length` 過大 → **413 `request_too_large`**。 下一步:[五分鐘跑一次](/zh-TW/get-started/quickstart.md)。 # 快速開始 這一節帶你把整條路徑跑一次。兩頁: 1. [**認證與 Base URL**](/zh-TW/get-started/auth.md):拿 token、認識 base URL、API key scope 與 404/503 語意。 2. [**五分鐘跑一次**](/zh-TW/get-started/quickstart.md):釘 **SCFG Standard**(或上傳+建置),再任務 → 執行 → 結果。 ## 前置條件 - 一組 TeamSync 認證(使用者 JWT,或 API key)。取得方式見 [認證](/zh-TW/get-started/auth.md)。 - Sandbox **預設開啟**(`SANDBOX_ENABLED` 預設 `true`)。Kill switch 是 `SANDBOX_ENABLED=false`:非 GET 的變更回 **503 `sandbox_disabled`**,路徑以 `/cancel` 或 `/download` 結尾者除外。 - 環境寫入依公司/部門 owner scope 判斷,公司設定仍需要公司管理員;當前 UI 能力探測請見[能力與管理查詢](/zh-TW/concepts/integration-contract.md)。 ## 心智模型(30 秒版) ```text SCFG Standard(system_curated, ready)─┐ ├→ 部門/公司任務 → 發布 → 分享 → 房間啟用 → 選單 → 執行 → 產出物 環境 → 上傳 → 版本 → ready ───────────┘ ``` - 幾乎所有呼叫都是 **`/private/module/sandbox`**。Region/profile 下拉是 `GET /public/info/sandbox/{regions,profiles}`。 - 目錄任務是 **部門或公司**擁有。分享不是 Agent 啟用。見 [任務受眾](/zh-TW/concepts/job-audience.md)。 - **沒有 streaming**,用輪詢。 - 提交執行要帶 **`Idempotency-Key`**。 - 下載產出物拿的是**短效 capability token**,不是 URL。 先讀完 [認證](/zh-TW/get-started/auth.md),再照 [五分鐘跑一次](/zh-TW/get-started/quickstart.md) 做。 # 五分鐘跑一次 除非另註,範例省略前綴 `{BASE}/private/module/sandbox`。用 `Authorization: Bearer` 或 `X-Api-Key` 認證。公司擁有環境需要公司管理員;部門擁有環境與上傳需要該部門管理員,公司管理員可管理兩者。建立目錄任務是 **部門或公司管理者** 工作 — **不是**聊天室擁有。房間啟用走 **聊天室管理者階梯**。提交執行是成員資格 + 適用的 share。見 [角色與權限](/zh-TW/concepts/personas.md) 與 [任務受眾](/zh-TW/concepts/job-audience.md)。 ## 0. 確認目錄 ```bash GET /public/info/sandbox/regions # 沒有 /private 前綴;不必登入 GET /public/info/sandbox/profiles # 跳過 tenant_selectable=false 的 id(xlarge) ``` ## 1. 快路徑 — 釘 **SCFG Standard**(跳過上傳/建置) `GET /environments` 含 `owner_kind=system_curated`。live 目錄名稱是 **SCFG Standard**。取一個 `state=ready` 的版本,跳到 [步驟 5](#5-建立部門任務並發布版本)。一般使用者永遠不要建環境。 ```bash GET /environments?lifecycle=active&page_size=100 GET /environments/{environment_id}/versions?lifecycle=active&page_size=100 # 選 system_curated/名稱 "SCFG Standard"/version state == "ready" ``` 只有在你需要**租戶自建**映像時才做步驟 2–4。`FROM` 任何公開映像——沒有固定允許清單,`scratch` 也合法。CVE 掃描結果只是參考,不會讓建置失敗。 ## 2. 建立自訂環境 ```bash POST /environments { "name": "my-runner", "description": "demo" } # → SandboxEnvironmentResponse { id, state: "active", ... } ``` ## 3. 上傳 context 並釘版本 ```bash POST /environments/{environment_id}/context-uploads { "declared_bytes": 123456, "archive_format": "tar.gz" } # → { upload_session_id, capability_token, put_header_name, put_content_type, ... } PUT /environments/{environment_id}/context-uploads/{upload_session_id} X-Sandbox-Upload-Capability: Content-Type: application/octet-stream # → { bytes_received, digest } POST /environments/{environment_id}/context-uploads/{upload_session_id}/complete { "expected_digest": "sha256:" } # → { owned_object_id, digest, byte_size } POST /environments/{environment_id}/versions { "owned_object_id": "", "resource_profile": "standard", "source_ref": "demo.tar.gz" } # → SandboxEnvironmentVersionResponse { id, state: "draft", ... } ``` **不要**送 generic `blob_id`。單獨送 `source_digest` 只給重試用,而且該 digest 必須已經對應一個完成的 context 物件。`resource_profile` 必須租戶可選。 ## 4. 觸發建置並輪詢到 ready ```bash POST /environments/{environment_id}/versions/{version_id}/builds { "build_profile": "standard" } # 可選 build_region:取自 GET /public/info/sandbox/regions 的代碼;省略則用該部署的預設值 GET /environments/{environment_id}/versions/{version_id}/builds/{attempt_id} # 直到 terminal_class 有值 GET /environments/{environment_id}/versions/{version_id} # 直到 version state == "ready" ``` **沒有 streaming**。 ## 5. 建立部門任務並發布版本 ```bash POST /tasks { "name": "hello", "owner_scope": "department", "owner_id": "{department_id}" } # chatroom owner_scope → 422 POST /tasks/{task_id}/versions { "environment_version_id": "{version_id}", "work_script": "echo hi", "timeout_seconds": 1800, "requires_confirmation": false } POST /tasks/{task_id}/versions/{task_version_id}/publish ``` ## 6. 分享,然後房間啟用 ```bash POST /tasks/{task_id}/shares { "target_kind": "chatroom", "target_id": "{chatroom_id}" } # 分享 ≠ Agent 選單 POST /chatrooms/{chatroom_id}/granted-jobs/{task_id}/enable { } # 省略 task_version_id → 釘目前已發布版 GET /chatrooms/{chatroom_id}/menu # 已授予且已啟用;每個呼叫者同一份清單 ``` ## 7. 提交執行(帶 Idempotency-Key) ```bash POST /chatrooms/{chatroom_id}/runs Idempotency-Key: { "task_version_id": "{task_version_id}", "input": { "any": "json" } } ``` 手動 REST 可以跑已授予、已發布的版本,即使房間尚未啟用。選單/Agent 只顯示已啟用的任務。產品 UI 應從選單提交。 ## 8. 輪詢狀態並取產出物 ```bash GET /chatrooms/{chatroom_id}/runs/{run_id} GET /chatrooms/{chatroom_id}/runs/{run_id}/content GET /chatrooms/{chatroom_id}/runs/{run_id}/artifacts POST /chatrooms/{chatroom_id}/runs/{run_id}/artifacts/{artifact_id}/download POST /chatrooms/{chatroom_id}/runs/{run_id}/output/public-link # → { url, token }(也有 .../log/public-link) GET # = GET /public/sandbox/artifacts/{token},免登入,DELETE .../public-link 可撤銷 # artifacts/{artifact_id}/download 回的 capability_token 目前沒有兌換路由——output/log 請用 public-link。 ``` ## Company manager 歷史(可選) ```bash GET /tasks/{task_id}/runs?offset=0&limit=10&order=desc GET /tasks/{task_id}/runs/numOfData ``` 受限(`Sandbox` scope)金鑰打這兩條路由會在認證層被擋下,回 **403**(`Access denied, restricted Sandbox key cannot use this route`),不是 404 —— `/private/module/sandbox/tasks` 在受限金鑰的拒絕路徑清單裡,請求到不了 router。 ## 想更快?Quick Run(只有管理者) 仍會鑄一個**隱藏的聊天室擁有**父任務給一次性腳本。不要把那個形狀抄到 `POST /tasks`。 ```bash POST /chatrooms/{chatroom_id}/quick-run Idempotency-Key: { "name": "adhoc", "environment_version_id": "{ready_version_id}", "startup_script": "", "work_script": "echo hi", "ordinary_env": {}, "input": {}, "timeout_seconds": 600 } ``` 完整欄位:[API 端點目錄](/zh-TW/reference/endpoints.md)。逐步流程:[操作流程](/zh-TW/flows/index.md)。 整個生命週期的參考實作(環境 → python/node/go 的 zip 任務包 → 9 次執行 → 下載輸出),零相依、Node ≥ 20:`teamsync-backend` 的 `sandbox/e2e-js/`。 # 架構:控制面與資料面 ## 前端呼叫什麼 幾乎所有 sandbox API 都在 **`/private/module/sandbox`**(`module` 是單數 — 後端把這條路當安全相關路徑做請求內容遮罩)。 不在 `/private/module/sandbox` 底下的租戶相關路徑有**兩組**,都不必登入:確認目錄(前端直接呼叫),以及公開產出物下載 `GET /public/sandbox/artifacts/{token}`(由下面第 4 點的 public-link 鑄出、給收件人點的連結,不是前端呼叫的 API)。確認目錄是: ```text GET /public/info/sandbox/regions GET /public/info/sandbox/profiles ``` 下拉選單用這兩個。不要自己發明 region 或 profile 字串。租戶 runtime 子集仍是 `GET /settings.allowed_regions`。營運端啟用仍是 `/root/sandbox/regions`。 `/private/module/sandbox` 底下的前綴: | 資源 | 前綴 | | --- | --- | | 公司設定 | `/settings` | | 環境/版本/建置/**context 上傳** | `/environments` | | 任務/版本/分享/**公司執行歷史** | `/tasks` | | Bindings(與 granted-jobs 啟用同一釘選) | `/bindings` | | Secrets/授權 | `/secrets`、`/secret-approvals` | | 選單、granted-jobs、執行 | `/chatrooms/{chatroom_id}/...` | 任務受眾(誰擁有、誰可分享、啟用代表什麼)見 [任務受眾](/zh-TW/concepts/job-audience.md)。 ## 前端看不到的世界 | 路由群 | 用途 | 認證 | | --- | --- | --- | | `/sandbox-control/*` | 隔離工作負載回呼、結果物件上傳、控制面**內部**的建置內容串流;地端部署另有 executor 的 work lease(`/sandbox-control/onprem/work/*`) | Google 服務帳戶 OIDC + capability(地端:以地端 executor token 取代 Google OIDC);`include_in_schema=False`。人類不該打 | | `/root/sandbox/*` | 營運/root | [營運與 Root](/zh-TW/concepts/operators.md) | | `POST /public/module/custom_tables/callback/command-output/{token_id}` | 由 Command 輸出 run 自己的 work script 呼叫(v5.21.0),不是前端 | 該 run 的密封 Bearer 憑證;見[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md) | ## 六個位元組接觸點 前端碰位元組的地方有**六個**。其餘(R2 key、R2 multipart part、runner 串流)都在控制面。 1. **建置 context 封存** — environment owner-scope manager 在 `/environments/{id}/context-uploads` 走 `init → PUT(X-Sandbox-Upload-Capability)→ complete`,再把 `owned_object_id` 送到建立版本。單一部分,最大 1 GiB。這**是**租戶 capability;**不是** generic blob_id,也**不是** R2 presign。見 [建立環境並建置](/zh-TW/flows/environment-and-build.md)。 2. **執行 `input`** — `POST .../runs` 的 canonical JSON。 3. **產出物下載** — `POST .../download` 回一個 **5 分鐘**的 `capability_token`,不是 URL。沒有租戶兌換路由。 4. **永久公開連結** — `POST .../runs/{run_id}/{log|output}/public-link` 鑄出一個不必登入、沒有 TTL 的 `GET /public/sandbox/artifacts/{token}` URL,給 run 的 log 或 output 用。見 [內容、Digest 與下載](/zh-TW/concepts/content-and-downloads.md)。 5. **公開目錄** — 很小的 JSON 列表,不是封存。 6. **腳本檔上傳**——兩條路由。`POST /tasks/{task_id}/script-uploads`(multipart `file`,一個 UTF-8 文字檔 ≤1 MiB,owner-manager)在版本還沒建立時就先驗證檔案,回一個 24 小時可重用的 handle,建立/修改版本時當 `startup_script_id`/`work_script_id` 帶入——建版本表單走的就是這條。`POST /tasks/{task_id}/versions/{vid}/script-file?target=startup|work`(同樣的檔案規則,只限草稿)直接寫進既有草稿;語意等同用字串 PATCH `startup_script`/`work_script`。 建立版本時單獨送 `source_digest` 是**重試/測試**逃生門,而且該 digest 必須已經對應一個完成的 context 物件。新的產品 UI 應該先上傳,再送 `owned_object_id`。 租戶面的 OpenAPI **雙 tag**:總 tag `Module: Sandbox` **加上**分區 `Module: Sandbox - Settings`/`Environments`/`Tasks`/`Bindings`/`Secrets`/`Runs`。 ## 沒有 streaming 租戶面沒有 WebSocket/SSE/StreamingResponse。建置與執行進度用**輪詢**。見 [狀態機](/zh-TW/concepts/states.md)。 # 內容、Digest 與下載 前端碰位元組的地方有**五個**:**context 上傳**、**執行 `input`**、**產出物下載**、**永久公開連結**,以及很小的**公開目錄**。見 [架構](/zh-TW/concepts/architecture.md)。 ## 1. 建置內容:有圍籬的上傳(不是只宣告 digest) Company manager 對 **公司擁有**(`owner_kind=company`)的環境走這條路。策展環境上傳會 404。 1. `POST /environments/{id}/context-uploads`,帶 `declared_bytes`(1..1 GiB)與 `archive_format`(`zip`/`tar`/`tar.gz`)。 2. `PUT /environments/{id}/context-uploads/{session_id}`,標頭 **`X-Sandbox-Upload-Capability`**,`Content-Type: application/octet-stream`。Body ≤ `declared_bytes`。 3. `POST .../complete` → `owned_object_id` + `digest`。 4. `POST /environments/{id}/versions` 帶那個 `owned_object_id`(可選、必須相符的 `source_digest`)。 這**不是**聊天室/DCC 的 `blob_id`,也**不是** R2 presign。Capability TTL 是 **15 分鐘**(`DEFAULT_CAPABILITY_TTL_SECONDS`)。PUT 還沒開始就過期就重新 init。 建立版本時單獨送 `source_digest` 只有在該 digest 已對應一個完成的 context 物件時才接受(重試/測試)。產品 UI 不該跳過上傳。 上限:每個封存 1 GiB、20 個 live staging slot、每公司 20 GiB 宣告量、每分鐘 5 次 init。未完成的上傳會佔住一個 slot,直到你呼叫 abort,**或該 session 的 24 小時 TTL(`UPLOAD_SESSION_TTL_SECONDS`)過期**;已完成的 session 不計入。slot 用滿時的 429 連 build 的 scan report 寫入也會一起卡住。錯誤:413 `context_too_large`、429 `staging_slot_exhausted`/`upload_rate_limited`、409 `capability_fenced`、503 `capability_unavailable`。 可輪詢的 session 狀態:`uploading` → `completed`,或 `aborted`/`abort_pending`。 ## 2. 執行 input:canonical JSON 提交執行時,`input` 是任意 JSON,但會當 **canonical JSON** 驗證(`parse_canonical_json`): | 限制 | 值 | | --- | --- | | UTF-8 大小 | ≤ 1 MiB(`MAX_INPUT_JSON_UTF8_BYTES`) | | 巢狀深度 | ≤ 64(`MAX_JSON_DEPTH`) | | 節點數 | ≤ 100,000(`MAX_JSON_NODES`) | | 其他 | 不可有 NUL、不可有重複鍵 | 超過回 422。提交端點還有**讀 body 之前**的大小閘:過大的 `Content-Length` 直接 **413 `request_too_large`**(不會把 1 GiB JSON 讀進記憶體)。 ## 3. 下載產出物:capability token,不是 URL 1. `GET /chatrooms/{id}/runs/{run_id}/content` → **布林旗標** `has_input`/`has_output`/`has_log`/`has_artifact_bundle`(+ `input_digest`)。**只有旗標。** `state` 可能是 `"missing"`。 2. `GET /chatrooms/{id}/runs/{run_id}/artifacts` → metadata(`relative_path`、`byte_size`、`digest`、`deletion_state`、`declared_content_type`……)。 3. `POST /chatrooms/{id}/runs/{run_id}/artifacts/{artifact_id}/download` → 短效 `capability_token` + `expires_at` + `content_type`/`content_disposition`/`x_content_type_options: nosniff`。 ```text capability_token = token_urlsafe(32) # 不透明;不是 URL;不是任何租戶路由的 Bearer expires_at = now + 5 minutes # _DOWNLOAD_CAPABILITY_TTL content_type = application/octet-stream content_disposition = attachment; filename="" x_content_type_options = nosniff ``` `/private/module/sandbox` 底下**沒有**串流產出物位元組的 `GET`,也沒有文件化的兌換標頭。不要發明兌換 URL。不要把這個 token 跟 `X-Sandbox-Upload-Capability`(上傳)或公開確認目錄搞混。 產品 UI 今天能做的: - 用 `GET .../artifacts` 顯示**中繼資料**。 - 打 `POST .../download` 證明目前 principal 可以碰這個 `artifact_id`。 - 把回傳的 token 當授權證明,給未來的位元組傳遞面用。`expires_at` 過了再發一次。 - `has_output`/`has_log` 只是旗標。REST 不回那些本體。Agent 工具 `sandbox_job_status` 可能回**有界、不可信預覽**(output ≤64 KiB,log 頭尾 ≤16 KiB)。見 [Agent toolkit](/zh-TW/reference/agent-tools.md)。 規則: - 授權永遠用 `artifact_id`(+公司/聊天室擁有權),不用 key。 - `deletion_state != active` → 下載 **404**。 - 下載 capability 是 **5 分鐘**;上傳 capability 是 **15 分鐘**。不要混用。 ## 4. 永久公開連結(不必登入) 一次 run 的 **log** 或 **output** 物件(不含個別產出物)可以拿到一個永久、不必登入的下載 URL——給 TeamSync 之外的人看結果用: ```bash POST /chatrooms/{id}/runs/{run_id}/{kind}/public-link # kind: log | output # → { run_id, object_kind, url, token, revoked, created_at } DELETE /chatrooms/{id}/runs/{run_id}/{kind}/public-link # 撤銷 # → 同樣的形狀,revoked: true ``` - **設計上就是永久——沒有 TTL。** 連結會一直活著,除非你撤銷它,或底層物件離開 `deletion_state=active`(保留期滿、scrub、offboarding)——兩種情況都會讓公開 URL 從此永遠 404。 - 鑄造是**冪等**的:連結還活著時再鑄一次,回的是同一個 token/URL。先撤銷再鑄一次,才會拿到**新** token——舊的永遠死了。 - 鑄造/撤銷需要跟本頁其他動作一樣的 run 內容可見性(房間成員資格/管理者階梯)。連結一旦鑄出,本身就**沒有**任何驗證——誰拿到 token 誰就能下載那個物件。 - 公開 URL 是 `GET /public/sandbox/artifacts/{token}`——沒有 `/private` 前綴、不必登入、不在 `/private/module/sandbox` 底下。回應是完整 buffer 後的 attachment(`Content-Disposition: attachment`、`X-Content-Type-Options: nosniff`、`Cache-Control: private, max-age=3600`);**不是**轉址,也**不是** presigned provider URL。**物件大於 100 MiB 時同樣回 404**(`MAX_PUBLIC_STREAM_BYTES`),即使連結還活著、物件仍是 `active`;provider 讀取失敗也一樣回 404。 - `kind` 只有 `log` 或 `output`——**不含** `artifact`。個別產出物檔案沒有永久公開連結,它們仍然只能走上面 5 分鐘的下載 capability。 - 鑄造/撤銷之前,該 run 必須已經有對應的 `log`/`output` 物件:`GET .../content` 的 `has_log`/`has_output` 為 false 時,`POST`(與 `DELETE`)`.../{kind}/public-link` 回 **404**。先輪詢到 run 進入 terminal 且旗標為 true 再鑄連結。 - 受限的 Sandbox API key 不能鑄造或撤銷公開連結:請求在 scope 過濾層就被擋下,回 **405**(`Access to POST … is not allowed for your role`,detail 帶的是實際請求路徑)。 因為這個連結是永久、猜不到但也不是什麼機密,把「鑄一個公開連結」當成任何「除非你記得撤銷否則不可逆」的分享動作來看——把「分享」按鈕直接接上這個功能之前,先想清楚產出物是不是敏感內容。 ## Digest 與內容定址 擁有物件的儲存 key 由後端產生且不會全域重用,形如 `sandbox/{company_id}/{object_kind}/{token}`;`object_kind` ∈ `context`/`input`/`output`/`log`/`artifact_bundle`/`report`。前端拿不到這些 key。授權用資源 id(`owned_object_id`、`artifact_id`、session id)。 # 概念總覽 Sandbox 讓前端把使用者定義的**任務(task)**,綁定一個已建置好的**環境版本(environment version)**,在**聊天室(chatroom)**裡**執行(run)**,再把日誌與產出物取回。 幾乎所有呼叫都是 **`/private/module/sandbox`**。Region/profile 下拉是不必登入的 `GET /public/info/sandbox/{regions,profiles}`。Company manager **會**經租戶 context-upload 路由上傳建置封存。Runner 串流與 R2 key 仍在控制面。 串接前先讀這幾頁: - [**架構:控制面與資料面**](/zh-TW/concepts/architecture.md):你呼叫哪些 API、哪些是後端系統對系統。 - [**角色與權限**](/zh-TW/concepts/personas.md):一般使用者、聊天室/部門/公司管理員、受限 key 誰能做什麼。 - [**任務受眾**](/zh-TW/concepts/job-audience.md):部門/公司擁有、分享 vs 啟用 vs 選單 vs writeback。 - [**營運與 Root**](/zh-TW/concepts/operators.md):`/root/sandbox` 營運面;不是產品前端。 - [**資源模型**](/zh-TW/concepts/resources.md):環境/context 上傳/任務/grant/啟用/執行。 - [**狀態機與輪詢**](/zh-TW/concepts/states.md):所有狀態值與轉換;**沒有 streaming,前端用輪詢**。 - [**內容、Digest 與下載**](/zh-TW/concepts/content-and-downloads.md):context 上傳、輸入 JSON 限制、產出物下載 token。 - [**Secret 與授權**](/zh-TW/concepts/secrets.md):secret binding、slot、borrower 授權。 - [**限制與配額**](/zh-TW/concepts/limits.md):分頁、輸入/腳本大小、逾時、每公司政策上限。 - [**保留期**](/zh-TW/concepts/retention.md):內容/映像/日誌各自的過期時鐘。 - [**通知**](/zh-TW/concepts/notifications.md):通知中心的 `sandbox_event`;它們只是提示,GET 狀態仍是權威。 ## 一頁看懂資料流 ```text 環境 Environment └─ context 上傳 → 版本 Version(owned_object_id → 建置 → ready) └─ 部門/公司任務 → 任務版本 → 發布 → 分享 └─ 房間啟用 → 選單 / Agent └─ 執行 Run(輪詢狀態;取產出物) ``` > **命名**:本手冊的「Sandbox」指 TeamSync 後端的 sandbox 模組,與 Cloudflare 的「Sandbox SDK」無關。 # 能力、管理查詢與執行規劃 本頁對應後端 release **v5.21.0**,釘選後端 `172a7f80bdf4dbdffdc018eb08bfa42c62bb485c`。Private 路徑共用 `/private/module/sandbox`。 ## 先讀 GET /me `GET /me` 回報目前使用者能管理什麼,本身不授予權限;之後每個操作仍重新檢查自己的 dependency。UI 可使用: | 欄位 | 介面用途 | | --- | --- | | `can_manage_environments`、`can_create_tasks` | 達部門/公司管理員門檻時顯示編輯控制 | | `can_read_settings` | 只對公司管理員顯示公司設定 | | `can_quick_run_any_room` | 只有公司管理員為 true;其他管理員仍可在自己能管理的房間 Quick Run | | `manageable_chatroom_scope` | `company` 是所有現有/未來房間,`listed` 是列出的 id,`none` 是沒有可管理房間 | | `manageable_chatroom_ids`、`manageable_chatroom_ids_truncated` | `company` 搭配空陣列代表全部房間,不是零個;被截斷的陣列不是完整名冊 | | `manageable_department_ids` | 呼叫者可管理的部門 scope | | `creatable_task_owner_scopes` | `{scope, ids}` 項目,可對應 task 的合法 `owner_scope`/`owner_id` 選項 | 房間/部門 id 陣列上限 5000。受限 Sandbox key 在 auth scope 回 403,外部 social client 回 404;這些整合應使用 room-addressed API。 ## 目錄可見與原始內容可見是兩個判斷 `GET /tasks`、任務 detail 與版本清單,只顯示使用者擁有/可管理,或透過 live audience grant 可見的任務。同公司不代表能看全部任務;已刪除的 owner 房間也不應讓整份目錄失敗。 `SandboxTaskVersionResponse.source_visible` 是**必填布林值**。True 表示 owner-source view;false 表示腳本、`ordinary_env`、`work_command`、slot declarations、output policy、task bundle 被隱藏,不能把 null 解讀成空任務。Room menu 永遠使用 borrower view,即使 owner 呼叫也回 `source_visible=false`。Owner 還要看 `content_state`:已 scrub 的版本也可能沒有可讀原始內容。 Borrower 可見的 `input_schema`、說明與範例仍可用來建立表單。`input_schema`/`input_example` 存在時是 JSON 編碼字串,使用前要解析;不要用角色數字或空腳本欄位猜 source 權限。 ## 跨房間讀取執行歷史 `GET /runs` 回 `{items, total, page_size, next_page_token}`。公司管理員可看公司 run;其他使用者可看自己的 run,加上自己建立/管理房間的 run,部門管理員再加上自己部門的房間。篩選只能縮小此範圍: ```http GET /private/module/sandbox/runs?status=platform_failed&status=customer_failed&submitted_by_me=true&page_size=50 ``` `chatroom_id`、`task_id`、`status` 可重複傳入。`from`/`to` 是包含邊界的 queued-time 篩選。排序為 `queued_at`、`id` 新到舊;從未 queued 的列排最後,有時間界線時則排除。`total` 在分頁前使用相同可見範圍與篩選。`page_size` 預設 50(1..100),下一頁原樣傳回不透明 cursor;格式錯誤會回第一頁。受限 key/social client 使用房間專屬歷史路徑。 ## 查找 binding 與遮罩 secret metadata | GET 路徑 | 誰可讀/用途 | | --- | --- | | `/tasks/{task_id}/bindings` | Owner-scope manager 可看採用紀錄;其他看得到任務的呼叫者,只看可管理房間的列 | | `/chatrooms/{chatroom_id}/bindings` | 房間管理員查看 binding 歷史;`task_visible=false` 時不顯示不可存取的任務名稱/owner metadata | | `/tasks/{task_id}/secrets` | 同時要求任務可見與 consumer-scope 權限;擁有任務不代表能看其他 consumer 的 secret binding | | `/tasks/{task_id}/secret-approvals` | 只看可管理 consumer scope 的核准 metadata | | `/secrets?consumer_scope=chatroom&consumer_id=...` | 精確 consumer-scope 管理員;可加 task/slot 篩選,外層與每列都有 `consumer_name` | 這些管理清單上限 2000 列,以 `truncated` 標示,不使用 page cursor。Binding 清單預設 `lifecycle=active`,傳 `all`/`retired` 可看撤銷的採用紀錄。它們與 `menu`(可執行的 Agent 目錄)、`granted-jobs`(live 可用性與啟用開關)不同。 請一起判讀 `enabled`、`authority_applicable`、`update_available`、state 與時間欄位。Secret 清單只提供遮罩狀態、generation 與 provider binding 是否存在,不回值、provider 路徑/id 或 fingerprint。讀取清單不會建立核准;請見[分享與 Enable 同意](/zh-TW/concepts/job-audience.md)及[Secret 核准](/zh-TW/concepts/secrets.md)。 ## 依 settings 規劃區域與費用 需登入且具公司管理權限的 settings 提供 `selectable_regions` 與可為 null 的 `pricing`。執行區域選項應使用目前 operator region codes,不要硬編碼公開確認 enum。`allowed_regions` 是已儲存的選擇,PUT 時會再次檢查可用性。Environment 的 `region_readiness` 保留 live codes,值為 `ready`/`not_ready`。 `pricing.regions[].profiles` 提供已含加價的客戶 `usd_per_second`。回應還包含費率/界線版本、`min_billable_seconds`、`finalization_seconds`、進出站費率,以及 outbound 預留 bytes/USD。預估上限使用符合條件區域中的最高 profile 費率: ```text max(min_billable_seconds, timeout_seconds + finalization_seconds) * highest_eligible_usd_per_second + outbound_reservation_usd ``` 不要再乘一次加價。沒有可選區域時 `pricing=null`;提交仍會重新檢查目前政策、費率與預算。這是規劃預估,不是最後的用量帳單。 `allowed_regions` 只控制 runtime execution,不控制 build、registry、R2、log 或 control-plane 的位置;`residency_guaranteed` 仍為 false。`runtime_egress_enabled` 是 settings 中唯讀、由 operator 管理的欄位;區域選擇不是 egress 或資料落地保證。 ## 雲端與地端部署(v5.10.11) 同一套租戶 API 可以跑在託管雲端(預設 provider),也可以跑在自架主機(`SANDBOX_PROVIDER=onprem`)。沒有欄位直接標示 provider;看區域目錄就能分辨,因為只有地端部署會公開 `onprem`。請依目錄、`GET /settings` 與 run 欄位建構介面,不要預設雲端行為。 | 面向 | 託管雲端 | 地端部署 | | --- | --- | --- | | 公開區域目錄(`GET /public/info/sandbox/regions`) | 雲端區域,`tier` 1 或 2 | 只有 `onprem`(「On-premises」),`tier` 3 | | `build_region` | 預設 `asia-east1`;`onprem` → 422 `build_region_unavailable` | 預設 `onprem`;其他代碼 → 422 `build_region_unavailable` | | `GET /settings` 的 `pricing` | Cloud Run 費率,已含加價 | tier 3 名目會計費率(預設每 vCPU-秒與每 GiB-秒 `0.000001` USD,operator 可覆寫,絕不為 0),同樣已含加價;`onprem` 是唯一可選區域時 `pricing_version` 為 `sandbox_onprem/v1.0.0` | | 等待中的 run | `queue_position` 為公司 `queued` run 中的位置;`eta_seconds` 一律 `null` | 跨公司的 executor 佇列位置(`starting` 時也有);有已完成 run 的歷史時才有 `eta_seconds` | | 佇列上限(429) | 公司政策 `queued_run_limit` → `sandbox_queued_run_limit` | 另有每公司(預設 20)與整個 fleet(預設 200 → `queue_full`,`Retry-After: 60`)上限;Quick Run 兩者都適用 | | `next_action` 用語 | 在適用處指名 Cloud Run | 不指名 provider:submit/start 狀態用「the local runtime」、容量狀態用「the on-prem sandbox」;`wait_reason` 值相同 | | 失敗詞彙 | Go runner 的字面值 | 相同的 `error_code`/`failure_stage` 字面值,逾時 `exit_code=124`、取消 `130`;少數 executor 端失敗使用地端專屬代碼,例如 `executor_error`——遇到不認得的代碼當成平台失敗,並顯示 `failure_hint` | | 工作 shell 的環境變數 | `PATH`、`ordinary_env`、secret slot 與三個 `TEAMSYNC_*` 路徑——沒有其他映像 `ENV` | 相同(其他映像 `ENV` 在 session 開始前就被 unset);另可能帶有下方的信任憑證變數 | | 執行期對外網路 | 公司 `runtime_egress_enabled` | 另需主機設 `SANDBOX_ONPREM_EGRESS=internet`(預設 `none`) | | 工作回呼這個 TeamSync(自訂表格 writeback、[Command 輸出](/zh-TW/flows/command-output.md)) | 公開的 API origin | 需要執行期對外網路,加上 operator 開放 API origin(`SANDBOX_ONPREM_TENANT_API_ORIGIN`)。設定 `SANDBOX_ONPREM_TENANT_CA_FILE` 後,主機的憑證經由 `/etc/teamsync/ca` 受信任,`SSL_CERT_FILE`、`REQUESTS_CA_BUNDLE`、`CURL_CA_BUNDLE`、`GIT_SSL_CAINFO`、`NODE_EXTRA_CA_CERTS` 會指向它,除非任務自己已經設定 | `selectable_regions` 是租戶 settings 使用的 active operator 列集合,不是封閉的公開目錄。正確 bootstrap 的地端部署只會有 active `onprem`;若 operator 另外註冊 tier 1/2 列,它們可能出現在 `selectable_regions`,即使公開目錄與 `build_region` gate 仍只發布 `onprem`。 營運端機制(executor work lease、排程器、佇列逾時、`GET /root/sandbox/queue`)見[營運與 Root](/zh-TW/concepts/operators.md)。Run 欄位細節見[執行與取回結果](/zh-TW/flows/run-and-results.md)。 ## 保留作者的 metadata 新的 `secret_slot_declarations` 使用物件,每筆必須有合法、非保留環境名稱的 `name`,並保留可選宣告 metadata。讀取側仍可能遇到歷史字串或不透明物件;請保留既有資料,新寫入則使用目前的具名物件格式。OpenAPI 會保留 name 與 region-readiness 值型別,供前端產生型別。 來源:Sandbox tenant router 的 `principal_views.py`、`ownership_views.py`、`server.py`、`tasks.py`、`runs.py`,以及 `src/schemas/sandbox.py` 與共用 access/binding helpers。Provider 差異:`src/components/sandbox/providers/`、`src/components/sandbox/constants.py`(`visible_region_codes`)、`src/components/sandbox/pricing.py`、`src/components/sandbox/run_visibility.py`、`src/crud/sandbox/run_submission.py`、`src/crud/sandbox/onprem_scheduler.py` 與地端 executor `src/workers/sandbox_onprem/`。 # 任務受眾 — 分享、啟用、選單 這一頁是 PR #975 之後目錄任務的**單一契約**。其他頁只連到這裡,不要各自重寫規則。若別頁與本頁衝突,以本頁為準(並當作手冊缺陷回報)。 **目錄任務**是 `POST /tasks` 建出的 `SandboxTask`。它**不是**聊天室擁有的物件。 ## 建立:只有 department 或 company | `owner_scope` | 誰可以建 | `owner_id` | | --- | --- | --- | | `department` | 該部門 manager,或 company manager | 部門 id | | `company` | company manager | 必須等於 `company_id` | `POST /tasks` 只接受 `SandboxTaskCreateOwnerScope`:**`department` | `company`**。送 `chatroom` 是 **422**(enum)。隱藏的 Quick Run 父任務仍由 `POST .../quick-run` 以聊天室擁有的內部物件鑄出——不要自己建那種。 歷史上的 `owner_scope=chatroom` 列仍可**讀**。不要把它當成建立路徑。 呼叫者不能擁有的 scope(錯部門、不是 company manager、`owner_id` ≠ company)是 **403 `owner_scope_forbidden`**。這仍是「用 404 藏存在性」的文件化例外。 任務父層的 `agent_enabled` 是**舊旗標**。Agent 能否看到任務,看的是房間的啟用開關,不是這個欄位。 ## 分享 ≠ Agent 啟用 `POST /tasks/{id}/shares` 授的是**使用權**。它**不會**把任務放進 `GET .../menu`,也不會交給 Agent。 部門擁有的任務,可由擁有部門 manager(或 company manager)分享到: 1. **`company` + 公司 id** — 整間公司,包含之後才建立的部門。 2. **`department` + 部門 id** — 該部門(包含之後才在該部門建立的房間)。 3. **`chatroom` + 房間 id** — 同公司任一存活房間,不必先分享整個部門。 擁有部門的管理員(或公司管理員)可直接分享到**同公司任一存活聊天室**,不必先建立公司/部門 grant。分享只提供可用性,接收方的房間管理員仍自行決定是否啟用;跨公司或已失效對象仍不可用。 公司擁有的任務可由 company manager 分享到同樣三種對象(都在公司內)。它們在每個房間對 writeback 都仍是 **borrowed**。 一筆分享也可以帶逐 slot 的**secret 憑證來源政策**(`secret_slot_policies`,只能在建立時設定——見 [Secret 與授權](/zh-TW/concepts/secrets.md)):借用方的房間是要自己綁 secret,還是借用 owner 的 binding。 撤銷會收回該 grant;若仍有等價的 live 授權,binding 可繼續用於後續提交。何時需要明確重新接受,請見下一節。 ## 重疊分享與憑證同意(v5.10.0) 適用分享依 **chatroom → department → company** 順序解析。房間保留精確的任務版本 pin,以及管理員已接受的逐 slot 憑證來源政策。撤銷其中一個 grant 後,政策等價的其他 live grant 可以涵蓋後續提交;不同的 secret 來源政策不會被悄悄採用,需要改變同意內容時必須明確 Enable/Update。已提交的 run 仍固定在當時的 grant 與 generation;撤銷該 grant 仍會取消受影響的工作。 Enable、明確建立 binding 與 accept-version,會在每個必要 slot 都能解析時,替操作者實際管理的 consumer scope 記錄精確版本的借用 secret 同意,不會讀取 secret 值。缺少 slot,或 consumer scope 由他人管理時,仍須設定或由該 scope 管理員明確核准。GET、發布、分享、一般 run 都不會建立同意;補齊 secret 後可再呼叫冪等 Enable 完成同意。接受新版本會保留原先已接受的憑證來源政策。 ## 啟用是房間開關 房間建立者/管理者([聊天室管理者階梯](/zh-TW/concepts/personas.md)): | Method + path | 效果 | | --- | --- | | `GET /chatrooms/{id}/granted-jobs` | 到達此房間的每個 live grant,加上 `enabled`。受限 Sandbox 金鑰在 scope 過濾層回 **405**,detail 是 `Access to GET /private/module/sandbox/chatrooms/{id}/granted-jobs is not allowed for your role`(帶實際房間 id)——不是 403,也不是 `invalid scope`。 | | `POST /chatrooms/{id}/granted-jobs/{task_id}/enable` | live binding 已釘在你要的版本(或沒送 `task_version_id`)時冪等。送**不同的** `task_version_id` 會重新釘版(後端 ≥ #1140:既有 binding 像 `disable` 一樣撤銷,再建新的);`POST /bindings/{binding_id}/accept-version` 仍是原地升版。任務沒有分享給這個聊天室(或其部門/公司)時回 **409 `task_not_shared_to_room`**,訊息寫出該呼叫哪條分享 API。任務進入選單/Agent。 | | `POST /chatrooms/{id}/granted-jobs/{task_id}/disable` | 撤銷 live binding。**share 還在**。任務從選單消失,直到再次啟用。 | `POST /bindings` 帶明確版本 id 做同一種採用,但**不冪等**:房間對該任務已有 live binding 時回 **409 `binding_already_live`**,而 `granted-jobs/{task_id}/enable` 會直接回傳現有 binding。產品路徑請用 granted-jobs。沒有根層 `GET /bindings`;請使用[能力與管理查詢](/zh-TW/concepts/integration-contract.md)的 task/room scoped binding 清單。 分享不會自動啟用。Grant 一開始是 `enabled=false`。 ## 兩份目錄、兩道提交閘 | 面 | 誰看得到 | 列出什麼 | 提交閘 | | --- | --- | --- | --- | | `GET /chatrooms/{id}/granted-jobs` | 只有房間管理者 | 已授予的任務(不論是否啟用) | 不適用 | | `GET /chatrooms/{id}/menu` | 看得到該房間的人,包含受限 Sandbox 金鑰 | **已授予且已啟用**。**每個呼叫者看到同一份清單** | 產品 UI 應提交這些 `task_version_id` | | `POST /chatrooms/{id}/runs` | 房間成員(或階梯上的管理者)+ live 適用 share | 不適用 | `can_execute_manually`:成員資格 + live share。**不要求啟用。** 已授予任務的任何 published+active 版本都可接受。 | | Agent 工具(`sandbox_job_menu`/`sandbox_submit_job`) | 房間 job 含 `"sandbox"` 才載入 | 同一份 granted+enabled 目錄 | `can_execute_via_agent`:已授予 **且** 此房間對**該版本**有 live 適用 binding | 不要寫「分享 = 選單」或「只有啟用才能跑」。手動 REST 可以跑尚未啟用、但已授予的任務。Agent 與房間選單不行。 ## Writeback 受眾(自訂表格) `output_policy` 是有型別的,不是不透明 JSON,有兩種:**表格 writeback**(`custom_table_writeback`)與 **Command 輸出**(`custom_table_command`,v5.21.0)。只有**部門擁有**的任務可以發布其中任何一種。公司擁有的任務發布 → **409 `output_policy_owner_scope_unsupported`**。 一次執行會**鑄出** callback 憑證,當: - 任務是部門擁有,**且** - 作用中的房間在**擁有部門**裡(即使該房間是透過明確 share 進來的),**且** - 提交者是互動式 JWT 使用者(不是 API key、不是受限 Sandbox 金鑰、不是社交客戶端),**且** - 所需的權限都在:表格 writeback 是發布者(發布時)與提交者(鑄憑證時)都持有該表的不受限寫入權;Command 輸出是提交者與版本的撰寫者(最後寫入草稿的人)在每次提交時都通過 Command 檢查,而發布者在發布時通過它。 對**表格 writeback**,作用中房間在**外來**部門,或任務是**公司擁有**,這次執行是 **borrowed**(**跳過**鑄憑證,run 仍會排隊 — 這**不是** 403)。Trigger 開火會直接拒絕 `custom_table_writeback` 的版本。Agent 仍走兩回合確認。403 `writeback_authority_denied` 只有在真的嘗試鑄憑證、且 JWT 提交者對該表沒有足夠權限時才會出現。細節見 [自訂表格 trigger](/zh-TW/flows/custom-table-trigger.md)。 **Command 輸出**使用同樣的擁有部門受眾,但從不跳過:borrowed 的 run、來自政策 `chatroom_id` 以外房間的 run、不是以 JWT 驗證的使用者的提交者(API key、受限 Sandbox 金鑰或社群媒體 client)、沒通過身分、grant 或範圍檢查的提交者/版本撰寫者,或不再是帶 `effect_identity` 的 restricted definition-authority 的 Command,一律以 **403 `output_policy_author_denied`** 拒絕,且什麼都不會排隊(Command 已被刪除或輸入不再相符,則改為 422)。Trigger 路徑與 Agent 接受這種版本(`requires_confirmation=false` 時,Agent 不需要確認回合)。契約見[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md)。 ## Company manager 的執行歷史 Company manager 可以列出一個目錄任務在所有房間的每一次執行: - `GET /tasks/{task_id}/runs` — `offset`/`limit`(預設 10,最大 100),`order=asc|desc` - `GET /tasks/{task_id}/runs/numOfData` — 同一組篩選,`{ num }` 受限金鑰在認證層 **403**(沒有聊天室路徑/tasks deny)。非 manager JWT **403** `Insufficient permissions.`。這不是房間執行清單(`GET /chatrooms/{id}/runs`)。 ## 產品 UI 該怎麼接 1. 公司/部門管理者建立**部門**(或公司)任務 → 草稿版本 → 發布 → 分享。 2. 房間管理者打開**已授予任務**,啟用要出現在 Agent/房間選單上的那些。 3. 一般成員從**選單**執行。 4. Company manager 在任務上看**每任務歷史**,不要逐房走訪。 ## 相關 - [角色與權限](/zh-TW/concepts/personas.md) — 誰可以分享/啟用/執行。 - [任務、發布與綁定](/zh-TW/flows/tasks-and-bindings.md) — HTTP 步驟。 - [Agent toolkit](/zh-TW/reference/agent-tools.md) — list/status/cancel 仍綁在 principal。 # 限制與配額 以下是**前端 API 實際會碰到**的限制(取自 `src/components/sandbox/constants.py`、`storage.py` 與 schema)。Context 上傳的 staging 配額對 company manager **是**租戶面。 ## 分頁 | 項目 | 值 | | --- | --- | | `page_size` | `ge=1`,`le=100`(`MAX_PAGE_SIZE`),預設 20 或 50(依端點) | | `page_token` | 不透明字串,`max_length=512` | ## 輸入與腳本大小 | 項目 | 值 | | --- | --- | | 執行輸入 JSON | ≤ 1 MiB、深度 ≤ 64、節點 ≤ 100,000、無 NUL、無重複 key | | startup / work 腳本 | 各 ≤ 1 MiB(`MAX_SCRIPT_UTF8_BYTES`) | | 環境變數 | ≤ 100 個名稱、每項 ≤ 64 KiB | | 提交 body(Content-Length) | 過大先回 413 `request_too_large` | ## 逾時 | 項目 | 值 | | --- | --- | | 預設 work 逾時 | 1800 秒(`DEFAULT_WORK_TIMEOUT_SECONDS`) | | 最大 work 逾時 | ≈ 7 天(168 小時 − finalization envelope) | | `timeout_seconds` 欄位 | `ge=1`,`le=604680`(`MAX_CUSTOMER_WORK_TIMEOUT_SECONDS`)。公司 `timeout_ceiling_seconds`(預設 86400)**只在發布任務版本時**檢查——任務版本發布與 quick-run(會發布隱藏的 v1)超過即 422 `timeout_exceeds_ceiling`;`POST .../runs` 與 Agent submit 上的 `timeout_seconds` 覆寫**不會**再對公司天花板檢查。 | | 提案 TTL | 1800s(`PROPOSAL_TTL_SECONDS`) | | Turn-receipt TTL | 600s(`TURN_RECEIPT_TTL_SECONDS`) | | Agent `wait_seconds` | 0..60(`MAX_STATUS_WAIT_SECONDS`) | | 下載 capability | 5 分鐘(`_DOWNLOAD_CAPABILITY_TTL`) | | Context 上傳 capability | 15 分鐘(`DEFAULT_CAPABILITY_TTL_SECONDS`) | | Run 的 runner 憑證(manifest、進度與完成回報、結果上傳) | 認領起算 `timeout_seconds` + 120 秒(`FINALIZATION_ENVELOPE_SECONDS`),所以最多 604800 秒。v5.21.0 之前這兩把憑證用的是平台預設的 600 秒。 | ## Context 上傳(company manager) | 項目 | 值 | | --- | --- | | 封存大小 | ≤ 1 GiB(`MAX_DECLARED_OBJECT_BYTES`) | | Live staging slots | 20(`MAX_STAGING_SLOTS`) | | 宣告 staging 位元組 | 20 GiB(`MAX_STAGING_DECLARED_BYTES`) | | 每公司每分鐘 init | 5(`MAX_UPLOAD_INITS_PER_MINUTE`)——計 context 上傳與腳本/任務包上傳;run `input` 與 runner 自己的結果物件不計(後端 ≥ #1140) | | 模式 | 只有 `single_part` | | 格式 | `zip`/`tar`/`tar.gz` | | Dockerfile `FROM` | 任何公開映像,含 `scratch`——2026-08-25 起沒有固定允許清單 | 429 `staging_slot_exhausted`/`upload_rate_limited`。413 `context_too_large`。未完成的上傳會佔住一個 live slot(與其宣告位元組),直到被 abort **或該 session 過期**(`UPLOAD_SESSION_TTL_SECONDS` = 24 小時);已 complete 的 session 會立刻釋放 slot。20 個佔滿時,連 scan report 的 staging lease 也開不出來。 ## 產出物 | 項目 | 值 | | --- | --- | | 單一 run 產出物檔案數 | ≤ 1000(`MAX_ARTIFACT_FILES`) | | Agent 產出物頁 | ≤ 20 列(`ARTIFACT_LIST_PAGE_SIZE`) | | Agent output 預覽 | ≤ 64 KiB(`MAX_OUTPUT_PREVIEW_UTF8_BYTES`) | | Agent log 預覽 | ≤ 16 KiB head+tail(`MAX_LOG_PREVIEW_UTF8_BYTES`) | | Idempotency-Key | 1..128 字元,禁 NUL | | Ordinary env 序列化 | ≤ 64 KiB(`MAX_ORDINARY_ENV_SERIALIZED_UTF8_BYTES`) | | Ordinary + secret env | ≤ 256 KiB(`MAX_ORDINARY_PLUS_SECRET_ENV_UTF8_BYTES`) | ## 每公司政策上限(`SandboxCompanyPolicy`) 由後端政策決定、可經 `PUT /settings`(部分)調整或屬營運範圍: - `active_environment_limit`:可用環境數上限。預設 **30**(`DEFAULT_ACTIVE_ENVIRONMENT_LIMIT`)。 - `capacity_retained_version_limit`:營運端設定的**輸入值**,預設 **100**。實際生效上限是 `min(設定值, floor(0.10 × 各 active region 中最小的已確認 Cloud Run Job 配額))`;任一 active region 配額未知、或完全沒有 active region 時即為 0。計數是整間公司的 capacity-retained 版本數;超過就 409 `version_allocation_exhausted`,所以實際可用的版本數常遠低於 100。 - 預設公司 run 併發 **50**、queued-run 上限 **100**、active-build 上限 **2**、queued-build 上限 **20**。queued-run 上限把關 REST 提交、重試、Agent 提交與自訂表格 trigger;Quick Run 不受它檢查。 - 地端部署另有兩個 operator 設定的等待 run 上限(v5.10.11):每公司 `SANDBOX_ONPREM_MAX_QUEUED_PER_COMPANY`(預設 **20**;實際的公司上限取它與政策上限中較小者,Quick Run 也適用)與整個 fleet 的 `SANDBOX_ONPREM_MAX_QUEUED`(預設 **200** → 429 `queue_full`,帶 `Retry-After: 60`)。託管雲端兩者皆無。 - `run_content_retention_days`:1..365(可設定;預設 30)。 - `unreferenced_image_retention_days`:1..90(可設定;預設 30)。 - 執行期 profile(`SandboxResourceProfile`): | Profile | vCPU | 顧客記憶體 | Workspace | 租戶可選 | | --- | --- | --- | --- | --- | | `standard` | 1 | 1280 MiB | 512 MiB | 是(預設) | | `performance` | 2 | 2560 MiB | 1024 MiB | 是 | | `large` | 4 | 5120 MiB | 2048 MiB | 是 | | `xlarge` | 8 | 10240 MiB | 4096 MiB | **否** — 只有營運端可開 | 確認集合:`GET /public/info/sandbox/profiles`。 提交/佇列建置的定價拒絕:403 `budget_reservation_exceeded`(envelope 塞不進配額;**不建列**)或 422 `no_active_region`/`missing_price`/`invalid_envelope`/`missing_window`。queued-run 上限 → **429** `sandbox_queued_run_limit`(body 含 `limit`);地端 fleet 佇列已滿 → **429** `queue_full`(`limit`、`retry_after_seconds`、`Retry-After`)。因為沒有已確認的區域 Job 配額而版本上限為 0 → 409 `version_allocation_exhausted`。平台 stage-image 輪替期間(全平台、非單一公司的限制),`POST .../builds` 與 `POST .../retry-build` 會回 **503 `build_admission_fenced`**,body 帶 `retry_after_seconds`(同值也在 `Retry-After` header)——此時**什麼都沒排進佇列**,依 `Retry-After` 重試即可(通常數分鐘);Redis 不可用時 fence 會 fail-open 放行。 第一次 `GET /settings` 可能**插入**一列預設 `SandboxCompanyPolicy`(`get_or_create_company_policy` 然後 commit)。把它當成一次可能寫入預設值的讀取。 超過相關上限時,對應端點回錯誤(多為 409/422,未授權為 404)。錯誤碼見 [錯誤碼對照表](/zh-TW/reference/errors.md)。 # 通知 Sandbox 透過既有的 TeamSync 通知中心(`NotificationCenterType.SANDBOX_EVENT` = `"sandbox_event"`)寫入 inbox + FCM/MQTT。它們**不能取代輪詢**。`GET` run/build 狀態(`terminal_at`/`terminal_class`)仍是真相來源。 用**既有**通知 API 讀 inbox(不在 `/private/module/sandbox` 底下): ```text GET /private/notifications?type=sandbox_event ``` 每列是 `UserNotificationResponse`:`id`、`type`、`title`、`body`、`metadata`、`source_type`、`source_id`……。`metadata.kind` 是 `sandbox_event`。社群/LINE run **不會**幫外部 client 建 inbox 列——會扇出給聊天室管理者(否則公司管理者)。把管理者 deep-link 到該 run。 Payload **只帶** id、status、error_code、標籤與 deep-link metadata。絕不包含腳本、secret、input/output/log 本體、ENV、capability token 或 URL(`src/crud/sandbox/notifications.py` 的 `_FORBIDDEN_PAYLOAD_KEYS`)。 ## 事件目錄 | `event`(`SandboxNotificationEvent`) | Deep-link 資源 | 典型收件人 | | --- | --- | --- | | `run_terminal` | `run` | 內部呼叫者本人;外部/社群呼叫者 → 聊天室管理者,否則公司管理者 | | `run_content_expiring` | `run` | 合格呼叫者,否則聊天室管理者 | | `run_content_expired` | `run` | 同上 | | `build_ready` | `build_attempt` | 環境 owner-scope 管理者 + 公司管理者(後端 #1149 起有 producer;與 attempt 終態同一交易寫入,以 `attempt_id` 冪等) | | `build_failed` | `build_attempt` | 同上——body 帶 `error_code`/`error_detail` | | `build_cancelled` | `build_attempt` | 同上 | | `curated_environment_published` | `environment` | 已釘住該 curated 環境任一版本的每家公司的公司管理者(不會廣播給所有租戶);後端 ≥ #1149 | | `security_finding_discovered` | `finding` | 提交者 + owner-scope 管理者 + 公司管理者 | | `security_finding_resolved` | `finding` | 同上 | | `security_finding_reappeared` | `finding` | 同上 | | `security_finding_blocked` | `finding` | 同上 | | `security_remediation_deadline` | `finding` | 同上 | | `security_exception_granted` | `finding` | 同上 | | `security_exception_revoked` | `finding` | 同上 | | `security_exception_expired` | `finding` | 同上 | | `runner_replacement_available` | `environment` | 環境擁有者 + 公司管理者 + 受影響房間管理者 | | `runner_handoff_completed` | `environment` | 同上 | | `runner_handoff_expired` | `environment` | 同上 | | `runner_handoff_failed` | `environment` | 同上 | | `image_storage_renewal_blocked` | `company` | 公司管理者 | | `image_storage_cleanup_completed` | `company` | 公司管理者 | Inbox 冪等鍵是 `(user_id, type, source_type, source_id)`。`source_type` 例如:`sandbox_run_terminal`、`sandbox_build_ready`、`sandbox_security_finding_discovered`。`run_terminal` 的 `source_id` 是 `run_id`。 ## Dispatch payload(安全欄位) FCM/通知中心的 `dispatch_data` 是字串化的,包含: ```text notification_type = "sandbox_event" event = SandboxNotificationEvent company_id status error_code run_id (適用時) build_attempt_id (適用時) finding_id (適用時) deep_link_resource_type = run | build_attempt | finding | environment | company deep_link_resource_id ``` 用 deep-link 對去開既有的狀態頁,然後**再 GET** 該資源。不要只信推播上的 `status`。 ## `run_terminal` 文案(zh-TW) `run_terminal`、三個 `build_*` 事件與 `curated_environment_published` 有專屬的繁體中文標題/內文(本部署的使用者語言;build/curated 文案自後端 #1149 起)。其他事件維持英文預設。 | Run `status` | 標題 | 內文模式 | | --- | --- | --- | | `completed` | 沙盒任務執行完成 | `「{task}」已順利執行完成,點開查看結果。` | | `cancelled` | 沙盒任務已取消 | `「{task}」已被取消。` | | `customer_failed` / `platform_failed` | 沙盒任務執行失敗 | `「{task}」執行失敗(錯誤代碼:{error_code}),點開查看詳情。` | | 其他 | 沙盒任務已結束 | `「{task}」已結束(狀態:…)。` | 目前**每一則** `run_terminal` 的 `{task}` 都是 `您的沙盒任務`:唯一的生產端 `record_run_terminal` 不傳 `task_label`,`metadata.task_label` 也永遠是 `null`。要在通知裡顯示任務名,前端得用 `metadata.task_id`/`run_id` 自行查。 ## 前端規則 - 走既有通知中心/FCM 客戶端訂閱。沒有 sandbox 專用 websocket。 - 尊重使用者通知偏好;後端已做 `apply_notification_preferences`。 - 沒收到通知 ≠ run 不存在。繼續輪詢。 - Deep-link 只開使用者本來就能 `GET` 的 run/build。A-010 仍適用:擁有被分享的任務,不能因此打開 borrower 房間的 run。 - 不要把通知 `data` 當成含有 log 或產出物來渲染。 ## 相關 - [狀態機與輪詢](/zh-TW/concepts/states.md) - [執行與取回結果](/zh-TW/flows/run-and-results.md) - [狀態列舉值](/zh-TW/reference/enums.md) # 營運與 Root 這頁給 **root/營運**,不是產品前端。租戶 JWT(`get_sandbox_principal`)打不到這裡。產品 UI 該讀的是 [角色與權限](/zh-TW/concepts/personas.md) 與 [API 端點目錄](/zh-TW/reference/endpoints.md)。 路徑前綴:`{BASE}/root/sandbox`。認證是 **operator auth**(`get_super_root_key`),不是租戶 JWT。 變更類操作必須帶**有型別的 `reason`**(8–1024 字元)、寫入 `SandboxAuditLog`,且**永不回傳租戶 secret 值或客戶內容原文**。V1 沒有 Binary Authorization break-glass 路由。 滾動/回滾/gate 的完整契約在後端 `docs/runbooks/sandbox-operations.md`。本頁不複製那份 runbook。 ## 誰擁有什麼 | 表面 | 擁有者 | 產品前端 | | --- | --- | --- | | 區域目錄與狀態(註冊/啟用/抽乾/停用/backfill/reconcile) | root | 否 | | 配額 snapshot 與 preflight | root | 否 | | 系統策展環境與版本 | root | 租戶可**讀/釘**策展版本;不可建立或發布 | | 精確 digest 封鎖與時限例外 | root | 否 | | 公司離場圍欄(arm/cancel) | root | 否 | | 公司設定:保留天數、`allowed_regions`、`policy_version` CAS | 租戶 company manager(`GET`/`PUT /settings`) | 是 | | 環境/任務/綁定/secret/執行 | 租戶管理者(見 [角色與權限](/zh-TW/concepts/personas.md)) | 是 | `allowed_regions` **只管執行期 Job/Execution 放置**,不管建置、validator、Artifact Registry、R2、日誌或控制面駐留。產品 UI 的封閉 V1 確認集合是 `GET /public/info/sandbox/regions` — 再與租戶 `allowed_regions` 取交集。不要把 `/root/sandbox/regions` 當前端目錄。 ## Kill switch 與「租戶自建其實發不出去」 這是兩件不同的事,不要混成一個開關。 - **`SANDBOX_ENABLED` 預設 `true`。** `false` 才是營運 **kill switch**。它擋新的租戶管理/提交/派送;GET 狀態、取消、下載、以及控制面 reconcile 仍可用。租戶面回 **503 `sandbox_disabled`**。 - **`tenant_custom_build_enabled` 不是獨立開關。** `GET /status` 從「模組是否開啟 × 是否有 active 區域 × 是否有已確認的區域 Job 定義配額 snapshot」**推導**。缺區域配額時,有效版本 cap 為 0,租戶發布被拒,錯誤碼 **409 `version_allocation_exhausted`**。營運看到 `enabled: false` 該看 `blocked_by`,不要去找一個不存在的 flag。 `blocked_by` 可能是:`sandbox_disabled`、`no_active_region`、`no_confirmed_regional_job_quota`。能發布時為空。 ## `GET /status` 回報: - `sandbox_enabled` - `tenant_custom_build_enabled`(上述推導值) - `blocked_by[]` - `active_regions` - `regions_with_job_definition_quota` - `gates`(live 監督 gates,目前 `pending`) - `live_proof`(目前 `"pending"`) - `break_glass_routes`(固定 `false`) ## 端點目錄 以下 27 條掛在 `/root/sandbox`。變更類 POST/PUT 帶 `reason`;本表不展開 request body。 | 方法 | 路徑 | 用途 | | --- | --- | --- | | `GET` | `/status` | Kill switch 與租戶自建是否真能發布 | | `GET` | `/regions` | 列區域 | | `POST` | `/regions` | 註冊區域(進入 provisioning)。`tier` 1..3,但 tier 3 只接受地端部署上的 `region_code=onprem`(其餘回 422) | | `POST` | `/regions/{region_code}/activate` | 啟用已就緒區域 | | `POST` | `/regions/{region_code}/drain` | 抽乾(擋住新 attempt) | | `POST` | `/regions/{region_code}/disable` | 在 reconcile 後停用 | | `POST` | `/regions/{region_code}/backfill` | 接受區域 Job backfill 意圖 | | `POST` | `/regions/{region_code}/reconcile` | 接受區域 reconcile 意圖(kill switch 下仍保留) | | `POST` | `/quota-preflight` | 本地殘差調整後的配額 preflight(不打 provider) | | `POST` | `/quota-snapshots/refresh` | 讀 live provider 上限並寫入 snapshot(讓版本 cap 變成正數的那條) | | `GET` | `/quota-snapshots/refreshes/{refresh_id}` | 讀取單次 provider 容量 refresh 的持久化狀態(`POST /quota-snapshots/refresh` 只寫入意圖,用這條輪詢結果) | | `GET` | `/curated-environments` | 列系統策展環境 | | `POST` | `/curated-environments` | 建立策展環境身分(尚無版本) | | `POST` | `/curated-environments/{environment_id}/versions` | 以營運提供的 digest 發布策展版本 | | `GET` | `/curated-environments/{environment_id}/versions` | 列該策展環境的版本 | | `POST` | `/security/blocks` | 封鎖一個精確映像 digest | | `POST` | `/security/exceptions` | 對精確 digest finding 給時限例外 | | `POST` | `/security/exceptions/{exception_id}/revoke` | 撤銷例外 | | `GET` | `/companies/{company_id}/offboarding` | 讀離場 ticket 與圍欄狀態 | | `POST` | `/companies/{company_id}/offboarding/arm` | 武裝真正的離場圍欄 | | `POST` | `/companies/{company_id}/offboarding/cancel` | 在尚未做破壞性工作時解除圍欄 | | `GET` | `/companies/{company_id}/policy` | 讀取公司的 Sandbox 政策上限(可能建立預設列) | | `PUT` | `/companies/{company_id}/policy` | 部分更新這些上限並寫入稽核(環境/版本/run/建置上限、保留天數、逾時上限、exposure、執行期對外網路) | | `GET` | `/queue` | **v5.10.11。**依服務順序列出等待(或剛開始佔用)run slot 的工作,以及每個 executor 最後回報的容量。每種部署都是同一路由、同一形狀;雲端的 `executors` 為空 | | `GET` | `/runner-releases` | **後端 ≥ #1162。** 已核准跑 task-bundle 的 runner release(表列 + 環境變數 bootstrap 集合 + 生效聯集) | | `POST` | `/runner-releases` | 核准一個 runner release(`release_sha` = 40 hex 的 stage image 來源 commit)。下一個請求即生效、不用重啟;冪等 | | `DELETE` | `/runner-releases/{release_sha}` | 撤銷;該 runner 上的 bundle 執行在下一個請求就回 `409 task_bundle_runner_unsupported` | ## Runner release 核准(後端 ≥ #1162) 任務 **bundle**(隨任務出貨的客戶程式碼)只能在 `runner_release`(烘進合成映像的 stage image 來源 commit)已核准的環境上執行。核准存在 `sandbox_runner_release_approvals` 表,每次發佈/執行請求都會讀,所以 `POST`/`DELETE /runner-releases` 立即生效。stage image 發佈流程在 controller rollout 成功後會自動核准自己的 commit,新的 runner release 不再讓新建的環境拒絕 bundle 工作。環境變數 `SANDBOX_TASK_BUNDLE_RUNNER_RELEASES` 只是 bootstrap 備援,正在清空。版本回應多了 `runner_release_approved` + `runner_release_next_action`。 ## 地端 provider(v5.10.11) 自架部署設定 `SANDBOX_PROVIDER=onprem`(空白或 `gcp` = 託管雲端;其他值會被拒絕)。以下僅供營運參考;租戶可見的差異見[雲端與地端部署](/zh-TW/concepts/integration-contract.md)。安裝套件與 runbook 在後端的 `docs/deployment/on-prem/`。 - **喚醒與驗證。**地端的 `SANDBOX_CONTROL_WAKE` 預設 `poll`(雲端預設 `pubsub`,且 controller 會拒絕 `poll`);`SANDBOX_EXECUTION_AUTH` 預設 `onprem_token`(executor token),取代 `gcp_oidc`。 - **區域與價格。**地端 poll-mode controller 會建立一個 active 的 `onprem` 區域(tier 3)。名目費率來自 `SANDBOX_ONPREM_PRICE_CPU_USD_PER_VCPU_SECOND`/`SANDBOX_ONPREM_PRICE_MEMORY_USD_PER_GIB_SECOND`(預設 `0.000001`,必須 > 0)。 - **Executor。**Docker executor 從 `/sandbox-control/onprem/work/*`(只在地端掛載)領取 run 與建置關卡,每次領取請求都回報剩餘與總 slot 數;佇列預估時間以整個 fleet 的 run slot 數去除。 - **佇列政策。**`SANDBOX_ONPREM_MAX_QUEUED_PER_COMPANY`(預設 20)與 `SANDBOX_ONPREM_MAX_QUEUED`(預設 200)限制等待中的 run(租戶端 429)。排隊超過 `SANDBOX_ONPREM_QUEUE_TIMEOUT_SECONDS`(預設 3600,最小 60)仍未執行的 run,會被 controller 的佇列 sweeper 判定失敗。`GET /root/sandbox/queue` 顯示佇列的 `lease_id`、`priority`(0 緊急、1 Quick Run、2 run、3 建置)、`vcpu`、`mem_mib` 與各 executor 容量。 - **網路與信任。**`SANDBOX_ONPREM_EGRESS`(預設 `none`,或 `internet`)決定主機上的執行期對外網路。`SANDBOX_ONPREM_TENANT_API_ORIGIN` 只讓部署自己的 API origin 穿過 executor 的網路圍欄,`SANDBOX_ONPREM_TENANT_CA_FILE` 讓租戶容器取得含主機憑證的信任憑證庫。 - **容量更新(v5.21.0)。**controller 的 provider 容量更新,以及 `GET /quota-snapshots/refreshes/{refresh_id}`,會把快照範圍限定在所選的 provider:地端部署是別名 `onprem`(不需要 GCP project),託管雲端是設定好的 sandbox GCP project;在託管雲端,該 project 缺少或格式不對時,狀態路由仍回 409 `sandbox_gcp_project_unset`/`sandbox_gcp_project_invalid`。 - **建置。**同一條十關建置流程改由 executor 執行;弱點掃描使用 `SANDBOX_ONPREM_SCAN`(預設 `trivy`),本機 registry 上的簽章與 Binary Authorization 會記為 `not_applicable`。 ## 控制面(人不要打) `/sandbox-control/*` 是系統對系統的 OIDC + capability,`include_in_schema=False`。人不要呼叫。地端的 executor 以地端 executor token 驗證,取代 Google OIDC。 # 角色與權限 Sandbox 的權限是一條**階梯**,不是「凡是變更都要 company manager」。誰能寫環境、誰能建任務、誰能啟用房間、誰能看到別人的 run,分別看 **owner scope**、**consumer scope** 與 **聊天室管理員階梯**。目錄任務**只有部門或公司擁有** — 分享/啟用/選單的切分見 [任務受眾](/zh-TW/concepts/job-audience.md)。一般使用者的執行路徑見 [一般使用者執行任務](/zh-TW/flows/tenant-user.md)。 Root / 營運操作不在本頁,見 [營運與 Root](/zh-TW/concepts/operators.md)。 ## 角色整數 門檻來自環境變數,**不要在前端自己重算**: | 變數 | 預設 | 意義 | | --- | --- | --- | | `CAN_MANAGE_DEPARTMENT` | `2` | `role ≥ 2` → **部門管理員** | | `CAN_MANAGE_COMPANY` | `3` | `role ≥ 3` → **公司管理員**(租戶管理員 / tenant manager) | `role < 2` 且只是聊天室成員,就是**一般租戶使用者**。角色整數**不是**聊天室管理員:聊天室管理員是另一條階梯。 ## 聊天室管理員階梯 `is_chatroom_manager_for_principal` 成立,當呼叫者是使用者(非受限 key),且符合以下任一: 1. **直接管理員**:該室的 `creator`,或在 `chatroom_manager_association` 裡。 2. **同部門的部門管理員**:`role ≥ 2` 且 `department_id` 等於該室的部門。 3. **公司管理員**:`role ≥ 3`,全公司的室都算。 受限 Sandbox API key 與外部 social client **永遠不算**聊天室管理員。 Quick-run、綁定的建立/接受/撤銷、對「聊天室」consumer 寫 secret,都走這條階梯,不是「只要 role ≥ 2」。部門管理員**不能**對別的部門的室硬綁 Agent;除非他們也符合該室的階梯。 ## 受限 Sandbox API key `Sandbox` scope 的 API key 比一般使用者**更窄**。它只允許:允許清單內聊天室的 menu、手動提交、查**自己的** run(list / status / content / artifacts / download)、取消**自己的** run。 受限金鑰不能用的管理面:quick-run、retry、environments(含 context 上傳)、tasks(含公司執行歷史)、granted-jobs、bindings、secrets、settings、root。要用這些,需要使用者 JWT 或 `Full` scope key(`Full` 仍受租戶/角色限制)。 受限金鑰打 quick-run、`/root`、`/environments`、`/tasks`,或路徑缺聊天室段/聊天室不在允許清單時,**認證層回 403**(bindings、secrets、settings 這類沒有聊天室段的路由都落在這條);帶聊天室段但不在 Sandbox 允許清單上的其他路由(granted-jobs、retry、public-link…)則由 `is_valid_api` 回 **405 `Access to is not allowed for your role`**。Sandbox router 若收到受限 principal 會 **404**。見 [認證](/zh-TW/get-started/auth.md)。受限金鑰欄列出實際 auth/route 閘門;可用路徑上的未授權資源仍可能回 404。 ## 404 與 403 例外 未授權的識別碼**通常回 404**(隱藏存在性)。所以 404 常常是「沒權限」而不是「不存在」。 **例外**(不要寫「未授權一律 404」): - 建立呼叫者不能擁有的 department/company 任務 → **403 `owner_scope_forbidden`**。 - `POST /tasks` 送 `owner_scope=chatroom` → **422**(enum `SandboxTaskCreateOwnerScope`)。 - `GET`/`PUT /settings` 走共用的 `COMPANY_MANAGER` 角色依賴:`role < 3` → **403 `Insufficient permissions.`**(不是 404)。 - [錯誤碼對照表](/zh-TW/reference/errors.md)裡的 writeback/預算 403。 取消與讀取同一條邊界(`can_cancel_run == can_read_run`):管理員可以取消他們看得到的 run,不只是「自己的」。**Agent 工具不用這條放寬**(A-011):`sandbox_cancel_job`/`sandbox_job_status`/`sandbox_submitted_jobs` 即使使用者是管理員,仍綁行為主體。 `can_execute_manually`(`POST /runs` + trigger 存檔/觸發)是:同公司 +(受限 key 允許該房)+ 呼叫者是該房**成員**(或在該房管理員階梯上)+ 仍有活著的適用 **share**(歷史上的擁有方聊天室任務仍算)。**這道閘不要求啟用**。外部社群 client 必須釘在那個精確的 `chatroom_id`。不是成員、也不在該房階梯上的部門管理員,不能在那裡執行。 `GET /chatrooms/{id}/menu` 與 Agent 目錄**更嚴**:已授予 **且** 房間已啟用,每個呼叫者看到同一份清單。見 [任務受眾](/zh-TW/concepts/job-audience.md)。 沒有根層 `GET /bindings`;除 `GET /chatrooms/{id}/granted-jobs` 外,也可用 task/room scoped binding 歷史,請見[管理查詢](/zh-TW/concepts/integration-contract.md)。一般使用者用 [menu](/zh-TW/flows/tenant-user.md)。 ## 權限矩陣 | 動作 | 一般租戶使用者(聊天室成員,`role < 2`) | 聊天室管理員 | 部門管理員(`role ≥ 2`) | 公司管理員(`role ≥ 3`) | 受限 Sandbox API key | | --- | --- | --- | --- | --- | --- | | 建立 / 更新 / 歸檔環境 | 否(404) | 除非另具 owner-scope 管理權 | 自己部門擁有的環境 | 全部公司/部門擁有環境 | 403 at auth | | `GET`/`PUT /settings` | 否(403) | 否(403) | 否(403) | 是 | 403 at auth | | 列出 / 讀取環境、版本、建置 | 是(自家公司 + `system_curated`) | 是 | 是 | 是 | 403 at auth | | 建立公司擁有任務 | 否 | 否 | 否 | 是 | 403 at auth | | 建立部門擁有任務 | 否 | 否 | 僅自己的部門 | 是 | 403 at auth | | 建立聊天室擁有的目錄任務 | **422**(已移除) | **422** | **422** | **422** | 403 at auth | | 管理 / 發布 / 歸檔任務、分享 | 僅 `can_manage_task_owner_scope` | 僅歷史上的聊天室擁有任務,且他們管理該室 | 自己部門擁有的任務;公司管理員可全部 | 公司擁有 + 全部 | 403 at auth | | 列出 / 啟用 / 停用 granted-jobs | 否 | 該室 | 若符合該室管理員階梯 | 是(經階梯) | 405 on an allowed room | | 建立 / 接受 / 撤銷綁定(與啟用同一釘選) | 否 | 目標聊天室管理員階梯 | 若符合該室管理員階梯 | 是(經階梯) | 403 at auth | | Company manager 每任務歷史 `GET /tasks/{id}/runs` | 否 | 否 | 否 | 是 | 403 at auth | | Secret 寫入 / 輪替 / 撤銷 + 核准 | 否 | 自己管理的 consumer 聊天室 | 自己管理的 consumer 部門 | consumer 公司 | 403 at auth | | `GET /chatrooms/{id}/menu` | 已授予 **且** 已啟用(每個呼叫者同一份清單) | 同左 | 同左 | 同左 | 是(允許的聊天室) | | `POST` runs | 成員資格 + 適用的分享(**不**要求啟用) | 若可執行 | 同左 | 同左 | 是(允許的聊天室) | | 列出 / 讀取 run、content、artifacts、download、cancel | 僅自己的 run | 逐筆路由(status/content/artifacts/download/cancel)可及自己**直接**管理的室裡所有 run;`GET /chatrooms/{id}/runs` **列表**只對 `role ≥ 2` 放寬,`role < 2` 的直接管理員仍只列到自己提交的 run | 自己部門聊天室的 run | 公司所有聊天室 run | 僅自己的 run、允許的聊天室 | | retry | 若 `can_execute_manually` | 是 | 是 | 是 | 允許房間 405;router 404 | | quick-run | 否(404) | 是(該室) | 若符合該室管理員階梯 | 是 | 403 at auth | | `GET /tasks` 列表 | 只看 live grant 受眾 | 可管理任務或適用受眾 | 自己部門任務或適用受眾 | 公司全部任務 | 403 at auth | | 借用者視圖 | `startup_script` / `work_script` / `work_command` / `ordinary_env` / `secret_slot_declarations` / `output_policy` 皆為 `null`(`output_policy` 內含擁有方的 table id,或 Command 與聊天室的 id),`content_hash` 為空字串;`secret_slot_names` 仍會回傳。`source_digest` / `source_ref` 是 **environment version** 借用者視圖才拿掉的欄位,不在 task version 上 | 若管理 owner scope 則為擁有者視圖 | 同左 | 同左 | 不適用 | 管理任務(發布、歸檔、分享)走 `can_manage_task_owner_scope`:歷史上的聊天室擁有 → 該室管理員階梯;部門擁有 → 該部門的部門管理員(公司管理員可全部);公司擁有 → 只有公司管理員。新的目錄建立不能是聊天室擁有。 Secret 的寫入與核准走 **consumer-scope manager**,不是任務擁有方:聊天室 consumer → 該室階梯;部門 consumer → 該部門管理員;公司 consumer → 公司管理員。任務擁有方**不能**代替借用方核准 secret。 ### A-010:分享任務的擁有方看不到借用方的 run 共用任務的 **OWNER 不會自動看到借用方聊天室的 run**。房間定址的 run API 仍只看:自己提交的、自己直接管理的室(僅逐筆路由;`GET /chatrooms/{id}/runs` 列表只對 `role ≥ 2` 放寬)、自己部門的室、或公司管理員看全公司。擁有任務 ≠ 看到別人在別的室跑它。 **例外:** company manager 可以用 `GET /tasks/{task_id}/runs`(+ `/numOfData`)列出一個目錄任務的每一次執行,不必逐房走訪。那不是房間 run 清單;任務擁有者除非同時是 company manager,否則也拿不到。 ## 接下來做什麼 - [一般使用者執行任務](/zh-TW/flows/tenant-user.md):menu → 提交 → 輪詢 → 產出物(不要建環境或任務)。 - [建立環境並建置](/zh-TW/flows/environment-and-build.md):環境寫入依公司/部門 owner scope 判斷;`GET`/`PUT /settings` 同樣只要公司管理員(保留天數見 [保留期](/zh-TW/concepts/retention.md))。 - [任務受眾](/zh-TW/concepts/job-audience.md):部門/公司擁有、分享 vs 啟用 vs 選單。 - [任務、分享與啟用](/zh-TW/flows/tasks-and-bindings.md):HTTP 步驟。 - [Secret 與授權](/zh-TW/concepts/secrets.md):consumer-scope manager 才能寫值與核准。 - [營運與 Root](/zh-TW/concepts/operators.md):root 不在本頁。 - [Agent toolkit](/zh-TW/reference/agent-tools.md):在房間啟用 `ChatroomJobType.sandbox`;工具仍綁行為主體。 - [自訂表格 trigger](/zh-TW/flows/custom-table-trigger.md):觸發時的 principal 仍必須 `can_execute_manually`(Command 輸出的版本則改為要求 trigger 的撰寫者)。 # 資源模型 前端碰的資源如下。受眾規則(誰擁有、誰可分享、啟用代表什麼)寫在 [任務受眾](/zh-TW/concepts/job-audience.md)。這裡不要再重寫那些規則。 ## 關係圖 ```text Environment ──context 上傳──▶ owned_object_id └─has many──▶ Environment Version ──build──▶ ready ▲ │ 釘 ready 版本 目錄任務 (department|company) └─has many──▶ Task Version ──publish──▶ │ │ 分享 (company / department / chatroom) ▼ Grant ──房間啟用──▶ Binding(精確版本釘選) │ ▼ 選單 / Agent (已授予且已啟用) 手動 REST (share + 成員資格;不要求啟用) │ ▼ Run ``` ## 資源一覽 | 資源 | 是什麼 | 誰建立 | | --- | --- | --- | | **Environment** | 公司或部門擁有的租戶父層,或 `system_curated` | Owner-scope manager 呼叫 `POST /environments`;curated 僅 operator。 | | **Context upload** | zip/tar/tar.gz 的 staging session | `POST /environments/{id}/context-uploads` | | **Environment Version** | 把 `owned_object_id`(或已知 digest)+ profile 釘死 | `POST /environments/{id}/versions` | | **Build Attempt** | 某一版的一次建置 | `POST /.../versions/{vid}/builds` | | **Task(目錄任務)** | 部門或公司擁有的可執行物 | `POST /tasks`(`owner_scope` `department` \| `company`) | | **Task Version** | 腳本 + ready 環境版本 + 可選 `output_policy` | `POST /tasks/{id}/versions` → `publish` | | **Share Grant** | 讓符合條件的房間可以用這個任務 | `POST /tasks/{id}/shares` | | **Granted-job 啟用 / Binding** | 房間開關 + 精確版本釘選。同一物件。 | 首選 `POST /chatrooms/{id}/granted-jobs/{task_id}/enable` | | **Run** | 在聊天室的一次執行 | `POST /chatrooms/{id}/runs`、quick-run、agent `sandbox_submit_job`、或 `submit_sandbox_job` | ## 不能漂移的規則 - 環境版本必須 `ready`,任務版本才能對它發布。 - 任務版本必須 `published`(+ `content_state=active`)才能分享、啟用、執行。 - 發布後版本不可變;要改就開新版。 - 未授權識別子**通常**回 **404**。例外:**403 `owner_scope_forbidden`**(不能擁有該 department/company scope),以及錯誤表裡的 writeback/預算 403。 - 列表預設 `lifecycle=active`,除了 `GET /tasks/{id}/shares`(`all`)。 - 擁有者 vs 借用者:借用者看不到 scripts、`work_command`(是 `null`,不只是缺席)、`source_digest`、`secret_slot_declarations`、`output_policy`。 > 端點與欄位:[API 目錄](/zh-TW/reference/endpoints.md)。 # 保留期 Sandbox 的物件依用途套用**獨立的保留時鐘**。前端若快取資源 id 或下載連結,要理解它們何時會消失。 ## 可設定的保留(公司設定) 經 `PUT /settings` 調整(company manager): | 設定 | 範圍 | 意義 | | --- | --- | --- | | `run_content_retention_days` | 1..365 | 執行內容(輸入/輸出/日誌/產物)保留天數 | | `unreferenced_image_retention_days` | 1..90 | 未被引用的建置映像保留天數 | ## 固定的內部時鐘 | 類別 | 規則 | 常數 | | --- | --- | --- | | 成功正式來源 | 活躍/被引用期間保留,之後 **30 天**歸檔寬限 | `SOURCE_ARCHIVE_GRACE_DAYS = 30` | | 失敗情境內容/日誌/報告 | 終止後 **7 天**過期 | `FAILED_CONTEXT_RETENTION_DAYS = 7` | | 建置重試窗 | `retryable_failed` / `cancelled` 的 attempt,**7 天**內可重試 | `BUILD_RETRY_WINDOW_DAYS = 7` | | 下載 capability | **5 分鐘**(過期重換) | `_DOWNLOAD_CAPABILITY_TTL = timedelta(minutes=5)` | | Agent 提案 | 30 分鐘 | `PROPOSAL_TTL_SECONDS = 1800` | | Agent turn receipt | 10 分鐘 | `TURN_RECEIPT_TTL_SECONDS = 600` | | 已歸檔的環境版本(**後端 ≥ SBW-08**) | 歸檔後幾分鐘內,controller 會回收它各區的 Cloud Run Job、quarantine + execution 映像套件、attestation 與建置報告(未完成的回收每 10 分鐘重試)。歸檔是終態 —— 之後沒有東西能釘住或執行該版本,建置日誌也一併消失。例外(v5.10.11):若仍有未歸檔的 curated 版本釘住同一個映像 digest,該 execution 套件會保留(各區 Job 仍照常回收),等 curated 版本歸檔後,下一輪回收才刪除。 | `RECLAIM_RECHECK_INTERVAL = 10 min` | ## 給前端的實務建議 - 不要把 `run_id` / `artifact_id` 當永久連結;受保留天數限制。 - 失敗情境的日誌/報告 7 天後消失——要留存請及時下載。 - 下載 capability 過期就重新 `POST .../download` 換一張。 - 改 `PUT /settings` 的保留天數**不會**改寫既有 run/產出物的過期快照。只有新 run 吃新時鐘。 # Secret 與授權 任務版本可以宣告需要的 **secret slot**(`secret_slot_declarations` / `secret_slot_names`)。實際的 secret 值透過獨立的 secret API 綁定、端對端傳輸加密,**值永遠不回傳**、不記錄。 ## 兩種 API ### Secret binding(自己的任務綁值) 把一個 secret 值綁到 `(consumer_scope, consumer_id, task_id, slot_name)`: - `POST /secrets`:建立值。 - `POST /secrets/rotate`:輪替值。 - `POST /secrets/revoke`:撤銷值。 - `GET /secrets/{binding_id}`:讀**遮蔽後**的 binding(只有 `has_provider_binding: bool`、`state` ∈ `pending`/`active`/`revoking`/`revoked`、`generation`…,**沒有值**)。 規則: - 需要 **consumer-scope manager** 權限。 - 建立/輪替/撤銷都要帶 **`Idempotency-Key`** header,缺了回 422 `idempotency_key_required`。 - 值只透過 `encrypted_value`(混合傳輸信封)傳遞——見下面的**傳輸加密**。明文 `value` 一律拒絕。 - 解密後的值:4 UTF-8 bytes..64 KiB,不可含 NUL,**永不回顯**。 - secret adapter / pepper 不可用時回 503。 ### Secret approval(借用他人分享的任務) 當你執行的是**別人分享**的任務,且該任務需要 secret,必須由**consumer-scope manager**(借用方的聊天室/部門/公司管理員——**不是**任務擁有者)核准該 consumer 能用哪些 slot: - `POST /secret-approvals`(或別名 `POST /shared-task-secret-approvals`):對**精確的任務版本 digest**核准一組 `slot_names`。 - `POST /secret-approvals/{approval_id}/revoke`:撤銷。 規則: - `SandboxSecretApprovalCreateRequest` 要帶 `task_version_digest`(`sha256:...`)與 `risk_accepted: true`(**必須剛好是 true**)。 - digest 與已發布版本不符 → 409 `task_version_digest_mismatch`。 - 撤銷不改寫歷史,只作廢後續使用。 ## 啟用同意與驗證(v5.10.0) 房間管理員的 Enable/Update 可替自己管理、且解析為 borrower 的 secret scope 記錄精確版本同意,請見[同意規則](/zh-TW/concepts/job-audience.md);這不代表能替其他 scope 的值核准。`GET /secrets?consumer_scope=chatroom&consumer_id=...` 可查找遮罩 binding,回應也有 `consumer_name`;task scoped secret/approval 清單請見[能力與管理查詢](/zh-TW/concepts/integration-contract.md)。 Create/rotate 的解密後值至少需 **4 個 UTF-8 bytes**,上限 64 KiB,不可含 NUL。這是位元組數:一個三位元組中文字仍太短。值過短回 422,`detail[].type = sandbox_value_too_short`,與加密信封錯誤不同;已存值不會被這個寫入下限改寫。 明確核准要求已發布版本、相符 digest、`risk_accepted: true`,以及非空、合法且正規化的 slot 名稱集合。Draft 版本回 409 `task_version_not_published`;digest 不符回 409 `task_version_digest_mismatch`。Route 不計算實際 borrower-resolved 子集,claim 仍要求核准集合與解析後的 pins 精確相符;核准不會授予超出 consumer 管理權限的值。 ## 傳輸加密(寫入路徑) `POST /secrets` 與 `POST /secrets/rotate` **只**接受 `encrypted_value` 信封。明文 `value` **一律**拒絕(422)——即使這個部署根本沒設定傳輸金鑰對也一樣。沒有明文備援,永遠沒有(owner 決策 2026-08-30)。 Client 端流程: 1. `GET /public/info/model_key/public_key` → `{ public_key_pem, algorithm }`(PEM,SubjectPublicKeyInfo)。與 model-catalog `encrypted_api_key` 流程共用同一組金鑰對/端點。這裡回 **503** 代表伺服器沒設定金鑰對——加密寫入仍會 422,不會退回明文。 2. 產生隨機 AES-256 金鑰與新的 12-byte IV。用 AES-256-GCM 加密 secret 值(GCM tag 附加在 ciphertext 後面)。 3. 用抓到的 PEM 以 RSA-OAEP-SHA256 包住 AES 金鑰。 4. 三段都用標準字母表 base64 編碼後送出: ```json { "encrypted_value": { "encrypted_key": "", "iv": "", "ciphertext": "" } } ``` 解密後的明文仍會檢查一般的 4 UTF-8 bytes..64 KiB 上限。四種可分辨的 422 錯誤代碼(在 `detail[].type`;線上的 `msg` 就是精確的 `validation_error:`——`input`/`ctx` 一律剝除、不外洩): | Slug | 原因 | | --- | --- | | `sandbox_plaintext_value_rejected` | 送了**非空**的明文 `value` 欄位(不論有沒有一起送 `encrypted_value`);空字串或 null 的 `value` 會被忽略,不算明文 | | `sandbox_encrypted_value_required` | `encrypted_value` 缺失或為 null | | `sandbox_transit_keypair_missing` | 伺服器沒設定解密金鑰對 | | `sandbox_encrypted_value_undecryptable` | 信封形狀合法但解密失敗(金鑰不對/ciphertext 損毀/IV 不對/明文非 UTF-8) | 解密後,secret 值會以**環境變數**的形式送進 runner,在 work script 開始前就匯出——不會跟執行 input 一樣寫進磁碟。 ## slot 命名 `slot_name` 必須符合 `^[A-Za-z_][A-Za-z0-9_]{0,127}$`(與 `ENV_NAME_PATTERN` 相同),且不能是保留環境變數名(`PATH`、`HOME`、任何 `TEAMSYNC_`/`GOOGLE_`/`K_SERVICE` 前綴……)。保留名與 `ordinary_env` 鍵一樣會被拒絕。任務版本/選單回應只給 `secret_slot_names`(名稱清單),不給值或 provider 路徑。 **沒有** binding 或 approval 的列表端點。若之後要 `GET /secrets/{id}` 或撤銷 approval,請自己留下建立時回的 `id`。 若必填 slot 沒有活著的 binding(或分享任務沒有活著的 approval),提交仍會建 run,但 runner 解不到 secret 時執行會失敗——在提交前就把選單上的 `secret_slot_names` 與 `secret_use_risk_warning` 秀出來。 ## 共享 secret slot 政策(borrower / owner / owner_overridable) 分享 grant(`POST /tasks/{id}/shares`)可以帶一個逐 slot 的**憑證來源政策**,**只能在建立時**設定——要改就是撤銷再重新分享(開新的 grant generation),跟其他 grant 異動一樣: ```json { "target_kind": "chatroom", "target_id": "...", "secret_slot_policies": { "SLOT_NAME": "owner_overridable" } } ``` | 政策 | 意義 | | --- | --- | | `borrower` | Map 裡沒列到的 slot **預設**用這個。借用方自己的 binding 必須存在**且**已核准(見上面的 Secret approval)。這個 slot 永遠不會用 owner 的 binding。 | | `owner` | Owner 的 binding **借給**借用方——這個 slot 不需要借用方自己的 binding 或 approval。 | | `owner_overridable` | 借用方有綁定就用借用方的,否則退回用 owner 的。 | **Owner 出借只有跨 scope 才觀察得到。** `effective_source` 只有在 consumer chain(借用房間的聊天室 → 部門 → 公司)跟任務的擁有部門不同時才會真的解析成 `"owner"`——也就是部門擁有的任務分享到**不同**部門的房間。當 consumer 剛好跟 owner 重疊時(例如公司擁有任務上的公司範圍 binding,同一個 binding 同時落在 consumer 與 owner 兩條鏈上),即使政策是 `owner_overridable`,解析結果也會保守地降級成 `"borrower"`——這種情況下一個 slot 永遠不會被歸類成 `"owner"`。 一次 run 在提交時會**凍結**它所依據的那筆 grant。之後有兩道獨立的存活檢查保護 owner 出借的 pin,防止 grant 之後失效: - **認領時**:run 第一次被認領去執行時,會重新驗證那筆 grant 是否還活著(state、active slot、generation、task id)。 - **Manifest 揭露時**:每次 runner 的 secret manifest 被(重新)讀取——包括重試/重放——平台都會再檢查一次 grant,外加目標的 authority 有沒有變動過。這刻意是比認領時檢查更嚴的超集,因為部門改組(或重新分享、或撤銷)可能發生在認領與揭露之間的空檔。Borrower 解析的 pin 不帶 grant 依賴,完全不用付這個成本。 選單(`GET .../menu`)與已授予任務(`GET .../granted-jobs`)會露出逐 slot 的履行狀態: ```json "secret_slots": [ { "name": "SLOT_NAME", "policy": "owner_overridable", "borrower_bound": false, "owner_bound": true, "effective_source": "owner" } ] ``` Run 詳情(`GET .../runs/{run_id}`)會露出一份**遮蔽過**的快照,記錄每個 slot 實際怎麼解析、在 run 的 secret pin 於認領時發布(publish)那一刻凍結——絕不含 fingerprint、binding id 或 provider 路徑: ```json "secret_slot_provenance": { "SLOT_NAME": { "resolved_via": "owner", "consumer_scope": "chatroom" } } ``` Run 沒有留存快照時 `secret_slot_provenance` 是 `null`;格式不對的項目會逐筆跳過,不會整包報錯。 A-012 核准在 claim 時必須符合 borrower-resolved slot 子集。POST 會檢查非空且正規化的 slot 集合、consumer 管理權、已發布版本與 digest,但不判斷哪些 slot 實際解析為 borrower;claim 時不相符仍是 `secret_approval_required`,不要要求另行核准 owner 出借的 slot。 ## 逐任務的 secret 路徑 同一個 consumer(同一組 `consumer_scope`/`consumer_id`)底下,兩個不同任務用同一個 `slot_name` 不會再互相碰撞——底層 provider secret 路徑現在按任務分了命名空間。這之前就已經有已確認 provider secret 的 binding,會永遠留在舊的(不分命名空間的)路徑上;只有還沒確認過的 binding 才會採用新的逐任務路徑。前端兩種情況都看不到 provider 路徑,所以這件事是透明的——只有你之前在繞開碰撞問題時才會在意。 > 確切請求/回應欄位見 [API 端點目錄](/zh-TW/reference/endpoints.md)。 # 狀態機與輪詢 **沒有 streaming。** 前端用**輪詢**(GET 狀態)追蹤進度。以下是各資源的狀態值(取自 `src/schemas/enums.py`)與轉換。 ## 環境版本 `SandboxEnvironmentVersionState` ```text draft → build_queued → building → quarantined → verifying → verified → regional_provisioning → ready ← 可被 Task Version 引用 ``` 分支/失敗狀態:`retryable_failed`、`rejected`、`abandoned`、`blocked`、`superseded`、`archived`、`provisioning_blocked`。**`quarantined` 不是失敗狀態**——它是成功路徑上的必經一站:compose(建置)成功後版本一定先進 `quarantined`,掃描階段開始才轉 `verifying`。 - `retryable_failed` → `POST /.../retry-build`。 - `provisioning_blocked` / provisioning 失敗 → `POST /.../retry-provisioning`。 ## 建置嘗試 `SandboxBuildAttemptState` ```text validation_queued → validating → build_queued → building → verifying → completed 取消:cancel_requested → cancelling → cancelled 失敗:customer_failed / platform_failed ``` - `terminal_class` ∈ `completed` / `customer_failed` / `platform_failed` / `cancelled`。 - `error_code` / `stage_code` / `phase` 提供細節;`report_total_bytes > 0` 表示有建置報告。常見 `error_code`:`policy_reject`(客戶)、`identity_config`、`tenant_cancel`(CVE 掃描結果自 2026-08-25 起只是參考——`vulnerability_reject` 已經不會再出現)。 - **取消不是同一請求內結算。** `POST .../builds/{id}/cancel` 圍欄 attempt(`cancel_requested`)並寫入可持久化的 provider-cancel intent,即使 attempt 還在排隊。輪詢到 `terminal_class=cancelled`。在 `cancel_requested`/`cancelling` 再取消一次是冪等。 ## 執行 `SandboxRunState` ```text queued → starting → running → completed 取消:cancel_requested → cancelled 失敗:customer_failed / platform_failed ``` - 終態以 `terminal_at` 標記。 - `error_code` 提供失敗原因。 ## 任務版本 `SandboxTaskVersionState` ```text draft → published → archived ``` 另有 `content_state`:`active`(可跑)或 `scrubbed`(保留期/offboarding 後內容被清掉)。選單/可執行判斷要求 `state == "published"` **且** `content_state == "active"`。 環境父層 `state`:`active` → `archived`。Binding 活著的 `state`:`enabled`(選單還要 `authority_applicable`)。Share grant 退休:`revoked`。 ## Agent 提案 `SandboxProposalState`(Agent 確認流程) ```text prepared → committing → committed 其他:superseded(被更新提案取代)/ cancelled / expired / failed ``` 詳見 [Agent 確認流程](/zh-TW/flows/agent-confirm.md)。 ## 輪詢建議 - 建置:輪詢 `GET /environments/{id}/versions/{vid}/builds/{attempt_id}`,直到 `terminal_class` 有值。 - 執行:輪詢 `GET /chatrooms/{id}/runs/{run_id}`,直到 `terminal_at` 有值。 - REST 建議間隔:**2 秒**,約 30 秒後退避到 **10 秒**,看到 `terminal_at`/`terminal_class` 就停。`MAX_STATUS_WAIT_SECONDS = 60` **只**是 agent 工具 `sandbox_job_status.wait_seconds` 上限,不是 REST long-poll。 - 通知:`SandboxNotificationEvent`(見 [通知](/zh-TW/concepts/notifications.md))可以叫醒 UI;不改變上面的輪詢模型。一定要再 GET。 ## 取消(runs) - 未認領(`queued`/尚未派送):`POST .../cancel` 立刻設成 `cancelled`,釋放定價 hold,佇列 lease 標 `released`——自 2026-08-30 起,佇列/併發容量 slot 也會立刻釋放(之前若碰上競態的併發派工,可能讓已取消的 run 卡住佔著 slot)。 - 已認領/執行中:設成 `cancel_requested`,寫入耐久 cancel outbox;runner 再走到 `cancelled`。 - 租戶取消圍欄贏過 runner `executions/complete` 時,公開 `error_code` 是 **`cancelled_by_request`**(PR #951)。不要把已取消 run 上的空 `error_code` 當成「未知」。 - `cancel_requested_at` 有持久化,但**不在** `SandboxRunDetailResponse`。看 `status` + `error_code`。 - `can_cancel_run == can_read_run`。受限 key 仍只能看自己的 run。 - 極少數情況下,若控制面在派工中途掛掉,run 可能卡在 `starting`。背景 reaper 會自動自癒(重新派送同一次 dispatch,剛好一次)——不需要租戶動作,這期間取消照樣可用。 # Agent 確認流程 > **重點**:Agent 的 sandbox 提交是一個 **LangGraph 工具**(`src/components/tools/custom/sandbox/factory.py` 的 `sandbox_submit_job`),由 Agent 在對話回合中呼叫。**前端沒有專屬的 confirm/reject REST 端點**。前端的角色是呈現提案、觀察結果,實際確認發生在後續的對話回合。另外四個工具(`sandbox_job_menu`、`sandbox_submitted_jobs`、`sandbox_job_status`、`sandbox_cancel_job`)與 `ChatroomJobType.sandbox` 寫在 [Agent toolkit](/zh-TW/reference/agent-tools.md)。 ## 為什麼有這個流程 當任務 `requires_confirmation = true`——或宣告了表格 writeback 的 `output_policy`(`custom_table_writeback`,即使 `requires_confirmation` 是 false 也視為需確認等級)——Agent 不能在同一回合直接執行:它先**提案**,經過人為確認(下一回合)才**提交**執行。這是防止 Agent 未經同意就跑有副作用的任務。Command 輸出的版本(`custom_table_command`)且 `requires_confirmation = false` 是例外:它直接提交(見[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md))。 ## 兩段式(工具內部語意) 1. **提案 `mode="request"`**:Agent 帶 `task_version_id` + `input` 呼叫工具。後端建立一個 `prepared` 提案,回: ```json { "kind": "prepared", "proposal_id": "...", "proposal_receipt": "<簽章 token>", "summary": "...", "requires_confirmation": true, "task_id": "...", "task_version_id": "..." } ``` - 會**取代**同一 principal 其他 pending 提案(`superseded`)。 - `proposal_receipt` 是簽章多段 token(含 4 個點;只給不透明 id 會被判 `forged_receipt`)。 2. **提交 `mode="commit"`**:**必須是後續回合**(不允許同回合繞過)。Agent 只帶 `proposal_receipt`。後端驗證提案 receipt + 帶外的 **turn receipt**(綁定 company/chatroom/principal/human-message),才建立 run(`submission_source="agent"`),回: ```json { "status": "ok", "run_id": "...", "proposal_id": "..." } ``` - 回應遺失重送:回 `{ "kind": "replay", "run_id": "..." }`。 ## 提案狀態 `SandboxProposalState` ```text prepared → committing → committed 其他:superseded / cancelled / expired / failed ``` TTL:`PROPOSAL_TTL_SECONDS = 1800`、`TURN_RECEIPT_TTL_SECONDS = 600`。同一輪 commit 會被拒,錯誤碼是 **`same_turn`**(確認回合與提案回合相同);若確認訊息時間不晚於提案訊息則是 `not_later`;同一則確認訊息重複用在另一個提案是 `receipt_reused`。不帶 `run_id` 的 `sandbox_cancel_job` 取消此 principal 的待確認提案(`cancelled`)。 ## 前端要做什麼 前端**不呼叫工具**,但要把「有一個待確認的提案」呈現給使用者,並在確認後觀察結果: - **menu** 上的 `requires_confirmation: true` 告訴你這個任務會走確認流程。 - 提案的 `summary` / `requires_confirmation` 透過對話/助理頻道呈現給使用者。 - 工具的 `list_submitted` 會回該 principal 自己的 `proposals[]`(各帶 `proposal_receipt`)與 `runs[]`,讓後續回合能接續處理。 - 確認提交後,前端就照 [執行與取回結果](/zh-TW/flows/run-and-results.md) 用 run 狀態端點觀察那個 `run_id`。 ## 錯誤碼(工具層) 工具錯誤信封:`{ "status": "error", "error": "", "message": "..." }`,碼為 `validation_error`/`forged_receipt`/`not_found`/`unauthorized`/`retryable`/`confirmation_input_unavailable`/`internal_error`/`provider_unavailable`/`same_turn`/`not_later`/`receipt_reused`/`already_committed`/`proposal_not_pending`/`forbidden`/`hidden`/`reauth_failed`;job-contract 422 的 `validation_error` 會另外帶 `code`(`input_schema_violation`/`job_contract_invalid`)與 `errors[]`。**過期的 receipt 回 `forged_receipt`**(TTL 檢查在簽章驗證裡),**同一輪回 `same_turn`**;只有「別人的/不存在的提案」才是 `not_found`。後一輪已驗證但耐久 input 無法重放 → `confirmation_input_unavailable`(提案仍 pending)。`mode=commit` 用 `sandbox_submitted_jobs` **重簽**的 `proposal_receipt`,不要用快取的準備收據。完整表:[Agent toolkit](/zh-TW/reference/agent-tools.md)。 > 人類使用者的提交走 `POST /chatrooms/{id}/runs`(見 [執行流程](/zh-TW/flows/run-and-results.md));Agent 提交走這個工具。兩者最終都產生一個可用 run 狀態端點觀察的 run。 # 任務輸出交給自訂表格 Command 任務版本可以用**執行一個自訂表格 Command** 來收尾。版本宣告一個 `kind` 為 `custom_table_command` 的 `output_policy`;每次 run 都會拿到一把單次 run 憑證,這把憑證只能在某一個聊天室裡呼叫這一個 Command,work script 產出 Command 的輸入後就把它送出去。平台再用與 Commands API 相同的執行器去執行這個 Command。 本頁**只寫 Sandbox 契約**(v5.21.0 發布)。Command 的輸入、效果、核准與結果的意義,仍留在自訂表格的文件。較早的那種,表格 writeback(`custom_table_writeback`),契約不變,只多了一項:它現在會拒絕 `write_policy` 為 `commands_only` 的表;本頁會標出兩者不同的地方。 ## 1. 在草稿版本宣告 `POST /tasks/{task_id}/versions` 與 `PATCH .../versions/{vid}` 上的 `output_policy` 是以 `kind` 區分的聯集型別。Command 這一種長這樣: ```json { "output_policy": { "kind": "custom_table_command", "command_id": "", "chatroom_id": "", "input_schema": [ { "name": "order_id", "type": "string", "required": true } ] } } ``` | 欄位 | 說明 | | --- | --- | | `kind` | 字面 `custom_table_command`。 | | `command_id` | 這次 run 可以呼叫的 Command。≤36 字元,字元集 `[A-Za-z0-9._-]`。 | | `chatroom_id` | Command 執行所在的聊天室(同樣的格式)。此版本的每一次 run 都必須從這個聊天室提交。 | | `input_schema` | Command 的輸入宣告,≤200 項(每項是自訂表格的 `CommandInput`:至少 `name`、`type`、`required`)。必須等於該 Command 自己的 `inputs`(正規化之後比對):順序相同,每個輸入的每個欄位值都相同(包括 `description` 與 `nullable`)。 | 未知欄位是 422。這個政策會被雜湊進版本的 `canonical_digest`,只出現在 owner 視圖(borrower 看到 `null`)。和表格 writeback 一樣,它屬於**部門擁有**的任務:公司擁有的任務在發布時回 409 `output_policy_owner_scope_unsupported`,而且只有部門擁有的任務的 run 能通過 §4。草稿與發布時都不會檢查 `chatroom_id` 是否為擁有部門的聊天室;若不是,這個版本能發布,但每一次 run 都會因為 borrowed 而被拒。 ## 2. 什麼樣的 Command 合格 草稿建立、草稿 `PATCH`、發布,以及每一次提交 run 時都會檢查 Command: | 檢查 | 拒絕方式 | | --- | --- | | Command 存在、沒被刪除,且屬於你的公司 | 422 `output_policy_command_not_found` | | `input_schema` 等於 Command 宣告的 `inputs`,且 Command 是寫入型(`mode` 為 `write`) | 422 `output_policy_schema_mismatch` | | Command 以 *definition* authority 執行、execute policy 為 `restricted`,且宣告了 `effect_identity` | 403 `output_policy_author_denied` | 在**發布**時,這三項檢查的任何一項失敗(包括第三項)都會以 **409 `output_policy_unsatisfiable`** 回報——和表格 writeback 的表格不見了是同一種回報方式(訊息文字是通用的、提到的是 table;以 code 為準)。只有下一節對發布者與已記錄撰寫者做的身分、grant 與範圍檢查,仍是 **403 `output_policy_author_denied`**。在**提交**時,表中的 code 會原樣回傳,所以 Command 若在發布後被刪除或重新宣告,這次提交就會被拒(什麼都不會排隊),直到 Command 與版本的 policy 又一致為止。 還有一件這些檢查抓不到的事:需要凍結提案的 Command(有 `proposal_policy`)能通過這三項檢查,但 executor 對 run 發出的每一次呼叫都會拒絕(`ct.governed_context_required`),所以它不能當作 run 的輸出。 ## 3. 誰能撰寫、發布與提交 **撰寫者**與**提交者**必須各自通過同一套 Command 檢查,而且每一步都會重做: - **身分:**公司內有效(已驗證、未停用、未過期)、非 service account 的使用者,且是 `chatroom_id` 的成員(該聊天室必須還在)。 - **Grant:**該 Command 有效的 execute grant(以 `chatroom_id` 為行動中的聊天室來判斷)。光是管理該 Command 的表、或持有 execute 委派 lease 都不夠,雖然 Commands API 本身對 restricted Command 兩者都會放行。 - **範圍:**部門範圍的 Command,`chatroom_id` 必須是該部門的聊天室;聊天室範圍的 Command,`chatroom_id` 必須就是那個聊天室。 失敗是 **403 `output_policy_author_denied`**。撰寫者是最後寫入草稿的人:草稿建立、每次草稿 `PATCH` 與每次 `POST .../script-file` 上傳都檢查呼叫者並讓呼叫者成為撰寫者,發布則同時檢查發布者(平台 root operator 除外)與已記錄的撰寫者。 ## 4. 提交 run 每一條會建立 run 的路徑(手動 `POST /chatrooms/{id}/runs`、重試、Agent 工具、自訂表格 trigger)都會在同一個交易裡為這次 run 鑄出**一把**密封憑證。表格 writeback 遇到 borrowed 的 run、或提交者不是以 JWT 驗證的使用者(API key、受限 Sandbox 金鑰或社群媒體 client)時會跳過鑄憑證,run 照樣排隊。Command 輸出的版本則是**拒絕這次提交**(什麼都不會排隊),回 **403 `output_policy_author_denied`**,除非下列條件全部成立(Command 已被刪除或不再相符,則是 §2 的 422): - 任務是部門擁有,且這次 run 不是 *borrowed*(房間屬於擁有部門;公司擁有的任務或其他部門的房間都算 borrowed——見[任務受眾](/zh-TW/concepts/job-audience.md)); - run 的聊天室就是政策的 `chatroom_id`; - 提交者是以 JWT 驗證的使用者主體——已登入的使用者、trigger 觸發的 run 中 trigger 的撰寫者,或內部聊天中 Agent 代表的使用者;API key、受限 Sandbox 金鑰、社群媒體 client 都不算; - 提交者與版本的撰寫者都通過 Command 檢查。 若平台的 writeback 簽章金鑰缺少或太短,提交會回 503 `sandbox_writeback_key_unavailable`,與表格 writeback 相同。 ## 5. run 裡的憑證 認領時,憑證透過**密封 secret 通道**送進 work script,用的是與表格 writeback 相同的兩個變數名稱。它們是 runner 保留的 `TEAMSYNC_` 名稱,不能設在 `ordinary_env`: | 變數 | Command 輸出 run 的值 | | --- | --- | | `TEAMSYNC_CT_WRITEBACK_URL` | `{API origin}/public/module/custom_tables/callback/command-output/{credential id}` | | `TEAMSYNC_CT_WRITEBACK_TOKEN` | 這個 URL 的 Bearer secret | - 每次 run 一把憑證;只儲存 secret 的 SHA-256。 - 它會在下列三個限制中最早到的那個失效:run 離開 `queued`/`starting`/`running`;認領時間 + `timeout_seconds` + 120 秒收尾包絡(finalization envelope);以及提交時設下的保底期限,也就是提交時間 + 48 小時與 `timeout_seconds` + 120 秒兩者中較大的那個(排隊等待也算在內,超過之後才被認領的 run 會在沒有這兩個變數的情況下啟動)。每次進入終態都會撤銷。 - **請檢查這兩個變數是否存在。**若平台在認領時無法揭露憑證——run 排隊期間撰寫者或提交者的 grant 被收回、Command 或版本的 policy 變了、任務或版本被封存、上述保底期限已過、簽章金鑰或公開的 API origin 不可用,或多出來的變數會讓環境超過大小上限——run 仍會啟動,只是沒有這兩個變數。 - work script 必須連得到 API origin:託管雲端需要執行期對外網路([限制](/zh-TW/concepts/limits.md));地端部署另外需要主機的 `SANDBOX_ONPREM_EGRESS=internet` 與 operator 對 API origin 的開放([雲端與地端部署](/zh-TW/concepts/integration-contract.md))。 ## 6. 送出輸出 ```text POST {TEAMSYNC_CT_WRITEBACK_URL} # /public/module/custom_tables/callback/command-output/{credential id} Authorization: Bearer {TEAMSYNC_CT_WRITEBACK_TOKEN} Content-Type: application/json { "inputs": { "order_id": "A-1001" } } ``` ```python import json, os, urllib.request request = urllib.request.Request( os.environ["TEAMSYNC_CT_WRITEBACK_URL"], data=json.dumps({"inputs": {"order_id": "A-1001"}}).encode(), headers={ "Authorization": "Bearer " + os.environ["TEAMSYNC_CT_WRITEBACK_TOKEN"], "Content-Type": "application/json", }, method="POST", ) print(urllib.request.urlopen(request).read().decode()) ``` 這是公開路由(沒有 `/private` 前綴、不用租戶登入):Bearer secret 本身就是授權。Body 必須恰好是 `{ "inputs": { … } }`——任何其他頂層欄位都是 422——`inputs` 會對照 Command 宣告的輸入與它的 JSON 上限驗證。成功時的 body 是 Commands API 的執行回應(`CommandExecutionResponse`,省略 `null` 欄位);欄位與狀態請看自訂表格文件。 - Command 以 **run 的提交者**身分執行(trigger 觸發的 run:trigger 的撰寫者),以 `chatroom_id` 為行動中的聊天室,冪等鍵是 `sandbox-output:{run_id}`,所以每次 run 的 Command 最多生效一次。 - **第一份有效輸出勝出。**第一份通過宣告輸入檢查的 `inputs` 會在 Command 執行之前以 digest 釘住(對驗證後的 inputs 取 digest),即使 Command 隨後拒絕它們或執行失敗,這個釘選也不會解除:重試必須送相同的 inputs,改過的 inputs 在這次 run 剩下的時間裡都會得到 409 `output_command_conflict`。再送相同的 inputs 會走 Command 自己的冪等機制:已經成功(或已暫存等待核准)的執行會被重放、不會執行兩次,失敗的執行則可以重試(若呼叫與一個仍在進行中的執行競爭,可能收到 Commands API 自己的 409 `idempotency_in_progress`)。 - 每次呼叫都會重新檢查授權,所以 run 進行中 grant 被收回,呼叫就會變成 403 `output_policy_author_denied`。 - **核准需要 run 還活著。**若 Command 沒有直接完成、而是暫存了一個核准,放行時會重新解析 run 的憑證;run 一結束憑證就被撤銷,所以在 run 結束之後才放行的核准無法再成功。 | 狀態 | `detail.code` | 何時發生 | | --- | --- | --- | | 404 | `output_run_unavailable` | 憑證 id 不存在,或 Bearer secret 缺少、超過 256 字元或不對(統一一種回答,不洩漏任何資訊) | | 409 | `output_run_unavailable` | 憑證已撤銷或過期,或 run 已不在 `queued`/`starting`/`running`,或它的任務或版本不再是 active 且已發布 | | 409 | `output_command_stale` | run 提交之後,版本的政策、Command 的定義或它宣告的輸入變了,或該 Command 已被刪除、或已不再是你公司的寫入型 Command | | 409 | `output_command_conflict` | 這次 run 已經接受過另一份不同的有效 `inputs` | | 422 | `output_schema_violation` | `inputs` 不符合 Command 宣告的輸入 | | 403 | `output_policy_author_denied` | 提交者或撰寫者的 Command 授權被收回,或該 Command 不再是帶 effect identity 的 restricted definition-authority Command | | 422 | (validation 陣列) | body 不是 `{ "inputs": { … } }`、超過 JSON 上限,或路徑裡的憑證 id 不是小寫 UUID | | 其他 | Command 執行錯誤 | 同一個呼叫在 Commands API 會得到的任何回應,沿用它自己的錯誤形狀(`detail.error` 物件,或由 `{type, msg}` 項目組成的 `detail[]` 清單) | ## 7. Trigger、Agent 與前端 - **自訂表格 trigger。**`submit_sandbox_job` 可以對準 Command 輸出的版本(表格 writeback 的版本仍然被拒)。run 由 trigger 的撰寫者提交,輸入檔就是渲染後的 `input`,而 §4 的拒絕在觸發時是 trigger-run receipt 上一個失敗的 action(不會有 Sandbox run)。見[自訂表格 trigger](/zh-TW/flows/custom-table-trigger.md)。 - **Agent。**`requires_confirmation=false` 時 Agent 直接提交,不經提案回合;表格 writeback 的版本仍需要兩回合確認。§4 的 403 拒絕,對模型來說是工具錯誤 `unauthorized`;422(Command 不存在或不符)是 `validation_error`;503 是 `provider_unavailable`。見 [Agent toolkit](/zh-TW/reference/agent-tools.md)。 - **前端。**owner 在版本上看得到 `output_policy`,borrower 看到 `null`。處理上面的撰寫與提交拒絕。用一般的[執行 API](/zh-TW/flows/run-and-results.md)觀察 run;Sandbox 的 run 紀錄不帶 Command 的結果。 ## 相關 - [任務受眾](/zh-TW/concepts/job-audience.md) — 誰能發布、誰是 borrowed。 - [任務、分享與啟用](/zh-TW/flows/tasks-and-bindings.md) — 版本欄位。 - [錯誤碼對照表](/zh-TW/reference/errors.md) # 從自訂表格 trigger 提交執行 自訂表格的列 trigger 可以在列被**建立**或**更新**時排入一次 Sandbox run。這不是 `/private/module/sandbox` 端點。Action 活在 custom-tables 的 trigger 設定裡(`src/crud/custom_table_triggers.py`,型別 `submit_sandbox_job`)。產生的 run 是普通租戶 run(`submission_source="custom_table_trigger"`),前端用既有方式輪詢即可。 Trigger 的其餘機制(when 條件、receipt、重試)留在 custom-tables 手冊。本頁只寫 Sandbox 契約。 ## Action 形狀 ```json { "type": "submit_sandbox_job", "chatroom_id": "", "task_version_id": "", "timeout_seconds": 1800, "input": { "order_id": "$row.col_…", "note": "static" } } ``` | 欄位 | 必填 | 說明 | | --- | --- | --- | | `type` | 是 | 字面 `submit_sandbox_job`。 | | `task_version_id` | 是 | 同公司、`state=published`、`content_state=active`。 | | `chatroom_id` | 見說明 | 預設用**表格**的 chatroom。部門/公司範圍表格沒有隱含房間,所以必填。 | | `timeout_seconds` | 否 | 整數 1..604680。預設 = 版本的 `timeout_seconds`。 | | `input` | 是 | JSON **物件**。可用 `$row.col_`(改名穩定的內部鍵)插值。**模板本身**另有更嚴上限:根物件以下巢狀最多 8 層、每個字串葉節點最多 2000 字元,超過在存檔時就被拒。通過後才以 canonical JSON 規則(≤1 MiB、深度 ≤64)驗證,觸發時再重驗一次。 | 只有 `created`、`updated` 事件能帶這個 action。 ## 存檔時檢查(設定者) 編輯者必須是活著的使用者,且對所選任務在所選房間 `can_execute_manually`。列真正觸發時,會再用**造成寫入的行為主體**重跑同一套檢查。Action 裡的 id 只是設定,不是授權。 存檔會拒絕: - 任務版本找不到/不是 published+active/公司不對。 - `requires_confirmation=true` — 背景 trigger 沒有第二則 HumanMessage 可以 commit。 - 版本宣告了表格 writeback 的 `output_policy`(`custom_table_writeback`)— trigger 路徑永不鑄表格憑證。改用擁有部門房間裡、部門擁有任務的手動 JWT 提交([任務受眾](/zh-TW/concepts/job-audience.md))。Command 輸出的版本(`custom_table_command`)則可接受,但要符合下面[Command 輸出版本](#command-輸出版本)的額外規則。 - 設定者不能在該房執行該任務。 - 部門/公司表格缺 `chatroom_id`。 - `input` 不是物件,或 canonical JSON/`$row` 正規化失敗。 ## 觸發時授權 Worker 會解析**恰好一個**造成寫入的 principal(寫入該列的 user 或 client)。然後: - 目的地 `chatroom_id` 必須仍在該公司且活著。 - 造成者必須仍活著,且仍是**來源**行動房間的成員。 - 造成者必須能在**目的地**房間 `can_execute_manually` 該任務(Command 輸出的版本改為檢查 trigger 的撰寫者——見下)。 - 來源權限語境(當初讓這列可見的 ACL)仍須允許付費工作;principal 變了或來源房死了就拒絕。 - 造成者若是 client(社群媒體與 Agent 通道皆然),目的地 `chatroom_id` **必須就是該 client 自己的聊天室** — worker 以 `SocialMediaClient.chatroom_id == 目的地房間` 查詢,查不到就整個觸發被拒。所以部門/公司範圍表格若把目的地指到別的房間,由 client 造成的寫入永遠不會產生 Sandbox run。 - Agent 通道的 client **不會**被重對應成社群 Sandbox principal(那會記錯預算)。需要活著的 Agent delegate user(前一條的目的地房間限制通過後才做這個重對應)。 被拒的觸發記在 trigger run receipt 上;**不會**建立 Sandbox run。 ## Command 輸出版本 `output_policy.kind` 為 `custom_table_command` 的版本([任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md))是 trigger 唯一可以對準的 `output_policy` 種類。這種版本的規則是: - **存檔時。**Action 的目的地 `chatroom_id` 必須等於政策的 `chatroom_id`(否則是 `actions` 上的設定錯誤),而且設定者與版本的撰寫者都要通過 Command 檢查(失敗時保留各自的 code,例如 403 `output_policy_author_denied`)。 - **觸發時。**run 由 **trigger 的撰寫者**提交,不是造成寫入的 principal。Worker 會重新對 Command 檢查政策的房間、撰寫者與版本的撰寫者,而且撰寫者必須能在目的地房間 `can_execute_manually` 該任務;失敗就是被拒的觸發(不會有 Sandbox run)。造成者不再需要能執行該任務,但仍必須讀得到來源列與被引用的欄位,同上。 - **輸入。**任務的輸入檔就是渲染後的 `action.input` 物件——**不會**包在下面那個 `source`/`trigger`/`record`/`row` 信封裡——而 receipt 的 `input_digest` 就是這個物件的 digest。 ## 前端該呈現什麼 1. Trigger 編輯器:從編輯者能執行的房間裡挑已發布任務版本。隱藏/拒絕 `requires_confirmation` 版本。 2. 部門/公司表格必須明確填目的地 `chatroom_id`。 3. 列寫入後,看 custom-tables 的 trigger-run receipt。成功長這樣: ```json { "type": "submit_sandbox_job", "ok": true, "run_id": "...", "trigger_run_id": "...", "task_version_id": "...", "input_digest": "sha256:…", "run_status": "queued", "submission_source": "custom_table_trigger" } ``` Worker 用的冪等鍵:`ct-trigger:{trigger_run_id}:{action_id}`。保留回顯的 action `id`,重試才能接同一張 receipt。 sandbox 看到的耐久 run `input` **不是**整列原文。除了 Command 輸出的版本(見上),它是這個信封(只有你在 `action.input` 下列出的葉子;沒有整列、沒有 diff): ```json { "source": "custom_table_trigger", "trigger": { "run_id": "...", "trigger_id": "...", "action_id": "...", "event": "created|updated", "fired_at": "..." }, "record": { "table_id": "...", "record_id": "...", "record_version": 1 }, "row": { "order_id": "…" } } ``` 設定走 custom-tables 的 trigger 路由(`GET`/`PUT /private/module/custom_tables/{chatroom|department|company}/.../triggers`),再 `GET .../trigger-runs` 與 `POST .../trigger-runs/{id}/retry`。那些路徑不在 `/private/module/sandbox` 底下。 4. 在**目的地**聊天室用一般 [執行 API](/zh-TW/flows/run-and-results.md) 觀察: ```text GET /chatrooms/{destination_chatroom_id}/runs/{run_id} ``` `SandboxRunDetailResponse.submission_source` 是 `custom_table_trigger`。可見性仍是 A-010:你看得到它,是因為你是造成者,或你管理目的地房間。Command 輸出的版本,run 的 principal 是 trigger 的撰寫者,所以不是該撰寫者的造成者,只有以 manager 身分(目的地房間、其部門或公司的 manager)才看得到。 5. 輪詢 `terminal_at`。通知(`run_terminal`)仍不能取代 GET。 ## 這不是什麼 - 不是 agent 確認。不要等 `proposal_receipt`。 - 不是繞過分享的後門。`can_execute_manually` 仍要 live 適用 share(或歷史上的擁有方聊天室任務)。**這道閘不要求啟用/binding** — 那個開關是給 Agent 選單的。目的地房間不必已啟用該任務。 - 不是 REST 提交。不要替 trigger 去 `POST /runs`;worker 會提交。 ## 相關 - [執行與取回結果](/zh-TW/flows/run-and-results.md) - [Agent toolkit](/zh-TW/reference/agent-tools.md) — 需要確認的版本走另一條提交路 - [角色與權限](/zh-TW/concepts/personas.md) # 建立環境並建置 **環境權限:**公司擁有環境由公司管理員管理;部門擁有環境可由該部門管理員或公司管理員建立/管理。部門管理員建立時須傳 `owner_scope: "department"` 與該部門的 `owner_id`。Context 上傳、版本、build、重試與 archive 都套用相同 owner-scope 閘門;curated 環境沒有租戶寫入路徑。Metadata 仍可由同公司身分讀取,上傳 session 則要求 owner 管理權。未授權租戶在 router 回 404,受限 key 會先被 auth 拒絕。 ## 確認目錄(不必登入) 下拉選單**不要**自己發明 region 或 profile 字串。去抓封閉的 V1 集合: ```text GET /public/info/sandbox/regions GET /public/info/sandbox/profiles ``` 這些在 `/public/info` 底下,不在 `/private/module/sandbox`。不必 JWT。把 `code` 當成 `allowed_regions`/`build_region`。把 `id` 當成 `resource_profile`,**只有** `tenant_selectable` 為 true 才可以送。**`xlarge` 租戶不可選。** Region 再與 `GET /settings.allowed_regions` 取交集(只管 runtime 放置)。營運端啟用仍在 `GET /root/sandbox/regions`。 Region 清單是部署所用 provider 的目錄(v5.10.11):託管雲端列出雲端區域(`tier` 1 或 2),永遠不列 `onprem`;地端部署只列 `onprem`(`tier` 3)。見[雲端與地端部署](/zh-TW/concepts/integration-contract.md)。 ## 策展路徑(租戶不上傳、不建置) `GET /environments` 也會回 `owner_kind=system_curated`。那些版本由 root 發布。租戶**讀取並釘選**: 1. `GET /environments`/`GET /environments/{id}/versions` 直到可釘的版本(`state=ready`)。live 目錄名稱是 **SCFG Standard**,每個公開區域都是 ready。 2. `POST /tasks/{id}/versions` 帶那個 `environment_version_id`。**不要**上傳 context,也不要送租戶 `source_digest` 建置。 3. 發布 + 分享 + 房間啟用,與其他任務相同([任務受眾](/zh-TW/concepts/job-audience.md))。 不要叫一般使用者去建環境。除非產品需要租戶自寫映像,否則走這條。 ## 自訂環境步驟 1. **建立父層** — `POST /environments`(`name` 1–256,`description` ≤2048)。回 `state: "active"`。這一步不上傳位元組、也不開始建置。 2. **初始化 context 上傳** — `POST /environments/{id}/context-uploads`: ```json { "declared_bytes": 123456, "archive_format": "tar.gz" } ``` `archive_format` ∈ `zip`/`tar`/`tar.gz`(預設 `tar.gz`)。`declared_bytes` 是封存的**精確**大小,1..1 GiB。回應含 `upload_session_id`、`capability_token`、`capability_expires_at`(15 分鐘)、`put_header_name`(`X-Sandbox-Upload-Capability`)、`put_content_type`(`application/octet-stream`)。這**不是**聊天室/DCC 的 `blob_id`,也**不是** R2 presign。 3. **PUT 位元組** — `PUT /environments/{id}/context-uploads/{upload_session_id}`,標頭 `X-Sandbox-Upload-Capability`,`Content-Type: application/octet-stream`。Body 不得超過 `declared_bytes`。回 `bytes_received` + `digest`(`sha256:`)。 4. **Complete** — `POST /environments/{id}/context-uploads/{session_id}/complete`(可選 `expected_size`/`expected_digest`,用 PUT 回的值)。回 `owned_object_id`。已完成則冪等。Complete **不會**開始建置。需要看 `state` 就 `GET .../context-uploads/{session_id}`。 5. **Abort**(可選)— `POST .../context-uploads/{session_id}/abort` 釋放尚未完成的 slot。已完成的 session 不能 abort。 6. **建立版本** — `POST /environments/{id}/versions`: ```json { "owned_object_id": "", "source_ref": "app-env-v3.tar.gz", "resource_profile": "standard" } ``` 首選:complete 回的 `owned_object_id`。單獨送 `source_digest` 時後端**不會**比對任何已完成的 context 物件——只要格式是 `sha256:<64 hex>` 就會建出 draft 版本;若該 digest 沒有對應已上傳的封存,排隊建置仍會成功,錯誤要到建置實際執行、抓不到 context 時才出現,所以正式流程一律送 complete 回的 `owned_object_id`。兩個都送就必須相符。`resource_profile` 用公開目錄,且必須租戶可選。版本不可變;`state: "draft"`。 封存必須在**根層**帶一個名為 `Dockerfile` 的**一般檔案**(不可是符號連結、不可放在子目錄、不可改名為 `Dockerfile.prod`)。打包時要 `tar czf ctx.tar.gz -C myapp .`,不要 `tar czf ctx.tar.gz myapp/`——後者讓 Dockerfile 變成 `myapp/Dockerfile`,建置一定以 `error_code=context_invalid` 失敗。封存裡的 Dockerfile 可以 `FROM` **任何公開映像**(`node:20-alpine`、`python:3.12-slim`、`debian:bookworm-slim`、`ubuntu:24.04`、`scratch`……)——從 2026-08-25 起**沒有固定允許清單**了。隔離邊界是沙盒 runtime 本身(強制簽章 entrypoint,無法脫逃 host),不是建置期的映像允許清單,所以 compose 不再**按名字**擋你的 base 映像。但它**仍然會**檢查合成後的 layer:任何檔案、連結、whiteout 或 device node 落在平台保留路徑上(`usr/local/gcp/`、`teamsync-runner/`、`.teamsync-platform/`、`etc/ld.so.preload`,或落在 `proc/`、`sys/`、`dev/` 底下)都會讓建置失敗——`error_code=context_invalid`,`error_detail=reserved_path_or_hostile_entry`。每個真實 base 都會帶的無害節點——空的 `/dev`、`/proc`、`/sys` 掛載點,以及指到 `/proc` 的 `/etc/mtab` symlink——則明確放行。不論 base 是什麼,compose 仍然會直接拒絕:動態(`$`/`{`)的 `FROM` 參照、**任何** `# syntax=` 指示行(含最常見的 `# syntax=docker/dockerfile:1`,檔案中任何一行都算,不只第一行)、來自網路來源的 `ADD`、`RUN --network=host`、`type=secret`/`type=ssh` 的 `RUN --mount` 或 `from=` 指到未審查 stage 的 `--mount`、`COPY --from` 指到未審查的 stage,以及超過 1 MiB 的 Dockerfile——這些是在保護平台本身,不是在檢查你的內容。 **建置與(預設下)執行期都有網路**:Dockerfile 的 `RUN` 步驟可以連**公開網際網路**(私有網段、loopback、metadata 端點被防火牆擋掉);公司設定 `runtime_egress_enabled` 為 true(預設開啟)時,執行期的 `startup.sh`/`work.sh` 也能 `pip install`/`npm ci`/`go get`,對外流量會計量。想要快且可重現,仍建議把相依烤進映像——工作階段安裝每次執行都會重跑一遍。 CVE 掃描結果**只是參考**,永遠不會讓建置失敗——`vulnerability_reject` 已經不存在。SBOM 與漏洞報告仍會產生並附掛在該次 build attempt 上;只有真正的政策/契約違規(偽造 runner 身分、竄改 digest、缺簽章……)才會產生 `error_code=policy_reject`。 7. **排隊建置** — `POST /environments/{id}/versions/{vid}/builds`(省略 `build_region` → 託管雲端 `asia-east1`、地端部署 `onprem`;省略 `build_profile` → `standard`)。`build_region` 不在區域目錄內——雲端的 `onprem`、地端的雲端代碼——會是 **422 `build_region_unavailable`**。 8. **輪詢**(沒有 streaming): - Attempt:`GET /.../builds/{attempt_id}` 直到 `terminal_class` 有值。 - Version:`GET /.../versions/{vid}` 直到 `state` 是 **`ready`**。 前端**會撞到**的上限:每個封存 1 GiB、每公司 **20** 個 live staging slot 與 **20 GiB** 宣告量、**每分鐘 5 次** init。429 `staging_slot_exhausted`/`upload_rate_limited`。未完成的上傳會**漏** slot,直到 abort/reconcile — 那個 429 連 scan report 寫入也會卡住。**已 complete 但還沒被版本採用的 context 也會佔著 slot**:complete 不會釋放 staging lease,slot 要等 `POST .../versions` 採用該 `owned_object_id` 時才釋放,而已完成的 session 不能 abort(422),所以「上傳完但不建版本」會一路吃滿 20 個 slot。規則:每次 complete 之後就立刻建版本;Abort 只回收未完成的殘留 session;不要對 init 空轉重試。 除了 1 GiB 壓縮上限,封存**解開後**也有上限:解壓總量 **5 GiB**、entry 數 **100,000**、封存 metadata **64 MiB**;解壓/壓縮比 ≥100 且解壓量超過 10 MiB 會被當成 decompression bomb 拒絕。這些由 context validator 在建置排隊後才判定,失敗一律回 `error_code=context_invalid`,且 `error_detail` 也只是 `context_invalid`(驗證器的具體理由不會外露)——不是上傳當下的 4xx。 ## 要多久(2026-09-03 實測) 建環境+上傳 context+建版本+送出建置:API 時間約 **1 秒**。之後跑**兩個** Cloud Build(後端 ≥ #1154):先是你的 Dockerfile(customer build,獨立身分),再一個受信任的 release Build,其步驤為 static scan 與 smoke **並行**,再 sign → attest → promote → final verify,機型 `E2_HIGHCPU_8`。stage 映像每個 Build 只拉一次,不再每個 gate 拉一次: | 基底映像 | 就緒時間 | | --- | --- | | `python:3.12-slim` + 一個 pip 套件 | 約 5–6 分鐘(原約 12) | | `golang:1.22-bookworm` + apt python3/node | 約 7 分鐘(原約 19) | 輪詢 `GET …/versions/{id}`,進行中時顯示 `build_stage`(`build_stage_index / build_stage_count`、`build_stage_since`、`build_expected_seconds`)與 `build_next_action`;失敗時呈現 `last_build_error_code` / `last_build_error_detail` 與 `build_next_action`(後端 ≥ #1140)。`build_stage` 仍逐 gate 前進(`build_stage_count` 不變)——release Build 進行中時,每完成一個步驟對應的 gate 就會翻過去;`build_expected_seconds` 現在約 360。六個驗證 gate 在該 Build 內共用一個身分(`sandbox-release`);不受信任的 customer build 仍用自己的身分(2026-09-04 決策:以受信任鏈內的 gate 隔離換取建置時間)。 ## 失敗與重試分支 | 情況 | 版本狀態 | 動作 | | --- | --- | --- | | 可重試失敗 | `retryable_failed` | `POST /.../retry-build`——不吃 request body,且新 attempt **一律**用 `build_region=asia-east1`、`build_profile=standard`,不會沿用上次的區域或 builder 尺寸。要在其他區域或用別的 builder 尺寸重建,改打 `POST /.../versions/{vid}/builds` 並自己帶 `build_region`/`build_profile`(同樣受 7 天重試窗限制) | | 佈建卡住 | `provisioning_blocked`/佈建失敗 | `POST /.../retry-provisioning` | | 客戶端建置失敗 | build `terminal_class: customer_failed` | 先看 `error_code`(`context_invalid`、`dockerfile_exit`、`image_limit`、`timeout_customer`、`policy_reject`…)判斷類別,再看 `error_detail` 找出具體規則(`dockerfile_not_utf8`、`dynamic_base_forbidden`、`remote_add_source_forbidden`、`host_network_forbidden`、`remote_syntax_frontend_forbidden`、`copy_from_unknown_stage`、`dockerfile_too_large`、`reserved_path_or_hostile_entry`…),修正封存,開**新**版本 | | 平台失敗 | build `terminal_class: platform_failed` | 看 `error_code`(`identity_config`…)。重試;一直失敗就回報後端 | | 取消 | `POST /.../builds/{attempt_id}/cancel`(**kill switch 下仍可用**) | 一律 `cancel_requested` 加上可持久化的 provider-cancel intent — 即使 attempt 還在排隊。輪詢到 `terminal_class=cancelled`。不是同一請求內結算。 | 其他分支狀態:`quarantined`、`rejected`、`blocked`、`abandoned`、`superseded`、`archived`。 `archived` 是終態:controller 幾分鐘內就會回收該版本各區的 Job 與映像套件(見[保留](/zh-TW/concepts/retention.md)),所以只歸檔沒有任務還釘著的版本。`runner_release_approved` 為 false 的版本在 operator 核准該 runner release、或你重新排一次建置之前,都不能跑 **bundle** 工作;`runner_release_next_action` 會說要走哪條。 ## 常見錯誤 - **409 `build_not_eligible`**:版本目前狀態不能開始建置。 - **409 `nonterminal_exists`**:此版本已有未結束的 attempt。去輪詢那個。 - **409 `not_cancellable`**:attempt 已終態。`cancel_requested`/`cancelling` 冪等(回當下那一列)。 - **409 `capability_fenced`**:上傳 capability 對不上這個 session。 - **413 `context_too_large`**:PUT body/Content-Length 超過 `declared_bytes`。 - **422**:缺少 `owned_object_id` 與 `source_digest`、digest 格式、未知欄位、空 PUT body。 - **422 `build_region_unavailable`**(`POST .../builds`):`build_region` 不在這個部署的區域目錄內(地端在已有 active 區域列、但它不在其中時也是)。什麼都沒排入;請改用 `GET /public/info/sandbox/regions` 裡的代碼。 - **429 `staging_slot_exhausted`**/**`upload_rate_limited`**。 - **503 `build_admission_fenced`**(`POST .../builds`、`POST .../retry-build`):平台 stage image 正在輪替,暫時擋下新的建置 admission;沒有任何 attempt 被排入。照 `Retry-After`(最多 300 秒)重試即可,不要當成平台故障。 - **404**:未授權/別公司的 id/受限金鑰/對策展環境上傳。 ## UI 建議 - 三層「環境列表 → 版本列表 → 每版本建置狀態」。 - `state=ready` 之前禁止把版本釘到任務上。 - 建置報告:`report_total_bytes > 0` 表示有報告(本體不在租戶 API)。 - 不要送 generic blob_id。 下一步:[任務、分享與啟用](/zh-TW/flows/tasks-and-bindings.md)。 ## 選擇環境 owner(v5.10.0) `owner_scope` 預設 `company`,此時可省略 `owner_id` 以使用呼叫者的公司 id。部門管理員必須明確選擇自己的部門: ```json { "name": "Department runtime", "owner_scope": "department", "owner_id": "" } ``` 部門與公司環境共用同一個公司 active-parent 上限,PATCH 不可改 owner。看得到環境 metadata 不代表有寫入權;`owner_kind=company` 表示 tenant-custom,應以獨立的 `owner_scope` 決定顯示哪些管理操作。 ### 哪些任務可釘選此環境 公司擁有與 curated 環境可供同公司符合條件的任務釘選;部門擁有環境可供**同部門任務或公司擁有任務**釘選,但不可給其他部門任務或 chatroom-owned Quick Run 任務。Quick Run 因此需要公司擁有或 curated 環境;ready、security 與租戶檢查仍然有效。 # 操作流程總覽 前端最常要實作的流程: - [**一般使用者執行任務**](/zh-TW/flows/tenant-user.md):只走 menu → 提交 → 輪詢 → 下載;不建環境或任務。 - [**建立環境並建置**](/zh-TW/flows/environment-and-build.md):釘 **SCFG Standard**,或建環境 → 上傳 context → 釘 `owned_object_id` → 建置 → 輪詢到 `ready`(環境 owner-scope manager)。 - [**任務、分享與啟用**](/zh-TW/flows/tasks-and-bindings.md):部門/公司任務 → 發布 → 分享 → 房間啟用(見 [任務受眾](/zh-TW/concepts/job-audience.md))。 - [**執行與取回結果**](/zh-TW/flows/run-and-results.md):menu → 提交(帶 Idempotency-Key)→ 輪詢 → 內容旗標 → 產出物 → 下載 capability → 取消/重試。 - [**Agent 確認流程**](/zh-TW/flows/agent-confirm.md):Agent 工具型提案/確認;前端如何觀察。 - [**自訂表格 trigger**](/zh-TW/flows/custom-table-trigger.md):列建立/更新時的 `submit_sandbox_job`;輪詢產生的 run。 - [**任務輸出交給 Command**](/zh-TW/flows/command-output.md):`custom_table_command` 輸出政策——每次 run 拿到一把限於單次 run 的憑證,讓 work script 執行一個自訂表格 Command。 每頁都標註實際端點與狀態值。端點完整定義見 [API 參考](/zh-TW/reference/index.md)。 # 本機重現與除錯 沙盒執行你的程式時只做一件事:在你的環境映像裡,以 `/bin/sh` 跑一個固定 session: ```text . /workspace/startup.sh && . /workspace/driver.sh ``` 三個 UI 欄位落地成三個固定檔名(**上傳檔案的原始檔名不會保留**): | 欄位 | 落地路徑 | 誰讀它 | | --- | --- | --- | | 前置腳本(startup_script) | `/workspace/startup.sh` | 永遠是 `sh` | | 執行指令(work_command) | `/workspace/driver.sh` | 永遠是 `sh`(空白時內容是 `. /workspace/work.sh`) | | 工作腳本(work_script) | `/workspace/work.sh` | 看 driver 寫什麼——填了 `python3 /workspace/work.sh` 它就是 Python | 其餘的每次執行輸入:input JSON 會在腳本啟動前寫到 `$TEAMSYNC_INPUTS_FILE` 指的檔案;結果寫到 `$TEAMSYNC_OUTPUT_FILE` 指的路徑;之後想下載的檔案放到 `$TEAMSYNC_ARTIFACTS_DIR`(`/workspace/artifacts`,runner ≥ #1152);secret slot 以環境變數匯出。cwd 名義上是 `/workspace`,但契約不保證——**腳本與 work_command 一律寫絕對路徑**。 ## 本機 harness 自訂環境的 Dockerfile 是你自己寫的,所以你可以在本機 build 出同一個 base,然後用同一份契約跑: ```bash # 1) build 你上傳給環境的同一份封存 docker build -t my-env ./env-context # 2) 組出 /workspace,跟平台落地的一模一樣 mkdir -p ws cp startup.sh ws/startup.sh # 沒有就放空檔 cp my-work.py ws/work.sh # 注意:固定叫 work.sh,不管原本副檔名 printf '%s' 'python3 /workspace/work.sh' > ws/driver.sh # = 你的 work_command echo '{"demo": 1}' > ws/inputs.json # 3) 用同一個 session 跑(--network=none 是模擬 runtime_egress_enabled=false 的公司; # 預設部署執行期可對外連線——要一致就把這個旗標拿掉) docker run --rm --network=none \ -v "$PWD/ws":/workspace \ -e TEAMSYNC_INPUTS_FILE=/workspace/inputs.json \ -e TEAMSYNC_OUTPUT_FILE=/workspace/output.json \ -e TEAMSYNC_ARTIFACTS_DIR=/workspace/artifacts \ -e MY_SECRET_SLOT=dev-value \ my-env \ /bin/sh -c '. /workspace/startup.sh && . /workspace/driver.sh' cat ws/output.json ``` 路徑錯、套件沒裝到、runtime 版本不對、`node_modules` 解析失敗——這些在本機各花五秒就能排除,不必每次都走一次「上傳 → 建置 → ready → 執行」的迴圈。 ## 本機 harness 與真沙盒的差異 | 面向 | 本機 harness | 真沙盒 | | --- | --- | --- | | Base 映像 | 你 `docker build` 的那份 | 同一份封存建的映像 + 平台簽章 runner layer(只加 `teamsync-runner/` 底下的檔案,不動你的內容) | | 網路 | 只有模擬 `runtime_egress_enabled=false` 時才加 `--network=none` | 預設可對外連線(`runtime_egress_enabled`,`GET /settings`),會計量;私有/loopback/metadata 網段**沒有**被過濾 | | 隔離 | 一般容器 | gVisor 沙盒、非 root、資源上限(依 resource_profile) | | 環境變數 | 你的 `-e` 參數**加上映像自己的 `ENV`** | 只有 `PATH`、`ordinary_env`、secret slot 與三個 `TEAMSYNC_*` 路徑——映像的其他 `ENV`(`JAVA_HOME`、`LANG`……)不會繼承(地端自 v5.10.11 起也一樣) | | 行程模型 | `/bin/sh` 是容器的 PID 1;`id -u` 是映像的 `USER` | 託管雲端開啟對外網路、且 runner release 以 v5.10.11 原始碼建置的 run 會嘗試巢狀 user+PID 命名空間;主機允許私有 `/proc` 設定時,`/bin/sh` 是該命名空間的 PID 1,否則 runner 會記錄或使用直接 shell fallback。地端使用 Docker worker 的直接 `/bin/sh`;runner 會保留 `/workspace/.teamsync/` | | Secrets | `-e` 直接塞 | 密封通道匯出成環境變數,不落盤 | | 逾時 | 無 | `timeout_seconds`(也受公司 ceiling) | 行為差異幾乎只剩隔離強度、資源上限與被剝掉的映像 `ENV`;**契約(檔名、session、`TEAMSYNC_*` 變數)完全相同**。本機通過、上去失敗時,先查資源上限與逾時,再查腳本是否依賴映像 `ENV` 裡的變數,最後查映像是否真的是同一版。 ## Node 的 `node_modules` 陷阱 程式在 `/workspace/work.sh`,Node 的模組解析只會往上找 `/workspace/node_modules` → `/node_modules`。所以環境 Dockerfile 裝相依時要落在根目錄: ```dockerfile FROM node:20-slim WORKDIR / # 關鍵:讓 npm ci 產生 /node_modules COPY package*.json ./ RUN npm ci --omit=dev ``` 寫 `WORKDIR /app` 的話,`require('axios')` 在執行期找不到——本機 harness 會重現出一模一樣的失敗。Python 沒有這個問題(pip 裝進 site-packages,跟路徑無關)。 ## 「不知道映像裡裝了什麼」 環境的建置內容是**你上傳的封存**——把 Dockerfile 與 lockfile(`package-lock.json`/`requirements.txt`)留在自己的版本控制裡,映像內容就永遠可考。版本回應上的 `source_digest`/`source_ref`(owner 才看得到)能對回是哪一包封存。策展環境(SCFG Standard)目前拿不到 Dockerfile——需要清單請開票給營運方。 # 執行與取回結果 單一 run 操作以**聊天室**定址(跨房間歷史另有 `GET /runs`):`/chatrooms/{chatroom_id}/...`。受限 API key 可用 menu / 提交 / 查自己的 run / 下載自己的產出物 / 取消自己的 run;**retry 對受限 key 在允許房間的 route allowlist 回 405,quick-run 在 auth 回 403**(進到 router 則 404)。 ## 1. 探索可執行清單 ```bash GET /chatrooms/{chatroom_id}/menu # query page_size (ge1 le100, 預設 50) ``` 回 `items[]`(`SandboxMenuItemResponse`):`task_id`、`task_name`、`task_version_id`、`task_version_digest`、`requires_confirmation`、`secret_slot_names`、必填的 `source_visible=false`、`secret_slots[]`(逐 slot 的 borrower/owner 履行狀態)、`timeout_seconds`、`update_available`、`input_instructions` / `input_example` / `input_schema`、`runtime_contract`、`secret_use_risk_warning?`。**只有已授予且已啟用。每個呼叫者同一份清單。** **不含腳本原文**——這個選單回應上的 `runtime_contract.driver` 永遠是預設 fallback `". /workspace/work.sh"`,**任何**呼叫者(包括 owner)都看不到真正的 `work_command`(真正的 driver 只會出現在 `GET /tasks/{id}/versions/{vid}` 或 `POST .../input-preview`)。`page_size` 預設 50;下一頁存在時,`next_page_token` 是不透明 cursor。房間管理者用 [granted-jobs](/zh-TW/concepts/job-audience.md) 改目錄。 提交前先在 client 端用 `input_schema`(若有)驗證 `input`——伺服器端也會強制檢查(422 `input_schema_violation`,在 dispatch 之前)。`POST /tasks/{task_id}/versions/{vid}/input-preview` 可以讓作者先試跑一個候選 input,看清楚 work script 實際會讀到的位元組。見 [任務、發布與綁定](/zh-TW/flows/tasks-and-bindings.md)。 ## 2. 提交執行 ```bash POST /chatrooms/{chatroom_id}/runs Idempotency-Key: # 必帶,否則 422 idempotency_key_required { "task_version_id": "...", "input": {...}, "timeout_seconds": 1800 } ``` - `input`:canonical JSON(≤1 MiB、深度 ≤64、節點 ≤100k、無 NUL、無重複 key)。 - body 過大先回 **413 `request_too_large`**(依 Content-Length)。 - 版本非「published + content active」→ **409 `version_not_runnable`**(`content_state` 為 `scrubbed` 時則是 409 `content_scrubbed_irreversible`;`_refuse_not_runnable` 只是後端內部函式名,不是回應碼)。 - 回 `SandboxRunDetailResponse`,`status: "queued"`。 - 手動 REST 需要 live share + 成員資格(`can_execute_manually`)。**不要求啟用**。產品 UI 仍應提交選單上的 `task_version_id`。Agent 提交**需要**啟用。 - run 的 `input` 會先暫存為 owned object,但**不再**計入公司每分鐘 5 次上傳起始的額度(後端 ≥ #1140);連續提交會全部以 `queued` 接受。**422 `input_not_stageable`** 現在只代表 input JSON 無法 canonicalise 或寫入。 - **佇列背壓(429,什麼都沒排入)。**OpenAPI 在提交、重試與 Quick Run 上宣告為 `SandboxQueuedRunLimitErrorResponse`/`SandboxQueueFullErrorResponse`,兩者都包在 `detail` 裡。`sandbox_queued_run_limit` 表示公司等待中的 run 已達上限(body 的 `limit`);退避到等待中的 run 變少再送。`queue_full` 只會出現在地端部署:整個 executor fleet 的佇列已滿,body 帶 `limit` 與 `retry_after_seconds`,同一個值(60 秒)也放在 `Retry-After`。等過這段時間後,用同一把 `Idempotency-Key` 重送。見[雲端與地端部署](/zh-TW/concepts/integration-contract.md)。 - 託管雲端上,提交後幾秒內就會派送(API 在 commit 後喚醒 controller;另有 3 秒一次的掃描當後備)。剛發布版本的第一次執行要在 Cloud Run 拉映像(`wait_reason: container_starting`,30–90 秒);之後每次約 10–30 秒到 `running`。 ## 3. 輪詢狀態 ```bash GET /chatrooms/{chatroom_id}/runs/{run_id} ``` `status` 走 `queued → starting → running → completed`(或 `customer_failed` / `platform_failed`;取消 `cancel_requested → cancelled`)。**終態看 `terminal_at`**。 每個終態 run 都帶租戶可見的失敗來源(`completed` 時為空): | 欄位 | 意義 | | --- | --- | | `error_code` | 固定 slug,例如 `work_exit_nonzero`、`work_timeout`、`invalid_output`、`cancelled_by_request`、`binding_revoked`、`interrupted_by_platform`、`result_upload_failed`(工作成功但平台無法儲存輸出——`platform_failed`,請重試) | | `exit_code` | 工作指令真正的結束碼(`exit 3` 就是 `3`;`124` 逾時、`130` 取消、`127` 找不到指令) | | `failure_stage` | `task_bundle` / `secret_env` / `workspace` / `spawn` / `metering_*` / `work` / `output` / `timeout` / `cancel` / `egress_cap` / `wait` | | `failure_source` | `runner` / `reconcile` / `dispatch` / `submit` / `policy` / `tenant` | | `error_detail` | 已消毒的簡短說明(`[A-Za-z0-9._@:/+=%;-]`,≤128) | | `failure_hint` | 失敗終態附帶的修法建議,由 `error_code`/`exit_code` 推出(例如 exit 127 → 把工具鏈目錄加進 `PATH`、`work_timeout` → 調高 `timeout_seconds`)(後端 ≥ #1140) | | `measured_ingress_bytes`、`measured_egress_bytes` | 工作階段計量的網路位元組 | 每一次非終態的輪詢都會說明**正在發生什麼**與**該做什麼**(後端 ≥ #1140),請直接呈現給等待中的使用者: | 欄位 | 意義 | | --- | --- | | `wait_reason` | 封閉詞彙:`awaiting_dispatch`、`company_saturated`、`no_active_region`、`missing_image_digest`、`capacity_snapshot_stale`、`provider_capacity_exhausted`、`provider_submit_pending`、`provider_submit_uncertain`、`container_starting`、`cancel_pending`;`running` 與所有終態為空 | | `next_action` | 對應 `wait_reason` 的白話指引(要等還是要動手) | | `wait_since` | 目前這段等待從何時開始(排隊中為 `queued_at`,啟動中為 `started_at`) | | `queue_position` | 在 run 所等待佇列中的位置(從 1 起算);已被認領或派送、或根本沒在等待時為 `null`。雲端:在公司 `queued` run 中的位置。地端:跨公司的 executor 佇列位置,executor 接手前的 `starting` 也算 | | `eta_seconds` | 等待中的 run 大約還要幾秒開始,或 `null`。託管雲端一律 `null`。地端:`ceil(queue_position ÷ 整個 fleet 的 run slot 數) × 同一 resource profile 最近 20 筆已完成 run 的平均耗時`;沒有這些歷史時為 `null`。有值才顯示,絕不自行推算 | 給使用者看 `error_code` + `exit_code`,並提供 log(§5a)——log 裡有腳本的 stdout/stderr,例如 `/workspace/work.sh: line 1: python3: not found`。 `secret_env_name_collision`/`secret_env_value_collision`(`platform_failed`,stage `secret_env`)表示某個 `ordinary_env` 變數與已綁定的 secret slot **同名**,或其**值**與已綁定的 secret 值相同。Runner 在工作開始前就拒絕,`error_detail` 為空,使用者只看得到 `failure_hint`:把變數或 slot 改名讓兩組名稱不重疊,或移除該變數、改從 slot 讀取 secret。 列表 + 篩選: ```bash GET /chatrooms/{chatroom_id}/runs?status=queued&status=running # status 可重複 ``` 受限 key **和 department-manager 角色以下的一般使用者**都只會看到自己提交的 run;整個房間的清單需要 department manager(或 company manager)以上的角色,且即使是管理者,每一列仍會經過 `can_read_run` 把關。 ## 4. 看有哪些產出 ```bash GET /chatrooms/{chatroom_id}/runs/{run_id}/content # → { state, has_input, has_output, has_log, has_artifact_bundle, input_digest } ``` **只有布林旗標,沒有內容本體**(這是「c2 log/output」的 metadata 視圖)。`state` 可能是 `"missing"`(尚無內容列)。 Runner 的結果上傳不受作者上傳初始化次數預算限制。除了 work exit code,仍須分別檢查 `has_output`、`has_log` 與實際下載;`result_upload_failed` 是平台失敗,請依 `failure_hint` 處理。 ## 5. 列出並下載產出物 ```bash GET /chatrooms/{chatroom_id}/runs/{run_id}/artifacts # 預設 lifecycle=active # → items[] { id, relative_path, byte_size, digest, deletion_state, declared_content_type } POST /chatrooms/{chatroom_id}/runs/{run_id}/artifacts/{artifact_id}/download # → { capability_token, expires_at, content_type, content_disposition, x_content_type_options: "nosniff" } ``` - **怎麼產生產出物檔案**(runner ≥ #1152,2026-09-04 已在 staging 實測:兩個檔案、列表、逐檔公開連結、撤銷):工作把檔案寫到 `$TEAMSYNC_ARTIFACTS_DIR`(`/workspace/artifacts`,一開始是空的;子目錄變成路徑前綴)。工作結束後 runner 把裡面每個一般檔案打成一個確定性的 bundle;之後 `GET .../content` 會回 `has_artifact_bundle=true`,`GET .../artifacts` 逐檔列出 `relative_path`、`byte_size`、`digest`。遇到 symlink/hard link/特殊檔、`..`、超過 1000 個檔案、路徑過長、或總量超過 standard profile 上限(workspace 上限 − 16 MiB,最多 1 GiB)時整包拒收——run 照樣 completed,log 會寫 `stage=artifacts refused: <原因>`。`output.json` 仍是結構化結果。 - 回的是**短效 `capability_token`**(TTL 5 分鐘),不是原始 key / presigned URL。這個 POST 只做擁有權授權(產出物的 `deletion_state` 必須是 `active`)——**沒有端點會兌換這個 token**。要取單一檔案的位元組,用逐檔公開連結(後端 ≥ #1149):`POST .../runs/{run_id}/artifacts/{artifact_id}/public-link` → `{ url, token }`;`GET ` 驗過 `sha256` 後只串流該檔案在 bundle 裡的位元組區間,同路由 `DELETE` 撤銷,產出物或 bundle 退場後連結自動 404。只限 owner-manager(受限金鑰 404)。 - `deletion_state != active` 的產出物,download 一律 **404**。 - 要把一次執行的內容交出去,用 §5a 的 `POST .../runs/{run_id}/{log|output}/public-link`(`GET /public/sandbox/artifacts/{token}`);Agent 工具 `sandbox_job_status` 另可回有界、不可信的預覽(output ≤64 KiB、log 頭尾 ≤16 KiB)。 ## 5a. 把一次執行的 log/output 交給 TeamSync 之外的人(可選) ```bash POST /chatrooms/{chatroom_id}/runs/{run_id}/{log|output}/public-link # → { run_id, object_kind, url, token, revoked, created_at } ``` 這是**永久**的(沒有 TTL),連結一旦鑄出就不必登入——`url` 是 `GET /public/sandbox/artifacts/{token}`,不需要登入。鑄造冪等;`DELETE` 同一條路撤銷(要新 token 再鑄一次)。只有 `log`/`output`,不含個別產出物。完整契約見 [內容、Digest 與下載](/zh-TW/concepts/content-and-downloads.md)。 ## 6. 取消 / 重試 ```bash POST /chatrooms/{chatroom_id}/runs/{run_id}/cancel # 取消你讀得到的 run(受限金鑰:只有自己的;kill switch 下仍可用) POST /chatrooms/{chatroom_id}/runs/{run_id}/retry # 以新身分重試終態 run;帶 Idempotency-Key;受限 key 在允許房間回 405(若到 router 才是 404) ``` - retry 允許任何終態:`completed`/`customer_failed`/`platform_failed`/`cancelled`。永遠是**新的** `run_id`。受限 key 在允許房間回 405(若到 router 才是 404)。同一把 `Idempotency-Key` + 不同 request hash → 409 `idempotency_conflict`。 - Retry 保留精確的任務版本 pin,建立新的 run 身分。省略 body、送 `{}` 或 `{"input": null}` 會逐位元重放保留的來源 input;非 null 的 `input` 則覆寫參數,依一般輸入限制重新驗證與暫存。來源 input 不存在、已退役、無法讀取或 digest 不符時,重放回 409 `retry_input_unavailable`,請改傳明確的 input。必帶 `Idempotency-Key`;來源未終態回 409 `retry_not_eligible`。受限 Sandbox key 不可 retry:允許房間的請求會被 route allowlist 拒絕(405);不在 key scope 的房間可能更早回 403。 - 來源 run 還不是終態 → 409 `retry_not_eligible`。 - 未認領取消(`queued`):立刻變成 `cancelled`,釋放定價 hold,也立刻釋放排隊/並行容量 slot(2026-08-30 修正——之前一次競速中的並行 dispatch 可能讓已取消的 run 洩漏容量 slot)。已認領/執行中取消:`cancel_requested`,再由 runner 走到 `cancelled`;租戶取消圍欄贏了時 `error_code=cancelled_by_request`。`cancel_requested_at` 有持久化,不回傳。 - 極少數情況下,若控制平面在 dispatch 途中掛掉,run 可能卡在 `starting`;背景的 reaper 會自我修復(重新驅動同一次 dispatch,且只會執行一次)——不需要租戶動作,取消在這期間仍然可用。 - `submission_source` 是 `rest`(本頁)、`quick_run`、`agent` 或 `custom_table_trigger`。四條路的輪詢/下載契約相同。 ## Quick Run(manager-only) 一次原子建立**隱藏的聊天室擁有**父任務 + 版本 + 執行:`POST /chatrooms/{chatroom_id}/quick-run`(`SandboxQuickRunRequest`,帶 `Idempotency-Key`)。回 `{ task, version, run }`。受限 key 在 auth 回 403(若到 router 才是 404)。不要把那個 owner_scope 抄到 `POST /tasks`。 Quick Run 跟手動提交一樣暫存 `input`(v5.10.11;在此之前工作讀到空的 `/workspace/input.json`,`GET .../content` 也回 `has_input=false`)。因此在 manager 檢查與冪等重放之後,可能回 422 `input_not_stageable`/503 `input_staging_unavailable`,此時尚未建立任何任務或 run。Quick Run 不受公司政策的 queued-run 上限檢查;地端部署的每公司與 fleet 佇列上限仍然適用(429 `sandbox_queued_run_limit`/`queue_full`)。 ## Company manager 每任務歷史 不是房間清單。只有 company manager: ```bash GET /tasks/{task_id}/runs?offset=0&limit=10&order=desc GET /tasks/{task_id}/runs/numOfData ``` 兩邊同一組篩選:`status`、`method`(`agent`|`manual`)、`executor_kind`(`internal_user`|`external_user`)、`source_kind`(`chatroom`|`department`|`external_platform`)、`department_id`、`chatroom_id`。列表回應是歷史列的 **JSON 陣列**(duration、已結算 `cost_usd`、執行者、房間、部門、method)— 不是 `{ items, page_token }`。`numOfData` 是 `{ num }`。受限金鑰在認證層直接 **403**(路徑在 restricted-key 拒絕清單上,也不在 Sandbox scope 允許清單內),永遠到不了 router 的 404。 ## 常見錯誤速查 | 狀況 | 回應 | | --- | --- | | 缺 Idempotency-Key | 422 `idempotency_key_required` | | body 過大 | 413 `request_too_large` | | 版本不可執行 | 409 `version_not_runnable` | | retry 來源不合格 | 409 `retry_not_eligible` | | 未授權 / 受限 key 動了禁區 / 產出物非 active | 404 | | kill switch 下的變更 | 503 `sandbox_disabled`(`/cancel`、`/download` 除外) | | Envelope 塞不進配額 | 403 `budget_reservation_exceeded`(不建列) | | 公司 queued-run 上限已滿 | 429 `sandbox_queued_run_limit`(body 帶 `limit`;預設 100,可由公司政策覆寫;地端取它與主機每公司上限(預設 20)中較小者)——退避後再送,不要熱重試 | | 地端 executor fleet 佇列已滿 | 429 `queue_full`(body 帶 `limit`、`retry_after_seconds`;`Retry-After: 60`)——等過這段時間再重試 | | 定價拒絕(`no_active_region` …) | 422 | # 任務、分享與啟用 建立與管理目錄任務走 **owner scope**。房間啟用走 **目標聊天室管理者階梯**。這不是「一律 company manager」。受眾規則只寫在一處:[任務受眾](/zh-TW/concepts/job-audience.md)。受限 API 金鑰在 tasks/shares/granted-jobs/bindings 一律 404。 ## 步驟 1. **建立任務** — `POST /tasks`(`SandboxTaskCreateRequest`): - `name` 1–256,`description` ≤2048。 - `owner_scope`:**只有 `department` 或 `company`**。`chatroom` → **422**。 - `owner_id`:部門 id 或公司 id(company 必須等於 `company_id`)。 - `agent_enabled`(預設 false)是**舊旗標**。Agent 能否看到任務看房間啟用開關。 2. **建立草稿版本** — `POST /tasks/{task_id}/versions`: - `environment_version_id`:一個 **`ready`** 的環境版本(自家或策展)。 - `startup_script`/`work_script`(各 ≤1 MiB)。 - `ordinary_env`:名稱數與 secret slot **合計 ≤100**;每個 `NAME=VALUE` 條目 ≤64 KiB,**所有 ordinary 條目加總也只有 64 KiB**(ordinary+secret 合計 ≤256 KiB);名稱須符合 `^[A-Za-z_][A-Za-z0-9_]{0,127}$`,且不能是保留名(`TEAMSYNC_`、`AWS_`、`PATH`…)。注意:草稿建立/`PATCH` 與發布**完全不檢查**這些限制——超標的版本照樣發布;它們只在 secret claim/manifest reveal 時被強制,而且只對有宣告 secret slot 的版本生效——沒宣告 slot 的超標版本會一路通過後端不被擋。 - `input_instructions`/`input_example`(≤64 KiB)。 - `input_schema`(≤64 KiB,可選):JSON Schema(draft 2020-12),驗證每次 run 的 `input`。設定後,`input_example` 必須符合它(**就在這次草稿建立/更新呼叫時**檢查,不符合是 422 `job_contract_invalid`——發布不會重新驗證),而提交的 run 若 `input` 違反它,會在派工**前**被拒絕,回 **422 `input_schema_violation`**。 - `work_command`(≤4096 **UTF-8 位元組**,可選):work 步驟的 driver 指令列,例如 `python3 /workspace/work.sh`——`work_script` 永遠落地在 `/workspace/work.sh`,**上傳檔案的原始檔名不會保留**,所以指令要指向那個固定路徑,不是你上傳時的檔名(沒有 `work.py` 這種檔)。Runner 會把它原封不動寫進 `/workspace/driver.sh`,在 `startup.sh` 之後 source 它(絕不是 argv)。空白/省略 → `. /workspace/work.sh`(此時 work_script 必須是 shell;填了直譯器就可以是任何語言)。含 NUL 或超長一樣是 422 `job_contract_invalid`。見下面的**腳本檔案與 runtime 契約**。 - `task_bundle_upload_id`(可選):**任務包**——先用 `POST /tasks/{task_id}/bundle-uploads`(multipart 欄位 `file`,zip / tar / tar.gz,壓縮後 ≤20 MiB)上傳的封存檔。工作步驟開始前會解壓到 `/workspace`,所以一個任務可以帶任意多個檔案(程式、資料、設定),以絕對路徑讀取。見下方**任務包**。 - `timeout_seconds`(預設 1800,`ge=1`,`le≈7 天`,也受公司 ceiling 限制)。 - `secret_slot_declarations`:`{ "name": "SLOT" }` 或純字串。見 [Secrets](/zh-TW/concepts/secrets.md)。 - `requires_confirmation`(預設 false)。自訂表格 trigger 不能對準 `true`。 - `output_policy`(可選,**有型別**,以 `kind` 區分的聯集):表格 writeback `{ "kind": "custom_table_writeback", "table_id": "...", "allowed_ops": "create" | "create,update" }`,或 Command 輸出 `{ "kind": "custom_table_command", "command_id": "...", "chatroom_id": "...", "input_schema": [ … ] }`(v5.21.0——見[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md))。只允許**部門擁有**的任務。公司擁有的任務發布 → 409 `output_policy_owner_scope_unsupported`。不是不透明 JSON。 3. **改草稿**(可選)— `PATCH /tasks/{task_id}/versions/{vid}`(只有草稿)。跟建立同一組欄位,含 `input_schema`/`work_command`。 4. **發布** — `POST /tasks/{task_id}/versions/{vid}/publish` → `state: "published"`。**需要已發布版本的是啟用與執行,不是分享**:分享是**任務層級**的(`POST /tasks/{task_id}/shares` 不帶版本,任務連一個已發布版本都還沒有也能先分享);啟用一定釘一個 published 且 content active 的版本;執行時版本非 published/active → 409 `version_not_runnable`(已 scrub 的版本則是 409 `content_scrubbed_irreversible`)。 5. **分享** — `POST /tasks/{task_id}/shares`(`target_kind` + `target_id`,可選逐 slot 的 `secret_slot_policies`——見 [Secrets](/zh-TW/concepts/secrets.md))。見 [分享對象](/zh-TW/concepts/job-audience.md)。撤銷:`.../shares/{grant_id}/revoke`。分享**不是** Agent 啟用。 6. **房間啟用** — `POST /chatrooms/{chatroom_id}/granted-jobs/{task_id}/enable`(可選 `task_version_id`;省略 → 目前已發布版)。釘版本並放進 `GET .../menu`。當 live binding 已經釘在你要的版本(或你沒送 `task_version_id`)時,`enable` 是冪等的。送**不同的** `task_version_id` 會重新釘版(後端 ≥ #1140):既有 binding 會像 `disable` 一樣被撤銷(舊版本上排隊中的 run 會被取消),再建立新的 binding;回應的 `pinned_task_version_id` 一定是你要求的版本。`POST /bindings/{binding_id}/accept-version` 仍是原地升版的方式。若任務**沒有分享**給這個聊天室(或其部門/公司),enable 會回 **409 `task_not_shared_to_room`**,訊息會寫出該呼叫哪條分享 API(#1140 之前是單純的 404)。停用:`.../disable`(share 還在)。 `POST /bindings` 是同一個釘選,只是必須帶明確版本。產品路徑請用 granted-jobs。 ## 任務包(多檔案任務) ```bash POST /tasks/{task_id}/bundle-uploads # multipart:file=(zip / tar / tar.gz,壓縮後 ≤20 MiB) # → { id, filename, archive_format, compressed_byte_size, extracted_byte_size, canonical_byte_size, file_count, sha256, manifest_sha256, expires_at } POST /tasks/{task_id}/versions # { environment_version_id, task_bundle_upload_id: , work_script | work_command, ... } ``` - 封存檔根目錄就是 `/workspace`:zip 裡的 `app/main.py` 在執行期是 `/workspace/app/main.py`。一律用絕對路徑讀取包內檔案。 - **保留的根目錄檔名**:`startup.sh`、`work.sh`、`driver.sh`、`input.json` 屬於 runner。封存檔根目錄含有其中任一個會被拒絕:**422 `bundle_path_forbidden`**,訊息會寫出是哪個檔案(`runner-reserved root path: work.sh (rename it; …)`,後端 ≥ #1140)。把進入點腳本放到子目錄(例如 `app/run.sh`)再用 `work_command` 指過去,或把進入點寫在 inline 的 `work_script`。 - 已發布版本帶 `task_bundle` 摘要(`sha256`、`manifest_sha256`、`file_count`、各種位元組大小);上傳本身若沒有版本採用會在 `expires_at` 後過期。 - 可用的模式(2026-09-03 實機驗證):一個環境裝好各 runtime,三個任務掛在同一環境版本上,每個任務包 = `app/<程式>` + `data/params.json`,`work_script` 在工作階段安裝該任務的套件(`pip install --target /tmp/pylib …`、`/tmp/app` 下 `npm install …`、`/tmp/gomod` 下 `go get …`)再執行程式,程式讀取包內資料檔與 `$TEAMSYNC_INPUTS_FILE`。參考實作:`teamsync-backend` 的 `sandbox/e2e-js/`。 ## 腳本檔案與 runtime 契約 腳本檔案有兩條 **owner-manager** 路由: - `POST /tasks/{task_id}/script-uploads`(multipart `file`,一個 UTF-8 文字檔 ≤1 MiB)——**版本還沒建立就能用**。立即驗證腳本並回一個綁在任務上、可重用的 handle(`SandboxScriptUploadResponse`:`id`、`filename`、`byte_size`、`sha256`、`expires_at`——24 小時)。把 `id` 當 `work_script_id` 或 `startup_script_id` 帶進 `POST /tasks/{id}/versions` 或 `PATCH .../versions/{vid}`;內容會複製進版本,handle 在過期前可重複使用。建版本表單讓使用者選檔案時,走的就是這條。 - `POST /tasks/{task_id}/versions/{vid}/script-file?target=startup|work`(multipart,一個 UTF-8 文字檔,≤1 MiB;**只限草稿**)——直接把檔案內容上傳進 `startup_script` 或 `work_script`,語意跟把該欄位 `PATCH` 成字串完全一樣。檔案有問題回 422 `script_too_large`/`script_not_utf8`/`script_contains_nul`。已發布版本 409(不可變——跟 `PATCH` 一樣)。 另外還有一條**公司裡任何非受限成員**都能打的路由,草稿或已發布版本都行(沒有 owner-manager 閘、也沒有限草稿——它用的讀取權限跟 `GET /tasks/{id}/versions/{vid}` 一樣): - `POST /tasks/{task_id}/versions/{vid}/input-preview`(`{ "input": <任意 JSON> }`)——不會派工的 dry run。回傳會真正落地到容器裡的精確 canonical 位元組(`input_file_content`)、其 digest(`input_digest`)、`runtime_contract`(見下)、以及 `input_schema` 的驗證結果(`schema_valid` + `schema_errors[]`)。用這個讓作者在發布前先測 `input_schema`,或讓任何呼叫者(owner 或 borrower)看看自己的 `input` 實際落地會長什麼樣子。Borrower 的預覽一樣會把 `runtime_contract.driver` 遮蔽成預設 fallback——只要呼叫者不是這個任務的管理者,handler 在組出 `runtime_contract` 前就會把 `work_command` 設為 null。 每個 run 都在一份固定的 **runtime 契約**下執行: ```text invocation = ". /workspace/startup.sh && . /workspace/driver.sh" driver = 你的 work_command,空白時是 ". /workspace/work.sh" working_directory = runner 以 cwd=/workspace 啟動 sh,但契約不保證——一律寫絕對路徑 network = 公司設定 runtime_egress_enabled 為 true 時可對外連線(GET /settings;目前預設開啟)——pip / npm / go get 在工作階段可用;對外流量會計量(measured_egress_bytes) inputs_file_env = "TEAMSYNC_INPUTS_FILE" # 存放 input JSON 檔案路徑的環境變數 output_file_env = "TEAMSYNC_OUTPUT_FILE" # 存放結果該寫去哪裡的環境變數 artifacts_dir_env = "TEAMSYNC_ARTIFACTS_DIR" # /workspace/artifacts——留在這裡的檔案會變成可下載的產出物 ``` 提交的 `input` 會在腳本啟動前寫進 `$TEAMSYNC_INPUTS_FILE` 指的那個檔案(用任何語言讀都行,例如 `json.load(open(os.environ["TEAMSYNC_INPUTS_FILE"]))`);宣告的 secret slot 會以環境變數匯出,不會寫進磁碟。 腳本必須知道的執行期事實(皆為實機觀察): - **`PATH`** 就是映像設定的 `ENV PATH`(會驗證:只接受絕對路徑條目);映像沒設時採慣用的 `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`(runner 版本 ≥ #1145/stage image `7abca452…`;之前 work shell 根本沒有 `PATH`,只靠 shell 內建的預設搜尋路徑)。裝在這些目錄之外的工具鏈仍要在 `work.sh` 裡 `export PATH=/usr/local/go/bin:$PATH`。`runtime_contract.path` 會寫明這件事。exit 127(`python3: not found`)代表直譯器的目錄不在生效的 PATH 上。 - 工作 shell 的環境變數只有 `PATH`、你的 `ordinary_env`、宣告的 secret slot 與三個 `TEAMSYNC_*` 檔案路徑。映像裡烘焙的其他 `ENV`(`JAVA_HOME`、`LANG`、`PYTHON_VERSION`……)**不會**繼承——需要的請在 `startup.sh` 自行 export。地端部署自 v5.10.11 起行為相同(之前會漏出映像的 `ENV`)。 - 託管雲端開啟對外網路的 run(預設)若使用以 v5.10.11 原始碼建置的 runner release,shell 會成為自己的 user+PID 命名空間的 PID 1;主機允許私有 `/proc` 設定時,`$$` 為 `1`,`ps` 只看得到這個任務的行程。短命的孤兒行程(例如 BusyBox `wget https://…` 分出的 `ssl_client`)在巢狀設定啟用時不再讓 run 以 exit 137 被砍;shell 結束時仍在執行的行程會一併被終止。主機允許 id 對應時 `id -u` 為 `0`,否則是溢位 uid `65534` 且沒有任何 capability;兩種情況 `/workspace` 都仍可寫。若主機拒絕命名空間,runner 使用直接 shell fallback。地端工作使用 Docker executor 的直接 shell 路徑,不進入這個 Go gate。Runner 會在 `/workspace/.teamsync/` 下寫自己的紀錄——不要使用這個名稱。版本會沿用建置時的 runner(`runner_release`)。 - 只有 `/workspace` 與 `/tmp` 可寫。快取與安裝放 `/tmp`(`HOME=/tmp`、`GOPATH=/tmp/go`、`npm_config_cache=/tmp/npm-cache`)。 - `python -m venv` 需要能透過 `PATH` 找到 `python3`(找不到時會出現 "Unable to determine path to the running Python interpreter")。沿用映像 PATH 之後,官方 `python:*` 映像上可正常使用;`python3 -m pip install --target /tmp/pylib …` 搭配 `PYTHONPATH=/tmp/pylib` 仍是較輕量的替代作法。 - 執行之間不保留任何東西:每次執行都重新安裝。 **借用者**看不到真正的 `work_command`/`driver`(會遮蔽成預設的 fallback)——但看得到真正的 `input_schema`,因為那描述的是呼叫者自己 `input` 的形狀,不是 owner 的實作細節。 ## 版本升級(binding) - 啟用會**釘住精確版本**。新發布會把 binding/granted-job/選單項目的 `update_available` 設為 true。 - 升級:`POST /bindings/{binding_id}/accept-version`(`task_version_id`)— 從不自動升。 - 撤銷釘選:granted-jobs `disable`,或 `POST /bindings/{id}/revoke`。**兩者都有副作用**:該 binding 底下所有 runner 尚未 claim 的 run(狀態 `queued`)會被立刻標成 `cancelled`(`error_code="binding_revoked"`),待確認的 Agent proposal 也會被取消;已 claim 的 run(`starting`/`running`…)保留。撤銷 share(`.../shares/{grant_id}/revoke`)同樣會把該任務在 grant 涵蓋的每個房間裡未 claim 的 run 取消(`error_code="share_revoked"`)。對已終態的 binding 再 revoke → 409 `binding_terminal`。 ## 擁有者 vs 借用者 - 你**管理**的任務/版本:回應含 scripts、`work_command`、`ordinary_env`、`secret_slot_declarations`、`output_policy`,以及會回顯真正指令的 `runtime_contract.driver`。 - **分享給你**的任務:那些欄位會省略(`work_command` 是 `null`、`runtime_contract.driver` 只顯示預設 fallback)。借用者永遠看不到 `output_policy`(裡面嵌了擁有者的 table id,或 Command 與聊天室的 id)。借用者**看得到**真正的 `input_schema`——那描述的是自己 `input` 的形狀,不是 owner 的實作。 ## 常見錯誤 - **422**:`owner_scope=chatroom`(enum)、欄位驗證、未知欄位。 - **422 `job_contract_invalid`**:在草稿建立/更新時(發布不會出現這個)——`input_example` 不是合法 JSON、不符合 `input_schema`,或 `work_command` 含 NUL/超過 4096 UTF-8 位元組。 - **422 `input_schema_violation`**:提交時,run 的 `input` 不符合版本的 `input_schema`(派工前就拒絕;只有存好的 schema 本身解析不出來時才會 fail-open——放行並記警告)。 - **422 `script_too_large`/`script_not_utf8`/`script_contains_nul`**:`script-file` 上傳的檔案有問題。 - **403 `owner_scope_forbidden`**:呼叫者不能擁有的 department/company scope。 - **403 `output_policy_author_denied`**:表格 writeback——發布者對目標表沒有不受限寫入權。Command 輸出——撰寫者、發布者或提交者沒有通過身分、grant 或範圍檢查,該 Command 不再是帶 `effect_identity` 的 restricted definition-authority Command(發布時是下面的 409),或提交 run 的方式不符合[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md)的規則。 - **409 `output_policy_owner_scope_unsupported`**:公司擁有的任務帶 writeback 或 Command 輸出 policy。 - **409 `output_policy_unsatisfiable`**:表格 writeback,在發布時,以及提交 run 且真的鑄出憑證時——表已不在、有 channel rule,或是 `commands_only`。Command 輸出:在發布時,三項 Command 檢查(Command 存在、輸入與模式相符、restricted definition authority 且有 `effect_identity`)任何一項失敗;只有對發布者與已記錄撰寫者的身分、grant 與範圍檢查仍是 403。 - **422 `output_policy_governed_table`**:草稿建立/更新——表格 writeback policy 指向 `write_policy` 為 `commands_only` 的表(它的紀錄只能透過 Command 變更)。 - **422 `output_policy_command_not_found`/`output_policy_schema_mismatch`**:Command 輸出,草稿建立/更新時,以及提交 run 時再次檢查——policy 指的不是你公司裡一個活著的 Command,或 `input_schema` 與該 Command 的 `inputs` 不同(或該 Command 不是寫入型)。 - **422 `secret_slot_policies_invalid`**:分享建立時的 `secret_slot_policies` map 格式不對。 - **409**:`already_published`/`version_not_draft`(重複發布)、`already_archived`/`version_not_published`(封存)、`environment_version_not_ready`(發布時環境版本非 ready)、`published_version_immutable`(`PATCH` 或 script-file 打在非草稿版本——已發布或已封存都算)、`task_not_active`(在非 active 的任務上開草稿)、`content_scrubbed_irreversible`(對已 scrub 的內容發布/PATCH)、`share_already_live`(同一 task+target 已有 live grant)、`binding_already_live`(同一 room+task 已有 live binding——`POST /bindings` 會撞到;`enable` 對仍適用的 live binding 直接回傳同一個 binding,但 binding 已不適用時也可能出現)、`binding_terminal`(對已終態的 binding 再 accept-version/revoke)。 - **422 `bundle_path_forbidden`**:任務包根目錄含 runner 保留檔名(`startup.sh`、`work.sh`、`driver.sh`、`input.json`)。 - **`enable` 回 404 "Task not found"**:任務還沒分享給該房間(或其部門/公司)——先 `POST /tasks/{task_id}/shares`。 - **404**:未授權/跨公司分享對象/受限金鑰。 下一步:[執行與取回結果](/zh-TW/flows/run-and-results.md)。 # 一般使用者執行任務 這頁只給**一般租戶使用者**(聊天室成員,`role < 2`):從 menu 找到可跑的任務、提交、等結果。不要建環境、不要建任務、不要建綁定——那些是管理員的事,見 [角色與權限](/zh-TW/concepts/personas.md)。 公司沒有自訂環境也沒關係。live 目錄是名為 **SCFG Standard** 的 **`system_curated`** 環境(每個公開區域都是 `state=ready`)。管理員把那個版本釘到**部門或公司**任務上(租戶不上傳)、分享、**房間啟用**,你還是從下面的選單開始。見 [任務受眾](/zh-TW/concepts/job-audience.md)。 受限 API key 只允許 menu、提交與自己的 run。Retry 被 route allowlist 排除(允許房間回 405),Quick Run 與任務管理則更早在 auth 回 403;請見[認證](/zh-TW/get-started/auth.md)。 ## 1. 從 menu 發現可執行任務 **沒有 `GET /bindings` 列表。** 一般使用者靠聊天室 menu 發現能跑什麼: ```bash GET /chatrooms/{chatroom_id}/menu # query page_size (ge1 le100, 預設 50) ``` 回 `items[]`(`SandboxMenuItemResponse`):`task_id`、`task_name`、`task_version_id`、`task_version_digest`、`requires_confirmation`、`secret_slot_names`、`secret_slots[]`、`timeout_seconds`、`update_available`、`input_instructions` / `input_example`、`input_schema?`、`runtime_contract?`、`secret_use_risk_warning?`。如果有 `input_schema`,client 端就先照它驗證 `input`——伺服器也會強制檢查(派工前 422 `input_schema_violation`)。這個選單回應上的 `runtime_contract.driver` 永遠是預設 fallback,不管誰來問都不會是真正的 `work_command`——見 [Schemas](/zh-TW/reference/schemas.md)。 選單列出**已授予此房間且已在此啟用**的任務。清單不因你是誰而變。已授予但未啟用的任務不會出現。受限金鑰可對允許清單內的房間呼叫。未授權的房間 → **404**。 公司內任何非受限 JWT 也可以 `GET /tasks` 看公司任務目錄(隱藏的 quick-run parent 會過濾),但**可執行清單仍以 menu 為準**。 ## 2. 提交執行(必帶 Idempotency-Key) ```bash POST /chatrooms/{chatroom_id}/runs Idempotency-Key: # 必帶,否則 422 idempotency_key_required { "task_version_id": "...", "input": {...}, "timeout_seconds": 1800 } ``` - `input`:canonical JSON(≤1 MiB、深度 ≤64、節點 ≤100k、無 NUL、無重複 key)。 - body 過大先回 **413 `request_too_large`**(依 Content-Length)。 - 版本非「published + content active」→ 409。 - 回 `SandboxRunDetailResponse`,`status: "queued"`。 同一把 `Idempotency-Key` 配**完全相同的請求**(同房間、`task_version_id`、`input`、實際生效的 `timeout_seconds`)重送,會重放同一結果。同一把 key 換了內容則回 **409 `idempotency_conflict`**,不是重放;請求雜湊也包含 `retry_of_run_id`,所以同一把 key 混用 retry 與新提交同樣是 409——新的意圖一律用新的 UUID。 ## 3. 輪詢狀態 ```bash GET /chatrooms/{chatroom_id}/runs/{run_id} ``` `status` 走 `queued → starting → running → completed`(或 `customer_failed` / `platform_failed`;取消 `cancel_requested → cancelled`)。**終態看 `terminal_at`**。失敗看 `error_code`。沒有 streaming。 列表: ```bash GET /chatrooms/{chatroom_id}/runs?status=queued&status=running # status 可重複 ``` 一般使用者**只會看到自己的 run**。別人的 run、別的室的 run、借用方跑你沒提交的任務——對你都是 404。狀態細節見 [執行與取回結果](/zh-TW/flows/run-and-results.md)。 ## 4. 看有哪些產出 ```bash GET /chatrooms/{chatroom_id}/runs/{run_id}/content # → { state, has_input, has_output, has_log, has_artifact_bundle, input_digest } ``` **只有布林旗標,沒有內容本體。** `state` 可能是 `"missing"`(尚無內容列)。 ## 5. 列出並下載產出物 ```bash GET /chatrooms/{chatroom_id}/runs/{run_id}/artifacts # 預設 lifecycle=active # → items[] { id, relative_path, byte_size, digest, deletion_state, declared_content_type } POST /chatrooms/{chatroom_id}/runs/{run_id}/artifacts/{artifact_id}/download # → { capability_token, expires_at, content_type, content_disposition, x_content_type_options: "nosniff" } ``` - `POST .../download` 只做擁有權授權,回傳 5 分鐘效期的 `capability_token`——這個 token 不會被儲存,**目前沒有任何端點能兌換它**,拿不到位元組。 - 要實際下載 run 的位元組,改為對 run 的 log 或 output 鑄造永久公開連結:`POST /chatrooms/{chatroom_id}/runs/{run_id}/{log|output}/public-link` → `{ url, token }`,再 `GET /public/sandbox/artifacts/{token}`;用同一路由的 `DELETE` 撤銷。run 還沒有 log/output 物件時鑄造會 **404**;受限 API key 一律被拒絕鑄造(因此完全沒有位元組下載路徑)。 - 個別檔案現在可用 per-file public link 供瀏覽器下載;短效 artifact-capability 端點只授權,不回位元組或 raw URL。請使用回傳的 public-link URL,不需分享時將其撤銷,詳見[下載契約](/zh-TW/concepts/content-and-downloads.md)。 - `deletion_state != active` 的產出物,download 一律 **404**。 ## 6. 取消自己進行中的 run ```bash POST /chatrooms/{chatroom_id}/runs/{run_id}/cancel ``` 一般使用者只能取消**自己的**進行中 run(kill switch 下這條仍可用)。別人的 run 對你是 404。管理員能取消他們看得到的 run——那不是本頁。 Retry 保留精確的任務版本 pin,建立新的 run 身分。省略 body、送 `{}` 或 `{"input": null}` 會逐位元重放保留的來源 input;非 null 的 `input` 則覆寫參數,依一般輸入限制重新驗證與暫存。來源 input 不存在、已退役、無法讀取或 digest 不符時,重放回 409 `retry_input_unavailable`,請改傳明確的 input。必帶 `Idempotency-Key`;來源未終態回 409 `retry_not_eligible`。受限 Sandbox key 不可 retry:允許房間的請求會被 route allowlist 拒絕(405);不在 key scope 的房間可能更早回 403。 ## 借用者目錄:沒有腳本 menu 與借用者視圖**不含腳本原文**。別人分享給你的任務,版本回應中 `startup_script` / `work_script` / `ordinary_env` / `work_command` / `secret_slot_declarations` / `output_policy` 全都回 `null`(欄位存在但為 null,不是缺席),`content_hash` 回空字串。task version 沒有 `source_digest` 這個欄位(那是環境版本的欄位);版本摘要 `canonical_digest`(menu 上叫 `task_version_digest`)借用者**看得到**,secret 核准就是用它。你只看得到執行所需(`secret_slot_names`、說明、逾時)。只有管理該任務 owner scope 的人才看得到擁有者視圖。 ## 一般使用者做不到的事 不要呼叫這些;未授權識別碼通常是 **404**(隱藏存在性): - 建立 / 更新 / 歸檔環境 - `GET` / `PUT /settings`——**例外**:這兩條走共用的 `COMPANY_MANAGER`(role ≥ 3)依賴,role 不足回 **403 `Insufficient permissions.`**,不是 404;前端不要假設所有管理員專用端點都長成 404 - 建立任務(任何 owner scope)、發布 / 歸檔 / 分享 - 建立 / 接受 / 撤銷綁定 - secret 寫入 / 輪替 / 撤銷與核准 - `POST /chatrooms/{id}/quick-run` 一般使用者不該建任務。`POST /tasks` 送 `owner_scope=chatroom` 是 **422**。不能擁有的 department/company scope 是 **403 `owner_scope_forbidden`**。 ## 404 對你代表什麼 對一般使用者,404 幾乎都是「這個 id 你不能碰」——可能不存在,也可能存在但你沒權限。前端不要用 404 判斷資源被刪了。跨公司、非成員聊天室、別人的 run、管理員專用端點,都長一樣。 完整階梯與矩陣見 [角色與權限](/zh-TW/concepts/personas.md)。 # Agent toolkit Agent **不會**打 `/private/module/sandbox` REST 來提交。它呼叫 `src/components/tools/custom/sandbox/factory.py` 的五個 LangGraph 工具。前端從不直接呼叫這些工具。前端要做的是:在房間啟用 job、在對話裡呈現 `requires_confirmation` 提案,然後用租戶 run API 輪詢得到的 `run_id`。 ## 啟用條件 `tool_loader` 才是準則(factory 開頭寫「與 `job_list` 無關」是**過時**的)。五個工具**只有**在以下全部成立時才會載入: 1. 房間的 `jobs` 清單含 `ChatroomJobType.SANDBOX`(`"sandbox"`)。 2. 有 `SANDBOX_ENABLED`、DB、可信 principal、簽過名的 turn context。 開了 job 也會加上 protocol prompt,並把五個工具釘成 critical,避免被剪掉。 用既有的聊天室 jobs API 開啟(不在 `/private/module/sandbox` 底下): ```text PATCH /private/chatrooms/setting/jobs/{chatroom_id} { "jobs": ["sandbox", ...] } ``` 認證是**聊天室管理員階梯**,失敗是 **403**,不是 sandbox 404。`jobs: null` 回到舊的自動偵測。`jobs` 沒設的房間**永遠**不會出現在 `GET /private/chatrooms/by_job/sandbox`。 那條路由的 OpenAPI 說明在有效的 job 類型裡列有 `sandbox`,`ChatroomJobsPayload` 也吃 `ChatroomJobType`,包含 `"sandbox"`。 列出明確開了這個 job 的房間: ```text GET /private/chatrooms/by_job/sandbox GET /private/chatrooms/by_job/sandbox/numOfData ``` Query:`department_id?`、`offset`、`limit` 1–100 預設 100、`order` `asc|desc`。省略 `department_id` → 已加入的房間。有設 → 該部門的房間(部門不在公司裡 → 404)。收藏的房間排前面。 建構時只捕捉伺服器 tuple `(company_id, chatroom_id, principal_type, principal_id, persisted_human_message_id, signed_turn_receipt)`。模型參數從不提供 turn 身份。 某個**具體目錄任務**能不能跑,需要 live **share** **且** 此房間的**啟用**開關(granted-jobs)。`task.agent_enabled` 是舊的父層旗標,**不會**把任務放進 Agent 目錄。房間開了 `"sandbox"` job 但沒啟用任何任務,選單是**空的**。開了 `ChatroomJobType.sandbox` 不會讓每個已授予任務可跑 — 房間仍必須 `POST .../granted-jobs/{task_id}/enable`。Agent 選單**與呼叫者身分無關**(與 `GET .../menu` 同一份)。見 [任務受眾](/zh-TW/concepts/job-audience.md)。 外部通道(LINE/LINE 群/LINE room/Messenger/Instagram)的 `principal_type=social_media_client`,用 pipeline 解析出的 client id。內部聊天用已驗證的 `user_id`。 ## 五個工具 | 工具名 | 參數(模型可見) | 回傳 | | --- | --- | --- | | `sandbox_job_menu` | `page_size`(預設 20,1..100)、`page_token?`、`query?`(≤256) | 此房已採用的精確版本目錄。沒有腳本。 | | `sandbox_submit_job` | 依 `mode` 分流——見下 | 直接 run、確認提案,或 replay。 | | `sandbox_submitted_jobs` | `page_size`、`page_token?` | 此 principal 待確認提案(含 `proposal_receipt`)與近期 runs。沒有 output/log/secret/URL。 | | `sandbox_job_status` | `run_id`、`wait_seconds` 0..60、`artifact_page_token?` | 狀態 + **有界、不可信預覽**。 | | `sandbox_cancel_job` | `run_id?` | 取消自己的 run;省略則取消此 principal 待確認提案。Manager 身分**不會**擴大擁有範圍(A-011)。 | 選單項目帶了 REST 選單 job-contract 欄位裡的兩個(見 [Schemas](/zh-TW/reference/schemas.md)):`input_schema`(呼叫 `sandbox_submit_job` 前先驗證 `input`——不合格在提交時會是 422 `input_schema_violation`)與 `secret_slots[]`(逐 slot 的 borrower/owner 履行狀態)。Agent 選單**完全不帶** `runtime_contract`——這個欄位只存在於 `GET /tasks/{id}/versions/{vid}`(owner 看得到真實 `work_command`)與 `POST .../input-preview`(同樣);**REST** 的 `GET .../menu` 自己的 `runtime_contract` 不論呼叫者是誰都是預設 fallback,所以它也不是能拿到真值的來源(見 [Schemas](/zh-TW/reference/schemas.md))。 工具結果是 JSON 字串。錯誤: ```json { "status": "error", "error": "", "message": "..." } ``` | `error` | 何時 | | --- | --- | | `unauthorized` | 重新授權/擁有權失敗(`SandboxAgentAuthError`),或提交遇到 401/403 拒絕(例如 Command 輸出的規則) | | `validation_error` | 工具參數不合法,或提交遇到 400/409/422 拒絕 | | `forged_receipt` | 收據不是簽過名的多段格式(必須含 4 個點) | | `not_found` | 過期、別人的、隱藏、同一輪,或已用過的收據 | | `confirmation_rejected` | 其他閉形式確認失敗 | | `already_committed` | 此提案已經變成 run | | `proposal_not_pending` | 狀態不是 `prepared` | | `same_turn` | 確認的 HumanMessage 就是起源那一輪 | | `not_later` | 確認訊息不是嚴格較晚 | | `receipt_reused` | 這一輪確認已經 commit 過另一個提案 | | `retryable` | 暫時性 commit 失敗;提案留在 `prepared`,收據可重用 | | `confirmation_input_unavailable` | 後一輪已驗證,但耐久 input 重放失敗(沒抓到/R2 讀不到/digest 不符/不是 JSON)。**不建 run;提案留在 `prepared`。** Agent 可再 `mode=commit`。 | | `provider_unavailable` | 控制面不可用,或工具沒有對應到其他代碼的提交拒絕——包括佇列的 429(`sandbox_queued_run_limit`,以及地端部署的 `queue_full`)。什麼都沒排入 | | `internal_error` | 未預期;message 是泛用句 | ## `sandbox_submit_job` ### `mode="request"` 必填:`task_version_id`、`input`(canonical JSON)。可選:`timeout_seconds`。**禁止:** `proposal_receipt`。 - 版本 `requires_confirmation=false`、沒有宣告表格 writeback 的 `output_policy`,且 principal 可執行 → 伺服器提交 run(`submission_source="agent"`)並回 run。 - 版本帶 Command 輸出政策(`custom_table_command`,v5.21.0)且 `requires_confirmation=false`,同樣直接提交:伺服器先暫存確切的輸入位元組再提交,不經提案回合。若 Command 輸出的規則拒絕這次提交,403(例如 principal 是外部通道的 client、不是使用者)會以 `unauthorized` 回來,422(Command 不存在或不符)是 `validation_error`,503 的金鑰錯誤是 `provider_unavailable`。見[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md)。 - `requires_confirmation=true`——或版本宣告了表格 writeback 的 `output_policy`(`custom_table_writeback`,一律視為需確認等級)→ 伺服器建立 `prepared` 提案,**取代**此 principal 其他待處理提案,並回: ```json { "kind": "prepared", "proposal_id": "...", "proposal_receipt": "", "summary": "...", "requires_confirmation": true, "task_id": "...", "task_version_id": "..." } ``` `proposal_receipt` 對模型是不透明的。只傳一個裸 id 會被判 `forged_receipt`。 ### `mode="commit"` 必填:只有 `proposal_receipt`。**禁止:** `task_version_id`、`input`、`timeout_seconds`、region、profile、cost、ENV、version。 必須是**之後的 HumanMessage 輪次**。Factory 在帶外附上新的 signed turn receipt。同一輪、隱藏、較舊、別人的、偽造、重用的收據一律失敗(`not_found`/`forged_receipt`)。 成功:`{ "status": "ok", "kind": "submitted", "run_id": "...", "proposal_id": "...", "submission_source": "agent", "task_id": "...", "task_version_id": "...", "run_status": "queued" }`。 回應遺失、同一組 winning pair 重送:`{ "kind": "replay", "run_id": "..." }`。 準備時拿到的 `proposal_receipt` **不一定**能活過後面幾輪。`sandbox_submitted_jobs` 會為每個待確認提案**重簽**一張收據——`mode=commit` 用那張,不要用你快取的準備收據。 若確認已驗證、但原始 input 無法從耐久儲存重放,commit 回 `confirmation_input_unavailable`,提案留著。不要自己編 input。 ## 前端該知道的 TTL | 常數 | 值 | 意義 | | --- | --- | --- | | `PROPOSAL_TTL_SECONDS` | 1800(30 分) | 已準備提案過期 → `expired`。 | | `TURN_RECEIPT_TTL_SECONDS` | 600(10 分) | 簽過名的 HumanMessage 收據壽命。 | | `MAX_STATUS_WAIT_SECONDS` | 60 | `sandbox_job_status.wait_seconds` 上限。**不**套用到 REST 輪詢。 | 對話裡的自然語言「好」只決定 agent 要不要呼叫 `mode=commit`。伺服器仍要求另一則之後的可信 HumanMessage 收據。 ## `sandbox_job_status` 預覽(T-002) 這是**唯一**把顧客 output/log 位元組交給模型的面。REST `GET .../content` 只有旗標。 | 預覽 | 上限 | 形狀 | | --- | --- | --- | | Output | ≤64 KiB 前綴(`MAX_OUTPUT_PREVIEW_UTF8_BYTES`) | `{ untrusted: true, label: "untrusted_customer_output", truncated, byte_length, text, content_kind: "customer_data", not_instructions: true }` | | Log | ≤16 KiB head+tail(`MAX_LOG_PREVIEW_UTF8_BYTES`) | 同樣框線,欄位是 `head`/`tail`,`label: "untrusted_customer_log"` | | Artifacts | ≤20 筆 metadata(`ARTIFACT_LIST_PAGE_SIZE`) | `id`, `relative_path`, `byte_size`, `digest`, `declared_content_type`。**沒有** signed URL/object key。 | R2 不可用時 lane 退化成 `{ available: bool }` 佔位。`job_status` 仍回狀態。把巢狀顧客文字當資料,永遠不當指令。 `wait_seconds` 與 `artifact_page_token` **兩個都是 v1 no-op**(`db_lane.job_status` 同一行 `del wait_seconds, artifact_page_token`)。產出物預覽永遠只回前 ≤20 筆、沒有翻頁——回應裡的 `truncated`/`total_reported` 是唯一能看出有列被截掉的訊號。不要等這個工具。前端仍輪詢 `GET /chatrooms/{id}/runs/{run_id}` 直到 `terminal_at`。 ## 前端要做的事 1. 提供房間設定,對 `PATCH /private/chatrooms/setting/jobs/{id}` 在 `jobs` 裡放 `"sandbox"`。用 `GET /private/chatrooms/by_job/sandbox` 列這些房間。 2. 在房間設定列出 `GET .../granted-jobs`,讓房間管理者啟用/停用。在任務編輯器顯示 `requires_confirmation`(不要把 `agent_enabled` 當 Agent 開關)。 3. 當 assistant 輪出現 `prepared` 提案,渲染 `summary`,等下一則人類訊息。**不要**做一個打 sandbox 端點的 REST 確認/拒絕按鈕——沒有那個端點。 4. Commit 之後拿 `run_id`,走 [執行 API](/zh-TW/flows/run-and-results.md) 輪詢。 5. `sandbox_submitted_jobs` 只給 agent 用來找回收據。沒有 REST 提案列表。 人類使用者仍用 `POST /chatrooms/{id}/runs` 提交。兩條路最後都是同一個 `SandboxRunDetailResponse`(`submission_source` 不同)。 ## 相關 - [Agent 確認流程](/zh-TW/flows/agent-confirm.md) — propose/commit 狀態機。 - [自訂表格 trigger](/zh-TW/flows/custom-table-trigger.md) — 不能對準 `requires_confirmation=true`。 - [角色與權限](/zh-TW/concepts/personas.md) — 即使使用者是 manager,agent 的 cancel/list/status 仍綁行為主體。 # API 端點目錄 除非另註,路徑省略前綴 `{BASE}/private/module/sandbox`。租戶路由經 `get_sandbox_principal`;未授權回 404(例外:不能擁有的 department/company 建立是 403 `owner_scope_forbidden`;`owner_scope=chatroom` 是 422)。完整欄位:[請求與回應欄位](/zh-TW/reference/schemas.md)。OpenAPI 雙 tag:總 tag `Module: Sandbox` **加上** `Module: Sandbox - Settings`/`Environments`/`Tasks`/`Bindings`/`Secrets`/`Runs`。 ## 公開確認目錄(沒有 `/private` 前綴,不必登入) | 方法 + 路徑 | 用途 | 回應 | | --- | --- | --- | | `GET /public/info/sandbox/regions` | 部署所用 provider 的封閉 region 集合,給下拉/`allowed_regions`/`build_region`:託管服務是雲端區域(`tier` 1 = 較便宜/優先,2 = 溢流);地端部署只有 `onprem`(`tier` 3 = 固定容量,以名目會計費率計價) | `SandboxPublicRegionListResponse` `{ items: [{ code, display_name, location, tier }] }`(`tier` 1..3) | | `GET /public/info/sandbox/profiles` | 封閉 resource-profile 集合。只有 `tenant_selectable` 才能送 `id` | `SandboxPublicProfileListResponse` `{ items: [{ id, display_name, vcpu, provider_memory_mib, customer_memory_mib, workspace_mib, tenant_selectable, is_default }] }` | `xlarge` 的 `tenant_selectable=false`。Region 再與 `GET /settings.allowed_regions` 取交集。營運端 live 集合是 `/root/sandbox/regions`。 | 方法 + 路徑 | 用途 | 回應 | | --- | --- | --- | | `GET /public/info/model_key/public_key` | 給 client 端加密 secret/API key 用的 RSA 公鑰(PEM) | `ModelKeyPublicKeyResponse` `{ public_key_pem, algorithm }` | 用來加密 secret 寫入的 `encrypted_value`——見 [Secret 與授權](/zh-TW/concepts/secrets.md)。跟 model-catalog 的 `encrypted_api_key` 流程共用(不是 sandbox 專屬)。503 代表伺服器沒設定金鑰對(加密寫入會回 422 `sandbox_transit_keypair_missing`,絕不會退回明文)。 ## 永久公開產出物連結(沒有 `/private` 前綴,不必登入) | 方法 + 路徑 | 用途 | 回應 | | --- | --- | --- | | `GET /public/sandbox/artifacts/{token}` | 串流由下方 public-link 路由鑄出的 run log/output 物件,或單一產出物檔案的位元組區間(驗 digest) | 原始位元組(`Content-Disposition: attachment`、`X-Content-Type-Options: nosniff`) | 沒有 TTL。撤銷後,或底層物件離開 `deletion_state=active` 後,一律 404。見 [內容、Digest 與下載](/zh-TW/concepts/content-and-downloads.md)。 ## 單次 run 的 Command 輸出端點(沒有 `/private` 前綴,Bearer 憑證) | 方法 + 路徑 | 用途 | 請求 | 回應 | | --- | --- | --- | --- | | `POST /public/module/custom_tables/callback/command-output/{token_id}` | Command 輸出 run 的 work script 送出它唯一宣告的自訂表格 Command 的輸入。`Authorization: Bearer `;兩者都來自 `TEAMSYNC_CT_WRITEBACK_URL`/`TEAMSYNC_CT_WRITEBACK_TOKEN`。不是前端呼叫。 | `SandboxCommandOutputRequest` `{ inputs }` | Command 執行回應(省略 `null` 欄位)。錯誤:404/409 `output_run_unavailable`、409 `output_command_stale`、409 `output_command_conflict`、422 `output_schema_violation`、403 `output_policy_author_denied` | 掛在自訂表格 callback 路由底下,不是 Sandbox 的路由。見[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md)。 ## 公司設定 | 方法 + 路徑 | 用途 | 認證 | 請求 | 回應 | | --- | --- | --- | --- | --- | | `GET /settings` | 讀公司 sandbox 設定 | company manager | — | `SandboxCompanySettingsResponse` | | `PUT /settings` | 更新保留天數 + 允許區域 | company manager | `SandboxCompanySettingsUpdate` | `SandboxCompanySettingsResponse` | `SandboxCompanySettingsUpdate`(可寫):`run_content_retention_days`(1..365)、`unreferenced_image_retention_days`(1..90)、`allowed_regions`(≥1)、`policy_version`(樂觀鎖 CAS)。CAS 不符 → 409。GET 與 PUT 皆需 **COMPANY_MANAGER**。 `SandboxCompanySettingsResponse` 另外回傳(唯讀/衍生):`timeout_ceiling_seconds`、`active_environment_limit`、`capacity_retained_version_limit`、`placement_scope`(固定 `"runtime_execution_only"`)、`does_not_govern[]`(`build`/`validator`/`registry`/`r2`/`log`/`control_plane`)、`residency_guaranteed`(固定 `false`)、`regional_job_capacity_grace_days_fixed`。`allowed_regions` **只管執行期 Job/Execution 放置**,不管建置/validator/registry/R2/log/控制面駐留。 ## 環境 / 版本 / 建置(前綴 `/environments`) **環境權限:**公司擁有環境由公司管理員管理;部門擁有環境可由該部門管理員或公司管理員建立/管理。部門管理員建立時須傳 `owner_scope: "department"` 與該部門的 `owner_id`。Context 上傳、版本、build、重試與 archive 都套用相同 owner-scope 閘門;curated 環境沒有租戶寫入路徑。Metadata 仍可由同公司身分讀取,上傳 session 則要求 owner 管理權。未授權租戶在 router 回 404,受限 key 會先被 auth 拒絕。 | 方法 + 路徑 | 用途 | 請求 | 回應 | | --- | --- | --- | --- | | `GET /environments` | 列環境(含系統策展) | q: `page_size`,`page_token`,`lifecycle` | `SandboxEnvironmentListResponse` | | `POST /environments` | 建環境 | `SandboxEnvironmentCreateRequest` | `SandboxEnvironmentResponse` | | `GET /environments/{id}` | 取環境 | — | `SandboxEnvironmentResponse` | | `PATCH /environments/{id}` | 改名稱/描述 | `SandboxEnvironmentUpdateRequest` | `SandboxEnvironmentResponse` | | `POST /environments/{id}/archive` | 歸檔環境 | — | `SandboxEnvironmentResponse` | | `GET /environments/{id}/versions` | 列版本 | q: `page_size`,`page_token`,`lifecycle` | `SandboxEnvironmentVersionListResponse` | | `GET /environments/{id}/context-uploads` | 列出此環境已完成的 context 上傳,並附由它建出的版本(environment owner-scope manager;q: `page_size`,`page_token`,`lifecycle`;後端 ≥ #1151) | — | `SandboxContextObjectListResponse` | | `POST /environments/{id}/context-uploads` | 初始化建置 context 上傳 | `SandboxContextUploadInitRequest` | `SandboxContextUploadInitResponse` | | `GET /environments/{id}/context-uploads/{sid}` | 輪詢上傳 session | — | `SandboxContextUploadSessionResponse` | | `PUT /environments/{id}/context-uploads/{sid}` | PUT 封存位元組 | raw body + `X-Sandbox-Upload-Capability` | `SandboxContextUploadPutResponse` | | `POST /environments/{id}/context-uploads/{sid}/complete` | 鑄出 `owned_object_id` | `SandboxContextUploadCompleteRequest` | `SandboxContextUploadCompleteResponse` | | `POST /environments/{id}/context-uploads/{sid}/abort` | 丟掉未完成 session | — | `SandboxContextUploadSessionResponse` | | `POST /environments/{id}/versions` | 建不可變版本 | `SandboxEnvironmentVersionCreateRequest` | `SandboxEnvironmentVersionResponse` | | `GET /environments/{id}/versions/{vid}` | 取版本(輪詢 ready) | — | `SandboxEnvironmentVersionResponse` | | `POST /environments/{id}/versions/{vid}/archive` | 歸檔版本 | — | `SandboxEnvironmentVersionResponse` | | `POST /environments/{id}/versions/{vid}/builds` | 佇列一次建置 | `SandboxBuildAttemptCreateRequest` | `SandboxBuildAttemptResponse` | | `GET /environments/{id}/versions/{vid}/builds` | 列建置 | q: `page_size`,`page_token`,`status[]`(可重複 `SandboxBuildAttemptState`) | `SandboxBuildAttemptListResponse` | | `GET /environments/{id}/versions/{vid}/builds/{aid}` | 取建置(輪詢) | — | `SandboxBuildAttemptResponse` | | `POST /environments/{id}/versions/{vid}/builds/{aid}/cancel` | 圍欄未終態建置(`cancel_requested` + 可持久化 cancel intent;輪詢到 `cancelled`) | — | `SandboxBuildAttemptResponse` | | `POST /environments/{id}/versions/{vid}/retry-build` | 重試失敗建置 | — | `SandboxBuildAttemptResponse` | | `POST /environments/{id}/versions/{vid}/retry-provisioning` | 重試區域佈建 | — | `SandboxEnvironmentVersionResponse` | **請求欄位** - `SandboxEnvironmentCreateRequest`:`name`(1–256)、`description`(≤2048)。 - Context 上傳:company manager;策展環境 404。`declared_bytes` 1..1 GiB;`archive_format` `zip`/`tar`/`tar.gz`。PUT 要 `X-Sandbox-Upload-Capability` + `Content-Type: application/octet-stream`。上限:20 slots、20 GiB 宣告、每分鐘 5 次 init。Abort 未完成 session — 漏掉的 slot 會 429 `staging_slot_exhausted`,連 scan report 也卡住。 - Dockerfile `FROM`(compose):任何公開映像都可以,包含 `scratch`——2026-08-25 起沒有固定允許清單(合成後的 layer 仍會檢查平台保留路徑,`error_code=context_invalid`)。compose 仍拒絕:動態 `FROM`、**任何** `# syntax=` 指示行(檔案中任何一行都算)、來自網路來源的 `ADD`、`RUN --network=host`、`type=secret`/`type=ssh` 的 `RUN --mount` 或 `from=` 指到未審查 stage 的 `--mount`、指到未審查 stage 的 `COPY --from`,以及超過 1 MiB 的 Dockerfile。 - `SandboxEnvironmentVersionCreateRequest`:首選 `owned_object_id`(來自 complete)。`source_digest` 可選(`^sha256:[0-9a-f]{64}$`),但**單獨**送時後端只驗格式,不會確認它對應任何已完成的 context 物件——會建出一個沒有綁定封存的版本,後續建置仍會被受理並保留預算,直到建置時才失敗;請一律送 `owned_object_id`。兩個都送必須相符。`source_ref`(≤1024)、`resource_profile`(僅租戶可選;不是 `xlarge`)。 - `SandboxBuildAttemptCreateRequest`:`build_region`(`SandboxRegionCode` 列舉,可省略;託管雲端預設 `asia-east1`,地端部署預設 `onprem`;集合見 `GET /public/info/sandbox/regions`)、`build_profile`(`SandboxBuildProfile` 列舉,可省略,預設 `standard`)。沒有 ≤64/≤32 的長度限制——送不在列舉內的字串是 422 enum 錯誤。列舉內但該部署目錄沒公開的值(雲端的 `onprem`、地端的任何雲端代碼)是 422 `build_region_unavailable`;地端在已有 active 區域列、但指定的不在其中時也回同一個 422。 **回應重點欄位** - `SandboxEnvironmentVersionResponse`:`id`、`environment_id`、`version_number`、`state`、`security_state`、`resource_profile`、`capacity_retained`、`ready_at?`…;擁有者專屬:`source_digest?`、`source_ref?`(借用視圖省略)。 - `SandboxBuildAttemptResponse`:`id`、`environment_version_id`、`attempt_number`、`status`、`phase`、`terminal_class`、`stage_code`、`error_code`、`error_detail`(≤128 字元,`error_code` 底下具體是哪條規則,例如 `reserved_path_or_hostile_entry`)、`build_region`、`build_profile`、`report_total_bytes`(**無任何憑證/金鑰**)。常見 `error_code`:`context_invalid`、`dockerfile_exit`、`policy_reject`、`identity_config`、`tenant_cancel`(CVE 掃描結果自 2026-08-25 起只是參考——`vulnerability_reject` 已經不會再出現)。 ## 任務 / 版本 / 分享(前綴 `/tasks`) | 方法 + 路徑 | 用途 | 請求 | 回應 | | --- | --- | --- | --- | | `GET /tasks` | 列任務 | q: `page_size`,`page_token`,`lifecycle` | `SandboxTaskListResponse` | | `POST /tasks` | 建目錄任務(`department` \| `company`) | `SandboxTaskCreateRequest` | `SandboxTaskResponse` | | `GET /tasks/{id}/runs` | Company-manager 歷史(offset/limit) | q: `offset`,`limit`,`order`、篩選 | `SandboxTaskRunHistoryItem[]` | | `GET /tasks/{id}/runs/numOfData` | 該歷史的筆數 | 同一組篩選 | `NumOfData` `{ num }` | | `GET /tasks/{id}` | 取任務 | — | `SandboxTaskResponse` | | `PATCH /tasks/{id}` | 只改 `name`/`description`/`agent_enabled`(owner-manager;已歸檔 → 409 `task_archived`;空 body → 422 `no_fields`;後端 ≥ #1151) | `SandboxTaskUpdateRequest` | `SandboxTaskResponse` | | `POST /tasks/{id}/archive` | 整個任務歸檔:版本全部歸檔、聊天室 binding 撤銷(排隊中的 run 取消)、分享紀錄保留;再呼叫一次 409 `already_archived`(後端 ≥ #1151) | — | `SandboxTaskResponse` | | `GET /tasks/{id}/versions` | 列版本 | q: 分頁,`lifecycle` | `SandboxTaskVersionListResponse` | | `POST /tasks/{id}/script-uploads` | 上傳 UTF-8 腳本檔(≤1 MiB),得到 24 小時可重用的 handle,供 `work_script_id`/`startup_script_id`(版本建立前) | multipart `file` | `SandboxScriptUploadResponse` | | `POST /tasks/{id}/versions` | 建 draft 版本 | `SandboxTaskVersionCreateRequest` | `SandboxTaskVersionResponse` | | `GET /tasks/{id}/versions/{vid}` | 取版本 | — | `SandboxTaskVersionResponse` | | `PATCH /tasks/{id}/versions/{vid}` | 改 draft 版本 | `SandboxTaskVersionUpdateRequest` | `SandboxTaskVersionResponse` | | `POST /tasks/{id}/versions/{vid}/script-file?target=startup\|work` | 上傳 UTF-8 腳本檔(≤1 MiB)進 draft 的 `startup_script`/`work_script` | multipart `file` | `SandboxTaskVersionResponse` | | `POST /tasks/{id}/versions/{vid}/input-preview` | Dry-run 一個候選 `input`:精確位元組、digest、runtime 契約、schema 驗證結果——不會派工 | `SandboxInputPreviewRequest` `{ input }` | `SandboxInputPreviewResponse` | | `POST /tasks/{id}/versions/{vid}/publish` | 發布版本 | — | `SandboxTaskVersionResponse` | | `POST /tasks/{id}/versions/{vid}/archive` | 歸檔版本 | — | `SandboxTaskVersionResponse` | | `GET /tasks/{id}/shares` | 列分享(預設 `lifecycle=all`) | q: 分頁,`lifecycle` | `SandboxShareGrantListResponse` | | `POST /tasks/{id}/shares` | 分享給部門/公司/聊天室 | `SandboxShareCreateRequest` | `SandboxShareGrantResponse` | | `POST /tasks/{id}/shares/{gid}/revoke` | 撤銷分享 | `SandboxShareRevokeRequest` | `SandboxShareGrantResponse` | **請求欄位** - `SandboxTaskCreateRequest`:`name`(1–256)、`description`(≤2048)、`owner_scope`(**只有 `department`/`company`**;`chatroom` → 422)、`owner_id`(pattern)、`agent_enabled`(預設 false,**舊旗標**)。 - `SandboxTaskVersionCreateRequest`/`UpdateRequest`(僅 PATCH draft):`environment_version_id`、`startup_script`(≤1MiB)、`work_script`(≤1MiB)、`ordinary_env?`、`input_instructions?`(≤64KiB)、`input_example?`(≤64KiB,若設了 `input_schema` 必須符合它——不符合是這次草稿建立/更新呼叫的 422 `job_contract_invalid`,發布時絕不會出現)、`input_schema?`(≤64KiB,JSON Schema draft 2020-12;每次 run 提交都會強制檢查,不符合是 422 `input_schema_violation`)、`work_command?`(≤4096 UTF-8 位元組——寫進 `/workspace/driver.sh` 的 driver 指令列,在 `startup.sh` 之後 source;空白 → `. /workspace/work.sh`;含 NUL 或超長 → 422 `job_contract_invalid`)、`timeout_seconds`(預設1800,最大 604680)、`secret_slot_declarations?`(`{name}` 或字串的 list)、`requires_confirmation`(預設 false)、`output_policy?`(以 `kind` 區分的聯集:`{ kind: "custom_table_writeback", table_id, allowed_ops }`,或自 v5.21.0 起的 `{ kind: "custom_table_command", command_id, chatroom_id, input_schema }`;只有部門擁有)。 - 分享 vs 啟用 vs 選單:[任務受眾](/zh-TW/concepts/job-audience.md)。歷史篩選:`status`、`method`、`executor_kind`、`source_kind`、`department_id`、`chatroom_id`。`limit` 預設 10,最大 100。 - `SandboxShareCreateRequest`:`target_kind`、`target_id`、`secret_slot_policies?`(`{slot_name: "borrower"|"owner"|"owner_overridable"}`,≤100 筆,只能在建立時設定——見 [Secrets](/zh-TW/concepts/secrets.md))。`SandboxShareRevokeRequest`:`reason`(≤512)。 - `SandboxInputPreviewRequest`:`{ input: <任意 JSON> }`。 **回應重點**:`SandboxTaskVersionResponse` 含必填的 `source_visible`、`state`、`content_state`(`active`/`scrubbed`)、`requires_confirmation`、`secret_slot_names[]`、`canonical_digest`、`content_hash`、`update_available`;擁有者專屬:`startup_script?`、`work_script?`、`work_command?`、`ordinary_env?`、`secret_slot_declarations?`、`output_policy?`(借用視圖省略;借用者的 `work_command` 是 `null`)。`GET /tasks` 會過濾隱藏的 quick-run 父任務。`SandboxShareGrantResponse` 也帶 `secret_slot_policies?`(owner 與 borrower 視圖都看得到——政策名稱不是機密)。`SandboxInputPreviewResponse`:`input_file_content`(`$TEAMSYNC_INPUTS_FILE` 的精確位元組)、`input_digest`、`runtime_contract`(見下)、`schema_valid`、`schema_errors[]`。 ## 綁定(前綴 `/bindings`) | 方法 + 路徑 | 用途 | 請求 | 回應 | | --- | --- | --- | --- | | `POST /bindings` | 把已發布版本釘進聊天室 | `SandboxBindingCreateRequest` | `SandboxBindingResponse` | | `GET /bindings/{id}` | 取綁定 | — | `SandboxBindingResponse` | | `POST /bindings/{id}/accept-version` | 明確升級到新版本 | `SandboxBindingAcceptVersionRequest` | `SandboxBindingResponse` | | `POST /bindings/{id}/revoke` | 撤銷綁定 | `SandboxBindingRevokeRequest` | `SandboxBindingResponse` | `SandboxBindingCreateRequest`:`chatroom_id`、`task_id`、`task_version_id`。與 granted-jobs 啟用同一釘選。首選 `POST /chatrooms/{id}/granted-jobs/{task_id}/enable`。**沒有 `GET /bindings` 列表。** 房間管理者用 `GET /chatrooms/{id}/granted-jobs` 列 grant。Agent/UI 目錄是 `GET /chatrooms/{id}/menu`(已授予 **且** 已啟用)。 ## Secret / 授權(路徑 `/secrets*`、`/secret-approvals*`、`/shared-task-secret-approvals*`) 需 consumer-scope manager;建立/輪替/撤銷要帶 `Idempotency-Key`;值永不回顯。 | 方法 + 路徑 | 用途 | 請求 | 回應 | | --- | --- | --- | --- | | `POST /secrets` | 建立 secret 值 | `SandboxSecretWriteRequest` | `SandboxSecretOperationResponse` | | `POST /secrets/rotate` | 輪替值 | `SandboxSecretWriteRequest` | `SandboxSecretOperationResponse` | | `POST /secrets/revoke` | 撤銷值 | `SandboxSecretRevokeRequest` | `SandboxSecretOperationResponse` | | `GET /secrets/{id}` | 取遮蔽 binding | — | `SandboxSecretBindingResponse` | | `POST /secret-approvals` | 借用授權(精確 digest) | `SandboxSecretApprovalCreateRequest` | `SandboxSecretApprovalResponse` | | `POST /secret-approvals/{id}/revoke` | 撤銷授權 | `SandboxSecretApprovalRevokeRequest` | `SandboxSecretApprovalResponse` | | `POST /shared-task-secret-approvals` | 借用授權別名(同 `POST /secret-approvals`) | `SandboxSecretApprovalCreateRequest` | `SandboxSecretApprovalResponse` | | `POST /shared-task-secret-approvals/{id}/revoke` | 撤銷授權別名(同 `POST /secret-approvals/{id}/revoke`) | `SandboxSecretApprovalRevokeRequest` | `SandboxSecretApprovalResponse` | `SandboxSecretWriteRequest`:`consumer_scope`、`consumer_id`、`task_id`、`slot_name`(`^[A-Za-z_][A-Za-z0-9_]{0,127}$`)、`encrypted_value`(**必填**;`SandboxEncryptedValue` `{ encrypted_key, iv, ciphertext }`,全都 base64——混合 AES-256-GCM + RSA-OAEP-SHA256 傳輸信封;解密後的值 1..64KiB,不記錄)。明文 `value` 欄位一律拒絕——加密流程與四種可分辨的 422 slug 見 [Secrets](/zh-TW/concepts/secrets.md)。`SandboxSecretApprovalCreateRequest`:`consumer_scope`、`consumer_id`、`task_id`、`task_version_id`、`task_version_digest`(`sha256:`)、`slot_names`(1..100——**理論上**在共享 secret slot 政策下必須剛好等於 borrower 解析出的 slot 子集,但這條路由本身不檢查;不符只會在之後認領時才被抓到,讓 run 被 `secret_approval_required` 拒絕——見 [Secrets](/zh-TW/concepts/secrets.md))、`risk_accepted: true`(必為 true)。digest 不符 → 409。binding/approval 沒有列表端點。 ## 執行(路徑帶 `/chatrooms/{chatroom_id}/...`) | 方法 + 路徑 | 用途 | 受限 key | 請求 | 回應 | | --- | --- | --- | --- | --- | | `GET /chatrooms/{cid}/menu` | 已授予 **且** 已啟用的目錄(每個呼叫者同一份) | ✅ | q: `page_size`,`page_token`(預設50) | `SandboxMenuResponse` | | `GET /chatrooms/{cid}/granted-jobs` | Grant + 房間啟用開關 | ❌ 405 | — | `SandboxGrantedJobListResponse` | | `POST /chatrooms/{cid}/granted-jobs/{tid}/enable` | 啟用給 Agent/選單 | ❌ 405 | `SandboxGrantedJobEnableRequest`(`task_version_id?`) | `SandboxGrantedJobItemResponse` | | `POST /chatrooms/{cid}/granted-jobs/{tid}/disable` | 從 Agent/選單拿掉;share 還在 | ❌ 405 | — | `SandboxGrantedJobItemResponse` | | `POST /chatrooms/{cid}/runs` | 提交手動執行 | ✅ | `SandboxManualRunSubmitRequest` + `Idempotency-Key` | `SandboxRunDetailResponse` | | `GET /chatrooms/{cid}/runs` | 列 run(受限只見自己) | ✅ | q: `page_size`,`page_token`,`status[]` | `SandboxRunListResponse` | | `GET /chatrooms/{cid}/runs/{rid}` | 取 run 狀態(輪詢) | ✅ | — | `SandboxRunDetailResponse` | | `GET /chatrooms/{cid}/runs/{rid}/content` | 內容旗標(無本體) | ✅ | — | `SandboxRunContentResponse` | | `GET /chatrooms/{cid}/runs/{rid}/artifacts` | 列產出物 | ✅ | q: `page_size`,`page_token`,`lifecycle` | `SandboxRunArtifactListResponse` | | `POST /chatrooms/{cid}/runs/{rid}/artifacts/{aid}/download` | 授權並發下載 capability(沒有兌換路由——要交出檔案請用下面的 public-link) | ✅ | — | `SandboxArtifactDownloadCapabilityResponse` | | `POST /chatrooms/{cid}/runs/{rid}/artifacts/{aid}/public-link` | 為**單一**產出物檔案鑄永久公開連結(冪等;後端 ≥ #1149) | ❌ 404 | — | `SandboxArtifactPublicLinkResponse` | | `DELETE /chatrooms/{cid}/runs/{rid}/artifacts/{aid}/public-link` | 撤銷該連結(冪等;下次鑄造會拿到新 token) | ❌ 404 | — | `SandboxArtifactPublicLinkResponse` | | `POST /chatrooms/{cid}/runs/{rid}/{log\|output}/public-link` | 鑄一個永久公開下載連結(冪等) | ❌ 405 | — | `SandboxPublicLinkResponse` | | `DELETE /chatrooms/{cid}/runs/{rid}/{log\|output}/public-link` | 撤銷公開連結(冪等;下次鑄造會拿到新 token) | ❌ 405 | — | `SandboxPublicLinkResponse` | | `POST /chatrooms/{cid}/runs/{rid}/cancel` | 取消你讀得到的 run(`can_cancel_run == can_read_run`)。受限金鑰:只有自己的 | ✅ | — | `SandboxRunDetailResponse` | | `POST /chatrooms/{cid}/runs/{rid}/retry` | 以新身分重試終態 run(重放保留的 input,也可覆寫——見下) | ❌ 405 | `Idempotency-Key` | `SandboxRunDetailResponse` | | `POST /chatrooms/{cid}/quick-run` | manager-only 原子 quick run | ❌ 403 | `SandboxQuickRunRequest` + `Idempotency-Key` | `SandboxQuickRunResponse` | 受限金鑰欄的 `❌ 405` 來自 scope allowlist(路由不在受限金鑰的允許清單),`quick-run` 的 `❌ 403` 來自 denied-path 規則——沒有任何一條會回 404。這些是金鑰已涵蓋該聊天室時的結果;金鑰 `allowed_chatrooms` 不含 `{cid}` 時,任何路由都先回 403。 **請求欄位** - `SandboxManualRunSubmitRequest`:`task_version_id`、`input`(canonical JSON——若版本宣告了 `input_schema` 且不符合,會在派工前回 422 `input_schema_violation`)、`timeout_seconds?`。手動 REST 需要 live share + 成員資格;**不要求啟用**。Agent/選單需要 granted+enabled。 - Retry 保留精確的任務版本 pin,並建立新的 run 身分。省略 body、送 `{}` 或 `{"input": null}` 會逐位元重放保留的來源 input;非 null 的 `input` 會覆寫參數,並依一般 input 限制驗證與暫存。來源 input 不存在、已退役、無法讀取或 digest 不符時,重放回 409 `retry_input_unavailable`;請改傳明確 input。必須帶 `Idempotency-Key`;來源尚未終態時回 409 `retry_not_eligible`。受限 Sandbox key 不能 retry:允許房間的請求由 route allowlist 回 405,超出 scope 的房間可能更早回 403。 - `SandboxQuickRunRequest`:`name`、`environment_version_id`、`startup_script`、`work_script`、`ordinary_env`、`input`、`timeout_seconds`(必填)。`input` 跟手動提交一樣會暫存(v5.10.11),所以 Quick Run 也可能回 422 `input_not_stageable`/503 `input_staging_unavailable`。 - 提交、重試與 Quick Run 把 **429** 宣告為 `SandboxQueuedRunLimitErrorResponse`(`sandbox_queued_run_limit`)或 `SandboxQueueFullErrorResponse`(`queue_full`,僅地端,帶 `Retry-After`)。什麼都沒排入佇列。見[執行與取回結果](/zh-TW/flows/run-and-results.md)。 - 選單 `page_size` 預設 **50**;後端 ≥ #1149 起 `next_page_token` 是真的游標(用 `page_token` 帶回)。items 還含 `task_id`、`task_name`、`task_version_digest`、`input_schema?`、`secret_slots[]`(逐 slot 的 `{name, policy, borrower_bound, owner_bound, effective_source}`)、`runtime_contract?`、`secret_use_risk_warning?`。Granted-jobs items 也帶 `secret_slots[]`。 - run 上的 `submission_source`:`rest`/`agent`/`quick_run`/`custom_table_trigger`。 **回應重點** - `SandboxRunDetailResponse`:`id`、`company_id`、`chatroom_id`、`status`、`resource_profile`、`principal_type`、`principal_id`、`auth_method`、`task_id`、`task_version_id`、`timeout_seconds`、`submission_source`、`input_digest`、`retry_of_run_id`、`error_code`、`queue_position?`、`eta_seconds?`(v5.10.11;託管雲端一律 `null`)、`queued_at?`、`terminal_at?`、`secret_slot_provenance?`(`{slot_name: {resolved_via, consumer_scope}}`,已遮蔽——run 沒有快照時是 `null`)。(`cancel_requested_at` 有持久化,不回傳。`share_grant_id` 在提交時內部凍結,但**不在**這個回應上。) - `SandboxRunContentResponse`:`state`、`has_input`、`has_output`、`has_log`、`has_artifact_bundle`、`input_digest`(**只有旗標** — REST 沒有 log/output 位元組)。 - `SandboxArtifactDownloadCapabilityResponse`:`artifact_id`、`run_id`、`company_id`、`capability_token`、`expires_at`(**5 分鐘**)、`content_type`、`content_disposition`、`x_content_type_options: nosniff`(**無原始 key/URL**,也沒有租戶兌換路由 — 見 [下載](/zh-TW/concepts/content-and-downloads.md))。 - `SandboxPublicLinkResponse`:`run_id`、`object_kind`(`log`/`output`)、`url`(永久,沒有 TTL)、`token`、`revoked`、`created_at?`。見 [下載](/zh-TW/concepts/content-and-downloads.md)。 ## 不對前端開放(僅供理解) - `/sandbox-control/*`:系統對系統 OIDC + capability,`include_in_schema=False`。人不要呼叫。地端部署另外掛載 executor 的 work-lease 路由 `/sandbox-control/onprem/work/*`(以 executor token 驗證;託管雲端沒有)。 - `/root/sandbox/*`:營運/root,不是前端——包括工作佇列檢視 `GET /root/sandbox/queue`。見 [營運與 Root](/zh-TW/concepts/operators.md)。 > 欄位或路徑若與後端不符,以 `src/routers/private/modules/sandbox/` 與 `src/schemas/sandbox.py` 為準。 ## 能力與管理清單(v5.10.0) 下列路徑共用 private Sandbox base。授權、回應欄位與 UI 串接請見[能力與管理查詢](/zh-TW/concepts/integration-contract.md)。 | Method | Path | 用途 | | --- | --- | --- | | GET | `/me` | 當前使用者能力提示,本身不授予權限 | | GET | `/runs` | 依可見範圍篩選的跨聊天室歷史、total 與 keyset 分頁 | | GET | `/tasks/{task_id}/bindings` | 任務採用歷史;非 owner 只看可管理房間 | | GET | `/chatrooms/{chatroom_id}/bindings` | 房間管理員的 binding 歷史,可要求 retired 列 | | GET | `/tasks/{task_id}/secrets` | 縮限到可管理 consumer scope 的遮罩 binding | | GET | `/tasks/{task_id}/secret-approvals` | 可管理 consumer scope 的精確版本核准 metadata | | GET | `/secrets` | 以必填 consumer_scope、consumer_id 查找可管理的 binding | # 狀態列舉值 以下字串值取自 `src/schemas/enums.py`(除另註明)。前端渲染或條件判斷時,以這些精確字面值為準。 ## 執行 `SandboxRunState` `queued`、`starting`、`running`、`cancel_requested`、`completed`、`customer_failed`、`platform_failed`、`cancelled` ## 建置嘗試 `SandboxBuildAttemptState` `validation_queued`、`validating`、`build_queued`、`building`、`verifying`、`completed`、`customer_failed`、`platform_failed`、`cancel_requested`、`cancelling`、`cancelled` ## 環境版本 `SandboxEnvironmentVersionState` `draft`、`build_queued`、`building`、`quarantined`、`verifying`、`verified`、`regional_provisioning`、`ready`、`retryable_failed`、`rejected`、`abandoned`、`blocked`、`superseded`、`archived`、`provisioning_blocked` ## 任務版本 `SandboxTaskVersionState` `draft`、`published`、`archived` ## 任務版本 `content_state`(非正式 enum;存成字串) `active`、`scrubbed` — 選單/執行要求 `published` **且** `active`。 ## 環境父層 `state`/binding `state`/分享 `state` - 環境:`active`、`archived` - Binding(活著的選單):`enabled`/`revoked`(外加 `authority_applicable`) - Share grant:`active`/`revoked`(retirement 篩選用 `revoked`) - env/version/task 的 `security_state`:`clear`(預設)/`blocked`(root digest-block) - Secret binding:`pending`/`active`/`revoking`/`revoked` - Shared-secret approval:`active`/`revoked` `SandboxOwnerScope`(儲存/歷史)是 `chatroom`/`department`/`company` — **沒有** user scope。**建立**用 `SandboxTaskCreateOwnerScope`:**只有 `department`/`company`**。`POST /tasks` 送 `chatroom` 是 422。不能擁有的 department/company 建立是 403 `owner_scope_forbidden`。 ## Run `submission_source`(`SandboxRunDetailResponse` 上的字串) `rest`、`agent`、`quick_run`、`custom_table_trigger`(未設時為空字串) ## 聊天室 job `ChatroomJobType.SANDBOX` `"sandbox"` — 在房間載入五個 agent 工具。每個任務的 Agent 曝光是 **已授予 + 房間啟用**,不是 `task.agent_enabled`。 ## 通知中心型別 `sandbox_event`(`NotificationCenterType.SANDBOX_EVENT`) ## 終態類別 `SandboxTerminalClass` / `SandboxBuildTerminalClass` `completed`、`customer_failed`、`platform_failed`、`cancelled` ## 資源規格 `SandboxResourceProfile` `standard`、`performance`、`large`、`xlarge` — 租戶建立版本只能送前三個(`TENANT_SELECTABLE_PROFILES`)。確認集合:`GET /public/info/sandbox/profiles`。 ## 區域 `SandboxRegionCode` `asia-east1`、`asia-northeast1`、`asia-northeast2`、`asia-south1`、`asia-southeast3`、`asia-east2`、`asia-northeast3`、`asia-southeast1`、`asia-southeast2`、`asia-south2`、`onprem`(v5.10.11)。`onprem` 是地端部署唯一的區域:託管雲端的目錄永遠不列它,拿它當 `build_region` 會被拒絕(422 `build_region_unavailable`);地端目錄則只列它。確認集合:`GET /public/info/sandbox/regions`(公開 `tier` 1..3)。 ## Context 封存 `SandboxContextArchiveFormat` `zip`、`tar`、`tar.gz` ## 執行歷史篩選 `SandboxRunExecutorKind`:`internal_user`、`external_user`。 `SandboxRunSourceKind`:`chatroom`、`department`、`external_platform`。 `SandboxRunHistoryMethod`:`agent`、`manual`(`rest`/`quick_run`/`custom_table_trigger` 都對到 `manual`)。 ## 建置規格 `SandboxBuildProfile` `standard`、`fast`、`large` ## Scope 類 `SandboxOwnerScope` / `SandboxShareTargetKind` / `SandboxConsumerScope` `chatroom`、`department`、`company` ## 環境擁有者 `SandboxEnvironmentOwnerKind` `system_curated`、`company` ## Principal `SandboxPrincipalType` / 認證 `SandboxAuthMethod` `user`、`social_media_client` / `jwt`、`api_key` ## 生命週期篩選 `SandboxLifecycleFilter` `active`、`retired`、`all`(列表查詢參數;多數預設 `active`,`list_task_shares` 預設 `all`) ## Agent 提案 `SandboxProposalState` `prepared`、`committing`、`committed`、`superseded`、`cancelled`、`expired`、`failed` ## Secret 操作 `SandboxSecretOperationState` `prepared`、`applying`、`reconciling`、`applied`、`revoking`、`terminal` ## 共享 secret slot 政策(share grant 的 `secret_slot_policies` 值) `borrower`(slot 不在 map 裡時的預設)、`owner`、`owner_overridable`。見 [Secret 與授權](/zh-TW/concepts/secrets.md)。 ## Secret slot 履行狀態 `effective_source`(選單/granted-jobs 的 `secret_slots[]`) `borrower`、`owner`、`missing` ## Secret slot 溯源 `resolved_via`(run 詳情的 `secret_slot_provenance`) `borrower`、`owner` ## 公開連結 object kind(`POST/DELETE .../{kind}/public-link`) `log`、`output`——沒有 `artifact`。見 [內容、Digest 與下載](/zh-TW/concepts/content-and-downloads.md)。 ## 通知事件 `SandboxNotificationEvent` `build_ready`、`build_failed`、`build_cancelled`、`curated_environment_published`、`run_terminal`、`security_finding_discovered`、`security_finding_resolved`、`security_finding_reappeared`、`security_finding_blocked`、`security_exception_granted`、`security_exception_revoked`、`security_exception_expired`、`security_remediation_deadline`、`runner_replacement_available`、`runner_handoff_completed`、`runner_handoff_expired`、`runner_handoff_failed`、`image_storage_renewal_blocked`、`image_storage_cleanup_completed`、`run_content_expiring`、`run_content_expired` 這些事件經 **TeamSync 通知中心**以 `type=sandbox_event` 送達;**不取代輪詢**。前端仍以 GET 狀態為準。完整收件人/deep-link 表:[通知](/zh-TW/concepts/notifications.md)。 --- ## 內部物件狀態(間接出現在回應) 取自 `src/components/sandbox/storage.py`。前端最常見的是產出物 `deletion_state` 與 run content `state`。 - **object_kind**:`context`、`input`、`output`、`log`、`artifact_bundle`、`report` - **Owned Object `deletion_state`**:`active`、`delete_pending`、`deleted`、`tombstoned`(非 `active` 的產出物 download → 404) - **上傳模式**:租戶 context 上傳**只有 `single_part`**。`multipart` 仍在控制面。 - **Session 狀態**(租戶 context 上傳):共宣告六個 — `initiating`、`uploading`、`completing`、`completed`、`abort_pending`、`aborted`。租戶單段 context 上傳實際只會出現後五個;`completing` 是**非終態**(complete 已嘗試但驗證失敗,可重試 complete 或 abort),輪詢 `GET .../context-uploads/{sid}` 的判斷不要漏掉。`initiating` 只在控制面/multipart 路徑寫入,租戶輪詢不會看到。 - **Lease 狀態**:`uploading`、`queued`、`active_transferred`、`release_pending`、`released`(控制面;佔槽狀態 `uploading`/`queued`) ## 數值上限(`src/components/sandbox/constants.py`) `MAX_PAGE_SIZE=100`、`DEFAULT_PAGE_SIZE=20`;輸入/腳本 JSON ≤1 MiB;`MAX_JSON_DEPTH=64`、`MAX_JSON_NODES=100000`;env ≤100 名稱、每項 ≤64 KiB;`DEFAULT_WORK_TIMEOUT_SECONDS=1800`、`MAX_CUSTOMER_WORK_TIMEOUT_SECONDS=604680`;`MAX_STATUS_WAIT_SECONDS=60`(僅 agent 工具);`MAX_ARTIFACT_FILES=1000`;`PROPOSAL_TTL_SECONDS=1800`;`TURN_RECEIPT_TTL_SECONDS=600`;`_DOWNLOAD_CAPABILITY_TTL=5min`;上傳 capability 15min;context 封存 ≤1 GiB;20 staging slots/20 GiB/每分鐘 5 次 init;內容保留 1..365 天、映像保留 1..90 天。欄位表:[請求與回應欄位](/zh-TW/reference/schemas.md)。 # 錯誤碼對照表 ## 通則 - **404 = 未授權或不存在**。sandbox 刻意「不洩漏存在性」:沒權限、別家公司的 id、受限 key 動了禁區,全都回 404 而非 403。 - **403 是真實例外,不是預設。** 建立呼叫者不能擁有的 department/company 任務 → `owner_scope_forbidden`。Writeback 發布/提交閘與預算 hold 也是 403。送 `owner_scope=chatroom` 是 **422**,不是 403。 - **422 = 請求驗證**。未知欄位(`extra="forbid"`)、pattern 不符、大小超限、缺必帶 header。預算/hold 定價拒絕也可能是 422。 - **409 = 狀態衝突**。非法狀態轉換、樂觀鎖 CAS 不符、digest 不符、不可重試、版本容量耗盡。 - **413 = body 過大**。提交執行時依 `Content-Length` 先擋。 - **503 = kill switch**。`SANDBOX_ENABLED=false` 時的變更(預設 **true**)。 ## 對照表 | 狀態碼 | 錯誤 / 情境 | 何時發生 | | --- | --- | --- | | 403 | 受限 Sandbox 金鑰/缺聊天室路徑/`invalid scope` | 認證層字串,不是 `{code}`。只發生在 `/private/module/sandbox/environments*`、`/tasks*`(含每任務歷史 `/tasks/{id}/runs`)、`/root*`、`/chatrooms/{id}/quick-run`、路徑裡完全沒有 chatroom 片段的路由(`/settings`、`/bindings*`、`/secrets*`、`/secret-approvals*`),以及路徑上的 chatroom id 不在金鑰的 allowed chatrooms 時。 | | 405 | 受限 Sandbox 金鑰動了 scope allowlist 以外的其他 sandbox 路由 | `granted-jobs`(含 enable/disable)、`runs/{id}/retry`、`runs/{id}/{log\|output}/public-link` 由 scope 檢查直接回 405 Method Not Allowed——不是 403 也不是 404。 | | 403 | `Insufficient permissions.` | settings 與每任務歷史的 `COMPANY_MANAGER` 閘(非 manager JWT)。 | | 404 | 未授權 / 跨公司 / 資源不存在 | sandbox **router 內**的藏存在性預設 | | 404 | 產出物 `deletion_state != active` 的 download | 下載已刪/墓碑產出物 | | 403 | `owner_scope_forbidden` | 建立呼叫者不能擁有的 department/company 任務。這是用 404 藏存在性的例外。 | | 403 | `chatroom_owner_scope_removed` | 若 chatroom 目錄建立繞過 enum 進到 CRUD(HTTP `POST /tasks` 先是 422)。 | | 403 | `output_policy_author_denied` | 表格 writeback:發布者對目標表沒有不受限寫入權。Command 輸出(v5.21.0):撰寫者、發布者或提交者沒通過身分、grant 或範圍檢查;該 Command 不再是帶 `effect_identity` 的 restricted definition-authority Command(發布時是下面的 409);或提交 run 的方式不符合[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md)的規則(什麼都不會排隊)。run 進行中該授權被收回時,command-output 端點也回這個。 | | 403 | `writeback_authority_denied` | 提交者不能鑄 writeback 憑證。 | | 403 | `budget_reservation_exceeded` | 提交執行/佇列建置時,定價 envelope 放不進公司配額;不建 row | | 422 | `idempotency_key_required` | 提交/重試/quick-run/secret 寫入缺 `Idempotency-Key` | | 422 | 欄位驗證 | 未知欄位、`owner_scope=chatroom`(enum)、digest pattern、缺少 `owned_object_id`+`source_digest`、canonical JSON 超限、空 PUT body | | 422 | `output_policy_invalid`/`output_policy_table_not_found`/`output_policy_channel_table` | 草稿/發布時 writeback policy 不合法 | | 422 | `output_policy_governed_table` | 草稿建立/更新:表格 writeback policy 指向 `write_policy` 為 `commands_only` 的表(v5.21.0)。發布與提交 run 時,同樣的狀況是 409 `output_policy_unsatisfiable` | | 422 | `output_policy_command_not_found`/`output_policy_schema_mismatch` | Command 輸出 policy(v5.21.0),草稿建立/更新時,以及提交 run 時再次檢查:你公司裡沒有這個 id 的活 Command,或 `input_schema` 與 Command 的 `inputs` 不同/該 Command 不是寫入型 | | 422 | 預算/hold 定價拒絕 | 提交時定價或 hold 驗證失敗,例如 `no_active_region`、`missing_price`、`invalid_envelope`、`missing_window` | | 422 | `timeout_exceeds_ceiling` | `timeout_seconds` > 公司 `timeout_ceiling_seconds` | | 422 | `job_contract_invalid` | 在草稿建立/更新時(`POST /tasks/{id}/versions` 或 `PATCH .../versions/{vid}`)——發布時絕不會出現:`input_example` 不是合法 JSON、不符合版本自己的 `input_schema`;`work_command` 含 NUL/超過 4096 UTF-8 位元組;或 **`input_schema` 本身不合法**——不是 JSON、不是 JSON object、超過 64 KiB(UTF-8 位元組計,非字元數)、含 NUL、或不是合法的 draft 2020-12 schema。回應的 `detail.errors` 會一次列出所有問題(最多 20 筆) | | 422 | `input_schema_violation` | 提交執行時:`input` 不符合已發布版本的 `input_schema`。在**派工前**就拒絕。只有存好的 schema 本身解析不出來或拿不到時才會 fail-open(放行、記警告) | | 422 | `input_not_stageable` | `POST /chatrooms/{cid}/runs`:input JSON 無法 canonicalise 或寫入耐久儲存。發生在房間/版本授權之前,所以不會建出 run。run input 不再計入公司每分鐘 5 次上傳起始額度(後端 ≥ #1140),連續提交會被接受而不是拒絕。自 v5.10.11 起 `POST /chatrooms/{cid}/quick-run` 也會暫存 input,可能在 manager 檢查之後、建立任何任務或 run 之前回這個 | | 422 | `build_region_unavailable` | `POST .../versions/{vid}/builds`:`build_region` 不在該部署公開的目錄內(託管雲端的 `onprem`;地端部署的任何雲端代碼),或地端已有 active 區域列、但指定的不在其中。什麼都沒排入 | | 422 | `bundle_path_forbidden` | `POST /tasks/{id}/bundle-uploads`:封存檔根目錄含 runner 保留檔名(`startup.sh`、`work.sh`、`driver.sh`、`input.json`)、絕對路徑或磁碟機絕對路徑、含 NUL/反斜線的路徑;訊息會寫出是哪個條目(後端 ≥ #1140) | | 409 | `task_not_shared_to_room` | `POST /chatrooms/{cid}/granted-jobs/{task_id}/enable`:任務存在,但沒有分享給這個聊天室(或其部門/公司);訊息會寫出該呼叫哪條分享 API。#1140 之前是單純的 404 | | 409 | `task_bundle_runner_unsupported` | 發佈或執行**帶 bundle** 的任務版本,但環境的 runner release 未核准(版本上 `runner_release_approved=false`;**後端 ≥ #1162** 直接讀 operator 核准表)。解法:重新排一次環境建置(會帶上目前的 runner),或請 operator `POST /root/sandbox/runner-releases`。照 `runner_release_next_action` 做 | | 422 | `script_too_large`/`script_not_utf8`/`script_contains_nul` | `POST /tasks/{id}/script-uploads` 或 `POST .../versions/{vid}/script-file` 的檔案有問題 | | 422 | `secret_slot_policies_invalid` | `POST /tasks/{id}/shares` 的 `secret_slot_policies` map 格式不對(slot 名不合法、政策值不合法、或超過 100 筆) | | 413 | `request_too_large` | 提交執行 body 依 Content-Length 過大 | | 413 | `context_too_large` | Context PUT body/Content-Length 超過 `declared_bytes` | | 400 | `invalid_content_length` | Content-Length header 不合法 | | 409 | `build_not_eligible` | 版本 state 無法開始建置 | | 409 | `nonterminal_exists` | 此版本已有未終態的建置 attempt | | 409 | `not_cancellable` | 建置已終態。`cancel_requested`/`cancelling` 冪等 | | 409 | `retry_not_eligible` | 重試一個不在 `{completed, customer_failed, platform_failed, cancelled}` 的 run,或 `POST .../versions/{vid}/retry-build` 時版本 state 不是 `retryable_failed` | | 409 | `retry_window_expired` | 建置重試超過 7 天窗 | | 409 | `provisioning_retry_not_eligible` | `POST .../versions/{vid}/retry-provisioning`:版本 state 不在 `regional_provisioning`/`provisioning_blocked`/`ready` | | 409 | `task_version_digest_mismatch` | secret 授權的 digest 與已發布版本不符 | | 409 | `task_version_not_published` | `POST /secret-approvals`(與別名 `/shared-task-secret-approvals`):`task_version_id` 指到的版本存在但不是 published。這道檢查在 digest 比對之前 | | 409 | `share_already_live` | `POST /tasks/{id}/shares`:此 task + target 已經有一筆 live grant。要換 `secret_slot_policies` 必須先 revoke 再重新分享(新 generation) | | 409 | `policy_version_conflict` | `PUT /settings` CAS 不符 | | 409 | `idempotency_conflict` | 同一把 `Idempotency-Key`,不同 request hash | | 409 | `active_environment_limit` | 公司撞上 active 環境上限(預設 30) | | 429 | `sandbox_queued_run_limit` | 提交/重試(以及 Agent 與自訂表格 trigger 路徑)的公司 queued-run 上限:政策預設 100;地端部署取它與主機每公司上限(預設 20)中較小者。Quick Run 不看政策上限,但受地端上限約束。body `detail` = `{ code, message, limit }`(`SandboxQueuedRunLimitErrorResponse`)。什麼都沒排入 | | 429 | `queue_full` | 僅地端:整個 executor fleet 的佇列已達上限(預設 200)。body `detail` = `{ code, message, limit, retry_after_seconds }`(`SandboxQueueFullErrorResponse`),另有 `Retry-After: 60` header。什麼都沒排入——等過這段時間再重試 | | 429 | `staging_slot_exhausted` | 20 個 live context-upload slot 或 20 GiB 宣告 staging。未完成上傳會漏 slot 直到 abort;連 scan report 寫入也會卡住 | | 429 | `upload_rate_limited` | 每公司每分鐘超過 5 次 context-upload init | | 409 | (非法狀態轉換 / CAS) | 發布/歸檔非法轉換 | | 409 | `content_scrubbed_irreversible`/`version_not_runnable` | 提交執行時版本非 published+content active(`_refuse_not_runnable`):`content_state=scrubbed` 回 `content_scrubbed_irreversible`(永久不可執行);其他任何非 `published`+`active` 組合回 `version_not_runnable` | | 409 | `version_allocation_exhausted` | 公司無法再分配一個 capacity-retained 版本(沒有已確認的區域配額/cap 為 0) | | 409 | `capability_fenced` | 上傳 capability 對不上這個 session | | 409 | `output_policy_owner_scope_unsupported` | 公司擁有的任務試圖發布 writeback 或 Command 輸出 policy | | 409 | `output_policy_unsatisfiable` | 發布時,或提交且真的鑄出憑證時,writeback 表已不在/有 channel rule/是 `commands_only`;Command 輸出 policy 則是發布時任何一項 Command 檢查失敗(查找、輸入與模式、restricted definition authority 且有 `effect_identity`)——對發布者與已記錄撰寫者的身分、grant 與範圍檢查仍是 403 `output_policy_author_denied` | | 503 | `sandbox_disabled` | kill switch 下的變更(`SANDBOX_ENABLED=false`;預設 **true**)。`/cancel`、`/download` 仍可用 | | 503 | `sandbox_secret_pepper_missing` / `sandbox_infisical_unconfigured` | secret 寫入時 adapter/pepper 掛了 | | 503 | `capability_unavailable`/`storage_unavailable` | 上傳 capability 或物件儲存掛了 | | 503 | `input_staging_unavailable` | `POST /chatrooms/{cid}/runs`(自 v5.10.11 起還有 `POST /chatrooms/{cid}/quick-run`):暫存 input 的物件儲存不可用。手動提交在房間/版本授權之前暫存,Quick Run 在 manager 檢查之後;兩者都不會建出 run | | 503 | `build_admission_fenced` | `POST .../versions/{vid}/builds` 與 `POST .../versions/{vid}/retry-build`:平台正在做 stage-image rollout,建置 admission 被全域圍欄擋住(數分鐘)。body 是 `{ code, message, retry_after_seconds }`,同值也放在 `Retry-After` header;沒有任何東西被佇列,照 `Retry-After` 重試即可 | | 503 | `sandbox_writeback_key_unavailable` | 宣告了 policy 但 HMAC key 缺失/太短 | ## Secret 傳輸加密錯誤(回應形狀不一樣) `POST /secrets` 與 `POST /secrets/rotate` 用 pydantic model validator 驗證 `encrypted_value`,所以這四個**不是**本頁其他地方那種 `{"detail": {"code": "..."}}` 形狀——它們回的是 FastAPI 標準的驗證錯誤陣列,slug 在 `detail[].type`(也會鏡射進 `detail[].msg`,精確等於 `validation_error:`,`input`/`ctx` 會從回應裡剝除): | Slug(`detail[].type`) | 原因 | | --- | --- | | `sandbox_plaintext_value_rejected` | 送了**非空**的明文 `value` 欄位(不論有沒有一起送 `encrypted_value`);空字串或 null 的 `value` 會被忽略,不算明文 | | `sandbox_encrypted_value_required` | `encrypted_value` 缺失或為 null | | `sandbox_transit_keypair_missing` | 伺服器沒設定解密金鑰對 | | `sandbox_encrypted_value_undecryptable` | 信封形狀合法但解密失敗 | 四個都是 HTTP 422。Client 端加密流程見 [Secret 與授權](/zh-TW/concepts/secrets.md)。 ## 不是 REST 錯誤:`share_grant_revoked` `share_grant_revoked`(409)只會出現在 runner 呼叫的**控制面** manifest 揭露路由上——絕不會出現在 `/private/module/sandbox` 的租戶端點。當一個 owner 出借的 secret pin,其分享 grant 在 run 認領之後、到後續某次 manifest 讀取之間死掉了(撤銷、重新分享成新 generation、或 authority 變動),就會觸發這個。前端不會直接收到這個代碼,只會看到對應的 run 以失敗終結。見 [Secret 與授權](/zh-TW/concepts/secrets.md)。 ## 不在 `/private/module/sandbox` 底下:command-output 端點(v5.21.0) `POST /public/module/custom_tables/callback/command-output/{token_id}` 是 Command 輸出 run 的 work script 拿密封憑證去呼叫的公開端點。它回 `{"detail": {"code": "..."}}`:404 `output_run_unavailable`(憑證不存在,或 Bearer secret 錯誤/缺少)、409 `output_run_unavailable`(憑證已撤銷或過期、run 已不在進行中,或它的任務或版本不再是 active 且已發布)、409 `output_command_stale`、409 `output_command_conflict`、422 `output_schema_violation`、403 `output_policy_author_denied`;body 格式不對或 `token_id` 不是 UUID 時,則是 FastAPI 的 422 validation 陣列,Command 執行本身也可能帶來它自己的錯誤。各代碼的意義見[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md)。 ## Agent 工具錯誤(非 REST) 五個工具回 `{ "status": "error", "error": "", "message": "..." }`: | `error` | 何時 | | --- | --- | | `unauthorized` | 重新授權/擁有權(`SandboxAgentAuthError`),或提交遇到 401/403 拒絕(例如 Command 輸出的規則) | | `validation_error` | 參數不合法,或提交遇到 400/409/422 拒絕 | | `forged_receipt` | 收據不是簽過名的多段格式(必須含 4 個點) | | `not_found` | 過期/別人的/隱藏/同一輪/已用過的收據 | | `confirmation_rejected` | 其他閉形式確認失敗 | | `already_committed` | 提案已經變成 run | | `proposal_not_pending` | 狀態不是 `prepared` | | `same_turn` / `not_later` / `receipt_reused` | 同一輪、不夠晚、或確認輪次已用過 | | `retryable` | 暫時性 commit;提案留在 `prepared` | | `confirmation_input_unavailable` | 確認已驗證;耐久 input 無法重放。不建 run;提案仍 pending | | `provider_unavailable` | 控制面不可用,或工具沒有對應到其他代碼的提交拒絕——包括兩種 429(`sandbox_queued_run_limit`、地端的 `queue_full`) | | `internal_error` | 未預期;泛用訊息 | 見 [Agent toolkit](/zh-TW/reference/agent-tools.md)。 ## 前端處理建議 - **404**:別假設是「不存在」——先確認權限、公司、key scope、id 是否屬於當前使用者。 - **403 `owner_scope_forbidden`**:換一個呼叫者能擁有的 scope,或換有該 scope 管理權的人。 - **403 `budget_reservation_exceeded`**:公司配額蓋不住這次 envelope;提示額度,不要重送同一請求。 - **422 缺 header**:補 `Idempotency-Key`(每次新意圖用新 UUID,重試沿用)。 - **409**:重新讀取資源目前 state 再決定下一步(別盲目重送)。 - **409 `version_allocation_exhausted`**:沒有已確認的區域配額或 cap 為 0——這是營運/配額問題,不是租戶少填欄位。 - **503 `sandbox_disabled`**:kill switch 關掉了(`SANDBOX_ENABLED=false`;預設開)。提示使用者稍後再試,取消/下載仍可用。 - **418**:`Authorization` 與 `X-Api-Key` 都沒帶(共用 TeamSync credential 例外,`Could not validate credentials`,發生在 sandbox 404 隱藏之前)——不是 401;見[認證](/zh-TW/get-started/auth.md)。 - 租戶面的 429:`sandbox_queued_run_limit`、`queue_full`(地端;遵守 `Retry-After`)、`staging_slot_exhausted`、`upload_rate_limited`。退避;不要對 init 空轉重試。503 以外的 5xx 是平台失敗——退避重試;除非你還握著 `Idempotency-Key`,否則不要假設 run 沒建出來。 ## v5.10.0 新增的驗證與重試情況 - 422 `detail[].type = sandbox_value_too_short`:create/rotate 解密後少於 4 個 UTF-8 bytes。 - 409 `task_version_not_published`:明確 secret 核准指向未發布版本。 - 409 `retry_input_unavailable`:無法重放保留的來源 input,請傳明確 input。 - 核准 slot 集合非法/空白,或作者宣告使用保留 slot 名稱時,回 422 驗證錯誤。 請使用目前 OpenAPI 的 `wait_reason`、`error_code`、`failure_stage` 與 `failure_source` 型別。Provider 拒絕在重試處理後仍保留實際且已遮罩的來源資訊,不要自行轉成沒有內容的成功狀態。 # API 參考總覽 - [**API 端點目錄**](/zh-TW/reference/endpoints.md):所有前端端點,依資源分組(方法、路徑、認證、請求、回應)。 - [**請求與回應欄位**](/zh-TW/reference/schemas.md):完整請求/回應模型。 - [**Agent toolkit**](/zh-TW/reference/agent-tools.md):五個 LangGraph 工具(不是 REST)。 - [**狀態列舉值**](/zh-TW/reference/enums.md):前端會渲染的所有狀態字串。 - [**錯誤碼對照表**](/zh-TW/reference/errors.md):HTTP 狀態碼與 sandbox 錯誤語意。 ## 通則 - Base:`{BASE}/private/module/sandbox`。 - 認證:TeamSync JWT 或 API key(見 [認證](/zh-TW/get-started/auth.md))。受限 `Sandbox` scope key 只能動自己的 run 與 menu/提交。 - 請求 body:`extra="forbid"`(未知欄位 → 422)。 - 分頁:`page_size`(`ge=1 le=100`)、`page_token`(≤512)。 - 列表:`lifecycle` 查詢參數(`active` / `retired` / `all`,多數預設 `active`)。 - 未授權:**404**(不洩漏存在性)。例外:不能擁有的 department/company 建立是 **403 `owner_scope_forbidden`**;`owner_scope=chatroom` 是 **422**。Kill switch 下的變更:**503 `sandbox_disabled`**。 - 確認目錄:`GET /public/info/sandbox/regions` 與 `/profiles`(不必登入)。 - 提交/輪替類:必帶 **`Idempotency-Key`**(1..128 字元,禁 NUL)。 - OpenAPI **雙 tag**:總 tag `Module: Sandbox`(來自 `src/routers/private/modules/server.py`)**加上**分區 `Module: Sandbox - Settings`/`Environments`/`Tasks`/`Bindings`/`Secrets`/`Runs`。Codegen 兩種分組都會看到。 > 本手冊對照後端 `origin/master` 撰寫(見 [變更紀錄](/zh-TW/changelog/index.md))。欄位以租戶 router 與 `src/schemas/sandbox.py` 為準。 # 請求與回應欄位 以下每個 request body 都是 `extra="forbid"`:未知欄位 → **422**。路徑省略 `{BASE}/private/module/sandbox`。模型定義在租戶 router(`src/routers/private/modules/sandbox/*.py`)與 `src/schemas/sandbox.py`。本頁與 `origin/master` 不一致時,以後端為準。 **表格慣例** - `?` = 可選/可空/某些視圖會省略。 - `owner-only` = 只有 owner-scope manager 看得到,borrower 視圖省略。 - id 字串符合 `^[A-Za-z0-9_.\-]+$`(部分寫入欄位更嚴:`^[A-Za-z0-9_.:@-]{1,64}$`)。 - Digest 為 `sha256:` + 64 位小寫 hex。 ## 共用:分頁 | 欄位 | 型別 | 說明 | | --- | --- | --- | | `page_size` | int | `ge=1`、`le=100`(`MAX_PAGE_SIZE`),所有列表一致。預設 **20** 適用環境/環境版本/建置/任務/任務版本/分享列表(`DEFAULT_PAGE_SIZE`);**三條 chatroom 路由預設 50**:`GET .../menu`、`GET .../runs`、`GET .../runs/{rid}/artifacts`。 | | `page_token` | string? | 不透明,`max_length=512`。把上一頁的 `next_page_token` 原封不動送回;格式錯誤的 token 會從第一頁重來(不會 422)。後端 #1149 起**所有**列表都收,包括 `GET .../menu`、`GET .../runs`(游標釘在 `(queued_at, id)`,最新在前)與 `GET .../runs/{rid}/artifacts`(依 `relative_path` 排序)。 | | `next_page_token` | string? | 缺/null = 最後一頁。還有資料時每條列表都會回真的游標(後端 ≥ #1149;之前三條 chatroom 路由永遠回 `null`,無法翻頁)。 | | `lifecycle` | `active` / `retired` / `all` | 多數列表預設 `active`。`GET /tasks/{id}/shares` 預設 `all`。 | | `status` | 可重複 query | 僅 run 列表。`?status=queued&status=running`。 | Retired 詞彙(`src/routers/private/modules/sandbox/_filters.py`): | 列表 | 預設隱藏的 retired 值(除非 `lifecycle=retired` 或 `all`) | | --- | --- | | 環境 | `archived` | | 環境版本 | `archived`(`retryable_failed` / `rejected` 仍可見) | | 任務 | `archived` | | 任務版本 | `archived` | | 分享授權 | `revoked`(預設 `all`,讓撤銷可見) | | Run 產出物 | `delete_pending` / `deleted` / `tombstoned` | ## 公司設定 ### `SandboxCompanySettingsUpdate`(`PUT /settings`) | 欄位 | 型別 | 限制 | | --- | --- | --- | | `run_content_retention_days` | int | 1..365 | | `unreferenced_image_retention_days` | int | 1..90 | | `allowed_regions` | string[] | `min_length=1`。**只管**執行期 Job / Execution 放置。 | | `policy_version` | int | `ge=1`。樂觀鎖 CAS;不符 → 409。 | ### `SandboxCompanySettingsResponse`(`GET`/`PUT /settings`) 上述可寫欄位,加上唯讀: | 欄位 | 型別 | 說明 | | --- | --- | --- | | `company_id` | string | | | `selectable_regions` | string[] | 設定寫入接受的活躍 operator 區域,可超出公開確認集合。 | | `pricing` | `SandboxRuntimePricing?` | 已登入可讀的客戶費率與預留界線;無可選區域時為 null。 | | `runtime_egress_enabled` | bool | 唯讀 operator 政策;租戶 PUT /settings 不可修改。 | | `timeout_ceiling_seconds` | int | 租戶上限。預設 86400。 | | `active_environment_limit` | int | 預設 30。 | | `capacity_retained_version_limit` | int | 預設 100。 | | `placement_scope` | `"runtime_execution_only"` | 字面值。 | | `does_not_govern` | string[] | `build` / `validator` / `registry` / `r2` / `log` / `control_plane`。 | | `residency_guaranteed` | `false` | 字面值。 | | `regional_job_capacity_grace_days_fixed` | int | 30。 | ## 環境 / 版本 / 建置 ### `SandboxEnvironmentCreateRequest` / `UpdateRequest` | 欄位 | 建立 | 更新 | 限制 | | --- | --- | --- | --- | | `name` | 必填 | 可選 | 1..256 | | `owner_scope` | 可選,預設 `company` | 不可修改 | `company` 或 `department`,沒有 chatroom owner | | `owner_id` | 公司可省略;部門須提供部門 id | 不可修改 | 公司 id 預設呼叫者的公司;部門須符合 owner 管理權 | | `description` | 可選,預設 `""` | 可選 | ≤2048 | ### `SandboxEnvironmentResponse` | 欄位 | 型別 | 說明 | | --- | --- | --- | | `id` | string | | | `company_id` | string? | `system_curated` 環境為 null。 | | `owner_kind` | `system_curated` / `company` | 租戶可 pin 策展版本,不能建立策展環境。 | | `name` | string | | | `description` | string | | | `state` | string | 活著:`active`。退休:`archived`。 | | `security_state` | string | 通常 `clear`。Root digest-block 可設 `blocked`。 | | `current_version_id` | string? | | ### Context 上傳 | 模型 | 欄位 | | --- | --- | | `SandboxContextUploadInitRequest` | `declared_bytes` 1..1 GiB。`archive_format` `zip`/`tar`/`tar.gz`(預設 `tar.gz`)。 | | `SandboxContextUploadInitResponse` | `upload_session_id`、`capability_token`、`capability_expires_at`(15 分鐘)、`upload_mode`(`single_part`)、`object_kind`(`context`)、`archive_format`、`declared_bytes`、`max_bytes`、`put_header_name`(`X-Sandbox-Upload-Capability`)、`put_content_type`(`application/octet-stream`)。 | | `SandboxContextUploadPutResponse` | `upload_session_id`、`bytes_received`、`digest`。 | | `SandboxContextUploadCompleteRequest` | `expected_size?`、`expected_digest?`(`sha256:`)。 | | `SandboxContextUploadCompleteResponse` | `owned_object_id`、`digest`、`byte_size`、`upload_session_id`、`environment_id`。 | | `SandboxContextUploadSessionResponse` | `upload_session_id`、`environment_id`、`state`(`uploading`/`completed`/`aborted`/`abort_pending`)、`object_kind`、`declared_bytes`、`owned_object_id?`、`digest?`、`byte_size?`、`session_expires_at?`。 | PUT body 是原始位元組,不是 JSON。 ### `SandboxEnvironmentVersionCreateRequest` | 欄位 | 型別 | 限制 | | --- | --- | --- | | `owned_object_id` | string? | 來自 complete。1..36。首選。 | | `source_digest` | string? | `^sha256:[0-9a-f]{64}$`。至少要有一個。**單獨送不會被比對到任何已完成的 context 物件**——後端只檢查格式,就會建出一個沒有綁定封存的版本;排建置本身會成功(還會先扣預算),錯誤要到建置階段才浮現(`context_invalid`)。實務上一律送 complete 回傳的 `owned_object_id`;兩個都送時 digest 必須與該物件相符(否則 422 `source_digest_mismatch`)。 | | `source_ref` | string | 可選,預設 `""`,≤1024。擁有者標籤(檔名)。不是 key 或 URL。 | | `resource_profile` | enum | 確認集合 `GET /public/info/sandbox/profiles`。**不是 `xlarge`。** 必填。 | ### `SandboxEnvironmentVersionResponse` | 欄位 | 型別 | 說明 | | --- | --- | --- | | `id`, `environment_id` | string | | | `company_id` | string? | | | `version_number` | int | `ge=1` | | `state` | `SandboxEnvironmentVersionState` | 輪詢到 `ready`。 | | `security_state` | string | | | `resource_profile` | string | | | `capacity_retained` | bool | 計入公司版本配額。 | | `promoted_digest` | string? | 推廣後的映像 digest。 | | `composed_execution_digest` | string? | 執行期合成。 | | `runner_release`, `runner_abi_revision`, `policy_revision` | string? | Runner 身份。 | | `runner_release_approved` | bool | **後端 ≥ #1162。** 此版本的 runner release 已核准跑 task-bundle 工作時為 true。草稿、或由尚未核准的 runner 建出的版本為 false —— 此時 bundle 的發佈/執行回 `409 task_bundle_runner_unsupported`。 | | `runner_release_next_action` | string | `runner_release_approved` 為 false 時的白話指引(重新排一次建置,或請 operator 核准該 release);否則為空。 | | `region_readiness` | any? | 各區佈建對照。 | | `ready_at`, `verified_at` | datetime? | | | `compressed_bytes`, `unpacked_bytes` | int? | | | `last_build_attempt_id`, `last_build_status`, `last_build_terminal_class`, `last_build_stage_code`, `last_build_error_code`, `last_build_error_detail` | string | 最新一次建置嘗試;`error_detail` 僅擁有者可見。 | | `build_stage` | string | 最新嘗試目前所在階段:`build_queued`、`context_validator`、`customer_build`、`static_scan`、`smoke`、`sign`、`attest`、`promote`、`final_verify`、`runner_floor`、`regional_readback`、`terminal`;尚未建置時為空。 | | `build_stage_index`, `build_stage_count` | int | 驗證鏈進度的分子/分母(排隊中或終態為 `0`)。 | | `build_stage_since` | datetime? | 最新嘗試進入 `build_stage` 的時間。 | | `build_expected_seconds` | int | 本平台一次完整建置的典型耗時;沒有進行中的建置時為 `0`。 | | `build_next_action` | string | ≤400。建置進行中或失敗後「正在發生什麼/該做什麼」;`ready` 之後為空。 | | `source_digest`, `source_ref` | string? | **Owner-only。** borrower 視圖省略。 | ### `SandboxBuildAttemptCreateRequest` | 欄位 | 型別 | 預設 | | --- | --- | --- | | `build_region` | `SandboxRegionCode?` | 確認集合 `GET /public/info/sandbox/regions`。省略 → 部署預設值:託管雲端 `asia-east1`,地端部署 `onprem`。不在該部署公開目錄內的代碼(例如雲端的 `onprem`)→ 422 `build_region_unavailable`;地端在已有 active 區域列、但指定的不在其中時也回同一個 422。 | | `build_profile` | string? | ≤32。預設 `standard`。列舉:`standard` / `fast` / `large`。 | ### `SandboxBuildAttemptResponse` 絕不包含憑證、金鑰或物件 URL。 | 欄位 | 型別 | 說明 | | --- | --- | --- | | `id`, `company_id`, `environment_version_id` | string | | | `attempt_number` | int | `ge=1` | | `status` | `SandboxBuildAttemptState` | | | `phase`, `stage_code`, `error_code` | string | 細節;沒用時為空字串。 | | `error_detail` | string | ≤128 字元,`;` 分隔的 slug,點名 `error_code` 底下具體是哪條規則,例如 `reserved_path_or_hostile_entry` 或 `dockerfile_too_large`。絕不是自由文字或顧客內容。終態沒有更多細節時為空。 | | `terminal_class` | `SandboxBuildTerminalClass` 或 `""` | 非空 = 終態。 | | `build_region`, `build_profile` | string | | | `raw_upload_digest`, `canonical_digest` | string | | | `queued_at` | datetime? | | | `retryable_until` | datetime? | 後端 ≥ #1149。失敗/取消的 attempt 在版本仍是 `retryable_failed` 時帶:`POST .../versions/{vid}/retry-build` 還會接受的期限(版本最後一次狀態變更起 7 天)。期限過了、版本離開該狀態、或 attempt 不是失敗結束時為 `null`。同一版本的每個失敗 attempt 值相同。 | | `report_total_bytes` | int | `> 0` 代表有報告。報告本體**不在**租戶 API。 | | `stage_index`, `stage_count`, `stage_since`, `next_action` | int, int, datetime?, string | 與版本的 `build_stage_*` 相同的階段進度資訊。 | 列表包裝:環境/版本/建置皆為 `{ items, page_size, next_page_token }`。 ## 任務 / 版本 / 分享 ### `SandboxTaskCreateRequest` | 欄位 | 型別 | 限制 | | --- | --- | --- | | `name` | string | 1..256 | | `description` | string | 預設 `""`,≤2048 | | `owner_scope` | enum | **只有 `department` / `company`**(`SandboxTaskCreateOwnerScope`)。`chatroom` → 422。 | | `owner_id` | string | 1..36,id pattern。部門 id 或公司 id。 | | `agent_enabled` | bool | 預設 `false`。**舊旗標。** Agent 目錄看房間啟用。 | 呼叫者不能擁有的 department/company scope → **403 `owner_scope_forbidden`**。見 [任務受眾](/zh-TW/concepts/job-audience.md)。 ### `SandboxTaskResponse` | 欄位 | 型別 | 說明 | | --- | --- | --- | | `id`, `company_id`, `owner_scope`, `owner_id` | string | | | `name`, `description` | string | | | `agent_enabled` | bool | | | `state` | string | `active` / `archived`(`GET /tasks` 會過濾隱藏的 quick-run 父任務)。`POST /tasks/{id}/archive`(後端 ≥ #1151)會將它改為 `archived`,並連帶歸檔版本、撤銷 binding。 | | `security_state` | string | | | `current_published_version_id` | string? | | | `current_draft_version_id` | string? | | ### `SandboxTaskUpdateRequest`(`PATCH /tasks/{id}`,後端 ≥ #1151) 只送要改的欄位;至少一個(否則 422 `no_fields`)。 | 欄位 | 型別 | 限制 | | --- | --- | --- | | `name` | string? | 1..256,不可空白 | | `description` | string? | ≤2048(可為空) | | `agent_enabled` | bool? | 任務層的 Agent 旗標;聊天室仍需 grant + enable | ### `SandboxContextObjectItemResponse`/`SandboxContextObjectListResponse`(`GET /environments/{id}/context-uploads`,後端 ≥ #1151) | 欄位 | 型別 | 說明 | | --- | --- | --- | | `owned_object_id` | string | `POST .../versions` 接受的 `owned_object_id`。 | | `environment_id` | string | | | `byte_size` | int | | | `digest` | string | 上傳封存的 `sha256:`;等於版本的 `source_digest`。 | | `deletion_state` | string | `active` 才能用;其他都是退場狀態,不能拿來建版本。 | | `used_by_version_ids` | string[] | 此環境中 `source_digest` 相同的版本。 | | `created_at`、`expires_at` | datetime? | | 列表包裝 `{ items, page_size, next_page_token }`;`lifecycle` 預設 `active`。絕不回儲存 key 或 URL。已經拿去建版本的上傳仍會列出(它會改綁到那個版本,`used_by_version_ids` 會寫出來——後端 ≥ #1156);平台把物件退場後只在 `lifecycle=retired`/`all` 看得到。 ### `SandboxTaskVersionCreateRequest` / `UpdateRequest` 建立必填 `environment_version_id`。更新僅限 draft(`PATCH`)。 | 欄位 | 型別 | 限制 | | --- | --- | --- | | `environment_version_id` | string | 必須是 **`ready`** 版本。 | | `startup_script` | string | 預設 `""`,≤1 MiB,禁 NUL | | `work_script` | string | 預設 `""`,≤1 MiB,禁 NUL | | `startup_script_id`、`work_script_id` | string? | `POST /tasks/{id}/script-uploads` 回的 handle(24 小時,可重用);內容會複製進 `startup_script`/`work_script`。與直接內嵌該腳本互斥。 | | `ordinary_env` | object? | 名字規則(與 secrets 合計 ≤100 個;每筆 ≤64 KiB;名字 `^[A-Za-z_][A-Za-z0-9_]{0,127}$`;保留名 `TEAMSYNC_`… 拒絕)在**這兩條草稿路由上不會被驗證**——後端原樣存下,違規要到 run 認領時才被控制面擋下(而且**僅限**該版本宣告了 secret slot 的情況),結果是 run 失敗而不是 422。只有 Quick Run 的 `ordinary_env` 會在請求時就驗證。Writeback URL/token 走**密封 secret 通道**,不走這裡。 | | `input_instructions` | string? | ≤65536 | | `input_example` | string? | ≤65536。若設了 `input_schema`,必須符合它(**草稿建立/更新時**不符合會是 422 `job_contract_invalid`——發布不會重新驗證)。 | | `input_schema` | string? | ≤65536。JSON Schema(draft 2020-12)字串。設定後,每次提交的 `input` 都會照它驗證——不符合在派工前回 422 `input_schema_violation`。 | | `work_command` | string? | ≤4096 **UTF-8 位元組**(照位元組算,不是字元——非 ASCII 時位元組上限先擋下)。自由格式的 driver 指令列,例如 `python3 /workspace/work.sh`(work_script 永遠落地在 `/workspace/work.sh`,上傳檔名不保留——指向固定路徑,不是上傳時的檔名)。原封不動寫進 `/workspace/driver.sh`,在 `startup.sh` 之後 source(絕不是 argv)。空白/省略 → `. /workspace/work.sh`。含 NUL 或超長 → 422 `job_contract_invalid`。 | | `timeout_seconds` | int | 預設 1800;`ge=1`;`le=604680`(7 天 − 120 秒收尾包絡) | | `secret_slot_declarations` | list? | 見下。 | | `requires_confirmation` | bool | 預設 `false`。Agent 必須先提案、下一輪再 commit。Trigger 不能用。 | | `output_policy` | `SandboxOutputPolicy?` | 有型別,以 `kind` 區分的聯集:`custom_table_writeback`(`table_id`、`allowed_ops` `create`(預設)或 `create,update`)或 `custom_table_command`(`command_id`、`chatroom_id`、`input_schema`;v5.21.0)——見下。只有部門擁有。打進 `canonical_digest`。借用者永遠看不到。 | **`secret_slot_declarations` 形狀。** Owner 視圖存 list。每項是字串名字,或 `{ "name": "" }`。`name` 必須符合 env-name pattern 且非保留字。Borrower/選單只露出 `secret_slot_names: string[]`。 ### `SandboxOutputPolicy`(以 `kind` 區分) 版本建立/更新請求,以及 owner 的版本回應上的 `output_policy` 是聯集型別;由 `kind` 選擇哪一支。兩支都是 `extra="forbid"`。 | `kind` | 欄位 | 說明 | | --- | --- | --- | | `custom_table_writeback` | `table_id`(1..36 字元,id 格式)、`allowed_ops`(`create` 預設,或 `create,update`) | 限於單次 run 的表格 callback token。見[任務受眾](/zh-TW/concepts/job-audience.md)。 | | `custom_table_command`(v5.21.0) | `command_id`(≤36 字元,id 格式)、`chatroom_id`(≤36 字元,id 格式)、`input_schema`(自訂表格 `CommandInput` 的 list,≤200) | 限於單次 run 的 Command 憑證。見[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md)。 | 舊版本這個欄位可能仍帶著歷史上未被解讀的物件(回應型別是聯集或一般物件);新的寫入一律對聯集驗證。 ### `SandboxRuntimeContract` 每個 work script 執行時都套用的固定契約——除了 `driver`,其餘每個任務都一樣。出現在選單項目與 input-preview 回應的 `runtime_contract` 裡。 | 欄位 | 型別 | 說明 | | --- | --- | --- | | `invocation` | string | 固定:`". /workspace/startup.sh && . /workspace/driver.sh"`。 | | `driver` | string | 這個任務 `/workspace/driver.sh` 的內容——owner 看到真正的 `work_command`,否則是預設 fallback `". /workspace/work.sh"`(borrower 永遠看不到真正那行)。 | | `working_directory` | string | 固定說明:runner 以 `cwd=/workspace` 啟動 `/bin/sh` session,但契約不保證工作目錄——腳本與 `work_command` 一律用絕對路徑 `/workspace/...`。 | | `network` | string | 固定說明:**公司設定 `runtime_egress_enabled` 為 true 時(`GET /settings`;預設開啟),執行期可對外連線**——`startup_script`/`work_script` 裡的 `pip install`/`npm ci`/`go get` 可以用,對外流量會計量(`measured_egress_bytes`)。仍建議把相依烤進環境映像(Dockerfile `RUN` 連得到公網;私有、loopback 與 metadata 網段仍被封鎖),執行才快且可重現。Operator 可關閉該公司的執行期 egress,關掉後工作階段的安裝會失敗。 | | `inputs_file_env` | string | 固定:`"TEAMSYNC_INPUTS_FILE"`——存放這次 run 輸入 JSON 檔案路徑的環境變數名。 | | `output_file_env` | string | 固定:`"TEAMSYNC_OUTPUT_FILE"`——存放腳本該把結果寫去哪裡的環境變數名。 | | `artifacts_dir_env` | string | 固定:`"TEAMSYNC_ARTIFACTS_DIR"`(= `/workspace/artifacts`,`startup.sh` 之前建好、空的)——留在裡面的每個一般檔案都會變成可下載的產出物檔案(後端/runner ≥ #1152)。 | | `artifacts` | string | 人讀規則:子目錄保留為路徑前綴;遇到 symlink/hard link/特殊檔、`..`/控制字元、超過 1000 個檔案、路徑 >1024 B 或單一層 >255 B、總量超過 standard profile 上限(workspace 上限 − 16 MiB,最多 1 GiB)時**整包拒收**(run 照樣 completed,log 說明原因)。 | | `input_delivery` | string | 人類可讀說明:input JSON 會在腳本啟動前寫進 `$TEAMSYNC_INPUTS_FILE`。 | | `secrets_delivery` | string | 人類可讀說明:宣告的 secret slot 會在腳本執行前以環境變數匯出——絕不寫進磁碟。 | ### `SandboxInputPreviewRequest` / `Response`(`POST .../versions/{vid}/input-preview`) Request:`{ input: <任意 JSON> }`。不會派工。 | 回應欄位 | 型別 | 說明 | | --- | --- | --- | | `input_file_content` | string | 這個 input 會落在 `$TEAMSYNC_INPUTS_FILE` 的精確 UTF-8 位元組。 | | `input_digest` | string | `sha256:` + 64 hex——跟真的提交 run 會拿到的 digest 一樣。 | | `runtime_contract` | `SandboxRuntimeContract` | 只有呼叫者管理這個任務時,`driver` 才會反映真正的 `work_command`。 | | `schema_valid` | bool | `input` 符合版本的 `input_schema`(或根本沒設)時是 `true`。 | | `schema_errors` | string[] | ≤20 筆。`schema_valid` 時是空陣列。 | ### `SandboxScriptUploadResponse` | 欄位 | 型別 | 說明 | | --- | --- | --- | | `id` | string | 當 `work_script_id`/`startup_script_id` 用。 | | `filename` | string | 只作顯示;不會被解讀。 | | `byte_size` | int | ≤ 1 MiB。 | | `sha256` | string | 驗證過位元組的 `sha256:`。 | | `expires_at` | datetime | handle 壽命(24 小時)。 | ### `SandboxTaskVersionResponse` | 欄位 | 型別 | 說明 | | --- | --- | --- | | `id`, `task_id`, `company_id` | string | | | `version_number` | int | | | `state` | `draft` / `published` / `archived` | | | `content_state` | `active` / `scrubbed` | 選單/執行需要 `state=published` **且** `content_state=active`。 | | `environment_version_id` | string | | | `environment_id`、`environment_name` | string | 後端 ≥ #1149。釘住的版本所屬的環境(`GET /environments/{id}` 用的 id)與顯示名稱——curated 或自家的都有。只有釘住的版本列已不存在時兩者才為空。 | | `timeout_seconds` | int | | | `requires_confirmation` | bool | | | `secret_slot_names` | string[] | 一定在(可為空)。 | | `canonical_digest`, `content_hash` | string | 精確版本身份。Approval 釘的是 `canonical_digest`。 | | `published_at`, `archived_at` | datetime? | | | `update_available` | bool | 有更新的已發布版本(binding 語境)。 | | `input_instructions`, `input_example` | string? | 出現在選單。 | | `input_schema` | string? | **owner 與 borrower 都看得到**——它描述的是呼叫者自己 `input` 的形狀,不是 owner 的實作。 | | `runtime_contract` | `SandboxRuntimeContract?` | 兩種視圖都有(不是 owner-only):owner 視圖的 `driver` 回顯真正的 `work_command`,borrower 視圖是預設 fallback `". /workspace/work.sh"`。 | | `source_visible` | bool,必填 | Owner source view 為 true;false 表示原始內容被隱藏,不是空值。Menu 永遠為 false。 | | `startup_script`, `work_script`, `work_command`, `ordinary_env`, `secret_slot_declarations`, `output_policy` | owner-only | borrower 為 null/隱藏(`work_command` 是 `null`,不只是缺席)。 | ### `SandboxShareCreateRequest` / `RevokeRequest` / `GrantResponse` 建立:`target_kind`(`chatroom`、`department`、`company`)+ `target_id` + 可選 `secret_slot_policies`。有權的 owner 可直接分享到同公司任一存活聊天室,不必先建更廣的 grant。撤銷可帶最多 512 字的 `reason`;分享不會啟用 Agent。 | 請求欄位 | 型別 | 說明 | | --- | --- | --- | | `secret_slot_policies` | `Dict[str, "borrower"\|"owner"\|"owner_overridable"]?` | 逐 slot 的 secret 憑證來源政策。≤100 筆;key 必須是合法 slot 名。**只能在建立時設定**——要改就是撤銷再重新分享(開新 generation)。map 格式不對是 422 `secret_slot_policies_invalid`。見 [Secret 與授權](/zh-TW/concepts/secrets.md)。 | | 回應欄位 | 型別 | 說明 | | --- | --- | --- | | `id`, `company_id`, `task_id`, `target_kind`, `target_id` | string | | | `generation` | int | | | `state` | string | 活著 vs `revoked`。 | | `active` | bool | | | `authority_applicable` | bool | 聊天室改掛/刪除/authority bump 後為 false。 | | `owner_authority_generation`, `target_authority_generation` | int | | | `revoked_at` | datetime? | | | `revoke_reason` | string | | | `secret_slot_policies` | object? | 跟請求同一種形狀。回在 owner-manager 的分享路由上(三條都要求 `_get_managed_task`)——根本沒有 borrower 能打的分享端點會回 `SandboxShareGrantResponse`。Borrower 要看有效政策,得從選單/granted-jobs 的 `secret_slots[].policy` 讀。 | ### Company-manager 歷史 `GET /tasks/{task_id}/runs` 回 `SandboxTaskRunHistoryItem` 的 **JSON 陣列**(不是 `{ items, page_token }`)。搭配 `GET /tasks/{task_id}/runs/numOfData` → `{ num }`。 Query:`offset` ≥0 預設 0、`limit` 1..100 預設 10、`order` `asc`/`desc`(依 `queued_at` 再 id)、可選 `status`、`method`(`agent`/`manual`)、`executor_kind`(`internal_user`/`external_user`)、`source_kind`(`chatroom`/`department`/`external_platform`)、`department_id`、`chatroom_id`。 歷史列欄位:`id`、`task_id`、`task_version_id`、`status`、`queued_at?`、`started_at?`、`terminal_at?`、`duration_seconds?`、`cost_usd?`(已結算 USD,未結算為 null)、`executor_kind`、`executor_id`、`executor_display_name`、`source_kind`、`chatroom_id`、`chatroom_name`、`department_id`、`department_name`、`external_platform?`(`line`/`messenger`/`instagram`/`agent`)、`method`、`submission_source`、`resource_profile`、`error_code`。 ## Granted jobs | 模型 | 欄位 | | --- | --- | | `SandboxGrantedJobEnableRequest` | `task_version_id?` — 省略則釘目前已發布版。 | | `SandboxGrantedJobItemResponse` | `task_id`、`task_name`、`task_description`、`owner_scope`、`owner_id`、`secret_slots`(`SandboxSecretSlotStatus[]`,啟用時對應釘住的版本,否則對應目前已發布版本)、`enabled`、`binding_id?`、`pinned_task_version_id?`、`current_published_version_id?`、`update_available`。 | | `SandboxGrantedJobListResponse` | `chatroom_id`、`items`。 | ## Bindings **沒有** `GET /bindings` 列表。首選 granted-jobs 啟用。 ### 請求 | 模型 | 欄位 | | --- | --- | | `SandboxBindingCreateRequest` | `chatroom_id`, `task_id`, `task_version_id`(已發布且 active) | | `SandboxBindingAcceptVersionRequest` | `task_version_id`(同一任務的較新已發布版本) | | `SandboxBindingRevokeRequest` | `reason` ≤512 | ### `SandboxBindingResponse` | 欄位 | 型別 | 說明 | | --- | --- | --- | | `id`, `company_id`, `chatroom_id`, `task_id`, `task_version_id` | string | | | `generation`, `share_generation`, `target_authority_generation` | int | | | `state` | string | 選單要看到它,必須是 `enabled`。 | | `update_available` | bool | 有較新已發布版本;永不自動升級。 | | `authority_applicable` | bool | 分享/房間 authority 仍有效。 | | `accepted_at`, `revoked_at` | datetime? | | ## Secrets / approvals 值**絕不**回傳。建立/輪替/撤銷必須帶 `Idempotency-Key`(1..128 字元,禁 NUL)。 ### `SandboxEncryptedValue`(混合傳輸信封) secret 值在線上**唯一**接受的形狀。明文 `value` 欄位一律拒絕。Client 端加密流程見 [Secret 與授權](/zh-TW/concepts/secrets.md)。 | 欄位 | 型別 | 說明 | | --- | --- | --- | | `encrypted_key` | string | Base64,≤1024 字元。隨機 AES-256 金鑰,用 `GET /public/info/model_key/public_key` 以 RSA-OAEP-SHA256 包住。 | | `iv` | string | Base64,**剛好 16 字元**(不補 padding——大小正確的 12-byte IV 本來就不需要)。AES-GCM nonce。 | | `ciphertext` | string | Base64。AES-256-GCM ciphertext,GCM tag 附加在後。 | ### `SandboxSecretWriteRequest` / `RevokeRequest` | 欄位 | 型別 | 說明 | | --- | --- | --- | | `consumer_scope` | `chatroom` / `department` / `company` | 是 consumer,不是任務 owner。 | | `consumer_id` | string | | | `task_id` | string | | | `slot_name` | string | `^[A-Za-z_][A-Za-z0-9_]{0,127}$`,且非保留。 | | `encrypted_value` | `SandboxEncryptedValue`(僅寫入,**必填**) | 解密後的值最少 4 UTF-8 bytes、最多 64 KiB,禁 NUL,永不回顯。撤銷**沒有**此欄。明文 `value` 一律拒絕,不管有沒有設金鑰對——四種可分辨的 422 slug 見 [錯誤](/zh-TW/reference/errors.md)。 | ### `SandboxSecretBindingResponse` / `SandboxSecretOperationResponse` Binding:`id`, `company_id`, `consumer_scope`, `consumer_id`, `task_id`, `slot_name`, `generation`, `state`, `has_provider_binding`(bool)。沒有值、路徑、provider id。 Operation:`operation_id`, `operation_type`(`create` / `rotate` / `revoke`), `operation_state`(`SandboxSecretOperationState`), `binding`。 ### `SandboxSecretApprovalCreateRequest` | 欄位 | 型別 | 說明 | | --- | --- | --- | | `consumer_scope`, `consumer_id`, `task_id`, `task_version_id` | string | Borrower 身份 + 精確版本。 | | `task_version_digest` | `sha256:…` | 必須等於已發布的 `canonical_digest`,否則 409 `task_version_digest_mismatch`。 | | `slot_names` | string[] | 1..100,不重複,合法 slot 名。 | | `risk_accepted` | `true` | 只能是字面 `true`。 | 撤銷:`reason`(預設 `"revoked"`,≤512)。別名路徑 `/shared-task-secret-approvals` 語意相同。 ### `SandboxSecretApprovalResponse` `id`, `company_id`, `consumer_scope`, `consumer_id`, `owner_scope`, `owner_id`, `task_id`, `task_version_id`, `task_version_digest`, `slot_set_hash`(排序後名字的 `sha256:`)、`generation`, `state`, `active`, `created_at`, `revoked_at`。絕不帶 secret 值。 ## Runs ### `SandboxManualRunSubmitRequest` | 欄位 | 型別 | 說明 | | --- | --- | --- | | `task_version_id` | string | 已發布且 `content_state=active`。手動 REST:任何已授予版本。選單/Agent:已啟用的釘選。 | | `input` | 任意 JSON | Canonical JSON(見 [內容](/zh-TW/concepts/content-and-downloads.md))。 | | `timeout_seconds` | int? | `ge=1`,`le=604680`。省略 → 用版本預設。 | Header:必須帶 `Idempotency-Key`(1..128 字元,禁 NUL)。 ### `SandboxQuickRunRequest` 僅 manager。`name` 1..256、`environment_version_id`、`startup_script`、`work_script`、`ordinary_env`(預設 `{}`)、`input`、`timeout_seconds` **必填**。回傳 `{ task, version, run }`。`input` 跟手動提交一樣會暫存(v5.10.11),因此會落在 `$TEAMSYNC_INPUTS_FILE`,run 的內容也回 `has_input=true`。 ### 佇列背壓的 429 body 宣告為 `POST .../runs`、`POST .../runs/{run_id}/retry` 與 `POST .../quick-run` 的 429 回應。FastAPI 會把 body 包在 `detail` 裡,所以具名元件是外層信封。什麼都沒排入佇列。 | 模型 | 欄位 | | --- | --- | | `SandboxQueuedRunLimitErrorResponse` | `detail`:`SandboxQueuedRunLimitError`——`code` 固定為 `sandbox_queued_run_limit`、`message`、`limit`(int `ge=1`,觸及的上限)。 | | `SandboxQueueFullErrorResponse` | `detail`:`SandboxQueueFullError`——`code` 固定為 `queue_full`、`message`、`limit`(int `ge=1`,整個 fleet 的佇列上限)、`retry_after_seconds`(int `ge=0`,也以 `Retry-After` 送出)。僅地端部署。 | ### `SandboxSecretSlotStatus` 逐 slot 的 borrower/owner 履行狀態——只有 metadata,**絕不含 secret 值、binding id 或 provider 路徑**。出現在選單項目與 granted-job 項目的 `secret_slots[]`。 | 欄位 | 型別 | 說明 | | --- | --- | --- | | `name` | string | Slot 名稱。 | | `policy` | `borrower` / `owner` / `owner_overridable` | 這筆 grant 的逐 slot 政策(分享時沒設就預設 `borrower`)。 | | `borrower_bound` | bool | 借用方是否對這個 slot 有 live binding。 | | `owner_bound` | bool | Owner 是否對這個 slot 有 live binding。 | | `effective_source` | `borrower` / `owner` / `missing` | 現在提交 run 的話,實際會用哪一邊供值。 | ### `SandboxMenuItemResponse` | 欄位 | 型別 | 說明 | | --- | --- | --- | | `task_id`, `task_name` | string | | | `task_description` | string | | | `owner_scope`, `owner_id` | string | | | `task_version_id` | string | 提交時用這個。 | | `task_version_digest` | string | Approval 用的精確 digest。 | | `version_number` | int | | | `version_state` | string | 選單上是 `published`。 | | `input_instructions`, `input_example` | string? | | | `input_schema` | string? | `input` 必須符合的 JSON Schema(否則提交是 422 `input_schema_violation`)。 | | `secret_slot_names` | string[] | | | `secret_slots` | `SandboxSecretSlotStatus[]` | 逐 slot 履行狀態(版本沒宣告 secret slot 就是空陣列)。 | | `timeout_seconds` | int | | | `requires_confirmation` | bool | Agent 確認;人類仍走 `POST /runs`。 | | `update_available` | bool | Binding 有較新已發布版本。 | | `secret_use_risk_warning` | string? | 此版本會用到 borrower secrets 時出現。 | | `runtime_contract` | `SandboxRuntimeContract?` | **選單**上的 `driver` 永遠是預設 fallback `". /workspace/work.sh"`,就算是擁有 scope 的管理者也一樣——`_menu_item_from_view` 寫死 `build_runtime_contract(None)`,選單永遠不會帶真正的 `work_command`。要看真正的 driver,請走 `GET /tasks/{id}/versions/{vid}`(owner 視圖)或以管理者身分打 `POST .../input-preview`。 | **沒有腳本原文。** 選單 `page_size` 預設 50;後端 ≥ #1149 起 `next_page_token` 是真的游標(依 `task_id` 排序),之前永遠 `null`。 ### `SandboxSlotProvenance` Run 凍結的 secret 快照裡,逐 slot 的遮蔽解析溯源——絕不含 fingerprint、binding id 或 provider 路徑。 | 欄位 | 型別 | 說明 | | --- | --- | --- | | `resolved_via` | `borrower` / `owner` | 這次 run 這個 slot 實際是哪一邊供的值。 | | `consumer_scope` | `chatroom` / `department` / `company` | 這次 run 解析時用的 consumer scope。 | ### `SandboxRunDetailResponse` | 欄位 | 型別 | 說明 | | --- | --- | --- | | `id`, `company_id`, `chatroom_id` | string | | | `status` | `SandboxRunState` | 有 `terminal_at` 就是終態。 | | `resource_profile` | string | 繼承自環境版本。 | | `principal_type` | `user` / `social_media_client` | | | `principal_id` | string | 行為主體。Restricted key 只能看自己的。 | | `auth_method` | `jwt` / `api_key` | | | `task_id`, `task_version_id` | string | | | `timeout_seconds` | int | | | `submission_source` | string | `rest` / `agent` / `quick_run` / `custom_table_trigger`。未設時為空字串。 | | `error_code`、`error_detail` | string | 失敗來源;`completed` 時為空。`error_detail` ≤128,`[A-Za-z0-9._@:/+=%;-]`。 | | `exit_code` | int? | 工作指令真正的結束碼(未終態時 `null`;completed 為 `0`)。 | | `failure_stage` | enum | `''` / `task_bundle` / `secret_env` / `workspace` / `spawn` / `metering_arm` / `metering_start` / `metering_wait` / `metering_terminal` / `work` / `output` / `timeout` / `cancel` / `egress_cap` / `wait` | | `failure_source` | enum | `''` / `runner` / `reconcile` / `dispatch` / `submit` / `policy` / `tenant` | | `wait_reason` | enum | `''` / `awaiting_dispatch` / `company_saturated` / `no_active_region` / `missing_image_digest` / `capacity_snapshot_stale` / `provider_capacity_exhausted` / `provider_submit_pending` / `provider_submit_uncertain` / `container_starting` / `cancel_pending`。非終態 run 還沒跑起來的原因;`running` 與終態為空。 | | `next_action` | string | ≤400。對應 `wait_reason` 的指引;`wait_reason` 為空時也為空。 | | `wait_since` | datetime? | 目前這段等待開始的時間。 | | `queue_position` | int? | `ge=1`。在 run 等待 run slot 的佇列中的位置;已被認領或派送、或沒在等待時為 `null`。雲端:在公司 `queued` run 中。地端:在 executor 佇列中,`starting` 時也有。 | | `eta_seconds` | int? | `ge=0`。等待中的 run 大約還要幾秒開始,依佇列位置、fleet 的 run slot 數與同一 profile 近期已完成 run 的平均耗時估算。沒在等待或沒有歷史時為 `null`——絕不是猜的數字。託管雲端一律 `null`。 | | `failure_hint` | string | ≤400。失敗終態的修法建議(由 `error_code`/`exit_code` 推出);其餘為空。 | | `measured_ingress_bytes`、`measured_egress_bytes` | int | 工作階段計量的網路位元組。 | | `input_digest` | string | 提交 input 的 canonical hash。 | | `retry_of_run_id` | string | 不是 retry 則為空。 | | `error_code` | string | 成功時為空。 | | `queued_at`, `terminal_at` | datetime? | **輪詢 `terminal_at`。** | | `secret_slot_provenance` | `Dict[str, SandboxSlotProvenance]?` | 逐 slot 的遮蔽解析結果,在 run 的 secret pin 於認領時發布(publish)那一刻凍結。Run 沒有留存快照、或快照裡每一筆都格式不對被跳過時是 `null`。 | `cancel_requested_at` 有持久化(PR #951 的 cancel provenance),但**不在**此回應。看 `status=cancel_requested` 再變 `cancelled`。Run 提交時凍結的 `share_grant_id` 同樣只是內部用——**不在**此回應上。 ### `SandboxRunContentResponse` 只有旗標。沒有本體、沒有 object key。 | 欄位 | 型別 | 說明 | | --- | --- | --- | | `run_id`, `company_id` | string | | | `state` | string | 還沒有 content 列時是 `"missing"`。 | | `input_digest` | string | | | `has_input`, `has_output`, `has_log`, `has_artifact_bundle` | bool | **沒有**租戶端點回傳 log/output 位元組。 | ### `SandboxRunArtifactItemResponse` `id`, `run_id`, `relative_path`, `path_hash`, `byte_size`, `digest`, `deletion_state`(`active` / `delete_pending` / `deleted` / `tombstoned`)、`declared_content_type`。 ### `SandboxArtifactDownloadCapabilityResponse` | 欄位 | 型別 | 說明 | | --- | --- | --- | | `artifact_id`, `run_id`, `company_id` | string | | | `capability_token` | string | 不透明 `token_urlsafe(32)`。**不是 URL。** | | `expires_at` | datetime | `now + 5 分鐘`(`_DOWNLOAD_CAPABILITY_TTL`)。 | | `content_type` | string | `application/octet-stream` | | `content_disposition` | string | `attachment; filename="…"`(已消毒)。 | | `x_content_type_options` | `"nosniff"` | | 前端實際能拿這個 token 做什麼,見 [內容、Digest 與下載](/zh-TW/concepts/content-and-downloads.md)。 ### `SandboxArtifactPublicLinkResponse`(`POST`/`DELETE .../artifacts/{artifact_id}/public-link`,後端 ≥ #1149) | 欄位 | 型別 | 說明 | | --- | --- | --- | | `run_id`、`artifact_id` | string | | | `relative_path` | string | 檔案在任務包裡的路徑(只作顯示)。 | | `object_kind` | `"artifact"` | | | `url` | string | `{base}/public/sandbox/artifacts/{token}`——永久有效,驗過記錄的 `sha256` 後**只串流這個檔案的位元組區間**,不是整個 bundle。免登入。 | | `token` | string | 16..64 字元,不可猜。 | | `revoked` | bool | | | `created_at` | datetime? | | ### `SandboxPublicLinkResponse`(`POST`/`DELETE .../{kind}/public-link`) | 欄位 | 型別 | 說明 | | --- | --- | --- | | `run_id` | string | | | `object_kind` | `log` / `output` | 絕不是 `artifact`。 | | `url` | string | 完整永久公開 URL(`{base}/public/sandbox/artifacts/{token}`)。沒有 TTL。撤銷回應如果本來就沒有連結,會是空字串。 | | `token` | string | 16..64 字元。撤銷回應如果本來就沒有連結,會是 `0000000000000000` 佔位符(不是活著的 token)。 | | `revoked` | bool | 撤銷回應是 `true`。 | | `created_at` | datetime? | | 鑄造是冪等的(回既有的 live 連結);先撤銷再鑄一次會拿到**新**token。受限 Sandbox API 金鑰打這兩條路由都是 404。 ### `SandboxCommandOutputRequest`(`POST /public/module/custom_tables/callback/command-output/{token_id}`,v5.21.0) 公開路由,授權是 `TEAMSYNC_CT_WRITEBACK_TOKEN` 的 Bearer secret——不是租戶路由。Body 是 `{ "inputs": object }`,沒有別的(`extra="forbid"`)。`inputs` 在解析 body 時先檢查自訂表格 Command 的 JSON 上限,處理呼叫時再對照 Command 宣告的輸入(422 `output_schema_violation`)。回應是 Command 的執行回應(`CommandExecutionResponse`,省略 `null` 欄位)。見[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md)。 ## Agent 工具輸入(不是 REST) 這些是 LangGraph 工具參數。從不帶 company/chatroom/principal/turn 身份——伺服器在帶外附上。見 [Agent toolkit](/zh-TW/reference/agent-tools.md)。 | 模型 | 欄位 | | --- | --- | | `SandboxAgentMenuInput` | `page_size`, `page_token?`, `query?`(≤256) | | `SandboxAgentSubmitToolInput` | `mode=request`:`task_version_id` + `input` + 可選 `timeout_seconds`。`mode=commit`:**只能**有 `proposal_receipt`(≤4096)。 | | `SandboxSubmittedJobsInput` | `page_size`, `page_token?` | | `SandboxJobStatusToolInput` | `run_id`, `wait_seconds` 0..60, `artifact_page_token?` | | `SandboxCancelJobInput` | `run_id?`(省略 = 取消此 principal 待確認提案) | ## 本頁刻意不列 - `/sandbox-control/*` 請求本體(託管雲端:OIDC + capability;地端 work lease:executor-token 驗證;不是前端)。 - `/root/sandbox/*` 請求本體(營運面;見 [營運與 Root](/zh-TW/concepts/operators.md))。`src/schemas/sandbox.py` 裡的 `SandboxWorkQueueResponse`/`SandboxWorkQueueItem`/`SandboxExecutorCapacity` 屬於營運路由 `GET /root/sandbox/queue`,不是租戶路由。 - Provider 名稱、object key、presigned URL、secret 值。 ## 目前的管理查詢與執行型別(v5.10.0) `SandboxPrincipalCapabilitiesResponse`、`SandboxCompanyRunListResponse`、task/room binding 清單,以及遮罩 secret/approval 清單,請見[能力與管理查詢](/zh-TW/concepts/integration-contract.md)。Binding/secret/approval 管理清單使用 `truncated`;`/runs` 與有分頁的目錄仍使用 cursor。 新的 secret-slot declarations 使用 `SandboxSecretSlotDeclarations`:每個物件必須有合法、非保留名稱的 `name`,同時保留可選 metadata。讀取側 `SandboxStoredSecretSlotDeclarations` 仍接受歷史字串/不透明宣告,不要在讀取時改寫。`region_readiness` 是 live region code 到 `ready`/`not_ready` 的 map。 `SandboxRunRetryRequest` 含可省略的 `input`:缺席/null 重放來源位元組,非 null 值提供新輸入,請見[重試契約](/zh-TW/flows/run-and-results.md)。`SandboxRuntimePricing` 包含幣別、費率/界線版本、各區 profile 費率、進出站費率、最低計費時間、finalization 時間與 outbound 預留界線,請見[執行規劃](/zh-TW/concepts/integration-contract.md)。 Environment 回應除了 `owner_kind`,也有 `owner_scope` 與 `owner_id`。`owner_kind=company` 仍表示 tenant-custom,不代表 `owner_scope=company`。 # 變更紀錄 這份手冊對準 **teamsync-backend `origin/master`**。 | 釘選 | 值 | | --- | --- | | Commit | `172a7f80bdf4dbdffdc018eb08bfa42c62bb485c` — v5.21.0 版本提交(tag `v5.21.0`) | | Subject | chore: bump version to 5.21.0 | | Date | 2026-10-08 (Asia/Taipei) | | 租戶 router | `src/routers/private/modules/sandbox/` | | 公開目錄 | `src/routers/public/info/server.py`(`GET /public/info/sandbox/{regions,profiles}`)、`src/routers/public/sandbox_artifacts.py`(`GET /public/sandbox/artifacts/{token}`) | | 共用 schema | `src/schemas/sandbox.py`、`src/schemas/enums.py` | | 常數 | `src/components/sandbox/constants.py`、`src/components/sandbox/storage.py`、`src/components/sandbox/input_schema.py`、`src/components/sandbox/secrets.py` | | Provider | `src/components/sandbox/providers/`(`SANDBOX_PROVIDER` 切換;`onprem/` 的區域、計價 tier 與佇列上限)、地端 executor `src/workers/sandbox_onprem/` | | Operator root(僅供參考) | `src/routers/root/sandbox.py` | | Agent 工具 | `src/components/tools/custom/sandbox/` | | Trigger | `src/crud/custom_table_triggers.py`(`submit_sandbox_job`) | | Writeback | `src/crud/sandbox/writeback.py` | | Command 輸出 | `src/crud/sandbox/command_output.py`;公開路由 `POST /public/module/custom_tables/callback/command-output/{token_id}` 在 `src/routers/public/custom_tables_callback/server.py` | | 通知 | `src/crud/sandbox/notifications.py` | | Runner/建置路徑 | `sandbox/runner/`、`sandbox/build/`(Go runner + Cloud Build compose/scan 路徑) | | 控制面基礎設施 | `infrastructure/sandbox/gcp/`、`infrastructure/app/gcp/k8s/base/sandbox-controller-*.yaml` | 頁面與那棵樹不一致時,以後端為準。重新稽核就從這個 SHA diff 那些路徑。 ## 2026-10-08 — release v5.21.0 **來源與上線:**後端 `172a7f80bdf4dbdffdc018eb08bfa42c62bb485c` 為 v5.21.0 版本提交(tag `v5.21.0`,`chore: bump version to 5.21.0`,2026-10-08 11:07 UTC = Asia/Taipei 19:07)。這是 v5.10.11 之後的第一支釘,所以本則檢視 `70862ceae7..172a7f80bd` 範圍內 1,848 個 commit 中的 111 個:碰到下方「如何更新這支釘」指令所列路徑的 71 個,加上碰到路徑含 `sandbox` 的檔案、或訊息提到 Sandbox 的另外 40 個。它們幾乎都屬於其他模組;**有五個改變了 Sandbox 契約**。下列內容都讀自已發布的樹。本則沒有呼叫任何已部署的 API,所以沒有可引用的 production `info.version` 讀回結果。日期用 Asia/Taipei。 ### 任務輸出可以執行自訂表格 Command `output_policy` 現在是以 `kind` 區分的聯集型別。除了表格 writeback(`custom_table_writeback`),**部門擁有**的任務版本現在可以宣告 `{ "kind": "custom_table_command", "command_id", "chatroom_id", "input_schema" }`。該版本的每一次 run 會透過既有的 `TEAMSYNC_CT_WRITEBACK_URL`/`TEAMSYNC_CT_WRITEBACK_TOKEN` 變數收到一把在提交時鑄出的密封、限於單次 run 的憑證(若認領時無法揭露,run 會在沒有這兩個變數的情況下啟動),這兩個變數現在指向公開端點 `POST /public/module/custom_tables/callback/command-output/{token_id}`。work script 送出 `{ "inputs": { … } }`,平台就以 run 的提交者身分,在那一個聊天室裡,執行那一個 Command,每次 run 最多一次:第一份有效輸出勝出,端點會回 404/409 `output_run_unavailable`、409 `output_command_stale`、409 `output_command_conflict`、422 `output_schema_violation`、403 `output_policy_author_denied`,或 Commands API 自己的錯誤。 這個 policy 會在草稿建立/更新(422 `output_policy_command_not_found`、422 `output_policy_schema_mismatch`、403 `output_policy_author_denied`)、發布(任何一項 Command 檢查失敗都是 409 `output_policy_unsatisfiable`;對發布者與已記錄撰寫者的身分、grant 與範圍檢查仍是 403 `output_policy_author_denied`),以及每一次提交 run 時檢查。和表格 writeback 不同,不合格的 run——borrowed 的房間、政策指定房間以外的房間、API key/受限金鑰/社群 client 的提交者,或撰寫者或提交者沒有該 Command 的 grant——會被**拒絕**,回 403 `output_policy_author_denied`,什麼都不會排隊。自訂表格 trigger 現在可以對準這種版本(run 由 trigger 的撰寫者提交,任務的輸入檔是渲染後的 `input` 物件本身,而不是 `source`/`trigger`/`record`/`row` 信封)。`requires_confirmation` 為 false 時,Agent 直接提交,不經確認回合;表格 writeback 的版本仍需要兩回合確認。這些檢查用到的版本撰寫者歸屬是內部的,不會出現在任何回應。 **前端調整:**讓 owner 能撰寫新的種類並顯示撰寫時的錯誤碼;borrower 看到的 `output_policy` 仍是 `null`(現在裡面還多了 Command 與聊天室的 id),不要預期有值;把提交時的 403 `output_policy_author_denied` 當作授權問題,而不是資源不存在。觀察 run 的方式不變,Sandbox 的 run 紀錄不帶 Command 的結果。見[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md)、[任務受眾](/zh-TW/concepts/job-audience.md)、[自訂表格 trigger](/zh-TW/flows/custom-table-trigger.md)、[Agent toolkit](/zh-TW/reference/agent-tools.md)、[Schema](/zh-TW/reference/schemas.md)與[錯誤](/zh-TW/reference/errors.md)。 ### runner 憑證跟著 `timeout_seconds` 認領時,runner 用來讀取 manifest、回報進度與完成、以及上傳結果的兩把 runtime 憑證,有效期限現在是已儲存的 run `timeout_seconds` 加上 120 秒收尾包絡(最多 604800 秒)。在這一版之前,有效期限只有平台預設的 600 秒,之後控制面就拒絕它們,所以更長的 run 無法回報結果,會被 reconcile 判成 `platform_failed`/`provider_terminal_without_callback`;這個 release 的地端 E2E(`scripts/e2e/custom_tables/sandbox_ttl`)在針對舊程式碼的 RED 階段,就是要求一個 `timeout_seconds=1800`、跑 12 分鐘的 run 以這個結果結束。在託管雲端上,每個 Cloud Run execution 也會以同樣的總長度作為它自己的 `timeoutSeconds` 覆寫啟動(不超過 168 小時),與重複使用的 Job 範本無關。已儲存的 timeout 缺少或超出範圍,會讓認領 fail closed(僅控制面)。 **前端調整:**無。很長的 `timeout_seconds` 不必再為了回報結果而壓在 10 分鐘以內。見[限制](/zh-TW/concepts/limits.md)。 ### runner 會重試它的完成回報 Go runner 的終態 `complete` 呼叫,現在每次嘗試有 30 秒預算,當控制面回 408、429、500、502、503 或 504,或傳輸失敗(逾時、EOF、連線錯誤;找不到主機不重試)時,最多嘗試三次、每次相隔 500 毫秒。依這項變更自己的標題,重點是:那個呼叫的一次暫時性失敗,不會再讓已完成的 run 被 reconcile 判成 `platform_failed`,而不是帶著客戶的 exit code 或逾時結束。runner 的變更只有在環境版本由含該變更的 runner release 建置時才會到達它;版本的 `runner_release` 記錄它用的是哪個 release。 **前端調整:**無。 ### 表格 writeback 與 `commands_only` 的表 自訂表格的表可以帶 `write_policy: commands_only`(它的紀錄只能透過 Command 變更)。表格 writeback 的 `output_policy` 不能指向這種表:草稿建立/更新回 422 `output_policy_governed_table`;發布時,以及提交 run 且真的鑄出憑證時,同樣的狀況是 409 `output_policy_unsatisfiable`;在該政策開啟之前就鑄出的、限於單次 run 的表格 token,會被 callback 以有型別的 403 `governed_context_required` 拒絕。 **前端調整:**顯示新的 422;必須寫入這種表的任務,改用 Command 輸出這一種。見[任務、分享與啟用](/zh-TW/flows/tasks-and-bindings.md)與[錯誤](/zh-TW/reference/errors.md)。 ### 營運面(僅供參考) 在地端部署上,provider 容量更新與 `GET /root/sandbox/quota-snapshots/refreshes/{refresh_id}` 現在把快照範圍限定在別名 `onprem`,不再要求 GCP project;託管雲端在 project 缺少或格式不對時,仍回 409 `sandbox_gcp_project_unset`/`sandbox_gcp_project_invalid`。營運路由表不變(27 條)。見[營運與 Root](/zh-TW/concepts/operators.md)。 ### 這次稽核找到的更正 下列內容在這個 release 之前就是錯的或不完整,兩種語言都已修正: - 錯誤碼頁寫「`Authorization` 與 `X-Api-Key` 都沒帶」是 **401**;實際是 **418**(`Could not validate credentials`),認證頁本來就是這樣寫。 - Agent toolkit 頁說 `PATCH /private/chatrooms/setting/jobs/{chatroom_id}` 的 OpenAPI 說明只列 wms 與 booking;實際上它列有 `sandbox`(v5.10.11 時就是如此)。 - Agent 相關頁面沒有寫明:表格 writeback 的 `output_policy` 即使 `requires_confirmation` 是 false 也會強制走提案路徑;現在寫了。 ### 釘選與匯出 README、兩個首頁與執行規劃頁現在寫 v5.21.0 與完整 SHA `172a7f80bdf4…`,schema 頁寫 v5.21.0。下方「如何更新這支釘」指令從這個 SHA 開始,並且另外涵蓋 `src/routers/public/custom_tables_callback`、`src/tasks/sandbox_*.py`、`src/dependencies/sandbox.py` 與 `src/database/models/sandbox.py`。`llms.txt`、`llms-full.txt` 與 Markdown twin 由這些頁面重新產生,包括新頁面[任務輸出交給自訂表格 Command](/zh-TW/flows/command-output.md)。 ### 稽核附註——commit 分類 搜尋方式:(A) 對下方「如何更新這支釘」指令的路徑做 `git log 70862ceae7..172a7f80bd`(71 個 commit:60 個非 merge、11 個 merge);(B) 碰到任何路徑含 `sandbox` 的檔案的非 merge commit;(C) commit 訊息提到 Sandbox 的 commit。聯集共 111 個 commit(99 個非 merge、12 個 merge),每個都恰好分類一次: - **有文件記載/串接端看得到的契約變更(上面各節涵蓋;5 個 commit):**`7c41cbc501`(Command 輸出,#1853)、`739894a878`(憑證壽命,#1826)、`433e22bd00`(完成回報重試,#1819)、`3fa893b56d`(`commands_only`,#1788)、`4b11540fa6`(所選 provider 的容量更新,#1804)。 - **沒有額外契約變更的 merge commit(12 個):**`c7e3d41426`、`aba63cb121`、`e65d71e298`、`a81511bb15`、`9a4d7b0660`、`028db39d42`、`a3c9665e10`、`8e80f29a49`、`ba3290fcb7`、`d65e9e5b26`、`62b4075d81`、`d77bcfa6e4`。 - **開發環境、基礎設施、CI 與打包——對已發布產品沒有影響(9 個):**`03d3f5d4df`、`3cb548cb43`、`9057cc086d`(Sandbox 開發環境啟用、啟動期間維持關閉、在共用 project 內隔離;production 資源名稱不變)、`69598743e9`(較早把 Sandbox 從開發部署移除)、`feba983f1e`、`4d7415d467`(agent-workspace IaC)、`1bea1ef4ec`(CI workflow)、`45242b8711`(雜湊鎖定的 Python lock)、`552609edbf`(release 與已部署映像的佈局契約)。 - **內部變更,沒有契約變更(1 個):**`37b79e2222`(行程回收現在會等待有追蹤的行程內工作,包括 run 排入佇列後喚醒 controller 的執行緒;3 秒 sweep 仍是備援)。 - **地端安裝套件、營運腳本、教學與 compose add-on(26 個):**`e19594605b`、`4c11ddea98`、`3d05af8193`、`690d574faa`、`415bd335d1`、`10a72f6c1f`、`11b49fd897`、`695e7bf761`、`200512a125`、`150a8fc8be`、`46e9051e6c`、`d4a6be835b`、`caa80868dd`、`16e82ca37f`、`840b0c7220`、`fd991177fb`、`d72b880df9`、`67c513e294`、`4891aab5e2`、`e62c9907eb`、`73e377d58c`、`bbabc707ac`、`22ca3e74d0`、`aafb03c9be`、`368713044a`、`ebb3e0c988`。它們改的是引導式安裝程式、它的診斷、add-on 位址處理與打包,不是租戶 API,手冊記載的任何變數也沒有改變意義;手冊把安裝套件交給後端的 `docs/deployment/on-prem/`。 - **測試工具與 Agent 工具選擇(3 個):**`1d89736847`(針對既有 Sandbox scope 拒絕行為的測試)、`badb87d738`(自訂表格驗收 runner)、`9ec0fbae42`(智慧工具選擇器;五個 Sandbox 工具仍被釘住)。 - **其他模組——共用被稽核的路徑、只在訊息裡順帶提到 Sandbox、或碰到檔名含 `sandbox` 的檔案;都沒有改變 Sandbox 行為(55 個):**`7df284baa0`、`7a0a2b8a0d`、`dbdaeb4243`、`a21ba222ef`、`6bad38fbe9`、`737955fb69`、`6bf9760687`、`c9392060d1`、`c15a12f9b0`、`eaf7c84d76`、`cc5d2db9c8`、`1a607e52b8`、`bfb9ced050`、`cfb7457a4f`、`430d9aa23c`、`a510143539`、`9750eac491`、`74a6a2b13a`、`c48f763467`、`457140d0de`、`0f8985db78`、`2f548ae2a7`、`d48281f258`、`4ac973afae`、`2b78575d80`、`f0e17d4259`、`e5770ead73`、`ce26227b87`、`c6c21d4194`、`8ecc1f9a54`、`63636600ae`、`49103a6c5d`、`82042fed21`、`3b380b1a63`、`68f5beffc2`、`455fa388c6`、`e932ccdc0a`、`ae5229549e`、`9f7f93b7fd`、`2b0c952fef`、`1bc159f300`、`8cb89701ef`、`1eb1d3e517`、`68bc44c431`、`48577334a5`、`9587d94e93`、`8c0435fd3f`、`0e89a8032d`、`64843e7b66`、`19c720aad2`、`168e3d0da0`、`0de6458c1e`、`ba8ca8b3cd`、`c6d02fa6fa`、`78d30b5ff6`。備註:新增的 OAuth access token(只在 `/mcp/*` 有效)與 Command service key(只在兩條 Command 路由有效)在 Sandbox 路由上都會被拒;`7df284baa0` 讓自訂表格的請求處理離開事件迴圈(command-output 路由變成在 threadpool 執行的一般 handler,契約相同);`455fa388c6` 移除 AI 呼叫上的價格閘門,並保留 Sandbox 雲端建置的價格閘門;`4ac973afae` 修正表格 callback 的 `update` data 說明(僅文件)。 ## 2026-09-19 — production v5.10.11 **來源與上線:**後端 `70862ceae7869470c14f4ed7cc7dfc3609e299f4` 為 v5.10.11 版本提交(`origin/master` HEAD,tag `v5.10.11`)。[Production release](https://github.com/ShuChenAI/teamsync-backend/releases/tag/v5.10.11) 由 [「Release & Deploy to GCP (production)」run 35428530132](https://github.com/ShuChenAI/teamsync-backend/actions/runs/35428530132) 部署,該 run 於 2026-09-19 成功結束;production 讀回 `GET https://api.cluster.scfg.io/openapi.json` 的 `info.version` 為 `5.10.11`。本則稽核 `9b8b95e59..70862ceae` 之間、碰到下方「如何更新這支釘」指令所列路徑的每一個 commit(共 202 個:190 個非 merge commit 加 12 個 merge commit)。這段範圍大多是新的**地端 provider**(`SANDBOX_PROVIDER=onprem`);除非另外註明,託管雲端部署的行為不變。 ### Run 佇列:位置、預估時間與 429 背壓 `SandboxRunDetailResponse` 在 `queue_position` 旁新增可為 null 的 `eta_seconds`,所有回傳 run 的路由都有(提交、列表、明細、取消、重試、Quick Run)。託管雲端上 `queue_position` 意義不變(在公司 `queued` run 中從 1 起算),`eta_seconds` 一律為 `null`。地端部署兩者都來自 executor 佇列:位置也涵蓋 executor 尚未接手的 `starting`,預估時間為 `ceil(位置 ÷ 整個 fleet 的 run slot 數) × 同一 profile 最近 20 筆已完成 run 的平均耗時`,沒有歷史時為 `null`。OpenAPI 現在把 `POST .../runs`、`POST .../runs/{run_id}/retry` 與 `POST .../quick-run` 的 429 宣告為 `SandboxQueuedRunLimitErrorResponse` 或 `SandboxQueueFullErrorResponse`;`queue_full`(僅地端:整個 executor fleet 的佇列已滿)帶 `limit`、`retry_after_seconds` 與 60 秒的 `Retry-After` header。 **前端調整:**`eta_seconds` 非 null 才顯示,絕不自行推算;依兩個 429 元件重新產生 client。遇到 `queue_full` 先等 `Retry-After`;遇到 `sandbox_queued_run_limit` 則退避到等待中的 run 變少再送。見[執行與結果](/zh-TW/flows/run-and-results.md)、[Schema](/zh-TW/reference/schemas.md)、[錯誤](/zh-TW/reference/errors.md)與[限制](/zh-TW/concepts/limits.md)。 ### Quick Run 的 input 終於送達工作 在這一版之前,Quick Run 從未暫存 `input`:工作讀到的是空的 `/workspace/input.json`,`GET .../content` 也回 `has_input=false`。Quick Run 現在跟手動提交一樣暫存 input,`$TEAMSYNC_INPUTS_FILE` 就是送出的 JSON。因此 Quick Run 也可能回 422 `input_not_stageable`/503 `input_staging_unavailable`(在 manager 檢查與冪等重放之後、建立任何任務或 run 之前)。 **前端調整:**拿掉針對 Quick Run 空 input 的權宜做法,兩個暫存錯誤比照手動提交處理。見 [Quick Run](/zh-TW/flows/run-and-results.md) 與[錯誤](/zh-TW/reference/errors.md)。 ### 依 provider 過濾的區域目錄與 `onprem` `SandboxRegionCode` 新增 `onprem`,公開區域的 `tier` 範圍放寬為 `1..3`(3 = 固定/專屬容量,以名目會計費率計價)。`GET /public/info/sandbox/regions` 回傳部署所用 provider 的目錄:託管雲端永遠不列 `onprem`;地端部署只列 `onprem`。`selectable_regions` 是租戶 settings 使用的 active operator 列集合;正確 bootstrap 的地端部署只有 `onprem`,但 operator 額外建立的 tier 1/2 列可能出現在其中,而公開目錄與 build gate 仍依 provider 過濾。`POST .../versions/{vid}/builds` 若 `build_region` 不在該部署公開的目錄內,回 422 `build_region_unavailable`(地端在已有 active 區域列、但指定的不在其中時也回這個)。省略 `build_region` 時,地端預設 `onprem`,雲端仍是 `asia-east1`。 **前端調整:**區域選項維持由目錄驅動,接受 `tier=3`,建立建置的請求不要寫死 `asia-east1`。無 body 的 `retry-build` 端點在此釘選仍由後端固定使用 `asia-east1`。見[環境與建置](/zh-TW/flows/environment-and-build.md)、[端點](/zh-TW/reference/endpoints.md)、[列舉](/zh-TW/reference/enums.md)與[執行規劃](/zh-TW/concepts/integration-contract.md)。 ### 地端部署 自架主機用 Docker executor 取代 Cloud Run,執行同一套租戶 API。串接端看得到的差異:只有一個 `onprem` 區域並以 tier 3 名目費率計價;有每公司與整個 fleet 的佇列上限(`queue_full`);submit/start 狀態的 `next_action` 用語改為不指名供應商(「the local runtime」;容量狀態則是「the on-prem sandbox」);沿用雲端 runner 的 `error_code`/`failure_stage` 詞彙,逾時 `exit_code=124`、取消 `130`;映像的其他 `ENV` 不再滲進工作 shell;執行期對外網路另需主機設 `SANDBOX_ONPREM_EGRESS=internet`;工作要回呼主機自己的 API,需要 operator 開放 API origin 並提供信任憑證庫。Agent 提交遇到任一種 429 時,工具錯誤為 `provider_unavailable`。 **前端調整:**依目錄與 `GET /settings` 建構介面,不要預設雲端行為;`next_action` 原文呈現。見[雲端與地端部署](/zh-TW/concepts/integration-contract.md)、[營運](/zh-TW/concepts/operators.md)與 [Agent 工具](/zh-TW/reference/agent-tools.md)。 ### Runner:巢狀行程命名空間與計量修正 這一段 runner 說的是託管雲端;地端使用 Docker worker 的直接 shell 路徑。託管雲端開啟執行期對外網路的 run(預設)上,此釘的 runner 原始碼會嘗試讓工作 shell 成為巢狀 user+PID 命名空間的 PID 1;主機允許私有 `/proc` 設定時才會擁有自己的 `/proc`,若主機不允許命名空間則保留直接 shell fallback。關閉對外網路的 run 維持直接啟動 shell。短命的孤兒行程(例如 BusyBox `wget https://…` 分出的 `ssl_client`)在巢狀設定啟用時不再讓整個 run 被 SIGKILL 成 `exit_code=137`。命名空間內,主機允許 id 對應時 `id -u` 為 `0`,否則是溢位 uid `65534` 且沒有任何 capability;`/workspace` 與 `$TEAMSYNC_OUTPUT_FILE` 仍可寫入。Runner 在 `/workspace/.teamsync/` 下保留自己的紀錄。另一項修正:工作已 exit 0 的 run,不會再因網路介面拆除的時間差被結算成 `platform_failed`/`metering_failed`/stage `metering_wait`/`exit_code=130`。Runner 的變更要等環境版本由包含它的 runner release 建置後才生效;版本的 `runner_release` 記錄是哪一版。 **前端調整:**不需要調整;有背景行程時不要對 `exit_code=137` 做特例。見[執行期須知](/zh-TW/flows/tasks-and-bindings.md)與[本機重現](/zh-TW/flows/local-debug.md)。 ### 失敗提示與保留 `secret_env_name_collision` 的 `failure_hint` 改為說明真正的規則(某個一般環境變數與已綁定的 secret slot 同名),`secret_env_value_collision` 也有了自己的提示(某個一般環境變數的值與已綁定的 secret 值相同)。歸檔回收在仍有未歸檔的 curated 版本釘住同一 digest 時,保留租戶建置的 execution 套件。見[執行與結果](/zh-TW/flows/run-and-results.md)與[保留](/zh-TW/concepts/retention.md)。 ### 營運面(僅供參考) `GET /root/sandbox/queue` 以同一形狀在每種部署列出等待中的工作與 executor 容量。註冊 tier 3 區域只接受地端部署上的 `onprem`。營運路由表現在列出全部 27 條 root 路由,包括先前漏列的 `GET`/`PUT /companies/{company_id}/policy`。見[營運](/zh-TW/concepts/operators.md)。 ### 釘選與匯出 README、兩個首頁、執行規劃頁與 Schema 頁改標 v5.10.11 與 `70862ceae`。下方「如何更新這支釘」指令改從這個 SHA 起算,並加入營運與控制面 router、地端 executor 與 controller、`sandbox/onprem/`、整個 `infrastructure/sandbox/`,以及這些頁面引用的其他後端路徑。`llms.txt`、`llms-full.txt` 與 Markdown twins 由這些頁面重新產生。 ### 稽核附註——commit 分類 範圍內每個 commit 都已分類。先列出 29 個契約變更 commit;其餘 commit 沒有改變任何文件化或串接端可見的契約: - **文件化/串接端可見的契約變更(由上方各節涵蓋;共 29 個):** `3edebc843`、`3dd307ba0`、`fa423ad8c`、`03b3eca5f`、`1d806d00b`、`a08bd498e`、`ee8a6de1b`、`aecbe16d6`、`fe074dfe0`、`6d42c0d3a`、`0e8a4b584`、`c9b5894fe`、`4687c6553`、`f916c29fe`、`a3f50f512`、`ec39191f0`、`3205659d2`、`c6c55f12f`、`ee54b9584`、`dfd96f0ca`、`5c69c5917`、`d5edcffc5`、`d384d06a5`、`dd005d9fe`、`33eebf13d`、`e157b4093`、`e882217cc`、`eee7aff4b`、`a78a0c47a`。 - **Merge commit,除了合併進來的 commit 外沒有額外文件化契約變更:** `bf5968970`、`6f7718d4b`、`54c2c1012`、`d331534b1`、`e773750a4`、`f93f24597`、`679147a5f`、`d9aac6f71`、`d615306d9`、`a70e4fe8f`、`37f3fd1f3`、`ae8783293`。 - **地端 provider 內部**(work lease、排程器、sweeper、executor、建置關卡、registry、dind 打包;僅供營運參考):`6491285ef`、`bcee513d9`、`90c7cb8f1`、`acd18b7c1`、`542defb74`、`f232d0852`、`ec3d10407`、`50f1373f8`、`b7e54b141`、`b9909ad18`、`ec9dedd7a`、`2b31cd051`、`810484db2`、`d12a69949`、`761092f43`、`bd8d951a2`、`56e695fad`、`6c435f033`、`5741e685d`、`bf9f8ce37`、`d0d872302`、`3e8f8d4fc`、`c8387f7bf`、`f3e3388ae`、`9a62fb64b`、`fb75f449a`、`9f9a299f4`、`cf8cd1ff0`、`51f334f33`、`af83f77df`、`302fd9542`、`c2cc9ec74`、`c2d75c8a3`、`7867f3971`、`8047773ee`、`0119b6a50`、`fa6a3dcfd`、`beb9996a4`、`14ba3ab85`、`b8f16252a`、`aa0e1f609`、`5faa8bf52`、`410b1f53b`、`1fe95018c`、`24ab14393`、`76b048f28`、`6e2f05e2b`、`87fb335e3`、`8a2c6f337`、`30c27f6d3`、`a78b86674`、`bf42d7e23`、`d7ed040ae`、`5ffa0eaa4`、`50c52d071`、`c3a495845`、`e3ed8437f`、`8197701e2`、`3000ac16e`、`e7f7d9f45`、`fbdd515b1`、`ff9a4ca3d`、`28b6b3312`、`2a7da3852`、`8daf2a81a`、`9c688487f`、`08c3fcf77`、`6c40e71d5`、`3b5780264`、`cece7e91b`、`da366aaca`、`73c3455e7`、`3917dc89b`、`7c0bd4de6`、`abbeb5bad`、`e140b4c42`、`dc05cd786`、`bbe709b1a`、`93951d7f1`、`fa4c6b5f5`、`c53f60d98`、`8fa0aec70`、`28e9fdd5e`。 - **共用重構、效能與測試**(雲端回應逐位元不變):`ecfeeca63`、`8cb94350e`、`f58826b93`、`f8795d46f`、`04d436164`、`c5cbb1011`、`275c43f31`、`d78342af0`、`106469961`、`543ad2b73`、`d926fd9f1`、`37b70caf3`、`e8a0f7327`、`0ea91eaf5`、`b0602e136`、`b14239834`、`bb043e755`、`544737775`、`dac6cd145`、`44f0b9d4a`、`c51dc3406`。 - **Runner subreaper 嘗試,已在同一範圍內被巢狀命名空間取代:**`5d1acdf93`、`9847dc302`。 - **共用稽核路徑的其他模組**(`src/schemas/enums.py` 的非 sandbox 列舉、`infrastructure/sandbox/gcp/**/agent-workspace`、`src/routers/private/modules/server.py` 的私有模組掛載):`bbe49538b`、`ebf8c6d6e`、`c9c805528`、`d911258d8`、`cbae44462`、`6501074b0`、`b86990f66`、`3320fcacd`、`89677a14c`、`1206ffea9`、`68d62b320`、`f30db1557`、`50ff40109`、`79722e274`、`9819680b3`、`22a4e375f`、`8f3fd1757`、`ff1fdfa7b`、`3e238babc`、`037b51362`、`e1a955a22`、`a9d6127b6`、`71dc5749c`、`643bb249a`、`55998aecc`、`3cccd926a`、`7877bb6fd`、`c7c5f9fc2`、`90ad65f5d`、`e7b251ecc`、`27f9fe4be`、`49658aa62`、`92934f0c9`、`4aa4aea74`、`bfab0b7dc`、`e2f7d67d2`、`ae2254aa6`、`3e6e6e0e6`、`293488605`、`8df1ababe`、`b5ab0d2db`、`3916a119e`、`d38cca5a9`、`145c7f438`、`7ba15c58e`、`3a79fb629`、`4e4ecec5e`、`2649934fe`、`a9ecda8fb`、`2f7f9a7b6`、`c2a9d7dbe`、`f3f70735e`、`b8c8fe2b8`、`1b295eb93`、`a396ac72f`。 ## 2026-09-10 — production v5.10.0 **來源與上線:**後端 `9b8b95e598a7b1f606c030d8dd0f27f26c748c83` 為 v5.10.0 版本提交,執行程式碼與已驗收的 `9393591ae` 相同。[Production release](https://github.com/ShuChenAI/teamsync-backend/releases/tag/v5.10.0) 與[部署讀回](https://github.com/ShuChenAI/teamsync-backend/actions/runs/34374369505)已完成;本頁日期採 Asia/Taipei。以下描述目前契約,舊紀錄保留當時的上線範圍。 ### 環境 owner 與釘選受眾 環境編輯不再只限公司管理員。`POST /environments` 接受 `owner_scope=company|department` 與 `owner_id`;預設 scope 是 company,因此部門管理員必須明確選自己的部門。PATCH、context 上傳、版本/build 操作與 archive 都依環境 owner scope 授權,兩種 owner 共用公司的 active-environment 上限。 **前端調整:**保留 `owner_kind` 以及獨立的 `owner_scope`/`owner_id`,依目前授權顯示控制。部門環境可給同部門任務或公司擁有任務釘選,不可給其他部門任務或 Quick Run;Quick Run 需要公司擁有或 curated 環境。見[環境建立](/zh-TW/flows/environment-and-build.md)及[權限矩陣](/zh-TW/concepts/personas.md)。 ### 能力提示與 source 可見性 `GET /me` 提供當前管理能力與可建立的 owner scopes。`manageable_chatroom_scope=company` 配合空房間 id 陣列,表示所有現有/未來房間。Owner 目錄(`GET /tasks`、detail、versions)依 live audience grant 顯示;同公司身分不等於能看全部任務。 **前端調整:**顯示 source 編輯功能前先讀必填的 `source_visible`。False 表示原始內容被隱藏,owner view 也須看 `content_state` 才能區分已清除的內容。Room menu 即使由 owner 呼叫仍使用 borrower view。見[能力與管理查詢](/zh-TW/concepts/integration-contract.md)。 ### 跨房間歷史與管理清單 `GET /runs` 提供依可見範圍篩選的跨聊天室歷史,支援可重複的 room/task/status、`submitted_by_me`、queued-time 界線、`total` 與 keyset 分頁。Task/room binding、task secret、task approval、consumer secret 清單提供管理既有物件所需的 metadata;consumer 外層回應也有 `consumer_name`。 **前端調整:**原樣傳回 `/runs` cursor,使用其授權範圍內的 total。管理清單上限 2000 列並回 `truncated`,能力 id 清單上限 5000;binding 歷史預設 active,需要 retired/all 時明確傳入。`task_visible=false` 時隱藏不可用的任務身分資訊;secret/approval 清單仍縮限到可管理的 consumer scope,不回 secret 值。見[管理查詢](/zh-TW/concepts/integration-contract.md)。 ### 分享、重疊 grant 與 Enable 同意 部門任務可直接分享到同公司任一存活房間,舊的「先分享整個部門」步驟已不需要。Grant 依 room、department、company 順序解析;剩餘等價授權可保留 binding,不會悄悄接受不同的憑證來源政策。既有 run 仍固定在當時的 grant/generation。 **前端調整:**Share 與 Enable 仍是兩個操作。Enable、明確建立 binding、accept-version,可替操作者有管理權且已解析的 borrower-secret scope 記錄精確版本同意。缺少 slot 或他人管理的 scope,仍須設定/核准;GET、發布、分享、一般 run 不會核准。接受版本保留已接受的政策。見[任務受眾](/zh-TW/concepts/job-audience.md)與[Secret 同意](/zh-TW/concepts/secrets.md)(後端 #1260/#1262)。 ### 忠實重試與可判讀的失敗狀態 `POST .../runs/{run_id}/retry` 在省略 body/input 或 input 為 null 時,逐位元重放保留的來源 input;非 null `input` 可在相同版本 pin 上以新 run 身分執行新參數。保留的 input 不存在、已退役、不可讀或 digest 不符時回 409 `retry_input_unavailable`,不再改用空 payload。 **前端調整:**新的重試意圖使用新的 Idempotency-Key;保留期限阻止重放時,請傳明確 input。呈現有型別的等待/失敗欄位與保留的 provider 拒絕來源,除了 work exit code,也分別檢查儲存內容與實際下載。受限 key 在允許房間呼叫 retry 回 405;管理/Quick Run 則可能更早在 auth 回 403。見[執行/重試](/zh-TW/flows/run-and-results.md)及[錯誤](/zh-TW/reference/errors.md)。 ### Secret 驗證與作者 metadata Create/rotate 解密後最少 **4 個 UTF-8 bytes**,不足時回 422 `detail[].type=sandbox_value_too_short`。明確核准會檢查已發布版本、相符 digest、`risk_accepted=true` 與非空、正規化、合法的 slot 名稱;claim 仍要求精確 borrower 子集。 **前端調整:**驗證位元組數而非字數,將過短值與信封/解密錯誤分開處理。新的 slot declarations 採具名物件,保留可選 metadata;讀取側仍支援歷史宣告。請更新產生的型別,不要把宣告攤平成失去欄位的任意 map。見[Secret](/zh-TW/concepts/secrets.md)及[Schema](/zh-TW/reference/schemas.md)。 ### 執行區域與費用規劃 公司 settings 提供 live `selectable_regions`、可為 null 的有型別 `pricing`,以及唯讀 operator egress 政策;region-readiness map 保留 live codes。費率含已加價的 profile 單價、最低計費/finalization 時間與網路預留界線。 **前端調整:**從目前需授權的 settings 取得區域,分別保留已儲存選擇,依文件的預估上限公式計算且不要再加價。提交時仍會重新檢查費率/政策/預算;runtime placement 不代表資料落地保證。見[執行規劃](/zh-TW/concepts/integration-contract.md)。 ### 手冊與 Agent 匯出 英文與繁體中文教學、endpoint/schema 表、首頁 pin、權限矩陣一起同步;更正了目前章節裡 retry 遺失 input、menu cursor 永遠為 null、結果上傳初始化限制、環境只限公司管理員等舊描述。`llms.txt`、`llms-full.txt` 與各語言 Markdown twins 由相同頁面重新產生。驗證包含 typecheck、靜態匯出與全部內部連結;來源/production 讀回及歷史 live E2E 證據仍各自註明範圍。 ## 2026-09-05(四)— runner release 核准 + 歸檔版本回收(後端 #1162、SBW-08) - **Runner release 核准**(後端 #1162):task-bundle 的 runner 允許清單從環境變數搬進 operator 表,每次請求即時讀取。`GET/POST /root/sandbox/runner-releases`、`DELETE /root/sandbox/runner-releases/{sha}`;版本回應新增 `runner_release_approved` + `runner_release_next_action`;補上 `409 task_bundle_runner_unsupported`。stage image 發佈會自動核准自己的 commit,新的 runner release 不再讓新建環境卡住。 - **歸檔版本回收**(SBW-08):歸檔環境版本後幾分鐘內即回收其各區 Cloud Run Job、quarantine/execution 映像套件、attestation 與建置報告;孤兒 Job/套件也一併清掃。歸檔是終態。 - 建置線:提早離開共用 release Build 的 attempt(取消、逾時、步驟失敗)不再卡在 `cleanup_pending`。 ## 2026-09-04(三)— 環境建置約快 2 倍(後端 #1154) - 六個驗證 gate(static scan ∥ smoke → sign → attest → promote → final verify)現在是同一個 Cloud Build 的步驟,跑在 `E2_HIGHCPU_8`,接在 customer build 之後。輕量映像約 5–6 分鐘就 `ready`(原約 12),重的約 7 分鐘(原約 19)。進度欄位不變;`build_expected_seconds` 約 360。 ## 2026-09-04(二)— 任務管理 + 產出物檔案(後端 #1151,runner #1152) - `PATCH /tasks/{id}`(name/description/agent_enabled)與 `POST /tasks/{id}/archive`(整個任務歸檔:版本歸檔、binding 撤銷、排隊中的 run 取消、分享紀錄保留)。 - `GET /environments/{id}/context-uploads`:唯讀列出已完成的建置 context 上傳,附 `used_by_version_ids`。 - 任務可以產出檔案:放在 `$TEAMSYNC_ARTIFACTS_DIR`(`/workspace/artifacts`)底下的東西會變成該次執行的產出物 bundle——`GET .../artifacts` 逐檔列出,`POST .../artifacts/{artifact_id}/public-link` 逐檔分享。整包拒收規則寫在 runtime 契約的 `artifacts`。 - context-uploads 列表補正(#1156):已被版本吃掉的上傳也會列出,並以 `used_by_version_ids` 連回版本。 ## 2026-09-04 — 前端追蹤項(後端 #1149) - `GET /chatrooms/{id}/runs`(游標 `(queued_at, id)`,最新在前)、`GET .../menu`(依 `task_id`)、`GET .../runs/{rid}/artifacts`(依 `relative_path`)都收 `page_token`;三條的 `next_page_token` 現在是真的游標。 - `POST /bindings/{id}/accept-version` 重新計算 `update_available`,不再寫死 `false`。 - `SandboxTaskVersionResponse` 新增 `environment_id`/`environment_name`。 - `SandboxBuildAttemptResponse` 新增 `retryable_until`(版本在 `retryable_failed` 期間的 7 天重試視窗)。 - `build_ready`/`build_failed`/`build_cancelled` 有通知 producer(環境 owner-scope + 公司管理者,繁中文案),並新增 `curated_environment_published` 事件(通知有釘該環境的公司)。 - 逐檔產出物公開連結:`POST`/`DELETE .../artifacts/{artifact_id}/public-link`;`GET /public/sandbox/artifacts/{token}` 串流該檔案驗過 digest 的位元組區間。 - 手冊自我矛盾修正(執行期網路、`script-uploads`)——見下方 09-03 條目。 ## 2026-09-01 — FE 疑問修正 + 全站 docs↔implementation 稽核 vs 上一支釘 `7011b318d` 前端 2026-08-31 的沙盒疑問清單觸發了這一輪:先修 OpenAPI 契約(後端 PR #1100,待合——`SandboxRuntimeContract` 新增 `working_directory`/`network` 欄位、`work_command` 欄位描述的 `python3 work.py` 壞例子改成 `python3 /workspace/work.sh`),再對全站 21 頁跑一輪 docs↔implementation 稽核:**133 個經對抗驗證確認的修正全數套用**(64 遺漏、50 矛盾、17 過時、2 雙語分歧)。影響最大的幾個: - **認證**:缺 credential 是 **418**(`Could not validate credentials`),不是 401;受限金鑰打 `/tasks` 路徑是認證層 **403**,不是 404。 - **runtime 契約**:補 `working_directory`(cwd 名義上 `/workspace` 但不保證——一律絕對路徑)與 `network`(執行期無對外網路;建置期 `RUN` 可連公網)。 - **建置**:`source_digest` 單獨送**不會**比對已完成物件(錯誤晚到建置期才爆);Dockerfile 必須是封存**根層**的一般檔案;**任何** `# syntax=` 行都拒(不只遠端);解壓上限 5 GiB/100k entries;`retry-build` 不吃 body、不沿用 region/profile;503 `build_admission_fenced`;complete 未被版本採用會佔住 staging slot。 - **執行**:run retry **不重放 input**(新 run 拿到空 `{}`,`input_digest` 卻對齊原 run——要重放請重新 `POST /runs`);產出物下載回的是 5 分鐘 capability token。 新頁:[本機重現與除錯](/zh-TW/flows/local-debug.md)——用本機 docker harness 重現執行契約(固定檔名、同一個 sh session、兩個環境變數)、Node `node_modules` 陷阱、與真沙盒的差異表。 ## 2026-08-31 — job contract、共享 secret slot 政策、永久公開連結、傳輸加密 vs 上一支釘 `e94b54c3d` Master 自上一支釘之後上了五波租戶面功能,加一波大型建置路徑穩定性修正(PR #1033–#1087,總共 41 個 sandbox 範圍的 PR——完整清單見下方)。`e94b54c3` 手冊裡以下句子現在是錯的: - 建置 context 裡的 Dockerfile 必須 `FROM` `alpine:3.20`/`busybox:1.36`/`debian:bookworm-slim` 之一;`scratch` 在 compose 會被拒。 - Base 映像裡的 CVE(例如 Alpine:3.20)仍可能讓掃描失敗,`error_code=vulnerability_reject`。 - 沒有辦法宣告 work script 怎麼被呼叫、預期什麼 JSON input 形狀,也無法在提交 run 之前先驗證那個形狀。 - 分享 grant 除了讓借用方自己提供值之外,沒有任何 secret 管理機制。 - run 的 log/output 永遠只能透過已驗證租戶走 5 分鐘的產出物下載 capability 取得。 - `POST /secrets` / `POST /secrets/rotate` 接受明文 `value`。 正確替代:[任務、發布與綁定](/zh-TW/flows/tasks-and-bindings.md)(job contract)、[Secret 與授權](/zh-TW/concepts/secrets.md)(slot 政策 + 傳輸加密)、[內容、Digest 與下載](/zh-TW/concepts/content-and-downloads.md)(公開連結)、[建立環境並建置](/zh-TW/flows/environment-and-build.md)(base 映像與 CVE 立場)、[任務受眾](/zh-TW/concepts/job-audience.md)(分享受眾 fail-fast)。 ### Job contract(PR #1065) - **#1065** — `work_command`(自由格式的驅動指令,materialise 進 `/workspace/driver.sh`,在 `startup.sh` 之後被 source;留空 → `. /workspace/work.sh`,跟舊行為位元組相同)、`input_schema`(JSON Schema draft 2020-12;提交的 `input` 違反它就是 422 `input_schema_violation`,**在 dispatch 之前**擋下;`input_example` 在**這次草稿建立/更新呼叫**時必須通過它,否則 422 `job_contract_invalid`——發布永遠不會重新驗證)、一個 `runtime_contract` 區塊(固定呼叫方式 + `TEAMSYNC_INPUTS_FILE`/`TEAMSYNC_OUTPUT_FILE` 環境變數名稱),出現在房間選單(`SandboxMenuItemResponse`——不論呼叫者是誰都是遮蔽過的預設值)、版本詳情回應(owner 看得到真正的 `work_command`)與 input-preview 回應上,一條 multipart 的 `POST .../versions/{vid}/script-file` 上傳路由(owner-manager、只限草稿),以及一個 `POST .../versions/{vid}/input-preview` 試跑端點(精確位元組 + digest + schema 判定,不會 dispatch 任何東西——公司裡**任何**非受限成員都能對草稿或已發布版本呼叫,不是 owner-manager 專屬)。`work_command` 對 borrower 禁止(遮蔽成預設值,就連選單上 owner 自己看到的也是遮蔽值);`input_schema` borrower 看得到。Agent 工具選單項目帶 `input_schema` 與 `secret_slots`,但**不帶** `runtime_contract`。 ### Dispatch 出包鏈(PR #1066–#1072) - **#1066** — `sandbox_cloud_run_quota_leases.weight` 從 `INT` 加寬成 `BIGINT`。記憶體維度的 admission 權重是位元組數(標準 profile 的 2 GiB 租約會溢位有號 INT);自 ~08-26 起每個 dispatch 都卡在 `starting`。 - **#1067** — 給 `sandbox_job_executor` 自訂 IAM role 加上 `run.jobs.runWithOverrides`——跟 `run.jobs.run` 是不同的權限,每個帶 env override 的 dispatch 都需要它;之前每個 live dispatch 都 403 `provider_forbidden`。 - **#1068** — **執行中 job 的網路對外連線現在無條件停用。** Cloud Run Sandboxes launcher 的 `--allow-egress` 要建立 veth pair,需要 root;runner 依設計拒絕以 root 執行。目前沒有逐任務的 opt-in。 - **#1069** — REST 手動提交現在會在建立 run 之前,把 input JSON staging 進 owned-object store。之前從來沒做過——每個 REST 提交的 run,work script 都會默默看到空 input。 - **#1070** — 修掉 #1069 造成的自我 deadlock:staging 現在發生在拿公司 policy row lock **之前**,不是在 lock 底下。 - **#1071** — 手動 run 的 input staging 拒絕現在會記錄完整例外鏈,並在 422 回應裡點名根本原因。 - **#1072** — 手動 run 的 input staging 現在用 scoped idempotency key 的確定性 `uuid5` 當 subject,而不是原始(無界限)的 key——後者曾經溢位 `SandboxOwnedObject.subject_id`(`String(36)`)。 ### 永久公開連結 + 共享 secret 管理(PR #1074–#1081) - **#1074** — **永久公開結果連結**:`POST`/`DELETE .../chatrooms/{id}/runs/{run_id}/{log|output}/public-link` 為一次 run 的 log 或 output 鑄造/撤銷一個沒有 TTL、不需驗證的 `GET /public/sandbox/artifacts/{token}` 下載連結。 - **#1075** — 修掉一個 `NameError`(`_reject_restricted` 定義錯了模組)——之前在 staging 上每次 mint/revoke 呼叫都會 500。 - **#1076** — **共享 secret slot 政策**:分享 grant 可以設定 `secret_slot_policies`(`{slot_name: "borrower"|"owner"|"owner_overridable"}`),只能在建立時設,要改就重新分享。 - **#1077** — **Secret 傳輸加密**:`POST /secrets` 與 `POST /secrets/rotate` 現在**只**接受混合 AES-256-GCM + RSA-OAEP-SHA256 的 `encrypted_value` 信封(`{encrypted_key, iv, ciphertext}`,都是 base64,金鑰來自 `GET /public/info/model_key/public_key`)。明文 `value` 一律拒絕——四種可分辨的 422 錯誤代碼(`sandbox_plaintext_value_rejected`/`sandbox_encrypted_value_required`/`sandbox_transit_keypair_missing`/`sandbox_encrypted_value_undecryptable`)。 - **#1078** — 修正了 `GET /public/info/model_key/public_key` 的描述,之前還宣稱明文「可以當備援接受」。 - **#1079** — 把部門擁有的任務直接分享給受眾之外的聊天室,現在在分享建立時就 fail-fast **409 `share_target_not_in_audience`**,不會是一個悄悄永遠用不到的 grant。 - **#1080** — Provider secret 路徑現在按任務分段(加上 `.../tasks/{task_id}`)——兩個不同任務共用同一個 consumer scope 與 slot 名稱,不再會撞上同一條未分段路徑。 - **#1081** — Manifest 揭露現在會重新檢查 owner 出借 pin 的 grant 存活狀態。一筆 grant 若在認領與之後的 manifest 讀取之間被撤銷,現在會被拒絕(控制面 409 `share_grant_revoked`),而不是仍然解析出那個過期的 pin。 ### 建置路徑穩定性(PR #1033–#1055) 一條 20 個 PR 的鏈,全部是在 2026-08-21 到 2026-08-25 之間於 live 環境發現的,重新打通了租戶自訂建置的快樂路徑,並縮短了 rollout/診斷時間。兩個對租戶可見的結果已在上方點名(base 映像允許清單移除、CVE 閘門移除);其餘都是維運/CI 穩定性修正。 - **#1033** — 打通自訂建置:Dockerfile base 允許清單自 08-21 起卡住每次建置(例如 `node:20-alpine` 被拒),且沒寫回任何原因——只有一個乾巴巴的 `wrapper_fault`。 - **#1035** — Compose 接受 Node 系映像;每個 base 映像都會帶的一個無害 `/etc/mtab` symlink,之前被誤判成保留路徑逃逸。每個 builder 拒絕現在都會點名自己。 - **#1037** — Stage 映像發布從四個手動步驟改成一個 CI job,背後有一個真的 admission 圍欄(之前的「圍欄」只是一個沒有強制力的貼上字串)。 - **#1038** — 依對抗性 review 加固了那個 CI 圍欄:每次長等待前都重新取得 re-entrant hold(之前的 TTL 可能在 rollout 途中過期、悄悄開了圍欄),並修正 rollback 的 annotation 處理。 - **#1040** / **#1041** — Compose 直接接受任何公開 base 映像(`node`、`golang`、`python-slim`、`debian`、`ubuntu`、`busybox`、`nginx` 全部 live 驗證過);CI admission 圍欄現在以 Job 執行。 - **#1042** — CVE 掃描結果重新歸類為只是參考——`HARD_FAILURE_SEVERITIES` 現在是空的,OPA policy 也沒有嚴重度規則了,所以沒有 CVE 能擋建置。live 掃描階段現在一律把拒絕回報成 `policy_reject`;`vulnerability_reject` 仍留在封閉的 receipt 集合與 terminal-class 對照表裡(程式碼沒刪),但 live 路徑已經到不了那裡。SBOM/漏洞報告仍會產生並附掛。 - **#1043** — 把 stage rollout 從約 22 分鐘砍到約 6 分鐘:一旦資料庫已經證明沒有進行中工作,就跳過一個 600 秒的 pod-grace 等待。 - **#1044** — 一個沒留下 receipt 就死掉的 stage,現在會印出 `stage_error gate= code=`,而不是靜靜地 exit 2。 - **#1045** — 修好 `attest` 閘門:Container Analysis v1 不接受 client 提供的 occurrence id;這個閘門自從上線就一直失敗。 - **#1046** — Rollout 現在會強制刪除已排空的 controller pod,而不是用一個縮短過的等待去跟 deployment 自己的 600 秒 termination grace 賽跑。 - **#1047** — 修掉一個洩漏的 context 上傳 staging slot(`complete_upload` 從沒釋放過它)——之前卡住每個租戶發布 E2E,撞 `staging_slot_exhausted`。 - **#1048** — Settled-lease 的 staging 上限檢查改寫成 correlated `EXISTS`(之前是 O(公司終身上傳數量) 的 `IN` 清單)。 - **#1049** — Smoke 閘門不再要求 `bin/sh`(usrmerge 系發行版如 Debian/Ubuntu/Fedora 只有 `usr/bin/sh`);區域 Job 建立現在指向 promote 實際寫入的東西。 - **#1050** — 一個重試中的 `regional_readback` 閘門現在會記下真正的原因(例如 `provider_forbidden`),而不是用一個只點名計時器的代碼結案。 - **#1051** — 一個因期限觸發的取消,現在會保留閘門已經記下的原因,而不是拿一個乾巴巴的取消標記蓋掉它。 - **#1052** — Buildkit scratch 目錄的收尾,不再因為 Python `TemporaryDirectory` 清理時的 `EPERM` 而讓一個原本已完成的建置失敗。 - **#1053** — Promote 的 receipt 現在回報真正的搬移位元組計數,不只是邏輯/manifest 大小。 - **#1054** — 把 EPERM 收尾修正做得更持久(純 `mkdtemp` + best-effort `rmtree`);跨套件的 blob 複製現在會重新雜湊並登記到目標套件下,而不是相信一個僅限套件範圍的存在性檢查。 - **#1055** — 給 `sandbox-job-provisioner` 加上 `sandbox-execution` 的 Artifact Registry 讀取權——Cloud Run 建立時的映像可讀性檢查之前對每個租戶版本都失敗封閉。 ### 控制面穩定性(PR #1082–#1087) - **#1082** — 每個控制面拒絕(claim/bootstrap/progress/manifest/……)現在都會記錄 stage、subject、拒絕代碼與狀態——之前只有裸的 HTTP 狀態碼會進 access log。 - **#1083** — 把 sandbox-controller pod 釘住不讓 GKE Autopilot autoscaler 驅逐(`cluster-autoscaler.kubernetes.io/safe-to-evict: "false"`)——整併曾經每 5–7 分鐘就殺掉這個單副本 controller,悄悄讓已 staging 的 dispatch 卡死。 - **#1085** — Sandbox-controller 的 KEDA scaler 從 `gcp-pubsub` 換成 `gcp-stackdriver`——pubsub scaler 已棄用,且它固定的回看視窗對這個低流量的訂閱回傳空值。 - **#1086** — 一個背景 reaper 現在會救回卡在 `starting`/`provider_submit_staged` 的 run(controller 在 dispatch 途中掛掉時,重新驅動同一條恰好一次的送出路徑)。取消一個尚未認領/正在競速的 run,現在會可靠地立刻釋放它的排隊**與**並行 lease,而不是偶爾洩漏。 - **#1087** — Capacity-keepalive 刷新現在會在快照過期前約 60 秒觸發,而不是剛好卡在過期門檻上。Dispatch 拒絕與 sweeper 執行緒當機現在都會被記錄,不再靜默;controller 自己的 log 現在真的會送到 stdout(之前打到一個沒設定的 root logger 就消失了)。 **仍不是前端面** - `/sandbox-control/*`、`/root/sandbox/*` 請求本體。 - Cloud Build tags、stage 映像 digest、CI admission 圍欄內部、GKE Deployment/ScaledObject YAML、KEDA trigger 設定。 - 原始 R2 key/presigned URL/secret 值/writeback HMAC key/provider secret 路徑。 ## 2026-08-23 — 實作建置路徑 vs 上一支釘 `d99b4cad6` #975 之後 master 上了租戶 compose/scan 實作路徑與可持久化的建置取消(到 #1023)。租戶**路由清單與列舉沒變**。#975 手冊裡以下句子現在是錯的: - 取消仍在排隊的建置 attempt **當下就結算**。 - Writeback 把 `TEAMSYNC_CT_WRITEBACK_URL` 注入 ordinary env(只有 token 走密封通道)。 - 任意公開 `FROM` 映像都能 compose。(`scratch` 不在允許清單;alpine:3.20 掃描常失敗。)**(已於 2026-08-25 被取代——見上方 2026-08-31 的條目。)** - 模組能用之前,公司必須先打開 `SANDBOX_ENABLED`。 正確替代:[建立環境並建置](/zh-TW/flows/environment-and-build.md)、[五分鐘跑一次](/zh-TW/get-started/quickstart.md)。 **實作路徑補上的(#1012–#1025 時期,釘在 #1023)** - 租戶自訂建置是真的 Cloud Build compose + scan。Dockerfile `FROM` 必須是 `alpine:3.20`、`busybox:1.36`、`debian:bookworm-slim`(ECR public library 標籤)。其他 base 會 compose 失敗。掃描仍可能 `customer_failed`,`error_code=vulnerability_reject`/`policy_reject`。**(已於 2026-08-25 被取代——見上方 2026-08-31 的條目。)** - `POST .../builds/{id}/cancel` 一律圍欄 stage capability 並寫入可持久化的 provider-cancel intent。輪詢到 `terminal_class=cancelled`。排隊中取消**不是**同一請求內結算。 - Writeback 的 URL **與** token 都走密封 secret 通道。兩者都不能進 `ordinary_env`(`TEAMSYNC_` 是保留前綴)。 - `SANDBOX_ENABLED` 預設 **true**。`false` 才是 kill switch(503 `sandbox_disabled`),不是啟用單。 - 快路徑:釘 live `system_curated` 目錄 **SCFG Standard**(每個公開區域都是 `state=ready`)。除非你需要自訂映像,否則跳過租戶上傳/建置。 - 未完成的 context 上傳會漏 staging lease。撞上 20 個 live slot 會 429 `staging_slot_exhausted`,連 scan report 寫入也會卡住。Abort 未完成 session;不要對 init 空轉重試。 **仍不是前端面** - Cloud Build tags、stage 映像 digest、`/sandbox-control/*`、`/root/sandbox/*` 請求本體。 ## 2026-08-19 — PR #975 + #964 vs 上一支釘 `076c1278` 上一支手冊釘是 #956 的 merge。之後 master 進了自訂表格 writeback(#964)以及部門任務/上傳/歷史面(#975)。 **不要再留舊故事。** 以下句子現在是錯的: - 目錄任務可以用 `owner_scope=chatroom` 建立。 - 建立版本必填 `source_digest`;前端永遠不上傳位元組。 - 分享就會把任務放進 Agent 選單。 - `output_policy` 是不透明 JSON/只有聊天室擁有才能 writeback。 - Staging 20/20GiB/每分鐘 5 次只在控制面。 - 租戶面除了 queued-run 沒有 429(上傳 429 存在)。 - `task.agent_enabled` 是 Agent 開關。 正確替代:[任務受眾](/zh-TW/concepts/job-audience.md)。 **#975 補上的** - 建立只有 department/company;`POST /tasks` 送 `chatroom` 是 422。隱藏 Quick Run 父任務仍在。 - 分享對象:整間公司(含未來部門)、任一部門、擁有部門或已有 grant 的部門裡的房間。 - 房間 `GET/POST .../granted-jobs.../enable|disable`。選單 + Agent = 已授予 **且** 已啟用,每個呼叫者同一份清單。手動 REST 仍走 `can_execute_manually`(share + 成員資格;不要求啟用)。 - Context 上傳:init → PUT `X-Sandbox-Upload-Capability` → complete → `owned_object_id`。單一部分、1 GiB、15 分鐘 capability。 - `GET /public/info/sandbox/regions` 與 `/profiles`。`xlarge` 租戶不可選。 - Company manager `GET /tasks/{id}/runs` + `/numOfData`(`offset`/`limit`)。 - 擁有部門的 writeback 即使房間是透過 share 進來,只要在擁有部門裡就會鑄憑證。公司擁有的任務不能發布 `output_policy`。 **#964 補上的(以 #975 之後的狀態為準)** - 有型別的 `SandboxOutputPolicy`。執行期把 `TEAMSYNC_CT_WRITEBACK_URL`/`TEAMSYNC_CT_WRITEBACK_TOKEN` 注入**密封 secret 通道**(不在 REST,也不在 `ordinary_env`)。 - 只有 JWT 使用者;API key/受限金鑰/社交客戶端不鑄。 - Trigger 路徑拒絕 `output_policy` 版本。Agent 仍走確認。 - 403 `output_policy_author_denied`/`writeback_authority_denied`;409 `output_policy_owner_scope_unsupported`/`output_policy_unsatisfiable`;503 `sandbox_writeback_key_unavailable`。 **仍不是前端面** - `/sandbox-control/*`、`/root/sandbox/*` 請求本體。 - 原始 R2 key/presigned URL/secret 值/writeback HMAC key。 ## 2026-08-19 — 更早一次相對 2026-08-13 的缺口補齊 2026-08-13 的手冊已有租戶路由清單。那次補了 schemas、agent 工具、通知、自訂表格 trigger、下載 TTL、取消來源,並修了若干內部矛盾。那些頁還在;這支釘改寫被 #975 證偽的頁。 ## 如何更新這支釘 ```bash git -C ../teamsync-backend fetch origin master git -C ../teamsync-backend log -1 --format='%H %s %ci' origin/master git -C ../teamsync-backend diff 172a7f80bdf4dbdffdc018eb08bfa42c62bb485c..origin/master -- \ src/routers/private/modules/sandbox \ src/routers/private/modules/server.py \ src/routers/public/info/server.py \ src/routers/public/sandbox_artifacts.py \ src/routers/public/custom_tables_callback \ src/routers/root/sandbox.py \ src/routers/sandbox_control \ src/schemas/sandbox.py src/schemas/enums.py \ src/components/sandbox \ src/components/tools/custom/sandbox \ src/crud/sandbox \ src/crud/custom_table_triggers.py \ src/database/models/sandbox.py src/dependencies/sandbox.py \ 'src/tasks/sandbox_*.py' \ src/workers/sandbox_onprem src/workers/sandbox_controller.py \ sandbox/runner sandbox/build sandbox/onprem sandbox/e2e-js \ docs/runbooks/sandbox-operations.md \ infrastructure/sandbox \ infrastructure/app/gcp/k8s/base/sandbox-controller-deployment.yaml \ infrastructure/app/gcp/k8s/base/sandbox-controller-scaledobject.yaml ``` 要分類的 commit 清單用 `git log --oneline ..origin/master -- <同一組路徑>`,再加上碰到路徑含 `sandbox` 的任何檔案的非 merge commit(例如 `scripts/sandbox/` 底下的地端安裝套件與 `docker-compose.sandbox.yaml`),以及訊息提到 Sandbox 的 commit(`git log -i --grep=sandbox`)。`src/schemas/enums.py`、`src/crud/custom_table_triggers.py` 與 `infrastructure/sandbox/gcp/` 也放了其他模組(自訂表格、Edge Workers、agent workspace);這些 commit 請歸類為本模組以外,不要略過不記。 ## 2026-09-03 —— 對 master `0203b987` 的實機驗證 由 staging 實機執行(GCP log + 9 次執行的 JS 套件)補上的事實: - 任務包:`POST /tasks/{id}/bundle-uploads`(zip / tar / tar.gz,≤20 MiB),解壓到 `/workspace`;runner 保留的根目錄檔名 → 422 `bundle_path_forbidden`。 - runtime 契約修正:`runtime_egress_enabled` 開啟時**有**對外網路(pip / npm / go get 在工作階段可用);只有 `/workspace` 與 `/tmp` 可寫。~~`PATH` 極簡;`python -m venv` 會失敗~~——已被 #1145 取代:work shell 沿用映像的 `PATH`(沒設時採慣用預設),官方 `python:*` 映像上 `venv` 可用。 - `SandboxRunDetailResponse` 的失敗來源:`error_code`、`error_detail`、`exit_code`(精確,含 launcher 壓縮過的結束碼)、`failure_stage`、`failure_source`、計量位元組。 - ~~每分鐘 5 次上傳額度下提交 → 422 `input_not_stageable`;新版本第一次執行 1–5 分鐘~~——已被 #1140/#1145/#1146 取代:run `input` 不再計入額度(連續提交全部 `queued`),第一次執行約 10–35 秒到 `running`。環境建置仍約 11–19 分鐘(七個循序驗證階段)。 - ~~已知限制:runner 上傳結果可能在該額度下被略過(`completed` + `has_output=false`)~~——#1140 已修(結果上傳不計入額度),#1145 再補強(輸出遺失時以 `platform_failed`/`result_upload_failed` 結束,不會是 `completed`)。 - 參考實作:`teamsync-backend` 的 `sandbox/e2e-js/`。 # Locale: en # Sandbox Frontend Handbook TeamSync · Sandbox Sandbox Frontend Handbook Package a user's code into an environment, send it to the deployment to build and run, then pull the logs and output objects back into the frontend. This handbook is how the frontend wires that up. ## How to read this Start with **Get started** and run the whole happy path once: pin the curated **SCFG Standard** environment (or upload+build a custom image) → create a **department** job → publish → share → **room-enable** → submit a run → fetch results. After that, the rest is reference material. **Concepts** is worth reading before you wire anything up. Almost every call is **`/private/module/sandbox`**; region/profile dropdowns are `GET /public/info/sandbox/{regions,profiles}`. The things integrations most often get wrong are — **catalog jobs are department/company, not chatroom** ([Job audience](/en/concepts/job-audience.md)), **unauthorized usually returns 404** (wrong owner-scope is **403**; `chatroom` create is **422**), **share is not the Agent menu**, **there is no streaming (you poll)**, and **two different capability tokens** (15-minute upload vs 5-minute download — neither is a URL). Permissions are a ladder, not "company manager for everything" — see [Personas](/en/concepts/personas.md). **Flows** are task-shaped, one page per thing you were asked to ship. **API reference** lists endpoints, full request/response fields, the five agent tools, enums, and errors. **Changelog** pins the backend SHA this handbook was audited against. [Get started → The shortest full path from a token to a run result.](/en/get-started/index.md) [Concepts → Control vs data plane, the resource model, state machines and polling, content and downloads.](/en/concepts/index.md) [Flows → Create environments and build, tasks and bindings, run and fetch results, agent confirm.](/en/flows/index.md) [API reference → Endpoint catalog, field-complete schemas, agent tools, enums, errors.](/en/reference/index.md) ## For LLMs / agents This handbook ships a machine-readable text surface (same convention as [custom-tables-docs](https://custom-tables-docs.pages.dev/llms.txt)): - [`/llms.txt`](/llms.txt) — index: reading order, tenant-API contract, links to every `.md` twin. - [`/llms-full.txt`](/llms-full.txt) — full bilingual corpus (zh-TW first, then English). - Every page also has a Markdown twin, e.g. [`/en/concepts/personas.md`](/en/concepts/personas.md). ## Where the content comes from Every page is written against the backend source at `origin/master`. The current pin is in [Changelog](/en/changelog/index.md) (`172a7f80bdf4dbdffdc018eb08bfa42c62bb485c`, release v5.21.0, 2026-10-08). Frontend endpoints come from `src/routers/private/modules/sandbox/` (base `/private/module/sandbox`) plus `GET /public/info/sandbox/*`; field tables from those routers plus `src/schemas/sandbox.py`; enum values and limits from `src/schemas/enums.py`, `src/components/sandbox/constants.py`, and `src/components/sandbox/storage.py`. Agent tools live in `src/components/tools/custom/sandbox/`. The control plane (`/sandbox-control/*`) and root (`/root/sandbox/*`) are not frontend-facing. If the backend and this handbook disagree, the backend source wins — please report it so we fix the doc. For v5.21.0 integration changes (a job's `output_policy` can now run a Custom Tables Command, runner credentials now last the whole `timeout_seconds`, the runner retries its completion report), start with the [changelog](/en/changelog/index.md), then [Job output as a Command](/en/flows/command-output.md). The v5.10.11 changes (queue position/ETA, queue 429 bodies, Quick Run input, provider-filtered regions and on-prem deployments) are in the same changelog and in [capabilities, discovery and runtime planning](/en/concepts/integration-contract.md). # Authentication and base URL ## Base URL Almost every frontend sandbox endpoint lives under: ```text {TEAMSYNC_API_BASE}/private/module/sandbox ``` (Note that `module` is singular.) Tenant paths later in this handbook omit this prefix — for example, "`GET /settings`" = `GET {BASE}/private/module/sandbox/settings`. **Exception — no `/private` prefix, no auth:** `GET /public/info/sandbox/regions` and `GET /public/info/sandbox/profiles`. Do not put those under `/private/module/sandbox`. ## Authentication Carry standard TeamSync credentials. `get_current_user` then `get_sandbox_principal` resolve the acting principal. | Header | When | | --- | --- | | `Authorization: Bearer ` | User login (OAuth2 password bearer). | | `X-Api-Key: ` | API key. Same header `get_current_user` already accepts. Sandbox re-reads it to attach scope / fingerprint / allowed rooms. | Send one of the two. Missing both → the shared credential exception, which is **HTTP 418** (`Could not validate credentials`, with `WWW-Authenticate: Bearer`) — **not 401**, and not a sandbox 404. Client re-auth/token-refresh logic must branch on 418; a 401-only handler will never fire. API keys are also origin-checked against the key's `domains` unless the domain list contains a bare `*`. The backend resolves the principal via `get_sandbox_principal`: | Field | Value | | --- | --- | | `auth_method` | `jwt` (user login) or `api_key` | | `principal_type` | `user` or `social_media_client` | | Carries | `company_id`, `role`, `api_key_scope`, `allowed_chatroom_ids`… | ### Important restriction on API-key scope **A `Sandbox` scope API key is a "restricted key"** and only allows: the chatroom menu, manual submission, querying your own runs (list/status/content/artifacts/download), and cancelling your own runs. Management surfaces the key cannot use: quick-run, retry, environments (including context upload), tasks (including company run history), granted-jobs, bindings, secrets, settings, root. Those need a **user JWT** or a **`Full` scope** key (`Full` is still subject to tenant/role restrictions). **Status codes:** the first auth gate returns 403 for Quick Run, environment/task management, missing room paths and rooms outside the key allowlist (authoring upload handles retain their explicit 404). A room-addressed route excluded by the Sandbox route registry, such as retry or granted-jobs, returns 405. A restricted principal that reaches a denying Sandbox router sees 404. These responses do not grant permission to retry through another route. ## Permissions (roles) Who can mutate what depends on **owner scope**, **consumer scope**, and the **chatroom-manager ladder** — not "every mutation needs a company manager". - Environment mutations use owner-scope management (company, or the owning department). `GET`/`PUT /settings` remains company-manager only (`role ≥ 3`). - Creating a catalog job: company-owned → company manager; department-owned → that department manager (or company manager). **`owner_scope=chatroom` on `POST /tasks` is 422.** A department/company scope the caller cannot own returns **403 `owner_scope_forbidden`**. - Room-enable, bindings, secrets, and quick-run follow the chatroom / consumer ladder; a department manager cannot force-enable another department's room. Share vs enable: [Job audience](/en/concepts/job-audience.md). - Unauthorized identifiers **usually return 404**, never revealing whether a resource exists. So a 404 often means "no permission" rather than "doesn't exist". The full matrix is in [Personas and permissions](/en/concepts/personas.md). ## Kill switch → 503 `SANDBOX_ENABLED` defaults **true**. When it is `false`: all **non-GET mutations** return **503 `sandbox_disabled`**; the exception is paths ending in `/cancel` or `/download`, which stay available. GET/HEAD/OPTIONS are always allowed through. ## Idempotency-Key (required for submissions) The following endpoints **must** carry an `Idempotency-Key` header, otherwise they return **422 `idempotency_key_required`**: - `POST /chatrooms/{id}/runs` (submit a run) - `POST /chatrooms/{id}/runs/{run_id}/retry` - `POST /chatrooms/{id}/quick-run` - `POST /secrets`, `/secrets/rotate`, `/secrets/revoke` Rules (`MAX_IDEMPOTENCY_KEY_CHARS = 128`): non-empty after strip, ≤128 characters, no NUL. A UUID is a good client default; the server does not require UUID syntax. Re-sending the same key returns the same result (safe retry). Use a fresh key for each new intent. ## Request body rules - All request bodies are `extra="forbid"`: an unknown field → **422**. - The run-submission endpoint has a size guard that runs before reading the body: an oversized `Content-Length` → **413 `request_too_large`**. Next: [Run it in five minutes](/en/get-started/quickstart.md). # Get started This section walks you through the entire path once. Two pages: 1. [**Authentication and base URL**](/en/get-started/auth.md): get a token, learn the base URL, the API-key scope, and the meaning of 404/503. 2. [**Run it in five minutes**](/en/get-started/quickstart.md): pin **SCFG Standard** (or upload+build), then job → run → results. ## Prerequisites - A set of TeamSync credentials (a user JWT, or an API key). See [Authentication](/en/get-started/auth.md) for how to obtain them. - Sandbox is **on by default** (`SANDBOX_ENABLED` defaults `true`). The kill switch is `SANDBOX_ENABLED=false`: non-GET mutations return **503 `sandbox_disabled`**, except paths ending in `/cancel` or `/download`. - Environment writes follow company/department owner scope; company settings require a company manager. Use [Capabilities and discovery](/en/concepts/integration-contract.md) for the current UI capability probe. ## Mental model (30-second version) ```text SCFG Standard (system_curated, ready) ─┐ ├→ department/company job → publish → share → room-enable → menu → run → artifacts Environment → upload → Version → ready ─┘ ``` - Almost every call is **`/private/module/sandbox`**. Region/profile dropdowns are `GET /public/info/sandbox/{regions,profiles}`. - Catalog jobs are **department or company** owned. Share is not Agent-enablement. See [Job audience](/en/concepts/job-audience.md). - There is **no streaming** — you poll. - Submitting a run must carry an **`Idempotency-Key`**. - Downloading an artifact gets you a **short-lived capability token**, not a URL. Finish reading [Authentication](/en/get-started/auth.md) first, then follow [Run it in five minutes](/en/get-started/quickstart.md). # Run it in five minutes The examples omit the prefix `{BASE}/private/module/sandbox` unless noted. Authenticate with `Authorization: Bearer` or `X-Api-Key`. Company-owned environments require a company manager; department-owned environments and uploads require that department’s manager (company managers may manage both). Creating a catalog job is **department- or company-manager** work — **not** chatroom-owned. Room-enable is the **chatroom-manager ladder**. Submitting a run is membership + applicable share. See [Personas](/en/concepts/personas.md) and [Job audience](/en/concepts/job-audience.md). ## 0. Confirmation catalogs ```bash GET /public/info/sandbox/regions # no /private prefix; no auth GET /public/info/sandbox/profiles # skip ids with tenant_selectable=false (xlarge) ``` ## 1. Fast path — pin **SCFG Standard** (skip upload/build) `GET /environments` includes `owner_kind=system_curated` rows. The live catalog is named **SCFG Standard**. Take a version with `state=ready` and jump to [step 5](#5-create-a-department-job-and-publish-a-version). Ordinary users never create environments. ```bash GET /environments?lifecycle=active&page_size=100 GET /environments/{environment_id}/versions?lifecycle=active&page_size=100 # pick system_curated / name "SCFG Standard" / version state == "ready" ``` Only do steps 2–4 if you need a **tenant-built** image. `FROM` any public base image — there is no fixed allowlist, and `scratch` is legal. CVE findings are advisory only and never fail the build. ## 2. Create a custom environment ```bash POST /environments { "name": "my-runner", "description": "demo" } # → SandboxEnvironmentResponse { id, state: "active", ... } ``` ## 3. Upload context and pin a version ```bash POST /environments/{environment_id}/context-uploads { "declared_bytes": 123456, "archive_format": "tar.gz" } # → { upload_session_id, capability_token, put_header_name, put_content_type, ... } PUT /environments/{environment_id}/context-uploads/{upload_session_id} X-Sandbox-Upload-Capability: Content-Type: application/octet-stream # → { bytes_received, digest } POST /environments/{environment_id}/context-uploads/{upload_session_id}/complete { "expected_digest": "sha256:" } # → { owned_object_id, digest, byte_size } POST /environments/{environment_id}/versions { "owned_object_id": "", "resource_profile": "standard", "source_ref": "demo.tar.gz" } # → SandboxEnvironmentVersionResponse { id, state: "draft", ... } ``` Do **not** send a generic `blob_id`. `source_digest` alone is only for retries when that digest already identifies a completed context object. `resource_profile` must be tenant-selectable. ## 4. Trigger the build and poll to ready ```bash POST /environments/{environment_id}/versions/{version_id}/builds { "build_profile": "standard" } # optionally send build_region: a code from GET /public/info/sandbox/regions; omit it for the deployment default GET /environments/{environment_id}/versions/{version_id}/builds/{attempt_id} # until terminal_class is set GET /environments/{environment_id}/versions/{version_id} # until version state == "ready" ``` There is **no streaming**. ## 5. Create a department job and publish a version ```bash POST /tasks { "name": "hello", "owner_scope": "department", "owner_id": "{department_id}" } # chatroom owner_scope → 422 POST /tasks/{task_id}/versions { "environment_version_id": "{version_id}", "work_script": "echo hi", "timeout_seconds": 1800, "requires_confirmation": false } POST /tasks/{task_id}/versions/{task_version_id}/publish ``` ## 6. Share, then room-enable ```bash POST /tasks/{task_id}/shares { "target_kind": "chatroom", "target_id": "{chatroom_id}" } # share ≠ Agent menu POST /chatrooms/{chatroom_id}/granted-jobs/{task_id}/enable { } # omit task_version_id → pin current published version GET /chatrooms/{chatroom_id}/menu # granted AND enabled; same list for every caller ``` ## 7. Submit a run (carry Idempotency-Key) ```bash POST /chatrooms/{chatroom_id}/runs Idempotency-Key: { "task_version_id": "{task_version_id}", "input": { "any": "json" } } ``` Manual REST accepts a granted published version even if the room has not enabled it. The menu / Agent only show enabled jobs. Product UI should submit from the menu. ## 8. Poll status and fetch artifacts ```bash GET /chatrooms/{chatroom_id}/runs/{run_id} GET /chatrooms/{chatroom_id}/runs/{run_id}/content GET /chatrooms/{chatroom_id}/runs/{run_id}/artifacts POST /chatrooms/{chatroom_id}/runs/{run_id}/artifacts/{artifact_id}/download POST /chatrooms/{chatroom_id}/runs/{run_id}/output/public-link # → { url, token } (also .../log/public-link) GET # = GET /public/sandbox/artifacts/{token}, no login, revocable with DELETE .../public-link # artifacts/{artifact_id}/download returns a capability_token that has NO redeem route today — use public-link for output/log. ``` ## Company-manager history (optional) ```bash GET /tasks/{task_id}/runs?offset=0&limit=10&order=desc GET /tasks/{task_id}/runs/numOfData ``` Restricted (`Sandbox` scope) keys get **403** here (`Access denied, restricted Sandbox key cannot use this route`), not 404 — `/private/module/sandbox/tasks` is on the restricted-key denied-path list and is rejected in the auth layer before the sandbox router runs. ## Want it faster? Quick Run (manager-only) Still mints a **hidden chatroom-owned** parent for a one-off. Do not copy that shape onto `POST /tasks`. ```bash POST /chatrooms/{chatroom_id}/quick-run Idempotency-Key: { "name": "adhoc", "environment_version_id": "{ready_version_id}", "startup_script": "", "work_script": "echo hi", "ordinary_env": {}, "input": {}, "timeout_seconds": 600 } ``` Full fields: [API endpoint catalog](/en/reference/endpoints.md). Step-by-step: [Flows](/en/flows/index.md). Reference implementation of the whole lifecycle (environment → zip-bundle jobs in python/node/go → 9 executions → output download), zero dependencies, Node ≥ 20: `sandbox/e2e-js/` in `teamsync-backend`. # Architecture: control vs data plane ## What the frontend calls Almost every sandbox API lives under **`/private/module/sandbox`** (`module` is singular — the backend treats this as a security-relevant path for request-content masking). There are **two** unauthenticated route groups outside `/private/module/sandbox`: the confirmation catalogs (called directly by the frontend), and the public artifact download `GET /public/sandbox/artifacts/{token}` (minted by the public-link route in touchpoint 4 below — a link handed to a recipient, not an API the frontend calls). The catalogs are: ```text GET /public/info/sandbox/regions GET /public/info/sandbox/profiles ``` Use those for dropdowns. Do not invent region or profile strings. Tenant runtime subset is still `GET /settings.allowed_regions`. Operator enablement is still `/root/sandbox/regions`. Sub-route prefixes under `/private/module/sandbox`: | Resource | Prefix | | --- | --- | | Company settings | `/settings` | | Environments / versions / builds / **context uploads** | `/environments` | | Tasks / versions / shares / **company run history** | `/tasks` | | Bindings (same pin as granted-jobs enable) | `/bindings` | | Secrets / approvals | `/secrets`, `/secret-approvals` | | Menu, granted-jobs, runs | `/chatrooms/{chatroom_id}/...` | Job audience (who owns a job, who may share, what enable means) is [Job audience](/en/concepts/job-audience.md). ## The world the frontend cannot see | Route group | Purpose | Auth | | --- | --- | --- | | `/sandbox-control/*` | Isolated-workload callbacks, result-object uploads, build-content streaming **inside** the control plane; on an on-prem deployment also the executor work leases (`/sandbox-control/onprem/work/*`) | Google service-account OIDC + capability (on-prem: an on-prem executor token instead of Google OIDC); `include_in_schema=False`. Humans should not call it | | `/root/sandbox/*` | Operators / root | [Operators and root](/en/concepts/operators.md) | | `POST /public/module/custom_tables/callback/command-output/{token_id}` | Called by a Command-output Run's own work script (v5.21.0), not by the frontend | The Run's sealed Bearer credential; see [Job output as a Command](/en/flows/command-output.md) | ## Six byte touchpoints The frontend touches bytes in **six** places. Everything else (R2 keys, R2 multipart parts, runner streaming) stays in the control plane. 1. **Build-context archive** — environment owner-scope manager `init → PUT (X-Sandbox-Upload-Capability) → complete` on `/environments/{id}/context-uploads`, then `owned_object_id` on version create. Single-part, max 1 GiB. This **is** a tenant capability; it is **not** a generic blob_id and **not** a presigned URL. See [Create an environment and build](/en/flows/environment-and-build.md). 2. **Run `input`** — canonical JSON on `POST .../runs`. 3. **Artifact download** — `POST .../download` returns a **5-minute** `capability_token` that is not a URL. There is no tenant redeem route. 4. **Permanent public links** — `POST .../runs/{run_id}/{log|output}/public-link` mints an unauthenticated, no-TTL `GET /public/sandbox/artifacts/{token}` URL for a run's log or output. See [Content, digests, and downloads](/en/concepts/content-and-downloads.md). 5. **Public catalogs** — tiny JSON lists, not archives. 6. **Script-file upload** — two routes. `POST /tasks/{task_id}/script-uploads` (multipart `file`, one UTF-8 text file ≤1 MiB, owner-manager) validates the file before any version exists and returns a 24 h reusable handle to pass as `startup_script_id` / `work_script_id` on version create/patch — the route a create-version form uses. `POST /tasks/{task_id}/versions/{vid}/script-file?target=startup|work` (same file rules, draft-only) writes straight into an existing draft; semantically identical to PATCHing `startup_script`/`work_script` as a string. `source_digest` alone on version create is a **retry / test** escape hatch when that digest already identifies a completed context object. New product UI should upload, then send `owned_object_id`. OpenAPI tags on the tenant surface are **double-tagged**: umbrella `Module: Sandbox` **plus** per-area `Module: Sandbox - Settings` / `Environments` / `Tasks` / `Bindings` / `Secrets` / `Runs`. ## There is no streaming No WebSocket / SSE / StreamingResponse on the tenant surface. Build and run progress is **polling**. See [State machines](/en/concepts/states.md). # Content, digests, and downloads The frontend touches bytes in **five** places: **context upload**, **run `input`**, **artifact download**, **permanent public links**, and the tiny **public catalogs**. See [Architecture](/en/concepts/architecture.md). ## 1. Build context: capability-fenced upload (not a digest-only declaration) Company-manager sequence on a **company-owned** environment (`owner_kind=company`). Curated environments 404 on upload. 1. `POST /environments/{id}/context-uploads` with `declared_bytes` (1..1 GiB) and `archive_format` (`zip` / `tar` / `tar.gz`). 2. `PUT /environments/{id}/context-uploads/{session_id}` with header **`X-Sandbox-Upload-Capability`** and `Content-Type: application/octet-stream`. Body ≤ `declared_bytes`. 3. `POST .../complete` → `owned_object_id` + `digest`. 4. `POST /environments/{id}/versions` with that `owned_object_id` (optional matching `source_digest`). This is **not** a chatroom/DCC `blob_id` and **not** a presigned R2 URL. Capability TTL is **15 minutes** (`DEFAULT_CAPABILITY_TTL_SECONDS`). Re-init if the PUT has not started by then. `source_digest` alone on version create is accepted only when it already identifies a completed context object (retry / tests). Product UI should not skip the upload. Caps: 1 GiB per archive, 20 live staging slots, 20 GiB declared staging per company, 5 inits per minute. An incomplete upload holds a slot until you abort it **or the session's 24-hour TTL (`UPLOAD_SESSION_TTL_SECONDS`) expires**; completed sessions do not count. When slots are exhausted, that 429 also wedges build scan-report writes. Errors: 413 `context_too_large`, 429 `staging_slot_exhausted` / `upload_rate_limited`, 409 `capability_fenced`, 503 `capability_unavailable`. Session states you may poll: `uploading` → `completed`, or `aborted` / `abort_pending`. ## 2. Run input: canonical JSON When submitting a run, `input` is arbitrary JSON validated as **canonical JSON** (`parse_canonical_json`): | Limit | Value | | --- | --- | | UTF-8 size | ≤ 1 MiB (`MAX_INPUT_JSON_UTF8_BYTES`) | | Nesting depth | ≤ 64 (`MAX_JSON_DEPTH`) | | Node count | ≤ 100,000 (`MAX_JSON_NODES`) | | Other | No NUL, no duplicate keys | Exceeding these returns 422. The submission endpoint also has a **size guard before reading the body**: an oversized `Content-Length` returns **413 `request_too_large`** (it will not buffer a 1 GiB JSON body). ## 3. Downloading artifacts: capability token, not a URL 1. `GET /chatrooms/{id}/runs/{run_id}/content` → **boolean flags** `has_input` / `has_output` / `has_log` / `has_artifact_bundle` (+ `input_digest`). **Flags only.** `state` may be `"missing"`. 2. `GET /chatrooms/{id}/runs/{run_id}/artifacts` → metadata (`relative_path`, `byte_size`, `digest`, `deletion_state`, `declared_content_type`…). 3. `POST /chatrooms/{id}/runs/{run_id}/artifacts/{artifact_id}/download` → short-lived `capability_token` + `expires_at` + `content_type` / `content_disposition` / `x_content_type_options: nosniff`. ```text capability_token = token_urlsafe(32) # opaque; not a URL; not a Bearer for any tenant route expires_at = now + 5 minutes # _DOWNLOAD_CAPABILITY_TTL content_type = application/octet-stream content_disposition = attachment; filename="" x_content_type_options = nosniff ``` There is **no** `GET` under `/private/module/sandbox` that streams artifact bytes, and no documented header that redeems `capability_token`. Do not invent a redeem URL. Do not confuse this token with `X-Sandbox-Upload-Capability` (upload) or the public confirmation catalogs. What the product UI can do today: - Show artifact **metadata** from `GET .../artifacts`. - Call `POST .../download` to prove the current principal may access that `artifact_id`. - Treat the returned token as a proof-of-authorization receipt for a future byte-delivery surface. Re-issue when `expires_at` passes. - `has_output` / `has_log` are flags only. REST does not return those bodies. The agent tool `sandbox_job_status` may return **bounded untrusted previews** (≤64 KiB output, ≤16 KiB log head+tail). See [Agent toolkit](/en/reference/agent-tools.md). Rules: - Authorization is always by `artifact_id` (+ company/chatroom ownership), never by key. - `deletion_state != active` → download **404**. - Download capability is **5 minutes**; upload capability is **15 minutes**. Do not mix them. ## 4. Permanent public links (no auth) A run's **log** or **output** object (not individual artifacts) can be given a permanent, unauthenticated download URL — for handing results to someone outside TeamSync: ```bash POST /chatrooms/{id}/runs/{run_id}/{kind}/public-link # kind: log | output # → { run_id, object_kind, url, token, revoked, created_at } DELETE /chatrooms/{id}/runs/{run_id}/{kind}/public-link # revoke # → same shape, revoked: true ``` - **Permanent by design — no TTL.** The link survives until you revoke it, or the underlying object leaves `deletion_state=active` (retention, scrub, offboarding) — either one 404s the public URL forever after. - Minting is **idempotent**: calling it again while a link is live returns the same token/URL. Revoke, then mint again, and you get a **new** token — the old one is dead for good. - Minting/revoking requires the same run-content visibility as everything else on this page (room membership / manager ladder). Once minted, the link itself carries **no** auth — anyone with the token can download that object. - The public URL is `GET /public/sandbox/artifacts/{token}` — no `/private` prefix, no auth, not under `/private/module/sandbox`. It returns the fully buffered object as an attachment (`Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, `Cache-Control: private, max-age=3600`); it is **not** a redirect and **not** a presigned provider URL. **An object larger than 100 MiB also 404s** (`MAX_PUBLIC_STREAM_BYTES`) even when the link is live and the object is still `active`; a provider read failure 404s the same way. - `kind` is only `log` or `output` — **not** `artifact`. There is no permanent public link for individual artifact files; those stay behind the 5-minute download capability above. - The run must already have the corresponding `log` / `output` object before you mint or revoke: while `has_log` / `has_output` from `GET .../content` is false, `POST` (and `DELETE`) `.../{kind}/public-link` return **404**. Poll until the run is terminal and the flag is true before minting. - Restricted Sandbox API keys cannot mint or revoke a public link — the request is refused with **405** by the scope filter (`Access to POST … is not allowed for your role`, with the concrete request path in the detail), not 404. Because the link is permanent and unguessable-but-not-secret, treat "mint a public link" the same as any other irrevocable-until-you-remember-to-revoke-it share action — think before wiring a one-click "share" button to it for sensitive output. ## Digests and content addressing An owned object's storage key is generated by the backend and never globally reused, of the form `sandbox/{company_id}/{object_kind}/{token}`; `object_kind` ∈ `context` / `input` / `output` / `log` / `artifact_bundle` / `report`. The frontend never receives these keys. Authorization is by resource id (`owned_object_id`, `artifact_id`, session id). # Concepts overview Sandbox lets the frontend bind a user-defined **task** to an already-built **environment version**, **run** it inside a **chatroom**, and pull back the logs and artifacts. Almost every call is **`/private/module/sandbox`**. Region/profile dropdowns are unauthenticated `GET /public/info/sandbox/{regions,profiles}`. Company managers **do** upload build-context archives through tenant context-upload routes. Runner streaming and R2 keys stay in the control plane. Read these pages before integrating: - [**Architecture: control vs data plane**](/en/concepts/architecture.md): which APIs you call, and which are backend system-to-system. - [**Personas and permissions**](/en/concepts/personas.md): who can do what — ordinary user, chatroom / department / company manager, restricted key. - [**Job audience**](/en/concepts/job-audience.md): department/company ownership, share vs enable vs menu vs writeback. - [**Operators and root**](/en/concepts/operators.md): the `/root/sandbox` operator surface; not the product frontend. - [**Resource model**](/en/concepts/resources.md): environment / context upload / job / grant / enable / run. - [**State machines and polling**](/en/concepts/states.md): every status value and transition; **there is no streaming — the frontend polls**. - [**Content, digests, and downloads**](/en/concepts/content-and-downloads.md): context upload, input-JSON limits, and artifact download tokens. - [**Secrets and approvals**](/en/concepts/secrets.md): secret bindings, slots, and borrower approvals. - [**Limits and quotas**](/en/concepts/limits.md): pagination, input/script sizes, timeouts, and per-company policy caps. - [**Retention**](/en/concepts/retention.md): the separate expiry clocks for content / images / logs. - [**Notifications**](/en/concepts/notifications.md): `sandbox_event` on the notification center; they hint, GET status remains authoritative. ## The data flow on one page ```text Environment └─ context upload → Version (owned_object_id → build → ready) └─ department/company job → Task Version → publish → share └─ room enable → Menu / Agent └─ Run (poll status; fetch artifacts) ``` > **Naming**: "Sandbox" in this handbook refers to the sandbox module in the TeamSync backend, and is unrelated to Cloudflare's "Sandbox SDK". # Capabilities, discovery and runtime planning This page describes backend release **v5.21.0**, pinned to backend `172a7f80bdf4dbdffdc018eb08bfa42c62bb485c`. Private paths use `/private/module/sandbox`. ## Start with GET /me `GET /me` reports what the current user may manage. It grants no authority; every later operation rechecks its own dependency. Use these fields to render the interface: | Field | UI use | | --- | --- | | `can_manage_environments`, `can_create_tasks` | Show authoring controls when the department/company-manager floor is met | | `can_read_settings` | Show company settings only to a company manager | | `can_quick_run_any_room` | True only for the company-manager tier; other managers may still use Quick Run in their manageable rooms | | `manageable_chatroom_scope` | `company` means all current/future rooms; `listed` means the listed ids; `none` means no manageable room | | `manageable_chatroom_ids`, `manageable_chatroom_ids_truncated` | An empty list with `company` means all rooms, not zero rooms. A truncated list is not a complete room roster | | `manageable_department_ids` | Departments whose scope the caller manages | | `creatable_task_owner_scopes` | Entries `{scope, ids}` that map to valid task `owner_scope` / `owner_id` choices | Room/department id arrays are bounded at 5000. Restricted Sandbox keys are rejected at their auth scope (403); external social clients get 404. Keep their integration on the room-addressed APIs. ## Catalog visibility and source visibility are different `GET /tasks`, task detail and version lists expose only jobs the user owns/manages or can see through live audience grants. Being in the same company does not expose every job. A removed owner room must not make the whole catalog fail. `SandboxTaskVersionResponse.source_visible` is a **required boolean**. True identifies the owner-source view. False means scripts, `ordinary_env`, `work_command`, slot declarations, output policy and task bundle are withheld; null is not evidence that the job is empty. The room menu always uses the borrower view and returns `source_visible=false`, even for an owner. Owners still need `content_state`: a scrubbed version can have unavailable source content. The borrower-visible `input_schema`, instructions and example remain available to build a form. `input_schema` / `input_example` are JSON-encoded strings when present; parse them before use. Do not infer source permission from role numbers or an empty script field. ## Read run history across rooms `GET /runs` returns `{items, total, page_size, next_page_token}`. A company manager sees company runs; other callers see their own runs plus rooms they created/manage, with own-department rooms added for department managers. Filters only narrow that scope: ```http GET /private/module/sandbox/runs?status=platform_failed&status=customer_failed&submitted_by_me=true&page_size=50 ``` Repeat `chatroom_id`, `task_id` or `status` for multiple values. `from` and `to` are inclusive queued-time bounds. Rows are newest first by `queued_at`, then `id`; never-queued rows sort last and are excluded when a time bound is present. `total` uses the same visibility and filters before pagination. `page_size` defaults to 50 (1..100); echo the opaque cursor. Malformed cursors restart at page one. Restricted keys/social clients use the room-specific history routes instead. ## Discover bindings and masked secret metadata | GET path | Who / what | | --- | --- | | `/tasks/{task_id}/bindings` | Owner-scope manager sees adoption rows; another caller who can see the job sees only rooms they manage | | `/chatrooms/{chatroom_id}/bindings` | Room manager sees binding history; `task_visible=false` withholds inaccessible job names/owner metadata | | `/tasks/{task_id}/secrets` | Visible job plus consumer-scope authority; ownership alone does not reveal another consumer’s secret binding | | `/tasks/{task_id}/secret-approvals` | Approval metadata narrowed to consumer scopes the caller manages | | `/secrets?consumer_scope=chatroom&consumer_id=...` | Exact consumer-scope manager; optional task/slot filters; envelope and rows carry `consumer_name` | These management lists are bounded at 2000 rows and return `truncated`; they do not use page cursors. Binding lists default to `lifecycle=active`; request `all` or `retired` to inspect revoked adoptions. They differ from `menu` (executable Agent catalog) and `granted-jobs` (live availability plus enable switch). Use `enabled`, `authority_applicable`, `update_available`, state and timestamps together. Secret lists expose masked state, generation and whether a provider binding exists; they never return a value, provider path/id or fingerprint. Read lists never create approvals. See [sharing and Enable consent](/en/concepts/job-audience.md) and [secret approval](/en/concepts/secrets.md). ## Plan runtime region and cost from settings Authenticated company settings expose `selectable_regions` and nullable `pricing`. Populate runtime placement choices from these current operator region codes, not a hard-coded copy of the public confirmation enum. `allowed_regions` is the saved selection; availability is checked again on PUT. Environment `region_readiness` preserves live codes with values `ready` / `not_ready`. `pricing.regions[].profiles` contains customer `usd_per_second` rates with markup already applied. The response also includes pricing/bounds versions, `min_billable_seconds`, `finalization_seconds`, inbound/outbound rates and outbound reservation bytes/USD. An upper-bound estimate uses the highest eligible regional profile rate: ```text max(min_billable_seconds, timeout_seconds + finalization_seconds) * highest_eligible_usd_per_second + outbound_reservation_usd ``` Do not apply another markup. No selectable region means `pricing=null`; submission rechecks current policy, prices and budget. This is a planning estimate, not the final usage bill. `allowed_regions` governs runtime execution only, not build, registry, R2, logs or control-plane location; `residency_guaranteed` remains false. `runtime_egress_enabled` is an operator-controlled, read-only field on settings. Do not treat the region selector as an egress or data-residency guarantee. ## Cloud and on-prem deployments (v5.10.11) The same tenant API runs on the hosted cloud (the default provider) and on a self-hosted box (`SANDBOX_PROVIDER=onprem`). No field names the provider; the region catalog tells them apart, because only an on-prem deployment publishes `onprem`. Build the UI from the catalogs, `GET /settings` and the run fields rather than from cloud assumptions. | Area | Hosted cloud | On-prem deployment | | --- | --- | --- | | Public region catalog (`GET /public/info/sandbox/regions`) | Cloud regions, `tier` 1 or 2 | Only `onprem` (“On-premises”), `tier` 3 | | `build_region` | Default `asia-east1`; `onprem` → 422 `build_region_unavailable` | Default `onprem`; any other code → 422 `build_region_unavailable` | | `GET /settings` `pricing` | Cloud Run rates with markup applied | Nominal tier-3 accounting rates (default `0.000001` USD per vCPU-second and per GiB-second, operator-overridable, never 0) with the same markup applied; `pricing_version` `sandbox_onprem/v1.0.0` when `onprem` is the only selectable region | | Waiting run | `queue_position` among the company's `queued` runs; `eta_seconds` always `null` | Position in the executor queue across companies (also while `starting`); `eta_seconds` when completed-run history exists | | Queue caps (429) | Company policy `queued_run_limit` → `sandbox_queued_run_limit` | Also per-company (default 20) and fleet-wide (default 200 → `queue_full`, `Retry-After: 60`) caps; Quick Run gets both | | `next_action` wording | Names Cloud Run where applicable | Provider-neutral: “the local runtime” for submit/start states and “the on-prem sandbox” for capacity; same `wait_reason` values | | Failure vocabulary | Go runner literals | Same `error_code` / `failure_stage` literals, timeout `exit_code=124`, cancel `130`; a few executor-side failures use on-prem-only codes such as `executor_error` — treat an unknown code as a platform failure and show `failure_hint` | | Work-shell environment | `PATH`, `ordinary_env`, secret slots and the three `TEAMSYNC_*` paths — no other image `ENV` | Same (other image `ENV` values are unset before the session); may also carry the trust-store variables below | | Runtime egress | Company `runtime_egress_enabled` | Also requires the box's `SANDBOX_ONPREM_EGRESS=internet` (default `none`) | | Work calling this TeamSync (custom-table writeback, [Command output](/en/flows/command-output.md)) | Public API origin | Needs runtime egress plus the operator's API-origin allowance (`SANDBOX_ONPREM_TENANT_API_ORIGIN`). With `SANDBOX_ONPREM_TENANT_CA_FILE` the box's certificate is trusted through `/etc/teamsync/ca`, and `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `NODE_EXTRA_CA_CERTS` point at it unless the job set them itself | `selectable_regions` is the active operator-row set used by tenant settings, not the closed public catalog. A correctly bootstrapped on-prem deployment has only active `onprem`; if an operator registers extra tier-1/2 rows, they can appear in `selectable_regions` even though the public catalog and `build_region` gate still publish only `onprem`. Operator-side mechanics (executor work leases, scheduler, queue timeout, `GET /root/sandbox/queue`) are on [Operators and root](/en/concepts/operators.md). Run-field details: [Run and fetch results](/en/flows/run-and-results.md). ## Preserve authoring metadata New `secret_slot_declarations` contain objects with a required valid, non-reserved environment `name`; optional declaration metadata is retained. Read-side declarations can still contain historical strings or opaque objects. Preserve existing read data, and send the current named-object shape for new writes. The OpenAPI schemas retain the name field and typed region-readiness values for frontend code generation. Source authorities: `principal_views.py`, `ownership_views.py`, `server.py`, `tasks.py`, `runs.py` under the Sandbox tenant router, plus `src/schemas/sandbox.py` and the shared access/binding helpers. Provider differences: `src/components/sandbox/providers/`, `src/components/sandbox/constants.py` (`visible_region_codes`), `src/components/sandbox/pricing.py`, `src/components/sandbox/run_visibility.py`, `src/crud/sandbox/run_submission.py`, `src/crud/sandbox/onprem_scheduler.py` and the on-prem executor in `src/workers/sandbox_onprem/`. # Job audience — share, enable, menu This page is the **single contract** for catalog jobs after PR #975. Other pages link here instead of restating the rules. If a sentence elsewhere disagrees with this page, this page wins (and should be filed as a handbook bug). A **catalog job** is a `SandboxTask` created with `POST /tasks`. It is **not** a chatroom-owned object. ## Create: department or company only | `owner_scope` | Who may create | `owner_id` | | --- | --- | --- | | `department` | That department's manager, or a company manager | Department id | | `company` | Company manager | Must equal `company_id` | `POST /tasks` accepts only `SandboxTaskCreateOwnerScope`: **`department` | `company`**. Sending `chatroom` is **422** (enum). Hidden Quick Run parents are still minted by `POST .../quick-run` as chatroom-owned internals — do not create those yourself. Historical `owner_scope=chatroom` rows remain **readable**. Do not treat them as a create path. A scope the caller cannot own (wrong department, not a company manager, `owner_id` ≠ company) is **403 `owner_scope_forbidden`**. That is still the documented exception to existence-hiding 404. `agent_enabled` on the task parent is a **legacy flag**. Agent exposure is the room enable switch, not this field. ## Share is not Agent-enablement `POST /tasks/{id}/shares` grants **use**. It does **not** put the job on `GET .../menu` or give it to the Agent. Department-owned jobs may be shared by the owning department manager (or a company manager) to: 1. **`company` + company id** — the whole company, including departments created later. 2. **`department` + department id** — that department (including rooms created later in it). 3. **`chatroom` + room id** — any live room in the same company; no department-wide share is needed first. An owning department manager (or company manager) may share directly to **any live chatroom in the same company**. No company/department grant is required first. Sharing grants availability; the receiving room manager still chooses whether to enable the job. Foreign or non-live targets remain unavailable. Company-owned jobs may be shared by a company manager to the same three kinds of target, all inside the company. They stay **borrowed for writeback** in every room. A share may also carry a per-slot **secret credential-source policy** (`secret_slot_policies`, set only at creation — see [Secrets and approvals](/en/concepts/secrets.md)): whether the borrowing room must bind its own secret for a slot, or whether the owner's binding is lent to it. Revocation withdraws that grant. Equivalent remaining live coverage can keep the binding usable for future submissions. The next section explains when explicit re-acceptance is required. ## Overlapping grants and credential consent (v5.10.0) Applicable shares resolve in **chatroom → department → company** order. A room keeps its exact task-version pin and the credential-source policies accepted by its manager. If one grant is revoked, another live grant with equivalent per-slot policies may cover future submissions. A different secret-source policy is not silently adopted; explicitly Enable/Update when consent must change. Already submitted runs keep their frozen grant and generation, and revoking that grant still cancels its affected work. Enable, explicit binding creation, and accept-version also record exact-version borrower-secret consent for scopes the acting manager actually controls, when every required slot resolves. They do not read secret values. Missing slots or a consumer scope managed by someone else still require configuration/explicit approval by that scope’s manager. A GET, publish, share, or ordinary run never creates consent. An idempotent Enable may complete consent after a missing secret is configured. Version acceptance preserves the previously accepted credential policies. ## Enable is the room switch Room creator / manager (the [chatroom-manager ladder](/en/concepts/personas.md)): | Method + path | Effect | | --- | --- | | `GET /chatrooms/{id}/granted-jobs` | Every live grant that reaches this room, plus `enabled`. A restricted Sandbox key is refused with **405** by the scope filter, detail `Access to GET /private/module/sandbox/chatrooms/{id}/granted-jobs is not allowed for your role` (with the real room id in the path) — not 403, and not `invalid scope`. | | `POST /chatrooms/{id}/granted-jobs/{task_id}/enable` | Idempotent while the live binding already carries the requested pin (or no `task_version_id` was sent). Sending a **different** `task_version_id` re-pins (backend ≥ #1140: the live binding is revoked as `disable` would, then a new one is created); `POST /bindings/{binding_id}/accept-version` remains the in-place upgrade. A job not shared to this room (or its department/company) answers **409 `task_not_shared_to_room`** naming the share call. Puts the job on the menu / Agent. | | `POST /chatrooms/{id}/granted-jobs/{task_id}/disable` | Revokes the live binding. The **share remains**. Job drops off the menu until enabled again. | `POST /bindings` performs the same adoption with an explicit version id but is **not idempotent**: it returns **409 `binding_already_live`** when the room already has a live binding for that job, where `granted-jobs/{task_id}/enable` returns the existing binding. Prefer the granted-jobs routes. There is no root `GET /bindings`; use the task- and room-scoped binding lists in [Capabilities and discovery](/en/concepts/integration-contract.md). A share never auto-enables. Grants start `enabled=false`. ## Two catalogs, two submit gates | Surface | Who sees it | What is listed | Submit gate | | --- | --- | --- | --- | | `GET /chatrooms/{id}/granted-jobs` | Room manager only | Granted jobs (enabled or not) | n/a | | `GET /chatrooms/{id}/menu` | Anyone who can see the room, including a restricted Sandbox key | **Granted and enabled** only. **Same list for every caller** | Product UI should submit these `task_version_id`s | | `POST /chatrooms/{id}/runs` | Room member (or ladder manager) + live applicable share | n/a | `can_execute_manually`: membership + live share. **Does not require enable.** Any published+active version of a granted job is accepted. | | Agent tools (`sandbox_job_menu` / `sandbox_submit_job`) | Loaded when the room job is `"sandbox"` | Same granted+enabled catalog | `can_execute_via_agent`: granted **and** this room has a live applicable binding on **that** version | Do **not** write "share = menu" or "enable = the only way to run". Manual REST can run a granted job that is not enabled. Agent and the room menu cannot. ## Writeback audience (Custom-Table) `output_policy` is typed, not opaque, and has two kinds: **table writeback** (`custom_table_writeback`) and **Command output** (`custom_table_command`, v5.21.0). Only **department-owned** jobs may publish either one. Company-owned publish → **409 `output_policy_owner_scope_unsupported`**. A run **mints** the callback credential when: - the job is department-owned, **and** - the acting room is in the **owning department** (even if the room entered through an explicit share), **and** - the submitter is an interactive JWT user (not an API key, not a restricted Sandbox key, not a social client), **and** - the required authority is held: for table writeback, the publisher (at publish) and the submitter (at mint) both hold unrestricted table authority; for Command output, the submitter and the version's author (the user who last wrote the draft) both pass the Command check at every submission, and the publisher passes it at publish. For **table writeback**, a run is **borrowed** (mint **skipped**, run still queues — this is **not** 403) when the acting room is in a **foreign** department, or the job is **company-owned**. Trigger fire refuses `custom_table_writeback` versions outright. Agent still goes through the two-turn confirm. 403 `writeback_authority_denied` is only when mint is attempted and the JWT submitter lacks table authority. Details: [Custom-table trigger](/en/flows/custom-table-trigger.md). **Command output** uses the same owning-department audience but never skips: a borrowed run, a run from any room other than the policy's `chatroom_id`, a submitter who is not a JWT-authenticated user (an API key, a restricted Sandbox key or a social-media client), a submitter or version author who fails the identity, grant or scope check, or a Command that no longer runs under restricted definition authority with an `effect_identity` is refused with **403 `output_policy_author_denied`**, and nothing is queued (a Command that was deleted or whose inputs no longer match is a 422 instead). The trigger lane and the Agent accept these versions (the Agent without a confirmation turn when `requires_confirmation=false`). Contract: [Job output as a Command](/en/flows/command-output.md). ## Company-manager history Company managers list every execution of one catalog job across rooms: - `GET /tasks/{task_id}/runs` — `offset` / `limit` (default 10, max 100), `order=asc|desc` - `GET /tasks/{task_id}/runs/numOfData` — same filters, `{ num }` Restricted keys **403** at auth (no chatroom path / tasks deny). Non-manager JWT **403** `Insufficient permissions.` This is not the room run list (`GET /chatrooms/{id}/runs`). ## What to wire in the product UI 1. Company/department manager creates a **department** (or company) job → draft version → publish → share. 2. Room manager opens **granted jobs** and enables the ones that belong on the Agent / room menu. 3. Ordinary members run from **menu**. 4. Company manager reads **per-job history** on the job, not by walking every room. ## Related - [Personas and permissions](/en/concepts/personas.md) — the ladder that decides *who* may share / enable / execute. - [Tasks, publish, and bind](/en/flows/tasks-and-bindings.md) — the HTTP sequence. - [Agent toolkit](/en/reference/agent-tools.md) — tools stay principal-bound for list/status/cancel. # Limits and quotas Below are the limits the **frontend API actually hits** (from `src/components/sandbox/constants.py`, `storage.py`, and the schemas). Context-upload staging quotas **are** tenant-facing for company-manager uploads. ## Pagination | Item | Value | | --- | --- | | `page_size` | `ge=1`, `le=100` (`MAX_PAGE_SIZE`), default 20 or 50 (depending on the endpoint) | | `page_token` | Opaque string, `max_length=512` | ## Input and script sizes | Item | Value | | --- | --- | | Run input JSON | ≤ 1 MiB, depth ≤ 64, nodes ≤ 100,000, no NUL, no duplicate keys | | startup / work script | ≤ 1 MiB each (`MAX_SCRIPT_UTF8_BYTES`) | | Environment variables | ≤ 100 names, ≤ 64 KiB each | | Submission body (Content-Length) | Oversized returns 413 `request_too_large` first | ## Timeouts | Item | Value | | --- | --- | | Default work timeout | 1800 seconds (`DEFAULT_WORK_TIMEOUT_SECONDS`) | | Max work timeout | ≈ 7 days (168 hours − finalization envelope) | | `timeout_seconds` field | `ge=1`, `le=604680` (`MAX_CUSTOMER_WORK_TIMEOUT_SECONDS`). The company `timeout_ceiling_seconds` (default 86400) is enforced **only when a task version is published** — task-version publish and quick-run (which publishes a hidden v1) exceeding it → 422 `timeout_exceeds_ceiling`; a `timeout_seconds` override on `POST .../runs` or agent submit is **not** re-checked against the company ceiling. | | Proposal TTL | 1800s (`PROPOSAL_TTL_SECONDS`) | | Turn-receipt TTL | 600s (`TURN_RECEIPT_TTL_SECONDS`) | | Agent `wait_seconds` | 0..60 (`MAX_STATUS_WAIT_SECONDS`) | | Download capability | 5 minutes (`_DOWNLOAD_CAPABILITY_TTL`) | | Context-upload capability | 15 minutes (`DEFAULT_CAPABILITY_TTL_SECONDS`) | | Runner credentials of a run (manifest, progress and completion reports, result upload) | `timeout_seconds` + 120 s (`FINALIZATION_ENVELOPE_SECONDS`) counted from claim, so at most 604800 s. Releases before v5.21.0 gave both credentials the platform's 600 s default. | ## Context upload (company manager) | Item | Value | | --- | --- | | Archive size | ≤ 1 GiB (`MAX_DECLARED_OBJECT_BYTES`) | | Live staging slots | 20 (`MAX_STAGING_SLOTS`) | | Declared staging bytes | 20 GiB (`MAX_STAGING_DECLARED_BYTES`) | | Inits per company per minute | 5 (`MAX_UPLOAD_INITS_PER_MINUTE`) — counts context uploads, script/bundle uploads; run `input` and the runner's own result objects are exempt (backend ≥ #1140) | | Mode | `single_part` only | | Formats | `zip` / `tar` / `tar.gz` | | Dockerfile `FROM` | Any public base image, including `scratch` — no fixed allowlist since 2026-08-25 | 429 `staging_slot_exhausted` / `upload_rate_limited`. 413 `context_too_large`. An incomplete upload holds a live slot (and its declared bytes) until it is aborted **or its session expires** (`UPLOAD_SESSION_TTL_SECONDS` = 24 hours); a completed session releases its slot immediately. A full 20 also wedges scan-report staging leases. ## Artifacts | Item | Value | | --- | --- | | Artifact file count per run | ≤ 1000 (`MAX_ARTIFACT_FILES`) | | Agent artifact page | ≤ 20 rows (`ARTIFACT_LIST_PAGE_SIZE`) | | Agent output preview | ≤ 64 KiB (`MAX_OUTPUT_PREVIEW_UTF8_BYTES`) | | Agent log preview | ≤ 16 KiB head+tail (`MAX_LOG_PREVIEW_UTF8_BYTES`) | | Idempotency-Key | 1..128 chars, no NUL | | Ordinary env serialized | ≤ 64 KiB (`MAX_ORDINARY_ENV_SERIALIZED_UTF8_BYTES`) | | Ordinary + secret env | ≤ 256 KiB (`MAX_ORDINARY_PLUS_SECRET_ENV_UTF8_BYTES`) | ## Per-company policy caps (`SandboxCompanyPolicy`) Determined by backend policy; some are adjustable via `PUT /settings` or fall under the operations scope: - `active_environment_limit`: cap on usable environments. Default **30** (`DEFAULT_ACTIVE_ENVIRONMENT_LIMIT`). - `capacity_retained_version_limit`: the operator-set **input**, default **100**. The effective cap is `min(configured, floor(0.10 × the smallest confirmed regional Cloud Run Job quota across active regions))`; it is 0 when any active region's quota is unknown, or when there are no active regions at all. It counts capacity-retained versions company-wide; exceeding it is 409 `version_allocation_exhausted`, so the usable number is often far below 100. - Default company run concurrency **50**, queued-run limit **100**, active-build limit **2**, queued-build limit **20**. The queued-run limit gates REST submit, retry, Agent submit and custom-table triggers; Quick Run is not checked against it. - On-prem deployments add two operator caps on waiting runs (v5.10.11): per company `SANDBOX_ONPREM_MAX_QUEUED_PER_COMPANY` (default **20**; the effective company cap is the lower of this and the policy limit, and it also applies to Quick Run) and fleet-wide `SANDBOX_ONPREM_MAX_QUEUED` (default **200** → 429 `queue_full` with `Retry-After: 60`). The hosted cloud has neither. - `run_content_retention_days`: 1..365 (configurable; default 30). - `unreferenced_image_retention_days`: 1..90 (configurable; default 30). - Runtime profiles (`SandboxResourceProfile`): | Profile | vCPU | Customer memory | Workspace | Tenant selectable | | --- | --- | --- | --- | --- | | `standard` | 1 | 1280 MiB | 512 MiB | yes (default) | | `performance` | 2 | 2560 MiB | 1024 MiB | yes | | `large` | 4 | 5120 MiB | 2048 MiB | yes | | `xlarge` | 8 | 10240 MiB | 4096 MiB | **no** — operator-opened only | Confirmation set: `GET /public/info/sandbox/profiles`. Submit / queue-build pricing refusals: 403 `budget_reservation_exceeded` (envelope does not fit quota; **no row created**) or 422 `no_active_region` / `missing_price` / `invalid_envelope` / `missing_window`. Queued-run cap → **429** `sandbox_queued_run_limit` (body includes `limit`); on-prem fleet queue full → **429** `queue_full` (`limit`, `retry_after_seconds`, `Retry-After`). Version cap 0 because no confirmed regional Job quota → 409 `version_allocation_exhausted`. While a platform stage-image rollout holds the build-admission fence (platform-wide, not a per-company limit), `POST .../builds` and `POST .../retry-build` return **503 `build_admission_fenced`** with `retry_after_seconds` in the body and the same value in the `Retry-After` header — nothing was queued; retry after `Retry-After` (usually minutes). The fence fails open when Redis is unreachable. First `GET /settings` can **insert** a default `SandboxCompanyPolicy` row (`get_or_create_company_policy` then commit). Treat it as a read that may persist defaults. When a relevant cap is exceeded, the corresponding endpoint returns an error (usually 409/422, or 404 when unauthorized). See the [Error-code table](/en/reference/errors.md) for error codes. # Notifications Sandbox stages inbox + FCM/MQTT rows through the existing TeamSync notification center (`NotificationCenterType.SANDBOX_EVENT` = `"sandbox_event"`). They **do not replace polling**. `GET` run/build status (`terminal_at` / `terminal_class`) is still the source of truth. Read the inbox on the **existing** notifications API (not under `/private/module/sandbox`): ```text GET /private/notifications?type=sandbox_event ``` Each row is a `UserNotificationResponse`: `id`, `type`, `title`, `body`, `metadata`, `source_type`, `source_id`, …. `metadata.kind` is `sandbox_event`. Social-client / LINE runs do **not** create an inbox row for the external client — they fan out to chatroom managers (else company managers). Deep-link the manager to the run. Payloads carry **ids, status, error_code, labels, and deep-link metadata only**. They never include scripts, secrets, input/output/log bodies, ENV, capability tokens, or URLs (`src/crud/sandbox/notifications.py` `_FORBIDDEN_PAYLOAD_KEYS`). ## Event catalog | `event` (`SandboxNotificationEvent`) | Deep-link resource | Typical recipients | | --- | --- | --- | | `run_terminal` | `run` | Internal invoker themselves; external/social invoker → chatroom managers, else company managers | | `run_content_expiring` | `run` | Eligible invoker, else chatroom managers | | `run_content_expired` | `run` | same | | `build_ready` | `build_attempt` | Environment owner-scope managers + company managers (producer live since backend #1149; staged in the same transaction as the attempt's terminal, idempotent on `attempt_id`) | | `build_failed` | `build_attempt` | same — body carries `error_code` / `error_detail` | | `build_cancelled` | `build_attempt` | same | | `curated_environment_published` | `environment` | Company managers of every company that already pins a version of that curated environment (never every tenant); backend ≥ #1149 | | `security_finding_discovered` | `finding` | Submitter + owner-scope managers + company managers | | `security_finding_resolved` | `finding` | same | | `security_finding_reappeared` | `finding` | same | | `security_finding_blocked` | `finding` | same | | `security_remediation_deadline` | `finding` | same | | `security_exception_granted` | `finding` | same | | `security_exception_revoked` | `finding` | same | | `security_exception_expired` | `finding` | same | | `runner_replacement_available` | `environment` | Env owners + company managers + affected room managers | | `runner_handoff_completed` | `environment` | same | | `runner_handoff_expired` | `environment` | same | | `runner_handoff_failed` | `environment` | same | | `image_storage_renewal_blocked` | `company` | Company managers | | `image_storage_cleanup_completed` | `company` | Company managers | Inbox idempotency key is `(user_id, type, source_type, source_id)`. `source_type` examples: `sandbox_run_terminal`, `sandbox_build_ready`, `sandbox_security_finding_discovered`. For `run_terminal`, `source_id` is the `run_id`. ## Dispatch payload (safe fields) FCM / notification-center `dispatch_data` is stringly-typed and includes: ```text notification_type = "sandbox_event" event = SandboxNotificationEvent company_id status error_code run_id (when applicable) build_attempt_id (when applicable) finding_id (when applicable) deep_link_resource_type = run | build_attempt | finding | environment | company deep_link_resource_id ``` Use the deep-link pair to open the existing status page, then **re-GET** the resource. Do not trust `status` on the push alone. ## `run_terminal` copy (zh-TW) `run_terminal`, the three `build_*` events and `curated_environment_published` carry dedicated Traditional Chinese title/body (this deployment's user language; build/curated copy since backend #1149). Other events keep English defaults. | Run `status` | Title | Body pattern | | --- | --- | --- | | `completed` | 沙盒任務執行完成 | `「{task}」已順利執行完成,點開查看結果。` | | `cancelled` | 沙盒任務已取消 | `「{task}」已被取消。` | | `customer_failed` / `platform_failed` | 沙盒任務執行失敗 | `「{task}」執行失敗(錯誤代碼:{error_code}),點開查看詳情。` | | other | 沙盒任務已結束 | `「{task}」已結束(狀態:…)。` | Today **every** `run_terminal` `{task}` renders as the `您的沙盒任務` fallback: the sole producer `record_run_terminal` never passes `task_label`, and `metadata.task_label` is always `null`. Resolve the job name client-side from `metadata.task_id` / `run_id` if you need it. ## Frontend rules - Subscribe via the existing notification-center / FCM client. There is no sandbox-specific websocket. - Honor user notification preferences; the backend already filters `apply_notification_preferences`. - A missing notification is not a missing run. Keep polling. - Deep-link to the run/build the user can already `GET`. A-010 still applies: owning a shared task does not let you open a borrower-room run. - Do not render notification `data` as if it contained logs or artifacts. ## Related - [State machines and polling](/en/concepts/states.md) - [Run and fetch results](/en/flows/run-and-results.md) - [Enums](/en/reference/enums.md) # Operators and root This page is for **root / operators**, not the product frontend. A tenant JWT (`get_sandbox_principal`) cannot reach this surface. Product UI should follow [Personas and permissions](/en/concepts/personas.md) and the [API endpoint catalog](/en/reference/endpoints.md). Path prefix: `{BASE}/root/sandbox`. Auth is **operator auth** (`get_super_root_key`), not the tenant JWT. Mutating operations require a **typed `reason`** (8–1024 characters), write a `SandboxAuditLog`, and **never return tenant secret values or raw customer content**. V1 exposes no Binary Authorization break-glass route. The contract for rollout, rollback, and gates lives in the backend `docs/runbooks/sandbox-operations.md`. This page does not copy that runbook. ## Who owns what | Surface | Owner | Product frontend | | --- | --- | --- | | Region catalog and state (register / activate / drain / disable / backfill / reconcile) | root | No | | Quota snapshots and preflight | root | No | | System-curated environments and versions | root | Tenants may **read / pin** curated versions; they may not create or publish them | | Exact-digest blocks and time-bounded exceptions | root | No | | Company offboarding fence (arm / cancel) | root | No | | Company settings: retention days, `allowed_regions`, `policy_version` CAS | Tenant company manager (`GET`/`PUT /settings`) | Yes | | Environments / tasks / bindings / secrets / runs | Tenant managers (see [Personas and permissions](/en/concepts/personas.md)) | Yes | `allowed_regions` governs **runtime Job / Execution placement only**. It does not govern build, validator, Artifact Registry, R2, log, or control-plane residency. The closed V1 confirmation set for the product UI is `GET /public/info/sandbox/regions` — intersect with tenant `allowed_regions`. Do not treat `/root/sandbox/regions` as a frontend catalog. ## Kill switch vs "tenant custom build is not actually publishable" These are two different controls. Do not collapse them into one switch. - **`SANDBOX_ENABLED` defaults `true`.** `false` is the operator **kill switch**. It blocks new tenant management / submission / dispatch; GET status, cancel, download, and control-plane reconcile stay available. The tenant surface returns **503 `sandbox_disabled`**. - **`tenant_custom_build_enabled` is not a separate switch.** `GET /status` **derives** it from "module on × at least one active region × at least one confirmed regional Job-definition quota snapshot". Missing regional quota makes the effective version cap 0, and tenant publication is refused with **409 `version_allocation_exhausted`**. An operator who sees `enabled: false` should read `blocked_by`, not hunt for a flag that does not exist. `blocked_by` may be: `sandbox_disabled`, `no_active_region`, `no_confirmed_regional_job_quota`. Empty when publication can proceed. ## `GET /status` Reports: - `sandbox_enabled` - `tenant_custom_build_enabled` (the derived value above) - `blocked_by[]` - `active_regions` - `regions_with_job_definition_quota` - `gates` (live supervisor gates, currently `pending`) - `live_proof` (currently `"pending"`) - `break_glass_routes` (always `false`) ## Endpoint catalog These 27 routes hang under `/root/sandbox`. Mutating POSTs/PUTs carry `reason`; this table does not expand request bodies. | Method | Path | Purpose | | --- | --- | --- | | `GET` | `/status` | Kill switch and whether tenant custom build can actually publish | | `GET` | `/regions` | List regions | | `POST` | `/regions` | Register a region (enters provisioning). `tier` 1..3, but tier 3 is accepted only for `region_code=onprem` on an on-prem deployment (422 otherwise) | | `POST` | `/regions/{region_code}/activate` | Activate a ready region | | `POST` | `/regions/{region_code}/drain` | Drain (fence new attempts) | | `POST` | `/regions/{region_code}/disable` | Disable after reconcile | | `POST` | `/regions/{region_code}/backfill` | Accept regional Job backfill intent | | `POST` | `/regions/{region_code}/reconcile` | Accept regional reconcile intent (kept under the kill switch) | | `POST` | `/quota-preflight` | Local residual-adjusted quota preflight (no provider call) | | `POST` | `/quota-snapshots/refresh` | Read live provider limits and persist snapshots (the route that can make the version cap positive) | | `GET` | `/quota-snapshots/refreshes/{refresh_id}` | Read the durable status of one provider-capacity refresh (`POST /quota-snapshots/refresh` only records the intent; poll this route for the outcome) | | `GET` | `/curated-environments` | List system-curated environments | | `POST` | `/curated-environments` | Create a curated environment identity (no version yet) | | `POST` | `/curated-environments/{environment_id}/versions` | Publish a curated version from an operator-supplied digest | | `GET` | `/curated-environments/{environment_id}/versions` | List versions of that curated environment | | `POST` | `/security/blocks` | Block an exact image digest | | `POST` | `/security/exceptions` | Grant a time-bounded exception for an exact-digest finding | | `POST` | `/security/exceptions/{exception_id}/revoke` | Revoke an exception | | `GET` | `/companies/{company_id}/offboarding` | Read the offboarding ticket and fence state | | `POST` | `/companies/{company_id}/offboarding/arm` | Arm the real offboarding fence | | `POST` | `/companies/{company_id}/offboarding/cancel` | Release the fence if no destructive work has run | | `GET` | `/companies/{company_id}/policy` | Read a company's Sandbox policy limits (may create the default row) | | `PUT` | `/companies/{company_id}/policy` | Partial, audited update of those limits (environment/version/run/build caps, retention days, timeout ceiling, exposure, runtime egress) | | `GET` | `/queue` | **v5.10.11.** Work waiting for (or just starting on) a run slot, in serve order, plus each executor's last reported capacity. Same route and shape on every deployment; `executors` is empty on the cloud | | `GET` | `/runner-releases` | **backend ≥ #1162.** Runner releases approved for task-bundle jobs (table rows + the env-var bootstrap set + the effective union) | | `POST` | `/runner-releases` | Approve one runner release (`release_sha` = 40-hex stage-image source commit). Live on the next request, no restart; idempotent | | `DELETE` | `/runner-releases/{release_sha}` | Revoke; bundle runs on that runner are refused `409 task_bundle_runner_unsupported` on the next request | ## Runner release approvals (backend ≥ #1162) A task **bundle** (customer code shipped with the task) may only run on an environment whose `runner_release` — the stage-image source commit baked into the composed image — is approved. Approvals live in the `sandbox_runner_release_approvals` table and are read on every publish/run request, so `POST`/`DELETE /runner-releases` take effect immediately. The stage-image release workflow approves its own commit after the controller rollout succeeds, so a new runner release no longer leaves fresh environment builds refusing bundle jobs. The env var `SANDBOX_TASK_BUNDLE_RUNNER_RELEASES` is only a bootstrap fallback and is being emptied. The version response exposes `runner_release_approved` + `runner_release_next_action`. ## On-prem provider (v5.10.11) A self-hosted deployment sets `SANDBOX_PROVIDER=onprem` (empty or `gcp` = the hosted cloud; any other value is refused). Context for operators; the tenant-visible differences are on [cloud and on-prem deployments](/en/concepts/integration-contract.md). The install kit and runbooks live in the backend `docs/deployment/on-prem/`. - **Wake and auth.** `SANDBOX_CONTROL_WAKE` defaults to `poll` on-prem (`pubsub` on the cloud, where the controller refuses `poll`); `SANDBOX_EXECUTION_AUTH` defaults to `onprem_token` (executor tokens) instead of `gcp_oidc`. - **Region and price.** The on-prem poll-mode controller seeds one active `onprem` region (tier 3). Its nominal rates come from `SANDBOX_ONPREM_PRICE_CPU_USD_PER_VCPU_SECOND` / `SANDBOX_ONPREM_PRICE_MEMORY_USD_PER_GIB_SECOND` (default `0.000001`, must be > 0). - **Executors.** Docker executors lease runs and build gates from `/sandbox-control/onprem/work/*` (mounted only on-prem) and report their free and total slots on each lease request; the queue ETA divides by the fleet's run slots. - **Queue policy.** `SANDBOX_ONPREM_MAX_QUEUED_PER_COMPANY` (default 20) and `SANDBOX_ONPREM_MAX_QUEUED` (default 200) cap waiting runs (tenant 429s). A run still queued after `SANDBOX_ONPREM_QUEUE_TIMEOUT_SECONDS` (default 3600, minimum 60) is failed by the controller's queue sweeper. `GET /root/sandbox/queue` shows the queue with `lease_id`, `priority` (0 urgent, 1 Quick Run, 2 run, 3 build), `vcpu`, `mem_mib` and per-executor capacity. - **Network and trust.** `SANDBOX_ONPREM_EGRESS` (`none` by default, or `internet`) gates runtime egress on the box. `SANDBOX_ONPREM_TENANT_API_ORIGIN` admits exactly the deployment's own API origin through the executor's network fence, and `SANDBOX_ONPREM_TENANT_CA_FILE` gives tenant containers a trust store that includes the box's certificate. - **Capacity refresh (v5.21.0).** The controller's provider-capacity refresh and `GET /quota-snapshots/refreshes/{refresh_id}` scope their snapshots to the selected provider: the alias `onprem` on an on-prem deployment (no GCP project is needed), the configured sandbox GCP project on the hosted cloud, where the status route still answers 409 `sandbox_gcp_project_unset` / `sandbox_gcp_project_invalid` when that project is missing or malformed. - **Builds.** The same ten-gate build lane runs through the executor; the vulnerability scan uses `SANDBOX_ONPREM_SCAN` (default `trivy`), while signing and Binary Authorization are recorded as `not_applicable` on a local registry. ## Control plane (not for humans) `/sandbox-control/*` is system-to-system OIDC + capability, `include_in_schema=False`. Humans should not call it. On-prem, executors authenticate with on-prem executor tokens instead of Google OIDC. # Personas and permissions Sandbox permissions are a **ladder**, not "every mutation needs a company manager". Who can write environments, create jobs, enable a room, or see someone else's run depends on **owner scope**, **consumer scope**, and the **chatroom-manager ladder**. Catalog jobs are **department or company only** — the share / enable / menu split is [Job audience](/en/concepts/job-audience.md). The ordinary-user execute path is [Run a task as a tenant user](/en/flows/tenant-user.md). Root / operator actions are out of this page; see [Operators and root](/en/concepts/operators.md). ## Role integers Thresholds come from environment variables. **Do not re-derive them in the frontend**: | Variable | Default | Meaning | | --- | --- | --- | | `CAN_MANAGE_DEPARTMENT` | `2` | `role ≥ 2` → **department manager** | | `CAN_MANAGE_COMPANY` | `3` | `role ≥ 3` → **company manager** (tenant manager) | A chatroom member with `role < 2` is an **ordinary tenant user**. The integer is **not** the chatroom-manager bit: chatroom management is a separate ladder. ## Chatroom-manager ladder `is_chatroom_manager_for_principal` is true when the caller is a user (not a restricted key) and any of: 1. **Direct manager**: the room's `creator`, or a row in `chatroom_manager_association`. 2. **Same-department department manager**: `role ≥ 2` and `department_id` equals the room's department. 3. **Company manager**: `role ≥ 3` counts for every room in the company. A restricted Sandbox API key and an external social client **never** qualify as a chatroom manager. Quick-run, create/accept/revoke binding, and writing secrets for a chatroom consumer all use this ladder — not "any `role ≥ 2`". A department manager **cannot** force an Agent binding onto another department's room unless they also qualify on that room's ladder. ## Restricted Sandbox API key A `Sandbox` scope API key is **narrower** than a normal user. It only allows: the menu for rooms on its allow-list, manual submit, querying **its own** runs (list / status / content / artifacts / download), and cancelling **its own** runs. Management surfaces the key cannot use: quick-run, retry, environments (including context upload), tasks (including company run history), granted-jobs, bindings, secrets, settings, root. Those need a user JWT or a `Full` scope key (`Full` is still subject to tenant/role limits). The **auth layer returns 403** on quick-run, `/root`, `/environments`, `/tasks`, and on a missing/disallowed chatroom path (bindings, secrets, settings carry no chatroom segment, so they fall under that clause). Any other chatroom-addressed route that is simply not on the Sandbox allowlist (granted-jobs, retry, public-link, …) gets **405 `Access to is not allowed for your role`** from `is_valid_api`. Sandbox routers would **404** if a restricted principal reached them. See [Authentication](/en/get-started/auth.md). The restricted-key matrix column lists the live auth/route gates; permitted resource reads can still return 404 for inaccessible ids. ## 404 vs the 403 exception Unauthorized identifiers **usually return 404** (existence-hiding). A 404 often means "no permission", not "doesn't exist". **Exceptions** (do not claim "unauthorized is always 404"): - Creating a department/company job the caller cannot own → **403 `owner_scope_forbidden`**. - Sending `owner_scope=chatroom` on `POST /tasks` → **422** (enum `SandboxTaskCreateOwnerScope`). - `GET`/`PUT /settings` goes through the shared `COMPANY_MANAGER` role dependency: `role < 3` → **403 `Insufficient permissions.`** (not 404). - Writeback / budget 403s in the [error table](/en/reference/errors.md). Cancel uses the same boundary as read (`can_cancel_run == can_read_run`): managers can cancel runs they can see — not only "their own". **Agent tools do not use this widening** (A-011): `sandbox_cancel_job` / `sandbox_job_status` / `sandbox_submitted_jobs` stay principal-bound even if the user is a manager. `can_execute_manually` (`POST /runs` + trigger save/fire) is: same company + (restricted key allows the room) + the caller is a **member** of the room (or a manager on that room's ladder) + a still-live applicable **share** (historical owner-room tasks still count). **Enable is not required** for this gate. External social clients must be pinned to that exact `chatroom_id`. A department manager who is not a member and does not qualify on the room ladder cannot execute there. `GET /chatrooms/{id}/menu` and the Agent catalog are **stricter**: granted **and** room-enabled, same list for every caller. See [Job audience](/en/concepts/job-audience.md). There is no root `GET /bindings`; task- and room-scoped binding history is available, alongside `GET /chatrooms/{id}/granted-jobs`. See [management discovery](/en/concepts/integration-contract.md). Ordinary users use the [menu](/en/flows/tenant-user.md). ## Permission matrix | Action | Ordinary tenant user (chatroom member, `role < 2`) | Chatroom manager | Department manager (`role ≥ 2`) | Company manager (`role ≥ 3`) | Restricted Sandbox API key | | --- | --- | --- | --- | --- | --- | | Create / update / archive environments | no (404) | no unless separately an owner-scope manager | own department-owned environments | all company/department-owned environments | 403 at auth | | `GET`/`PUT /settings` | no (403) | no (403) | no (403) | yes | 403 at auth | | List / get environments + versions + builds | yes (own company + `system_curated`) | yes | yes | yes | 403 at auth | | Create company-owned job | no | no | no | yes | 403 at auth | | Create department-owned job | no | no | own department only | yes | 403 at auth | | Create chatroom-owned catalog job | **422** (removed) | **422** | **422** | **422** | 403 at auth | | Manage / publish / archive job, shares | only if `can_manage_task_owner_scope` | historical chatroom-owned only, if they manage that room | dept-owned jobs for own dept; company manager can all | company-owned + all | 403 at auth | | List / enable / disable granted jobs | no | that room | yes if they qualify as that room's manager | yes via ladder | 405 on an allowed room | | Create / accept / revoke binding (same pin as enable) | no | target chatroom manager ladder | yes if they qualify as that room's manager | yes via ladder | 403 at auth | | Company-manager per-job history `GET /tasks/{id}/runs` | no | no | no | yes | 403 at auth | | Secrets write / rotate / revoke + approvals | no | consumer chatroom they manage | consumer department they manage | consumer company | 403 at auth | | `GET /chatrooms/{id}/menu` | granted **and** enabled (same list for every caller) | same | same | same | yes (allowed rooms) | | `POST` runs | membership + applicable share (enable **not** required) | yes if they can execute | same | same | yes (allowed rooms) | | List / get run, content, artifacts, download, cancel | own runs only | by-id routes (status / content / artifacts / download / cancel) reach all runs in rooms they **directly** manage; the `GET /chatrooms/{id}/runs` **list** widens only for `role ≥ 2` — a direct manager with `role < 2` still lists only the runs they submitted | runs in rooms of their department | all company room runs | own runs only, allowed rooms | | retry | if `can_execute_manually` | yes | yes | yes | 405 on allowed room; router 404 | | quick-run | no (404) | yes (that room) | yes if they are on that room's manager ladder | yes | 403 at auth | | `GET /tasks` list | live-grant audience only | managed tasks or applicable audience | own department tasks or applicable audience | all company tasks | 403 at auth | | Borrower view | `startup_script` / `work_script` / `work_command` / `ordinary_env` / `secret_slot_declarations` / `output_policy` all `null` (`output_policy` embeds the owner's table, or Command and room, ids), `content_hash` empty string; `secret_slot_names` is still returned. `source_digest` / `source_ref` are stripped on the **environment version** borrower view, not the task version | owner view if they manage owner scope | same | same | n/a | Task management (publish, archive, shares) goes through `can_manage_task_owner_scope`: historical chatroom-owned → that room's manager ladder; department-owned → that department's manager (company manager can all); company-owned → company manager only. New catalog creates cannot be chatroom-owned. Secret writes and approvals go through the **consumer-scope manager**, not the task owner: chatroom consumer → that room's ladder; department consumer → that department manager; company consumer → company manager. The task owner **cannot** approve secrets on behalf of the borrower. ### A-010: a shared-task owner does not see borrower-room runs The **OWNER of a shared task does not automatically see runs in borrower rooms**. Room-scoped run APIs stay: runs you submitted, rooms you directly manage (by-id routes only; the `GET /chatrooms/{id}/runs` list widens only for `role ≥ 2`), rooms in your department, or (company manager) every room in the company. Owning the task ≠ seeing other people run it elsewhere. **Carve-out:** a company manager can list every execution of one catalog job with `GET /tasks/{task_id}/runs` (+ `/numOfData`) without walking rooms. That is not a room-run list and is not available to the task owner unless they are also a company manager. ## What to read next - [Run a task as a tenant user](/en/flows/tenant-user.md): menu → submit → poll → artifacts (do not create environments or tasks). - [Create an environment and build](/en/flows/environment-and-build.md): environment writes follow company/department owner scope; `GET`/`PUT /settings` is likewise company-manager only (retention fields are in [Retention](/en/concepts/retention.md)). - [Job audience](/en/concepts/job-audience.md): department/company ownership, share vs enable vs menu. - [Jobs, share, and enable](/en/flows/tasks-and-bindings.md): HTTP sequence. - [Secrets and approvals](/en/concepts/secrets.md): only the consumer-scope manager can write values and approve. - [Operators and root](/en/concepts/operators.md): root is out of this page. - [Agent toolkit](/en/reference/agent-tools.md): enable `ChatroomJobType.sandbox` on the room; tools stay principal-bound. - [Custom-table trigger](/en/flows/custom-table-trigger.md): fire-time principal must still `can_execute_manually` (for a Command-output version, the trigger's author must). # Resource model The frontend touches the resources below. Audience rules (who owns a job, who may share, what enable means) live in [Job audience](/en/concepts/job-audience.md). Do not recount those rules here. ## Relationship graph ```text Environment ──context upload──▶ owned_object_id └─has many──▶ Environment Version ──build──▶ ready ▲ │ pin a ready version Catalog job (department|company) └─has many──▶ Task Version ──publish──▶ │ │ share (company / department / chatroom) ▼ Grant ──room enable──▶ Binding (exact version pin) │ ▼ Menu / Agent (granted AND enabled) Manual REST (share + membership; enable not required) │ ▼ Run ``` ## Resources at a glance | Resource | What it is | Created by | | --- | --- | --- | | **Environment** | Tenant parent owned by a company or department, or `system_curated` | `POST /environments` by owner-scope manager; curated is operator-only. | | **Context upload** | Staging session for the zip/tar/tar.gz | `POST /environments/{id}/context-uploads` | | **Environment Version** | Immutable pin of an `owned_object_id` (or a known digest) + profile | `POST /environments/{id}/versions` | | **Build Attempt** | One build of a version | `POST /.../versions/{vid}/builds` | | **Task (catalog job)** | Department- or company-owned runnable | `POST /tasks` (`owner_scope` `department` \| `company`) | | **Task Version** | Scripts + ready environment version + optional `output_policy` | `POST /tasks/{id}/versions` → `publish` | | **Share Grant** | Makes a job usable in matching rooms | `POST /tasks/{id}/shares` | | **Granted-job enable / Binding** | Room switch + exact-version pin. Same object. | Prefer `POST /chatrooms/{id}/granted-jobs/{task_id}/enable` | | **Run** | One execution in a chatroom | `POST /chatrooms/{id}/runs`, quick-run, agent `sandbox_submit_job`, or `submit_sandbox_job` | ## Rules that must not drift - A version must be `ready` before a task version can publish against it. - A task version must be `published` (+ `content_state=active`) before it can be shared, enabled, or run. - Versions are immutable after publish; change means a new version. - Unauthorized identifiers **usually** return **404**. Exceptions: **403 `owner_scope_forbidden`** (cannot own that department/company scope), plus writeback / budget 403s in the [error table](/en/reference/errors.md). - Lists default to `lifecycle=active` except `GET /tasks/{id}/shares` (`all`). - Owner vs borrower: borrowers omit scripts, `work_command` (`null`, not merely absent), `source_digest`, `secret_slot_declarations`, and `output_policy`. > Endpoints and fields: [API catalog](/en/reference/endpoints.md). # Retention Sandbox objects apply **separate retention clocks** by purpose. If the frontend caches resource ids or download links, understand when they disappear. ## Configurable retention (company settings) Adjusted via `PUT /settings` (company manager): | Setting | Range | Meaning | | --- | --- | --- | | `run_content_retention_days` | 1..365 | Days to retain run content (input/output/logs/artifacts) | | `unreferenced_image_retention_days` | 1..90 | Days to retain unreferenced build images | ## Fixed internal clocks | Category | Rule | Constant | | --- | --- | --- | | Successful source of truth | Retained while active/referenced, then a **30-day** archive grace period | `SOURCE_ARCHIVE_GRACE_DAYS = 30` | | Failure-scenario content/logs/reports | Expire **7 days** after termination | `FAILED_CONTEXT_RETENTION_DAYS = 7` | | Build retry window | A `retryable_failed` / `cancelled` attempt can be retried within **7 days** | `BUILD_RETRY_WINDOW_DAYS = 7` | | Download capability | **5 minutes** (re-issue when expired) | `_DOWNLOAD_CAPABILITY_TTL = timedelta(minutes=5)` | | Agent proposal | 30 minutes | `PROPOSAL_TTL_SECONDS = 1800` | | Agent turn receipt | 10 minutes | `TURN_RECEIPT_TTL_SECONDS = 600` | | Archived environment version (**backend ≥ SBW-08**) | Its per-region Cloud Run Jobs, quarantine + execution image packages, attestations and build reports are reclaimed by the controller within minutes of archiving (incomplete reclaims retry every 10 min). Archiving is terminal — nothing can pin or run the version afterwards, and its build logs are gone with it. Exception (v5.10.11): while a non-archived curated version still pins the same image digest, that execution package is kept (its regional Jobs are still reclaimed) and deleted on a later pass once the curated version is archived. | `RECLAIM_RECHECK_INTERVAL = 10 min` | ## Practical advice for the frontend - Do not treat `run_id` / `artifact_id` as permanent links; they are subject to the retention days. - Failure-scenario logs/reports disappear after 7 days — download them promptly if you need to keep them. - When a download capability expires, `POST .../download` again to exchange for a new one. - Changing `PUT /settings` retention days does **not** rewrite existing run/artifact expiry snapshots. Only new runs pick up the new clock. # Secrets and approvals A task version can declare the **secret slots** it needs (`secret_slot_declarations` / `secret_slot_names`). The actual secret values are bound through a separate secret API, transit-encrypted end-to-end, and the **values are never returned** or logged. ## Two APIs ### Secret binding (bind a value to your own task) Bind a secret value to `(consumer_scope, consumer_id, task_id, slot_name)`: - `POST /secrets`: create a value. - `POST /secrets/rotate`: rotate a value. - `POST /secrets/revoke`: revoke a value. - `GET /secrets/{binding_id}`: read the **masked** binding (only `has_provider_binding: bool`, `state` ∈ `pending`/`active`/`revoking`/`revoked`, `generation`…, **no value**). Rules: - Requires **consumer-scope manager** permission. - Create/rotate/revoke must all carry an **`Idempotency-Key`** header; missing it returns 422 `idempotency_key_required`. - The value travels only as `encrypted_value` (hybrid transit envelope) — see **Transit encryption** below. Plaintext `value` is rejected unconditionally. - Decrypted value: 4 UTF-8 bytes..64 KiB, no NUL, **never echoed back**. - Returns 503 when the secret adapter / pepper is unavailable. ### Secret approval (borrowing a task someone else shared) When you run a task **shared by someone else** that requires secrets, the **consumer-scope manager** (the borrower's chatroom / department / company manager — **not** the task owner) must approve which slots that consumer may use: - `POST /secret-approvals` (or the alias `POST /shared-task-secret-approvals`): approve a set of `slot_names` for an **exact task-version digest**. - `POST /secret-approvals/{approval_id}/revoke`: revoke. Rules: - `SandboxSecretApprovalCreateRequest` must carry `task_version_digest` (`sha256:...`) and `risk_accepted: true` (**must be exactly true**). - If the digest does not match the published version → 409 `task_version_digest_mismatch`. - Revoking does not rewrite history; it only invalidates future use. ## Enable consent and validation (v5.10.0) A room-manager Enable/Update can record exact-version approval for borrower-resolved secret scopes that manager controls; see [the consent rules](/en/concepts/job-audience.md). It is not a blanket approval of another scope’s values. `GET /secrets?consumer_scope=chatroom&consumer_id=...` discovers masked bindings and returns `consumer_name`; the task-scoped secret and approval lists are described in [Capabilities and discovery](/en/concepts/integration-contract.md). Create/rotate requires at least **4 UTF-8 bytes** after decryption, up to 64 KiB, with no NUL. This is a byte count: one three-byte Chinese character is too short. A short value returns 422 with `detail[].type = sandbox_value_too_short`, distinct from an invalid encryption envelope. Existing stored values are not rewritten by this write-time floor. Explicit approval requires a published version, matching digest, `risk_accepted: true`, and a nonempty set of valid, normalized slot names. A draft version returns 409 `task_version_not_published`; a digest mismatch returns 409 `task_version_digest_mismatch`. The route does not compute the actual borrower-resolved subset: claim still requires the approved subset to match the resolved pins exactly. Approvals never grant access to values outside the caller’s consumer authority. ## Transit encryption (write path) `POST /secrets` and `POST /secrets/rotate` accept **only** an `encrypted_value` envelope. A plaintext `value` is rejected **unconditionally** (422) — even on a deployment with no transit keypair configured. There is no plaintext fallback, ever (owner decision 2026-08-30). Client recipe: 1. `GET /public/info/model_key/public_key` → `{ public_key_pem, algorithm }` (PEM, SubjectPublicKeyInfo). Same keypair/endpoint the model-catalog `encrypted_api_key` flow already uses. A **503** here means the server has no keypair configured — encrypted writes will still 422, not fall back to plaintext. 2. Generate a random AES-256 key and a fresh 12-byte IV. Encrypt the secret value (AES-256-GCM; the GCM tag is appended to the ciphertext). 3. RSA-OAEP-SHA256-wrap the AES key against the fetched PEM. 4. Base64 (standard alphabet) all three parts and submit: ```json { "encrypted_value": { "encrypted_key": "", "iv": "", "ciphertext": "" } } ``` The decrypted plaintext is still checked against the normal 4 UTF-8 bytes..64 KiB cap. Four distinguishable 422 slugs (in `detail[].type`; `msg` on the wire is exactly `validation_error:` — `input`/`ctx` are stripped and never leaked): | Slug | Cause | | --- | --- | | `sandbox_plaintext_value_rejected` | A **non-empty** plaintext `value` field was sent (with or without `encrypted_value`); an empty-string or null `value` is ignored, not treated as plaintext | | `sandbox_encrypted_value_required` | `encrypted_value` missing or null | | `sandbox_transit_keypair_missing` | Server has no decryption keypair configured | | `sandbox_encrypted_value_undecryptable` | Envelope shape is valid but decryption failed (wrong/foreign key, corrupt ciphertext, wrong IV, non-UTF-8 plaintext) | Once decrypted, secret values reach the runner as **environment variables**, exported before the work script starts — they are never written to disk alongside the run input. ## Slot naming `slot_name` must match `^[A-Za-z_][A-Za-z0-9_]{0,127}$` (same as `ENV_NAME_PATTERN`) and must not be a reserved env name (`PATH`, `HOME`, any `TEAMSYNC_` / `GOOGLE_` / `K_SERVICE` prefix, …). Reserved names are rejected the same way as `ordinary_env` keys. The task-version / menu response gives only `secret_slot_names` (the list of names), not values or provider paths. There is **no** list endpoint for bindings or approvals. Keep the `id` from the create response if you need to `GET /secrets/{id}` or revoke an approval. If a required slot has no live binding (or no live approval, for a shared task), submit still creates a run but the execution will fail when the runner cannot resolve the secret — surface `secret_slot_names` and `secret_use_risk_warning` on the menu **before** submit. ## Shared-secret slot policies (borrower / owner / owner_overridable) A share grant (`POST /tasks/{id}/shares`) may carry a per-slot **credential-source policy**, set **only at creation** — changing it means revoke + re-share (a new grant generation), like every other grant mutation: ```json { "target_kind": "chatroom", "target_id": "...", "secret_slot_policies": { "SLOT_NAME": "owner_overridable" } } ``` | Policy | Meaning | | --- | --- | | `borrower` | **Default** when a slot is absent from the map. The borrower's own binding must exist **and** be approved (see Secret approval above). The owner's binding is never used for that slot. | | `owner` | The owner's binding is **lent** to the borrower — no borrower binding or approval needed for that slot. | | `owner_overridable` | The borrower's binding wins if bound; otherwise falls back to the owner's. | **Owner-lending is only observable cross-scope.** `effective_source` only actually resolves to `"owner"` when the consumer chain (chatroom → department → company of the *borrowing* room) and the task's owning department diverge — i.e. a department-owned task shared into a **different** department's room. When the consumer coincides with the owner (for example a company-scope binding on a company-owned task, where the same binding sits in both the consumer and owner chains), resolution is conservatively downgraded to `"borrower"` even under `owner_overridable` — a slot is never classified `"owner"` in that case. A run **freezes** the exact grant it was submitted under at submit time. Two separate liveness checks then protect an owner-lent pin from a grant that dies later: - **Claim time**: the run re-verifies the grant is still live (state, active slot, generation, task id) when it is first claimed for execution. - **Manifest reveal**: every time the runner's secret manifest is (re-)read — including retries/replays — the platform re-checks the grant again, plus that the target's authority hasn't moved since. This is deliberately a stricter superset of the claim-time check, because a department reorg (or a re-share, or a revoke) can happen in the window between claim and reveal. Borrower-resolved pins carry no grant dependency and never pay this cost. Menu (`GET .../menu`) and granted-jobs (`GET .../granted-jobs`) expose per-slot fulfillment status: ```json "secret_slots": [ { "name": "SLOT_NAME", "policy": "owner_overridable", "borrower_bound": false, "owner_bound": true, "effective_source": "owner" } ] ``` Run detail (`GET .../runs/{run_id}`) exposes a **redacted** snapshot of how each slot actually resolved, frozen when the run's secret pins are published at claim time — never a fingerprint, binding id, or provider path: ```json "secret_slot_provenance": { "SLOT_NAME": { "resolved_via": "owner", "consumer_scope": "chatroom" } } ``` `secret_slot_provenance` is `null` when the run has no persisted snapshot; malformed entries are skipped per-key rather than surfaced. A-012 approval must match the borrower-resolved slot subset at claim. POST validates a nonempty normalized slot set, consumer-manager authority, published version and digest; it does not determine which declared slots actually resolve as borrower. A mismatch at claim remains `secret_approval_required`. Do not ask for separate approval of owner-lent slots. ## Per-task secret paths Two different tasks sharing one consumer (same `consumer_scope`/`consumer_id`) using the same `slot_name` no longer collide — the underlying provider secret path is namespaced per task. A binding that already had a confirmed provider secret before this change keeps its legacy (un-namespaced) path forever; only a not-yet-confirmed binding adopts the new per-task path. The frontend never sees provider paths either way, so this is transparent — it only matters if you were previously working around the collision. > For exact request/response fields see the [API endpoint catalog](/en/reference/endpoints.md). # State machines and polling **There is no streaming.** The frontend tracks progress by **polling** (GET status). Below are the status values (from `src/schemas/enums.py`) and transitions for each resource. ## Environment version `SandboxEnvironmentVersionState` ```text draft → build_queued → building → quarantined → verifying → verified → regional_provisioning → ready ← can be referenced by a Task Version ``` Branch/failure states: `retryable_failed`, `rejected`, `abandoned`, `blocked`, `superseded`, `archived`, `provisioning_blocked`. **`quarantined` is not a failure** — it is a mandatory step on the success path: a successful compose always moves the version to `quarantined`, and the scan gate then moves it to `verifying`. - `retryable_failed` → `POST /.../retry-build`. - `provisioning_blocked` / provisioning failure → `POST /.../retry-provisioning`. ## Build attempt `SandboxBuildAttemptState` ```text validation_queued → validating → build_queued → building → verifying → completed cancel: cancel_requested → cancelling → cancelled failure: customer_failed / platform_failed ``` - `terminal_class` ∈ `completed` / `customer_failed` / `platform_failed` / `cancelled`. - `error_code` / `stage_code` / `phase` provide detail; `report_total_bytes > 0` means a build report exists. Common `error_code`s: `policy_reject` (customer), `identity_config`, `tenant_cancel` (CVE findings are advisory-only since 2026-08-25 — `vulnerability_reject` no longer occurs). - **Cancel is not a same-request settle.** `POST .../builds/{id}/cancel` fences the attempt (`cancel_requested`) and writes a durable provider-cancel intent even if the attempt was still queued. Poll until `terminal_class=cancelled`. Repeating cancel while `cancel_requested` / `cancelling` is idempotent. ## Run `SandboxRunState` ```text queued → starting → running → completed cancel: cancel_requested → cancelled failure: customer_failed / platform_failed ``` - Terminal states are marked by `terminal_at`. - `error_code` provides the failure reason. ## Task version `SandboxTaskVersionState` ```text draft → published → archived ``` There is also `content_state`: `active` (runnable) or `scrubbed` (content removed after retention / offboarding). The menu / runnable check requires `state == "published"` **and** `content_state == "active"`. Environment parent `state`: `active` → `archived`. Binding live `state`: `enabled` (menu requires this plus `authority_applicable`). Share grant retirement: `revoked`. ## Agent proposal `SandboxProposalState` (agent-confirmation flow) ```text prepared → committing → committed others: superseded (replaced by a newer proposal) / cancelled / expired / failed ``` See [Agent-confirmation flow](/en/flows/agent-confirm.md) for details. ## Polling advice - Build: poll `GET /environments/{id}/versions/{vid}/builds/{attempt_id}` until `terminal_class` has a value. - Run: poll `GET /chatrooms/{id}/runs/{run_id}` until `terminal_at` has a value. - Recommended REST interval: **2s**, backoff to **10s** after ~30s, stop when `terminal_at` / `terminal_class` is set. `MAX_STATUS_WAIT_SECONDS = 60` is **only** the agent tool `sandbox_job_status.wait_seconds` cap, not a REST long-poll. - Notifications: `SandboxNotificationEvent` (see [Notifications](/en/concepts/notifications.md)) can wake the UI; they do not change the polling model above. Always re-GET. ## Cancel (runs) - Unclaimed (`queued` / not yet dispatched): `POST .../cancel` sets `cancelled` immediately, releases the priced hold, marks the queue lease `released` — and, since 2026-08-30, releases the queue/concurrency capacity slot immediately too (a raced concurrent dispatch could previously leak that slot on an already-cancelled run). - Claimed / running: sets `cancel_requested`, enqueues a durable cancel outbox; the runner then walks to `cancelled`. - When a tenant cancel fence wins the race with runner `executions/complete`, the public `error_code` is **`cancelled_by_request`** (PR #951). Do not treat an empty `error_code` on a cancelled run as “unknown”. - `cancel_requested_at` is persisted but is **not** on `SandboxRunDetailResponse`. Watch `status` + `error_code`. - `can_cancel_run == can_read_run`. Restricted keys still only see their own runs. - A run can very rarely appear stuck in `starting` if the control plane died mid-dispatch. A background reaper self-heals it (re-drives the same dispatch exactly once) — no tenant action needed, and cancel still works on it in the meantime. # Agent-confirmation flow > **Key point**: the agent's sandbox submission is a **LangGraph tool** (`sandbox_submit_job` in `src/components/tools/custom/sandbox/factory.py`), called by the agent during a conversation turn. **The frontend has no dedicated confirm/reject REST endpoint.** The frontend's role is to present the proposal and observe the result; the actual confirmation happens in a later conversation turn. The other four tools (`sandbox_job_menu`, `sandbox_submitted_jobs`, `sandbox_job_status`, `sandbox_cancel_job`) and `ChatroomJobType.sandbox` are documented in [Agent toolkit](/en/reference/agent-tools.md). ## Why this flow exists When a task has `requires_confirmation = true` — or declares a table-writeback `output_policy` (`custom_table_writeback`), which is treated as confirmation-grade even when `requires_confirmation` is false — the agent cannot execute it directly in the same turn: it first **proposes**, and only after human confirmation (in the next turn) does it **submit** the run. This prevents the agent from running a side-effecting task without consent. A Command-output version (`custom_table_command`) with `requires_confirmation = false` is the exception: it is submitted directly ([Job output as a Command](/en/flows/command-output.md)). ## Two phases (the tool's internal semantics) 1. **Propose `mode="request"`**: the agent calls the tool with `task_version_id` + `input`. The backend creates a `prepared` proposal and returns: ```json { "kind": "prepared", "proposal_id": "...", "proposal_receipt": "", "summary": "...", "requires_confirmation": true, "task_id": "...", "task_version_id": "..." } ``` - This **supersedes** the same principal's other pending proposals (`superseded`). - `proposal_receipt` is a signed multi-segment token (containing 4 dots; passing an opaque id alone is judged `forged_receipt`). 2. **Commit `mode="commit"`**: this **must be a later turn** (bypassing within the same turn is not allowed). The agent carries only the `proposal_receipt`. The backend validates the proposal receipt + the out-of-band **turn receipt** (bound to company/chatroom/principal/human-message), then creates the run (`submission_source="agent"`) and returns: ```json { "status": "ok", "run_id": "...", "proposal_id": "..." } ``` - Response lost and resent: returns `{ "kind": "replay", "run_id": "..." }`. ## Proposal state `SandboxProposalState` ```text prepared → committing → committed others: superseded / cancelled / expired / failed ``` TTL: `PROPOSAL_TTL_SECONDS = 1800`, `TURN_RECEIPT_TTL_SECONDS = 600`. A same-turn commit is rejected with code **`same_turn`** (the confirming turn is the same as the proposing turn); a confirmation message that is not strictly later than the proposal's message gives `not_later`, and reusing one confirming turn on a second proposal gives `receipt_reused`. `sandbox_cancel_job` without `run_id` cancels this principal's pending proposal (`cancelled`). ## What the frontend needs to do The frontend **does not call the tool**, but it does need to present "there is a proposal awaiting confirmation" to the user, and observe the result after confirmation: - `requires_confirmation: true` on the **menu** tells you this task goes through the confirmation flow. - The proposal's `summary` / `requires_confirmation` are presented to the user through the conversation/assistant channel. - The tool's `list_submitted` returns that principal's own `proposals[]` (each carrying a `proposal_receipt`) and `runs[]`, so a later turn can pick up where it left off. - After confirmation and submission, the frontend observes that `run_id` using the run status endpoints, per [Run and fetch results](/en/flows/run-and-results.md). ## Error codes (tool layer) Tool error envelope: `{ "status": "error", "error": "", "message": "..." }` with `validation_error` / `forged_receipt` / `not_found` / `unauthorized` / `retryable` / `confirmation_input_unavailable` / `internal_error` / `provider_unavailable` / `same_turn` / `not_later` / `receipt_reused` / `already_committed` / `proposal_not_pending` / `forbidden` / `hidden` / `reauth_failed`; a job-contract 422's `validation_error` additionally carries a `code` (`input_schema_violation` / `job_contract_invalid`) and `errors[]`. An **expired receipt returns `forged_receipt`** (the TTL is checked inside signature verification) and a **same-turn commit returns `same_turn`**; only an unknown or someone else's proposal is `not_found`. Commit that verifies the later turn but cannot replay durable input → `confirmation_input_unavailable` (proposal stays pending). Use the **re-issued** `proposal_receipt` from `sandbox_submitted_jobs`, not a cached prepare token. Full table: [Agent toolkit](/en/reference/agent-tools.md). > A human user's submission goes through `POST /chatrooms/{id}/runs` (see [Run flow](/en/flows/run-and-results.md)); an agent's submission goes through this tool. Both ultimately produce a run that can be observed with the run status endpoints. # Job output as a Custom Tables Command A job version can finish by running **one Custom Tables Command**. The version declares an `output_policy` of kind `custom_table_command`. Every Run of it receives a one-run credential that can call exactly that Command, in exactly one chatroom, and the work script posts the Command's inputs when it has produced them. The platform then executes the Command through the same executor as the Commands API. This page is the **Sandbox contract only** (released in v5.21.0). What a Command's inputs, effects, approvals and result mean stays in the Custom Tables documentation. The older kind, table writeback (`custom_table_writeback`), keeps its contract except that it now refuses a table whose `write_policy` is `commands_only`; this page marks the places where the two differ. ## 1. Declare it on a draft version `output_policy` on `POST /tasks/{task_id}/versions` and `PATCH .../versions/{vid}` is a union discriminated by `kind`. The Command kind looks like this: ```json { "output_policy": { "kind": "custom_table_command", "command_id": "", "chatroom_id": "", "input_schema": [ { "name": "order_id", "type": "string", "required": true } ] } } ``` | Field | Notes | | --- | --- | | `kind` | Literal `custom_table_command`. | | `command_id` | The Command the Run may call. ≤36 characters from `[A-Za-z0-9._-]`. | | `chatroom_id` | The room the Command runs in (same pattern). Every Run of this version must be submitted from this room. | | `input_schema` | The Command's input declarations, ≤200 items (each a Custom Tables `CommandInput`: at least `name`, `type`, `required`). It must equal the Command's own `inputs` after normalization: the same order and the same value in every field of every input (`description` and `nullable` included). | An unknown field is a 422. The policy is hashed into the version's `canonical_digest` and appears only in the owner view (`null` for a borrower). Like table writeback it belongs on a **department-owned** job: a company-owned job answers 409 `output_policy_owner_scope_unsupported` at publish, and only a department-owned job's Runs can pass §4. Nothing checks at draft or publish that `chatroom_id` is a room of the owning department; if it is not, the version publishes but every Run is refused as borrowed. ## 2. Which Command qualifies Draft create, draft `PATCH`, publish and every Run submission check the Command: | Check | Refusal | | --- | --- | | The Command exists, is not deleted and belongs to your company | 422 `output_policy_command_not_found` | | `input_schema` equals the Command's declared `inputs`, and the Command is a write Command (`mode` `write`) | 422 `output_policy_schema_mismatch` | | The Command runs under *definition* authority, has execute policy `restricted` and declares an `effect_identity` | 403 `output_policy_author_denied` | At **publish** a failure of any of these three checks, the third included, is reported as **409 `output_policy_unsatisfiable`**, the same way a vanished table is reported for table writeback (its message text is generic and speaks of a table; the code is the contract). Only the identity, grant and scope checks of the next section, run on the publisher and on the stored author, stay **403 `output_policy_author_denied`**. At **submit** the codes in the table are returned as they are, so a Command that was deleted or re-declared after publication refuses the submission (nothing is queued) until the Command and the version's policy agree again. One more thing the checks do not catch: a Command that requires a frozen proposal (`proposal_policy`) passes all three, but the executor refuses every call a Run makes to it (`ct.governed_context_required`), so it cannot serve as a Run's output. ## 3. Who may author, publish and submit The **author** and the **submitter** must each pass the same Command check, and it is repeated at every step: - **Identity:** a live (verified, enabled, unexpired) non-service-account user of your company who is a member of `chatroom_id` (a live room). - **Grant:** an effective execute grant on the Command, evaluated with `chatroom_id` as the acting room. Neither managing the Command's tables nor holding an execute-delegation lease is enough here, although the Commands API itself admits both to a restricted Command. - **Scope:** a department Command needs `chatroom_id` to be a room of that department; a chatroom Command needs it to be that room. A failure is **403 `output_policy_author_denied`**. The author is the user who last wrote the draft: draft create, every draft `PATCH` and every `POST .../script-file` upload check the caller and make the caller the author, and publish checks both the publisher (a platform root operator is exempt) and the stored author. ## 4. Submitting a Run Every route that creates a Run (manual `POST /chatrooms/{id}/runs`, retry, the Agent tool, a custom-table trigger) mints **one** sealed credential for the Run in the same transaction. Table writeback skips minting, and still queues the Run, for a borrowed Run or for a submitter that is not a JWT-authenticated user (an API key, a restricted Sandbox key or a social-media client). A Command-output version instead **refuses the submission** — nothing is queued — with **403 `output_policy_author_denied`** unless all of these hold (a Command that was deleted or no longer matches is the 422 of §2): - the job is department-owned and the Run is not *borrowed* (the room belongs to the owning department; a company-owned job or another department's room is borrowed — see [Job audience](/en/concepts/job-audience.md)); - the Run's room is the policy's `chatroom_id`; - the submitter is a user principal authenticated as JWT — a signed-in user, the trigger's author for a trigger-fired Run, or the Agent's user in an internal chat; an API key, a restricted Sandbox key or a social-media client never qualifies; - the submitter and the version's author both pass the Command check. If the platform's writeback signing key is missing or too short, the submission is 503 `sandbox_writeback_key_unavailable`, as for table writeback. ## 5. The credential inside the Run At claim time the credential reaches the work script through the **sealed secret channel**, under the same two variable names that table writeback uses. They are runner-reserved `TEAMSYNC_` names and cannot be set in `ordinary_env`: | Variable | Value for a Command-output Run | | --- | --- | | `TEAMSYNC_CT_WRITEBACK_URL` | `{API origin}/public/module/custom_tables/callback/command-output/{credential id}` | | `TEAMSYNC_CT_WRITEBACK_TOKEN` | The Bearer secret for that URL | - One credential per Run; only the SHA-256 of its secret is stored. - It stops working at the earliest of three limits: the Run leaving `queued` / `starting` / `running`; claim time + `timeout_seconds` + the 120 s finalization envelope; and a backstop set at submission, submission time + the larger of 48 h and `timeout_seconds` + 120 s (queue wait counts against it, and a Run claimed after it starts without the variables). It is revoked at every terminal transition. - **Check that both variables exist.** If the platform cannot reveal the credential at claim — the author's or submitter's grant was withdrawn while the Run waited, the Command or the version's policy changed or the task or version was archived meanwhile, the backstop above passed, the signing key or the public API origin is unavailable, or the extra variables would push the environment over its size cap — the Run still starts, without them. - The work script must be able to reach the API origin: runtime egress on the hosted cloud ([limits](/en/concepts/limits.md)); on an on-prem deployment also the box's `SANDBOX_ONPREM_EGRESS=internet` and its API-origin allowance ([cloud and on-prem deployments](/en/concepts/integration-contract.md)). ## 6. Posting the output ```text POST {TEAMSYNC_CT_WRITEBACK_URL} # /public/module/custom_tables/callback/command-output/{credential id} Authorization: Bearer {TEAMSYNC_CT_WRITEBACK_TOKEN} Content-Type: application/json { "inputs": { "order_id": "A-1001" } } ``` ```python import json, os, urllib.request request = urllib.request.Request( os.environ["TEAMSYNC_CT_WRITEBACK_URL"], data=json.dumps({"inputs": {"order_id": "A-1001"}}).encode(), headers={ "Authorization": "Bearer " + os.environ["TEAMSYNC_CT_WRITEBACK_TOKEN"], "Content-Type": "application/json", }, method="POST", ) print(urllib.request.urlopen(request).read().decode()) ``` This is a public route (no `/private` prefix, no tenant sign-in): the Bearer secret is the authority. The body is exactly `{ "inputs": { … } }` — any other top-level field is a 422 — and `inputs` is validated against the Command's declared inputs and its JSON limits. The success body is the Commands API's execution response (`CommandExecutionResponse`, `null` fields omitted); read its fields and statuses in the Custom Tables documentation. - The Command executes **as the Run's submitter** (for a trigger-fired Run: the trigger's author), with `chatroom_id` as the acting room, and its idempotency key is `sandbox-output:{run_id}`, so the Command takes effect at most once per Run. - **The first valid output wins.** The first `inputs` that pass the declared-input check are pinned by digest (of the validated inputs) before the Command runs, and the pin stays even if the Command then refuses them or the execution fails: a retry must send the same inputs, and corrected inputs get 409 `output_command_conflict` for the rest of the Run. Posting the same inputs again goes through the Command's own idempotency: an execution that already succeeded (or is staged for approval) is replayed, never run twice, while a failed one can be retried (a call that races an execution still in progress can get the Commands API's own 409 `idempotency_in_progress`). - Authority is re-checked on every call, so a grant withdrawn mid-Run turns the call into 403 `output_policy_author_denied`. - **Approvals need a live Run.** If the Command stages an approval instead of finishing, releasing it re-resolves the Run's credential. That credential is revoked once the Run ends, so an approval released after the Run is over can no longer succeed. | Status | `detail.code` | When | | --- | --- | --- | | 404 | `output_run_unavailable` | Unknown credential id, or the Bearer secret is missing, over 256 characters or wrong (one uniform answer, so nothing is revealed) | | 409 | `output_run_unavailable` | The credential is revoked or expired, or the Run is no longer `queued` / `starting` / `running`, or its task or version is no longer active and published | | 409 | `output_command_stale` | The version's policy, the Command's definition or its declared inputs changed after the Run was submitted, or the Command was deleted or is no longer a write Command of your company | | 409 | `output_command_conflict` | A different valid `inputs` was already accepted for this Run | | 422 | `output_schema_violation` | `inputs` do not satisfy the Command's declared inputs | | 403 | `output_policy_author_denied` | The submitter's or the author's Command authority was withdrawn, or the Command no longer runs under restricted definition authority with an effect identity | | 422 | (validation array) | The body is not `{ "inputs": { … } }`, exceeds the JSON limits, or the credential id in the path is not a lowercase UUID | | other | Command execution errors | Whatever the Commands API answers for the same call, in its own error shape (`detail.error` objects, or `detail[]` lists of `{type, msg}` items) | ## 7. Triggers, the Agent and the frontend - **Custom-table trigger.** `submit_sandbox_job` may target a Command-output version (table-writeback versions stay refused). The Run is submitted as the trigger's author, the input file is exactly the rendered `input`, and a refusal from §4 at fire time is a failed action on the trigger-run receipt (no Sandbox Run exists). See [Custom-table trigger](/en/flows/custom-table-trigger.md). - **Agent.** With `requires_confirmation=false` the Agent submits directly, with no proposal turn; table-writeback versions still need the two-turn confirmation. A 403 refusal from §4 reaches the model as the tool error `unauthorized`, a 422 (Command missing or mismatched) as `validation_error`, and the 503 as `provider_unavailable`. See [Agent toolkit](/en/reference/agent-tools.md). - **Frontend.** Owners see `output_policy` on the version; borrowers see `null`. Handle the authoring and submit refusals above. Observe the Run through the normal [run API](/en/flows/run-and-results.md); the Sandbox run record does not carry the Command's result. ## Related - [Job audience](/en/concepts/job-audience.md) — who may publish and who is borrowed. - [Jobs, share, and enable](/en/flows/tasks-and-bindings.md) — the version fields. - [Error-code table](/en/reference/errors.md) # Submit a run from a custom-table trigger A custom-table row trigger can enqueue a Sandbox run when a row is **created** or **updated**. This is not a `/private/module/sandbox` endpoint. The action lives on the custom-tables trigger config (`src/crud/custom_table_triggers.py`, action type `submit_sandbox_job`). The resulting run is a normal tenant run (`submission_source="custom_table_trigger"`) that the frontend already knows how to poll. Full trigger mechanics (when predicates, receipts, retries) stay in the custom-tables handbook. This page is only the Sandbox contract. ## Action shape ```json { "type": "submit_sandbox_job", "chatroom_id": "", "task_version_id": "", "timeout_seconds": 1800, "input": { "order_id": "$row.col_…", "note": "static" } } ``` | Field | Required | Notes | | --- | --- | --- | | `type` | yes | Literal `submit_sandbox_job`. | | `task_version_id` | yes | Same-company, `state=published`, `content_state=active`. | | `chatroom_id` | see notes | Defaults to the **table's** chatroom. Required on department/company-scope tables (those tables have no implicit room). | | `timeout_seconds` | no | Integer 1..604680. Default = the version's `timeout_seconds`. | | `input` | yes | JSON **object**. Templates may interpolate `$row.col_` (rename-stable internal keys). The **template itself** carries tighter bounds: at most 8 levels of nesting below the root object and at most 2000 characters per string leaf, both refused at save time. Only then is it validated as canonical JSON (≤1 MiB, depth ≤64), and re-validated at fire time. | Only trigger events `created` and `updated` may carry this action. ## Save-time checks (configuring user) The editor must be a live user who `can_execute_manually` the chosen task in the chosen room. The same checks run again as the **causing principal** when the row fires. Identifiers in the action are configuration, never authority. Refused at save: - Task version not found / not published+active / wrong company. - `requires_confirmation=true` — a background trigger has no second HumanMessage to commit with. - Version declares a table-writeback `output_policy` (`custom_table_writeback`) — trigger lane never mints a table credential. Use a manual JWT submit on a department-owned job from an owning-department room instead ([Job audience](/en/concepts/job-audience.md)). A Command-output version (`custom_table_command`) is accepted, under the extra rules in [Command-output versions](#command-output-versions) below. - Configuring user cannot execute that task in that room. - Department/company table missing `chatroom_id`. - `input` not an object, or fails canonical JSON / `$row` normalization. ## Fire-time authority At fire time the worker resolves **exactly one** causing principal (the user or client that caused the row write). Then: - The destination `chatroom_id` must still be live in the company. - The causing principal must still be live and still a member of the **source** acting room. - The causing principal must `can_execute_manually` the task in the **destination** room (for a Command-output version the trigger's author is checked instead — see below). - Source permission context (the ACL that exposed the row) must still allow paid work; a changed principal or a dead source room denies the action. - When the causing principal is a client (social-media and Agent-channel alike), the destination `chatroom_id` **must be that client's own chatroom** — the worker resolves the client with `SocialMediaClient.chatroom_id == ` and otherwise denies the whole fire. A department/company-scope table pointing at any other destination room therefore never produces a Sandbox run for client-caused writes. - Agent-channel clients are **not** remapped to a social-media Sandbox principal (that would charge the wrong budget). The live Agent delegate user is required (this remap happens only after the destination-room constraint above has passed). Denied fire is recorded on the trigger run receipt; it does **not** create a Sandbox run. ## Command-output versions A version whose `output_policy.kind` is `custom_table_command` ([Job output as a Command](/en/flows/command-output.md)) is the one `output_policy` kind a trigger may target. For these versions: - **Save time.** The action's destination `chatroom_id` must equal the policy's `chatroom_id` (otherwise a configuration error on `actions`), and both the configuring user and the version's author must pass the Command check (a failure keeps its own code, for example 403 `output_policy_author_denied`). - **Fire time.** The Run is submitted as the **trigger's author**, not as the causing principal. The worker re-checks the policy room, the author and the version's author against the Command, and the author must `can_execute_manually` the task in the destination room; a failure is a denied fire (no Sandbox run). The causing principal no longer needs to be able to execute the task, but must still be able to read the source row and the referenced columns, as above. - **Input.** The job's input file is exactly the rendered `action.input` object — it is **not** wrapped in the `source` / `trigger` / `record` / `row` envelope shown below — and the receipt's `input_digest` is the digest of that object. ## What the frontend should show 1. Trigger editor: pick a published task version from rooms the editor can execute in. Hide / reject `requires_confirmation` versions. 2. For department/company tables, require an explicit destination `chatroom_id`. 3. After a row write, look at the custom-tables trigger-run receipt. Success: ```json { "type": "submit_sandbox_job", "ok": true, "run_id": "...", "trigger_run_id": "...", "task_version_id": "...", "input_digest": "sha256:…", "run_status": "queued", "submission_source": "custom_table_trigger" } ``` Idempotency key the worker uses: `ct-trigger:{trigger_run_id}:{action_id}`. Keep the echoed action `id` so retries resume the same receipt. The durable run `input` the sandbox sees is **not** the raw row. Except for Command-output versions (above), it is this envelope (only the leaves you listed under `action.input`; no full row, no diff): ```json { "source": "custom_table_trigger", "trigger": { "run_id": "...", "trigger_id": "...", "action_id": "...", "event": "created|updated", "fired_at": "..." }, "record": { "table_id": "...", "record_id": "...", "record_version": 1 }, "row": { "order_id": "…" } } ``` Configure `GET`/`PUT` on the custom-tables trigger routes (`/private/module/custom_tables/{chatroom|department|company}/.../triggers`), then `GET .../trigger-runs` and `POST .../trigger-runs/{id}/retry`. Those paths are not under `/private/module/sandbox`. 4. Observe that run with the normal [run API](/en/flows/run-and-results.md) in the **destination** chatroom: ```text GET /chatrooms/{destination_chatroom_id}/runs/{run_id} ``` `SandboxRunDetailResponse.submission_source` is `custom_table_trigger`. Visibility is still A-010: you see the run if you submitted it (you were the causing principal) or you manage that destination room. For a Command-output version the run's principal is the trigger's author, so a causing user who is not that author sees it only as a manager (of the destination room, of its department, or of the company). 5. Poll `terminal_at`. Notifications (`run_terminal`) still do not replace GET. ## What this is not - Not an agent confirmation. Do not wait for `proposal_receipt`. - Not a way to bypass share. `can_execute_manually` still requires a live applicable share (or a historical owner-room task). **Enable / binding is not required** for this gate — that switch is for the Agent menu. The destination room does not have to have enabled the job. - Not a REST submit. Do not POST `/runs` on behalf of the trigger; the worker submits. ## Related - [Run and fetch results](/en/flows/run-and-results.md) - [Agent toolkit](/en/reference/agent-tools.md) — confirmation-required versions are the other submit path - [Personas](/en/concepts/personas.md) # Create an environment and build **Environment authority:** company-owned environments require a company manager. Department-owned environments can be created/managed by the owning department manager or a company manager; a department creator must send `owner_scope: "department"` and that department’s `owner_id`. Context uploads, versions, builds, retries and archive use the same owner-scope gate. Curated environments have no tenant write path. Metadata reads remain company-visible; upload-session reads require owner management. Unauthorized tenant principals get 404 at the router; restricted keys are rejected earlier by auth. ## Confirmation catalogs (no auth) Dropdowns do **not** invent region or profile strings. Fetch the closed V1 sets: ```text GET /public/info/sandbox/regions GET /public/info/sandbox/profiles ``` These live under `/public/info`, not `/private/module/sandbox`. No JWT. Send `code` as `allowed_regions` / `build_region`. Send `id` as `resource_profile` **only** when `tenant_selectable` is true. **`xlarge` is not tenant-selectable.** Intersect regions with `GET /settings.allowed_regions` (runtime placement only). Live operator enablement stays on `GET /root/sandbox/regions`. The region list is the catalog of the deployment's provider (v5.10.11): the hosted cloud lists its cloud regions (`tier` 1 or 2) and never `onprem`; an on-prem deployment lists only `onprem` (`tier` 3). See [cloud and on-prem deployments](/en/concepts/integration-contract.md). ## Curated path (no tenant upload or build) `GET /environments` also returns `owner_kind=system_curated` rows. Root publishes those versions. Tenants **read and pin** them: 1. `GET /environments` / `GET /environments/{id}/versions` until a pin-able version (`state=ready`). The live catalog is named **SCFG Standard** and is ready in every public region. 2. `POST /tasks/{id}/versions` with that `environment_version_id`. Do **not** upload context or send a tenant `source_digest` build. 3. Publish + share + room-enable, same as any other job ([Job audience](/en/concepts/job-audience.md)). Do not tell ordinary users to create environments. Prefer this path unless the product needs a tenant-authored image. ## Choose the environment owner (v5.10.0) `owner_scope` defaults to `company`; its `owner_id` may default to the caller’s company id. A department manager must explicitly choose their department: ```json { "name": "Department runtime", "owner_scope": "department", "owner_id": "" } ``` Department and company environments share the same company-wide active-parent cap. Ownership cannot be changed by PATCH. Visibility of environment metadata does not grant write authority. `owner_kind=company` means tenant-custom; use the separate `owner_scope` to decide which management controls to show. ## Custom environment sequence 1. **Create the parent** — `POST /environments` (`name` 1–256, `description` ≤2048). Returns `state: "active"`. This does not upload bytes or start a build. 2. **Init a context upload** — `POST /environments/{id}/context-uploads`: ```json { "declared_bytes": 123456, "archive_format": "tar.gz" } ``` `archive_format` ∈ `zip` / `tar` / `tar.gz` (default `tar.gz`). `declared_bytes` is the **exact** archive size, 1..1 GiB. Response includes `upload_session_id`, `capability_token`, `capability_expires_at` (15 minutes), `put_header_name` (`X-Sandbox-Upload-Capability`), `put_content_type` (`application/octet-stream`). This is **not** a chatroom/DCC `blob_id` and **not** a presigned R2 URL. 3. **PUT the bytes** — `PUT /environments/{id}/context-uploads/{upload_session_id}` with header `X-Sandbox-Upload-Capability` and `Content-Type: application/octet-stream`. Body must not exceed `declared_bytes`. Returns `bytes_received` + `digest` (`sha256:`). 4. **Complete** — `POST /environments/{id}/context-uploads/{session_id}/complete` (optional `expected_size` / `expected_digest` from the PUT). Returns `owned_object_id`. Idempotent if already completed. Complete does **not** start a build. Poll `GET .../context-uploads/{session_id}` if you need `state`. 5. **Abort** (optional) — `POST .../context-uploads/{session_id}/abort` frees a not-yet-completed slot. Completed sessions cannot abort. 6. **Create the version** — `POST /environments/{id}/versions`: ```json { "owned_object_id": "", "source_ref": "app-env-v3.tar.gz", "resource_profile": "standard" } ``` Preferred: `owned_object_id` from complete. When `source_digest` is sent alone, the backend does **not** check it against any completed context object — any well-formed `sha256:<64 hex>` value creates a draft version; if that digest matches no uploaded archive, queueing the build still succeeds and the error only surfaces when the build actually runs and cannot fetch the context, so production flows always send the `owned_object_id` returned by complete. If both are sent they must match. `resource_profile` from the public catalog, tenant-selectable only. Version is immutable; `state: "draft"`. The archive must carry a **regular file named `Dockerfile` at the archive root** — not a symlink, not in a subdirectory, not renamed to `Dockerfile.prod`. Pack with `tar czf ctx.tar.gz -C myapp .`, never `tar czf ctx.tar.gz myapp/` — the latter puts the Dockerfile at `myapp/Dockerfile` and the build always fails with `error_code=context_invalid`. The Dockerfile in that archive may `FROM` **any public base image** (`node:20-alpine`, `python:3.12-slim`, `debian:bookworm-slim`, `ubuntu:24.04`, `scratch`, …) — there is **no fixed allowlist** as of 2026-08-25. The isolation boundary is the sandboxed runtime itself (forced signed entrypoint, no host escape), not a build-time image allowlist, so compose no longer gates your base image **by name**. It **does** still inspect the composed layers: a file, link, whiteout, or device node at a platform-reserved path (`usr/local/gcp/`, `teamsync-runner/`, `.teamsync-platform/`, `etc/ld.so.preload`, or under `proc/`, `sys/`, `dev/`) fails the build — `error_code=context_invalid`, `error_detail=reserved_path_or_hostile_entry`. The inert nodes every real base ships — the empty `/dev`, `/proc`, `/sys` mount points and the `/etc/mtab` symlink into `/proc` — are explicitly allowed. What compose **does** still refuse outright, regardless of base: a dynamic (`$`/`{`) `FROM` reference, **any** `# syntax=` directive line (including the common `# syntax=docker/dockerfile:1` — any line in the file counts, not just the first), `ADD` from a network source, `RUN --network=host`, a `RUN --mount` of `type=secret`/`type=ssh` or whose `from=` names an unreviewed stage, `COPY --from` naming an unreviewed stage, and a Dockerfile over 1 MiB — these guard the platform, not your content. **Both the build and (by default) the runtime have network**: Dockerfile `RUN` steps reach the **public internet** (private ranges, loopback and the metadata endpoint are firewalled), and a run's `startup.sh`/`work.sh` can also `pip install` / `npm ci` / `go get` while the company's `runtime_egress_enabled` is true (default on; egress bytes are metered). Bake dependencies into the image anyway when you want fast, reproducible runs — a work-time install is repeated on every execution. CVE findings are **advisory only** and never fail a build — `vulnerability_reject` no longer exists as an outcome. SBOM and vulnerability reports are still generated and attached to the build attempt; only a real policy/contract violation (forged runner identity, tampered digest, missing signature, …) produces `error_code=policy_reject`. 7. **Queue the build** — `POST /environments/{id}/versions/{vid}/builds` (`build_region` omit → `asia-east1` on the hosted cloud, `onprem` on an on-prem deployment; `build_profile` omit → `standard`). A `build_region` the region catalog does not list — `onprem` on the cloud, a cloud code on-prem — is **422 `build_region_unavailable`**. 8. **Poll** (no streaming): - Attempt: `GET /.../builds/{attempt_id}` until `terminal_class` is set. - Version: `GET /.../versions/{vid}` until `state` is **`ready`**. Caps the frontend **does** hit: 1 GiB per archive, **20** live staging slots and **20 GiB** declared staging per company, **5 inits per minute**. 429 `staging_slot_exhausted` / `upload_rate_limited`. Incomplete uploads **leak** a slot until abort/reconcile — that 429 also wedges scan-report writes. **A context that is completed but not yet adopted by a version also holds its slot**: complete does not release the staging lease — the slot is freed only when `POST .../versions` adopts that `owned_object_id` — and a completed session cannot be aborted (422), so "upload but never create a version" eats through all 20 slots. Rule: create the version immediately after every complete; abort only reclaims incomplete leftover sessions; do not spin-retry inits. Beyond the 1 GiB compressed cap, the archive also has **unpack** caps: **5 GiB** total extracted, **100,000** entries, **64 MiB** of archive metadata; a decompression ratio ≥100 with more than 10 MiB uncompressed is refused as a decompression bomb. The context validator enforces these only after the build is queued, and the failure is always `error_code=context_invalid` with an `error_detail` that is also just `context_invalid` (the validator's specific reason is not exposed) — not a 4xx at upload time. ## How long it takes (measured 2026-09-03) Create environment + upload context + create version + submit build: **~1 s** of API time. The build then runs **two** Cloud Builds (backend ≥ #1154): your Dockerfile (customer build, its own identity), then ONE trusted release Build whose steps run static scan and smoke **in parallel**, then sign → attest → promote → final verify, on an `E2_HIGHCPU_8` worker. The stage image is pulled once per Build instead of once per gate: | Base image | Ready after | | --- | --- | | `python:3.12-slim` + one pip package | ~5–6 min (was ~12) | | `golang:1.22-bookworm` + apt python3/node | ~7 min (was ~19) | Poll `GET …/versions/{id}` and show `build_stage` (`build_stage_index / build_stage_count`, `build_stage_since`, `build_expected_seconds`) plus `build_next_action` while it is live; on failure surface `last_build_error_code` / `last_build_error_detail` and `build_next_action` (backend ≥ #1140). `build_stage` still walks every gate (`build_stage_count` is unchanged) — while the release Build runs, the gates flip as each of its steps finishes, and `build_expected_seconds` is now ~360. The six verification gates share one identity (`sandbox-release`) inside that Build; the untrusted customer build keeps its own (decision 2026-09-04: gate separation inside the trusted chain was traded for build time). ## Failure and retry branches | Situation | Version state | Action | | --- | --- | --- | | Retryable failure | `retryable_failed` | `POST /.../retry-build` — takes no request body, and the new attempt **always** queues with `build_region=asia-east1`, `build_profile=standard`; it does not reuse the failed attempt's region or builder size. To retry in another region or builder size, call `POST /.../versions/{vid}/builds` with `build_region` / `build_profile` yourself (subject to the same 7-day retry window) | | Provisioning stuck | `provisioning_blocked` / provisioning failure | `POST /.../retry-provisioning` | | Customer-side build failure | build `terminal_class: customer_failed` | Look at `error_code` (`context_invalid`, `dockerfile_exit`, `image_limit`, `timeout_customer`, `policy_reject`, …) for the category, then `error_detail` for the specific rule (`dockerfile_not_utf8`, `dynamic_base_forbidden`, `remote_add_source_forbidden`, `host_network_forbidden`, `remote_syntax_frontend_forbidden`, `copy_from_unknown_stage`, `dockerfile_too_large`, `reserved_path_or_hostile_entry`, …), fix the archive, open a **new** version | | Platform failure | build `terminal_class: platform_failed` | Look at `error_code` (`identity_config`, …). Retry; if it keeps failing, report to the backend | | Cancel | `POST /.../builds/{attempt_id}/cancel` (**still available under the kill switch**) | Always `cancel_requested` plus a durable provider-cancel intent — even if the attempt was still queued. Poll until `terminal_class=cancelled`. Not a same-request settle. | Other branch states: `quarantined`, `rejected`, `blocked`, `abandoned`, `superseded`, `archived`. `archived` is terminal: the controller reclaims the version's regional Jobs and image packages within minutes (see [Retention](/en/concepts/retention.md)), so archive only versions no task still pins. A version whose `runner_release_approved` is false cannot run **bundle** jobs until the operator approves that runner release or you queue a new build; `runner_release_next_action` says which. ## Common errors - **409 `build_not_eligible`**: the version's current state cannot start a build. - **409 `nonterminal_exists`**: this version already has a nonterminal attempt. Poll that attempt. - **409 `not_cancellable`**: cancelling an attempt that is already terminal. `cancel_requested` / `cancelling` is idempotent (returns the live row). - **409 `capability_fenced`**: upload capability does not match this session. - **413 `context_too_large`**: PUT body / Content-Length exceeds `declared_bytes`. - **422**: missing `owned_object_id` and `source_digest`, digest pattern, unknown field, empty PUT body. - **422 `build_region_unavailable`** (on `POST .../builds`): `build_region` is not in this deployment's region catalog (on-prem also when region rows are active but this one is not). Nothing was queued; pick a code from `GET /public/info/sandbox/regions`. - **429 `staging_slot_exhausted`** / **`upload_rate_limited`**. - **503 `build_admission_fenced`** (on `POST .../builds` and `POST .../retry-build`): a platform stage-image rollout holds the build-admission fence; nothing was queued. Retry after the `Retry-After` header (capped at 300 s) — this is not an outage. - **404**: unauthorized / another company's id / restricted key / curated environment upload. ## UI advice - Three-level "environment list → version list → build status". - Before `state=ready`, forbid pinning the version on a job. - Build report: `report_total_bytes > 0` means a report exists (body is not on the tenant API). - Do not send a generic blob_id. Next: [Jobs, share, and enable](/en/flows/tasks-and-bindings.md). ### Which jobs can pin the environment Company-owned and curated environments can be pinned by eligible tasks of the same company. A department-owned environment can be pinned by a task of that same department **or a company-owned task**, but not another department’s task or a chatroom-owned Quick Run task. Quick Run therefore needs a company-owned or curated environment. Readiness, security and tenant checks still apply. # Flows overview The flows the frontend most often needs to implement: - [**Run a task as a tenant user**](/en/flows/tenant-user.md): menu → submit → poll → download only; do not create environments or tasks. - [**Create an environment and build**](/en/flows/environment-and-build.md): pin **SCFG Standard**, or create environment → upload context → pin `owned_object_id` → build → poll to `ready` (environment owner-scope manager). - [**Jobs, share, and enable**](/en/flows/tasks-and-bindings.md): department/company job → publish → share → room-enable (see [Job audience](/en/concepts/job-audience.md)). - [**Run and fetch results**](/en/flows/run-and-results.md): menu → submit (with Idempotency-Key) → poll → content flags → artifacts → download capability → cancel/retry. - [**Agent-confirmation flow**](/en/flows/agent-confirm.md): the agent's tool-based propose/confirm; how the frontend observes it. - [**Custom-table trigger**](/en/flows/custom-table-trigger.md): `submit_sandbox_job` from a row create/update; poll the resulting run. - [**Job output as a Command**](/en/flows/command-output.md): the `custom_table_command` output policy — a run-scoped credential lets the work script run one Custom Tables Command. Each page annotates the actual endpoints and status values. For full endpoint definitions see the [API reference](/en/reference/index.md). # Local reproduction and debugging When the sandbox runs your job it does exactly one thing: inside your environment image, it runs one fixed `/bin/sh` session: ```text . /workspace/startup.sh && . /workspace/driver.sh ``` The three UI fields land as three fixed filenames (**an uploaded file's original name is never preserved**): | Field | Lands at | Read by | | --- | --- | --- | | Startup script (startup_script) | `/workspace/startup.sh` | always `sh` | | Work command (work_command) | `/workspace/driver.sh` | always `sh` (blank → contents are `. /workspace/work.sh`) | | Work script (work_script) | `/workspace/work.sh` | whatever the driver says — with `python3 /workspace/work.sh` it is Python | Per-run inputs: the input JSON is written to the file named by `$TEAMSYNC_INPUTS_FILE` before your script starts; write results to the path in `$TEAMSYNC_OUTPUT_FILE`; drop any files you want to download afterwards under `$TEAMSYNC_ARTIFACTS_DIR` (`/workspace/artifacts`, runner ≥ #1152); secret slots are exported as environment variables. The cwd is nominally `/workspace` but the contract leaves it unspecified — **always use absolute paths** in scripts and `work_command`. ## The local harness A custom environment's Dockerfile is yours, so you can build the same base locally and run the same contract: ```bash # 1) build the same archive you uploaded for the environment docker build -t my-env ./env-context # 2) assemble /workspace exactly as the platform materialises it mkdir -p ws cp startup.sh ws/startup.sh # empty file if you have none cp my-work.py ws/work.sh # note: always work.sh, whatever the original extension printf '%s' 'python3 /workspace/work.sh' > ws/driver.sh # = your work_command echo '{"demo": 1}' > ws/inputs.json # 3) run the same session (--network=none simulates a company with runtime_egress_enabled=false; # the default deployment allows outbound network at runtime — drop the flag to match it) docker run --rm --network=none \ -v "$PWD/ws":/workspace \ -e TEAMSYNC_INPUTS_FILE=/workspace/inputs.json \ -e TEAMSYNC_OUTPUT_FILE=/workspace/output.json \ -e TEAMSYNC_ARTIFACTS_DIR=/workspace/artifacts \ -e MY_SECRET_SLOT=dev-value \ my-env \ /bin/sh -c '. /workspace/startup.sh && . /workspace/driver.sh' cat ws/output.json ``` Wrong paths, missing packages, wrong runtime version, `node_modules` resolution failures — each takes five seconds to rule out locally, instead of one full upload → build → ready → run cycle per hypothesis. ## Local harness vs the real sandbox | Aspect | Local harness | Real sandbox | | --- | --- | --- | | Base image | your `docker build` | the image built from the same archive + the platform's signed runner layer (adds files under `teamsync-runner/` only; never touches your content) | | Network | `--network=none` only when simulating `runtime_egress_enabled=false` | outbound allowed by default (`runtime_egress_enabled`, `GET /settings`), metered; private/loopback/metadata ranges are NOT filtered | | Isolation | plain container | gVisor sandbox, non-root, resource limits (per resource_profile) | | Environment | your `-e` flags **plus the image's own `ENV`** | only `PATH`, `ordinary_env`, secret slots and the three `TEAMSYNC_*` pointers — other image `ENV` values (`JAVA_HOME`, `LANG`, …) are not inherited (on-prem too since v5.10.11) | | Process model | `/bin/sh` is PID 1 of the container; `id -u` is the image's `USER` | Hosted-cloud egress-enabled runs on a runner release built from v5.10.11 source attempt a nested user+PID namespace; when the host permits the private `/proc` setup, `/bin/sh` is PID 1 with that `/proc`, and otherwise the runner records or uses its direct-shell fallback. On-prem uses the Docker worker's direct `/bin/sh`; the runner keeps `/workspace/.teamsync/` | | Secrets | plain `-e` | sealed channel, exported as env vars, never on disk | | Timeout | none | `timeout_seconds` (also capped by the company ceiling) | The behavioural gap is essentially isolation strength, resource caps and the stripped image `ENV`; **the contract (filenames, session, `TEAMSYNC_*` variables) is identical**. If it passes locally but fails deployed, check resource limits and timeout first, then any variable your script expects from the image's `ENV`, then whether the pinned environment version really is the same image. ## The Node `node_modules` trap Your code runs at `/workspace/work.sh`, so Node resolves modules upward through `/workspace/node_modules` → `/node_modules`. Dependencies in the environment Dockerfile must therefore land at the root: ```dockerfile FROM node:20-slim WORKDIR / # key: makes npm ci produce /node_modules COPY package*.json ./ RUN npm ci --omit=dev ``` With `WORKDIR /app`, `require('axios')` fails at runtime — and the local harness reproduces exactly that failure. Python has no equivalent problem (pip installs into site-packages, independent of path). ## "I don't know what's inside the image" An environment's build content is **the archive you uploaded** — keep the Dockerfile and lockfiles (`package-lock.json` / `requirements.txt`) in your own version control and the image contents stay auditable forever. The version response's `source_digest` / `source_ref` (owner-only) tie a version back to the exact archive. Curated environments (SCFG Standard) do not expose their Dockerfile today — file a ticket with the operators if you need the manifest. # Run and fetch results Individual run actions are addressed by **chatroom** (cross-room history is also available at `GET /runs`): `/chatrooms/{chatroom_id}/...`. A restricted API key can use menu / submit / query its own runs / download its own artifacts / cancel its own runs; **retry is 405 at the route allowlist and quick-run is 403 at auth** for a restricted key (404 if they reached the router). ## 1. Discover the runnable list ```bash GET /chatrooms/{chatroom_id}/menu # query page_size (ge1 le100, default 50) ``` Returns `items[]` (`SandboxMenuItemResponse`): `task_id`, `task_name`, `task_version_id`, `task_version_digest`, `requires_confirmation`, `secret_slot_names`, required `source_visible=false`, `secret_slots[]` (per-slot borrower/owner fulfillment), `timeout_seconds`, `update_available`, `input_instructions` / `input_example` / `input_schema`, `runtime_contract`, `secret_use_risk_warning?`. **Granted and enabled only. Same list for every caller.** **Does not include the script source** — `runtime_contract.driver` on this menu response is always the default fallback `". /workspace/work.sh"`, never the real `work_command`, for **any** caller including the owner (the real driver is only on `GET /tasks/{id}/versions/{vid}` or `POST .../input-preview`). `page_size` default 50; `next_page_token` is an opaque cursor when another page exists. Room managers change the catalog with [granted-jobs](/en/concepts/job-audience.md). Validate `input` against `input_schema` client-side if present — the server enforces it too (422 `input_schema_violation`, before dispatch). `POST /tasks/{task_id}/versions/{vid}/input-preview` lets an author dry-run a candidate input and see the exact bytes the work script will read. See [Tasks, publish, and bind](/en/flows/tasks-and-bindings.md). ## 2. Submit a run ```bash POST /chatrooms/{chatroom_id}/runs Idempotency-Key: # required, otherwise 422 idempotency_key_required { "task_version_id": "...", "input": {...}, "timeout_seconds": 1800 } ``` - `input`: canonical JSON (≤1 MiB, depth ≤64, nodes ≤100k, no NUL, no duplicate keys). - An oversized body returns **413 `request_too_large`** first (by Content-Length). - A version that is not "published + content active" → **409 `version_not_runnable`** (409 `content_scrubbed_irreversible` when `content_state` is `scrubbed`; `_refuse_not_runnable` is an internal helper name, not the error code). - Returns `SandboxRunDetailResponse`, `status: "queued"`. - Manual REST needs a live share + membership (`can_execute_manually`). Enable is **not** required. Product UI should still submit the menu's `task_version_id`. Agent submit **does** require enable. - The run's `input` is staged as an owned object but **does not** count against the company's 5-upload-initiations-per-minute budget (backend ≥ #1140); a burst of submits is accepted as `queued`. **422 `input_not_stageable`** now only means the input JSON could not be canonicalised or written. - **Queue backpressure (429, nothing queued).** OpenAPI declares it on submit, retry and Quick Run as `SandboxQueuedRunLimitErrorResponse` / `SandboxQueueFullErrorResponse`, both wrapped in `detail`. `sandbox_queued_run_limit` means the company already has as many runs waiting as it may (`limit` in the body); back off and resubmit once fewer runs are waiting. `queue_full` exists only on an on-prem deployment: the whole executor fleet's queue is full; the body carries `limit` and `retry_after_seconds`, and the same value (60 s) is sent as `Retry-After`. Resubmit with the same `Idempotency-Key` after that delay. See [cloud and on-prem deployments](/en/concepts/integration-contract.md). - On the hosted cloud, dispatch starts within seconds of the submit (the API wakes the controller after commit; a 3 s sweep is the fallback). The first run on a freshly published version pulls the image on Cloud Run (`wait_reason: container_starting`, 30–90 s); later runs reach `running` in ~10–30 s. ## 3. Poll status ```bash GET /chatrooms/{chatroom_id}/runs/{run_id} ``` `status` walks `queued → starting → running → completed` (or `customer_failed` / `platform_failed`; on cancel `cancel_requested → cancelled`). **Watch `terminal_at` for the terminal state.** Every non-terminal poll says what is happening and what to do (backend ≥ #1140) — show these verbatim while the user waits: | Field | Meaning | | --- | --- | | `wait_reason` | closed vocabulary: `awaiting_dispatch`, `company_saturated`, `no_active_region`, `missing_image_digest`, `capacity_snapshot_stale`, `provider_capacity_exhausted`, `provider_submit_pending`, `provider_submit_uncertain`, `container_starting`, `cancel_pending`; empty while `running` and on every terminal | | `next_action` | plain-language guidance matching `wait_reason` (wait vs. act) | | `wait_since` | when the current wait began (`queued_at` while queued, `started_at` while starting) | | `queue_position` | 1-based position in the queue the run waits in; `null` once it is claimed or dispatched and whenever it is not waiting. Cloud: position among the company's `queued` runs. On-prem: position in the executor queue across companies, also while `starting` until an executor takes it | | `eta_seconds` | rough seconds until a waiting run starts, or `null`. Always `null` on the hosted cloud deployment. On-prem: `ceil(queue_position ÷ fleet run slots) × mean duration of the last 20 completed runs on the same resource profile`; `null` without that history. Show it only when present — never compute your own | Every terminal run carries tenant-visible failure provenance (empty on `completed`): | Field | Meaning | | --- | --- | | `error_code` | closed slug, e.g. `work_exit_nonzero`, `work_timeout`, `invalid_output`, `cancelled_by_request`, `binding_revoked`, `interrupted_by_platform`, `result_upload_failed` (work succeeded but the platform could not store its output — `platform_failed`, retry) | | `exit_code` | the work command's real exit status (`3` for `exit 3`; `124` timeout, `130` cancelled, `127` command not found) | | `failure_stage` | `task_bundle` / `secret_env` / `workspace` / `spawn` / `metering_*` / `work` / `output` / `timeout` / `cancel` / `egress_cap` / `wait` | | `failure_source` | `runner` / `reconcile` / `dispatch` / `submit` / `policy` / `tenant` | | `error_detail` | short sanitized detail (`[A-Za-z0-9._@:/+=%;-]`, ≤128) | | `failure_hint` | on a failed terminal: the usual fix derived from `error_code` / `exit_code` (e.g. exit 127 → put a toolchain directory on `PATH`, `work_timeout` → raise `timeout_seconds`) | | `measured_ingress_bytes`, `measured_egress_bytes` | metered network bytes of the work step | Show `error_code` + `exit_code` to the user and offer the log (§5a) — the log contains the script's stdout/stderr, e.g. `/workspace/work.sh: line 1: python3: not found`. `secret_env_name_collision` / `secret_env_value_collision` (`platform_failed`, stage `secret_env`) mean an `ordinary_env` variable has the same **name** as a bound secret slot, or the same **value** as a bound secret. The runner refuses before the work starts and sends an empty `error_detail`, so the `failure_hint` is all the user gets: rename the variable or the slot so the two sets do not overlap, or remove the variable and read the secret from its slot. List + filter: ```bash GET /chatrooms/{chatroom_id}/runs?status=queued&status=running # status may repeat ``` A restricted key **and any ordinary user below department-manager role** see only the runs they submitted; the full room list requires department-manager (or company-manager) role or above, and even for managers each row is still gated by `can_read_run`. ## 4. See what was produced ```bash GET /chatrooms/{chatroom_id}/runs/{run_id}/content # → { state, has_input, has_output, has_log, has_artifact_bundle, input_digest } ``` **Boolean flags only, no content body** (this is the "c2 log/output" metadata view). `state` may be `"missing"` (no content row yet). Runner result uploads are exempt from the authoring upload-initiation rate budget. Check `has_output`, `has_log` and the actual downloads independently of the work exit code. A `result_upload_failed` terminal is a platform failure; follow its `failure_hint`. ## 5. List and download artifacts ```bash GET /chatrooms/{chatroom_id}/runs/{run_id}/artifacts # default lifecycle=active # → items[] { id, relative_path, byte_size, digest, deletion_state, declared_content_type } POST /chatrooms/{chatroom_id}/runs/{run_id}/artifacts/{artifact_id}/download # → { capability_token, expires_at, content_type, content_disposition, x_content_type_options: "nosniff" } ``` - **Producing artifact files** (runner ≥ #1152, live-verified on staging 2026-09-04 — two files, list, per-file public link, revoke): the work writes them under `$TEAMSYNC_ARTIFACTS_DIR` (`/workspace/artifacts`, empty at start; subdirectories become path prefixes). After the work exits the runner packs every regular file below it into one deterministic bundle; `GET .../content` then reports `has_artifact_bundle=true` and `GET .../artifacts` lists each file with `relative_path`, `byte_size`, `digest`. The bundle is refused as a whole — the run still completes, the log carries `stage=artifacts refused: ` — on any symlink/hard link/special file, `..`, >1000 files, over-long paths, or a payload over the standard-profile cap (workspace cap − 16 MiB, ≤ 1 GiB). `output.json` stays the structured result. - What you get back is a **short-lived `capability_token`** (5-minute TTL), not a raw key / presigned URL. This POST only proves ownership authorization (the artifact's `deletion_state` must be `active`) — **no endpoint redeems that token**. To fetch one file's bytes use the per-file public link (backend ≥ #1149): `POST .../runs/{run_id}/artifacts/{artifact_id}/public-link` → `{ url, token }`; `GET ` streams exactly that file's byte range out of the bundle after verifying its `sha256`, `DELETE` on the same route revokes it, and the link 404s once the artifact or bundle is retired. Owner-manager only (restricted keys 404). - An artifact whose `deletion_state != active` always returns **404** on download. - To hand out a run's content, use §5a `POST .../runs/{run_id}/{log|output}/public-link` (`GET /public/sandbox/artifacts/{token}`); the agent tool `sandbox_job_status` can also return bounded untrusted previews (≤64 KiB output, ≤16 KiB log head+tail). ## 5a. Hand a run's log/output to someone outside TeamSync (optional) ```bash POST /chatrooms/{chatroom_id}/runs/{run_id}/{log|output}/public-link # → { run_id, object_kind, url, token, revoked, created_at } ``` This is **permanent** (no TTL) and unauthenticated once minted — `url` is `GET /public/sandbox/artifacts/{token}`, no login required. Idempotent mint; `DELETE` the same route to revoke (then mint again for a fresh token). Only `log`/`output`, not individual artifacts. Full contract: [Content, digests, and downloads](/en/concepts/content-and-downloads.md). ## 6. Cancel / retry ```bash POST /chatrooms/{chatroom_id}/runs/{run_id}/cancel # cancel a run you can read (restricted key: own only; still available under the kill switch) POST /chatrooms/{chatroom_id}/runs/{run_id}/retry # retry a terminal run under a new identity; carry Idempotency-Key; restricted key 405 on an allowed room ``` - Retry is allowed from **any** terminal status: `completed` / `customer_failed` / `platform_failed` / `cancelled`. Always a **new** `run_id`. Restricted key 405 on an allowed room (404 if it reaches the router). Same `Idempotency-Key` + different request hash → 409 `idempotency_conflict`. - Retry keeps the exact pinned task version and creates a new run identity. Omit the body, send `{}`, or send `{"input": null}` to replay the retained source input byte-for-byte. A non-null `input` overrides the parameters and is validated/staged under the normal input limits. If retained input is missing, retired, unreadable or digest-mismatched, replay returns 409 `retry_input_unavailable`; provide explicit input instead. Carry `Idempotency-Key`; a source that is not terminal is 409 `retry_not_eligible`. A restricted Sandbox key cannot retry: an allowed-room request is refused by the route allowlist (405); an out-of-scope room can fail earlier with 403. - If the source run is not terminal → 409 `retry_not_eligible`. - Unclaimed cancel (`queued`): becomes `cancelled` immediately, the priced hold is released, and the queue/concurrency capacity slot is released immediately too (fixed 2026-08-30 — a raced concurrent dispatch could previously leak the slot on an already-cancelled run). Claimed / running cancel: `cancel_requested` then the runner walks to `cancelled` with `error_code=cancelled_by_request` when the tenant cancel fence wins. `cancel_requested_at` is persisted, not returned. - A run can very rarely appear stuck in `starting` right after submit if the control plane died mid-dispatch; a background reaper self-heals it (re-drives the same dispatch, exactly once) — no tenant action needed, and cancel still works on it in the meantime. - `submission_source` is `rest` (this page), `quick_run`, `agent`, or `custom_table_trigger`. Same poll/download contract for all four. ## Quick Run (manager-only) Atomically creates a **hidden chatroom-owned** parent + version + run: `POST /chatrooms/{chatroom_id}/quick-run` (`SandboxQuickRunRequest`, carry `Idempotency-Key`). Returns `{ task, version, run }`. Restricted key 403 at auth (404 if it reaches the router). Do not copy that owner_scope onto `POST /tasks`. Quick Run stages its `input` exactly like a manual submit (v5.10.11; before that the work read an empty `/workspace/input.json` and `GET .../content` said `has_input=false`). After the manager check and the idempotent replay it can therefore answer 422 `input_not_stageable` / 503 `input_staging_unavailable`, before any job or run is created. It is not checked against the company policy's queued-run limit; on an on-prem deployment the per-company and fleet queue caps still apply (429 `sandbox_queued_run_limit` / `queue_full`). ## Company-manager per-job history Not the room list. Company manager only: ```bash GET /tasks/{task_id}/runs?offset=0&limit=10&order=desc GET /tasks/{task_id}/runs/numOfData ``` Same filters on both: `status`, `method` (`agent`|`manual`), `executor_kind` (`internal_user`|`external_user`), `source_kind` (`chatroom`|`department`|`external_platform`), `department_id`, `chatroom_id`. Response of the list is a **JSON array** of history rows (duration, settled `cost_usd`, executor, room, department, method) — not `{ items, page_token }`. `numOfData` is `{ num }`. Restricted keys are refused with **403 at auth** (the path is on the restricted-key denied list and off the Sandbox scope allowlist) — they never reach the router's 404. ## Common errors quick reference | Situation | Response | | --- | --- | | Missing Idempotency-Key | 422 `idempotency_key_required` | | Oversized body | 413 `request_too_large` | | Version not runnable | 409 `version_not_runnable` | | Retry source not eligible | 409 `retry_not_eligible` | | Unauthorized / restricted key touched a forbidden area / artifact not active | 404 | | Mutation under the kill switch | 503 `sandbox_disabled` (except `/cancel`, `/download`) | | Envelope does not fit quota | 403 `budget_reservation_exceeded` (no row created) | | Company queued-run limit reached | 429 `sandbox_queued_run_limit` (body carries `limit`; default 100, overridable per company policy; on-prem the lower of that and the box's per-company cap, default 20) — back off before resubmitting, do not retry hot | | On-prem executor fleet queue full | 429 `queue_full` (body carries `limit`, `retry_after_seconds`; `Retry-After: 60`) — retry after the delay | | Pricing refuse (`no_active_region`, …) | 422 | # Jobs, share, and enable Creating and managing a catalog job follows **owner scope**. Room enable follows the **target chatroom-manager ladder**. This is not "company manager for everything". The audience rules live in one place: [Job audience](/en/concepts/job-audience.md). A restricted API key always gets 404 on tasks / shares / granted-jobs / bindings. ## Steps 1. **Create the job** — `POST /tasks` (`SandboxTaskCreateRequest`): - `name` 1–256, `description` ≤2048. - `owner_scope`: **`department` or `company` only**. `chatroom` → **422**. - `owner_id`: department id or company id (company must equal `company_id`). - `agent_enabled` (default false) is **legacy**. Agent exposure is the room enable switch. 2. **Create a draft version** — `POST /tasks/{task_id}/versions`: - `environment_version_id`: a **`ready`** environment version (your company's or curated). - `startup_script` / `work_script` (≤1 MiB each). - `ordinary_env`: the name count is **ordinary + secret slots combined ≤100**; each `NAME=VALUE` entry is ≤64 KiB, and **all ordinary entries together are also capped at 64 KiB** (ordinary+secret combined ≤256 KiB); names must match `^[A-Za-z_][A-Za-z0-9_]{0,127}$` and must not be reserved (`TEAMSYNC_`, `AWS_`, `PATH`, …). Note: draft create/`PATCH` and publish check **none** of this — an over-budget version publishes fine; the caps are enforced only at secret claim / manifest reveal, and only for versions that declare secret slots — a violating version with no slots sails through the backend unchecked. - `input_instructions` / `input_example` (≤64 KiB). - `input_schema` (≤64 KiB, optional): JSON Schema (draft 2020-12) validating every run's `input`. When set, `input_example` must satisfy it (checked **at this draft create/update call**, 422 `job_contract_invalid` otherwise — publish does not re-validate), and a submitted run whose `input` violates it is rejected **422 `input_schema_violation`** before dispatch. - `work_command` (≤4096 UTF-8 bytes, optional): the driver line for the work step, e.g. `python3 /workspace/work.sh` — the `work_script` always lands at `/workspace/work.sh`, and **an uploaded file's original name is never preserved**, so point the command at that fixed path, not at the name you uploaded (there is no `work.py`). The runner writes it verbatim into `/workspace/driver.sh` and sources that file after `startup.sh` (never argv). Blank/omitted → `. /workspace/work.sh` (the work_script must then be shell; with an interpreter named it can be any language). A NUL byte or over-length value is also 422 `job_contract_invalid`. See **Script files and the runtime contract** below. - `task_bundle_upload_id` (optional): a **job bundle** — a zip / tar / tar.gz you uploaded with `POST /tasks/{task_id}/bundle-uploads` (multipart field `file`, ≤20 MiB compressed). The bundle is unpacked at `/workspace` before the work step, so a job can ship as many files as it needs (programs, data, config) and read them by absolute path. See **Job bundles** below. - `timeout_seconds` (default 1800, `ge=1`, `le≈7 days`, also capped by company ceiling). - `secret_slot_declarations`: `{ "name": "SLOT" }` or bare strings. See [Secrets](/en/concepts/secrets.md). - `requires_confirmation` (default false). Custom-table triggers cannot target `true`. - `output_policy` (optional, **typed**, a union discriminated by `kind`): table writeback `{ "kind": "custom_table_writeback", "table_id": "...", "allowed_ops": "create" | "create,update" }`, or Command output `{ "kind": "custom_table_command", "command_id": "...", "chatroom_id": "...", "input_schema": [ … ] }` (v5.21.0 — see [Job output as a Command](/en/flows/command-output.md)). Allowed on **department-owned** jobs only. Company-owned publish → 409 `output_policy_owner_scope_unsupported`. Not opaque JSON. 3. **Edit the draft** (optional) — `PATCH /tasks/{task_id}/versions/{vid}` (draft only). Same fields as create, including `input_schema` / `work_command`. 4. **Publish** — `POST /tasks/{task_id}/versions/{vid}/publish` → `state: "published"`. **A published version is required to enable and to run — not to share**: a share is **task-scoped** (`POST /tasks/{task_id}/shares` names no version and succeeds even before the task has any published version); an enable always pins a published, content-active version; at submit a version that is not published/active → 409 `version_not_runnable` (a scrubbed version → 409 `content_scrubbed_irreversible`). 5. **Share** — `POST /tasks/{task_id}/shares` (`target_kind` + `target_id`, optional per-slot `secret_slot_policies` — see [Secrets](/en/concepts/secrets.md)). See [share targets](/en/concepts/job-audience.md). Revoke: `.../shares/{grant_id}/revoke`. A share is **not** Agent-enablement. 6. **Room-enable** — `POST /chatrooms/{chatroom_id}/granted-jobs/{task_id}/enable` (optional `task_version_id`; omit → current published). Pins the version and puts the job on `GET .../menu`. `enable` is idempotent while the live binding already carries the pin you asked for (or you sent no `task_version_id`). Sending a **different** `task_version_id` re-pins (backend ≥ #1140): the live binding is revoked exactly as `disable` would (queued runs on the old pin are cancelled) and a new binding is created; the response's `pinned_task_version_id` is always the pin you asked for. `POST /bindings/{binding_id}/accept-version` remains the in-place upgrade. If the job is **not shared** to this room (or its department/company) enable answers **409 `task_not_shared_to_room`** naming the share call to make (it was a bare 404 before #1140). Disable: `.../disable` (share remains). `POST /bindings` is the same pin with an explicit version. Prefer granted-jobs. ## Job bundles (multi-file jobs) ```bash POST /tasks/{task_id}/bundle-uploads # multipart: file= (zip / tar / tar.gz, ≤20 MiB compressed) # → { id, filename, archive_format, compressed_byte_size, extracted_byte_size, canonical_byte_size, file_count, sha256, manifest_sha256, expires_at } POST /tasks/{task_id}/versions # { environment_version_id, task_bundle_upload_id: , work_script | work_command, ... } ``` - The archive root becomes `/workspace`: `app/main.py` in the zip is `/workspace/app/main.py` at run time. Reference bundled files by absolute path. - **Reserved root names**: `startup.sh`, `work.sh`, `driver.sh`, `input.json` belong to the runner. An archive containing one of them at its root is refused with **422 `bundle_path_forbidden`** whose message names the entry (`runner-reserved root path: work.sh (rename it; …)`, backend ≥ #1140). Put your entry script under a folder (e.g. `app/run.sh`) and point `work_command` at it, or keep the entry point in the inline `work_script`. - The published version carries a `task_bundle` summary (`sha256`, `manifest_sha256`, `file_count`, byte sizes); the upload itself expires (`expires_at`) if no version adopts it. - Working pattern (verified live, 2026-09-03): one environment with the runtimes, three jobs on it, each bundle = `app/` + `data/params.json`, `work_script` installs the job's package at work time (`pip install --target /tmp/pylib …`, `npm install …` under `/tmp/app`, `go get …` under `/tmp/gomod`) and runs the program, which reads its bundled data file and `$TEAMSYNC_INPUTS_FILE`. Reference implementation: `sandbox/e2e-js/` in `teamsync-backend`. ## Script files and the runtime contract Two **owner-manager** file routes for scripts: - `POST /tasks/{task_id}/script-uploads` (multipart `file`, one UTF-8 text file ≤1 MiB) — **before a version exists**. Validates the script immediately and returns a task-bound reusable handle (`SandboxScriptUploadResponse`: `id`, `filename`, `byte_size`, `sha256`, `expires_at` — 24 h). Pass the `id` as `work_script_id` or `startup_script_id` on `POST /tasks/{id}/versions` or `PATCH .../versions/{vid}`; the content is copied into the version, the handle stays reusable until it expires. This is the route a create-version form uses when the user picks a file. - `POST /tasks/{task_id}/versions/{vid}/script-file?target=startup|work` (multipart, one UTF-8 text file, ≤1 MiB; **draft-only**) — uploads a file straight into `startup_script` or `work_script`, identical semantics to `PATCH`ing that field as a string. 422 `script_too_large` / `script_not_utf8` / `script_contains_nul` on a bad file. Published versions 409 (immutable — same as `PATCH`). And one route **any non-restricted member of the company** may call, on a **draft or published** version (no owner-manager gate, no draft-only gate — it uses the same read access as `GET /tasks/{id}/versions/{vid}`): - `POST /tasks/{task_id}/versions/{vid}/input-preview` (`{ "input": }`) — a dry run that dispatches nothing. Returns the exact canonical bytes that would land in the container (`input_file_content`), their digest (`input_digest`), the `runtime_contract` (below), and the `input_schema` verdict (`schema_valid` + `schema_errors[]`). Use this to let an author test their `input_schema` before publishing, or to show any caller (owner or borrower) what their `input` will actually look like on disk. A borrower's preview still redacts `runtime_contract.driver` to the default fallback — the handler nulls `work_command` before building it whenever the caller does not manage the task. Every run executes under a fixed **runtime contract**: ```text invocation = ". /workspace/startup.sh && . /workspace/driver.sh" driver = your work_command, or ". /workspace/work.sh" if blank working_directory = the runner starts sh with cwd=/workspace, but the contract leaves it unspecified — always use absolute paths network = outbound allowed when the company's runtime_egress_enabled is true (GET /settings; default on today) — pip / npm / go get work at work time; egress bytes are metered (measured_egress_bytes) inputs_file_env = "TEAMSYNC_INPUTS_FILE" # env var naming the input JSON file path output_file_env = "TEAMSYNC_OUTPUT_FILE" # env var naming where to write results artifacts_dir_env = "TEAMSYNC_ARTIFACTS_DIR" # /workspace/artifacts — files left here become downloadable artifacts ``` The submitted `input` is written to the file named by `$TEAMSYNC_INPUTS_FILE` before the script starts (read it in any language — e.g. `json.load(open(os.environ["TEAMSYNC_INPUTS_FILE"]))`); declared secret slots are exported as environment variables, never written to disk. Runtime facts your script must know (all observed live): - **`PATH`** is the image's configured `ENV PATH` (validated: absolute entries only) or, when the image sets none, the conventional `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` (runner release ≥ #1145 / stage image `7abca452…`; before that the work shell had no `PATH` at all and fell back to the shell's built-in default). Toolchains outside those directories still need `export PATH=/usr/local/go/bin:$PATH` in `work.sh`. `runtime_contract.path` states this. Exit 127 (`python3: not found`) means the interpreter's directory is not on the effective PATH. - The work shell's environment is only `PATH`, your `ordinary_env`, the declared secret slots and the three `TEAMSYNC_*` file pointers. Other `ENV` values baked into the image (`JAVA_HOME`, `LANG`, `PYTHON_VERSION`, …) are **not** inherited — export what you need in `startup.sh`. On-prem deployments behave the same since v5.10.11 (before that they leaked the image `ENV`). - Hosted-cloud egress-enabled runs (the default) on a runner release built from v5.10.11 source start the shell as PID 1 of its own user+PID namespace; where the host permits the private `/proc` setup, `$$` is `1` and `ps` shows only the job's processes. A short-lived orphan (such as the `ssl_client` helper BusyBox `wget https://…` forks) no longer gets the run killed with exit 137 when that nested setup is active, and anything still running when the shell exits is killed with it. `id -u` reads `0` where the host permits the id map, otherwise the overflow uid `65534` with no capabilities; `/workspace` stays writable either way. If the host refuses the namespace, the runner uses its direct-shell fallback. On-prem work uses the Docker executor's direct shell path rather than this Go gate. The runner writes its own record under `/workspace/.teamsync/` — do not use that name. A version keeps the runner it was built with (`runner_release`). - Only `/workspace` and `/tmp` are writable. Put caches and installs under `/tmp` (`HOME=/tmp`, `GOPATH=/tmp/go`, `npm_config_cache=/tmp/npm-cache`). - `python -m venv` needs `python3` resolvable through `PATH` ("Unable to determine path to the running Python interpreter" when it is not). With the inherited PATH it works on the official `python:*` images; `python3 -m pip install --target /tmp/pylib …` + `PYTHONPATH=/tmp/pylib` remains the lighter alternative. - Nothing persists between executions: every run installs afresh. A **borrower** never sees the real `work_command`/`driver` (it is redacted to the default fallback) — but does see the real `input_schema`, since that describes the caller's own `input` shape, not the owner's implementation. ## Version upgrade (binding) - Enable **pins an exact version**. A newer publish sets `update_available` on the binding / granted-job / menu item. - Upgrade: `POST /bindings/{binding_id}/accept-version` (`task_version_id`) — never auto-bumps. - Revoke the pin: granted-jobs `disable`, or `POST /bindings/{id}/revoke`. **Both have side effects**: every run on that binding the runner has not yet claimed (status `queued`) is immediately marked `cancelled` (`error_code="binding_revoked"`), and pending Agent proposals are cancelled; claimed runs (`starting`/`running`…) are preserved. Revoking a share (`.../shares/{grant_id}/revoke`) likewise cancels that job's unclaimed runs in every room the grant covers (`error_code="share_revoked"`). Revoking an already-terminal binding → 409 `binding_terminal`. ## Owner vs borrower - Jobs/versions you **manage**: response includes scripts, `work_command`, `ordinary_env`, `secret_slot_declarations`, `output_policy`, and a `runtime_contract.driver` that echoes the real command. - Jobs **shared with you**: those fields are omitted (`work_command` is `null`, `runtime_contract.driver` shows only the default fallback). Borrowers never see `output_policy` (it embeds the owner's table, or Command and room, ids). Borrowers **do** see the real `input_schema` — it describes the shape of their own `input`, not the owner's implementation. ## Common errors - **422**: `owner_scope=chatroom` (enum), field validation, unknown field. - **422 `job_contract_invalid`**: at draft create/update (never at publish), `input_example` is not JSON, does not satisfy `input_schema`, or `work_command` has a NUL byte / exceeds 4096 UTF-8 bytes. - **422 `input_schema_violation`**: at submit, a run's `input` fails the version's `input_schema` (rejected before dispatch; fails open — proceeds with a warning — only if the stored schema itself is unparsable). - **422 `script_too_large` / `script_not_utf8` / `script_contains_nul`**: bad file on `script-file` upload. - **403 `owner_scope_forbidden`**: department/company scope the caller cannot own. - **403 `output_policy_author_denied`**: table writeback — the publisher lacks unrestricted write authority on the target table. Command output — the author, publisher or submitter fails the identity, grant or scope check, the Command no longer runs under restricted definition authority with an `effect_identity` (at publish that is the 409 below), or a Run is submitted outside the rules in [Job output as a Command](/en/flows/command-output.md). - **409 `output_policy_owner_scope_unsupported`**: company-owned job with a writeback or Command-output policy. - **409 `output_policy_unsatisfiable`**: table writeback, at publish and — when a credential is actually minted — at run submit: the table is gone, channel-ruled or `commands_only`. Command output: at publish, any of the three Command checks failing (Command exists, inputs and mode match, restricted definition authority with an `effect_identity`); only the identity, grant and scope checks on the publisher and on the stored author stay 403. - **422 `output_policy_governed_table`**: draft create/update — the table writeback policy targets a table whose `write_policy` is `commands_only` (its records change only through Commands). - **422 `output_policy_command_not_found` / `output_policy_schema_mismatch`**: Command output, at draft create/update and again at run submit — the policy names no live Command of your company, or `input_schema` differs from the Command's `inputs` (or the Command is not a write Command). - **422 `secret_slot_policies_invalid`**: malformed `secret_slot_policies` map on share create. - **409**: `already_published` / `version_not_draft` (re-publish), `already_archived` / `version_not_published` (archive), `environment_version_not_ready` (publishing against a non-ready environment version), `published_version_immutable` (`PATCH` or script-file on a non-draft version — published or archived alike), `task_not_active` (drafting on a non-active job), `content_scrubbed_irreversible` (publish/PATCH on scrubbed content), `share_already_live` (a live grant already covers this task+target), `binding_already_live` (a live binding already exists for this room+task — `POST /bindings` hits it; `enable` returns the same binding for a still-applicable live binding, but can surface this too when the binding is no longer applicable), `binding_terminal` (accept-version/revoke on an already-terminal binding). - **422 `bundle_path_forbidden`**: the job bundle has a runner-reserved file at its root (`startup.sh`, `work.sh`, `driver.sh`, `input.json`). - **404 "Task not found" on `enable`**: the task is not shared to that room (or its department/company) yet — `POST /tasks/{task_id}/shares` first. - **404**: unauthorized / cross-company share target / restricted key. Next: [Run and fetch results](/en/flows/run-and-results.md). # Run a task as a tenant user This page is only for an **ordinary tenant user** (a chatroom member with `role < 2`): find a runnable task on the menu, submit it, wait for the result. Do not create environments, tasks, or bindings — those are manager work; see [Personas and permissions](/en/concepts/personas.md). If the company has no custom environments, that is fine. The live catalog is the **`system_curated`** environment named **SCFG Standard** (`state=ready` in every public region). A manager pins that version onto a **department or company** job (no tenant upload), shares it, **room-enables** it, and you still start at the menu below. See [Job audience](/en/concepts/job-audience.md). A restricted API key is narrower: menu, submit and its own runs only. Retry is excluded by the route allowlist (405 on an allowed room); Quick Run and task management fail earlier at auth (403). See [Authentication](/en/get-started/auth.md). ## 1. Discover runnable tasks from the menu **There is no `GET /bindings` list.** Ordinary users find what they can run from the chatroom menu: ```bash GET /chatrooms/{chatroom_id}/menu # query page_size (ge1 le100, default 50) ``` Returns `items[]` (`SandboxMenuItemResponse`): `task_id`, `task_name`, `task_version_id`, `task_version_digest`, `requires_confirmation`, `secret_slot_names`, `secret_slots[]`, `timeout_seconds`, `update_available`, `input_instructions` / `input_example`, `input_schema?`, `runtime_contract?`, `secret_use_risk_warning?`. Validate `input` against `input_schema` client-side if present — the server enforces it too (422 `input_schema_violation`, before dispatch). `runtime_contract.driver` on this menu response is always the default fallback, never the real `work_command`, regardless of who is asking — see [Schemas](/en/reference/schemas.md). The menu lists jobs that are **granted to this room and enabled here**. The list does not change with who you are. A granted-but-not-enabled job is absent. Restricted keys may call this for allow-listed rooms. Unauthorized room → **404**. Any non-restricted JWT in the company can also `GET /tasks` (hidden quick-run parents are filtered), but the **runnable list is still the menu**. ## 2. Submit a run (Idempotency-Key required) ```bash POST /chatrooms/{chatroom_id}/runs Idempotency-Key: # required, otherwise 422 idempotency_key_required { "task_version_id": "...", "input": {...}, "timeout_seconds": 1800 } ``` - `input`: canonical JSON (≤1 MiB, depth ≤64, nodes ≤100k, no NUL, no duplicate keys). - An oversized body returns **413 `request_too_large`** first (by Content-Length). - A version that is not "published + content active" → 409. - Returns `SandboxRunDetailResponse`, `status: "queued"`. Re-sending the same `Idempotency-Key` with the **identical** request (same room, `task_version_id`, `input`, and effective `timeout_seconds`) replays the same result. Reusing the key with a different body returns **409 `idempotency_conflict`**, not a replay; the request hash also covers `retry_of_run_id`, so mixing a retry and a fresh submit under one key conflicts too — always use a fresh UUID for a new intent. ## 3. Poll status ```bash GET /chatrooms/{chatroom_id}/runs/{run_id} ``` `status` walks `queued → starting → running → completed` (or `customer_failed` / `platform_failed`; on cancel `cancel_requested → cancelled`). **Watch `terminal_at` for the terminal state.** Look at `error_code` for failures. There is no streaming. List: ```bash GET /chatrooms/{chatroom_id}/runs?status=queued&status=running # status may repeat ``` An ordinary user **sees only their own runs**. Someone else's run, a run in another room, a borrower running a task you did not submit — all 404 to you. Status detail is in [Run and fetch results](/en/flows/run-and-results.md). ## 4. See what was produced ```bash GET /chatrooms/{chatroom_id}/runs/{run_id}/content # → { state, has_input, has_output, has_log, has_artifact_bundle, input_digest } ``` **Boolean flags only, no content body.** `state` may be `"missing"` (no content row yet). ## 5. List and download artifacts ```bash GET /chatrooms/{chatroom_id}/runs/{run_id}/artifacts # default lifecycle=active # → items[] { id, relative_path, byte_size, digest, deletion_state, declared_content_type } POST /chatrooms/{chatroom_id}/runs/{run_id}/artifacts/{artifact_id}/download # → { capability_token, expires_at, content_type, content_disposition, x_content_type_options: "nosniff" } ``` - `POST .../download` only authorizes ownership and returns a 5-minute `capability_token` — the token is not stored and **no endpoint currently redeems it**, so it cannot fetch bytes. - To actually download a run's bytes, mint a permanent public link for the run's log or output: `POST /chatrooms/{chatroom_id}/runs/{run_id}/{log|output}/public-link` → `{ url, token }`, then `GET /public/sandbox/artifacts/{token}`; `DELETE` the same route to revoke. Minting **404**s while the run has no log/output object yet; a restricted API key is always rejected from minting (so it has no byte-download path at all). - Individual files now support per-file public links for browser downloads. The short-lived artifact-capability endpoint only authorizes access; it does not return bytes or a raw URL. Follow the returned public-link URL and revoke it when sharing is no longer needed; see [Downloads](/en/concepts/content-and-downloads.md). - An artifact whose `deletion_state != active` always returns **404** on download. ## 6. Cancel your own in-flight run ```bash POST /chatrooms/{chatroom_id}/runs/{run_id}/cancel ``` An ordinary user can cancel only **their own** in-flight run (this path stays available under the kill switch). Someone else's run is 404. Managers can cancel runs they can see — that is not this page. Retry keeps the exact pinned task version and creates a new run identity. Omit the body, send `{}`, or send `{"input": null}` to replay the retained source input byte-for-byte. A non-null `input` overrides the parameters and is validated/staged under the normal input limits. If retained input is missing, retired, unreadable or digest-mismatched, replay returns 409 `retry_input_unavailable`; provide explicit input instead. Carry `Idempotency-Key`; a source that is not terminal is 409 `retry_not_eligible`. A restricted Sandbox key cannot retry: an allowed-room request is refused by the route allowlist (405); an out-of-scope room can fail earlier with 403. ## Borrower catalog: no scripts The menu and the borrower view **do not include script source**. For a task someone shared with you, the version response returns `startup_script` / `work_script` / `ordinary_env` / `work_command` / `secret_slot_declarations` / `output_policy` all as `null` (present-but-null, not absent) and `content_hash` as an empty string. There is no `source_digest` on a task version (that field belongs to environment versions); the version digest `canonical_digest` (`task_version_digest` on the menu) **is** visible to borrowers — secret approval requires it. You only get what you need to run (`secret_slot_names`, instructions, timeout). Owner view is only for people who manage that task's owner scope. ## What an ordinary user cannot do Do not call these. Unauthorized identifiers are usually **404** (existence-hiding): - Create / update / archive environments - `GET` / `PUT /settings` — **exception**: these go through the shared `COMPANY_MANAGER` (role ≥ 3) dependency and return **403 `Insufficient permissions.`**, not 404; do not assume every manager-only endpoint looks like a 404 - Create a task (any owner scope), publish / archive / share - Create / accept / revoke a binding - Secret write / rotate / revoke and approvals - `POST /chatrooms/{id}/quick-run` Ordinary users should not create jobs. `POST /tasks` with `owner_scope=chatroom` is **422**. A department/company scope you cannot own is **403 `owner_scope_forbidden`**. ## What 404 means for you For an ordinary user, 404 almost always means "you cannot touch this id" — it may not exist, or it exists and you lack permission. Do not treat 404 as proof the resource was deleted. Cross-company ids, rooms you are not in, other people's runs, and manager-only endpoints all look the same. The full ladder and matrix are in [Personas and permissions](/en/concepts/personas.md). # Agent toolkit The agent does **not** call `/private/module/sandbox` REST to submit. It calls five LangGraph tools from `src/components/tools/custom/sandbox/factory.py`. The frontend never invokes these tools. The frontend's job is: enable the job on the room, present `requires_confirmation` proposals in the conversation, then poll the resulting `run_id` on the tenant run API. ## Enablement `tool_loader` is authoritative (the factory header that says “independent of `job_list`” is **stale**). The five tools load **only** when all of these are true: 1. The room's `jobs` list contains `ChatroomJobType.SANDBOX` (`"sandbox"`). 2. `SANDBOX_ENABLED`, DB, a trusted principal, and a signed turn context exist. Enabling the job also adds the protocol prompt and pins the five tools as critical so they are not pruned. Set the job with the existing chatroom jobs API (not under `/private/module/sandbox`): ```text PATCH /private/chatrooms/setting/jobs/{chatroom_id} { "jobs": ["sandbox", ...] } ``` Auth is the **chatroom-manager ladder** and failures are **403**, not sandbox 404. `jobs: null` reverts to legacy auto-detect. Rooms with `jobs` unset **never** match `GET /private/chatrooms/by_job/sandbox`. The OpenAPI description of that route lists `sandbox` among the valid job types, and `ChatroomJobsPayload` accepts `ChatroomJobType`, including `"sandbox"`. List rooms that explicitly enabled the job: ```text GET /private/chatrooms/by_job/sandbox GET /private/chatrooms/by_job/sandbox/numOfData ``` Query: `department_id?`, `offset`, `limit` 1–100 default 100, `order` `asc|desc`. Omit `department_id` → joined rooms. Set it → that department’s rooms (404 if dept not in company). Collected rooms sort first. Construction captures only the server tuple `(company_id, chatroom_id, principal_type, principal_id, persisted_human_message_id, signed_turn_receipt)`. Model arguments never supply turn identity. Adoption of a *specific* catalog job requires a live **share** **and** this room's **enable** switch (granted-jobs). `task.agent_enabled` is a legacy parent flag and does **not** put the job on the Agent catalog. An `"sandbox"` room job with nothing enabled yields **empty menus**. Enabling `ChatroomJobType.sandbox` does not make every granted job runnable — the room must still POST `.../granted-jobs/{task_id}/enable`. The Agent menu is **identity-independent** (same list as `GET .../menu`). See [Job audience](/en/concepts/job-audience.md). External channels (LINE / LINE group / LINE room / Messenger / Instagram) act as `principal_type=social_media_client` using the pipeline-resolved client id. Internal chat uses the authenticated `user_id`. ## The five tools | Tool name | Args (model-visible) | Returns | | --- | --- | --- | | `sandbox_job_menu` | `page_size` (default 20, 1..100), `page_token?`, `query?` (≤256) | Adopted exact-version catalog for this room. No scripts. | | `sandbox_submit_job` | Discriminated `mode` — see below | Direct run, confirmation proposal, or replay. | | `sandbox_submitted_jobs` | `page_size`, `page_token?` | This principal's pending proposals (with `proposal_receipt`) and recent runs. No output/logs/secrets/URLs. | | `sandbox_job_status` | `run_id`, `wait_seconds` 0..60, `artifact_page_token?` | Status + **bounded untrusted previews**. | | `sandbox_cancel_job` | `run_id?` | Cancel owned run, or (if omitted) this principal's pending proposal. Manager status does **not** widen ownership (A-011). | Menu items carry two of the REST menu's job-contract fields ([Schemas](/en/reference/schemas.md)): `input_schema` (validate `input` before calling `sandbox_submit_job` — a violation is 422 `input_schema_violation` on submit) and `secret_slots[]` (per-slot borrower/owner fulfillment). The Agent menu does **not** carry `runtime_contract` at all — that field only exists on `GET /tasks/{id}/versions/{vid}` (real `work_command` for an owner) and `POST .../input-preview` (same); the **REST** `GET .../menu`'s own `runtime_contract` is always the default fallback regardless of caller, so it is not a useful source either (see [Schemas](/en/reference/schemas.md)). Tool results are JSON strings. Errors: ```json { "status": "error", "error": "", "message": "..." } ``` | `error` | When | | --- | --- | | `unauthorized` | Reauthorization / ownership failure (`SandboxAgentAuthError`), or a 401/403 refusal of the submit (for example a Command-output rule) | | `validation_error` | Bad tool args, or a 400/409/422 refusal of the submit | | `forged_receipt` | Receipt is not the signed multi-segment form (must contain 4 dots) | | `not_found` | Expired, foreign, hidden, same-turn, or already-used receipt | | `confirmation_rejected` | Other closed-form confirm failures | | `already_committed` | This proposal already became a run | | `proposal_not_pending` | State is not `prepared` | | `same_turn` | Confirming HumanMessage is the origin turn | | `not_later` | Confirming message is not strictly later | | `receipt_reused` | This confirming turn already committed another proposal | | `retryable` | Transient commit; proposal stays `prepared`, receipts reusable | | `confirmation_input_unavailable` | Later turn verified, but durable input could not be replayed (missing / R2 miss / digest mismatch / invalid JSON). **No run created; proposal stays `prepared`.** Agent may retry `mode=commit`. | | `provider_unavailable` | Control plane unavailable, or a submit refusal the tool does not map to another code — including the queue 429s (`sandbox_queued_run_limit`, and `queue_full` on an on-prem deployment). Nothing was queued | | `internal_error` | Unexpected; message is generic | ## `sandbox_submit_job` ### `mode="request"` Required: `task_version_id`, `input` (canonical JSON). Optional: `timeout_seconds`. **Forbidden:** `proposal_receipt`. - If the version has `requires_confirmation=false`, declares no table-writeback `output_policy` and the principal may execute it, the server submits a run (`submission_source="agent"`) and returns the run. - A version with a Command-output policy (`custom_table_command`, v5.21.0) and `requires_confirmation=false` is also submitted directly: the server stages the exact input bytes first, then submits, with no proposal turn. If the Command-output rules refuse the submission, a 403 (for example the principal is an external-channel client rather than a user) comes back as `unauthorized`, a 422 (Command missing or mismatched) as `validation_error`, and the 503 key error as `provider_unavailable`. See [Job output as a Command](/en/flows/command-output.md). - If `requires_confirmation=true` — or the version declares a table-writeback `output_policy` (`custom_table_writeback`), which is always confirmation-grade — the server creates a `prepared` proposal, **supersedes** this principal's other pending proposals, and returns: ```json { "kind": "prepared", "proposal_id": "...", "proposal_receipt": "", "summary": "...", "requires_confirmation": true, "task_id": "...", "task_version_id": "..." } ``` `proposal_receipt` is opaque to the model. Passing a bare id is `forged_receipt`. ### `mode="commit"` Required: `proposal_receipt` only. **Forbidden:** `task_version_id`, `input`, `timeout_seconds`, region, profile, cost, ENV, version. Must be a **later HumanMessage turn**. The factory attaches the new signed turn receipt out of band. Same-turn, hidden, older, foreign, forged, and reused receipts fail closed (`not_found` / `forged_receipt`). Success: `{ "status": "ok", "kind": "submitted", "run_id": "...", "proposal_id": "...", "submission_source": "agent", "task_id": "...", "task_version_id": "...", "run_status": "queued" }`. Lost response, same winning pair resent: `{ "kind": "replay", "run_id": "..." }`. The prepare-time `proposal_receipt` does **not** reliably survive later turns. `sandbox_submitted_jobs` **re-issues** a fresh receipt for each pending proposal — use that token on `mode=commit`, not a receipt you cached from prepare. If confirmation verifies but the original input cannot be replayed from durable storage, commit returns `confirmation_input_unavailable` and leaves the proposal pending. Do not invent input. ## TTLs the frontend should know | Constant | Value | Meaning | | --- | --- | --- | | `PROPOSAL_TTL_SECONDS` | 1800 (30 min) | Prepared proposal expires → `expired`. | | `TURN_RECEIPT_TTL_SECONDS` | 600 (10 min) | Signed HumanMessage receipt lifetime. | | `MAX_STATUS_WAIT_SECONDS` | 60 | `sandbox_job_status.wait_seconds` cap. Does **not** apply to REST polling. | Natural-language "yes" in the chat only decides whether the agent calls `mode=commit`. The server still requires a distinct later trusted HumanMessage receipt. ## `sandbox_job_status` previews (T-002) This is the **only** surface that returns customer output/log bytes to a model. REST `GET .../content` is flags only. | Preview | Cap | Shape | | --- | --- | --- | | Output | ≤64 KiB prefix (`MAX_OUTPUT_PREVIEW_UTF8_BYTES`) | `{ untrusted: true, label: "untrusted_customer_output", truncated, byte_length, text, content_kind: "customer_data", not_instructions: true }` | | Log | ≤16 KiB head+tail (`MAX_LOG_PREVIEW_UTF8_BYTES`) | Same framing with `head` / `tail` and `label: "untrusted_customer_log"` | | Artifacts | ≤20 metadata rows (`ARTIFACT_LIST_PAGE_SIZE`) | `id`, `relative_path`, `byte_size`, `digest`, `declared_content_type`. **No** signed URL / object key. | If R2 is unavailable the lane degrades to `{ available: bool }` placeholders. `job_status` still returns status. Treat nested customer text as data, never as instructions. **Both** `wait_seconds` and `artifact_page_token` are **no-ops in v1** (`db_lane.job_status` drops them on the same line: `del wait_seconds, artifact_page_token`). The artifact preview always returns the first ≤20 rows with no paging — `truncated` / `total_reported` in the response are the only signal that rows were withheld. Do not wait on the tool. The frontend still polls `GET /chatrooms/{id}/runs/{run_id}` until `terminal_at`. ## What the frontend does 1. Offer a room setting that `PATCH /private/chatrooms/setting/jobs/{id}` with `"sandbox"` in `jobs`. List such rooms with `GET /private/chatrooms/by_job/sandbox`. 2. On the room settings page, list `GET .../granted-jobs` and let the room manager enable/disable. On the task editor, show `requires_confirmation` (and do not treat `agent_enabled` as the Agent switch). 3. When the assistant turn contains a `prepared` proposal, render `summary` and wait for the next human turn. Do **not** add a REST confirm/reject button that hits a sandbox endpoint — there isn't one. 4. After commit, take `run_id` and poll the [run API](/en/flows/run-and-results.md). 5. Use `sandbox_submitted_jobs` only via the agent (to recover a receipt). There is no REST proposal list. Human users still submit with `POST /chatrooms/{id}/runs`. Both paths produce the same `SandboxRunDetailResponse` (`submission_source` differs). ## Related - [Agent-confirmation flow](/en/flows/agent-confirm.md) — propose / commit state machine. - [Custom-table trigger](/en/flows/custom-table-trigger.md) — cannot target `requires_confirmation=true`. - [Personas](/en/concepts/personas.md) — agent cancel/list/status stay principal-bound even if the user is a manager. # API endpoint catalog Unless noted, paths omit the prefix `{BASE}/private/module/sandbox`. Tenant routes go through `get_sandbox_principal`; unauthorized returns 404 (exceptions: 403 `owner_scope_forbidden` on unownable department/company create; 422 if `owner_scope=chatroom`). Field-complete models: [Schemas](/en/reference/schemas.md). OpenAPI is double-tagged: umbrella `Module: Sandbox` **plus** `Module: Sandbox - Settings` / `Environments` / `Tasks` / `Bindings` / `Secrets` / `Runs`. ## Public confirmation catalogs (no `/private` prefix, no auth) | Method + path | Purpose | Response | | --- | --- | --- | | `GET /public/info/sandbox/regions` | Closed region set of the deployment's provider for dropdowns / `allowed_regions` / `build_region`: the cloud regions (`tier` 1 = cheaper/preferred, 2 = overflow) on the hosted service; only `onprem` (`tier` 3 = fixed capacity at a nominal accounting rate) on an on-prem deployment | `SandboxPublicRegionListResponse` `{ items: [{ code, display_name, location, tier }] }` (`tier` 1..3) | | `GET /public/info/sandbox/profiles` | Closed resource-profile set. Send `id` only when `tenant_selectable` | `SandboxPublicProfileListResponse` `{ items: [{ id, display_name, vcpu, provider_memory_mib, customer_memory_mib, workspace_mib, tenant_selectable, is_default }] }` | `xlarge` has `tenant_selectable=false`. Intersect regions with `GET /settings.allowed_regions`. Operator live set is `/root/sandbox/regions`. | Method + path | Purpose | Response | | --- | --- | --- | | `GET /public/info/model_key/public_key` | RSA public key (PEM) for client-side secret/API-key encryption | `ModelKeyPublicKeyResponse` `{ public_key_pem, algorithm }` | Used to encrypt `encrypted_value` on secret writes — see [Secrets and approvals](/en/concepts/secrets.md). Shared with the model-catalog `encrypted_api_key` flow (not sandbox-specific). 503 means no server keypair configured (encrypted writes then 422 `sandbox_transit_keypair_missing`, never a plaintext fallback). ## Permanent public artifact links (no `/private` prefix, no auth) | Method + path | Purpose | Response | | --- | --- | --- | | `GET /public/sandbox/artifacts/{token}` | Stream a run's log/output object, or one artifact file's byte range (digest-verified), minted via a public-link route below | Raw bytes (`Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`) | No TTL. 404 once revoked or once the underlying object leaves `deletion_state=active`. See [Content, digests, and downloads](/en/concepts/content-and-downloads.md). ## Run-scoped Command-output endpoint (no `/private` prefix, Bearer credential) | Method + path | Purpose | Request | Response | | --- | --- | --- | --- | | `POST /public/module/custom_tables/callback/command-output/{token_id}` | A Command-output Run's work script posts the inputs of its one declared Custom Tables Command. `Authorization: Bearer `; both come from `TEAMSYNC_CT_WRITEBACK_URL` / `TEAMSYNC_CT_WRITEBACK_TOKEN`. Not a frontend call. | `SandboxCommandOutputRequest` `{ inputs }` | Command execution response (`null` fields omitted). Errors: 404/409 `output_run_unavailable`, 409 `output_command_stale`, 409 `output_command_conflict`, 422 `output_schema_violation`, 403 `output_policy_author_denied` | Mounted with the Custom Tables callback router, not with the Sandbox routers. See [Job output as a Command](/en/flows/command-output.md). ## Company settings | Method + path | Purpose | Auth | Request | Response | | --- | --- | --- | --- | --- | | `GET /settings` | Read company sandbox settings | company manager | — | `SandboxCompanySettingsResponse` | | `PUT /settings` | Update retention days + allowed regions | company manager | `SandboxCompanySettingsUpdate` | `SandboxCompanySettingsResponse` | `SandboxCompanySettingsUpdate` (writable): `run_content_retention_days`(1..365), `unreferenced_image_retention_days`(1..90), `allowed_regions`(≥1), `policy_version`(optimistic-lock CAS). CAS mismatch → 409. Both GET and PUT require **COMPANY_MANAGER**. `SandboxCompanySettingsResponse` also returns (read-only / derived): `timeout_ceiling_seconds`, `active_environment_limit`, `capacity_retained_version_limit`, `placement_scope` (always `"runtime_execution_only"`), `does_not_govern[]` (`build` / `validator` / `registry` / `r2` / `log` / `control_plane`), `residency_guaranteed` (always `false`), `regional_job_capacity_grace_days_fixed`. `allowed_regions` governs **runtime Job / Execution placement only**, not build / validator / registry / R2 / log / control-plane residency. ## Environments / versions / builds (prefix `/environments`) **Environment authority:** company-owned environments require a company manager. Department-owned environments can be created/managed by the owning department manager or a company manager; a department creator must send `owner_scope: "department"` and that department’s `owner_id`. Context uploads, versions, builds, retries and archive use the same owner-scope gate. Curated environments have no tenant write path. Metadata reads remain company-visible; upload-session reads require owner management. Unauthorized tenant principals get 404 at the router; restricted keys are rejected earlier by auth. | Method + path | Purpose | Request | Response | | --- | --- | --- | --- | | `GET /environments` | List environments (including system-curated) | q: `page_size`,`page_token`,`lifecycle` | `SandboxEnvironmentListResponse` | | `POST /environments` | Create an environment | `SandboxEnvironmentCreateRequest` | `SandboxEnvironmentResponse` | | `GET /environments/{id}` | Get an environment | — | `SandboxEnvironmentResponse` | | `PATCH /environments/{id}` | Change name/description | `SandboxEnvironmentUpdateRequest` | `SandboxEnvironmentResponse` | | `POST /environments/{id}/archive` | Archive an environment | — | `SandboxEnvironmentResponse` | | `GET /environments/{id}/versions` | List versions | q: `page_size`,`page_token`,`lifecycle` | `SandboxEnvironmentVersionListResponse` | | `GET /environments/{id}/context-uploads` | List completed context uploads of the environment with the versions built from each (company-manager; q: `page_size`,`page_token`,`lifecycle`; backend ≥ #1151) | — | `SandboxContextObjectListResponse` | | `POST /environments/{id}/context-uploads` | Init build-context upload | `SandboxContextUploadInitRequest` | `SandboxContextUploadInitResponse` | | `GET /environments/{id}/context-uploads/{sid}` | Poll upload session | — | `SandboxContextUploadSessionResponse` | | `PUT /environments/{id}/context-uploads/{sid}` | PUT archive bytes | raw body + `X-Sandbox-Upload-Capability` | `SandboxContextUploadPutResponse` | | `POST /environments/{id}/context-uploads/{sid}/complete` | Mint `owned_object_id` | `SandboxContextUploadCompleteRequest` | `SandboxContextUploadCompleteResponse` | | `POST /environments/{id}/context-uploads/{sid}/abort` | Drop an unfinished session | — | `SandboxContextUploadSessionResponse` | | `POST /environments/{id}/versions` | Create an immutable version | `SandboxEnvironmentVersionCreateRequest` | `SandboxEnvironmentVersionResponse` | | `GET /environments/{id}/versions/{vid}` | Get a version (poll for ready) | — | `SandboxEnvironmentVersionResponse` | | `POST /environments/{id}/versions/{vid}/archive` | Archive a version | — | `SandboxEnvironmentVersionResponse` | | `POST /environments/{id}/versions/{vid}/builds` | Queue a build | `SandboxBuildAttemptCreateRequest` | `SandboxBuildAttemptResponse` | | `GET /environments/{id}/versions/{vid}/builds` | List builds | q: `page_size`,`page_token`,`status[]` (repeatable `SandboxBuildAttemptState`) | `SandboxBuildAttemptListResponse` | | `GET /environments/{id}/versions/{vid}/builds/{aid}` | Get a build (poll) | — | `SandboxBuildAttemptResponse` | | `POST /environments/{id}/versions/{vid}/builds/{aid}/cancel` | Fence a non-terminal build (`cancel_requested` + durable cancel intent; poll to `cancelled`) | — | `SandboxBuildAttemptResponse` | | `POST /environments/{id}/versions/{vid}/retry-build` | Retry a failed build | — | `SandboxBuildAttemptResponse` | | `POST /environments/{id}/versions/{vid}/retry-provisioning` | Retry regional provisioning | — | `SandboxEnvironmentVersionResponse` | **Request fields** - `SandboxEnvironmentCreateRequest`: `name`(1–256), `description`(≤2048). - Context upload: environment owner-scope manager; curated environments 404. `declared_bytes` 1..1 GiB; `archive_format` `zip`/`tar`/`tar.gz`. PUT requires `X-Sandbox-Upload-Capability` + `Content-Type: application/octet-stream`. Caps: 20 slots, 20 GiB declared, 5 inits/min. Abort unfinished sessions — leaked slots 429 `staging_slot_exhausted` and wedge scan reports. - Dockerfile `FROM` (compose): any public base image, including `scratch` — no fixed allowlist since 2026-08-25 (composed layers are still inspected for platform-reserved paths, `error_code=context_invalid`). Compose still refuses a dynamic `FROM`, **any** `# syntax=` directive line (any line in the file counts), `ADD` from a network source, `RUN --network=host`, a `RUN --mount` of `type=secret`/`type=ssh` or whose `from=` names an unreviewed stage, `COPY --from` naming an unreviewed stage, and a Dockerfile over 1 MiB. - `SandboxEnvironmentVersionCreateRequest`: preferred `owned_object_id` (from complete). `source_digest` optional (`^sha256:[0-9a-f]{64}$`), but sent **alone** it is only format-checked — the backend never confirms it maps to a completed context object, so the version is created bound to no archive, and the subsequent build is still admitted and budget-reserved before it fails; always send `owned_object_id`. If both are sent they must match. `source_ref`(≤1024), `resource_profile` (tenant-selectable only; not `xlarge`). - `SandboxBuildAttemptCreateRequest`: `build_region` (the `SandboxRegionCode` enum, optional; default `asia-east1` on the hosted cloud, `onprem` on an on-prem deployment; set at `GET /public/info/sandbox/regions`), `build_profile` (the `SandboxBuildProfile` enum, optional, default `standard`). There is no ≤64 / ≤32 length bound — an out-of-set string is a 422 enum error. An enum member the deployment's catalog does not publish (`onprem` on the cloud, any cloud code on-prem) is 422 `build_region_unavailable`; on-prem the same 422 also applies when region rows are active but the requested one is not. **Key response fields** - `SandboxEnvironmentVersionResponse`: `id`, `environment_id`, `version_number`, `state`, `security_state`, `resource_profile`, `capacity_retained`, `ready_at?`…; owner-only: `source_digest?`, `source_ref?` (omitted in the borrower view). - `SandboxBuildAttemptResponse`: `id`, `environment_version_id`, `attempt_number`, `status`, `phase`, `terminal_class`, `stage_code`, `error_code`, `error_detail` (≤128 chars, the specific rule inside `error_code`, e.g. `reserved_path_or_hostile_entry`), `build_region`, `build_profile`, `report_total_bytes` (**no credentials/keys whatsoever**). Common `error_code`: `context_invalid`, `dockerfile_exit`, `policy_reject`, `identity_config`, `tenant_cancel` (CVE findings are advisory-only since 2026-08-25 — `vulnerability_reject` no longer occurs). ## Tasks / versions / shares (prefix `/tasks`) | Method + path | Purpose | Request | Response | | --- | --- | --- | --- | | `GET /tasks` | List tasks | q: `page_size`,`page_token`,`lifecycle` | `SandboxTaskListResponse` | | `POST /tasks` | Create a catalog job (`department` \| `company`) | `SandboxTaskCreateRequest` | `SandboxTaskResponse` | | `GET /tasks/{id}/runs` | Company-manager history (offset/limit) | q: `offset`,`limit`,`order`, filters | `SandboxTaskRunHistoryItem[]` | | `GET /tasks/{id}/runs/numOfData` | Count for that history | same filters | `NumOfData` `{ num }` | | `GET /tasks/{id}` | Get a task | — | `SandboxTaskResponse` | | `PATCH /tasks/{id}` | Edit `name` / `description` / `agent_enabled` only (owner-manager; archived → 409 `task_archived`; empty body → 422 `no_fields`; backend ≥ #1151) | `SandboxTaskUpdateRequest` | `SandboxTaskResponse` | | `POST /tasks/{id}/archive` | Archive the whole task: versions archived, room bindings revoked (queued runs cancelled), share grants kept; second call 409 `already_archived` (backend ≥ #1151) | — | `SandboxTaskResponse` | | `GET /tasks/{id}/versions` | List versions | q: pagination,`lifecycle` | `SandboxTaskVersionListResponse` | | `POST /tasks/{id}/script-uploads` | Upload a UTF-8 script file (≤1 MiB) as a reusable 24 h handle for `work_script_id` / `startup_script_id` (before a version exists) | multipart `file` | `SandboxScriptUploadResponse` | | `POST /tasks/{id}/versions` | Create a draft version | `SandboxTaskVersionCreateRequest` | `SandboxTaskVersionResponse` | | `GET /tasks/{id}/versions/{vid}` | Get a version | — | `SandboxTaskVersionResponse` | | `PATCH /tasks/{id}/versions/{vid}` | Change a draft version | `SandboxTaskVersionUpdateRequest` | `SandboxTaskVersionResponse` | | `POST /tasks/{id}/versions/{vid}/script-file?target=startup\|work` | Upload a UTF-8 script file (≤1 MiB) into a draft's `startup_script`/`work_script` | multipart `file` | `SandboxTaskVersionResponse` | | `POST /tasks/{id}/versions/{vid}/input-preview` | Dry-run a candidate `input`: exact bytes, digest, runtime contract, schema verdict — dispatches nothing | `SandboxInputPreviewRequest` `{ input }` | `SandboxInputPreviewResponse` | | `POST /tasks/{id}/versions/{vid}/publish` | Publish a version | — | `SandboxTaskVersionResponse` | | `POST /tasks/{id}/versions/{vid}/archive` | Archive a version | — | `SandboxTaskVersionResponse` | | `GET /tasks/{id}/shares` | List shares (default `lifecycle=all`) | q: pagination,`lifecycle` | `SandboxShareGrantListResponse` | | `POST /tasks/{id}/shares` | Share to a department/company/chatroom | `SandboxShareCreateRequest` | `SandboxShareGrantResponse` | | `POST /tasks/{id}/shares/{gid}/revoke` | Revoke a share | `SandboxShareRevokeRequest` | `SandboxShareGrantResponse` | **Request fields** - `SandboxTaskCreateRequest`: `name`(1–256), `description`(≤2048), `owner_scope`(**`department`/`company` only**; `chatroom` → 422), `owner_id`(pattern), `agent_enabled`(default false, **legacy**). - `SandboxTaskVersionCreateRequest` / `UpdateRequest` (PATCH draft only): `environment_version_id`, `startup_script`(≤1MiB), `work_script`(≤1MiB), `ordinary_env?`, `input_instructions?`(≤64KiB), `input_example?`(≤64KiB, must satisfy `input_schema` if one is set — 422 `job_contract_invalid` at this draft create/update call otherwise, never at publish), `input_schema?`(≤64KiB, JSON Schema draft 2020-12; enforced on every run submit as 422 `input_schema_violation`), `work_command?`(≤4096 UTF-8 bytes — driver line written to `/workspace/driver.sh`, sourced after `startup.sh`; blank → `. /workspace/work.sh`; NUL or over-length → 422 `job_contract_invalid`), `timeout_seconds`(default 1800, max 604680), `secret_slot_declarations?` (list of `{name}` or strings), `requires_confirmation`(default false), `output_policy?` (union by `kind`: `{ kind: "custom_table_writeback", table_id, allowed_ops }` or, since v5.21.0, `{ kind: "custom_table_command", command_id, chatroom_id, input_schema }`; department-owned only). - Share vs enable vs menu: [Job audience](/en/concepts/job-audience.md). History filters: `status`, `method`, `executor_kind`, `source_kind`, `department_id`, `chatroom_id`. `limit` default 10, max 100. - `SandboxShareCreateRequest`: `target_kind`, `target_id`, `secret_slot_policies?` (`{slot_name: "borrower"|"owner"|"owner_overridable"}`, ≤100 entries, set only at creation — see [Secrets](/en/concepts/secrets.md)). `SandboxShareRevokeRequest`: `reason`(≤512). - `SandboxInputPreviewRequest`: `{ input: }`. **Key responses**: `SandboxTaskVersionResponse` includes required `source_visible`, `state`, `content_state` (`active`/`scrubbed`), `requires_confirmation`, `secret_slot_names[]`, `canonical_digest`, `content_hash`, `update_available`; owner-only: `startup_script?`, `work_script?`, `work_command?`, `ordinary_env?`, `secret_slot_declarations?`, `output_policy?` (omitted in the borrower view; `work_command` is `null` for a borrower). Hidden quick-run parent tasks are filtered from `GET /tasks`. `SandboxShareGrantResponse` also carries `secret_slot_policies?` (both owner and borrower views see it — policy names are not secret). `SandboxInputPreviewResponse`: `input_file_content` (exact bytes for `$TEAMSYNC_INPUTS_FILE`), `input_digest`, `runtime_contract` (see below), `schema_valid`, `schema_errors[]`. ## Bindings (prefix `/bindings`) | Method + path | Purpose | Request | Response | | --- | --- | --- | --- | | `POST /bindings` | Pin a published version into a chatroom | `SandboxBindingCreateRequest` | `SandboxBindingResponse` | | `GET /bindings/{id}` | Get a binding | — | `SandboxBindingResponse` | | `POST /bindings/{id}/accept-version` | Explicitly upgrade to a new version | `SandboxBindingAcceptVersionRequest` | `SandboxBindingResponse` | | `POST /bindings/{id}/revoke` | Revoke a binding | `SandboxBindingRevokeRequest` | `SandboxBindingResponse` | `SandboxBindingCreateRequest`: `chatroom_id`, `task_id`, `task_version_id`. Same pin as granted-jobs enable. Prefer `POST /chatrooms/{id}/granted-jobs/{task_id}/enable`. **There is no `GET /bindings` list.** Room managers list grants with `GET /chatrooms/{id}/granted-jobs`. The Agent/UI catalog is `GET /chatrooms/{id}/menu` (granted **and** enabled). ## Secrets / approvals (paths `/secrets*`, `/secret-approvals*`, `/shared-task-secret-approvals*`) Requires a consumer-scope manager; create/rotate/revoke must carry an `Idempotency-Key`; values are never echoed back. | Method + path | Purpose | Request | Response | | --- | --- | --- | --- | | `POST /secrets` | Create a secret value | `SandboxSecretWriteRequest` | `SandboxSecretOperationResponse` | | `POST /secrets/rotate` | Rotate a value | `SandboxSecretWriteRequest` | `SandboxSecretOperationResponse` | | `POST /secrets/revoke` | Revoke a value | `SandboxSecretRevokeRequest` | `SandboxSecretOperationResponse` | | `GET /secrets/{id}` | Get the masked binding | — | `SandboxSecretBindingResponse` | | `POST /secret-approvals` | Borrower approval (exact digest) | `SandboxSecretApprovalCreateRequest` | `SandboxSecretApprovalResponse` | | `POST /secret-approvals/{id}/revoke` | Revoke an approval | `SandboxSecretApprovalRevokeRequest` | `SandboxSecretApprovalResponse` | | `POST /shared-task-secret-approvals` | Borrower-approval alias (same as `POST /secret-approvals`) | `SandboxSecretApprovalCreateRequest` | `SandboxSecretApprovalResponse` | | `POST /shared-task-secret-approvals/{id}/revoke` | Revoke-approval alias (same as `POST /secret-approvals/{id}/revoke`) | `SandboxSecretApprovalRevokeRequest` | `SandboxSecretApprovalResponse` | `SandboxSecretWriteRequest`: `consumer_scope`, `consumer_id`, `task_id`, `slot_name` (`^[A-Za-z_][A-Za-z0-9_]{0,127}$`), `encrypted_value` (**required**; `SandboxEncryptedValue` `{ encrypted_key, iv, ciphertext }`, all base64 — hybrid AES-256-GCM + RSA-OAEP-SHA256 transit envelope; decrypted value 1..64KiB, not logged). A plaintext `value` field is rejected unconditionally — see [Secrets](/en/concepts/secrets.md) for the encryption recipe and the four distinguishable 422 slugs. `SandboxSecretApprovalCreateRequest`: `consumer_scope`, `consumer_id`, `task_id`, `task_version_id`, `task_version_digest`(`sha256:`), `slot_names`(1..100 — must equal exactly the borrower-resolved slot subset under shared-secret slot policies **in principle**, but the route itself does not check this; a mismatch is only caught later, at claim time, denying the run with `secret_approval_required` — see [Secrets](/en/concepts/secrets.md)), `risk_accepted: true`(must be true). Digest mismatch → 409. No list endpoints for bindings or approvals. ## Runs (paths carry `/chatrooms/{chatroom_id}/...`) | Method + path | Purpose | Restricted key | Request | Response | | --- | --- | --- | --- | --- | | `GET /chatrooms/{cid}/menu` | Granted **and** enabled catalog (same for every caller) | ✅ | q: `page_size`,`page_token`(default 50) | `SandboxMenuResponse` | | `GET /chatrooms/{cid}/granted-jobs` | Grants + room enable switch | ❌ 405 | — | `SandboxGrantedJobListResponse` | | `POST /chatrooms/{cid}/granted-jobs/{tid}/enable` | Enable for Agent/menu | ❌ 405 | `SandboxGrantedJobEnableRequest` (`task_version_id?`) | `SandboxGrantedJobItemResponse` | | `POST /chatrooms/{cid}/granted-jobs/{tid}/disable` | Drop from Agent/menu; share remains | ❌ 405 | — | `SandboxGrantedJobItemResponse` | | `POST /chatrooms/{cid}/runs` | Submit a manual run | ✅ | `SandboxManualRunSubmitRequest` + `Idempotency-Key` | `SandboxRunDetailResponse` | | `GET /chatrooms/{cid}/runs` | List runs (restricted sees only its own) | ✅ | q: `page_size`,`page_token`,`status[]` | `SandboxRunListResponse` | | `GET /chatrooms/{cid}/runs/{rid}` | Get run status (poll) | ✅ | — | `SandboxRunDetailResponse` | | `GET /chatrooms/{cid}/runs/{rid}/content` | Content flags (no body) | ✅ | — | `SandboxRunContentResponse` | | `GET /chatrooms/{cid}/runs/{rid}/artifacts` | List artifacts | ✅ | q: `page_size`,`page_token`,`lifecycle` | `SandboxRunArtifactListResponse` | | `POST /chatrooms/{cid}/runs/{rid}/artifacts/{aid}/download` | Authorize and issue a download capability (no redeem route — use the public-link below to hand a file out) | ✅ | — | `SandboxArtifactDownloadCapabilityResponse` | | `POST /chatrooms/{cid}/runs/{rid}/artifacts/{aid}/public-link` | Mint a permanent public link for ONE artifact file (idempotent; backend ≥ #1149) | ❌ 404 | — | `SandboxArtifactPublicLinkResponse` | | `DELETE /chatrooms/{cid}/runs/{rid}/artifacts/{aid}/public-link` | Revoke that link (idempotent; next mint issues a new token) | ❌ 404 | — | `SandboxArtifactPublicLinkResponse` | | `POST /chatrooms/{cid}/runs/{rid}/{log\|output}/public-link` | Mint a permanent public download link (idempotent) | ❌ 405 | — | `SandboxPublicLinkResponse` | | `DELETE /chatrooms/{cid}/runs/{rid}/{log\|output}/public-link` | Revoke the public link (idempotent; next mint issues a new token) | ❌ 405 | — | `SandboxPublicLinkResponse` | | `POST /chatrooms/{cid}/runs/{rid}/cancel` | Cancel a run you can read (`can_cancel_run == can_read_run`). Restricted key: own runs only | ✅ | — | `SandboxRunDetailResponse` | | `POST /chatrooms/{cid}/runs/{rid}/retry` | Retry a terminal run under a new identity (replays retained input unless overridden — see below) | ❌ 405 | `Idempotency-Key` | `SandboxRunDetailResponse` | | `POST /chatrooms/{cid}/quick-run` | manager-only atomic quick run | ❌ 403 | `SandboxQuickRunRequest` + `Idempotency-Key` | `SandboxQuickRunResponse` | In the restricted-key column, `❌ 405` comes from the scope allowlist (the route is not on the restricted key's allowlist) and quick-run's `❌ 403` from the denied-path rule — none of these routes returns 404. These are the answers for a key that covers the chatroom; when the key's `allowed_chatrooms` does not include `{cid}`, every route returns 403 first. **Request fields** - `SandboxManualRunSubmitRequest`: `task_version_id`, `input`(canonical JSON — rejected 422 `input_schema_violation` before dispatch if the version declares an `input_schema` and this fails it), `timeout_seconds?`. Manual REST needs a live share + membership; **enable is not required**. Agent / menu require granted+enabled. - Retry keeps the exact pinned task version and creates a new run identity. Omit the body, send `{}`, or send `{"input": null}` to replay the retained source input byte-for-byte. A non-null `input` overrides the parameters and is validated/staged under the normal input limits. If retained input is missing, retired, unreadable or digest-mismatched, replay returns 409 `retry_input_unavailable`; provide explicit input instead. Carry `Idempotency-Key`; a source that is not terminal is 409 `retry_not_eligible`. A restricted Sandbox key cannot retry: an allowed-room request is refused by the route allowlist (405); an out-of-scope room can fail earlier with 403. - `SandboxQuickRunRequest`: `name`, `environment_version_id`, `startup_script`, `work_script`, `ordinary_env`, `input`, `timeout_seconds`(required). `input` is staged like a manual submit (v5.10.11), so Quick Run can also answer 422 `input_not_stageable` / 503 `input_staging_unavailable`. - Submit, retry and Quick Run declare **429** as `SandboxQueuedRunLimitErrorResponse` (`sandbox_queued_run_limit`) or `SandboxQueueFullErrorResponse` (`queue_full`, on-prem only, with `Retry-After`). Nothing was queued. See [Run and fetch results](/en/flows/run-and-results.md). - Menu `page_size` default **50**; since backend #1149 `next_page_token` is a real cursor (pass it back as `page_token`). Items also include `task_id`, `task_name`, `task_version_digest`, `input_schema?`, `secret_slots[]` (per-slot `{name, policy, borrower_bound, owner_bound, effective_source}`), `runtime_contract?`, `secret_use_risk_warning?`. Granted-jobs items also carry `secret_slots[]`. - `submission_source` on the run: `rest` / `agent` / `quick_run` / `custom_table_trigger`. **Key responses** - `SandboxRunDetailResponse`: `id`, `company_id`, `chatroom_id`, `status`, `resource_profile`, `principal_type`, `principal_id`, `auth_method`, `task_id`, `task_version_id`, `timeout_seconds`, `submission_source`, `input_digest`, `retry_of_run_id`, `error_code`, `queue_position?`, `eta_seconds?` (v5.10.11; always `null` on the hosted cloud), `queued_at?`, `terminal_at?`, `secret_slot_provenance?` (`{slot_name: {resolved_via, consumer_scope}}`, redacted — `null` if the run has no snapshot). (`cancel_requested_at` is persisted, not returned. `share_grant_id` is frozen internally at submit but is **not** on this response.) - `SandboxRunContentResponse`: `state`, `has_input`, `has_output`, `has_log`, `has_artifact_bundle`, `input_digest` (**flags only** — no log/output bytes on REST). - `SandboxArtifactDownloadCapabilityResponse`: `artifact_id`, `run_id`, `company_id`, `capability_token`, `expires_at` (**5 minutes**), `content_type`, `content_disposition`, `x_content_type_options: nosniff` (**no raw key/URL**, and no tenant redeem route — see [Downloads](/en/concepts/content-and-downloads.md)). - `SandboxPublicLinkResponse`: `run_id`, `object_kind` (`log`/`output`), `url` (permanent, no TTL), `token`, `revoked`, `created_at?`. See [Downloads](/en/concepts/content-and-downloads.md). ## Not exposed to the frontend (for understanding only) - `/sandbox-control/*`: system-to-system OIDC + capability, `include_in_schema=False`. Humans should not call it. An on-prem deployment also mounts the executor work-lease routes `/sandbox-control/onprem/work/*` (executor token auth; absent on the hosted cloud). - `/root/sandbox/*`: operators / root, not the frontend — including the work-queue view `GET /root/sandbox/queue`. See [Operators and root](/en/concepts/operators.md). > If a field or path disagrees with the backend, `src/routers/private/modules/sandbox/` and `src/schemas/sandbox.py` are authoritative. ## Discovery and management lists (v5.10.0) All paths below use the same private Sandbox base. See [Capabilities and discovery](/en/concepts/integration-contract.md) for authority, response fields and UI wiring. | Method | Path | Purpose | | --- | --- | --- | | GET | `/me` | Current user capabilities; does not grant authorization | | GET | `/runs` | Visibility-filtered cross-chatroom run history, total and keyset pagination | | GET | `/tasks/{task_id}/bindings` | Job adoption history, narrowed to manageable rooms for non-owners | | GET | `/chatrooms/{chatroom_id}/bindings` | Room-manager binding history, including retired entries on request | | GET | `/tasks/{task_id}/secrets` | Masked bindings narrowed to consumer scopes the caller manages | | GET | `/tasks/{task_id}/secret-approvals` | Exact-version approval metadata for manageable consumer scopes | | GET | `/secrets` | Consumer-manager discovery by required consumer_scope and consumer_id | # Status enum values The string values below come from `src/schemas/enums.py` (except where otherwise noted). When rendering or making conditional checks, treat these exact literal values as authoritative. ## Run `SandboxRunState` `queued`, `starting`, `running`, `cancel_requested`, `completed`, `customer_failed`, `platform_failed`, `cancelled` ## Build attempt `SandboxBuildAttemptState` `validation_queued`, `validating`, `build_queued`, `building`, `verifying`, `completed`, `customer_failed`, `platform_failed`, `cancel_requested`, `cancelling`, `cancelled` ## Environment version `SandboxEnvironmentVersionState` `draft`, `build_queued`, `building`, `quarantined`, `verifying`, `verified`, `regional_provisioning`, `ready`, `retryable_failed`, `rejected`, `abandoned`, `blocked`, `superseded`, `archived`, `provisioning_blocked` ## Task version `SandboxTaskVersionState` `draft`, `published`, `archived` ## Task-version `content_state` (not a formal enum; stored string) `active`, `scrubbed` — menu/run require `published` **and** `active`. ## Environment parent `state` / binding `state` / share `state` - Environment: `active`, `archived` - Binding (live menu): `enabled` / `revoked` (plus `authority_applicable`) - Share grant: `active` / `revoked` (retirement filter uses `revoked`) - `security_state` on env / version / task: `clear` (default) / `blocked` (root digest-block) - Secret binding: `pending` / `active` / `revoking` / `revoked` - Shared-secret approval: `active` / `revoked` `SandboxOwnerScope` (stored / historical) is `chatroom` / `department` / `company` — **no user scope**. **Create** uses `SandboxTaskCreateOwnerScope`: **`department` / `company` only**. Sending `chatroom` on `POST /tasks` is 422. Unownable department/company create is 403 `owner_scope_forbidden`. ## Run `submission_source` (string on `SandboxRunDetailResponse`) `rest`, `agent`, `quick_run`, `custom_table_trigger` (empty string if unset) ## Chatroom job `ChatroomJobType.SANDBOX` `"sandbox"` — loads the five agent tools on a room. Per-job Agent exposure is **granted + room-enable**, not `task.agent_enabled`. ## Notification center type `sandbox_event` (`NotificationCenterType.SANDBOX_EVENT`) ## Terminal class `SandboxTerminalClass` / `SandboxBuildTerminalClass` `completed`, `customer_failed`, `platform_failed`, `cancelled` ## Resource profile `SandboxResourceProfile` `standard`, `performance`, `large`, `xlarge` — tenant version create may send only the first three (`TENANT_SELECTABLE_PROFILES`). Confirmation set: `GET /public/info/sandbox/profiles`. ## Region `SandboxRegionCode` `asia-east1`, `asia-northeast1`, `asia-northeast2`, `asia-south1`, `asia-southeast3`, `asia-east2`, `asia-northeast3`, `asia-southeast1`, `asia-southeast2`, `asia-south2`, `onprem` (v5.10.11). `onprem` is the single region of an on-prem deployment: the hosted cloud catalog never lists it and refuses it as a `build_region` (422 `build_region_unavailable`), while an on-prem catalog lists only it. Confirmation set: `GET /public/info/sandbox/regions` (public `tier` 1..3). ## Context archive `SandboxContextArchiveFormat` `zip`, `tar`, `tar.gz` ## Run history filters `SandboxRunExecutorKind`: `internal_user`, `external_user`. `SandboxRunSourceKind`: `chatroom`, `department`, `external_platform`. `SandboxRunHistoryMethod`: `agent`, `manual` (`rest` / `quick_run` / `custom_table_trigger` all map to `manual`). ## Build profile `SandboxBuildProfile` `standard`, `fast`, `large` ## Scope types `SandboxOwnerScope` / `SandboxShareTargetKind` / `SandboxConsumerScope` `chatroom`, `department`, `company` ## Environment owner `SandboxEnvironmentOwnerKind` `system_curated`, `company` ## Principal `SandboxPrincipalType` / auth `SandboxAuthMethod` `user`, `social_media_client` / `jwt`, `api_key` ## Lifecycle filter `SandboxLifecycleFilter` `active`, `retired`, `all` (list query parameter; mostly defaults to `active`, `list_task_shares` defaults to `all`) ## Agent proposal `SandboxProposalState` `prepared`, `committing`, `committed`, `superseded`, `cancelled`, `expired`, `failed` ## Secret operation `SandboxSecretOperationState` `prepared`, `applying`, `reconciling`, `applied`, `revoking`, `terminal` ## Shared-secret slot policy (share-grant `secret_slot_policies` values) `borrower` (default when a slot is absent), `owner`, `owner_overridable`. See [Secrets and approvals](/en/concepts/secrets.md). ## Secret slot fulfillment `effective_source` (menu / granted-jobs `secret_slots[]`) `borrower`, `owner`, `missing` ## Secret slot provenance `resolved_via` (run detail `secret_slot_provenance`) `borrower`, `owner` ## Public link object kind (`POST/DELETE .../{kind}/public-link`) `log`, `output` — not `artifact`. See [Content, digests, and downloads](/en/concepts/content-and-downloads.md). ## Notification event `SandboxNotificationEvent` `build_ready`, `build_failed`, `build_cancelled`, `curated_environment_published`, `run_terminal`, `security_finding_discovered`, `security_finding_resolved`, `security_finding_reappeared`, `security_finding_blocked`, `security_exception_granted`, `security_exception_revoked`, `security_exception_expired`, `security_remediation_deadline`, `runner_replacement_available`, `runner_handoff_completed`, `runner_handoff_expired`, `runner_handoff_failed`, `image_storage_renewal_blocked`, `image_storage_cleanup_completed`, `run_content_expiring`, `run_content_expired` These events arrive via the **TeamSync notification center** as `type=sandbox_event`; they **do not replace polling**. GET status remains authoritative. Full recipient / deep-link table: [Notifications](/en/concepts/notifications.md). --- ## Internal object states (appear indirectly in responses) Taken from `src/components/sandbox/storage.py`. The ones the frontend most commonly sees are the artifact `deletion_state` and the run content `state`. - **object_kind**: `context`, `input`, `output`, `log`, `artifact_bundle`, `report` - **Owned Object `deletion_state`**: `active`, `delete_pending`, `deleted`, `tombstoned` (an artifact that is not `active` → download 404) - **Upload mode**: tenant context upload is **`single_part` only**. `multipart` remains control-plane. - **Session state** (tenant context upload): six are declared — `initiating`, `uploading`, `completing`, `completed`, `abort_pending`, `aborted`. Tenant single-part context uploads only ever surface the last five; `completing` is a **non-terminal** state (complete was attempted but failed verification — retry complete or abort), so don't drop it when polling `GET .../context-uploads/{sid}`. `initiating` is written only on control-plane/multipart paths and never surfaces to tenant polls. - **Lease state**: `uploading`, `queued`, `active_transferred`, `release_pending`, `released` (control plane; the slot-holding states are `uploading`/`queued`) ## Numeric caps (`src/components/sandbox/constants.py`) `MAX_PAGE_SIZE=100`, `DEFAULT_PAGE_SIZE=20`; input/script JSON ≤1 MiB; `MAX_JSON_DEPTH=64`, `MAX_JSON_NODES=100000`; env ≤100 names, ≤64 KiB each; `DEFAULT_WORK_TIMEOUT_SECONDS=1800`, `MAX_CUSTOMER_WORK_TIMEOUT_SECONDS=604680`; `MAX_STATUS_WAIT_SECONDS=60` (agent tool only); `MAX_ARTIFACT_FILES=1000`; `PROPOSAL_TTL_SECONDS=1800`; `TURN_RECEIPT_TTL_SECONDS=600`; `_DOWNLOAD_CAPABILITY_TTL=5min`; upload capability 15min; context archive ≤1 GiB; 20 staging slots / 20 GiB / 5 inits per minute; content retention 1..365 days, image retention 1..90 days. Field tables: [Schemas](/en/reference/schemas.md). # Error-code table ## General rules - **404 = unauthorized or nonexistent**. Sandbox deliberately "does not reveal existence": no permission, another company's id, a restricted key touching a forbidden area — all return 404 rather than 403. - **403 is a real exception, not the default.** Creating a department/company job the caller cannot own → `owner_scope_forbidden`. Writeback author/submitter gates and budget holds are also 403. Sending `owner_scope=chatroom` is **422**, not 403. - **422 = request validation**. Unknown field (`extra="forbid"`), pattern mismatch, size over the limit, missing required header. Budget / hold pricing refusals can also be 422. - **409 = state conflict**. Illegal state transition, optimistic-lock CAS mismatch, digest mismatch, not retryable, version-capacity exhausted. - **413 = body too large**. Blocked by `Content-Length` when submitting a run. - **503 = kill switch**. A mutation while `SANDBOX_ENABLED=false` (the default is **true**). ## Table | Status | Error / situation | When it happens | | --- | --- | --- | | 403 | Restricted Sandbox key / missing chatroom path / `invalid scope` | Auth-layer strings, not `{code}`. Fires only on `/private/module/sandbox/environments*`, `/tasks*` (incl. per-job history `/tasks/{id}/runs`), `/root*`, `/chatrooms/{id}/quick-run`, routes with no chatroom segment at all (`/settings`, `/bindings*`, `/secrets*`, `/secret-approvals*`), and when the path's chatroom id is not in the key's allowed chatrooms. | | 405 | Restricted Sandbox key on any other sandbox route outside the scope allowlist | `granted-jobs` (incl. enable/disable), `runs/{id}/retry` and `runs/{id}/{log\|output}/public-link` get 405 Method Not Allowed straight from the scope check — not 403 and not 404. | | 403 | `Insufficient permissions.` | `COMPANY_MANAGER` gate on settings and per-job history (non-manager JWT). | | 404 | Unauthorized / cross-company / resource nonexistent | Existence-hiding default **inside sandbox routers** | | 404 | Download of an artifact with `deletion_state != active` | Downloading a deleted/tombstoned artifact | | 403 | `owner_scope_forbidden` | Creating a department/company job the caller cannot own. Exception to existence-hiding 404. | | 403 | `chatroom_owner_scope_removed` | CRUD defense if a chatroom catalog create bypasses the enum (HTTP `POST /tasks` is 422 first). | | 403 | `output_policy_author_denied` | Table writeback: the publisher lacks unrestricted write authority on the table. Command output (v5.21.0): the author, publisher or submitter fails the identity, grant or scope check; the Command no longer runs under restricted definition authority with an `effect_identity` (at publish that is the 409 below); or a Run is submitted outside the rules in [Job output as a Command](/en/flows/command-output.md) (nothing is queued). Also the answer of the command-output endpoint when that authority is withdrawn mid-Run. | | 403 | `writeback_authority_denied` | Submitter cannot mint the writeback credential. | | 403 | `budget_reservation_exceeded` | On submit run / queue build, the priced envelope does not fit company quota; no row is created | | 422 | `idempotency_key_required` | Submit/retry/quick-run/secret write missing `Idempotency-Key` | | 422 | Field validation | Unknown field, `owner_scope=chatroom` (enum), digest pattern, missing `owned_object_id`+`source_digest`, canonical JSON over limit, empty PUT body | | 422 | `output_policy_invalid` / `output_policy_table_not_found` / `output_policy_channel_table` | Bad writeback policy at draft/publish | | 422 | `output_policy_governed_table` | Draft create/update: a table-writeback policy targets a table whose `write_policy` is `commands_only` (v5.21.0). At publish and at run submit the same condition is 409 `output_policy_unsatisfiable` | | 422 | `output_policy_command_not_found` / `output_policy_schema_mismatch` | Command-output policy (v5.21.0), at draft create/update and again at run submit: no live Command of your company with that id, or `input_schema` differs from the Command's `inputs` / the Command is not a write Command | | 422 | Budget / hold pricing refusal | Pricing or hold validation failed on submit, e.g. `no_active_region`, `missing_price`, `invalid_envelope`, `missing_window` | | 422 | `timeout_exceeds_ceiling` | `timeout_seconds` > company `timeout_ceiling_seconds` | | 422 | `job_contract_invalid` | At draft create/update (`POST /tasks/{id}/versions` or `PATCH .../versions/{vid}`) — never at publish: `input_example` is not JSON, fails the version's own `input_schema`; `work_command` has a NUL byte / exceeds 4096 UTF-8 bytes; or **the `input_schema` itself is malformed** — not JSON, not a JSON object, over 64 KiB of UTF-8 (bytes, not chars), containing a NUL byte, or not a valid draft 2020-12 schema. The response's `detail.errors` lists every problem at once (capped at 20) | | 422 | `input_schema_violation` | At run submit: `input` fails the published version's `input_schema`. Rejected **before dispatch**. Fails open (proceeds, logs a warning) only if the stored schema itself is unparsable or unavailable | | 422 | `input_not_stageable` | `POST /chatrooms/{cid}/runs`: the input JSON cannot be canonicalised or written to durable storage. Fires before room/version authorization, so no run row is created. Run input no longer counts against the 5-upload-initiations-per-minute budget (backend ≥ #1140), so a submit burst is accepted rather than refused. Since v5.10.11 `POST /chatrooms/{cid}/quick-run` stages its input too and can return this after its manager check, before any job or run is created | | 422 | `build_region_unavailable` | `POST .../versions/{vid}/builds`: `build_region` is not in the deployment's published catalog (`onprem` on the hosted cloud; any cloud code on an on-prem deployment), or on-prem region rows are active but the requested one is not. Nothing was queued | | 422 | `bundle_path_forbidden` | `POST /tasks/{id}/bundle-uploads`: the archive has a runner-reserved file at its root (`startup.sh`, `work.sh`, `driver.sh`, `input.json`), an absolute or drive-absolute path, or a NUL/backslash path; the message names the offending entry (backend ≥ #1140) | | 409 | `task_not_shared_to_room` | `POST /chatrooms/{cid}/granted-jobs/{task_id}/enable`: the job exists but is not shared to this chatroom (or its department/company); the message names the share call. Was a bare 404 before #1140 | | 409 | `task_bundle_runner_unsupported` | Publishing or running a **bundle-bearing** task version whose environment runner release is not approved (`runner_release_approved=false` on the version; **backend ≥ #1162** reads the operator approval table live). Fix: queue a new environment build (it picks up the current runner) or have the operator `POST /root/sandbox/runner-releases`. Follow `runner_release_next_action` | | 422 | `script_too_large` / `script_not_utf8` / `script_contains_nul` | Bad file on `POST /tasks/{id}/script-uploads` or `POST .../versions/{vid}/script-file` | | 422 | `secret_slot_policies_invalid` | Malformed `secret_slot_policies` map on `POST /tasks/{id}/shares` (bad slot name, bad policy value, or over 100 entries) | | 413 | `request_too_large` | Run-submission body too large by Content-Length | | 413 | `context_too_large` | Context PUT body / Content-Length exceeds `declared_bytes` | | 400 | `invalid_content_length` | Content-Length header is invalid | | 409 | `build_not_eligible` | The version's state cannot start a build | | 409 | `nonterminal_exists` | The version already has a nonterminal build attempt | | 409 | `not_cancellable` | Build is already terminal. `cancel_requested` / `cancelling` is idempotent | | 409 | `retry_not_eligible` | Retrying a run not in `{completed, customer_failed, platform_failed, cancelled}`, or `POST .../versions/{vid}/retry-build` when the version state is not `retryable_failed` | | 409 | `retry_window_expired` | Build retry outside the 7-day window | | 409 | `provisioning_retry_not_eligible` | `POST .../versions/{vid}/retry-provisioning` when the version state is not `regional_provisioning` / `provisioning_blocked` / `ready` | | 409 | `task_version_digest_mismatch` | The secret approval's digest does not match the published version | | 409 | `task_version_not_published` | `POST /secret-approvals` (and the `/shared-task-secret-approvals` alias): the referenced `task_version_id` exists but is not published. Checked before the digest comparison | | 409 | `share_already_live` | `POST /tasks/{id}/shares` when a live grant already covers this task and target. Changing `secret_slot_policies` means revoke then re-share (a new generation) | | 409 | `policy_version_conflict` | `PUT /settings` CAS mismatch | | 409 | `idempotency_conflict` | Same `Idempotency-Key`, different request hash | | 409 | `active_environment_limit` | Company hit the active-environment cap (default 30) | | 429 | `sandbox_queued_run_limit` | Company queued-run cap on submit / retry (and the Agent and custom-table-trigger lanes): policy default 100; on an on-prem deployment the lower of that and the box's per-company cap (default 20). Quick Run skips the policy limit but gets the on-prem cap. Body `detail` = `{ code, message, limit }` (`SandboxQueuedRunLimitErrorResponse`). Nothing was queued | | 429 | `queue_full` | On-prem only: the whole executor fleet's queue is at its cap (default 200). Body `detail` = `{ code, message, limit, retry_after_seconds }` (`SandboxQueueFullErrorResponse`) plus a `Retry-After: 60` header. Nothing was queued — retry after the delay | | 429 | `staging_slot_exhausted` | 20 live context-upload slots or 20 GiB declared staging. Incomplete uploads leak slots until abort; that also wedges scan-report writes | | 429 | `upload_rate_limited` | More than 5 context-upload inits per company per minute | | 409 | (illegal state transition / CAS) | Illegal publish/archive transition | | 409 | `content_scrubbed_irreversible` / `version_not_runnable` | On submit, the version is not published+content active (`_refuse_not_runnable`): `content_state=scrubbed` returns `content_scrubbed_irreversible` (permanently non-runnable); any other non-`published`+`active` combination returns `version_not_runnable` | | 409 | `version_allocation_exhausted` | The company cannot allocate another capacity-retained version (no confirmed regional quota / cap is 0) | | 409 | `capability_fenced` | Upload capability does not match this session | | 409 | `output_policy_owner_scope_unsupported` | Company-owned job tried to publish a writeback or Command-output policy | | 409 | `output_policy_unsatisfiable` | Writeback table gone / channel-ruled / `commands_only` at publish, or at submit when a credential is minted; for a Command-output policy, any failed Command check at publish (lookup, inputs and mode, restricted definition authority with an `effect_identity`) — the identity, grant and scope checks on the publisher and the stored author stay 403 `output_policy_author_denied` | | 503 | `sandbox_disabled` | A mutation under the kill switch (`SANDBOX_ENABLED=false`; default is **true**). `/cancel` and `/download` stay up | | 503 | `sandbox_secret_pepper_missing` / `sandbox_infisical_unconfigured` | Secret write while the adapter / pepper is down | | 503 | `capability_unavailable` / `storage_unavailable` | Upload capability or object store down | | 503 | `input_staging_unavailable` | `POST /chatrooms/{cid}/runs` (and, since v5.10.11, `POST /chatrooms/{cid}/quick-run`): object store unavailable while staging the input. Manual submit stages before room/version authorization, Quick Run after its manager check; either way no run row is created | | 503 | `build_admission_fenced` | `POST .../versions/{vid}/builds` and `POST .../versions/{vid}/retry-build` while a platform stage-image rollout holds the global build-admission fence (minutes). Body is `{ code, message, retry_after_seconds }` and the same value is sent as the `Retry-After` header; nothing was queued — retry after that delay | | 503 | `sandbox_writeback_key_unavailable` | HMAC key missing/short when a policy is declared | ## Secret transit-encryption errors (a different response shape) `POST /secrets` and `POST /secrets/rotate` validate `encrypted_value` with a pydantic model validator, so these four are **not** the `{"detail": {"code": "..."}}` shape used everywhere else on this page — they come back as FastAPI's standard validation-error array, and the slug is in `detail[].type` (also mirrored into `detail[].msg` as exactly `validation_error:`, with `input`/`ctx` stripped from the response): | Slug (`detail[].type`) | Cause | | --- | --- | | `sandbox_plaintext_value_rejected` | A **non-empty** plaintext `value` field was sent (with or without `encrypted_value`); an empty-string or null `value` is ignored, not treated as plaintext | | `sandbox_encrypted_value_required` | `encrypted_value` missing or null | | `sandbox_transit_keypair_missing` | Server has no decryption keypair configured | | `sandbox_encrypted_value_undecryptable` | Envelope shape is valid but decryption failed | All four are HTTP 422. See [Secrets and approvals](/en/concepts/secrets.md) for the client-side encryption recipe. ## Not a REST error: `share_grant_revoked` `share_grant_revoked` (409) is raised only on the **control-plane** manifest-reveal route the runner calls — never on a `/private/module/sandbox` tenant endpoint. It fires when an owner-lent secret pin's share grant died (revoked, re-shared into a new generation, or authority moved) between the run's claim and a later manifest read. The frontend never receives this code directly; it only sees the resulting run terminate in failure. See [Secrets and approvals](/en/concepts/secrets.md). ## Not under `/private/module/sandbox`: the command-output endpoint (v5.21.0) `POST /public/module/custom_tables/callback/command-output/{token_id}` is the public endpoint a Command-output Run's work script calls with its sealed credential. It answers `{"detail": {"code": "..."}}`: 404 `output_run_unavailable` (unknown credential or wrong/missing Bearer secret), 409 `output_run_unavailable` (credential revoked or expired, the Run is no longer live, or its task or version is no longer active and published), 409 `output_command_stale`, 409 `output_command_conflict`, 422 `output_schema_violation` and 403 `output_policy_author_denied`; a malformed body, or a `token_id` that is not a UUID, gets FastAPI's 422 validation array, and the Command execution itself can add its own errors. Meanings: [Job output as a Command](/en/flows/command-output.md). ## Agent tool errors (not REST) Returned as `{ "status": "error", "error": "", "message": "..." }` from the five tools: | `error` | When | | --- | --- | | `unauthorized` | Reauthorization / ownership (`SandboxAgentAuthError`), or a 401/403 refusal of the submit (for example a Command-output rule) | | `validation_error` | Bad args, or a 400/409/422 refusal of the submit | | `forged_receipt` | Receipt is not the signed multi-segment format (must contain 4 dots) | | `not_found` | Expired / foreign / hidden / same-turn / reused receipt | | `confirmation_rejected` | Other closed-form confirm failure | | `already_committed` | Proposal already became a run | | `proposal_not_pending` | State is not `prepared` | | `same_turn` / `not_later` / `receipt_reused` | Same-turn, not-later, or reused confirming turn | | `retryable` | Transient commit; proposal stays `prepared` | | `confirmation_input_unavailable` | Confirm verified; durable input could not be replayed. No run; proposal stays pending | | `provider_unavailable` | Control plane unavailable, or a submit refusal the tool does not map to another code — including both 429s (`sandbox_queued_run_limit`, on-prem `queue_full`) | | `internal_error` | Unexpected; generic message | See [Agent toolkit](/en/reference/agent-tools.md). ## Frontend handling advice - **404**: don't assume "nonexistent" — first check permission, company, key scope, and whether the id belongs to the current user. - **403 `owner_scope_forbidden`**: pick a scope the caller can own, or a principal who manages that scope. - **403 `budget_reservation_exceeded`**: company quota cannot cover this envelope; surface the cap, don't resend the same request. - **422 missing header**: add the `Idempotency-Key` (a fresh UUID per new intent, reused on retry). - **409**: re-read the resource's current state before deciding the next step (don't blindly resend). - **409 `version_allocation_exhausted`**: no confirmed regional quota, or the cap is 0 — this is an operator / quota problem, not a missing tenant field. - **503 `sandbox_disabled`**: the kill switch is off (`SANDBOX_ENABLED=false`; default is on). Tell the user to try again later — cancel/download still work. - **418**: missing both `Authorization` and `X-Api-Key` (the shared TeamSync credential exception, `Could not validate credentials`, before sandbox 404 hiding) — not 401; see [Authentication](/en/get-started/auth.md). - Tenant 429s exist: `sandbox_queued_run_limit`, `queue_full` (on-prem; honor `Retry-After`), `staging_slot_exhausted`, `upload_rate_limited`. Back off; do not spin-retry inits. Other 5xx besides 503 are platform failures — retry with backoff; do not assume the run was not created unless you still hold the `Idempotency-Key`. ## v5.10.0 validation and retry additions - 422 `detail[].type = sandbox_value_too_short`: decrypted create/rotate value has fewer than 4 UTF-8 bytes. - 409 `task_version_not_published`: explicit secret approval targets an unpublished version. - 409 `retry_input_unavailable`: original retained input cannot be replayed; supply explicit input. - Invalid/empty approval slot sets and reserved authoring slot names are 422 validation errors. Use the typed `wait_reason`, `error_code`, `failure_stage` and `failure_source` from the current OpenAPI. A provider refusal retains its actual safe provenance through retry handling; do not replace it with a made-up empty-success state. # API reference overview - [**Endpoint catalog**](/en/reference/endpoints.md): every frontend endpoint, grouped by resource (method, path, auth, request, response). - [**Request and response schemas**](/en/reference/schemas.md): field-complete request/response models. - [**Agent toolkit**](/en/reference/agent-tools.md): the five LangGraph tools (not REST). - [**Status enums**](/en/reference/enums.md): every status string the frontend will render. - [**Error-code table**](/en/reference/errors.md): HTTP status codes and sandbox error semantics. ## General rules - Base: `{BASE}/private/module/sandbox`. - Auth: TeamSync JWT or API key (see [Authentication](/en/get-started/auth.md)). A restricted `Sandbox` scope key can only touch its own runs plus menu/submit. - Request body: `extra="forbid"` (unknown field → 422). - Pagination: `page_size` (`ge=1 le=100`), `page_token` (≤512). - Lists: a `lifecycle` query parameter (`active` / `retired` / `all`, mostly defaulting to `active`). - Unauthorized: **404** (never revealing existence). Exceptions: unownable department/company create is **403 `owner_scope_forbidden`**; `owner_scope=chatroom` is **422**. Mutation under the kill switch: **503 `sandbox_disabled`**. - Confirmation catalogs: `GET /public/info/sandbox/regions` and `/profiles` (no auth). - Submission/rotation operations: must carry an **`Idempotency-Key`** (1..128 chars, no NUL). - OpenAPI is **double-tagged**: umbrella `Module: Sandbox` (from `src/routers/private/modules/server.py`) **plus** per-area `Module: Sandbox - Settings` / `Environments` / `Tasks` / `Bindings` / `Secrets` / `Runs`. Codegen will group both ways. > This handbook is written against backend `origin/master` (see [Changelog](/en/changelog/index.md)). Fields follow the tenant routers and `src/schemas/sandbox.py`. # Request and response schemas Every request body below is `extra="forbid"`: an unknown field is **422**. Paths omit `{BASE}/private/module/sandbox`. Models live on the tenant routers (`src/routers/private/modules/sandbox/*.py`) and in `src/schemas/sandbox.py`. If this page and `origin/master` disagree, the backend wins. **Conventions used in every table** - `?` = optional / nullable / omitted in some views. - `owner-only` = present for the owner-scope manager, null/withheld in the borrower view. - String ids match `^[A-Za-z0-9_.\-]+$` (or the tighter `^[A-Za-z0-9_.:@-]{1,64}$` on some write fields). - Digests are `sha256:` + 64 lowercase hex. ## Shared: pagination | Field | Type | Notes | | --- | --- | --- | | `page_size` | int | `ge=1`, `le=100` (`MAX_PAGE_SIZE`) on paginated lists. Default **20** on the environment / environment-version / build / task / task-version / share lists (`DEFAULT_PAGE_SIZE`); default **50** on exactly three chatroom routes: `GET .../menu`, `GET .../runs`, `GET .../runs/{rid}/artifacts`. | | `page_token` | string? | Opaque, `max_length=512`. Echo a page's `next_page_token` back verbatim; a malformed token restarts from page one (no 422). Since backend #1149 accepted by **every** list, including `GET .../menu`, `GET .../runs` (cursor pins `(queued_at, id)`, newest first) and `GET .../runs/{rid}/artifacts` (ordered by `relative_path`). | | `next_page_token` | string? | Absent / null = last page. Every list returns a real cursor when more rows exist (backend ≥ #1149; before that the three chatroom routes always returned `null` and could not be paged). | | `lifecycle` | `active` / `retired` / `all` | Most lists default `active`. `GET /tasks/{id}/shares` defaults `all`. | | `status` | repeatable query | Run list only. Repeat `?status=queued&status=running`. | Retired vocabularies (`src/routers/private/modules/sandbox/_filters.py`): | List | Retired values (hidden unless `lifecycle=retired` or `all`) | | --- | --- | | Environments | `archived` | | Environment versions | `archived` (`retryable_failed` / `rejected` stay visible) | | Tasks | `archived` | | Task versions | `archived` | | Share grants | `revoked` (default filter is `all` so revocation stays visible) | | Run artifacts | `delete_pending` / `deleted` / `tombstoned` | ## Company settings ### `SandboxCompanySettingsUpdate` (`PUT /settings`) | Field | Type | Constraint | | --- | --- | --- | | `run_content_retention_days` | int | 1..365 | | `unreferenced_image_retention_days` | int | 1..90 | | `allowed_regions` | string[] | `min_length=1`. Runtime Job / Execution placement **only**. | | `policy_version` | int | `ge=1`. Optimistic-lock CAS; mismatch → 409. | ### `SandboxCompanySettingsResponse` (`GET`/`PUT /settings`) Writable fields above, plus read-only: | Field | Type | Notes | | --- | --- | --- | | `company_id` | string | | | `selectable_regions` | string[] | Active operator region codes accepted by settings writes; may extend the public confirmation set. | | `pricing` | `SandboxRuntimePricing?` | Authenticated customer rates and reservation bounds; null when no region is selectable. | | `runtime_egress_enabled` | bool | Read-only operator policy; not writable through tenant PUT /settings. | | `timeout_ceiling_seconds` | int | Tenant ceiling. Default 86400. | | `active_environment_limit` | int | Default 30. | | `capacity_retained_version_limit` | int | Default 100. | | `placement_scope` | `"runtime_execution_only"` | Literal. | | `does_not_govern` | string[] | `build` / `validator` / `registry` / `r2` / `log` / `control_plane`. | | `residency_guaranteed` | `false` | Literal. | | `regional_job_capacity_grace_days_fixed` | int | 30. | ## Environments / versions / builds ### `SandboxEnvironmentCreateRequest` / `UpdateRequest` | Field | Create | Update | Constraint | | --- | --- | --- | --- | | `name` | required | optional | 1..256 | | `owner_scope` | optional, default `company` | immutable | `company` or `department`; no chatroom owner | | `owner_id` | optional for company; required department id for department | immutable | Company id defaults to the caller’s company; department must match owner authority | | `description` | optional, default `""` | optional | ≤2048 | ### `SandboxEnvironmentResponse` | Field | Type | Notes | | --- | --- | --- | | `id` | string | | | `company_id` | string? | Null on a `system_curated` environment. | | `owner_kind` | `system_curated` / `company` | Tenants may pin curated versions; they cannot create them. | | `name` | string | | | `description` | string | | | `state` | string | Live: `active`. Retired: `archived`. | | `security_state` | string | Typically `clear`. Root digest-block can set `blocked`. | | `current_version_id` | string? | | ### Context upload | Model | Fields | | --- | --- | | `SandboxContextUploadInitRequest` | `declared_bytes` 1..1 GiB. `archive_format` `zip`/`tar`/`tar.gz` (default `tar.gz`). | | `SandboxContextUploadInitResponse` | `upload_session_id`, `capability_token`, `capability_expires_at` (15 min), `upload_mode` (`single_part`), `object_kind` (`context`), `archive_format`, `declared_bytes`, `max_bytes`, `put_header_name` (`X-Sandbox-Upload-Capability`), `put_content_type` (`application/octet-stream`). | | `SandboxContextUploadPutResponse` | `upload_session_id`, `bytes_received`, `digest`. | | `SandboxContextUploadCompleteRequest` | `expected_size?`, `expected_digest?` (`sha256:`). | | `SandboxContextUploadCompleteResponse` | `owned_object_id`, `digest`, `byte_size`, `upload_session_id`, `environment_id`. | | `SandboxContextUploadSessionResponse` | `upload_session_id`, `environment_id`, `state` (`uploading`/`completed`/`aborted`/`abort_pending`), `object_kind`, `declared_bytes`, `owned_object_id?`, `digest?`, `byte_size?`, `session_expires_at?`. | PUT body is raw bytes, not JSON. ### `SandboxEnvironmentVersionCreateRequest` | Field | Type | Constraint | | --- | --- | --- | | `owned_object_id` | string? | From complete. 1..36. Preferred. | | `source_digest` | string? | `^sha256:[0-9a-f]{64}$`. At least one of the two is required. **Sent alone it is never matched against any completed context object** — the backend only checks the shape and creates a version bound to no archive; queueing a build still succeeds (and reserves budget first), and the failure only surfaces during the build stage (`context_invalid`). Always send the `owned_object_id` returned by complete. When both are sent the digest must equal the object's (422 `source_digest_mismatch` otherwise). | | `source_ref` | string | Optional, default `""`, ≤1024. Owner-only label (filename). Not a key or URL. | | `resource_profile` | enum | Confirmation set `GET /public/info/sandbox/profiles`. **Not `xlarge`.** Required. | ### `SandboxEnvironmentVersionResponse` | Field | Type | Notes | | --- | --- | --- | | `id`, `environment_id` | string | | | `company_id` | string? | | | `version_number` | int | `ge=1` | | `state` | `SandboxEnvironmentVersionState` | Poll until `ready`. | | `security_state` | string | | | `resource_profile` | string | | | `capacity_retained` | bool | Counts against the company version cap. | | `promoted_digest` | string? | Built image digest after promotion. | | `composed_execution_digest` | string? | Runtime composition. | | `runner_release`, `runner_abi_revision`, `policy_revision` | string? | Runner identity. | | `runner_release_approved` | bool | **backend ≥ #1162.** True when this version's runner release is approved for task-bundle jobs. False on drafts and on versions built by a runner the operator has not approved — bundle publish/run then returns `409 task_bundle_runner_unsupported`. | | `runner_release_next_action` | string | Plain-language guidance when `runner_release_approved` is false (queue a new build, or ask the operator to approve the release); empty otherwise. | | `region_readiness` | any? | Per-region provisioning map. | | `ready_at`, `verified_at` | datetime? | | | `compressed_bytes`, `unpacked_bytes` | int? | | | `last_build_attempt_id`, `last_build_status`, `last_build_terminal_class`, `last_build_stage_code`, `last_build_error_code`, `last_build_error_detail` | string | Newest build attempt; `error_detail` owner-only. | | `build_stage` | string | Stage the newest attempt is in: `build_queued`, `context_validator`, `customer_build`, `static_scan`, `smoke`, `sign`, `attest`, `promote`, `final_verify`, `runner_floor`, `regional_readback`, `terminal`; empty before any build. | | `build_stage_index`, `build_stage_count` | int | Progress numerator/denominator over the gate sequence (`0` while queued or terminal). | | `build_stage_since` | datetime? | When the newest attempt entered `build_stage`. | | `build_expected_seconds` | int | Typical wall-clock for a whole build on this platform; `0` when no build is live. | | `build_next_action` | string | ≤400. What is happening / what to do while the build is live or after it failed; empty once `ready`. | | `source_digest`, `source_ref` | string? | **Owner-only.** Omitted in the borrower view. | ### `SandboxBuildAttemptCreateRequest` | Field | Type | Default | | --- | --- | --- | | `build_region` | `SandboxRegionCode?` | Confirmation set `GET /public/info/sandbox/regions`. Omit → the deployment default: `asia-east1` on the hosted cloud, `onprem` on an on-prem deployment. A code the deployment's catalog does not publish (e.g. `onprem` on the cloud) → 422 `build_region_unavailable`; on-prem the same 422 applies when region rows are active but the requested one is not. | | `build_profile` | string? | ≤32. Default `standard`. Enum: `standard` / `fast` / `large`. | ### `SandboxBuildAttemptResponse` Never contains credentials, keys, or object URLs. | Field | Type | Notes | | --- | --- | --- | | `id`, `company_id`, `environment_version_id` | string | | | `attempt_number` | int | `ge=1` | | `status` | `SandboxBuildAttemptState` | | | `phase`, `stage_code`, `error_code` | string | Detail; empty when unused. | | `error_detail` | string | ≤128 chars, `;`-joined slug(s) naming the specific rule inside `error_code`, e.g. `reserved_path_or_hostile_entry` or `dockerfile_too_large`. Never free text or customer content. Empty when the terminal carried no further detail. | | `terminal_class` | `SandboxBuildTerminalClass` or `""` | Non-empty = terminal. | | `build_region`, `build_profile` | string | | | `raw_upload_digest`, `canonical_digest` | string | | | `queued_at` | datetime? | | | `retryable_until` | datetime? | Backend ≥ #1149. Set on a failed/cancelled attempt while its version is still `retryable_failed`: the deadline (7 days from the version's last state change) before which `POST .../versions/{vid}/retry-build` is accepted. `null` once the window closed, the version moved on, or the attempt did not fail. Same value for every failed attempt of one version. | | `report_total_bytes` | int | `> 0` means a report exists. The report body is **not** on the tenant API. | | `stage_index`, `stage_count`, `stage_since`, `next_action` | int, int, datetime?, string | Same stage-progress view as the version's `build_stage_*` fields. | List wrappers: `{ items, page_size, next_page_token }` for environments, versions, and builds. ## Tasks / versions / shares ### `SandboxTaskCreateRequest` | Field | Type | Constraint | | --- | --- | --- | | `name` | string | 1..256 | | `description` | string | default `""`, ≤2048 | | `owner_scope` | enum | **`department` / `company` only** (`SandboxTaskCreateOwnerScope`). `chatroom` → 422. | | `owner_id` | string | 1..36, id pattern. Department id or company id. | | `agent_enabled` | bool | default `false`. **Legacy.** Agent catalog uses room enable. | Creating a department/company scope the caller cannot own → **403 `owner_scope_forbidden`**. See [Job audience](/en/concepts/job-audience.md). ### `SandboxTaskResponse` | Field | Type | Notes | | --- | --- | --- | | `id`, `company_id`, `owner_scope`, `owner_id` | string | | | `name`, `description` | string | | | `agent_enabled` | bool | | | `state` | string | `active` / `archived` (and hidden quick-run parents filtered from `GET /tasks`). `POST /tasks/{id}/archive` (backend ≥ #1151) moves it to `archived` and cascades to versions and bindings. | | `security_state` | string | | | `current_published_version_id` | string? | | | `current_draft_version_id` | string? | | ### `SandboxTaskUpdateRequest` (`PATCH /tasks/{id}`, backend ≥ #1151) Send only the fields to change; at least one is required (422 `no_fields`). | Field | Type | Constraint | | --- | --- | --- | | `name` | string? | 1..256, not blank | | `description` | string? | ≤2048 (may be empty) | | `agent_enabled` | bool? | Parent-level Agent flag; rooms still need grant + enable | ### `SandboxContextObjectItemResponse` / `SandboxContextObjectListResponse` (`GET /environments/{id}/context-uploads`, backend ≥ #1151) | Field | Type | Notes | | --- | --- | --- | | `owned_object_id` | string | What `POST .../versions` accepts as `owned_object_id`. | | `environment_id` | string | | | `byte_size` | int | | | `digest` | string | `sha256:` of the uploaded archive; equals a version's `source_digest`. | | `deletion_state` | string | `active` is usable; anything else is retired and cannot seed a version. | | `used_by_version_ids` | string[] | Versions of this environment whose `source_digest` matches. | | `created_at`, `expires_at` | datetime? | | List wrapper `{ items, page_size, next_page_token }`; `lifecycle` defaults to `active`. Never a storage key or URL. An upload that a version was created from stays listed (it is re-bound to that version; `used_by_version_ids` names it — backend ≥ #1156); once the platform retires the object it appears only under `lifecycle=retired` / `all`. ### `SandboxTaskVersionCreateRequest` / `UpdateRequest` Create requires `environment_version_id`. Update is draft-only (`PATCH`). | Field | Type | Constraint | | --- | --- | --- | | `environment_version_id` | string | Must reference a **`ready`** version. | | `startup_script` | string | default `""`, ≤1 MiB, no NUL | | `work_script` | string | default `""`, ≤1 MiB, no NUL | | `startup_script_id`, `work_script_id` | string? | Handle from `POST /tasks/{id}/script-uploads` (24 h, reusable); its content is copied into `startup_script` / `work_script`. Mutually exclusive with sending that script inline. | | `ordinary_env` | object? | The name rules (≤100 names total with secrets; each entry ≤64 KiB; name `^[A-Za-z_][A-Za-z0-9_]{0,127}$`; reserved names `TEAMSYNC_`… rejected) are **not enforced on these two draft routes** — the map is stored verbatim, and a violation is only refused by the control plane at run claim time (and **only** when the version declares secret slots), surfacing as a failed run, not a 422. Only Quick Run validates `ordinary_env` at request time. Writeback URL/token are injected on the **sealed secret channel**, not here. | | `input_instructions` | string? | ≤65536 | | `input_example` | string? | ≤65536. Must satisfy `input_schema` if one is set (422 `job_contract_invalid` at draft create/update otherwise — publish does not re-validate). | | `input_schema` | string? | ≤65536. JSON Schema (draft 2020-12) as a string. When set, every submitted `input` is validated against it — 422 `input_schema_violation` before dispatch on a violation. | | `work_command` | string? | ≤4096 **UTF-8 bytes** (checked at the byte level, not chars — binds first for non-ASCII). Free-form driver line, e.g. `python3 /workspace/work.sh` (the work_script always lands at `/workspace/work.sh` and an uploaded file's name is never preserved — point at the fixed path, not the uploaded filename). Written verbatim into `/workspace/driver.sh`, sourced after `startup.sh` (never argv). Blank/omitted → `. /workspace/work.sh`. NUL or over-length → 422 `job_contract_invalid`. | | `timeout_seconds` | int | default 1800; `ge=1`; `le=604680` (7 days − 120s finalization envelope) | | `secret_slot_declarations` | list? | See below. | | `requires_confirmation` | bool | default `false`. Agent must propose then commit on a later turn. Triggers cannot use this. | | `output_policy` | `SandboxOutputPolicy?` | Typed union discriminated by `kind`: `custom_table_writeback` (`table_id`, `allowed_ops` `create` (default) or `create,update`) or `custom_table_command` (`command_id`, `chatroom_id`, `input_schema`; v5.21.0) — see below. Department-owned only. Hashed into `canonical_digest`. Borrowers never see this field. | **`secret_slot_declarations` shape.** The owner view stores a list. Each item is either a string name or `{ "name": "" }`. `name` must match the env-name pattern and must not be reserved. The borrower / menu view exposes only `secret_slot_names: string[]`. ### `SandboxOutputPolicy` (discriminated by `kind`) `output_policy` on the version create/update request and on the owner's version response is a union; `kind` selects the arm. Both arms are `extra="forbid"`. | `kind` | Fields | Notes | | --- | --- | --- | | `custom_table_writeback` | `table_id` (1..36 chars, id pattern), `allowed_ops` (`create` default, or `create,update`) | Run-scoped table callback token. See [Job audience](/en/concepts/job-audience.md). | | `custom_table_command` (v5.21.0) | `command_id` (≤36 chars, id pattern), `chatroom_id` (≤36 chars, id pattern), `input_schema` (list of Custom Tables `CommandInput`, ≤200) | Run-scoped Command credential. See [Job output as a Command](/en/flows/command-output.md). | An old version may still carry a historical, uninterpreted object in this field (the response type is the union or a plain object); new writes are always validated against the union. ### `SandboxRuntimeContract` The fixed contract every work script runs under — identical for every job except `driver`. Appears as `runtime_contract` on the menu item and the input-preview response. | Field | Type | Notes | | --- | --- | --- | | `invocation` | string | Fixed: `". /workspace/startup.sh && . /workspace/driver.sh"`. | | `driver` | string | Contents of `/workspace/driver.sh` for this job — the real `work_command` for an owner, else the default fallback `". /workspace/work.sh"` (a borrower never sees the real line). | | `working_directory` | string | Fixed guidance: the runner starts the `/bin/sh` session with `cwd=/workspace`, but the contract leaves the working directory unspecified — always use absolute `/workspace/...` paths in scripts and `work_command`. | | `network` | string | Fixed guidance: **outbound network is available at runtime when the company's `runtime_egress_enabled` is true (`GET /settings`; default on)** — `pip install` / `npm ci` / `go get` work inside `startup_script`/`work_script`, and egress bytes are metered (`measured_egress_bytes`). Baking dependencies into the environment image (Dockerfile `RUN`, public internet reachable; private, loopback and metadata ranges stay blocked) is still recommended for fast, reproducible runs. An operator may switch the company’s runtime egress off; then installs at work time fail. | | `inputs_file_env` | string | Fixed: `"TEAMSYNC_INPUTS_FILE"` — env var naming the path of this run's input JSON file. | | `output_file_env` | string | Fixed: `"TEAMSYNC_OUTPUT_FILE"` — env var naming the path the script should write results to. | | `artifacts_dir_env` | string | Fixed: `"TEAMSYNC_ARTIFACTS_DIR"` (= `/workspace/artifacts`, created empty before `startup.sh`) — every regular file left below it becomes a downloadable artifact file (backend/runner ≥ #1152). | | `artifacts` | string | Human-readable rules: subdirectories kept as path prefixes; refused **as a whole** (run still completes, log says why) on any symlink/hard link/special file, `..`/control chars, >1000 files, path >1024 B or component >255 B, or payload over the standard-profile cap (workspace cap − 16 MiB, ≤ 1 GiB). | | `input_delivery` | string | Human-readable description: the input JSON is written to `$TEAMSYNC_INPUTS_FILE` before the script starts. | | `secrets_delivery` | string | Human-readable description: declared secret slots are exported as environment variables before the script runs — never written to disk. | ### `SandboxInputPreviewRequest` / `Response` (`POST .../versions/{vid}/input-preview`) Request: `{ input: }`. Dispatches nothing. | Response field | Type | Notes | | --- | --- | --- | | `input_file_content` | string | Exact UTF-8 bytes that would land at `$TEAMSYNC_INPUTS_FILE` for this input. | | `input_digest` | string | `sha256:` + 64 hex — same digest a real submitted run would get. | | `runtime_contract` | `SandboxRuntimeContract` | `driver` reflects the real `work_command` only if the caller manages the task. | | `schema_valid` | bool | `true` when `input` satisfies the version's `input_schema` (or none is set). | | `schema_errors` | string[] | ≤20 entries. Empty when `schema_valid`. | ### `SandboxScriptUploadResponse` | Field | Type | Notes | | --- | --- | --- | | `id` | string | Pass as `work_script_id` / `startup_script_id`. | | `filename` | string | Display only; never interpreted. | | `byte_size` | int | ≤ 1 MiB. | | `sha256` | string | `sha256:` of the validated bytes. | | `expires_at` | datetime | Handle lifetime (24 h). | ### `SandboxTaskVersionResponse` | Field | Type | Notes | | --- | --- | --- | | `id`, `task_id`, `company_id` | string | | | `version_number` | int | | | `state` | `draft` / `published` / `archived` | | | `content_state` | `active` / `scrubbed` | Menu / run require `state=published` **and** `content_state=active`. | | `environment_version_id` | string | | | `environment_id`, `environment_name` | string | Backend ≥ #1149. The environment that owns the pinned version (the id `GET /environments/{id}` uses) and its display name — curated or your own. Both empty only when the pinned version row no longer exists. | | `timeout_seconds` | int | | | `requires_confirmation` | bool | | | `secret_slot_names` | string[] | Always present (may be empty). | | `canonical_digest`, `content_hash` | string | Exact-version identity. Approvals pin `canonical_digest`. | | `published_at`, `archived_at` | datetime? | | | `update_available` | bool | Newer published version exists (binding context). | | `input_instructions`, `input_example` | string? | Shown on the menu. | | `input_schema` | string? | Shown on the menu **to owner and borrower alike** — it describes the caller's own `input` shape, not the owner's implementation. | | `runtime_contract` | `SandboxRuntimeContract?` | Present for **both** views (not owner-only): `driver` echoes the real `work_command` in the owner view, the default fallback `". /workspace/work.sh"` in the borrower view. | | `source_visible` | bool, required | True for owner source view. False means source is withheld, not empty; menu always returns false. | | `startup_script`, `work_script`, `work_command`, `ordinary_env`, `secret_slot_declarations`, `output_policy` | owner-only | Null/withheld for borrowers (`work_command` is `null`, not merely absent). | ### `SandboxShareCreateRequest` / `RevokeRequest` / `GrantResponse` Create: `target_kind` (`chatroom`, `department`, `company`) + `target_id` + optional `secret_slot_policies`. An authorized owner may target any live same-company chatroom directly; no broader grant is needed first. Revoke carries `reason` up to 512 characters. Sharing does not enable the Agent. | Request field | Type | Notes | | --- | --- | --- | | `secret_slot_policies` | `Dict[str, "borrower"\|"owner"\|"owner_overridable"]?` | Per-slot secret credential-source policy. ≤100 entries; keys must be valid slot names. **Set only at creation** — changing it means revoke + re-share (a new generation). 422 `secret_slot_policies_invalid` on a malformed map. See [Secrets and approvals](/en/concepts/secrets.md). | | Response field | Type | Notes | | --- | --- | --- | | `id`, `company_id`, `task_id`, `target_kind`, `target_id` | string | | | `generation` | int | | | `state` | string | Live vs `revoked`. | | `active` | bool | | | `authority_applicable` | bool | False after chatroom reparent / delete / authority bump. | | `owner_authority_generation`, `target_authority_generation` | int | | | `revoked_at` | datetime? | | | `revoke_reason` | string | | | `secret_slot_policies` | object? | Same shape as the request. Returned on the owner-manager share routes (all three require `_get_managed_task`) — there is no borrower-facing share endpoint that returns `SandboxShareGrantResponse` at all. A borrower instead reads the effective policy from `secret_slots[].policy` on the menu / granted-jobs. | ### Company-manager history `GET /tasks/{task_id}/runs` returns a **JSON array** of `SandboxTaskRunHistoryItem` (not `{ items, page_token }`). Pair with `GET /tasks/{task_id}/runs/numOfData` → `{ num }`. Query: `offset` ≥0 default 0, `limit` 1..100 default 10, `order` `asc`/`desc` (by `queued_at`, then id), optional `status`, `method` (`agent`/`manual`), `executor_kind` (`internal_user`/`external_user`), `source_kind` (`chatroom`/`department`/`external_platform`), `department_id`, `chatroom_id`. History row fields: `id`, `task_id`, `task_version_id`, `status`, `queued_at?`, `started_at?`, `terminal_at?`, `duration_seconds?`, `cost_usd?` (settled USD, null until settlement), `executor_kind`, `executor_id`, `executor_display_name`, `source_kind`, `chatroom_id`, `chatroom_name`, `department_id`, `department_name`, `external_platform?` (`line`/`messenger`/`instagram`/`agent`), `method`, `submission_source`, `resource_profile`, `error_code`. ## Granted jobs | Model | Fields | | --- | --- | | `SandboxGrantedJobEnableRequest` | `task_version_id?` — omit to pin current published. | | `SandboxGrantedJobItemResponse` | `task_id`, `task_name`, `task_description`, `owner_scope`, `owner_id`, `secret_slots` (`SandboxSecretSlotStatus[]`, for the pinned version if enabled else the current published version), `enabled`, `binding_id?`, `pinned_task_version_id?`, `current_published_version_id?`, `update_available`. | | `SandboxGrantedJobListResponse` | `chatroom_id`, `items`. | ## Bindings There is **no** `GET /bindings` list. Prefer granted-jobs enable. ### Requests | Model | Fields | | --- | --- | | `SandboxBindingCreateRequest` | `chatroom_id`, `task_id`, `task_version_id` (published + active) | | `SandboxBindingAcceptVersionRequest` | `task_version_id` (newer published version of the same task) | | `SandboxBindingRevokeRequest` | `reason` ≤512 | ### `SandboxBindingResponse` | Field | Type | Notes | | --- | --- | --- | | `id`, `company_id`, `chatroom_id`, `task_id`, `task_version_id` | string | | | `generation`, `share_generation`, `target_authority_generation` | int | | | `state` | string | Live menu requires `enabled`. | | `update_available` | bool | New published version exists; never auto-bumps. | | `authority_applicable` | bool | Share / room authority still valid. | | `accepted_at`, `revoked_at` | datetime? | | ## Secrets / approvals Values are **never** echoed. Create / rotate / revoke require `Idempotency-Key` (1..128 chars, no NUL). ### `SandboxEncryptedValue` (hybrid transit envelope) The **only** accepted shape for a secret value on the wire. A plaintext `value` field is rejected unconditionally. See [Secrets and approvals](/en/concepts/secrets.md) for the client-side encryption recipe. | Field | Type | Notes | | --- | --- | --- | | `encrypted_key` | string | Base64, ≤1024 chars. The random AES-256 key, RSA-OAEP-SHA256-wrapped against `GET /public/info/model_key/public_key`. | | `iv` | string | Base64, **exactly 16 chars** (no padding — a correctly-sized 12-byte IV never needs it). The AES-GCM nonce. | | `ciphertext` | string | Base64. AES-256-GCM ciphertext with the GCM tag appended. | ### `SandboxSecretWriteRequest` / `RevokeRequest` | Field | Type | Notes | | --- | --- | --- | | `consumer_scope` | `chatroom` / `department` / `company` | Consumer, not task owner. | | `consumer_id` | string | | | `task_id` | string | | | `slot_name` | string | `^[A-Za-z_][A-Za-z0-9_]{0,127}$`, not reserved. | | `encrypted_value` | `SandboxEncryptedValue` (write only, **required**) | Decrypted value 4 UTF-8 bytes..64 KiB, no NUL, never echoed back. **Not** on revoke. A plaintext `value` is rejected unconditionally, keypair configured or not — four distinguishable 422 slugs, see [Errors](/en/reference/errors.md). | ### `SandboxSecretBindingResponse` / `SandboxSecretOperationResponse` Binding: `id`, `company_id`, `consumer_scope`, `consumer_id`, `task_id`, `slot_name`, `generation`, `state`, `has_provider_binding` (bool). No value, path, or provider id. Operation: `operation_id`, `operation_type` (`create` / `rotate` / `revoke`), `operation_state` (`SandboxSecretOperationState`), `binding`. ### `SandboxSecretApprovalCreateRequest` | Field | Type | Notes | | --- | --- | --- | | `consumer_scope`, `consumer_id`, `task_id`, `task_version_id` | string | Borrower identity + exact version. | | `task_version_digest` | `sha256:…` | Must match the published `canonical_digest` else 409 `task_version_digest_mismatch`. | | `slot_names` | string[] | 1..100, unique, valid slot names. | | `risk_accepted` | `true` | Literal `true` only. | Revoke: `reason` (default `"revoked"`, ≤512). Alias paths `/shared-task-secret-approvals` are byte-equivalent. ### `SandboxSecretApprovalResponse` `id`, `company_id`, `consumer_scope`, `consumer_id`, `owner_scope`, `owner_id`, `task_id`, `task_version_id`, `task_version_digest`, `slot_set_hash` (`sha256:` of sorted names), `generation`, `state`, `active`, `created_at`, `revoked_at`. Never secret values. ## Runs ### `SandboxManualRunSubmitRequest` | Field | Type | Notes | | --- | --- | --- | | `task_version_id` | string | Published + `content_state=active`. Manual REST: any granted version. Menu/Agent: the enabled pin. | | `input` | any JSON | Canonical JSON (see [Content](/en/concepts/content-and-downloads.md)). | | `timeout_seconds` | int? | `ge=1`, `le=604680`. Omit → version default. | Header: `Idempotency-Key` required (1..128 chars, no NUL). ### `SandboxQuickRunRequest` Manager-only. `name` 1..256, `environment_version_id`, `startup_script`, `work_script`, `ordinary_env` (default `{}`), `input`, `timeout_seconds` **required**. Returns `{ task, version, run }`. `input` is staged like a manual submit (v5.10.11), so it lands at `$TEAMSYNC_INPUTS_FILE` and the run's content reports `has_input=true`. ### Queue backpressure 429 bodies Declared as the 429 response of `POST .../runs`, `POST .../runs/{run_id}/retry` and `POST .../quick-run`. FastAPI wraps each body in `detail`, so the named components are the envelopes. Nothing was queued. | Model | Fields | | --- | --- | | `SandboxQueuedRunLimitErrorResponse` | `detail`: `SandboxQueuedRunLimitError` — `code` literal `sandbox_queued_run_limit`, `message`, `limit` (int `ge=1`, the cap that was reached). | | `SandboxQueueFullErrorResponse` | `detail`: `SandboxQueueFullError` — `code` literal `queue_full`, `message`, `limit` (int `ge=1`, the fleet-wide queue cap), `retry_after_seconds` (int `ge=0`, also sent as `Retry-After`). On-prem deployments only. | ### `SandboxSecretSlotStatus` Per-slot borrower/owner fulfillment status — metadata only, **never a secret value, binding id, or provider path**. Appears as `secret_slots[]` on the menu item and the granted-job item. | Field | Type | Notes | | --- | --- | --- | | `name` | string | Slot name. | | `policy` | `borrower` / `owner` / `owner_overridable` | This grant's per-slot policy (default `borrower` if the share never set one). | | `borrower_bound` | bool | Whether the borrower has a live binding for this slot. | | `owner_bound` | bool | Whether the owner has a live binding for this slot. | | `effective_source` | `borrower` / `owner` / `missing` | Which side would actually supply the value if a run submitted now. | ### `SandboxMenuItemResponse` | Field | Type | Notes | | --- | --- | --- | | `task_id`, `task_name` | string | | | `task_description` | string | | | `owner_scope`, `owner_id` | string | | | `task_version_id` | string | What you submit. | | `task_version_digest` | string | Exact digest for approvals. | | `version_number` | int | | | `version_state` | string | `published` on the menu. | | `input_instructions`, `input_example` | string? | | | `input_schema` | string? | JSON Schema the `input` must satisfy (submit is 422 `input_schema_violation` otherwise). | | `secret_slot_names` | string[] | | | `secret_slots` | `SandboxSecretSlotStatus[]` | Per-slot fulfillment status (empty list if the version declares no secret slots). | | `timeout_seconds` | int | | | `requires_confirmation` | bool | Agent confirmation; humans still `POST /runs`. | | `update_available` | bool | Binding has a newer published version. | | `secret_use_risk_warning` | string? | Present when running this version uses borrower secrets. | | `runtime_contract` | `SandboxRuntimeContract?` | `driver` on the **menu** is always the default fallback `". /workspace/work.sh"`, even for a manager of the owner scope — `_menu_item_from_view` hardcodes `build_runtime_contract(None)`, so the menu never carries the real `work_command`. To see the real driver, `GET /tasks/{id}/versions/{vid}` (owner view) or `POST .../input-preview` as a manager. | **No script source.** Menu `page_size` default 50; since backend #1149 `next_page_token` is a real cursor (ordered by `task_id`) — before that it was always `null`. ### `SandboxSlotProvenance` Redacted per-slot resolution provenance for a run's frozen secret snapshot — never a fingerprint, binding id, or provider path. | Field | Type | Notes | | --- | --- | --- | | `resolved_via` | `borrower` / `owner` | Which side actually supplied this slot's value for this run. | | `consumer_scope` | `chatroom` / `department` / `company` | The consumer scope this run resolved against. | ### `SandboxRunDetailResponse` | Field | Type | Notes | | --- | --- | --- | | `id`, `company_id`, `chatroom_id` | string | | | `status` | `SandboxRunState` | Terminal when `terminal_at` is set. | | `resource_profile` | string | Inherited from the environment version. | | `principal_type` | `user` / `social_media_client` | | | `principal_id` | string | Acting principal. Restricted keys only see their own. | | `auth_method` | `jwt` / `api_key` | | | `task_id`, `task_version_id` | string | | | `timeout_seconds` | int | | | `submission_source` | string | `rest` / `agent` / `quick_run` / `custom_table_trigger`. Empty string if unset. | | `error_code`, `error_detail` | string | Failure provenance; empty on `completed`. `error_detail` ≤128, `[A-Za-z0-9._@:/+=%;-]`. | | `exit_code` | int? | Real exit status of the work command (`null` until terminal; `0` on completed). | | `failure_stage` | enum | `''` / `task_bundle` / `secret_env` / `workspace` / `spawn` / `metering_arm` / `metering_start` / `metering_wait` / `metering_terminal` / `work` / `output` / `timeout` / `cancel` / `egress_cap` / `wait` | | `failure_source` | enum | `''` / `runner` / `reconcile` / `dispatch` / `submit` / `policy` / `tenant` | | `wait_reason` | enum | `''` / `awaiting_dispatch` / `company_saturated` / `no_active_region` / `missing_image_digest` / `capacity_snapshot_stale` / `provider_capacity_exhausted` / `provider_submit_pending` / `provider_submit_uncertain` / `container_starting` / `cancel_pending`. Why a non-terminal run is not running yet; empty while `running` and on terminals. | | `next_action` | string | ≤400. Guidance matching `wait_reason`; empty when it is empty. | | `wait_since` | datetime? | When the current wait began. | | `queue_position` | int? | `ge=1`. Position in the queue the run waits in for a run slot; `null` once claimed or dispatched and whenever the run is not waiting. Cloud: among the company's `queued` runs. On-prem: in the executor queue, also while `starting`. | | `eta_seconds` | int? | `ge=0`. Rough seconds until a waiting run starts, from the queue position, the fleet's run slots and the mean duration of recent completed runs on the same profile. `null` when not waiting or without history — never a guessed number. Always `null` on the hosted cloud deployment. | | `failure_hint` | string | ≤400. Fix suggestion for a failed terminal (from `error_code` / `exit_code`); empty otherwise. | | `measured_ingress_bytes`, `measured_egress_bytes` | int | Metered network bytes of the work step. | | `input_digest` | string | Canonical hash of submitted input. | | `retry_of_run_id` | string | Empty if not a retry. | | `error_code` | string | Empty on success. | | `queued_at`, `terminal_at` | datetime? | **Poll `terminal_at`.** | | `secret_slot_provenance` | `Dict[str, SandboxSlotProvenance]?` | Redacted per-slot resolution, frozen when the run's secret pins are published at claim time. `null` when the run has no persisted snapshot, or every entry was skipped as malformed. | `cancel_requested_at` is persisted for cancel provenance (PR #951) but is **not** on this response. Watch `status=cancel_requested` then `cancelled`. The `share_grant_id` a run froze at submit is likewise internal-only — it is **not** on this response. ### `SandboxRunContentResponse` Flags only. No bodies, no object keys. | Field | Type | Notes | | --- | --- | --- | | `run_id`, `company_id` | string | | | `state` | string | `"missing"` until a content row exists. | | `input_digest` | string | | | `has_input`, `has_output`, `has_log`, `has_artifact_bundle` | bool | There is **no** tenant endpoint that returns log/output bytes. | ### `SandboxRunArtifactItemResponse` `id`, `run_id`, `relative_path`, `path_hash`, `byte_size`, `digest`, `deletion_state` (`active` / `delete_pending` / `deleted` / `tombstoned`), `declared_content_type`. ### `SandboxArtifactDownloadCapabilityResponse` | Field | Type | Notes | | --- | --- | --- | | `artifact_id`, `run_id`, `company_id` | string | | | `capability_token` | string | Opaque `token_urlsafe(32)`. **Not a URL.** | | `expires_at` | datetime | `now + 5 minutes` (`_DOWNLOAD_CAPABILITY_TTL`). | | `content_type` | string | `application/octet-stream` | | `content_disposition` | string | `attachment; filename="…"` (sanitized). | | `x_content_type_options` | `"nosniff"` | | See [Content, digests, and downloads](/en/concepts/content-and-downloads.md) for what the frontend can actually do with this token. ### `SandboxArtifactPublicLinkResponse` (`POST`/`DELETE .../artifacts/{artifact_id}/public-link`, backend ≥ #1149) | Field | Type | Notes | | --- | --- | --- | | `run_id`, `artifact_id` | string | | | `relative_path` | string | The file's path inside the bundle (display only). | | `object_kind` | `"artifact"` | | | `url` | string | `{base}/public/sandbox/artifacts/{token}` — permanent, streams **only this file's byte range** out of the run's artifact bundle after verifying the recorded `sha256`. No login. | | `token` | string | 16..64 chars, unguessable. | | `revoked` | bool | | | `created_at` | datetime? | | ### `SandboxPublicLinkResponse` (`POST`/`DELETE .../{kind}/public-link`) | Field | Type | Notes | | --- | --- | --- | | `run_id` | string | | | `object_kind` | `log` / `output` | Never `artifact`. | | `url` | string | Full permanent public URL (`{base}/public/sandbox/artifacts/{token}`). No TTL. Empty string on a revoke response where no link ever existed. | | `token` | string | 16..64 chars. On a revoke response where no link ever existed, a `0000000000000000` placeholder (not a live token). | | `revoked` | bool | `true` on the revoke response. | | `created_at` | datetime? | | Minting is idempotent (returns the existing live link); revoking then minting again issues a **new** token. Restricted Sandbox API keys get 404 on both routes. ### `SandboxCommandOutputRequest` (`POST /public/module/custom_tables/callback/command-output/{token_id}`, v5.21.0) A public route whose authority is the Bearer secret from `TEAMSYNC_CT_WRITEBACK_TOKEN` — not a tenant route. The body is `{ "inputs": object }` and nothing else (`extra="forbid"`). `inputs` is checked against the Custom Tables Command JSON limits when the body is parsed and against the Command's declared inputs when the call is handled (422 `output_schema_violation`). The response is the Command execution response (`CommandExecutionResponse`, `null` fields omitted). See [Job output as a Command](/en/flows/command-output.md). ## Agent-tool inputs (not REST) These are LangGraph tool arguments. They never carry company / chatroom / principal / turn identity — the server attaches that out of band. See [Agent toolkit](/en/reference/agent-tools.md). | Model | Fields | | --- | --- | | `SandboxAgentMenuInput` | `page_size`, `page_token?`, `query?` (≤256) | | `SandboxAgentSubmitToolInput` | `mode=request`: `task_version_id` + `input` + optional `timeout_seconds`. `mode=commit`: **only** `proposal_receipt` (≤4096). | | `SandboxSubmittedJobsInput` | `page_size`, `page_token?` | | `SandboxJobStatusToolInput` | `run_id`, `wait_seconds` 0..60, `artifact_page_token?` | | `SandboxCancelJobInput` | `run_id?` (omit = cancel this principal's pending proposal) | ## What this page deliberately omits - `/sandbox-control/*` request bodies (hosted cloud: OIDC + capability; on-prem work leases: executor-token auth; not frontend). - `/root/sandbox/*` request bodies (operator surface; see [Operators](/en/concepts/operators.md)). `SandboxWorkQueueResponse` / `SandboxWorkQueueItem` / `SandboxExecutorCapacity` in `src/schemas/sandbox.py` belong to the operator route `GET /root/sandbox/queue`, not to a tenant route. - Provider names, object keys, presigned URLs, secret values. ## Current discovery and runtime types (v5.10.0) See [Capabilities and discovery](/en/concepts/integration-contract.md) for `SandboxPrincipalCapabilitiesResponse`, `SandboxCompanyRunListResponse`, the task/room binding lists, and masked secret/approval lists. Only the binding/secret/approval management lists use `truncated`; `/runs` and the paginated catalogs retain their page cursors. New secret-slot declarations use `SandboxSecretSlotDeclarations`: named objects with a required valid, non-reserved `name`, while optional metadata is preserved. Read-side `SandboxStoredSecretSlotDeclarations` also accepts historical string/opaque declarations; do not rewrite them on read. `region_readiness` is a map from live region code to `ready` or `not_ready`. `SandboxRunRetryRequest` has optional `input`: absent/null replays the source bytes; a non-null value supplies new input. See [retry behavior](/en/flows/run-and-results.md). `SandboxRuntimePricing` includes currency, pricing/bounds versions, per-region profile rates, inbound/outbound rates, minimum billable time, finalization time and outbound reservation bounds. See [runtime planning](/en/concepts/integration-contract.md). Environment responses carry `owner_scope` and `owner_id` in addition to `owner_kind`. `owner_kind=company` still means tenant-custom; it does not imply `owner_scope=company`. # Changelog This handbook is written against **teamsync-backend `origin/master`**. | Pin | Value | | --- | --- | | Commit | `172a7f80bdf4dbdffdc018eb08bfa42c62bb485c` — v5.21.0 version commit (tag `v5.21.0`) | | Subject | chore: bump version to 5.21.0 | | Date | 2026-10-08 (Asia/Taipei) | | Tenant routers | `src/routers/private/modules/sandbox/` | | Public catalogs | `src/routers/public/info/server.py` (`GET /public/info/sandbox/{regions,profiles}`), `src/routers/public/sandbox_artifacts.py` (`GET /public/sandbox/artifacts/{token}`) | | Shared schemas | `src/schemas/sandbox.py`, `src/schemas/enums.py` | | Constants | `src/components/sandbox/constants.py`, `src/components/sandbox/storage.py`, `src/components/sandbox/input_schema.py`, `src/components/sandbox/secrets.py` | | Providers | `src/components/sandbox/providers/` (`SANDBOX_PROVIDER` switch; `onprem/` region, pricing tier and queue caps), on-prem executor `src/workers/sandbox_onprem/` | | Operator root (context) | `src/routers/root/sandbox.py` | | Agent tools | `src/components/tools/custom/sandbox/` | | Triggers | `src/crud/custom_table_triggers.py` (`submit_sandbox_job`) | | Writeback | `src/crud/sandbox/writeback.py` | | Command output | `src/crud/sandbox/command_output.py`; public route `POST /public/module/custom_tables/callback/command-output/{token_id}` in `src/routers/public/custom_tables_callback/server.py` | | Notifications | `src/crud/sandbox/notifications.py` | | Runner / build lane | `sandbox/runner/`, `sandbox/build/` (Go runner + Cloud Build compose/scan lane) | | Control plane infra | `infrastructure/sandbox/gcp/`, `infrastructure/app/gcp/k8s/base/sandbox-controller-*.yaml` | If a page and that tree disagree, the backend wins. Re-audit by diffing those paths from this SHA. ## 2026-10-08 — release v5.21.0 **Source and release:** backend `172a7f80bdf4dbdffdc018eb08bfa42c62bb485c` is the v5.21.0 version commit (tag `v5.21.0`, `chore: bump version to 5.21.0`, 2026-10-08 11:07 UTC = 19:07 Asia/Taipei). It is the first pin after v5.10.11, so this entry examines 111 of the 1,848 commits in `70862ceae7..172a7f80bd`: the 71 that touch the paths in the “How to refresh this pin” command below, plus 40 more that touch a file with `sandbox` in its path or mention Sandbox in their message. Almost all of them belong to other modules; **five change the Sandbox contract**. Everything below is read from the released tree. No deployed API was called for this entry, so there is no production `info.version` readback to quote. Dates use Asia/Taipei. ### Job output can run a Custom Tables Command `output_policy` is now a union discriminated by `kind`. Beside table writeback (`custom_table_writeback`), a **department-owned** job version can declare `{ "kind": "custom_table_command", "command_id", "chatroom_id", "input_schema" }`. Every Run of that version is minted one sealed, run-scoped credential at submission and receives it at claim through the existing `TEAMSYNC_CT_WRITEBACK_URL` / `TEAMSYNC_CT_WRITEBACK_TOKEN` variables (if it cannot be revealed, the Run starts without them), which now point at the public endpoint `POST /public/module/custom_tables/callback/command-output/{token_id}`. The work script posts `{ "inputs": { … } }`, and the platform runs exactly that Command, in exactly that chatroom, as the Run’s submitter, at most once per Run: the first valid output wins, and the endpoint answers 404/409 `output_run_unavailable`, 409 `output_command_stale`, 409 `output_command_conflict`, 422 `output_schema_violation`, 403 `output_policy_author_denied` or the Commands API’s own errors. The policy is checked at draft create/update (422 `output_policy_command_not_found`, 422 `output_policy_schema_mismatch`, 403 `output_policy_author_denied`), at publish (any failed Command check is 409 `output_policy_unsatisfiable`; the identity, grant and scope checks on the publisher and on the stored author stay 403 `output_policy_author_denied`) and again at every Run submission. Unlike table writeback, a Run that does not qualify — a borrowed room, a room other than the policy’s, an API-key / restricted-key / social-client submitter, or an author or submitter without the Command grant — is **refused** with 403 `output_policy_author_denied` and nothing is queued. Custom-table triggers may now target these versions (the Run is submitted as the trigger’s author, and the job’s input file is the rendered `input` object itself rather than the `source` / `trigger` / `record` / `row` envelope). The Agent submits them directly, without a confirmation turn, when `requires_confirmation` is false; table-writeback versions still need the two-turn confirmation. The version-author attribution these checks use is internal and appears in no response. **Frontend action:** let owners author the new kind and show the authoring codes; expect `output_policy` to stay `null` for borrowers (it now also embeds a Command and a room id); treat 403 `output_policy_author_denied` on submit as an authority problem, not a missing resource. Observing a Run is unchanged, and the Sandbox run record does not carry the Command’s result. See [Job output as a Command](/en/flows/command-output.md), [job audience](/en/concepts/job-audience.md), [custom-table trigger](/en/flows/custom-table-trigger.md), [agent toolkit](/en/reference/agent-tools.md), [schemas](/en/reference/schemas.md) and [errors](/en/reference/errors.md). ### Runner credentials follow `timeout_seconds` At claim, the two runtime credentials a runner uses to read its manifest, report progress and completion and upload results now last the persisted run `timeout_seconds` plus the 120 s finalization envelope (at most 604800 s). Before this release they lasted the platform’s 600 s default, after which the control plane refuses them, so a longer run could not report its result and was settled by reconciliation as `platform_failed` / `provider_terminal_without_callback`; the release’s on-prem E2E (`scripts/e2e/custom_tables/sandbox_ttl`) requires, in its RED phase against the old code, exactly that outcome for a 12-minute run with `timeout_seconds=1800`. On the hosted cloud each Cloud Run execution is also started with that same total as its per-execution `timeoutSeconds` override (never above 168 h), independent of the reused Job template. A missing or out-of-range persisted timeout makes the claim fail closed (control plane only). **Frontend action:** none. A long `timeout_seconds` no longer needs to stay under 10 minutes to report its result. See [limits](/en/concepts/limits.md). ### The runner retries its completion report The Go runner’s terminal `complete` call now has a 30 s budget per attempt and up to three attempts 500 ms apart when the control plane answers 408, 429, 500, 502, 503 or 504, or the transport fails (timeout, EOF, connection error; an unknown host is not retried). Per the change’s own title, the point is that a transient failure of that call no longer leaves a finished run to be settled by reconciliation as `platform_failed` instead of with the customer’s exit code or timeout. Runner changes reach an environment version only when it is built by a runner release that contains them; the version’s `runner_release` records which release it has. **Frontend action:** none. ### Table writeback and `commands_only` tables A Custom Tables table can carry `write_policy: commands_only` (its records change only through Commands). A table-writeback `output_policy` cannot target such a table: draft create/update answers 422 `output_policy_governed_table`; at publish, and at Run submission when a credential is minted, the same condition is 409 `output_policy_unsatisfiable`; and a run-scoped table token minted before the policy was switched on is refused by the callback with the typed 403 `governed_context_required`. **Frontend action:** show the new 422; a job that must write to such a table should use the Command-output kind instead. See [jobs, share, and enable](/en/flows/tasks-and-bindings.md) and [errors](/en/reference/errors.md). ### Operator surface (context only) On an on-prem deployment the provider-capacity refresh and `GET /root/sandbox/quota-snapshots/refreshes/{refresh_id}` now scope their snapshots to the alias `onprem` instead of requiring a GCP project; the hosted cloud still answers 409 `sandbox_gcp_project_unset` / `sandbox_gcp_project_invalid` when its project is missing or malformed. The operator route table is unchanged (27 routes). See [operators](/en/concepts/operators.md). ### Corrections found by this audit These were wrong or incomplete before this release and are fixed in both locales: - The error-code page said a request with neither `Authorization` nor `X-Api-Key` is **401**; it is **418** (`Could not validate credentials`), as the authentication page already said. - The agent toolkit page said the OpenAPI description of `PATCH /private/chatrooms/setting/jobs/{chatroom_id}` lists only wms and booking; it lists `sandbox` (it did at v5.10.11 too). - The agent pages did not say that a table-writeback `output_policy` forces the proposal path even when `requires_confirmation` is false; they now do. ### Pins and exports The README, both homepages and the runtime-planning page now name v5.21.0 and the full SHA `172a7f80bdf4…`, and the schema page names v5.21.0. The “How to refresh” command below starts from this SHA and also covers `src/routers/public/custom_tables_callback`, `src/tasks/sandbox_*.py`, `src/dependencies/sandbox.py` and `src/database/models/sandbox.py`. `llms.txt`, `llms-full.txt` and the Markdown twins are regenerated from these pages, including the new page [Job output as a Command](/en/flows/command-output.md). ### Audit note — commit classification Searches: (A) `git log 70862ceae7..172a7f80bd` over the paths of the “How to refresh this pin” command below (71 commits: 60 non-merge, 11 merge); (B) non-merge commits that touch any file with `sandbox` in its path; (C) commits whose message mentions Sandbox. The union is 111 commits (99 non-merge, 12 merge), each classified exactly once: - **Documented/integrator-visible contract changes (covered by the sections above; 5 commits):** `7c41cbc501` (Command output, #1853), `739894a878` (credential lifetime, #1826), `433e22bd00` (completion retry, #1819), `3fa893b56d` (`commands_only`, #1788), `4b11540fa6` (selected-provider capacity refresh, #1804). - **Merge commits with no additional documented contract change (12):** `c7e3d41426`, `aba63cb121`, `e65d71e298`, `a81511bb15`, `9a4d7b0660`, `028db39d42`, `a3c9665e10`, `8e80f29a49`, `ba3290fcb7`, `d65e9e5b26`, `62b4075d81`, `d77bcfa6e4`. - **Development environment, infrastructure, CI and packaging — no effect on the released product (9):** `03d3f5d4df`, `3cb548cb43`, `9057cc086d` (the Sandbox dev environment is armed, kept dark through bootstrap and isolated inside the shared project; production resource names are unchanged), `69598743e9` (earlier removal of Sandbox from the dev deployment), `feba983f1e`, `4d7415d467` (agent-workspace IaC), `1bea1ef4ec` (CI workflows), `45242b8711` (hash-pinned Python lock), `552609edbf` (release and deployed-image layout contracts). - **Internal change, no contract change (1):** `37b79e2222` (process recycling now waits for tracked in-process work, including the thread that wakes the controller after a Run is queued; the 3 s sweep stays the fallback). - **On-prem install kit, operator scripts, tutorials and compose add-ons (26):** `e19594605b`, `4c11ddea98`, `3d05af8193`, `690d574faa`, `415bd335d1`, `10a72f6c1f`, `11b49fd897`, `695e7bf761`, `200512a125`, `150a8fc8be`, `46e9051e6c`, `d4a6be835b`, `caa80868dd`, `16e82ca37f`, `840b0c7220`, `fd991177fb`, `d72b880df9`, `67c513e294`, `4891aab5e2`, `e62c9907eb`, `73e377d58c`, `bbabc707ac`, `22ca3e74d0`, `aafb03c9be`, `368713044a`, `ebb3e0c988`. They change the guided installer, its diagnostics, add-on address handling and packaging, not the tenant API, and no variable the handbook documents changes meaning; the handbook defers the install kit to the backend `docs/deployment/on-prem/`. - **Test harness and Agent tool selection (3):** `1d89736847` (a test of the existing Sandbox scope refusal), `badb87d738` (the Custom Tables acceptance runner), `9ec0fbae42` (the smart tool selector; the five Sandbox tools stay pinned). - **Other modules — they share an audited path, mention Sandbox only in passing, or touch a file with `sandbox` in its name; none changes Sandbox behaviour (55):** `7df284baa0`, `7a0a2b8a0d`, `dbdaeb4243`, `a21ba222ef`, `6bad38fbe9`, `737955fb69`, `6bf9760687`, `c9392060d1`, `c15a12f9b0`, `eaf7c84d76`, `cc5d2db9c8`, `1a607e52b8`, `bfb9ced050`, `cfb7457a4f`, `430d9aa23c`, `a510143539`, `9750eac491`, `74a6a2b13a`, `c48f763467`, `457140d0de`, `0f8985db78`, `2f548ae2a7`, `d48281f258`, `4ac973afae`, `2b78575d80`, `f0e17d4259`, `e5770ead73`, `ce26227b87`, `c6c21d4194`, `8ecc1f9a54`, `63636600ae`, `49103a6c5d`, `82042fed21`, `3b380b1a63`, `68f5beffc2`, `455fa388c6`, `e932ccdc0a`, `ae5229549e`, `9f7f93b7fd`, `2b0c952fef`, `1bc159f300`, `8cb89701ef`, `1eb1d3e517`, `68bc44c431`, `48577334a5`, `9587d94e93`, `8c0435fd3f`, `0e89a8032d`, `64843e7b66`, `19c720aad2`, `168e3d0da0`, `0de6458c1e`, `ba8ca8b3cd`, `c6d02fa6fa`, `78d30b5ff6`. Notes: the new OAuth access token (valid only on `/mcp/*`) and the Command service key (valid only on two Command routes) are refused on Sandbox routes; `7df284baa0` runs Custom Tables request handling off the event loop (the command-output route became a plain handler run in the threadpool, same contract); `455fa388c6` removes price gates on AI calls and keeps the Sandbox cloud-build price gate; `4ac973afae` corrects the description of the table callback's `update` data (documentation only). ## 2026-09-19 — production v5.10.11 **Source and release:** backend `70862ceae7869470c14f4ed7cc7dfc3609e299f4` is the v5.10.11 version commit (`origin/master` HEAD, tag `v5.10.11`). The [production release](https://github.com/ShuChenAI/teamsync-backend/releases/tag/v5.10.11) was deployed by the [“Release & Deploy to GCP (production)” run 35428530132](https://github.com/ShuChenAI/teamsync-backend/actions/runs/35428530132), which concluded successfully on 2026-09-19; the production readback `GET https://api.cluster.scfg.io/openapi.json` reports `info.version` = `5.10.11`. This entry audits every commit in `9b8b95e59..70862ceae` that touches the paths in the “How to refresh this pin” command below (202 commits: 190 non-merge commits plus 12 merge commits). Most of the range is the new **on-prem provider** (`SANDBOX_PROVIDER=onprem`); the hosted cloud deployment keeps its behaviour except where stated. ### Run queue: position, ETA and 429 backpressure `SandboxRunDetailResponse` adds nullable `eta_seconds` beside `queue_position` on every route that returns a run (submit, list, detail, cancel, retry, Quick Run). On the hosted cloud deployment `queue_position` keeps its meaning (1-based among the company’s `queued` runs) and `eta_seconds` is always `null`. On an on-prem deployment both come from the executor queue: the position also covers `starting` until an executor takes the run, and the ETA is `ceil(position ÷ fleet run slots) × mean duration of the last 20 completed runs on the same profile` — `null` without history. OpenAPI now declares the 429 of `POST .../runs`, `POST .../runs/{run_id}/retry` and `POST .../quick-run` as `SandboxQueuedRunLimitErrorResponse` or `SandboxQueueFullErrorResponse`; `queue_full` (on-prem only: the whole executor fleet’s queue is full) carries `limit`, `retry_after_seconds` and a 60 s `Retry-After` header. **Frontend action:** show `eta_seconds` only when it is non-null — never invent one — and regenerate clients for the two 429 components. On `queue_full` wait for `Retry-After`; on `sandbox_queued_run_limit` back off until fewer runs are waiting. See [run and results](/en/flows/run-and-results.md), [schemas](/en/reference/schemas.md), [errors](/en/reference/errors.md) and [limits](/en/concepts/limits.md). ### Quick Run input now reaches the work Before this release Quick Run never staged its `input`: the work read an empty `/workspace/input.json` and `GET .../content` reported `has_input=false`. Quick Run now stages the input exactly like a manual submit, so `$TEAMSYNC_INPUTS_FILE` holds the submitted JSON. Quick Run can therefore also answer 422 `input_not_stageable` / 503 `input_staging_unavailable` (after the manager check and idempotent replay, before any job or run is created). **Frontend action:** stop working around empty Quick Run input and handle the two staging errors as on manual submit. See [Quick Run](/en/flows/run-and-results.md) and [errors](/en/reference/errors.md). ### Provider-filtered region catalog and `onprem` `SandboxRegionCode` gains `onprem`, and the public region `tier` widens to `1..3` (3 = fixed/dedicated capacity billed at a nominal accounting rate). `GET /public/info/sandbox/regions` returns the catalog of the deployment’s provider: the hosted cloud never lists `onprem`; an on-prem deployment lists only `onprem`. `selectable_regions` is the active operator-row set used by tenant settings; a correctly bootstrapped on-prem deployment has only `onprem`, but extra operator-created tier-1/2 rows can appear there while the public catalog and build gate remain provider-filtered. `POST .../versions/{vid}/builds` refuses a `build_region` the deployment does not publish with 422 `build_region_unavailable` (on-prem also when region rows are active but the requested one is not). An omitted `build_region` defaults to `onprem` on an on-prem deployment and stays `asia-east1` on the cloud. **Frontend action:** keep region choices catalog-driven, accept `tier=3`, and do not hard-code `asia-east1` in create-build requests. The body-less `retry-build` endpoint is still backend-fixed to `asia-east1` at this pin. See [environment and build](/en/flows/environment-and-build.md), [endpoints](/en/reference/endpoints.md), [enums](/en/reference/enums.md) and [runtime planning](/en/concepts/integration-contract.md). ### On-prem deployments A self-hosted box runs the same tenant API with a Docker executor instead of Cloud Run. Integrator-visible differences: a single `onprem` region with nominal tier-3 pricing; per-company and fleet queue caps (`queue_full`); provider-neutral `next_action` wording for submit/start states (“the local runtime”, while capacity uses “the on-prem sandbox”); the cloud runner’s `error_code` / `failure_stage` vocabulary with timeout `exit_code=124` and cancel `130`; other image `ENV` values no longer leak into the work shell; runtime egress additionally needs the box’s `SANDBOX_ONPREM_EGRESS=internet`; and calls from tenant work back into the box’s own API need the operator’s API-origin allowance and trust store. An Agent submit refused with either 429 surfaces as the tool error `provider_unavailable`. **Frontend action:** build the UI from the catalogs and `GET /settings` rather than cloud assumptions, and show `next_action` verbatim. See [cloud and on-prem deployments](/en/concepts/integration-contract.md), [operators](/en/concepts/operators.md) and [agent tools](/en/reference/agent-tools.md). ### Runner: nested process namespace and a metering fix This runner section is hosted-cloud-specific; on-prem uses the Docker worker’s direct shell path. For hosted-cloud runs with runtime egress enabled (the default), runner source at this pin attempts to start the work shell as PID 1 of a nested user+PID namespace; where the host permits the private `/proc` setup it has its own `/proc`, and if namespace setup is unavailable the runner keeps its direct-shell fallback. Egress-off runs keep the direct shell. A short-lived orphan process (for example the `ssl_client` helper of BusyBox `wget https://…`) no longer gets the whole run SIGKILLed with `exit_code=137` when that nested setup is active. Inside the namespace `id -u` is `0` where the host permits the id map and otherwise the overflow uid `65534` with no capabilities; `/workspace` and `$TEAMSYNC_OUTPUT_FILE` stay writable. The runner keeps its own record under `/workspace/.teamsync/`. A separate fix stops a run whose work exited 0 from settling `platform_failed` / `metering_failed` / stage `metering_wait` / `exit_code=130` during network-interface teardown. Runner changes reach an environment version when it is built by a runner release that contains them; the version’s `runner_release` records which release it has. **Frontend action:** none required; do not special-case `exit_code=137` after background processes. See [runtime facts](/en/flows/tasks-and-bindings.md) and [local reproduction](/en/flows/local-debug.md). ### Failure hints and retention `failure_hint` for `secret_env_name_collision` now states the actual rule (an ordinary env var has the same name as a bound secret slot), and `secret_env_value_collision` gets its own hint (an ordinary env value equals a bound secret value). Archive reclaim now keeps a tenant build’s execution package while a non-archived curated version still pins its digest. See [run and results](/en/flows/run-and-results.md) and [retention](/en/concepts/retention.md). ### Operator surface (context only) `GET /root/sandbox/queue` lists waiting work and executor capacity in the same shape on every deployment. Registering a tier-3 region is refused unless the code is `onprem` on an on-prem deployment. The operator route table now lists all 27 root routes, including the previously unlisted `GET`/`PUT /companies/{company_id}/policy`. See [operators](/en/concepts/operators.md). ### Pins and exports The README, both homepages, the runtime-planning page and the schema page now name v5.10.11 and `70862ceae`. The “How to refresh” command below starts from this SHA and now also covers the operator and control-plane routers, the on-prem executor and controller, `sandbox/onprem/`, all of `infrastructure/sandbox/`, and the other backend paths these pages cite. `llms.txt`, `llms-full.txt` and the Markdown twins are regenerated from these pages. ### Audit note — commit classification Every commit in the range was classified. The 29 contract-changing commits are listed first; the remaining commits changed no documented or integrator-visible contract: - **Documented/integrator-visible contract changes (covered by the sections above; 29 commits):** `3edebc843`, `3dd307ba0`, `fa423ad8c`, `03b3eca5f`, `1d806d00b`, `a08bd498e`, `ee8a6de1b`, `aecbe16d6`, `fe074dfe0`, `6d42c0d3a`, `0e8a4b584`, `c9b5894fe`, `4687c6553`, `f916c29fe`, `a3f50f512`, `ec39191f0`, `3205659d2`, `c6c55f12f`, `ee54b9584`, `dfd96f0ca`, `5c69c5917`, `d5edcffc5`, `d384d06a5`, `dd005d9fe`, `33eebf13d`, `e157b4093`, `e882217cc`, `eee7aff4b`, `a78a0c47a`. - **Merge commits with no additional documented contract change beyond their merged commits:** `bf5968970`, `6f7718d4b`, `54c2c1012`, `d331534b1`, `e773750a4`, `f93f24597`, `679147a5f`, `d9aac6f71`, `d615306d9`, `a70e4fe8f`, `37f3fd1f3`, `ae8783293`. - **On-prem provider internals** (work leases, scheduler, sweepers, executor, build gates, registry, dind packaging; operator context only): `6491285ef`, `bcee513d9`, `90c7cb8f1`, `acd18b7c1`, `542defb74`, `f232d0852`, `ec3d10407`, `50f1373f8`, `b7e54b141`, `b9909ad18`, `ec9dedd7a`, `2b31cd051`, `810484db2`, `d12a69949`, `761092f43`, `bd8d951a2`, `56e695fad`, `6c435f033`, `5741e685d`, `bf9f8ce37`, `d0d872302`, `3e8f8d4fc`, `c8387f7bf`, `f3e3388ae`, `9a62fb64b`, `fb75f449a`, `9f9a299f4`, `cf8cd1ff0`, `51f334f33`, `af83f77df`, `302fd9542`, `c2cc9ec74`, `c2d75c8a3`, `7867f3971`, `8047773ee`, `0119b6a50`, `fa6a3dcfd`, `beb9996a4`, `14ba3ab85`, `b8f16252a`, `aa0e1f609`, `5faa8bf52`, `410b1f53b`, `1fe95018c`, `24ab14393`, `76b048f28`, `6e2f05e2b`, `87fb335e3`, `8a2c6f337`, `30c27f6d3`, `a78b86674`, `bf42d7e23`, `d7ed040ae`, `5ffa0eaa4`, `50c52d071`, `c3a495845`, `e3ed8437f`, `8197701e2`, `3000ac16e`, `e7f7d9f45`, `fbdd515b1`, `ff9a4ca3d`, `28b6b3312`, `2a7da3852`, `8daf2a81a`, `9c688487f`, `08c3fcf77`, `6c40e71d5`, `3b5780264`, `cece7e91b`, `da366aaca`, `73c3455e7`, `3917dc89b`, `7c0bd4de6`, `abbeb5bad`, `e140b4c42`, `dc05cd786`, `bbe709b1a`, `93951d7f1`, `fa4c6b5f5`, `c53f60d98`, `8fa0aec70`, `28e9fdd5e`. - **Shared refactors, performance and tests** (cloud responses byte-identical): `ecfeeca63`, `8cb94350e`, `f58826b93`, `f8795d46f`, `04d436164`, `c5cbb1011`, `275c43f31`, `d78342af0`, `106469961`, `543ad2b73`, `d926fd9f1`, `37b70caf3`, `e8a0f7327`, `0ea91eaf5`, `b0602e136`, `b14239834`, `bb043e755`, `544737775`, `dac6cd145`, `44f0b9d4a`, `c51dc3406`. - **Runner subreaper attempt, replaced in the same range by the nested namespace:** `5d1acdf93`, `9847dc302`. - **Other modules that share an audited path** (`src/schemas/enums.py` non-sandbox enums, `infrastructure/sandbox/gcp/**/agent-workspace`, the private module mount in `src/routers/private/modules/server.py`): `bbe49538b`, `ebf8c6d6e`, `c9c805528`, `d911258d8`, `cbae44462`, `6501074b0`, `b86990f66`, `3320fcacd`, `89677a14c`, `1206ffea9`, `68d62b320`, `f30db1557`, `50ff40109`, `79722e274`, `9819680b3`, `22a4e375f`, `8f3fd1757`, `ff1fdfa7b`, `3e238babc`, `037b51362`, `e1a955a22`, `a9d6127b6`, `71dc5749c`, `643bb249a`, `55998aecc`, `3cccd926a`, `7877bb6fd`, `c7c5f9fc2`, `90ad65f5d`, `e7b251ecc`, `27f9fe4be`, `49658aa62`, `92934f0c9`, `4aa4aea74`, `bfab0b7dc`, `e2f7d67d2`, `ae2254aa6`, `3e6e6e0e6`, `293488605`, `8df1ababe`, `b5ab0d2db`, `3916a119e`, `d38cca5a9`, `145c7f438`, `7ba15c58e`, `3a79fb629`, `4e4ecec5e`, `2649934fe`, `a9ecda8fb`, `2f7f9a7b6`, `c2a9d7dbe`, `f3f70735e`, `b8c8fe2b8`, `1b295eb93`, `a396ac72f`. ## 2026-09-10 — production v5.10.0 **Source and release:** backend `9b8b95e598a7b1f606c030d8dd0f27f26c748c83` is the v5.10.0 version commit; application sources match tested `9393591ae`. [Production release](https://github.com/ShuChenAI/teamsync-backend/releases/tag/v5.10.0) and [deployment readback](https://github.com/ShuChenAI/teamsync-backend/actions/runs/34374369505) are complete. Dates on this page use Asia/Taipei. The changes below describe the current contract; older entries retain their original rollout scope. ### Environment ownership and pinning Environment authoring is no longer company-manager-only. `POST /environments` accepts `owner_scope=company|department` and `owner_id`; a department manager must explicitly select their department because the default scope is company. PATCH, context upload, version/build operations and archive follow the environment’s owner scope. Both ownership kinds share the company’s active-environment cap. **Frontend action:** preserve `owner_kind` and the separate `owner_scope`/`owner_id`, and show controls from current authority. A department-owned environment can be pinned by a same-department task or a company-owned task, but not another department’s task or Quick Run. Quick Run needs company-owned or curated environments. See [environment creation](/en/flows/environment-and-build.md) and [the permission matrix](/en/concepts/personas.md). ### Capabilities and source visibility `GET /me` provides current management capabilities and creatable owner scopes. `manageable_chatroom_scope=company` with an empty room-id list means all current/future rooms. Owner catalogs (`GET /tasks`, detail and versions) now respect live audience grants; company membership alone is not catalog visibility. **Frontend action:** use the required `source_visible` boolean before offering source editing. False means source is withheld; an owner view still needs `content_state` to distinguish scrubbed content. The room menu always returns the borrower view, including for owners. See [capabilities and discovery](/en/concepts/integration-contract.md). ### Cross-room history and management lists `GET /runs` adds visibility-filtered cross-chatroom history with repeatable room/task/status filters, `submitted_by_me`, queued-time bounds, `total` and keyset pagination. The task/room binding, task secret, task approval and consumer secret lists expose the metadata needed to manage existing objects; consumer envelopes include `consumer_name`. **Frontend action:** echo `/runs` cursors and trust its scoped total. Management lists use a 2000-row cap with `truncated`, while capability id lists are bounded at 5000. Binding history defaults to active; request retired/all explicitly. Hide unavailable job identity when `task_visible=false`. Secret/approval lists remain narrowed to consumer scopes the caller manages and never expose secret values. See [management discovery](/en/concepts/integration-contract.md). ### Sharing, overlapping grants and Enable consent Department-owned tasks can be shared directly to any live same-company room; the older “share the department first” workaround is obsolete. Grant resolution prioritizes room, department, then company. Equivalent remaining coverage can preserve a binding; a changed credential-source policy is not silently accepted. Existing runs remain tied to their frozen grant/generation. **Frontend action:** keep Share and Enable separate. Enable, explicit binding creation and accept-version can record exact-version consent for resolved borrower-secret scopes the acting manager controls. Missing slots or other managers’ scopes still require their configuration/approval; GET, publish, share and ordinary run do not approve. Version acceptance preserves the accepted policies. See [job audience](/en/concepts/job-audience.md) and [secret consent](/en/concepts/secrets.md) (backend #1260/#1262). ### Faithful retry and useful failure state `POST .../runs/{run_id}/retry` now replays retained source input byte-for-byte when the body/input is omitted or null. Non-null `input` supplies new parameters on the same pinned version with a new run identity. Missing, retired, unreadable or mismatched retained input returns 409 `retry_input_unavailable` instead of an empty payload. **Frontend action:** use a new Idempotency-Key for a new retry intent and provide explicit input when retention prevents replay. Render typed wait/failure fields and preserved provider-refusal provenance; check stored content and actual downloads separately from the work exit code. Restricted-key retry on an allowed room is 405, whereas management/Quick Run can fail at the first auth gate with 403. See [run/retry](/en/flows/run-and-results.md) and [errors](/en/reference/errors.md). ### Secret validation and authoring metadata Create/rotate now enforce a minimum of **4 UTF-8 bytes** after decryption, with 422 `detail[].type=sandbox_value_too_short`. Explicit approval validates a published version, matching digest, `risk_accepted=true`, and nonempty normalized valid slot names; exact borrower-subset matching is still enforced at claim. **Frontend action:** validate byte length rather than character count and distinguish short values from envelope/decryption errors. New slot declarations use named objects and preserve optional metadata; read-side legacy declarations remain supported. Refresh generated types rather than flattening a declaration into an untyped map. See [secrets](/en/concepts/secrets.md) and [schemas](/en/reference/schemas.md). ### Runtime planning and pricing Company settings expose live `selectable_regions`, nullable typed `pricing`, and the read-only operator egress policy. Region-readiness maps preserve live region codes. Price data includes already-marked-up profile rates, minimum billable/finalization time, and network reservation bounds. **Frontend action:** populate regions from the authenticated current settings, retain saved selections separately, and use the documented upper-bound estimate without adding markup again. Submission rechecks prices/policy/budget. Runtime placement is not a data-residency guarantee. See [runtime planning](/en/concepts/integration-contract.md). ### Handbook and agent exports The English and Traditional Chinese guides, endpoint/schema tables, homepage pin and permission matrix were synchronized together. Obsolete current statements about lost retry input, null-only menu cursors, result-upload initiation limits and company-only environment management were corrected. `llms.txt`, `llms-full.txt` and localized Markdown twins are regenerated from the same pages. Validation covers typecheck, static export and all internal links; source/production readback and historical live E2E evidence remain explicitly scoped. ## 2026-09-05 (d) — runner release approvals + archived-version reclaim (backend #1162, SBW-08) - **Runner release approvals** (backend #1162): the task-bundle runner allowlist moved from an env var into an operator table read on every request. `GET/POST /root/sandbox/runner-releases`, `DELETE /root/sandbox/runner-releases/{sha}`; version response gains `runner_release_approved` + `runner_release_next_action`; `409 task_bundle_runner_unsupported` documented. The stage-image release approves its own commit, so new runner releases no longer strand fresh environment builds. - **Archived-version reclaim** (SBW-08): archiving an environment version now reclaims its regional Cloud Run Jobs, quarantine/execution image packages, attestations and build reports within minutes; orphaned Jobs/packages are swept too. Archiving is terminal. - Build lane: an attempt that leaves the shared release Build early (cancel, deadline, failed step) no longer wedges in `cleanup_pending`. ## 2026-09-04 (c) — environment builds ≈ 2× faster (backend #1154) - The six verification gates (static scan ∥ smoke → sign → attest → promote → final verify) now run as steps of ONE Cloud Build on an `E2_HIGHCPU_8` worker, after the customer build. Slim images reach `ready` in ~5–6 min (was ~12), heavy ones ~7 min (was ~19). Progress fields unchanged; `build_expected_seconds` ≈ 360. ## 2026-09-04 (b) — task management + artifact files (backend #1151, runner #1152) - `PATCH /tasks/{id}` (name / description / agent_enabled) and `POST /tasks/{id}/archive` (whole-task archive: versions archived, bindings revoked, queued runs cancelled, share grants kept). - `GET /environments/{id}/context-uploads`: read-only list of completed build-context uploads with `used_by_version_ids`. - Jobs may emit files: everything under `$TEAMSYNC_ARTIFACTS_DIR` (`/workspace/artifacts`) becomes the run's artifact bundle — listed at `GET .../artifacts`, shareable per file with `POST .../artifacts/{artifact_id}/public-link`. Whole-bundle refusal rules in the runtime contract (`artifacts`). - Context-uploads list follow-up (#1156): uploads already consumed by a version are listed and linked via `used_by_version_ids`. ## 2026-09-04 — FE follow-ups (backend #1149) - `page_token` accepted on `GET /chatrooms/{id}/runs` (cursor `(queued_at, id)`, newest first), `GET .../menu` (by `task_id`) and `GET .../runs/{rid}/artifacts` (by `relative_path`); `next_page_token` is now a real cursor on all three. - `POST /bindings/{id}/accept-version` recomputes `update_available` instead of writing `false`. - `SandboxTaskVersionResponse` adds `environment_id` / `environment_name`. - `SandboxBuildAttemptResponse` adds `retryable_until` (7-day retry window while the version is `retryable_failed`). - Notification producers for `build_ready` / `build_failed` / `build_cancelled` (environment owner-scope + company managers, zh-TW copy) and the new `curated_environment_published` event (companies pinning that environment). - Per-file artifact public links: `POST`/`DELETE .../artifacts/{artifact_id}/public-link`; `GET /public/sandbox/artifacts/{token}` streams that file's digest-verified byte range. - Handbook self-contradictions fixed (runtime network, `script-uploads`) — see the 09-03 entry below. ## 2026-09-01 — FE-question fixes + full docs↔implementation audit vs previous pin `7011b318d` The frontend's 2026-08-31 sandbox question list triggered this round: first the OpenAPI contract fixes (backend PR #1100, pending merge — `SandboxRuntimeContract` gains `working_directory` / `network`, and the `work_command` description's bogus `python3 work.py` example becomes `python3 /workspace/work.sh`), then a full docs↔implementation audit across all 21 pages: **133 adversarially-verified corrections applied** (64 missing facts, 50 contradictions, 17 stale, 2 locale divergences). The highest-impact ones: - **Auth**: missing credentials → **418** (`Could not validate credentials`), not 401; restricted keys on `/tasks` paths → auth-layer **403**, not 404. - **Runtime contract**: adds `working_directory` (nominally `/workspace` but unspecified — always absolute paths) and `network` (no runtime egress; build-stage `RUN` reaches the public internet). - **Build**: a lone `source_digest` is **never** matched against a completed object (the failure surfaces only at build time); the Dockerfile must be a regular file at the archive **root**; **any** `# syntax=` line is refused (not just remote ones); unpack caps 5 GiB / 100k entries; `retry-build` takes no body and does not reuse region/profile; 503 `build_admission_fenced`; a completed context not yet adopted by a version keeps holding its staging slot. - **Runs**: retry does **not** replay input (the new run gets an empty `{}` while `input_digest` mirrors the source — resubmit `POST /runs` to replay); artifact downloads return a 5-minute capability token. New page: [Local reproduction and debugging](/en/flows/local-debug.md) — a local docker harness reproducing the execution contract (fixed filenames, the same sh session, the two env vars), the Node `node_modules` trap, and a harness-vs-sandbox difference table. ## 2026-08-31 — job contract, shared-secret slot policies, permanent public links, transit encryption vs previous pin `e94b54c3d` Master landed five tenant-facing feature waves and one large build-lane reliability wave since the previous pin (PRs #1033–#1087, 41 sandbox-scoped PRs total — see the full list below). These sentences from the `e94b54c3` handbook are now false: - The Dockerfile in a build context must `FROM` one of `alpine:3.20` / `busybox:1.36` / `debian:bookworm-slim`; `scratch` is refused at compose. - A CVE in the base image (e.g. Alpine:3.20) can still fail scan with `error_code=vulnerability_reject`. - There is no way to declare how a work script is invoked, what JSON input shape it expects, or to test that shape before submitting a run. - A share grant has no secret-management story beyond the borrower providing their own value. - A run's log/output can only ever be fetched by an authenticated tenant through the 5-minute artifact-download capability. - `POST /secrets` / `POST /secrets/rotate` accept a plaintext `value`. Canonical replacements: [Tasks, publish, and bind](/en/flows/tasks-and-bindings.md) (job contract), [Secrets and approvals](/en/concepts/secrets.md) (slot policies + transit encryption), [Content, digests, and downloads](/en/concepts/content-and-downloads.md) (public links), [Create an environment and build](/en/flows/environment-and-build.md) (base images + CVE stance), [Job audience](/en/concepts/job-audience.md) (share audience fail-fast). ### Job contract (PR #1065) - **#1065** — `work_command` (free-form driver line, materialised into `/workspace/driver.sh` and sourced after `startup.sh`; blank → `. /workspace/work.sh`, byte-identical to the old behavior), `input_schema` (JSON Schema draft 2020-12; a submitted `input` that violates it is 422 `input_schema_violation` **before dispatch**; `input_example` must satisfy it at this draft create/update call, else 422 `job_contract_invalid` — publish never re-validates), a `runtime_contract` block (fixed invocation shape + `TEAMSYNC_INPUTS_FILE` / `TEAMSYNC_OUTPUT_FILE` env names) surfaced on the room menu (`SandboxMenuItemResponse`, always the redacted fallback there regardless of caller), the version detail response (real `work_command` for an owner), and the input-preview response, a multipart `POST .../versions/{vid}/script-file` upload route (owner-manager, draft-only), and a `POST .../versions/{vid}/input-preview` dry-run endpoint (exact bytes + digest + schema verdict, dispatches nothing — callable by **any** non-restricted company member on a draft or published version, not owner-manager-gated). `work_command` is borrower-forbidden (redacted to the fallback, and always redacted on the menu even for the owner); `input_schema` is borrower-visible. Agent-tool menu items carry `input_schema` and `secret_slots` but **not** `runtime_contract`. ### Dispatch outage chain (PRs #1066–#1072) - **#1066** — `sandbox_cloud_run_quota_leases.weight` widened `INT`→`BIGINT`. Memory-dimension admission weights are byte counts (a 2 GiB standard-profile lease overflows signed INT); every dispatch had wedged in `starting` since ~08-26. - **#1067** — Granted the `sandbox_job_executor` custom IAM role `run.jobs.runWithOverrides` — distinct from `run.jobs.run`, and required by every env-override dispatch; every live dispatch had 403'd `provider_forbidden`. - **#1068** — **Network egress from a running job is now disabled, unconditionally.** The Cloud Run Sandboxes launcher's `--allow-egress` creates a veth pair that needs root; the runner refuses to run as root by design. There is no per-task opt-in yet. - **#1069** — REST manual-run submit now stages the input JSON to the owned-object store before creating the run. It never had — every REST-submitted run's work script had silently seen an empty input. - **#1070** — Fixed a self-deadlock #1069 introduced: staging now happens *before* the company-policy row lock, not under it. - **#1071** — Manual-run input-staging refusals now log the full exception chain and name the underlying cause in the 422 response body. - **#1072** — Manual-run input staging now keys its owned object on a deterministic `uuid5` of the scoped idempotency key, not the raw (unbounded) key, which had been overflowing `SandboxOwnedObject.subject_id` (`String(36)`). ### Permanent public links + shared-secret management (PRs #1074–#1081) - **#1074** — **Permanent public result links**: `POST`/`DELETE .../chatrooms/{id}/runs/{run_id}/{log|output}/public-link` mints/revokes a no-TTL, unauthenticated `GET /public/sandbox/artifacts/{token}` download for a run's log or output. - **#1075** — Fixed a `NameError` (`_reject_restricted` lived in the wrong router module) that 500'd every mint/revoke call on staging. - **#1076** — **Shared-secret slot policies**: a share grant may set `secret_slot_policies` (`{slot_name: "borrower"|"owner"|"owner_overridable"}`), creation-only — re-share to change it. - **#1077** — **Secret transit encryption**: `POST /secrets` and `POST /secrets/rotate` accept **only** a hybrid AES-256-GCM + RSA-OAEP-SHA256 `encrypted_value` envelope (`{encrypted_key, iv, ciphertext}`, all base64, key from `GET /public/info/model_key/public_key`). Plaintext `value` is rejected unconditionally — four distinguishable 422 slugs (`sandbox_plaintext_value_rejected` / `sandbox_encrypted_value_required` / `sandbox_transit_keypair_missing` / `sandbox_encrypted_value_undecryptable`). - **#1078** — Corrected the `GET /public/info/model_key/public_key` description, which still claimed plaintext "remains accepted as a fallback." - **#1079** — Sharing a department-owned task directly to a chatroom outside its audience is now fail-fast **409 `share_target_not_in_audience`** at share-create, not a grant that quietly never applies. - **#1080** — Provider secret paths are namespaced per task (`.../tasks/{task_id}` segment) — two different tasks sharing a consumer scope and slot name no longer race on an identical un-segmented path. - **#1081** — Manifest reveal re-checks share-grant liveness for an owner-lent secret pin. A grant revoked between claim and a later manifest read is now denied (control-plane `share_grant_revoked`, 409) instead of still resolving the stale pin. ### Build-lane reliability (PRs #1033–#1055) A 20-PR chain, all found live between 2026-08-21 and 2026-08-25, that reopened the tenant custom-build happy path and cut rollout/diagnosis time. The two tenant-visible outcomes are called out above (base-image allowlist removed, CVE gate removed); the rest is operator/CI reliability. - **#1033** — Unblocked custom builds: the Dockerfile base allowlist had wedged every build since 08-21 (e.g. `node:20-alpine` rejected), with no reason written back — just a bare `wrapper_fault`. - **#1035** — Compose accepts Node-family images; an inert `/etc/mtab` symlink every base ships had been mis-flagged as a reserved-path escape. Every builder refusal now names itself. - **#1037** — Stage-image release moved from four hand-run steps to one CI job behind a real admission fence (the previous "fence" was an unenforced pasted string). - **#1038** — Hardened that CI fence per adversarial review: re-entrant hold refresh before every long wait (the TTL could previously expire mid-rollout and silently fall open), plus a rollback annotation fix. - **#1040** / **#1041** — Compose accepts any public base image outright (`node`, `golang`, `python-slim`, `debian`, `ubuntu`, `busybox`, `nginx` all verified live); the CI admission fence now runs as a Job. - **#1042** — CVE findings reclassified advisory-only — `HARD_FAILURE_SEVERITIES` is now empty and the OPA policy carries no severity rule, so no CVE can reject a build. The live scan stage now always reports a rejection as `policy_reject`; `vulnerability_reject` remains in the closed receipt set and the terminal-class matrix (not removed from the code) but is unreachable from the live lane. SBOM/vulnerability reports are still generated and attached. - **#1043** — Cut stage rollout from ~22 minutes to ~6 by skipping a 600s pod-grace wait once the DB already proves no work is in flight. - **#1044** — A stage that dies without a receipt now prints `stage_error gate= code=` instead of a silent bare exit 2. - **#1045** — Fixed the `attest` gate: Container Analysis v1 rejects a client-supplied occurrence id; every attest attempt had failed since it shipped. - **#1046** — Rollout now force-deletes drained controller pods instead of racing a shortened wait against the deployment's own 600s termination grace. - **#1047** — Fixed a leaked context-upload staging slot (`complete_upload` never released it) that wedged every tenant-publish E2E at `staging_slot_exhausted`. - **#1048** — Settled-lease staging-cap check rewritten as a correlated `EXISTS` (was an O(company's-lifetime-uploads) `IN` list). - **#1049** — Smoke gate no longer requires `bin/sh` (usrmerge distros like Debian/Ubuntu/Fedora only ship `usr/bin/sh`); regional Job creation now points at what promote actually writes. - **#1050** — A retrying `regional_readback` gate now records the real cause (e.g. `provider_forbidden`) instead of terminalizing under a code that only names the timer. - **#1051** — A deadline-triggered cancel now keeps the cause the gate already recorded instead of overwriting it with a bare cancel marker. - **#1052** — Buildkit scratch-directory teardown no longer fails an otherwise-completed build on an `EPERM` from Python's `TemporaryDirectory` cleanup. - **#1053** — Promote's receipt now reports real transfer byte counters, not just logical/manifest size. - **#1054** — Made the EPERM teardown fix durable (plain `mkdtemp` + best-effort `rmtree`); cross-package blob copies are now re-hashed and registered under the destination package instead of trusting a repo-scoped presence check. - **#1055** — Granted `sandbox-job-provisioner` Artifact Registry read on `sandbox-execution` — Cloud Run's create-time image-readability check had been failing closed for every tenant version. ### Control-plane reliability (PRs #1082–#1087) - **#1082** — Every control-plane refusal (claim / bootstrap / progress / manifest / …) now logs stage, subject, deny code, and status — previously only the bare HTTP status reached the access log. - **#1083** — Pinned the sandbox-controller pod against GKE Autopilot autoscaler eviction (`cluster-autoscaler.kubernetes.io/safe-to-evict: "false"`) — consolidation had been killing the single-replica controller every 5–7 minutes, silently stalling staged dispatches. - **#1085** — Sandbox-controller's KEDA scaler switched `gcp-pubsub` → `gcp-stackdriver` — the pubsub scaler is deprecated and its fixed lookback window returned empty for this low-traffic subscription. - **#1086** — A background reaper now recovers runs stranded in `starting`/`provider_submit_staged` when the controller dies mid-dispatch (re-drives the exact same exactly-once send path). Cancelling an unclaimed/racing run now reliably releases its queue **and** concurrency lease immediately instead of occasionally leaking it. - **#1087** — Capacity-keepalive refresh now fires ~60s before a snapshot goes stale instead of exactly at the staleness cutoff. Dispatch refusals and sweeper-thread crashes are now logged instead of silent, and the controller's own logs now actually reach stdout (previously they hit an unconfigured root logger and vanished). **Still not frontend-facing** - `/sandbox-control/*`, `/root/sandbox/*` request bodies. - Cloud Build tags, stage-image digests, CI admission-fence internals, GKE Deployment/ScaledObject YAML, KEDA trigger configuration. - Raw R2 keys / presigned URLs / secret values / writeback HMAC keys / provider secret paths. ## 2026-08-23 — live build lane vs previous pin `d99b4cad6` Master after #975 landed the live tenant compose/scan lane and the durable build-cancel path (through #1023). Tenant **route list and enums did not change**. These sentences from the #975 handbook are now false: - Cancelling a still-queued build attempt settles the attempt **immediately**. - Writeback injects `TEAMSYNC_CT_WRITEBACK_URL` into ordinary env (only the token is sealed). - Any public `FROM` image will compose. (`scratch` is not allowed; alpine:3.20 often fails scan.) **(Superseded 2026-08-25 — see the 2026-08-31 entry above.)** - `SANDBOX_ENABLED` is something the company must turn on before the module works. Canonical replacements: [Create an environment and build](/en/flows/environment-and-build.md), [Run it in five minutes](/en/get-started/quickstart.md). **Filled from the live lane (#1012–#1025 era, pin #1023)** - Tenant custom build is a real Cloud Build compose + scan. Dockerfile `FROM` must be one of `alpine:3.20`, `busybox:1.36`, `debian:bookworm-slim` (ECR public library tags). Other bases fail compose. Scan may still `customer_failed` with `error_code=vulnerability_reject` / `policy_reject`. **(Superseded 2026-08-25 — see the 2026-08-31 entry above.)** - `POST .../builds/{id}/cancel` always fences stage capabilities and writes a durable provider-cancel intent. Poll until `terminal_class=cancelled`. Queued cancel is **not** a same-request settle. - Writeback URL **and** token go through the sealed secret channel. Neither may enter `ordinary_env` (`TEAMSYNC_` is reserved). - `SANDBOX_ENABLED` defaults **true**. `false` is the kill switch (503 `sandbox_disabled`), not an enablement ticket. - Fast path: pin the live `system_curated` catalog named **SCFG Standard** (`state=ready` in every public region). Skip tenant upload/build unless you need a custom image. - Incomplete context uploads leak staging leases. Hitting 20 live slots 429s `staging_slot_exhausted` and also wedges scan-report writes. Abort unfinished sessions; do not spin-retry inits. **Still not frontend-facing** - Cloud Build tags, stage image digests, `/sandbox-control/*`, `/root/sandbox/*` request bodies. ## 2026-08-19 — PR #975 + #964 vs previous pin `076c1278` Previous handbook pin was merge of #956. Master then landed custom-table writeback (#964) and the department-job / upload / history surface (#975). **Do not keep the old story.** These sentences are now false: - Catalog jobs can be created with `owner_scope=chatroom`. - Version create requires `source_digest`; the frontend never uploads bytes. - Share puts a job on the Agent menu. - `output_policy` is opaque JSON / chatroom-owned-only writeback. - Staging 20/20GiB/5-per-minute is control-plane only. - There is no tenant 429 besides queued-run (upload 429s exist). - `task.agent_enabled` is the Agent switch. Canonical replacement: [Job audience](/en/concepts/job-audience.md). **Filled from #975** - Department/company create only; `chatroom` on `POST /tasks` is 422. Hidden Quick Run parents remain. - Share targets: whole company (future depts), any department, rooms in owning dept or already-shared depts. - Room `GET/POST .../granted-jobs.../enable|disable`. Menu + Agent = granted **and** enabled, same list for every caller. Manual REST still uses `can_execute_manually` (share + membership; enable not required). - Context upload: init → PUT `X-Sandbox-Upload-Capability` → complete → `owned_object_id`. Single-part, 1 GiB, 15-minute capability. - `GET /public/info/sandbox/regions` and `/profiles`. `xlarge` not tenant-selectable. - Company-manager `GET /tasks/{id}/runs` + `/numOfData` (`offset`/`limit`). - Owner-department writeback mints from rooms in the owning department even via share. Company-owned jobs cannot publish `output_policy`. **Filled from #964 (as it stands after #975)** - Typed `SandboxOutputPolicy`. Runtime injects `TEAMSYNC_CT_WRITEBACK_URL` / `TEAMSYNC_CT_WRITEBACK_TOKEN` through the **sealed secret channel** (not REST, not `ordinary_env`). - JWT user only; API key / restricted key / social client skip mint. - Trigger lane refuses `output_policy` versions. Agent still confirms. - 403 `output_policy_author_denied` / `writeback_authority_denied`; 409 `output_policy_owner_scope_unsupported` / `output_policy_unsatisfiable`; 503 `sandbox_writeback_key_unavailable`. **Still not frontend-facing** - `/sandbox-control/*`, `/root/sandbox/*` request bodies. - Raw R2 keys / presigned URLs / secret values / writeback HMAC keys. ## 2026-08-19 — earlier gap fill vs 2026-08-13 The 2026-08-13 handbook already had the tenant route list. That pass added schemas, agent tools, notifications, custom-table trigger, download TTL, cancel provenance, and removed several internal contradictions. Those pages remain; this pin rewrites the ones #975 made false. ## How to refresh this pin ```bash git -C ../teamsync-backend fetch origin master git -C ../teamsync-backend log -1 --format='%H %s %ci' origin/master git -C ../teamsync-backend diff 172a7f80bdf4dbdffdc018eb08bfa42c62bb485c..origin/master -- \ src/routers/private/modules/sandbox \ src/routers/private/modules/server.py \ src/routers/public/info/server.py \ src/routers/public/sandbox_artifacts.py \ src/routers/public/custom_tables_callback \ src/routers/root/sandbox.py \ src/routers/sandbox_control \ src/schemas/sandbox.py src/schemas/enums.py \ src/components/sandbox \ src/components/tools/custom/sandbox \ src/crud/sandbox \ src/crud/custom_table_triggers.py \ src/database/models/sandbox.py src/dependencies/sandbox.py \ 'src/tasks/sandbox_*.py' \ src/workers/sandbox_onprem src/workers/sandbox_controller.py \ sandbox/runner sandbox/build sandbox/onprem sandbox/e2e-js \ docs/runbooks/sandbox-operations.md \ infrastructure/sandbox \ infrastructure/app/gcp/k8s/base/sandbox-controller-deployment.yaml \ infrastructure/app/gcp/k8s/base/sandbox-controller-scaledobject.yaml ``` Use `git log --oneline ..origin/master -- ` for the commit list to classify, and add the non-merge commits that touch any file with `sandbox` in its path (for example the on-prem install kit under `scripts/sandbox/` and `docker-compose.sandbox.yaml`) and the commits whose message mentions Sandbox (`git log -i --grep=sandbox`). `src/schemas/enums.py`, `src/crud/custom_table_triggers.py` and `infrastructure/sandbox/gcp/` also carry other modules (Custom Tables, Edge Workers, the agent workspace); classify those commits as outside this module rather than skipping them. ## 2026-09-03 — live verification against master `0203b987` Facts added from a live staging run (GCP logs + a 9-execution JS suite): - Job bundles: `POST /tasks/{id}/bundle-uploads` (zip / tar / tar.gz, ≤20 MiB), unpacked at `/workspace`; runner-reserved root names → 422 `bundle_path_forbidden`. - Runtime contract corrections: outbound network **is** available when `runtime_egress_enabled` (pip / npm / go get work at work time); only `/workspace` and `/tmp` are writable. ~~`PATH` is minimal; `python -m venv` fails~~ — superseded by #1145: the work shell inherits the image `PATH` (conventional fallback) and `venv` works on the official `python:*` images. - Run failure provenance on `SandboxRunDetailResponse`: `error_code`, `error_detail`, `exit_code` (exact, incl. launcher-collapsed exits), `failure_stage`, `failure_source`, metered bytes. - ~~Submit under the 5-per-minute upload budget → 422 `input_not_stageable`; first run of a new version 1–5 min~~ — superseded by #1140/#1145/#1146: run `input` no longer counts against the budget (bursts are accepted as `queued`) and the first run reaches `running` in ~10–35 s. Environment build is still ~11–19 min (seven sequential verification stages). - ~~Known limitation: runner result uploads can be dropped under that budget (`completed` + `has_output=false`)~~ — fixed in #1140 (result uploads are exempt) and #1145 (a lost output settles `platform_failed` / `result_upload_failed`, never `completed`). - Reference implementation: `sandbox/e2e-js/` in `teamsync-backend`.