chore: delete old plan
This commit is contained in:
parent
1661fa0131
commit
05bf4d0497
@ -1,217 +0,0 @@
|
||||
# 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.
|
||||
Loading…
Reference in New Issue
Block a user