cc-libs/.plans/archived/trap-sandbox/trap-sandbox-gateway-spec.md
2026-06-15 19:59:26 +02:00

9.1 KiB

Spec: trap-sandbox gateway + trapos-sandbox client (first iteration)

Status: SETTLED — do not revisit the Decision log. The decisions below were hardened through a full design interview and are frozen input for every sub-plan. If a sub-plan surfaces a reason to reopen one, stop and raise it here first; do not silently deviate.

This is the shared contract. Sub-plans implement against it. The cross-cutting pieces (Context, Decision log, Protocol, Out-of-scope, Known limitations) live here and are not duplicated into sub-plans — sub-plans link back to this file.

Context

tools/trap-sandbox/ is a new TypeScript service that replaces the legacy tools/mcp-bridge (kept only for inspiration). It delivers a general-purpose Fastify API on port 4444, organized into "sandbox modules" (Fastify plugins). The only module in this iteration is the trapos-cloud-gateway: a WebSocket gateway giving a CraftOS (trapos) computer and the sandbox/opencode a generic, bi-directional request/reply protocol.

On the Lua side, a new trapos-sandbox package installs servers/sandbox.lua (a boot daemon that owns one persistent WS connection and bridges WS frames to in-OS events) plus a sandbox command. The legacy trapos-sandbox package is renamed to trapos-sandbox-legacy.

Intended outcome: after installing the new trapos and setting sandbox.url, running sandbox health in-game prints sandboxOk: true / opencodeOk: true|false.

This plan was hardened through a full design interview (see Decision log). Notably, auth moved from Basic-Auth-at-upgrade to an in-protocol hello handshake, which also prepares a future "one account owns many computers, account stored server-side" model without building it yet.

Milestones & todolist

High-level only — each sub-plan owns its granular steps. Tick a milestone when its sub-plan's verification gate is green.

  • M1 — TS gateway (01-ts-gateway): service builds, listens on 4444, TS unit tests green (npm test, npm run lint/check clean). No Lua dependency — build first.
  • M2 — Lua client (02-lua-client): libsandbox/servers/programs in place, Lua unit tests green via just _craftos-test. Implements the Protocol contract.
  • M3 — Packages (03-packages): legacy renamed, new trapos-sandbox package + meta bump wired; packages/index.json consistent.
  • M4 — Integration & tooling (04-integration-tooling): just recipes, docs/public-ports.md, TS integration harness + full-boot e2e green. Requires both sides — build last.

Dependency order: M1 → M2 → M3 → M4. M3 may proceed in parallel with M2 (mechanical).

Sub-plans index

# Sub-plan Scope
01 TS gateway tools/trap-sandbox/ service, registry, protocol, opencode probe, TS unit tests
02 Lua client apis/libsandbox.lua, servers/sandbox.lua, programs/sandbox.lua, Lua unit tests
03 Packages legacy rename, new package, index.json, meta bump
04 Integration & tooling just/npm.just, sandbox-serve, docs, TS integration + full-boot e2e

Decision log (grill outcomes)

