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`,
# or after `just clean` / `just reinstall`.
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.
-- Runtime is Lua 5.1 inside the CC sandbox, not standard Lua, so we
-- Luacheck config for CC:Tweaked (ComputerCraft) Lua runtime.
-- 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.
stds.cc = {
read_globals = {
-- ComputerCraft APIs injected as globals by the sandbox.
-- ComputerCraft APIs injected as globals by the runtime.
"fs",
"peripheral",
"parallel",

View File

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

View File

@ -12,8 +12,8 @@ permission:
bash:
"*": ask
"just check": allow
trapos-cloud-gateway-mcp_probe-computers: allow
trapos-cloud-gateway-mcp_exec-lua: allow
trapos-mcp_probe-computers: allow
trapos-mcp_exec-lua: allow
---
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.
- Minecraft `1.21.1`.
- 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:

View File

@ -1,7 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"trapos-cloud-gateway-mcp": {
"trapos-mcp": {
"type": "remote",
"url": "http://127.0.0.1:4445",
"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
`/startup/servers.lua` lookup; assert reboot occurs after `trapos-postinstall`. In the
`--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).
### 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)
- `tests/startup-servers.lua`**rename** `tests/boot.lua` (policy cases)
- `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`
- `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`
present, drop in the new `boot.lua` and reboot; confirm both stale files are deleted and only
`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.

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
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"
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.
@ -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
live `/startup/servers.lua` shell, which never returns. So the autorun **copies `.settings`
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
boot/reboot rework is a separate effort.
- **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.installerLabel (default 'TrapOS Installer')
-- 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:

View File

@ -7,8 +7,8 @@
1. MCP tools and a dedicated ComputerCraft link transport.
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 sandbox MCP endpoint (`http://127.0.0.1:4445`). The remaining live dependency is the old
The MCP tools have already moved to `tools/trapos-server` and `.mcp.json` already points at
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`).
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.
- Remove `ai` WebSocket bridge-mode support.
- 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
@ -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
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.
- 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.
## Tooling Changes
@ -76,7 +76,7 @@ Update `packages/trapos-ai/tests/ai.lua`:
- Remove `npm-check-mcp-bridge`.
- Remove `npm-test-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-build`
- `npm-check`
@ -85,14 +85,14 @@ Update `packages/trapos-ai/tests/ai.lua`:
### `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.
- Update the comment above `clean`.
### `just/test.just`
- 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`
@ -111,7 +111,7 @@ Update `packages/index.json`:
- 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
@ -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`.
- `docs/public-ports.md`: remove the old `4243` / `CC_LINK_PORT` ComputerCraft bridge entry,
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
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
verification example with `tools/trap-sandbox`.
verification example with `tools/trapos-server`.
### Historical / Optional Cleanup
@ -149,7 +149,7 @@ No `trapos-sandbox` package bump is required unless its behavior changes during
- Modify: `packages/index.json`
- Modify: `DEVELOPMENT.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`
- Optional: `docs/adrs/adr-0017-mcp-remote-lua-execution.md`
- Optional: `.opencode/agent/atm10-expert.md`
@ -170,4 +170,4 @@ Expected result:
- No source file references `require('/apis/libhttpws')`, `opencc.bridge_url`, or
`opencc.request_timeout_seconds` except archived history or generated/local CraftOS state.
- `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
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.

View File

@ -12,7 +12,7 @@ This installs local Git hooks, checks required tools, generates `.env`, and veri
## 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.
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/craftos.just'
import 'just/opencode.just'
import 'just/sandbox.just'
import 'just/server.just'
import 'just/test.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
```
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.
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-ui`: the terminal UI toolkit (`libtui`, `tuidemo`).
- `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
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-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-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.
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:
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)).
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
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:

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`.
- 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`.
- `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.
## Future Work

View File

@ -10,7 +10,7 @@ Accepted
## 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).
@ -27,7 +27,7 @@ Keep the Node package scripts simple and one-purpose:
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 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.
@ -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`.
- `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.
- 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

View File

@ -2,8 +2,8 @@
## Status
Accepted. Transport superseded by [ADR-0019](adr-0019-mcp-over-sandbox-gateway.md) — the
MCP tools now ride the sandbox gateway and address computers by `traposId`, and
Accepted. Transport superseded by [ADR-0019](adr-0019-mcp-over-cloud-gateway.md) — the
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
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
@ -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
the `mcp-computer` / `mcp-computer-server` Lua programs.
Meanwhile `tools/trap-sandbox` introduced a second, richer host↔computer connection: a
single persistent gateway WebSocket per computer (`servers/sandbox` + `apis/libsandbox`),
Meanwhile `tools/trapos-server` introduced a second, richer host↔computer connection: a
single persistent gateway WebSocket per computer (`servers/cloud` + `apis/libcloud`),
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
computer. The sandbox gateway already had everything MCP needs.
computer. The cloud gateway already had everything MCP needs.
## 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
bridge. Each tool call is a `GatewayRegistry.request(accountId, traposId, type, payload)`
reusing the existing gateway socket. Computers are addressed by **`traposId`**
(`"<id>:<label>"`, the registry's key) rather than a numeric `computerId`;
`probe-computers` lists the connected traposIds.
- **Computer side:** `apis/libsandbox` gains a reactive serving path symmetric to its
request path. In-OS servers call `sandbox.serve(type, handler, { eventloop })`, which
advertises the type to the daemon (`trapos_sandbox_serve`) and reacts to inbound
`trapos_sandbox_request` events, replying via `trapos_sandbox_respond`
- **Computer side:** `apis/libcloud` gains a reactive serving path symmetric to its
request path. In-OS servers call `cloud.serve(type, handler, { eventloop })`, which
advertises the type to the daemon (`trapos_cloud_serve`) and reacts to inbound
`trapos_cloud_request` events, replying via `trapos_cloud_respond`
(`computer_response`). Unadvertised non-`ping` types still get an immediate
`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
sandbox daemon.
cloud daemon.
- **`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
`sandbox.url` being set. `apis/libmcpcomputer` is slimmed to the protocol-agnostic
execution primitives (`executeLua`, `writeFile`); both move into the `trapos-sandbox`
package (autostart `servers/mcp-computer-server` after `servers/sandbox`). The standalone
`exec-lua` / `write-file` over the gateway via `cloud.serve`, and is gated on
`cloud.url` being set. `apis/libmcpcomputer` is slimmed to the protocol-agnostic
execution primitives (`executeLua`, `writeFile`); both move into the `trapos-cloud`
package (autostart `servers/mcp-computer-server` after `servers/cloud`). The standalone
`programs/mcp-computer` client is deleted and the MCP entries are removed from
`trapos-sandbox-legacy`.
- 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
- 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` +
`SANDBOX_PASSWORD`) plus the local-only MCP bind, instead of "trust the bridge you run
- The exec trust boundary is now the authenticated gateway connection (`cloud.url` +
`TRAPOS_SERVER_PASSWORD`) plus the local-only MCP bind, instead of "trust the bridge you run
`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.
- Tests cover host-side MCP routing (unit) and the real CraftOS gateway round-trip
(integration), per [ADR-0016](adr-0016-js-tool-verification.md).

View File

@ -62,13 +62,13 @@ ai "say hello from TrapOS"
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
sandbox health). From this repository on your real machine:
The MCP tools now ride on the `trapos-server` gateway (the same WS connection used for
cloud health). From this repository on your real machine:
```sh
just sandbox-serve
just serve
```
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
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
On the ComputerCraft computer, point it at the gateway and reboot:
```sh
set sandbox.url ws://<public-host>:4444
set sandbox.password secret # only if SANDBOX_PASSWORD is set on the host
set cloud.url ws://<public-host>:4444
set cloud.password secret # only if TRAPOS_SERVER_PASSWORD is set on the host
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:
```sh
sandbox health
cloud health
```
## 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
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.
`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` 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`).
- MCP sees no computers: confirm `sandbox.url` is set and the computer was rebooted so `servers/sandbox` is running.
- `exec-lua` / `write-file` return `unknown_type`: that computer is not running `servers/mcp-computer-server` (set `sandbox.url` and reboot).
- `cloud health` fails: enable ComputerCraft HTTP/WebSocket support and check `cloud.url` (port `4444`).
- 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 `cloud.url` and reboot).
- `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).

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`. |
| `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. |
| `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

View File

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

View File

@ -1,33 +1,33 @@
# Install Node dependencies for repository tools.
npm-install: npm-install-trap-sandbox
npm-install: npm-install-trapos-server
# Install Node dependencies for trap-sandbox.
npm-install-trap-sandbox:
npm install --prefix tools/trap-sandbox/
# Install Node dependencies for trapos-server.
npm-install-trapos-server:
npm install --prefix tools/trapos-server/
# Build Node-based repository tools.
npm-build: npm-build-trap-sandbox
npm-build: npm-build-trapos-server
npm-build-trap-sandbox:
npm run build --prefix tools/trap-sandbox/
npm-build-trapos-server:
npm run build --prefix tools/trapos-server/
# Check Node-based repository tools.
npm-check: npm-check-trap-sandbox
npm-check: npm-check-trapos-server
npm-check-trap-sandbox:
npm run check --prefix tools/trap-sandbox/
npm-check-trapos-server:
npm run check --prefix tools/trapos-server/
# Run Node-based tool tests.
npm-test: npm-test-trap-sandbox
npm-test: npm-test-trapos-server
npm-test-trap-sandbox:
npm test --prefix tools/trap-sandbox/
npm-test-trapos-server:
npm test --prefix tools/trapos-server/
# 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 run test:integration --prefix tools/trap-sandbox/
npm-test-integration-trapos-server:
npm run test:integration --prefix tools/trapos-server/
# Build generated artifacts.
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
@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
# Run integration and harness tests that are too broad for unit test targets.

View File

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

View File

@ -1,13 +1,13 @@
{
"packages": {
"trapos-core": "0.9.2",
"trapos-core": "0.9.3",
"trapos-test": "0.2.3",
"trapos-boot": "0.4.2",
"trapos-net": "0.3.2",
"trapos-ui": "0.2.4",
"trapos-ai": "0.8.0",
"trapos-sandbox": "0.3.5",
"trapos-cloud": "0.4.0",
"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
-- .plans/trap-sandbox/trap-sandbox-gateway-spec.md). A boot daemon owns one
-- Implements the Lua side of the trapos-server WS gateway protocol (see
-- .plans/trapos-server/trapos-server-gateway-spec.md). A boot daemon owns one
-- 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
-- share the single os event queue.
-- 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).
local DISPLACED_CLOSE_CODE = 4000;
@ -43,7 +43,7 @@ local function buildConnectUrl(base)
return trimmed .. '/gateway';
end
local function createSandbox()
local function createCloud()
local api = {};
api.uuid = makeUuid;
@ -88,7 +88,7 @@ local function createSandbox()
function api.buildGatewayRequest(traposReceiverId, messageId, msgType, payload)
local frame = {
event = 'gateway_request',
event = 'server_request',
traposReceiverId = traposReceiverId,
messageId = messageId,
type = msgType,
@ -101,7 +101,7 @@ local function createSandbox()
function api.buildGatewayResponse(traposReceiverId, messageId, msgType, ok, payload, err)
local frame = {
event = 'gateway_response',
event = 'server_response',
traposReceiverId = traposReceiverId,
messageId = messageId,
type = msgType,
@ -138,7 +138,7 @@ local function createSandbox()
return;
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.
-- 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
@ -166,14 +166,14 @@ local function createSandbox()
return;
end
if frame.event == 'gateway_response' then
if frame.event == 'server_response' then
if frame.type == 'hello' then
if ctx.onHello then
ctx.onHello(frame.ok == true, coercePayload(frame.payload), frame.error);
end
elseif ctx.queueEvent then
ctx.queueEvent(
'trapos_sandbox_reply', frame.messageId, frame.ok == true,
'trapos_cloud_reply', frame.messageId, frame.ok == true,
coercePayload(frame.payload), frame.error
);
end
@ -214,8 +214,8 @@ local function createSandbox()
-- 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.
local reconnecting = false;
-- Gateway_request types advertised by in-OS servers (via trapos_sandbox_serve).
-- Inbound frames of an advertised type are re-emitted as trapos_sandbox_request
-- Gateway_request types advertised by in-OS servers (via trapos_cloud_serve).
-- Inbound frames of an advertised type are re-emitted as trapos_cloud_request
-- events; unadvertised (non-ping) types get an immediate unknown_type reply.
local servedTypes = {};
@ -268,7 +268,7 @@ local function createSandbox()
else
log('info', 'connected', { traposId = traposId });
end
queueEvent('trapos_sandbox_connected', traposId);
queueEvent('trapos_cloud_connected', traposId);
else
state = 'unauthorized';
lastError = (type(err) == 'table' and err.code) or 'unauthorized';
@ -304,7 +304,7 @@ local function createSandbox()
onHello = onHello,
isServed = function(msgType) return servedTypes[msgType] == true; end,
queueRequest = function(messageId, msgType, payload)
queueEvent('trapos_sandbox_request', messageId, msgType, payload);
queueEvent('trapos_cloud_request', messageId, msgType, payload);
end,
});
end);
@ -344,23 +344,23 @@ local function createSandbox()
end);
-- Bridge program requests onto the wire. Only emit an immediate reply on
-- failure; on success the real reply arrives later as a gateway_response.
el.register('trapos_sandbox_send', function(messageId, msgType, payload)
-- failure; on success the real reply arrives later as a server_response.
el.register('trapos_cloud_send', function(messageId, msgType, payload)
if state ~= 'ready' then
queueEvent('trapos_sandbox_reply', messageId, false, nil,
{ code = 'not_connected', message = 'sandbox daemon not connected' });
queueEvent('trapos_cloud_reply', messageId, false, nil,
{ code = 'not_connected', message = 'cloud daemon not connected' });
return;
end
local sent = sendFrame(api.buildComputerRequest(traposId, messageId, msgType, payload));
if not sent then
queueEvent('trapos_sandbox_reply', messageId, false, nil,
{ code = 'not_connected', message = 'sandbox send failed' });
queueEvent('trapos_cloud_reply', messageId, false, nil,
{ code = 'not_connected', message = 'cloud send failed' });
end
end);
-- In-OS servers advertise the gateway_request types they handle. Recorded so
-- onMessage routes those (and only those) as trapos_sandbox_request events.
el.register('trapos_sandbox_serve', function(msgType)
-- In-OS servers advertise the server_request types they handle. Recorded so
-- onMessage routes those (and only those) as trapos_cloud_request events.
el.register('trapos_cloud_serve', function(msgType)
if type(msgType) == 'string' and msgType ~= '' then
servedTypes[msgType] = true;
end
@ -368,7 +368,7 @@ local function createSandbox()
-- 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.
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
return;
end
@ -394,7 +394,7 @@ local function createSandbox()
local timeout = opts.timeout or 10;
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);
while true do
@ -402,7 +402,7 @@ local function createSandbox()
local name = event[1];
if name == 'timer' and event[2] == timerId then
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
cancelTimer(timerId);
if event[3] then
@ -411,23 +411,23 @@ local function createSandbox()
return false, event[5] or { code = 'internal', message = 'request failed' };
end
-- 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
-- Ignore any other event and keep waiting.
end
end
-- Server-side reply: queue a computer_response for an inbound gateway_request that
-- the daemon delivered as a trapos_sandbox_request event. The daemon owns the wire.
-- Server-side reply: queue a computer_response for an inbound server_request that
-- the daemon delivered as a trapos_cloud_request event. The daemon owns the wire.
function api.respond(messageId, msgType, ok, payload, err, opts)
opts = opts or {};
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
-- 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
-- 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
-- error is reported as { code = 'internal', message = <error> }.
function api.serve(msgType, handler, opts)
@ -437,9 +437,9 @@ local function createSandbox()
assert(type(handler) == 'function', 'serve requires a handler');
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
return;
end
@ -463,4 +463,4 @@ local function createSandbox()
return api;
end
return createSandbox;
return createCloud;

View File

@ -1,9 +1,9 @@
-- Computer-side MCP execution primitives.
--
-- Protocol-agnostic building blocks for the MCP `exec-lua` / `write-file` features.
-- The transport lives elsewhere: servers/mcp-computer-server.lua reacts to sandbox
-- gateway_request events and replies through the trap-sandbox gateway (see
-- apis/libsandbox.lua). This module only knows how to run code and write files.
-- The transport lives elsewhere: servers/mcp-computer-server.lua reacts to cloud
-- server_request events and replies through the trapos-server gateway (see
-- apis/libcloud.lua). This module only knows how to run code and write files.
local function defaultOs()
return os;
@ -90,7 +90,7 @@ end
-- 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
-- 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 make;
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 URL_SETTING = 'sandbox.url';
local URL_SETTING = 'cloud.url';
local rawArgs = table.pack(...);
local command = rawArgs[1];
local function printUsage()
print('sandbox usage:');
print('cloud usage:');
print();
print(' sandbox health');
print(' sandbox --version');
print(' sandbox --help');
print(' cloud health');
print(' cloud --version');
print(' cloud --help');
print();
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(' sandbox.password (shared secret for the hello handshake)');
print(' cloud.password (shared secret for the hello handshake)');
end
if command == '--version' or command == '-version' or command == 'version' or command == '-v' then
@ -32,19 +32,19 @@ end
if command == 'health' then
local url = settings.get(URL_SETTING);
if type(url) ~= 'string' or url == '' then
print('set sandbox.url first');
print('set cloud.url first');
return;
end
local ok, result = createSandbox().request('probe_gateway');
local ok, result = createCloud().request('probe_server');
if not ok then
local code = (type(result) == 'table' and result.code) or 'internal';
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;
end
print('sandboxOk: ' .. tostring(result.sandboxOk));
print('serverOk: ' .. tostring(result.serverOk));
print('opencodeOk: ' .. tostring(result.opencodeOk));
if not result.opencodeOk and result.opencodeDetail then
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 URL_SETTING = 'sandbox.url';
local PASSWORD_SETTING = 'sandbox.password';
local LOG_PATH = '/logs/sandbox.log';
local URL_SETTING = 'cloud.url';
local PASSWORD_SETTING = 'cloud.password';
local LOG_PATH = '/logs/cloud.log';
-- 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
-- otherwise spawn a second daemon that fights the first over the shared traposId.
if _G.__trapos_sandbox_daemon then
print('sandbox: daemon already running, skipping.');
if _G.__trapos_cloud_daemon then
print('cloud: daemon already running, skipping.');
return;
end
local url = settings.get(URL_SETTING);
if type(url) ~= 'string' or url == '' then
print('sandbox: ' .. URL_SETTING .. ' not set, daemon inactive.');
print('cloud: ' .. URL_SETTING .. ' not set, daemon inactive.');
return;
end
if not http or not http.websocket then
print('sandbox: HTTP/WebSocket unavailable, daemon inactive.');
print('cloud: HTTP/WebSocket unavailable, daemon inactive.');
return;
end
@ -41,9 +41,9 @@ end
local secret = settings.get(PASSWORD_SETTING);
_G.__trapos_sandbox_daemon = true;
_G.__trapos_cloud_daemon = true;
createSandbox().startSession({
createCloud().startSession({
eventloop = _G.bootEventLoop,
url = url,
secret = secret,
@ -51,4 +51,4 @@ createSandbox().startSession({
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
-- sandbox daemon's event bus (apis/libsandbox.serve), so requests arrive over the one
-- gateway connection owned by servers/sandbox.lua and replies are sent back through it.
-- It only handles requests while a sandbox is configured, so exec stays gated behind
-- cloud daemon's event bus (apis/libcloud.serve), so requests arrive over the one
-- gateway connection owned by servers/cloud.lua and replies are sent back through it.
-- 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.
local createSandbox = require('/apis/libsandbox');
local createCloud = require('/apis/libcloud');
local createMcpComputer = require('/apis/libmcpcomputer');
local createVersion = require('/apis/libversion');
local URL_SETTING = 'sandbox.url';
local URL_SETTING = 'cloud.url';
local url = settings.get(URL_SETTING);
if type(url) ~= 'string' or url == '' then
@ -24,13 +24,13 @@ if not el then
return;
end
local sandbox = createSandbox();
local cloud = createCloud();
local mcp = createMcpComputer({ shell = shell });
-- 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
-- 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);
return true, {
ok = result.ok,
@ -40,7 +40,7 @@ sandbox.serve('exec-lua', function(payload)
};
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);
return true, {
ok = result.ok,
@ -50,7 +50,7 @@ sandbox.serve('run-file', function(payload)
};
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);
return true, {
ok = result.ok,
@ -61,4 +61,4 @@ sandbox.serve('write-file', function(payload)
end, { eventloop = el });
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 createSandbox = require('/apis/libsandbox');
local createCloud = require('/apis/libcloud');
local fakes = require('/tests/harness/fake-eventloop');
local testlib = createLibTest({ ... });
@ -10,21 +10,21 @@ local encode = textutils.serializeJSON;
-- traposId ----------------------------------------------------------------
testlib.test('traposId joins id and label', function()
local sandbox = createSandbox();
testlib.assertEquals(sandbox.traposId(fakeOs(7, 'base')), '7:base');
local cloud = createCloud();
testlib.assertEquals(cloud.traposId(fakeOs(7, 'base')), '7:base');
end);
testlib.test('traposId renders empty/nil label as trailing colon', function()
local sandbox = createSandbox();
testlib.assertEquals(sandbox.traposId(fakeOs(7, nil)), '7:');
testlib.assertEquals(sandbox.traposId(fakeOs(8, '')), '8:');
local cloud = createCloud();
testlib.assertEquals(cloud.traposId(fakeOs(7, nil)), '7:');
testlib.assertEquals(cloud.traposId(fakeOs(8, '')), '8:');
end);
-- uuid --------------------------------------------------------------------
testlib.test('uuid emits a v4-shaped string', function()
local sandbox = createSandbox();
local id = sandbox.uuid();
local cloud = createCloud();
local id = cloud.uuid();
testlib.assertEquals(string.len(id), 36);
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);
@ -33,40 +33,40 @@ end);
-- envelope builders -------------------------------------------------------
testlib.test('buildComputerRequest carries sender id and omits empty payload', function()
local sandbox = createSandbox();
local frame = sandbox.buildComputerRequest('7:base', 'mid-1', 'probe_gateway', nil);
local cloud = createCloud();
local frame = cloud.buildComputerRequest('7:base', 'mid-1', 'probe_server', nil);
testlib.assertEquals(frame.event, 'computer_request');
testlib.assertEquals(frame.traposSenderId, '7:base');
testlib.assertEquals(frame.messageId, 'mid-1');
testlib.assertEquals(frame.type, 'probe_gateway');
testlib.assertEquals(frame.type, 'probe_server');
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);
end);
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.type, 'hello');
testlib.assertEquals(withSecret.traposSenderId, '7:base');
testlib.assertEquals(withSecret.payload.traposId, '7:base');
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);
local emptySecret = sandbox.buildHello('7:base', '', 'mid');
local emptySecret = cloud.buildHello('7:base', '', 'mid');
testlib.assertEquals(emptySecret.payload.secret, nil);
end);
-- onMessage dispatch ------------------------------------------------------
testlib.test('onMessage replies to a reverse ping', function()
local sandbox = createSandbox();
local cloud = createCloud();
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',
send = function(frame) sent[#sent + 1] = frame; end,
});
@ -81,9 +81,9 @@ testlib.test('onMessage replies to a reverse ping', function()
end);
testlib.test('onMessage rejects unknown reverse type with unknown_type', function()
local sandbox = createSandbox();
local cloud = createCloud();
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',
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');
end);
testlib.test('onMessage routes a served reverse type to trapos_sandbox_request', function()
local sandbox = createSandbox();
testlib.test('onMessage routes a served reverse type to trapos_cloud_request', function()
local cloud = createCloud();
local requested = {};
sandbox.onMessage(encode({
event = 'gateway_request', type = 'exec-lua', messageId = 'g-3', payload = { code = 'x' },
cloud.onMessage(encode({
event = 'server_request', type = 'exec-lua', messageId = 'g-3', payload = { code = 'x' },
}), {
traposId = '7:base',
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);
testlib.test('onMessage replies unknown_type for a non-served reverse type', function()
local sandbox = createSandbox();
local cloud = createCloud();
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',
send = function(frame) sent[#sent + 1] = frame; end,
isServed = function() return false; end,
@ -129,9 +129,9 @@ testlib.test('onMessage replies unknown_type for a non-served reverse type', fun
end);
testlib.test('onMessage drives hello via onHello', function()
local sandbox = createSandbox();
local cloud = createCloud();
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,
});
@ -140,27 +140,27 @@ testlib.test('onMessage drives hello via onHello', function()
testlib.assertEquals(calls[1].payload.accountId, 'local');
end);
testlib.test('onMessage queues a reply for a non-hello gateway_response', function()
local sandbox = createSandbox();
testlib.test('onMessage queues a reply for a non-hello server_response', function()
local cloud = createCloud();
local queued = {};
sandbox.onMessage(encode({
event = 'gateway_response', type = 'probe_gateway', ok = true,
messageId = 'mid-9', payload = { sandboxOk = true },
cloud.onMessage(encode({
event = 'server_response', type = 'probe_server', ok = true,
messageId = 'mid-9', payload = { serverOk = true },
}), {
queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
});
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][3], true);
testlib.assertEquals(queued[1][4].sandboxOk, true);
testlib.assertEquals(queued[1][4].serverOk, true);
end);
testlib.test('onMessage coerces a missing payload to an empty table', function()
local sandbox = createSandbox();
local cloud = createCloud();
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,
});
@ -170,7 +170,7 @@ testlib.test('onMessage coerces a missing payload to an empty table', function()
end);
testlib.test('onMessage stays silent on malformed frames', function()
local sandbox = createSandbox();
local cloud = createCloud();
local sent = 0;
local ctx = {
traposId = '7:base',
@ -178,15 +178,15 @@ testlib.test('onMessage stays silent on malformed frames', function()
queueEvent = function() sent = sent + 1; end,
onHello = function() sent = sent + 1; end,
};
sandbox.onMessage('not json', ctx);
sandbox.onMessage(encode({ event = 'computer_request', type = 'ping' }), ctx);
cloud.onMessage('not json', ctx);
cloud.onMessage(encode({ event = 'computer_request', type = 'ping' }), ctx);
testlib.assertEquals(sent, 0);
end);
-- startSession wiring -----------------------------------------------------
local function startSession(extra)
local sandbox = createSandbox();
local cloud = createCloud();
local el = fakes.fakeEventloop();
local http = fakes.fakeHttp();
local queued = {};
@ -200,8 +200,8 @@ local function startSession(extra)
if extra then
for k, v in pairs(extra) do opts[k] = v; end
end
local session = sandbox.startSession(opts);
return { sandbox = sandbox, el = el, http = http, queued = queued, session = session, url = http.connects[1] };
local session = cloud.startSession(opts);
return { cloud = cloud, el = el, http = http, queued = queued, session = session, url = http.connects[1] };
end
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.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);
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');
end);
@ -235,7 +235,7 @@ testlib.test('startSession treats unauthorized hello as terminal', function()
local timersAfterOpen = #ctx.el.timers; -- hello watchdog
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);
@ -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.
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(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".
ctx.el.fireTimer(#ctx.el.timers); -- reconnect timer -> connect()
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(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 ws = fakes.fakeWs();
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);
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 ws = fakes.fakeWs();
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);
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);
-- 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);
end);
@ -369,9 +369,9 @@ end);
testlib.test('send handler replies not_connected when not ready', function()
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];
testlib.assertEquals(reply[1], 'trapos_sandbox_reply');
testlib.assertEquals(reply[1], 'trapos_cloud_reply');
testlib.assertEquals(reply[2], 'mid-1');
testlib.assertEquals(reply[3], false);
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 ws = fakes.fakeWs();
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;
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
local frame = textutils.unserializeJSON(ws.sent[#ws.sent]);
testlib.assertEquals(frame.event, 'computer_request');
testlib.assertEquals(frame.type, 'probe_gateway');
testlib.assertEquals(frame.type, 'probe_server');
testlib.assertEquals(frame.messageId, 'mid-2');
end);
-- request() ---------------------------------------------------------------
testlib.test('request resolves on a matching reply', function()
local sandbox = createSandbox();
local cloud = createCloud();
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,
startTimer = events.startTimer, cancelTimer = events.cancelTimer,
});
testlib.assertEquals(ok, true);
testlib.assertEquals(payload.sandboxOk, true);
testlib.assertEquals(payload.serverOk, true);
testlib.assertEquals(events.cancelled[1], 'timer-1');
end);
testlib.test('request returns the error on a failed reply', function()
local sandbox = createSandbox();
local cloud = createCloud();
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,
startTimer = events.startTimer, cancelTimer = events.cancelTimer,
});
@ -425,29 +425,29 @@ testlib.test('request returns the error on a failed reply', function()
end);
testlib.test('request re-queues a mismatched reply and keeps waiting', function()
local sandbox = createSandbox();
local cloud = createCloud();
local events = fakes.fakeEvents({
{ 'trapos_sandbox_reply', 'other', true, { y = 2 }, n = 4 },
{ 'trapos_sandbox_reply', 'mid', true, { x = 1 }, n = 4 },
{ 'trapos_cloud_reply', 'other', true, { y = 2 }, 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,
startTimer = events.startTimer, cancelTimer = events.cancelTimer,
});
testlib.assertEquals(ok, true);
testlib.assertEquals(payload.x, 1);
-- queued[1] is the trapos_sandbox_send; queued[2] is the re-queued reply.
testlib.assertEquals(events.queued[2][1], 'trapos_sandbox_reply');
-- queued[1] is the trapos_cloud_send; queued[2] is the re-queued reply.
testlib.assertEquals(events.queued[2][1], 'trapos_cloud_reply');
testlib.assertEquals(events.queued[2][2], 'other');
end);
testlib.test('request times out when the timer fires first', function()
local sandbox = createSandbox();
local cloud = createCloud();
local events = fakes.fakeEvents({
{ '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,
startTimer = events.startTimer, cancelTimer = events.cancelTimer,
});
@ -462,24 +462,24 @@ local function readySession()
local ctx = startSession();
local ws = fakes.fakeWs();
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;
return ctx;
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();
ctx.el.fire('trapos_sandbox_serve', 'exec-lua');
ctx.el.fire('trapos_cloud_serve', 'exec-lua');
local sentBefore = #ctx.ws.sent;
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.
testlib.assertEquals(#ctx.ws.sent, sentBefore);
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[3], 'exec-lua');
testlib.assertEquals(req[4].code, 'x');
@ -488,7 +488,7 @@ end);
testlib.test('startSession replies unknown_type for an unadvertised reverse type', function()
local ctx = readySession();
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]);
@ -501,7 +501,7 @@ testlib.test('respond handler sends a computer_response when ready', function()
local ctx = readySession();
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);
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()
local ctx = startSession(); -- never reaches 'ready'
-- 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);
end);
-- serve() / respond() helpers ---------------------------------------------
testlib.test('respond queues a trapos_sandbox_respond event', function()
local sandbox = createSandbox();
testlib.test('respond queues a trapos_cloud_respond event', function()
local cloud = createCloud();
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,
});
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][3], 'exec-lua');
testlib.assertEquals(queued[1][4], true);
@ -537,57 +537,57 @@ testlib.test('respond queues a trapos_sandbox_respond event', function()
end);
testlib.test('serve advertises the type and auto-responds on success', function()
local sandbox = createSandbox();
local cloud = createCloud();
local el = fakes.fakeEventloop();
local queued = {};
sandbox.serve('exec-lua', function(payload)
cloud.serve('exec-lua', function(payload)
return true, { echoed = payload.code };
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');
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];
testlib.assertEquals(respond[1], 'trapos_sandbox_respond');
testlib.assertEquals(respond[1], 'trapos_cloud_respond');
testlib.assertEquals(respond[2], 'mid-7');
testlib.assertEquals(respond[4], true);
testlib.assertEquals(respond[5].echoed, 'print(1)');
end);
testlib.test('serve ignores requests for other types', function()
local sandbox = createSandbox();
local cloud = createCloud();
local el = fakes.fakeEventloop();
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,
});
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
end);
testlib.test('serve responds with the error table on a failure result', function()
local sandbox = createSandbox();
local cloud = createCloud();
local el = fakes.fakeEventloop();
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,
});
el.fire('trapos_sandbox_request', 'mid', 'exec-lua', {});
el.fire('trapos_cloud_request', 'mid', 'exec-lua', {});
local respond = queued[#queued];
testlib.assertEquals(respond[4], false);
testlib.assertEquals(respond[6].code, 'internal');
end);
testlib.test('serve reports a thrown handler as an internal error', function()
local sandbox = createSandbox();
local cloud = createCloud();
local el = fakes.fakeEventloop();
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,
});
el.fire('trapos_sandbox_request', 'mid', 'exec-lua', {});
el.fire('trapos_cloud_request', 'mid', 'exec-lua', {});
local respond = queued[#queued];
testlib.assertEquals(respond[4], false);
testlib.assertEquals(respond[6].code, 'internal');

View File

@ -10,7 +10,7 @@
-- - ccpm.cache.json -> { packages = { <name> = { version, registry } } }
--
-- 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';

View File

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

View File

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

View File

@ -5,7 +5,7 @@ local testlib = createLibTest({ ... })
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()
counter = counter + 1
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 drive = { name = 'top', mountPath = disk };
local settingsApi = fakeSettings();
writeFile(settingsPath, 'sandbox.url=https://example.test');
writeFile(settingsPath, 'cloud.url=https://example.test');
local installer = createInstallerDisk({
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(settingsApi.saveCount, 1);
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(not result.settingsWarning);
end);
@ -217,7 +217,7 @@ testlib.test('create reinstall disk skips settings clone', function()
local settingsPath = fs.combine(freshDir(), '.settings');
local drive = { name = 'top', mountPath = disk };
local settingsApi = fakeSettings();
writeFile(settingsPath, 'sandbox.url=https://example.test');
writeFile(settingsPath, 'cloud.url=https://example.test');
local installer = createInstallerDisk({
peripheral = fakePeripheral({ drive }),

View File

@ -2,7 +2,7 @@ local createLibTest = require('/apis/libtest');
local testlib = createLibTest({ ... });
local DEFAULT_SANDBOX_URL = 'wss://os.trapcloud.fr';
local DEFAULT_CLOUD_URL = 'wss://os.trapcloud.fr';
local function fakeSettings(values)
return {
@ -50,48 +50,48 @@ local function containsLine(lines, expected)
return false;
end
testlib.test('postinstall defaults missing sandbox url', function()
testlib.test('postinstall defaults missing cloud url', function()
local settingsLib = fakeSettings();
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][1], 'set');
testlib.assertEquals(shellRuns[1][2], 'sandbox.url');
testlib.assertEquals(shellRuns[1][3], DEFAULT_SANDBOX_URL);
testlib.assertTrue(containsLine(lines, '=> Configured sandbox.url to ' .. DEFAULT_SANDBOX_URL .. '.'));
testlib.assertTrue(containsLine(lines, '=> Sandbox password not set.'));
testlib.assertEquals(shellRuns[1][2], 'cloud.url');
testlib.assertEquals(shellRuns[1][3], DEFAULT_CLOUD_URL);
testlib.assertTrue(containsLine(lines, '=> Configured cloud.url to ' .. DEFAULT_CLOUD_URL .. '.'));
testlib.assertTrue(containsLine(lines, '=> Cloud password not set.'));
end);
testlib.test('postinstall treats empty sandbox url as missing', function()
local settingsLib = fakeSettings({ ['sandbox.url'] = '' });
testlib.test('postinstall treats empty cloud url as missing', function()
local settingsLib = fakeSettings({ ['cloud.url'] = '' });
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);
end);
testlib.test('postinstall preserves existing sandbox url and password', function()
testlib.test('postinstall preserves existing cloud url and password', function()
local settingsLib = fakeSettings({
['sandbox.url'] = 'wss://custom.example',
['sandbox.password'] = 'secret',
['cloud.url'] = 'wss://custom.example',
['cloud.password'] = 'secret',
});
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.assertTrue(containsLine(lines, '=> Keeping existing sandbox.url.'));
testlib.assertTrue(containsLine(lines, '=> Sandbox password already configured.'));
testlib.assertTrue(containsLine(lines, '=> Keeping existing cloud.url.'));
testlib.assertTrue(containsLine(lines, '=> Cloud password already configured.'));
end);
testlib.test('postinstall treats empty sandbox password as missing', function()
testlib.test('postinstall treats empty cloud password as missing', function()
local settingsLib = fakeSettings({
['sandbox.url'] = 'wss://custom.example',
['sandbox.password'] = '',
['cloud.url'] = 'wss://custom.example',
['cloud.password'] = '',
});
local lines = runPostinstall(settingsLib);
testlib.assertTrue(containsLine(lines, '=> Sandbox password not set.'));
testlib.assertTrue(containsLine(lines, '=> Cloud password not set.'));
end);
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
-- (discoverTests skips subdirectories), while /tests is still mounted so it can

View File

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

View File

@ -6,7 +6,7 @@ local function parentDir(path)
return path:match('^(.*)/[^/]+$');
end
local function sandboxPath(root, path)
local function scopedPath(root, path)
if path:sub(1, 1) == '/' then
return root .. path;
end
@ -16,16 +16,16 @@ end
local function fakeFs(root)
return {
exists = function(path)
return fs.exists(sandboxPath(root, path));
return fs.exists(scopedPath(root, path));
end,
makeDir = function(path)
fs.makeDir(sandboxPath(root, path));
fs.makeDir(scopedPath(root, path));
end,
open = function(path, mode)
return fs.open(sandboxPath(root, path), mode);
return fs.open(scopedPath(root, path), mode);
end,
delete = function(path)
fs.delete(sandboxPath(root, path));
fs.delete(scopedPath(root, path));
end,
};
end
@ -59,7 +59,7 @@ local function installRoutes()
};
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.
local function runInstall(root, args)
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",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trap-sandbox",
"name": "trapos-server",
"version": "0.2.2",
"dependencies": {
"@fastify/websocket": "^11",

View File

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

View File

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

View File

@ -1,10 +1,10 @@
import { createApp, type Config } from "./app.js";
const config: Config = {
host: process.env.SANDBOX_HOST ?? "0.0.0.0",
port: readPort(process.env.SANDBOX_PORT, 4444),
sandboxPassword: process.env.SANDBOX_PASSWORD,
requestTimeoutMs: readPositiveInt(process.env.SANDBOX_REQUEST_TIMEOUT_MS, 600_000),
host: process.env.TRAPOS_SERVER_HOST ?? "0.0.0.0",
port: readPort(process.env.TRAPOS_SERVER_PORT, 4444),
serverPassword: process.env.TRAPOS_SERVER_PASSWORD,
requestTimeoutMs: readPositiveInt(process.env.TRAPOS_SERVER_REQUEST_TIMEOUT_MS, 600_000),
opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4242",
opencodeUsername: process.env.OPENCODE_USERNAME ?? "opencode",
opencodeServerPassword: process.env.OPENCODE_SERVER_PASSWORD,
@ -24,7 +24,7 @@ const { app, mcpApp } = await createApp(config);
try {
await app.listen({ host: config.host, port: config.port });
} catch (err) {
reportListenError(err, config.host, config.port, "SANDBOX_PORT");
reportListenError(err, config.host, config.port, "TRAPOS_SERVER_PORT");
app.log.error(err);
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 {
if (isErrnoException(err) && err.code === "EADDRINUSE") {
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`,
);
process.exit(1);

