From 68c1b9d6aa58df046f179c76251f2ff9fd944534 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Sun, 14 Jun 2026 17:17:28 +0200 Subject: [PATCH] test(sandbox): add integration tooling --- .plans/trap-sandbox/04-integration-tooling.md | 16 +- .../trap-sandbox/trap-sandbox-gateway-spec.md | 2 +- Justfile | 1 + docs/public-ports.md | 1 + just/npm.just | 34 ++-- just/sandbox.just | 18 ++ just/test.just | 2 +- .../test-integration/auth.test.ts | 47 +++++ .../test-integration/duplicate.test.ts | 52 ++++++ .../test-integration/full-boot.test.ts | 27 +++ .../trap-sandbox/test-integration/harness.ts | 172 ++++++++++++++++++ .../test-integration/health.test.ts | 49 +++++ .../test-integration/lua/full-boot.lua | 30 +++ .../test-integration/lua/gateway-client.lua | 77 ++++++++ .../test-integration/reverse-ping.test.ts | 28 +++ 15 files changed, 536 insertions(+), 20 deletions(-) create mode 100644 just/sandbox.just create mode 100644 tools/trap-sandbox/test-integration/auth.test.ts create mode 100644 tools/trap-sandbox/test-integration/duplicate.test.ts create mode 100644 tools/trap-sandbox/test-integration/full-boot.test.ts create mode 100644 tools/trap-sandbox/test-integration/harness.ts create mode 100644 tools/trap-sandbox/test-integration/health.test.ts create mode 100644 tools/trap-sandbox/test-integration/lua/full-boot.lua create mode 100644 tools/trap-sandbox/test-integration/lua/gateway-client.lua create mode 100644 tools/trap-sandbox/test-integration/reverse-ping.test.ts diff --git a/.plans/trap-sandbox/04-integration-tooling.md b/.plans/trap-sandbox/04-integration-tooling.md index e1bca04..4e5e71d 100644 --- a/.plans/trap-sandbox/04-integration-tooling.md +++ b/.plans/trap-sandbox/04-integration-tooling.md @@ -9,13 +9,14 @@ Host tooling (`just`/`npm.just` recipes, `sandbox-serve`, docs) and the cross-si **integration** (real craftos ↔ real gateway) plus the **full-boot e2e**. ## Task checklist -- [ ] `just/npm.just`: extend `install/build/check/test/test-integration/ci` to cover +- [x] `just/npm.just`: extend `install/build/check/test/test-integration/ci` to cover `tools/trap-sandbox` alongside mcp-bridge; ensure `just install`/`just ci` include it. -- [ ] `just/opencode.just` (or new recipe): add **`just sandbox-serve`** mirroring `opencode-serve`. -- [ ] `docs/public-ports.md`: add a `4444` row. -- [ ] TS integration harness + cases (`tools/trap-sandbox/test-integration/`). -- [ ] Full-boot e2e. -- [ ] Gate: `npm run test:integration` green; `just ci` covers both tools. + (Symmetric per-tool recipes; `npm-trap-sandbox-ci` removed.) +- [x] **`just sandbox-serve`** added in new `just/sandbox.just` (warn-only on missing secret). +- [x] `docs/public-ports.md`: added a `4444` row. +- [x] TS integration harness + cases (`tools/trap-sandbox/test-integration/`). +- [x] Full-boot e2e. +- [x] Gate: `npm run test:integration` green; `just ci` covers both tools. ## Tooling - `just/npm.just`: extend recipes (install/build/check/test/test-integration/ci) to cover @@ -44,6 +45,7 @@ contains `sandboxOk: true`. Bounded by generous timeouts. ## Verification gate (also the project-level acceptance) - `npm test`, `npm run test:integration`, `npm run lint`/`check` clean in `tools/trap-sandbox`. - `just _craftos-test` runs `tests/sandbox.lua`; `just ci` covers both tools. -- Manual: `just sandbox-serve`; in-game `set sandbox.url ws://:4444/gateway` (+ +- Manual: `just sandbox-serve`; in-game `set sandbox.url ws://:4444` (origin only — + libsandbox appends `/gateway`) (+ `set sandbox.password ` if `SANDBOX_PASSWORD` set), reboot, `sandbox health` → `sandboxOk: true` / `opencodeOk: …`. diff --git a/.plans/trap-sandbox/trap-sandbox-gateway-spec.md b/.plans/trap-sandbox/trap-sandbox-gateway-spec.md index ee948ac..afa781b 100644 --- a/.plans/trap-sandbox/trap-sandbox-gateway-spec.md +++ b/.plans/trap-sandbox/trap-sandbox-gateway-spec.md @@ -38,7 +38,7 @@ verification gate is green. in place, Lua unit tests green via `just _craftos-test`. *Implements the Protocol contract.* - [x] **M3 — Packages** ([03-packages](./03-packages.md)): legacy renamed, new `trapos-sandbox` package + meta bump wired; `packages/index.json` consistent. -- [ ] **M4 — Integration & tooling** ([04-integration-tooling](./04-integration-tooling.md)): +- [x] **M4 — Integration & tooling** ([04-integration-tooling](./04-integration-tooling.md)): `just` recipes, `docs/public-ports.md`, TS integration harness + full-boot e2e green. *Requires both sides — build last.* diff --git a/Justfile b/Justfile index 0d4830f..0e91085 100644 --- a/Justfile +++ b/Justfile @@ -6,6 +6,7 @@ import 'just/install.just' import 'just/npm.just' import 'just/craftos.just' import 'just/opencode.just' +import 'just/sandbox.just' import 'just/test.just' import 'just/check.just' diff --git a/docs/public-ports.md b/docs/public-ports.md index 4038d96..e7ecf8f 100644 --- a/docs/public-ports.md +++ b/docs/public-ports.md @@ -7,6 +7,7 @@ TrapOS public production services use the `4242-4244` TCP range. Keep local deve | `4242` | opencode server HTTP API | Production/public port for `opencode serve`. Local/dev examples may still use opencode's default `4096`. | | `4243` | ComputerCraft bridge WebSocket | Production/public equivalent of the local bridge link default `3001`. Use `CC_LINK_PORT=4243` for production bridge deployments. | | `4244` | Reserved | Free public production port reserved for a future service. Do not assign it casually in local tooling. | +| `4444` | trap-sandbox gateway | Fastify service (`tools/trap-sandbox`) exposing the `trapos-cloud-gateway` WebSocket at `/gateway`. Sits **outside** the `4242-4244` range on purpose (4244 stays reserved). Default `SANDBOX_PORT`; serve with `just sandbox-serve`. | ## Local Vs Production diff --git a/just/npm.just b/just/npm.just index a92a760..0f7f711 100644 --- a/just/npm.just +++ b/just/npm.just @@ -10,28 +10,40 @@ npm-install-trap-sandbox: npm install --prefix tools/trap-sandbox/ # Build Node-based repository tools. -npm-build: +npm-build: npm-build-mcp-bridge npm-build-trap-sandbox + +npm-build-mcp-bridge: npm run build --prefix tools/mcp-bridge +npm-build-trap-sandbox: + npm run build --prefix tools/trap-sandbox/ + # Check Node-based repository tools. -npm-check: +npm-check: npm-check-mcp-bridge npm-check-trap-sandbox + +npm-check-mcp-bridge: npm run check --prefix tools/mcp-bridge +npm-check-trap-sandbox: + npm run check --prefix tools/trap-sandbox/ + # Run Node-based tool tests. -npm-test: +npm-test: npm-test-mcp-bridge npm-test-trap-sandbox + +npm-test-mcp-bridge: npm test --prefix tools/mcp-bridge +npm-test-trap-sandbox: + npm test --prefix tools/trap-sandbox/ + # Run Node-based tool integration tests. -npm-test-integration: +npm-test-integration: npm-test-integration-mcp-bridge npm-test-integration-trap-sandbox + +npm-test-integration-mcp-bridge: npm run test:integration --prefix tools/mcp-bridge -# Run Node-based tool CI. -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 +npm-test-integration-trap-sandbox: + npm run test:integration --prefix tools/trap-sandbox/ # Build generated artifacts. build: npm-build diff --git a/just/sandbox.just b/just/sandbox.just new file mode 100644 index 0000000..0bef92f --- /dev/null +++ b/just/sandbox.just @@ -0,0 +1,18 @@ +# Serve the trap-sandbox gateway (port 4444) for remote/in-game attachment using +# repo-local .env secrets. Mirrors `opencode-serve`, but auth is optional: an unset +# SANDBOX_PASSWORD means the gateway runs with auth disabled (warn, do not fail). +[positional-arguments] +sandbox-serve *args: + #!/usr/bin/env bash + set -euo pipefail + repo='{{justfile_directory()}}' + if [ -f "$repo/.env" ]; then set -a; . "$repo/.env"; set +a; fi + if [ -z "${SANDBOX_PASSWORD:-}" ]; then + printf '%s\n' 'sandbox-serve: SANDBOX_PASSWORD unset in .env — gateway auth disabled' >&2 + fi + export SANDBOX_PASSWORD="${SANDBOX_PASSWORD:-}" + export OPENCODE_SERVER_PASSWORD="${OPENCODE_SERVER_PASSWORD:-}" + export OPENCODE_URL="${OPENCODE_URL:-http://127.0.0.1:4242}" + export SANDBOX_HOST="${SANDBOX_HOST:-0.0.0.0}" + export SANDBOX_PORT="${SANDBOX_PORT:-4444}" + exec npm run dev --prefix tools/trap-sandbox -- "$@" diff --git a/just/test.just b/just/test.just index f8dd1bf..832861b 100644 --- a/just/test.just +++ b/just/test.just @@ -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 npm-trap-sandbox-ci +ci *args: check-craftos check check-packages npm-build npm-test @just _craftos-test {{args}} @just test-integration diff --git a/tools/trap-sandbox/test-integration/auth.test.ts b/tools/trap-sandbox/test-integration/auth.test.ts new file mode 100644 index 0000000..9544156 --- /dev/null +++ b/tools/trap-sandbox/test-integration/auth.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatFailure, startCraftos, startSandbox } from "./harness.js"; + +const SECRET = "s3cr3t-canary"; + +test("hello with the wrong secret is rejected in-band (unauthorized)", async () => { + const sandbox = await startSandbox({ sandboxPassword: SECRET }); + const craftos = startCraftos("gateway-client.lua", { + computerId: 7, + computerLabel: "auth-bad", + shellArgs: [sandbox.baseUrl, "wrong-secret", "auth"], + }); + try { + const result = await craftos.done; + assert.match( + result.output, + /__SANDBOX_HELLO__ ok=false code=unauthorized/, + formatFailure("expected hello ok=false code=unauthorized", result.output), + ); + } finally { + craftos.abort(); + await craftos.done.catch(() => undefined); + await sandbox.close(); + } +}); + +test("hello with the correct secret succeeds", async () => { + const sandbox = await startSandbox({ sandboxPassword: SECRET }); + const craftos = startCraftos("gateway-client.lua", { + computerId: 7, + computerLabel: "auth-ok", + shellArgs: [sandbox.baseUrl, SECRET, "auth"], + }); + try { + const result = await craftos.done; + assert.match( + result.output, + /__SANDBOX_HELLO__ ok=true/, + formatFailure("expected hello ok=true", result.output), + ); + } finally { + craftos.abort(); + await craftos.done.catch(() => undefined); + await sandbox.close(); + } +}); diff --git a/tools/trap-sandbox/test-integration/duplicate.test.ts b/tools/trap-sandbox/test-integration/duplicate.test.ts new file mode 100644 index 0000000..c3b1dab --- /dev/null +++ b/tools/trap-sandbox/test-integration/duplicate.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatFailure, startCraftos, startSandbox, waitFor } from "./harness.js"; + +// Two real computers with the same traposId (id:label): newest-wins. After both +// complete hello, exactly one registration exists; aborting the newest clears it +// (the displaced first computer is in its 5s reconnect backoff), proving the newest +// connection is the one that's registered. +test("duplicate traposId is resolved newest-wins", async () => { + const sandbox = await startSandbox(); + const TRAPOS_ID = "9:dup"; + + const first = startCraftos("gateway-client.lua", { + computerId: 9, + computerLabel: "dup", + shellArgs: [sandbox.baseUrl, "-", "idle"], + }); + const second = startCraftos("gateway-client.lua", { + computerId: 9, + computerLabel: "dup", + shellArgs: [sandbox.baseUrl, "-", "idle"], + }); + + try { + await waitFor(() => first.snapshot().includes("__SANDBOX_READY__"), 12_000, "first connected"); + assert.ok(sandbox.registry.has("local", TRAPOS_ID), "first should be registered"); + + await waitFor(() => second.snapshot().includes("__SANDBOX_READY__"), 12_000, "second connected"); + // Newest-wins: still exactly one registration for the shared id. + assert.equal(sandbox.registry.count(), 1, "exactly one registration after the duplicate"); + assert.ok(sandbox.registry.has("local", TRAPOS_ID)); + + // Aborting the newest clears the entry; the displaced first is in reconnect + // backoff (~5s) and has not re-registered yet. + second.abort(); + await second.done.catch(() => undefined); + await waitFor(() => sandbox.registry.count() === 0, 3_000, "newest was the live registration"); + } catch (error) { + throw new Error( + formatFailure( + error instanceof Error ? error.message : String(error), + `first:\n${first.snapshot()}\nsecond:\n${second.snapshot()}`, + ), + { cause: error }, + ); + } finally { + first.abort(); + second.abort(); + await Promise.all([first.done.catch(() => undefined), second.done.catch(() => undefined)]); + await sandbox.close(); + } +}); diff --git a/tools/trap-sandbox/test-integration/full-boot.test.ts b/tools/trap-sandbox/test-integration/full-boot.test.ts new file mode 100644 index 0000000..4ff7e23 --- /dev/null +++ b/tools/trap-sandbox/test-integration/full-boot.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatFailure, startCraftos, startFakeOpencode, startSandbox } from "./harness.js"; + +// Full-boot e2e: a real CraftOS computer runs the real servers/sandbox.lua daemon +// (driven by sandbox.* settings) and the real `sandbox health` program against the +// real gateway, with a healthy fake-opencode behind it. +test("full boot: settings -> daemon -> sandbox health prints sandboxOk/opencodeOk true", async () => { + const opencode = await startFakeOpencode({ status: 200 }); + const sandbox = await startSandbox({ sandboxPassword: "boot-secret", opencodeUrl: opencode.url }); + const craftos = startCraftos("full-boot.lua", { + computerId: 11, + computerLabel: "boot", + shellArgs: [sandbox.baseUrl, "boot-secret"], + timeoutMs: 20_000, + }); + try { + const result = await craftos.done; + assert.match(result.output, /sandboxOk:\s*true/, formatFailure("expected sandboxOk: true", result.output)); + assert.match(result.output, /opencodeOk:\s*true/, formatFailure("expected opencodeOk: true", result.output)); + } finally { + craftos.abort(); + await craftos.done.catch(() => undefined); + await sandbox.close(); + await opencode.close(); + } +}); diff --git a/tools/trap-sandbox/test-integration/harness.ts b/tools/trap-sandbox/test-integration/harness.ts new file mode 100644 index 0000000..c8d995f --- /dev/null +++ b/tools/trap-sandbox/test-integration/harness.ts @@ -0,0 +1,172 @@ +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AddressInfo } from "node:net"; +import { createApp, type Config } from "../src/app.js"; +import type { GatewayRegistry } from "../src/modules/trapos-cloud-gateway/registry.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LUA_DIR = join(HERE, "lua"); +const REPO_ROOT = join(HERE, "../../.."); + +export type Sandbox = { + registry: GatewayRegistry; + // Gateway origin (no path). This is what `sandbox.url` expects: libsandbox appends + // `/gateway` itself. Pass this to the Lua scripts. + baseUrl: string; + // Full WS endpoint (origin + `/gateway`), for direct ws clients. + socketUrl: string; + close: () => Promise; +}; + +export type SandboxOptions = { + sandboxPassword?: string; + opencodeUrl?: string; + requestTimeoutMs?: number; +}; + +// Real Fastify gateway on an ephemeral port. Mirrors index.ts's wiring but with a +// silent logger and test-supplied config (never reads process.env). +export async function startSandbox(opts: SandboxOptions = {}): Promise { + const config: Config = { + host: "127.0.0.1", + port: 0, + sandboxPassword: opts.sandboxPassword, + requestTimeoutMs: opts.requestTimeoutMs ?? 5_000, + opencodeUrl: opts.opencodeUrl, + opencodeUsername: "opencode", + opencodeServerPassword: undefined, + logger: { level: "silent" }, + }; + + const { app, registry } = await createApp(config); + await app.listen({ host: config.host, port: config.port }); + const { port } = app.server.address() as AddressInfo; + + const baseUrl = `ws://127.0.0.1:${port}`; + return { + registry, + baseUrl, + socketUrl: `${baseUrl}/gateway`, + close: () => app.close(), + }; +} + +export type FakeOpencode = { + url: string; + close: () => Promise; +}; + +// Tiny stand-in for the opencode server. The probe issues `GET /config`; we reply +// with `status` (default 200) on every path so opencodeOk can be steered per test. +export async function startFakeOpencode(opts: { status?: number } = {}): Promise { + const status = opts.status ?? 200; + const server = createServer((_req, res) => { + res.writeHead(status, { "content-type": "application/json" }); + res.end("{}"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +export async function waitFor(predicate: () => boolean, timeoutMs: number, label: string): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await sleep(50); + } + throw new Error(`waitFor timed out: ${label}`); +} + +export type CraftosResult = { + status: number | null; + signal: NodeJS.Signals | null; + output: string; +}; + +export type CraftosHandle = { + done: Promise; + abort: () => void; + // Stdout/stderr accumulated so far; lets a test await a marker line while the + // process is still running (e.g. wait for "__SANDBOX_READY__" before acting). + snapshot: () => string; +}; + +// Spawn headless CraftOS-PC running a Lua script from test-integration/lua, with the +// repo mounted read-only (/apis, /servers, /programs, /trapos) so the scripts can +// require the real libsandbox / servers / programs. +export function startCraftos( + luaName: string, + opts: { computerId?: number; computerLabel?: string; shellArgs?: string[]; timeoutMs?: number } = {}, +): CraftosHandle { + const timeoutMs = opts.timeoutMs ?? 15_000; + const controller = new AbortController(); + const chunks: Buffer[] = []; + + const done = (async (): Promise => { + const dataDir = await mkdtemp(join(tmpdir(), "trap-sandbox-it-")); + const watchdog = setTimeout(() => controller.abort(), timeoutMs); + try { + const args: string[] = [ + "--directory", dataDir, + "--headless", + "--mount-ro", `/staging=${LUA_DIR}`, + "--mount-ro", `/trapos=${REPO_ROOT}`, + "--mount-ro", `/apis=${join(REPO_ROOT, "apis")}`, + "--mount-ro", `/servers=${join(REPO_ROOT, "servers")}`, + "--mount-ro", `/programs=${join(REPO_ROOT, "programs")}`, + ]; + if (opts.computerId !== undefined) { + args.push("--id", String(opts.computerId)); + } + if (process.platform === "darwin") { + args.push("--rom", "/Applications/CraftOS-PC.app/Contents/Resources"); + } + args.push("--exec", buildExecCode(luaName, opts.shellArgs ?? [], opts.computerLabel)); + + const child = spawn("craftos", args, { signal: controller.signal }); + child.stdout.on("data", (d: Buffer) => chunks.push(d)); + child.stderr.on("data", (d: Buffer) => chunks.push(d)); + + const result = await new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.once("close", (code, signal) => resolve({ status: code, signal })); + child.once("error", () => resolve({ status: null, signal: null })); + }); + + return { status: result.status, signal: result.signal, output: Buffer.concat(chunks).toString("utf8") }; + } finally { + clearTimeout(watchdog); + await rm(dataDir, { recursive: true, force: true }); + } + })(); + + return { done, abort: () => controller.abort(), snapshot: () => Buffer.concat(chunks).toString("utf8") }; +} + +export function formatFailure(message: string, craftosOutput: string): string { + return ["\x1b[31mFAIL\x1b[0m " + message, "--- craftos output ---", craftosOutput.trimEnd(), "----------------------"].join("\n"); +} + +function buildExecCode(luaName: string, shellArgs: string[], computerLabel?: string): string { + const programPath = luaName.startsWith("/") ? luaName : `/staging/${luaName}`; + const parts = [luaQuote(programPath), ...shellArgs.map(luaQuote)]; + const setup = computerLabel === undefined ? "" : `os.setComputerLabel(${luaQuote(computerLabel)}); `; + return `${setup}shell.run(${parts.join(", ")})`; +} + +function luaQuote(value: string): string { + return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/tools/trap-sandbox/test-integration/health.test.ts b/tools/trap-sandbox/test-integration/health.test.ts new file mode 100644 index 0000000..d3a9af4 --- /dev/null +++ b/tools/trap-sandbox/test-integration/health.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatFailure, startCraftos, startFakeOpencode, startSandbox } from "./harness.js"; + +test("probe_gateway reports opencodeOk=true when opencode is healthy", async () => { + const opencode = await startFakeOpencode({ status: 200 }); + const sandbox = await startSandbox({ opencodeUrl: opencode.url }); + const craftos = startCraftos("gateway-client.lua", { + computerId: 7, + computerLabel: "health-ok", + shellArgs: [sandbox.baseUrl, "-", "health"], + }); + try { + const result = await craftos.done; + assert.match( + result.output, + /__SANDBOX_HEALTH__ sandboxOk=true opencodeOk=true/, + formatFailure("expected sandboxOk=true opencodeOk=true", result.output), + ); + } finally { + craftos.abort(); + await craftos.done.catch(() => undefined); + await sandbox.close(); + await opencode.close(); + } +}); + +test("probe_gateway reports opencodeOk=false when opencode is unhealthy", async () => { + const opencode = await startFakeOpencode({ status: 500 }); + const sandbox = await startSandbox({ opencodeUrl: opencode.url }); + const craftos = startCraftos("gateway-client.lua", { + computerId: 7, + computerLabel: "health-bad", + shellArgs: [sandbox.baseUrl, "-", "health"], + }); + try { + const result = await craftos.done; + assert.match( + result.output, + /__SANDBOX_HEALTH__ sandboxOk=true opencodeOk=false/, + formatFailure("expected sandboxOk=true opencodeOk=false", result.output), + ); + } finally { + craftos.abort(); + await craftos.done.catch(() => undefined); + await sandbox.close(); + await opencode.close(); + } +}); diff --git a/tools/trap-sandbox/test-integration/lua/full-boot.lua b/tools/trap-sandbox/test-integration/lua/full-boot.lua new file mode 100644 index 0000000..9e57488 --- /dev/null +++ b/tools/trap-sandbox/test-integration/lua/full-boot.lua @@ -0,0 +1,30 @@ +-- Full-boot e2e: replicate the real boot path (startup/servers.lua) closely enough +-- to prove the whole stack works end to end. Sets the sandbox.* settings, creates +-- the global event loop, runs the REAL servers/sandbox.lua daemon, then under +-- parallel waits for the connection and runs the REAL `sandbox health` program. +-- +-- Args (via shell.run): url, secret ('' => none). +-- NOTE: shell.run drops empty-string args; the harness passes '-' for "no secret". +local args = { ... }; +local url = args[1]; +local secret = args[2]; + +settings.set('sandbox.url', url); +if secret and secret ~= '' and secret ~= '-' then + settings.set('sandbox.password', secret); +end + +_G.bootEventLoop = require('/apis/eventloop')(); + +-- Start the real daemon (reads sandbox.url/sandbox.password, opens the WS). +shell.run('/servers/sandbox.lua'); + +parallel.waitForAny( + function() _G.bootEventLoop.runLoop(true); end, + function() + os.pullEvent('trapos_sandbox_connected'); + shell.run('/programs/sandbox.lua', 'health'); + end +); + +os.shutdown(); diff --git a/tools/trap-sandbox/test-integration/lua/gateway-client.lua b/tools/trap-sandbox/test-integration/lua/gateway-client.lua new file mode 100644 index 0000000..fa87e4c --- /dev/null +++ b/tools/trap-sandbox/test-integration/lua/gateway-client.lua @@ -0,0 +1,77 @@ +-- Integration-test driver: exercises the REAL trapos-sandbox client (libsandbox) +-- against a real gateway. Reuses startSession/request/onMessage so the test covers +-- production client code + CraftOS JSON serialization quirks. +-- +-- Args (via shell.run): url, secret, scenario. +-- url gateway origin, e.g. ws://127.0.0.1:PORT (daemon appends /gateway) +-- secret shared secret ('' => none) +-- scenario health | auth | idle (default: health) +-- +-- Each scenario prints a single sentinel line the TS test asserts on. +-- NOTE: shell.run re-tokenizes its arguments, so empty-string args are dropped and +-- positions shift. The harness passes '-' as the "no secret" sentinel instead of ''. +local args = { ... }; +local url = args[1]; +local secret = args[2]; +if secret == '' or secret == '-' then secret = nil; end +local scenario = args[3] or 'health'; + +local createSandbox = require('/apis/libsandbox'); +local createEventLoop = require('/apis/eventloop'); + +_G.bootEventLoop = createEventLoop(); +local sandbox = createSandbox(); +local session = sandbox.startSession({ + eventloop = _G.bootEventLoop, + url = url, + secret = secret, + os = os, +}); + +-- Connect, probe the gateway, report opencodeOk. +local function runHealth() + os.pullEvent('trapos_sandbox_connected'); + local ok, payload = sandbox.request('probe_gateway'); + if ok then + print('__SANDBOX_HEALTH__ sandboxOk=' .. tostring(payload.sandboxOk) + .. ' opencodeOk=' .. tostring(payload.opencodeOk)); + else + print('__SANDBOX_HEALTH__ error=' .. tostring(type(payload) == 'table' and payload.code)); + end +end + +-- Poll the hello outcome: ready => ok:true, terminal lastError => ok:false code. +local function runAuth() + local deadline = os.epoch('utc') + 8000; + while os.epoch('utc') < deadline do + if session.isReady() then + print('__SANDBOX_HELLO__ ok=true'); + return; + end + local err = session.lastError(); + if err then + print('__SANDBOX_HELLO__ ok=false code=' .. tostring(err)); + return; + end + os.sleep(0.1); + end + print('__SANDBOX_HELLO__ ok=false code=timeout'); +end + +-- Connect and stay registered so the TS side can drive the gateway->computer +-- direction (reverse ping) or assert registry state (duplicate newest-wins). +local function runIdle() + os.pullEvent('trapos_sandbox_connected'); + print('__SANDBOX_READY__ ' .. sandbox.traposId(os)); + os.sleep(10); +end + +local scenarios = { health = runHealth, auth = runAuth, idle = runIdle }; +local run = scenarios[scenario] or runHealth; + +parallel.waitForAny( + function() _G.bootEventLoop.runLoop(true); end, + run +); + +os.shutdown(); diff --git a/tools/trap-sandbox/test-integration/reverse-ping.test.ts b/tools/trap-sandbox/test-integration/reverse-ping.test.ts new file mode 100644 index 0000000..f58d39f --- /dev/null +++ b/tools/trap-sandbox/test-integration/reverse-ping.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatFailure, startCraftos, startSandbox, waitFor } from "./harness.js"; + +// Exercises the gateway -> computer direction: the gateway sends a `ping` +// gateway_request and the real libsandbox client answers with a computer_response. +test("gateway->computer reverse ping is answered by the client", async () => { + const sandbox = await startSandbox(); + const craftos = startCraftos("gateway-client.lua", { + computerId: 7, + computerLabel: "ping", + shellArgs: [sandbox.baseUrl, "-", "idle"], + }); + try { + await waitFor(() => sandbox.registry.has("local", "7:ping"), 12_000, "computer registered"); + const payload = await sandbox.registry.request("local", "7:ping", "ping"); + assert.deepEqual(payload, { traposId: "7:ping" }); + } catch (error) { + throw new Error( + formatFailure(error instanceof Error ? error.message : String(error), craftos.snapshot()), + { cause: error }, + ); + } finally { + craftos.abort(); + await craftos.done.catch(() => undefined); + await sandbox.close(); + } +});