feat(trap-sandbox): add gateway service
This commit is contained in:
parent
a8485a780f
commit
cd6427c454
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }`.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
26
tools/trap-sandbox/eslint.config.js
Normal file
26
tools/trap-sandbox/eslint.config.js
Normal 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",
|
||||
},
|
||||
},
|
||||
);
|
||||
2657
tools/trap-sandbox/package-lock.json
generated
2657
tools/trap-sandbox/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
44
tools/trap-sandbox/src/app.ts
Normal file
44
tools/trap-sandbox/src/app.ts
Normal 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 };
|
||||
}
|
||||
15
tools/trap-sandbox/src/auth.ts
Normal file
15
tools/trap-sandbox/src/auth.ts
Normal 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;
|
||||
}
|
||||
58
tools/trap-sandbox/src/index.ts
Normal file
58
tools/trap-sandbox/src/index.ts
Normal 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;
|
||||
}
|
||||
@ -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 };
|
||||
49
tools/trap-sandbox/src/modules/trapos-cloud-gateway/index.ts
Normal file
49
tools/trap-sandbox/src/modules/trapos-cloud-gateway/index.ts
Normal 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;
|
||||
111
tools/trap-sandbox/src/modules/trapos-cloud-gateway/protocol.ts
Normal file
111
tools/trap-sandbox/src/modules/trapos-cloud-gateway/protocol.ts
Normal 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(),
|
||||
});
|
||||
159
tools/trap-sandbox/src/modules/trapos-cloud-gateway/registry.ts
Normal file
159
tools/trap-sandbox/src/modules/trapos-cloud-gateway/registry.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
62
tools/trap-sandbox/src/opencode.ts
Normal file
62
tools/trap-sandbox/src/opencode.ts
Normal 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}` };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
22
tools/trap-sandbox/test/auth.test.ts
Normal file
22
tools/trap-sandbox/test/auth.test.ts
Normal 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);
|
||||
});
|
||||
161
tools/trap-sandbox/test/connection.test.ts
Normal file
161
tools/trap-sandbox/test/connection.test.ts
Normal 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);
|
||||
});
|
||||
43
tools/trap-sandbox/test/helpers.ts
Normal file
43
tools/trap-sandbox/test/helpers.ts
Normal 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 }),
|
||||
};
|
||||
}
|
||||
80
tools/trap-sandbox/test/opencode.test.ts
Normal file
80
tools/trap-sandbox/test/opencode.test.ts
Normal 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);
|
||||
});
|
||||
86
tools/trap-sandbox/test/protocol.test.ts
Normal file
86
tools/trap-sandbox/test/protocol.test.ts
Normal 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);
|
||||
});
|
||||
135
tools/trap-sandbox/test/registry.test.ts
Normal file
135
tools/trap-sandbox/test/registry.test.ts
Normal 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, {}));
|
||||
});
|
||||
17
tools/trap-sandbox/tsconfig.json
Normal file
17
tools/trap-sandbox/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user