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

6.3 KiB

01 — TS gateway (tools/trap-sandbox/)

Sub-plan of trap-sandbox-gateway-spec. 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 healthprobe_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 scriptdev 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.

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.tsresolveAccount(secret).
  • src/opencode.tscreateOpencodeProbe(config).
  • src/modules/trapos-cloud-gateway/protocol.ts — envelope/error types + parse/validate.
  • src/modules/trapos-cloud-gateway/registry.tsGatewayRegistry.
  • src/modules/trapos-cloud-gateway/index.ts — Fastify plugin: GET /health (open) + GET /gateway (ws).
  • src/app.tscreateApp(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.