feat(trap-sandbox): add gateway service

This commit is contained in:
Guillaume ARM 2026-06-14 14:41:29 +02:00
parent a8485a780f
commit cd6427c454
23 changed files with 3950 additions and 20 deletions

View File

@ -4,6 +4,24 @@
> **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

View File

@ -19,7 +19,10 @@ tests. Package/meta changes are [03-packages](./03-packages.md); integration/e2e
## `apis/libsandbox.lua` — template: `apis/libmcpcomputer.lua`
`createSandbox()` returns:
- `traposId(osLike)``id .. ":" .. (label or "")`.
- Envelope builders for the 4 events + hello.
- Envelope builders for the 4 events + hello. **Every `computer_request` (including `hello`) must
carry a non-empty `traposSenderId` = the computer's `traposId`** — the TS gateway's zod schema
requires it (decided in the 01-ts-gateway grill). The `hello` identity used for registration is
still `payload.traposId`, but the envelope `traposSenderId` must also be present.
- `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,
@ -42,9 +45,10 @@ Read `sandbox.url` (unset/`http.websocket` missing → "daemon inactive", return
## `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`).
- `health` command → `request('probe_gateway')` (wire `type` renamed from `health` during the
01-ts-gateway grill; the in-game command name stays `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)

View File

@ -113,9 +113,11 @@ JSON frames. Computer-originated carry `traposSenderId`; gateway-originated carr
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.
- **probe_gateway** (computer→gateway): `computer_request type:"probe_gateway"` → `gateway_response
type:"probe_gateway" ok:true payload:{ sandboxOk:true, opencodeOk:boolean, opencodeDetail?:string }`.
`sandboxOk` is trivially true; `opencodeOk` from the probe; `opencodeDetail` (url + status/error)
only when false. (The in-game command is still `sandbox health`; only the wire `type` is
`probe_gateway`. Renamed from `health` during the 01-ts-gateway grill.)
- **ping** (gateway→computer, reverse-direction example/test): `gateway_request type:"ping"`
`computer_response type:"ping" ok:true payload:{ traposId }`.

View File

@ -1,7 +1,14 @@
# Install Node dependencies for repository tools.
npm-install:
npm-install: npm-install-mcp-bridge npm-install-trap-sandbox
# Install Node dependencies for the MCP bridge.
npm-install-mcp-bridge:
npm install --prefix tools/mcp-bridge
# Install Node dependencies for trap-sandbox.
npm-install-trap-sandbox:
npm install --prefix tools/trap-sandbox/
# Build Node-based repository tools.
npm-build:
npm run build --prefix tools/mcp-bridge
@ -22,5 +29,9 @@ npm-test-integration:
npm-ci:
npm run test:ci --prefix tools/mcp-bridge
# Run trap-sandbox CI.
npm-trap-sandbox-ci:
npm --prefix tools/trap-sandbox/ run test:ci
# Build generated artifacts.
build: npm-build

View File

@ -1,5 +1,5 @@
# Local CI entry point used by Git hooks. Pass args through to CraftOS tests.
ci *args: check-craftos check check-packages npm-build npm-test
ci *args: check-craftos check check-packages npm-build npm-test npm-trap-sandbox-ci
@just _craftos-test {{args}}
@just test-integration

View File

@ -0,0 +1,26 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist/**"] },
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
globals: globals.node,
parserOptions: {
projectService: {
allowDefaultProject: ["eslint.config.js"],
},
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ["test/**/*.ts", "test-integration/**/*.ts"],
rules: {
"@typescript-eslint/no-floating-promises": "off",
},
},
);

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,39 @@
{
"name": "trap-sandbox",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc",
"eslint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
"lint": "npm run eslint",
"clean": "rm -rf node_modules/.cache/tsc node_modules/.cache/eslint",
"check": "npm run eslint",
"test:all": "npm run check && npm run build && npm run test",
"test:ci": "npm run test:all && npm run test:integration",
"dev": "LOG_PRETTY=1 tsx watch src/index.ts",
"start": "npm run build && node dist/src/index.js",
"test": "tsx --test test/*.test.ts",
"test:integration": "tsx --test --test-concurrency=1 test-integration/*.test.ts"
},
"dependencies": {
"@fastify/websocket": "^11",
"@opencode-ai/sdk": "^1.17.4",
"fastify": "^5",
"zod": "^4"
},
"devDependencies": {
"@opencode-ai/sdk": "1.17.4"
"@eslint/js": "^10.0.1",
"@types/node": "^20.14.10",
"@types/ws": "^8.5.10",
"eslint": "^10.4.1",
"globals": "^17.6.0",
"pino-pretty": "^13",
"tsx": "^4.16.2",
"typescript": "^5.5.3",
"typescript-eslint": "^8.61.0"
}
}

View File

@ -0,0 +1,44 @@
import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify";
import websocket from "@fastify/websocket";
import { resolveAccount } from "./auth.js";
import { createOpencodeProbe } from "./opencode.js";
import gatewayModule from "./modules/trapos-cloud-gateway/index.js";
import { GatewayRegistry } from "./modules/trapos-cloud-gateway/registry.js";
export type Config = {
host: string;
port: number;
sandboxPassword?: string;
requestTimeoutMs: number;
opencodeUrl?: string;
opencodeUsername: string;
opencodeServerPassword?: string;
logger: FastifyServerOptions["logger"];
};
const MAX_PAYLOAD_BYTES = 1024 * 1024; // ~1MB
// Composes the service: registry + opencode probe + auth seam, wired into the
// trapos-cloud-gateway module. Never reads process.env (that is index.ts's job),
// so it is fully constructable from tests.
export async function createApp(
config: Config,
): Promise<{ app: FastifyInstance; registry: GatewayRegistry }> {
const app = Fastify({ logger: config.logger });
const registry = new GatewayRegistry(app.log, config.requestTimeoutMs);
const probe = createOpencodeProbe({
opencodeUrl: config.opencodeUrl,
opencodeUsername: config.opencodeUsername,
opencodeServerPassword: config.opencodeServerPassword,
});
await app.register(websocket, { options: { maxPayload: MAX_PAYLOAD_BYTES } });
await app.register(gatewayModule, {
registry,
probe,
resolveAccount: (secret) => resolveAccount(secret, config.sandboxPassword),
});
return { app, registry };
}