View File

@ -24,7 +24,7 @@ type JsonRpcRequest = {
params?: unknown;
};
const SERVER_VERSION = "0.2.2";
const SERVER_VERSION = "0.3.0";
const TOOLS = [
{
@ -97,7 +97,7 @@ async function handleSingle(request: JsonRpcRequest, deps: McpDeps): Promise<unk
return jsonRpcResult(id, {
protocolVersion: "2024-11-05",
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,
): void {
const frame: Record<string, unknown> = {
event: "gateway_response",
event: "server_response",
traposReceiverId: req.traposSenderId,
messageId: req.messageId,
type: req.type,
@ -84,7 +84,7 @@ export function createConnection(deps: ConnectionDeps): Connection {
async function handleProbeGateway(req: ComputerRequest): Promise<void> {
log.debug({ accountId: state?.accountId, traposId: state?.traposId, messageId: req.messageId }, "probing gateway dependencies");
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) {
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");
return;
}
if (frame.type === "probe_gateway") {
if (frame.type === "probe_server") {
await handleProbeGateway(frame);
return;
}
@ -120,7 +120,7 @@ export function createConnection(deps: ConnectionDeps): Connection {
return;
}
if (frame.event === "computer_response") {
// resolve a pending gateway_request
// resolve a pending server_request
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.
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");
return;
}

View File

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

View File

@ -121,7 +121,7 @@ export class GatewayRegistry {
const messageId = randomUUID();
const frame = {
event: "gateway_request",
event: "server_request",
traposReceiverId: traposId,
messageId,
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.
resolveResponse(socket: GatewaySocket, accountId: string, traposId: string, frame: ComputerResponse): void {
const entry = this.entries.get(this.key(accountId, traposId));

View File

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

View File

@ -1,6 +1,6 @@
import assert from "node:assert/strict";
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));
@ -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
// two initial connects; pre-fix this number climbed once per reconnectDelay.
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";
// Aggressive reconnect so the pre-fix loop would be obvious within the observation window.
const RECONNECT_DELAY = "1";
@ -21,29 +21,29 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
const first = startCraftos("gateway-client.lua", {
computerId: 9,
computerLabel: "dup",
shellArgs: [sandbox.baseUrl, "-", "idle", RECONNECT_DELAY],
shellArgs: [server.baseUrl, "-", "idle", RECONNECT_DELAY],
});
let second: ReturnType<typeof startCraftos> | undefined;
try {
await waitFor(() => first.snapshot().includes("__SANDBOX_READY__"), 12_000, "first connected");
await waitFor(() => sandbox.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
await waitFor(() => server.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
const secondHandle = startCraftos("gateway-client.lua", {
computerId: 9,
computerLabel: "dup",
shellArgs: [sandbox.baseUrl, "-", "idle", RECONNECT_DELAY],
shellArgs: [server.baseUrl, "-", "idle", RECONNECT_DELAY],
});
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
// see a fresh "gateway connection opened" every ~1s here.
await sleep(5_000);
const openedAfterWait = sandbox.countLog("gateway connection opened");
const openedAfterWait = server.countLog("gateway connection opened");
assert.equal(
openedAfterWait,
openedAfterBoth,
@ -51,10 +51,10 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
);
// The two initial connects, and nothing more.
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.
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");
} catch (error) {
throw new Error(
@ -68,6 +68,6 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
first.abort();
second?.abort();
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 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
// complete hello, exactly one registration exists; aborting the displaced first
// computer does not affect the live registration, and a gateway->computer ping is
// still answered by the newest computer.
test("duplicate traposId is resolved newest-wins", async () => {
const sandbox = await startSandbox();
const server = await startServer();
const TRAPOS_ID = "9:dup";
// 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", {
computerId: 9,
computerLabel: "dup",
shellArgs: [sandbox.baseUrl, "-", "idle", "60"],
shellArgs: [server.baseUrl, "-", "idle", "60"],
});
let second: ReturnType<typeof startCraftos> | undefined;
try {
await waitFor(() => first.snapshot().includes("__SANDBOX_READY__"), 12_000, "first connected");
await waitFor(() => sandbox.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
await waitFor(() => server.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
const secondHandle = startCraftos("gateway-client.lua", {
computerId: 9,
computerLabel: "dup",
shellArgs: [sandbox.baseUrl, "-", "idle"],
shellArgs: [server.baseUrl, "-", "idle"],
});
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.
assert.equal(sandbox.registry.count(), 1, "exactly one registration after the duplicate");
assert.ok(sandbox.registry.has("local", TRAPOS_ID));
assert.equal(server.registry.count(), 1, "exactly one registration after the duplicate");
assert.ok(server.registry.has("local", TRAPOS_ID));
first.abort();
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");
} catch (error) {
throw new Error(
@ -52,6 +52,6 @@ test("duplicate traposId is resolved newest-wins", async () => {
first.abort();
second?.abort();
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 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
// (driven by sandbox.* settings) and the real `sandbox health` program against the
// Full-boot e2e: a real CraftOS computer runs the real servers/cloud.lua daemon
// (driven by cloud.* settings) and the real `cloud health` program against the
// real gateway, with a healthy fake-opencode behind it.
test("full boot: settings -> daemon -> sandbox health prints sandboxOk/opencodeOk true", async () => {
test("full boot: settings -> daemon -> cloud health prints serverOk/opencodeOk true", async () => {
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", {
computerId: 11,
computerLabel: "boot",
shellArgs: [sandbox.baseUrl, "boot-secret"],
shellArgs: [server.baseUrl, "boot-secret"],
timeoutMs: 20_000,
});
try {
const result = await craftos.done;
assert.match(result.output, /sandboxOk:\s*true/, formatFailure("expected sandboxOk: true", result.output));
assert.match(result.output, /serverOk:\s*true/, formatFailure("expected serverOk: true", result.output));
assert.match(result.output, /opencodeOk:\s*true/, formatFailure("expected opencodeOk: true", result.output));
} finally {
craftos.abort();
await craftos.done.catch(() => undefined);
await sandbox.close();
await server.close();
await opencode.close();
}
});

View File

@ -14,25 +14,25 @@ const HERE = dirname(fileURLToPath(import.meta.url));
const LUA_DIR = join(HERE, "lua");
const REPO_ROOT = join(HERE, "../../..");
export type Sandbox = {
export type Server = {
registry: GatewayRegistry;
// The MCP Fastify app, sharing the gateway registry. Drive it in-process with
// `mcpApp.inject({ method: "POST", url: "/", payload })` — no real listener needed.
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.
baseUrl: string;
// Full WS endpoint (origin + `/gateway`), for direct ws clients.
socketUrl: string;
// 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).
countLog: (substring: string) => number;
close: () => Promise<void>;
};
export type SandboxOptions = {
sandboxPassword?: string;
export type ServerOptions = {
serverPassword?: string;
opencodeUrl?: string;
requestTimeoutMs?: number;
// 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
// 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 logger: Config["logger"] = opts.captureLogs
? { 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 = {
host: "127.0.0.1",
port: 0,
sandboxPassword: opts.sandboxPassword,
serverPassword: opts.serverPassword,
requestTimeoutMs: timeoutMs,
opencodeUrl: opts.opencodeUrl,
opencodeUsername: "opencode",
@ -130,13 +130,13 @@ export type CraftosHandle = {
done: Promise<CraftosResult>;
abort: () => void;
// Stdout/stderr accumulated so far; lets a test await a marker line while the
// process is still running (e.g. wait for "__SANDBOX_READY__" before acting).
// process is still running (e.g. wait for "__CLOUD_READY__" before acting).
snapshot: () => string;
};
// Spawn headless CraftOS-PC running a Lua script from test-integration/lua, with the
// repo mounted read-only (/apis, /servers, /programs, /trapos) so the scripts can
// require the real libsandbox / servers / programs.
// require the real libcloud / servers / programs.
export function startCraftos(
luaName: string,
opts: { computerId?: number; computerLabel?: string; shellArgs?: string[]; timeoutMs?: number } = {},
@ -146,7 +146,7 @@ export function startCraftos(
const chunks: Buffer[] = [];
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);
try {
if (!existsSync(join(REPO_ROOT, ".stage/apis"))) {

View File

@ -1,49 +1,49 @@
import assert from "node:assert/strict";
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 sandbox = await startSandbox({ opencodeUrl: opencode.url });
const server = await startServer({ opencodeUrl: opencode.url });
const craftos = startCraftos("gateway-client.lua", {
computerId: 7,
computerLabel: "health-ok",
shellArgs: [sandbox.baseUrl, "-", "health"],
shellArgs: [server.baseUrl, "-", "health"],
});
try {
const result = await craftos.done;
assert.match(
result.output,
/__SANDBOX_HEALTH__ sandboxOk=true opencodeOk=true/,
formatFailure("expected sandboxOk=true opencodeOk=true", result.output),
/__CLOUD_HEALTH__ serverOk=true opencodeOk=true/,
formatFailure("expected serverOk=true opencodeOk=true", result.output),
);
} finally {
craftos.abort();
await craftos.done.catch(() => undefined);
await sandbox.close();
await server.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 sandbox = await startSandbox({ opencodeUrl: opencode.url });
const server = await startServer({ opencodeUrl: opencode.url });
const craftos = startCraftos("gateway-client.lua", {
computerId: 7,
computerLabel: "health-bad",
shellArgs: [sandbox.baseUrl, "-", "health"],
shellArgs: [server.baseUrl, "-", "health"],
});
try {
const result = await craftos.done;
assert.match(
result.output,
/__SANDBOX_HEALTH__ sandboxOk=true opencodeOk=false/,
formatFailure("expected sandboxOk=true opencodeOk=false", result.output),
/__CLOUD_HEALTH__ serverOk=true opencodeOk=false/,
formatFailure("expected serverOk=true opencodeOk=false", result.output),
);
} finally {
craftos.abort();
await craftos.done.catch(() => undefined);
await sandbox.close();
await server.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
-- 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 reconnectDelay = tonumber(args[4] or '');
local createSandbox = require('/apis/libsandbox');
local createCloud = require('/apis/libcloud');
local createEventLoop = require('/apis/eventloop');
_G.bootEventLoop = createEventLoop();
local sandbox = createSandbox();
local cloud = createCloud();
local sessionOpts = {
eventloop = _G.bootEventLoop,
url = url,
@ -31,17 +31,17 @@ local sessionOpts = {
if reconnectDelay then
sessionOpts.reconnectDelay = reconnectDelay;
end
local session = sandbox.startSession(sessionOpts);
local session = cloud.startSession(sessionOpts);
-- Connect, probe the gateway, report opencodeOk.
local function runHealth()
os.pullEvent('trapos_sandbox_connected');
local ok, payload = sandbox.request('probe_gateway');
os.pullEvent('trapos_cloud_connected');
local ok, payload = cloud.request('probe_server');
if ok then
print('__SANDBOX_HEALTH__ sandboxOk=' .. tostring(payload.sandboxOk)
print('__CLOUD_HEALTH__ serverOk=' .. tostring(payload.serverOk)
.. ' opencodeOk=' .. tostring(payload.opencodeOk));
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
@ -50,24 +50,24 @@ local function runAuth()
local deadline = os.epoch('utc') + 8000;
while os.epoch('utc') < deadline do
if session.isReady() then
print('__SANDBOX_HELLO__ ok=true');
print('__CLOUD_HELLO__ ok=true');
return;
end
local err = session.lastError();
if err then
print('__SANDBOX_HELLO__ ok=false code=' .. tostring(err));
print('__CLOUD_HELLO__ ok=false code=' .. tostring(err));
return;
end
os.sleep(0.1);
end
print('__SANDBOX_HELLO__ ok=false code=timeout');
print('__CLOUD_HELLO__ ok=false code=timeout');
end
-- Connect and stay registered so the TS side can drive the gateway->computer
-- direction (reverse ping) or assert registry state (duplicate newest-wins).
local function runIdle()
os.pullEvent('trapos_sandbox_connected');
print('__SANDBOX_READY__ ' .. sandbox.traposId(os));
os.pullEvent('trapos_cloud_connected');
print('__CLOUD_READY__ ' .. cloud.traposId(os));
os.sleep(10);
end

View File

@ -1,5 +1,5 @@
-- 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
-- side can drive the MCP tools over the gateway.
--
@ -11,23 +11,23 @@ local url = args[1];
local secret = args[2];
if secret == '' or secret == '-' then secret = nil; end
local createSandbox = require('/apis/libsandbox');
local createCloud = require('/apis/libcloud');
local createMcpComputer = require('/apis/libmcpcomputer');
local createEventLoop = require('/apis/eventloop');
_G.bootEventLoop = createEventLoop();
local el = _G.bootEventLoop;
local sandbox = createSandbox();
local cloud = createCloud();
local mcp = createMcpComputer();
sandbox.startSession({
cloud.startSession({
eventloop = el,
url = url,
secret = secret,
os = os,
});
sandbox.serve('exec-lua', function(payload)
cloud.serve('exec-lua', function(payload)
local result = mcp.executeLua(payload and payload.code);
return true, {
ok = result.ok,
@ -37,7 +37,7 @@ sandbox.serve('exec-lua', function(payload)
};
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);
return true, {
ok = result.ok,
@ -47,7 +47,7 @@ sandbox.serve('run-file', function(payload)
};
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);
return true, {
ok = result.ok,
@ -58,8 +58,8 @@ sandbox.serve('write-file', function(payload)
end, { eventloop = el });
local function runReady()
os.pullEvent('trapos_sandbox_connected');
print('__SANDBOX_READY__ ' .. sandbox.traposId(os));
os.pullEvent('trapos_cloud_connected');
print('__CLOUD_READY__ ' .. cloud.traposId(os));
os.sleep(12);
end

View File

@ -1,12 +1,12 @@
import assert from "node:assert/strict";
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
// MCP app is driven in-process via inject() (no extra listener).
async function callTool(sandbox: Sandbox, name: string, args: Record<string, unknown> = {}): Promise<string> {
const res = await sandbox.mcpApp.inject({
async function callTool(server: Server, name: string, args: Record<string, unknown> = {}): Promise<string> {
const res = await server.mcpApp.inject({
method: "POST",
url: "/",
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 () => {
const sandbox = await startSandbox();
const server = await startServer();
const craftos = startCraftos("mcp-server-client.lua", {
computerId: 7,
computerLabel: "base",
shellArgs: [sandbox.baseUrl, "-"],
shellArgs: [server.baseUrl, "-"],
});
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, /"value":2/); // return value serialized
assert.match(exec, /\nhi$/); // captured print output
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",
path: "/mcp-it.lua",
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, 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, /"value":"install"/);
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$/);
// 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",
code: "local h = fs.open('/mcp-it.lua', 'r'); local c = h.readAll(); h.close(); return c",
});
assert.match(verify, /shell\.getRunningProgram/);
// 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:/);
} catch (error) {
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 {
craftos.abort();
await craftos.done.catch(() => undefined);
await sandbox.close();
await server.close();
}
});

View File

@ -1,19 +1,19 @@
import assert from "node:assert/strict";
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`
// 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 () => {
const sandbox = await startSandbox();
const server = await startServer();
const craftos = startCraftos("gateway-client.lua", {
computerId: 7,
computerLabel: "ping",
shellArgs: [sandbox.baseUrl, "-", "idle"],
shellArgs: [server.baseUrl, "-", "idle"],
});
try {
await waitFor(() => sandbox.registry.has("local", "7:ping"), 12_000, "computer registered");
const payload = await sandbox.registry.request("local", "7:ping", "ping");
await waitFor(() => server.registry.has("local", "7:ping"), 12_000, "computer registered");
const payload = await server.registry.request("local", "7:ping", "ping");
assert.deepEqual(payload, { traposId: "7:ping" });
} catch (error) {
throw new Error(
@ -23,6 +23,6 @@ test("gateway->computer reverse ping is answered by the client", async () => {
} finally {
craftos.abort();
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 () => {
const { socket, registry, connection } = setup();
await connection.onMessage(requestFrame("probe_gateway"));
await connection.onMessage(requestFrame("probe_server"));
const frame = socket.lastFrame();
assert.equal(frame.event, "gateway_response");
assert.equal(frame.event, "server_response");
assert.equal(frame.ok, false);
assert.deepEqual(frame.error, { code: "not_authenticated", message: "hello required before any other request" });
assert.equal(socket.closed, false);
@ -77,7 +77,7 @@ test("hello ok -> registers and replies with accountId", async () => {
const { socket, registry, connection } = setup();
await connection.onMessage(helloFrame("7:", "anything"));
const frame = socket.lastFrame();
assert.equal(frame.event, "gateway_response");
assert.equal(frame.event, "server_response");
assert.equal(frame.type, "hello");
assert.equal(frame.ok, true);
assert.deepEqual(frame.payload, { accountId: "local" });
@ -85,21 +85,21 @@ test("hello ok -> registers and replies with accountId", async () => {
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 } });
await connection.onMessage(helloFrame("7:"));
await connection.onMessage(requestFrame("probe_gateway"));
await connection.onMessage(requestFrame("probe_server"));
const frame = socket.lastFrame();
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" } });
await connection.onMessage(helloFrame("7:"));
await connection.onMessage(requestFrame("probe_gateway"));
await connection.onMessage(requestFrame("probe_server"));
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 () => {
@ -126,7 +126,7 @@ test("post-hello wrong-direction frame is dropped", async () => {
await connection.onMessage(helloFrame("7:"));
const afterHello = socket.sent.length;
await connection.onMessage(
JSON.stringify({ event: "gateway_request", traposReceiverId: "7:", messageId: "g1", type: "ping", payload: {} }),
JSON.stringify({ event: "server_request", traposReceiverId: "7:", messageId: "g1", type: "ping", payload: {} }),
);
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 sent = socket.lastFrame();
assert.equal(sent.event, "gateway_request");
assert.equal(sent.event, "server_request");
assert.equal(sent.type, "ping");
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 };
}
// 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(
deps: McpDeps,
socket: FakeSocket,
@ -42,10 +42,10 @@ async function callAndAnswer(
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 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 () => {

View File

@ -14,14 +14,14 @@ test("parses all four event types", () => {
assert.equal(cRes.event, "computer_response");
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(
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", () => {

View File

@ -43,14 +43,14 @@ test("same traposId under different accounts coexist", () => {
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 a = new FakeSocket();
reg.register("local", "7:", a);
const promise = reg.request("local", "7:", "ping", {});
const frame = a.lastFrame();
assert.equal(frame.event, "gateway_request");
assert.equal(frame.event, "server_request");
assert.equal(frame.type, "ping");
assert.equal(frame.traposReceiverId, "7:");