cc-libs/.plans/archived/trap-sandbox/01-ts-gateway.md
2026-06-15 19:59:26 +02:00

94 lines
6.3 KiB
Markdown

# 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.
> **Grill-refined (implemented).** This sub-plan was hardened in a follow-up grill and built. Deltas
> from the text below, all in the shipped code:
> - WS `type` `health` → **`probe_gateway`** (in-game command stays `sandbox health`).
> - Validation uses **zod** (`protocol.ts`); `parse()` throws on malformed / unknown `event` / failed
> schema (handler catches → debug-logs → drops). `payload` (`[]`/missing/non-object → `{}`) via zod.
> - `resolveAccount(secret, password)` owns the password gate (signature gained `password`).
> - Dispatch state machine extracted to **`connection.ts`** (pure, FakeSocket-testable); the module
> `index.ts` is the thin `ws` adapter. Registry depends on a minimal `GatewaySocket { send, close }`.
> - `registry.request()` rejects a typed **`GatewayError { code }`** on not_connected/timeout/ok:false,
> resolves the payload on success. Newest-wins eviction rejects the old socket's pendings + closes it;
> `unregister`/close are reference-identity-checked.
> - Logging via Fastify's **pino**; env `LOG_LEVEL` (default `info`, `silent` in tests), `LOG_PRETTY`;
> silent drops log at `debug` with `{ event, type, reason }`. **No `serve` script** — `dev` is the
> pretty entry point (`LOG_PRETTY=1 tsx watch …`); `pino-pretty` devDep; `zod` dep.
> - Every `computer_request` (incl. `hello`) must carry non-empty `traposSenderId` (zod-enforced).
> - Open `GET /health` is liveness-only (`{ok:true}`, no auth, no probe). Opencode probe: client built
> once, fresh uncached 5s-bounded `config.get()` per call, no-URL short-circuit.
## 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.