View File

@ -0,0 +1,15 @@
export type AccountId = "local";
// The single future swap-point: credential -> account home. Today it also owns the
// password gate. Returns the account id for a valid secret, or null = unauthorized.
// - password unset/empty => auth disabled, any secret (or none) maps to "local"
// - password set => secret must match exactly, else null
export function resolveAccount(
secret: string | undefined,
password: string | undefined,
): AccountId | null {
if (password === undefined || password === "") {
return "local";
}
return secret === password ? "local" : null;
}

View File

@ -0,0 +1,58 @@
import { createApp, type Config } from "./app.js";
const config: Config = {
host: process.env.SANDBOX_HOST ?? "0.0.0.0",
port: readPort(process.env.SANDBOX_PORT, 4444),
sandboxPassword: process.env.SANDBOX_PASSWORD,
requestTimeoutMs: readPositiveInt(process.env.SANDBOX_REQUEST_TIMEOUT_MS, 600_000),
opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4242",
opencodeUsername: process.env.OPENCODE_USERNAME ?? "opencode",
opencodeServerPassword: process.env.OPENCODE_SERVER_PASSWORD,
logger: buildLogger(),
};
const { app } = await createApp(config);
try {
await app.listen({ host: config.host, port: config.port });
} catch (err) {
if (isErrnoException(err) && err.code === "EADDRINUSE") {
process.stderr.write(
`\n[trap-sandbox] Port ${config.port} is already in use on ${config.host}.\n` +
`Another instance may be running, or set SANDBOX_PORT to a free port.\n\n`,
);
process.exit(1);
}
app.log.error(err);
process.exit(1);
}
function buildLogger(): Config["logger"] {
const level = process.env.LOG_LEVEL ?? "info";
if (process.env.LOG_PRETTY) {
return {
level,
transport: {
target: "pino-pretty",
options: { translateTime: "HH:MM:ss", ignore: "pid,hostname" },
},
};
}
return { level };
}
function readPort(value: string | undefined, fallback: number): number {
return readPositiveInt(value, fallback);
}
function readPositiveInt(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
return err instanceof Error && "code" in err;
}

View File

@ -0,0 +1,156 @@
import {
GatewayError,
helloPayloadSchema,
parse,
type ComputerRequest,
type Frame,
type GatewayErrorBody,
} from "./protocol.js";
import type { GatewayRegistry, GatewaySocket, Logger } from "./registry.js";
import type { OpencodeProbe } from "../../opencode.js";
import type { AccountId } from "../../auth.js";
export type ConnectionDeps = {
socket: GatewaySocket;
registry: GatewayRegistry;
probe: OpencodeProbe;
resolveAccount: (secret: string | undefined) => AccountId | null;
log: Logger;
};
export type Connection = {
onMessage(raw: unknown): Promise<void>;
onClose(): void;
};
type State = { accountId: AccountId; traposId: string };
function toText(raw: unknown): string {
if (typeof raw === "string") return raw;
if (Buffer.isBuffer(raw)) return raw.toString("utf8");
if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString("utf8");
if (Array.isArray(raw)) return Buffer.concat(raw as Buffer[]).toString("utf8");
return "";
}
// One per WebSocket connection. Owns the pre-/post-hello state machine. The only
// event that ever earns an error reply is `computer_request`; everything the gateway
// shouldn't receive is dropped (with a debug log). `unauthorized` is the only close.
export function createConnection(deps: ConnectionDeps): Connection {
const { socket, registry, probe, resolveAccount, log } = deps;
let state: State | null = null;
function reply(
req: ComputerRequest,
ok: boolean,
payload?: Record<string, unknown>,
error?: GatewayErrorBody,
): void {
const frame: Record<string, unknown> = {
event: "gateway_response",
traposReceiverId: req.traposSenderId,
messageId: req.messageId,
type: req.type,
ok,
};
if (payload) frame.payload = payload;
if (error) frame.error = error;
socket.send(JSON.stringify(frame));
}
function handleHello(req: ComputerRequest): void {
const parsed = helloPayloadSchema.safeParse(req.payload);
if (!parsed.success) {
log.debug({ reason: "malformed" }, "hello with missing/empty traposId");
reply(req, false, undefined, { code: "not_authenticated", message: "missing traposId" });
return;
}
const { traposId, secret } = parsed.data;
const accountId = resolveAccount(secret);
if (accountId === null) {
reply(req, false, undefined, { code: "unauthorized", message: "invalid secret" });
socket.close();
return;
}
registry.register(accountId, traposId, socket);
state = { accountId, traposId };
reply(req, true, { accountId });
}
async function handleProbeGateway(req: ComputerRequest): Promise<void> {
const result = await probe.probe();
const payload: Record<string, unknown> = { sandboxOk: true, opencodeOk: result.ok };
if (!result.ok && result.detail !== undefined) {
payload.opencodeDetail = result.detail;
}
reply(req, true, payload);
}
function dispatchPreHello(frame: Frame): Promise<void> | void {
if (frame.event === "computer_request") {
if (frame.type === "hello") {
return handleHello(frame);
}
reply(frame, false, undefined, {
code: "not_authenticated",
message: "hello required before any other request",
});
return;
}
log.debug({ event: frame.event, reason: "pre_hello" }, "dropping pre-hello frame");
}
async function dispatchPostHello(frame: Frame, current: State): Promise<void> {
if (frame.event === "computer_request") {
if (frame.type === "hello") {
log.debug({ reason: "duplicate_hello" }, "ignoring duplicate hello on live connection");
return;
}
if (frame.type === "probe_gateway") {
await handleProbeGateway(frame);
return;
}
reply(frame, false, undefined, { code: "unknown_type", message: `unknown type: ${frame.type}` });
return;
}
if (frame.event === "computer_response") {
// resolve a pending gateway_request
registry.resolveResponse(socket, current.accountId, current.traposId, frame);
}
}
async function onMessage(raw: unknown): Promise<void> {
let frame: Frame;
try {
frame = parse(toText(raw));
} catch {
log.debug({ reason: "malformed" }, "dropping malformed frame");
return;
}
// The gateway never legitimately receives gateway_* events from a client.
if (frame.event === "gateway_request" || frame.event === "gateway_response") {
log.debug({ event: frame.event, reason: "wrong_direction" }, "dropping wrong-direction frame");
return;
}
if (state === null) {
await dispatchPreHello(frame);
return;
}
await dispatchPostHello(frame, state);
}
function onClose(): void {
if (state) {
registry.unregister(state.accountId, state.traposId, socket);
}
}
return { onMessage, onClose };
}
// Re-export so callers can detect coded errors without importing protocol directly.
export { GatewayError };

