14 KiB
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_request→computer_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, mirroringOPENCODE_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:booleanand, whenok: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-objectpayloadas{}; the Lua side may omitpayloadwhen empty andonMessagetolerates 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
- On socket open, the daemon sends
computer_request type:"hello" payload:{ traposId, secret }. - Gateway validates
secretagainstSANDBOX_PASSWORD(unset ⇒ auth disabled, any secret OK), runsresolveAccount(secret) → "local", registers(accountId, traposId)(newest-wins), stores{accountId, traposId}on the socket, and repliesgateway_response type:"hello" ok:true payload:{ accountId }. - On bad secret →
ok:false error:{code:"unauthorized"}then close. - 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 }.sandboxOkis trivially true;opencodeOkfrom 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 onlyhellois honored; post-hello handlecomputer_request(health, elseunknown_type) andcomputer_response(resolve pendinggateway_requestby messageId).close/error→ unregister- reject that connection's pendings.
- GatewayRegistry:
register/unregister/has/count;request(accountId, traposId, type, payload, timeoutMs=600000)sendsgateway_request, races matchingcomputer_responsevs timeout. - opencode probe:
createOpencodeClient({ baseUrl: OPENCODE_URL, headers:{ Authorization: basic ("opencode", OPENCODE_SERVER_PASSWORD) }, throwOnError:false })thenconfig.get()withAbortSignal.timeout(5000), wrapped in try/catch.ok = response 2xx;detail= url + status or error. NoOPENCODE_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-inping(elseok:false unknown_type) replied ascomputer_responsevia pcall-send;gateway_response→ iftype=="hello"drive the handshake state, elsequeueEvent('trapos_sandbox_reply', messageId, ok, payload, error).startSession(opts)(daemon; injectablehttp/eventloop/encode/decode/os): builds the connect URL from baseopts.url, computestraposId;websocketAsync; onwebsocket_successsend hello; on hellook:trueset state=ready +queueEvent('trapos_sandbox_connected', traposId); on hellook:falseset state=unauthorized (terminal, storelastError, close, no reconnect); onwebsocket_failure/_closedreconnect viael.setTimeout(connect, 5)(unless unauthorized); hello with no ack in ~10s → close + reconnect;trapos_sandbox_sendhandler buildscomputer_request, pcall-send only when ready, else fasttrapos_sandbox_reply(messageId, false, nil, {code:"not_connected"}). Returns{ isReady, lastError }.request(type, payload, opts)(injectablequeueEvent/pullEvent/startTimer): mint messageId, queuetrapos_sandbox_send, wait for matchingtrapos_sandbox_replyvs 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.urlfirst; unset → immediateset sandbox.url first(no 10s wait). health→request('health'); printsandboxOk/opencodeOk(+opencode: <detail>when false); onnot okprintsandbox health failed: <code> <message>(e.g.unauthorized,not_connected,timeout).--version/--help.
Package changes
- Rename
packages/trapos-sandbox/→packages/trapos-sandbox-legacy/; set"name": "trapos-sandbox-legacy"(keep its files/autostart). - New
packages/trapos-sandbox/ccpm.json: nametrapos-sandbox, version0.1.0, deps["trapos-core"], files["apis/libsandbox.lua","programs/sandbox.lua","servers/sandbox.lua"], autostart["servers/sandbox"]. packages/index.json:trapos-sandbox: "0.1.0"+ addtrapos-sandbox-legacy: "0.2.2".packages/trapos/ccpm.json: addtrapos-sandboxto deps; bump meta version.
Tooling
just/npm.just: extend recipes (install/build/check/test/test-integration/ci) to covertools/trap-sandboxalongside mcp-bridge; ensurejust install/just ciinclude it.just/opencode.just(or a new recipe): addjust sandbox-servemirroringopencode-serve(source.env, exportOPENCODE_SERVER_PASSWORD, defaultOPENCODE_URL/SANDBOX_PORT, runnpm run dev --prefix tools/trap-sandbox).docs/public-ports.md: add a4444row (note it sits outside the 4242–4244 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/checkclean intools/trap-sandbox.just _craftos-testruns the newtests/sandbox.lua;just cicovers both tools.- Manual:
just sandbox-serve; in-gameset sandbox.url ws://<host>:4444/gateway(+set sandbox.password <secret>ifSANDBOX_PASSWORDset), reboot,sandbox health→sandboxOk: 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:labelis 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.