refactor: rename accountId in networkId + cleanup

This commit is contained in:
Guillaume ARM 2026-06-18 20:23:06 +02:00
parent 4f2c64ef1d
commit 9bc1ce711d
35 changed files with 194 additions and 871 deletions

View File

@ -18,7 +18,7 @@ Use [`docs/README.md`](docs/README.md) as the entrypoint for CC:Tweaked, CraftOS
- Git hooks own commit/push verification: pre-commit runs `just check test`, and pre-push runs `just ci`. When explicitly asked to commit and/or push, do not run `just test` manually first; rely on the hooks. See [ADR-0011](docs/adrs/adr-0011-repo-conventions.md).
- After editing Lua or Markdown, run `just check` and fix all `luacheck` warnings and `lychee` broken-link reports (markdown link validation is offline-only via `just lint-markdown`).
- Use 2-space indent, semicolons, and `local function`.
- `require` paths are absolute ComputerCraft paths, for example `require('/apis/net')()`.
- `require` paths are absolute ComputerCraft paths, for example `require('/apis/eventloop')()`.
- Most API modules return factories; call the required module once before use.
## Running CraftOS-PC
@ -40,10 +40,7 @@ Always go through the `just` recipes — they set repo-local state and the macOS
- `apis/eventloop.lua` is the single-threaded event loop around `os.pullEventRaw`; consider using it everywhere async behavior is needed. A handler that returns `api.STOP` auto-unregisters.
- `startup/boot.lua` creates a single boot eventloop at `_G.bootEventLoop` and runs it alongside the shell via `parallel.waitForAny`. Servers register handlers on it and return; programs call services via `os.pullEvent` without touching the loop. See [ADR-0002](docs/adrs/adr-0002-eventloop-and-service-bus.md).
- `apis/libtest.lua` is the lightweight test helper used by scripts under `tests/`; `/programs/runtest.lua` discovers tests, renders suite output, and owns the `__TRAPOS_TEST_OK__` success marker.
- `apis/net.lua` is a service-name bus on a single channel (`10`). `net.serve(name, handler)` registers a server handler; `net.call(name, payload, { destId, timeout })` and `net.send(name, payload, { destId })` are the client surface. `require('/apis/net')()` returns a singleton bound to `_G.bootEventLoop` when present, otherwise an ephemeral instance.
- A router (`/programs/router.lua`) must be running somewhere on the network; it registers a `modem_message` handler that stamps `routerId`, resolves label-addressed packets via a TTL map populated by `servers/net-registrar.lua`, and rebroadcasts. Without it, packets stay unrouted and consumers ignore them.
- `servers/` register handlers on the boot eventloop and return; `programs/` are clients that exit.
- Single well-known channel: `10` (the bus). Service multiplexing happens inside the packet body.
## Boot And Install

View File

@ -18,7 +18,6 @@ cherry-pick packages yourself:
```
wget run https://os.trapcloud.fr/install --cpm-only
ccpm update
ccpm install trapos-net
ccpm install trapos-ui
```
@ -31,10 +30,9 @@ TrapOS is split into packages, each described by a `packages/<name>/ccpm.json`:
- `trapos-core`: the package manager (`ccpm`), event loop, `trapos-upgrade`, `trapos-postinstall`, `events`.
- `trapos-test`: the test framework (`libtest`) and suite runner (`runtest`).
- `trapos-boot`: the startup MOTD and autostart server launcher.
- `trapos-net`: routed modem networking (`net`, `router`, `ping`, `ping-server`).
- `trapos-ui`: the terminal UI toolkit (`libtui`, `tuidemo`).
- `trapos-ai`: the AI client for `opencode serve`.
- `trapos`: full TrapOS meta-package (`trapos-boot`, `trapos-net`, `trapos-ui`, `trapos-test`, `trapos-ai`, `trapos-cloud`).
- `trapos`: full TrapOS meta-package (`trapos-boot`, `trapos-ui`, `trapos-test`, `trapos-ai`, `trapos-cloud`).
The `trapos` meta-package is the user-facing full install. Package descriptors list
files and autostart servers; installed state is tracked under `/trapos`.
@ -67,16 +65,11 @@ supported. State lives in `/trapos/ccpm.json` (registries),
## APIs
- `/apis/eventloop`: a simple event loop API.
- `/apis/net`: an API to simplify sending and receiving routed messages, based on the `eventloop` library.
## Servers
Servers listed in `manifest.autostart` are launched at boot by `startup/boot.lua`.
- `/servers/ping-server`: allows a machine to respond to a `ping` command.
## Programs
- `router`: routes messages. You need to set up a router to use all `apis/net`-based programs and libraries.
- `ping`: pings machines using `apis/net`.
- `events`: emits and logs computer events.
- `trapos-upgrade`: runs `ccpm update` then `ccpm upgrade`; pass `--reboot`/`-r` to reboot after upgrading.
- `trapos-postinstall`: prints the post-install welcome and next steps (run automatically by the installer).

View File