View File

@ -0,0 +1,49 @@
import type { FastifyPluginAsync } from "fastify";
import type { WebSocket } from "@fastify/websocket";
import { createConnection } from "./connection.js";
import type { GatewayRegistry, GatewaySocket } from "./registry.js";
import type { OpencodeProbe } from "../../opencode.js";
import type { AccountId } from "../../auth.js";
export type GatewayModuleOptions = {
registry: GatewayRegistry;
probe: OpencodeProbe;
resolveAccount: (secret: string | undefined) => AccountId | null;
};
// Adapter: the only place that touches the real `ws` socket. Bridges socket events
// into the (pure) connection state machine. @fastify/websocket must be registered
// on a parent scope before this plugin.
const gatewayModule: FastifyPluginAsync<GatewayModuleOptions> = (fastify, opts) => {
// Open liveness check for the HTTP service itself — no auth, no opencode probe.
fastify.get("/health", () => ({ ok: true }));
fastify.get("/gateway", { websocket: true }, (socket: WebSocket) => {
const gatewaySocket: GatewaySocket = {
send: (data) => socket.send(data),
close: (code, reason) => socket.close(code, reason),
};
const connection = createConnection({
socket: gatewaySocket,
registry: opts.registry,
probe: opts.probe,
resolveAccount: opts.resolveAccount,
log: fastify.log,
});
socket.on("message", (raw) => {
void connection.onMessage(raw);
});
socket.on("close", () => {
connection.onClose();
});
socket.on("error", () => {
connection.onClose();
});
});
return Promise.resolve();
};
export default gatewayModule;

View File

@ -0,0 +1,111 @@
import { z } from "zod";
// Stable v1 error-code enum (see spec "Error model").
export const ERROR_CODES = [
"unauthorized",
"not_authenticated",
"unknown_type",
"not_connected",
"internal",
"timeout",
] as const;
export type ErrorCode = (typeof ERROR_CODES)[number];
export type GatewayErrorBody = { code: string; message: string };
// A coded error. registry.request() rejects with this; the connection handler is
// the single place that turns a caught GatewayError back into an outbound error envelope.
export class GatewayError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message?: string) {
super(message ?? code);
this.name = "GatewayError";
this.code = code;
}
toBody(): GatewayErrorBody {
return { code: this.code, message: this.message };
}
}
// Empty-payload gotcha: CraftOS serializes `{}` as `"[]"`. Treat a missing / array /
// non-object payload as `{}` so downstream always sees a plain object.
const payloadSchema = z.preprocess(
(value) =>
typeof value === "object" && value !== null && !Array.isArray(value) ? value : {},
z.record(z.string(), z.unknown()),
);
// Inbound errors are validated loosely: code may be any string (we only narrow our own).
const errorSchema = z.object({
code: z.string(),
message: z.string().optional(),
});
const computerRequestSchema = z.object({
event: z.literal("computer_request"),
traposSenderId: z.string().min(1),
messageId: z.string().min(1),
type: z.string().min(1),
payload: payloadSchema,
});
const computerResponseSchema = z.object({
event: z.literal("computer_response"),
traposSenderId: z.string().min(1),
messageId: z.string().min(1),
type: z.string().min(1),
ok: z.boolean(),
payload: payloadSchema,
error: errorSchema.optional(),
});
const gatewayRequestSchema = z.object({
event: z.literal("gateway_request"),
traposReceiverId: z.string().min(1),
messageId: z.string().min(1),
type: z.string().min(1),
payload: payloadSchema,
});
const gatewayResponseSchema = z.object({
event: z.literal("gateway_response"),
traposReceiverId: z.string().min(1),
messageId: z.string().min(1),
type: z.string().min(1),
ok: z.boolean(),
payload: payloadSchema,
error: errorSchema.optional(),
});
const frameSchema = z.discriminatedUnion("event", [
computerRequestSchema,
computerResponseSchema,
gatewayRequestSchema,
gatewayResponseSchema,
]);
export type Frame = z.infer<typeof frameSchema>;
export type ComputerRequest = z.infer<typeof computerRequestSchema>;
export type ComputerResponse = z.infer<typeof computerResponseSchema>;
// Parse + validate a raw frame. Throws on malformed JSON, schema failure, or an
// `event` outside the 4-event enum — the caller's try/catch drops (and logs) it.
// Does NOT validate `type`: an unknown type is a protocol *reply* (`unknown_type`),
// decided in dispatch, not a dropped frame.
export function parse(raw: string): Frame {
const json: unknown = JSON.parse(raw);
const result = frameSchema.safeParse(json);
if (!result.success) {
throw new Error(`invalid frame: ${result.error.message}`);
}
return result.data;
}
// Payload schema for the `hello` request. Strict: traposId must be a non-empty
// string; secret is optional (absent secret with auth on is treated as unauthorized).
export const helloPayloadSchema = z.object({
traposId: z.string().min(1),
secret: z.string().optional(),
});

