refactor: rename trapos-sandbox to trapos-server

This commit is contained in:
Guillaume ARM 2026-06-17 01:25:16 +02:00
parent 2d13242f29
commit 618e1a62dc
84 changed files with 531 additions and 906 deletions

View File

@ -7,4 +7,4 @@ TRAPOS_TEST_TIMEOUT_WATCHDOG_SECONDS=1
# Test placeholders. The real values are generated in `.env` on first `just install`, # Test placeholders. The real values are generated in `.env` on first `just install`,
# or after `just clean` / `just reinstall`. # or after `just clean` / `just reinstall`.
OPENCODE_SERVER_PASSWORD=redacted OPENCODE_SERVER_PASSWORD=redacted
SANDBOX_PASSWORD=redacted TRAPOS_SERVER_PASSWORD=redacted

View File

@ -1,10 +1,10 @@
-- Luacheck config for CC:Tweaked (ComputerCraft) Lua sandbox. -- Luacheck config for CC:Tweaked (ComputerCraft) Lua runtime.
-- Runtime is Lua 5.1 inside the CC sandbox, not standard Lua, so we -- Runtime is Lua 5.1 inside the CC:Tweaked runtime, not standard Lua, so we
-- extend lua51 with the globals and os/table extensions CC provides. -- extend lua51 with the globals and os/table extensions CC provides.
stds.cc = { stds.cc = {
read_globals = { read_globals = {
-- ComputerCraft APIs injected as globals by the sandbox. -- ComputerCraft APIs injected as globals by the runtime.
"fs", "fs",
"peripheral", "peripheral",
"parallel", "parallel",

View File

@ -1,6 +1,6 @@
{ {
"mcpServers": { "mcpServers": {
"trapos-cloud-gateway-mcp": { "trapos-mcp": {
"type": "http", "type": "http",
"url": "http://127.0.0.1:4445" "url": "http://127.0.0.1:4445"
} }

View File

@ -12,8 +12,8 @@ permission:
bash: bash:
"*": ask "*": ask
"just check": allow "just check": allow
trapos-cloud-gateway-mcp_probe-computers: allow trapos-mcp_probe-computers: allow
trapos-cloud-gateway-mcp_exec-lua: allow trapos-mcp_exec-lua: allow
--- ---
You answer players using TrapOS `ai` from inside ATM10 / All The Mods 10. You answer players using TrapOS `ai` from inside ATM10 / All The Mods 10.
@ -23,7 +23,7 @@ Target environment:
- ATM10 / All The Mods 10 pack version `7.0`; accept `0.7.0` as the same user alias. - ATM10 / All The Mods 10 pack version `7.0`; accept `0.7.0` as the same user alias.
- Minecraft `1.21.1`. - Minecraft `1.21.1`.
- NeoForge `21.1.228`. - NeoForge `21.1.228`.
- ComputerCraft is CC:Tweaked in the ComputerCraft sandbox, not desktop Lua. - ComputerCraft is CC:Tweaked in the ComputerCraft runtime, not desktop Lua.
Response style: Response style:

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"mcp": { "mcp": {
"trapos-cloud-gateway-mcp": { "trapos-mcp": {
"type": "remote", "type": "remote",
"url": "http://127.0.0.1:4445", "url": "http://127.0.0.1:4445",
"enabled": true "enabled": true

View File

@ -114,7 +114,7 @@ ADR-0019, not fixed here to keep scope contained.)
Rewrite the "postinstall before startup servers" test (lines 164-174): drop the Rewrite the "postinstall before startup servers" test (lines 164-174): drop the
`/startup/servers.lua` lookup; assert reboot occurs after `trapos-postinstall`. In the `/startup/servers.lua` lookup; assert reboot occurs after `trapos-postinstall`. In the
`--cpm-only` test (176-184), assert reboot was **not** called. `--cpm-only` test (176-184), assert reboot was **not** called.
- `tools/trap-sandbox/test-integration/lua/full-boot.lua:1`: update the comment reference - `tools/trapos-server/test-integration/lua/full-boot.lua:1`: update the comment reference
`startup/servers.lua``startup/boot.lua` (no functional change — it doesn't load the file). `startup/servers.lua``startup/boot.lua` (no functional change — it doesn't load the file).
### 7. Docs ### 7. Docs
@ -138,7 +138,7 @@ ADR-0019, not fixed here to keep scope contained.)
- `packages/trapos-boot/ccpm.json`, `packages/index.json` (file list + version bumps) - `packages/trapos-boot/ccpm.json`, `packages/index.json` (file list + version bumps)
- `tests/startup-servers.lua`**rename** `tests/boot.lua` (policy cases) - `tests/startup-servers.lua`**rename** `tests/boot.lua` (policy cases)
- `tests/install-trapos.lua` (os stub + reboot assertions) - `tests/install-trapos.lua` (os stub + reboot assertions)
- `tools/trap-sandbox/test-integration/lua/full-boot.lua` (comment) - `tools/trapos-server/test-integration/lua/full-boot.lua` (comment)
- `docs/adrs/adr-0019-boot-lifecycle.md` (**new**), `docs/adrs/README.md` - `docs/adrs/adr-0019-boot-lifecycle.md` (**new**), `docs/adrs/README.md`
- `README.md`, `AGENTS.md`, `docs/periphemu.md` - `README.md`, `AGENTS.md`, `docs/periphemu.md`
@ -157,5 +157,5 @@ ADR-0019, not fixed here to keep scope contained.)
- **Upgrade migration**: on a computer with the old `startup/servers.lua` + `startup/motd.lua` - **Upgrade migration**: on a computer with the old `startup/servers.lua` + `startup/motd.lua`
present, drop in the new `boot.lua` and reboot; confirm both stale files are deleted and only present, drop in the new `boot.lua` and reboot; confirm both stale files are deleted and only
`boot.lua` remains in `/startup/`. `boot.lua` remains in `/startup/`.
- **Installer**: run `install-trapos.lua` end-to-end (or via the sandbox harness) and confirm it - **Installer**: run `install-trapos.lua` end-to-end (or via the test harness) and confirm it
finishes with `=> Rebooting...` and comes back up into a live TrapOS rather than a nested shell. finishes with `=> Rebooting...` and comes back up into a live TrapOS rather than a nested shell.

View File

@ -1,93 +0,0 @@
# 01 — TS gateway (`tools/trap-sandbox/`)
> Sub-plan of [trap-sandbox-gateway-spec](./trap-sandbox-gateway-spec.md). Implements the
> **Protocol** and **Handshake** contracts defined there — do not redefine frame shapes or error
> codes here; reference the spec. **Build first** (M1): no Lua dependency.
> **Grill-refined (implemented).** This sub-plan was hardened in a follow-up grill and built. Deltas
> from the text below, all in the shipped code:
> - WS `type` `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
**unit** tests. Integration/e2e tests live in [04-integration-tooling](./04-integration-tooling.md).
## Task checklist
- [ ] Scaffold `tools/trap-sandbox/` (ESM, `tsc` build, `tsx` dev/test, `node:test`, typescript-eslint
flat config — copy `mcp-bridge`'s `tsconfig.json`/`eslint.config.js`).
- [ ] `package.json`: deps + devDeps + scripts (see below).
- [ ] `src/auth.ts``resolveAccount(secret)`.
- [ ] `src/opencode.ts``createOpencodeProbe(config)`.
- [ ] `src/modules/trapos-cloud-gateway/protocol.ts` — envelope/error types + parse/validate.
- [ ] `src/modules/trapos-cloud-gateway/registry.ts``GatewayRegistry`.
- [ ] `src/modules/trapos-cloud-gateway/index.ts` — Fastify plugin: `GET /health` (open) + `GET /gateway` (ws).
- [ ] `src/app.ts``createApp(config)` returning `{ app, registry }`.
- [ ] `src/index.ts` — env config, listen, `EADDRINUSE` clear message + exit.
- [ ] TS unit tests (`test/`): protocol, registry, auth/hello, probe.
- [ ] Gate: `npm test`, `npm run lint`/`check` clean.
## Layout
ESM, `tsc` build, `tsx` dev/test, `node:test`, typescript-eslint flat config (copy mcp-bridge's
`tsconfig.json`/`eslint.config.js`). Fastify `^5` + `@fastify/websocket` `^11`; `engines.node>=20`.
```
src/
├── index.ts # env config; build app; listen on SANDBOX_PORT; clear EADDRINUSE message + exit
├── app.ts # createApp(config): Fastify + GatewayRegistry; register @fastify/websocket + module; returns { app, registry }
├── auth.ts # resolveAccount(secret): "local" | null (constant seam; null = unauthorized)
├── opencode.ts # createOpencodeProbe(config): probe(): { ok, detail } via @opencode-ai/sdk
└── modules/trapos-cloud-gateway/
├── index.ts # Fastify plugin: GET /health (open) + GET /gateway (ws)
├── protocol.ts # envelope/error types + parse/validate (4 events, hello, empty-payload→{})
└── registry.ts # GatewayRegistry keyed by (accountId,traposId); newest-wins; request()+timeout; pending cleanup on close
test/ # TS unit (protocol, registry, auth/hello, probe) with FakeSocket + fake fetch
```
## Behavior
- **WS `/gateway`** (`{ websocket: true }`): no upgrade auth (auth is the hello frame). `maxPayload`
~1MB. `socket.on("message")` parses a frame inside try/catch → dispatch: pre-hello only `hello` is
honored; post-hello handle `computer_request` (`health`, else `unknown_type`) and
`computer_response` (resolve pending `gateway_request` by messageId). `close`/`error` → unregister
+ reject that connection's pendings. (Handshake steps & error codes: see spec **Handshake**.)
- **GatewayRegistry**: `register`/`unregister`/`has`/`count`; `request(accountId, traposId, type,
payload, timeoutMs=600000)` sends `gateway_request`, races matching `computer_response` vs timeout.
Keyed by `(accountId, traposId)`, newest-wins.
- **opencode probe**: `createOpencodeClient({ baseUrl: OPENCODE_URL, headers:{ Authorization: basic
("opencode", OPENCODE_SERVER_PASSWORD) }, throwOnError:false })` then `config.get()` with
`AbortSignal.timeout(5000)`, wrapped in try/catch. `ok = response 2xx`; `detail` = url + status or
error. No `OPENCODE_URL``{ ok:false, detail:"OPENCODE_URL not set" }` without a call.
- **auth**: `resolveAccount(secret)``"local" | null` (constant seam; `null` = unauthorized).
## Env (`index.ts`)
`SANDBOX_HOST`(`0.0.0.0`), `SANDBOX_PORT`(`4444`), `SANDBOX_PASSWORD`(unset⇒ auth off),
`SANDBOX_REQUEST_TIMEOUT_MS`(`600000`), `OPENCODE_URL`(`http://127.0.0.1:4242`),
`OPENCODE_SERVER_PASSWORD`, `OPENCODE_USERNAME`(`opencode`).
## package.json
- deps: `fastify`, `@fastify/websocket`, `@opencode-ai/sdk` (move from devDeps).
- devDeps: copy mcp-bridge (`typescript`, `tsx`, `@eslint/js`, `typescript-eslint`, `@types/node`,
`globals`, `eslint`, `@types/ws`).
- scripts: copy mcp-bridge **plus `"lint":"npm run eslint"`**.
## TS unit tests (`test/`)
Protocol parse/validate (4 events, hello, `ok`/`error`, empty-payload→`{}`), registry (duplicate
newest-wins per account, messageId correlation, timeout, pending cleanup on close), `resolveAccount`,
opencode probe (fake fetch/server: ok / 401 / timeout). Use `FakeSocket` + fake fetch.
## Verification gate
`npm test` + `npm run lint`/`check` clean in `tools/trap-sandbox`; service starts and listens on 4444.

View File

@ -1,60 +0,0 @@
# 02 — Lua client (`trapos-sandbox`)
> Sub-plan of [trap-sandbox-gateway-spec](./trap-sandbox-gateway-spec.md). Implements the
> **Protocol**, **Handshake**, and **os-event contract** from the spec — reference them, don't
> redefine. M2; depends on the protocol contract (not on M1 code existing).
## Scope
The Lua side: `apis/libsandbox.lua`, `servers/sandbox.lua`, `programs/sandbox.lua`, and Lua **unit**
tests. Package/meta changes are [03-packages](./03-packages.md); integration/e2e is
[04-integration-tooling](./04-integration-tooling.md).
## Task checklist
- [ ] `apis/libsandbox.lua` (template: `apis/libmcpcomputer.lua`).
- [ ] `servers/sandbox.lua` (template: `servers/mcp-computer-server.lua`).
- [ ] `programs/sandbox.lua` (template: `programs/ai.lua`).
- [ ] Lua unit tests (`tests/sandbox.lua`).
- [ ] Gate: `just _craftos-test` runs `tests/sandbox.lua` green.
## `apis/libsandbox.lua` — template: `apis/libmcpcomputer.lua`
`createSandbox()` returns:
- `traposId(osLike)``id .. ":" .. (label or "")`.
- 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,
payload, error)`.
- `startSession(opts)` (daemon; injectable `http`/`eventloop`/`encode`/`decode`/`os`): builds the
connect URL from base `opts.url`, computes `traposId`; `websocketAsync`; on `websocket_success`
send hello; on hello `ok:true` set state=ready + `queueEvent('trapos_sandbox_connected', traposId)`;
on hello `ok:false` set state=unauthorized (**terminal**, store `lastError`, close, no reconnect);
on `websocket_failure`/`_closed` reconnect via `el.setTimeout(connect, 5)` (unless unauthorized);
hello with no ack in ~10s → close + reconnect; `trapos_sandbox_send` handler builds
`computer_request`, **pcall-send** only when ready, else fast `trapos_sandbox_reply(messageId,
false, nil, {code:"not_connected"})`. Returns `{ isReady, lastError }`.
- `request(type, payload, opts)` (injectable `queueEvent`/`pullEvent`/`startTimer`): mint messageId,
queue `trapos_sandbox_send`, wait for matching `trapos_sandbox_reply` vs a timer (**default 10s**,
overridable). Returns `(ok, payloadOrError)`.
## `servers/sandbox.lua` — template: `servers/mcp-computer-server.lua`
Read `sandbox.url` (unset/`http.websocket` missing → "daemon inactive", return) and `sandbox.password`;
`startSession{ eventloop=_G.bootEventLoop, url, password(secret), os }`; print version.
## `programs/sandbox.lua` — template: `programs/ai.lua`
- Reads `sandbox.url` first; unset → immediate `set sandbox.url first` (no 10s wait).
- `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)
Envelope builders, `traposId`, `onMessage` dispatch (ping ok / unknown_type / hello), empty-payload
coercion, `startSession` wiring (fake eventloop+http: hello sent on open, ready on ack, terminal on
unauthorized, reconnect on drop), `request()` resolve + timeout via injected event primitives.
## Verification gate
`just _craftos-test` runs the new `tests/sandbox.lua` green.

View File

@ -1,27 +0,0 @@
# 03 — Packages (rename + new package + meta)
> Sub-plan of [trap-sandbox-gateway-spec](./trap-sandbox-gateway-spec.md). Mechanical wiring. M3;
> may run in parallel with M2. Depends on the Lua file paths from
> [02-lua-client](./02-lua-client.md) being settled.
## Scope
ccpm package definitions only: rename the legacy package, register the new `trapos-sandbox`, keep
`packages/index.json` consistent, and add `trapos-sandbox` to the `trapos` meta with a version bump.
## Task checklist
- [ ] **Rename** `packages/trapos-sandbox/``packages/trapos-sandbox-legacy/`; set
`"name": "trapos-sandbox-legacy"` (keep its files/autostart).
- [ ] **New** `packages/trapos-sandbox/ccpm.json`: name `trapos-sandbox`, version `0.1.0`, deps
`["trapos-core"]`, files `["apis/libsandbox.lua","programs/sandbox.lua","servers/sandbox.lua"]`,
autostart `["servers/sandbox"]`.
- [ ] **`packages/index.json`**: `trapos-sandbox: "0.1.0"` + add `trapos-sandbox-legacy: "0.2.2"`.
- [ ] **`packages/trapos/ccpm.json`**: add `trapos-sandbox` to deps; bump meta version.
## Notes
- The daemon stays dormant until `sandbox.url` is set, so adding it to the `trapos` meta is safe.
- Confirm the legacy version pinned in `index.json` (`0.2.2`) matches the renamed package's current
version before committing.
## Verification gate
`packages/index.json` resolves both names; `trapos` meta installs `trapos-sandbox`; legacy package
still installs under its new name with files/autostart intact.

View File

@ -1,51 +0,0 @@
# 04 — Integration & tooling
> Sub-plan of [trap-sandbox-gateway-spec](./trap-sandbox-gateway-spec.md). **Build last** (M4):
> requires both the TS gateway (M1) and the Lua client (M2) to exist. References the spec's
> **Protocol**/**Handshake** for assertions.
## Scope
Host tooling (`just`/`npm.just` recipes, `sandbox-serve`, docs) and the cross-side tests: TS
**integration** (real craftos ↔ real gateway) plus the **full-boot e2e**.
## Task checklist
- [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.
(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
`tools/trap-sandbox` alongside mcp-bridge; ensure `just install`/`just ci` include it.
- `just/opencode.just` (or a new recipe): add **`just sandbox-serve`** mirroring `opencode-serve`
(source `.env`, export `OPENCODE_SERVER_PASSWORD`, default `OPENCODE_URL`/`SANDBOX_PORT`, run
`npm run dev --prefix tools/trap-sandbox`).
- `docs/public-ports.md`: add a `4444` row (note it sits outside the 42424244 range; 4244 reserved).
## Test layout
```
test-integration/ # harness.ts (app on :0, startCraftos, fake-opencode http server) + lua/ + *.test.ts
```
## TS integration (`tools/trap-sandbox/test-integration`, real craftos ↔ real gateway, serialized)
Self-contained `gateway-client.lua` for the health round-trip (opencodeOk true/false via the
fake-opencode server), reverse `ping` via `registry.request`, duplicate (newest-wins), and the
**auth canary** — wrong secret asserts in-band `hello ok:false code:"unauthorized"`, correct secret
asserts `hello ok:true`.
## Full-boot e2e (TS integration)
craftos `--exec` replicates boot (set `sandbox.url`, `_G.bootEventLoop`, run real `servers/sandbox`,
then under `parallel` wait for `trapos_sandbox_connected` and run `sandbox health`); assert stdout
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://<host>:4444` (origin only —
libsandbox appends `/gateway`) (+
`set sandbox.password <secret>` if `SANDBOX_PASSWORD` set), reboot, `sandbox health`
`sandboxOk: true` / `opencodeOk: …`.

View File

@ -1,144 +0,0 @@
# Spec: trap-sandbox gateway + trapos-sandbox client (first iteration)
> **Status: SETTLED — do not revisit the Decision log.** The decisions below were hardened
> through a full design interview and are frozen input for every sub-plan. If a sub-plan
> surfaces a reason to reopen one, stop and raise it here first; do not silently deviate.
This is the **shared contract**. Sub-plans implement against it. The cross-cutting pieces
(Context, Decision log, Protocol, Out-of-scope, Known limitations) live here and are *not*
duplicated into sub-plans — sub-plans link back to this file.
## Context
`tools/trap-sandbox/` is a new TypeScript service that replaces the legacy `tools/mcp-bridge`
(kept only for inspiration). It delivers a **general-purpose Fastify API on port 4444**, organized
into "sandbox modules" (Fastify plugins). The only module in this iteration is the
**`trapos-cloud-gateway`**: a WebSocket gateway giving a CraftOS (trapos) computer and the
sandbox/opencode a generic, bi-directional request/reply protocol.
On the Lua side, a new `trapos-sandbox` package installs `servers/sandbox.lua` (a boot daemon that
owns one persistent WS connection and bridges WS frames to in-OS events) plus a `sandbox` command.
The legacy `trapos-sandbox` package is renamed to `trapos-sandbox-legacy`.
**Intended outcome:** after installing the new trapos and setting `sandbox.url`, running
`sandbox health` in-game prints `sandboxOk: true` / `opencodeOk: true|false`.
This plan was hardened through a full design interview (see **Decision log**). Notably, auth moved
from Basic-Auth-at-upgrade to an **in-protocol `hello` handshake**, which also prepares a future
"one account owns many computers, account stored server-side" model without building it yet.
## Milestones & todolist
High-level only — each sub-plan owns its granular steps. Tick a milestone when its sub-plan's
verification gate is green.
- [x] **M1 — TS gateway** ([01-ts-gateway](./01-ts-gateway.md)): service builds, listens on 4444,
TS unit tests green (`npm test`, `npm run lint`/`check` clean). *No Lua dependency — build first.*
- [x] **M2 — Lua client** ([02-lua-client](./02-lua-client.md)): `libsandbox`/`servers`/`programs`
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.
- [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.*
Dependency order: **M1 → M2 → M3 → M4**. M3 may proceed in parallel with M2 (mechanical).
## Sub-plans index
| # | Sub-plan | Scope |
|---|---|---|
| 01 | [TS gateway](./01-ts-gateway.md) | `tools/trap-sandbox/` service, registry, protocol, opencode probe, TS unit tests |
| 02 | [Lua client](./02-lua-client.md) | `apis/libsandbox.lua`, `servers/sandbox.lua`, `programs/sandbox.lua`, Lua unit tests |
| 03 | [Packages](./03-packages.md) | legacy rename, new package, `index.json`, meta bump |
| 04 | [Integration & tooling](./04-integration-tooling.md) | `just`/`npm.just`, `sandbox-serve`, docs, TS integration + full-boot e2e |
## Decision log (grill outcomes)
| Topic | Decision |
|---|---|
| Event shape | Explicit `event` field: `computer_request|computer_response|gateway_request|gateway_response`;`type` = redux action |
| Bidi scope | All 4 events now; reverse direction (`gateway_request`→`computer_response`) exercised by tests |
| Duplicate trapos id | **Replace existing (newest-wins)** — deliberate deviation from INIT's "reject the duplicate" |
| Identity | **Trust the frame** — gateway uses the client-declared `traposId`; no connection↔frame cross-validation |
| **Auth** | **In-protocol `hello` handshake** — secret in payload; **supersedes** Basic-Auth-at-upgrade + `?id=&label=` query and the "header support" risk |
| Auth reject | **Terminal**: daemon stops reconnecting on `unauthorized`, stores `lastError`; network drops still reconnect |
| Error model | `ok` + `error: { code, message }` with a stable code enum |
| Health disconnected | **Fail fast** with clear error; ~10s command timeout; ~5s opencode probe bound |
| Opencode wiring | `OPENCODE_SERVER_PASSWORD` + default `OPENCODE_URL=http://127.0.0.1:4242`, Basic Auth (opencode link only) |
| Health payload | `{ sandboxOk, opencodeOk, opencodeDetail? }` (detail shown only on failure) |
| Reverse scope (Lua) | Built-in `ping` only; no extensibility fan-out/response path in v1 |
| Future account seam | `accountId` constant `"local"` via `resolveAccount(secret)`; registry keyed by `(accountId, traposId)`; structured error codes |
| Host tooling | Full wiring: `just sandbox-serve`, `npm.just`/`install`/`ci`, `docs/public-ports.md` row for 4444 |
| Meta package | Add `trapos-sandbox` to `trapos` meta + bump version (daemon dormant until `sandbox.url` set) |
| Versions | Fastify `^5` + `@fastify/websocket` `^11`; `engines.node >=20` (Node 24 in use) |
| Tests | 3 layers (Lua-unit, TS-unit, TS-integration) + one full-boot e2e |
> The "basic password mechanism" from INIT is preserved *in spirit* (a shared secret =
> `SANDBOX_PASSWORD`, mirroring `OPENCODE_SERVER_PASSWORD`) but delivered **in-band via the hello
> frame**, not as a Basic-Auth header. The opencode probe still uses Basic Auth to the opencode
> server (unchanged).
## Protocol
JSON frames. Computer-originated carry `traposSenderId`; gateway-originated carry `traposReceiverId`.
`traposId = "<computerId>:<label>"` (label may be empty → `"7:"`).
```
// computer -> gateway
{ event:"computer_request", traposSenderId, messageId, type, payload }
{ event:"computer_response", traposSenderId, messageId, type, ok, payload?, error? }
// gateway -> computer
{ event:"gateway_request", traposReceiverId, messageId, type, payload }
{ event:"gateway_response", traposReceiverId, messageId, type, ok, payload?, error? }
```
- **messageId** is minted by the *initiator*; the reply echoes it. Gateway uses `randomUUID()`;
Lua mints a **v4 UUID** as well (amended during the 02-lua-client grill — supersedes the
earlier `"<os.epoch('utc')>-<seq>"` format; no `_G` counter needed). `messageId` is **opaque to
the gateway** (echoed, never parsed). Each side keys its own pending map by messageId.
- **Error model**: responses carry `ok:boolean` and, when `ok:false`, `error:{ code, message }`.
v1 codes: `unauthorized | not_authenticated | unknown_type | not_connected | internal | timeout`.
- **Empty-payload gotcha**: CraftOS `textutils.serializeJSON({})` emits `"[]"`. The TS parser treats
a missing / array / non-object `payload` as `{}`; the Lua side may omit `payload` when empty and
`onMessage` tolerates the same. Covered by a unit test.
- **Malformed/unknown frames** are ignored (no crash). Each side ignores events it shouldn't receive.
### Handshake (`hello`) — auth + registration + account seam
1. On socket open, the daemon sends `computer_request type:"hello" payload:{ traposId, secret }`.
2. Gateway validates `secret` against `SANDBOX_PASSWORD` (unset ⇒ auth disabled, any secret OK),
runs `resolveAccount(secret) → "local"`, registers `(accountId, traposId)` (**newest-wins**),
stores `{accountId, traposId}` on the socket, and replies
`gateway_response type:"hello" ok:true payload:{ accountId }`.
3. On bad secret → `ok:false error:{code:"unauthorized"}` then close.
4. Any non-hello frame before a successful hello → `ok:false error:{code:"not_authenticated"}`.
### Concrete types
- **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 }`.
### os-event contract (Lua, all `trapos_sandbox_`-prefixed)
`trapos_sandbox_send(messageId,type,payload)`, `trapos_sandbox_reply(messageId,ok,payload,error)`,
`trapos_sandbox_connected(traposId)`.
## Out of scope / future
- The account model itself (multi-computer-per-account). Seam only: `resolveAccount`, `(accountId,
traposId)` keying, structured errors, hello as the credential→account home.
- Full opencode integration beyond the health probe; rewriting `ai.lua`; cross-module wiring.
## Known limitations (v1, documented)
- `traposId = id:label` is **not unique across Minecraft servers/worlds** (computer ids reset per
world; labels may be empty). With newest-wins, a cross-server collision would evict the other
computer. Accepted per INIT ("id+label for now"); the future account model is the real fix.
- No rate-limiting / per-frame size policing beyond `maxPayload`; no reconnect backoff (fixed 5s);
trusted-LAN assumptions.

View File

@ -3,7 +3,7 @@
## Context ## Context
Today TrapOS is installed by typing `wget run https://os.trapcloud.fr/install` on each Today TrapOS is installed by typing `wget run https://os.trapcloud.fr/install` on each
fresh computer, then manually configuring settings (`sandbox.url`, `sandbox.password`, …) per fresh computer, then manually configuring settings (`cloud.url`, `cloud.password`, …) per
machine. We want a **provisioning floppy**: run one program on a configured machine to "bake" machine. We want a **provisioning floppy**: run one program on a configured machine to "bake"
an installer disk, then on any blank machine just insert the disk and boot — it installs the an installer disk, then on any blank machine just insert the disk and boot — it installs the
latest TrapOS online and clones *your* `.settings` onto the new computer, hands-free. latest TrapOS online and clones *your* `.settings` onto the new computer, hands-free.
@ -19,7 +19,7 @@ The disk's value is **hands-free install + config cloning**, not offline file sh
- **Ordering (copy-then-install, no reboot)**: `install-trapos.lua` ends by handing off to the - **Ordering (copy-then-install, no reboot)**: `install-trapos.lua` ends by handing off to the
live `/startup/servers.lua` shell, which never returns. So the autorun **copies `.settings` live `/startup/servers.lua` shell, which never returns. So the autorun **copies `.settings`
first**, calls `settings.load()`, *then* runs the installer (whose `trapos-postinstall` first**, calls `settings.load()`, *then* runs the installer (whose `trapos-postinstall`
respects the already-set `sandbox.url` and never touches `sandbox.password`, so the disk's respects the already-set `cloud.url` and never touches `cloud.password`, so the disk's
config wins). **No changes to `install-trapos.lua`** and **no reboot** here — the config wins). **No changes to `install-trapos.lua`** and **no reboot** here — the
boot/reboot rework is a separate effort. boot/reboot rework is a separate effort.
- **Sentinel**: autorun no-ops if `/trapos` exists. To reinstall: `rm /trapos`, reinsert, boot. - **Sentinel**: autorun no-ops if `/trapos` exists. To reinstall: `rm /trapos`, reinsert, boot.
@ -51,7 +51,7 @@ in `apis/libccpm.lua`).
-- opts.settingsSourcePath (default '/.settings') -- computer config to clone -- opts.settingsSourcePath (default '/.settings') -- computer config to clone
-- opts.installerLabel (default 'TrapOS Installer') -- opts.installerLabel (default 'TrapOS Installer')
-- opts.installUrl (default 'https://os.trapcloud.fr/install') -- opts.installUrl (default 'https://os.trapcloud.fr/install')
-- (uses real global `fs`; tests sandbox with isolated real dirs, like tests/ccpm.lua) -- (uses real global `fs`; tests isolate with real dirs, like tests/ccpm.lua)
``` ```
Public API: Public API:

View File

@ -7,8 +7,8 @@
1. MCP tools and a dedicated ComputerCraft link transport. 1. MCP tools and a dedicated ComputerCraft link transport.
2. An opencode HTTP-over-WebSocket proxy used by the in-game `ai` bridge mode. 2. An opencode HTTP-over-WebSocket proxy used by the in-game `ai` bridge mode.
The MCP tools have already moved to `tools/trap-sandbox` and `.mcp.json` already points at The MCP tools have already moved to `tools/trapos-server` and `.mcp.json` already points at
the sandbox MCP endpoint (`http://127.0.0.1:4445`). The remaining live dependency is the old the cloud MCP endpoint (`http://127.0.0.1:4445`). The remaining live dependency is the old
opencode proxy path (`opencc.bridge_url` / `packages/trapos-ai/apis/libhttpws.lua`). opencode proxy path (`opencc.bridge_url` / `packages/trapos-ai/apis/libhttpws.lua`).
Post-pull path note: Lua source files now live under `packages/<pkg>/...`. The CraftOS-PC Post-pull path note: Lua source files now live under `packages/<pkg>/...`. The CraftOS-PC
@ -24,7 +24,7 @@ scratch later; the old implementation remains available in git history.
- Delete `tools/mcp-bridge/` entirely. - Delete `tools/mcp-bridge/` entirely.
- Remove `ai` WebSocket bridge-mode support. - Remove `ai` WebSocket bridge-mode support.
- Keep direct HTTP/HTTPS opencode support through `packages/trapos-ai/apis/libhttp.lua`. - Keep direct HTTP/HTTPS opencode support through `packages/trapos-ai/apis/libhttp.lua`.
- Keep current MCP tooling in `tools/trap-sandbox` unchanged, except for docs/tooling cleanup. - Keep current MCP tooling in `tools/trapos-server` unchanged, except for docs/tooling cleanup.
## Runtime Changes ## Runtime Changes
@ -62,9 +62,9 @@ Update `packages/trapos-ai/tests/ai.lua`:
- Delete all `tools/mcp-bridge/test/` and `tools/mcp-bridge/test-integration/` tests with the - Delete all `tools/mcp-bridge/test/` and `tools/mcp-bridge/test-integration/` tests with the
directory. directory.
- Do not port `opencode-proxy.test.ts` or `ai-cli.test.ts` to `tools/trap-sandbox`; the proxy - Do not port `opencode-proxy.test.ts` or `ai-cli.test.ts` to `tools/trapos-server`; the proxy
behavior is intentionally removed. behavior is intentionally removed.
- Keep `tools/trap-sandbox/test/` and `tools/trap-sandbox/test-integration/` as the supported - Keep `tools/trapos-server/test/` and `tools/trapos-server/test-integration/` as the supported
MCP/gateway coverage. MCP/gateway coverage.
## Tooling Changes ## Tooling Changes
@ -76,7 +76,7 @@ Update `packages/trapos-ai/tests/ai.lua`:
- Remove `npm-check-mcp-bridge`. - Remove `npm-check-mcp-bridge`.
- Remove `npm-test-mcp-bridge`. - Remove `npm-test-mcp-bridge`.
- Remove `npm-test-integration-mcp-bridge`. - Remove `npm-test-integration-mcp-bridge`.
- Make aggregate Node recipes target only `tools/trap-sandbox`: - Make aggregate Node recipes target only `tools/trapos-server`:
- `npm-install` - `npm-install`
- `npm-build` - `npm-build`
- `npm-check` - `npm-check`
@ -85,14 +85,14 @@ Update `packages/trapos-ai/tests/ai.lua`:
### `just/install.just` ### `just/install.just`
- Change `clean` to clean `tools/trap-sandbox` caches instead of `tools/mcp-bridge`, or make it - Change `clean` to clean `tools/trapos-server` caches instead of `tools/mcp-bridge`, or make it
a broader Node-tool clean if useful. a broader Node-tool clean if useful.
- Update the comment above `clean`. - Update the comment above `clean`.
### `just/test.just` ### `just/test.just`
- Update comments that describe end-to-end tests as spanning the MCP bridge; they now span the - Update comments that describe end-to-end tests as spanning the MCP bridge; they now span the
sandbox gateway / MCP endpoint. cloud gateway / MCP endpoint.
### `just/stage.just` ### `just/stage.just`
@ -111,7 +111,7 @@ Update `packages/index.json`:
- Mirror the new `trapos-ai` version. - Mirror the new `trapos-ai` version.
No `trapos-sandbox` package bump is required unless its behavior changes during cleanup. No `trapos-cloud` package bump is required unless its behavior changes during cleanup.
## Documentation Cleanup ## Documentation Cleanup
@ -120,11 +120,11 @@ No `trapos-sandbox` package bump is required unless its behavior changes during
- `DEVELOPMENT.md`: update `just clean` description so it no longer mentions `mcp-bridge`. - `DEVELOPMENT.md`: update `just clean` description so it no longer mentions `mcp-bridge`.
- `docs/public-ports.md`: remove the old `4243` / `CC_LINK_PORT` ComputerCraft bridge entry, - `docs/public-ports.md`: remove the old `4243` / `CC_LINK_PORT` ComputerCraft bridge entry,
unless a new service explicitly claims that port later. unless a new service explicitly claims that port later.
- `docs/adrs/adr-0019-mcp-over-sandbox-gateway.md`: replace the statement that - `docs/adrs/adr-0019-mcp-over-cloud-gateway.md`: replace the statement that
`tools/mcp-bridge` is kept for the opencode proxy with a note that it has been removed and `tools/mcp-bridge` is kept for the opencode proxy with a note that it has been removed and
the proxy was intentionally dropped pending a future rewrite. the proxy was intentionally dropped pending a future rewrite.
- `docs/adrs/adr-0016-js-tool-verification.md`: replace `tools/mcp-bridge` as the JS tool - `docs/adrs/adr-0016-js-tool-verification.md`: replace `tools/mcp-bridge` as the JS tool
verification example with `tools/trap-sandbox`. verification example with `tools/trapos-server`.
### Historical / Optional Cleanup ### Historical / Optional Cleanup
@ -149,7 +149,7 @@ No `trapos-sandbox` package bump is required unless its behavior changes during
- Modify: `packages/index.json` - Modify: `packages/index.json`
- Modify: `DEVELOPMENT.md` - Modify: `DEVELOPMENT.md`
- Modify: `docs/public-ports.md` - Modify: `docs/public-ports.md`
- Modify: `docs/adrs/adr-0019-mcp-over-sandbox-gateway.md` - Modify: `docs/adrs/adr-0019-mcp-over-cloud-gateway.md`
- Modify: `docs/adrs/adr-0016-js-tool-verification.md` - Modify: `docs/adrs/adr-0016-js-tool-verification.md`
- Optional: `docs/adrs/adr-0017-mcp-remote-lua-execution.md` - Optional: `docs/adrs/adr-0017-mcp-remote-lua-execution.md`
- Optional: `.opencode/agent/atm10-expert.md` - Optional: `.opencode/agent/atm10-expert.md`
@ -170,4 +170,4 @@ Expected result:
- No source file references `require('/apis/libhttpws')`, `opencc.bridge_url`, or - No source file references `require('/apis/libhttpws')`, `opencc.bridge_url`, or
`opencc.request_timeout_seconds` except archived history or generated/local CraftOS state. `opencc.request_timeout_seconds` except archived history or generated/local CraftOS state.
- `ai` works only through direct HTTP/HTTPS `opencc.server_url`. - `ai` works only through direct HTTP/HTTPS `opencc.server_url`.
- MCP tools continue to work through `tools/trap-sandbox`. - MCP tools continue to work through `tools/trapos-server`.

View File

@ -4,7 +4,7 @@ Concise guidance for agents working in this repository.
## Project ## Project
ComputerCraft / CC:Tweaked Lua APIs, servers, and programs for Minecraft 1.21. Code runs in the ComputerCraft sandbox, not standard Lua. ComputerCraft / CC:Tweaked Lua APIs, servers, and programs for Minecraft 1.21. Code runs in the ComputerCraft runtime, not standard Lua.
Use [`docs/README.md`](docs/README.md) as the entrypoint for CC:Tweaked, CraftOS-PC, Advanced Peripherals, and Create integration documentation links. Use [`docs/adrs/README.md`](docs/adrs/README.md) for repository architecture decisions. Use [`docs/README.md`](docs/README.md) as the entrypoint for CC:Tweaked, CraftOS-PC, Advanced Peripherals, and Create integration documentation links. Use [`docs/adrs/README.md`](docs/adrs/README.md) for repository architecture decisions.

View File

@ -12,7 +12,7 @@ This installs local Git hooks, checks required tools, generates `.env`, and veri
## Cleaning ## Cleaning
- `just clean` — clears the `trap-sandbox` build caches (`node_modules/.cache/tsc`, `node_modules/.cache/eslint`). Safe to run anytime; `just reinstall` chains it with `just install`. - `just clean` — clears the `trapos-server` build caches (`node_modules/.cache/tsc`, `node_modules/.cache/eslint`). Safe to run anytime; `just reinstall` chains it with `just install`.
- `just clean-env` — deletes `.env`. Run only when you actually want to drop locally generated tokens. - `just clean-env` — deletes `.env`. Run only when you actually want to drop locally generated tokens.
See [`docs/README.md`](docs/README.md) for repository docs and [`docs/adrs/README.md`](docs/adrs/README.md) for architecture decisions. See [`docs/README.md`](docs/README.md) for repository docs and [`docs/adrs/README.md`](docs/adrs/README.md) for architecture decisions.

View File

@ -7,7 +7,7 @@ import 'just/npm.just'
import 'just/stage.just' import 'just/stage.just'
import 'just/craftos.just' import 'just/craftos.just'
import 'just/opencode.just' import 'just/opencode.just'
import 'just/sandbox.just' import 'just/server.just'
import 'just/test.just' import 'just/test.just'
import 'just/check.just' import 'just/check.just'

View File

@ -10,7 +10,7 @@ registry (`guillaumearm/cc-libs`), and installs the full TrapOS:
wget run https://os.trapcloud.fr/install wget run https://os.trapcloud.fr/install
``` ```
When it finishes, run `sandbox --help` to get started, or `trapos-upgrade` to When it finishes, run `cloud --help` to get started, or `trapos-upgrade` to
update later. update later.
Install only the package manager (no full OS) with `--cpm-only`, then Install only the package manager (no full OS) with `--cpm-only`, then
@ -34,7 +34,7 @@ TrapOS is split into packages, each described by a `packages/<name>/ccpm.json`:
- `trapos-net`: routed modem networking (`net`, `router`, `ping`, `ping-server`). - `trapos-net`: routed modem networking (`net`, `router`, `ping`, `ping-server`).
- `trapos-ui`: the terminal UI toolkit (`libtui`, `tuidemo`). - `trapos-ui`: the terminal UI toolkit (`libtui`, `tuidemo`).
- `trapos-ai`: the AI client for `opencode serve`. - `trapos-ai`: the AI client for `opencode serve`.
- `trapos`: full TrapOS meta-package (`trapos-boot`, `trapos-net`, `trapos-ui`, `trapos-test`, `trapos-ai`, `trapos-sandbox`). - `trapos`: full TrapOS meta-package (`trapos-boot`, `trapos-net`, `trapos-ui`, `trapos-test`, `trapos-ai`, `trapos-cloud`).
The `trapos` meta-package is the user-facing full install. Package descriptors list The `trapos` meta-package is the user-facing full install. Package descriptors list
files and autostart servers; installed state is tracked under `/trapos`. files and autostart servers; installed state is tracked under `/trapos`.

View File

@ -17,7 +17,7 @@ Future ADRs can reuse the shape of the existing files when it is useful.
- [`adr-0016-js-tool-verification.md`](adr-0016-js-tool-verification.md) — JavaScript/TypeScript tool build, check, test, CI, and future integration-test split. - [`adr-0016-js-tool-verification.md`](adr-0016-js-tool-verification.md) — JavaScript/TypeScript tool build, check, test, CI, and future integration-test split.
- [`adr-0017-mcp-remote-lua-execution.md`](adr-0017-mcp-remote-lua-execution.md) — MCP `exec-lua` remote execution for linked ComputerCraft computers. - [`adr-0017-mcp-remote-lua-execution.md`](adr-0017-mcp-remote-lua-execution.md) — MCP `exec-lua` remote execution for linked ComputerCraft computers.
- [`adr-0018-justfile-organization.md`](adr-0018-justfile-organization.md) — Root Justfile split with imports while preserving flat recipe names. - [`adr-0018-justfile-organization.md`](adr-0018-justfile-organization.md) — Root Justfile split with imports while preserving flat recipe names.
- [`adr-0019-mcp-over-sandbox-gateway.md`](adr-0019-mcp-over-sandbox-gateway.md) — MCP tools migrated onto the trap-sandbox gateway; reactive `libsandbox.serve`; `traposId` addressing. - [`adr-0019-mcp-over-cloud-gateway.md`](adr-0019-mcp-over-cloud-gateway.md) — MCP tools migrated onto the trapos-server gateway; reactive `libcloud.serve`; `traposId` addressing.
- [`adr-0020-boot-lifecycle.md`](adr-0020-boot-lifecycle.md) — TrapOS boot orchestrator, session-end policy, installer reboot path, and stale startup-file self-healing. - [`adr-0020-boot-lifecycle.md`](adr-0020-boot-lifecycle.md) — TrapOS boot orchestrator, session-end policy, installer reboot path, and stale startup-file self-healing.
Gaps in numbering (0003, 0004, 0006, 0008, 0009, 0012, 0013, 0014, 0015) are records that were either superseded by later decisions or consolidated into the surviving ADRs above. Gaps in numbering (0003, 0004, 0006, 0008, 0009, 0012, 0013, 0014, 0015) are records that were either superseded by later decisions or consolidated into the surviving ADRs above.

View File

@ -53,7 +53,7 @@ In library, server, and program code that may run inside an eventloop (directly
`os.sleep` remains acceptable only in narrow cases: `os.sleep` remains acceptable only in narrow cases:
1. One-shot programs that are purely sequential and register no event handlers — a `programs/foo.lua` that prints, sleeps, prints again, and exits. 1. One-shot programs that are purely sequential and register no event handlers — a `programs/foo.lua` that prints, sleeps, prints again, and exits.
2. `parallel.waitForAny(task, function() sleep(t); end)` used as an isolated guard to bound an inner task (e.g. the AI Lua-exec sandbox in `apis/libai.lua` and the `parallel.waitForAny`-driven per-case timer in `apis/libtest.lua`). The guard sleep is private to its own coroutine group; it does not block anything external. 2. `parallel.waitForAny(task, function() sleep(t); end)` used as an isolated guard to bound an inner task (e.g. the AI Lua-exec isolation in `apis/libai.lua` and the `parallel.waitForAny`-driven per-case timer in `apis/libtest.lua`). The guard sleep is private to its own coroutine group; it does not block anything external.
3. Tests that are themselves driven by `libtest`'s per-case timeout (see [ADR-0007](adr-0007-test-framework.md)). 3. Tests that are themselves driven by `libtest`'s per-case timeout (see [ADR-0007](adr-0007-test-framework.md)).
New code must not expose a `sleep` injection point on its constructor. If a wait is needed, accept an `eventloop` factory and schedule through `setTimeout`. Tests substitute a synchronous deterministic eventloop fake the same way they substitute `http` or `settings`. New code must not expose a `sleep` injection point on its constructor. If a wait is needed, accept an `eventloop` factory and schedule through `setTimeout`. Tests substitute a synchronous deterministic eventloop fake the same way they substitute `http` or `settings`.

View File

@ -10,7 +10,7 @@ Accepted
## Context ## Context
This repository targets CC:Tweaked on Minecraft 1.21. The Lua we ship runs inside the ComputerCraft sandbox: it depends on `os.pullEventRaw`, `peripheral`, `rednet`, `textutils.serializeJSON`, modem channels, and CC-specific globals. Standard Lua cannot execute this code as-is, so a normal local test harness was never a serious option. This repository targets CC:Tweaked on Minecraft 1.21. The Lua we ship runs inside the ComputerCraft runtime: it depends on `os.pullEventRaw`, `peripheral`, `rednet`, `textutils.serializeJSON`, modem channels, and CC-specific globals. Standard Lua cannot execute this code as-is, so a normal local test harness was never a serious option.
Contributors have been running the code in two places: Contributors have been running the code in two places:

View File

@ -67,7 +67,7 @@ Two independent timeout layers, ordered so the finer one fires first.
- The `__TRAPOS_TEST_OK__` marker remains the single shell-level success contract and is owned by `runtest`. - The `__TRAPOS_TEST_OK__` marker remains the single shell-level success contract and is owned by `runtest`.
- A hung case fails in ~3s with a per-case message instead of taking down the whole process anonymously. - A hung case fails in ~3s with a per-case message instead of taking down the whole process anonymously.
- `just test-timeout` is a self-asserting harness regression guard wired into `just ci`. It chains `test-timeout-lua` (Layer 1: libtest cancels the slow case immediately with `--timeout 0`, before the shell backstop) and `test-timeout-shell` (Layer 2: the `TRAPOS_TEST_TIMEOUT_WATCHDOG_SECONDS` watchdog, default `1`, kills the slow case with libtest bypassed). Both drive a single `tests/harness/slow-case.lua` fixture; the tight timeouts — not the fixture's sleep length — decide which layer fires, so the harness itself is covered against regressions on every `ci`. - `just test-timeout` is a self-asserting harness regression guard wired into `just ci`. It chains `test-timeout-lua` (Layer 1: libtest cancels the slow case immediately with `--timeout 0`, before the shell backstop) and `test-timeout-shell` (Layer 2: the `TRAPOS_TEST_TIMEOUT_WATCHDOG_SECONDS` watchdog, default `1`, kills the slow case with libtest bypassed). Both drive a single `tests/harness/slow-case.lua` fixture; the tight timeouts — not the fixture's sleep length — decide which layer fires, so the harness itself is covered against regressions on every `ci`.
- `libtest` stays a normal ComputerCraft program: `parallel` and `sleep` are sandbox globals, so the timeout works in CraftOS-PC and in-game alike. - `libtest` stays a normal ComputerCraft program: `parallel` and `sleep` are runtime globals, so the timeout works in CraftOS-PC and in-game alike.
- Host-specific concerns remain outside production Lua code. - Host-specific concerns remain outside production Lua code.
## Future Work ## Future Work

View File

@ -10,7 +10,7 @@ Accepted
## Context ## Context
The repository contains `tools/trap-sandbox`, a TypeScript Node.js tool that sits next to the ComputerCraft Lua code. It has a different build and test lifecycle than TrapOS packages, but it still participates in the same local developer workflow and Git hooks described by [ADR-0011](adr-0011-repo-conventions.md). (This convention was first established for `tools/mcp-bridge`, which has since been removed; `trap-sandbox` is now the JS tool it governs.) The repository contains `tools/trapos-server`, a TypeScript Node.js tool that sits next to the ComputerCraft Lua code. It has a different build and test lifecycle than TrapOS packages, but it still participates in the same local developer workflow and Git hooks described by [ADR-0011](adr-0011-repo-conventions.md). (This convention was first established for `tools/mcp-bridge`, which has since been removed; `trapos-server` is now the JS tool it governs.)
The tool also needs end-to-end coverage that spans both runtimes: a host Node process and a headless CraftOS-PC computer. That is broader and slower than normal unit tests, and it needs the same kind of timeout discipline as the Lua harness in [ADR-0007](adr-0007-test-framework.md). The tool also needs end-to-end coverage that spans both runtimes: a host Node process and a headless CraftOS-PC computer. That is broader and slower than normal unit tests, and it needs the same kind of timeout discipline as the Lua harness in [ADR-0007](adr-0007-test-framework.md).
@ -27,7 +27,7 @@ Keep the Node package scripts simple and one-purpose:
Expose matching repository recipes for the Node lifecycle: Expose matching repository recipes for the Node lifecycle:
- `just build` delegates to `npm run build` for the sandbox tool. - `just build` delegates to `npm run build` for the trapos-server tool.
- `just test` depends on `just build`, runs `npm run test`, then runs the existing CraftOS-PC test suite. - `just test` depends on `just build`, runs `npm run test`, then runs the existing CraftOS-PC test suite.
- `just check` includes `npm run check` alongside Lua and Markdown checks. - `just check` includes `npm run check` alongside Lua and Markdown checks.
- `just ci` uses `npm run test:ci`, then runs CraftOS-PC tests and the broader integration/harness target. - `just ci` uses `npm run test:ci`, then runs CraftOS-PC tests and the broader integration/harness target.
@ -39,7 +39,7 @@ Expose matching repository recipes for the Node lifecycle:
- TypeScript errors are no longer caught by `npm run test`; they are caught by `npm run build`, which stays wired into `npm run test:ci`, `just build`, `just test`, and `just ci`. - TypeScript errors are no longer caught by `npm run test`; they are caught by `npm run build`, which stays wired into `npm run test:ci`, `just build`, `just test`, and `just ci`.
- `just ci` avoids duplicating Node unit tests by calling `npm run test:ci` directly and then invoking only the CraftOS-side test body. - `just ci` avoids duplicating Node unit tests by calling `npm run test:ci` directly and then invoking only the CraftOS-side test body.
- ESLint failures are part of `just check`, so they are covered by the same pre-commit and pre-push hooks as Lua and Markdown checks. - ESLint failures are part of `just check`, so they are covered by the same pre-commit and pre-push hooks as Lua and Markdown checks.
- Integration tests live in `tools/trap-sandbox/test-integration/`, with Lua client fixtures under `test-integration/lua/`. Slow end-to-end behavior stays out of the fast unit-test path. - Integration tests live in `tools/trapos-server/test-integration/`, with Lua client fixtures under `test-integration/lua/`. Slow end-to-end behavior stays out of the fast unit-test path.
## Future Work ## Future Work

View File

@ -2,8 +2,8 @@
## Status ## Status
Accepted. Transport superseded by [ADR-0019](adr-0019-mcp-over-sandbox-gateway.md) — the Accepted. Transport superseded by [ADR-0019](adr-0019-mcp-over-cloud-gateway.md) — the
MCP tools now ride the sandbox gateway and address computers by `traposId`, and MCP tools now ride the cloud gateway and address computers by `traposId`, and
`tools/mcp-bridge` (referenced below in the past tense) has since been removed. The `tools/mcp-bridge` (referenced below in the past tense) has since been removed. The
execution semantics and danger model below still apply. execution semantics and danger model below still apply.

View File

@ -1,4 +1,4 @@
# ADR 0019: MCP Over The Sandbox Gateway # ADR 0019: MCP Over The Cloud Gateway
## Status ## Status
@ -14,37 +14,37 @@ The MCP tools (`probe-computers`, `exec-lua`, `write-file`) shipped in `tools/mc
([ADR-0017](adr-0017-mcp-remote-lua-execution.md)) over a dedicated CC-link WebSocket and ([ADR-0017](adr-0017-mcp-remote-lua-execution.md)) over a dedicated CC-link WebSocket and
the `mcp-computer` / `mcp-computer-server` Lua programs. the `mcp-computer` / `mcp-computer-server` Lua programs.
Meanwhile `tools/trap-sandbox` introduced a second, richer host↔computer connection: a Meanwhile `tools/trapos-server` introduced a second, richer host↔computer connection: a
single persistent gateway WebSocket per computer (`servers/sandbox` + `apis/libsandbox`), single persistent gateway WebSocket per computer (`servers/cloud` + `apis/libcloud`),
a hello handshake with a shared secret, a coded error model, and a bidirectional a hello handshake with a shared secret, a coded error model, and a bidirectional
request/reply (`GatewayRegistry.request` → `gateway_request` → `computer_response`). request/reply (`GatewayRegistry.request` → `server_request` → `computer_response`).
Running both stacks meant two sockets, two protocols, and a second autostart daemon per Running both stacks meant two sockets, two protocols, and a second autostart daemon per
computer. The sandbox gateway already had everything MCP needs. computer. The cloud gateway already had everything MCP needs.
## Decision ## Decision
Move the MCP features onto the sandbox gateway and retire the dedicated MCP transport. Move the MCP features onto the cloud gateway and retire the dedicated MCP transport.
- **Host (`tools/trap-sandbox`):** a second Fastify listener (`src/modules/mcp`) bound to - **Host (`tools/trapos-server`):** a second Fastify listener (`src/modules/mcp`) bound to
`127.0.0.1` (default `MCP_PORT=4445`) speaks the same custom JSON-RPC 2.0 as the old `127.0.0.1` (default `MCP_PORT=4445`) speaks the same custom JSON-RPC 2.0 as the old
bridge. Each tool call is a `GatewayRegistry.request(accountId, traposId, type, payload)` bridge. Each tool call is a `GatewayRegistry.request(accountId, traposId, type, payload)`
reusing the existing gateway socket. Computers are addressed by **`traposId`** reusing the existing gateway socket. Computers are addressed by **`traposId`**
(`"<id>:<label>"`, the registry's key) rather than a numeric `computerId`; (`"<id>:<label>"`, the registry's key) rather than a numeric `computerId`;
`probe-computers` lists the connected traposIds. `probe-computers` lists the connected traposIds.
- **Computer side:** `apis/libsandbox` gains a reactive serving path symmetric to its - **Computer side:** `apis/libcloud` gains a reactive serving path symmetric to its
request path. In-OS servers call `sandbox.serve(type, handler, { eventloop })`, which request path. In-OS servers call `cloud.serve(type, handler, { eventloop })`, which
advertises the type to the daemon (`trapos_sandbox_serve`) and reacts to inbound advertises the type to the daemon (`trapos_cloud_serve`) and reacts to inbound
`trapos_sandbox_request` events, replying via `trapos_sandbox_respond` `trapos_cloud_request` events, replying via `trapos_cloud_respond`
(`computer_response`). Unadvertised non-`ping` types still get an immediate (`computer_response`). Unadvertised non-`ping` types still get an immediate
`unknown_type` reply (the daemon tracks advertised types, so there is no timer race). `unknown_type` reply (the daemon tracks advertised types, so there is no timer race).
`ping` stays built-in, so `probe-computers` works against any computer running just the `ping` stays built-in, so `probe-computers` works against any computer running just the
sandbox daemon. cloud daemon.
- **`servers/mcp-computer-server`** is rewritten to be reactive: it owns no socket, serves - **`servers/mcp-computer-server`** is rewritten to be reactive: it owns no socket, serves
`exec-lua` / `write-file` over the gateway via `sandbox.serve`, and is gated on `exec-lua` / `write-file` over the gateway via `cloud.serve`, and is gated on
`sandbox.url` being set. `apis/libmcpcomputer` is slimmed to the protocol-agnostic `cloud.url` being set. `apis/libmcpcomputer` is slimmed to the protocol-agnostic
execution primitives (`executeLua`, `writeFile`); both move into the `trapos-sandbox` execution primitives (`executeLua`, `writeFile`); both move into the `trapos-cloud`
package (autostart `servers/mcp-computer-server` after `servers/sandbox`). The standalone package (autostart `servers/mcp-computer-server` after `servers/cloud`). The standalone
`programs/mcp-computer` client is deleted and the MCP entries are removed from `programs/mcp-computer` client is deleted and the MCP entries are removed from
`trapos-sandbox-legacy`. `trapos-sandbox-legacy`.
- A code error or a failed write is reported as a *successful* round-trip carrying an - A code error or a failed write is reported as a *successful* round-trip carrying an
@ -60,10 +60,10 @@ needed again it will be re-implemented from scratch. The old code remains in git
## Consequences ## Consequences
- One connection and one protocol per computer; no `mcp-computer.ws-url` setting. - One connection and one protocol per computer; no `mcp-computer.ws-url` setting.
- The exec trust boundary is now the authenticated gateway connection (`sandbox.url` + - The exec trust boundary is now the authenticated gateway connection (`cloud.url` +
`SANDBOX_PASSWORD`) plus the local-only MCP bind, instead of "trust the bridge you run `TRAPOS_SERVER_PASSWORD`) plus the local-only MCP bind, instead of "trust the bridge you run
`mcp-computer` against." `mcp-computer` against."
- `apis/libsandbox.serve` / `respond` are reusable for any future computer-side service - `apis/libcloud.serve` / `respond` are reusable for any future computer-side service
invoked from the host, not just MCP. invoked from the host, not just MCP.
- Tests cover host-side MCP routing (unit) and the real CraftOS gateway round-trip - Tests cover host-side MCP routing (unit) and the real CraftOS gateway round-trip
(integration), per [ADR-0016](adr-0016-js-tool-verification.md). (integration), per [ADR-0016](adr-0016-js-tool-verification.md).

View File

@ -62,13 +62,13 @@ ai "say hello from TrapOS"
Expected ping: `pong`. Expected ping: `pong`.
## 4. Start The Sandbox Gateway On Host ## 4. Start The Cloud Gateway On Host
The MCP tools now ride on the `trap-sandbox` gateway (the same WS connection used for The MCP tools now ride on the `trapos-server` gateway (the same WS connection used for
sandbox health). From this repository on your real machine: cloud health). From this repository on your real machine:
```sh ```sh
just sandbox-serve just serve
``` ```
This serves two listeners: This serves two listeners:
@ -80,23 +80,23 @@ MCP endpoint (OpenCode connects here): http://127.0.0.1:4445
The MCP endpoint stays bound to `127.0.0.1` on purpose: `exec-lua` / `write-file` are The MCP endpoint stays bound to `127.0.0.1` on purpose: `exec-lua` / `write-file` are
as powerful as the linked computer, so only local processes (your OpenCode) reach them. as powerful as the linked computer, so only local processes (your OpenCode) reach them.
Set `SANDBOX_PASSWORD` in `.env` to require a shared secret on the gateway connection. Set `TRAPOS_SERVER_PASSWORD` in `.env` to require a shared secret on the gateway connection.
## 5. Link The Computer To The Gateway ## 5. Link The Computer To The Gateway
On the ComputerCraft computer, point it at the gateway and reboot: On the ComputerCraft computer, point it at the gateway and reboot:
```sh ```sh
set sandbox.url ws://<public-host>:4444 set cloud.url ws://<public-host>:4444
set sandbox.password secret # only if SANDBOX_PASSWORD is set on the host set cloud.password secret # only if TRAPOS_SERVER_PASSWORD is set on the host
reboot reboot
``` ```
The `servers/sandbox` daemon connects automatically at boot, and `servers/mcp-computer-server` The `servers/cloud` daemon connects automatically at boot, and `servers/mcp-computer-server`
starts serving `exec-lua` / `write-file` over that one connection. Check the link with: starts serving `exec-lua` / `write-file` over that one connection. Check the link with:
```sh ```sh
sandbox health cloud health
``` ```
## 6. Connect OpenCode To MCP ## 6. Connect OpenCode To MCP
@ -127,7 +127,7 @@ term.write('visible on screen')
`exec-lua` is powerful and unsafe by design: it can do anything the linked computer can `exec-lua` is powerful and unsafe by design: it can do anything the linked computer can
do, including file, peripheral, turtle, and reboot operations. The trust boundary is the do, including file, peripheral, turtle, and reboot operations. The trust boundary is the
authenticated gateway connection (`sandbox.url` + `SANDBOX_PASSWORD`) plus the local-only authenticated gateway connection (`cloud.url` + `TRAPOS_SERVER_PASSWORD`) plus the local-only
MCP bind — only connect a computer to a gateway you control. MCP bind — only connect a computer to a gateway you control.
`write-file` writes content to a path on the target computer and overwrites any existing `write-file` writes content to a path on the target computer and overwrites any existing
@ -138,9 +138,9 @@ fail instead of being created automatically.
- `ai` says missing `opencc.server_url`: run the `set opencc.server_url ...` command again. - `ai` says missing `opencc.server_url`: run the `set opencc.server_url ...` command again.
- `ai` cannot reach server: check `opencode serve`, public host, port `4242`, and ComputerCraft HTTP rules. - `ai` cannot reach server: check `opencode serve`, public host, port `4242`, and ComputerCraft HTTP rules.
- `sandbox health` fails: enable ComputerCraft HTTP/WebSocket support and check `sandbox.url` (port `4444`). - `cloud health` fails: enable ComputerCraft HTTP/WebSocket support and check `cloud.url` (port `4444`).
- MCP sees no computers: confirm `sandbox.url` is set and the computer was rebooted so `servers/sandbox` is running. - MCP sees no computers: confirm `cloud.url` is set and the computer was rebooted so `servers/cloud` is running.
- `exec-lua` / `write-file` return `unknown_type`: that computer is not running `servers/mcp-computer-server` (set `sandbox.url` and reboot). - `exec-lua` / `write-file` return `unknown_type`: that computer is not running `servers/mcp-computer-server` (set `cloud.url` and reboot).
- `exec-lua` or `write-file` is missing after updating: restart OpenCode so it reloads the MCP tool list. - `exec-lua` or `write-file` is missing after updating: restart OpenCode so it reloads the MCP tool list.
More detail: [`opencode_server_guide.md`](opencode_server_guide.md), [`public-ports.md`](public-ports.md). More detail: [`opencode_server_guide.md`](opencode_server_guide.md), [`public-ports.md`](public-ports.md).

View File

@ -7,7 +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`. | | `4242` | opencode server HTTP API | Production/public port for `opencode serve`. Local/dev examples may still use opencode's default `4096`. |
| `4243` | Reserved | Formerly the ComputerCraft bridge WebSocket (removed with `tools/mcp-bridge`). Free for a future service. | | `4243` | Reserved | Formerly the ComputerCraft bridge WebSocket (removed with `tools/mcp-bridge`). Free for a future service. |
| `4244` | Reserved | Free public production port reserved for a future service. Do not assign it casually in local tooling. | | `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`. | | `4444` | trapos-server gateway | Fastify service (`tools/trapos-server`) exposing the `trapos-cloud-gateway` WebSocket at `/gateway`. Sits **outside** the `4242-4244` range on purpose (4244 stays reserved). Default `TRAPOS_SERVER_PORT`; serve with `just serve`. |
## Local Vs Production ## Local Vs Production

View File

@ -1,9 +1,9 @@
# Install local development tooling. # Install local development tooling.
install: install-git-hooks check-install npm-install generate-env install: install-git-hooks check-install npm-install generate-env
# Remove build caches (tsc / eslint) for the trap-sandbox tool. # Remove build caches (tsc / eslint) for the trapos-server tool.
clean: clean:
npm run clean --prefix tools/trap-sandbox npm run clean --prefix tools/trapos-server
# Remove generated local environment files (e.g. .env with tokens). # Remove generated local environment files (e.g. .env with tokens).
clean-env: clean-env:
@ -32,7 +32,7 @@ generate-env:
password="$(openssl rand -hex 32)" password="$(openssl rand -hex 32)"
while IFS= read -r line || [ -n "$line" ]; do while IFS= read -r line || [ -n "$line" ]; do
case "$line" in case "$line" in
OPENCODE_SERVER_PASSWORD=*|SANDBOX_PASSWORD=*) OPENCODE_SERVER_PASSWORD=*|TRAPOS_SERVER_PASSWORD=*)
printf '%s\n' "${line%%=*}=$password" printf '%s\n' "${line%%=*}=$password"
;; ;;
*) *)

View File

@ -1,33 +1,33 @@
# Install Node dependencies for repository tools. # Install Node dependencies for repository tools.
npm-install: npm-install-trap-sandbox npm-install: npm-install-trapos-server
# Install Node dependencies for trap-sandbox. # Install Node dependencies for trapos-server.
npm-install-trap-sandbox: npm-install-trapos-server:
npm install --prefix tools/trap-sandbox/ npm install --prefix tools/trapos-server/
# Build Node-based repository tools. # Build Node-based repository tools.
npm-build: npm-build-trap-sandbox npm-build: npm-build-trapos-server
npm-build-trap-sandbox: npm-build-trapos-server:
npm run build --prefix tools/trap-sandbox/ npm run build --prefix tools/trapos-server/
# Check Node-based repository tools. # Check Node-based repository tools.
npm-check: npm-check-trap-sandbox npm-check: npm-check-trapos-server
npm-check-trap-sandbox: npm-check-trapos-server:
npm run check --prefix tools/trap-sandbox/ npm run check --prefix tools/trapos-server/
# Run Node-based tool tests. # Run Node-based tool tests.
npm-test: npm-test-trap-sandbox npm-test: npm-test-trapos-server
npm-test-trap-sandbox: npm-test-trapos-server:
npm test --prefix tools/trap-sandbox/ npm test --prefix tools/trapos-server/
# Run Node-based tool integration tests. # Run Node-based tool integration tests.
npm-test-integration: stage npm-test-integration-trap-sandbox npm-test-integration: stage npm-test-integration-trapos-server
npm-test-integration-trap-sandbox: npm-test-integration-trapos-server:
npm run test:integration --prefix tools/trap-sandbox/ npm run test:integration --prefix tools/trapos-server/
# Build generated artifacts. # Build generated artifacts.
build: npm-build stage build: npm-build stage

View File

@ -1,18 +0,0 @@
# 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 -- "$@"

18
just/server.just Normal file
View File

@ -0,0 +1,18 @@
# Serve the trapos-server gateway (port 4444) for remote/in-game attachment using
# repo-local .env secrets. Mirrors `opencode-serve`, but auth is optional: an unset
# TRAPOS_SERVER_PASSWORD means the gateway runs with auth disabled (warn, do not fail).
[positional-arguments]
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 "${TRAPOS_SERVER_PASSWORD:-}" ]; then
printf '%s\n' 'serve: TRAPOS_SERVER_PASSWORD unset in .env — gateway auth disabled' >&2
fi
export TRAPOS_SERVER_PASSWORD="${TRAPOS_SERVER_PASSWORD:-}"
export OPENCODE_SERVER_PASSWORD="${OPENCODE_SERVER_PASSWORD:-}"
export OPENCODE_URL="${OPENCODE_URL:-http://127.0.0.1:4242}"
export TRAPOS_SERVER_HOST="${TRAPOS_SERVER_HOST:-0.0.0.0}"
export TRAPOS_SERVER_PORT="${TRAPOS_SERVER_PORT:-4444}"
exec npm run dev --prefix tools/trapos-server -- "$@"

View File

@ -8,7 +8,7 @@ ci *args: check-craftos check check-packages npm-build npm-test
test *args: build npm-test test *args: build npm-test
@just _craftos-test {{args}} @just _craftos-test {{args}}
# Run end-to-end tests that span the trap-sandbox gateway / MCP endpoint and headless CraftOS-PC. # Run end-to-end tests that span the trapos-server gateway / MCP endpoint and headless CraftOS-PC.
e2e: npm-test-integration e2e: npm-test-integration
# Run integration and harness tests that are too broad for unit test targets. # Run integration and harness tests that are too broad for unit test targets.

View File

@ -1,6 +1,6 @@
{ {
"name": "TrapOS", "name": "TrapOS",
"version": "0.11.6", "version": "0.12.0",
"branch": "master", "branch": "master",
"packages": [ "packages": [
"trapos" "trapos"

View File

@ -1,13 +1,13 @@
{ {
"packages": { "packages": {
"trapos-core": "0.9.2", "trapos-core": "0.9.3",
"trapos-test": "0.2.3", "trapos-test": "0.2.3",
"trapos-boot": "0.4.2", "trapos-boot": "0.4.2",
"trapos-net": "0.3.2", "trapos-net": "0.3.2",
"trapos-ui": "0.2.4", "trapos-ui": "0.2.4",
"trapos-ai": "0.8.0", "trapos-ai": "0.8.0",
"trapos-sandbox": "0.3.5", "trapos-cloud": "0.4.0",
"trapos-sandbox-legacy": "0.3.2", "trapos-sandbox-legacy": "0.3.2",
"trapos": "0.11.6" "trapos": "0.12.0"
} }
} }

View File

@ -1,14 +1,14 @@
-- trapos-sandbox Lua client. -- trapos-cloud Lua client.
-- --
-- Implements the Lua side of the trap-sandbox WS gateway protocol (see -- Implements the Lua side of the trapos-server WS gateway protocol (see
-- .plans/trap-sandbox/trap-sandbox-gateway-spec.md). A boot daemon owns one -- .plans/trapos-server/trapos-server-gateway-spec.md). A boot daemon owns one
-- persistent websocket connection (startSession) and bridges WS frames to in-OS -- persistent websocket connection (startSession) and bridges WS frames to in-OS
-- `trapos_sandbox_*` events; programs talk to the daemon through those events via -- `trapos_cloud_*` events; programs talk to the daemon through those events via
-- request(). Both run as separate coroutines under the same Lua state, so they -- request(). Both run as separate coroutines under the same Lua state, so they
-- share the single os event queue. -- share the single os event queue.
-- Server-side application close code emitted when a newer registration displaces an -- Server-side application close code emitted when a newer registration displaces an
-- older one (see trap-sandbox registry). Treated as terminal: the loser yields rather -- older one (see trapos-server registry). Treated as terminal: the loser yields rather
-- than reconnecting, so two daemons sharing a traposId collapse to one (no eviction loop). -- than reconnecting, so two daemons sharing a traposId collapse to one (no eviction loop).
local DISPLACED_CLOSE_CODE = 4000; local DISPLACED_CLOSE_CODE = 4000;
@ -43,7 +43,7 @@ local function buildConnectUrl(base)
return trimmed .. '/gateway'; return trimmed .. '/gateway';
end end
local function createSandbox() local function createCloud()
local api = {}; local api = {};
api.uuid = makeUuid; api.uuid = makeUuid;
@ -88,7 +88,7 @@ local function createSandbox()
function api.buildGatewayRequest(traposReceiverId, messageId, msgType, payload) function api.buildGatewayRequest(traposReceiverId, messageId, msgType, payload)
local frame = { local frame = {
event = 'gateway_request', event = 'server_request',
traposReceiverId = traposReceiverId, traposReceiverId = traposReceiverId,
messageId = messageId, messageId = messageId,
type = msgType, type = msgType,
@ -101,7 +101,7 @@ local function createSandbox()
function api.buildGatewayResponse(traposReceiverId, messageId, msgType, ok, payload, err) function api.buildGatewayResponse(traposReceiverId, messageId, msgType, ok, payload, err)
local frame = { local frame = {
event = 'gateway_response', event = 'server_response',
traposReceiverId = traposReceiverId, traposReceiverId = traposReceiverId,
messageId = messageId, messageId = messageId,
type = msgType, type = msgType,
@ -138,7 +138,7 @@ local function createSandbox()
return; return;
end end
if frame.event == 'gateway_request' then if frame.event == 'server_request' then
-- Reverse direction. `ping` stays built-in so it answers without any handler. -- Reverse direction. `ping` stays built-in so it answers without any handler.
-- Other types are delegated to in-OS servers (re-emitted via ctx.queueRequest) -- Other types are delegated to in-OS servers (re-emitted via ctx.queueRequest)
-- when a handler has advertised the type (ctx.isServed); an unadvertised type -- when a handler has advertised the type (ctx.isServed); an unadvertised type
@ -166,14 +166,14 @@ local function createSandbox()
return; return;
end end
if frame.event == 'gateway_response' then if frame.event == 'server_response' then
if frame.type == 'hello' then if frame.type == 'hello' then
if ctx.onHello then if ctx.onHello then
ctx.onHello(frame.ok == true, coercePayload(frame.payload), frame.error); ctx.onHello(frame.ok == true, coercePayload(frame.payload), frame.error);
end end
elseif ctx.queueEvent then elseif ctx.queueEvent then
ctx.queueEvent( ctx.queueEvent(
'trapos_sandbox_reply', frame.messageId, frame.ok == true, 'trapos_cloud_reply', frame.messageId, frame.ok == true,
coercePayload(frame.payload), frame.error coercePayload(frame.payload), frame.error
); );
end end
@ -214,8 +214,8 @@ local function createSandbox()
-- open / connection failed / connection closed) so a long outage logs once on entry -- open / connection failed / connection closed) so a long outage logs once on entry
-- and once on recovery instead of flooding the file every reconnectDelay seconds. -- and once on recovery instead of flooding the file every reconnectDelay seconds.
local reconnecting = false; local reconnecting = false;
-- Gateway_request types advertised by in-OS servers (via trapos_sandbox_serve). -- Gateway_request types advertised by in-OS servers (via trapos_cloud_serve).
-- Inbound frames of an advertised type are re-emitted as trapos_sandbox_request -- Inbound frames of an advertised type are re-emitted as trapos_cloud_request
-- events; unadvertised (non-ping) types get an immediate unknown_type reply. -- events; unadvertised (non-ping) types get an immediate unknown_type reply.
local servedTypes = {}; local servedTypes = {};
@ -268,7 +268,7 @@ local function createSandbox()
else else
log('info', 'connected', { traposId = traposId }); log('info', 'connected', { traposId = traposId });
end end
queueEvent('trapos_sandbox_connected', traposId); queueEvent('trapos_cloud_connected', traposId);
else else
state = 'unauthorized'; state = 'unauthorized';
lastError = (type(err) == 'table' and err.code) or 'unauthorized'; lastError = (type(err) == 'table' and err.code) or 'unauthorized';
@ -304,7 +304,7 @@ local function createSandbox()
onHello = onHello, onHello = onHello,
isServed = function(msgType) return servedTypes[msgType] == true; end, isServed = function(msgType) return servedTypes[msgType] == true; end,
queueRequest = function(messageId, msgType, payload) queueRequest = function(messageId, msgType, payload)
queueEvent('trapos_sandbox_request', messageId, msgType, payload); queueEvent('trapos_cloud_request', messageId, msgType, payload);
end, end,
}); });
end); end);
@ -344,23 +344,23 @@ local function createSandbox()
end); end);
-- Bridge program requests onto the wire. Only emit an immediate reply on -- Bridge program requests onto the wire. Only emit an immediate reply on
-- failure; on success the real reply arrives later as a gateway_response. -- failure; on success the real reply arrives later as a server_response.
el.register('trapos_sandbox_send', function(messageId, msgType, payload) el.register('trapos_cloud_send', function(messageId, msgType, payload)
if state ~= 'ready' then if state ~= 'ready' then
queueEvent('trapos_sandbox_reply', messageId, false, nil, queueEvent('trapos_cloud_reply', messageId, false, nil,
{ code = 'not_connected', message = 'sandbox daemon not connected' }); { code = 'not_connected', message = 'cloud daemon not connected' });
return; return;
end end
local sent = sendFrame(api.buildComputerRequest(traposId, messageId, msgType, payload)); local sent = sendFrame(api.buildComputerRequest(traposId, messageId, msgType, payload));
if not sent then if not sent then
queueEvent('trapos_sandbox_reply', messageId, false, nil, queueEvent('trapos_cloud_reply', messageId, false, nil,
{ code = 'not_connected', message = 'sandbox send failed' }); { code = 'not_connected', message = 'cloud send failed' });
end end
end); end);
-- In-OS servers advertise the gateway_request types they handle. Recorded so -- In-OS servers advertise the server_request types they handle. Recorded so
-- onMessage routes those (and only those) as trapos_sandbox_request events. -- onMessage routes those (and only those) as trapos_cloud_request events.
el.register('trapos_sandbox_serve', function(msgType) el.register('trapos_cloud_serve', function(msgType)
if type(msgType) == 'string' and msgType ~= '' then if type(msgType) == 'string' and msgType ~= '' then
servedTypes[msgType] = true; servedTypes[msgType] = true;
end end
@ -368,7 +368,7 @@ local function createSandbox()
-- Bridge a server reply back onto the wire as a computer_response. Dropped when -- Bridge a server reply back onto the wire as a computer_response. Dropped when
-- not connected: the gateway-side request then times out, which is the reply. -- not connected: the gateway-side request then times out, which is the reply.
el.register('trapos_sandbox_respond', function(messageId, msgType, ok, payload, err) el.register('trapos_cloud_respond', function(messageId, msgType, ok, payload, err)
if state ~= 'ready' then if state ~= 'ready' then
return; return;
end end
@ -394,7 +394,7 @@ local function createSandbox()
local timeout = opts.timeout or 10; local timeout = opts.timeout or 10;
local messageId = opts.messageId or makeUuid(opts.random); local messageId = opts.messageId or makeUuid(opts.random);
queueEvent('trapos_sandbox_send', messageId, msgType, payload); queueEvent('trapos_cloud_send', messageId, msgType, payload);
local timerId = startTimer(timeout); local timerId = startTimer(timeout);
while true do while true do
@ -402,7 +402,7 @@ local function createSandbox()
local name = event[1]; local name = event[1];
if name == 'timer' and event[2] == timerId then if name == 'timer' and event[2] == timerId then
return false, { code = 'timeout', message = 'request timed out' }; return false, { code = 'timeout', message = 'request timed out' };
elseif name == 'trapos_sandbox_reply' then elseif name == 'trapos_cloud_reply' then
if event[2] == messageId then if event[2] == messageId then
cancelTimer(timerId); cancelTimer(timerId);
if event[3] then if event[3] then
@ -411,23 +411,23 @@ local function createSandbox()
return false, event[5] or { code = 'internal', message = 'request failed' }; return false, event[5] or { code = 'internal', message = 'request failed' };
end end
-- Not ours: re-queue so a concurrent waiter can claim it. -- Not ours: re-queue so a concurrent waiter can claim it.
queueEvent('trapos_sandbox_reply', event[2], event[3], event[4], event[5]); queueEvent('trapos_cloud_reply', event[2], event[3], event[4], event[5]);
end end
-- Ignore any other event and keep waiting. -- Ignore any other event and keep waiting.
end end
end end
-- Server-side reply: queue a computer_response for an inbound gateway_request that -- Server-side reply: queue a computer_response for an inbound server_request that
-- the daemon delivered as a trapos_sandbox_request event. The daemon owns the wire. -- the daemon delivered as a trapos_cloud_request event. The daemon owns the wire.
function api.respond(messageId, msgType, ok, payload, err, opts) function api.respond(messageId, msgType, ok, payload, err, opts)
opts = opts or {}; opts = opts or {};
local queueEvent = opts.queueEvent or os.queueEvent; local queueEvent = opts.queueEvent or os.queueEvent;
queueEvent('trapos_sandbox_respond', messageId, msgType, ok == true, payload, err); queueEvent('trapos_cloud_respond', messageId, msgType, ok == true, payload, err);
end end
-- Register an in-OS handler for an inbound gateway_request type. Advertises the -- Register an in-OS handler for an inbound server_request type. Advertises the
-- type to the daemon (so it routes instead of replying unknown_type) and reacts to -- type to the daemon (so it routes instead of replying unknown_type) and reacts to
-- matching trapos_sandbox_request events. handler(payload, messageId) returns -- matching trapos_cloud_request events. handler(payload, messageId) returns
-- (true, resultTable) on success or (false, { code, message }) on failure; a thrown -- (true, resultTable) on success or (false, { code, message }) on failure; a thrown
-- error is reported as { code = 'internal', message = <error> }. -- error is reported as { code = 'internal', message = <error> }.
function api.serve(msgType, handler, opts) function api.serve(msgType, handler, opts)
@ -437,9 +437,9 @@ local function createSandbox()
assert(type(handler) == 'function', 'serve requires a handler'); assert(type(handler) == 'function', 'serve requires a handler');
local queueEvent = opts.queueEvent or os.queueEvent; local queueEvent = opts.queueEvent or os.queueEvent;
queueEvent('trapos_sandbox_serve', msgType); queueEvent('trapos_cloud_serve', msgType);
el.register('trapos_sandbox_request', function(messageId, requestType, payload) el.register('trapos_cloud_request', function(messageId, requestType, payload)
if requestType ~= msgType then if requestType ~= msgType then
return; return;
end end
@ -463,4 +463,4 @@ local function createSandbox()
return api; return api;
end end
return createSandbox; return createCloud;

View File

@ -1,9 +1,9 @@
-- Computer-side MCP execution primitives. -- Computer-side MCP execution primitives.
-- --
-- Protocol-agnostic building blocks for the MCP `exec-lua` / `write-file` features. -- Protocol-agnostic building blocks for the MCP `exec-lua` / `write-file` features.
-- The transport lives elsewhere: servers/mcp-computer-server.lua reacts to sandbox -- The transport lives elsewhere: servers/mcp-computer-server.lua reacts to cloud
-- gateway_request events and replies through the trap-sandbox gateway (see -- server_request events and replies through the trapos-server gateway (see
-- apis/libsandbox.lua). This module only knows how to run code and write files. -- apis/libcloud.lua). This module only knows how to run code and write files.
local function defaultOs() local function defaultOs()
return os; return os;
@ -90,7 +90,7 @@ end
-- Builds a CraftOS-style require/package pair the same way /rom/programs/shell.lua does -- Builds a CraftOS-style require/package pair the same way /rom/programs/shell.lua does
-- (dofile of cc.require, then .make(env, dir)). Returned factory caches the make function so -- (dofile of cc.require, then .make(env, dir)). Returned factory caches the make function so
-- the rom module is only loaded once. Stays synchronous (require never yields), so it is safe -- the rom module is only loaded once. Stays synchronous (require never yields), so it is safe
-- inside the synchronous sandbox handler -- unlike spawning a shell, which yields and deadlocks. -- inside the synchronous cloud handler -- unlike spawning a shell, which yields and deadlocks.
local function defaultRequireFactory() local function defaultRequireFactory()
local make; local make;
local loaded = false; local loaded = false;

View File

@ -0,0 +1,14 @@
{
"name": "trapos-cloud",
"version": "0.4.0",
"description": "TrapOS cloud gateway client: WS daemon (servers/cloud) + cloud command + MCP exec/write server",
"dependencies": ["trapos-core"],
"files": [
"apis/libcloud.lua",
"apis/libmcpcomputer.lua",
"programs/cloud.lua",
"servers/cloud.lua",
"servers/mcp-computer-server.lua"
],
"autostart": ["servers/cloud", "servers/mcp-computer-server"]
}

View File

@ -1,22 +1,22 @@
local createSandbox = require('/apis/libsandbox'); local createCloud = require('/apis/libcloud');
local createVersion = require('/apis/libversion'); local createVersion = require('/apis/libversion');
local URL_SETTING = 'sandbox.url'; local URL_SETTING = 'cloud.url';
local rawArgs = table.pack(...); local rawArgs = table.pack(...);
local command = rawArgs[1]; local command = rawArgs[1];
local function printUsage() local function printUsage()
print('sandbox usage:'); print('cloud usage:');
print(); print();
print(' sandbox health'); print(' cloud health');
print(' sandbox --version'); print(' cloud --version');
print(' sandbox --help'); print(' cloud --help');
print(); print();
print('settings required:'); print('settings required:');
print(' sandbox.url (gateway origin, e.g. ws://host:4444)'); print(' cloud.url (gateway origin, e.g. ws://host:4444)');
print('settings optional:'); print('settings optional:');
print(' sandbox.password (shared secret for the hello handshake)'); print(' cloud.password (shared secret for the hello handshake)');
end end
if command == '--version' or command == '-version' or command == 'version' or command == '-v' then if command == '--version' or command == '-version' or command == 'version' or command == '-v' then
@ -32,19 +32,19 @@ end
if command == 'health' then if command == 'health' then
local url = settings.get(URL_SETTING); local url = settings.get(URL_SETTING);
if type(url) ~= 'string' or url == '' then if type(url) ~= 'string' or url == '' then
print('set sandbox.url first'); print('set cloud.url first');
return; return;
end end
local ok, result = createSandbox().request('probe_gateway'); local ok, result = createCloud().request('probe_server');
if not ok then if not ok then
local code = (type(result) == 'table' and result.code) or 'internal'; local code = (type(result) == 'table' and result.code) or 'internal';
local message = (type(result) == 'table' and result.message) or ''; local message = (type(result) == 'table' and result.message) or '';
print('sandbox health failed: ' .. tostring(code) .. ' ' .. tostring(message)); print('cloud health failed: ' .. tostring(code) .. ' ' .. tostring(message));
return; return;
end end
print('sandboxOk: ' .. tostring(result.sandboxOk)); print('serverOk: ' .. tostring(result.serverOk));
print('opencodeOk: ' .. tostring(result.opencodeOk)); print('opencodeOk: ' .. tostring(result.opencodeOk));
if not result.opencodeOk and result.opencodeDetail then if not result.opencodeOk and result.opencodeDetail then
print('opencode: ' .. tostring(result.opencodeDetail)); print('opencode: ' .. tostring(result.opencodeDetail));

View File

@ -1,26 +1,26 @@
local createSandbox = require('/apis/libsandbox'); local createCloud = require('/apis/libcloud');
local createVersion = require('/apis/libversion'); local createVersion = require('/apis/libversion');
local URL_SETTING = 'sandbox.url'; local URL_SETTING = 'cloud.url';
local PASSWORD_SETTING = 'sandbox.password'; local PASSWORD_SETTING = 'cloud.password';
local LOG_PATH = '/logs/sandbox.log'; local LOG_PATH = '/logs/cloud.log';
-- Single-instance guard: a clean reboot resets _G, so this is a no-op then. It only -- Single-instance guard: a clean reboot resets _G, so this is a no-op then. It only
-- bites if boot ever re-runs in a VM whose previous eventloop survived, which would -- bites if boot ever re-runs in a VM whose previous eventloop survived, which would
-- otherwise spawn a second daemon that fights the first over the shared traposId. -- otherwise spawn a second daemon that fights the first over the shared traposId.
if _G.__trapos_sandbox_daemon then if _G.__trapos_cloud_daemon then
print('sandbox: daemon already running, skipping.'); print('cloud: daemon already running, skipping.');
return; return;
end end
local url = settings.get(URL_SETTING); local url = settings.get(URL_SETTING);
if type(url) ~= 'string' or url == '' then if type(url) ~= 'string' or url == '' then
print('sandbox: ' .. URL_SETTING .. ' not set, daemon inactive.'); print('cloud: ' .. URL_SETTING .. ' not set, daemon inactive.');
return; return;
end end
if not http or not http.websocket then if not http or not http.websocket then
print('sandbox: HTTP/WebSocket unavailable, daemon inactive.'); print('cloud: HTTP/WebSocket unavailable, daemon inactive.');
return; return;
end end
@ -41,9 +41,9 @@ end
local secret = settings.get(PASSWORD_SETTING); local secret = settings.get(PASSWORD_SETTING);
_G.__trapos_sandbox_daemon = true; _G.__trapos_cloud_daemon = true;
createSandbox().startSession({ createCloud().startSession({
eventloop = _G.bootEventLoop, eventloop = _G.bootEventLoop,
url = url, url = url,
secret = secret, secret = secret,
@ -51,4 +51,4 @@ createSandbox().startSession({
log = fileLog, log = fileLog,
}); });
print('sandbox v' .. createVersion().forSelf() .. ' started (' .. url .. '), logging to ' .. LOG_PATH .. '.'); print('cloud v' .. createVersion().forSelf() .. ' started (' .. url .. '), logging to ' .. LOG_PATH .. '.');

View File

@ -1,16 +1,16 @@
-- MCP computer server: reacts to sandbox gateway requests. -- MCP computer server: reacts to cloud gateway requests.
-- --
-- No longer owns a websocket. It registers `exec-lua` / `write-file` handlers on the -- No longer owns a websocket. It registers `exec-lua` / `write-file` handlers on the
-- sandbox daemon's event bus (apis/libsandbox.serve), so requests arrive over the one -- cloud daemon's event bus (apis/libcloud.serve), so requests arrive over the one
-- gateway connection owned by servers/sandbox.lua and replies are sent back through it. -- gateway connection owned by servers/cloud.lua and replies are sent back through it.
-- It only handles requests while a sandbox is configured, so exec stays gated behind -- It only handles requests while a cloud is configured, so exec stays gated behind
-- the same trusted, authenticated connection as the rest of the gateway. -- the same trusted, authenticated connection as the rest of the gateway.
local createSandbox = require('/apis/libsandbox'); local createCloud = require('/apis/libcloud');
local createMcpComputer = require('/apis/libmcpcomputer'); local createMcpComputer = require('/apis/libmcpcomputer');
local createVersion = require('/apis/libversion'); local createVersion = require('/apis/libversion');
local URL_SETTING = 'sandbox.url'; local URL_SETTING = 'cloud.url';
local url = settings.get(URL_SETTING); local url = settings.get(URL_SETTING);
if type(url) ~= 'string' or url == '' then if type(url) ~= 'string' or url == '' then
@ -24,13 +24,13 @@ if not el then
return; return;
end end
local sandbox = createSandbox(); local cloud = createCloud();
local mcp = createMcpComputer({ shell = shell }); local mcp = createMcpComputer({ shell = shell });
-- A code error or a failed file write is a *successful* round-trip that carries an -- A code error or a failed file write is a *successful* round-trip that carries an
-- ok:false result (so captured output survives the reply); only a thrown handler or -- ok:false result (so captured output survives the reply); only a thrown handler or
-- a dropped connection becomes a transport-level failure. -- a dropped connection becomes a transport-level failure.
sandbox.serve('exec-lua', function(payload) cloud.serve('exec-lua', function(payload)
local result = mcp.executeLua(payload and payload.code); local result = mcp.executeLua(payload and payload.code);
return true, { return true, {
ok = result.ok, ok = result.ok,
@ -40,7 +40,7 @@ sandbox.serve('exec-lua', function(payload)
}; };
end, { eventloop = el }); end, { eventloop = el });
sandbox.serve('run-file', function(payload) cloud.serve('run-file', function(payload)
local result = mcp.executeFile(payload and payload.path, nil, payload and payload.args); local result = mcp.executeFile(payload and payload.path, nil, payload and payload.args);
return true, { return true, {
ok = result.ok, ok = result.ok,
@ -50,7 +50,7 @@ sandbox.serve('run-file', function(payload)
}; };
end, { eventloop = el }); end, { eventloop = el });
sandbox.serve('write-file', function(payload) cloud.serve('write-file', function(payload)
local result = mcp.writeFile(payload and payload.path, payload and payload.content); local result = mcp.writeFile(payload and payload.path, payload and payload.content);
return true, { return true, {
ok = result.ok, ok = result.ok,
@ -61,4 +61,4 @@ sandbox.serve('write-file', function(payload)
end, { eventloop = el }); end, { eventloop = el });
print('mcp-computer-server v' .. createVersion().forSelf() print('mcp-computer-server v' .. createVersion().forSelf()
.. ' started (serving exec-lua/run-file/write-file over sandbox).'); .. ' started (serving exec-lua/run-file/write-file over cloud).');

View File

@ -1,5 +1,5 @@
local createLibTest = require('/apis/libtest'); local createLibTest = require('/apis/libtest');
local createSandbox = require('/apis/libsandbox'); local createCloud = require('/apis/libcloud');
local fakes = require('/tests/harness/fake-eventloop'); local fakes = require('/tests/harness/fake-eventloop');
local testlib = createLibTest({ ... }); local testlib = createLibTest({ ... });
@ -10,21 +10,21 @@ local encode = textutils.serializeJSON;
-- traposId ---------------------------------------------------------------- -- traposId ----------------------------------------------------------------
testlib.test('traposId joins id and label', function() testlib.test('traposId joins id and label', function()
local sandbox = createSandbox(); local cloud = createCloud();
testlib.assertEquals(sandbox.traposId(fakeOs(7, 'base')), '7:base'); testlib.assertEquals(cloud.traposId(fakeOs(7, 'base')), '7:base');
end); end);
testlib.test('traposId renders empty/nil label as trailing colon', function() testlib.test('traposId renders empty/nil label as trailing colon', function()
local sandbox = createSandbox(); local cloud = createCloud();
testlib.assertEquals(sandbox.traposId(fakeOs(7, nil)), '7:'); testlib.assertEquals(cloud.traposId(fakeOs(7, nil)), '7:');
testlib.assertEquals(sandbox.traposId(fakeOs(8, '')), '8:'); testlib.assertEquals(cloud.traposId(fakeOs(8, '')), '8:');
end); end);
-- uuid -------------------------------------------------------------------- -- uuid --------------------------------------------------------------------
testlib.test('uuid emits a v4-shaped string', function() testlib.test('uuid emits a v4-shaped string', function()
local sandbox = createSandbox(); local cloud = createCloud();
local id = sandbox.uuid(); local id = cloud.uuid();
testlib.assertEquals(string.len(id), 36); testlib.assertEquals(string.len(id), 36);
testlib.assertEquals(string.sub(id, 15, 15), '4'); testlib.assertEquals(string.sub(id, 15, 15), '4');
testlib.assertTrue(string.match(id, '^%x%x%x%x%x%x%x%x%-%x%x%x%x%-4') ~= nil); testlib.assertTrue(string.match(id, '^%x%x%x%x%x%x%x%x%-%x%x%x%x%-4') ~= nil);
@ -33,40 +33,40 @@ end);
-- envelope builders ------------------------------------------------------- -- envelope builders -------------------------------------------------------
testlib.test('buildComputerRequest carries sender id and omits empty payload', function() testlib.test('buildComputerRequest carries sender id and omits empty payload', function()
local sandbox = createSandbox(); local cloud = createCloud();
local frame = sandbox.buildComputerRequest('7:base', 'mid-1', 'probe_gateway', nil); local frame = cloud.buildComputerRequest('7:base', 'mid-1', 'probe_server', nil);
testlib.assertEquals(frame.event, 'computer_request'); testlib.assertEquals(frame.event, 'computer_request');
testlib.assertEquals(frame.traposSenderId, '7:base'); testlib.assertEquals(frame.traposSenderId, '7:base');
testlib.assertEquals(frame.messageId, 'mid-1'); testlib.assertEquals(frame.messageId, 'mid-1');
testlib.assertEquals(frame.type, 'probe_gateway'); testlib.assertEquals(frame.type, 'probe_server');
testlib.assertEquals(frame.payload, nil); testlib.assertEquals(frame.payload, nil);
local withPayload = sandbox.buildComputerRequest('7:base', 'mid-2', 'x', { a = 1 }); local withPayload = cloud.buildComputerRequest('7:base', 'mid-2', 'x', { a = 1 });
testlib.assertEquals(withPayload.payload.a, 1); testlib.assertEquals(withPayload.payload.a, 1);
end); end);
testlib.test('buildHello includes secret only when non-empty', function() testlib.test('buildHello includes secret only when non-empty', function()
local sandbox = createSandbox(); local cloud = createCloud();
local withSecret = sandbox.buildHello('7:base', 'hunter2', 'mid'); local withSecret = cloud.buildHello('7:base', 'hunter2', 'mid');
testlib.assertEquals(withSecret.event, 'computer_request'); testlib.assertEquals(withSecret.event, 'computer_request');
testlib.assertEquals(withSecret.type, 'hello'); testlib.assertEquals(withSecret.type, 'hello');
testlib.assertEquals(withSecret.traposSenderId, '7:base'); testlib.assertEquals(withSecret.traposSenderId, '7:base');
testlib.assertEquals(withSecret.payload.traposId, '7:base'); testlib.assertEquals(withSecret.payload.traposId, '7:base');
testlib.assertEquals(withSecret.payload.secret, 'hunter2'); testlib.assertEquals(withSecret.payload.secret, 'hunter2');
local noSecret = sandbox.buildHello('7:base', nil, 'mid'); local noSecret = cloud.buildHello('7:base', nil, 'mid');
testlib.assertEquals(noSecret.payload.secret, nil); testlib.assertEquals(noSecret.payload.secret, nil);
local emptySecret = sandbox.buildHello('7:base', '', 'mid'); local emptySecret = cloud.buildHello('7:base', '', 'mid');
testlib.assertEquals(emptySecret.payload.secret, nil); testlib.assertEquals(emptySecret.payload.secret, nil);
end); end);
-- onMessage dispatch ------------------------------------------------------ -- onMessage dispatch ------------------------------------------------------
testlib.test('onMessage replies to a reverse ping', function() testlib.test('onMessage replies to a reverse ping', function()
local sandbox = createSandbox(); local cloud = createCloud();
local sent = {}; local sent = {};
sandbox.onMessage(encode({ event = 'gateway_request', type = 'ping', messageId = 'g-1' }), { cloud.onMessage(encode({ event = 'server_request', type = 'ping', messageId = 'g-1' }), {
traposId = '7:base', traposId = '7:base',
send = function(frame) sent[#sent + 1] = frame; end, send = function(frame) sent[#sent + 1] = frame; end,
}); });
@ -81,9 +81,9 @@ testlib.test('onMessage replies to a reverse ping', function()
end); end);
testlib.test('onMessage rejects unknown reverse type with unknown_type', function() testlib.test('onMessage rejects unknown reverse type with unknown_type', function()
local sandbox = createSandbox(); local cloud = createCloud();
local sent = {}; local sent = {};
sandbox.onMessage(encode({ event = 'gateway_request', type = 'dance', messageId = 'g-2' }), { cloud.onMessage(encode({ event = 'server_request', type = 'dance', messageId = 'g-2' }), {
traposId = '7:base', traposId = '7:base',
send = function(frame) sent[#sent + 1] = frame; end, send = function(frame) sent[#sent + 1] = frame; end,
}); });
@ -93,11 +93,11 @@ testlib.test('onMessage rejects unknown reverse type with unknown_type', functio
testlib.assertEquals(sent[1].error.code, 'unknown_type'); testlib.assertEquals(sent[1].error.code, 'unknown_type');
end); end);
testlib.test('onMessage routes a served reverse type to trapos_sandbox_request', function() testlib.test('onMessage routes a served reverse type to trapos_cloud_request', function()
local sandbox = createSandbox(); local cloud = createCloud();
local requested = {}; local requested = {};
sandbox.onMessage(encode({ cloud.onMessage(encode({
event = 'gateway_request', type = 'exec-lua', messageId = 'g-3', payload = { code = 'x' }, event = 'server_request', type = 'exec-lua', messageId = 'g-3', payload = { code = 'x' },
}), { }), {
traposId = '7:base', traposId = '7:base',
send = function() error('served type must not reply directly', 0); end, send = function() error('served type must not reply directly', 0); end,
@ -114,9 +114,9 @@ testlib.test('onMessage routes a served reverse type to trapos_sandbox_request',
end); end);
testlib.test('onMessage replies unknown_type for a non-served reverse type', function() testlib.test('onMessage replies unknown_type for a non-served reverse type', function()
local sandbox = createSandbox(); local cloud = createCloud();
local sent = {}; local sent = {};
sandbox.onMessage(encode({ event = 'gateway_request', type = 'exec-lua', messageId = 'g-4' }), { cloud.onMessage(encode({ event = 'server_request', type = 'exec-lua', messageId = 'g-4' }), {
traposId = '7:base', traposId = '7:base',
send = function(frame) sent[#sent + 1] = frame; end, send = function(frame) sent[#sent + 1] = frame; end,
isServed = function() return false; end, isServed = function() return false; end,
@ -129,9 +129,9 @@ testlib.test('onMessage replies unknown_type for a non-served reverse type', fun
end); end);
testlib.test('onMessage drives hello via onHello', function() testlib.test('onMessage drives hello via onHello', function()
local sandbox = createSandbox(); local cloud = createCloud();
local calls = {}; local calls = {};
sandbox.onMessage(encode({ event = 'gateway_response', type = 'hello', ok = true, payload = { accountId = 'local' } }), { cloud.onMessage(encode({ event = 'server_response', type = 'hello', ok = true, payload = { accountId = 'local' } }), {
onHello = function(ok, payload) calls[#calls + 1] = { ok = ok, payload = payload }; end, onHello = function(ok, payload) calls[#calls + 1] = { ok = ok, payload = payload }; end,
}); });
@ -140,27 +140,27 @@ testlib.test('onMessage drives hello via onHello', function()
testlib.assertEquals(calls[1].payload.accountId, 'local'); testlib.assertEquals(calls[1].payload.accountId, 'local');
end); end);
testlib.test('onMessage queues a reply for a non-hello gateway_response', function() testlib.test('onMessage queues a reply for a non-hello server_response', function()
local sandbox = createSandbox(); local cloud = createCloud();
local queued = {}; local queued = {};
sandbox.onMessage(encode({ cloud.onMessage(encode({
event = 'gateway_response', type = 'probe_gateway', ok = true, event = 'server_response', type = 'probe_server', ok = true,
messageId = 'mid-9', payload = { sandboxOk = true }, messageId = 'mid-9', payload = { serverOk = true },
}), { }), {
queueEvent = function(...) queued[#queued + 1] = table.pack(...); end, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
}); });
testlib.assertEquals(#queued, 1); testlib.assertEquals(#queued, 1);
testlib.assertEquals(queued[1][1], 'trapos_sandbox_reply'); testlib.assertEquals(queued[1][1], 'trapos_cloud_reply');
testlib.assertEquals(queued[1][2], 'mid-9'); testlib.assertEquals(queued[1][2], 'mid-9');
testlib.assertEquals(queued[1][3], true); testlib.assertEquals(queued[1][3], true);
testlib.assertEquals(queued[1][4].sandboxOk, true); testlib.assertEquals(queued[1][4].serverOk, true);
end); end);
testlib.test('onMessage coerces a missing payload to an empty table', function() testlib.test('onMessage coerces a missing payload to an empty table', function()
local sandbox = createSandbox(); local cloud = createCloud();
local queued = {}; local queued = {};
sandbox.onMessage(encode({ event = 'gateway_response', type = 'noop', ok = true, messageId = 'mid-x' }), { cloud.onMessage(encode({ event = 'server_response', type = 'noop', ok = true, messageId = 'mid-x' }), {
queueEvent = function(...) queued[#queued + 1] = table.pack(...); end, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
}); });
@ -170,7 +170,7 @@ testlib.test('onMessage coerces a missing payload to an empty table', function()
end); end);
testlib.test('onMessage stays silent on malformed frames', function() testlib.test('onMessage stays silent on malformed frames', function()
local sandbox = createSandbox(); local cloud = createCloud();
local sent = 0; local sent = 0;
local ctx = { local ctx = {
traposId = '7:base', traposId = '7:base',
@ -178,15 +178,15 @@ testlib.test('onMessage stays silent on malformed frames', function()
queueEvent = function() sent = sent + 1; end, queueEvent = function() sent = sent + 1; end,
onHello = function() sent = sent + 1; end, onHello = function() sent = sent + 1; end,
}; };
sandbox.onMessage('not json', ctx); cloud.onMessage('not json', ctx);
sandbox.onMessage(encode({ event = 'computer_request', type = 'ping' }), ctx); cloud.onMessage(encode({ event = 'computer_request', type = 'ping' }), ctx);
testlib.assertEquals(sent, 0); testlib.assertEquals(sent, 0);
end); end);
-- startSession wiring ----------------------------------------------------- -- startSession wiring -----------------------------------------------------
local function startSession(extra) local function startSession(extra)
local sandbox = createSandbox(); local cloud = createCloud();
local el = fakes.fakeEventloop(); local el = fakes.fakeEventloop();
local http = fakes.fakeHttp(); local http = fakes.fakeHttp();
local queued = {}; local queued = {};
@ -200,8 +200,8 @@ local function startSession(extra)
if extra then if extra then
for k, v in pairs(extra) do opts[k] = v; end for k, v in pairs(extra) do opts[k] = v; end
end end
local session = sandbox.startSession(opts); local session = cloud.startSession(opts);
return { sandbox = sandbox, el = el, http = http, queued = queued, session = session, url = http.connects[1] }; return { cloud = cloud, el = el, http = http, queued = queued, session = session, url = http.connects[1] };
end end
testlib.test('startSession connects to the /gateway path', function() testlib.test('startSession connects to the /gateway path', function()
@ -220,11 +220,11 @@ testlib.test('startSession sends hello on open and goes ready on ack', function(
testlib.assertEquals(hello.type, 'hello'); testlib.assertEquals(hello.type, 'hello');
testlib.assertEquals(hello.payload.secret, 'hunter2'); testlib.assertEquals(hello.payload.secret, 'hunter2');
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
testlib.assertEquals(ctx.session.isReady(), true); testlib.assertEquals(ctx.session.isReady(), true);
local connected = ctx.queued[#ctx.queued]; local connected = ctx.queued[#ctx.queued];
testlib.assertEquals(connected[1], 'trapos_sandbox_connected'); testlib.assertEquals(connected[1], 'trapos_cloud_connected');
testlib.assertEquals(connected[2], '7:base'); testlib.assertEquals(connected[2], '7:base');
end); end);
@ -235,7 +235,7 @@ testlib.test('startSession treats unauthorized hello as terminal', function()
local timersAfterOpen = #ctx.el.timers; -- hello watchdog local timersAfterOpen = #ctx.el.timers; -- hello watchdog
ctx.el.fire('websocket_message', ctx.url, encode({ ctx.el.fire('websocket_message', ctx.url, encode({
event = 'gateway_response', type = 'hello', ok = false, error = { code = 'unauthorized', message = 'bad secret' }, event = 'server_response', type = 'hello', ok = false, error = { code = 'unauthorized', message = 'bad secret' },
})); }));
testlib.assertEquals(ctx.session.isReady(), false); testlib.assertEquals(ctx.session.isReady(), false);
@ -285,7 +285,7 @@ testlib.test('startSession logs the reconnect loop once on entry and once on rec
-- Healthy connect: no reconnect-loop logs yet. -- Healthy connect: no reconnect-loop logs yet.
ctx.el.fire('websocket_success', ctx.url, fakes.fakeWs()); ctx.el.fire('websocket_success', ctx.url, fakes.fakeWs());
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
testlib.assertEquals(ctx.session.isReady(), true); testlib.assertEquals(ctx.session.isReady(), true);
testlib.assertEquals(countMsg('reconnect loop started'), 0); testlib.assertEquals(countMsg('reconnect loop started'), 0);
@ -305,7 +305,7 @@ testlib.test('startSession logs the reconnect loop once on entry and once on rec
-- Recovery: a single "reconnected" line, no second "loop started". -- Recovery: a single "reconnected" line, no second "loop started".
ctx.el.fireTimer(#ctx.el.timers); -- reconnect timer -> connect() ctx.el.fireTimer(#ctx.el.timers); -- reconnect timer -> connect()
ctx.el.fire('websocket_success', ctx.url, fakes.fakeWs()); ctx.el.fire('websocket_success', ctx.url, fakes.fakeWs());
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
testlib.assertEquals(ctx.session.isReady(), true); testlib.assertEquals(ctx.session.isReady(), true);
testlib.assertEquals(countMsg('reconnect loop ended; reconnected'), 1); testlib.assertEquals(countMsg('reconnect loop ended; reconnected'), 1);
@ -316,7 +316,7 @@ testlib.test('startSession treats a displacement close (4000) as terminal', func
local ctx = startSession(); local ctx = startSession();
local ws = fakes.fakeWs(); local ws = fakes.fakeWs();
ctx.el.fire('websocket_success', ctx.url, ws); ctx.el.fire('websocket_success', ctx.url, ws);
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
testlib.assertEquals(ctx.session.isReady(), true); testlib.assertEquals(ctx.session.isReady(), true);
local timersBefore = #ctx.el.timers; local timersBefore = #ctx.el.timers;
@ -333,7 +333,7 @@ testlib.test('startSession treats a displacement close by reason as terminal', f
local ctx = startSession(); local ctx = startSession();
local ws = fakes.fakeWs(); local ws = fakes.fakeWs();
ctx.el.fire('websocket_success', ctx.url, ws); ctx.el.fire('websocket_success', ctx.url, ws);
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
testlib.assertEquals(ctx.session.isReady(), true); testlib.assertEquals(ctx.session.isReady(), true);
local timersBefore = #ctx.el.timers; local timersBefore = #ctx.el.timers;
@ -355,7 +355,7 @@ testlib.test('startSession ignores a displacement of an already-superseded socke
testlib.assertEquals(ctx.session.lastError(), nil); testlib.assertEquals(ctx.session.lastError(), nil);
-- Not terminal: a hello ack on the live socket still brings us to ready. -- Not terminal: a hello ack on the live socket still brings us to ready.
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
testlib.assertEquals(ctx.session.isReady(), true); testlib.assertEquals(ctx.session.isReady(), true);
end); end);
@ -369,9 +369,9 @@ end);
testlib.test('send handler replies not_connected when not ready', function() testlib.test('send handler replies not_connected when not ready', function()
local ctx = startSession(); local ctx = startSession();
ctx.el.fire('trapos_sandbox_send', 'mid-1', 'probe_gateway', nil); ctx.el.fire('trapos_cloud_send', 'mid-1', 'probe_server', nil);
local reply = ctx.queued[#ctx.queued]; local reply = ctx.queued[#ctx.queued];
testlib.assertEquals(reply[1], 'trapos_sandbox_reply'); testlib.assertEquals(reply[1], 'trapos_cloud_reply');
testlib.assertEquals(reply[2], 'mid-1'); testlib.assertEquals(reply[2], 'mid-1');
testlib.assertEquals(reply[3], false); testlib.assertEquals(reply[3], false);
testlib.assertEquals(reply[5].code, 'not_connected'); testlib.assertEquals(reply[5].code, 'not_connected');
@ -381,41 +381,41 @@ testlib.test('send handler forwards a request when ready without an immediate re
local ctx = startSession(); local ctx = startSession();
local ws = fakes.fakeWs(); local ws = fakes.fakeWs();
ctx.el.fire('websocket_success', ctx.url, ws); ctx.el.fire('websocket_success', ctx.url, ws);
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
local queuedBefore = #ctx.queued; local queuedBefore = #ctx.queued;
ctx.el.fire('trapos_sandbox_send', 'mid-2', 'probe_gateway', nil); ctx.el.fire('trapos_cloud_send', 'mid-2', 'probe_server', nil);
testlib.assertEquals(#ctx.queued, queuedBefore); -- no immediate reply testlib.assertEquals(#ctx.queued, queuedBefore); -- no immediate reply
local frame = textutils.unserializeJSON(ws.sent[#ws.sent]); local frame = textutils.unserializeJSON(ws.sent[#ws.sent]);
testlib.assertEquals(frame.event, 'computer_request'); testlib.assertEquals(frame.event, 'computer_request');
testlib.assertEquals(frame.type, 'probe_gateway'); testlib.assertEquals(frame.type, 'probe_server');
testlib.assertEquals(frame.messageId, 'mid-2'); testlib.assertEquals(frame.messageId, 'mid-2');
end); end);
-- request() --------------------------------------------------------------- -- request() ---------------------------------------------------------------
testlib.test('request resolves on a matching reply', function() testlib.test('request resolves on a matching reply', function()
local sandbox = createSandbox(); local cloud = createCloud();
local events = fakes.fakeEvents({ local events = fakes.fakeEvents({
{ 'trapos_sandbox_reply', 'mid', true, { sandboxOk = true }, n = 4 }, { 'trapos_cloud_reply', 'mid', true, { serverOk = true }, n = 4 },
}); });
local ok, payload = sandbox.request('probe_gateway', nil, { local ok, payload = cloud.request('probe_server', nil, {
messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent, messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
startTimer = events.startTimer, cancelTimer = events.cancelTimer, startTimer = events.startTimer, cancelTimer = events.cancelTimer,
}); });
testlib.assertEquals(ok, true); testlib.assertEquals(ok, true);
testlib.assertEquals(payload.sandboxOk, true); testlib.assertEquals(payload.serverOk, true);
testlib.assertEquals(events.cancelled[1], 'timer-1'); testlib.assertEquals(events.cancelled[1], 'timer-1');
end); end);
testlib.test('request returns the error on a failed reply', function() testlib.test('request returns the error on a failed reply', function()
local sandbox = createSandbox(); local cloud = createCloud();
local events = fakes.fakeEvents({ local events = fakes.fakeEvents({
{ 'trapos_sandbox_reply', 'mid', false, nil, { code = 'unauthorized', message = 'x' }, n = 5 }, { 'trapos_cloud_reply', 'mid', false, nil, { code = 'unauthorized', message = 'x' }, n = 5 },
}); });
local ok, err = sandbox.request('probe_gateway', nil, { local ok, err = cloud.request('probe_server', nil, {
messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent, messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
startTimer = events.startTimer, cancelTimer = events.cancelTimer, startTimer = events.startTimer, cancelTimer = events.cancelTimer,
}); });
@ -425,29 +425,29 @@ testlib.test('request returns the error on a failed reply', function()
end); end);
testlib.test('request re-queues a mismatched reply and keeps waiting', function() testlib.test('request re-queues a mismatched reply and keeps waiting', function()
local sandbox = createSandbox(); local cloud = createCloud();
local events = fakes.fakeEvents({ local events = fakes.fakeEvents({
{ 'trapos_sandbox_reply', 'other', true, { y = 2 }, n = 4 }, { 'trapos_cloud_reply', 'other', true, { y = 2 }, n = 4 },
{ 'trapos_sandbox_reply', 'mid', true, { x = 1 }, n = 4 }, { 'trapos_cloud_reply', 'mid', true, { x = 1 }, n = 4 },
}); });
local ok, payload = sandbox.request('probe_gateway', nil, { local ok, payload = cloud.request('probe_server', nil, {
messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent, messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
startTimer = events.startTimer, cancelTimer = events.cancelTimer, startTimer = events.startTimer, cancelTimer = events.cancelTimer,
}); });
testlib.assertEquals(ok, true); testlib.assertEquals(ok, true);
testlib.assertEquals(payload.x, 1); testlib.assertEquals(payload.x, 1);
-- queued[1] is the trapos_sandbox_send; queued[2] is the re-queued reply. -- queued[1] is the trapos_cloud_send; queued[2] is the re-queued reply.
testlib.assertEquals(events.queued[2][1], 'trapos_sandbox_reply'); testlib.assertEquals(events.queued[2][1], 'trapos_cloud_reply');
testlib.assertEquals(events.queued[2][2], 'other'); testlib.assertEquals(events.queued[2][2], 'other');
end); end);
testlib.test('request times out when the timer fires first', function() testlib.test('request times out when the timer fires first', function()
local sandbox = createSandbox(); local cloud = createCloud();
local events = fakes.fakeEvents({ local events = fakes.fakeEvents({
{ 'timer', 'timer-1', n = 2 }, { 'timer', 'timer-1', n = 2 },
}); });
local ok, err = sandbox.request('probe_gateway', nil, { local ok, err = cloud.request('probe_server', nil, {
messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent, messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
startTimer = events.startTimer, cancelTimer = events.cancelTimer, startTimer = events.startTimer, cancelTimer = events.cancelTimer,
}); });
@ -462,24 +462,24 @@ local function readySession()
local ctx = startSession(); local ctx = startSession();
local ws = fakes.fakeWs(); local ws = fakes.fakeWs();
ctx.el.fire('websocket_success', ctx.url, ws); ctx.el.fire('websocket_success', ctx.url, ws);
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
ctx.ws = ws; ctx.ws = ws;
return ctx; return ctx;
end end
testlib.test('startSession routes an advertised reverse type to trapos_sandbox_request', function() testlib.test('startSession routes an advertised reverse type to trapos_cloud_request', function()
local ctx = readySession(); local ctx = readySession();
ctx.el.fire('trapos_sandbox_serve', 'exec-lua'); ctx.el.fire('trapos_cloud_serve', 'exec-lua');
local sentBefore = #ctx.ws.sent; local sentBefore = #ctx.ws.sent;
ctx.el.fire('websocket_message', ctx.url, encode({ ctx.el.fire('websocket_message', ctx.url, encode({
event = 'gateway_request', type = 'exec-lua', messageId = 'g-9', payload = { code = 'x' }, event = 'server_request', type = 'exec-lua', messageId = 'g-9', payload = { code = 'x' },
})); }));
-- No immediate reply on the wire; a request event is queued for the server instead. -- No immediate reply on the wire; a request event is queued for the server instead.
testlib.assertEquals(#ctx.ws.sent, sentBefore); testlib.assertEquals(#ctx.ws.sent, sentBefore);
local req = ctx.queued[#ctx.queued]; local req = ctx.queued[#ctx.queued];
testlib.assertEquals(req[1], 'trapos_sandbox_request'); testlib.assertEquals(req[1], 'trapos_cloud_request');
testlib.assertEquals(req[2], 'g-9'); testlib.assertEquals(req[2], 'g-9');
testlib.assertEquals(req[3], 'exec-lua'); testlib.assertEquals(req[3], 'exec-lua');
testlib.assertEquals(req[4].code, 'x'); testlib.assertEquals(req[4].code, 'x');
@ -488,7 +488,7 @@ end);
testlib.test('startSession replies unknown_type for an unadvertised reverse type', function() testlib.test('startSession replies unknown_type for an unadvertised reverse type', function()
local ctx = readySession(); local ctx = readySession();
ctx.el.fire('websocket_message', ctx.url, encode({ ctx.el.fire('websocket_message', ctx.url, encode({
event = 'gateway_request', type = 'exec-lua', messageId = 'g-10', event = 'server_request', type = 'exec-lua', messageId = 'g-10',
})); }));
local frame = textutils.unserializeJSON(ctx.ws.sent[#ctx.ws.sent]); local frame = textutils.unserializeJSON(ctx.ws.sent[#ctx.ws.sent]);
@ -501,7 +501,7 @@ testlib.test('respond handler sends a computer_response when ready', function()
local ctx = readySession(); local ctx = readySession();
local sentBefore = #ctx.ws.sent; local sentBefore = #ctx.ws.sent;
ctx.el.fire('trapos_sandbox_respond', 'g-11', 'exec-lua', true, { ok = true, output = 'hi' }, nil); ctx.el.fire('trapos_cloud_respond', 'g-11', 'exec-lua', true, { ok = true, output = 'hi' }, nil);
testlib.assertEquals(#ctx.ws.sent, sentBefore + 1); testlib.assertEquals(#ctx.ws.sent, sentBefore + 1);
local frame = textutils.unserializeJSON(ctx.ws.sent[#ctx.ws.sent]); local frame = textutils.unserializeJSON(ctx.ws.sent[#ctx.ws.sent]);
@ -515,21 +515,21 @@ end);
testlib.test('respond handler drops the reply when not connected', function() testlib.test('respond handler drops the reply when not connected', function()
local ctx = startSession(); -- never reaches 'ready' local ctx = startSession(); -- never reaches 'ready'
-- Must not raise even though there is no live socket to send on. -- Must not raise even though there is no live socket to send on.
ctx.el.fire('trapos_sandbox_respond', 'g-12', 'exec-lua', true, { ok = true }, nil); ctx.el.fire('trapos_cloud_respond', 'g-12', 'exec-lua', true, { ok = true }, nil);
testlib.assertEquals(ctx.session.isReady(), false); testlib.assertEquals(ctx.session.isReady(), false);
end); end);
-- serve() / respond() helpers --------------------------------------------- -- serve() / respond() helpers ---------------------------------------------
testlib.test('respond queues a trapos_sandbox_respond event', function() testlib.test('respond queues a trapos_cloud_respond event', function()
local sandbox = createSandbox(); local cloud = createCloud();
local queued = {}; local queued = {};
sandbox.respond('mid-1', 'exec-lua', true, { ok = true }, nil, { cloud.respond('mid-1', 'exec-lua', true, { ok = true }, nil, {
queueEvent = function(...) queued[#queued + 1] = table.pack(...); end, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
}); });
testlib.assertEquals(#queued, 1); testlib.assertEquals(#queued, 1);
testlib.assertEquals(queued[1][1], 'trapos_sandbox_respond'); testlib.assertEquals(queued[1][1], 'trapos_cloud_respond');
testlib.assertEquals(queued[1][2], 'mid-1'); testlib.assertEquals(queued[1][2], 'mid-1');
testlib.assertEquals(queued[1][3], 'exec-lua'); testlib.assertEquals(queued[1][3], 'exec-lua');
testlib.assertEquals(queued[1][4], true); testlib.assertEquals(queued[1][4], true);
@ -537,57 +537,57 @@ testlib.test('respond queues a trapos_sandbox_respond event', function()
end); end);
testlib.test('serve advertises the type and auto-responds on success', function() testlib.test('serve advertises the type and auto-responds on success', function()
local sandbox = createSandbox(); local cloud = createCloud();
local el = fakes.fakeEventloop(); local el = fakes.fakeEventloop();
local queued = {}; local queued = {};
sandbox.serve('exec-lua', function(payload) cloud.serve('exec-lua', function(payload)
return true, { echoed = payload.code }; return true, { echoed = payload.code };
end, { eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end }); end, { eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end });
testlib.assertEquals(queued[1][1], 'trapos_sandbox_serve'); testlib.assertEquals(queued[1][1], 'trapos_cloud_serve');
testlib.assertEquals(queued[1][2], 'exec-lua'); testlib.assertEquals(queued[1][2], 'exec-lua');
el.fire('trapos_sandbox_request', 'mid-7', 'exec-lua', { code = 'print(1)' }); el.fire('trapos_cloud_request', 'mid-7', 'exec-lua', { code = 'print(1)' });
local respond = queued[#queued]; local respond = queued[#queued];
testlib.assertEquals(respond[1], 'trapos_sandbox_respond'); testlib.assertEquals(respond[1], 'trapos_cloud_respond');
testlib.assertEquals(respond[2], 'mid-7'); testlib.assertEquals(respond[2], 'mid-7');
testlib.assertEquals(respond[4], true); testlib.assertEquals(respond[4], true);
testlib.assertEquals(respond[5].echoed, 'print(1)'); testlib.assertEquals(respond[5].echoed, 'print(1)');
end); end);
testlib.test('serve ignores requests for other types', function() testlib.test('serve ignores requests for other types', function()
local sandbox = createSandbox(); local cloud = createCloud();
local el = fakes.fakeEventloop(); local el = fakes.fakeEventloop();
local queued = {}; local queued = {};
sandbox.serve('exec-lua', function() return true, {}; end, { cloud.serve('exec-lua', function() return true, {}; end, {
eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end, eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
}); });
local before = #queued; local before = #queued;
el.fire('trapos_sandbox_request', 'mid', 'write-file', {}); el.fire('trapos_cloud_request', 'mid', 'write-file', {});
testlib.assertEquals(#queued, before); -- no respond queued testlib.assertEquals(#queued, before); -- no respond queued
end); end);
testlib.test('serve responds with the error table on a failure result', function() testlib.test('serve responds with the error table on a failure result', function()
local sandbox = createSandbox(); local cloud = createCloud();
local el = fakes.fakeEventloop(); local el = fakes.fakeEventloop();
local queued = {}; local queued = {};
sandbox.serve('exec-lua', function() return false, { code = 'internal', message = 'nope' }; end, { cloud.serve('exec-lua', function() return false, { code = 'internal', message = 'nope' }; end, {
eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end, eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
}); });
el.fire('trapos_sandbox_request', 'mid', 'exec-lua', {}); el.fire('trapos_cloud_request', 'mid', 'exec-lua', {});
local respond = queued[#queued]; local respond = queued[#queued];
testlib.assertEquals(respond[4], false); testlib.assertEquals(respond[4], false);
testlib.assertEquals(respond[6].code, 'internal'); testlib.assertEquals(respond[6].code, 'internal');
end); end);
testlib.test('serve reports a thrown handler as an internal error', function() testlib.test('serve reports a thrown handler as an internal error', function()
local sandbox = createSandbox(); local cloud = createCloud();
local el = fakes.fakeEventloop(); local el = fakes.fakeEventloop();
local queued = {}; local queued = {};
sandbox.serve('exec-lua', function() error('boom', 0); end, { cloud.serve('exec-lua', function() error('boom', 0); end, {
eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end, eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
}); });
el.fire('trapos_sandbox_request', 'mid', 'exec-lua', {}); el.fire('trapos_cloud_request', 'mid', 'exec-lua', {});
local respond = queued[#queued]; local respond = queued[#queued];
testlib.assertEquals(respond[4], false); testlib.assertEquals(respond[4], false);
testlib.assertEquals(respond[6].code, 'internal'); testlib.assertEquals(respond[6].code, 'internal');

View File

@ -10,7 +10,7 @@
-- - ccpm.cache.json -> { packages = { <name> = { version, registry } } } -- - ccpm.cache.json -> { packages = { <name> = { version, registry } } }
-- --
-- Files are written under `opts.installRoot` (default '' -> filesystem root), so -- Files are written under `opts.installRoot` (default '' -> filesystem root), so
-- tests can sandbox downloads. `opts.http` overrides the global `http` for tests. -- tests can isolate downloads. `opts.http` overrides the global `http` for tests.
local DEFAULT_STATE_DIR = '/trapos'; local DEFAULT_STATE_DIR = '/trapos';

View File

@ -1,6 +1,6 @@
{ {
"name": "trapos-core", "name": "trapos-core",
"version": "0.9.2", "version": "0.9.3",
"description": "TrapOS base: package manager, event loop, upgrade and event tools", "description": "TrapOS base: package manager, event loop, upgrade and event tools",
"dependencies": [], "dependencies": [],
"files": [ "files": [

View File

@ -1,8 +1,8 @@
local createVersion = require('/apis/libversion'); local createVersion = require('/apis/libversion');
local DEFAULT_SANDBOX_URL = 'wss://os.trapcloud.fr'; local DEFAULT_CLOUD_URL = 'wss://os.trapcloud.fr';
local SANDBOX_URL_SETTING = 'sandbox.url'; local CLOUD_URL_SETTING = 'cloud.url';
local SANDBOX_PASSWORD_SETTING = 'sandbox.password'; local CLOUD_PASSWORD_SETTING = 'cloud.password';
local function isBlank(value) local function isBlank(value)
return type(value) ~= 'string' or value == ''; return type(value) ~= 'string' or value == '';
@ -44,14 +44,14 @@ local function readManifestVersion()
end end
local version = readManifestVersion(); local version = readManifestVersion();
local configuredSandboxUrl = false; local configuredCloudUrl = false;
if isBlank(settings.get(SANDBOX_URL_SETTING)) then if isBlank(settings.get(CLOUD_URL_SETTING)) then
shell.run('set', SANDBOX_URL_SETTING, DEFAULT_SANDBOX_URL); shell.run('set', CLOUD_URL_SETTING, DEFAULT_CLOUD_URL);
configuredSandboxUrl = true; configuredCloudUrl = true;
end end
local hasSandboxPassword = not isBlank(settings.get(SANDBOX_PASSWORD_SETTING)); local hasCloudPassword = not isBlank(settings.get(CLOUD_PASSWORD_SETTING));
print(); print();
if version then if version then
@ -59,18 +59,18 @@ if version then
else else
print('=> TrapOS installed.'); print('=> TrapOS installed.');
end end
if configuredSandboxUrl then if configuredCloudUrl then
print('=> Configured sandbox.url to ' .. DEFAULT_SANDBOX_URL .. '.'); print('=> Configured cloud.url to ' .. DEFAULT_CLOUD_URL .. '.');
else else
print('=> Keeping existing sandbox.url.'); print('=> Keeping existing cloud.url.');
end end
if hasSandboxPassword then if hasCloudPassword then
print('=> Sandbox password already configured.'); print('=> Cloud password already configured.');
else else
print('=> Sandbox password not set.'); print('=> Cloud password not set.');
end end
print(); print();
print('Get started:'); print('Get started:');
print(' sandbox --help -- open the sandbox help'); print(' cloud --help -- open the cloud help');
print(' trapos-upgrade -- update and upgrade TrapOS'); print(' trapos-upgrade -- update and upgrade TrapOS');
print(); print();

View File

@ -5,7 +5,7 @@ local testlib = createLibTest({ ... })
local counter = 0 local counter = 0
-- Fresh, isolated state + install sandbox per case (never touches real /trapos). -- Fresh, isolated state + isolated install per case (never touches real /trapos).
local function freshDirs() local function freshDirs()
counter = counter + 1 counter = counter + 1
local stateDir = "/ccpm-test/state-" .. counter local stateDir = "/ccpm-test/state-" .. counter

View File

@ -175,7 +175,7 @@ testlib.test('create install disk labels, copies settings, writes startup', func
local settingsPath = fs.combine(freshDir(), '.settings'); local settingsPath = fs.combine(freshDir(), '.settings');
local drive = { name = 'top', mountPath = disk }; local drive = { name = 'top', mountPath = disk };
local settingsApi = fakeSettings(); local settingsApi = fakeSettings();
writeFile(settingsPath, 'sandbox.url=https://example.test'); writeFile(settingsPath, 'cloud.url=https://example.test');
local installer = createInstallerDisk({ local installer = createInstallerDisk({
peripheral = fakePeripheral({ drive }), peripheral = fakePeripheral({ drive }),
@ -189,7 +189,7 @@ testlib.test('create install disk labels, copies settings, writes startup', func
testlib.assertEquals(result.mode, 'install'); testlib.assertEquals(result.mode, 'install');
testlib.assertEquals(settingsApi.saveCount, 1); testlib.assertEquals(settingsApi.saveCount, 1);
testlib.assertEquals(readAll(fs.combine(disk, 'startup.lua')), installer.buildAutorun('install')); testlib.assertEquals(readAll(fs.combine(disk, 'startup.lua')), installer.buildAutorun('install'));
testlib.assertEquals(readAll(fs.combine(disk, '.settings')), 'sandbox.url=https://example.test'); testlib.assertEquals(readAll(fs.combine(disk, '.settings')), 'cloud.url=https://example.test');
testlib.assertTrue(result.settingsCopied); testlib.assertTrue(result.settingsCopied);
testlib.assertTrue(not result.settingsWarning); testlib.assertTrue(not result.settingsWarning);
end); end);
@ -217,7 +217,7 @@ testlib.test('create reinstall disk skips settings clone', function()
local settingsPath = fs.combine(freshDir(), '.settings'); local settingsPath = fs.combine(freshDir(), '.settings');
local drive = { name = 'top', mountPath = disk }; local drive = { name = 'top', mountPath = disk };
local settingsApi = fakeSettings(); local settingsApi = fakeSettings();
writeFile(settingsPath, 'sandbox.url=https://example.test'); writeFile(settingsPath, 'cloud.url=https://example.test');
local installer = createInstallerDisk({ local installer = createInstallerDisk({
peripheral = fakePeripheral({ drive }), peripheral = fakePeripheral({ drive }),

View File

@ -2,7 +2,7 @@ local createLibTest = require('/apis/libtest');
local testlib = createLibTest({ ... }); local testlib = createLibTest({ ... });
local DEFAULT_SANDBOX_URL = 'wss://os.trapcloud.fr'; local DEFAULT_CLOUD_URL = 'wss://os.trapcloud.fr';
local function fakeSettings(values) local function fakeSettings(values)
return { return {
@ -50,48 +50,48 @@ local function containsLine(lines, expected)
return false; return false;
end end
testlib.test('postinstall defaults missing sandbox url', function() testlib.test('postinstall defaults missing cloud url', function()
local settingsLib = fakeSettings(); local settingsLib = fakeSettings();
local lines, shellRuns = runPostinstall(settingsLib); local lines, shellRuns = runPostinstall(settingsLib);
testlib.assertEquals(settingsLib.values['sandbox.url'], DEFAULT_SANDBOX_URL); testlib.assertEquals(settingsLib.values['cloud.url'], DEFAULT_CLOUD_URL);
testlib.assertEquals(#shellRuns, 1); testlib.assertEquals(#shellRuns, 1);
testlib.assertEquals(shellRuns[1][1], 'set'); testlib.assertEquals(shellRuns[1][1], 'set');
testlib.assertEquals(shellRuns[1][2], 'sandbox.url'); testlib.assertEquals(shellRuns[1][2], 'cloud.url');
testlib.assertEquals(shellRuns[1][3], DEFAULT_SANDBOX_URL); testlib.assertEquals(shellRuns[1][3], DEFAULT_CLOUD_URL);
testlib.assertTrue(containsLine(lines, '=> Configured sandbox.url to ' .. DEFAULT_SANDBOX_URL .. '.')); testlib.assertTrue(containsLine(lines, '=> Configured cloud.url to ' .. DEFAULT_CLOUD_URL .. '.'));
testlib.assertTrue(containsLine(lines, '=> Sandbox password not set.')); testlib.assertTrue(containsLine(lines, '=> Cloud password not set.'));
end); end);
testlib.test('postinstall treats empty sandbox url as missing', function() testlib.test('postinstall treats empty cloud url as missing', function()
local settingsLib = fakeSettings({ ['sandbox.url'] = '' }); local settingsLib = fakeSettings({ ['cloud.url'] = '' });
local _, shellRuns = runPostinstall(settingsLib); local _, shellRuns = runPostinstall(settingsLib);
testlib.assertEquals(settingsLib.values['sandbox.url'], DEFAULT_SANDBOX_URL); testlib.assertEquals(settingsLib.values['cloud.url'], DEFAULT_CLOUD_URL);
testlib.assertEquals(#shellRuns, 1); testlib.assertEquals(#shellRuns, 1);
end); end);
testlib.test('postinstall preserves existing sandbox url and password', function() testlib.test('postinstall preserves existing cloud url and password', function()
local settingsLib = fakeSettings({ local settingsLib = fakeSettings({
['sandbox.url'] = 'wss://custom.example', ['cloud.url'] = 'wss://custom.example',
['sandbox.password'] = 'secret', ['cloud.password'] = 'secret',
}); });
local lines, shellRuns = runPostinstall(settingsLib); local lines, shellRuns = runPostinstall(settingsLib);
testlib.assertEquals(settingsLib.values['sandbox.url'], 'wss://custom.example'); testlib.assertEquals(settingsLib.values['cloud.url'], 'wss://custom.example');
testlib.assertEquals(#shellRuns, 0); testlib.assertEquals(#shellRuns, 0);
testlib.assertTrue(containsLine(lines, '=> Keeping existing sandbox.url.')); testlib.assertTrue(containsLine(lines, '=> Keeping existing cloud.url.'));
testlib.assertTrue(containsLine(lines, '=> Sandbox password already configured.')); testlib.assertTrue(containsLine(lines, '=> Cloud password already configured.'));
end); end);
testlib.test('postinstall treats empty sandbox password as missing', function() testlib.test('postinstall treats empty cloud password as missing', function()
local settingsLib = fakeSettings({ local settingsLib = fakeSettings({
['sandbox.url'] = 'wss://custom.example', ['cloud.url'] = 'wss://custom.example',
['sandbox.password'] = '', ['cloud.password'] = '',
}); });
local lines = runPostinstall(settingsLib); local lines = runPostinstall(settingsLib);
testlib.assertTrue(containsLine(lines, '=> Sandbox password not set.')); testlib.assertTrue(containsLine(lines, '=> Cloud password not set.'));
end); end);
testlib.run(); testlib.run();

View File

@ -1,14 +0,0 @@
{
"name": "trapos-sandbox",
"version": "0.3.5",
"description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command + MCP exec/write server",
"dependencies": ["trapos-core"],
"files": [
"apis/libsandbox.lua",
"apis/libmcpcomputer.lua",
"programs/sandbox.lua",
"servers/sandbox.lua",
"servers/mcp-computer-server.lua"
],
"autostart": ["servers/sandbox", "servers/mcp-computer-server"]
}

View File

@ -1,4 +1,4 @@
-- Shared test fakes for the trapos-sandbox Lua client. -- Shared test fakes for the trapos-cloud Lua client.
-- --
-- Lives under tests/harness/ so runtest.lua does NOT auto-discover it as a test -- Lives under tests/harness/ so runtest.lua does NOT auto-discover it as a test
-- (discoverTests skips subdirectories), while /tests is still mounted so it can -- (discoverTests skips subdirectories), while /tests is still mounted so it can

View File

@ -1,6 +1,6 @@
{ {
"name": "trapos", "name": "trapos",
"version": "0.11.6", "version": "0.12.0",
"description": "TrapOS full install meta-package", "description": "TrapOS full install meta-package",
"dependencies": [ "dependencies": [
"trapos-boot", "trapos-boot",
@ -8,7 +8,7 @@
"trapos-ui", "trapos-ui",
"trapos-test", "trapos-test",
"trapos-ai", "trapos-ai",
"trapos-sandbox" "trapos-cloud"
], ],
"files": [], "files": [],
"autostart": [] "autostart": []

View File

@ -6,7 +6,7 @@ local function parentDir(path)
return path:match('^(.*)/[^/]+$'); return path:match('^(.*)/[^/]+$');
end end
local function sandboxPath(root, path) local function scopedPath(root, path)
if path:sub(1, 1) == '/' then if path:sub(1, 1) == '/' then
return root .. path; return root .. path;
end end
@ -16,16 +16,16 @@ end
local function fakeFs(root) local function fakeFs(root)
return { return {
exists = function(path) exists = function(path)
return fs.exists(sandboxPath(root, path)); return fs.exists(scopedPath(root, path));
end, end,
makeDir = function(path) makeDir = function(path)
fs.makeDir(sandboxPath(root, path)); fs.makeDir(scopedPath(root, path));
end, end,
open = function(path, mode) open = function(path, mode)
return fs.open(sandboxPath(root, path), mode); return fs.open(scopedPath(root, path), mode);
end, end,
delete = function(path) delete = function(path)
fs.delete(sandboxPath(root, path)); fs.delete(scopedPath(root, path));
end, end,
}; };
end end
@ -59,7 +59,7 @@ local function installRoutes()
}; };
end end
-- Run install-trapos.lua inside a sandboxed env. `args` is a list of CLI args. -- Run install-trapos.lua inside a isolated env. `args` is a list of CLI args.
-- Returns { calls, root } for assertions. -- Returns { calls, root } for assertions.
local function runInstall(root, args) local function runInstall(root, args)
fs.delete(root); fs.delete(root);

View File

@ -1,30 +0,0 @@
-- Full-boot e2e: replicate the real boot path (startup/boot.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();

View File

@ -1,11 +1,11 @@
{ {
"name": "trap-sandbox", "name": "trapos-server",
"version": "0.2.2", "version": "0.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trap-sandbox", "name": "trapos-server",
"version": "0.2.2", "version": "0.2.2",
"dependencies": { "dependencies": {
"@fastify/websocket": "^11", "@fastify/websocket": "^11",

View File

@ -1,6 +1,6 @@
{ {
"name": "trap-sandbox", "name": "trapos-server",
"version": "0.2.2", "version": "0.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {

View File

@ -9,7 +9,7 @@ import mcpModule from "./modules/mcp/index.js";
export type Config = { export type Config = {
host: string; host: string;
port: number; port: number;
sandboxPassword?: string; serverPassword?: string;
requestTimeoutMs: number; requestTimeoutMs: number;
opencodeUrl?: string; opencodeUrl?: string;
opencodeUsername: string; opencodeUsername: string;
@ -48,7 +48,7 @@ export async function createApp(
await app.register(gatewayModule, { await app.register(gatewayModule, {
registry, registry,
probe, probe,
resolveAccount: (secret) => resolveAccount(secret, config.sandboxPassword), resolveAccount: (secret) => resolveAccount(secret, config.serverPassword),
}); });
const mcpApp = config.mcpEnabled ? await createMcpApp(config, registry) : null; const mcpApp = config.mcpEnabled ? await createMcpApp(config, registry) : null;

View File

@ -1,10 +1,10 @@
import { createApp, type Config } from "./app.js"; import { createApp, type Config } from "./app.js";
const config: Config = { const config: Config = {
host: process.env.SANDBOX_HOST ?? "0.0.0.0", host: process.env.TRAPOS_SERVER_HOST ?? "0.0.0.0",
port: readPort(process.env.SANDBOX_PORT, 4444), port: readPort(process.env.TRAPOS_SERVER_PORT, 4444),
sandboxPassword: process.env.SANDBOX_PASSWORD, serverPassword: process.env.TRAPOS_SERVER_PASSWORD,
requestTimeoutMs: readPositiveInt(process.env.SANDBOX_REQUEST_TIMEOUT_MS, 600_000), requestTimeoutMs: readPositiveInt(process.env.TRAPOS_SERVER_REQUEST_TIMEOUT_MS, 600_000),
opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4242", opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4242",
opencodeUsername: process.env.OPENCODE_USERNAME ?? "opencode", opencodeUsername: process.env.OPENCODE_USERNAME ?? "opencode",
opencodeServerPassword: process.env.OPENCODE_SERVER_PASSWORD, opencodeServerPassword: process.env.OPENCODE_SERVER_PASSWORD,
@ -24,7 +24,7 @@ const { app, mcpApp } = await createApp(config);
try { try {
await app.listen({ host: config.host, port: config.port }); await app.listen({ host: config.host, port: config.port });
} catch (err) { } catch (err) {
reportListenError(err, config.host, config.port, "SANDBOX_PORT"); reportListenError(err, config.host, config.port, "TRAPOS_SERVER_PORT");
app.log.error(err); app.log.error(err);
process.exit(1); process.exit(1);
} }
@ -72,7 +72,7 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
function reportListenError(err: unknown, host: string, port: number, portEnv: string): void { function reportListenError(err: unknown, host: string, port: number, portEnv: string): void {
if (isErrnoException(err) && err.code === "EADDRINUSE") { if (isErrnoException(err) && err.code === "EADDRINUSE") {
process.stderr.write( process.stderr.write(
`\n[trap-sandbox] Port ${port} is already in use on ${host}.\n` + `\n[trapos-server] Port ${port} is already in use on ${host}.\n` +
`Another instance may be running, or set ${portEnv} to a free port.\n\n`, `Another instance may be running, or set ${portEnv} to a free port.\n\n`,
); );
process.exit(1); process.exit(1);

View File

@ -24,7 +24,7 @@ type JsonRpcRequest = {
params?: unknown; params?: unknown;
}; };
const SERVER_VERSION = "0.2.2"; const SERVER_VERSION = "0.3.0";
const TOOLS = [ const TOOLS = [
{ {
@ -97,7 +97,7 @@ async function handleSingle(request: JsonRpcRequest, deps: McpDeps): Promise<unk
return jsonRpcResult(id, { return jsonRpcResult(id, {
protocolVersion: "2024-11-05", protocolVersion: "2024-11-05",
capabilities: { tools: {} }, capabilities: { tools: {} },
serverInfo: { name: "trap-sandbox", version: SERVER_VERSION }, serverInfo: { name: "trapos-mcp", version: SERVER_VERSION },
}); });
} }

View File

@ -47,7 +47,7 @@ export function createConnection(deps: ConnectionDeps): Connection {
error?: GatewayErrorBody, error?: GatewayErrorBody,
): void { ): void {
const frame: Record<string, unknown> = { const frame: Record<string, unknown> = {
event: "gateway_response", event: "server_response",
traposReceiverId: req.traposSenderId, traposReceiverId: req.traposSenderId,
messageId: req.messageId, messageId: req.messageId,
type: req.type, type: req.type,
@ -84,7 +84,7 @@ export function createConnection(deps: ConnectionDeps): Connection {
async function handleProbeGateway(req: ComputerRequest): Promise<void> { async function handleProbeGateway(req: ComputerRequest): Promise<void> {
log.debug({ accountId: state?.accountId, traposId: state?.traposId, messageId: req.messageId }, "probing gateway dependencies"); log.debug({ accountId: state?.accountId, traposId: state?.traposId, messageId: req.messageId }, "probing gateway dependencies");
const result = await probe.probe(); const result = await probe.probe();
const payload: Record<string, unknown> = { sandboxOk: true, opencodeOk: result.ok }; const payload: Record<string, unknown> = { serverOk: true, opencodeOk: result.ok };
if (!result.ok && result.detail !== undefined) { if (!result.ok && result.detail !== undefined) {
payload.opencodeDetail = result.detail; payload.opencodeDetail = result.detail;
} }
@ -111,7 +111,7 @@ export function createConnection(deps: ConnectionDeps): Connection {
log.debug({ accountId: current.accountId, traposId: current.traposId, reason: "duplicate_hello" }, "ignoring duplicate hello on live connection"); log.debug({ accountId: current.accountId, traposId: current.traposId, reason: "duplicate_hello" }, "ignoring duplicate hello on live connection");
return; return;
} }
if (frame.type === "probe_gateway") { if (frame.type === "probe_server") {
await handleProbeGateway(frame); await handleProbeGateway(frame);
return; return;
} }
@ -120,7 +120,7 @@ export function createConnection(deps: ConnectionDeps): Connection {
return; return;
} }
if (frame.event === "computer_response") { if (frame.event === "computer_response") {
// resolve a pending gateway_request // resolve a pending server_request
registry.resolveResponse(socket, current.accountId, current.traposId, frame); registry.resolveResponse(socket, current.accountId, current.traposId, frame);
} }
} }
@ -135,7 +135,7 @@ export function createConnection(deps: ConnectionDeps): Connection {
} }
// The gateway never legitimately receives gateway_* events from a client. // The gateway never legitimately receives gateway_* events from a client.
if (frame.event === "gateway_request" || frame.event === "gateway_response") { if (frame.event === "server_request" || frame.event === "server_response") {
log.debug({ event: frame.event, reason: "wrong_direction" }, "dropping wrong-direction frame"); log.debug({ event: frame.event, reason: "wrong_direction" }, "dropping wrong-direction frame");
return; return;
} }

View File

@ -62,7 +62,7 @@ const computerResponseSchema = z.object({
}); });
const gatewayRequestSchema = z.object({ const gatewayRequestSchema = z.object({
event: z.literal("gateway_request"), event: z.literal("server_request"),
traposReceiverId: z.string().min(1), traposReceiverId: z.string().min(1),
messageId: z.string().min(1), messageId: z.string().min(1),
type: z.string().min(1), type: z.string().min(1),
@ -70,7 +70,7 @@ const gatewayRequestSchema = z.object({
}); });
const gatewayResponseSchema = z.object({ const gatewayResponseSchema = z.object({
event: z.literal("gateway_response"), event: z.literal("server_response"),
traposReceiverId: z.string().min(1), traposReceiverId: z.string().min(1),
messageId: z.string().min(1), messageId: z.string().min(1),
type: z.string().min(1), type: z.string().min(1),

View File

@ -121,7 +121,7 @@ export class GatewayRegistry {
const messageId = randomUUID(); const messageId = randomUUID();
const frame = { const frame = {
event: "gateway_request", event: "server_request",
traposReceiverId: traposId, traposReceiverId: traposId,
messageId, messageId,
type, type,
@ -159,7 +159,7 @@ export class GatewayRegistry {
}); });
} }
// Resolve a pending gateway_request from a matching computer_response. Silently // Resolve a pending server_request from a matching computer_response. Silently
// dropped (with a debug log) when there is no matching pending. // dropped (with a debug log) when there is no matching pending.
resolveResponse(socket: GatewaySocket, accountId: string, traposId: string, frame: ComputerResponse): void { resolveResponse(socket: GatewaySocket, accountId: string, traposId: string, frame: ComputerResponse): void {
const entry = this.entries.get(this.key(accountId, traposId)); const entry = this.entries.get(this.key(accountId, traposId));

View File

@ -1,47 +1,47 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { formatFailure, startCraftos, startSandbox } from "./harness.js"; import { formatFailure, startCraftos, startServer } from "./harness.js";
const SECRET = "s3cr3t-canary"; const SECRET = "s3cr3t-canary";
test("hello with the wrong secret is rejected in-band (unauthorized)", async () => { test("hello with the wrong secret is rejected in-band (unauthorized)", async () => {
const sandbox = await startSandbox({ sandboxPassword: SECRET }); const server = await startServer({ serverPassword: SECRET });
const craftos = startCraftos("gateway-client.lua", { const craftos = startCraftos("gateway-client.lua", {
computerId: 7, computerId: 7,
computerLabel: "auth-bad", computerLabel: "auth-bad",
shellArgs: [sandbox.baseUrl, "wrong-secret", "auth"], shellArgs: [server.baseUrl, "wrong-secret", "auth"],
}); });
try { try {
const result = await craftos.done; const result = await craftos.done;
assert.match( assert.match(
result.output, result.output,
/__SANDBOX_HELLO__ ok=false code=unauthorized/, /__CLOUD_HELLO__ ok=false code=unauthorized/,
formatFailure("expected hello ok=false code=unauthorized", result.output), formatFailure("expected hello ok=false code=unauthorized", result.output),
); );
} finally { } finally {
craftos.abort(); craftos.abort();
await craftos.done.catch(() => undefined); await craftos.done.catch(() => undefined);
await sandbox.close(); await server.close();
} }
}); });
test("hello with the correct secret succeeds", async () => { test("hello with the correct secret succeeds", async () => {
const sandbox = await startSandbox({ sandboxPassword: SECRET }); const server = await startServer({ serverPassword: SECRET });
const craftos = startCraftos("gateway-client.lua", { const craftos = startCraftos("gateway-client.lua", {
computerId: 7, computerId: 7,
computerLabel: "auth-ok", computerLabel: "auth-ok",
shellArgs: [sandbox.baseUrl, SECRET, "auth"], shellArgs: [server.baseUrl, SECRET, "auth"],
}); });
try { try {
const result = await craftos.done; const result = await craftos.done;
assert.match( assert.match(
result.output, result.output,
/__SANDBOX_HELLO__ ok=true/, /__CLOUD_HELLO__ ok=true/,
formatFailure("expected hello ok=true", result.output), formatFailure("expected hello ok=true", result.output),
); );
} finally { } finally {
craftos.abort(); craftos.abort();
await craftos.done.catch(() => undefined); await craftos.done.catch(() => undefined);
await sandbox.close(); await server.close();
} }
}); });

View File

@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { formatFailure, startCraftos, startSandbox, waitFor } from "./harness.js"; import { formatFailure, startCraftos, startServer, waitFor } from "./harness.js";
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
@ -13,7 +13,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout
// "gateway connection opened" log lines. With the fix the loser yields, so opens stay at the // "gateway connection opened" log lines. With the fix the loser yields, so opens stay at the
// two initial connects; pre-fix this number climbed once per reconnectDelay. // two initial connects; pre-fix this number climbed once per reconnectDelay.
test("displaced daemon yields instead of reconnect-looping", async () => { test("displaced daemon yields instead of reconnect-looping", async () => {
const sandbox = await startSandbox({ captureLogs: true }); const server = await startServer({ captureLogs: true });
const TRAPOS_ID = "9:dup"; const TRAPOS_ID = "9:dup";
// Aggressive reconnect so the pre-fix loop would be obvious within the observation window. // Aggressive reconnect so the pre-fix loop would be obvious within the observation window.
const RECONNECT_DELAY = "1"; const RECONNECT_DELAY = "1";
@ -21,29 +21,29 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
const first = startCraftos("gateway-client.lua", { const first = startCraftos("gateway-client.lua", {
computerId: 9, computerId: 9,
computerLabel: "dup", computerLabel: "dup",
shellArgs: [sandbox.baseUrl, "-", "idle", RECONNECT_DELAY], shellArgs: [server.baseUrl, "-", "idle", RECONNECT_DELAY],
}); });
let second: ReturnType<typeof startCraftos> | undefined; let second: ReturnType<typeof startCraftos> | undefined;
try { try {
await waitFor(() => first.snapshot().includes("__SANDBOX_READY__"), 12_000, "first connected"); await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
await waitFor(() => sandbox.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side"); await waitFor(() => server.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
const secondHandle = startCraftos("gateway-client.lua", { const secondHandle = startCraftos("gateway-client.lua", {
computerId: 9, computerId: 9,
computerLabel: "dup", computerLabel: "dup",
shellArgs: [sandbox.baseUrl, "-", "idle", RECONNECT_DELAY], shellArgs: [server.baseUrl, "-", "idle", RECONNECT_DELAY],
}); });
second = secondHandle; second = secondHandle;
await waitFor(() => secondHandle.snapshot().includes("__SANDBOX_READY__"), 12_000, "second connected"); await waitFor(() => secondHandle.snapshot().includes("__CLOUD_READY__"), 12_000, "second connected");
const openedAfterBoth = sandbox.countLog("gateway connection opened"); const openedAfterBoth = server.countLog("gateway connection opened");
// Let several reconnect intervals elapse. If the displaced daemon reconnected, we would // Let several reconnect intervals elapse. If the displaced daemon reconnected, we would
// see a fresh "gateway connection opened" every ~1s here. // see a fresh "gateway connection opened" every ~1s here.
await sleep(5_000); await sleep(5_000);
const openedAfterWait = sandbox.countLog("gateway connection opened"); const openedAfterWait = server.countLog("gateway connection opened");
assert.equal( assert.equal(
openedAfterWait, openedAfterWait,
openedAfterBoth, openedAfterBoth,
@ -51,10 +51,10 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
); );
// The two initial connects, and nothing more. // The two initial connects, and nothing more.
assert.ok(openedAfterWait <= 3, `bounded connection count, got ${openedAfterWait}`); assert.ok(openedAfterWait <= 3, `bounded connection count, got ${openedAfterWait}`);
assert.equal(sandbox.registry.count(), 1, "exactly one registration survives"); assert.equal(server.registry.count(), 1, "exactly one registration survives");
// The surviving (newest) daemon still answers a gateway->computer ping. // The surviving (newest) daemon still answers a gateway->computer ping.
const payload = await sandbox.registry.request("local", TRAPOS_ID, "ping", {}, 2_000); const payload = await server.registry.request("local", TRAPOS_ID, "ping", {}, 2_000);
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "survivor answers reverse ping"); assert.deepEqual(payload, { traposId: TRAPOS_ID }, "survivor answers reverse ping");
} catch (error) { } catch (error) {
throw new Error( throw new Error(
@ -68,6 +68,6 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
first.abort(); first.abort();
second?.abort(); second?.abort();
await Promise.all([first.done.catch(() => undefined), second?.done.catch(() => undefined)]); await Promise.all([first.done.catch(() => undefined), second?.done.catch(() => undefined)]);
await sandbox.close(); await server.close();
} }
}); });

View File

@ -1,13 +1,13 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { formatFailure, startCraftos, startSandbox, waitFor } from "./harness.js"; import { formatFailure, startCraftos, startServer, waitFor } from "./harness.js";
// Two real computers with the same traposId (id:label): newest-wins. After both // Two real computers with the same traposId (id:label): newest-wins. After both
// complete hello, exactly one registration exists; aborting the displaced first // complete hello, exactly one registration exists; aborting the displaced first
// computer does not affect the live registration, and a gateway->computer ping is // computer does not affect the live registration, and a gateway->computer ping is
// still answered by the newest computer. // still answered by the newest computer.
test("duplicate traposId is resolved newest-wins", async () => { test("duplicate traposId is resolved newest-wins", async () => {
const sandbox = await startSandbox(); const server = await startServer();
const TRAPOS_ID = "9:dup"; const TRAPOS_ID = "9:dup";
// Newest-wins is decided by the order hello frames reach the gateway, which is // Newest-wins is decided by the order hello frames reach the gateway, which is
@ -17,28 +17,28 @@ test("duplicate traposId is resolved newest-wins", async () => {
const first = startCraftos("gateway-client.lua", { const first = startCraftos("gateway-client.lua", {
computerId: 9, computerId: 9,
computerLabel: "dup", computerLabel: "dup",
shellArgs: [sandbox.baseUrl, "-", "idle", "60"], shellArgs: [server.baseUrl, "-", "idle", "60"],
}); });
let second: ReturnType<typeof startCraftos> | undefined; let second: ReturnType<typeof startCraftos> | undefined;
try { try {
await waitFor(() => first.snapshot().includes("__SANDBOX_READY__"), 12_000, "first connected"); await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
await waitFor(() => sandbox.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side"); await waitFor(() => server.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
const secondHandle = startCraftos("gateway-client.lua", { const secondHandle = startCraftos("gateway-client.lua", {
computerId: 9, computerId: 9,
computerLabel: "dup", computerLabel: "dup",
shellArgs: [sandbox.baseUrl, "-", "idle"], shellArgs: [server.baseUrl, "-", "idle"],
}); });
second = secondHandle; second = secondHandle;
await waitFor(() => secondHandle.snapshot().includes("__SANDBOX_READY__"), 12_000, "second connected"); await waitFor(() => secondHandle.snapshot().includes("__CLOUD_READY__"), 12_000, "second connected");
// Newest-wins: still exactly one registration for the shared id. // Newest-wins: still exactly one registration for the shared id.
assert.equal(sandbox.registry.count(), 1, "exactly one registration after the duplicate"); assert.equal(server.registry.count(), 1, "exactly one registration after the duplicate");
assert.ok(sandbox.registry.has("local", TRAPOS_ID)); assert.ok(server.registry.has("local", TRAPOS_ID));
first.abort(); first.abort();
await first.done.catch(() => undefined); await first.done.catch(() => undefined);
const payload = await sandbox.registry.request("local", TRAPOS_ID, "ping", {}, 2_000); const payload = await server.registry.request("local", TRAPOS_ID, "ping", {}, 2_000);
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "newest stayed registered after first exited"); assert.deepEqual(payload, { traposId: TRAPOS_ID }, "newest stayed registered after first exited");
} catch (error) { } catch (error) {
throw new Error( throw new Error(
@ -52,6 +52,6 @@ test("duplicate traposId is resolved newest-wins", async () => {
first.abort(); first.abort();
second?.abort(); second?.abort();
await Promise.all([first.done.catch(() => undefined), second?.done.catch(() => undefined)]); await Promise.all([first.done.catch(() => undefined), second?.done.catch(() => undefined)]);
await sandbox.close(); await server.close();
} }
}); });

View File

@ -1,27 +1,27 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { formatFailure, startCraftos, startFakeOpencode, startSandbox } from "./harness.js"; import { formatFailure, startCraftos, startFakeOpencode, startServer } from "./harness.js";
// Full-boot e2e: a real CraftOS computer runs the real servers/sandbox.lua daemon // Full-boot e2e: a real CraftOS computer runs the real servers/cloud.lua daemon
// (driven by sandbox.* settings) and the real `sandbox health` program against the // (driven by cloud.* settings) and the real `cloud health` program against the
// real gateway, with a healthy fake-opencode behind it. // real gateway, with a healthy fake-opencode behind it.
test("full boot: settings -> daemon -> sandbox health prints sandboxOk/opencodeOk true", async () => { test("full boot: settings -> daemon -> cloud health prints serverOk/opencodeOk true", async () => {
const opencode = await startFakeOpencode({ status: 200 }); const opencode = await startFakeOpencode({ status: 200 });
const sandbox = await startSandbox({ sandboxPassword: "boot-secret", opencodeUrl: opencode.url }); const server = await startServer({ serverPassword: "boot-secret", opencodeUrl: opencode.url });
const craftos = startCraftos("full-boot.lua", { const craftos = startCraftos("full-boot.lua", {
computerId: 11, computerId: 11,
computerLabel: "boot", computerLabel: "boot",
shellArgs: [sandbox.baseUrl, "boot-secret"], shellArgs: [server.baseUrl, "boot-secret"],
timeoutMs: 20_000, timeoutMs: 20_000,
}); });
try { try {
const result = await craftos.done; const result = await craftos.done;
assert.match(result.output, /sandboxOk:\s*true/, formatFailure("expected sandboxOk: true", result.output)); assert.match(result.output, /serverOk:\s*true/, formatFailure("expected serverOk: true", result.output));
assert.match(result.output, /opencodeOk:\s*true/, formatFailure("expected opencodeOk: true", result.output)); assert.match(result.output, /opencodeOk:\s*true/, formatFailure("expected opencodeOk: true", result.output));
} finally { } finally {
craftos.abort(); craftos.abort();
await craftos.done.catch(() => undefined); await craftos.done.catch(() => undefined);
await sandbox.close(); await server.close();
await opencode.close(); await opencode.close();
} }
}); });

View File

@ -14,25 +14,25 @@ const HERE = dirname(fileURLToPath(import.meta.url));
const LUA_DIR = join(HERE, "lua"); const LUA_DIR = join(HERE, "lua");
const REPO_ROOT = join(HERE, "../../.."); const REPO_ROOT = join(HERE, "../../..");
export type Sandbox = { export type Server = {
registry: GatewayRegistry; registry: GatewayRegistry;
// The MCP Fastify app, sharing the gateway registry. Drive it in-process with // The MCP Fastify app, sharing the gateway registry. Drive it in-process with
// `mcpApp.inject({ method: "POST", url: "/", payload })` — no real listener needed. // `mcpApp.inject({ method: "POST", url: "/", payload })` — no real listener needed.
mcpApp: FastifyInstance; mcpApp: FastifyInstance;
// Gateway origin (no path). This is what `sandbox.url` expects: libsandbox appends // Gateway origin (no path). This is what `cloud.url` expects: libcloud appends
// `/gateway` itself. Pass this to the Lua scripts. // `/gateway` itself. Pass this to the Lua scripts.
baseUrl: string; baseUrl: string;
// Full WS endpoint (origin + `/gateway`), for direct ws clients. // Full WS endpoint (origin + `/gateway`), for direct ws clients.
socketUrl: string; socketUrl: string;
// Count captured server log lines whose `msg` contains `substring` (0 unless the // Count captured server log lines whose `msg` contains `substring` (0 unless the
// sandbox was started with `captureLogs: true`). Lets a test observe connection // server was started with `captureLogs: true`). Lets a test observe connection
// churn that registry.count() hides (newest-wins keeps the count pinned at 1). // churn that registry.count() hides (newest-wins keeps the count pinned at 1).
countLog: (substring: string) => number; countLog: (substring: string) => number;
close: () => Promise<void>; close: () => Promise<void>;
}; };
export type SandboxOptions = { export type ServerOptions = {
sandboxPassword?: string; serverPassword?: string;
opencodeUrl?: string; opencodeUrl?: string;
requestTimeoutMs?: number; requestTimeoutMs?: number;
// Capture server logs (at info level) so tests can assert on message frequency. // Capture server logs (at info level) so tests can assert on message frequency.
@ -41,7 +41,7 @@ export type SandboxOptions = {
// Real Fastify gateway on an ephemeral port. Mirrors index.ts's wiring but with a // 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). // silent logger and test-supplied config (never reads process.env).
export async function startSandbox(opts: SandboxOptions = {}): Promise<Sandbox> { export async function startServer(opts: ServerOptions = {}): Promise<Server> {
const logLines: string[] = []; const logLines: string[] = [];
const logger: Config["logger"] = opts.captureLogs const logger: Config["logger"] = opts.captureLogs
? { level: "info", stream: { write: (line: string) => void logLines.push(line) } } ? { level: "info", stream: { write: (line: string) => void logLines.push(line) } }
@ -51,7 +51,7 @@ export async function startSandbox(opts: SandboxOptions = {}): Promise<Sandbox>
const config: Config = { const config: Config = {
host: "127.0.0.1", host: "127.0.0.1",
port: 0, port: 0,
sandboxPassword: opts.sandboxPassword, serverPassword: opts.serverPassword,
requestTimeoutMs: timeoutMs, requestTimeoutMs: timeoutMs,
opencodeUrl: opts.opencodeUrl, opencodeUrl: opts.opencodeUrl,
opencodeUsername: "opencode", opencodeUsername: "opencode",
@ -130,13 +130,13 @@ export type CraftosHandle = {
done: Promise<CraftosResult>; done: Promise<CraftosResult>;
abort: () => void; abort: () => void;
// Stdout/stderr accumulated so far; lets a test await a marker line while the // 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). // process is still running (e.g. wait for "__CLOUD_READY__" before acting).
snapshot: () => string; snapshot: () => string;
}; };
// Spawn headless CraftOS-PC running a Lua script from test-integration/lua, with the // 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 // repo mounted read-only (/apis, /servers, /programs, /trapos) so the scripts can
// require the real libsandbox / servers / programs. // require the real libcloud / servers / programs.
export function startCraftos( export function startCraftos(
luaName: string, luaName: string,
opts: { computerId?: number; computerLabel?: string; shellArgs?: string[]; timeoutMs?: number } = {}, opts: { computerId?: number; computerLabel?: string; shellArgs?: string[]; timeoutMs?: number } = {},
@ -146,7 +146,7 @@ export function startCraftos(
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
const done = (async (): Promise<CraftosResult> => { const done = (async (): Promise<CraftosResult> => {
const dataDir = await mkdtemp(join(tmpdir(), "trap-sandbox-it-")); const dataDir = await mkdtemp(join(tmpdir(), "trapos-server-it-"));
const watchdog = setTimeout(() => controller.abort(), timeoutMs); const watchdog = setTimeout(() => controller.abort(), timeoutMs);
try { try {
if (!existsSync(join(REPO_ROOT, ".stage/apis"))) { if (!existsSync(join(REPO_ROOT, ".stage/apis"))) {

View File

@ -1,49 +1,49 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { formatFailure, startCraftos, startFakeOpencode, startSandbox } from "./harness.js"; import { formatFailure, startCraftos, startFakeOpencode, startServer } from "./harness.js";
test("probe_gateway reports opencodeOk=true when opencode is healthy", async () => { test("probe_server reports opencodeOk=true when opencode is healthy", async () => {
const opencode = await startFakeOpencode({ status: 200 }); const opencode = await startFakeOpencode({ status: 200 });
const sandbox = await startSandbox({ opencodeUrl: opencode.url }); const server = await startServer({ opencodeUrl: opencode.url });
const craftos = startCraftos("gateway-client.lua", { const craftos = startCraftos("gateway-client.lua", {
computerId: 7, computerId: 7,
computerLabel: "health-ok", computerLabel: "health-ok",
shellArgs: [sandbox.baseUrl, "-", "health"], shellArgs: [server.baseUrl, "-", "health"],
}); });
try { try {
const result = await craftos.done; const result = await craftos.done;
assert.match( assert.match(
result.output, result.output,
/__SANDBOX_HEALTH__ sandboxOk=true opencodeOk=true/, /__CLOUD_HEALTH__ serverOk=true opencodeOk=true/,
formatFailure("expected sandboxOk=true opencodeOk=true", result.output), formatFailure("expected serverOk=true opencodeOk=true", result.output),
); );
} finally { } finally {
craftos.abort(); craftos.abort();
await craftos.done.catch(() => undefined); await craftos.done.catch(() => undefined);
await sandbox.close(); await server.close();
await opencode.close(); await opencode.close();
} }
}); });
test("probe_gateway reports opencodeOk=false when opencode is unhealthy", async () => { test("probe_server reports opencodeOk=false when opencode is unhealthy", async () => {
const opencode = await startFakeOpencode({ status: 500 }); const opencode = await startFakeOpencode({ status: 500 });
const sandbox = await startSandbox({ opencodeUrl: opencode.url }); const server = await startServer({ opencodeUrl: opencode.url });
const craftos = startCraftos("gateway-client.lua", { const craftos = startCraftos("gateway-client.lua", {
computerId: 7, computerId: 7,
computerLabel: "health-bad", computerLabel: "health-bad",
shellArgs: [sandbox.baseUrl, "-", "health"], shellArgs: [server.baseUrl, "-", "health"],
}); });
try { try {
const result = await craftos.done; const result = await craftos.done;
assert.match( assert.match(
result.output, result.output,
/__SANDBOX_HEALTH__ sandboxOk=true opencodeOk=false/, /__CLOUD_HEALTH__ serverOk=true opencodeOk=false/,
formatFailure("expected sandboxOk=true opencodeOk=false", result.output), formatFailure("expected serverOk=true opencodeOk=false", result.output),
); );
} finally { } finally {
craftos.abort(); craftos.abort();
await craftos.done.catch(() => undefined); await craftos.done.catch(() => undefined);
await sandbox.close(); await server.close();
await opencode.close(); await opencode.close();
} }
}); });

View File

@ -0,0 +1,30 @@
-- Full-boot e2e: replicate the real boot path (startup/boot.lua) closely enough
-- to prove the whole stack works end to end. Sets the cloud.* settings, creates
-- the global event loop, runs the REAL servers/cloud.lua daemon, then under
-- parallel waits for the connection and runs the REAL `cloud 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('cloud.url', url);
if secret and secret ~= '' and secret ~= '-' then
settings.set('cloud.password', secret);
end
_G.bootEventLoop = require('/apis/eventloop')();
-- Start the real daemon (reads cloud.url/cloud.password, opens the WS).
shell.run('/servers/cloud.lua');
parallel.waitForAny(
function() _G.bootEventLoop.runLoop(true); end,
function()
os.pullEvent('trapos_cloud_connected');
shell.run('/programs/cloud.lua', 'health');
end
);
os.shutdown();

View File

@ -1,4 +1,4 @@
-- Integration-test driver: exercises the REAL trapos-sandbox client (libsandbox) -- Integration-test driver: exercises the REAL trapos-cloud client (libcloud)
-- against a real gateway. Reuses startSession/request/onMessage so the test covers -- against a real gateway. Reuses startSession/request/onMessage so the test covers
-- production client code + CraftOS JSON serialization quirks. -- production client code + CraftOS JSON serialization quirks.
-- --
@ -17,11 +17,11 @@ if secret == '' or secret == '-' then secret = nil; end
local scenario = args[3] or 'health'; local scenario = args[3] or 'health';
local reconnectDelay = tonumber(args[4] or ''); local reconnectDelay = tonumber(args[4] or '');
local createSandbox = require('/apis/libsandbox'); local createCloud = require('/apis/libcloud');
local createEventLoop = require('/apis/eventloop'); local createEventLoop = require('/apis/eventloop');
_G.bootEventLoop = createEventLoop(); _G.bootEventLoop = createEventLoop();
local sandbox = createSandbox(); local cloud = createCloud();
local sessionOpts = { local sessionOpts = {
eventloop = _G.bootEventLoop, eventloop = _G.bootEventLoop,
url = url, url = url,
@ -31,17 +31,17 @@ local sessionOpts = {
if reconnectDelay then if reconnectDelay then
sessionOpts.reconnectDelay = reconnectDelay; sessionOpts.reconnectDelay = reconnectDelay;
end end
local session = sandbox.startSession(sessionOpts); local session = cloud.startSession(sessionOpts);
-- Connect, probe the gateway, report opencodeOk. -- Connect, probe the gateway, report opencodeOk.
local function runHealth() local function runHealth()
os.pullEvent('trapos_sandbox_connected'); os.pullEvent('trapos_cloud_connected');
local ok, payload = sandbox.request('probe_gateway'); local ok, payload = cloud.request('probe_server');
if ok then if ok then
print('__SANDBOX_HEALTH__ sandboxOk=' .. tostring(payload.sandboxOk) print('__CLOUD_HEALTH__ serverOk=' .. tostring(payload.serverOk)
.. ' opencodeOk=' .. tostring(payload.opencodeOk)); .. ' opencodeOk=' .. tostring(payload.opencodeOk));
else else
print('__SANDBOX_HEALTH__ error=' .. tostring(type(payload) == 'table' and payload.code)); print('__CLOUD_HEALTH__ error=' .. tostring(type(payload) == 'table' and payload.code));
end end
end end
@ -50,24 +50,24 @@ local function runAuth()
local deadline = os.epoch('utc') + 8000; local deadline = os.epoch('utc') + 8000;
while os.epoch('utc') < deadline do while os.epoch('utc') < deadline do
if session.isReady() then if session.isReady() then
print('__SANDBOX_HELLO__ ok=true'); print('__CLOUD_HELLO__ ok=true');
return; return;
end end
local err = session.lastError(); local err = session.lastError();
if err then if err then
print('__SANDBOX_HELLO__ ok=false code=' .. tostring(err)); print('__CLOUD_HELLO__ ok=false code=' .. tostring(err));
return; return;
end end
os.sleep(0.1); os.sleep(0.1);
end end
print('__SANDBOX_HELLO__ ok=false code=timeout'); print('__CLOUD_HELLO__ ok=false code=timeout');
end end
-- Connect and stay registered so the TS side can drive the gateway->computer -- Connect and stay registered so the TS side can drive the gateway->computer
-- direction (reverse ping) or assert registry state (duplicate newest-wins). -- direction (reverse ping) or assert registry state (duplicate newest-wins).
local function runIdle() local function runIdle()
os.pullEvent('trapos_sandbox_connected'); os.pullEvent('trapos_cloud_connected');
print('__SANDBOX_READY__ ' .. sandbox.traposId(os)); print('__CLOUD_READY__ ' .. cloud.traposId(os));
os.sleep(10); os.sleep(10);
end end

View File

@ -1,5 +1,5 @@
-- Integration-test driver: the REAL reactive MCP computer server. Connects the real -- Integration-test driver: the REAL reactive MCP computer server. Connects the real
-- libsandbox daemon to the gateway and serves exec-lua / run-file / write-file via sandbox.serve -- libcloud daemon to the gateway and serves exec-lua / run-file / write-file via cloud.serve
-- (same wiring as servers/mcp-computer-server.lua), then stays registered so the TS -- (same wiring as servers/mcp-computer-server.lua), then stays registered so the TS
-- side can drive the MCP tools over the gateway. -- side can drive the MCP tools over the gateway.
-- --
@ -11,23 +11,23 @@ local url = args[1];
local secret = args[2]; local secret = args[2];
if secret == '' or secret == '-' then secret = nil; end if secret == '' or secret == '-' then secret = nil; end
local createSandbox = require('/apis/libsandbox'); local createCloud = require('/apis/libcloud');
local createMcpComputer = require('/apis/libmcpcomputer'); local createMcpComputer = require('/apis/libmcpcomputer');
local createEventLoop = require('/apis/eventloop'); local createEventLoop = require('/apis/eventloop');
_G.bootEventLoop = createEventLoop(); _G.bootEventLoop = createEventLoop();
local el = _G.bootEventLoop; local el = _G.bootEventLoop;
local sandbox = createSandbox(); local cloud = createCloud();
local mcp = createMcpComputer(); local mcp = createMcpComputer();
sandbox.startSession({ cloud.startSession({
eventloop = el, eventloop = el,
url = url, url = url,
secret = secret, secret = secret,
os = os, os = os,
}); });
sandbox.serve('exec-lua', function(payload) cloud.serve('exec-lua', function(payload)
local result = mcp.executeLua(payload and payload.code); local result = mcp.executeLua(payload and payload.code);
return true, { return true, {
ok = result.ok, ok = result.ok,
@ -37,7 +37,7 @@ sandbox.serve('exec-lua', function(payload)
}; };
end, { eventloop = el }); end, { eventloop = el });
sandbox.serve('run-file', function(payload) cloud.serve('run-file', function(payload)
local result = mcp.executeFile(payload and payload.path, nil, payload and payload.args); local result = mcp.executeFile(payload and payload.path, nil, payload and payload.args);
return true, { return true, {
ok = result.ok, ok = result.ok,
@ -47,7 +47,7 @@ sandbox.serve('run-file', function(payload)
}; };
end, { eventloop = el }); end, { eventloop = el });
sandbox.serve('write-file', function(payload) cloud.serve('write-file', function(payload)
local result = mcp.writeFile(payload and payload.path, payload and payload.content); local result = mcp.writeFile(payload and payload.path, payload and payload.content);
return true, { return true, {
ok = result.ok, ok = result.ok,
@ -58,8 +58,8 @@ sandbox.serve('write-file', function(payload)
end, { eventloop = el }); end, { eventloop = el });
local function runReady() local function runReady()
os.pullEvent('trapos_sandbox_connected'); os.pullEvent('trapos_cloud_connected');
print('__SANDBOX_READY__ ' .. sandbox.traposId(os)); print('__CLOUD_READY__ ' .. cloud.traposId(os));
os.sleep(12); os.sleep(12);
end end

View File

@ -1,12 +1,12 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { formatFailure, startCraftos, startSandbox, waitFor, type Sandbox } from "./harness.js"; import { formatFailure, startCraftos, startServer, waitFor, type Server } from "./harness.js";
// Drives the MCP tools end to end: a real reactive mcp-computer server (libsandbox + // Drives the MCP tools end to end: a real reactive mcp-computer server (libcloud +
// libmcpcomputer) on a real CraftOS-PC client, reached through the real gateway. The // libmcpcomputer) on a real CraftOS-PC client, reached through the real gateway. The
// MCP app is driven in-process via inject() (no extra listener). // MCP app is driven in-process via inject() (no extra listener).
async function callTool(sandbox: Sandbox, name: string, args: Record<string, unknown> = {}): Promise<string> { async function callTool(server: Server, name: string, args: Record<string, unknown> = {}): Promise<string> {
const res = await sandbox.mcpApp.inject({ const res = await server.mcpApp.inject({
method: "POST", method: "POST",
url: "/", url: "/",
payload: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }, payload: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } },
@ -19,24 +19,24 @@ async function callTool(sandbox: Sandbox, name: string, args: Record<string, unk
} }
test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway", async () => { test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway", async () => {
const sandbox = await startSandbox(); const server = await startServer();
const craftos = startCraftos("mcp-server-client.lua", { const craftos = startCraftos("mcp-server-client.lua", {
computerId: 7, computerId: 7,
computerLabel: "base", computerLabel: "base",
shellArgs: [sandbox.baseUrl, "-"], shellArgs: [server.baseUrl, "-"],
}); });
try { try {
await waitFor(() => sandbox.registry.has("local", "7:base"), 12_000, "computer registered"); await waitFor(() => server.registry.has("local", "7:base"), 12_000, "computer registered");
assert.equal(await callTool(sandbox, "probe-computers"), "ok from 7:base"); assert.equal(await callTool(server, "probe-computers"), "ok from 7:base");
const exec = await callTool(sandbox, "exec-lua", { traposId: "7:base", code: "print('hi'); return 1 + 1" }); const exec = await callTool(server, "exec-lua", { traposId: "7:base", code: "print('hi'); return 1 + 1" });
assert.match(exec, /^computer: 7:base\nok: true/); assert.match(exec, /^computer: 7:base\nok: true/);
assert.match(exec, /"value":2/); // return value serialized assert.match(exec, /"value":2/); // return value serialized
assert.match(exec, /\nhi$/); // captured print output assert.match(exec, /\nhi$/); // captured print output
const program = "local a, b = ...; print(a .. ':' .. b); return arg[1], arg[2], shell.getRunningProgram()"; const program = "local a, b = ...; print(a .. ':' .. b); return arg[1], arg[2], shell.getRunningProgram()";
const write = await callTool(sandbox, "write-file", { const write = await callTool(server, "write-file", {
traposId: "7:base", traposId: "7:base",
path: "/mcp-it.lua", path: "/mcp-it.lua",
content: program, content: program,
@ -44,7 +44,7 @@ test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway",
assert.match(write, /ok: true/); assert.match(write, /ok: true/);
assert.match(write, new RegExp(`bytes: ${program.length}`)); assert.match(write, new RegExp(`bytes: ${program.length}`));
const run = await callTool(sandbox, "run-file", { traposId: "7:base", path: "/mcp-it.lua", args: ["install", "pkg"] }); const run = await callTool(server, "run-file", { traposId: "7:base", path: "/mcp-it.lua", args: ["install", "pkg"] });
assert.match(run, /^computer: 7:base\nok: true/); assert.match(run, /^computer: 7:base\nok: true/);
assert.match(run, /"value":"install"/); assert.match(run, /"value":"install"/);
assert.match(run, /"value":"pkg"/); assert.match(run, /"value":"pkg"/);
@ -52,14 +52,14 @@ test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway",
assert.match(run, /\ninstall:pkg$/); assert.match(run, /\ninstall:pkg$/);
// Read it back through exec-lua to prove the write actually landed. // Read it back through exec-lua to prove the write actually landed.
const verify = await callTool(sandbox, "exec-lua", { const verify = await callTool(server, "exec-lua", {
traposId: "7:base", traposId: "7:base",
code: "local h = fs.open('/mcp-it.lua', 'r'); local c = h.readAll(); h.close(); return c", code: "local h = fs.open('/mcp-it.lua', 'r'); local c = h.readAll(); h.close(); return c",
}); });
assert.match(verify, /shell\.getRunningProgram/); assert.match(verify, /shell\.getRunningProgram/);
// Unknown traposId is a transport (not_connected) failure, not a crash. // Unknown traposId is a transport (not_connected) failure, not a crash.
const ghost = await callTool(sandbox, "exec-lua", { traposId: "9:ghost", code: "return 1" }); const ghost = await callTool(server, "exec-lua", { traposId: "9:ghost", code: "return 1" });
assert.match(ghost, /^computer: 9:ghost\nok: false\nerror: not_connected:/); assert.match(ghost, /^computer: 9:ghost\nok: false\nerror: not_connected:/);
} catch (error) { } catch (error) {
throw new Error(formatFailure(error instanceof Error ? error.message : String(error), craftos.snapshot()), { throw new Error(formatFailure(error instanceof Error ? error.message : String(error), craftos.snapshot()), {
@ -68,6 +68,6 @@ test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway",
} finally { } finally {
craftos.abort(); craftos.abort();
await craftos.done.catch(() => undefined); await craftos.done.catch(() => undefined);
await sandbox.close(); await server.close();
} }
}); });

View File

@ -1,19 +1,19 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { formatFailure, startCraftos, startSandbox, waitFor } from "./harness.js"; import { formatFailure, startCraftos, startServer, waitFor } from "./harness.js";
// Exercises the gateway -> computer direction: the gateway sends a `ping` // Exercises the gateway -> computer direction: the gateway sends a `ping`
// gateway_request and the real libsandbox client answers with a computer_response. // server_request and the real libcloud client answers with a computer_response.
test("gateway->computer reverse ping is answered by the client", async () => { test("gateway->computer reverse ping is answered by the client", async () => {
const sandbox = await startSandbox(); const server = await startServer();
const craftos = startCraftos("gateway-client.lua", { const craftos = startCraftos("gateway-client.lua", {
computerId: 7, computerId: 7,
computerLabel: "ping", computerLabel: "ping",
shellArgs: [sandbox.baseUrl, "-", "idle"], shellArgs: [server.baseUrl, "-", "idle"],
}); });
try { try {
await waitFor(() => sandbox.registry.has("local", "7:ping"), 12_000, "computer registered"); await waitFor(() => server.registry.has("local", "7:ping"), 12_000, "computer registered");
const payload = await sandbox.registry.request("local", "7:ping", "ping"); const payload = await server.registry.request("local", "7:ping", "ping");
assert.deepEqual(payload, { traposId: "7:ping" }); assert.deepEqual(payload, { traposId: "7:ping" });
} catch (error) { } catch (error) {
throw new Error( throw new Error(
@ -23,6 +23,6 @@ test("gateway->computer reverse ping is answered by the client", async () => {
} finally { } finally {
craftos.abort(); craftos.abort();
await craftos.done.catch(() => undefined); await craftos.done.catch(() => undefined);
await sandbox.close(); await server.close();
} }
}); });

View File

@ -36,9 +36,9 @@ function requestFrame(type: string, messageId = "r1"): string {
test("pre-hello non-hello request -> not_authenticated, no close, not registered", async () => { test("pre-hello non-hello request -> not_authenticated, no close, not registered", async () => {
const { socket, registry, connection } = setup(); const { socket, registry, connection } = setup();
await connection.onMessage(requestFrame("probe_gateway")); await connection.onMessage(requestFrame("probe_server"));
const frame = socket.lastFrame(); const frame = socket.lastFrame();
assert.equal(frame.event, "gateway_response"); assert.equal(frame.event, "server_response");
assert.equal(frame.ok, false); assert.equal(frame.ok, false);
assert.deepEqual(frame.error, { code: "not_authenticated", message: "hello required before any other request" }); assert.deepEqual(frame.error, { code: "not_authenticated", message: "hello required before any other request" });
assert.equal(socket.closed, false); assert.equal(socket.closed, false);
@ -77,7 +77,7 @@ test("hello ok -> registers and replies with accountId", async () => {
const { socket, registry, connection } = setup(); const { socket, registry, connection } = setup();
await connection.onMessage(helloFrame("7:", "anything")); await connection.onMessage(helloFrame("7:", "anything"));
const frame = socket.lastFrame(); const frame = socket.lastFrame();
assert.equal(frame.event, "gateway_response"); assert.equal(frame.event, "server_response");
assert.equal(frame.type, "hello"); assert.equal(frame.type, "hello");
assert.equal(frame.ok, true); assert.equal(frame.ok, true);
assert.deepEqual(frame.payload, { accountId: "local" }); assert.deepEqual(frame.payload, { accountId: "local" });
@ -85,21 +85,21 @@ test("hello ok -> registers and replies with accountId", async () => {
assert.equal(registry.count(), 1); assert.equal(registry.count(), 1);
}); });
test("post-hello probe_gateway (ok) -> sandboxOk + opencodeOk, no detail", async () => { test("post-hello probe_server (ok) -> serverOk + opencodeOk, no detail", async () => {
const { socket, connection } = setup({ probe: { ok: true } }); const { socket, connection } = setup({ probe: { ok: true } });
await connection.onMessage(helloFrame("7:")); await connection.onMessage(helloFrame("7:"));
await connection.onMessage(requestFrame("probe_gateway")); await connection.onMessage(requestFrame("probe_server"));
const frame = socket.lastFrame(); const frame = socket.lastFrame();
assert.equal(frame.ok, true); assert.equal(frame.ok, true);
assert.deepEqual(frame.payload, { sandboxOk: true, opencodeOk: true }); assert.deepEqual(frame.payload, { serverOk: true, opencodeOk: true });
}); });
test("post-hello probe_gateway (fail) -> opencodeOk:false + opencodeDetail", async () => { test("post-hello probe_server (fail) -> opencodeOk:false + opencodeDetail", async () => {
const { socket, connection } = setup({ probe: { ok: false, detail: "http://x -> 401" } }); const { socket, connection } = setup({ probe: { ok: false, detail: "http://x -> 401" } });
await connection.onMessage(helloFrame("7:")); await connection.onMessage(helloFrame("7:"));
await connection.onMessage(requestFrame("probe_gateway")); await connection.onMessage(requestFrame("probe_server"));
const frame = socket.lastFrame(); const frame = socket.lastFrame();
assert.deepEqual(frame.payload, { sandboxOk: true, opencodeOk: false, opencodeDetail: "http://x -> 401" }); assert.deepEqual(frame.payload, { serverOk: true, opencodeOk: false, opencodeDetail: "http://x -> 401" });
}); });
test("post-hello unknown type -> unknown_type error", async () => { test("post-hello unknown type -> unknown_type error", async () => {
@ -126,7 +126,7 @@ test("post-hello wrong-direction frame is dropped", async () => {
await connection.onMessage(helloFrame("7:")); await connection.onMessage(helloFrame("7:"));
const afterHello = socket.sent.length; const afterHello = socket.sent.length;
await connection.onMessage( await connection.onMessage(
JSON.stringify({ event: "gateway_request", traposReceiverId: "7:", messageId: "g1", type: "ping", payload: {} }), JSON.stringify({ event: "server_request", traposReceiverId: "7:", messageId: "g1", type: "ping", payload: {} }),
); );
assert.equal(socket.sent.length, afterHello); assert.equal(socket.sent.length, afterHello);
}); });
@ -137,7 +137,7 @@ test("reverse direction: gateway request resolved by computer_response", async (
const promise = registry.request("local", "7:", "ping", {}); const promise = registry.request("local", "7:", "ping", {});
const sent = socket.lastFrame(); const sent = socket.lastFrame();
assert.equal(sent.event, "gateway_request"); assert.equal(sent.event, "server_request");
assert.equal(sent.type, "ping"); assert.equal(sent.type, "ping");
await connection.onMessage( await connection.onMessage(

View File

@ -24,7 +24,7 @@ function response(traposId: string, messageId: string, type: string, ok: boolean
return { event: "computer_response", traposSenderId: traposId, messageId, type, ok, payload }; return { event: "computer_response", traposSenderId: traposId, messageId, type, ok, payload };
} }
// Run a tool call and resolve the single gateway_request it emits with `payload`. // Run a tool call and resolve the single server_request it emits with `payload`.
async function callAndAnswer( async function callAndAnswer(
deps: McpDeps, deps: McpDeps,
socket: FakeSocket, socket: FakeSocket,
@ -42,10 +42,10 @@ async function callAndAnswer(
return resultText(await pending); return resultText(await pending);
} }
test("initialize advertises the trap-sandbox server", async () => { test("initialize advertises the trapos-mcp server", async () => {
const reply = await handleMcpRequest(rpc("initialize"), makeDeps(new GatewayRegistry(silentLog))); const reply = await handleMcpRequest(rpc("initialize"), makeDeps(new GatewayRegistry(silentLog)));
const info = (reply as { result: { serverInfo: { name: string } } }).result.serverInfo; const info = (reply as { result: { serverInfo: { name: string } } }).result.serverInfo;
assert.equal(info.name, "trap-sandbox"); assert.equal(info.name, "trapos-mcp");
}); });
test("tools/list exposes the tools addressed by traposId", async () => { test("tools/list exposes the tools addressed by traposId", async () => {

View File

@ -14,14 +14,14 @@ test("parses all four event types", () => {
assert.equal(cRes.event, "computer_response"); assert.equal(cRes.event, "computer_response");
const gReq = parse( const gReq = parse(
JSON.stringify({ event: "gateway_request", traposReceiverId: "7:", messageId: "m2", type: "ping", payload: {} }), JSON.stringify({ event: "server_request", traposReceiverId: "7:", messageId: "m2", type: "ping", payload: {} }),
); );
assert.equal(gReq.event, "gateway_request"); assert.equal(gReq.event, "server_request");
const gRes = parse( const gRes = parse(
JSON.stringify({ event: "gateway_response", traposReceiverId: "7:", messageId: "m2", type: "health", ok: true, payload: {} }), JSON.stringify({ event: "server_response", traposReceiverId: "7:", messageId: "m2", type: "health", ok: true, payload: {} }),
); );
assert.equal(gRes.event, "gateway_response"); assert.equal(gRes.event, "server_response");
}); });
test("parses an ok:false response with error", () => { test("parses an ok:false response with error", () => {

View File

@ -43,14 +43,14 @@ test("same traposId under different accounts coexist", () => {
assert.equal(reg.count(), 2); assert.equal(reg.count(), 2);
}); });
test("request sends a gateway_request frame and resolves on matching ok response", async () => { test("request sends a server_request frame and resolves on matching ok response", async () => {
const reg = new GatewayRegistry(silentLog); const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket(); const a = new FakeSocket();
reg.register("local", "7:", a); reg.register("local", "7:", a);
const promise = reg.request("local", "7:", "ping", {}); const promise = reg.request("local", "7:", "ping", {});
const frame = a.lastFrame(); const frame = a.lastFrame();
assert.equal(frame.event, "gateway_request"); assert.equal(frame.event, "server_request");
assert.equal(frame.type, "ping"); assert.equal(frame.type, "ping");
assert.equal(frame.traposReceiverId, "7:"); assert.equal(frame.traposReceiverId, "7:");