refactor: rename trapos-sandbox to trapos-server
This commit is contained in:
parent
2d13242f29
commit
618e1a62dc
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"trapos-cloud-gateway-mcp": {
|
||||
"trapos-mcp": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:4445"
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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 4242–4244 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: …`.
|
||||
@ -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.
|
||||
@ -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:
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
2
Justfile
2
Justfile
@ -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'
|
||||
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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).
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
;;
|
||||
*)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
18
just/server.just
Normal 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 -- "$@"
|
||||
@ -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.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TrapOS",
|
||||
"version": "0.11.6",
|
||||
"version": "0.12.0",
|
||||
"branch": "master",
|
||||
"packages": [
|
||||
"trapos"
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
14
packages/trapos-cloud/ccpm.json
Normal file
14
packages/trapos-cloud/ccpm.json
Normal 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"]
|
||||
}
|
||||
@ -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));
|
||||
@ -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 .. '.');
|
||||
@ -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).');
|
||||
@ -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');
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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": []
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
@ -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",
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trap-sandbox",
|
||||
"version": "0.2.2",
|
||||
"name": "trapos-server",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@ -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;
|
||||
@ -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);
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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),
|
||||
@ -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));
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
@ -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"))) {
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
30
tools/trapos-server/test-integration/lua/full-boot.lua
Normal file
30
tools/trapos-server/test-integration/lua/full-boot.lua
Normal 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();
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
@ -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(
|
||||
@ -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 () => {
|
||||
@ -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", () => {
|
||||
@ -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:");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user