View File

@ -0,0 +1,159 @@
import { randomUUID } from "node:crypto";
import { GatewayError, type ComputerResponse, type ErrorCode, ERROR_CODES } from "./protocol.js";
// Minimal surface the registry needs from a WS connection. Keeps the registry pure
// and unit-testable with a FakeSocket; the real `.on(...)` wiring lives in the plugin.
export type GatewaySocket = {
send(data: string): void;
close(code?: number, reason?: string): void;
};
type LogFn = (obj: object, msg?: string) => void;
export type Logger = { debug: LogFn; warn: LogFn };
type Pending = {
resolve: (payload: Record<string, unknown>) => void;
reject: (err: GatewayError) => void;
timer: ReturnType<typeof setTimeout>;
};
type Entry = {
socket: GatewaySocket;
pending: Map<string, Pending>;
};
const DEFAULT_REQUEST_TIMEOUT_MS = 600_000;
function isErrorCode(code: string): code is ErrorCode {
return (ERROR_CODES as readonly string[]).includes(code);
}
// Registry of live computer connections keyed by (accountId, traposId), newest-wins.
// Each entry owns its own pending map so eviction / close rejects only that socket's
// in-flight requests.
export class GatewayRegistry {
private readonly entries = new Map<string, Entry>();
constructor(
private readonly log: Logger,
private readonly defaultTimeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
) {}
private key(accountId: string, traposId: string): string {
return `${accountId}\u0000${traposId}`;
}
register(accountId: string, traposId: string, socket: GatewaySocket): void {
const k = this.key(accountId, traposId);
const existing = this.entries.get(k);
if (existing) {
this.log.debug(
{ accountId, traposId, reason: "duplicate_hello" },
"evicting previous connection (newest-wins)",
);
this.rejectAll(existing, new GatewayError("not_connected", "connection displaced by newer registration"));
try {
existing.socket.close();
} catch {
// already closed / errored - nothing to do
}
}
this.entries.set(k, { socket, pending: new Map() });
}
// Identity-checked: a late close from an evicted socket must not remove the socket
// that replaced it.
unregister(accountId: string, traposId: string, socket: GatewaySocket): void {
const k = this.key(accountId, traposId);
const entry = this.entries.get(k);
if (!entry || entry.socket !== socket) {
return;
}
this.entries.delete(k);
this.rejectAll(entry, new GatewayError("not_connected", "connection closed"));
}
has(accountId: string, traposId: string): boolean {
return this.entries.has(this.key(accountId, traposId));
}
count(): number {
return this.entries.size;
}
// Gateway -> computer request/reply. Rejects with a coded GatewayError on
// not_connected / timeout / response ok:false; resolves with the response payload.
// The deadline is the caller's concern; defaultTimeoutMs is only a safety ceiling.
request(
accountId: string,
traposId: string,
type: string,
payload: Record<string, unknown> = {},
timeoutMs: number = this.defaultTimeoutMs,
): Promise<Record<string, unknown>> {
const entry = this.entries.get(this.key(accountId, traposId));
if (!entry) {
return Promise.reject(
new GatewayError("not_connected", `no computer registered for ${traposId}`),
);
}
const messageId = randomUUID();
const frame = {
event: "gateway_request",
traposReceiverId: traposId,
messageId,
type,
payload,
};
return new Promise<Record<string, unknown>>((resolve, reject) => {
const timer = setTimeout(() => {
entry.pending.delete(messageId);
reject(new GatewayError("timeout", `request "${type}" timed out after ${timeoutMs}ms`));
}, timeoutMs);
entry.pending.set(messageId, { resolve, reject, timer });
try {
entry.socket.send(JSON.stringify(frame));
} catch (err) {
clearTimeout(timer);
entry.pending.delete(messageId);
reject(new GatewayError("internal", err instanceof Error ? err.message : String(err)));
}
});
}
// Resolve a pending gateway_request from a matching computer_response. Silently
// dropped (with a debug log) when there is no matching pending.
resolveResponse(socket: GatewaySocket, accountId: string, traposId: string, frame: ComputerResponse): void {
const entry = this.entries.get(this.key(accountId, traposId));
if (!entry || entry.socket !== socket) {
this.log.debug({ messageId: frame.messageId, reason: "no_pending" }, "no entry for computer_response");
return;
}
const pending = entry.pending.get(frame.messageId);
if (!pending) {
this.log.debug({ messageId: frame.messageId, reason: "no_pending" }, "unmatched computer_response");
return;
}
entry.pending.delete(frame.messageId);
clearTimeout(pending.timer);
if (frame.ok) {
pending.resolve(frame.payload);
return;
}
const code = frame.error && isErrorCode(frame.error.code) ? frame.error.code : "internal";
pending.reject(new GatewayError(code, frame.error?.message));
}
private rejectAll(entry: Entry, err: GatewayError): void {
for (const pending of entry.pending.values()) {
clearTimeout(pending.timer);
pending.reject(err);
}
entry.pending.clear();
}
}