Topic Decision
Event shape Explicit event field: `computer_request
Bidi scope All 4 events now; reverse direction (gateway_requestcomputer_response) exercised by tests
Duplicate trapos id Replace existing (newest-wins) — deliberate deviation from INIT's "reject the duplicate"
Identity Trust the frame — gateway uses the client-declared traposId; no connection↔frame cross-validation
Auth In-protocol hello handshake — secret in payload; supersedes Basic-Auth-at-upgrade + ?id=&label= query and the "header support" risk
Auth reject Terminal: daemon stops reconnecting on unauthorized, stores lastError; network drops still reconnect
Error model ok + error: { code, message } with a stable code enum
Health disconnected Fail fast with clear error; ~10s command timeout; ~5s opencode probe bound
Opencode wiring OPENCODE_SERVER_PASSWORD + default OPENCODE_URL=http://127.0.0.1:4242, Basic Auth (opencode link only)
Health payload { sandboxOk, opencodeOk, opencodeDetail? } (detail shown only on failure)
Reverse scope (Lua) Built-in ping only; no extensibility fan-out/response path in v1
Future account seam accountId constant "local" via resolveAccount(secret); registry keyed by (accountId, traposId); structured error codes
Host tooling Full wiring: just sandbox-serve, npm.just/install/ci, docs/public-ports.md row for 4444
Meta package Add trapos-sandbox to trapos meta + bump version (daemon dormant until sandbox.url set)
Versions Fastify ^5 + @fastify/websocket ^11; engines.node >=20 (Node 24 in use)
Tests 3 layers (Lua-unit, TS-unit, TS-integration) + one full-boot e2e

The "basic password mechanism" from INIT is preserved in spirit (a shared secret = SANDBOX_PASSWORD, mirroring OPENCODE_SERVER_PASSWORD) but delivered in-band via the hello frame, not as a Basic-Auth header. The opencode probe still uses Basic Auth to the opencode server (unchanged).

Protocol

JSON frames. Computer-originated carry traposSenderId; gateway-originated carry traposReceiverId. traposId = "<computerId>:<label>" (label may be empty → "7:").

// computer -> gateway
{ event:"computer_request",  traposSenderId,   messageId, type, payload }
{ event:"computer_response", traposSenderId,   messageId, type, ok, payload?, error? }
// gateway -> computer
{ event:"gateway_request",   traposReceiverId, messageId, type, payload }
{ event:"gateway_response",  traposReceiverId, messageId, type, ok, payload?, error? }
  • messageId is minted by the initiator; the reply echoes it. Gateway uses randomUUID(); Lua mints a v4 UUID as well (amended during the 02-lua-client grill — supersedes the earlier "<os.epoch('utc')>-<seq>" format; no _G counter needed). messageId is opaque to the gateway (echoed, never parsed). Each side keys its own pending map by messageId.
  • Error model: responses carry ok:boolean and, when ok:false, error:{ code, message }. v1 codes: unauthorized | not_authenticated | unknown_type | not_connected | internal | timeout.
  • Empty-payload gotcha: CraftOS textutils.serializeJSON({}) emits "[]". The TS parser treats a missing / array / non-object payload as {}; the Lua side may omit payload when empty and onMessage tolerates the same. Covered by a unit test.
  • Malformed/unknown frames are ignored (no crash). Each side ignores events it shouldn't receive.

Handshake (hello) — auth + registration + account seam

  1. On socket open, the daemon sends computer_request type:"hello" payload:{ traposId, secret }.
  2. Gateway validates secret against SANDBOX_PASSWORD (unset ⇒ auth disabled, any secret OK), runs resolveAccount(secret) → "local", registers (accountId, traposId) (newest-wins), stores {accountId, traposId} on the socket, and replies gateway_response type:"hello" ok:true payload:{ accountId }.
  3. On bad secret → ok:false error:{code:"unauthorized"} then close.
  4. Any non-hello frame before a successful hello → ok:false error:{code:"not_authenticated"}.

Concrete types

  • probe_gateway (computer→gateway): computer_request type:"probe_gateway"gateway_response type:"probe_gateway" ok:true payload:{ sandboxOk:true, opencodeOk:boolean, opencodeDetail?:string }. sandboxOk is trivially true; opencodeOk from the probe; opencodeDetail (url + status/error) only when false. (The in-game command is still sandbox health; only the wire type is probe_gateway. Renamed from health during the 01-ts-gateway grill.)
  • ping (gateway→computer, reverse-direction example/test): gateway_request type:"ping"computer_response type:"ping" ok:true payload:{ traposId }.

os-event contract (Lua, all trapos_sandbox_-prefixed)

trapos_sandbox_send(messageId,type,payload), trapos_sandbox_reply(messageId,ok,payload,error), trapos_sandbox_connected(traposId).

Out of scope / future

  • The account model itself (multi-computer-per-account). Seam only: resolveAccount, (accountId, traposId) keying, structured errors, hello as the credential→account home.
  • Full opencode integration beyond the health probe; rewriting ai.lua; cross-module wiring.

Known limitations (v1, documented)

  • traposId = id:label is not unique across Minecraft servers/worlds (computer ids reset per world; labels may be empty). With newest-wins, a cross-server collision would evict the other computer. Accepted per INIT ("id+label for now"); the future account model is the real fix.
  • No rate-limiting / per-frame size policing beyond maxPayload; no reconnect backoff (fixed 5s); trusted-LAN assumptions.