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`,
|
# Test placeholders. The real values are generated in `.env` on first `just install`,
|
||||||
# or after `just clean` / `just reinstall`.
|
# or after `just clean` / `just reinstall`.
|
||||||
OPENCODE_SERVER_PASSWORD=redacted
|
OPENCODE_SERVER_PASSWORD=redacted
|
||||||
SANDBOX_PASSWORD=redacted
|
TRAPOS_SERVER_PASSWORD=redacted
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
-- Luacheck config for CC:Tweaked (ComputerCraft) Lua sandbox.
|
-- Luacheck config for CC:Tweaked (ComputerCraft) Lua runtime.
|
||||||
-- Runtime is Lua 5.1 inside the CC sandbox, not standard Lua, so we
|
-- Runtime is Lua 5.1 inside the CC:Tweaked runtime, not standard Lua, so we
|
||||||
-- extend lua51 with the globals and os/table extensions CC provides.
|
-- extend lua51 with the globals and os/table extensions CC provides.
|
||||||
|
|
||||||
stds.cc = {
|
stds.cc = {
|
||||||
read_globals = {
|
read_globals = {
|
||||||
-- ComputerCraft APIs injected as globals by the sandbox.
|
-- ComputerCraft APIs injected as globals by the runtime.
|
||||||
"fs",
|
"fs",
|
||||||
"peripheral",
|
"peripheral",
|
||||||
"parallel",
|
"parallel",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"trapos-cloud-gateway-mcp": {
|
"trapos-mcp": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "http://127.0.0.1:4445"
|
"url": "http://127.0.0.1:4445"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,8 +12,8 @@ permission:
|
|||||||
bash:
|
bash:
|
||||||
"*": ask
|
"*": ask
|
||||||
"just check": allow
|
"just check": allow
|
||||||
trapos-cloud-gateway-mcp_probe-computers: allow
|
trapos-mcp_probe-computers: allow
|
||||||
trapos-cloud-gateway-mcp_exec-lua: allow
|
trapos-mcp_exec-lua: allow
|
||||||
---
|
---
|
||||||
|
|
||||||
You answer players using TrapOS `ai` from inside ATM10 / All The Mods 10.
|
You answer players using TrapOS `ai` from inside ATM10 / All The Mods 10.
|
||||||
@ -23,7 +23,7 @@ Target environment:
|
|||||||
- ATM10 / All The Mods 10 pack version `7.0`; accept `0.7.0` as the same user alias.
|
- ATM10 / All The Mods 10 pack version `7.0`; accept `0.7.0` as the same user alias.
|
||||||
- Minecraft `1.21.1`.
|
- Minecraft `1.21.1`.
|
||||||
- NeoForge `21.1.228`.
|
- NeoForge `21.1.228`.
|
||||||
- ComputerCraft is CC:Tweaked in the ComputerCraft sandbox, not desktop Lua.
|
- ComputerCraft is CC:Tweaked in the ComputerCraft runtime, not desktop Lua.
|
||||||
|
|
||||||
Response style:
|
Response style:
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"trapos-cloud-gateway-mcp": {
|
"trapos-mcp": {
|
||||||
"type": "remote",
|
"type": "remote",
|
||||||
"url": "http://127.0.0.1:4445",
|
"url": "http://127.0.0.1:4445",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|||||||
@ -114,7 +114,7 @@ ADR-0019, not fixed here to keep scope contained.)
|
|||||||
Rewrite the "postinstall before startup servers" test (lines 164-174): drop the
|
Rewrite the "postinstall before startup servers" test (lines 164-174): drop the
|
||||||
`/startup/servers.lua` lookup; assert reboot occurs after `trapos-postinstall`. In the
|
`/startup/servers.lua` lookup; assert reboot occurs after `trapos-postinstall`. In the
|
||||||
`--cpm-only` test (176-184), assert reboot was **not** called.
|
`--cpm-only` test (176-184), assert reboot was **not** called.
|
||||||
- `tools/trap-sandbox/test-integration/lua/full-boot.lua:1`: update the comment reference
|
- `tools/trapos-server/test-integration/lua/full-boot.lua:1`: update the comment reference
|
||||||
`startup/servers.lua` → `startup/boot.lua` (no functional change — it doesn't load the file).
|
`startup/servers.lua` → `startup/boot.lua` (no functional change — it doesn't load the file).
|
||||||
|
|
||||||
### 7. Docs
|
### 7. Docs
|
||||||
@ -138,7 +138,7 @@ ADR-0019, not fixed here to keep scope contained.)
|
|||||||
- `packages/trapos-boot/ccpm.json`, `packages/index.json` (file list + version bumps)
|
- `packages/trapos-boot/ccpm.json`, `packages/index.json` (file list + version bumps)
|
||||||
- `tests/startup-servers.lua` → **rename** `tests/boot.lua` (policy cases)
|
- `tests/startup-servers.lua` → **rename** `tests/boot.lua` (policy cases)
|
||||||
- `tests/install-trapos.lua` (os stub + reboot assertions)
|
- `tests/install-trapos.lua` (os stub + reboot assertions)
|
||||||
- `tools/trap-sandbox/test-integration/lua/full-boot.lua` (comment)
|
- `tools/trapos-server/test-integration/lua/full-boot.lua` (comment)
|
||||||
- `docs/adrs/adr-0019-boot-lifecycle.md` (**new**), `docs/adrs/README.md`
|
- `docs/adrs/adr-0019-boot-lifecycle.md` (**new**), `docs/adrs/README.md`
|
||||||
- `README.md`, `AGENTS.md`, `docs/periphemu.md`
|
- `README.md`, `AGENTS.md`, `docs/periphemu.md`
|
||||||
|
|
||||||
@ -157,5 +157,5 @@ ADR-0019, not fixed here to keep scope contained.)
|
|||||||
- **Upgrade migration**: on a computer with the old `startup/servers.lua` + `startup/motd.lua`
|
- **Upgrade migration**: on a computer with the old `startup/servers.lua` + `startup/motd.lua`
|
||||||
present, drop in the new `boot.lua` and reboot; confirm both stale files are deleted and only
|
present, drop in the new `boot.lua` and reboot; confirm both stale files are deleted and only
|
||||||
`boot.lua` remains in `/startup/`.
|
`boot.lua` remains in `/startup/`.
|
||||||
- **Installer**: run `install-trapos.lua` end-to-end (or via the sandbox harness) and confirm it
|
- **Installer**: run `install-trapos.lua` end-to-end (or via the test harness) and confirm it
|
||||||
finishes with `=> Rebooting...` and comes back up into a live TrapOS rather than a nested shell.
|
finishes with `=> Rebooting...` and comes back up into a live TrapOS rather than a nested shell.
|
||||||
|
|||||||
@ -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
|
## Context
|
||||||
|
|
||||||
Today TrapOS is installed by typing `wget run https://os.trapcloud.fr/install` on each
|
Today TrapOS is installed by typing `wget run https://os.trapcloud.fr/install` on each
|
||||||
fresh computer, then manually configuring settings (`sandbox.url`, `sandbox.password`, …) per
|
fresh computer, then manually configuring settings (`cloud.url`, `cloud.password`, …) per
|
||||||
machine. We want a **provisioning floppy**: run one program on a configured machine to "bake"
|
machine. We want a **provisioning floppy**: run one program on a configured machine to "bake"
|
||||||
an installer disk, then on any blank machine just insert the disk and boot — it installs the
|
an installer disk, then on any blank machine just insert the disk and boot — it installs the
|
||||||
latest TrapOS online and clones *your* `.settings` onto the new computer, hands-free.
|
latest TrapOS online and clones *your* `.settings` onto the new computer, hands-free.
|
||||||
@ -19,7 +19,7 @@ The disk's value is **hands-free install + config cloning**, not offline file sh
|
|||||||
- **Ordering (copy-then-install, no reboot)**: `install-trapos.lua` ends by handing off to the
|
- **Ordering (copy-then-install, no reboot)**: `install-trapos.lua` ends by handing off to the
|
||||||
live `/startup/servers.lua` shell, which never returns. So the autorun **copies `.settings`
|
live `/startup/servers.lua` shell, which never returns. So the autorun **copies `.settings`
|
||||||
first**, calls `settings.load()`, *then* runs the installer (whose `trapos-postinstall`
|
first**, calls `settings.load()`, *then* runs the installer (whose `trapos-postinstall`
|
||||||
respects the already-set `sandbox.url` and never touches `sandbox.password`, so the disk's
|
respects the already-set `cloud.url` and never touches `cloud.password`, so the disk's
|
||||||
config wins). **No changes to `install-trapos.lua`** and **no reboot** here — the
|
config wins). **No changes to `install-trapos.lua`** and **no reboot** here — the
|
||||||
boot/reboot rework is a separate effort.
|
boot/reboot rework is a separate effort.
|
||||||
- **Sentinel**: autorun no-ops if `/trapos` exists. To reinstall: `rm /trapos`, reinsert, boot.
|
- **Sentinel**: autorun no-ops if `/trapos` exists. To reinstall: `rm /trapos`, reinsert, boot.
|
||||||
@ -51,7 +51,7 @@ in `apis/libccpm.lua`).
|
|||||||
-- opts.settingsSourcePath (default '/.settings') -- computer config to clone
|
-- opts.settingsSourcePath (default '/.settings') -- computer config to clone
|
||||||
-- opts.installerLabel (default 'TrapOS Installer')
|
-- opts.installerLabel (default 'TrapOS Installer')
|
||||||
-- opts.installUrl (default 'https://os.trapcloud.fr/install')
|
-- opts.installUrl (default 'https://os.trapcloud.fr/install')
|
||||||
-- (uses real global `fs`; tests sandbox with isolated real dirs, like tests/ccpm.lua)
|
-- (uses real global `fs`; tests isolate with real dirs, like tests/ccpm.lua)
|
||||||
```
|
```
|
||||||
|
|
||||||
Public API:
|
Public API:
|
||||||
|
|||||||
@ -7,8 +7,8 @@
|
|||||||
1. MCP tools and a dedicated ComputerCraft link transport.
|
1. MCP tools and a dedicated ComputerCraft link transport.
|
||||||
2. An opencode HTTP-over-WebSocket proxy used by the in-game `ai` bridge mode.
|
2. An opencode HTTP-over-WebSocket proxy used by the in-game `ai` bridge mode.
|
||||||
|
|
||||||
The MCP tools have already moved to `tools/trap-sandbox` and `.mcp.json` already points at
|
The MCP tools have already moved to `tools/trapos-server` and `.mcp.json` already points at
|
||||||
the sandbox MCP endpoint (`http://127.0.0.1:4445`). The remaining live dependency is the old
|
the cloud MCP endpoint (`http://127.0.0.1:4445`). The remaining live dependency is the old
|
||||||
opencode proxy path (`opencc.bridge_url` / `packages/trapos-ai/apis/libhttpws.lua`).
|
opencode proxy path (`opencc.bridge_url` / `packages/trapos-ai/apis/libhttpws.lua`).
|
||||||
|
|
||||||
Post-pull path note: Lua source files now live under `packages/<pkg>/...`. The CraftOS-PC
|
Post-pull path note: Lua source files now live under `packages/<pkg>/...`. The CraftOS-PC
|
||||||
@ -24,7 +24,7 @@ scratch later; the old implementation remains available in git history.
|
|||||||
- Delete `tools/mcp-bridge/` entirely.
|
- Delete `tools/mcp-bridge/` entirely.
|
||||||
- Remove `ai` WebSocket bridge-mode support.
|
- Remove `ai` WebSocket bridge-mode support.
|
||||||
- Keep direct HTTP/HTTPS opencode support through `packages/trapos-ai/apis/libhttp.lua`.
|
- Keep direct HTTP/HTTPS opencode support through `packages/trapos-ai/apis/libhttp.lua`.
|
||||||
- Keep current MCP tooling in `tools/trap-sandbox` unchanged, except for docs/tooling cleanup.
|
- Keep current MCP tooling in `tools/trapos-server` unchanged, except for docs/tooling cleanup.
|
||||||
|
|
||||||
## Runtime Changes
|
## Runtime Changes
|
||||||
|
|
||||||
@ -62,9 +62,9 @@ Update `packages/trapos-ai/tests/ai.lua`:
|
|||||||
|
|
||||||
- Delete all `tools/mcp-bridge/test/` and `tools/mcp-bridge/test-integration/` tests with the
|
- Delete all `tools/mcp-bridge/test/` and `tools/mcp-bridge/test-integration/` tests with the
|
||||||
directory.
|
directory.
|
||||||
- Do not port `opencode-proxy.test.ts` or `ai-cli.test.ts` to `tools/trap-sandbox`; the proxy
|
- Do not port `opencode-proxy.test.ts` or `ai-cli.test.ts` to `tools/trapos-server`; the proxy
|
||||||
behavior is intentionally removed.
|
behavior is intentionally removed.
|
||||||
- Keep `tools/trap-sandbox/test/` and `tools/trap-sandbox/test-integration/` as the supported
|
- Keep `tools/trapos-server/test/` and `tools/trapos-server/test-integration/` as the supported
|
||||||
MCP/gateway coverage.
|
MCP/gateway coverage.
|
||||||
|
|
||||||
## Tooling Changes
|
## Tooling Changes
|
||||||
@ -76,7 +76,7 @@ Update `packages/trapos-ai/tests/ai.lua`:
|
|||||||
- Remove `npm-check-mcp-bridge`.
|
- Remove `npm-check-mcp-bridge`.
|
||||||
- Remove `npm-test-mcp-bridge`.
|
- Remove `npm-test-mcp-bridge`.
|
||||||
- Remove `npm-test-integration-mcp-bridge`.
|
- Remove `npm-test-integration-mcp-bridge`.
|
||||||
- Make aggregate Node recipes target only `tools/trap-sandbox`:
|
- Make aggregate Node recipes target only `tools/trapos-server`:
|
||||||
- `npm-install`
|
- `npm-install`
|
||||||
- `npm-build`
|
- `npm-build`
|
||||||
- `npm-check`
|
- `npm-check`
|
||||||
@ -85,14 +85,14 @@ Update `packages/trapos-ai/tests/ai.lua`:
|
|||||||
|
|
||||||
### `just/install.just`
|
### `just/install.just`
|
||||||
|
|
||||||
- Change `clean` to clean `tools/trap-sandbox` caches instead of `tools/mcp-bridge`, or make it
|
- Change `clean` to clean `tools/trapos-server` caches instead of `tools/mcp-bridge`, or make it
|
||||||
a broader Node-tool clean if useful.
|
a broader Node-tool clean if useful.
|
||||||
- Update the comment above `clean`.
|
- Update the comment above `clean`.
|
||||||
|
|
||||||
### `just/test.just`
|
### `just/test.just`
|
||||||
|
|
||||||
- Update comments that describe end-to-end tests as spanning the MCP bridge; they now span the
|
- Update comments that describe end-to-end tests as spanning the MCP bridge; they now span the
|
||||||
sandbox gateway / MCP endpoint.
|
cloud gateway / MCP endpoint.
|
||||||
|
|
||||||
### `just/stage.just`
|
### `just/stage.just`
|
||||||
|
|
||||||
@ -111,7 +111,7 @@ Update `packages/index.json`:
|
|||||||
|
|
||||||
- Mirror the new `trapos-ai` version.
|
- Mirror the new `trapos-ai` version.
|
||||||
|
|
||||||
No `trapos-sandbox` package bump is required unless its behavior changes during cleanup.
|
No `trapos-cloud` package bump is required unless its behavior changes during cleanup.
|
||||||
|
|
||||||
## Documentation Cleanup
|
## Documentation Cleanup
|
||||||
|
|
||||||
@ -120,11 +120,11 @@ No `trapos-sandbox` package bump is required unless its behavior changes during
|
|||||||
- `DEVELOPMENT.md`: update `just clean` description so it no longer mentions `mcp-bridge`.
|
- `DEVELOPMENT.md`: update `just clean` description so it no longer mentions `mcp-bridge`.
|
||||||
- `docs/public-ports.md`: remove the old `4243` / `CC_LINK_PORT` ComputerCraft bridge entry,
|
- `docs/public-ports.md`: remove the old `4243` / `CC_LINK_PORT` ComputerCraft bridge entry,
|
||||||
unless a new service explicitly claims that port later.
|
unless a new service explicitly claims that port later.
|
||||||
- `docs/adrs/adr-0019-mcp-over-sandbox-gateway.md`: replace the statement that
|
- `docs/adrs/adr-0019-mcp-over-cloud-gateway.md`: replace the statement that
|
||||||
`tools/mcp-bridge` is kept for the opencode proxy with a note that it has been removed and
|
`tools/mcp-bridge` is kept for the opencode proxy with a note that it has been removed and
|
||||||
the proxy was intentionally dropped pending a future rewrite.
|
the proxy was intentionally dropped pending a future rewrite.
|
||||||
- `docs/adrs/adr-0016-js-tool-verification.md`: replace `tools/mcp-bridge` as the JS tool
|
- `docs/adrs/adr-0016-js-tool-verification.md`: replace `tools/mcp-bridge` as the JS tool
|
||||||
verification example with `tools/trap-sandbox`.
|
verification example with `tools/trapos-server`.
|
||||||
|
|
||||||
### Historical / Optional Cleanup
|
### Historical / Optional Cleanup
|
||||||
|
|
||||||
@ -149,7 +149,7 @@ No `trapos-sandbox` package bump is required unless its behavior changes during
|
|||||||
- Modify: `packages/index.json`
|
- Modify: `packages/index.json`
|
||||||
- Modify: `DEVELOPMENT.md`
|
- Modify: `DEVELOPMENT.md`
|
||||||
- Modify: `docs/public-ports.md`
|
- Modify: `docs/public-ports.md`
|
||||||
- Modify: `docs/adrs/adr-0019-mcp-over-sandbox-gateway.md`
|
- Modify: `docs/adrs/adr-0019-mcp-over-cloud-gateway.md`
|
||||||
- Modify: `docs/adrs/adr-0016-js-tool-verification.md`
|
- Modify: `docs/adrs/adr-0016-js-tool-verification.md`
|
||||||
- Optional: `docs/adrs/adr-0017-mcp-remote-lua-execution.md`
|
- Optional: `docs/adrs/adr-0017-mcp-remote-lua-execution.md`
|
||||||
- Optional: `.opencode/agent/atm10-expert.md`
|
- Optional: `.opencode/agent/atm10-expert.md`
|
||||||
@ -170,4 +170,4 @@ Expected result:
|
|||||||
- No source file references `require('/apis/libhttpws')`, `opencc.bridge_url`, or
|
- No source file references `require('/apis/libhttpws')`, `opencc.bridge_url`, or
|
||||||
`opencc.request_timeout_seconds` except archived history or generated/local CraftOS state.
|
`opencc.request_timeout_seconds` except archived history or generated/local CraftOS state.
|
||||||
- `ai` works only through direct HTTP/HTTPS `opencc.server_url`.
|
- `ai` works only through direct HTTP/HTTPS `opencc.server_url`.
|
||||||
- MCP tools continue to work through `tools/trap-sandbox`.
|
- MCP tools continue to work through `tools/trapos-server`.
|
||||||
|
|||||||
@ -4,7 +4,7 @@ Concise guidance for agents working in this repository.
|
|||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
ComputerCraft / CC:Tweaked Lua APIs, servers, and programs for Minecraft 1.21. Code runs in the ComputerCraft sandbox, not standard Lua.
|
ComputerCraft / CC:Tweaked Lua APIs, servers, and programs for Minecraft 1.21. Code runs in the ComputerCraft runtime, not standard Lua.
|
||||||
|
|
||||||
Use [`docs/README.md`](docs/README.md) as the entrypoint for CC:Tweaked, CraftOS-PC, Advanced Peripherals, and Create integration documentation links. Use [`docs/adrs/README.md`](docs/adrs/README.md) for repository architecture decisions.
|
Use [`docs/README.md`](docs/README.md) as the entrypoint for CC:Tweaked, CraftOS-PC, Advanced Peripherals, and Create integration documentation links. Use [`docs/adrs/README.md`](docs/adrs/README.md) for repository architecture decisions.
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ This installs local Git hooks, checks required tools, generates `.env`, and veri
|
|||||||
|
|
||||||
## Cleaning
|
## Cleaning
|
||||||
|
|
||||||
- `just clean` — clears the `trap-sandbox` build caches (`node_modules/.cache/tsc`, `node_modules/.cache/eslint`). Safe to run anytime; `just reinstall` chains it with `just install`.
|
- `just clean` — clears the `trapos-server` build caches (`node_modules/.cache/tsc`, `node_modules/.cache/eslint`). Safe to run anytime; `just reinstall` chains it with `just install`.
|
||||||
- `just clean-env` — deletes `.env`. Run only when you actually want to drop locally generated tokens.
|
- `just clean-env` — deletes `.env`. Run only when you actually want to drop locally generated tokens.
|
||||||
|
|
||||||
See [`docs/README.md`](docs/README.md) for repository docs and [`docs/adrs/README.md`](docs/adrs/README.md) for architecture decisions.
|
See [`docs/README.md`](docs/README.md) for repository docs and [`docs/adrs/README.md`](docs/adrs/README.md) for architecture decisions.
|
||||||
|
|||||||
2
Justfile
2
Justfile
@ -7,7 +7,7 @@ import 'just/npm.just'
|
|||||||
import 'just/stage.just'
|
import 'just/stage.just'
|
||||||
import 'just/craftos.just'
|
import 'just/craftos.just'
|
||||||
import 'just/opencode.just'
|
import 'just/opencode.just'
|
||||||
import 'just/sandbox.just'
|
import 'just/server.just'
|
||||||
import 'just/test.just'
|
import 'just/test.just'
|
||||||
import 'just/check.just'
|
import 'just/check.just'
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ registry (`guillaumearm/cc-libs`), and installs the full TrapOS:
|
|||||||
wget run https://os.trapcloud.fr/install
|
wget run https://os.trapcloud.fr/install
|
||||||
```
|
```
|
||||||
|
|
||||||
When it finishes, run `sandbox --help` to get started, or `trapos-upgrade` to
|
When it finishes, run `cloud --help` to get started, or `trapos-upgrade` to
|
||||||
update later.
|
update later.
|
||||||
|
|
||||||
Install only the package manager (no full OS) with `--cpm-only`, then
|
Install only the package manager (no full OS) with `--cpm-only`, then
|
||||||
@ -34,7 +34,7 @@ TrapOS is split into packages, each described by a `packages/<name>/ccpm.json`:
|
|||||||
- `trapos-net`: routed modem networking (`net`, `router`, `ping`, `ping-server`).
|
- `trapos-net`: routed modem networking (`net`, `router`, `ping`, `ping-server`).
|
||||||
- `trapos-ui`: the terminal UI toolkit (`libtui`, `tuidemo`).
|
- `trapos-ui`: the terminal UI toolkit (`libtui`, `tuidemo`).
|
||||||
- `trapos-ai`: the AI client for `opencode serve`.
|
- `trapos-ai`: the AI client for `opencode serve`.
|
||||||
- `trapos`: full TrapOS meta-package (`trapos-boot`, `trapos-net`, `trapos-ui`, `trapos-test`, `trapos-ai`, `trapos-sandbox`).
|
- `trapos`: full TrapOS meta-package (`trapos-boot`, `trapos-net`, `trapos-ui`, `trapos-test`, `trapos-ai`, `trapos-cloud`).
|
||||||
|
|
||||||
The `trapos` meta-package is the user-facing full install. Package descriptors list
|
The `trapos` meta-package is the user-facing full install. Package descriptors list
|
||||||
files and autostart servers; installed state is tracked under `/trapos`.
|
files and autostart servers; installed state is tracked under `/trapos`.
|
||||||
|
|||||||
@ -17,7 +17,7 @@ Future ADRs can reuse the shape of the existing files when it is useful.
|
|||||||
- [`adr-0016-js-tool-verification.md`](adr-0016-js-tool-verification.md) — JavaScript/TypeScript tool build, check, test, CI, and future integration-test split.
|
- [`adr-0016-js-tool-verification.md`](adr-0016-js-tool-verification.md) — JavaScript/TypeScript tool build, check, test, CI, and future integration-test split.
|
||||||
- [`adr-0017-mcp-remote-lua-execution.md`](adr-0017-mcp-remote-lua-execution.md) — MCP `exec-lua` remote execution for linked ComputerCraft computers.
|
- [`adr-0017-mcp-remote-lua-execution.md`](adr-0017-mcp-remote-lua-execution.md) — MCP `exec-lua` remote execution for linked ComputerCraft computers.
|
||||||
- [`adr-0018-justfile-organization.md`](adr-0018-justfile-organization.md) — Root Justfile split with imports while preserving flat recipe names.
|
- [`adr-0018-justfile-organization.md`](adr-0018-justfile-organization.md) — Root Justfile split with imports while preserving flat recipe names.
|
||||||
- [`adr-0019-mcp-over-sandbox-gateway.md`](adr-0019-mcp-over-sandbox-gateway.md) — MCP tools migrated onto the trap-sandbox gateway; reactive `libsandbox.serve`; `traposId` addressing.
|
- [`adr-0019-mcp-over-cloud-gateway.md`](adr-0019-mcp-over-cloud-gateway.md) — MCP tools migrated onto the trapos-server gateway; reactive `libcloud.serve`; `traposId` addressing.
|
||||||
- [`adr-0020-boot-lifecycle.md`](adr-0020-boot-lifecycle.md) — TrapOS boot orchestrator, session-end policy, installer reboot path, and stale startup-file self-healing.
|
- [`adr-0020-boot-lifecycle.md`](adr-0020-boot-lifecycle.md) — TrapOS boot orchestrator, session-end policy, installer reboot path, and stale startup-file self-healing.
|
||||||
|
|
||||||
Gaps in numbering (0003, 0004, 0006, 0008, 0009, 0012, 0013, 0014, 0015) are records that were either superseded by later decisions or consolidated into the surviving ADRs above.
|
Gaps in numbering (0003, 0004, 0006, 0008, 0009, 0012, 0013, 0014, 0015) are records that were either superseded by later decisions or consolidated into the surviving ADRs above.
|
||||||
|
|||||||
@ -53,7 +53,7 @@ In library, server, and program code that may run inside an eventloop (directly
|
|||||||
`os.sleep` remains acceptable only in narrow cases:
|
`os.sleep` remains acceptable only in narrow cases:
|
||||||
|
|
||||||
1. One-shot programs that are purely sequential and register no event handlers — a `programs/foo.lua` that prints, sleeps, prints again, and exits.
|
1. One-shot programs that are purely sequential and register no event handlers — a `programs/foo.lua` that prints, sleeps, prints again, and exits.
|
||||||
2. `parallel.waitForAny(task, function() sleep(t); end)` used as an isolated guard to bound an inner task (e.g. the AI Lua-exec sandbox in `apis/libai.lua` and the `parallel.waitForAny`-driven per-case timer in `apis/libtest.lua`). The guard sleep is private to its own coroutine group; it does not block anything external.
|
2. `parallel.waitForAny(task, function() sleep(t); end)` used as an isolated guard to bound an inner task (e.g. the AI Lua-exec isolation in `apis/libai.lua` and the `parallel.waitForAny`-driven per-case timer in `apis/libtest.lua`). The guard sleep is private to its own coroutine group; it does not block anything external.
|
||||||
3. Tests that are themselves driven by `libtest`'s per-case timeout (see [ADR-0007](adr-0007-test-framework.md)).
|
3. Tests that are themselves driven by `libtest`'s per-case timeout (see [ADR-0007](adr-0007-test-framework.md)).
|
||||||
|
|
||||||
New code must not expose a `sleep` injection point on its constructor. If a wait is needed, accept an `eventloop` factory and schedule through `setTimeout`. Tests substitute a synchronous deterministic eventloop fake the same way they substitute `http` or `settings`.
|
New code must not expose a `sleep` injection point on its constructor. If a wait is needed, accept an `eventloop` factory and schedule through `setTimeout`. Tests substitute a synchronous deterministic eventloop fake the same way they substitute `http` or `settings`.
|
||||||
|
|||||||
@ -10,7 +10,7 @@ Accepted
|
|||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
This repository targets CC:Tweaked on Minecraft 1.21. The Lua we ship runs inside the ComputerCraft sandbox: it depends on `os.pullEventRaw`, `peripheral`, `rednet`, `textutils.serializeJSON`, modem channels, and CC-specific globals. Standard Lua cannot execute this code as-is, so a normal local test harness was never a serious option.
|
This repository targets CC:Tweaked on Minecraft 1.21. The Lua we ship runs inside the ComputerCraft runtime: it depends on `os.pullEventRaw`, `peripheral`, `rednet`, `textutils.serializeJSON`, modem channels, and CC-specific globals. Standard Lua cannot execute this code as-is, so a normal local test harness was never a serious option.
|
||||||
|
|
||||||
Contributors have been running the code in two places:
|
Contributors have been running the code in two places:
|
||||||
|
|
||||||
|
|||||||
@ -67,7 +67,7 @@ Two independent timeout layers, ordered so the finer one fires first.
|
|||||||
- The `__TRAPOS_TEST_OK__` marker remains the single shell-level success contract and is owned by `runtest`.
|
- The `__TRAPOS_TEST_OK__` marker remains the single shell-level success contract and is owned by `runtest`.
|
||||||
- A hung case fails in ~3s with a per-case message instead of taking down the whole process anonymously.
|
- A hung case fails in ~3s with a per-case message instead of taking down the whole process anonymously.
|
||||||
- `just test-timeout` is a self-asserting harness regression guard wired into `just ci`. It chains `test-timeout-lua` (Layer 1: libtest cancels the slow case immediately with `--timeout 0`, before the shell backstop) and `test-timeout-shell` (Layer 2: the `TRAPOS_TEST_TIMEOUT_WATCHDOG_SECONDS` watchdog, default `1`, kills the slow case with libtest bypassed). Both drive a single `tests/harness/slow-case.lua` fixture; the tight timeouts — not the fixture's sleep length — decide which layer fires, so the harness itself is covered against regressions on every `ci`.
|
- `just test-timeout` is a self-asserting harness regression guard wired into `just ci`. It chains `test-timeout-lua` (Layer 1: libtest cancels the slow case immediately with `--timeout 0`, before the shell backstop) and `test-timeout-shell` (Layer 2: the `TRAPOS_TEST_TIMEOUT_WATCHDOG_SECONDS` watchdog, default `1`, kills the slow case with libtest bypassed). Both drive a single `tests/harness/slow-case.lua` fixture; the tight timeouts — not the fixture's sleep length — decide which layer fires, so the harness itself is covered against regressions on every `ci`.
|
||||||
- `libtest` stays a normal ComputerCraft program: `parallel` and `sleep` are sandbox globals, so the timeout works in CraftOS-PC and in-game alike.
|
- `libtest` stays a normal ComputerCraft program: `parallel` and `sleep` are runtime globals, so the timeout works in CraftOS-PC and in-game alike.
|
||||||
- Host-specific concerns remain outside production Lua code.
|
- Host-specific concerns remain outside production Lua code.
|
||||||
|
|
||||||
## Future Work
|
## Future Work
|
||||||
|
|||||||
@ -10,7 +10,7 @@ Accepted
|
|||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
The repository contains `tools/trap-sandbox`, a TypeScript Node.js tool that sits next to the ComputerCraft Lua code. It has a different build and test lifecycle than TrapOS packages, but it still participates in the same local developer workflow and Git hooks described by [ADR-0011](adr-0011-repo-conventions.md). (This convention was first established for `tools/mcp-bridge`, which has since been removed; `trap-sandbox` is now the JS tool it governs.)
|
The repository contains `tools/trapos-server`, a TypeScript Node.js tool that sits next to the ComputerCraft Lua code. It has a different build and test lifecycle than TrapOS packages, but it still participates in the same local developer workflow and Git hooks described by [ADR-0011](adr-0011-repo-conventions.md). (This convention was first established for `tools/mcp-bridge`, which has since been removed; `trapos-server` is now the JS tool it governs.)
|
||||||
|
|
||||||
The tool also needs end-to-end coverage that spans both runtimes: a host Node process and a headless CraftOS-PC computer. That is broader and slower than normal unit tests, and it needs the same kind of timeout discipline as the Lua harness in [ADR-0007](adr-0007-test-framework.md).
|
The tool also needs end-to-end coverage that spans both runtimes: a host Node process and a headless CraftOS-PC computer. That is broader and slower than normal unit tests, and it needs the same kind of timeout discipline as the Lua harness in [ADR-0007](adr-0007-test-framework.md).
|
||||||
|
|
||||||
@ -27,7 +27,7 @@ Keep the Node package scripts simple and one-purpose:
|
|||||||
|
|
||||||
Expose matching repository recipes for the Node lifecycle:
|
Expose matching repository recipes for the Node lifecycle:
|
||||||
|
|
||||||
- `just build` delegates to `npm run build` for the sandbox tool.
|
- `just build` delegates to `npm run build` for the trapos-server tool.
|
||||||
- `just test` depends on `just build`, runs `npm run test`, then runs the existing CraftOS-PC test suite.
|
- `just test` depends on `just build`, runs `npm run test`, then runs the existing CraftOS-PC test suite.
|
||||||
- `just check` includes `npm run check` alongside Lua and Markdown checks.
|
- `just check` includes `npm run check` alongside Lua and Markdown checks.
|
||||||
- `just ci` uses `npm run test:ci`, then runs CraftOS-PC tests and the broader integration/harness target.
|
- `just ci` uses `npm run test:ci`, then runs CraftOS-PC tests and the broader integration/harness target.
|
||||||
@ -39,7 +39,7 @@ Expose matching repository recipes for the Node lifecycle:
|
|||||||
- TypeScript errors are no longer caught by `npm run test`; they are caught by `npm run build`, which stays wired into `npm run test:ci`, `just build`, `just test`, and `just ci`.
|
- TypeScript errors are no longer caught by `npm run test`; they are caught by `npm run build`, which stays wired into `npm run test:ci`, `just build`, `just test`, and `just ci`.
|
||||||
- `just ci` avoids duplicating Node unit tests by calling `npm run test:ci` directly and then invoking only the CraftOS-side test body.
|
- `just ci` avoids duplicating Node unit tests by calling `npm run test:ci` directly and then invoking only the CraftOS-side test body.
|
||||||
- ESLint failures are part of `just check`, so they are covered by the same pre-commit and pre-push hooks as Lua and Markdown checks.
|
- ESLint failures are part of `just check`, so they are covered by the same pre-commit and pre-push hooks as Lua and Markdown checks.
|
||||||
- Integration tests live in `tools/trap-sandbox/test-integration/`, with Lua client fixtures under `test-integration/lua/`. Slow end-to-end behavior stays out of the fast unit-test path.
|
- Integration tests live in `tools/trapos-server/test-integration/`, with Lua client fixtures under `test-integration/lua/`. Slow end-to-end behavior stays out of the fast unit-test path.
|
||||||
|
|
||||||
## Future Work
|
## Future Work
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted. Transport superseded by [ADR-0019](adr-0019-mcp-over-sandbox-gateway.md) — the
|
Accepted. Transport superseded by [ADR-0019](adr-0019-mcp-over-cloud-gateway.md) — the
|
||||||
MCP tools now ride the sandbox gateway and address computers by `traposId`, and
|
MCP tools now ride the cloud gateway and address computers by `traposId`, and
|
||||||
`tools/mcp-bridge` (referenced below in the past tense) has since been removed. The
|
`tools/mcp-bridge` (referenced below in the past tense) has since been removed. The
|
||||||
execution semantics and danger model below still apply.
|
execution semantics and danger model below still apply.
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# ADR 0019: MCP Over The Sandbox Gateway
|
# ADR 0019: MCP Over The Cloud Gateway
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@ -14,37 +14,37 @@ The MCP tools (`probe-computers`, `exec-lua`, `write-file`) shipped in `tools/mc
|
|||||||
([ADR-0017](adr-0017-mcp-remote-lua-execution.md)) over a dedicated CC-link WebSocket and
|
([ADR-0017](adr-0017-mcp-remote-lua-execution.md)) over a dedicated CC-link WebSocket and
|
||||||
the `mcp-computer` / `mcp-computer-server` Lua programs.
|
the `mcp-computer` / `mcp-computer-server` Lua programs.
|
||||||
|
|
||||||
Meanwhile `tools/trap-sandbox` introduced a second, richer host↔computer connection: a
|
Meanwhile `tools/trapos-server` introduced a second, richer host↔computer connection: a
|
||||||
single persistent gateway WebSocket per computer (`servers/sandbox` + `apis/libsandbox`),
|
single persistent gateway WebSocket per computer (`servers/cloud` + `apis/libcloud`),
|
||||||
a hello handshake with a shared secret, a coded error model, and a bidirectional
|
a hello handshake with a shared secret, a coded error model, and a bidirectional
|
||||||
request/reply (`GatewayRegistry.request` → `gateway_request` → `computer_response`).
|
request/reply (`GatewayRegistry.request` → `server_request` → `computer_response`).
|
||||||
|
|
||||||
Running both stacks meant two sockets, two protocols, and a second autostart daemon per
|
Running both stacks meant two sockets, two protocols, and a second autostart daemon per
|
||||||
computer. The sandbox gateway already had everything MCP needs.
|
computer. The cloud gateway already had everything MCP needs.
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
Move the MCP features onto the sandbox gateway and retire the dedicated MCP transport.
|
Move the MCP features onto the cloud gateway and retire the dedicated MCP transport.
|
||||||
|
|
||||||
- **Host (`tools/trap-sandbox`):** a second Fastify listener (`src/modules/mcp`) bound to
|
- **Host (`tools/trapos-server`):** a second Fastify listener (`src/modules/mcp`) bound to
|
||||||
`127.0.0.1` (default `MCP_PORT=4445`) speaks the same custom JSON-RPC 2.0 as the old
|
`127.0.0.1` (default `MCP_PORT=4445`) speaks the same custom JSON-RPC 2.0 as the old
|
||||||
bridge. Each tool call is a `GatewayRegistry.request(accountId, traposId, type, payload)`
|
bridge. Each tool call is a `GatewayRegistry.request(accountId, traposId, type, payload)`
|
||||||
reusing the existing gateway socket. Computers are addressed by **`traposId`**
|
reusing the existing gateway socket. Computers are addressed by **`traposId`**
|
||||||
(`"<id>:<label>"`, the registry's key) rather than a numeric `computerId`;
|
(`"<id>:<label>"`, the registry's key) rather than a numeric `computerId`;
|
||||||
`probe-computers` lists the connected traposIds.
|
`probe-computers` lists the connected traposIds.
|
||||||
- **Computer side:** `apis/libsandbox` gains a reactive serving path symmetric to its
|
- **Computer side:** `apis/libcloud` gains a reactive serving path symmetric to its
|
||||||
request path. In-OS servers call `sandbox.serve(type, handler, { eventloop })`, which
|
request path. In-OS servers call `cloud.serve(type, handler, { eventloop })`, which
|
||||||
advertises the type to the daemon (`trapos_sandbox_serve`) and reacts to inbound
|
advertises the type to the daemon (`trapos_cloud_serve`) and reacts to inbound
|
||||||
`trapos_sandbox_request` events, replying via `trapos_sandbox_respond`
|
`trapos_cloud_request` events, replying via `trapos_cloud_respond`
|
||||||
(`computer_response`). Unadvertised non-`ping` types still get an immediate
|
(`computer_response`). Unadvertised non-`ping` types still get an immediate
|
||||||
`unknown_type` reply (the daemon tracks advertised types, so there is no timer race).
|
`unknown_type` reply (the daemon tracks advertised types, so there is no timer race).
|
||||||
`ping` stays built-in, so `probe-computers` works against any computer running just the
|
`ping` stays built-in, so `probe-computers` works against any computer running just the
|
||||||
sandbox daemon.
|
cloud daemon.
|
||||||
- **`servers/mcp-computer-server`** is rewritten to be reactive: it owns no socket, serves
|
- **`servers/mcp-computer-server`** is rewritten to be reactive: it owns no socket, serves
|
||||||
`exec-lua` / `write-file` over the gateway via `sandbox.serve`, and is gated on
|
`exec-lua` / `write-file` over the gateway via `cloud.serve`, and is gated on
|
||||||
`sandbox.url` being set. `apis/libmcpcomputer` is slimmed to the protocol-agnostic
|
`cloud.url` being set. `apis/libmcpcomputer` is slimmed to the protocol-agnostic
|
||||||
execution primitives (`executeLua`, `writeFile`); both move into the `trapos-sandbox`
|
execution primitives (`executeLua`, `writeFile`); both move into the `trapos-cloud`
|
||||||
package (autostart `servers/mcp-computer-server` after `servers/sandbox`). The standalone
|
package (autostart `servers/mcp-computer-server` after `servers/cloud`). The standalone
|
||||||
`programs/mcp-computer` client is deleted and the MCP entries are removed from
|
`programs/mcp-computer` client is deleted and the MCP entries are removed from
|
||||||
`trapos-sandbox-legacy`.
|
`trapos-sandbox-legacy`.
|
||||||
- A code error or a failed write is reported as a *successful* round-trip carrying an
|
- A code error or a failed write is reported as a *successful* round-trip carrying an
|
||||||
@ -60,10 +60,10 @@ needed again it will be re-implemented from scratch. The old code remains in git
|
|||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- One connection and one protocol per computer; no `mcp-computer.ws-url` setting.
|
- One connection and one protocol per computer; no `mcp-computer.ws-url` setting.
|
||||||
- The exec trust boundary is now the authenticated gateway connection (`sandbox.url` +
|
- The exec trust boundary is now the authenticated gateway connection (`cloud.url` +
|
||||||
`SANDBOX_PASSWORD`) plus the local-only MCP bind, instead of "trust the bridge you run
|
`TRAPOS_SERVER_PASSWORD`) plus the local-only MCP bind, instead of "trust the bridge you run
|
||||||
`mcp-computer` against."
|
`mcp-computer` against."
|
||||||
- `apis/libsandbox.serve` / `respond` are reusable for any future computer-side service
|
- `apis/libcloud.serve` / `respond` are reusable for any future computer-side service
|
||||||
invoked from the host, not just MCP.
|
invoked from the host, not just MCP.
|
||||||
- Tests cover host-side MCP routing (unit) and the real CraftOS gateway round-trip
|
- Tests cover host-side MCP routing (unit) and the real CraftOS gateway round-trip
|
||||||
(integration), per [ADR-0016](adr-0016-js-tool-verification.md).
|
(integration), per [ADR-0016](adr-0016-js-tool-verification.md).
|
||||||
@ -62,13 +62,13 @@ ai "say hello from TrapOS"
|
|||||||
|
|
||||||
Expected ping: `pong`.
|
Expected ping: `pong`.
|
||||||
|
|
||||||
## 4. Start The Sandbox Gateway On Host
|
## 4. Start The Cloud Gateway On Host
|
||||||
|
|
||||||
The MCP tools now ride on the `trap-sandbox` gateway (the same WS connection used for
|
The MCP tools now ride on the `trapos-server` gateway (the same WS connection used for
|
||||||
sandbox health). From this repository on your real machine:
|
cloud health). From this repository on your real machine:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
just sandbox-serve
|
just serve
|
||||||
```
|
```
|
||||||
|
|
||||||
This serves two listeners:
|
This serves two listeners:
|
||||||
@ -80,23 +80,23 @@ MCP endpoint (OpenCode connects here): http://127.0.0.1:4445
|
|||||||
|
|
||||||
The MCP endpoint stays bound to `127.0.0.1` on purpose: `exec-lua` / `write-file` are
|
The MCP endpoint stays bound to `127.0.0.1` on purpose: `exec-lua` / `write-file` are
|
||||||
as powerful as the linked computer, so only local processes (your OpenCode) reach them.
|
as powerful as the linked computer, so only local processes (your OpenCode) reach them.
|
||||||
Set `SANDBOX_PASSWORD` in `.env` to require a shared secret on the gateway connection.
|
Set `TRAPOS_SERVER_PASSWORD` in `.env` to require a shared secret on the gateway connection.
|
||||||
|
|
||||||
## 5. Link The Computer To The Gateway
|
## 5. Link The Computer To The Gateway
|
||||||
|
|
||||||
On the ComputerCraft computer, point it at the gateway and reboot:
|
On the ComputerCraft computer, point it at the gateway and reboot:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
set sandbox.url ws://<public-host>:4444
|
set cloud.url ws://<public-host>:4444
|
||||||
set sandbox.password secret # only if SANDBOX_PASSWORD is set on the host
|
set cloud.password secret # only if TRAPOS_SERVER_PASSWORD is set on the host
|
||||||
reboot
|
reboot
|
||||||
```
|
```
|
||||||
|
|
||||||
The `servers/sandbox` daemon connects automatically at boot, and `servers/mcp-computer-server`
|
The `servers/cloud` daemon connects automatically at boot, and `servers/mcp-computer-server`
|
||||||
starts serving `exec-lua` / `write-file` over that one connection. Check the link with:
|
starts serving `exec-lua` / `write-file` over that one connection. Check the link with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sandbox health
|
cloud health
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6. Connect OpenCode To MCP
|
## 6. Connect OpenCode To MCP
|
||||||
@ -127,7 +127,7 @@ term.write('visible on screen')
|
|||||||
|
|
||||||
`exec-lua` is powerful and unsafe by design: it can do anything the linked computer can
|
`exec-lua` is powerful and unsafe by design: it can do anything the linked computer can
|
||||||
do, including file, peripheral, turtle, and reboot operations. The trust boundary is the
|
do, including file, peripheral, turtle, and reboot operations. The trust boundary is the
|
||||||
authenticated gateway connection (`sandbox.url` + `SANDBOX_PASSWORD`) plus the local-only
|
authenticated gateway connection (`cloud.url` + `TRAPOS_SERVER_PASSWORD`) plus the local-only
|
||||||
MCP bind — only connect a computer to a gateway you control.
|
MCP bind — only connect a computer to a gateway you control.
|
||||||
|
|
||||||
`write-file` writes content to a path on the target computer and overwrites any existing
|
`write-file` writes content to a path on the target computer and overwrites any existing
|
||||||
@ -138,9 +138,9 @@ fail instead of being created automatically.
|
|||||||
|
|
||||||
- `ai` says missing `opencc.server_url`: run the `set opencc.server_url ...` command again.
|
- `ai` says missing `opencc.server_url`: run the `set opencc.server_url ...` command again.
|
||||||
- `ai` cannot reach server: check `opencode serve`, public host, port `4242`, and ComputerCraft HTTP rules.
|
- `ai` cannot reach server: check `opencode serve`, public host, port `4242`, and ComputerCraft HTTP rules.
|
||||||
- `sandbox health` fails: enable ComputerCraft HTTP/WebSocket support and check `sandbox.url` (port `4444`).
|
- `cloud health` fails: enable ComputerCraft HTTP/WebSocket support and check `cloud.url` (port `4444`).
|
||||||
- MCP sees no computers: confirm `sandbox.url` is set and the computer was rebooted so `servers/sandbox` is running.
|
- MCP sees no computers: confirm `cloud.url` is set and the computer was rebooted so `servers/cloud` is running.
|
||||||
- `exec-lua` / `write-file` return `unknown_type`: that computer is not running `servers/mcp-computer-server` (set `sandbox.url` and reboot).
|
- `exec-lua` / `write-file` return `unknown_type`: that computer is not running `servers/mcp-computer-server` (set `cloud.url` and reboot).
|
||||||
- `exec-lua` or `write-file` is missing after updating: restart OpenCode so it reloads the MCP tool list.
|
- `exec-lua` or `write-file` is missing after updating: restart OpenCode so it reloads the MCP tool list.
|
||||||
|
|
||||||
More detail: [`opencode_server_guide.md`](opencode_server_guide.md), [`public-ports.md`](public-ports.md).
|
More detail: [`opencode_server_guide.md`](opencode_server_guide.md), [`public-ports.md`](public-ports.md).
|
||||||
|
|||||||
@ -7,7 +7,7 @@ TrapOS public production services use the `4242-4244` TCP range. Keep local deve
|
|||||||
| `4242` | opencode server HTTP API | Production/public port for `opencode serve`. Local/dev examples may still use opencode's default `4096`. |
|
| `4242` | opencode server HTTP API | Production/public port for `opencode serve`. Local/dev examples may still use opencode's default `4096`. |
|
||||||
| `4243` | Reserved | Formerly the ComputerCraft bridge WebSocket (removed with `tools/mcp-bridge`). Free for a future service. |
|
| `4243` | Reserved | Formerly the ComputerCraft bridge WebSocket (removed with `tools/mcp-bridge`). Free for a future service. |
|
||||||
| `4244` | Reserved | Free public production port reserved for a future service. Do not assign it casually in local tooling. |
|
| `4244` | Reserved | Free public production port reserved for a future service. Do not assign it casually in local tooling. |
|
||||||
| `4444` | trap-sandbox gateway | Fastify service (`tools/trap-sandbox`) exposing the `trapos-cloud-gateway` WebSocket at `/gateway`. Sits **outside** the `4242-4244` range on purpose (4244 stays reserved). Default `SANDBOX_PORT`; serve with `just sandbox-serve`. |
|
| `4444` | trapos-server gateway | Fastify service (`tools/trapos-server`) exposing the `trapos-cloud-gateway` WebSocket at `/gateway`. Sits **outside** the `4242-4244` range on purpose (4244 stays reserved). Default `TRAPOS_SERVER_PORT`; serve with `just serve`. |
|
||||||
|
|
||||||
## Local Vs Production
|
## Local Vs Production
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
# Install local development tooling.
|
# Install local development tooling.
|
||||||
install: install-git-hooks check-install npm-install generate-env
|
install: install-git-hooks check-install npm-install generate-env
|
||||||
|
|
||||||
# Remove build caches (tsc / eslint) for the trap-sandbox tool.
|
# Remove build caches (tsc / eslint) for the trapos-server tool.
|
||||||
clean:
|
clean:
|
||||||
npm run clean --prefix tools/trap-sandbox
|
npm run clean --prefix tools/trapos-server
|
||||||
|
|
||||||
# Remove generated local environment files (e.g. .env with tokens).
|
# Remove generated local environment files (e.g. .env with tokens).
|
||||||
clean-env:
|
clean-env:
|
||||||
@ -32,7 +32,7 @@ generate-env:
|
|||||||
password="$(openssl rand -hex 32)"
|
password="$(openssl rand -hex 32)"
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
case "$line" in
|
case "$line" in
|
||||||
OPENCODE_SERVER_PASSWORD=*|SANDBOX_PASSWORD=*)
|
OPENCODE_SERVER_PASSWORD=*|TRAPOS_SERVER_PASSWORD=*)
|
||||||
printf '%s\n' "${line%%=*}=$password"
|
printf '%s\n' "${line%%=*}=$password"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@ -1,33 +1,33 @@
|
|||||||
# Install Node dependencies for repository tools.
|
# Install Node dependencies for repository tools.
|
||||||
npm-install: npm-install-trap-sandbox
|
npm-install: npm-install-trapos-server
|
||||||
|
|
||||||
# Install Node dependencies for trap-sandbox.
|
# Install Node dependencies for trapos-server.
|
||||||
npm-install-trap-sandbox:
|
npm-install-trapos-server:
|
||||||
npm install --prefix tools/trap-sandbox/
|
npm install --prefix tools/trapos-server/
|
||||||
|
|
||||||
# Build Node-based repository tools.
|
# Build Node-based repository tools.
|
||||||
npm-build: npm-build-trap-sandbox
|
npm-build: npm-build-trapos-server
|
||||||
|
|
||||||
npm-build-trap-sandbox:
|
npm-build-trapos-server:
|
||||||
npm run build --prefix tools/trap-sandbox/
|
npm run build --prefix tools/trapos-server/
|
||||||
|
|
||||||
# Check Node-based repository tools.
|
# Check Node-based repository tools.
|
||||||
npm-check: npm-check-trap-sandbox
|
npm-check: npm-check-trapos-server
|
||||||
|
|
||||||
npm-check-trap-sandbox:
|
npm-check-trapos-server:
|
||||||
npm run check --prefix tools/trap-sandbox/
|
npm run check --prefix tools/trapos-server/
|
||||||
|
|
||||||
# Run Node-based tool tests.
|
# Run Node-based tool tests.
|
||||||
npm-test: npm-test-trap-sandbox
|
npm-test: npm-test-trapos-server
|
||||||
|
|
||||||
npm-test-trap-sandbox:
|
npm-test-trapos-server:
|
||||||
npm test --prefix tools/trap-sandbox/
|
npm test --prefix tools/trapos-server/
|
||||||
|
|
||||||
# Run Node-based tool integration tests.
|
# Run Node-based tool integration tests.
|
||||||
npm-test-integration: stage npm-test-integration-trap-sandbox
|
npm-test-integration: stage npm-test-integration-trapos-server
|
||||||
|
|
||||||
npm-test-integration-trap-sandbox:
|
npm-test-integration-trapos-server:
|
||||||
npm run test:integration --prefix tools/trap-sandbox/
|
npm run test:integration --prefix tools/trapos-server/
|
||||||
|
|
||||||
# Build generated artifacts.
|
# Build generated artifacts.
|
||||||
build: npm-build stage
|
build: npm-build stage
|
||||||
|
|||||||
@ -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
|
test *args: build npm-test
|
||||||
@just _craftos-test {{args}}
|
@just _craftos-test {{args}}
|
||||||
|
|
||||||
# Run end-to-end tests that span the trap-sandbox gateway / MCP endpoint and headless CraftOS-PC.
|
# Run end-to-end tests that span the trapos-server gateway / MCP endpoint and headless CraftOS-PC.
|
||||||
e2e: npm-test-integration
|
e2e: npm-test-integration
|
||||||
|
|
||||||
# Run integration and harness tests that are too broad for unit test targets.
|
# Run integration and harness tests that are too broad for unit test targets.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "TrapOS",
|
"name": "TrapOS",
|
||||||
"version": "0.11.6",
|
"version": "0.12.0",
|
||||||
"branch": "master",
|
"branch": "master",
|
||||||
"packages": [
|
"packages": [
|
||||||
"trapos"
|
"trapos"
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"packages": {
|
"packages": {
|
||||||
"trapos-core": "0.9.2",
|
"trapos-core": "0.9.3",
|
||||||
"trapos-test": "0.2.3",
|
"trapos-test": "0.2.3",
|
||||||
"trapos-boot": "0.4.2",
|
"trapos-boot": "0.4.2",
|
||||||
"trapos-net": "0.3.2",
|
"trapos-net": "0.3.2",
|
||||||
"trapos-ui": "0.2.4",
|
"trapos-ui": "0.2.4",
|
||||||
"trapos-ai": "0.8.0",
|
"trapos-ai": "0.8.0",
|
||||||
"trapos-sandbox": "0.3.5",
|
"trapos-cloud": "0.4.0",
|
||||||
"trapos-sandbox-legacy": "0.3.2",
|
"trapos-sandbox-legacy": "0.3.2",
|
||||||
"trapos": "0.11.6"
|
"trapos": "0.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
-- trapos-sandbox Lua client.
|
-- trapos-cloud Lua client.
|
||||||
--
|
--
|
||||||
-- Implements the Lua side of the trap-sandbox WS gateway protocol (see
|
-- Implements the Lua side of the trapos-server WS gateway protocol (see
|
||||||
-- .plans/trap-sandbox/trap-sandbox-gateway-spec.md). A boot daemon owns one
|
-- .plans/trapos-server/trapos-server-gateway-spec.md). A boot daemon owns one
|
||||||
-- persistent websocket connection (startSession) and bridges WS frames to in-OS
|
-- persistent websocket connection (startSession) and bridges WS frames to in-OS
|
||||||
-- `trapos_sandbox_*` events; programs talk to the daemon through those events via
|
-- `trapos_cloud_*` events; programs talk to the daemon through those events via
|
||||||
-- request(). Both run as separate coroutines under the same Lua state, so they
|
-- request(). Both run as separate coroutines under the same Lua state, so they
|
||||||
-- share the single os event queue.
|
-- share the single os event queue.
|
||||||
|
|
||||||
-- Server-side application close code emitted when a newer registration displaces an
|
-- Server-side application close code emitted when a newer registration displaces an
|
||||||
-- older one (see trap-sandbox registry). Treated as terminal: the loser yields rather
|
-- older one (see trapos-server registry). Treated as terminal: the loser yields rather
|
||||||
-- than reconnecting, so two daemons sharing a traposId collapse to one (no eviction loop).
|
-- than reconnecting, so two daemons sharing a traposId collapse to one (no eviction loop).
|
||||||
local DISPLACED_CLOSE_CODE = 4000;
|
local DISPLACED_CLOSE_CODE = 4000;
|
||||||
|
|
||||||
@ -43,7 +43,7 @@ local function buildConnectUrl(base)
|
|||||||
return trimmed .. '/gateway';
|
return trimmed .. '/gateway';
|
||||||
end
|
end
|
||||||
|
|
||||||
local function createSandbox()
|
local function createCloud()
|
||||||
local api = {};
|
local api = {};
|
||||||
|
|
||||||
api.uuid = makeUuid;
|
api.uuid = makeUuid;
|
||||||
@ -88,7 +88,7 @@ local function createSandbox()
|
|||||||
|
|
||||||
function api.buildGatewayRequest(traposReceiverId, messageId, msgType, payload)
|
function api.buildGatewayRequest(traposReceiverId, messageId, msgType, payload)
|
||||||
local frame = {
|
local frame = {
|
||||||
event = 'gateway_request',
|
event = 'server_request',
|
||||||
traposReceiverId = traposReceiverId,
|
traposReceiverId = traposReceiverId,
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
type = msgType,
|
type = msgType,
|
||||||
@ -101,7 +101,7 @@ local function createSandbox()
|
|||||||
|
|
||||||
function api.buildGatewayResponse(traposReceiverId, messageId, msgType, ok, payload, err)
|
function api.buildGatewayResponse(traposReceiverId, messageId, msgType, ok, payload, err)
|
||||||
local frame = {
|
local frame = {
|
||||||
event = 'gateway_response',
|
event = 'server_response',
|
||||||
traposReceiverId = traposReceiverId,
|
traposReceiverId = traposReceiverId,
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
type = msgType,
|
type = msgType,
|
||||||
@ -138,7 +138,7 @@ local function createSandbox()
|
|||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
if frame.event == 'gateway_request' then
|
if frame.event == 'server_request' then
|
||||||
-- Reverse direction. `ping` stays built-in so it answers without any handler.
|
-- Reverse direction. `ping` stays built-in so it answers without any handler.
|
||||||
-- Other types are delegated to in-OS servers (re-emitted via ctx.queueRequest)
|
-- Other types are delegated to in-OS servers (re-emitted via ctx.queueRequest)
|
||||||
-- when a handler has advertised the type (ctx.isServed); an unadvertised type
|
-- when a handler has advertised the type (ctx.isServed); an unadvertised type
|
||||||
@ -166,14 +166,14 @@ local function createSandbox()
|
|||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
if frame.event == 'gateway_response' then
|
if frame.event == 'server_response' then
|
||||||
if frame.type == 'hello' then
|
if frame.type == 'hello' then
|
||||||
if ctx.onHello then
|
if ctx.onHello then
|
||||||
ctx.onHello(frame.ok == true, coercePayload(frame.payload), frame.error);
|
ctx.onHello(frame.ok == true, coercePayload(frame.payload), frame.error);
|
||||||
end
|
end
|
||||||
elseif ctx.queueEvent then
|
elseif ctx.queueEvent then
|
||||||
ctx.queueEvent(
|
ctx.queueEvent(
|
||||||
'trapos_sandbox_reply', frame.messageId, frame.ok == true,
|
'trapos_cloud_reply', frame.messageId, frame.ok == true,
|
||||||
coercePayload(frame.payload), frame.error
|
coercePayload(frame.payload), frame.error
|
||||||
);
|
);
|
||||||
end
|
end
|
||||||
@ -214,8 +214,8 @@ local function createSandbox()
|
|||||||
-- open / connection failed / connection closed) so a long outage logs once on entry
|
-- open / connection failed / connection closed) so a long outage logs once on entry
|
||||||
-- and once on recovery instead of flooding the file every reconnectDelay seconds.
|
-- and once on recovery instead of flooding the file every reconnectDelay seconds.
|
||||||
local reconnecting = false;
|
local reconnecting = false;
|
||||||
-- Gateway_request types advertised by in-OS servers (via trapos_sandbox_serve).
|
-- Gateway_request types advertised by in-OS servers (via trapos_cloud_serve).
|
||||||
-- Inbound frames of an advertised type are re-emitted as trapos_sandbox_request
|
-- Inbound frames of an advertised type are re-emitted as trapos_cloud_request
|
||||||
-- events; unadvertised (non-ping) types get an immediate unknown_type reply.
|
-- events; unadvertised (non-ping) types get an immediate unknown_type reply.
|
||||||
local servedTypes = {};
|
local servedTypes = {};
|
||||||
|
|
||||||
@ -268,7 +268,7 @@ local function createSandbox()
|
|||||||
else
|
else
|
||||||
log('info', 'connected', { traposId = traposId });
|
log('info', 'connected', { traposId = traposId });
|
||||||
end
|
end
|
||||||
queueEvent('trapos_sandbox_connected', traposId);
|
queueEvent('trapos_cloud_connected', traposId);
|
||||||
else
|
else
|
||||||
state = 'unauthorized';
|
state = 'unauthorized';
|
||||||
lastError = (type(err) == 'table' and err.code) or 'unauthorized';
|
lastError = (type(err) == 'table' and err.code) or 'unauthorized';
|
||||||
@ -304,7 +304,7 @@ local function createSandbox()
|
|||||||
onHello = onHello,
|
onHello = onHello,
|
||||||
isServed = function(msgType) return servedTypes[msgType] == true; end,
|
isServed = function(msgType) return servedTypes[msgType] == true; end,
|
||||||
queueRequest = function(messageId, msgType, payload)
|
queueRequest = function(messageId, msgType, payload)
|
||||||
queueEvent('trapos_sandbox_request', messageId, msgType, payload);
|
queueEvent('trapos_cloud_request', messageId, msgType, payload);
|
||||||
end,
|
end,
|
||||||
});
|
});
|
||||||
end);
|
end);
|
||||||
@ -344,23 +344,23 @@ local function createSandbox()
|
|||||||
end);
|
end);
|
||||||
|
|
||||||
-- Bridge program requests onto the wire. Only emit an immediate reply on
|
-- Bridge program requests onto the wire. Only emit an immediate reply on
|
||||||
-- failure; on success the real reply arrives later as a gateway_response.
|
-- failure; on success the real reply arrives later as a server_response.
|
||||||
el.register('trapos_sandbox_send', function(messageId, msgType, payload)
|
el.register('trapos_cloud_send', function(messageId, msgType, payload)
|
||||||
if state ~= 'ready' then
|
if state ~= 'ready' then
|
||||||
queueEvent('trapos_sandbox_reply', messageId, false, nil,
|
queueEvent('trapos_cloud_reply', messageId, false, nil,
|
||||||
{ code = 'not_connected', message = 'sandbox daemon not connected' });
|
{ code = 'not_connected', message = 'cloud daemon not connected' });
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
local sent = sendFrame(api.buildComputerRequest(traposId, messageId, msgType, payload));
|
local sent = sendFrame(api.buildComputerRequest(traposId, messageId, msgType, payload));
|
||||||
if not sent then
|
if not sent then
|
||||||
queueEvent('trapos_sandbox_reply', messageId, false, nil,
|
queueEvent('trapos_cloud_reply', messageId, false, nil,
|
||||||
{ code = 'not_connected', message = 'sandbox send failed' });
|
{ code = 'not_connected', message = 'cloud send failed' });
|
||||||
end
|
end
|
||||||
end);
|
end);
|
||||||
|
|
||||||
-- In-OS servers advertise the gateway_request types they handle. Recorded so
|
-- In-OS servers advertise the server_request types they handle. Recorded so
|
||||||
-- onMessage routes those (and only those) as trapos_sandbox_request events.
|
-- onMessage routes those (and only those) as trapos_cloud_request events.
|
||||||
el.register('trapos_sandbox_serve', function(msgType)
|
el.register('trapos_cloud_serve', function(msgType)
|
||||||
if type(msgType) == 'string' and msgType ~= '' then
|
if type(msgType) == 'string' and msgType ~= '' then
|
||||||
servedTypes[msgType] = true;
|
servedTypes[msgType] = true;
|
||||||
end
|
end
|
||||||
@ -368,7 +368,7 @@ local function createSandbox()
|
|||||||
|
|
||||||
-- Bridge a server reply back onto the wire as a computer_response. Dropped when
|
-- Bridge a server reply back onto the wire as a computer_response. Dropped when
|
||||||
-- not connected: the gateway-side request then times out, which is the reply.
|
-- not connected: the gateway-side request then times out, which is the reply.
|
||||||
el.register('trapos_sandbox_respond', function(messageId, msgType, ok, payload, err)
|
el.register('trapos_cloud_respond', function(messageId, msgType, ok, payload, err)
|
||||||
if state ~= 'ready' then
|
if state ~= 'ready' then
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
@ -394,7 +394,7 @@ local function createSandbox()
|
|||||||
local timeout = opts.timeout or 10;
|
local timeout = opts.timeout or 10;
|
||||||
local messageId = opts.messageId or makeUuid(opts.random);
|
local messageId = opts.messageId or makeUuid(opts.random);
|
||||||
|
|
||||||
queueEvent('trapos_sandbox_send', messageId, msgType, payload);
|
queueEvent('trapos_cloud_send', messageId, msgType, payload);
|
||||||
local timerId = startTimer(timeout);
|
local timerId = startTimer(timeout);
|
||||||
|
|
||||||
while true do
|
while true do
|
||||||
@ -402,7 +402,7 @@ local function createSandbox()
|
|||||||
local name = event[1];
|
local name = event[1];
|
||||||
if name == 'timer' and event[2] == timerId then
|
if name == 'timer' and event[2] == timerId then
|
||||||
return false, { code = 'timeout', message = 'request timed out' };
|
return false, { code = 'timeout', message = 'request timed out' };
|
||||||
elseif name == 'trapos_sandbox_reply' then
|
elseif name == 'trapos_cloud_reply' then
|
||||||
if event[2] == messageId then
|
if event[2] == messageId then
|
||||||
cancelTimer(timerId);
|
cancelTimer(timerId);
|
||||||
if event[3] then
|
if event[3] then
|
||||||
@ -411,23 +411,23 @@ local function createSandbox()
|
|||||||
return false, event[5] or { code = 'internal', message = 'request failed' };
|
return false, event[5] or { code = 'internal', message = 'request failed' };
|
||||||
end
|
end
|
||||||
-- Not ours: re-queue so a concurrent waiter can claim it.
|
-- Not ours: re-queue so a concurrent waiter can claim it.
|
||||||
queueEvent('trapos_sandbox_reply', event[2], event[3], event[4], event[5]);
|
queueEvent('trapos_cloud_reply', event[2], event[3], event[4], event[5]);
|
||||||
end
|
end
|
||||||
-- Ignore any other event and keep waiting.
|
-- Ignore any other event and keep waiting.
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Server-side reply: queue a computer_response for an inbound gateway_request that
|
-- Server-side reply: queue a computer_response for an inbound server_request that
|
||||||
-- the daemon delivered as a trapos_sandbox_request event. The daemon owns the wire.
|
-- the daemon delivered as a trapos_cloud_request event. The daemon owns the wire.
|
||||||
function api.respond(messageId, msgType, ok, payload, err, opts)
|
function api.respond(messageId, msgType, ok, payload, err, opts)
|
||||||
opts = opts or {};
|
opts = opts or {};
|
||||||
local queueEvent = opts.queueEvent or os.queueEvent;
|
local queueEvent = opts.queueEvent or os.queueEvent;
|
||||||
queueEvent('trapos_sandbox_respond', messageId, msgType, ok == true, payload, err);
|
queueEvent('trapos_cloud_respond', messageId, msgType, ok == true, payload, err);
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Register an in-OS handler for an inbound gateway_request type. Advertises the
|
-- Register an in-OS handler for an inbound server_request type. Advertises the
|
||||||
-- type to the daemon (so it routes instead of replying unknown_type) and reacts to
|
-- type to the daemon (so it routes instead of replying unknown_type) and reacts to
|
||||||
-- matching trapos_sandbox_request events. handler(payload, messageId) returns
|
-- matching trapos_cloud_request events. handler(payload, messageId) returns
|
||||||
-- (true, resultTable) on success or (false, { code, message }) on failure; a thrown
|
-- (true, resultTable) on success or (false, { code, message }) on failure; a thrown
|
||||||
-- error is reported as { code = 'internal', message = <error> }.
|
-- error is reported as { code = 'internal', message = <error> }.
|
||||||
function api.serve(msgType, handler, opts)
|
function api.serve(msgType, handler, opts)
|
||||||
@ -437,9 +437,9 @@ local function createSandbox()
|
|||||||
assert(type(handler) == 'function', 'serve requires a handler');
|
assert(type(handler) == 'function', 'serve requires a handler');
|
||||||
local queueEvent = opts.queueEvent or os.queueEvent;
|
local queueEvent = opts.queueEvent or os.queueEvent;
|
||||||
|
|
||||||
queueEvent('trapos_sandbox_serve', msgType);
|
queueEvent('trapos_cloud_serve', msgType);
|
||||||
|
|
||||||
el.register('trapos_sandbox_request', function(messageId, requestType, payload)
|
el.register('trapos_cloud_request', function(messageId, requestType, payload)
|
||||||
if requestType ~= msgType then
|
if requestType ~= msgType then
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
@ -463,4 +463,4 @@ local function createSandbox()
|
|||||||
return api;
|
return api;
|
||||||
end
|
end
|
||||||
|
|
||||||
return createSandbox;
|
return createCloud;
|
||||||
@ -1,9 +1,9 @@
|
|||||||
-- Computer-side MCP execution primitives.
|
-- Computer-side MCP execution primitives.
|
||||||
--
|
--
|
||||||
-- Protocol-agnostic building blocks for the MCP `exec-lua` / `write-file` features.
|
-- Protocol-agnostic building blocks for the MCP `exec-lua` / `write-file` features.
|
||||||
-- The transport lives elsewhere: servers/mcp-computer-server.lua reacts to sandbox
|
-- The transport lives elsewhere: servers/mcp-computer-server.lua reacts to cloud
|
||||||
-- gateway_request events and replies through the trap-sandbox gateway (see
|
-- server_request events and replies through the trapos-server gateway (see
|
||||||
-- apis/libsandbox.lua). This module only knows how to run code and write files.
|
-- apis/libcloud.lua). This module only knows how to run code and write files.
|
||||||
|
|
||||||
local function defaultOs()
|
local function defaultOs()
|
||||||
return os;
|
return os;
|
||||||
@ -90,7 +90,7 @@ end
|
|||||||
-- Builds a CraftOS-style require/package pair the same way /rom/programs/shell.lua does
|
-- Builds a CraftOS-style require/package pair the same way /rom/programs/shell.lua does
|
||||||
-- (dofile of cc.require, then .make(env, dir)). Returned factory caches the make function so
|
-- (dofile of cc.require, then .make(env, dir)). Returned factory caches the make function so
|
||||||
-- the rom module is only loaded once. Stays synchronous (require never yields), so it is safe
|
-- the rom module is only loaded once. Stays synchronous (require never yields), so it is safe
|
||||||
-- inside the synchronous sandbox handler -- unlike spawning a shell, which yields and deadlocks.
|
-- inside the synchronous cloud handler -- unlike spawning a shell, which yields and deadlocks.
|
||||||
local function defaultRequireFactory()
|
local function defaultRequireFactory()
|
||||||
local make;
|
local make;
|
||||||
local loaded = false;
|
local loaded = false;
|
||||||
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 createVersion = require('/apis/libversion');
|
||||||
|
|
||||||
local URL_SETTING = 'sandbox.url';
|
local URL_SETTING = 'cloud.url';
|
||||||
|
|
||||||
local rawArgs = table.pack(...);
|
local rawArgs = table.pack(...);
|
||||||
local command = rawArgs[1];
|
local command = rawArgs[1];
|
||||||
|
|
||||||
local function printUsage()
|
local function printUsage()
|
||||||
print('sandbox usage:');
|
print('cloud usage:');
|
||||||
print();
|
print();
|
||||||
print(' sandbox health');
|
print(' cloud health');
|
||||||
print(' sandbox --version');
|
print(' cloud --version');
|
||||||
print(' sandbox --help');
|
print(' cloud --help');
|
||||||
print();
|
print();
|
||||||
print('settings required:');
|
print('settings required:');
|
||||||
print(' sandbox.url (gateway origin, e.g. ws://host:4444)');
|
print(' cloud.url (gateway origin, e.g. ws://host:4444)');
|
||||||
print('settings optional:');
|
print('settings optional:');
|
||||||
print(' sandbox.password (shared secret for the hello handshake)');
|
print(' cloud.password (shared secret for the hello handshake)');
|
||||||
end
|
end
|
||||||
|
|
||||||
if command == '--version' or command == '-version' or command == 'version' or command == '-v' then
|
if command == '--version' or command == '-version' or command == 'version' or command == '-v' then
|
||||||
@ -32,19 +32,19 @@ end
|
|||||||
if command == 'health' then
|
if command == 'health' then
|
||||||
local url = settings.get(URL_SETTING);
|
local url = settings.get(URL_SETTING);
|
||||||
if type(url) ~= 'string' or url == '' then
|
if type(url) ~= 'string' or url == '' then
|
||||||
print('set sandbox.url first');
|
print('set cloud.url first');
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
local ok, result = createSandbox().request('probe_gateway');
|
local ok, result = createCloud().request('probe_server');
|
||||||
if not ok then
|
if not ok then
|
||||||
local code = (type(result) == 'table' and result.code) or 'internal';
|
local code = (type(result) == 'table' and result.code) or 'internal';
|
||||||
local message = (type(result) == 'table' and result.message) or '';
|
local message = (type(result) == 'table' and result.message) or '';
|
||||||
print('sandbox health failed: ' .. tostring(code) .. ' ' .. tostring(message));
|
print('cloud health failed: ' .. tostring(code) .. ' ' .. tostring(message));
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
print('sandboxOk: ' .. tostring(result.sandboxOk));
|
print('serverOk: ' .. tostring(result.serverOk));
|
||||||
print('opencodeOk: ' .. tostring(result.opencodeOk));
|
print('opencodeOk: ' .. tostring(result.opencodeOk));
|
||||||
if not result.opencodeOk and result.opencodeDetail then
|
if not result.opencodeOk and result.opencodeDetail then
|
||||||
print('opencode: ' .. tostring(result.opencodeDetail));
|
print('opencode: ' .. tostring(result.opencodeDetail));
|
||||||
@ -1,26 +1,26 @@
|
|||||||
local createSandbox = require('/apis/libsandbox');
|
local createCloud = require('/apis/libcloud');
|
||||||
local createVersion = require('/apis/libversion');
|
local createVersion = require('/apis/libversion');
|
||||||
|
|
||||||
local URL_SETTING = 'sandbox.url';
|
local URL_SETTING = 'cloud.url';
|
||||||
local PASSWORD_SETTING = 'sandbox.password';
|
local PASSWORD_SETTING = 'cloud.password';
|
||||||
local LOG_PATH = '/logs/sandbox.log';
|
local LOG_PATH = '/logs/cloud.log';
|
||||||
|
|
||||||
-- Single-instance guard: a clean reboot resets _G, so this is a no-op then. It only
|
-- Single-instance guard: a clean reboot resets _G, so this is a no-op then. It only
|
||||||
-- bites if boot ever re-runs in a VM whose previous eventloop survived, which would
|
-- bites if boot ever re-runs in a VM whose previous eventloop survived, which would
|
||||||
-- otherwise spawn a second daemon that fights the first over the shared traposId.
|
-- otherwise spawn a second daemon that fights the first over the shared traposId.
|
||||||
if _G.__trapos_sandbox_daemon then
|
if _G.__trapos_cloud_daemon then
|
||||||
print('sandbox: daemon already running, skipping.');
|
print('cloud: daemon already running, skipping.');
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
local url = settings.get(URL_SETTING);
|
local url = settings.get(URL_SETTING);
|
||||||
if type(url) ~= 'string' or url == '' then
|
if type(url) ~= 'string' or url == '' then
|
||||||
print('sandbox: ' .. URL_SETTING .. ' not set, daemon inactive.');
|
print('cloud: ' .. URL_SETTING .. ' not set, daemon inactive.');
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
if not http or not http.websocket then
|
if not http or not http.websocket then
|
||||||
print('sandbox: HTTP/WebSocket unavailable, daemon inactive.');
|
print('cloud: HTTP/WebSocket unavailable, daemon inactive.');
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -41,9 +41,9 @@ end
|
|||||||
|
|
||||||
local secret = settings.get(PASSWORD_SETTING);
|
local secret = settings.get(PASSWORD_SETTING);
|
||||||
|
|
||||||
_G.__trapos_sandbox_daemon = true;
|
_G.__trapos_cloud_daemon = true;
|
||||||
|
|
||||||
createSandbox().startSession({
|
createCloud().startSession({
|
||||||
eventloop = _G.bootEventLoop,
|
eventloop = _G.bootEventLoop,
|
||||||
url = url,
|
url = url,
|
||||||
secret = secret,
|
secret = secret,
|
||||||
@ -51,4 +51,4 @@ createSandbox().startSession({
|
|||||||
log = fileLog,
|
log = fileLog,
|
||||||
});
|
});
|
||||||
|
|
||||||
print('sandbox v' .. createVersion().forSelf() .. ' started (' .. url .. '), logging to ' .. LOG_PATH .. '.');
|
print('cloud v' .. createVersion().forSelf() .. ' started (' .. url .. '), logging to ' .. LOG_PATH .. '.');
|
||||||
@ -1,16 +1,16 @@
|
|||||||
-- MCP computer server: reacts to sandbox gateway requests.
|
-- MCP computer server: reacts to cloud gateway requests.
|
||||||
--
|
--
|
||||||
-- No longer owns a websocket. It registers `exec-lua` / `write-file` handlers on the
|
-- No longer owns a websocket. It registers `exec-lua` / `write-file` handlers on the
|
||||||
-- sandbox daemon's event bus (apis/libsandbox.serve), so requests arrive over the one
|
-- cloud daemon's event bus (apis/libcloud.serve), so requests arrive over the one
|
||||||
-- gateway connection owned by servers/sandbox.lua and replies are sent back through it.
|
-- gateway connection owned by servers/cloud.lua and replies are sent back through it.
|
||||||
-- It only handles requests while a sandbox is configured, so exec stays gated behind
|
-- It only handles requests while a cloud is configured, so exec stays gated behind
|
||||||
-- the same trusted, authenticated connection as the rest of the gateway.
|
-- the same trusted, authenticated connection as the rest of the gateway.
|
||||||
|
|
||||||
local createSandbox = require('/apis/libsandbox');
|
local createCloud = require('/apis/libcloud');
|
||||||
local createMcpComputer = require('/apis/libmcpcomputer');
|
local createMcpComputer = require('/apis/libmcpcomputer');
|
||||||
local createVersion = require('/apis/libversion');
|
local createVersion = require('/apis/libversion');
|
||||||
|
|
||||||
local URL_SETTING = 'sandbox.url';
|
local URL_SETTING = 'cloud.url';
|
||||||
|
|
||||||
local url = settings.get(URL_SETTING);
|
local url = settings.get(URL_SETTING);
|
||||||
if type(url) ~= 'string' or url == '' then
|
if type(url) ~= 'string' or url == '' then
|
||||||
@ -24,13 +24,13 @@ if not el then
|
|||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
|
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local mcp = createMcpComputer({ shell = shell });
|
local mcp = createMcpComputer({ shell = shell });
|
||||||
|
|
||||||
-- A code error or a failed file write is a *successful* round-trip that carries an
|
-- A code error or a failed file write is a *successful* round-trip that carries an
|
||||||
-- ok:false result (so captured output survives the reply); only a thrown handler or
|
-- ok:false result (so captured output survives the reply); only a thrown handler or
|
||||||
-- a dropped connection becomes a transport-level failure.
|
-- a dropped connection becomes a transport-level failure.
|
||||||
sandbox.serve('exec-lua', function(payload)
|
cloud.serve('exec-lua', function(payload)
|
||||||
local result = mcp.executeLua(payload and payload.code);
|
local result = mcp.executeLua(payload and payload.code);
|
||||||
return true, {
|
return true, {
|
||||||
ok = result.ok,
|
ok = result.ok,
|
||||||
@ -40,7 +40,7 @@ sandbox.serve('exec-lua', function(payload)
|
|||||||
};
|
};
|
||||||
end, { eventloop = el });
|
end, { eventloop = el });
|
||||||
|
|
||||||
sandbox.serve('run-file', function(payload)
|
cloud.serve('run-file', function(payload)
|
||||||
local result = mcp.executeFile(payload and payload.path, nil, payload and payload.args);
|
local result = mcp.executeFile(payload and payload.path, nil, payload and payload.args);
|
||||||
return true, {
|
return true, {
|
||||||
ok = result.ok,
|
ok = result.ok,
|
||||||
@ -50,7 +50,7 @@ sandbox.serve('run-file', function(payload)
|
|||||||
};
|
};
|
||||||
end, { eventloop = el });
|
end, { eventloop = el });
|
||||||
|
|
||||||
sandbox.serve('write-file', function(payload)
|
cloud.serve('write-file', function(payload)
|
||||||
local result = mcp.writeFile(payload and payload.path, payload and payload.content);
|
local result = mcp.writeFile(payload and payload.path, payload and payload.content);
|
||||||
return true, {
|
return true, {
|
||||||
ok = result.ok,
|
ok = result.ok,
|
||||||
@ -61,4 +61,4 @@ sandbox.serve('write-file', function(payload)
|
|||||||
end, { eventloop = el });
|
end, { eventloop = el });
|
||||||
|
|
||||||
print('mcp-computer-server v' .. createVersion().forSelf()
|
print('mcp-computer-server v' .. createVersion().forSelf()
|
||||||
.. ' started (serving exec-lua/run-file/write-file over sandbox).');
|
.. ' started (serving exec-lua/run-file/write-file over cloud).');
|
||||||
@ -1,5 +1,5 @@
|
|||||||
local createLibTest = require('/apis/libtest');
|
local createLibTest = require('/apis/libtest');
|
||||||
local createSandbox = require('/apis/libsandbox');
|
local createCloud = require('/apis/libcloud');
|
||||||
local fakes = require('/tests/harness/fake-eventloop');
|
local fakes = require('/tests/harness/fake-eventloop');
|
||||||
|
|
||||||
local testlib = createLibTest({ ... });
|
local testlib = createLibTest({ ... });
|
||||||
@ -10,21 +10,21 @@ local encode = textutils.serializeJSON;
|
|||||||
-- traposId ----------------------------------------------------------------
|
-- traposId ----------------------------------------------------------------
|
||||||
|
|
||||||
testlib.test('traposId joins id and label', function()
|
testlib.test('traposId joins id and label', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
testlib.assertEquals(sandbox.traposId(fakeOs(7, 'base')), '7:base');
|
testlib.assertEquals(cloud.traposId(fakeOs(7, 'base')), '7:base');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('traposId renders empty/nil label as trailing colon', function()
|
testlib.test('traposId renders empty/nil label as trailing colon', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
testlib.assertEquals(sandbox.traposId(fakeOs(7, nil)), '7:');
|
testlib.assertEquals(cloud.traposId(fakeOs(7, nil)), '7:');
|
||||||
testlib.assertEquals(sandbox.traposId(fakeOs(8, '')), '8:');
|
testlib.assertEquals(cloud.traposId(fakeOs(8, '')), '8:');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
-- uuid --------------------------------------------------------------------
|
-- uuid --------------------------------------------------------------------
|
||||||
|
|
||||||
testlib.test('uuid emits a v4-shaped string', function()
|
testlib.test('uuid emits a v4-shaped string', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local id = sandbox.uuid();
|
local id = cloud.uuid();
|
||||||
testlib.assertEquals(string.len(id), 36);
|
testlib.assertEquals(string.len(id), 36);
|
||||||
testlib.assertEquals(string.sub(id, 15, 15), '4');
|
testlib.assertEquals(string.sub(id, 15, 15), '4');
|
||||||
testlib.assertTrue(string.match(id, '^%x%x%x%x%x%x%x%x%-%x%x%x%x%-4') ~= nil);
|
testlib.assertTrue(string.match(id, '^%x%x%x%x%x%x%x%x%-%x%x%x%x%-4') ~= nil);
|
||||||
@ -33,40 +33,40 @@ end);
|
|||||||
-- envelope builders -------------------------------------------------------
|
-- envelope builders -------------------------------------------------------
|
||||||
|
|
||||||
testlib.test('buildComputerRequest carries sender id and omits empty payload', function()
|
testlib.test('buildComputerRequest carries sender id and omits empty payload', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local frame = sandbox.buildComputerRequest('7:base', 'mid-1', 'probe_gateway', nil);
|
local frame = cloud.buildComputerRequest('7:base', 'mid-1', 'probe_server', nil);
|
||||||
testlib.assertEquals(frame.event, 'computer_request');
|
testlib.assertEquals(frame.event, 'computer_request');
|
||||||
testlib.assertEquals(frame.traposSenderId, '7:base');
|
testlib.assertEquals(frame.traposSenderId, '7:base');
|
||||||
testlib.assertEquals(frame.messageId, 'mid-1');
|
testlib.assertEquals(frame.messageId, 'mid-1');
|
||||||
testlib.assertEquals(frame.type, 'probe_gateway');
|
testlib.assertEquals(frame.type, 'probe_server');
|
||||||
testlib.assertEquals(frame.payload, nil);
|
testlib.assertEquals(frame.payload, nil);
|
||||||
|
|
||||||
local withPayload = sandbox.buildComputerRequest('7:base', 'mid-2', 'x', { a = 1 });
|
local withPayload = cloud.buildComputerRequest('7:base', 'mid-2', 'x', { a = 1 });
|
||||||
testlib.assertEquals(withPayload.payload.a, 1);
|
testlib.assertEquals(withPayload.payload.a, 1);
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('buildHello includes secret only when non-empty', function()
|
testlib.test('buildHello includes secret only when non-empty', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
|
|
||||||
local withSecret = sandbox.buildHello('7:base', 'hunter2', 'mid');
|
local withSecret = cloud.buildHello('7:base', 'hunter2', 'mid');
|
||||||
testlib.assertEquals(withSecret.event, 'computer_request');
|
testlib.assertEquals(withSecret.event, 'computer_request');
|
||||||
testlib.assertEquals(withSecret.type, 'hello');
|
testlib.assertEquals(withSecret.type, 'hello');
|
||||||
testlib.assertEquals(withSecret.traposSenderId, '7:base');
|
testlib.assertEquals(withSecret.traposSenderId, '7:base');
|
||||||
testlib.assertEquals(withSecret.payload.traposId, '7:base');
|
testlib.assertEquals(withSecret.payload.traposId, '7:base');
|
||||||
testlib.assertEquals(withSecret.payload.secret, 'hunter2');
|
testlib.assertEquals(withSecret.payload.secret, 'hunter2');
|
||||||
|
|
||||||
local noSecret = sandbox.buildHello('7:base', nil, 'mid');
|
local noSecret = cloud.buildHello('7:base', nil, 'mid');
|
||||||
testlib.assertEquals(noSecret.payload.secret, nil);
|
testlib.assertEquals(noSecret.payload.secret, nil);
|
||||||
local emptySecret = sandbox.buildHello('7:base', '', 'mid');
|
local emptySecret = cloud.buildHello('7:base', '', 'mid');
|
||||||
testlib.assertEquals(emptySecret.payload.secret, nil);
|
testlib.assertEquals(emptySecret.payload.secret, nil);
|
||||||
end);
|
end);
|
||||||
|
|
||||||
-- onMessage dispatch ------------------------------------------------------
|
-- onMessage dispatch ------------------------------------------------------
|
||||||
|
|
||||||
testlib.test('onMessage replies to a reverse ping', function()
|
testlib.test('onMessage replies to a reverse ping', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local sent = {};
|
local sent = {};
|
||||||
sandbox.onMessage(encode({ event = 'gateway_request', type = 'ping', messageId = 'g-1' }), {
|
cloud.onMessage(encode({ event = 'server_request', type = 'ping', messageId = 'g-1' }), {
|
||||||
traposId = '7:base',
|
traposId = '7:base',
|
||||||
send = function(frame) sent[#sent + 1] = frame; end,
|
send = function(frame) sent[#sent + 1] = frame; end,
|
||||||
});
|
});
|
||||||
@ -81,9 +81,9 @@ testlib.test('onMessage replies to a reverse ping', function()
|
|||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('onMessage rejects unknown reverse type with unknown_type', function()
|
testlib.test('onMessage rejects unknown reverse type with unknown_type', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local sent = {};
|
local sent = {};
|
||||||
sandbox.onMessage(encode({ event = 'gateway_request', type = 'dance', messageId = 'g-2' }), {
|
cloud.onMessage(encode({ event = 'server_request', type = 'dance', messageId = 'g-2' }), {
|
||||||
traposId = '7:base',
|
traposId = '7:base',
|
||||||
send = function(frame) sent[#sent + 1] = frame; end,
|
send = function(frame) sent[#sent + 1] = frame; end,
|
||||||
});
|
});
|
||||||
@ -93,11 +93,11 @@ testlib.test('onMessage rejects unknown reverse type with unknown_type', functio
|
|||||||
testlib.assertEquals(sent[1].error.code, 'unknown_type');
|
testlib.assertEquals(sent[1].error.code, 'unknown_type');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('onMessage routes a served reverse type to trapos_sandbox_request', function()
|
testlib.test('onMessage routes a served reverse type to trapos_cloud_request', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local requested = {};
|
local requested = {};
|
||||||
sandbox.onMessage(encode({
|
cloud.onMessage(encode({
|
||||||
event = 'gateway_request', type = 'exec-lua', messageId = 'g-3', payload = { code = 'x' },
|
event = 'server_request', type = 'exec-lua', messageId = 'g-3', payload = { code = 'x' },
|
||||||
}), {
|
}), {
|
||||||
traposId = '7:base',
|
traposId = '7:base',
|
||||||
send = function() error('served type must not reply directly', 0); end,
|
send = function() error('served type must not reply directly', 0); end,
|
||||||
@ -114,9 +114,9 @@ testlib.test('onMessage routes a served reverse type to trapos_sandbox_request',
|
|||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('onMessage replies unknown_type for a non-served reverse type', function()
|
testlib.test('onMessage replies unknown_type for a non-served reverse type', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local sent = {};
|
local sent = {};
|
||||||
sandbox.onMessage(encode({ event = 'gateway_request', type = 'exec-lua', messageId = 'g-4' }), {
|
cloud.onMessage(encode({ event = 'server_request', type = 'exec-lua', messageId = 'g-4' }), {
|
||||||
traposId = '7:base',
|
traposId = '7:base',
|
||||||
send = function(frame) sent[#sent + 1] = frame; end,
|
send = function(frame) sent[#sent + 1] = frame; end,
|
||||||
isServed = function() return false; end,
|
isServed = function() return false; end,
|
||||||
@ -129,9 +129,9 @@ testlib.test('onMessage replies unknown_type for a non-served reverse type', fun
|
|||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('onMessage drives hello via onHello', function()
|
testlib.test('onMessage drives hello via onHello', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local calls = {};
|
local calls = {};
|
||||||
sandbox.onMessage(encode({ event = 'gateway_response', type = 'hello', ok = true, payload = { accountId = 'local' } }), {
|
cloud.onMessage(encode({ event = 'server_response', type = 'hello', ok = true, payload = { accountId = 'local' } }), {
|
||||||
onHello = function(ok, payload) calls[#calls + 1] = { ok = ok, payload = payload }; end,
|
onHello = function(ok, payload) calls[#calls + 1] = { ok = ok, payload = payload }; end,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -140,27 +140,27 @@ testlib.test('onMessage drives hello via onHello', function()
|
|||||||
testlib.assertEquals(calls[1].payload.accountId, 'local');
|
testlib.assertEquals(calls[1].payload.accountId, 'local');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('onMessage queues a reply for a non-hello gateway_response', function()
|
testlib.test('onMessage queues a reply for a non-hello server_response', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local queued = {};
|
local queued = {};
|
||||||
sandbox.onMessage(encode({
|
cloud.onMessage(encode({
|
||||||
event = 'gateway_response', type = 'probe_gateway', ok = true,
|
event = 'server_response', type = 'probe_server', ok = true,
|
||||||
messageId = 'mid-9', payload = { sandboxOk = true },
|
messageId = 'mid-9', payload = { serverOk = true },
|
||||||
}), {
|
}), {
|
||||||
queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
||||||
});
|
});
|
||||||
|
|
||||||
testlib.assertEquals(#queued, 1);
|
testlib.assertEquals(#queued, 1);
|
||||||
testlib.assertEquals(queued[1][1], 'trapos_sandbox_reply');
|
testlib.assertEquals(queued[1][1], 'trapos_cloud_reply');
|
||||||
testlib.assertEquals(queued[1][2], 'mid-9');
|
testlib.assertEquals(queued[1][2], 'mid-9');
|
||||||
testlib.assertEquals(queued[1][3], true);
|
testlib.assertEquals(queued[1][3], true);
|
||||||
testlib.assertEquals(queued[1][4].sandboxOk, true);
|
testlib.assertEquals(queued[1][4].serverOk, true);
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('onMessage coerces a missing payload to an empty table', function()
|
testlib.test('onMessage coerces a missing payload to an empty table', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local queued = {};
|
local queued = {};
|
||||||
sandbox.onMessage(encode({ event = 'gateway_response', type = 'noop', ok = true, messageId = 'mid-x' }), {
|
cloud.onMessage(encode({ event = 'server_response', type = 'noop', ok = true, messageId = 'mid-x' }), {
|
||||||
queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -170,7 +170,7 @@ testlib.test('onMessage coerces a missing payload to an empty table', function()
|
|||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('onMessage stays silent on malformed frames', function()
|
testlib.test('onMessage stays silent on malformed frames', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local sent = 0;
|
local sent = 0;
|
||||||
local ctx = {
|
local ctx = {
|
||||||
traposId = '7:base',
|
traposId = '7:base',
|
||||||
@ -178,15 +178,15 @@ testlib.test('onMessage stays silent on malformed frames', function()
|
|||||||
queueEvent = function() sent = sent + 1; end,
|
queueEvent = function() sent = sent + 1; end,
|
||||||
onHello = function() sent = sent + 1; end,
|
onHello = function() sent = sent + 1; end,
|
||||||
};
|
};
|
||||||
sandbox.onMessage('not json', ctx);
|
cloud.onMessage('not json', ctx);
|
||||||
sandbox.onMessage(encode({ event = 'computer_request', type = 'ping' }), ctx);
|
cloud.onMessage(encode({ event = 'computer_request', type = 'ping' }), ctx);
|
||||||
testlib.assertEquals(sent, 0);
|
testlib.assertEquals(sent, 0);
|
||||||
end);
|
end);
|
||||||
|
|
||||||
-- startSession wiring -----------------------------------------------------
|
-- startSession wiring -----------------------------------------------------
|
||||||
|
|
||||||
local function startSession(extra)
|
local function startSession(extra)
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local el = fakes.fakeEventloop();
|
local el = fakes.fakeEventloop();
|
||||||
local http = fakes.fakeHttp();
|
local http = fakes.fakeHttp();
|
||||||
local queued = {};
|
local queued = {};
|
||||||
@ -200,8 +200,8 @@ local function startSession(extra)
|
|||||||
if extra then
|
if extra then
|
||||||
for k, v in pairs(extra) do opts[k] = v; end
|
for k, v in pairs(extra) do opts[k] = v; end
|
||||||
end
|
end
|
||||||
local session = sandbox.startSession(opts);
|
local session = cloud.startSession(opts);
|
||||||
return { sandbox = sandbox, el = el, http = http, queued = queued, session = session, url = http.connects[1] };
|
return { cloud = cloud, el = el, http = http, queued = queued, session = session, url = http.connects[1] };
|
||||||
end
|
end
|
||||||
|
|
||||||
testlib.test('startSession connects to the /gateway path', function()
|
testlib.test('startSession connects to the /gateway path', function()
|
||||||
@ -220,11 +220,11 @@ testlib.test('startSession sends hello on open and goes ready on ack', function(
|
|||||||
testlib.assertEquals(hello.type, 'hello');
|
testlib.assertEquals(hello.type, 'hello');
|
||||||
testlib.assertEquals(hello.payload.secret, 'hunter2');
|
testlib.assertEquals(hello.payload.secret, 'hunter2');
|
||||||
|
|
||||||
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} }));
|
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
|
||||||
testlib.assertEquals(ctx.session.isReady(), true);
|
testlib.assertEquals(ctx.session.isReady(), true);
|
||||||
|
|
||||||
local connected = ctx.queued[#ctx.queued];
|
local connected = ctx.queued[#ctx.queued];
|
||||||
testlib.assertEquals(connected[1], 'trapos_sandbox_connected');
|
testlib.assertEquals(connected[1], 'trapos_cloud_connected');
|
||||||
testlib.assertEquals(connected[2], '7:base');
|
testlib.assertEquals(connected[2], '7:base');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
@ -235,7 +235,7 @@ testlib.test('startSession treats unauthorized hello as terminal', function()
|
|||||||
local timersAfterOpen = #ctx.el.timers; -- hello watchdog
|
local timersAfterOpen = #ctx.el.timers; -- hello watchdog
|
||||||
|
|
||||||
ctx.el.fire('websocket_message', ctx.url, encode({
|
ctx.el.fire('websocket_message', ctx.url, encode({
|
||||||
event = 'gateway_response', type = 'hello', ok = false, error = { code = 'unauthorized', message = 'bad secret' },
|
event = 'server_response', type = 'hello', ok = false, error = { code = 'unauthorized', message = 'bad secret' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
testlib.assertEquals(ctx.session.isReady(), false);
|
testlib.assertEquals(ctx.session.isReady(), false);
|
||||||
@ -285,7 +285,7 @@ testlib.test('startSession logs the reconnect loop once on entry and once on rec
|
|||||||
|
|
||||||
-- Healthy connect: no reconnect-loop logs yet.
|
-- Healthy connect: no reconnect-loop logs yet.
|
||||||
ctx.el.fire('websocket_success', ctx.url, fakes.fakeWs());
|
ctx.el.fire('websocket_success', ctx.url, fakes.fakeWs());
|
||||||
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} }));
|
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
|
||||||
testlib.assertEquals(ctx.session.isReady(), true);
|
testlib.assertEquals(ctx.session.isReady(), true);
|
||||||
testlib.assertEquals(countMsg('reconnect loop started'), 0);
|
testlib.assertEquals(countMsg('reconnect loop started'), 0);
|
||||||
|
|
||||||
@ -305,7 +305,7 @@ testlib.test('startSession logs the reconnect loop once on entry and once on rec
|
|||||||
-- Recovery: a single "reconnected" line, no second "loop started".
|
-- Recovery: a single "reconnected" line, no second "loop started".
|
||||||
ctx.el.fireTimer(#ctx.el.timers); -- reconnect timer -> connect()
|
ctx.el.fireTimer(#ctx.el.timers); -- reconnect timer -> connect()
|
||||||
ctx.el.fire('websocket_success', ctx.url, fakes.fakeWs());
|
ctx.el.fire('websocket_success', ctx.url, fakes.fakeWs());
|
||||||
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} }));
|
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
|
||||||
|
|
||||||
testlib.assertEquals(ctx.session.isReady(), true);
|
testlib.assertEquals(ctx.session.isReady(), true);
|
||||||
testlib.assertEquals(countMsg('reconnect loop ended; reconnected'), 1);
|
testlib.assertEquals(countMsg('reconnect loop ended; reconnected'), 1);
|
||||||
@ -316,7 +316,7 @@ testlib.test('startSession treats a displacement close (4000) as terminal', func
|
|||||||
local ctx = startSession();
|
local ctx = startSession();
|
||||||
local ws = fakes.fakeWs();
|
local ws = fakes.fakeWs();
|
||||||
ctx.el.fire('websocket_success', ctx.url, ws);
|
ctx.el.fire('websocket_success', ctx.url, ws);
|
||||||
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} }));
|
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
|
||||||
testlib.assertEquals(ctx.session.isReady(), true);
|
testlib.assertEquals(ctx.session.isReady(), true);
|
||||||
local timersBefore = #ctx.el.timers;
|
local timersBefore = #ctx.el.timers;
|
||||||
|
|
||||||
@ -333,7 +333,7 @@ testlib.test('startSession treats a displacement close by reason as terminal', f
|
|||||||
local ctx = startSession();
|
local ctx = startSession();
|
||||||
local ws = fakes.fakeWs();
|
local ws = fakes.fakeWs();
|
||||||
ctx.el.fire('websocket_success', ctx.url, ws);
|
ctx.el.fire('websocket_success', ctx.url, ws);
|
||||||
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} }));
|
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
|
||||||
testlib.assertEquals(ctx.session.isReady(), true);
|
testlib.assertEquals(ctx.session.isReady(), true);
|
||||||
local timersBefore = #ctx.el.timers;
|
local timersBefore = #ctx.el.timers;
|
||||||
|
|
||||||
@ -355,7 +355,7 @@ testlib.test('startSession ignores a displacement of an already-superseded socke
|
|||||||
|
|
||||||
testlib.assertEquals(ctx.session.lastError(), nil);
|
testlib.assertEquals(ctx.session.lastError(), nil);
|
||||||
-- Not terminal: a hello ack on the live socket still brings us to ready.
|
-- Not terminal: a hello ack on the live socket still brings us to ready.
|
||||||
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} }));
|
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
|
||||||
testlib.assertEquals(ctx.session.isReady(), true);
|
testlib.assertEquals(ctx.session.isReady(), true);
|
||||||
end);
|
end);
|
||||||
|
|
||||||
@ -369,9 +369,9 @@ end);
|
|||||||
|
|
||||||
testlib.test('send handler replies not_connected when not ready', function()
|
testlib.test('send handler replies not_connected when not ready', function()
|
||||||
local ctx = startSession();
|
local ctx = startSession();
|
||||||
ctx.el.fire('trapos_sandbox_send', 'mid-1', 'probe_gateway', nil);
|
ctx.el.fire('trapos_cloud_send', 'mid-1', 'probe_server', nil);
|
||||||
local reply = ctx.queued[#ctx.queued];
|
local reply = ctx.queued[#ctx.queued];
|
||||||
testlib.assertEquals(reply[1], 'trapos_sandbox_reply');
|
testlib.assertEquals(reply[1], 'trapos_cloud_reply');
|
||||||
testlib.assertEquals(reply[2], 'mid-1');
|
testlib.assertEquals(reply[2], 'mid-1');
|
||||||
testlib.assertEquals(reply[3], false);
|
testlib.assertEquals(reply[3], false);
|
||||||
testlib.assertEquals(reply[5].code, 'not_connected');
|
testlib.assertEquals(reply[5].code, 'not_connected');
|
||||||
@ -381,41 +381,41 @@ testlib.test('send handler forwards a request when ready without an immediate re
|
|||||||
local ctx = startSession();
|
local ctx = startSession();
|
||||||
local ws = fakes.fakeWs();
|
local ws = fakes.fakeWs();
|
||||||
ctx.el.fire('websocket_success', ctx.url, ws);
|
ctx.el.fire('websocket_success', ctx.url, ws);
|
||||||
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} }));
|
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
|
||||||
local queuedBefore = #ctx.queued;
|
local queuedBefore = #ctx.queued;
|
||||||
|
|
||||||
ctx.el.fire('trapos_sandbox_send', 'mid-2', 'probe_gateway', nil);
|
ctx.el.fire('trapos_cloud_send', 'mid-2', 'probe_server', nil);
|
||||||
|
|
||||||
testlib.assertEquals(#ctx.queued, queuedBefore); -- no immediate reply
|
testlib.assertEquals(#ctx.queued, queuedBefore); -- no immediate reply
|
||||||
local frame = textutils.unserializeJSON(ws.sent[#ws.sent]);
|
local frame = textutils.unserializeJSON(ws.sent[#ws.sent]);
|
||||||
testlib.assertEquals(frame.event, 'computer_request');
|
testlib.assertEquals(frame.event, 'computer_request');
|
||||||
testlib.assertEquals(frame.type, 'probe_gateway');
|
testlib.assertEquals(frame.type, 'probe_server');
|
||||||
testlib.assertEquals(frame.messageId, 'mid-2');
|
testlib.assertEquals(frame.messageId, 'mid-2');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
-- request() ---------------------------------------------------------------
|
-- request() ---------------------------------------------------------------
|
||||||
|
|
||||||
testlib.test('request resolves on a matching reply', function()
|
testlib.test('request resolves on a matching reply', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local events = fakes.fakeEvents({
|
local events = fakes.fakeEvents({
|
||||||
{ 'trapos_sandbox_reply', 'mid', true, { sandboxOk = true }, n = 4 },
|
{ 'trapos_cloud_reply', 'mid', true, { serverOk = true }, n = 4 },
|
||||||
});
|
});
|
||||||
local ok, payload = sandbox.request('probe_gateway', nil, {
|
local ok, payload = cloud.request('probe_server', nil, {
|
||||||
messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
|
messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
|
||||||
startTimer = events.startTimer, cancelTimer = events.cancelTimer,
|
startTimer = events.startTimer, cancelTimer = events.cancelTimer,
|
||||||
});
|
});
|
||||||
|
|
||||||
testlib.assertEquals(ok, true);
|
testlib.assertEquals(ok, true);
|
||||||
testlib.assertEquals(payload.sandboxOk, true);
|
testlib.assertEquals(payload.serverOk, true);
|
||||||
testlib.assertEquals(events.cancelled[1], 'timer-1');
|
testlib.assertEquals(events.cancelled[1], 'timer-1');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('request returns the error on a failed reply', function()
|
testlib.test('request returns the error on a failed reply', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local events = fakes.fakeEvents({
|
local events = fakes.fakeEvents({
|
||||||
{ 'trapos_sandbox_reply', 'mid', false, nil, { code = 'unauthorized', message = 'x' }, n = 5 },
|
{ 'trapos_cloud_reply', 'mid', false, nil, { code = 'unauthorized', message = 'x' }, n = 5 },
|
||||||
});
|
});
|
||||||
local ok, err = sandbox.request('probe_gateway', nil, {
|
local ok, err = cloud.request('probe_server', nil, {
|
||||||
messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
|
messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
|
||||||
startTimer = events.startTimer, cancelTimer = events.cancelTimer,
|
startTimer = events.startTimer, cancelTimer = events.cancelTimer,
|
||||||
});
|
});
|
||||||
@ -425,29 +425,29 @@ testlib.test('request returns the error on a failed reply', function()
|
|||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('request re-queues a mismatched reply and keeps waiting', function()
|
testlib.test('request re-queues a mismatched reply and keeps waiting', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local events = fakes.fakeEvents({
|
local events = fakes.fakeEvents({
|
||||||
{ 'trapos_sandbox_reply', 'other', true, { y = 2 }, n = 4 },
|
{ 'trapos_cloud_reply', 'other', true, { y = 2 }, n = 4 },
|
||||||
{ 'trapos_sandbox_reply', 'mid', true, { x = 1 }, n = 4 },
|
{ 'trapos_cloud_reply', 'mid', true, { x = 1 }, n = 4 },
|
||||||
});
|
});
|
||||||
local ok, payload = sandbox.request('probe_gateway', nil, {
|
local ok, payload = cloud.request('probe_server', nil, {
|
||||||
messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
|
messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
|
||||||
startTimer = events.startTimer, cancelTimer = events.cancelTimer,
|
startTimer = events.startTimer, cancelTimer = events.cancelTimer,
|
||||||
});
|
});
|
||||||
|
|
||||||
testlib.assertEquals(ok, true);
|
testlib.assertEquals(ok, true);
|
||||||
testlib.assertEquals(payload.x, 1);
|
testlib.assertEquals(payload.x, 1);
|
||||||
-- queued[1] is the trapos_sandbox_send; queued[2] is the re-queued reply.
|
-- queued[1] is the trapos_cloud_send; queued[2] is the re-queued reply.
|
||||||
testlib.assertEquals(events.queued[2][1], 'trapos_sandbox_reply');
|
testlib.assertEquals(events.queued[2][1], 'trapos_cloud_reply');
|
||||||
testlib.assertEquals(events.queued[2][2], 'other');
|
testlib.assertEquals(events.queued[2][2], 'other');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('request times out when the timer fires first', function()
|
testlib.test('request times out when the timer fires first', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local events = fakes.fakeEvents({
|
local events = fakes.fakeEvents({
|
||||||
{ 'timer', 'timer-1', n = 2 },
|
{ 'timer', 'timer-1', n = 2 },
|
||||||
});
|
});
|
||||||
local ok, err = sandbox.request('probe_gateway', nil, {
|
local ok, err = cloud.request('probe_server', nil, {
|
||||||
messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
|
messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent,
|
||||||
startTimer = events.startTimer, cancelTimer = events.cancelTimer,
|
startTimer = events.startTimer, cancelTimer = events.cancelTimer,
|
||||||
});
|
});
|
||||||
@ -462,24 +462,24 @@ local function readySession()
|
|||||||
local ctx = startSession();
|
local ctx = startSession();
|
||||||
local ws = fakes.fakeWs();
|
local ws = fakes.fakeWs();
|
||||||
ctx.el.fire('websocket_success', ctx.url, ws);
|
ctx.el.fire('websocket_success', ctx.url, ws);
|
||||||
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} }));
|
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
|
||||||
ctx.ws = ws;
|
ctx.ws = ws;
|
||||||
return ctx;
|
return ctx;
|
||||||
end
|
end
|
||||||
|
|
||||||
testlib.test('startSession routes an advertised reverse type to trapos_sandbox_request', function()
|
testlib.test('startSession routes an advertised reverse type to trapos_cloud_request', function()
|
||||||
local ctx = readySession();
|
local ctx = readySession();
|
||||||
ctx.el.fire('trapos_sandbox_serve', 'exec-lua');
|
ctx.el.fire('trapos_cloud_serve', 'exec-lua');
|
||||||
local sentBefore = #ctx.ws.sent;
|
local sentBefore = #ctx.ws.sent;
|
||||||
|
|
||||||
ctx.el.fire('websocket_message', ctx.url, encode({
|
ctx.el.fire('websocket_message', ctx.url, encode({
|
||||||
event = 'gateway_request', type = 'exec-lua', messageId = 'g-9', payload = { code = 'x' },
|
event = 'server_request', type = 'exec-lua', messageId = 'g-9', payload = { code = 'x' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
-- No immediate reply on the wire; a request event is queued for the server instead.
|
-- No immediate reply on the wire; a request event is queued for the server instead.
|
||||||
testlib.assertEquals(#ctx.ws.sent, sentBefore);
|
testlib.assertEquals(#ctx.ws.sent, sentBefore);
|
||||||
local req = ctx.queued[#ctx.queued];
|
local req = ctx.queued[#ctx.queued];
|
||||||
testlib.assertEquals(req[1], 'trapos_sandbox_request');
|
testlib.assertEquals(req[1], 'trapos_cloud_request');
|
||||||
testlib.assertEquals(req[2], 'g-9');
|
testlib.assertEquals(req[2], 'g-9');
|
||||||
testlib.assertEquals(req[3], 'exec-lua');
|
testlib.assertEquals(req[3], 'exec-lua');
|
||||||
testlib.assertEquals(req[4].code, 'x');
|
testlib.assertEquals(req[4].code, 'x');
|
||||||
@ -488,7 +488,7 @@ end);
|
|||||||
testlib.test('startSession replies unknown_type for an unadvertised reverse type', function()
|
testlib.test('startSession replies unknown_type for an unadvertised reverse type', function()
|
||||||
local ctx = readySession();
|
local ctx = readySession();
|
||||||
ctx.el.fire('websocket_message', ctx.url, encode({
|
ctx.el.fire('websocket_message', ctx.url, encode({
|
||||||
event = 'gateway_request', type = 'exec-lua', messageId = 'g-10',
|
event = 'server_request', type = 'exec-lua', messageId = 'g-10',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
local frame = textutils.unserializeJSON(ctx.ws.sent[#ctx.ws.sent]);
|
local frame = textutils.unserializeJSON(ctx.ws.sent[#ctx.ws.sent]);
|
||||||
@ -501,7 +501,7 @@ testlib.test('respond handler sends a computer_response when ready', function()
|
|||||||
local ctx = readySession();
|
local ctx = readySession();
|
||||||
local sentBefore = #ctx.ws.sent;
|
local sentBefore = #ctx.ws.sent;
|
||||||
|
|
||||||
ctx.el.fire('trapos_sandbox_respond', 'g-11', 'exec-lua', true, { ok = true, output = 'hi' }, nil);
|
ctx.el.fire('trapos_cloud_respond', 'g-11', 'exec-lua', true, { ok = true, output = 'hi' }, nil);
|
||||||
|
|
||||||
testlib.assertEquals(#ctx.ws.sent, sentBefore + 1);
|
testlib.assertEquals(#ctx.ws.sent, sentBefore + 1);
|
||||||
local frame = textutils.unserializeJSON(ctx.ws.sent[#ctx.ws.sent]);
|
local frame = textutils.unserializeJSON(ctx.ws.sent[#ctx.ws.sent]);
|
||||||
@ -515,21 +515,21 @@ end);
|
|||||||
testlib.test('respond handler drops the reply when not connected', function()
|
testlib.test('respond handler drops the reply when not connected', function()
|
||||||
local ctx = startSession(); -- never reaches 'ready'
|
local ctx = startSession(); -- never reaches 'ready'
|
||||||
-- Must not raise even though there is no live socket to send on.
|
-- Must not raise even though there is no live socket to send on.
|
||||||
ctx.el.fire('trapos_sandbox_respond', 'g-12', 'exec-lua', true, { ok = true }, nil);
|
ctx.el.fire('trapos_cloud_respond', 'g-12', 'exec-lua', true, { ok = true }, nil);
|
||||||
testlib.assertEquals(ctx.session.isReady(), false);
|
testlib.assertEquals(ctx.session.isReady(), false);
|
||||||
end);
|
end);
|
||||||
|
|
||||||
-- serve() / respond() helpers ---------------------------------------------
|
-- serve() / respond() helpers ---------------------------------------------
|
||||||
|
|
||||||
testlib.test('respond queues a trapos_sandbox_respond event', function()
|
testlib.test('respond queues a trapos_cloud_respond event', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local queued = {};
|
local queued = {};
|
||||||
sandbox.respond('mid-1', 'exec-lua', true, { ok = true }, nil, {
|
cloud.respond('mid-1', 'exec-lua', true, { ok = true }, nil, {
|
||||||
queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
||||||
});
|
});
|
||||||
|
|
||||||
testlib.assertEquals(#queued, 1);
|
testlib.assertEquals(#queued, 1);
|
||||||
testlib.assertEquals(queued[1][1], 'trapos_sandbox_respond');
|
testlib.assertEquals(queued[1][1], 'trapos_cloud_respond');
|
||||||
testlib.assertEquals(queued[1][2], 'mid-1');
|
testlib.assertEquals(queued[1][2], 'mid-1');
|
||||||
testlib.assertEquals(queued[1][3], 'exec-lua');
|
testlib.assertEquals(queued[1][3], 'exec-lua');
|
||||||
testlib.assertEquals(queued[1][4], true);
|
testlib.assertEquals(queued[1][4], true);
|
||||||
@ -537,57 +537,57 @@ testlib.test('respond queues a trapos_sandbox_respond event', function()
|
|||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('serve advertises the type and auto-responds on success', function()
|
testlib.test('serve advertises the type and auto-responds on success', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local el = fakes.fakeEventloop();
|
local el = fakes.fakeEventloop();
|
||||||
local queued = {};
|
local queued = {};
|
||||||
sandbox.serve('exec-lua', function(payload)
|
cloud.serve('exec-lua', function(payload)
|
||||||
return true, { echoed = payload.code };
|
return true, { echoed = payload.code };
|
||||||
end, { eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end });
|
end, { eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end });
|
||||||
|
|
||||||
testlib.assertEquals(queued[1][1], 'trapos_sandbox_serve');
|
testlib.assertEquals(queued[1][1], 'trapos_cloud_serve');
|
||||||
testlib.assertEquals(queued[1][2], 'exec-lua');
|
testlib.assertEquals(queued[1][2], 'exec-lua');
|
||||||
|
|
||||||
el.fire('trapos_sandbox_request', 'mid-7', 'exec-lua', { code = 'print(1)' });
|
el.fire('trapos_cloud_request', 'mid-7', 'exec-lua', { code = 'print(1)' });
|
||||||
local respond = queued[#queued];
|
local respond = queued[#queued];
|
||||||
testlib.assertEquals(respond[1], 'trapos_sandbox_respond');
|
testlib.assertEquals(respond[1], 'trapos_cloud_respond');
|
||||||
testlib.assertEquals(respond[2], 'mid-7');
|
testlib.assertEquals(respond[2], 'mid-7');
|
||||||
testlib.assertEquals(respond[4], true);
|
testlib.assertEquals(respond[4], true);
|
||||||
testlib.assertEquals(respond[5].echoed, 'print(1)');
|
testlib.assertEquals(respond[5].echoed, 'print(1)');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('serve ignores requests for other types', function()
|
testlib.test('serve ignores requests for other types', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local el = fakes.fakeEventloop();
|
local el = fakes.fakeEventloop();
|
||||||
local queued = {};
|
local queued = {};
|
||||||
sandbox.serve('exec-lua', function() return true, {}; end, {
|
cloud.serve('exec-lua', function() return true, {}; end, {
|
||||||
eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
||||||
});
|
});
|
||||||
local before = #queued;
|
local before = #queued;
|
||||||
el.fire('trapos_sandbox_request', 'mid', 'write-file', {});
|
el.fire('trapos_cloud_request', 'mid', 'write-file', {});
|
||||||
testlib.assertEquals(#queued, before); -- no respond queued
|
testlib.assertEquals(#queued, before); -- no respond queued
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('serve responds with the error table on a failure result', function()
|
testlib.test('serve responds with the error table on a failure result', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local el = fakes.fakeEventloop();
|
local el = fakes.fakeEventloop();
|
||||||
local queued = {};
|
local queued = {};
|
||||||
sandbox.serve('exec-lua', function() return false, { code = 'internal', message = 'nope' }; end, {
|
cloud.serve('exec-lua', function() return false, { code = 'internal', message = 'nope' }; end, {
|
||||||
eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
||||||
});
|
});
|
||||||
el.fire('trapos_sandbox_request', 'mid', 'exec-lua', {});
|
el.fire('trapos_cloud_request', 'mid', 'exec-lua', {});
|
||||||
local respond = queued[#queued];
|
local respond = queued[#queued];
|
||||||
testlib.assertEquals(respond[4], false);
|
testlib.assertEquals(respond[4], false);
|
||||||
testlib.assertEquals(respond[6].code, 'internal');
|
testlib.assertEquals(respond[6].code, 'internal');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('serve reports a thrown handler as an internal error', function()
|
testlib.test('serve reports a thrown handler as an internal error', function()
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local el = fakes.fakeEventloop();
|
local el = fakes.fakeEventloop();
|
||||||
local queued = {};
|
local queued = {};
|
||||||
sandbox.serve('exec-lua', function() error('boom', 0); end, {
|
cloud.serve('exec-lua', function() error('boom', 0); end, {
|
||||||
eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
eventloop = el, queueEvent = function(...) queued[#queued + 1] = table.pack(...); end,
|
||||||
});
|
});
|
||||||
el.fire('trapos_sandbox_request', 'mid', 'exec-lua', {});
|
el.fire('trapos_cloud_request', 'mid', 'exec-lua', {});
|
||||||
local respond = queued[#queued];
|
local respond = queued[#queued];
|
||||||
testlib.assertEquals(respond[4], false);
|
testlib.assertEquals(respond[4], false);
|
||||||
testlib.assertEquals(respond[6].code, 'internal');
|
testlib.assertEquals(respond[6].code, 'internal');
|
||||||
@ -10,7 +10,7 @@
|
|||||||
-- - ccpm.cache.json -> { packages = { <name> = { version, registry } } }
|
-- - ccpm.cache.json -> { packages = { <name> = { version, registry } } }
|
||||||
--
|
--
|
||||||
-- Files are written under `opts.installRoot` (default '' -> filesystem root), so
|
-- Files are written under `opts.installRoot` (default '' -> filesystem root), so
|
||||||
-- tests can sandbox downloads. `opts.http` overrides the global `http` for tests.
|
-- tests can isolate downloads. `opts.http` overrides the global `http` for tests.
|
||||||
|
|
||||||
local DEFAULT_STATE_DIR = '/trapos';
|
local DEFAULT_STATE_DIR = '/trapos';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-core",
|
"name": "trapos-core",
|
||||||
"version": "0.9.2",
|
"version": "0.9.3",
|
||||||
"description": "TrapOS base: package manager, event loop, upgrade and event tools",
|
"description": "TrapOS base: package manager, event loop, upgrade and event tools",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
local createVersion = require('/apis/libversion');
|
local createVersion = require('/apis/libversion');
|
||||||
|
|
||||||
local DEFAULT_SANDBOX_URL = 'wss://os.trapcloud.fr';
|
local DEFAULT_CLOUD_URL = 'wss://os.trapcloud.fr';
|
||||||
local SANDBOX_URL_SETTING = 'sandbox.url';
|
local CLOUD_URL_SETTING = 'cloud.url';
|
||||||
local SANDBOX_PASSWORD_SETTING = 'sandbox.password';
|
local CLOUD_PASSWORD_SETTING = 'cloud.password';
|
||||||
|
|
||||||
local function isBlank(value)
|
local function isBlank(value)
|
||||||
return type(value) ~= 'string' or value == '';
|
return type(value) ~= 'string' or value == '';
|
||||||
@ -44,14 +44,14 @@ local function readManifestVersion()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local version = readManifestVersion();
|
local version = readManifestVersion();
|
||||||
local configuredSandboxUrl = false;
|
local configuredCloudUrl = false;
|
||||||
|
|
||||||
if isBlank(settings.get(SANDBOX_URL_SETTING)) then
|
if isBlank(settings.get(CLOUD_URL_SETTING)) then
|
||||||
shell.run('set', SANDBOX_URL_SETTING, DEFAULT_SANDBOX_URL);
|
shell.run('set', CLOUD_URL_SETTING, DEFAULT_CLOUD_URL);
|
||||||
configuredSandboxUrl = true;
|
configuredCloudUrl = true;
|
||||||
end
|
end
|
||||||
|
|
||||||
local hasSandboxPassword = not isBlank(settings.get(SANDBOX_PASSWORD_SETTING));
|
local hasCloudPassword = not isBlank(settings.get(CLOUD_PASSWORD_SETTING));
|
||||||
|
|
||||||
print();
|
print();
|
||||||
if version then
|
if version then
|
||||||
@ -59,18 +59,18 @@ if version then
|
|||||||
else
|
else
|
||||||
print('=> TrapOS installed.');
|
print('=> TrapOS installed.');
|
||||||
end
|
end
|
||||||
if configuredSandboxUrl then
|
if configuredCloudUrl then
|
||||||
print('=> Configured sandbox.url to ' .. DEFAULT_SANDBOX_URL .. '.');
|
print('=> Configured cloud.url to ' .. DEFAULT_CLOUD_URL .. '.');
|
||||||
else
|
else
|
||||||
print('=> Keeping existing sandbox.url.');
|
print('=> Keeping existing cloud.url.');
|
||||||
end
|
end
|
||||||
if hasSandboxPassword then
|
if hasCloudPassword then
|
||||||
print('=> Sandbox password already configured.');
|
print('=> Cloud password already configured.');
|
||||||
else
|
else
|
||||||
print('=> Sandbox password not set.');
|
print('=> Cloud password not set.');
|
||||||
end
|
end
|
||||||
print();
|
print();
|
||||||
print('Get started:');
|
print('Get started:');
|
||||||
print(' sandbox --help -- open the sandbox help');
|
print(' cloud --help -- open the cloud help');
|
||||||
print(' trapos-upgrade -- update and upgrade TrapOS');
|
print(' trapos-upgrade -- update and upgrade TrapOS');
|
||||||
print();
|
print();
|
||||||
|
|||||||
@ -5,7 +5,7 @@ local testlib = createLibTest({ ... })
|
|||||||
|
|
||||||
local counter = 0
|
local counter = 0
|
||||||
|
|
||||||
-- Fresh, isolated state + install sandbox per case (never touches real /trapos).
|
-- Fresh, isolated state + isolated install per case (never touches real /trapos).
|
||||||
local function freshDirs()
|
local function freshDirs()
|
||||||
counter = counter + 1
|
counter = counter + 1
|
||||||
local stateDir = "/ccpm-test/state-" .. counter
|
local stateDir = "/ccpm-test/state-" .. counter
|
||||||
|
|||||||
@ -175,7 +175,7 @@ testlib.test('create install disk labels, copies settings, writes startup', func
|
|||||||
local settingsPath = fs.combine(freshDir(), '.settings');
|
local settingsPath = fs.combine(freshDir(), '.settings');
|
||||||
local drive = { name = 'top', mountPath = disk };
|
local drive = { name = 'top', mountPath = disk };
|
||||||
local settingsApi = fakeSettings();
|
local settingsApi = fakeSettings();
|
||||||
writeFile(settingsPath, 'sandbox.url=https://example.test');
|
writeFile(settingsPath, 'cloud.url=https://example.test');
|
||||||
|
|
||||||
local installer = createInstallerDisk({
|
local installer = createInstallerDisk({
|
||||||
peripheral = fakePeripheral({ drive }),
|
peripheral = fakePeripheral({ drive }),
|
||||||
@ -189,7 +189,7 @@ testlib.test('create install disk labels, copies settings, writes startup', func
|
|||||||
testlib.assertEquals(result.mode, 'install');
|
testlib.assertEquals(result.mode, 'install');
|
||||||
testlib.assertEquals(settingsApi.saveCount, 1);
|
testlib.assertEquals(settingsApi.saveCount, 1);
|
||||||
testlib.assertEquals(readAll(fs.combine(disk, 'startup.lua')), installer.buildAutorun('install'));
|
testlib.assertEquals(readAll(fs.combine(disk, 'startup.lua')), installer.buildAutorun('install'));
|
||||||
testlib.assertEquals(readAll(fs.combine(disk, '.settings')), 'sandbox.url=https://example.test');
|
testlib.assertEquals(readAll(fs.combine(disk, '.settings')), 'cloud.url=https://example.test');
|
||||||
testlib.assertTrue(result.settingsCopied);
|
testlib.assertTrue(result.settingsCopied);
|
||||||
testlib.assertTrue(not result.settingsWarning);
|
testlib.assertTrue(not result.settingsWarning);
|
||||||
end);
|
end);
|
||||||
@ -217,7 +217,7 @@ testlib.test('create reinstall disk skips settings clone', function()
|
|||||||
local settingsPath = fs.combine(freshDir(), '.settings');
|
local settingsPath = fs.combine(freshDir(), '.settings');
|
||||||
local drive = { name = 'top', mountPath = disk };
|
local drive = { name = 'top', mountPath = disk };
|
||||||
local settingsApi = fakeSettings();
|
local settingsApi = fakeSettings();
|
||||||
writeFile(settingsPath, 'sandbox.url=https://example.test');
|
writeFile(settingsPath, 'cloud.url=https://example.test');
|
||||||
|
|
||||||
local installer = createInstallerDisk({
|
local installer = createInstallerDisk({
|
||||||
peripheral = fakePeripheral({ drive }),
|
peripheral = fakePeripheral({ drive }),
|
||||||
|
|||||||
@ -2,7 +2,7 @@ local createLibTest = require('/apis/libtest');
|
|||||||
|
|
||||||
local testlib = createLibTest({ ... });
|
local testlib = createLibTest({ ... });
|
||||||
|
|
||||||
local DEFAULT_SANDBOX_URL = 'wss://os.trapcloud.fr';
|
local DEFAULT_CLOUD_URL = 'wss://os.trapcloud.fr';
|
||||||
|
|
||||||
local function fakeSettings(values)
|
local function fakeSettings(values)
|
||||||
return {
|
return {
|
||||||
@ -50,48 +50,48 @@ local function containsLine(lines, expected)
|
|||||||
return false;
|
return false;
|
||||||
end
|
end
|
||||||
|
|
||||||
testlib.test('postinstall defaults missing sandbox url', function()
|
testlib.test('postinstall defaults missing cloud url', function()
|
||||||
local settingsLib = fakeSettings();
|
local settingsLib = fakeSettings();
|
||||||
local lines, shellRuns = runPostinstall(settingsLib);
|
local lines, shellRuns = runPostinstall(settingsLib);
|
||||||
|
|
||||||
testlib.assertEquals(settingsLib.values['sandbox.url'], DEFAULT_SANDBOX_URL);
|
testlib.assertEquals(settingsLib.values['cloud.url'], DEFAULT_CLOUD_URL);
|
||||||
testlib.assertEquals(#shellRuns, 1);
|
testlib.assertEquals(#shellRuns, 1);
|
||||||
testlib.assertEquals(shellRuns[1][1], 'set');
|
testlib.assertEquals(shellRuns[1][1], 'set');
|
||||||
testlib.assertEquals(shellRuns[1][2], 'sandbox.url');
|
testlib.assertEquals(shellRuns[1][2], 'cloud.url');
|
||||||
testlib.assertEquals(shellRuns[1][3], DEFAULT_SANDBOX_URL);
|
testlib.assertEquals(shellRuns[1][3], DEFAULT_CLOUD_URL);
|
||||||
testlib.assertTrue(containsLine(lines, '=> Configured sandbox.url to ' .. DEFAULT_SANDBOX_URL .. '.'));
|
testlib.assertTrue(containsLine(lines, '=> Configured cloud.url to ' .. DEFAULT_CLOUD_URL .. '.'));
|
||||||
testlib.assertTrue(containsLine(lines, '=> Sandbox password not set.'));
|
testlib.assertTrue(containsLine(lines, '=> Cloud password not set.'));
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('postinstall treats empty sandbox url as missing', function()
|
testlib.test('postinstall treats empty cloud url as missing', function()
|
||||||
local settingsLib = fakeSettings({ ['sandbox.url'] = '' });
|
local settingsLib = fakeSettings({ ['cloud.url'] = '' });
|
||||||
local _, shellRuns = runPostinstall(settingsLib);
|
local _, shellRuns = runPostinstall(settingsLib);
|
||||||
|
|
||||||
testlib.assertEquals(settingsLib.values['sandbox.url'], DEFAULT_SANDBOX_URL);
|
testlib.assertEquals(settingsLib.values['cloud.url'], DEFAULT_CLOUD_URL);
|
||||||
testlib.assertEquals(#shellRuns, 1);
|
testlib.assertEquals(#shellRuns, 1);
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('postinstall preserves existing sandbox url and password', function()
|
testlib.test('postinstall preserves existing cloud url and password', function()
|
||||||
local settingsLib = fakeSettings({
|
local settingsLib = fakeSettings({
|
||||||
['sandbox.url'] = 'wss://custom.example',
|
['cloud.url'] = 'wss://custom.example',
|
||||||
['sandbox.password'] = 'secret',
|
['cloud.password'] = 'secret',
|
||||||
});
|
});
|
||||||
local lines, shellRuns = runPostinstall(settingsLib);
|
local lines, shellRuns = runPostinstall(settingsLib);
|
||||||
|
|
||||||
testlib.assertEquals(settingsLib.values['sandbox.url'], 'wss://custom.example');
|
testlib.assertEquals(settingsLib.values['cloud.url'], 'wss://custom.example');
|
||||||
testlib.assertEquals(#shellRuns, 0);
|
testlib.assertEquals(#shellRuns, 0);
|
||||||
testlib.assertTrue(containsLine(lines, '=> Keeping existing sandbox.url.'));
|
testlib.assertTrue(containsLine(lines, '=> Keeping existing cloud.url.'));
|
||||||
testlib.assertTrue(containsLine(lines, '=> Sandbox password already configured.'));
|
testlib.assertTrue(containsLine(lines, '=> Cloud password already configured.'));
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.test('postinstall treats empty sandbox password as missing', function()
|
testlib.test('postinstall treats empty cloud password as missing', function()
|
||||||
local settingsLib = fakeSettings({
|
local settingsLib = fakeSettings({
|
||||||
['sandbox.url'] = 'wss://custom.example',
|
['cloud.url'] = 'wss://custom.example',
|
||||||
['sandbox.password'] = '',
|
['cloud.password'] = '',
|
||||||
});
|
});
|
||||||
local lines = runPostinstall(settingsLib);
|
local lines = runPostinstall(settingsLib);
|
||||||
|
|
||||||
testlib.assertTrue(containsLine(lines, '=> Sandbox password not set.'));
|
testlib.assertTrue(containsLine(lines, '=> Cloud password not set.'));
|
||||||
end);
|
end);
|
||||||
|
|
||||||
testlib.run();
|
testlib.run();
|
||||||
|
|||||||
@ -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
|
-- Lives under tests/harness/ so runtest.lua does NOT auto-discover it as a test
|
||||||
-- (discoverTests skips subdirectories), while /tests is still mounted so it can
|
-- (discoverTests skips subdirectories), while /tests is still mounted so it can
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos",
|
"name": "trapos",
|
||||||
"version": "0.11.6",
|
"version": "0.12.0",
|
||||||
"description": "TrapOS full install meta-package",
|
"description": "TrapOS full install meta-package",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"trapos-boot",
|
"trapos-boot",
|
||||||
@ -8,7 +8,7 @@
|
|||||||
"trapos-ui",
|
"trapos-ui",
|
||||||
"trapos-test",
|
"trapos-test",
|
||||||
"trapos-ai",
|
"trapos-ai",
|
||||||
"trapos-sandbox"
|
"trapos-cloud"
|
||||||
],
|
],
|
||||||
"files": [],
|
"files": [],
|
||||||
"autostart": []
|
"autostart": []
|
||||||
|
|||||||
@ -6,7 +6,7 @@ local function parentDir(path)
|
|||||||
return path:match('^(.*)/[^/]+$');
|
return path:match('^(.*)/[^/]+$');
|
||||||
end
|
end
|
||||||
|
|
||||||
local function sandboxPath(root, path)
|
local function scopedPath(root, path)
|
||||||
if path:sub(1, 1) == '/' then
|
if path:sub(1, 1) == '/' then
|
||||||
return root .. path;
|
return root .. path;
|
||||||
end
|
end
|
||||||
@ -16,16 +16,16 @@ end
|
|||||||
local function fakeFs(root)
|
local function fakeFs(root)
|
||||||
return {
|
return {
|
||||||
exists = function(path)
|
exists = function(path)
|
||||||
return fs.exists(sandboxPath(root, path));
|
return fs.exists(scopedPath(root, path));
|
||||||
end,
|
end,
|
||||||
makeDir = function(path)
|
makeDir = function(path)
|
||||||
fs.makeDir(sandboxPath(root, path));
|
fs.makeDir(scopedPath(root, path));
|
||||||
end,
|
end,
|
||||||
open = function(path, mode)
|
open = function(path, mode)
|
||||||
return fs.open(sandboxPath(root, path), mode);
|
return fs.open(scopedPath(root, path), mode);
|
||||||
end,
|
end,
|
||||||
delete = function(path)
|
delete = function(path)
|
||||||
fs.delete(sandboxPath(root, path));
|
fs.delete(scopedPath(root, path));
|
||||||
end,
|
end,
|
||||||
};
|
};
|
||||||
end
|
end
|
||||||
@ -59,7 +59,7 @@ local function installRoutes()
|
|||||||
};
|
};
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Run install-trapos.lua inside a sandboxed env. `args` is a list of CLI args.
|
-- Run install-trapos.lua inside a isolated env. `args` is a list of CLI args.
|
||||||
-- Returns { calls, root } for assertions.
|
-- Returns { calls, root } for assertions.
|
||||||
local function runInstall(root, args)
|
local function runInstall(root, args)
|
||||||
fs.delete(root);
|
fs.delete(root);
|
||||||
|
|||||||
@ -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",
|
"version": "0.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trap-sandbox",
|
"name": "trapos-server",
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/websocket": "^11",
|
"@fastify/websocket": "^11",
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trap-sandbox",
|
"name": "trapos-server",
|
||||||
"version": "0.2.2",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -9,7 +9,7 @@ import mcpModule from "./modules/mcp/index.js";
|
|||||||
export type Config = {
|
export type Config = {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
sandboxPassword?: string;
|
serverPassword?: string;
|
||||||
requestTimeoutMs: number;
|
requestTimeoutMs: number;
|
||||||
opencodeUrl?: string;
|
opencodeUrl?: string;
|
||||||
opencodeUsername: string;
|
opencodeUsername: string;
|
||||||
@ -48,7 +48,7 @@ export async function createApp(
|
|||||||
await app.register(gatewayModule, {
|
await app.register(gatewayModule, {
|
||||||
registry,
|
registry,
|
||||||
probe,
|
probe,
|
||||||
resolveAccount: (secret) => resolveAccount(secret, config.sandboxPassword),
|
resolveAccount: (secret) => resolveAccount(secret, config.serverPassword),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mcpApp = config.mcpEnabled ? await createMcpApp(config, registry) : null;
|
const mcpApp = config.mcpEnabled ? await createMcpApp(config, registry) : null;
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import { createApp, type Config } from "./app.js";
|
import { createApp, type Config } from "./app.js";
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
host: process.env.SANDBOX_HOST ?? "0.0.0.0",
|
host: process.env.TRAPOS_SERVER_HOST ?? "0.0.0.0",
|
||||||
port: readPort(process.env.SANDBOX_PORT, 4444),
|
port: readPort(process.env.TRAPOS_SERVER_PORT, 4444),
|
||||||
sandboxPassword: process.env.SANDBOX_PASSWORD,
|
serverPassword: process.env.TRAPOS_SERVER_PASSWORD,
|
||||||
requestTimeoutMs: readPositiveInt(process.env.SANDBOX_REQUEST_TIMEOUT_MS, 600_000),
|
requestTimeoutMs: readPositiveInt(process.env.TRAPOS_SERVER_REQUEST_TIMEOUT_MS, 600_000),
|
||||||
opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4242",
|
opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4242",
|
||||||
opencodeUsername: process.env.OPENCODE_USERNAME ?? "opencode",
|
opencodeUsername: process.env.OPENCODE_USERNAME ?? "opencode",
|
||||||
opencodeServerPassword: process.env.OPENCODE_SERVER_PASSWORD,
|
opencodeServerPassword: process.env.OPENCODE_SERVER_PASSWORD,
|
||||||
@ -24,7 +24,7 @@ const { app, mcpApp } = await createApp(config);
|
|||||||
try {
|
try {
|
||||||
await app.listen({ host: config.host, port: config.port });
|
await app.listen({ host: config.host, port: config.port });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reportListenError(err, config.host, config.port, "SANDBOX_PORT");
|
reportListenError(err, config.host, config.port, "TRAPOS_SERVER_PORT");
|
||||||
app.log.error(err);
|
app.log.error(err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@ -72,7 +72,7 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|||||||
function reportListenError(err: unknown, host: string, port: number, portEnv: string): void {
|
function reportListenError(err: unknown, host: string, port: number, portEnv: string): void {
|
||||||
if (isErrnoException(err) && err.code === "EADDRINUSE") {
|
if (isErrnoException(err) && err.code === "EADDRINUSE") {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`\n[trap-sandbox] Port ${port} is already in use on ${host}.\n` +
|
`\n[trapos-server] Port ${port} is already in use on ${host}.\n` +
|
||||||
`Another instance may be running, or set ${portEnv} to a free port.\n\n`,
|
`Another instance may be running, or set ${portEnv} to a free port.\n\n`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@ -24,7 +24,7 @@ type JsonRpcRequest = {
|
|||||||
params?: unknown;
|
params?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVER_VERSION = "0.2.2";
|
const SERVER_VERSION = "0.3.0";
|
||||||
|
|
||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
{
|
{
|
||||||
@ -97,7 +97,7 @@ async function handleSingle(request: JsonRpcRequest, deps: McpDeps): Promise<unk
|
|||||||
return jsonRpcResult(id, {
|
return jsonRpcResult(id, {
|
||||||
protocolVersion: "2024-11-05",
|
protocolVersion: "2024-11-05",
|
||||||
capabilities: { tools: {} },
|
capabilities: { tools: {} },
|
||||||
serverInfo: { name: "trap-sandbox", version: SERVER_VERSION },
|
serverInfo: { name: "trapos-mcp", version: SERVER_VERSION },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,7 +47,7 @@ export function createConnection(deps: ConnectionDeps): Connection {
|
|||||||
error?: GatewayErrorBody,
|
error?: GatewayErrorBody,
|
||||||
): void {
|
): void {
|
||||||
const frame: Record<string, unknown> = {
|
const frame: Record<string, unknown> = {
|
||||||
event: "gateway_response",
|
event: "server_response",
|
||||||
traposReceiverId: req.traposSenderId,
|
traposReceiverId: req.traposSenderId,
|
||||||
messageId: req.messageId,
|
messageId: req.messageId,
|
||||||
type: req.type,
|
type: req.type,
|
||||||
@ -84,7 +84,7 @@ export function createConnection(deps: ConnectionDeps): Connection {
|
|||||||
async function handleProbeGateway(req: ComputerRequest): Promise<void> {
|
async function handleProbeGateway(req: ComputerRequest): Promise<void> {
|
||||||
log.debug({ accountId: state?.accountId, traposId: state?.traposId, messageId: req.messageId }, "probing gateway dependencies");
|
log.debug({ accountId: state?.accountId, traposId: state?.traposId, messageId: req.messageId }, "probing gateway dependencies");
|
||||||
const result = await probe.probe();
|
const result = await probe.probe();
|
||||||
const payload: Record<string, unknown> = { sandboxOk: true, opencodeOk: result.ok };
|
const payload: Record<string, unknown> = { serverOk: true, opencodeOk: result.ok };
|
||||||
if (!result.ok && result.detail !== undefined) {
|
if (!result.ok && result.detail !== undefined) {
|
||||||
payload.opencodeDetail = result.detail;
|
payload.opencodeDetail = result.detail;
|
||||||
}
|
}
|
||||||
@ -111,7 +111,7 @@ export function createConnection(deps: ConnectionDeps): Connection {
|
|||||||
log.debug({ accountId: current.accountId, traposId: current.traposId, reason: "duplicate_hello" }, "ignoring duplicate hello on live connection");
|
log.debug({ accountId: current.accountId, traposId: current.traposId, reason: "duplicate_hello" }, "ignoring duplicate hello on live connection");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (frame.type === "probe_gateway") {
|
if (frame.type === "probe_server") {
|
||||||
await handleProbeGateway(frame);
|
await handleProbeGateway(frame);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -120,7 +120,7 @@ export function createConnection(deps: ConnectionDeps): Connection {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (frame.event === "computer_response") {
|
if (frame.event === "computer_response") {
|
||||||
// resolve a pending gateway_request
|
// resolve a pending server_request
|
||||||
registry.resolveResponse(socket, current.accountId, current.traposId, frame);
|
registry.resolveResponse(socket, current.accountId, current.traposId, frame);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,7 +135,7 @@ export function createConnection(deps: ConnectionDeps): Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The gateway never legitimately receives gateway_* events from a client.
|
// The gateway never legitimately receives gateway_* events from a client.
|
||||||
if (frame.event === "gateway_request" || frame.event === "gateway_response") {
|
if (frame.event === "server_request" || frame.event === "server_response") {
|
||||||
log.debug({ event: frame.event, reason: "wrong_direction" }, "dropping wrong-direction frame");
|
log.debug({ event: frame.event, reason: "wrong_direction" }, "dropping wrong-direction frame");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -62,7 +62,7 @@ const computerResponseSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const gatewayRequestSchema = z.object({
|
const gatewayRequestSchema = z.object({
|
||||||
event: z.literal("gateway_request"),
|
event: z.literal("server_request"),
|
||||||
traposReceiverId: z.string().min(1),
|
traposReceiverId: z.string().min(1),
|
||||||
messageId: z.string().min(1),
|
messageId: z.string().min(1),
|
||||||
type: z.string().min(1),
|
type: z.string().min(1),
|
||||||
@ -70,7 +70,7 @@ const gatewayRequestSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const gatewayResponseSchema = z.object({
|
const gatewayResponseSchema = z.object({
|
||||||
event: z.literal("gateway_response"),
|
event: z.literal("server_response"),
|
||||||
traposReceiverId: z.string().min(1),
|
traposReceiverId: z.string().min(1),
|
||||||
messageId: z.string().min(1),
|
messageId: z.string().min(1),
|
||||||
type: z.string().min(1),
|
type: z.string().min(1),
|
||||||
@ -121,7 +121,7 @@ export class GatewayRegistry {
|
|||||||
|
|
||||||
const messageId = randomUUID();
|
const messageId = randomUUID();
|
||||||
const frame = {
|
const frame = {
|
||||||
event: "gateway_request",
|
event: "server_request",
|
||||||
traposReceiverId: traposId,
|
traposReceiverId: traposId,
|
||||||
messageId,
|
messageId,
|
||||||
type,
|
type,
|
||||||
@ -159,7 +159,7 @@ export class GatewayRegistry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve a pending gateway_request from a matching computer_response. Silently
|
// Resolve a pending server_request from a matching computer_response. Silently
|
||||||
// dropped (with a debug log) when there is no matching pending.
|
// dropped (with a debug log) when there is no matching pending.
|
||||||
resolveResponse(socket: GatewaySocket, accountId: string, traposId: string, frame: ComputerResponse): void {
|
resolveResponse(socket: GatewaySocket, accountId: string, traposId: string, frame: ComputerResponse): void {
|
||||||
const entry = this.entries.get(this.key(accountId, traposId));
|
const entry = this.entries.get(this.key(accountId, traposId));
|
||||||
@ -1,47 +1,47 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { formatFailure, startCraftos, startSandbox } from "./harness.js";
|
import { formatFailure, startCraftos, startServer } from "./harness.js";
|
||||||
|
|
||||||
const SECRET = "s3cr3t-canary";
|
const SECRET = "s3cr3t-canary";
|
||||||
|
|
||||||
test("hello with the wrong secret is rejected in-band (unauthorized)", async () => {
|
test("hello with the wrong secret is rejected in-band (unauthorized)", async () => {
|
||||||
const sandbox = await startSandbox({ sandboxPassword: SECRET });
|
const server = await startServer({ serverPassword: SECRET });
|
||||||
const craftos = startCraftos("gateway-client.lua", {
|
const craftos = startCraftos("gateway-client.lua", {
|
||||||
computerId: 7,
|
computerId: 7,
|
||||||
computerLabel: "auth-bad",
|
computerLabel: "auth-bad",
|
||||||
shellArgs: [sandbox.baseUrl, "wrong-secret", "auth"],
|
shellArgs: [server.baseUrl, "wrong-secret", "auth"],
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await craftos.done;
|
const result = await craftos.done;
|
||||||
assert.match(
|
assert.match(
|
||||||
result.output,
|
result.output,
|
||||||
/__SANDBOX_HELLO__ ok=false code=unauthorized/,
|
/__CLOUD_HELLO__ ok=false code=unauthorized/,
|
||||||
formatFailure("expected hello ok=false code=unauthorized", result.output),
|
formatFailure("expected hello ok=false code=unauthorized", result.output),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
craftos.abort();
|
craftos.abort();
|
||||||
await craftos.done.catch(() => undefined);
|
await craftos.done.catch(() => undefined);
|
||||||
await sandbox.close();
|
await server.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hello with the correct secret succeeds", async () => {
|
test("hello with the correct secret succeeds", async () => {
|
||||||
const sandbox = await startSandbox({ sandboxPassword: SECRET });
|
const server = await startServer({ serverPassword: SECRET });
|
||||||
const craftos = startCraftos("gateway-client.lua", {
|
const craftos = startCraftos("gateway-client.lua", {
|
||||||
computerId: 7,
|
computerId: 7,
|
||||||
computerLabel: "auth-ok",
|
computerLabel: "auth-ok",
|
||||||
shellArgs: [sandbox.baseUrl, SECRET, "auth"],
|
shellArgs: [server.baseUrl, SECRET, "auth"],
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await craftos.done;
|
const result = await craftos.done;
|
||||||
assert.match(
|
assert.match(
|
||||||
result.output,
|
result.output,
|
||||||
/__SANDBOX_HELLO__ ok=true/,
|
/__CLOUD_HELLO__ ok=true/,
|
||||||
formatFailure("expected hello ok=true", result.output),
|
formatFailure("expected hello ok=true", result.output),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
craftos.abort();
|
craftos.abort();
|
||||||
await craftos.done.catch(() => undefined);
|
await craftos.done.catch(() => undefined);
|
||||||
await sandbox.close();
|
await server.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { formatFailure, startCraftos, startSandbox, waitFor } from "./harness.js";
|
import { formatFailure, startCraftos, startServer, waitFor } from "./harness.js";
|
||||||
|
|
||||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout
|
|||||||
// "gateway connection opened" log lines. With the fix the loser yields, so opens stay at the
|
// "gateway connection opened" log lines. With the fix the loser yields, so opens stay at the
|
||||||
// two initial connects; pre-fix this number climbed once per reconnectDelay.
|
// two initial connects; pre-fix this number climbed once per reconnectDelay.
|
||||||
test("displaced daemon yields instead of reconnect-looping", async () => {
|
test("displaced daemon yields instead of reconnect-looping", async () => {
|
||||||
const sandbox = await startSandbox({ captureLogs: true });
|
const server = await startServer({ captureLogs: true });
|
||||||
const TRAPOS_ID = "9:dup";
|
const TRAPOS_ID = "9:dup";
|
||||||
// Aggressive reconnect so the pre-fix loop would be obvious within the observation window.
|
// Aggressive reconnect so the pre-fix loop would be obvious within the observation window.
|
||||||
const RECONNECT_DELAY = "1";
|
const RECONNECT_DELAY = "1";
|
||||||
@ -21,29 +21,29 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
|
|||||||
const first = startCraftos("gateway-client.lua", {
|
const first = startCraftos("gateway-client.lua", {
|
||||||
computerId: 9,
|
computerId: 9,
|
||||||
computerLabel: "dup",
|
computerLabel: "dup",
|
||||||
shellArgs: [sandbox.baseUrl, "-", "idle", RECONNECT_DELAY],
|
shellArgs: [server.baseUrl, "-", "idle", RECONNECT_DELAY],
|
||||||
});
|
});
|
||||||
|
|
||||||
let second: ReturnType<typeof startCraftos> | undefined;
|
let second: ReturnType<typeof startCraftos> | undefined;
|
||||||
try {
|
try {
|
||||||
await waitFor(() => first.snapshot().includes("__SANDBOX_READY__"), 12_000, "first connected");
|
await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
|
||||||
await waitFor(() => sandbox.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
|
await waitFor(() => server.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
|
||||||
|
|
||||||
const secondHandle = startCraftos("gateway-client.lua", {
|
const secondHandle = startCraftos("gateway-client.lua", {
|
||||||
computerId: 9,
|
computerId: 9,
|
||||||
computerLabel: "dup",
|
computerLabel: "dup",
|
||||||
shellArgs: [sandbox.baseUrl, "-", "idle", RECONNECT_DELAY],
|
shellArgs: [server.baseUrl, "-", "idle", RECONNECT_DELAY],
|
||||||
});
|
});
|
||||||
second = secondHandle;
|
second = secondHandle;
|
||||||
await waitFor(() => secondHandle.snapshot().includes("__SANDBOX_READY__"), 12_000, "second connected");
|
await waitFor(() => secondHandle.snapshot().includes("__CLOUD_READY__"), 12_000, "second connected");
|
||||||
|
|
||||||
const openedAfterBoth = sandbox.countLog("gateway connection opened");
|
const openedAfterBoth = server.countLog("gateway connection opened");
|
||||||
|
|
||||||
// Let several reconnect intervals elapse. If the displaced daemon reconnected, we would
|
// Let several reconnect intervals elapse. If the displaced daemon reconnected, we would
|
||||||
// see a fresh "gateway connection opened" every ~1s here.
|
// see a fresh "gateway connection opened" every ~1s here.
|
||||||
await sleep(5_000);
|
await sleep(5_000);
|
||||||
|
|
||||||
const openedAfterWait = sandbox.countLog("gateway connection opened");
|
const openedAfterWait = server.countLog("gateway connection opened");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
openedAfterWait,
|
openedAfterWait,
|
||||||
openedAfterBoth,
|
openedAfterBoth,
|
||||||
@ -51,10 +51,10 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
|
|||||||
);
|
);
|
||||||
// The two initial connects, and nothing more.
|
// The two initial connects, and nothing more.
|
||||||
assert.ok(openedAfterWait <= 3, `bounded connection count, got ${openedAfterWait}`);
|
assert.ok(openedAfterWait <= 3, `bounded connection count, got ${openedAfterWait}`);
|
||||||
assert.equal(sandbox.registry.count(), 1, "exactly one registration survives");
|
assert.equal(server.registry.count(), 1, "exactly one registration survives");
|
||||||
|
|
||||||
// The surviving (newest) daemon still answers a gateway->computer ping.
|
// The surviving (newest) daemon still answers a gateway->computer ping.
|
||||||
const payload = await sandbox.registry.request("local", TRAPOS_ID, "ping", {}, 2_000);
|
const payload = await server.registry.request("local", TRAPOS_ID, "ping", {}, 2_000);
|
||||||
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "survivor answers reverse ping");
|
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "survivor answers reverse ping");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -68,6 +68,6 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
|
|||||||
first.abort();
|
first.abort();
|
||||||
second?.abort();
|
second?.abort();
|
||||||
await Promise.all([first.done.catch(() => undefined), second?.done.catch(() => undefined)]);
|
await Promise.all([first.done.catch(() => undefined), second?.done.catch(() => undefined)]);
|
||||||
await sandbox.close();
|
await server.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { formatFailure, startCraftos, startSandbox, waitFor } from "./harness.js";
|
import { formatFailure, startCraftos, startServer, waitFor } from "./harness.js";
|
||||||
|
|
||||||
// Two real computers with the same traposId (id:label): newest-wins. After both
|
// Two real computers with the same traposId (id:label): newest-wins. After both
|
||||||
// complete hello, exactly one registration exists; aborting the displaced first
|
// complete hello, exactly one registration exists; aborting the displaced first
|
||||||
// computer does not affect the live registration, and a gateway->computer ping is
|
// computer does not affect the live registration, and a gateway->computer ping is
|
||||||
// still answered by the newest computer.
|
// still answered by the newest computer.
|
||||||
test("duplicate traposId is resolved newest-wins", async () => {
|
test("duplicate traposId is resolved newest-wins", async () => {
|
||||||
const sandbox = await startSandbox();
|
const server = await startServer();
|
||||||
const TRAPOS_ID = "9:dup";
|
const TRAPOS_ID = "9:dup";
|
||||||
|
|
||||||
// Newest-wins is decided by the order hello frames reach the gateway, which is
|
// Newest-wins is decided by the order hello frames reach the gateway, which is
|
||||||
@ -17,28 +17,28 @@ test("duplicate traposId is resolved newest-wins", async () => {
|
|||||||
const first = startCraftos("gateway-client.lua", {
|
const first = startCraftos("gateway-client.lua", {
|
||||||
computerId: 9,
|
computerId: 9,
|
||||||
computerLabel: "dup",
|
computerLabel: "dup",
|
||||||
shellArgs: [sandbox.baseUrl, "-", "idle", "60"],
|
shellArgs: [server.baseUrl, "-", "idle", "60"],
|
||||||
});
|
});
|
||||||
|
|
||||||
let second: ReturnType<typeof startCraftos> | undefined;
|
let second: ReturnType<typeof startCraftos> | undefined;
|
||||||
try {
|
try {
|
||||||
await waitFor(() => first.snapshot().includes("__SANDBOX_READY__"), 12_000, "first connected");
|
await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
|
||||||
await waitFor(() => sandbox.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
|
await waitFor(() => server.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
|
||||||
|
|
||||||
const secondHandle = startCraftos("gateway-client.lua", {
|
const secondHandle = startCraftos("gateway-client.lua", {
|
||||||
computerId: 9,
|
computerId: 9,
|
||||||
computerLabel: "dup",
|
computerLabel: "dup",
|
||||||
shellArgs: [sandbox.baseUrl, "-", "idle"],
|
shellArgs: [server.baseUrl, "-", "idle"],
|
||||||
});
|
});
|
||||||
second = secondHandle;
|
second = secondHandle;
|
||||||
await waitFor(() => secondHandle.snapshot().includes("__SANDBOX_READY__"), 12_000, "second connected");
|
await waitFor(() => secondHandle.snapshot().includes("__CLOUD_READY__"), 12_000, "second connected");
|
||||||
// Newest-wins: still exactly one registration for the shared id.
|
// Newest-wins: still exactly one registration for the shared id.
|
||||||
assert.equal(sandbox.registry.count(), 1, "exactly one registration after the duplicate");
|
assert.equal(server.registry.count(), 1, "exactly one registration after the duplicate");
|
||||||
assert.ok(sandbox.registry.has("local", TRAPOS_ID));
|
assert.ok(server.registry.has("local", TRAPOS_ID));
|
||||||
|
|
||||||
first.abort();
|
first.abort();
|
||||||
await first.done.catch(() => undefined);
|
await first.done.catch(() => undefined);
|
||||||
const payload = await sandbox.registry.request("local", TRAPOS_ID, "ping", {}, 2_000);
|
const payload = await server.registry.request("local", TRAPOS_ID, "ping", {}, 2_000);
|
||||||
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "newest stayed registered after first exited");
|
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "newest stayed registered after first exited");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -52,6 +52,6 @@ test("duplicate traposId is resolved newest-wins", async () => {
|
|||||||
first.abort();
|
first.abort();
|
||||||
second?.abort();
|
second?.abort();
|
||||||
await Promise.all([first.done.catch(() => undefined), second?.done.catch(() => undefined)]);
|
await Promise.all([first.done.catch(() => undefined), second?.done.catch(() => undefined)]);
|
||||||
await sandbox.close();
|
await server.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1,27 +1,27 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { formatFailure, startCraftos, startFakeOpencode, startSandbox } from "./harness.js";
|
import { formatFailure, startCraftos, startFakeOpencode, startServer } from "./harness.js";
|
||||||
|
|
||||||
// Full-boot e2e: a real CraftOS computer runs the real servers/sandbox.lua daemon
|
// Full-boot e2e: a real CraftOS computer runs the real servers/cloud.lua daemon
|
||||||
// (driven by sandbox.* settings) and the real `sandbox health` program against the
|
// (driven by cloud.* settings) and the real `cloud health` program against the
|
||||||
// real gateway, with a healthy fake-opencode behind it.
|
// real gateway, with a healthy fake-opencode behind it.
|
||||||
test("full boot: settings -> daemon -> sandbox health prints sandboxOk/opencodeOk true", async () => {
|
test("full boot: settings -> daemon -> cloud health prints serverOk/opencodeOk true", async () => {
|
||||||
const opencode = await startFakeOpencode({ status: 200 });
|
const opencode = await startFakeOpencode({ status: 200 });
|
||||||
const sandbox = await startSandbox({ sandboxPassword: "boot-secret", opencodeUrl: opencode.url });
|
const server = await startServer({ serverPassword: "boot-secret", opencodeUrl: opencode.url });
|
||||||
const craftos = startCraftos("full-boot.lua", {
|
const craftos = startCraftos("full-boot.lua", {
|
||||||
computerId: 11,
|
computerId: 11,
|
||||||
computerLabel: "boot",
|
computerLabel: "boot",
|
||||||
shellArgs: [sandbox.baseUrl, "boot-secret"],
|
shellArgs: [server.baseUrl, "boot-secret"],
|
||||||
timeoutMs: 20_000,
|
timeoutMs: 20_000,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await craftos.done;
|
const result = await craftos.done;
|
||||||
assert.match(result.output, /sandboxOk:\s*true/, formatFailure("expected sandboxOk: true", result.output));
|
assert.match(result.output, /serverOk:\s*true/, formatFailure("expected serverOk: true", result.output));
|
||||||
assert.match(result.output, /opencodeOk:\s*true/, formatFailure("expected opencodeOk: true", result.output));
|
assert.match(result.output, /opencodeOk:\s*true/, formatFailure("expected opencodeOk: true", result.output));
|
||||||
} finally {
|
} finally {
|
||||||
craftos.abort();
|
craftos.abort();
|
||||||
await craftos.done.catch(() => undefined);
|
await craftos.done.catch(() => undefined);
|
||||||
await sandbox.close();
|
await server.close();
|
||||||
await opencode.close();
|
await opencode.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -14,25 +14,25 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|||||||
const LUA_DIR = join(HERE, "lua");
|
const LUA_DIR = join(HERE, "lua");
|
||||||
const REPO_ROOT = join(HERE, "../../..");
|
const REPO_ROOT = join(HERE, "../../..");
|
||||||
|
|
||||||
export type Sandbox = {
|
export type Server = {
|
||||||
registry: GatewayRegistry;
|
registry: GatewayRegistry;
|
||||||
// The MCP Fastify app, sharing the gateway registry. Drive it in-process with
|
// The MCP Fastify app, sharing the gateway registry. Drive it in-process with
|
||||||
// `mcpApp.inject({ method: "POST", url: "/", payload })` — no real listener needed.
|
// `mcpApp.inject({ method: "POST", url: "/", payload })` — no real listener needed.
|
||||||
mcpApp: FastifyInstance;
|
mcpApp: FastifyInstance;
|
||||||
// Gateway origin (no path). This is what `sandbox.url` expects: libsandbox appends
|
// Gateway origin (no path). This is what `cloud.url` expects: libcloud appends
|
||||||
// `/gateway` itself. Pass this to the Lua scripts.
|
// `/gateway` itself. Pass this to the Lua scripts.
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
// Full WS endpoint (origin + `/gateway`), for direct ws clients.
|
// Full WS endpoint (origin + `/gateway`), for direct ws clients.
|
||||||
socketUrl: string;
|
socketUrl: string;
|
||||||
// Count captured server log lines whose `msg` contains `substring` (0 unless the
|
// Count captured server log lines whose `msg` contains `substring` (0 unless the
|
||||||
// sandbox was started with `captureLogs: true`). Lets a test observe connection
|
// server was started with `captureLogs: true`). Lets a test observe connection
|
||||||
// churn that registry.count() hides (newest-wins keeps the count pinned at 1).
|
// churn that registry.count() hides (newest-wins keeps the count pinned at 1).
|
||||||
countLog: (substring: string) => number;
|
countLog: (substring: string) => number;
|
||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SandboxOptions = {
|
export type ServerOptions = {
|
||||||
sandboxPassword?: string;
|
serverPassword?: string;
|
||||||
opencodeUrl?: string;
|
opencodeUrl?: string;
|
||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
// Capture server logs (at info level) so tests can assert on message frequency.
|
// Capture server logs (at info level) so tests can assert on message frequency.
|
||||||
@ -41,7 +41,7 @@ export type SandboxOptions = {
|
|||||||
|
|
||||||
// Real Fastify gateway on an ephemeral port. Mirrors index.ts's wiring but with a
|
// Real Fastify gateway on an ephemeral port. Mirrors index.ts's wiring but with a
|
||||||
// silent logger and test-supplied config (never reads process.env).
|
// silent logger and test-supplied config (never reads process.env).
|
||||||
export async function startSandbox(opts: SandboxOptions = {}): Promise<Sandbox> {
|
export async function startServer(opts: ServerOptions = {}): Promise<Server> {
|
||||||
const logLines: string[] = [];
|
const logLines: string[] = [];
|
||||||
const logger: Config["logger"] = opts.captureLogs
|
const logger: Config["logger"] = opts.captureLogs
|
||||||
? { level: "info", stream: { write: (line: string) => void logLines.push(line) } }
|
? { level: "info", stream: { write: (line: string) => void logLines.push(line) } }
|
||||||
@ -51,7 +51,7 @@ export async function startSandbox(opts: SandboxOptions = {}): Promise<Sandbox>
|
|||||||
const config: Config = {
|
const config: Config = {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 0,
|
port: 0,
|
||||||
sandboxPassword: opts.sandboxPassword,
|
serverPassword: opts.serverPassword,
|
||||||
requestTimeoutMs: timeoutMs,
|
requestTimeoutMs: timeoutMs,
|
||||||
opencodeUrl: opts.opencodeUrl,
|
opencodeUrl: opts.opencodeUrl,
|
||||||
opencodeUsername: "opencode",
|
opencodeUsername: "opencode",
|
||||||
@ -130,13 +130,13 @@ export type CraftosHandle = {
|
|||||||
done: Promise<CraftosResult>;
|
done: Promise<CraftosResult>;
|
||||||
abort: () => void;
|
abort: () => void;
|
||||||
// Stdout/stderr accumulated so far; lets a test await a marker line while the
|
// Stdout/stderr accumulated so far; lets a test await a marker line while the
|
||||||
// process is still running (e.g. wait for "__SANDBOX_READY__" before acting).
|
// process is still running (e.g. wait for "__CLOUD_READY__" before acting).
|
||||||
snapshot: () => string;
|
snapshot: () => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spawn headless CraftOS-PC running a Lua script from test-integration/lua, with the
|
// Spawn headless CraftOS-PC running a Lua script from test-integration/lua, with the
|
||||||
// repo mounted read-only (/apis, /servers, /programs, /trapos) so the scripts can
|
// repo mounted read-only (/apis, /servers, /programs, /trapos) so the scripts can
|
||||||
// require the real libsandbox / servers / programs.
|
// require the real libcloud / servers / programs.
|
||||||
export function startCraftos(
|
export function startCraftos(
|
||||||
luaName: string,
|
luaName: string,
|
||||||
opts: { computerId?: number; computerLabel?: string; shellArgs?: string[]; timeoutMs?: number } = {},
|
opts: { computerId?: number; computerLabel?: string; shellArgs?: string[]; timeoutMs?: number } = {},
|
||||||
@ -146,7 +146,7 @@ export function startCraftos(
|
|||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
const done = (async (): Promise<CraftosResult> => {
|
const done = (async (): Promise<CraftosResult> => {
|
||||||
const dataDir = await mkdtemp(join(tmpdir(), "trap-sandbox-it-"));
|
const dataDir = await mkdtemp(join(tmpdir(), "trapos-server-it-"));
|
||||||
const watchdog = setTimeout(() => controller.abort(), timeoutMs);
|
const watchdog = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
if (!existsSync(join(REPO_ROOT, ".stage/apis"))) {
|
if (!existsSync(join(REPO_ROOT, ".stage/apis"))) {
|
||||||
@ -1,49 +1,49 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { formatFailure, startCraftos, startFakeOpencode, startSandbox } from "./harness.js";
|
import { formatFailure, startCraftos, startFakeOpencode, startServer } from "./harness.js";
|
||||||
|
|
||||||
test("probe_gateway reports opencodeOk=true when opencode is healthy", async () => {
|
test("probe_server reports opencodeOk=true when opencode is healthy", async () => {
|
||||||
const opencode = await startFakeOpencode({ status: 200 });
|
const opencode = await startFakeOpencode({ status: 200 });
|
||||||
const sandbox = await startSandbox({ opencodeUrl: opencode.url });
|
const server = await startServer({ opencodeUrl: opencode.url });
|
||||||
const craftos = startCraftos("gateway-client.lua", {
|
const craftos = startCraftos("gateway-client.lua", {
|
||||||
computerId: 7,
|
computerId: 7,
|
||||||
computerLabel: "health-ok",
|
computerLabel: "health-ok",
|
||||||
shellArgs: [sandbox.baseUrl, "-", "health"],
|
shellArgs: [server.baseUrl, "-", "health"],
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await craftos.done;
|
const result = await craftos.done;
|
||||||
assert.match(
|
assert.match(
|
||||||
result.output,
|
result.output,
|
||||||
/__SANDBOX_HEALTH__ sandboxOk=true opencodeOk=true/,
|
/__CLOUD_HEALTH__ serverOk=true opencodeOk=true/,
|
||||||
formatFailure("expected sandboxOk=true opencodeOk=true", result.output),
|
formatFailure("expected serverOk=true opencodeOk=true", result.output),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
craftos.abort();
|
craftos.abort();
|
||||||
await craftos.done.catch(() => undefined);
|
await craftos.done.catch(() => undefined);
|
||||||
await sandbox.close();
|
await server.close();
|
||||||
await opencode.close();
|
await opencode.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("probe_gateway reports opencodeOk=false when opencode is unhealthy", async () => {
|
test("probe_server reports opencodeOk=false when opencode is unhealthy", async () => {
|
||||||
const opencode = await startFakeOpencode({ status: 500 });
|
const opencode = await startFakeOpencode({ status: 500 });
|
||||||
const sandbox = await startSandbox({ opencodeUrl: opencode.url });
|
const server = await startServer({ opencodeUrl: opencode.url });
|
||||||
const craftos = startCraftos("gateway-client.lua", {
|
const craftos = startCraftos("gateway-client.lua", {
|
||||||
computerId: 7,
|
computerId: 7,
|
||||||
computerLabel: "health-bad",
|
computerLabel: "health-bad",
|
||||||
shellArgs: [sandbox.baseUrl, "-", "health"],
|
shellArgs: [server.baseUrl, "-", "health"],
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await craftos.done;
|
const result = await craftos.done;
|
||||||
assert.match(
|
assert.match(
|
||||||
result.output,
|
result.output,
|
||||||
/__SANDBOX_HEALTH__ sandboxOk=true opencodeOk=false/,
|
/__CLOUD_HEALTH__ serverOk=true opencodeOk=false/,
|
||||||
formatFailure("expected sandboxOk=true opencodeOk=false", result.output),
|
formatFailure("expected serverOk=true opencodeOk=false", result.output),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
craftos.abort();
|
craftos.abort();
|
||||||
await craftos.done.catch(() => undefined);
|
await craftos.done.catch(() => undefined);
|
||||||
await sandbox.close();
|
await server.close();
|
||||||
await opencode.close();
|
await opencode.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
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
|
-- against a real gateway. Reuses startSession/request/onMessage so the test covers
|
||||||
-- production client code + CraftOS JSON serialization quirks.
|
-- production client code + CraftOS JSON serialization quirks.
|
||||||
--
|
--
|
||||||
@ -17,11 +17,11 @@ if secret == '' or secret == '-' then secret = nil; end
|
|||||||
local scenario = args[3] or 'health';
|
local scenario = args[3] or 'health';
|
||||||
local reconnectDelay = tonumber(args[4] or '');
|
local reconnectDelay = tonumber(args[4] or '');
|
||||||
|
|
||||||
local createSandbox = require('/apis/libsandbox');
|
local createCloud = require('/apis/libcloud');
|
||||||
local createEventLoop = require('/apis/eventloop');
|
local createEventLoop = require('/apis/eventloop');
|
||||||
|
|
||||||
_G.bootEventLoop = createEventLoop();
|
_G.bootEventLoop = createEventLoop();
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local sessionOpts = {
|
local sessionOpts = {
|
||||||
eventloop = _G.bootEventLoop,
|
eventloop = _G.bootEventLoop,
|
||||||
url = url,
|
url = url,
|
||||||
@ -31,17 +31,17 @@ local sessionOpts = {
|
|||||||
if reconnectDelay then
|
if reconnectDelay then
|
||||||
sessionOpts.reconnectDelay = reconnectDelay;
|
sessionOpts.reconnectDelay = reconnectDelay;
|
||||||
end
|
end
|
||||||
local session = sandbox.startSession(sessionOpts);
|
local session = cloud.startSession(sessionOpts);
|
||||||
|
|
||||||
-- Connect, probe the gateway, report opencodeOk.
|
-- Connect, probe the gateway, report opencodeOk.
|
||||||
local function runHealth()
|
local function runHealth()
|
||||||
os.pullEvent('trapos_sandbox_connected');
|
os.pullEvent('trapos_cloud_connected');
|
||||||
local ok, payload = sandbox.request('probe_gateway');
|
local ok, payload = cloud.request('probe_server');
|
||||||
if ok then
|
if ok then
|
||||||
print('__SANDBOX_HEALTH__ sandboxOk=' .. tostring(payload.sandboxOk)
|
print('__CLOUD_HEALTH__ serverOk=' .. tostring(payload.serverOk)
|
||||||
.. ' opencodeOk=' .. tostring(payload.opencodeOk));
|
.. ' opencodeOk=' .. tostring(payload.opencodeOk));
|
||||||
else
|
else
|
||||||
print('__SANDBOX_HEALTH__ error=' .. tostring(type(payload) == 'table' and payload.code));
|
print('__CLOUD_HEALTH__ error=' .. tostring(type(payload) == 'table' and payload.code));
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -50,24 +50,24 @@ local function runAuth()
|
|||||||
local deadline = os.epoch('utc') + 8000;
|
local deadline = os.epoch('utc') + 8000;
|
||||||
while os.epoch('utc') < deadline do
|
while os.epoch('utc') < deadline do
|
||||||
if session.isReady() then
|
if session.isReady() then
|
||||||
print('__SANDBOX_HELLO__ ok=true');
|
print('__CLOUD_HELLO__ ok=true');
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
local err = session.lastError();
|
local err = session.lastError();
|
||||||
if err then
|
if err then
|
||||||
print('__SANDBOX_HELLO__ ok=false code=' .. tostring(err));
|
print('__CLOUD_HELLO__ ok=false code=' .. tostring(err));
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
os.sleep(0.1);
|
os.sleep(0.1);
|
||||||
end
|
end
|
||||||
print('__SANDBOX_HELLO__ ok=false code=timeout');
|
print('__CLOUD_HELLO__ ok=false code=timeout');
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Connect and stay registered so the TS side can drive the gateway->computer
|
-- Connect and stay registered so the TS side can drive the gateway->computer
|
||||||
-- direction (reverse ping) or assert registry state (duplicate newest-wins).
|
-- direction (reverse ping) or assert registry state (duplicate newest-wins).
|
||||||
local function runIdle()
|
local function runIdle()
|
||||||
os.pullEvent('trapos_sandbox_connected');
|
os.pullEvent('trapos_cloud_connected');
|
||||||
print('__SANDBOX_READY__ ' .. sandbox.traposId(os));
|
print('__CLOUD_READY__ ' .. cloud.traposId(os));
|
||||||
os.sleep(10);
|
os.sleep(10);
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
-- Integration-test driver: the REAL reactive MCP computer server. Connects the real
|
-- Integration-test driver: the REAL reactive MCP computer server. Connects the real
|
||||||
-- libsandbox daemon to the gateway and serves exec-lua / run-file / write-file via sandbox.serve
|
-- libcloud daemon to the gateway and serves exec-lua / run-file / write-file via cloud.serve
|
||||||
-- (same wiring as servers/mcp-computer-server.lua), then stays registered so the TS
|
-- (same wiring as servers/mcp-computer-server.lua), then stays registered so the TS
|
||||||
-- side can drive the MCP tools over the gateway.
|
-- side can drive the MCP tools over the gateway.
|
||||||
--
|
--
|
||||||
@ -11,23 +11,23 @@ local url = args[1];
|
|||||||
local secret = args[2];
|
local secret = args[2];
|
||||||
if secret == '' or secret == '-' then secret = nil; end
|
if secret == '' or secret == '-' then secret = nil; end
|
||||||
|
|
||||||
local createSandbox = require('/apis/libsandbox');
|
local createCloud = require('/apis/libcloud');
|
||||||
local createMcpComputer = require('/apis/libmcpcomputer');
|
local createMcpComputer = require('/apis/libmcpcomputer');
|
||||||
local createEventLoop = require('/apis/eventloop');
|
local createEventLoop = require('/apis/eventloop');
|
||||||
|
|
||||||
_G.bootEventLoop = createEventLoop();
|
_G.bootEventLoop = createEventLoop();
|
||||||
local el = _G.bootEventLoop;
|
local el = _G.bootEventLoop;
|
||||||
local sandbox = createSandbox();
|
local cloud = createCloud();
|
||||||
local mcp = createMcpComputer();
|
local mcp = createMcpComputer();
|
||||||
|
|
||||||
sandbox.startSession({
|
cloud.startSession({
|
||||||
eventloop = el,
|
eventloop = el,
|
||||||
url = url,
|
url = url,
|
||||||
secret = secret,
|
secret = secret,
|
||||||
os = os,
|
os = os,
|
||||||
});
|
});
|
||||||
|
|
||||||
sandbox.serve('exec-lua', function(payload)
|
cloud.serve('exec-lua', function(payload)
|
||||||
local result = mcp.executeLua(payload and payload.code);
|
local result = mcp.executeLua(payload and payload.code);
|
||||||
return true, {
|
return true, {
|
||||||
ok = result.ok,
|
ok = result.ok,
|
||||||
@ -37,7 +37,7 @@ sandbox.serve('exec-lua', function(payload)
|
|||||||
};
|
};
|
||||||
end, { eventloop = el });
|
end, { eventloop = el });
|
||||||
|
|
||||||
sandbox.serve('run-file', function(payload)
|
cloud.serve('run-file', function(payload)
|
||||||
local result = mcp.executeFile(payload and payload.path, nil, payload and payload.args);
|
local result = mcp.executeFile(payload and payload.path, nil, payload and payload.args);
|
||||||
return true, {
|
return true, {
|
||||||
ok = result.ok,
|
ok = result.ok,
|
||||||
@ -47,7 +47,7 @@ sandbox.serve('run-file', function(payload)
|
|||||||
};
|
};
|
||||||
end, { eventloop = el });
|
end, { eventloop = el });
|
||||||
|
|
||||||
sandbox.serve('write-file', function(payload)
|
cloud.serve('write-file', function(payload)
|
||||||
local result = mcp.writeFile(payload and payload.path, payload and payload.content);
|
local result = mcp.writeFile(payload and payload.path, payload and payload.content);
|
||||||
return true, {
|
return true, {
|
||||||
ok = result.ok,
|
ok = result.ok,
|
||||||
@ -58,8 +58,8 @@ sandbox.serve('write-file', function(payload)
|
|||||||
end, { eventloop = el });
|
end, { eventloop = el });
|
||||||
|
|
||||||
local function runReady()
|
local function runReady()
|
||||||
os.pullEvent('trapos_sandbox_connected');
|
os.pullEvent('trapos_cloud_connected');
|
||||||
print('__SANDBOX_READY__ ' .. sandbox.traposId(os));
|
print('__CLOUD_READY__ ' .. cloud.traposId(os));
|
||||||
os.sleep(12);
|
os.sleep(12);
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -1,12 +1,12 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { formatFailure, startCraftos, startSandbox, waitFor, type Sandbox } from "./harness.js";
|
import { formatFailure, startCraftos, startServer, waitFor, type Server } from "./harness.js";
|
||||||
|
|
||||||
// Drives the MCP tools end to end: a real reactive mcp-computer server (libsandbox +
|
// Drives the MCP tools end to end: a real reactive mcp-computer server (libcloud +
|
||||||
// libmcpcomputer) on a real CraftOS-PC client, reached through the real gateway. The
|
// libmcpcomputer) on a real CraftOS-PC client, reached through the real gateway. The
|
||||||
// MCP app is driven in-process via inject() (no extra listener).
|
// MCP app is driven in-process via inject() (no extra listener).
|
||||||
async function callTool(sandbox: Sandbox, name: string, args: Record<string, unknown> = {}): Promise<string> {
|
async function callTool(server: Server, name: string, args: Record<string, unknown> = {}): Promise<string> {
|
||||||
const res = await sandbox.mcpApp.inject({
|
const res = await server.mcpApp.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/",
|
url: "/",
|
||||||
payload: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } },
|
payload: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } },
|
||||||
@ -19,24 +19,24 @@ async function callTool(sandbox: Sandbox, name: string, args: Record<string, unk
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway", async () => {
|
test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway", async () => {
|
||||||
const sandbox = await startSandbox();
|
const server = await startServer();
|
||||||
const craftos = startCraftos("mcp-server-client.lua", {
|
const craftos = startCraftos("mcp-server-client.lua", {
|
||||||
computerId: 7,
|
computerId: 7,
|
||||||
computerLabel: "base",
|
computerLabel: "base",
|
||||||
shellArgs: [sandbox.baseUrl, "-"],
|
shellArgs: [server.baseUrl, "-"],
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await waitFor(() => sandbox.registry.has("local", "7:base"), 12_000, "computer registered");
|
await waitFor(() => server.registry.has("local", "7:base"), 12_000, "computer registered");
|
||||||
|
|
||||||
assert.equal(await callTool(sandbox, "probe-computers"), "ok from 7:base");
|
assert.equal(await callTool(server, "probe-computers"), "ok from 7:base");
|
||||||
|
|
||||||
const exec = await callTool(sandbox, "exec-lua", { traposId: "7:base", code: "print('hi'); return 1 + 1" });
|
const exec = await callTool(server, "exec-lua", { traposId: "7:base", code: "print('hi'); return 1 + 1" });
|
||||||
assert.match(exec, /^computer: 7:base\nok: true/);
|
assert.match(exec, /^computer: 7:base\nok: true/);
|
||||||
assert.match(exec, /"value":2/); // return value serialized
|
assert.match(exec, /"value":2/); // return value serialized
|
||||||
assert.match(exec, /\nhi$/); // captured print output
|
assert.match(exec, /\nhi$/); // captured print output
|
||||||
|
|
||||||
const program = "local a, b = ...; print(a .. ':' .. b); return arg[1], arg[2], shell.getRunningProgram()";
|
const program = "local a, b = ...; print(a .. ':' .. b); return arg[1], arg[2], shell.getRunningProgram()";
|
||||||
const write = await callTool(sandbox, "write-file", {
|
const write = await callTool(server, "write-file", {
|
||||||
traposId: "7:base",
|
traposId: "7:base",
|
||||||
path: "/mcp-it.lua",
|
path: "/mcp-it.lua",
|
||||||
content: program,
|
content: program,
|
||||||
@ -44,7 +44,7 @@ test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway",
|
|||||||
assert.match(write, /ok: true/);
|
assert.match(write, /ok: true/);
|
||||||
assert.match(write, new RegExp(`bytes: ${program.length}`));
|
assert.match(write, new RegExp(`bytes: ${program.length}`));
|
||||||
|
|
||||||
const run = await callTool(sandbox, "run-file", { traposId: "7:base", path: "/mcp-it.lua", args: ["install", "pkg"] });
|
const run = await callTool(server, "run-file", { traposId: "7:base", path: "/mcp-it.lua", args: ["install", "pkg"] });
|
||||||
assert.match(run, /^computer: 7:base\nok: true/);
|
assert.match(run, /^computer: 7:base\nok: true/);
|
||||||
assert.match(run, /"value":"install"/);
|
assert.match(run, /"value":"install"/);
|
||||||
assert.match(run, /"value":"pkg"/);
|
assert.match(run, /"value":"pkg"/);
|
||||||
@ -52,14 +52,14 @@ test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway",
|
|||||||
assert.match(run, /\ninstall:pkg$/);
|
assert.match(run, /\ninstall:pkg$/);
|
||||||
|
|
||||||
// Read it back through exec-lua to prove the write actually landed.
|
// Read it back through exec-lua to prove the write actually landed.
|
||||||
const verify = await callTool(sandbox, "exec-lua", {
|
const verify = await callTool(server, "exec-lua", {
|
||||||
traposId: "7:base",
|
traposId: "7:base",
|
||||||
code: "local h = fs.open('/mcp-it.lua', 'r'); local c = h.readAll(); h.close(); return c",
|
code: "local h = fs.open('/mcp-it.lua', 'r'); local c = h.readAll(); h.close(); return c",
|
||||||
});
|
});
|
||||||
assert.match(verify, /shell\.getRunningProgram/);
|
assert.match(verify, /shell\.getRunningProgram/);
|
||||||
|
|
||||||
// Unknown traposId is a transport (not_connected) failure, not a crash.
|
// Unknown traposId is a transport (not_connected) failure, not a crash.
|
||||||
const ghost = await callTool(sandbox, "exec-lua", { traposId: "9:ghost", code: "return 1" });
|
const ghost = await callTool(server, "exec-lua", { traposId: "9:ghost", code: "return 1" });
|
||||||
assert.match(ghost, /^computer: 9:ghost\nok: false\nerror: not_connected:/);
|
assert.match(ghost, /^computer: 9:ghost\nok: false\nerror: not_connected:/);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(formatFailure(error instanceof Error ? error.message : String(error), craftos.snapshot()), {
|
throw new Error(formatFailure(error instanceof Error ? error.message : String(error), craftos.snapshot()), {
|
||||||
@ -68,6 +68,6 @@ test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway",
|
|||||||
} finally {
|
} finally {
|
||||||
craftos.abort();
|
craftos.abort();
|
||||||
await craftos.done.catch(() => undefined);
|
await craftos.done.catch(() => undefined);
|
||||||
await sandbox.close();
|
await server.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1,19 +1,19 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { formatFailure, startCraftos, startSandbox, waitFor } from "./harness.js";
|
import { formatFailure, startCraftos, startServer, waitFor } from "./harness.js";
|
||||||
|
|
||||||
// Exercises the gateway -> computer direction: the gateway sends a `ping`
|
// Exercises the gateway -> computer direction: the gateway sends a `ping`
|
||||||
// gateway_request and the real libsandbox client answers with a computer_response.
|
// server_request and the real libcloud client answers with a computer_response.
|
||||||
test("gateway->computer reverse ping is answered by the client", async () => {
|
test("gateway->computer reverse ping is answered by the client", async () => {
|
||||||
const sandbox = await startSandbox();
|
const server = await startServer();
|
||||||
const craftos = startCraftos("gateway-client.lua", {
|
const craftos = startCraftos("gateway-client.lua", {
|
||||||
computerId: 7,
|
computerId: 7,
|
||||||
computerLabel: "ping",
|
computerLabel: "ping",
|
||||||
shellArgs: [sandbox.baseUrl, "-", "idle"],
|
shellArgs: [server.baseUrl, "-", "idle"],
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await waitFor(() => sandbox.registry.has("local", "7:ping"), 12_000, "computer registered");
|
await waitFor(() => server.registry.has("local", "7:ping"), 12_000, "computer registered");
|
||||||
const payload = await sandbox.registry.request("local", "7:ping", "ping");
|
const payload = await server.registry.request("local", "7:ping", "ping");
|
||||||
assert.deepEqual(payload, { traposId: "7:ping" });
|
assert.deepEqual(payload, { traposId: "7:ping" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -23,6 +23,6 @@ test("gateway->computer reverse ping is answered by the client", async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
craftos.abort();
|
craftos.abort();
|
||||||
await craftos.done.catch(() => undefined);
|
await craftos.done.catch(() => undefined);
|
||||||
await sandbox.close();
|
await server.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -36,9 +36,9 @@ function requestFrame(type: string, messageId = "r1"): string {
|
|||||||
|
|
||||||
test("pre-hello non-hello request -> not_authenticated, no close, not registered", async () => {
|
test("pre-hello non-hello request -> not_authenticated, no close, not registered", async () => {
|
||||||
const { socket, registry, connection } = setup();
|
const { socket, registry, connection } = setup();
|
||||||
await connection.onMessage(requestFrame("probe_gateway"));
|
await connection.onMessage(requestFrame("probe_server"));
|
||||||
const frame = socket.lastFrame();
|
const frame = socket.lastFrame();
|
||||||
assert.equal(frame.event, "gateway_response");
|
assert.equal(frame.event, "server_response");
|
||||||
assert.equal(frame.ok, false);
|
assert.equal(frame.ok, false);
|
||||||
assert.deepEqual(frame.error, { code: "not_authenticated", message: "hello required before any other request" });
|
assert.deepEqual(frame.error, { code: "not_authenticated", message: "hello required before any other request" });
|
||||||
assert.equal(socket.closed, false);
|
assert.equal(socket.closed, false);
|
||||||
@ -77,7 +77,7 @@ test("hello ok -> registers and replies with accountId", async () => {
|
|||||||
const { socket, registry, connection } = setup();
|
const { socket, registry, connection } = setup();
|
||||||
await connection.onMessage(helloFrame("7:", "anything"));
|
await connection.onMessage(helloFrame("7:", "anything"));
|
||||||
const frame = socket.lastFrame();
|
const frame = socket.lastFrame();
|
||||||
assert.equal(frame.event, "gateway_response");
|
assert.equal(frame.event, "server_response");
|
||||||
assert.equal(frame.type, "hello");
|
assert.equal(frame.type, "hello");
|
||||||
assert.equal(frame.ok, true);
|
assert.equal(frame.ok, true);
|
||||||
assert.deepEqual(frame.payload, { accountId: "local" });
|
assert.deepEqual(frame.payload, { accountId: "local" });
|
||||||
@ -85,21 +85,21 @@ test("hello ok -> registers and replies with accountId", async () => {
|
|||||||
assert.equal(registry.count(), 1);
|
assert.equal(registry.count(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("post-hello probe_gateway (ok) -> sandboxOk + opencodeOk, no detail", async () => {
|
test("post-hello probe_server (ok) -> serverOk + opencodeOk, no detail", async () => {
|
||||||
const { socket, connection } = setup({ probe: { ok: true } });
|
const { socket, connection } = setup({ probe: { ok: true } });
|
||||||
await connection.onMessage(helloFrame("7:"));
|
await connection.onMessage(helloFrame("7:"));
|
||||||
await connection.onMessage(requestFrame("probe_gateway"));
|
await connection.onMessage(requestFrame("probe_server"));
|
||||||
const frame = socket.lastFrame();
|
const frame = socket.lastFrame();
|
||||||
assert.equal(frame.ok, true);
|
assert.equal(frame.ok, true);
|
||||||
assert.deepEqual(frame.payload, { sandboxOk: true, opencodeOk: true });
|
assert.deepEqual(frame.payload, { serverOk: true, opencodeOk: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("post-hello probe_gateway (fail) -> opencodeOk:false + opencodeDetail", async () => {
|
test("post-hello probe_server (fail) -> opencodeOk:false + opencodeDetail", async () => {
|
||||||
const { socket, connection } = setup({ probe: { ok: false, detail: "http://x -> 401" } });
|
const { socket, connection } = setup({ probe: { ok: false, detail: "http://x -> 401" } });
|
||||||
await connection.onMessage(helloFrame("7:"));
|
await connection.onMessage(helloFrame("7:"));
|
||||||
await connection.onMessage(requestFrame("probe_gateway"));
|
await connection.onMessage(requestFrame("probe_server"));
|
||||||
const frame = socket.lastFrame();
|
const frame = socket.lastFrame();
|
||||||
assert.deepEqual(frame.payload, { sandboxOk: true, opencodeOk: false, opencodeDetail: "http://x -> 401" });
|
assert.deepEqual(frame.payload, { serverOk: true, opencodeOk: false, opencodeDetail: "http://x -> 401" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("post-hello unknown type -> unknown_type error", async () => {
|
test("post-hello unknown type -> unknown_type error", async () => {
|
||||||
@ -126,7 +126,7 @@ test("post-hello wrong-direction frame is dropped", async () => {
|
|||||||
await connection.onMessage(helloFrame("7:"));
|
await connection.onMessage(helloFrame("7:"));
|
||||||
const afterHello = socket.sent.length;
|
const afterHello = socket.sent.length;
|
||||||
await connection.onMessage(
|
await connection.onMessage(
|
||||||
JSON.stringify({ event: "gateway_request", traposReceiverId: "7:", messageId: "g1", type: "ping", payload: {} }),
|
JSON.stringify({ event: "server_request", traposReceiverId: "7:", messageId: "g1", type: "ping", payload: {} }),
|
||||||
);
|
);
|
||||||
assert.equal(socket.sent.length, afterHello);
|
assert.equal(socket.sent.length, afterHello);
|
||||||
});
|
});
|
||||||
@ -137,7 +137,7 @@ test("reverse direction: gateway request resolved by computer_response", async (
|
|||||||
|
|
||||||
const promise = registry.request("local", "7:", "ping", {});
|
const promise = registry.request("local", "7:", "ping", {});
|
||||||
const sent = socket.lastFrame();
|
const sent = socket.lastFrame();
|
||||||
assert.equal(sent.event, "gateway_request");
|
assert.equal(sent.event, "server_request");
|
||||||
assert.equal(sent.type, "ping");
|
assert.equal(sent.type, "ping");
|
||||||
|
|
||||||
await connection.onMessage(
|
await connection.onMessage(
|
||||||
@ -24,7 +24,7 @@ function response(traposId: string, messageId: string, type: string, ok: boolean
|
|||||||
return { event: "computer_response", traposSenderId: traposId, messageId, type, ok, payload };
|
return { event: "computer_response", traposSenderId: traposId, messageId, type, ok, payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run a tool call and resolve the single gateway_request it emits with `payload`.
|
// Run a tool call and resolve the single server_request it emits with `payload`.
|
||||||
async function callAndAnswer(
|
async function callAndAnswer(
|
||||||
deps: McpDeps,
|
deps: McpDeps,
|
||||||
socket: FakeSocket,
|
socket: FakeSocket,
|
||||||
@ -42,10 +42,10 @@ async function callAndAnswer(
|
|||||||
return resultText(await pending);
|
return resultText(await pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("initialize advertises the trap-sandbox server", async () => {
|
test("initialize advertises the trapos-mcp server", async () => {
|
||||||
const reply = await handleMcpRequest(rpc("initialize"), makeDeps(new GatewayRegistry(silentLog)));
|
const reply = await handleMcpRequest(rpc("initialize"), makeDeps(new GatewayRegistry(silentLog)));
|
||||||
const info = (reply as { result: { serverInfo: { name: string } } }).result.serverInfo;
|
const info = (reply as { result: { serverInfo: { name: string } } }).result.serverInfo;
|
||||||
assert.equal(info.name, "trap-sandbox");
|
assert.equal(info.name, "trapos-mcp");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tools/list exposes the tools addressed by traposId", async () => {
|
test("tools/list exposes the tools addressed by traposId", async () => {
|
||||||
@ -14,14 +14,14 @@ test("parses all four event types", () => {
|
|||||||
assert.equal(cRes.event, "computer_response");
|
assert.equal(cRes.event, "computer_response");
|
||||||
|
|
||||||
const gReq = parse(
|
const gReq = parse(
|
||||||
JSON.stringify({ event: "gateway_request", traposReceiverId: "7:", messageId: "m2", type: "ping", payload: {} }),
|
JSON.stringify({ event: "server_request", traposReceiverId: "7:", messageId: "m2", type: "ping", payload: {} }),
|
||||||
);
|
);
|
||||||
assert.equal(gReq.event, "gateway_request");
|
assert.equal(gReq.event, "server_request");
|
||||||
|
|
||||||
const gRes = parse(
|
const gRes = parse(
|
||||||
JSON.stringify({ event: "gateway_response", traposReceiverId: "7:", messageId: "m2", type: "health", ok: true, payload: {} }),
|
JSON.stringify({ event: "server_response", traposReceiverId: "7:", messageId: "m2", type: "health", ok: true, payload: {} }),
|
||||||
);
|
);
|
||||||
assert.equal(gRes.event, "gateway_response");
|
assert.equal(gRes.event, "server_response");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses an ok:false response with error", () => {
|
test("parses an ok:false response with error", () => {
|
||||||
@ -43,14 +43,14 @@ test("same traposId under different accounts coexist", () => {
|
|||||||
assert.equal(reg.count(), 2);
|
assert.equal(reg.count(), 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("request sends a gateway_request frame and resolves on matching ok response", async () => {
|
test("request sends a server_request frame and resolves on matching ok response", async () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register("local", "7:", a);
|
reg.register("local", "7:", a);
|
||||||
|
|
||||||
const promise = reg.request("local", "7:", "ping", {});
|
const promise = reg.request("local", "7:", "ping", {});
|
||||||
const frame = a.lastFrame();
|
const frame = a.lastFrame();
|
||||||
assert.equal(frame.event, "gateway_request");
|
assert.equal(frame.event, "server_request");
|
||||||
assert.equal(frame.type, "ping");
|
assert.equal(frame.type, "ping");
|
||||||
assert.equal(frame.traposReceiverId, "7:");
|
assert.equal(frame.traposReceiverId, "7:");
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user