View File

@ -0,0 +1,62 @@
import { createOpencodeClient } from "@opencode-ai/sdk";
export type OpencodeProbeConfig = {
opencodeUrl?: string;
opencodeUsername: string;
opencodeServerPassword?: string;
probeTimeoutMs?: number;
// Injectable for unit tests; defaults to the SDK's global fetch.
fetch?: typeof fetch;
};
export type ProbeResult = { ok: boolean; detail?: string };
export type OpencodeProbe = {
probe(): Promise<ProbeResult>;
};
const DEFAULT_PROBE_TIMEOUT_MS = 5000;
// Builds the opencode client once; probe() reuses it and issues a fresh, 5s-bounded
// `config.get()` per call (no caching). No OPENCODE_URL => short-circuit without a call.
export function createOpencodeProbe(config: OpencodeProbeConfig): OpencodeProbe {
const url = config.opencodeUrl;
if (!url) {
return {
probe: () => Promise.resolve({ ok: false, detail: "OPENCODE_URL not set" }),
};
}
const baseUrl = url.replace(/\/+$/, "");
const timeoutMs = config.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
const headers: Record<string, string> = {};
if (config.opencodeServerPassword) {
const basic = Buffer.from(
`${config.opencodeUsername}:${config.opencodeServerPassword}`,
).toString("base64");
headers.Authorization = `Basic ${basic}`;
}
const client = createOpencodeClient({
baseUrl,
headers,
throwOnError: false,
...(config.fetch ? { fetch: config.fetch } : {}),
});
return {
async probe(): Promise<ProbeResult> {
try {
const res = await client.config.get({ signal: AbortSignal.timeout(timeoutMs) });
if (res.response.ok) {
return { ok: true };
}
return { ok: false, detail: `${baseUrl} -> ${res.response.status}` };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, detail: `${baseUrl} -> ${message}` };
}
},
};
}

View File

@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { resolveAccount } from "../src/auth.js";
test("auth disabled when password unset/empty -> always local", () => {
assert.equal(resolveAccount(undefined, undefined), "local");
assert.equal(resolveAccount("anything", undefined), "local");
assert.equal(resolveAccount(undefined, ""), "local");
assert.equal(resolveAccount("anything", ""), "local");
});
test("password set: exact match -> local", () => {
assert.equal(resolveAccount("s3cr3t", "s3cr3t"), "local");
});
test("password set: mismatch -> null", () => {
assert.equal(resolveAccount("wrong", "s3cr3t"), null);
});
test("password set: missing secret -> null", () => {
assert.equal(resolveAccount(undefined, "s3cr3t"), null);
});

View File

