# 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. - [x] **M1 — TS gateway** ([01-ts-gateway](./01-ts-gateway.md)): service builds, listens on 4444, TS unit tests green (`npm test`, `npm run lint`/`check` clean). *No Lua dependency — build first.* - [x] **M2 — Lua client** ([02-lua-client](./02-lua-client.md)): `libsandbox`/`servers`/`programs` in place, Lua unit tests green via `just _craftos-test`. *Implements the Protocol contract.* - [x] **M3 — Packages** ([03-packages](./03-packages.md)): legacy renamed, new `trapos-sandbox` package + meta bump wired; `packages/index.json` consistent. - [ ] **M4 — Integration & tooling** ([04-integration-tooling](./04-integration-tooling.md)): `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](./01-ts-gateway.md) | `tools/trap-sandbox/` service, registry, protocol, opencode probe, TS unit tests | | 02 | [Lua client](./02-lua-client.md) | `apis/libsandbox.lua`, `servers/sandbox.lua`, `programs/sandbox.lua`, Lua unit tests | | 03 | [Packages](./03-packages.md) | legacy rename, new package, `index.json`, meta bump | | 04 | [Integration & tooling](./04-integration-tooling.md) | `just`/`npm.just`, `sandbox-serve`, docs, TS integration + full-boot e2e | ## Decision log (grill outcomes) | Topic | Decision | |---|---| | Event shape | Explicit `event` field: `computer_request|computer_response|gateway_request|gateway_response`;`type` = redux action | | 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`, 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 = ":