cc-libs/.plans/trap-sandbox-gateway-plan.md
2026-06-14 02:31:39 +02:00

14 KiB
Raw Blame History

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

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.

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 uses "<os.epoch('utc')>-<_G seq>" (a process-global monotonic seq so concurrent commands never collide). 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

  • health (computer→gateway): computer_request type:"health"gateway_response type:"health" ok:true payload:{ sandboxOk:true, opencodeOk:boolean, opencodeDetail?:string }. sandboxOk is trivially true; opencodeOk from the probe; opencodeDetail (url + status/error) only when false.
  • ping (gateway→computer, reverse-direction example/test): gateway_request type:"ping"computer_response type:"ping" ok:true payload:{ traposId }.

TypeScript side — tools/trap-sandbox/

ESM, tsc build, tsx dev/test, node:test, typescript-eslint flat config (copy mcp-bridge's tsconfig.json/eslint.config.js). Fastify ^5 + @fastify/websocket ^11; engines.node>=20.

src/
├── index.ts        # env config; build app; listen on SANDBOX_PORT; clear EADDRINUSE message + exit
├── app.ts          # createApp(config): Fastify + GatewayRegistry; register @fastify/websocket + module; returns { app, registry }
├── auth.ts         # resolveAccount(secret): "local" | null  (constant seam; null = unauthorized)
├── opencode.ts     # createOpencodeProbe(config): probe(): { ok, detail } via @opencode-ai/sdk
└── modules/trapos-cloud-gateway/
    ├── index.ts    # Fastify plugin: GET /health (open) + GET /gateway (ws)
    ├── protocol.ts # envelope/error types + parse/validate (4 events, hello, empty-payload→{})
    └── registry.ts # GatewayRegistry keyed by (accountId,traposId); newest-wins; request()+timeout; pending cleanup on close
test/               # TS unit (protocol, registry, auth/hello, probe) with FakeSocket + fake fetch
test-integration/   # harness.ts (app on :0, startCraftos, fake-opencode http server) + lua/ + *.test.ts
  • WS /gateway ({ websocket: true }): no upgrade auth (auth is the hello frame). maxPayload ~1MB. socket.on("message") parses a frame inside try/catch → dispatch: pre-hello only hello is honored; post-hello handle computer_request (health, else unknown_type) and computer_response (resolve pending gateway_request by messageId). close/error → unregister
    • reject that connection's pendings.
  • GatewayRegistry: register/unregister/has/count; request(accountId, traposId, type, payload, timeoutMs=600000) sends gateway_request, races matching computer_response vs timeout.
  • opencode probe: createOpencodeClient({ baseUrl: OPENCODE_URL, headers:{ Authorization: basic ("opencode", OPENCODE_SERVER_PASSWORD) }, throwOnError:false }) then config.get() with AbortSignal.timeout(5000), wrapped in try/catch. ok = response 2xx; detail = url + status or error. No OPENCODE_URL{ ok:false, detail:"OPENCODE_URL not set" } without a call.
  • Env (index.ts): SANDBOX_HOST(0.0.0.0), SANDBOX_PORT(4444), SANDBOX_PASSWORD(unset⇒ auth off), SANDBOX_REQUEST_TIMEOUT_MS(600000), OPENCODE_URL(http://127.0.0.1:4242), OPENCODE_SERVER_PASSWORD, OPENCODE_USERNAME(opencode).
  • package.json: deps fastify,@fastify/websocket,@opencode-ai/sdk (move from devDeps); devDeps copy mcp-bridge (typescript,tsx,@eslint/js,typescript-eslint,@types/node, globals,eslint,@types/ws); scripts copy mcp-bridge plus "lint":"npm run eslint".

Lua side

apis/libsandbox.lua (new) — template: apis/libmcpcomputer.lua

createSandbox() returns:

  • traposId(osLike)id .. ":" .. (label or "").
  • Envelope builders for the 4 events + hello.
  • onMessage(content, …): decode (pcall); gateway_request→ built-in ping (else ok:false unknown_type) replied as computer_response via pcall-send; gateway_response→ if type=="hello" drive the handshake state, else queueEvent('trapos_sandbox_reply', messageId, ok, payload, error).
  • startSession(opts) (daemon; injectable http/eventloop/encode/decode/os): builds the connect URL from base opts.url, computes traposId; websocketAsync; on websocket_success send hello; on hello ok:true set state=ready + queueEvent('trapos_sandbox_connected', traposId); on hello ok:false set state=unauthorized (terminal, store lastError, close, no reconnect); on websocket_failure/_closed reconnect via el.setTimeout(connect, 5) (unless unauthorized); hello with no ack in ~10s → close + reconnect; trapos_sandbox_send handler builds computer_request, pcall-send only when ready, else fast trapos_sandbox_reply(messageId, false, nil, {code:"not_connected"}). Returns { isReady, lastError }.
  • request(type, payload, opts) (injectable queueEvent/pullEvent/startTimer): mint messageId, queue trapos_sandbox_send, wait for matching trapos_sandbox_reply vs a timer (default 10s, overridable). Returns (ok, payloadOrError).

os-event contract (all trapos_sandbox_-prefixed): trapos_sandbox_send(messageId,type,payload), trapos_sandbox_reply(messageId,ok,payload,error), trapos_sandbox_connected(traposId).

servers/sandbox.lua (new) — template: servers/mcp-computer-server.lua

Read sandbox.url (unset/http.websocket missing → "daemon inactive", return) and sandbox.password; startSession{ eventloop=_G.bootEventLoop, url, password(secret), os }; print version.

programs/sandbox.lua (new) — template: programs/ai.lua

  • Reads sandbox.url first; unset → immediate set sandbox.url first (no 10s wait).
  • healthrequest('health'); print sandboxOk/opencodeOk (+ opencode: <detail> when false); on not ok print sandbox health failed: <code> <message> (e.g. unauthorized, not_connected, timeout).
  • --version / --help.

Package changes

  1. Rename packages/trapos-sandbox/packages/trapos-sandbox-legacy/; set "name": "trapos-sandbox-legacy" (keep its files/autostart).
  2. New packages/trapos-sandbox/ccpm.json: name trapos-sandbox, version 0.1.0, deps ["trapos-core"], files ["apis/libsandbox.lua","programs/sandbox.lua","servers/sandbox.lua"], autostart ["servers/sandbox"].
  3. packages/index.json: trapos-sandbox: "0.1.0" + add trapos-sandbox-legacy: "0.2.2".
  4. packages/trapos/ccpm.json: add trapos-sandbox to deps; bump meta version.

Tooling

  • just/npm.just: extend recipes (install/build/check/test/test-integration/ci) to cover tools/trap-sandbox alongside mcp-bridge; ensure just install/just ci include it.
  • just/opencode.just (or a new recipe): add just sandbox-serve mirroring opencode-serve (source .env, export OPENCODE_SERVER_PASSWORD, default OPENCODE_URL/SANDBOX_PORT, run npm run dev --prefix tools/trap-sandbox).
  • docs/public-ports.md: add a 4444 row (note it sits outside the 42424244 range; 4244 reserved).

Tests

Lua unit (tests/sandbox.lua, libtest + fakes): envelope builders, traposId, onMessage dispatch (ping ok / unknown_type / hello), empty-payload coercion, startSession wiring (fake eventloop+http: hello sent on open, ready on ack, terminal on unauthorized, reconnect on drop), request() resolve + timeout via injected event primitives.

TS unit (tools/trap-sandbox/test): protocol parse/validate (4 events, hello, ok/error, empty-payload→{}), registry (duplicate newest-wins per account, messageId correlation, timeout, pending cleanup on close), resolveAccount, opencode probe (fake fetch/server: ok / 401 / timeout).

TS integration (tools/trap-sandbox/test-integration, real craftos ↔ real gateway, serialized): self-contained gateway-client.lua for the health round-trip (opencodeOk true/false via the fake-opencode server), reverse ping via registry.request, duplicate (newest-wins), and the auth canary — wrong secret asserts in-band hello ok:false code:"unauthorized", correct secret asserts hello ok:true.

Full-boot e2e (TS integration): craftos --exec replicates boot (set sandbox.url, _G.bootEventLoop, run real servers/sandbox, then under parallel wait for trapos_sandbox_connected and run sandbox health); assert stdout contains sandboxOk: true. Bounded by generous timeouts.

Verification

  • npm test, npm run test:integration, npm run lint/check clean in tools/trap-sandbox.
  • just _craftos-test runs the new tests/sandbox.lua; just ci covers both tools.
  • Manual: just sandbox-serve; in-game set sandbox.url ws://<host>:4444/gateway (+ set sandbox.password <secret> if SANDBOX_PASSWORD set), reboot, sandbox healthsandboxOk: true / opencodeOk: ….

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.