@ -0,0 +1,161 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createConnection } from "../src/modules/trapos-cloud-gateway/connection.js";
import { GatewayRegistry } from "../src/modules/trapos-cloud-gateway/registry.js";
import type { OpencodeProbe, ProbeResult } from "../src/opencode.js";
import { FakeSocket, silentLog } from "./helpers.js";
function fakeProbe(result: ProbeResult): OpencodeProbe {
return { probe: () => Promise.resolve(result) };
}
function setup(opts?: { secret?: string; probe?: ProbeResult }) {
const socket = new FakeSocket();
const registry = new GatewayRegistry(silentLog);
const expectedSecret = opts?.secret;
const connection = createConnection({
socket,
registry,
probe: fakeProbe(opts?.probe ?? { ok: true }),
resolveAccount: (secret) => (expectedSecret === undefined || secret === expectedSecret ? "local" : null),
log: silentLog,
});
return { socket, registry, connection };
}
function helloFrame(traposId: string | undefined, secret?: string): string {
const payload: Record<string, unknown> = {};
if (traposId !== undefined) payload.traposId = traposId;
if (secret !== undefined) payload.secret = secret;
return JSON.stringify({ event: "computer_request", traposSenderId: traposId ?? "?", messageId: "h1", type: "hello", payload });
}
function requestFrame(type: string, messageId = "r1"): string {
return JSON.stringify({ event: "computer_request", traposSenderId: "7:", messageId, type, payload: {} });
}
test("pre-hello non-hello request -> not_authenticated, no close, not registered", async () => {
const { socket, registry, connection } = setup();
await connection.onMessage(requestFrame("probe_gateway"));
const frame = socket.lastFrame();
assert.equal(frame.event, "gateway_response");
assert.equal(frame.ok, false);
assert.deepEqual(frame.error, { code: "not_authenticated", message: "hello required before any other request" });
assert.equal(socket.closed, false);
assert.equal(registry.count(), 0);
});
test("pre-hello wrong-direction frame is dropped silently", async () => {
const { socket, connection } = setup();
await connection.onMessage(
JSON.stringify({ event: "computer_response", traposSenderId: "7:", messageId: "x", type: "ping", ok: true, payload: {} }),
);
assert.equal(socket.sent.length, 0);
});
test("hello missing traposId -> not_authenticated, no close, not registered", async () => {
const { socket, registry, connection } = setup();
await connection.onMessage(helloFrame(undefined));
const frame = socket.lastFrame();
assert.equal(frame.ok, false);
assert.deepEqual(frame.error, { code: "not_authenticated", message: "missing traposId" });
assert.equal(socket.closed, false);
assert.equal(registry.count(), 0);
});
test("hello bad secret -> unauthorized and close", async () => {
const { socket, registry, connection } = setup({ secret: "right" });
await connection.onMessage(helloFrame("7:", "wrong"));
const frame = socket.lastFrame();
assert.equal(frame.ok, false);
assert.deepEqual(frame.error, { code: "unauthorized", message: "invalid secret" });
assert.equal(socket.closed, true);
assert.equal(registry.count(), 0);
});
test("hello ok -> registers and replies with accountId", async () => {
const { socket, registry, connection } = setup();
await connection.onMessage(helloFrame("7:", "anything"));
const frame = socket.lastFrame();
assert.equal(frame.event, "gateway_response");
assert.equal(frame.type, "hello");
assert.equal(frame.ok, true);
assert.deepEqual(frame.payload, { accountId: "local" });
assert.equal(registry.has("local", "7:"), true);
assert.equal(registry.count(), 1);
});
test("post-hello probe_gateway (ok) -> sandboxOk + opencodeOk, no detail", async () => {
const { socket, connection } = setup({ probe: { ok: true } });
await connection.onMessage(helloFrame("7:"));
await connection.onMessage(requestFrame("probe_gateway"));
const frame = socket.lastFrame();
assert.equal(frame.ok, true);
assert.deepEqual(frame.payload, { sandboxOk: true, opencodeOk: true });
});
test("post-hello probe_gateway (fail) -> opencodeOk:false + opencodeDetail", async () => {
const { socket, connection } = setup({ probe: { ok: false, detail: "http://x -> 401" } });
await connection.onMessage(helloFrame("7:"));
await connection.onMessage(requestFrame("probe_gateway"));
const frame = socket.lastFrame();
assert.deepEqual(frame.payload, { sandboxOk: true, opencodeOk: false, opencodeDetail: "http://x -> 401" });
});
test("post-hello unknown type -> unknown_type error", async () => {
const { socket, connection } = setup();
await connection.onMessage(helloFrame("7:"));
await connection.onMessage(requestFrame("does_not_exist"));
const frame = socket.lastFrame();
assert.equal(frame.ok, false);
assert.equal((frame.error as { code: string }).code, "unknown_type");
});
test("post-hello duplicate hello is ignored", async () => {
const { socket, registry, connection } = setup();
await connection.onMessage(helloFrame("7:"));
const afterFirst = socket.sent.length;
await connection.onMessage(helloFrame("9:"));
assert.equal(socket.sent.length, afterFirst); // no reply
assert.equal(registry.has("local", "7:"), true);
assert.equal(registry.has("local", "9:"), false);
});
test("post-hello wrong-direction frame is dropped", async () => {
const { socket, connection } = setup();
await connection.onMessage(helloFrame("7:"));
const afterHello = socket.sent.length;
await connection.onMessage(
JSON.stringify({ event: "gateway_request", traposReceiverId: "7:", messageId: "g1", type: "ping", payload: {} }),
);
assert.equal(socket.sent.length, afterHello);
});
test("reverse direction: gateway request resolved by computer_response", async () => {
const { socket, registry, connection } = setup();
await connection.onMessage(helloFrame("7:"));
const promise = registry.request("local", "7:", "ping", {});
const sent = socket.lastFrame();
assert.equal(sent.event, "gateway_request");
assert.equal(sent.type, "ping");
await connection.onMessage(
JSON.stringify({ event: "computer_response", traposSenderId: "7:", messageId: sent.messageId, type: "ping", ok: true, payload: { traposId: "7:" } }),
);
assert.deepEqual(await promise, { traposId: "7:" });
});
test("onClose unregisters the connection", async () => {
const { registry, connection } = setup();
await connection.onMessage(helloFrame("7:"));
assert.equal(registry.count(), 1);
connection.onClose();
assert.equal(registry.count(), 0);
});
test("malformed frame is dropped silently", async () => {
const { socket, connection } = setup();
await connection.onMessage("not json at all");
assert.equal(socket.sent.length, 0);
});

View File

@ -0,0 +1,43 @@
import type { GatewaySocket, Logger } from "../src/modules/trapos-cloud-gateway/registry.js";
export class FakeSocket implements GatewaySocket {
readonly sent: string[] = [];
closed = false;
closeCode?: number;
send(data: string): void {
this.sent.push(data);
}
close(code?: number): void {
this.closed = true;
this.closeCode = code;
}
frames(): Record<string, unknown>[] {
return this.sent.map((s) => JSON.parse(s) as Record<string, unknown>);
}
lastFrame(): Record<string, unknown> {
if (this.sent.length === 0) {
throw new Error("no frames sent");
}
return JSON.parse(this.sent[this.sent.length - 1]) as Record<string, unknown>;
}
}
// Silent logger for unit tests (and a capturing variant when a test wants to assert
// on drop reasons).
export const silentLog: Logger = {
debug: () => undefined,
warn: () => undefined,
};
export function captureLog(): Logger & { entries: { obj: object; msg?: string }[] } {
const entries: { obj: object; msg?: string }[] = [];
return {
entries,
debug: (obj, msg) => entries.push({ obj, msg }),
warn: (obj, msg) => entries.push({ obj, msg }),
};
}

