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
typehealth→probe_gateway(in-game command stayssandbox health).- Validation uses zod (
protocol.ts);parse()throws on malformed / unknownevent/ failed schema (handler catches → debug-logs → drops).payload([]/missing/non-object →{}) via zod.resolveAccount(secret, password)owns the password gate (signature gainedpassword).- Dispatch state machine extracted to
connection.ts(pure, FakeSocket-testable); the moduleindex.tsis the thinwsadapter. Registry depends on a minimalGatewaySocket { send, close }.registry.request()rejects a typedGatewayError { 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(defaultinfo,silentin tests),LOG_PRETTY; silent drops log atdebugwith{ event, type, reason }. Noservescript —devis the pretty entry point (LOG_PRETTY=1 tsx watch …);pino-prettydevDep;zoddep.- Every
computer_request(incl.hello) must carry non-emptytraposSenderId(zod-enforced).- Open
GET /healthis liveness-only ({ok:true}, no auth, no probe). Opencode probe: client built once, fresh uncached 5s-boundedconfig.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,tscbuild,tsxdev/test,node:test, typescript-eslint flat config — copymcp-bridge'stsconfig.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,EADDRINUSEclear message + exit.- TS unit tests (
test/): protocol, registry, auth/hello, probe. - Gate:
npm test,npm run lint/checkclean.
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 onlyhellois honored; post-hello handlecomputer_request(health, elseunknown_type) andcomputer_response(resolve pendinggateway_requestby 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)sendsgateway_request, races matchingcomputer_responsevs timeout. Keyed by(accountId, traposId), newest-wins. - opencode probe:
createOpencodeClient({ baseUrl: OPENCODE_URL, headers:{ Authorization: basic ("opencode", OPENCODE_SERVER_PASSWORD) }, throwOnError:false })thenconfig.get()withAbortSignal.timeout(5000), wrapped in try/catch.ok = response 2xx;detail= url + status or error. NoOPENCODE_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.