8.8 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/checkclean). No Lua dependency — build first. - M2 — Lua client (02-lua-client):
libsandbox/servers/programsin place, Lua unit tests green viajust _craftos-test. Implements the Protocol contract. - M3 — Packages (03-packages): legacy renamed, new
trapos-sandboxpackage + meta bump wired;packages/index.jsonconsistent. - M4 — Integration & tooling (04-integration-tooling):
justrecipes,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_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 }.
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: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.