chore: save plans
This commit is contained in:
parent
aa3e18b4dc
commit
1661fa0131
217
.plans/trap-sandbox-gateway-plan.md
Normal file
217
.plans/trap-sandbox-gateway-plan.md
Normal file
@ -0,0 +1,217 @@
|
||||
# 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 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`/`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.
|
||||
75
.plans/trap-sandbox/01-ts-gateway.md
Normal file
75
.plans/trap-sandbox/01-ts-gateway.md
Normal file
@ -0,0 +1,75 @@
|
||||
# 01 — TS gateway (`tools/trap-sandbox/`)
|
||||
|
||||
> Sub-plan of [trap-sandbox-gateway-spec](./trap-sandbox-gateway-spec.md). Implements the
|
||||
> **Protocol** and **Handshake** contracts defined there — do not redefine frame shapes or error
|
||||
> codes here; reference the spec. **Build first** (M1): no Lua dependency.
|
||||
|
||||
## Scope
|
||||
The TypeScript service: Fastify app on port 4444, the `trapos-cloud-gateway` module
|
||||
(protocol/registry/WS), the opencode probe, the auth seam, env/config, package wiring, and TS
|
||||
**unit** tests. Integration/e2e tests live in [04-integration-tooling](./04-integration-tooling.md).
|
||||
|
||||
## Task checklist
|
||||
- [ ] Scaffold `tools/trap-sandbox/` (ESM, `tsc` build, `tsx` dev/test, `node:test`, typescript-eslint
|
||||
flat config — copy `mcp-bridge`'s `tsconfig.json`/`eslint.config.js`).
|
||||
- [ ] `package.json`: deps + devDeps + scripts (see below).
|
||||
- [ ] `src/auth.ts` — `resolveAccount(secret)`.
|
||||
- [ ] `src/opencode.ts` — `createOpencodeProbe(config)`.
|
||||
- [ ] `src/modules/trapos-cloud-gateway/protocol.ts` — envelope/error types + parse/validate.
|
||||
- [ ] `src/modules/trapos-cloud-gateway/registry.ts` — `GatewayRegistry`.
|
||||
- [ ] `src/modules/trapos-cloud-gateway/index.ts` — Fastify plugin: `GET /health` (open) + `GET /gateway` (ws).
|
||||
- [ ] `src/app.ts` — `createApp(config)` returning `{ app, registry }`.
|
||||
- [ ] `src/index.ts` — env config, listen, `EADDRINUSE` clear message + exit.
|
||||
- [ ] TS unit tests (`test/`): protocol, registry, auth/hello, probe.
|
||||
- [ ] Gate: `npm test`, `npm run lint`/`check` clean.
|
||||
|
||||
## Layout
|
||||
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
|
||||
```
|
||||
|
||||
## Behavior
|
||||
- **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. (Handshake steps & error codes: see spec **Handshake**.)
|
||||
- **GatewayRegistry**: `register`/`unregister`/`has`/`count`; `request(accountId, traposId, type,
|
||||
payload, timeoutMs=600000)` sends `gateway_request`, races matching `computer_response` vs timeout.
|
||||
Keyed by `(accountId, traposId)`, newest-wins.
|
||||
- **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.
|
||||
- **auth**: `resolveAccount(secret)` → `"local" | null` (constant seam; `null` = unauthorized).
|
||||
|
||||
## 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"`**.
|
||||
|
||||
## TS unit tests (`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). Use `FakeSocket` + fake fetch.
|
||||
|
||||
## Verification gate
|
||||
`npm test` + `npm run lint`/`check` clean in `tools/trap-sandbox`; service starts and listens on 4444.
|
||||
56
.plans/trap-sandbox/02-lua-client.md
Normal file
56
.plans/trap-sandbox/02-lua-client.md
Normal file
@ -0,0 +1,56 @@
|
||||
# 02 — Lua client (`trapos-sandbox`)
|
||||
|
||||
> Sub-plan of [trap-sandbox-gateway-spec](./trap-sandbox-gateway-spec.md). Implements the
|
||||
> **Protocol**, **Handshake**, and **os-event contract** from the spec — reference them, don't
|
||||
> redefine. M2; depends on the protocol contract (not on M1 code existing).
|
||||
|
||||
## Scope
|
||||
The Lua side: `apis/libsandbox.lua`, `servers/sandbox.lua`, `programs/sandbox.lua`, and Lua **unit**
|
||||
tests. Package/meta changes are [03-packages](./03-packages.md); integration/e2e is
|
||||
[04-integration-tooling](./04-integration-tooling.md).
|
||||
|
||||
## Task checklist
|
||||
- [ ] `apis/libsandbox.lua` (template: `apis/libmcpcomputer.lua`).
|
||||
- [ ] `servers/sandbox.lua` (template: `servers/mcp-computer-server.lua`).
|
||||
- [ ] `programs/sandbox.lua` (template: `programs/ai.lua`).
|
||||
- [ ] Lua unit tests (`tests/sandbox.lua`).
|
||||
- [ ] Gate: `just _craftos-test` runs `tests/sandbox.lua` green.
|
||||
|
||||
## `apis/libsandbox.lua` — 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)`.
|
||||
|
||||
## `servers/sandbox.lua` — 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` — 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`.
|
||||
|
||||
## Lua unit tests (`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.
|
||||
|
||||
## Verification gate
|
||||
`just _craftos-test` runs the new `tests/sandbox.lua` green.
|
||||
27
.plans/trap-sandbox/03-packages.md
Normal file
27
.plans/trap-sandbox/03-packages.md
Normal file
@ -0,0 +1,27 @@
|
||||
# 03 — Packages (rename + new package + meta)
|
||||
|
||||
> Sub-plan of [trap-sandbox-gateway-spec](./trap-sandbox-gateway-spec.md). Mechanical wiring. M3;
|
||||
> may run in parallel with M2. Depends on the Lua file paths from
|
||||
> [02-lua-client](./02-lua-client.md) being settled.
|
||||
|
||||
## Scope
|
||||
ccpm package definitions only: rename the legacy package, register the new `trapos-sandbox`, keep
|
||||
`packages/index.json` consistent, and add `trapos-sandbox` to the `trapos` meta with a version bump.
|
||||
|
||||
## Task checklist
|
||||
- [ ] **Rename** `packages/trapos-sandbox/` → `packages/trapos-sandbox-legacy/`; set
|
||||
`"name": "trapos-sandbox-legacy"` (keep its files/autostart).
|
||||
- [ ] **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"]`.
|
||||
- [ ] **`packages/index.json`**: `trapos-sandbox: "0.1.0"` + add `trapos-sandbox-legacy: "0.2.2"`.
|
||||
- [ ] **`packages/trapos/ccpm.json`**: add `trapos-sandbox` to deps; bump meta version.
|
||||
|
||||
## Notes
|
||||
- The daemon stays dormant until `sandbox.url` is set, so adding it to the `trapos` meta is safe.
|
||||
- Confirm the legacy version pinned in `index.json` (`0.2.2`) matches the renamed package's current
|
||||
version before committing.
|
||||
|
||||
## Verification gate
|
||||
`packages/index.json` resolves both names; `trapos` meta installs `trapos-sandbox`; legacy package
|
||||
still installs under its new name with files/autostart intact.
|
||||
49
.plans/trap-sandbox/04-integration-tooling.md
Normal file
49
.plans/trap-sandbox/04-integration-tooling.md
Normal file
@ -0,0 +1,49 @@
|
||||
# 04 — Integration & tooling
|
||||
|
||||
> Sub-plan of [trap-sandbox-gateway-spec](./trap-sandbox-gateway-spec.md). **Build last** (M4):
|
||||
> requires both the TS gateway (M1) and the Lua client (M2) to exist. References the spec's
|
||||
> **Protocol**/**Handshake** for assertions.
|
||||
|
||||
## Scope
|
||||
Host tooling (`just`/`npm.just` recipes, `sandbox-serve`, docs) and the cross-side tests: TS
|
||||
**integration** (real craftos ↔ real gateway) plus the **full-boot e2e**.
|
||||
|
||||
## Task checklist
|
||||
- [ ] `just/npm.just`: extend `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 new recipe): add **`just sandbox-serve`** mirroring `opencode-serve`.
|
||||
- [ ] `docs/public-ports.md`: add a `4444` row.
|
||||
- [ ] TS integration harness + cases (`tools/trap-sandbox/test-integration/`).
|
||||
- [ ] Full-boot e2e.
|
||||
- [ ] Gate: `npm run test:integration` green; `just ci` covers both tools.
|
||||
|
||||
## 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 4242–4244 range; 4244 reserved).
|
||||
|
||||
## Test layout
|
||||
```
|
||||
test-integration/ # harness.ts (app on :0, startCraftos, fake-opencode http server) + lua/ + *.test.ts
|
||||
```
|
||||
|
||||
## 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 gate (also the project-level acceptance)
|
||||
- `npm test`, `npm run test:integration`, `npm run lint`/`check` clean in `tools/trap-sandbox`.
|
||||
- `just _craftos-test` runs `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: …`.
|
||||
136
.plans/trap-sandbox/trap-sandbox-gateway-spec.md
Normal file
136
.plans/trap-sandbox/trap-sandbox-gateway-spec.md
Normal file
@ -0,0 +1,136 @@
|
||||
# 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](./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.*
|
||||
- [ ] **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.*
|
||||
- [ ] **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 = "<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 }`.
|
||||
|
||||
### 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.
|
||||
Loading…
Reference in New Issue
Block a user