@ -13,7 +13,7 @@ Accepted
ComputerCraft is event-driven. Direct `os.pullEvent` loops are easy to write but hard to compose when multiple things need to happen at the same time. Without a single substrate the repo accumulated several distinct problems:
- Each long-lived process owned a private event loop, including the router (`programs/router.lua` was a hand-rolled `while true / os.pullEvent`). With N autostart servers, `parallel.waitForAll` ran N coroutines each pumping an independent `os.pullEventRaw`. Events were broadcast to every coroutine but only one would have a relevant handler — wasteful and conceptually awkward.
- `_G.isRouterEnabled` mutated send behavior across the codebase. [`apis/net.lua`](../../packages/trapos-net/apis/net.lua) `sendRaw` switched its transmit path based on a global flag set by the router program, so the same function call meant different things depending on which machine ran it.
- `_G.isRouterEnabled` mutated send behavior across the codebase. `apis/net.lua` `sendRaw` switched its transmit path based on a global flag set by the router program, so the same function call meant different things depending on which machine ran it.
- Channel numbers leaked into every client. `servers/ping-server.lua` and `programs/ping.lua` both duplicated a `PING_CHANNEL` constant; there was no service registry. Adding a new service meant picking a free integer and replicating it on both ends.
- Label collision was a silent footgun. Two machines sharing the same label both accepted and rebroadcast packets addressed to that label, producing duplicate responses and duplicate retransmits.
- `os.sleep` looked innocent but broke the substrate. Its CC:Tweaked implementation yields via `os.pullEvent("timer")`. While the sleep is in flight, the enclosing eventloop's `os.pullEventRaw` is paused; non-`timer` events are silently discarded; even `eventloop.setTimeout` callbacks scheduled before the sleep cannot fire until it returns. This bit `apis/libai.lua` `pollMessage`, which used a sleep-based throttle and froze the whole loop the moment a caller invoked it from inside a handler.
@ -27,7 +27,7 @@ Net's blast radius at the time of the bus rewrite was small (only ping consumed
New async code uses [`apis/eventloop.lua`](../../packages/trapos-core/apis/eventloop.lua). Event handlers, timers, server listeners, and UI behavior compose through the eventloop instead of each feature owning its own blocking loop.
- Prefer `eventloop.register`, `setTimeout`, `onStart`, `onStop`, and `startLoop` for async behavior.
- APIs that listen for events accept an existing event loop as a constructor argument, the way [`apis/net.lua`](../../packages/trapos-net/apis/net.lua) does. Do not create a private loop inside a module.
- APIs that listen for events accept an existing event loop as a constructor argument, the way `apis/net.lua` does. Do not create a private loop inside a module.
- Direct `os.pullEvent` loops should be rare and justified (CLI programs waiting for a single reply are the main exception).
- A handler that returns `api.STOP` auto-unregisters.
@ -35,7 +35,7 @@ New async code uses [`apis/eventloop.lua`](../../packages/trapos-core/apis/event
`startup/servers.lua` creates a single `createEventLoop()` instance, stores it at `_G.bootEventLoop`, runs autostart server files (which register handlers and return without blocking), then runs `parallel.waitForAny(shellFn, eventLoopFn)`. The shell and the eventloop are the only two coroutines.
[`apis/net.lua`](../../packages/trapos-net/apis/net.lua) exposes a service-name bus on a single channel:
`apis/net.lua` exposes a service-name bus on a single channel:
- `net.serve(name, handler)` — register a server handler (server-side).
- `net.call(name, payload, opts)` — request/response with timeout (client-side).
@ -44,11 +44,11 @@ New async code uses [`apis/eventloop.lua`](../../packages/trapos-core/apis/event
All traffic flows on channel `10` and is demultiplexed inside the packet body via a `service` field. Channel numbers stop being a public concept. `require('/apis/net')()` returns a singleton bound to `_G.bootEventLoop` when present, otherwise an ephemeral instance. CLI programs stay standalone: `net.call` internally uses `os.pullEvent` with a timer, so programs do not need the boot eventloop to receive a response.
[`programs/router.lua`](../../packages/trapos-net/programs/router.lua) registers handlers on the same boot eventloop everything else uses. It owns a TTL-based label map extracted into [`apis/librouter.lua`](../../packages/trapos-net/apis/librouter.lua) for testability. Machines with a label autostart [`servers/net-registrar.lua`](../../packages/trapos-net/servers/net-registrar.lua), which periodically broadcasts `(id, label)` so the router can resolve label-addressed packets. Duplicate label registrations are rejected with a printed warning. `_G.isRouterEnabled` is gone; the router service flips a local flag via `net.setRouter(true)` instead.
`programs/router.lua` registers handlers on the same boot eventloop everything else uses. It owns a TTL-based label map extracted into `apis/librouter.lua` for testability. Machines with a label autostart `servers/net-registrar.lua`, which periodically broadcasts `(id, label)` so the router can resolve label-addressed packets. Duplicate label registrations are rejected with a printed warning. `_G.isRouterEnabled` is gone; the router service flips a local flag via `net.setRouter(true)` instead.
### 3. `os.sleep` discipline
In library, server, and program code that may run inside an eventloop (directly or transitively), use `eventloop.setTimeout` for any waiting, throttling, polling, or retry-with-delay. Libraries that need to temporize must take an eventloop factory through their constructor rather than baking a hardcoded sleep call. [`apis/net.lua`](../../packages/trapos-net/apis/net.lua) `sendRequest` is the canonical private-eventloop pattern: create a private eventloop, schedule the wait through `setTimeout`, then `runLoop` until the work resolves — synchronous from the caller's perspective, but the dispatcher stays alive internally so handlers can compose around it via `parallel.waitForAll`.
In library, server, and program code that may run inside an eventloop (directly or transitively), use `eventloop.setTimeout` for any waiting, throttling, polling, or retry-with-delay. Libraries that need to temporize must take an eventloop factory through their constructor rather than baking a hardcoded sleep call. `apis/net.lua` `sendRequest` is the canonical private-eventloop pattern: create a private eventloop, schedule the wait through `setTimeout`, then `runLoop` until the work resolves — synchronous from the caller's perspective, but the dispatcher stays alive internally so handlers can compose around it via `parallel.waitForAll`.
`os.sleep` remains acceptable only in narrow cases:
@ -64,8 +64,8 @@ New code must not expose a `sleep` injection point on its constructor. If a wait
- The router program returns immediately instead of blocking the shell. Users type `router` once on the chosen machine and continue using the shell.
- Label collisions are detected and rejected at registration time, with a clear warning, instead of causing silent duplicate delivery.
- A router must still be running somewhere on the network for cross-machine label-addressed packets; without one, non-router senders produce packets with `routerId = nil` and consumers drop them on receive.
- Programs that need to wait for events still work by direct `os.pullEvent`, but if a program registers a long-lived handler on `_G.bootEventLoop` and exits, the handler keeps firing with a stale closure. Programs should prefer `call`/`send` over `serve`/`listen`. This is documented in [`apis/net.lua`](../../packages/trapos-net/apis/net.lua) but not enforced.
- Tests for the router state machine live in [`tests/router.lua`](../../packages/trapos-net/tests/router.lua) and exercise [`apis/librouter.lua`](../../packages/trapos-net/apis/librouter.lua) with an injected clock. Tests for the net packet shape and dispatch live in [`tests/net.lua`](../../packages/trapos-net/tests/net.lua) with a fake modem.
- Programs that need to wait for events still work by direct `os.pullEvent`, but if a program registers a long-lived handler on `_G.bootEventLoop` and exits, the handler keeps firing with a stale closure. Programs should prefer `call`/`send` over `serve`/`listen`. This is documented in `apis/net.lua` but not enforced.
- Tests for the router state machine live in `tests/router.lua` and exercise `apis/librouter.lua` with an injected clock. Tests for the net packet shape and dispatch live in `tests/net.lua` with a fake modem.
- Slightly more ceremony in "synchronous-looking" library functions that wait: a private eventloop plus a small `attempt`/`finish` pair. The benefit is clean composition with any caller's eventloop.
- Test fakes shift from a `sleep` stub to a synchronous eventloop double. Ergonomics are comparable; the eventloop fake additionally lets tests observe `pending` and `stopped` state, catching leaks the sleep stub would have missed.
- Existing call sites are migrated opportunistically when they cause observable bugs. The first `os.sleep` migration is `apis/libai.lua`.

View File

@ -28,7 +28,7 @@ Move the MCP features onto the cloud gateway and retire the dedicated MCP transp
- **Host (`tools/trapos-server`):** a second Fastify listener (`src/modules/mcp`) bound to
`127.0.0.1` (default `MCP_PORT=4445`) speaks the same custom JSON-RPC 2.0 as the old
bridge. Each tool call is a `GatewayRegistry.request(accountId, traposId, type, payload)`
bridge. Each tool call is a `GatewayRegistry.request(networkId, traposId, type, payload)`
reusing the existing gateway socket. Computers are addressed by **`traposId`**
(`"<id>:<label>"`, the registry's key) rather than a numeric `computerId`;
`probe-computers` lists the connected traposIds.

View File

@ -65,5 +65,5 @@ Call `periphemu.names()` from a CraftOS-PC shell to confirm what the current bui
- **CC:T has no `periphemu`.** Always guard with `if periphemu then`. [AGENTS.md](./../AGENTS.md) and [ADR-0005](adrs/adr-0005-craftos-pc-harness-and-probes.md) treat that guard as mandatory.
- **Speaker `playAudio` differs from CC:T** unless [standards mode](https://www.craftos-pc.cc/docs/standards) is on — CraftOS-PC queues audio without ever returning `false`.
- **Modem network id** segregates emulated networks. Two modems with different ids will not see each other; useful for testing the [router](../packages/trapos-net/programs/router.lua) against a sealed network.
- **Modem network id** segregates emulated networks. Two modems with different ids will not see each other; useful for testing modem-based code against a sealed network.
- **Persisted per-computer state** lives at `~/Library/Application Support/CraftOS-PC/computer/<id>/` and `config/<id>.json` (labels stored base64-encoded). Spawning a new id leaves that state behind across sessions.

View File

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

View File

@ -3,11 +3,10 @@
"trapos-core": "0.9.4",
"trapos-test": "0.2.3",
"trapos-boot": "0.4.3",
"trapos-net": "0.3.2",
"trapos-ui": "0.2.4",
"trapos-ai": "0.8.0",
"trapos-cloud": "0.4.4",
"trapos-sandbox-legacy": "0.3.2",
"trapos": "0.12.4"
"trapos": "0.12.5"
}
}

View File

@ -131,13 +131,13 @@ end);
testlib.test('onMessage drives hello via onHello', function()
local cloud = createCloud();
local calls = {};
cloud.onMessage(encode({ event = 'server_response', type = 'hello', ok = true, payload = { accountId = 'local' } }), {
cloud.onMessage(encode({ event = 'server_response', type = 'hello', ok = true, payload = { networkId = 'global' } }), {
onHello = function(ok, payload) calls[#calls + 1] = { ok = ok, payload = payload }; end,
});
testlib.assertEquals(#calls, 1);
testlib.assertEquals(calls[1].ok, true);
testlib.assertEquals(calls[1].payload.accountId, 'local');
testlib.assertEquals(calls[1].payload.networkId, 'global');
end);
testlib.test('onMessage queues a reply for a non-hello server_response', function()

View File

@ -52,8 +52,8 @@ testlib.test("registryBaseUrl resolves a gitea branch", function()
"https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/"
)
testlib.assertEquals(
ccpm.descriptorUrl({ name = "guillaumearm/cc-libs", type = "gitea", branch = "next" }, "trapos-net"),
"https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/packages/trapos-net/ccpm.json"
ccpm.descriptorUrl({ name = "guillaumearm/cc-libs", type = "gitea", branch = "next" }, "trapos-ui"),
"https://git.trapcloud.fr/guillaumearm/cc-libs/raw/branch/next/packages/trapos-ui/ccpm.json"
)
end)
@ -64,8 +64,8 @@ testlib.test("registry urls resolve an http base", function()
"http://example.com/repo/"
)
testlib.assertEquals(
ccpm.descriptorUrl({ name = "http://example.com/repo/", type = "http" }, "trapos-net"),
"http://example.com/repo/packages/trapos-net/ccpm.json"
ccpm.descriptorUrl({ name = "http://example.com/repo/", type = "http" }, "trapos-ui"),
"http://example.com/repo/packages/trapos-ui/ccpm.json"
)
end)
@ -148,34 +148,34 @@ testlib.test("install downloads files and records the lock", function()
dependencies = {},
files = { "apis/eventloop.lua" },
}),
[base .. "packages/trapos-net/ccpm.json"] = textutils.serializeJSON({
name = "trapos-net",
[base .. "packages/trapos-ui/ccpm.json"] = textutils.serializeJSON({
name = "trapos-ui",
version = "1",
dependencies = { "trapos-core" },
files = { "apis/net.lua" },
files = { "apis/libtui.lua" },
}),
[base .. "packages/trapos-core/apis/eventloop.lua"] = "eventloop-body",
[base .. "packages/trapos-net/apis/net.lua"] = "net-body",
[base .. "packages/trapos-ui/apis/libtui.lua"] = "ui-body",
}
local sd, root = freshDirs()
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) })
ccpm.writeConfig({ registries = { { name = "me/repo", type = "gitea", branch = "master" } } })
local ok = ccpm.install("trapos-net", {})
local ok = ccpm.install("trapos-ui", {})
testlib.assertTrue(ok)
testlib.assertTrue(fs.exists(root .. "/apis/net.lua"))
testlib.assertTrue(fs.exists(root .. "/apis/libtui.lua"))
testlib.assertTrue(fs.exists(root .. "/apis/eventloop.lua"))
local f = fs.open(root .. "/apis/net.lua", "r")
local f = fs.open(root .. "/apis/libtui.lua", "r")
local body = f.readAll()
f.close()
testlib.assertEquals(body, "net-body")
testlib.assertEquals(body, "ui-body")
local lock = ccpm.readLock()
testlib.assertTrue(lock.packages["trapos-net"])
testlib.assertTrue(lock.packages["trapos-ui"])
testlib.assertTrue(lock.packages["trapos-core"])
testlib.assertEquals(lock.packages["trapos-net"].registry, "me/repo")
testlib.assertEquals(lock.packages["trapos-net"].explicit, true)
testlib.assertEquals(lock.packages["trapos-ui"].registry, "me/repo")
testlib.assertEquals(lock.packages["trapos-ui"].explicit, true)
testlib.assertEquals(lock.packages["trapos-core"].explicit, false)
end)
@ -188,31 +188,31 @@ testlib.test("install downloads files from a gitea registry", function()
dependencies = {},
files = { "apis/eventloop.lua" },
}),
[base .. "packages/trapos-net/ccpm.json"] = textutils.serializeJSON({
name = "trapos-net",
[base .. "packages/trapos-ui/ccpm.json"] = textutils.serializeJSON({
name = "trapos-ui",
version = "1",
dependencies = { "trapos-core" },
files = { "apis/net.lua" },
files = { "apis/libtui.lua" },
}),
[base .. "packages/trapos-core/apis/eventloop.lua"] = "eventloop-body",
[base .. "packages/trapos-net/apis/net.lua"] = "net-body",
[base .. "packages/trapos-ui/apis/libtui.lua"] = "ui-body",
}
local sd, root = freshDirs()
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) })
ccpm.writeConfig({ registries = { { name = "guillaumearm/cc-libs", type = "gitea", branch = "next" } } })
local ok = ccpm.install("trapos-net", {})
local ok = ccpm.install("trapos-ui", {})
testlib.assertTrue(ok)
testlib.assertTrue(fs.exists(root .. "/apis/net.lua"))
testlib.assertTrue(fs.exists(root .. "/apis/libtui.lua"))
testlib.assertTrue(fs.exists(root .. "/apis/eventloop.lua"))
local f = fs.open(root .. "/apis/net.lua", "r")
local f = fs.open(root .. "/apis/libtui.lua", "r")
local body = f.readAll()
f.close()
testlib.assertEquals(body, "net-body")
testlib.assertEquals(body, "ui-body")
local lock = ccpm.readLock()
testlib.assertEquals(lock.packages["trapos-net"].registry, "guillaumearm/cc-libs")
testlib.assertEquals(lock.packages["trapos-ui"].registry, "guillaumearm/cc-libs")
end)
testlib.test("installing trapos writes aggregated os state", function()
@ -221,7 +221,7 @@ testlib.test("installing trapos writes aggregated os state", function()
[base .. "packages/trapos/ccpm.json"] = textutils.serializeJSON({
name = "trapos",
version = "1",
dependencies = { "trapos-net" },
dependencies = { "trapos-ui" },
files = {},
}),
[base .. "packages/trapos-core/ccpm.json"] = textutils.serializeJSON({
@ -230,15 +230,15 @@ testlib.test("installing trapos writes aggregated os state", function()
dependencies = {},
files = { "programs/ccpm.lua" },
}),
[base .. "packages/trapos-net/ccpm.json"] = textutils.serializeJSON({
name = "trapos-net",
[base .. "packages/trapos-ui/ccpm.json"] = textutils.serializeJSON({
name = "trapos-ui",
version = "1",
dependencies = { "trapos-core" },
files = { "apis/net.lua" },
autostart = { "servers/ping-server" },
files = { "apis/libtui.lua" },
autostart = { "servers/demo" },
}),
[base .. "packages/trapos-core/programs/ccpm.lua"] = "ccpm-body",
[base .. "packages/trapos-net/apis/net.lua"] = "net-body",
[base .. "packages/trapos-ui/apis/libtui.lua"] = "ui-body",
}
local sd, root = freshDirs()
local ccpm = createCcpm({ stateDir = sd, installRoot = root, http = fakeHttp(routes) })
@ -251,7 +251,7 @@ testlib.test("installing trapos writes aggregated os state", function()
f.close()
testlib.assertEquals(manifest.version, "1")
testlib.assertEquals(#manifest.files, 2)
testlib.assertEquals(manifest.autostart[1], "servers/ping-server")
testlib.assertEquals(manifest.autostart[1], "servers/demo")
end)
testlib.test("registry add and remove round-trip", function()
@ -522,19 +522,19 @@ testlib.test("uninstall refuses a package with dependents", function()
ccpm.writeLock({
packages = {
["trapos-core"] = { version = "1", files = {}, dependencies = {} },
["trapos-net"] = { version = "1", files = {}, dependencies = { "trapos-core" } },
["trapos-ui"] = { version = "1", files = {}, dependencies = { "trapos-core" } },
},
})
local ok, err = ccpm.uninstall("trapos-core", {})
testlib.assertTrue(not ok)
testlib.assertTrue(string.find(err, "required by", 1, true))
testlib.assertTrue(string.find(err, "trapos-net", 1, true))
testlib.assertTrue(string.find(err, "trapos-ui", 1, true))
end)
testlib.test("uninstall removes a leaf package and its files", function()
local sd, root = freshDirs()
fs.makeDir(root .. "/apis")
local f = fs.open(root .. "/apis/net.lua", "w")
local f = fs.open(root .. "/apis/libtui.lua", "w")
f.write("x")
f.close()
@ -542,13 +542,13 @@ testlib.test("uninstall removes a leaf package and its files", function()
ccpm.writeLock({
packages = {
["trapos-core"] = { version = "1", files = {}, dependencies = {} },
["trapos-net"] = { version = "1", files = { "apis/net.lua" }, dependencies = { "trapos-core" } },
["trapos-ui"] = { version = "1", files = { "apis/libtui.lua" }, dependencies = { "trapos-core" } },
},
})
testlib.assertTrue(ccpm.uninstall("trapos-net", {}))
testlib.assertTrue(not fs.exists(root .. "/apis/net.lua"))
testlib.assertTrue(ccpm.readLock().packages["trapos-net"] == nil)
testlib.assertTrue(ccpm.uninstall("trapos-ui", {}))
testlib.assertTrue(not fs.exists(root .. "/apis/libtui.lua"))
testlib.assertTrue(ccpm.readLock().packages["trapos-ui"] == nil)
testlib.assertTrue(ccpm.readLock().packages["trapos-core"] ~= nil)
end)

View File

@ -44,10 +44,10 @@ end);
testlib.test('forFile falls back to packages/<name>/ccpm.json when no lock entry matches', function()
local stateDir, repoRoot = freshDirs();
writeJson(repoRoot .. '/packages/trapos-net/ccpm.json', {
name = 'trapos-net',
writeJson(repoRoot .. '/packages/trapos-cloud/ccpm.json', {
name = 'trapos-cloud',
version = '0.2.1',
files = { 'apis/net.lua', 'programs/router.lua' },
files = { 'apis/libcloud.lua', 'programs/cloud.lua' },
});
writeJson(repoRoot .. '/packages/trapos-ui/ccpm.json', {
name = 'trapos-ui',
@ -55,7 +55,7 @@ testlib.test('forFile falls back to packages/<name>/ccpm.json when no lock entry
files = { 'apis/libtui.lua' },
});
local v = createVersion({ stateDir = stateDir, repoRoot = repoRoot });
testlib.assertEquals(v.forFile('/programs/router.lua'), '0.2.1');
testlib.assertEquals(v.forFile('/programs/cloud.lua'), '0.2.1');
testlib.assertEquals(v.forFile('/apis/libtui.lua'), '0.2.2');
end);
@ -63,19 +63,19 @@ testlib.test('forFile prefers the lock entry over the repo descriptor', function
local stateDir, repoRoot = freshDirs();
writeJson(stateDir .. '/ccpm.lock.json', {
packages = {
['trapos-net'] = {
['trapos-ui'] = {
version = '9.9.9',
files = { 'programs/ping.lua' },
files = { 'programs/tuidemo.lua' },
},
},
});
writeJson(repoRoot .. '/packages/trapos-net/ccpm.json', {
name = 'trapos-net',
writeJson(repoRoot .. '/packages/trapos-ui/ccpm.json', {
name = 'trapos-ui',
version = '0.2.1',
files = { 'programs/ping.lua' },
files = { 'programs/tuidemo.lua' },
});
local v = createVersion({ stateDir = stateDir, repoRoot = repoRoot });
testlib.assertEquals(v.forFile('/programs/ping.lua'), '9.9.9');
testlib.assertEquals(v.forFile('/programs/tuidemo.lua'), '9.9.9');
end);
testlib.test('forFile returns "?" when neither source knows the file', function()

View File

@ -1,49 +0,0 @@
-- TrapOS router state machine: (label -> id) map with TTL.
--
-- Pure logic so it can be unit-tested without a modem or eventloop.
-- The wire glue lives in /programs/router.lua.
local DEFAULT_TTL = 90;
local function createRouter(options)
options = options or {};
local ttl = options.ttl or DEFAULT_TTL;
local nowFn = options.now or os.clock;
local labelMap = {};
local function isExpired(entry)
return entry.expiresAt < nowFn();
end
local function register(label, id)
local existing = labelMap[label];
if existing and existing.id ~= id and not isExpired(existing) then
return false, 'duplicate label';
end
labelMap[label] = { id = id, expiresAt = nowFn() + ttl };
return true;
end
local function resolve(label)
local entry = labelMap[label];
if not entry then return nil end
if isExpired(entry) then
labelMap[label] = nil;
return nil;
end
return entry.id;
end
local function forget(label)
labelMap[label] = nil;
end
return {
register = register,
resolve = resolve,
forget = forget,
ttl = ttl,
};
end
return createRouter;

View File

@ -1,212 +0,0 @@
-- TrapOS networking: service-name bus on a single channel.
--
-- Servers register handlers on the boot eventloop:
-- net.serve('ping', function(msg, reply) reply('pong') end)
-- net.listen('events.foo', function(msg, packet) ... end)
--
-- Clients (CLI programs) call or send without an eventloop:
-- local ok, res = net.call('ping', 'ping', { destId = 5, timeout = 0.5 })
-- net.send('events.foo', payload, { destId = 'alice' })
--
-- A router service (servers/router-server.lua) must run on exactly one machine.
-- It resolves label-addressed packets, stamps routerId, and rebroadcasts.
local createEventLoop = require('/apis/eventloop');
local BUS_CHANNEL = 10;
local DEFAULT_TIMEOUT = 0.5;
local nextRequestSeq = 1;
local function newRequestId()
local id = tostring(os.getComputerID()) .. ':' .. tostring(nextRequestSeq) .. ':' .. tostring(os.clock());
nextRequestSeq = nextRequestSeq + 1;
return id;
end
local function isPacketOk(packet)
if type(packet) ~= 'table' then return false end
if not packet.routerId or not packet.sourceId then return false end
if packet.destId == nil then return true end
if type(packet.destId) == 'number' and packet.destId == os.getComputerID() then return true end
if type(packet.destId) == 'string' and packet.destId == os.getComputerLabel() then return true end
return false
end
local function createNetwork(el, modem, modemSide)
el = el or createEventLoop();
modem = modem or peripheral.find('modem') or error('modem not found');
modem.open(BUS_CHANNEL);
local isRouter = false;
modemSide = modemSide or peripheral.getName(modem);
local function buildPacket(service, kind, payload, destId, requestId)
return {
sourceId = os.getComputerID(),
sourceLabel = os.getComputerLabel(),
destId = tonumber(destId) or destId,
service = service,
kind = kind,
requestId = requestId,
payload = payload,
routerId = isRouter and os.getComputerID() or nil,
};
end
local function transmit(packet)
local selfId = os.getComputerID();
local selfLabel = os.getComputerLabel();
local destIsSelfId = packet.destId == selfId;
local destIsSelfLabel = selfLabel ~= nil and packet.destId == selfLabel;
local destIsSelf = destIsSelfId or destIsSelfLabel;
local matchesSelf = packet.destId == nil or destIsSelf;
if matchesSelf then
local localPacket = {};
for k, v in pairs(packet) do localPacket[k] = v end
localPacket.routerId = localPacket.routerId or selfId;
os.queueEvent('modem_message', modemSide, BUS_CHANNEL, BUS_CHANNEL, localPacket, 0);
end
if not destIsSelf then
modem.transmit(BUS_CHANNEL, BUS_CHANNEL, packet);
end
end
local function serve(serviceName, handler)
return el.register('modem_message', function(_, _, replyChannel, packet)
if replyChannel ~= BUS_CHANNEL then return end
if not isPacketOk(packet) then return end
if packet.service ~= serviceName or packet.kind ~= 'req' then return end
local function reply(responsePayload)
local response = buildPacket(serviceName, 'res', responsePayload, packet.sourceId, packet.requestId);
transmit(response);
end
handler(packet.payload, reply, packet);
end);
end
local function listen(serviceName, handler)
return el.register('modem_message', function(_, _, replyChannel, packet)
if replyChannel ~= BUS_CHANNEL then return end
if not isPacketOk(packet) then return end
if packet.service ~= serviceName or packet.kind ~= 'evt' then return end
handler(packet.payload, packet);
end);
end
local function send(serviceName, payload, opts)
opts = opts or {};
transmit(buildPacket(serviceName, 'evt', payload, opts.destId, nil));
end
local function awaitResponse(serviceName, requestId, timeout, collectMultiple)
local timerId = os.startTimer(timeout);
local results = {};
local packets = {};
while true do
local event, p1, _, p3, p4 = os.pullEvent();
if event == 'timer' and p1 == timerId then
if collectMultiple then
if #results == 0 then return false, 'net.call timeout', {} end
return true, results, packets;
end
return false, 'net.call timeout', nil;
elseif event == 'modem_message' then
local replyChannel = p3;
local recvPacket = p4;
if replyChannel == BUS_CHANNEL
and isPacketOk(recvPacket)
and recvPacket.service == serviceName
and recvPacket.kind == 'res'
and recvPacket.requestId == requestId then
if collectMultiple then
table.insert(results, recvPacket.payload);
table.insert(packets, recvPacket);
else
os.cancelTimer(timerId);
return true, recvPacket.payload, recvPacket;
end
end
end
end
end
local function call(serviceName, payload, opts)
opts = opts or {};
local timeout = opts.timeout or DEFAULT_TIMEOUT;
local requestId = newRequestId();
transmit(buildPacket(serviceName, 'req', payload, opts.destId, requestId));
return awaitResponse(serviceName, requestId, timeout, false);
end
local function callMultiple(serviceName, payload, opts)
opts = opts or {};
local timeout = opts.timeout or DEFAULT_TIMEOUT;
local requestId = newRequestId();
transmit(buildPacket(serviceName, 'req', payload, opts.destId, requestId));
return awaitResponse(serviceName, requestId, timeout, true);
end
local function setRouter(enabled)
isRouter = enabled and true or false;
end
local function onUnrouted(handler)
return el.register('modem_message', function(_, _, replyChannel, packet)
if replyChannel ~= BUS_CHANNEL then return end
if type(packet) ~= 'table' then return end
if packet.routerId then return end
if not packet.sourceId then return end
handler(packet);
end);
end
local function rebroadcast(packet)
packet.routerId = packet.routerId or os.getComputerID();
local selfId = os.getComputerID();
local selfLabel = os.getComputerLabel();
local destIsSelfId = packet.destId == selfId;
local destIsSelfLabel = selfLabel ~= nil and packet.destId == selfLabel;
local destIsSelf = destIsSelfId or destIsSelfLabel;
local matchesSelf = packet.destId == nil or destIsSelf;
if matchesSelf then
os.queueEvent('modem_message', modemSide, BUS_CHANNEL, BUS_CHANNEL, packet, 0);
end
if not destIsSelf then
modem.transmit(BUS_CHANNEL, BUS_CHANNEL, packet);
end
end
return {
BUS_CHANNEL = BUS_CHANNEL,
DEFAULT_TIMEOUT = DEFAULT_TIMEOUT,
eventloop = el,
isPacketOk = isPacketOk,
serve = serve,
listen = listen,
send = send,
call = call,
callMultiple = callMultiple,
setRouter = setRouter,
onUnrouted = onUnrouted,
rebroadcast = rebroadcast,
};
end
local singleton = nil;
return function(el, modem, modemSide)
if el == nil and modem == nil and _G.bootEventLoop then
if not singleton then
singleton = createNetwork(_G.bootEventLoop, nil, nil);
end
return singleton;
end
return createNetwork(el, modem, modemSide);
end

View File

@ -1,15 +0,0 @@
{
"name": "trapos-net",
"version": "0.3.2",
"description": "TrapOS networking: service-name bus, router, ping",
"dependencies": ["trapos-core"],
"files": [
"apis/net.lua",
"apis/librouter.lua",
"programs/router.lua",
"programs/ping.lua",
"servers/ping-server.lua",
"servers/net-registrar.lua"
],
"autostart": ["servers/ping-server", "servers/net-registrar"]
}

View File

@ -1,35 +0,0 @@
local firstArg = ...;
if firstArg == '-version' or firstArg == '--version' then
print('v' .. require('/apis/libversion')().forSelf());
return;
end
if firstArg == '-help' or firstArg == '--help' then
print('Usage: ping [<id|label>]');
return;
end
local createNet = require('/apis/net');
local net = createNet();
local args = table.pack(...);
local targetComputerId = tonumber(args[1]) or args[1];
local sourceId = os.getComputerID();
local sourceLabel = os.getComputerLabel();
local ok, results, packets = net.callMultiple('ping', 'ping', { destId = targetComputerId });
if not ok and targetComputerId ~= sourceId and targetComputerId ~= sourceLabel then
error(results);
end
if not ok then return end
for k, message in ipairs(results) do
if message == 'pong' then
local packet = packets[k];
print("=> pong from " .. tostring(packet.sourceId)
.. (packet.sourceLabel and " (label=" .. tostring(packet.sourceLabel) .. ")" or ""));
end
end

View File

@ -1,69 +0,0 @@
local firstArg = ...;
if firstArg == '-version' or firstArg == '--version' then
print('v' .. require('/apis/libversion')().forSelf());
return;
end
if firstArg == '-help' or firstArg == '--help' then
print('Usage: router [-silent|--silent]');
print('Enables routing on this machine. Registers handlers on the boot eventloop.');
return;
end
local silent = (firstArg == '-silent' or firstArg == '--silent');
local printVerbose = silent and function() end or print;
local createEventLoop = require('/apis/eventloop');
local createNet = require('/apis/net');
local createRouter = require('/apis/librouter');
local ownsLoop = false;
if not _G.bootEventLoop then
_G.bootEventLoop = createEventLoop();
ownsLoop = true;
end
local net = createNet();
net.setRouter(true);
local router = createRouter();
net.listen('router.register', function(payload, packet)
if type(payload) ~= 'table' or type(payload.label) ~= 'string' then return end
local ok = router.register(payload.label, packet.sourceId);
if ok then
printVerbose("router: registered '" .. payload.label .. "' -> " .. tostring(packet.sourceId));
else
printVerbose("router: duplicate label '" .. payload.label .. "' from id " .. tostring(packet.sourceId));
end
end);
net.onUnrouted(function(packet)
if type(packet.destId) == 'string' then
local id = router.resolve(packet.destId);
if id then
packet.destId = id;
else
printVerbose("router: unknown label '" .. tostring(packet.destId) .. "' (dropping)");
return;
end
end
if packet.destId then
printVerbose("router: " .. tostring(packet.sourceId) .. " -> " .. tostring(packet.destId)
.. " [" .. tostring(packet.service) .. "/" .. tostring(packet.kind) .. "]");
else
printVerbose("router: " .. tostring(packet.sourceId) .. " broadcast"
.. " [" .. tostring(packet.service) .. "/" .. tostring(packet.kind) .. "]");
end
net.rebroadcast(packet);
end);
printVerbose('router v' .. require('/apis/libversion')().forSelf()
.. ' started on bus channel ' .. tostring(net.BUS_CHANNEL));
if ownsLoop then
_G.bootEventLoop.startLoop();
end

View File

@ -1,24 +0,0 @@
-- Periodically broadcasts (id, label) to the router so label-addressed
-- packets can be resolved network-wide. Skips machines with no label.
local createNet = require("/apis/net")
-- TOFIX: the idea of this file is to dynamically listen the computerLabel so in fact "os.getComputerLabel" should be called inside refresh()
local label = os.getComputerLabel()
if not label then
return
end
local net = createNet()
local el = net.eventloop
-- In the future we might want to consider to have core events like computer-label-changed (at os level)
local REFRESH_SECONDS = 30
local function refresh()
-- Note: I don't like "router.register" and net-registrar naming here
-- I think what we want here is a sort of hack to get an event computer_label_changed that will be used by the router directly, so maybe move this directly in `startup` dir ?
net.send("router.register", { label = label })
el.setTimeout(refresh, REFRESH_SECONDS)
end
el.onStart(refresh)

View File

@ -1,23 +0,0 @@
local createNet = require("/apis/net")
local createVersion = require("/apis/libversion")
local MODEM_DETECTION_TIME = 3
if not peripheral.find("modem") then
print("Warning: modem not found!")
end
-- TOFIX: os.sleep should not be used anymore since we are in the main eventloop here.
while not peripheral.find("modem") do
os.sleep(MODEM_DETECTION_TIME)
end
local net = createNet()
net.serve("ping", function(message, reply)
if message == "ping" then
reply("pong")
end
end)
print("ping-server v" .. createVersion().forSelf() .. " started.")

View File

@ -1,164 +0,0 @@
local createLibTest = require('/apis/libtest');
local createEventLoop = require('/apis/eventloop');
local createNet = require('/apis/net');
local testlib = createLibTest({ ... });
local function fakeModem()
local transmits = {};
return {
open = function() end,
close = function() end,
transmit = function(channel, replyChannel, payload)
transmits[#transmits + 1] = {
channel = channel,
replyChannel = replyChannel,
payload = payload,
};
end,
isOpen = function() return true end,
_transmits = transmits,
};
end
testlib.test('isPacketOk requires sourceId and routerId', function()
local modem = fakeModem();
local net = createNet(createEventLoop(), modem, 'test_modem');
testlib.assertEquals(net.isPacketOk(nil), false);
testlib.assertEquals(net.isPacketOk({}), false);
testlib.assertEquals(net.isPacketOk({ sourceId = 1 }), false);
testlib.assertEquals(net.isPacketOk({ sourceId = 1, routerId = 2 }), true);
end);
testlib.test('isPacketOk matches destId by id or label', function()
local modem = fakeModem();
local net = createNet(createEventLoop(), modem, 'test_modem');
local selfId = os.getComputerID();
testlib.assertEquals(
net.isPacketOk({ sourceId = 9, routerId = 9, destId = selfId }),
true
);
testlib.assertEquals(
net.isPacketOk({ sourceId = 9, routerId = 9, destId = selfId + 100 }),
false
);
end);
testlib.test('send transmits an evt packet on the bus channel', function()
local modem = fakeModem();
local net = createNet(createEventLoop(), modem, 'test_modem');
net.send('myservice', { hello = 'world' }, { destId = 42 });
testlib.assertEquals(#modem._transmits, 1);
local t = modem._transmits[1];
testlib.assertEquals(t.channel, net.BUS_CHANNEL);
testlib.assertEquals(t.replyChannel, net.BUS_CHANNEL);
testlib.assertEquals(t.payload.service, 'myservice');
testlib.assertEquals(t.payload.kind, 'evt');
testlib.assertEquals(t.payload.destId, 42);
testlib.assertEquals(t.payload.payload.hello, 'world');
end);
testlib.test('setRouter stamps routerId on outbound packets', function()
local modem = fakeModem();
local net = createNet(createEventLoop(), modem, 'test_modem');
net.send('foo', 'bar', { destId = 42 });
testlib.assertEquals(modem._transmits[1].payload.routerId, nil);
net.setRouter(true);
net.send('foo', 'bar', { destId = 42 });
testlib.assertEquals(modem._transmits[2].payload.routerId, os.getComputerID());
end);
testlib.test('serve dispatches a request loopback to a registered handler', function()
local modem = fakeModem();
local el = createEventLoop();
local net = createNet(el, modem, 'test_modem');
local received = nil;
net.serve('echo', function(payload, reply)
received = payload;
reply({ echoed = payload });
el.stopLoop();
end);
local packet = {
sourceId = os.getComputerID(),
sourceLabel = os.getComputerLabel(),
destId = os.getComputerID(),
service = 'echo',
kind = 'req',
requestId = 'test-req-1',
payload = { foo = 'bar' },
routerId = os.getComputerID(),
};
os.queueEvent('modem_message', 'test_modem', net.BUS_CHANNEL, net.BUS_CHANNEL, packet, 0);
el.runLoop();
testlib.assertEquals(received.foo, 'bar');
end);
testlib.test('serve ignores packets with a different service', function()
local modem = fakeModem();
local el = createEventLoop();
local net = createNet(el, modem, 'test_modem');
local hit = false;
net.serve('echo', function() hit = true end);
el.setTimeout(function() el.stopLoop() end, 0);
local packet = {
sourceId = os.getComputerID(),
sourceLabel = os.getComputerLabel(),
destId = os.getComputerID(),
service = 'other',
kind = 'req',
requestId = 'test-req-2',
payload = nil,
routerId = os.getComputerID(),
};
os.queueEvent('modem_message', 'test_modem', net.BUS_CHANNEL, net.BUS_CHANNEL, packet, 0);
el.runLoop();
testlib.assertEquals(hit, false);
end);
testlib.test('serve ignores unrouted packets (no routerId)', function()
local modem = fakeModem();
local el = createEventLoop();
local net = createNet(el, modem, 'test_modem');
local hit = false;
net.serve('echo', function() hit = true end);
el.setTimeout(function() el.stopLoop() end, 0);
local packet = {
sourceId = os.getComputerID(),
sourceLabel = os.getComputerLabel(),
destId = os.getComputerID(),
service = 'echo',
kind = 'req',
requestId = 'test-req-3',
payload = nil,
-- no routerId
};
os.queueEvent('modem_message', 'test_modem', net.BUS_CHANNEL, net.BUS_CHANNEL, packet, 0);
el.runLoop();
testlib.assertEquals(hit, false);
end);
testlib.run();

View File

@ -1,74 +0,0 @@
local createLibTest = require('/apis/libtest');
local createRouter = require('/apis/librouter');
local testlib = createLibTest({ ... });
local function makeClock()
local now = 0;
return function() return now; end, function(delta) now = now + delta; end;
end
testlib.test('register accepts a new label', function()
local now, _ = makeClock();
local router = createRouter({ now = now, ttl = 10 });
local ok, err = router.register('alice', 5);
testlib.assertEquals(ok, true);
testlib.assertEquals(err, nil);
testlib.assertEquals(router.resolve('alice'), 5);
end);
testlib.test('register rejects a duplicate label from a different id', function()
local now, _ = makeClock();
local router = createRouter({ now = now, ttl = 10 });
router.register('alice', 5);
local ok, err = router.register('alice', 7);
testlib.assertEquals(ok, false);
testlib.assertEquals(err, 'duplicate label');
testlib.assertEquals(router.resolve('alice'), 5);
end);
testlib.test('register refreshes a label from the same id', function()
local now, advance = makeClock();
local router = createRouter({ now = now, ttl = 10 });
router.register('alice', 5);
advance(5);
local ok = router.register('alice', 5);
testlib.assertEquals(ok, true);
advance(8);
testlib.assertEquals(router.resolve('alice'), 5);
end);
testlib.test('resolve returns nil after TTL expiry', function()
local now, advance = makeClock();
local router = createRouter({ now = now, ttl = 10 });
router.register('alice', 5);
advance(11);
testlib.assertEquals(router.resolve('alice'), nil);
end);
testlib.test('expired entries can be re-registered by a different id', function()
local now, advance = makeClock();
local router = createRouter({ now = now, ttl = 10 });
router.register('alice', 5);
advance(11);
local ok = router.register('alice', 7);
testlib.assertEquals(ok, true);
testlib.assertEquals(router.resolve('alice'), 7);
end);
testlib.test('forget removes a label', function()
local now, _ = makeClock();
local router = createRouter({ now = now, ttl = 10 });
router.register('alice', 5);
router.forget('alice');
testlib.assertEquals(router.resolve('alice'), nil);
end);
testlib.run();

View File

@ -1,10 +1,9 @@
{
"name": "trapos",
"version": "0.12.4",
"version": "0.12.5",
"description": "TrapOS full install meta-package",
"dependencies": [
"trapos-boot",
"trapos-net",
"trapos-ui",
"trapos-test",
"trapos-ai",

View File

@ -1,6 +1,6 @@
import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify";
import websocket from "@fastify/websocket";
import { resolveAccount, LOCAL_ACCOUNT_ID } from "./auth.js";
import { resolveNetwork, GLOBAL_NETWORK_ID } from "./auth.js";
import { createOpencodeProbe } from "./opencode.js";
import gatewayModule from "./modules/trapos-cloud-gateway/index.js";
import { GatewayRegistry } from "./modules/trapos-cloud-gateway/registry.js";
@ -48,7 +48,7 @@ export async function createApp(
await app.register(gatewayModule, {
registry,
probe,
resolveAccount: (secret) => resolveAccount(secret, config.serverPassword),
resolveNetwork: (secret) => resolveNetwork(secret, config.serverPassword),
});
const mcpApp = config.mcpEnabled ? await createMcpApp(config, registry) : null;
@ -60,7 +60,7 @@ async function createMcpApp(config: Config, registry: GatewayRegistry): Promise<
const mcpApp = Fastify({ logger: config.logger, disableRequestLogging: true });
await mcpApp.register(mcpModule, {
registry,
accountId: LOCAL_ACCOUNT_ID,
networkId: GLOBAL_NETWORK_ID,
defaultTimeoutMs: config.mcpDefaultTimeoutMs,
maxTimeoutMs: config.mcpMaxTimeoutMs,
probeTimeoutMs: config.mcpProbeTimeoutMs,

View File

@ -1,19 +1,19 @@
export type AccountId = "local";
export type NetworkId = "global";
// The single account today. Centralized so the gateway and the MCP server agree on
// the registry key (the future multi-account swap touches only resolveAccount).
export const LOCAL_ACCOUNT_ID: AccountId = "local";
// The single network today. Centralized so the gateway and the MCP server agree on
// the registry key (the future multi-network swap touches only resolveNetwork).
export const GLOBAL_NETWORK_ID: NetworkId = "global";
// The single future swap-point: credential -> account home. Today it also owns the
// password gate. Returns the account id for a valid secret, or null = unauthorized.
// - password unset/empty => auth disabled, any secret (or none) maps to "local"
// The single future swap-point: credential -> network home. Today it also owns the
// password gate. Returns the network id for a valid secret, or null = unauthorized.
// - password unset/empty => auth disabled, any secret (or none) maps to "global"
// - password set => secret must match exactly, else null
export function resolveAccount(
export function resolveNetwork(
secret: string | undefined,
password: string | undefined,
): AccountId | null {
): NetworkId | null {
if (password === undefined || password === "") {
return "local";
return GLOBAL_NETWORK_ID;
}
return secret === password ? "local" : null;
return secret === password ? GLOBAL_NETWORK_ID : null;
}

View File

@ -57,7 +57,7 @@ function buildLogger(): Config["logger"] {
}
// A non-empty password is mandatory: an empty TRAPOS_SERVER_PASSWORD disables auth
// on the public gateway (resolveAccount maps any secret to "local"), and an empty
// on the public gateway (resolveNetwork maps any secret to "global"), and an empty
// OPENCODE_SERVER_PASSWORD drops the outbound Basic-auth header. Refuse to start
// rather than run silently unauthenticated.
function requireSecret(name: string, value: string | undefined): string {

View File

@ -8,7 +8,7 @@ import type { GatewayRegistry } from "../trapos-cloud-gateway/registry.js";
export type McpDeps = {
registry: GatewayRegistry;
accountId: string;
networkId: string;
// Default deadline for exec-lua / run-file / write-file when the caller omits timeoutMs.
defaultTimeoutMs: number;
// Hard ceiling applied to any caller-supplied timeoutMs.
@ -153,7 +153,7 @@ async function handleSingle(request: JsonRpcRequest, deps: McpDeps): Promise<unk
}
async function probeComputers(deps: McpDeps): Promise<string> {
const traposIds = deps.registry.list(deps.accountId);
const traposIds = deps.registry.list(deps.networkId);
if (traposIds.length === 0) {
return "No computers connected.";
}
@ -161,7 +161,7 @@ async function probeComputers(deps: McpDeps): Promise<string> {
const lines = await Promise.all(
traposIds.map(async (traposId) => {
try {
await deps.registry.request(deps.accountId, traposId, "ping", {}, deps.probeTimeoutMs);
await deps.registry.request(deps.networkId, traposId, "ping", {}, deps.probeTimeoutMs);
return `ok from ${traposId}`;
} catch (err) {
return `error from ${traposId}: ${errorText(err)}`;
@ -174,7 +174,7 @@ async function probeComputers(deps: McpDeps): Promise<string> {
async function execLua(deps: McpDeps, traposId: string, code: string, timeoutMs: number): Promise<string> {
let payload: Record<string, unknown>;
try {
payload = await deps.registry.request(deps.accountId, traposId, "exec-lua", { code }, timeoutMs);
payload = await deps.registry.request(deps.networkId, traposId, "exec-lua", { code }, timeoutMs);
} catch (err) {
return transportError(traposId, err);
}
@ -201,7 +201,7 @@ function formatExecutionResult(traposId: string, payload: Record<string, unknown
async function runFile(deps: McpDeps, traposId: string, path: string, args: string[], timeoutMs: number): Promise<string> {
let payload: Record<string, unknown>;
try {
payload = await deps.registry.request(deps.accountId, traposId, "run-file", { path, args }, timeoutMs);
payload = await deps.registry.request(deps.networkId, traposId, "run-file", { path, args }, timeoutMs);
} catch (err) {
return transportError(traposId, err);
}
@ -218,7 +218,7 @@ async function writeFile(
): Promise<string> {
let payload: Record<string, unknown>;
try {
payload = await deps.registry.request(deps.accountId, traposId, "write-file", { path, content }, timeoutMs);
payload = await deps.registry.request(deps.networkId, traposId, "write-file", { path, content }, timeoutMs);
} catch (err) {
return transportError(traposId, err);
}

View File

@ -8,13 +8,13 @@ import {
} from "./protocol.js";
import type { GatewayRegistry, GatewaySocket, Logger } from "./registry.js";
import type { OpencodeProbe } from "../../opencode.js";
import type { AccountId } from "../../auth.js";
import type { NetworkId } from "../../auth.js";
export type ConnectionDeps = {
socket: GatewaySocket;
registry: GatewayRegistry;
probe: OpencodeProbe;
resolveAccount: (secret: string | undefined) => AccountId | null;
resolveNetwork: (secret: string | undefined) => NetworkId | null;
log: Logger;
};
@ -23,7 +23,7 @@ export type Connection = {
onClose(code?: number, reason?: string): void;
};
type State = { accountId: AccountId; traposId: string };
type State = { networkId: NetworkId; traposId: string };
function toText(raw: unknown): string {
if (typeof raw === "string") return raw;
@ -37,7 +37,7 @@ function toText(raw: unknown): string {
// event that ever earns an error reply is `computer_request`; everything the gateway
// shouldn't receive is dropped (with a debug log). `unauthorized` is the only close.
export function createConnection(deps: ConnectionDeps): Connection {
const { socket, registry, probe, resolveAccount, log } = deps;
const { socket, registry, probe, resolveNetwork, log } = deps;
let state: State | null = null;
function reply(
@ -67,22 +67,22 @@ export function createConnection(deps: ConnectionDeps): Connection {
}
const { traposId, secret } = parsed.data;
const accountId = resolveAccount(secret);
if (accountId === null) {
const networkId = resolveNetwork(secret);
if (networkId === null) {
log.warn({ traposId, reason: "unauthorized" }, "gateway hello rejected");
reply(req, false, undefined, { code: "unauthorized", message: "invalid secret" });
socket.close();
return;
}
registry.register(accountId, traposId, socket);
state = { accountId, traposId };
log.info({ accountId, traposId }, "gateway hello accepted");
reply(req, true, { accountId });
registry.register(networkId, traposId, socket);
state = { networkId, traposId };
log.info({ networkId, traposId }, "gateway hello accepted");
reply(req, true, { networkId });
}
async function handleProbeGateway(req: ComputerRequest): Promise<void> {
log.debug({ accountId: state?.accountId, traposId: state?.traposId, messageId: req.messageId }, "probing gateway dependencies");
log.debug({ networkId: state?.networkId, traposId: state?.traposId, messageId: req.messageId }, "probing gateway dependencies");
const result = await probe.probe();
const payload: Record<string, unknown> = { serverOk: true, opencodeOk: result.ok };
if (!result.ok && result.detail !== undefined) {
@ -108,20 +108,20 @@ export function createConnection(deps: ConnectionDeps): Connection {
async function dispatchPostHello(frame: Frame, current: State): Promise<void> {
if (frame.event === "computer_request") {
if (frame.type === "hello") {
log.debug({ accountId: current.accountId, traposId: current.traposId, reason: "duplicate_hello" }, "ignoring duplicate hello on live connection");
log.debug({ networkId: current.networkId, traposId: current.traposId, reason: "duplicate_hello" }, "ignoring duplicate hello on live connection");
return;
}
if (frame.type === "probe_server") {
await handleProbeGateway(frame);
return;
}
log.debug({ accountId: current.accountId, traposId: current.traposId, messageId: frame.messageId, type: frame.type, reason: "unknown_type" }, "rejecting unknown computer_request type");
log.debug({ networkId: current.networkId, traposId: current.traposId, messageId: frame.messageId, type: frame.type, reason: "unknown_type" }, "rejecting unknown computer_request type");
reply(frame, false, undefined, { code: "unknown_type", message: `unknown type: ${frame.type}` });
return;
}
if (frame.event === "computer_response") {
// resolve a pending server_request
registry.resolveResponse(socket, current.accountId, current.traposId, frame);
registry.resolveResponse(socket, current.networkId, current.traposId, frame);
}
}
@ -149,8 +149,8 @@ export function createConnection(deps: ConnectionDeps): Connection {
function onClose(code?: number, reason?: string): void {
if (state) {
log.info({ accountId: state.accountId, traposId: state.traposId, closeCode: code, closeReason: reason }, "gateway connection closed");
registry.unregister(state.accountId, state.traposId, socket);
log.info({ networkId: state.networkId, traposId: state.traposId, closeCode: code, closeReason: reason }, "gateway connection closed");
registry.unregister(state.networkId, state.traposId, socket);
state = null;
return;
}

View File

@ -3,7 +3,7 @@ import type { WebSocket } from "@fastify/websocket";
import { createConnection } from "./connection.js";
import type { GatewayRegistry, GatewaySocket } from "./registry.js";
import type { OpencodeProbe } from "../../opencode.js";
import type { AccountId } from "../../auth.js";
import type { NetworkId } from "../../auth.js";
let nextConnectionId = 1;
@ -18,7 +18,7 @@ const HEARTBEAT_INTERVAL_MS = 20_000;
export type GatewayModuleOptions = {
registry: GatewayRegistry;
probe: OpencodeProbe;
resolveAccount: (secret: string | undefined) => AccountId | null;
resolveNetwork: (secret: string | undefined) => NetworkId | null;
};
// Adapter: the only place that touches the real `ws` socket. Bridges socket events
@ -42,7 +42,7 @@ const gatewayModule: FastifyPluginAsync<GatewayModuleOptions> = (fastify, opts)
socket: gatewaySocket,
registry: opts.registry,
probe: opts.probe,
resolveAccount: opts.resolveAccount,
resolveNetwork: opts.resolveNetwork,
log,
});

View File

@ -34,7 +34,7 @@ function isErrorCode(code: string): code is ErrorCode {
return (ERROR_CODES as readonly string[]).includes(code);
}
// Registry of live computer connections keyed by (accountId, traposId), newest-wins.
// Registry of live computer connections keyed by (networkId, traposId), newest-wins.
// Each entry owns its own pending map so eviction / close rejects only that socket's
// in-flight requests.
export class GatewayRegistry {
@ -45,16 +45,16 @@ export class GatewayRegistry {
private readonly defaultTimeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
) {}
private key(accountId: string, traposId: string): string {
return `${accountId}\u0000${traposId}`;
private key(networkId: string, traposId: string): string {
return `${networkId}\u0000${traposId}`;
}
register(accountId: string, traposId: string, socket: GatewaySocket): void {
const k = this.key(accountId, traposId);
register(networkId: string, traposId: string, socket: GatewaySocket): void {
const k = this.key(networkId, traposId);
const existing = this.entries.get(k);
if (existing) {
this.log.info(
{ accountId, traposId, reason: "duplicate_hello" },
{ networkId, traposId, reason: "duplicate_hello" },
"evicting previous connection (newest-wins)",
);
this.rejectAll(existing, new GatewayError("not_connected", "connection displaced by newer registration"));
@ -71,8 +71,8 @@ export class GatewayRegistry {
// Identity-checked: a late close from an evicted socket must not remove the socket
// that replaced it.
unregister(accountId: string, traposId: string, socket: GatewaySocket): void {
const k = this.key(accountId, traposId);
unregister(networkId: string, traposId: string, socket: GatewaySocket): void {
const k = this.key(networkId, traposId);
const entry = this.entries.get(k);
if (!entry || entry.socket !== socket) {
return;
@ -81,18 +81,18 @@ export class GatewayRegistry {
this.rejectAll(entry, new GatewayError("not_connected", "connection closed"));
}
has(accountId: string, traposId: string): boolean {
return this.entries.has(this.key(accountId, traposId));
has(networkId: string, traposId: string): boolean {
return this.entries.has(this.key(networkId, traposId));
}
count(): number {
return this.entries.size;
}
// Registered traposIds for an account (newest-wins, so at most one per traposId).
// Registered traposIds for a network (newest-wins, so at most one per traposId).
// Used by the MCP `probe-computers` tool to enumerate addressable computers.
list(accountId: string): string[] {
const prefix = this.key(accountId, "");
list(networkId: string): string[] {
const prefix = this.key(networkId, "");
const out: string[] = [];
for (const key of this.entries.keys()) {
if (key.startsWith(prefix)) {
@ -106,13 +106,13 @@ export class GatewayRegistry {
// not_connected / timeout / response ok:false; resolves with the response payload.
// The deadline is the caller's concern; defaultTimeoutMs is only a safety ceiling.
request(
accountId: string,
networkId: string,
traposId: string,
type: string,
payload: Record<string, unknown> = {},
timeoutMs: number = this.defaultTimeoutMs,
): Promise<Record<string, unknown>> {
const entry = this.entries.get(this.key(accountId, traposId));
const entry = this.entries.get(this.key(networkId, traposId));
if (!entry) {
return Promise.reject(
new GatewayError("not_connected", `no computer registered for ${traposId}`),
@ -132,7 +132,7 @@ export class GatewayRegistry {
const timer = setTimeout(() => {
entry.pending.delete(messageId);
this.log.warn(
{ accountId, traposId, messageId, type, timeoutMs, durationMs: Date.now() - startedAt, pendingCount: entry.pending.size },
{ networkId, traposId, messageId, type, timeoutMs, durationMs: Date.now() - startedAt, pendingCount: entry.pending.size },
"gateway request timed out",
);
reject(new GatewayError("timeout", `request "${type}" timed out after ${timeoutMs}ms`));
@ -144,14 +144,14 @@ export class GatewayRegistry {
try {
entry.socket.send(JSON.stringify(frame));
this.log.debug(
{ accountId, traposId, messageId, type, timeoutMs, pendingCount: entry.pending.size },
{ networkId, traposId, messageId, type, timeoutMs, pendingCount: entry.pending.size },
"gateway request sent",
);
} catch (err) {
clearTimeout(timer);
entry.pending.delete(messageId);
this.log.warn(
{ accountId, traposId, messageId, type, pendingCount: entry.pending.size, err },
{ networkId, traposId, messageId, type, pendingCount: entry.pending.size, err },
"gateway request send failed",
);
reject(new GatewayError("internal", err instanceof Error ? err.message : String(err)));
@ -161,8 +161,8 @@ export class GatewayRegistry {
// Resolve a pending server_request from a matching computer_response. Silently
// dropped (with a debug log) when there is no matching pending.
resolveResponse(socket: GatewaySocket, accountId: string, traposId: string, frame: ComputerResponse): void {
const entry = this.entries.get(this.key(accountId, traposId));
resolveResponse(socket: GatewaySocket, networkId: string, traposId: string, frame: ComputerResponse): void {
const entry = this.entries.get(this.key(networkId, traposId));
if (!entry || entry.socket !== socket) {
this.log.debug({ messageId: frame.messageId, reason: "no_pending" }, "no entry for computer_response");
return;
@ -176,7 +176,7 @@ export class GatewayRegistry {
clearTimeout(pending.timer);
this.log.debug(
{
accountId,
networkId,
traposId,
messageId: frame.messageId,
type: pending.type,

View File

@ -27,7 +27,7 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
let second: ReturnType<typeof startCraftos> | undefined;
try {
await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
await waitFor(() => server.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
await waitFor(() => server.registry.has("global", TRAPOS_ID), 2_000, "first registered server-side");
const secondHandle = startCraftos("gateway-client.lua", {
computerId: 9,
@ -54,7 +54,7 @@ test("displaced daemon yields instead of reconnect-looping", async () => {
assert.equal(server.registry.count(), 1, "exactly one registration survives");
// The surviving (newest) daemon still answers a gateway->computer ping.
const payload = await server.registry.request("local", TRAPOS_ID, "ping", {}, 2_000);
const payload = await server.registry.request("global", TRAPOS_ID, "ping", {}, 2_000);
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "survivor answers reverse ping");
} catch (error) {
throw new Error(

View File

@ -23,7 +23,7 @@ test("duplicate traposId is resolved newest-wins", async () => {
let second: ReturnType<typeof startCraftos> | undefined;
try {
await waitFor(() => first.snapshot().includes("__CLOUD_READY__"), 12_000, "first connected");
await waitFor(() => server.registry.has("local", TRAPOS_ID), 2_000, "first registered server-side");
await waitFor(() => server.registry.has("global", TRAPOS_ID), 2_000, "first registered server-side");
const secondHandle = startCraftos("gateway-client.lua", {
computerId: 9,
@ -34,11 +34,11 @@ test("duplicate traposId is resolved newest-wins", async () => {
await waitFor(() => secondHandle.snapshot().includes("__CLOUD_READY__"), 12_000, "second connected");
// Newest-wins: still exactly one registration for the shared id.
assert.equal(server.registry.count(), 1, "exactly one registration after the duplicate");
assert.ok(server.registry.has("local", TRAPOS_ID));
assert.ok(server.registry.has("global", TRAPOS_ID));
first.abort();
await first.done.catch(() => undefined);
const payload = await server.registry.request("local", TRAPOS_ID, "ping", {}, 2_000);
const payload = await server.registry.request("global", TRAPOS_ID, "ping", {}, 2_000);
assert.deepEqual(payload, { traposId: TRAPOS_ID }, "newest stayed registered after first exited");
} catch (error) {
throw new Error(

View File

@ -26,7 +26,7 @@ test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway",
shellArgs: [server.baseUrl, "-"],
});
try {
await waitFor(() => server.registry.has("local", "7:base"), 12_000, "computer registered");
await waitFor(() => server.registry.has("global", "7:base"), 12_000, "computer registered");
assert.equal(await callTool(server, "probe-computers"), "ok from 7:base");

View File

@ -12,8 +12,8 @@ test("gateway->computer reverse ping is answered by the client", async () => {
shellArgs: [server.baseUrl, "-", "idle"],
});
try {
await waitFor(() => server.registry.has("local", "7:ping"), 12_000, "computer registered");
const payload = await server.registry.request("local", "7:ping", "ping");
await waitFor(() => server.registry.has("global", "7:ping"), 12_000, "computer registered");
const payload = await server.registry.request("global", "7:ping", "ping");
assert.deepEqual(payload, { traposId: "7:ping" });
} catch (error) {
throw new Error(

View File

@ -1,22 +1,22 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { resolveAccount } from "../src/auth.js";
import { resolveNetwork } from "../src/auth.js";
test("auth disabled when password unset/empty -> always local", () => {
assert.equal(resolveAccount(undefined, undefined), "local");
assert.equal(resolveAccount("anything", undefined), "local");
assert.equal(resolveAccount(undefined, ""), "local");
assert.equal(resolveAccount("anything", ""), "local");
assert.equal(resolveNetwork(undefined, undefined), "global");
assert.equal(resolveNetwork("anything", undefined), "global");
assert.equal(resolveNetwork(undefined, ""), "global");
assert.equal(resolveNetwork("anything", ""), "global");
});
test("password set: exact match -> local", () => {
assert.equal(resolveAccount("s3cr3t", "s3cr3t"), "local");
assert.equal(resolveNetwork("s3cr3t", "s3cr3t"), "global");
});
test("password set: mismatch -> null", () => {
assert.equal(resolveAccount("wrong", "s3cr3t"), null);
assert.equal(resolveNetwork("wrong", "s3cr3t"), null);
});
test("password set: missing secret -> null", () => {
assert.equal(resolveAccount(undefined, "s3cr3t"), null);
assert.equal(resolveNetwork(undefined, "s3cr3t"), null);
});

View File

@ -17,7 +17,7 @@ function setup(opts?: { secret?: string; probe?: ProbeResult }) {
socket,
registry,
probe: fakeProbe(opts?.probe ?? { ok: true }),
resolveAccount: (secret) => (expectedSecret === undefined || secret === expectedSecret ? "local" : null),
resolveNetwork: (secret) => (expectedSecret === undefined || secret === expectedSecret ? "global" : null),
log: silentLog,
});
return { socket, registry, connection };
@ -73,15 +73,15 @@ test("hello bad secret -> unauthorized and close", async () => {
assert.equal(registry.count(), 0);
});
test("hello ok -> registers and replies with accountId", async () => {
test("hello ok -> registers and replies with networkId", async () => {
const { socket, registry, connection } = setup();
await connection.onMessage(helloFrame("7:", "anything"));
const frame = socket.lastFrame();
assert.equal(frame.event, "server_response");
assert.equal(frame.type, "hello");
assert.equal(frame.ok, true);
assert.deepEqual(frame.payload, { accountId: "local" });
assert.equal(registry.has("local", "7:"), true);
assert.deepEqual(frame.payload, { networkId: "global" });
assert.equal(registry.has("global", "7:"), true);
assert.equal(registry.count(), 1);
});
@ -117,8 +117,8 @@ test("post-hello duplicate hello is ignored", async () => {
const afterFirst = socket.sent.length;
await connection.onMessage(helloFrame("9:"));
assert.equal(socket.sent.length, afterFirst); // no reply
assert.equal(registry.has("local", "7:"), true);
assert.equal(registry.has("local", "9:"), false);
assert.equal(registry.has("global", "7:"), true);
assert.equal(registry.has("global", "9:"), false);
});
test("post-hello wrong-direction frame is dropped", async () => {
@ -135,7 +135,7 @@ test("reverse direction: gateway request resolved by computer_response", async (
const { socket, registry, connection } = setup();
await connection.onMessage(helloFrame("7:"));
const promise = registry.request("local", "7:", "ping", {});
const promise = registry.request("global", "7:", "ping", {});
const sent = socket.lastFrame();
assert.equal(sent.event, "server_request");
assert.equal(sent.type, "ping");

View File

@ -5,10 +5,10 @@ import type { ComputerResponse } from "../src/modules/trapos-cloud-gateway/proto
import { handleMcpRequest, type McpDeps } from "../src/modules/mcp/tools.js";
import { FakeSocket, silentLog } from "./helpers.js";
const ACCOUNT = "local";
const NETWORK = "global";
function makeDeps(registry: GatewayRegistry): McpDeps {
return { registry, accountId: ACCOUNT, defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, probeTimeoutMs: 5_000 };
return { registry, networkId: NETWORK, defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, probeTimeoutMs: 5_000 };
}
function rpc(method: string, params?: unknown, id: unknown = 1): Record<string, unknown> {
@ -38,7 +38,7 @@ async function callAndAnswer(
// registry.request sends synchronously; a tick lets the async chain reach the await.
await Promise.resolve();
const frame = socket.lastFrame();
deps.registry.resolveResponse(socket, ACCOUNT, traposId, response(traposId, frame.messageId as string, frame.type as string, ok, payload));
deps.registry.resolveResponse(socket, NETWORK, traposId, response(traposId, frame.messageId as string, frame.type as string, ok, payload));
return resultText(await pending);
}
@ -72,13 +72,13 @@ test("probe-computers pings each connected computer by traposId", async () => {
const reg = new GatewayRegistry(silentLog);
const deps = makeDeps(reg);
const a = new FakeSocket();
reg.register(ACCOUNT, "7:base", a);
reg.register(NETWORK, "7:base", a);
const pending = handleMcpRequest(rpc("tools/call", { name: "probe-computers" }), deps);
await Promise.resolve();
const frame = a.lastFrame();
assert.equal(frame.type, "ping");
reg.resolveResponse(a, ACCOUNT, "7:base", response("7:base", frame.messageId as string, "ping", true, { traposId: "7:base" }));
reg.resolveResponse(a, NETWORK, "7:base", response("7:base", frame.messageId as string, "ping", true, { traposId: "7:base" }));
assert.equal(resultText(await pending), "ok from 7:base");
});
@ -86,7 +86,7 @@ test("exec-lua formats returns and trimmed output on success", async () => {
const reg = new GatewayRegistry(silentLog);
const deps = makeDeps(reg);
const a = new FakeSocket();
reg.register(ACCOUNT, "7:base", a);
reg.register(NETWORK, "7:base", a);
const text = await callAndAnswer(deps, a, "7:base", "exec-lua", { traposId: "7:base", code: "print('hi')" }, true, {
ok: true,
@ -103,7 +103,7 @@ test("exec-lua surfaces an ok:false result with its captured output", async () =
const reg = new GatewayRegistry(silentLog);
const deps = makeDeps(reg);
const a = new FakeSocket();
reg.register(ACCOUNT, "7:base", a);
reg.register(NETWORK, "7:base", a);
const text = await callAndAnswer(deps, a, "7:base", "exec-lua", { traposId: "7:base", code: "error('boom')" }, true, {
ok: false,
@ -129,7 +129,7 @@ test("run-file sends the path and formats execution output", async () => {
const reg = new GatewayRegistry(silentLog);
const deps = makeDeps(reg);
const a = new FakeSocket();
reg.register(ACCOUNT, "7:base", a);
reg.register(NETWORK, "7:base", a);
const text = await callAndAnswer(
deps,
@ -154,7 +154,7 @@ test("run-file defaults args to an empty array", async () => {
const reg = new GatewayRegistry(silentLog);
const deps = makeDeps(reg);
const a = new FakeSocket();
reg.register(ACCOUNT, "7:base", a);
reg.register(NETWORK, "7:base", a);
await callAndAnswer(deps, a, "7:base", "run-file", { traposId: "7:base", path: "/hello.lua" }, true, {
ok: true,
@ -179,7 +179,7 @@ test("write-file reports the written path and byte count", async () => {
const reg = new GatewayRegistry(silentLog);
const deps = makeDeps(reg);
const a = new FakeSocket();
reg.register(ACCOUNT, "7:base", a);
reg.register(NETWORK, "7:base", a);
const text = await callAndAnswer(
deps,
@ -205,11 +205,11 @@ test("exec-lua rejects a missing traposId with an invalid-params error", async (
assert.match(error?.message ?? "", /traposId/);
});
test("registry.list returns the traposIds registered for an account", () => {
test("registry.list returns the traposIds registered for a network", () => {
const reg = new GatewayRegistry(silentLog);
reg.register(ACCOUNT, "7:base", new FakeSocket());
reg.register(ACCOUNT, "8:turtle", new FakeSocket());
reg.register(NETWORK, "7:base", new FakeSocket());
reg.register(NETWORK, "8:turtle", new FakeSocket());
reg.register("other", "9:base", new FakeSocket());
assert.deepEqual(reg.list(ACCOUNT).sort(), ["7:base", "8:turtle"]);
assert.deepEqual(reg.list(NETWORK).sort(), ["7:base", "8:turtle"]);
assert.deepEqual(reg.list("other"), ["9:base"]);
});

View File

@ -15,11 +15,11 @@ function sentMessageId(socket: FakeSocket): string {
test("register / has / count / unregister", () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
reg.register("local", "7:", a);
assert.equal(reg.has("local", "7:"), true);
reg.register("global", "7:", a);
assert.equal(reg.has("global", "7:"), true);
assert.equal(reg.count(), 1);
reg.unregister("local", "7:", a);
assert.equal(reg.has("local", "7:"), false);
reg.unregister("global", "7:", a);
assert.equal(reg.has("global", "7:"), false);
assert.equal(reg.count(), 0);
});
@ -27,8 +27,8 @@ test("newest-wins evicts previous socket for same key", () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
const b = new FakeSocket();
reg.register("local", "7:", a);
reg.register("local", "7:", b);
reg.register("global", "7:", a);
reg.register("global", "7:", b);
assert.equal(a.closed, true);
// Distinct close code lets the displaced Lua client yield instead of reconnect-looping.
assert.equal(a.closeCode, DISPLACED_CLOSE_CODE);
@ -36,9 +36,9 @@ test("newest-wins evicts previous socket for same key", () => {
assert.equal(reg.count(), 1);
});
test("same traposId under different accounts coexist", () => {
test("same traposId under different networks coexist", () => {
const reg = new GatewayRegistry(silentLog);
reg.register("local", "7:", new FakeSocket());
reg.register("global", "7:", new FakeSocket());
reg.register("other", "7:", new FakeSocket());
assert.equal(reg.count(), 2);
});
@ -46,25 +46,25 @@ test("same traposId under different accounts coexist", () => {
test("request sends a server_request frame and resolves on matching ok response", async () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
reg.register("local", "7:", a);
reg.register("global", "7:", a);
const promise = reg.request("local", "7:", "ping", {});
const promise = reg.request("global", "7:", "ping", {});
const frame = a.lastFrame();
assert.equal(frame.event, "server_request");
assert.equal(frame.type, "ping");
assert.equal(frame.traposReceiverId, "7:");
reg.resolveResponse(a, "local", "7:", response(frame.messageId as string, true, { traposId: "7:" }));
reg.resolveResponse(a, "global", "7:", response(frame.messageId as string, true, { traposId: "7:" }));
assert.deepEqual(await promise, { traposId: "7:" });
});
test("request rejects with the response error code on ok:false", async () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
reg.register("local", "7:", a);
reg.register("global", "7:", a);
const promise = reg.request("local", "7:", "ping", {});
reg.resolveResponse(a, "local", "7:", response(sentMessageId(a), false, {}, { code: "unknown_type", message: "nope" }));
const promise = reg.request("global", "7:", "ping", {});
reg.resolveResponse(a, "global", "7:", response(sentMessageId(a), false, {}, { code: "unknown_type", message: "nope" }));
await assert.rejects(promise, (err: unknown) => err instanceof GatewayError && err.code === "unknown_type");
});
@ -72,16 +72,16 @@ test("request rejects with the response error code on ok:false", async () => {
test("request rejects not_connected when no socket registered", async () => {
const reg = new GatewayRegistry(silentLog);
await assert.rejects(
reg.request("local", "nobody", "ping", {}),
reg.request("global", "nobody", "ping", {}),
(err: unknown) => err instanceof GatewayError && err.code === "not_connected",
);
});
test("request rejects timeout after the deadline", async () => {
const reg = new GatewayRegistry(silentLog);
reg.register("local", "7:", new FakeSocket());
reg.register("global", "7:", new FakeSocket());
await assert.rejects(
reg.request("local", "7:", "ping", {}, 10),
reg.request("global", "7:", "ping", {}, 10),
(err: unknown) => err instanceof GatewayError && err.code === "timeout",
);
});
@ -89,17 +89,17 @@ test("request rejects timeout after the deadline", async () => {
test("messageId correlation: concurrent requests resolve independently", async () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
reg.register("local", "7:", a);
reg.register("global", "7:", a);
const p1 = reg.request("local", "7:", "ping", { n: 1 });
const p1 = reg.request("global", "7:", "ping", { n: 1 });
const id1 = a.lastFrame().messageId as string;
const p2 = reg.request("local", "7:", "ping", { n: 2 });
const p2 = reg.request("global", "7:", "ping", { n: 2 });
const id2 = a.lastFrame().messageId as string;
assert.notEqual(id1, id2);
// resolve out of order
reg.resolveResponse(a, "local", "7:", response(id2, true, { n: 2 }));
reg.resolveResponse(a, "local", "7:", response(id1, true, { n: 1 }));
reg.resolveResponse(a, "global", "7:", response(id2, true, { n: 2 }));
reg.resolveResponse(a, "global", "7:", response(id1, true, { n: 1 }));
assert.deepEqual(await p1, { n: 1 });
assert.deepEqual(await p2, { n: 2 });
});
@ -107,9 +107,9 @@ test("messageId correlation: concurrent requests resolve independently", async (
test("pending rejected on unregister (close)", async () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
reg.register("local", "7:", a);
const promise = reg.request("local", "7:", "ping", {});
reg.unregister("local", "7:", a);
reg.register("global", "7:", a);
const promise = reg.request("global", "7:", "ping", {});
reg.unregister("global", "7:", a);
await assert.rejects(promise, (err: unknown) => err instanceof GatewayError && err.code === "not_connected");
});
@ -117,21 +117,21 @@ test("eviction: old socket's pending rejected and late unregister(A) does not ev
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
const b = new FakeSocket();
reg.register("local", "7:", a);
const promise = reg.request("local", "7:", "ping", {});
reg.register("global", "7:", a);
const promise = reg.request("global", "7:", "ping", {});
reg.register("local", "7:", b); // evict A
reg.register("global", "7:", b); // evict A
await assert.rejects(promise, (err: unknown) => err instanceof GatewayError && err.code === "not_connected");
reg.unregister("local", "7:", a); // late close from evicted A — must be a no-op
assert.equal(reg.has("local", "7:"), true);
reg.unregister("global", "7:", a); // late close from evicted A — must be a no-op
assert.equal(reg.has("global", "7:"), true);
assert.equal(reg.count(), 1);
});
test("resolveResponse with no matching pending is a silent no-op", () => {
const reg = new GatewayRegistry(silentLog);
const a = new FakeSocket();
reg.register("local", "7:", a);
reg.register("global", "7:", a);
// should not throw
reg.resolveResponse(a, "local", "7:", response("unknown-id", true, {}));
reg.resolveResponse(a, "global", "7:", response("unknown-id", true, {}));
});