View File

@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createOpencodeProbe } from "../src/opencode.js";
function jsonResponse(status: number): Response {
return new Response(JSON.stringify({}), {
status,
headers: { "content-type": "application/json" },
});
}
test("no OPENCODE_URL -> ok:false without calling fetch", async () => {
let called = false;
const probe = createOpencodeProbe({
opencodeUrl: undefined,
opencodeUsername: "opencode",
fetch: () => {
called = true;
return Promise.resolve(jsonResponse(200));
},
});
const result = await probe.probe();
assert.equal(result.ok, false);
assert.equal(result.detail, "OPENCODE_URL not set");
assert.equal(called, false);
});
test("2xx -> ok:true with no detail", async () => {
const probe = createOpencodeProbe({
opencodeUrl: "http://127.0.0.1:4242",
opencodeUsername: "opencode",
opencodeServerPassword: "pw",
fetch: () => Promise.resolve(jsonResponse(200)),
});
const result = await probe.probe();
assert.equal(result.ok, true);
assert.equal(result.detail, undefined);
});
test("401 -> ok:false with url + status in detail", async () => {
const probe = createOpencodeProbe({
opencodeUrl: "http://127.0.0.1:4242",
opencodeUsername: "opencode",
opencodeServerPassword: "pw",
fetch: () => Promise.resolve(jsonResponse(401)),
});
const result = await probe.probe();
assert.equal(result.ok, false);
assert.match(result.detail ?? "", /127\.0\.0\.1:4242/);
assert.match(result.detail ?? "", /401/);
});
test("fetch throws (timeout/network) -> ok:false with error in detail", async () => {
const probe = createOpencodeProbe({
opencodeUrl: "http://127.0.0.1:4242",
opencodeUsername: "opencode",
fetch: () => Promise.reject(new Error("The operation was aborted")),
});
const result = await probe.probe();
assert.equal(result.ok, false);
assert.match(result.detail ?? "", /aborted/);
});
test("sends Basic auth header built from username + server password", async () => {
let seenAuth: string | null = null;
const probe = createOpencodeProbe({
opencodeUrl: "http://127.0.0.1:4242",
opencodeUsername: "customuser",
opencodeServerPassword: "pw",
fetch: (input, init) => {
// hey-api passes headers on the Request input; fall back to init for safety.
const headers = new Headers(input instanceof Request ? input.headers : init?.headers);
seenAuth = headers.get("authorization") ?? new Headers(init?.headers).get("authorization");
return Promise.resolve(jsonResponse(200));
},
});
await probe.probe();
const expected = "Basic " + Buffer.from("customuser:pw").toString("base64");
assert.equal(seenAuth, expected);
});

View File

@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { helloPayloadSchema, parse } from "../src/modules/trapos-cloud-gateway/protocol.js";
test("parses all four event types", () => {
const cReq = parse(
JSON.stringify({ event: "computer_request", traposSenderId: "7:", messageId: "m1", type: "health", payload: {} }),
);
assert.equal(cReq.event, "computer_request");
const cRes = parse(
JSON.stringify({ event: "computer_response", traposSenderId: "7:", messageId: "m1", type: "ping", ok: true, payload: { traposId: "7:" } }),
);
assert.equal(cRes.event, "computer_response");
const gReq = parse(
JSON.stringify({ event: "gateway_request", traposReceiverId: "7:", messageId: "m2", type: "ping", payload: {} }),
);
assert.equal(gReq.event, "gateway_request");
const gRes = parse(
JSON.stringify({ event: "gateway_response", traposReceiverId: "7:", messageId: "m2", type: "health", ok: true, payload: {} }),
);
assert.equal(gRes.event, "gateway_response");
});
test("parses an ok:false response with error", () => {
const frame = parse(
JSON.stringify({ event: "computer_response", traposSenderId: "7:", messageId: "m1", type: "ping", ok: false, error: { code: "internal", message: "boom" } }),
);
assert.equal(frame.event, "computer_response");
if (frame.event === "computer_response") {
assert.equal(frame.ok, false);
assert.deepEqual(frame.error, { code: "internal", message: "boom" });
}
});
test("normalizes empty-payload gotcha: [] / missing / non-object -> {}", () => {
const fromArray = parse(
JSON.stringify({ event: "computer_request", traposSenderId: "7:", messageId: "m1", type: "health", payload: [] }),
);
assert.deepEqual(fromArray.payload, {});
const fromMissing = parse(
JSON.stringify({ event: "computer_request", traposSenderId: "7:", messageId: "m1", type: "health" }),
);
assert.deepEqual(fromMissing.payload, {});
const fromScalar = parse(
JSON.stringify({ event: "computer_request", traposSenderId: "7:", messageId: "m1", type: "health", payload: 5 }),
);
assert.deepEqual(fromScalar.payload, {});
});
test("throws on malformed JSON", () => {
assert.throws(() => parse("not json"));
});
test("throws on unknown event", () => {
assert.throws(() =>
parse(JSON.stringify({ event: "bogus", traposSenderId: "7:", messageId: "m1", type: "x", payload: {} })),
);
});
test("throws on missing required fields", () => {
assert.throws(() =>
parse(JSON.stringify({ event: "computer_request", messageId: "m1", type: "x", payload: {} })),
);
assert.throws(() =>
parse(JSON.stringify({ event: "computer_request", traposSenderId: "7:", type: "x", payload: {} })),
);
});
test("does NOT validate type (unknown type is a dispatch concern)", () => {
const frame = parse(
JSON.stringify({ event: "computer_request", traposSenderId: "7:", messageId: "m1", type: "totally_made_up", payload: {} }),
);
assert.equal(frame.type, "totally_made_up");
});
test("hello payload schema: requires non-empty traposId, secret optional", () => {
assert.equal(helloPayloadSchema.safeParse({ traposId: "7:", secret: "x" }).success, true);
assert.equal(helloPayloadSchema.safeParse({ traposId: "7:" }).success, true);
assert.equal(helloPayloadSchema.safeParse({ traposId: "" }).success, false);
assert.equal(helloPayloadSchema.safeParse({ secret: "x" }).success, false);
});

