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

218 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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|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 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).
- `health``request('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 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: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.