cc-libs/.plans/trap-sandbox/trap-sandbox-gateway-spec.md

145 lines
9.1 KiB
Markdown

# 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.
- [x] **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 = "<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.