View File

@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { GatewayRegistry } from "../src/modules/trapos-cloud-gateway/registry.js";
import { GatewayError, type ComputerResponse } from "../src/modules/trapos-cloud-gateway/protocol.js";
import { FakeSocket, silentLog } from "./helpers.js";
function response(messageId: string, ok: boolean, payload: Record<string, unknown> = {}, error?: { code: string; message?: string }): ComputerResponse {
return { event: "computer_response", traposSenderId: "7:", messageId, type: "ping", ok, payload, error };
}
function sentMessageId(socket: FakeSocket): string {
return socket.lastFrame().messageId as string;
}
test("register / has / count / unregister", () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
reg.register("local", "7:", a);
assert.equal(reg.has("local", "7:"), true);
assert.equal(reg.count(), 1);
reg.unregister("local", "7:", a);
assert.equal(reg.has("local", "7:"), false);
assert.equal(reg.count(), 0);
});
test("newest-wins evicts previous socket for same key", () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
const b = new FakeSocket();
reg.register("local", "7:", a);
reg.register("local", "7:", b);
assert.equal(a.closed, true);
assert.equal(b.closed, false);
assert.equal(reg.count(), 1);
});
test("same traposId under different accounts coexist", () => {
const reg = new GatewayRegistry(silentLog);
reg.register("local", "7:", new FakeSocket());
reg.register("other", "7:", new FakeSocket());
assert.equal(reg.count(), 2);
});
test("request sends a gateway_request frame and resolves on matching ok response", async () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
reg.register("local", "7:", a);
const promise = reg.request("local", "7:", "ping", {});
const frame = a.lastFrame();
assert.equal(frame.event, "gateway_request");
assert.equal(frame.type, "ping");
assert.equal(frame.traposReceiverId, "7:");
reg.resolveResponse(a, "local", "7:", response(frame.messageId as string, true, { traposId: "7:" }));
assert.deepEqual(await promise, { traposId: "7:" });
});
test("request rejects with the response error code on ok:false", async () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
reg.register("local", "7:", a);
const promise = reg.request("local", "7:", "ping", {});
reg.resolveResponse(a, "local", "7:", response(sentMessageId(a), false, {}, { code: "unknown_type", message: "nope" }));
await assert.rejects(promise, (err: unknown) => err instanceof GatewayError && err.code === "unknown_type");
});
test("request rejects not_connected when no socket registered", async () => {
const reg = new GatewayRegistry(silentLog);
await assert.rejects(
reg.request("local", "nobody", "ping", {}),
(err: unknown) => err instanceof GatewayError && err.code === "not_connected",
);
});
test("request rejects timeout after the deadline", async () => {
const reg = new GatewayRegistry(silentLog);
reg.register("local", "7:", new FakeSocket());
await assert.rejects(
reg.request("local", "7:", "ping", {}, 10),
(err: unknown) => err instanceof GatewayError && err.code === "timeout",
);
});
test("messageId correlation: concurrent requests resolve independently", async () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
reg.register("local", "7:", a);
const p1 = reg.request("local", "7:", "ping", { n: 1 });
const id1 = a.lastFrame().messageId as string;
const p2 = reg.request("local", "7:", "ping", { n: 2 });
const id2 = a.lastFrame().messageId as string;
assert.notEqual(id1, id2);
// resolve out of order
reg.resolveResponse(a, "local", "7:", response(id2, true, { n: 2 }));
reg.resolveResponse(a, "local", "7:", response(id1, true, { n: 1 }));
assert.deepEqual(await p1, { n: 1 });
assert.deepEqual(await p2, { n: 2 });
});
test("pending rejected on unregister (close)", async () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
reg.register("local", "7:", a);
const promise = reg.request("local", "7:", "ping", {});
reg.unregister("local", "7:", a);
await assert.rejects(promise, (err: unknown) => err instanceof GatewayError && err.code === "not_connected");
});
test("eviction: old socket's pending rejected and late unregister(A) does not evict B", async () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
const b = new FakeSocket();
reg.register("local", "7:", a);
const promise = reg.request("local", "7:", "ping", {});
reg.register("local", "7:", b); // evict A
await assert.rejects(promise, (err: unknown) => err instanceof GatewayError && err.code === "not_connected");
reg.unregister("local", "7:", a); // late close from evicted A — must be a no-op
assert.equal(reg.has("local", "7:"), true);
assert.equal(reg.count(), 1);
});
test("resolveResponse with no matching pending is a silent no-op", () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
reg.register("local", "7:", a);
// should not throw
reg.resolveResponse(a, "local", "7:", response("unknown-id", true, {}));
});

View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": ".",
"declaration": true,
"incremental": true,
"tsBuildInfoFile": "node_modules/.cache/tsc/tsconfig.tsbuildinfo"
},
"include": ["src/**/*.ts", "test/**/*.ts", "test-integration/**/*.ts"]
}