relay: three auth tiers, signed run tasks, machine secrets, retention; clients on headers; TZ=UTC and curl -K checks (X23 X24 X25 X26 X27 X28 X29 G13 G14)

Relay (X23, X27): the intake key is its own tier (upload and file drops only, RELAY_INTAKE_COMPAT=0 closes it);
a run task needs an Ed25519 signature by the Mac run key over {to, nonce, body sha256, flags} (RELAY_RUN_PUB,
401 without) and an HMAC tag with the target's machine secret that the agent verifies before anything runs;
results and registration are bound to the machine the secret proves (403 on a forged from).
X24: every client and Mac tool sends x-relay-token as a header to /api/relay?fn=; the path token stays for the
phone page only. X25: the agent arms the logon task only for a restart a task asked for and disarms on start
and exit. X26: 30-day retention with blob deletion, feed capped at 100, the dl base as RELAY_DL_BASE held by the
agent, never in a body. X28: GET inbox never acks (POST inbox does), RELAY-REBOOT on its own line and only with a
reboot flag, 120/min and 10 failed auths/min per IP, no username or folder on register, WSL sudo scoped to
apt-get and dpkg with SETENV, no password on a command line. X29: the intake key reaches curl through -K in
upload.sh and both upload-log.bat; tools/ci/curl-header-check.sh fails the class. G14: TZ=UTC in ship-app.mjs
and publish-jobs.sh; tools/ci/commit-tz-check.sh fails the class; history-rewrite.md names the .old-2026-10-05
files as the values in the history. The handler moved to relay/lib/handler.mjs with injected sql and blobs
(relay/lib/blob.mjs holds @vercel/blob) so relay/test/handler.test.mjs drives it without a database:
47 tests across 6 suites, all green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-05 18:46:13 +00:00
parent 5aa75166d8
commit 1065b81d05
34 changed files with 1422 additions and 390 deletions

View file

@ -75,7 +75,11 @@ jobs:
run: node --test site/api/faucet.test.mjs
- name: ship tool self-test (version bump, the dl-both and public manifest helpers)
run: node tools/ship-app.mjs --self-test
- name: relay unit tests (parsers, secret compare, the wake endpoint)
run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs
- name: relay unit tests (parsers, secret compare, the wake endpoint, the guards, the handler, the clients' shape)
run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/guard.test.mjs relay/test/handler.test.mjs relay/test/clients.test.mjs
- name: every tooling commit path sets TZ=UTC (self-test first, then the tree; G14)
run: bash tools/ci/commit-tz-check.sh --self-test && bash tools/ci/commit-tz-check.sh
- name: no secret header on any curl command line (self-test first, then the tree; X29)
run: bash tools/ci/curl-header-check.sh --self-test && bash tools/ci/curl-header-check.sh
- name: miner app notice strip and update card (ordering, keys, wording, timers, when the card shows)
run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs

View file

@ -16,6 +16,7 @@ name" and "the login" stand for the values the script reads from the history its
| The intake key | 6 tracked files, 8 commits (`78df757` to `4c9810f`) | `packaging/mac/packaged-config.sh`, `infra/gpu-bench/upload.sh`, `proving/windows-wsl2/prove-block.sh`, `prove-shard.sh`, `proto-cuda/windows-miner/upload-log.bat`, `proto-cuda/windows-app/upload-log.bat` |
| The dl token | 1 tracked file, 1 commit (`c47ff03`) | `docs/plans/morning-2026-10-04.md` |
| The `.next` rotations of both | 0 files, 0 commits | `~/.config/igneum/log-intake-key.next`, `dl-token.next` (4 October 19:25) are not in the tree |
| After the 5 October rotation (confirmed 5 October 2026, night, ledger G14) | the values IN THE HISTORY are the OLD ones | `~/.config/igneum/log-intake-key.old-2026-10-05` and `dl-token.old-2026-10-05` hold them; the live files hold the new values, which are in 0 tracked files. `replace.txt` must be written from the `.old-2026-10-05` files, not from the live ones, or the pass replaces nothing. The relay key, the relay token, the relay run key and the machine secrets (`relay-machines/`) are in 0 files and 0 commits |
| The relay key and token (current and old) | 0 files, 0 commits | |
| The review files | `docs/fud-ledger.md` (36 commits from `39c20b7`), `docs/fud-fixes.md` (6 from `e7545d5`), `docs/review/` (4 from `5ab296c`), `site/ledger.html` (5 from `0ec11be`) | tracked, not ignored |
| Tracked files carrying the first name (case-insensitive) | 71 at HEAD; 93 commits touch such content; 10 commit messages carry it | `CLAUDE.md`, the agent file, plans, packaging, the app's WSL paths, the Chrome profile rule |

View file

@ -24,6 +24,9 @@ data = open(path, 'rb').read()[-262144:]
json.dump({"label": label, "machine": socket.gethostname(), "run_id": run_id, "lines": data.decode('utf-8', 'replace')}, open(out, 'w'))
print(f"upload: run_id {run_id}, {len(data)} bytes")
EOF
curl -sS --max-time 60 -X POST "$IGNEUM_LOG_URL" -H "Content-Type: application/json" -H "x-igneum-key: $IGNEUM_LOG_KEY" --data-binary "@$body"
# the key reaches curl through a config file (-K), never on its command line, where every local user can read it (X29)
cfg=$(mktemp); chmod 600 "$cfg"
printf 'header = "x-igneum-key: %s"\n' "$IGNEUM_LOG_KEY" > "$cfg"
curl -sS --max-time 60 -K "$cfg" -X POST "$IGNEUM_LOG_URL" -H "Content-Type: application/json" --data-binary "@$body"
echo
rm -f "$body"
rm -f "$body" "$cfg"

View file

@ -38,6 +38,7 @@
# folder and verifies the live file (up to --tries times, 5 s apart: the edge serves the previous file for a few
# seconds after a deploy). Testing: --dest <folder> writes elsewhere; --base-url overrides the file URLs.
set -euo pipefail
export TZ=UTC # every commit from here carries +0000, never the local offset (ledger G14)
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
export PATH="$HOME/.cargo/bin:$PATH"

View file

@ -32,10 +32,13 @@ if errorlevel 1 (
exit /b 3
)
curl.exe -sS --max-time 60 -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" -H "x-igneum-key: %IGNEUM_LOG_KEY%" --data-binary "@%OUT%"
rem the key reaches curl through a config file (-K), never on its command line, where every local process can read it (X29)
set "CFG=%TEMP%\igneum-upload-%RANDOM%.cfg"
>"%CFG%" echo header = "x-igneum-key: %IGNEUM_LOG_KEY%"
curl.exe -sS --max-time 60 -K "%CFG%" -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" --data-binary "@%OUT%"
set "RC=%ERRORLEVEL%"
echo.
del "%OUT%" >nul 2>&1
del "%OUT%" "%CFG%" >nul 2>&1
if not "%RC%"=="0" (
echo upload-log: curl failed with code %RC%
exit /b %RC%

View file

@ -32,10 +32,13 @@ if errorlevel 1 (
exit /b 3
)
curl.exe -sS --max-time 60 -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" -H "x-igneum-key: %IGNEUM_LOG_KEY%" --data-binary "@%OUT%"
rem the key reaches curl through a config file (-K), never on its command line, where every local process can read it (X29)
set "CFG=%TEMP%\igneum-upload-%RANDOM%.cfg"
>"%CFG%" echo header = "x-igneum-key: %IGNEUM_LOG_KEY%"
curl.exe -sS --max-time 60 -K "%CFG%" -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" --data-binary "@%OUT%"
set "RC=%ERRORLEVEL%"
echo.
del "%OUT%" >nul 2>&1
del "%OUT%" "%CFG%" >nul 2>&1
if not "%RC%"=="0" (
echo upload-log: curl failed with code %RC%
exit /b %RC%

View file

@ -22,9 +22,29 @@ Mac: `node tools/console.mjs post --kind log --title "..." --body "..."` writes
The app log (label `win-<id8>` or `mac-<id8>`) reaches the intake since b8b349a (4 Oct 2026, 0.3.3); apps before that show `app ?`. The parsers (labels, miner tail, app tail, the stale mark) live in `relay/lib/parse.mjs` with no dependencies, and `relay/lib/auth.mjs` holds the constant-time secret compare; `node --test relay/test/parse.test.mjs relay/test/auth.test.mjs` runs their tests, and CI runs them in the site job.
## The secret is the path
## The secrets, since 5 October 2026 (night): three tiers, headers, no token in a URL
The web page lives at `/r/<token>/` and every API call sits under `/r/<token>/api/<fn>`. The token is 20 base32 characters generated once and stored at `~/.config/igneum/relay-token` on the Mac (and as `RELAY_TOKEN` in the project). Anyone with the link can read and post, so the link stays with the project lead. Scripts may present the log intake key in `x-igneum-key` instead (`RELAY_KEY`, the same value as `~/.config/igneum/log-intake-key`). There is no other login. Blob file URLs carry a random segment and a random suffix; they are not listed anywhere.
| Secret | Where it lives | Sent as | May |
|---|---|---|---|
| the console token (20 base32 characters) | `~/.config/igneum/relay-token`, `RELAY_TOKEN` on the project | the `x-relay-token` header from every tool and client; the URL path `/r/<token>/` only for the phone's page (`ui.html`) and the calls that page makes (`/r/<token>/api/<fn>`, `/r/<token>/c/<fn>`, `/r/<token>/wake`) | everything, except that a `run` task also needs the run signature below |
| the relay key (48 characters, the relay's own since 4 October 2026) | `~/.config/igneum/relay-key`, `RELAY_KEY` | `x-igneum-key` | read the feed, items, files, inbox, machines; post notes, files, results; register; ack; done. Never `task`, `run`, `name`, `role`, `secret`, `delete` |
| the log-intake key (inside every shipped package) | `LOG_INTAKE_KEY`, `LOG_INTAKE_KEY_NEXT` | `x-igneum-key` | only `upload` and a `drop` of kind `file` or `text`: the PC apps' build-job outputs (`jobrun.rs`). No reads, no results, nothing else. `RELAY_INTAKE_COMPAT=0` on the project closes this tier once the apps carry a relay key of their own |
| the run key (Ed25519, `node tools/relay.mjs keygen`) | `~/.config/igneum/relay-run-key` (seed, 0600) and `.pub`; the public half as `RELAY_RUN_PUB` on the project | `flags.sig` on every `run` task, over the canonical text in `relay/lib/guard.mjs` (`runCanon`: machine, nonce, body sha256, the elevated, reboot_continue and reboot flags) | without a verifying signature the relay answers 401 to a `run`; without `RELAY_RUN_PUB` every `run` is refused |
| a machine secret per PC (64 hex, `node tools/relay.mjs secret PC1`) | `~/.config/igneum/relay-machines/<name>` on the Mac; `machine-secret.txt` beside the agent on that PC (from `make-clients.sh --machine`); its sha256 on the relay (`relay_machines.secret_hash`) | `x-machine-secret` on `register`, `result`, `done`; `flags.mac` on every `run` (an HMAC-SHA256 tag the agent verifies before it executes, because Windows PowerShell 5.1 has no Ed25519) | a result's `from` is the machine the secret proves (a mismatch is 403); a bound hostname cannot register without it; the agent runs nothing whose tag does not verify, and never the same nonce twice |
Review round 4 (X23) found one tier with every power: the intake key inside every package queued PowerShell on PC 1 as administrator. Now the token alone cannot queue a `run` either: the Mac signs it with the run key (checked by the relay) and tags it with the target's secret (checked by the agent). Compare is constant time (`lib/auth.mjs`). Blob file URLs carry a random segment and a random suffix; they are not listed anywhere. 120 calls a minute per IP, 10 failed authentications a minute per IP, then 429.
### Rotation (what each secret's change breaks, and the order)
| Rotate | How | What stops, until | Order |
|---|---|---|---|
| the console token | new value in `~/.config/igneum/relay-token` and `RELAY_TOKEN`; a new zip per PC (`make-clients.sh --machine`) carried by hand; the phone gets the new link | every PC client and the phone page, until the new zip and link are in place; the Mac tools at once (they read the file) | any time; the old zip on a PC is dead the moment the project env changes |
| the relay key | new value in `relay-key` and `RELAY_KEY`; new zips | the PC clients' reads and reports until the new zip; `tools/build-job.mjs` falls back to the token | with the token, same zip |
| the intake key | `LOG_INTAKE_KEY_NEXT` on the relay AND the site intake first, repackage every app with the next key (`packaged-config.sh`), publish; then move `_NEXT` to `LOG_INTAKE_KEY`; then `RELAY_INTAKE_COMPAT=0` once no shipped app uploads build outputs with it | build-job uploads from every app that still carries the old key; log uploads from every shipped package until it updates | the long one: apps update over days, so both values are accepted during the window (`docs/plans/rotation-phase-2.md`) |
| the run key | `node tools/relay.mjs keygen` after moving the old `relay-run-key` aside; `RELAY_RUN_PUB` on the project | every `run` queued with the old key is refused at the relay; nothing on a PC changes (the PCs hold no run key) | any time, in one step |
| a machine secret | `node tools/relay.mjs secret PC1 --rotate` (rebinds the sha256), `make-clients.sh --machine PC1`, carry the zip | that PC's results and registration until the new zip is there; queued `run` tasks tagged with the old secret are refused by the agent | per machine, any time |
What the 5 October rotation already did: the relay token (4 October), the intake key and the dl token (`.old-2026-10-05` beside the live files). What is still the project lead's: the hosted `igneum-relay-clients.zip` off the downloads host (it holds the old token and key; dead values now, but the file is the shape X23 names), `RELAY_RUN_PUB` on the project after `keygen`, one `secret` per PC and the zips carried by hand.
## What is stored where
@ -33,35 +53,40 @@ The web page lives at `/r/<token>/` and every API call sits under `/r/<token>/ap
| Items (text, title, who, kind, flags, read and done marks) | Neon table `relay_items` (database `igneum`) | body 1 MB |
| Machines (name, hostname, role, GPU and WSL facts, last seen) | Neon table `relay_machines` | |
| Files | Vercel Blob store `igneum-relay` (public URLs with random path and suffix, London) | 50 MB per file through a client token; 4 MB when pushed through the function |
| The token and key | `~/.config/igneum/relay-token`, `~/.config/igneum/relay-key` (the relay's own key since 4 October 2026, round 4 X23; the log-intake key no longer opens the relay); project env | never in the repo |
| The token and keys | `~/.config/igneum/relay-token`, `relay-key`, `relay-run-key`, `relay-machines/<name>`; project env (`RELAY_TOKEN`, `RELAY_KEY`, `RELAY_RUN_PUB`, `LOG_INTAKE_KEY`, `LOG_INTAKE_KEY_NEXT`) | never in the repo |
| Retention | rows older than 30 days are deleted with their blobs, checked on a feed read at most every 10 minutes per instance; `delete` removes the blob with the row (X26) | 30 days |
Kinds: `text` (a note), `file`, `task` (for a person or a Claude session on a PC), `run` (a script the agent executes), `result` (what a task produced, linked by `task_id`). Roles: `miner`, `prover`, `bench`, `mac`, `phone`.
Kinds: `text` (a note), `file`, `task` (for a person or a Claude session on a PC), `run` (a script the agent executes; signed and tagged, see above), `result` (what a task produced, linked by `task_id`; `from` is the machine the secret proves, `flags.unbound` marks one from a machine with no secret yet). Roles: `miner`, `prover`, `bench`, `mac`, `phone`.
## API (all under `/r/<token>/api/`)
## API (`/api/relay?fn=<name>` with the headers above; `/r/<token>/api/<fn>` from the phone's page only)
| Call | Does |
|---|---|
| `GET feed?since=&before=&machine=&limit=` | items newest first (200 by default) plus every machine with its unread count |
| `GET feed?since=&before=&machine=&limit=` | items newest first (50 by default, 100 at most) plus every machine with its unread count and whether it is bound |
| `GET item?id=` | one item with its full body |
| `GET file?id=[&download=1]` | 302 to the file |
| `GET inbox?machine=PC1&kind=run|task&ack=1` | unread, not done tasks for that machine; `ack=1` marks them read |
| `GET machines` | names, roles, hostnames, last seen |
| `POST drop` | JSON `{from,to,kind,title,body,file_name,file_url,size,task_id,flags}`; or raw bytes with `Content-Type: application/octet-stream` and `x-file-name` (4 MB cap) |
| `POST task` | same fields; `kind` `task` or `run`; `run` needs one named machine and flags `{elevated, reboot_continue}` |
| `GET inbox?machine=PC1&kind=run|task` | unread, not done tasks for that machine; a GET never marks anything |
| `POST inbox {machine, kind, ack:true}` | the same list, marked read (the agents use this) |
| `GET machines` | names, roles, hostnames, last seen, bound |
| `POST drop` | JSON `{from,to,kind,title,body,file_name,file_url,size,task_id,flags}`; or raw bytes with `Content-Type: application/octet-stream` and `x-file-name` (4 MB cap). A `result` needs `x-machine-secret` when its machine is bound |
| `POST task` | same fields; `kind` `task` or `run`; `run` needs one named machine and `flags {elevated, reboot_continue, reboot, nonce, sig, mac}`; `tools/relay.mjs run` fills the last three in. 401 without a verifying `sig`, 409 on a reused nonce |
| `POST upload` | `{name,size}` returns a one-hour Blob client token and `put_url`; PUT the bytes there, then `drop` with the returned `url` |
| `POST ack {ids}` `POST done {id,exit_code}` `POST delete {id}` | marks |
| `POST register {hostname,info}` | a machine checks in; returns its name, role and whether it is named |
| `POST name {hostname,name}` `POST role {name,role}` | naming and roles, from the Mac |
| `POST ack {ids}` `POST done {id,exit_code}` `POST delete {id}` | marks; `delete` takes the blob with the row (token only) |
| `POST register {hostname,info}` | a machine checks in; with `x-machine-secret` the secret names it whatever the hostname says (both PCs report DESKTOP-KMCV30N); `info.user` and `info.dir` are dropped |
| `POST secret {name, secret_hash}` | binds a machine to the sha256 of its secret (token only; `tools/relay.mjs secret` does it) |
| `POST name {hostname,name}` `POST role {name,role}` | naming and roles, from the Mac (token only) |
Tests: `node --test relay/test/guard.test.mjs relay/test/handler.test.mjs relay/test/clients.test.mjs` (the rules, the handler against a fake database and fake blobs, the clients' shape), beside the parse, auth and wake suites; CI runs all six.
Wake (`api/wake.mjs`, 0.3.6, 5 October 2026): `GET /wake?since=<stamp>` is public (the apps hold no token) and rate limited, 30 a minute per IP. It holds up to 45 s and answers `{stamp, at, added, changed, held_ms}` the moment the stored stamp differs from `since`, else the unchanged stamp at the deadline; without `since` it answers at once. `POST /r/<token>/wake {stamp, added}` (or `POST /wake` with `x-relay-token` or `x-igneum-key`) records the stamp; `packaging/ota/publish-jobs.sh` sends it after every verified deploy, with the ids it added. One row per stamp in Neon table `relay_wake` (created by the first POST); `tools/jobs.mjs status` reads the rows for the woken latency. The function's `maxDuration` is 60 s (`vercel.json`). Tests: `relay/test/wake.test.mjs` drives the handler with a fake database and clock.
## Mac
`node tools/relay.mjs` (feed), `read <id>`, `drop "<text>"|<file>`, `task PC2 "title" [file]`, `run PC2 "title" script.ps1 [--elevated] [--reboot-continue]`, `watch`, `inbox PC1`, `machines`, `role PC2 prover`, `name DESKTOP-XYZ PC2`, `ack|done|rm <id>`, `url`. Playbooks live in `relay/playbooks/`; `run` fills `__DL_BASE__` in from `~/.config/igneum/dl-token`.
`node tools/relay.mjs` (feed), `read <id>`, `drop "<text>"|<file>`, `task PC2 "title" [file]`, `run PC2 "title" script.ps1 [--elevated] [--reboot-continue] [--reboot]`, `keygen`, `secret PC2`, `watch`, `inbox PC1 [--ack]`, `machines`, `role PC2 prover`, `name DESKTOP-XYZ PC2`, `ack|done|rm <id>`, `url`. Playbooks live in `relay/playbooks/`; a playbook reads the downloads base from `$env:RELAY_DL_BASE` (bash: `$RELAY_DL_BASE`), which the agent holds; `run` refuses a body that says `__DL_BASE__` or carries the dl token (X26). A script asks for a restart by printing `RELAY-REBOOT` on a line of its own, and only a task queued with `--reboot` or `--reboot-continue` restarts the PC.
## PCs
`relay/clients/make-clients.sh` bakes the URL, key and token into copies of the clients and writes `~/Desktop/igneum-relay-clients.zip`. Unzip anywhere on the PC. `send.bat` for people and Claude sessions (see `CLAUDE-PC.md`), `igneum-agent.bat` for the automatic runner: double-click once, leave it open. It registers the PC (hostname, GPUs, WSL, nvcc), polls every 20 s, runs each `run` task in order, posts a `result` (exit code, last 64 KB inline, full log as a file when longer) and marks it done. A script that prints `RELAY-REBOOT` triggers `shutdown /r /t 10`; with `reboot_continue` the agent re-arms (scheduled task at logon with highest privileges, RunOnce as a fallback) and re-runs the task after the restart with `RELAY_PASS` incremented. The PC must sign in by itself for that to be unattended.
`relay/clients/make-clients.sh --machine PC1` bakes the URL, key, token, downloads base and that PC's secret into copies of the clients and writes `~/Desktop/igneum-relay-clients-PC1.zip`. Carry it by hand; never through the downloads host. Unzip anywhere on the PC. `send.bat` for people and Claude sessions (see `CLAUDE-PC.md`), `igneum-agent.bat` for the automatic runner: double-click once, leave it open. It registers the PC (GPUs, WSL, nvcc; no username, no folder), polls every 20 s, checks each `run` task's tag and nonce against its secret (a task that fails is answered with exit 77 and nothing of it runs), runs the rest in order, posts a `result` (exit code, last 64 KB inline, full log as a file when longer) and marks it done. A script that prints `RELAY-REBOOT` on its own line, in a task queued with `--reboot` or `--reboot-continue`, triggers `shutdown /r /t 10`; with `reboot_continue` the agent arms a logon task (highest privileges, RunOnce as a fallback) for that one restart and re-runs the task after it with `RELAY_PASS` incremented. The agent removes the logon task and the RunOnce key every time it starts and when it exits (Ctrl+C included; a closed window is caught by the next start). Nothing is armed on an ordinary start (X25). The PC must sign in by itself for a restart to be unattended.
An unknown hostname that registers appears in the feed with a "name this machine" box, or `node tools/relay.mjs name <hostname> PC2`. PC1 is DESKTOP-KMCV30N.
@ -70,8 +95,8 @@ An unknown hostname that registers appears in the feed with a "name this machine
```
cd relay && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes --scope igneum
```
Env on the project: `DATABASE_URL`, `RELAY_KEY`, `RELAY_TOKEN`, `BLOB_READ_WRITE_TOKEN` (added by `vercel blob create-store`), `DL_TOKEN` (the downloads folder token, for the console; added 4 Oct 2026). DNS: `relay` CNAME `cname.vercel-dns.com` in the deSEC zone.
Env on the project: `DATABASE_URL`, `RELAY_KEY`, `RELAY_TOKEN`, `RELAY_RUN_PUB` (the run key's public half; without it every `run` is refused), `LOG_INTAKE_KEY` and `LOG_INTAKE_KEY_NEXT` (the intake tier; `RELAY_INTAKE_COMPAT=0` closes it), `BLOB_READ_WRITE_TOKEN` (added by `vercel blob create-store`), `DL_TOKEN` (the downloads folder token, for the console; added 4 Oct 2026). DNS: `relay` CNAME `cname.vercel-dns.com` in the deSEC zone. The first `secret` or `feed` call after the deploy adds `relay_machines.secret_hash` (`ADD COLUMN IF NOT EXISTS`, no long lock).
## Untested until a PC runs it (4 Oct 2026)
`send.ps1`, `igneum-agent.ps1` and the five PowerShell playbooks were written and syntax-reviewed on the Mac (no `pwsh` here). The bash twin `agent.sh` and `send.sh` ran end to end against the live relay. Expect a first-run fix on Windows: `Start-Process -Wait` exit codes through the wrapper, `wsl --install --no-launch` on pass 2, the RunOnce path after a reboot.
`send.ps1`, `igneum-agent.ps1` and the five PowerShell playbooks were written and syntax-reviewed on the Mac (no `pwsh` here). The bash twin `agent.sh` and `send.sh` ran end to end against the live relay on 4 October. The 5 October (night) rewrite of all four clients (headers, the machine tag check, the disarm, POST inbox, `-K`) is covered by `relay/test/clients.test.mjs` on the Mac and by the PowerShell 5.1 parse in `windows.yml` on the next push; it has not run on a PC. Expect a first-run fix on Windows: `Start-Process -Wait` exit codes through the wrapper, `wsl --install --no-launch` on pass 2, the RunOnce path after a reboot, and `schtasks /Query /TN IgneumRelayAgent` on PC 1 (owed: it must return nothing after the new agent's first start).

View file

@ -1,5 +1,6 @@
// Igneum console: one function, dispatched on ?fn=, reached through the rewrite /r/<token>/c/<fn>.
// Auth: the token in the path (or x-relay-token), or the intake key in x-igneum-key. Nothing else. No secret
// Auth: the token in the path (or x-relay-token), or the relay key in x-igneum-key; the intake key opens nothing here
// (5 October 2026, night, X23). No secret
// reaches the client: DL_TOKEN (the downloads folder) is read here from the project env.
// Every GET answer is cached 10 s in the function instance.
// GET machines one card per machine from the log intake (miner_logs): node, cards, telemetry, faults
@ -11,7 +12,7 @@
// GET results bench entries (synced from docs/bench-log.md) + the FUD ledger counts
// POST post {kind,title,body,who,key?,meta?} one item; with key it upserts
// POST sync {items:[...]} bulk upsert by key
import { neon, authed, readJson, str, iso } from '../lib/relay.mjs';
import { neon, authedNoIntake as authed, readJson, str, iso } from '../lib/relay.mjs';
import { kv, kvNum, lastMatch, FAULT, parseLabel, parseMinerTail, parseHeader, parseAppTail, STALE_S, markStale } from '../lib/parse.mjs';
const json = (res, status, obj) => { res.status(status).setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(obj)); };

View file

@ -1,201 +1,18 @@
// Igneum relay: one function, dispatched on ?fn=. Reached through the rewrite /r/<token>/api/<fn>.
// Auth: the token in the path (or x-relay-token), or the intake key in x-igneum-key. Nothing else.
// GET feed ?since=<id> | ?before=<id> | ?machine=X | ?limit=N items newest first + machines + unread counts
// GET item ?id= one item with its full body
// GET file ?id= 302 to the Blob URL (or the inline bytes)
// GET inbox ?machine=PC1&kind=run|task|all&ack=1 unread tasks for a machine; ack marks them read
// GET machines every machine with role, hostname, last_seen
// POST drop JSON {from,to,kind,title,body,file_name,file_url,size,task_id,flags} or raw octet-stream (x-file-name, x-from)
// POST task JSON {to,title,body,file_name,file_url,size,kind:'task'|'run',flags:{elevated,reboot_continue},from}
// POST upload JSON {name,size} -> {token, put_url, api_version} client token for a direct PUT to Vercel Blob (50 MB)
// POST ack JSON {ids:[...]} mark read
// POST done JSON {id, exit_code} mark done (runner finished)
// POST register JSON {hostname, info, role?} machine checks in; returns its name and role
// POST name JSON {hostname, name} name an unknown machine
// POST role JSON {name, role} set a machine's role
// POST delete JSON {id}
import { neon, authed, readJson, readRaw, str, safeName, storeBuffer, clientUploadToken, ITEM_COLS, rowOut, iso, touch, KINDS, ROLES, MAX_INLINE, MAX_BODY } from '../lib/relay.mjs';
const machineOut = m => ({ ...m, last_seen: iso(m.last_seen) });
// Igneum relay: one function, dispatched on ?fn=. Reached as /api/relay?fn=<fn> with x-relay-token (the Mac tools
// and the PC clients) or x-igneum-key (the relay key; the intake key for uploads only), and through the rewrite
// /r/<token>/api/<fn> from the phone's page. The contract and the whole handler live in ../lib/handler.mjs so
// relay/test/handler.test.mjs drives it with a fake database and fake blobs (5 October 2026, night: X23 to X28).
import { neon, authed } from '../lib/relay.mjs';
import { storeBuffer, clientUploadToken, deleteBlobs } from '../lib/blob.mjs';
import { makeHandler } from '../lib/handler.mjs';
const json = (res, status, obj) => { res.status(status).setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(obj)); };
async function insertItem(sql, o) {
let kind = KINDS.has(o.kind) ? o.kind : (o.file_url || o.file_b64 ? 'file' : 'text');
if (kind === 'text' && o.file_url) kind = 'file';
const flags = o.flags && typeof o.flags === 'object' ? o.flags : {};
const rows = await sql(
`INSERT INTO relay_items (from_machine, to_machine, kind, title, body, file_name, file_url, file_b64, size, flags, task_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11) RETURNING id, ts`,
[str(o.from, 80) || 'unknown', str(o.to, 80) || 'all', kind, str(o.title, 300), str(o.body, MAX_BODY),
o.file_name ? safeName(o.file_name) : null, o.file_url ? str(o.file_url, 1000) : null, o.file_b64 || null,
Number(o.size) || 0, JSON.stringify(flags), o.task_id ? Number(o.task_id) : null]);
await touch(sql, o.from);
return { id: Number(rows[0].id), ts: rows[0].ts, kind };
}
const state = {};
let inner = null;
export default async function handler(req, res) {
res.setHeader('Cache-Control', 'no-store');
const via = authed(req);
if (!via) return json(res, 401, { ok: false, error: 'no token' });
const fn = String(req.query.fn || '');
const q = req.query;
let sql;
try { sql = neon(); } catch (e) { return json(res, 500, { ok: false, error: e.message }); }
try {
if (req.method === 'GET') {
if (fn === 'feed') {
const limit = Math.min(500, Math.max(1, Number(q.limit) || 200));
const where = []; const params = [];
if (q.since) { params.push(Number(q.since)); where.push(`id > $${params.length}`); }
if (q.before) { params.push(Number(q.before)); where.push(`id < $${params.length}`); }
if (q.machine) { params.push(str(q.machine, 80)); where.push(`(from_machine = $${params.length} OR to_machine = $${params.length})`); }
const items = await sql(`SELECT ${ITEM_COLS} FROM relay_items ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY id DESC LIMIT ${limit}`, params);
const machines = await sql(`SELECT m.name, m.hostname, m.role, m.named, m.info, m.last_seen,
(SELECT count(*) FROM relay_items i WHERE NOT i.read AND i.kind IN ('task','run') AND (i.to_machine = m.name OR (i.to_machine = 'all' AND i.kind = 'task')))::int AS unread
FROM relay_machines m ORDER BY m.last_seen DESC NULLS LAST, m.name`);
return json(res, 200, { ok: true, items: items.map(rowOut), machines: machines.map(machineOut), now: new Date().toISOString() });
}
if (fn === 'machines') {
const machines = await sql(`SELECT name, hostname, role, named, info, last_seen FROM relay_machines ORDER BY name`);
return json(res, 200, { ok: true, machines: machines.map(machineOut) });
}
if (fn === 'item') {
const rows = await sql(`SELECT ${ITEM_COLS} FROM relay_items WHERE id = $1`, [Number(q.id)]);
if (!rows.length) return json(res, 404, { ok: false, error: 'no such item' });
return json(res, 200, { ok: true, item: rowOut(rows[0]) });
}
if (fn === 'file') {
const rows = await sql(`SELECT file_name, file_url, file_b64 FROM relay_items WHERE id = $1`, [Number(q.id)]);
if (!rows.length || (!rows[0].file_url && !rows[0].file_b64)) return json(res, 404, { ok: false, error: 'no file' });
if (rows[0].file_url) { res.statusCode = 302; res.setHeader('Location', rows[0].file_url + (q.download ? '?download=1' : '')); return res.end(); }
const buf = Buffer.from(rows[0].file_b64, 'base64');
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${safeName(rows[0].file_name)}"`);
return res.status(200).end(buf);
}
if (fn === 'inbox') {
const machine = str(q.machine, 80);
if (!machine) return json(res, 400, { ok: false, error: 'machine required' });
const kind = q.kind === 'run' ? ['run'] : q.kind === 'task' ? ['task'] : ['task', 'run'];
// run items only ever go to one named machine; task items may be addressed to all
const rows = await sql(`SELECT ${ITEM_COLS} FROM relay_items
WHERE NOT read AND NOT done AND kind = ANY($2) AND (to_machine = $1 OR (to_machine = 'all' AND kind = 'task'))
ORDER BY id ASC LIMIT 50`, [machine, kind]);
if (q.ack && rows.length) await sql(`UPDATE relay_items SET read = true, read_at = now() WHERE id = ANY($1)`, [rows.map(r => Number(r.id))]);
await touch(sql, machine);
return json(res, 200, { ok: true, machine, items: rows.map(rowOut) });
}
return json(res, 404, { ok: false, error: `unknown fn ${fn}` });
}
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method' });
const ct = String(req.headers['content-type'] || '');
if (fn === 'drop' && !ct.includes('json')) {
// raw bytes through the function: curl --data-binary @file -H 'Content-Type: application/octet-stream' -H 'x-file-name: a.zip'
const buf = await readRaw(req);
if (!buf.length) return json(res, 400, { ok: false, error: 'empty body' });
if (buf.length > MAX_INLINE) return json(res, 413, { ok: false, error: `raw upload over ${MAX_INLINE} bytes; use fn=upload for a Blob client token` });
const name = safeName(req.headers['x-file-name'] || 'file.bin');
const stored = await storeBuffer(name, buf, ct || 'application/octet-stream');
const r = await insertItem(sql, { from: req.headers['x-from'] || q.from, to: req.headers['x-to'] || q.to, kind: 'file',
title: str(req.headers['x-title'] || q.title || name, 300), body: '', file_name: name, file_url: stored.url, size: buf.length,
task_id: req.headers['x-task-id'] || q.task_id });
return json(res, 200, { ok: true, ...r, file_url: stored.url });
}
let body;
try { body = await readJson(req); } catch { return json(res, 400, { ok: false, error: 'bad json' }); }
// Review round 4, X23: the intake key is in every miner package, so it may only report (drop text and files, ack,
// done, register, upload). Anything a machine would EXECUTE, and anything that renames or re-roles a machine,
// needs the console token.
if ((fn === 'task' || (fn === 'drop' && (body.kind === 'run' || body.kind === 'task')) || fn === 'name' || fn === 'role' || fn === 'delete') && via !== 'token')
return json(res, 403, { ok: false, error: 'this call needs the console token' });
if (fn === 'drop' || fn === 'task') {
const o = { ...body };
if (fn === 'task') { o.kind = o.kind === 'run' ? 'run' : 'task'; if (!o.to) return json(res, 400, { ok: false, error: 'to required' }); }
if (o.kind === 'run' && (!o.to || o.to === 'all')) return json(res, 400, { ok: false, error: 'a run task needs one named machine' });
if (o.file_b64 && !o.file_url) {
const buf = Buffer.from(String(o.file_b64), 'base64');
if (buf.length > MAX_INLINE) return json(res, 413, { ok: false, error: 'inline file over 4 MB; use fn=upload' });
const stored = await storeBuffer(o.file_name || 'file.bin', buf, o.content_type);
o.file_url = stored.url; o.size = buf.length; delete o.file_b64;
}
if (!o.body && !o.file_url && !o.title) return json(res, 400, { ok: false, error: 'nothing to send' });
if (o.file_url && !/^https:\/\/[a-z0-9.-]+\.public\.blob\.vercel-storage\.com\//i.test(o.file_url)) return json(res, 400, { ok: false, error: 'file_url must be a Vercel Blob URL from fn=upload' });
const r = await insertItem(sql, o);
return json(res, 200, { ok: true, ...r });
}
if (fn === 'upload') {
const name = safeName(body.name || 'file.bin');
const t = await clientUploadToken(name, Number(body.size) || 0);
return json(res, 200, { ok: true, name, ...t });
}
if (fn === 'ack') {
const ids = (Array.isArray(body.ids) ? body.ids : [body.id]).map(Number).filter(Boolean);
if (!ids.length) return json(res, 400, { ok: false, error: 'ids required' });
await sql(`UPDATE relay_items SET read = true, read_at = now() WHERE id = ANY($1)`, [ids]);
return json(res, 200, { ok: true, ids });
}
if (fn === 'done') {
const id = Number(body.id); if (!id) return json(res, 400, { ok: false, error: 'id required' });
const extra = body.exit_code === undefined ? {} : { exit_code: Number(body.exit_code) };
await sql(`UPDATE relay_items SET done = true, done_at = now(), read = true, read_at = COALESCE(read_at, now()), flags = flags || $2::jsonb WHERE id = $1`, [id, JSON.stringify(extra)]);
return json(res, 200, { ok: true, id });
}
if (fn === 'delete') {
const id = Number(body.id); if (!id) return json(res, 400, { ok: false, error: 'id required' });
await sql(`DELETE FROM relay_items WHERE id = $1`, [id]);
return json(res, 200, { ok: true, id });
}
if (fn === 'register') {
const hostname = str(body.hostname, 120).trim();
if (!hostname) return json(res, 400, { ok: false, error: 'hostname required' });
const info = body.info && typeof body.info === 'object' ? body.info : {};
let rows = await sql(`SELECT name, role, named FROM relay_machines WHERE hostname = $1`, [hostname]);
if (!rows.length) {
// first contact from this hostname: it shows up under its own hostname until the Mac names it
rows = await sql(`INSERT INTO relay_machines (name, hostname, role, named, info, last_seen) VALUES ($1, $1, $2, false, $3::jsonb, now())
ON CONFLICT (name) DO UPDATE SET hostname = EXCLUDED.hostname, last_seen = now(), info = EXCLUDED.info RETURNING name, role, named`,
[hostname, ROLES.has(body.role) ? body.role : '', JSON.stringify(info)]);
} else {
await sql(`UPDATE relay_machines SET last_seen = now(), info = $2::jsonb WHERE hostname = $1`, [hostname, JSON.stringify(info)]);
}
return json(res, 200, { ok: true, name: rows[0].name, role: rows[0].role || '', named: !!rows[0].named, hostname });
}
if (fn === 'name') {
const hostname = str(body.hostname, 120).trim(); const name = str(body.name, 80).trim();
if (!hostname || !name) return json(res, 400, { ok: false, error: 'hostname and name required' });
const target = await sql(`SELECT name, hostname FROM relay_machines WHERE name = $1`, [name]);
const old = await sql(`SELECT name FROM relay_machines WHERE hostname = $1`, [hostname]);
if (target.length && target[0].hostname && target[0].hostname !== hostname) return json(res, 409, { ok: false, error: `${name} is already ${target[0].hostname}` });
if (target.length) {
// a pre-seeded name (PC2 with no hostname yet): attach the hostname, drop the placeholder row, move its items
if (old.length && old[0].name !== name) {
await sql(`DELETE FROM relay_machines WHERE hostname = $1 AND name <> $2`, [hostname, name]);
await sql(`UPDATE relay_items SET from_machine = $2 WHERE from_machine = $1`, [old[0].name, name]);
await sql(`UPDATE relay_items SET to_machine = $2 WHERE to_machine = $1`, [old[0].name, name]);
}
await sql(`UPDATE relay_machines SET hostname = $1, named = true, last_seen = COALESCE(last_seen, now()) WHERE name = $2`, [hostname, name]);
} else if (old.length) {
await sql(`UPDATE relay_machines SET name = $2, named = true WHERE hostname = $1`, [hostname, name]);
await sql(`UPDATE relay_items SET from_machine = $2 WHERE from_machine = $1`, [old[0].name, name]);
await sql(`UPDATE relay_items SET to_machine = $2 WHERE to_machine = $1`, [old[0].name, name]);
} else {
await sql(`INSERT INTO relay_machines (name, hostname, role, named) VALUES ($2, $1, '', true)`, [hostname, name]);
}
return json(res, 200, { ok: true, hostname, name });
}
if (fn === 'role') {
const name = str(body.name, 80).trim(); const role = str(body.role, 20).trim();
if (!name || !ROLES.has(role)) return json(res, 400, { ok: false, error: `role must be one of ${[...ROLES].filter(Boolean).join(', ')}` });
await sql(`INSERT INTO relay_machines (name, role, named) VALUES ($1, $2, true) ON CONFLICT (name) DO UPDATE SET role = EXCLUDED.role`, [name, role]);
return json(res, 200, { ok: true, name, role });
}
return json(res, 404, { ok: false, error: `unknown fn ${fn}` });
} catch (e) {
return json(res, 500, { ok: false, error: String(e.message || e) });
}
try { sql = neon(); } catch (e) { res.setHeader('Cache-Control', 'no-store'); return json(res, 500, { ok: false, error: e.message }); }
if (!inner) inner = makeHandler({ sql, blob: { storeBuffer, clientUploadToken, deleteBlobs }, authed, json, state });
return inner(req, res);
}

View file

@ -2,9 +2,10 @@
// (public: the apps hold no token) and at /r/<token>/wake (the same function; the token matters only to POST).
// The contract and the whole handler live in ../lib/wake.mjs so the test can drive it without a database.
// GET wake?since=<stamp>[&hold=45] up to 45 s; {stamp, at, added, changed, held_ms}; 30 a minute per IP
// POST wake {stamp, added?} the relay's auth (token in the path or x-relay-token, or x-igneum-key)
// POST wake {stamp, added?} the relay's auth (token in the path or x-relay-token, or the relay key in x-igneum-key;
// the intake key opens nothing here, X23)
// maxDuration 60 s for this file is set in vercel.json.
import { neon, authed, readJson } from '../lib/relay.mjs';
import { neon, authedNoIntake as authed, readJson } from '../lib/relay.mjs';
import { makeHandler, RateLimit } from '../lib/wake.mjs';
const json = (res, status, obj) => { res.status(status).setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(obj)); };

View file

@ -26,6 +26,6 @@ Also: `send.bat "<text>"` posts a plain note, `send.bat peek` reads without mark
## Rules
- The URL, key and token baked into `send.ps1` and `igneum-agent.ps1` are the secret. Never paste them into chat, a commit, a screenshot or another machine.
- The URL, key and token baked into `send.ps1` and `igneum-agent.ps1`, and `machine-secret.txt` beside them, are the secret. Never paste them into chat, a commit, a screenshot or another machine. `machine-secret.txt` is what makes a `result` count as this PC's (the relay refuses a result from a bound machine without it) and what lets the agent run a signed task.
- Never edit `send.ps1` or `igneum-agent.ps1`. If they break, report it with `send.bat result` and the project lead ships a new zip.
- Copy law applies to results too: short sentences, numbers in tables, no em dashes.

View file

@ -2,21 +2,31 @@
# Igneum relay agent for the Mac (or Linux/WSL): the bash twin of igneum-agent.ps1 for `run` tasks whose body is a bash script.
# agent.sh register this machine and poll every 20 s; run each `run` task, post a result, mark it done
# agent.sh once one pass (used by the tests)
# Env: RELAY_MACHINE overrides the name (default: what the relay returns for this hostname, else `hostname -s`).
# State: ~/.local/state/igneum-relay (state.json, tasks/, logs/). Needs curl, python3 (jq optional).
# Before anything runs (X23) the task's HMAC tag must verify with this machine's secret (machine-secret.txt next to this
# file, or RELAY_MACHINE_SECRET) over the text the Mac signed, and the nonce must be new; else exit 77 and a result.
# The token, key and secret go to curl through a header file (-K), never on the command line or in the URL (X24, X29).
# Env: RELAY_MACHINE overrides the name (default: what the relay returns for this hostname, else `hostname -s`);
# RELAY_DL_BASE reaches every task (the downloads base the agent holds, never written into a body: X26).
# State: ~/.local/state/igneum-relay (state.json, nonces.txt, headers.cfg, tasks/, logs/). Needs curl, python3 (jq optional).
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
RELAY_URL='__RELAY_URL__'; RELAY_KEY='__RELAY_KEY__'; RELAY_TOKEN='__RELAY_TOKEN__'
BASE="$RELAY_URL/r/$RELAY_TOKEN/api"
STATE="${XDG_STATE_HOME:-$HOME/.local/state}/igneum-relay"; mkdir -p "$STATE/tasks" "$STATE/logs"
DL_BASE_BAKED='__DL_BASE__'
export RELAY_DL_BASE="${RELAY_DL_BASE:-$DL_BASE_BAKED}"
API="$RELAY_URL/api/relay?fn="
STATE="${XDG_STATE_HOME:-$HOME/.local/state}/igneum-relay"; mkdir -p "$STATE/tasks" "$STATE/logs"; chmod 700 "$STATE"
POLL="${RELAY_POLL:-20}"; TAIL=65536
SECRET="${RELAY_MACHINE_SECRET:-}"; [ -n "$SECRET" ] || { [ -f "$HERE/machine-secret.txt" ] && SECRET="$(tr -d '[:space:]' < "$HERE/machine-secret.txt")" || SECRET=""; }
HDR="$STATE/headers.cfg"
( umask 077; { printf 'header = "x-relay-token: %s"\nheader = "x-igneum-key: %s"\n' "$RELAY_TOKEN" "$RELAY_KEY"; [ -n "$SECRET" ] && printf 'header = "x-machine-secret: %s"\n' "$SECRET"; } > "$HDR" )
log() { echo "[$(date +%H:%M:%S)] $*"; }
get() { curl -sS --max-time 60 "$BASE/$1" -H "x-igneum-key: $RELAY_KEY"; }
post() { curl -sS --max-time 120 -X POST "$BASE/$1" -H 'Content-Type: application/json' -H "x-igneum-key: $RELAY_KEY" --data-binary "$2"; }
get() { curl -sS --max-time 60 -K "$HDR" "$API${1/\?/&}"; }
post() { curl -sS --max-time 120 -K "$HDR" -X POST "$API$1" -H 'Content-Type: application/json' --data-binary "$2"; }
py() { python3 -c "$@"; }
register() {
local info
info="$(py 'import json,platform,shutil,subprocess,os
# no username and no folder in the registration (X28)
info="$(py 'import json,platform,shutil,subprocess
gpus=[]
try:
out=subprocess.run(["system_profiler","SPDisplaysDataType"],capture_output=True,text=True,timeout=20).stdout
@ -25,12 +35,28 @@ except Exception: pass
if not gpus and shutil.which("nvidia-smi"):
try: gpus=[l.strip() for l in subprocess.run(["nvidia-smi","--query-gpu=name","--format=csv,noheader"],capture_output=True,text=True,timeout=10).stdout.splitlines() if l.strip()]
except Exception: pass
print(json.dumps({"hostname":platform.node().split(".")[0],"info":{"os":platform.platform(),"user":os.environ.get("USER",""),"gpus":gpus,"nvcc":bool(shutil.which("nvcc")),"agent":"agent.sh v1","dir":os.getcwd()}}))')"
print(json.dumps({"hostname":platform.node().split(".")[0],"info":{"os":platform.platform(),"gpus":gpus,"nvcc":bool(shutil.which("nvcc")),"agent":"agent.sh v2"}}))')"
local r; r="$(post register "$info")" || { log "register failed"; return 1; }
MACHINE="${RELAY_MACHINE:-$(printf '%s' "$r" | py 'import json,sys; print(json.load(sys.stdin)["name"])')}"
MACHINE="${RELAY_MACHINE:-$(printf '%s' "$r" | py 'import json,sys; print(json.load(sys.stdin)["name"])')}" || { log "register refused: $r"; return 1; }
ROLE="$(printf '%s' "$r" | py 'import json,sys; print(json.load(sys.stdin).get("role",""))')"
printf '%s\n' "$MACHINE" > "$STATE/machine.txt"
log "registered as $MACHINE (role ${ROLE:-unset})"
log "registered as $MACHINE (role ${ROLE:-unset}, bound $(printf '%s' "$r" | py 'import json,sys; print(json.load(sys.stdin).get("bound",False))'))"
[ -n "$SECRET" ] || log "no machine-secret.txt next to agent.sh: run tasks are refused until one is here"
}
check_task() { # json-of-one-item -> prints why it may not run, or nothing
RELAY_MACHINE_SECRET="$SECRET" NONCES="$STATE/nonces.txt" py 'import sys,json,hashlib,hmac,os,re
it=json.load(sys.stdin); f=it.get("flags") or {}
s=os.environ.get("RELAY_MACHINE_SECRET","")
if not s: print("this machine has no machine secret; nothing runs until machine-secret.txt is next to agent.sh"); sys.exit()
n=str(f.get("nonce","")); m=str(f.get("mac",""))
if not re.fullmatch(r"[0-9a-f]{32}", n): print("no nonce on the task"); sys.exit()
if not re.fullmatch(r"[0-9a-f]{64}", m): print("no machine tag (flags.mac) on the task"); sys.exit()
p=os.environ["NONCES"]
if os.path.exists(p) and n in open(p).read().split(): print("nonce already executed on this machine"); sys.exit()
fl=lambda v: "1" if v is True or str(v) in ("1","true") else "0"
canon="igneum-relay-run/1\nto=%s\nnonce=%s\nelevated=%s\nreboot_continue=%s\nreboot=%s\nbody_sha256=%s\n" % (it.get("to",""), n, fl(f.get("elevated")), fl(f.get("reboot_continue")), fl(f.get("reboot")), hashlib.sha256(str(it.get("body","")).encode()).hexdigest())
want=hmac.new(s.encode(), canon.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(want, m): print("the machine tag does not verify: not signed for this machine, or changed after signing")' <<< "$1"
}
post_result() { # id title code log note
local id="$1" title="$2" code="$3" logf="$4" note="${5:-}" body
@ -44,10 +70,17 @@ run_task() { # json-of-one-item pass
id="$(printf '%s' "$it" | py 'import json,sys; print(json.load(sys.stdin)["id"])')"
title="$(printf '%s' "$it" | py 'import json,sys; print(json.load(sys.stdin)["title"])')"
elevated="$(printf '%s' "$it" | py 'import json,sys; print("1" if json.load(sys.stdin)["flags"].get("elevated") else "")')"
local rebootc; rebootc="$(printf '%s' "$it" | py 'import json,sys; print("1" if json.load(sys.stdin)["flags"].get("reboot_continue") else "")')"
printf '%s' "$it" | py 'import json,sys; sys.stdout.write(json.load(sys.stdin)["body"])' > "$STATE/tasks/task-$id.sh"
local rebootc rebootok; rebootc="$(printf '%s' "$it" | py 'import json,sys; print("1" if json.load(sys.stdin)["flags"].get("reboot_continue") else "")')"
rebootok="$(printf '%s' "$it" | py 'import json,sys; f=json.load(sys.stdin)["flags"]; print("1" if f.get("reboot") or f.get("reboot_continue") else "")')"
local logf="$STATE/logs/task-$id-pass$pass-$(date +%Y%m%d-%H%M%S).log"
log "task #$id '$title' pass $pass${elevated:+ elevated}${rebootc:+ reboot_continue}"
local why; why="$(check_task "$it")"
if [ -n "$why" ]; then
log "task #$id REFUSED: $why"; echo "REFUSED: $why" >> "$logf"
post_result "$id" "$title" 77 "$logf" "refused: $why"; post done "{\"id\":$id,\"exit_code\":77}" >/dev/null; return 0
fi
printf '%s' "$it" | py 'import json,sys; print(json.load(sys.stdin)["flags"]["nonce"])' >> "$STATE/nonces.txt"
printf '%s' "$it" | py 'import json,sys; sys.stdout.write(json.load(sys.stdin)["body"])' > "$STATE/tasks/task-$id.sh"
if [ -n "$elevated" ]; then
RELAY_PASS="$pass" RELAY_TASK_ID="$id" RELAY_MACHINE="$MACHINE" RELAY_SEND="$HERE/send.sh" sudo -n -E bash "$STATE/tasks/task-$id.sh" > >(tee -a "$logf") 2>&1; rc=$?
else
@ -55,7 +88,8 @@ run_task() { # json-of-one-item pass
fi
wait 2>/dev/null; sleep 0.2
echo "__RELAY_EXIT__=$rc" >> "$logf"
if grep -q 'RELAY-REBOOT' "$logf"; then
# the marker on a line of its own (X28), and only for a task queued with --reboot or --reboot-continue
if grep -qx 'RELAY-REBOOT' "$logf" && [ -n "$rebootok" ]; then
if [ -n "$rebootc" ]; then
post_result "$id" "$title" "$rc" "$logf" "rebooting, resumes as pass $((pass+1))"
printf '{"pending":%s,"pass":%s}\n' "$id" "$((pass+1))" > "$STATE/state.json"
@ -70,8 +104,8 @@ one_pass() {
local pend pass; pend="$(py 'import json; d=json.load(open("'"$STATE/state.json"'")); print(d["pending"])')"; pass="$(py 'import json; d=json.load(open("'"$STATE/state.json"'")); print(d["pass"])')"
rm -f "$STATE/state.json"; run_task "$(get "item?id=$pend" | py 'import json,sys; print(json.dumps(json.load(sys.stdin)["item"]))')" "$pass"
fi
local j; j="$(get "inbox?machine=$MACHINE&kind=run&ack=1")" || { log "poll failed"; return 1; }
local n; n="$(printf '%s' "$j" | py 'import json,sys; print(len(json.load(sys.stdin)["items"]))')"
local j; j="$(post inbox "{\"machine\":$(printf '%s' "$MACHINE" | py 'import sys,json; print(json.dumps(sys.stdin.read()))'),\"kind\":\"run\",\"ack\":true}")" || { log "poll failed"; return 1; }
local n; n="$(printf '%s' "$j" | py 'import json,sys; print(len(json.load(sys.stdin)["items"]))')" || { log "poll refused: $j"; return 1; }
local i=0; while [ "$i" -lt "$n" ]; do run_task "$(printf '%s' "$j" | py 'import json,sys; print(json.dumps(json.load(sys.stdin)["items"]['"$i"']))')" 1; i=$((i+1)); done
[ "$n" = 0 ] && printf '\r[%s] idle as %s ' "$(date +%H:%M:%S)" "$MACHINE"
return 0

View file

@ -1,33 +1,49 @@
# Igneum relay agent for Windows (PowerShell 5.1 or later). Started by igneum-agent.bat, which keeps it alive.
# What it does: registers this PC on the relay (hostname, role from the relay, GPUs, WSL state, nvcc), then every 20 s
# fetches the `run` tasks queued for it on the Mac, runs each one in order (a PowerShell script per task), captures
# What it does: registers this PC on the relay (its machine secret names it; GPUs, WSL state, nvcc), then every 20 s
# fetches the `run` tasks queued for it on the Mac, checks each one, runs it (a PowerShell script per task), captures
# the output and posts a `result` item (exit code, last 64 KB inline, the full log as a file) and marks the task done.
# Before anything runs (review round 4, X23): the task's HMAC tag must verify with this PC's machine secret over the
# same text the Mac signed (machine, nonce, body hash, the flags), and the nonce must be new. A task without a valid
# tag is refused with exit 77 and a result that says so; nothing of it is executed.
# Flags per task: elevated (needs administrator; the agent itself runs elevated, so there is no prompt),
# reboot_continue (a task that prints RELAY-REBOOT is re-run after the restart with RELAY_PASS incremented).
# State lives in %LOCALAPPDATA%\igneum-relay (state.json, tasks\, logs\). Nothing else is written outside the task's own doing.
# The URL, key and token are written in by make-clients.sh. The repo copy holds placeholders.
# reboot (the script may ask for a restart by printing RELAY-REBOOT on a line of its own), reboot_continue (the task
# is re-run after the restart with RELAY_PASS incremented). The logon task that re-arms the agent is created only
# for that restart and removed again when the agent starts or exits (X25).
# Every call sends the token and the key as headers, never in the URL (X24). The downloads base reaches a task as
# $env:RELAY_DL_BASE and is never written into a task body (X26).
# State lives in %LOCALAPPDATA%\igneum-relay (state.json, nonces.txt, tasks\, logs\). Nothing else is written outside the task's own doing.
# The URL, key, token and downloads base are written in by make-clients.sh; machine-secret.txt next to this file (or
# RELAY_MACHINE_SECRET) is this PC's secret, from make-clients.sh --machine. The repo copy holds placeholders.
$ErrorActionPreference = 'Continue'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$RelayUrl = '__RELAY_URL__'
$RelayKey = '__RELAY_KEY__'
$RelayToken = '__RELAY_TOKEN__'
$Base = "$RelayUrl/r/$RelayToken/api"
$Headers = @{ 'x-igneum-key' = $RelayKey }
$DlBase = '__DL_BASE__'
if ($env:RELAY_DL_BASE) { $DlBase = $env:RELAY_DL_BASE }
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
$StateDir = Join-Path $env:LOCALAPPDATA 'igneum-relay'
$TaskDir = Join-Path $StateDir 'tasks'
$LogDir = Join-Path $StateDir 'logs'
$StateFile = Join-Path $StateDir 'state.json'
$NonceFile = Join-Path $StateDir 'nonces.txt'
$PollSeconds = 20
$TailBytes = 65536
New-Item -ItemType Directory -Force -Path $StateDir, $TaskDir, $LogDir | Out-Null
$MachineSecret = ''
if ($env:RELAY_MACHINE_SECRET) { $MachineSecret = $env:RELAY_MACHINE_SECRET.Trim() }
elseif (Test-Path (Join-Path $Here 'machine-secret.txt')) { $MachineSecret = (Get-Content (Join-Path $Here 'machine-secret.txt') -Raw).Trim() }
$Headers = @{ 'x-relay-token' = $RelayToken; 'x-igneum-key' = $RelayKey }
if ($MachineSecret) { $Headers['x-machine-secret'] = $MachineSecret }
function Log([string] $m) { Write-Host ("[" + (Get-Date -Format 'HH:mm:ss') + "] " + $m) }
function Is-Admin { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) }
function Api-Get([string] $Fn) { Invoke-RestMethod -Uri "$Base/$Fn" -Headers $Headers -TimeoutSec 60 }
function Api-Url([string] $Fn) { return ($RelayUrl + '/api/relay?fn=' + ($Fn -replace '\?', '&')) }
function Api-Get([string] $Fn) { Invoke-RestMethod -Uri (Api-Url $Fn) -Headers $Headers -TimeoutSec 60 }
function Api-Post([string] $Fn, $Body) {
$bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json $Body -Depth 8 -Compress))
Invoke-RestMethod -Method Post -Uri "$Base/$Fn" -Headers $Headers -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 120
Invoke-RestMethod -Method Post -Uri (Api-Url $Fn) -Headers $Headers -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 120
}
function Read-State { if (Test-Path $StateFile) { try { return (Get-Content $StateFile -Raw | ConvertFrom-Json) } catch {} }; return $null }
function Write-State($o) { if ($null -eq $o) { Remove-Item $StateFile -ErrorAction SilentlyContinue } else { ConvertTo-Json $o -Compress | Set-Content -Path $StateFile -Encoding ascii } }
@ -38,7 +54,8 @@ $mutex = New-Object System.Threading.Mutex($false, 'Global\IgneumRelayAgent')
if (-not $mutex.WaitOne(0)) { Log 'another igneum-agent is already running on this PC; this one exits'; Start-Sleep 5; exit 0 }
function Collect-Info {
$info = @{ os = ''; user = $env:USERNAME; admin = (Is-Admin); gpus = @(); wsl = ''; nvcc = $false; agent = 'igneum-agent.ps1 v1'; dir = $Here }
# no username and no folder (X28): the relay stores what it is told
$info = @{ os = ''; admin = (Is-Admin); gpus = @(); wsl = ''; nvcc = $false; agent = 'igneum-agent.ps1 v2' }
try { $info.os = (Get-CimInstance Win32_OperatingSystem).Caption + ' build ' + (Get-CimInstance Win32_OperatingSystem).BuildNumber } catch {}
try {
$nv = Get-Command nvidia-smi -ErrorAction SilentlyContinue
@ -61,16 +78,18 @@ function Register-Machine {
$r = Api-Post 'register' @{ hostname = $env:COMPUTERNAME; info = $info }
Set-Content -Path (Join-Path $StateDir 'machine.txt') -Value $r.name -Encoding ascii
$script:Machine = $r.name; $script:Role = $r.role
Log ("registered as " + $r.name + " (role " + ($(if ($r.role) { $r.role } else { 'unset' })) + ", named " + $r.named + "); gpus: " + ($info.gpus -join ', ') + "; wsl: " + $info.wsl + "; nvcc: " + $info.nvcc)
Log ("registered as " + $r.name + " (role " + ($(if ($r.role) { $r.role } else { 'unset' })) + ", named " + $r.named + ", bound " + $r.bound + "); gpus: " + ($info.gpus -join ', ') + "; wsl: " + $info.wsl + "; nvcc: " + $info.nvcc)
if (-not $r.named) { Log "this PC is not named yet. On the Mac: node tools/relay.mjs name $env:COMPUTERNAME PC2" }
if (-not $MachineSecret) { Log 'no machine-secret.txt next to the agent: run tasks are refused until one is here (node tools/relay.mjs secret <name>, then make-clients.sh --machine <name>)' }
}
function Arm-Restart {
# Re-arm after a reboot: a logon scheduled task with highest privileges (no UAC prompt), plus RunOnce as a fallback.
# Re-arm for ONE restart that a task asked for: a logon scheduled task with highest privileges (no UAC prompt), plus
# RunOnce as a fallback. Disarm-Restart removes both when the agent is back (X25).
$bat = Join-Path $Here 'igneum-agent.bat'
try {
& schtasks.exe /Create /F /TN 'IgneumRelayAgent' /SC ONLOGON /RL HIGHEST /TR ("cmd /c start `"igneum-agent`" `"$bat`"") 2>&1 | Out-Null
Log 'scheduled task IgneumRelayAgent set (runs at logon, highest privileges)'
Log 'scheduled task IgneumRelayAgent set for the restart (runs once at logon, highest privileges; removed when the agent is back)'
} catch { Log ("schtasks failed: " + $_.Exception.Message) }
try {
New-Item -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' -Force | Out-Null
@ -78,17 +97,54 @@ function Arm-Restart {
} catch { Log ("RunOnce failed: " + $_.Exception.Message) }
}
function Disarm-Restart {
# Nothing of the agent survives its exit: no logon task, no RunOnce key.
$had = $false
try { & schtasks.exe /Query /TN 'IgneumRelayAgent' 2>&1 | Out-Null; if ($LASTEXITCODE -eq 0) { $had = $true; & schtasks.exe /Delete /F /TN 'IgneumRelayAgent' 2>&1 | Out-Null } } catch {}
try {
$k = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce'
if ((Test-Path $k) -and ((Get-ItemProperty -Path $k -ErrorAction SilentlyContinue).PSObject.Properties.Name -contains 'IgneumRelayAgent')) { Remove-ItemProperty -Path $k -Name 'IgneumRelayAgent' -ErrorAction SilentlyContinue; $had = $true }
} catch {}
if ($had) { Log 'logon task and RunOnce key removed' }
}
function Sha256-Hex([byte[]] $bytes) {
$h = [Security.Cryptography.SHA256]::Create()
try { return (($h.ComputeHash($bytes)) | ForEach-Object { $_.ToString('x2') }) -join '' } finally { $h.Dispose() }
}
function Hmac-Hex([string] $secret, [string] $text) {
$h = New-Object Security.Cryptography.HMACSHA256 (,[Text.Encoding]::UTF8.GetBytes($secret))
try { return (($h.ComputeHash([Text.Encoding]::UTF8.GetBytes($text))) | ForEach-Object { $_.ToString('x2') }) -join '' } finally { $h.Dispose() }
}
function Flag-Text($v) { if ($v -eq $true -or "$v" -eq '1' -or "$v" -eq 'true') { return '1' }; return '0' }
function Run-Canon($task) {
# the same text relay/lib/guard.mjs runCanon() builds on the Mac and the relay checks
$f = $task.flags
return ("igneum-relay-run/1`nto=" + $task.to + "`nnonce=" + $f.nonce + "`nelevated=" + (Flag-Text $f.elevated) + "`nreboot_continue=" + (Flag-Text $f.reboot_continue) + "`nreboot=" + (Flag-Text $f.reboot) + "`nbody_sha256=" + (Sha256-Hex ([Text.Encoding]::UTF8.GetBytes("$($task.body)"))) + "`n")
}
function Check-Task($task) {
# '' when the task may run, else why not (X23: nothing runs on the token alone)
if (-not $MachineSecret) { return 'this PC has no machine secret; nothing runs until make-clients.sh --machine put machine-secret.txt here' }
$f = $task.flags
if (-not $f -or -not ("$($f.nonce)" -match '^[0-9a-f]{32}$')) { return 'no nonce on the task' }
if (-not ("$($f.mac)" -match '^[0-9a-f]{64}$')) { return 'no machine tag (flags.mac) on the task' }
if ((Test-Path $NonceFile) -and (Select-String -Path $NonceFile -Pattern ("^" + $f.nonce + "$") -Quiet)) { return 'nonce already executed on this PC' }
$want = Hmac-Hex $MachineSecret (Run-Canon $task)
if ($want -ne "$($f.mac)") { return 'the machine tag does not verify: not signed for this PC, or changed after signing' }
return ''
}
function Post-Result($task, [int] $code, [string] $logPath, [string] $note) {
$tail = ''
if (Test-Path $logPath) {
if ($logPath -and (Test-Path $logPath)) {
$bytes = [IO.File]::ReadAllBytes($logPath)
$n = [Math]::Min($bytes.Length, $TailBytes)
$tail = [Text.Encoding]::UTF8.GetString($bytes, $bytes.Length - $n, $n)
}
$o = @{ kind = 'result'; from = $script:Machine; to = 'all'; task_id = $task.id; body = $tail
title = ($task.title + ": exit " + $code + $(if ($note) { " (" + $note + ")" } else { "" }))
flags = @{ exit_code = $code; pass = [int]$env:RELAY_PASS; machine = $env:COMPUTERNAME } }
if ((Test-Path $logPath) -and (Get-Item $logPath).Length -gt $TailBytes) {
flags = @{ exit_code = $code; pass = [int]$env:RELAY_PASS } }
if ($logPath -and (Test-Path $logPath) -and (Get-Item $logPath).Length -gt $TailBytes) {
try { $f = & (Join-Path $Here 'send.ps1') -Machine $script:Machine -Kind 'file' -TaskId $task.id -Title ($task.title + ' full log') $logPath 2>&1 | Out-String; Log ("full log posted: " + $f.Trim()) } catch { Log ("log upload failed: " + $_.Exception.Message) }
}
try { $r = Api-Post 'drop' $o; Log ("result posted as #" + $r.id) } catch { Log ("result post failed: " + $_.Exception.Message) }
@ -101,15 +157,25 @@ function Run-Task($task, [int] $pass) {
$log = Join-Path $LogDir ("task-" + $id + "-pass" + $pass + "-" + (Get-Date -Format 'yyyyMMdd-HHmmss') + ".log")
$elevated = [bool]$task.flags.elevated
$rebootContinue = [bool]$task.flags.reboot_continue
Log ("task #" + $id + " '" + $task.title + "' pass " + $pass + $(if ($elevated) { " elevated" } else { "" }) + $(if ($rebootContinue) { " reboot_continue" } else { "" }))
$rebootAllowed = $rebootContinue -or [bool]$task.flags.reboot
Log ("task #" + $id + " '" + $task.title + "' pass " + $pass + $(if ($elevated) { " elevated" } else { "" }) + $(if ($rebootContinue) { " reboot_continue" } elseif ($rebootAllowed) { " reboot" } else { "" }))
$why = Check-Task $task
if ($why) {
Log ("task #" + $id + " REFUSED: " + $why)
Add-Content -Path $log -Value ("REFUSED: " + $why)
Post-Result $task 77 $log ("refused: " + $why)
try { Api-Post 'done' @{ id = $id; exit_code = 77 } | Out-Null } catch { Log ("done failed: " + $_.Exception.Message) }
return
}
Add-Content -Path $NonceFile -Value $task.flags.nonce
$body = "$($task.body)" -replace "`r?`n", "`r`n"
[IO.File]::WriteAllText($script, $body, (New-Object Text.UTF8Encoding $true))
$env:RELAY_PASS = "$pass"; $env:RELAY_TASK_ID = "$id"; $env:RELAY_MACHINE = $script:Machine; $env:RELAY_ROLE = $script:Role
$env:RELAY_SEND = (Join-Path $Here 'send.ps1'); $env:RELAY_HOME = $StateDir
$env:RELAY_SEND = (Join-Path $Here 'send.ps1'); $env:RELAY_HOME = $StateDir; $env:RELAY_DL_BASE = $DlBase
$wrapBody = @"
`$ErrorActionPreference = 'Continue'
`$env:RELAY_PASS = '$pass'; `$env:RELAY_TASK_ID = '$id'; `$env:RELAY_MACHINE = '$($script:Machine)'; `$env:RELAY_ROLE = '$($script:Role)'
`$env:RELAY_SEND = '$(Join-Path $Here 'send.ps1')'; `$env:RELAY_HOME = '$StateDir'
`$env:RELAY_SEND = '$(Join-Path $Here 'send.ps1')'; `$env:RELAY_HOME = '$StateDir'; `$env:RELAY_DL_BASE = '$DlBase'
Start-Transcript -Path '$log' -Append | Out-Null
`$code = 0
try { & '$script'; `$code = `$LASTEXITCODE; if (`$null -eq `$code) { `$code = 0 } } catch { Write-Host ("TASK ERROR: " + `$_.Exception.Message); `$code = 1 }
@ -130,44 +196,56 @@ exit `$code
$code = $p.ExitCode
} catch { Log ("could not start the task: " + $_.Exception.Message); Add-Content -Path $log -Value ("AGENT ERROR: " + $_.Exception.Message) }
$text = ''; if (Test-Path $log) { $text = Get-Content $log -Raw }
$reboot = $text -match 'RELAY-REBOOT'
# the marker on a line of its own (X28), and only when the task was queued with --reboot or --reboot-continue
$asked = [bool]($text -match '(?m)^RELAY-REBOOT\r?$')
$reboot = $asked -and $rebootAllowed
if ($asked -and -not $rebootAllowed) { Log ("task #" + $id + " printed RELAY-REBOOT but was not queued with --reboot; not restarting") }
if ($reboot -and $rebootContinue) {
Log ("task #" + $id + " asked for a reboot and continues after it (pass " + ($pass + 1) + ")")
Post-Result $task $code $log ("rebooting, resumes as pass " + ($pass + 1))
Write-State @{ pending = $id; pass = ($pass + 1); title = $task.title }
Arm-Restart
$script:KeepArmed = $true
& shutdown.exe /r /t 10 /c "Igneum relay: task #$id continues after the restart"
Log 'restart in 10 s; the agent exits now'
exit 0
}
Post-Result $task $code $log $(if ($reboot) { 'rebooting' } else { '' })
Post-Result $task $code $log $(if ($reboot) { 'rebooting' } elseif ($asked) { 'reboot refused: not queued with --reboot' } else { '' })
try { Api-Post 'done' @{ id = $id; exit_code = $code } | Out-Null } catch { Log ("done failed: " + $_.Exception.Message) }
if ($reboot) {
Log ("task #" + $id + " asked for a reboot")
Arm-Restart
$script:KeepArmed = $true
& shutdown.exe /r /t 10 /c "Igneum relay: task #$id asked for a restart"
exit 0
}
}
Log ("igneum relay agent on " + $env:COMPUTERNAME + " as " + $env:USERNAME + $(if (Is-Admin) { " (administrator)" } else { " (NOT administrator: elevated tasks will prompt)" }))
Arm-Restart
Disarm-Restart
$script:KeepArmed = $false
$registered = $false
while ($true) {
try {
if (-not $registered) { Register-Machine; $registered = $true }
$st = Read-State
if ($st -and $st.pending) {
$pending = [long]$st.pending; $pass = [int]$st.pass
Write-State $null
try { $it = (Api-Get ("item?id=" + $pending)).item; Run-Task $it $pass } catch { Log ("could not resume task #" + $pending + ": " + $_.Exception.Message) }
try {
while ($true) {
try {
if (-not $registered) { Register-Machine; $registered = $true }
$st = Read-State
if ($st -and $st.pending) {
$pending = [long]$st.pending; $pass = [int]$st.pass
Write-State $null
try { $it = (Api-Get ("item?id=" + $pending)).item; Run-Task $it $pass } catch { Log ("could not resume task #" + $pending + ": " + $_.Exception.Message) }
}
$j = Api-Post 'inbox' @{ machine = $script:Machine; kind = 'run'; ack = $true }
foreach ($t in @($j.items)) { Run-Task $t 1 }
if (-not $j.items -or $j.items.Count -eq 0) { Write-Host -NoNewline ("`r[" + (Get-Date -Format 'HH:mm:ss') + "] idle as " + $script:Machine + ", next check in " + $PollSeconds + " s ") }
} catch {
Log ("loop error: " + $_.Exception.Message)
$registered = $false
}
$j = Api-Get ("inbox?machine=" + [Uri]::EscapeDataString($script:Machine) + "&kind=run&ack=1")
foreach ($t in @($j.items)) { Run-Task $t 1 }
if (-not $j.items -or $j.items.Count -eq 0) { Write-Host -NoNewline ("`r[" + (Get-Date -Format 'HH:mm:ss') + "] idle as " + $script:Machine + ", next check in " + $PollSeconds + " s ") }
} catch {
Log ("loop error: " + $_.Exception.Message)
$registered = $false
Start-Sleep -Seconds $PollSeconds
}
Start-Sleep -Seconds $PollSeconds
} finally {
# Ctrl+C and a normal exit land here (a closed window does not run this, so the next start disarms again);
# the one exit that keeps the logon task is the restart a task asked for
if (-not $script:KeepArmed) { Disarm-Restart }
}

View file

@ -1,25 +1,49 @@
#!/usr/bin/env bash
# Bakes the relay URL, key and token into copies of the clients and zips them to ~/Desktop/igneum-relay-clients.zip.
# The files in the repo keep their placeholders; the zip is the secret-bearing artefact. Usage: make-clients.sh [outdir-for-zip]
# Bakes the relay URL, key, token and downloads base into copies of the clients and zips them. The files in the repo keep
# their placeholders; the zip is the secret-bearing artefact and goes to the PC by hand (USB stick, AirDrop), NEVER
# through the downloads host (review round 4, X23: the hosted zip put both relay secrets one dl token away).
#
# make-clients.sh [--machine NAME] [outdir-for-zip]
#
# --machine NAME also puts that machine's secret (~/.config/igneum/relay-machines/NAME, from `node tools/relay.mjs
# secret NAME`) into the zip as machine-secret.txt, which is what lets the agent there run signed tasks and lets its
# results carry its name (X23, X27). Without --machine the zip can read, post notes and files, and nothing runs.
# Reads ~/.config/igneum/relay-url (optional), relay-key, relay-token, dl-token (for RELAY_DL_BASE; X26: the base is a
# value the agent holds, never text in a task body).
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
CFG="$HOME/.config/igneum"
MACHINE=""; OUT="$HOME/Desktop"
while [ $# -gt 0 ]; do
case "$1" in --machine) MACHINE="$2"; shift 2 ;; --*) echo "unknown flag $1" >&2; exit 2 ;; *) OUT="$1"; shift ;; esac
done
URL="$(cat "$CFG/relay-url" 2>/dev/null | tr -d '\n' || true)"; URL="${URL:-https://relay.igneum.network}"
KEY="$(tr -d '\n' < "$CFG/relay-key")"; TOKEN="$(tr -d '\n' < "$CFG/relay-token")"
OUT="${1:-$HOME/Desktop}"; mkdir -p "$OUT"
STAGE="$(mktemp -d)/igneum-relay-clients"; mkdir -p "$STAGE"
DL="$(tr -d '[:space:]' < "$CFG/dl-token" 2>/dev/null || true)"
DL_BASE="https://dl.igneum.network/dl/${DL:-MISSING-DL-TOKEN}"
SECRET=""
if [ -n "$MACHINE" ]; then
SF="$CFG/relay-machines/$MACHINE"
[ -f "$SF" ] || { echo "no $SF: node tools/relay.mjs secret $MACHINE first" >&2; exit 1; }
SECRET="$(tr -d '[:space:]' < "$SF")"
[ ${#SECRET} = 64 ] || { echo "$SF is not a 64-hex secret" >&2; exit 1; }
fi
mkdir -p "$OUT"
NAME="igneum-relay-clients${MACHINE:+-$MACHINE}"
STAGE="$(mktemp -d)/$NAME"; mkdir -p "$STAGE"; umask 077
for f in send.bat send.ps1 send.sh igneum-agent.bat igneum-agent.ps1 agent.sh CLAUDE-PC.md; do
sed -e "s#__RELAY_URL__#$URL#g" -e "s#__RELAY_KEY__#$KEY#g" -e "s#__RELAY_TOKEN__#$TOKEN#g" "$HERE/$f" > "$STAGE/$f"
sed -e "s#__RELAY_URL__#$URL#g" -e "s#__RELAY_KEY__#$KEY#g" -e "s#__RELAY_TOKEN__#$TOKEN#g" -e "s#__DL_BASE__#$DL_BASE#g" "$HERE/$f" > "$STAGE/$f"
done
[ -n "$SECRET" ] && printf '%s\n' "$SECRET" > "$STAGE/machine-secret.txt"
# Windows reads CRLF batch files most reliably; PowerShell is fine either way
for f in send.bat igneum-agent.bat; do perl -pi -e 's/\r?\n/\r\n/' "$STAGE/$f"; done
chmod +x "$STAGE/send.sh" "$STAGE/agent.sh"
cat > "$STAGE/README.txt" <<TXT
Igneum relay clients. Unzip anywhere. send.bat "<text>" | send.bat <file> | send.bat inbox | send.bat result "<text>" | send.bat get <id>
Igneum relay clients${MACHINE:+ for $MACHINE}. Unzip anywhere. send.bat "<text>" | send.bat <file> | send.bat inbox | send.bat result "<text>" | send.bat get <id>
igneum-agent.bat: double-click once, leave open. CLAUDE-PC.md: paste into the Claude Code session on this PC.
These files contain the relay secret. Keep them off shared drives.
These files contain the relay secret${MACHINE:+ and this machine's own secret (machine-secret.txt)}. Keep them off shared drives and off the downloads host.
TXT
rm -f "$OUT/igneum-relay-clients.zip"
(cd "$(dirname "$STAGE")" && zip -qr "$OUT/igneum-relay-clients.zip" igneum-relay-clients)
rm -f "$OUT/$NAME.zip"
(cd "$(dirname "$STAGE")" && zip -qr "$OUT/$NAME.zip" "$NAME")
echo "staged: $STAGE"
echo "zip: $OUT/igneum-relay-clients.zip ($(du -h "$OUT/igneum-relay-clients.zip" | cut -f1))"
echo "zip: $OUT/$NAME.zip ($(du -h "$OUT/$NAME.zip" | cut -f1)); carry it to the PC by hand, never through the downloads host"

View file

@ -1,13 +1,15 @@
# Igneum relay client (Windows PowerShell 5.1 or later). Driven by send.bat; the agent and the playbooks call it too.
# send.ps1 "<text>" post a note to everyone
# send.ps1 <file> post a file (up to 50 MB, straight to Vercel Blob)
# send.ps1 result "<text>" [-TaskId N] [-File path] post a result (what a task produced)
# send.ps1 result "<text>" [-TaskId N] [-File path] post a result (what a task produced; needs this PC's machine secret)
# send.ps1 inbox print the unread tasks for this machine and mark them read
# send.ps1 peek print them without marking read
# send.ps1 get <id> print an item; download its file into the current folder
# send.ps1 done <id> mark a task done
# -To PC1 / -Title "..." / -Machine NAME override the defaults (machine = this PC's name on the relay, else its hostname)
# The URL, key and token below are written in by make-clients.sh. They are the secret; keep this file off shared drives.
# The token and key travel as headers, never in the URL (X24). The URL, key and token below are written in by
# make-clients.sh; machine-secret.txt next to this file (or RELAY_MACHINE_SECRET) names this PC on every result (X27).
# They are the secret; keep this folder off shared drives.
param(
[Parameter(Position = 0)] [string] $A,
[Parameter(Position = 1)] [string] $B,
@ -23,10 +25,15 @@ $ErrorActionPreference = 'Stop'
$RelayUrl = '__RELAY_URL__'
$RelayKey = '__RELAY_KEY__'
$RelayToken = '__RELAY_TOKEN__'
$Base = "$RelayUrl/r/$RelayToken/api"
$Headers = @{ 'x-igneum-key' = $RelayKey }
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
$StateDir = Join-Path $env:LOCALAPPDATA 'igneum-relay'
$MachineSecret = ''
if ($env:RELAY_MACHINE_SECRET) { $MachineSecret = $env:RELAY_MACHINE_SECRET.Trim() }
elseif (Test-Path (Join-Path $Here 'machine-secret.txt')) { $MachineSecret = (Get-Content (Join-Path $Here 'machine-secret.txt') -Raw).Trim() }
$Headers = @{ 'x-relay-token' = $RelayToken; 'x-igneum-key' = $RelayKey }
if ($MachineSecret) { $Headers['x-machine-secret'] = $MachineSecret }
function Api-Url([string] $Fn) { return ($RelayUrl + '/api/relay?fn=' + ($Fn -replace '\?', '&')) }
function Get-MachineName {
if ($Machine) { return $Machine }
if ($env:RELAY_MACHINE) { return $env:RELAY_MACHINE }
@ -35,7 +42,7 @@ function Get-MachineName {
return $env:COMPUTERNAME
}
function Invoke-Relay([string] $Method, [string] $Fn, $Body) {
$uri = "$Base/$Fn"
$uri = Api-Url $Fn
if ($Method -eq 'GET') { return Invoke-RestMethod -Uri $uri -Headers $Headers -TimeoutSec 60 }
$bytes = [Text.Encoding]::UTF8.GetBytes((ConvertTo-Json $Body -Depth 8 -Compress))
return Invoke-RestMethod -Method Post -Uri $uri -Headers $Headers -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 120
@ -69,24 +76,25 @@ function Show-Item($it) {
switch -Regex ($A) {
'^(?i)inbox$' {
$j = Invoke-RestMethod -Uri "$Base/inbox?machine=$([Uri]::EscapeDataString((Get-MachineName)))&ack=1" -Headers $Headers -TimeoutSec 60
# marking read is a POST (X28: a GET changes nothing)
$j = Invoke-Relay 'POST' 'inbox' @{ machine = (Get-MachineName); kind = 'task'; ack = $true }
if (-not $j.items) { Write-Host ("nothing waiting for " + (Get-MachineName)); break }
foreach ($it in $j.items) { Show-Item $it }
break
}
'^(?i)peek$' {
$j = Invoke-RestMethod -Uri "$Base/inbox?machine=$([Uri]::EscapeDataString((Get-MachineName)))" -Headers $Headers -TimeoutSec 60
$j = Invoke-Relay 'GET' ("inbox?machine=" + [Uri]::EscapeDataString((Get-MachineName)) + "&kind=task")
if (-not $j.items) { Write-Host ("nothing waiting for " + (Get-MachineName)); break }
foreach ($it in $j.items) { Show-Item $it }
break
}
'^(?i)get$' {
if (-not $B) { throw 'get <id>' }
$it = (Invoke-RestMethod -Uri "$Base/item?id=$B" -Headers $Headers -TimeoutSec 60).item
$it = (Invoke-Relay 'GET' ("item?id=" + $B)).item
Show-Item $it
if ($it.has_file) {
$out = Join-Path (Get-Location) ($it.id.ToString() + '-' + $it.file_name)
Invoke-WebRequest -Uri "$Base/file?id=$($it.id)" -Headers $Headers -OutFile $out -UseBasicParsing -TimeoutSec 600
Invoke-WebRequest -Uri (Api-Url ("file?id=" + $it.id)) -Headers $Headers -OutFile $out -UseBasicParsing -TimeoutSec 600
Write-Host "downloaded: $out"
}
break
@ -94,6 +102,7 @@ switch -Regex ($A) {
'^(?i)done$' { if (-not $B) { throw 'done <id>' }; Invoke-Relay 'POST' 'done' @{ id = [long]$B } | Out-Null; Write-Host "#$B done"; break }
'^(?i)result$' {
if (-not $B -and -not $File) { throw 'result "<text>" [-TaskId N] [-File path]' }
if (-not $MachineSecret) { Write-Host 'note: no machine-secret.txt next to send.ps1; the relay refuses results from a bound machine without it' }
Post-Item @{ kind = 'result'; body = "$B"; title = $Title } | Out-Null
break
}

View file

@ -3,17 +3,23 @@
# send.sh "<text>" note to everyone send.sh <file> file (up to 50 MB)
# send.sh inbox unread tasks for this machine, marked read send.sh peek same, not marked
# send.sh get <id> print an item, download its file here send.sh done <id>
# send.sh result "<text>" [--task-id N] [--file path]
# send.sh result "<text>" [--task-id N] [--file path] (needs this machine's secret)
# RELAY_TO=PC1 RELAY_TITLE="..." RELAY_MACHINE=Mac override the defaults.
# The URL, key and token are written in by make-clients.sh (the repo copy holds placeholders).
# The token, key and machine secret go to curl through a header file (-K), never on the command line or in the URL
# (X24, X29). The URL, key and token are written in by make-clients.sh (the repo copy holds placeholders);
# machine-secret.txt next to this file (or RELAY_MACHINE_SECRET) names this machine on every result (X27).
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
RELAY_URL='__RELAY_URL__'; RELAY_KEY='__RELAY_KEY__'; RELAY_TOKEN='__RELAY_TOKEN__'
BASE="$RELAY_URL/r/$RELAY_TOKEN/api"
STATE="${XDG_STATE_HOME:-$HOME/.local/state}/igneum-relay"
API="$RELAY_URL/api/relay?fn="
STATE="${XDG_STATE_HOME:-$HOME/.local/state}/igneum-relay"; mkdir -p "$STATE"; chmod 700 "$STATE"
SECRET="${RELAY_MACHINE_SECRET:-}"; [ -n "$SECRET" ] || { [ -f "$HERE/machine-secret.txt" ] && SECRET="$(tr -d '[:space:]' < "$HERE/machine-secret.txt")" || SECRET=""; }
HDR="$STATE/headers.cfg"
( umask 077; { printf 'header = "x-relay-token: %s"\nheader = "x-igneum-key: %s"\n' "$RELAY_TOKEN" "$RELAY_KEY"; [ -n "$SECRET" ] && printf 'header = "x-machine-secret: %s"\n' "$SECRET"; } > "$HDR" )
machine() { if [ -n "${RELAY_MACHINE:-}" ]; then echo "$RELAY_MACHINE"; elif [ -f "$STATE/machine.txt" ]; then cat "$STATE/machine.txt"; else hostname -s; fi; }
jqq() { if command -v jq >/dev/null; then jq "$@"; else python3 -c 'import json,sys; q=sys.argv[1]; d=json.load(sys.stdin); print(d[q.strip(".")])' "$@"; fi; }
post() { curl -sS --max-time 120 -X POST "$BASE/$1" -H 'Content-Type: application/json' -H "x-igneum-key: $RELAY_KEY" --data-binary "$2"; }
get() { curl -sS --max-time 60 "$BASE/$1" -H "x-igneum-key: $RELAY_KEY"; }
post() { curl -sS --max-time 120 -K "$HDR" -X POST "$API$1" -H 'Content-Type: application/json' --data-binary "$2"; }
get() { curl -sS --max-time 60 -K "$HDR" "$API${1/\?/&}"; }
jstr() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' ; }
upload() {
local path="$1" name size tok
@ -33,13 +39,14 @@ for x in (it if isinstance(it,list) else [it]):
if x.get("has_file"): print("file: %s (%s bytes); send.sh get %s downloads it" % (x["file_name"],x["size"],x["id"]))'; }
cmd="${1:-}"; M="$(machine)"; TO="${RELAY_TO:-all}"; TITLE="${RELAY_TITLE:-}"
case "$cmd" in
inbox) get "inbox?machine=$M&ack=1" | jqq .items | show ;;
peek) get "inbox?machine=$M" | jqq .items | show ;;
inbox) post inbox "{\"machine\":$(printf '%s' "$M" | jstr),\"kind\":\"task\",\"ack\":true}" | jqq .items | show ;; # marking read is a POST (X28)
peek) get "inbox?machine=$M&kind=task" | jqq .items | show ;;
get) [ -n "${2:-}" ] || { echo 'get <id>' >&2; exit 1; }; j="$(get "item?id=$2")"; printf '%s' "$j" | jqq .item | show
if [ "$(printf '%s' "$j" | jqq -r .item.has_file)" = "true" ]; then out="$2-$(printf '%s' "$j" | jqq -r .item.file_name)"; curl -sSL --max-time 600 "$BASE/file?id=$2" -H "x-igneum-key: $RELAY_KEY" -o "$out"; echo "downloaded: $out"; fi ;;
if [ "$(printf '%s' "$j" | jqq -r .item.has_file)" = "true" ]; then out="$2-$(printf '%s' "$j" | jqq -r .item.file_name)"; curl -sSL --max-time 600 -K "$HDR" "${API}file&id=$2" -o "$out"; echo "downloaded: $out"; fi ;;
done) [ -n "${2:-}" ] || { echo "done <id>" >&2; exit 1; }; post done "{\"id\":$2}" >/dev/null; echo "#$2 done" ;;
result) shift; text="${1:-}"; shift || true; task=0; file=""
while [ $# -gt 0 ]; do case "$1" in --task-id) task="$2"; shift 2;; --file) file="$2"; shift 2;; *) shift;; esac; done
[ -n "$SECRET" ] || echo "note: no machine-secret.txt next to send.sh; the relay refuses results from a bound machine without it" >&2
extra=""; [ -n "$file" ] && extra=",$(upload "$file")"
post drop "{\"from\":$(printf '%s' "$M" | jstr),\"to\":\"$TO\",\"kind\":\"result\",\"title\":$(printf '%s' "$TITLE" | jstr),\"body\":$(printf '%s' "$text" | jstr),\"task_id\":$task$extra}"; echo ;;
"") echo 'send.sh "<text>" | send.sh <file> | send.sh inbox | send.sh result "<text>" | send.sh get <id> | send.sh done <id>' >&2; exit 1 ;;

31
relay/lib/blob.mjs Normal file
View file

@ -0,0 +1,31 @@
// The Vercel Blob side of the relay (store igneum-relay): the only module that imports @vercel/blob, so everything
// else loads without node_modules. api/relay.mjs hands these three to lib/handler.mjs; the tests hand it fakes.
import { put, del } from '@vercel/blob';
import { generateClientTokenFromReadWriteToken } from '@vercel/blob/client';
import { blobPath, MAX_BLOB } from './relay.mjs';
export async function storeBuffer(name, buf, contentType) {
const r = await put(blobPath(name), buf, {
access: 'public', addRandomSuffix: true, contentType: contentType || 'application/octet-stream',
});
return { url: r.url, size: buf.length };
}
export async function clientUploadToken(name, size) {
const pathname = blobPath(name);
const token = await generateClientTokenFromReadWriteToken({
pathname,
addRandomSuffix: true,
allowOverwrite: false,
maximumSizeInBytes: MAX_BLOB,
validUntil: Date.now() + 60 * 60 * 1000,
});
return { token, pathname, put_url: `https://vercel.com/api/blob/?pathname=${encodeURIComponent(pathname)}`, api_version: '11', max: MAX_BLOB, size };
}
/** Deletes the blobs behind the given URLs (X26: a deleted or expired row takes its file with it). Never throws. */
export async function deleteBlobs(urls) {
const list = [...new Set(urls.filter(Boolean))];
if (!list.length) return 0;
try { await del(list); return list.length; } catch { return 0; }
}

124
relay/lib/guard.mjs Normal file
View file

@ -0,0 +1,124 @@
// The relay's guards (review round 4, ledger X23 to X28; fixed 5 October 2026, night). No dependencies, so
// `node --test relay/test/guard.test.mjs` covers every rule here.
//
// Three secrets, three tiers:
// token the console token: the URL path (the phone's page only) or the x-relay-token header. Everything.
// key the relay's own key (RELAY_KEY, ~/.config/igneum/relay-key) in x-igneum-key. Reports and reads;
// never task, run, name, role, delete, secret.
// intake the log-intake key (LOG_INTAKE_KEY, LOG_INTAKE_KEY_NEXT) in x-igneum-key: the key inside every shipped
// package. Only `upload` and a `drop` of kind file or text (the PC apps' build-job outputs). Nothing else,
// no reads. Closed by RELAY_INTAKE_COMPAT=0 once the apps carry a relay key of their own.
//
// A `run` task (a script the agent executes, often as administrator) needs more than the token:
// sig an Ed25519 signature by the Mac's run key (~/.config/igneum/relay-run-key) over runCanon(); the API
// verifies it with RELAY_RUN_PUB and refuses the task with 401 when it is missing or wrong.
// mac an HMAC-SHA256 tag with the target machine's own secret over the same canonical text; the agent verifies
// it before it executes anything (Windows PowerShell 5.1 has no Ed25519, so the agent's check is the HMAC).
// nonce 32 hex, unique per run task; the agent remembers executed nonces.
// The canonical text names the machine, the nonce, the body's sha256 and the three flags that change what the agent
// does, so none of them can be altered by a holder of the token or the database alone.
//
// Machines: a per-machine secret (32 hex, made on the Mac, `node tools/relay.mjs secret PC1`) whose sha256 the relay
// stores; `register` and every `result` present it in x-machine-secret and `from` must be that machine.
import { createHash, createHmac, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign as edSign, verify as edVerify, timingSafeEqual } from 'node:crypto';
import { sameSecret } from './auth.mjs';
export const FEED_LIMIT_DEFAULT = 50;
export const FEED_LIMIT_MAX = 100; // was 500: one secret no longer pages the whole history in five calls
export const RETENTION_DAYS = 30;
export const RATE_PER_MIN = 120; // authenticated calls per IP per minute (an agent polls 3 a minute)
export const AUTH_FAIL_PER_MIN = 10; // failed authentications per IP per minute
export const REBOOT_MARKER = 'RELAY-REBOOT';
const HEX = n => new RegExp(`^[0-9a-f]{${n}}$`);
export const isNonce = s => typeof s === 'string' && HEX(32).test(s);
export const isSig = s => typeof s === 'string' && HEX(128).test(s);
export const isMac = s => typeof s === 'string' && HEX(64).test(s);
export const isSecret = s => typeof s === 'string' && HEX(64).test(s);
export const isPub = s => typeof s === 'string' && HEX(64).test(s);
/** Which tier a request authenticates as, from its query (the rewrite's ?token=) and headers; null when none. */
export function authVia({ query = {}, headers = {} }, env = process.env) {
const given = query.token || headers['x-relay-token'];
if (sameSecret(given, env.RELAY_TOKEN)) return 'token';
const k = headers['x-igneum-key'];
if (sameSecret(k, env.RELAY_KEY)) return 'key';
if (env.RELAY_INTAKE_COMPAT !== '0') {
for (const name of ['LOG_INTAKE_KEY', 'LOG_INTAKE_KEY_NEXT']) if (env[name] && sameSecret(k, env[name])) return 'intake';
}
return null;
}
/** What each tier may call. POST fn names; GET reads are allowed for token and key only. */
export const POST_ALLOWED = {
token: new Set(['drop', 'task', 'upload', 'ack', 'done', 'delete', 'register', 'name', 'role', 'secret', 'inbox']),
key: new Set(['drop', 'upload', 'ack', 'done', 'register', 'inbox']),
intake: new Set(['drop', 'upload']),
};
export const DROP_KINDS = { token: null, key: new Set(['text', 'file', 'result']), intake: new Set(['text', 'file']) }; // null: any kind
export const mayRead = via => via === 'token' || via === 'key';
export const sha256hex = s => createHash('sha256').update(Buffer.isBuffer(s) ? s : Buffer.from(String(s), 'utf8')).digest('hex');
export const secretHash = secret => sha256hex(secret);
const flag = v => (v === true || v === 1 || v === '1' || v === 'true' ? '1' : '0');
/** The canonical text a run task is signed over. Deterministic; the agent builds the same string. */
export function runCanon({ to, nonce, body, flags = {} }) {
return ['igneum-relay-run/1', `to=${String(to || '')}`, `nonce=${String(nonce || '')}`, `elevated=${flag(flags.elevated)}`,
`reboot_continue=${flag(flags.reboot_continue)}`, `reboot=${flag(flags.reboot)}`, `body_sha256=${sha256hex(String(body || ''))}`, ''].join('\n');
}
// Ed25519 raw keys as hex (32-byte seed, 32-byte public), the OTA key's shape, through PKCS8 and SPKI DER prefixes.
const PKCS8 = Buffer.from('302e020100300506032b657004220420', 'hex');
const SPKI = Buffer.from('302a300506032b6570032100', 'hex');
export const privFromSeed = seedHex => createPrivateKey({ key: Buffer.concat([PKCS8, Buffer.from(seedHex, 'hex')]), format: 'der', type: 'pkcs8' });
export const pubFromHex = pubHex => createPublicKey({ key: Buffer.concat([SPKI, Buffer.from(pubHex, 'hex')]), format: 'der', type: 'spki' });
export function keygen() {
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
const seed = privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(PKCS8.length).toString('hex');
const pub = publicKey.export({ format: 'der', type: 'spki' }).subarray(SPKI.length).toString('hex');
return { seed, pub };
}
export const signRun = (canon, seedHex) => edSign(null, Buffer.from(canon, 'utf8'), privFromSeed(seedHex)).toString('hex');
export function verifyRun(canon, sigHex, pubHex) {
if (!isSig(sigHex) || !isPub(pubHex)) return false;
try { return edVerify(null, Buffer.from(canon, 'utf8'), pubFromHex(pubHex), Buffer.from(sigHex, 'hex')); } catch { return false; }
}
export const machineTag = (secret, canon) => createHmac('sha256', Buffer.from(String(secret), 'utf8')).update(Buffer.from(canon, 'utf8')).digest('hex');
export function sameTag(a, b) {
if (!isMac(a) || !isMac(b)) return false;
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
}
export const newNonce = () => randomBytes(16).toString('hex');
export const newSecret = () => randomBytes(32).toString('hex');
/**
* Checks everything a run task must carry before the API stores it. Returns null when good, else the refusal text.
* pubHex: RELAY_RUN_PUB; without it every run is refused (the safe failure at a deploy that forgot the key).
*/
export function checkRun(o, pubHex) {
const f = o.flags && typeof o.flags === 'object' ? o.flags : {};
if (!o.to || o.to === 'all') return 'a run task needs one named machine';
if (!isPub(pubHex)) return 'run tasks are refused: RELAY_RUN_PUB is not set on the relay';
if (!isNonce(f.nonce)) return 'a run task needs flags.nonce (32 hex)';
if (!isMac(f.mac)) return 'a run task needs flags.mac, the HMAC tag with the machine secret';
if (!isSig(f.sig)) return 'a run task needs flags.sig, the Ed25519 signature by the relay run key';
if (!verifyRun(runCanon({ to: o.to, nonce: f.nonce, body: o.body, flags: f }), f.sig, pubHex)) return 'the run signature does not verify against RELAY_RUN_PUB';
return null;
}
/** The reboot request: the marker on a line of its own, never inside other output (X28). */
export const wantsReboot = text => /(^|\r?\n)RELAY-REBOOT\r?(\n|$)/.test(String(text || ''));
export const feedLimit = q => Math.min(FEED_LIMIT_MAX, Math.max(1, Number(q && q.limit) || FEED_LIMIT_DEFAULT));
/** The oldest timestamp the relay keeps, as an ISO string, for `ts < $1`. */
export const retentionCutoff = (now = Date.now()) => new Date(now - RETENTION_DAYS * 86400_000).toISOString();
/** The machine a presented secret names: {name} from the rows, or an error text. rows: [{name, secret_hash}]. */
export function machineForSecret(secret, rows) {
if (!isSecret(secret)) return { error: 'x-machine-secret must be 64 hex' };
const h = secretHash(secret);
const hit = rows.find(r => r.secret_hash && sameSecret(h, r.secret_hash));
return hit ? { name: hit.name } : { error: 'unknown machine secret' };
}

295
relay/lib/handler.mjs Normal file
View file

@ -0,0 +1,295 @@
// The relay handler with its dependencies injected (api/relay.mjs wires Neon and Vercel Blob; relay/test/handler.test.mjs
// wires fakes). Dispatched on ?fn=; reached as /api/relay?fn=<fn> with x-relay-token or x-igneum-key, or through the
// rewrite /r/<token>/api/<fn> from the phone's page (the only caller that keeps the token in the path, X24).
//
// GET feed ?since=<id> | ?before=<id> | ?machine=X | ?limit=N (cap 100) items newest first + machines + unread counts
// GET item ?id= one item with its full body
// GET file ?id= 302 to the Blob URL (or the inline bytes)
// GET inbox ?machine=PC1&kind=run|task|all unread tasks for a machine; a GET never marks anything (X28)
// POST inbox JSON {machine, kind, ack:true} the same list, marked read
// GET machines every machine with role, hostname, last_seen, bound
// POST drop JSON {from,to,kind,title,body,file_name,file_url,size,task_id,flags} or raw octet-stream (x-file-name, x-from)
// POST task JSON {to,title,body,kind:'task'|'run',flags:{elevated,reboot_continue,reboot,nonce,sig,mac},from}
// run: token AND flags.sig (Ed25519 by the relay run key, verified here with RELAY_RUN_PUB) AND flags.mac
// (the machine's HMAC tag, verified by the agent) AND a fresh flags.nonce; anything less is 401 (X23)
// POST upload JSON {name,size} -> {token, put_url, api_version} client token for a direct PUT to Vercel Blob (50 MB)
// POST ack JSON {ids:[...]} mark read
// POST done JSON {id, exit_code} mark done (runner finished)
// POST register JSON {hostname, info, role?} + x-machine-secret machine checks in; the secret names it (X27)
// POST name JSON {hostname, name} name an unknown machine (token)
// POST role JSON {name, role} set a machine's role (token)
// POST secret JSON {name, secret_hash} bind a machine to sha256(its secret) (token)
// POST delete JSON {id} the row and its blob (token)
// Retention: rows older than 30 days go, blobs with them, checked on a feed read at most every 10 minutes per instance.
// Rate limit: 120 calls a minute per IP, 10 failed authentications a minute per IP (X28).
import { readJson, readRaw, str, safeName, ITEM_COLS, BLOB_URL_RE, rowOut, iso, touch, KINDS, ROLES, MAX_INLINE, MAX_BODY } from './relay.mjs';
import { POST_ALLOWED, DROP_KINDS, mayRead, checkRun, feedLimit, retentionCutoff, machineForSecret, secretHash, isSecret, RATE_PER_MIN, AUTH_FAIL_PER_MIN } from './guard.mjs';
import { RateLimit, ipOf } from './wake.mjs';
export const EXPIRE_EVERY_MS = 10 * 60_000;
const machineOut = m => ({ name: m.name, hostname: m.hostname, role: m.role, named: m.named, info: m.info, last_seen: iso(m.last_seen), bound: !!m.secret_hash, ...(m.unread !== undefined ? { unread: m.unread } : {}) });
async function insertItem(sql, o) {
let kind = KINDS.has(o.kind) ? o.kind : (o.file_url || o.file_b64 ? 'file' : 'text');
if (kind === 'text' && o.file_url) kind = 'file';
const flags = o.flags && typeof o.flags === 'object' ? o.flags : {};
const rows = await sql(
`INSERT INTO relay_items (from_machine, to_machine, kind, title, body, file_name, file_url, file_b64, size, flags, task_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11) RETURNING id, ts`,
[str(o.from, 80) || 'unknown', str(o.to, 80) || 'all', kind, str(o.title, 300), str(o.body, MAX_BODY),
o.file_name ? safeName(o.file_name) : null, o.file_url ? str(o.file_url, 1000) : null, o.file_b64 || null,
Number(o.size) || 0, JSON.stringify(flags), o.task_id ? Number(o.task_id) : null]);
await touch(sql, o.from);
return { id: Number(rows[0].id), ts: rows[0].ts, kind };
}
/** The secret_hash column, added once per instance (ADD COLUMN IF NOT EXISTS is idempotent and takes no long lock). */
async function ensureSecretColumn(sql, state) {
if (state.secretColumn) return;
await sql(`ALTER TABLE relay_machines ADD COLUMN IF NOT EXISTS secret_hash text`);
state.secretColumn = true;
}
/** Rows older than the retention and their blobs (X26). */
export async function expire(sql, blob, now = Date.now()) {
const rows = await sql(`DELETE FROM relay_items WHERE ts < $1 RETURNING id, file_url`, [retentionCutoff(now)]);
const blobs = await blob.deleteBlobs(rows.map(r => r.file_url));
return { rows: rows.length, blobs };
}
/**
* makeHandler({ sql, blob: {storeBuffer, clientUploadToken, deleteBlobs}, authed, json, env, now, limiter, authLimiter, state })
* json(res, status, obj) writes a reply; state is per instance (the expire clock and the column flag).
*/
export function makeHandler({ sql, blob, authed, json, env = process.env, now = Date.now, limiter, authLimiter, state = {} }) {
const limit = limiter || new RateLimit({ perMinute: RATE_PER_MIN, now });
const authLimit = authLimiter || new RateLimit({ perMinute: AUTH_FAIL_PER_MIN, now });
const bindMachine = async (secret) => {
// the machine a presented x-machine-secret names, or an error text
await ensureSecretColumn(sql, state);
const rows = await sql(`SELECT name, secret_hash FROM relay_machines WHERE secret_hash IS NOT NULL`);
return machineForSecret(secret, rows);
};
const hasSecret = async (name) => {
await ensureSecretColumn(sql, state);
const rows = await sql(`SELECT secret_hash FROM relay_machines WHERE name = $1`, [name]);
return !!(rows.length && rows[0].secret_hash);
};
return async function handler(req, res) {
res.setHeader('Cache-Control', 'no-store');
const ip = ipOf(req);
const via = authed(req);
if (!via) {
const wait = authLimit.take(ip);
if (wait !== null) { res.setHeader('Retry-After', String(wait)); return json(res, 429, { ok: false, error: `too many failed authentications; retry in ${wait} s` }); }
return json(res, 401, { ok: false, error: 'no token' });
}
const wait = limit.take(ip);
if (wait !== null) { res.setHeader('Retry-After', String(wait)); return json(res, 429, { ok: false, error: `rate limit; retry in ${wait} s` }); }
const fn = String(req.query.fn || '');
const q = req.query;
try {
if (req.method === 'GET') {
if (!mayRead(via)) return json(res, 403, { ok: false, error: 'the intake key may only upload and drop files' });
if (fn === 'feed') {
if (now() - (state.lastExpire || 0) > EXPIRE_EVERY_MS) { state.lastExpire = now(); try { state.expired = await expire(sql, blob, now()); } catch (e) { state.expireError = String(e.message || e); } }
const lim = feedLimit(q);
const where = []; const params = [];
if (q.since) { params.push(Number(q.since)); where.push(`id > $${params.length}`); }
if (q.before) { params.push(Number(q.before)); where.push(`id < $${params.length}`); }
if (q.machine) { params.push(str(q.machine, 80)); where.push(`(from_machine = $${params.length} OR to_machine = $${params.length})`); }
const items = await sql(`SELECT ${ITEM_COLS} FROM relay_items ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY id DESC LIMIT ${lim}`, params);
await ensureSecretColumn(sql, state);
const machines = await sql(`SELECT m.name, m.hostname, m.role, m.named, m.info, m.last_seen, m.secret_hash,
(SELECT count(*) FROM relay_items i WHERE NOT i.read AND i.kind IN ('task','run') AND (i.to_machine = m.name OR (i.to_machine = 'all' AND i.kind = 'task')))::int AS unread
FROM relay_machines m ORDER BY m.last_seen DESC NULLS LAST, m.name`);
return json(res, 200, { ok: true, items: items.map(rowOut), machines: machines.map(machineOut), limit: lim, now: new Date(now()).toISOString() });
}
if (fn === 'machines') {
await ensureSecretColumn(sql, state);
const machines = await sql(`SELECT name, hostname, role, named, info, last_seen, secret_hash FROM relay_machines ORDER BY name`);
return json(res, 200, { ok: true, machines: machines.map(machineOut) });
}
if (fn === 'item') {
const rows = await sql(`SELECT ${ITEM_COLS} FROM relay_items WHERE id = $1`, [Number(q.id)]);
if (!rows.length) return json(res, 404, { ok: false, error: 'no such item' });
return json(res, 200, { ok: true, item: rowOut(rows[0]) });
}
if (fn === 'file') {
const rows = await sql(`SELECT file_name, file_url, file_b64 FROM relay_items WHERE id = $1`, [Number(q.id)]);
if (!rows.length || (!rows[0].file_url && !rows[0].file_b64)) return json(res, 404, { ok: false, error: 'no file' });
if (rows[0].file_url) { res.statusCode = 302; res.setHeader('Location', rows[0].file_url + (q.download ? '?download=1' : '')); return res.end(); }
const buf = Buffer.from(rows[0].file_b64, 'base64');
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${safeName(rows[0].file_name)}"`);
return res.status(200).end(buf);
}
if (fn === 'inbox') return inbox(q, false);
return json(res, 404, { ok: false, error: `unknown fn ${fn}` });
}
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'method' });
if (!POST_ALLOWED[via].has(fn)) return json(res, via === 'intake' ? 403 : (POST_ALLOWED.token.has(fn) ? 403 : 404), { ok: false, error: POST_ALLOWED.token.has(fn) ? `${fn} needs the console token` : `unknown fn ${fn}` });
const ct = String(req.headers['content-type'] || '');
if (fn === 'drop' && !ct.includes('json')) {
// raw bytes through the function: curl --data-binary @file -H 'Content-Type: application/octet-stream' -H 'x-file-name: a.zip'
const buf = await readRaw(req);
if (!buf.length) return json(res, 400, { ok: false, error: 'empty body' });
if (buf.length > MAX_INLINE) return json(res, 413, { ok: false, error: `raw upload over ${MAX_INLINE} bytes; use fn=upload for a Blob client token` });
const name = safeName(req.headers['x-file-name'] || 'file.bin');
const stored = await blob.storeBuffer(name, buf, ct || 'application/octet-stream');
const r = await insertItem(sql, { from: req.headers['x-from'] || q.from, to: req.headers['x-to'] || q.to, kind: 'file',
title: str(req.headers['x-title'] || q.title || name, 300), body: '', file_name: name, file_url: stored.url, size: buf.length,
task_id: req.headers['x-task-id'] || q.task_id });
return json(res, 200, { ok: true, ...r, file_url: stored.url });
}
let body;
try { body = await readJson(req); } catch { return json(res, 400, { ok: false, error: 'bad json' }); }
const presented = req.headers['x-machine-secret'];
if (fn === 'inbox') { if (!POST_ALLOWED[via].has('inbox')) return json(res, 403, { ok: false, error: 'inbox needs the token or the relay key' }); return inbox(body, !!body.ack); }
if (fn === 'drop' || fn === 'task') {
const o = { ...body };
if (fn === 'task') { o.kind = o.kind === 'run' ? 'run' : 'task'; if (!o.to) return json(res, 400, { ok: false, error: 'to required' }); }
const kind = KINDS.has(o.kind) ? o.kind : (o.file_url || o.file_b64 ? 'file' : 'text');
const allowed = DROP_KINDS[via];
if (allowed && !allowed.has(kind)) return json(res, 403, { ok: false, error: `a ${kind} item needs the console token` });
if (kind === 'run') {
const why = checkRun(o, env.RELAY_RUN_PUB);
if (why) return json(res, why.startsWith('a run task needs one') ? 400 : 401, { ok: false, error: why });
const dup = await sql(`SELECT id FROM relay_items WHERE kind = 'run' AND flags->>'nonce' = $1`, [o.flags.nonce]);
if (dup.length) return json(res, 409, { ok: false, error: `nonce already used by #${dup[0].id}` });
}
if (kind === 'result') {
// X27: a result names the machine its secret proves; a forged `from` is refused
if (presented !== undefined) {
const m = await bindMachine(presented);
if (m.error) return json(res, 403, { ok: false, error: m.error });
if (o.from && o.from !== m.name) return json(res, 403, { ok: false, error: `from ${o.from} does not match the machine secret (${m.name})` });
o.from = m.name;
} else if (o.from && await hasSecret(o.from)) {
return json(res, 403, { ok: false, error: `results from ${o.from} need its machine secret (x-machine-secret)` });
} else {
o.flags = { ...(o.flags && typeof o.flags === 'object' ? o.flags : {}), unbound: true };
}
}
if (o.file_b64 && !o.file_url) {
const buf = Buffer.from(String(o.file_b64), 'base64');
if (buf.length > MAX_INLINE) return json(res, 413, { ok: false, error: 'inline file over 4 MB; use fn=upload' });
const stored = await blob.storeBuffer(o.file_name || 'file.bin', buf, o.content_type);
o.file_url = stored.url; o.size = buf.length; delete o.file_b64;
}
if (!o.body && !o.file_url && !o.title) return json(res, 400, { ok: false, error: 'nothing to send' });
if (o.file_url && !BLOB_URL_RE.test(o.file_url)) return json(res, 400, { ok: false, error: 'file_url must be a Vercel Blob URL from fn=upload' });
const r = await insertItem(sql, o);
return json(res, 200, { ok: true, ...r });
}
if (fn === 'upload') {
const name = safeName(body.name || 'file.bin');
const t = await blob.clientUploadToken(name, Number(body.size) || 0);
return json(res, 200, { ok: true, name, ...t });
}
if (fn === 'ack') {
const ids = (Array.isArray(body.ids) ? body.ids : [body.id]).map(Number).filter(Boolean);
if (!ids.length) return json(res, 400, { ok: false, error: 'ids required' });
await sql(`UPDATE relay_items SET read = true, read_at = now() WHERE id = ANY($1)`, [ids]);
return json(res, 200, { ok: true, ids });
}
if (fn === 'done') {
const id = Number(body.id); if (!id) return json(res, 400, { ok: false, error: 'id required' });
const extra = body.exit_code === undefined ? {} : { exit_code: Number(body.exit_code) };
if (presented !== undefined) { const m = await bindMachine(presented); if (m.error) return json(res, 403, { ok: false, error: m.error }); extra.done_by = m.name; }
await sql(`UPDATE relay_items SET done = true, done_at = now(), read = true, read_at = COALESCE(read_at, now()), flags = flags || $2::jsonb WHERE id = $1`, [id, JSON.stringify(extra)]);
return json(res, 200, { ok: true, id });
}
if (fn === 'delete') {
const id = Number(body.id); if (!id) return json(res, 400, { ok: false, error: 'id required' });
const rows = await sql(`DELETE FROM relay_items WHERE id = $1 RETURNING file_url`, [id]);
const blobs = await blob.deleteBlobs(rows.map(r => r.file_url));
return json(res, 200, { ok: true, id, blobs });
}
if (fn === 'register') {
const hostname = str(body.hostname, 120).trim();
if (!hostname) return json(res, 400, { ok: false, error: 'hostname required' });
const info = body.info && typeof body.info === 'object' ? { ...body.info } : {};
delete info.user; delete info.dir; // X28: no username and no secret folder in the registration
if (presented !== undefined) {
// the secret names the machine; the hostname is recorded against that row (both PCs report the same hostname)
const m = await bindMachine(presented);
if (m.error) return json(res, 403, { ok: false, error: m.error });
const rows = await sql(`UPDATE relay_machines SET hostname = $2, last_seen = now(), info = $3::jsonb WHERE name = $1 RETURNING name, role, named`, [m.name, hostname, JSON.stringify(info)]);
return json(res, 200, { ok: true, name: rows[0].name, role: rows[0].role || '', named: !!rows[0].named, hostname, bound: true });
}
await ensureSecretColumn(sql, state);
let rows = await sql(`SELECT name, role, named, secret_hash FROM relay_machines WHERE hostname = $1`, [hostname]);
if (rows.some(r => r.secret_hash)) return json(res, 403, { ok: false, error: `${hostname} is bound to a machine secret; present x-machine-secret` });
if (!rows.length) {
// first contact from this hostname: it shows up under its own hostname until the Mac names it
rows = await sql(`INSERT INTO relay_machines (name, hostname, role, named, info, last_seen) VALUES ($1, $1, $2, false, $3::jsonb, now())
ON CONFLICT (name) DO UPDATE SET hostname = EXCLUDED.hostname, last_seen = now(), info = EXCLUDED.info RETURNING name, role, named`,
[hostname, ROLES.has(body.role) ? body.role : '', JSON.stringify(info)]);
} else {
await sql(`UPDATE relay_machines SET last_seen = now(), info = $2::jsonb WHERE hostname = $1`, [hostname, JSON.stringify(info)]);
}
return json(res, 200, { ok: true, name: rows[0].name, role: rows[0].role || '', named: !!rows[0].named, hostname, bound: false });
}
if (fn === 'secret') {
const name = str(body.name, 80).trim(); const hash = str(body.secret_hash, 64).trim();
if (!name || !isSecret(hash)) return json(res, 400, { ok: false, error: 'name and secret_hash (64 hex, sha256 of the secret) required' });
await ensureSecretColumn(sql, state);
await sql(`INSERT INTO relay_machines (name, role, named, secret_hash) VALUES ($1, '', true, $2) ON CONFLICT (name) DO UPDATE SET secret_hash = EXCLUDED.secret_hash, named = true`, [name, hash]);
return json(res, 200, { ok: true, name, bound: true });
}
if (fn === 'name') {
const hostname = str(body.hostname, 120).trim(); const name = str(body.name, 80).trim();
if (!hostname || !name) return json(res, 400, { ok: false, error: 'hostname and name required' });
const target = await sql(`SELECT name, hostname FROM relay_machines WHERE name = $1`, [name]);
const old = await sql(`SELECT name FROM relay_machines WHERE hostname = $1`, [hostname]);
if (target.length && target[0].hostname && target[0].hostname !== hostname) return json(res, 409, { ok: false, error: `${name} is already ${target[0].hostname}` });
if (target.length) {
// a pre-seeded name (PC2 with no hostname yet): attach the hostname, drop the placeholder row, move its items
if (old.length && old[0].name !== name) {
await sql(`DELETE FROM relay_machines WHERE hostname = $1 AND name <> $2`, [hostname, name]);
await sql(`UPDATE relay_items SET from_machine = $2 WHERE from_machine = $1`, [old[0].name, name]);
await sql(`UPDATE relay_items SET to_machine = $2 WHERE to_machine = $1`, [old[0].name, name]);
}
await sql(`UPDATE relay_machines SET hostname = $1, named = true, last_seen = COALESCE(last_seen, now()) WHERE name = $2`, [hostname, name]);
} else if (old.length) {
await sql(`UPDATE relay_machines SET name = $2, named = true WHERE hostname = $1`, [hostname, name]);
await sql(`UPDATE relay_items SET from_machine = $2 WHERE from_machine = $1`, [old[0].name, name]);
await sql(`UPDATE relay_items SET to_machine = $2 WHERE to_machine = $1`, [old[0].name, name]);
} else {
await sql(`INSERT INTO relay_machines (name, hostname, role, named) VALUES ($2, $1, '', true)`, [hostname, name]);
}
return json(res, 200, { ok: true, hostname, name });
}
if (fn === 'role') {
const name = str(body.name, 80).trim(); const role = str(body.role, 20).trim();
if (!name || !ROLES.has(role)) return json(res, 400, { ok: false, error: `role must be one of ${[...ROLES].filter(Boolean).join(', ')}` });
await sql(`INSERT INTO relay_machines (name, role, named) VALUES ($1, $2, true) ON CONFLICT (name) DO UPDATE SET role = EXCLUDED.role`, [name, role]);
return json(res, 200, { ok: true, name, role });
}
return json(res, 404, { ok: false, error: `unknown fn ${fn}` });
} catch (e) {
return json(res, 500, { ok: false, error: String(e.message || e) });
}
async function inbox(src, ack) {
const machine = str(src.machine, 80);
if (!machine) return json(res, 400, { ok: false, error: 'machine required' });
const kind = src.kind === 'run' ? ['run'] : src.kind === 'task' ? ['task'] : ['task', 'run'];
// run items only ever go to one named machine; task items may be addressed to all
const rows = await sql(`SELECT ${ITEM_COLS} FROM relay_items
WHERE NOT read AND NOT done AND kind = ANY($2) AND (to_machine = $1 OR (to_machine = 'all' AND kind = 'task'))
ORDER BY id ASC LIMIT 50`, [machine, kind]);
if (ack && rows.length) await sql(`UPDATE relay_items SET read = true, read_at = now() WHERE id = ANY($1)`, [rows.map(r => Number(r.id))]);
await touch(sql, machine);
return json(res, 200, { ok: true, machine, items: rows.map(rowOut), acked: ack ? rows.length : 0 });
}
};
}

View file

@ -1,11 +1,10 @@
// Shared pieces for the Igneum relay function. Zero dependencies apart from @vercel/blob.
// Storage: Neon (HTTP SQL driver) for every item, Vercel Blob (store igneum-relay, public URLs with a
// random suffix) for files. The token in the URL path is the only secret the web page holds; scripts
// may also present the intake key in x-igneum-key.
import { put } from '@vercel/blob';
import { generateClientTokenFromReadWriteToken } from '@vercel/blob/client';
// Shared pieces for the Igneum relay function. Zero dependencies (the Vercel Blob calls live in lib/blob.mjs, so
// lib/handler.mjs and its tests load without node_modules). Storage: Neon (HTTP SQL driver) for every item,
// Vercel Blob (store igneum-relay, public URLs with a random suffix) for files. The token in the URL path is the
// only secret the web page holds; scripts send it in x-relay-token; the relay key and the intake key go in
// x-igneum-key with the powers lib/guard.mjs gives them (5 October 2026, night: X23, X24, X27).
import { randomBytes } from 'node:crypto';
import { sameSecret } from './auth.mjs';
import { authVia } from './guard.mjs';
export const MAX_INLINE = 4 * 1024 * 1024; // raw upload through the function (Vercel body cap is 4.5 MB)
export const MAX_BLOB = 50 * 1024 * 1024; // direct-to-Blob upload with a client token
@ -31,19 +30,10 @@ export function neon() {
export const str = (v, max) => (v === undefined || v === null ? '' : String(v)).slice(0, max);
export function authed(req) {
const q = req.query || {};
const token = process.env.RELAY_TOKEN;
const key = process.env.RELAY_KEY;
const given = q.token || req.headers['x-relay-token'];
if (sameSecret(given, token)) return 'token';
const k = req.headers['x-igneum-key'];
if (sameSecret(k, key)) return 'key';
// the key packaged into every miner app (LOG_INTAKE_KEY, and its successor during a rotation) reports too: the
// build job uploads its binaries with it (5 October 2026: 0.3.5 apps got "no token" after the relay key split)
for (const name of ['LOG_INTAKE_KEY', 'LOG_INTAKE_KEY_NEXT']) if (process.env[name] && sameSecret(k, process.env[name])) return 'key';
return null;
}
/** 'token', 'key', 'intake' or null (lib/guard.mjs). The intake tier may only upload and drop files. */
export const authed = (req, env = process.env) => authVia({ query: req.query || {}, headers: req.headers || {} }, env);
/** The same, with the intake tier excluded: the console and the wake POST take reports from nobody's package. */
export const authedNoIntake = (req, env = process.env) => { const v = authed(req, env); return v === 'intake' ? null : v; };
export async function readJson(req) {
if (req.body !== undefined && req.body !== null) {
@ -74,26 +64,8 @@ export function blobPath(name) {
return `relay/${randomBytes(6).toString('hex')}/${safeName(name)}`;
}
export async function storeBuffer(name, buf, contentType) {
const r = await put(blobPath(name), buf, {
access: 'public', addRandomSuffix: true, contentType: contentType || 'application/octet-stream',
});
return { url: r.url, size: buf.length };
}
export async function clientUploadToken(name, size) {
const pathname = blobPath(name);
const token = await generateClientTokenFromReadWriteToken({
pathname,
addRandomSuffix: true,
allowOverwrite: false,
maximumSizeInBytes: MAX_BLOB,
validUntil: Date.now() + 60 * 60 * 1000,
});
return { token, pathname, put_url: `https://vercel.com/api/blob/?pathname=${encodeURIComponent(pathname)}`, api_version: '11', max: MAX_BLOB, size };
}
export const ITEM_COLS = 'id, ts, from_machine, to_machine, kind, title, body, file_name, file_url, size, read, read_at, done, done_at, flags, task_id, (file_b64 IS NOT NULL) AS inline';
export const BLOB_URL_RE = /^https:\/\/[a-z0-9.-]+\.public\.blob\.vercel-storage\.com\//i;
export const iso = v => { if (!v) return null; const d = new Date(String(v).replace(' ', 'T').replace(/([+-]\d\d)$/, '$1:00')); return isNaN(d) ? String(v) : d.toISOString(); };

View file

@ -2,14 +2,14 @@
# Queue: node tools/relay.mjs run PC1 "miner v4" relay/playbooks/miner-v4.ps1
# UNTESTED on a PC as of 4 Oct 2026.
$ErrorActionPreference = 'Continue'
$zipUrl = '__DL_BASE__/igneum-windows-v4.zip'
$zipUrl = "$env:RELAY_DL_BASE/igneum-windows-v4.zip" # the agent holds the downloads base (X26); never a token in this file
$root = 'C:\igneum-v4'
$zip = Join-Path $env:TEMP 'igneum-windows-v4.zip'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$old = Join-Path $root 'igneum-windows\STOP-IGNEUM.bat'
if (Test-Path $old) { Write-Host 'stopping the running miner'; & cmd.exe /c "`"$old`"" 2>&1 | Out-Null; Start-Sleep 3 }
Get-Process igneum-miner, igneumd -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Write-Host "downloading $zipUrl"
Write-Host "downloading igneum-windows-v4.zip from the downloads host"
Invoke-WebRequest -Uri $zipUrl -OutFile $zip -UseBasicParsing -TimeoutSec 900
if (Test-Path $root) { Remove-Item $root -Recurse -Force }
New-Item -ItemType Directory -Force -Path $root | Out-Null

View file

@ -2,6 +2,6 @@
# Queue: node tools/relay.mjs run PC1 "one-click test" relay/playbooks/oneclick-test.ps1
$ErrorActionPreference = 'Continue'
Write-Host "oneclick-test placeholder on $env:COMPUTERNAME as $env:RELAY_MACHINE (pass $env:RELAY_PASS)"
Write-Host 'planned: download the one-click zip from __DL_BASE__, extract to C:\igneum-oneclick, run its START script, wait 60 s, post the log'
Write-Host 'planned: download the one-click zip from the downloads host ($env:RELAY_DL_BASE), extract to C:\igneum-oneclick, run its START script, wait 60 s, post the log'
Get-ChildItem C:\ -Directory -Filter 'igneum*' -ErrorAction SilentlyContinue | ForEach-Object { Write-Host ("present: " + $_.FullName) }
exit 0

View file

@ -1,15 +1,15 @@
# Igneum playbook: install the SP1 prover package inside WSL (CUDA toolkit, Rust, sp1up, pre-build). Needs wsl-setup first.
# Queue: node tools/relay.mjs run PC2 "prover setup" relay/playbooks/prover-setup.ps1
# Downloads igneum-prove-wsl2.zip from the downloads host (relay.mjs fills __DL_BASE__ in), extracts to C:\igneum-prove,
# runs setup-wsl.sh as the igneum user with sudo unlocked (NOPASSWD from wsl-setup; sudo -S with the password as a fallback).
# Downloads igneum-prove-wsl2.zip from the downloads host ($env:RELAY_DL_BASE, held by the agent), extracts to C:\igneum-prove,
# runs setup-wsl.sh as the igneum user; sudo is unlocked for apt-get and dpkg only (wsl-setup, X28), no password on any command line.
# 15 to 40 minutes, 4 to 5 GB of downloads (approximate, from the package README). UNTESTED on a PC as of 4 Oct 2026.
$ErrorActionPreference = 'Continue'
$distro = 'Ubuntu-24.04'
$zipUrl = '__DL_BASE__/igneum-prove-wsl2.zip'
$zipUrl = "$env:RELAY_DL_BASE/igneum-prove-wsl2.zip" # the agent holds the downloads base (X26); never a token in this file
$root = 'C:\igneum-prove'
$zip = Join-Path $env:TEMP 'igneum-prove-wsl2.zip'
function Strip([string] $s) { if ($null -eq $s) { return '' }; return ($s -replace "`0", '') }
Write-Host "downloading $zipUrl"
Write-Host "downloading igneum-prove-wsl2.zip from the downloads host"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri $zipUrl -OutFile $zip -UseBasicParsing -TimeoutSec 600
New-Item -ItemType Directory -Force -Path $root | Out-Null
@ -17,8 +17,8 @@ Expand-Archive -Path $zip -DestinationPath $root -Force
$pkg = Join-Path $root 'igneum-prove-wsl2'
if (-not (Test-Path (Join-Path $pkg 'setup-wsl.sh'))) { Write-Host "ERROR: setup-wsl.sh missing under $pkg"; exit 2 }
$linuxDir = '/mnt/c/igneum-prove/igneum-prove-wsl2'
Write-Host "running setup-wsl.sh inside $distro as igneum (sudo unlocked)"
$cmd = "echo igneum | sudo -S -v 2>/dev/null; sudo -n true || echo 'sudo still asks for a password: wsl-setup did not run'; cd $linuxDir && bash ./setup-wsl.sh"
Write-Host "running setup-wsl.sh inside $distro as igneum (sudo unlocked for apt-get and dpkg)"
$cmd = "sudo -n apt-get --version >/dev/null 2>&1 || echo 'sudo apt-get still asks for a password: wsl-setup did not run'; cd $linuxDir && bash ./setup-wsl.sh"
& wsl.exe -d $distro -u igneum -- bash -lc $cmd 2>&1 | ForEach-Object { Strip "$_" }
$rc = $LASTEXITCODE
Write-Host "setup-wsl.sh exit $rc"

View file

@ -14,7 +14,7 @@ $started = Get-Date
$deadline = $started.AddMinutes($budgetMinutes)
$distro = 'Ubuntu-24.04'
$wslUser = '[user]'
$zipUrl = '__DL_BASE__/igneum-prove-wsl2.zip'
$zipUrl = "$env:RELAY_DL_BASE/igneum-prove-wsl2.zip" # the agent holds the downloads base (X26); never a token in this file
$root = 'C:\igneum-prove'
$pkg = Join-Path $root 'igneum-prove-wsl2'
$zip = Join-Path $env:TEMP 'igneum-prove-wsl2.zip'

View file

@ -1,7 +1,9 @@
# Igneum playbook: WSL2 + Ubuntu 24.04 with a ready `igneum` user, no questions asked. Two passes around one reboot.
# Queue from the Mac: node tools/relay.mjs run PC2 "wsl setup" relay/playbooks/wsl-setup.ps1 --elevated --reboot-continue
# Pass 1: hypervisor on, VirtualMachinePlatform + WSL features, prints RELAY-REBOOT (the agent reboots and re-arms).
# Pass 2: wsl --install Ubuntu-24.04 --no-launch, creates the user igneum (password igneum, sudo without a password),
# Pass 1: hypervisor on, VirtualMachinePlatform + WSL features, prints RELAY-REBOOT on its own line (the agent reboots
# only because the task was queued with --reboot-continue, and re-arms for that one restart).
# Pass 2: wsl --install Ubuntu-24.04 --no-launch, creates the user igneum (password igneum; sudo without a password for
# apt-get and dpkg only, with SETENV for DEBIAN_FRONTEND, which is all setup-wsl.sh needs: X28, no blanket sudo),
# makes it the default, checks the GPU is visible inside WSL.
# UNTESTED on a PC as of 4 Oct 2026: written from the wsl.exe and dism.exe documentation; expect a first-run fix.
$ErrorActionPreference = 'Continue'
@ -37,7 +39,7 @@ $list = Strip (((& wsl.exe --list --verbose 2>&1) | Out-String)); Write-Host $li
if ($list -notmatch 'Ubuntu-24\.04') { Write-Host "ERROR: $distro is not registered after the install"; exit 2 }
$mk = 'id igneum >/dev/null 2>&1 || (useradd -m -s /bin/bash igneum && echo igneum:igneum | chpasswd && usermod -aG sudo igneum); ' +
'echo "igneum ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/igneum && chmod 440 /etc/sudoers.d/igneum; ' +
'echo "igneum ALL=(root) NOPASSWD:SETENV: /usr/bin/apt-get, /usr/bin/dpkg" > /etc/sudoers.d/igneum && chmod 440 /etc/sudoers.d/igneum && visudo -cf /etc/sudoers.d/igneum; ' +
'printf "[user]\ndefault=igneum\n[boot]\nsystemd=true\n" > /etc/wsl.conf; id igneum'
& wsl.exe -d $distro -u root -- bash -c $mk 2>&1 | ForEach-Object { Strip "$_" }
& wsl.exe --terminate $distro 2>&1 | Out-Null

119
relay/test/clients.test.mjs Normal file
View file

@ -0,0 +1,119 @@
// node --test relay/test/clients.test.mjs Structural checks of the clients, the playbooks and the Mac tools (no pwsh
// on the Mac: the PowerShell 5.1 parse is windows.yml's job; these catch the shapes the ledger names: X24, X25, X26,
// X27, X28, X29).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = p => readFileSync(join(ROOT, p), 'utf8');
const CLIENTS = ['relay/clients/igneum-agent.ps1', 'relay/clients/send.ps1', 'relay/clients/agent.sh', 'relay/clients/send.sh'];
const TOOLS = ['tools/relay.mjs', 'tools/console.mjs', 'tools/build-job.mjs'];
test('X24: every client and Mac tool sends x-relay-token as a header and never builds a tokened API path', () => {
for (const f of [...CLIENTS, ...TOOLS]) {
const s = read(f);
assert.match(s, /x-relay-token/, `${f} sends no x-relay-token header`);
assert.doesNotMatch(s, /\/r\/\$RelayToken\/api|\/r\/\$RELAY_TOKEN\/api|\/r\/\$\{token\b|\/r\/\$\{TOKEN\}\/(api|c)\//, `${f} still builds an API path with the token in it`);
}
// the one tokened path left is the phone's page, printed by `url`
assert.match(read('tools/relay.mjs'), /const WEB = `\$\{BASE\}\/r\/\$\{TOKEN\}`/);
assert.match(read('tools/relay.mjs'), /const API = `\$\{BASE\}\/api\/relay\?fn=`/);
});
test('X29: the shell clients hand curl its secret headers through a config file, never on the command line', () => {
for (const f of ['relay/clients/agent.sh', 'relay/clients/send.sh']) {
const s = read(f);
assert.match(s, /-K "\$HDR"/, `${f} does not use curl -K`);
assert.doesNotMatch(s, /curl[^\n]*-H "x-(igneum-key|relay-token|machine-secret):/, `${f} puts a secret header on the curl command line`);
}
});
test('X25: the agent arms the logon task only on the reboot paths and disarms on start and in finally', () => {
const s = read('relay/clients/igneum-agent.ps1');
const lines = s.split('\n');
const arms = lines.map((l, i) => [l, i]).filter(([l]) => /^\s*Arm-Restart\s*$/.test(l));
assert.equal(arms.length, 2, 'Arm-Restart is called exactly twice (the two reboot branches)');
for (const [, i] of arms) {
assert.ok(lines.slice(i, i + 4).some(l => /shutdown\.exe \/r/.test(l)), `Arm-Restart at line ${i + 1} is not followed by the restart`);
assert.ok(/^\s+/.test(lines[i]), 'Arm-Restart is never a top-level statement');
}
assert.match(s, /^Disarm-Restart$/m, 'the agent disarms at start');
assert.match(s, /finally \{[\s\S]*Disarm-Restart[\s\S]*\}/, 'the agent disarms in finally');
assert.match(s, /schtasks\.exe \/Delete \/F \/TN 'IgneumRelayAgent'/);
assert.match(s, /Remove-ItemProperty -Path \$k -Name 'IgneumRelayAgent'/);
});
test('X23: the agent checks the machine tag and the nonce before Start-Process, and refuses with exit 77', () => {
const s = read('relay/clients/igneum-agent.ps1');
const run = s.slice(s.indexOf('function Run-Task'), s.indexOf('Log ("igneum relay agent on'));
const check = run.indexOf('$why = Check-Task $task');
const start = run.indexOf('Start-Process powershell.exe');
assert.ok(check > 0 && start > check, 'Check-Task runs before Start-Process');
assert.match(run, /Post-Result \$task 77 \$log \("refused: " \+ \$why\)/);
assert.match(s, /HMACSHA256/);
assert.match(s, /igneum-relay-run\/1`nto=/);
const sh = read('relay/clients/agent.sh');
assert.ok(sh.indexOf('check_task "$it"') < sh.indexOf('bash "$STATE/tasks/task-$id.sh"'), 'agent.sh checks before it runs');
assert.match(sh, /hmac\.compare_digest/);
});
test('X28: the reboot marker must stand on its own line and the task must be queued with a reboot flag; GET inbox is never acked', () => {
const ps = read('relay/clients/igneum-agent.ps1');
assert.match(ps, /\(\?m\)\^RELAY-REBOOT\\r\?\$/);
assert.match(ps, /\$reboot = \$asked -and \$rebootAllowed/);
assert.match(ps, /Api-Post 'inbox' @\{ machine = \$script:Machine; kind = 'run'; ack = \$true \}/);
assert.doesNotMatch(ps, /inbox\?machine=[^\n]*ack=1/);
const sh = read('relay/clients/agent.sh');
assert.match(sh, /grep -qx 'RELAY-REBOOT' "\$logf" && \[ -n "\$rebootok" \]/);
assert.doesNotMatch(sh, /inbox\?machine=[^\n]*ack=1/);
for (const f of ['relay/clients/send.ps1', 'relay/clients/send.sh']) assert.doesNotMatch(read(f), /inbox\?machine=[^\n]*ack=1/, `${f} acks through a GET`);
});
test('X28: the registration carries no username and no folder', () => {
const ps = read('relay/clients/igneum-agent.ps1');
const info = ps.slice(ps.indexOf('function Collect-Info'), ps.indexOf('function Register-Machine'));
assert.doesNotMatch(info, /user = |dir = /);
const sh = read('relay/clients/agent.sh');
assert.doesNotMatch(sh, /"user":|"dir":/);
});
test('X26: no playbook carries __DL_BASE__ or prints the download URL; the agents hand the base over as RELAY_DL_BASE', () => {
for (const f of readdirSync(join(ROOT, 'relay/playbooks'))) {
const s = read(`relay/playbooks/${f}`);
assert.doesNotMatch(s, /__DL_BASE__/, `${f} still uses __DL_BASE__`);
assert.doesNotMatch(s, /Write-Host "downloading \$zipUrl"/, `${f} prints the tokened URL`);
}
assert.match(read('relay/clients/igneum-agent.ps1'), /\$env:RELAY_DL_BASE = '\$DlBase'/);
assert.match(read('relay/clients/agent.sh'), /export RELAY_DL_BASE=/);
const mjs = read('tools/relay.mjs');
assert.doesNotMatch(mjs, /replace\(\/__DL_BASE__\/g/, 'tools/relay.mjs still substitutes the dl base into bodies');
assert.match(mjs, /carries the dl token; it must never be in a task body/);
});
test('X27: results and registration carry the machine secret header; make-clients.sh bakes it per machine', () => {
for (const f of CLIENTS) assert.match(read(f), /x-machine-secret/, `${f} never presents the machine secret`);
const mk = read('relay/clients/make-clients.sh');
assert.match(mk, /--machine\) MACHINE=/);
assert.match(mk, /machine-secret\.txt/);
assert.match(mk, /__DL_BASE__#\$DL_BASE#g/);
});
test('X28: the WSL playbook grants sudo for apt-get and dpkg only, and no password travels on a command line', () => {
const w = read('relay/playbooks/wsl-setup.ps1');
assert.doesNotMatch(w, /NOPASSWD:ALL/);
assert.match(w, /NOPASSWD:SETENV: \/usr\/bin\/apt-get, \/usr\/bin\/dpkg/);
assert.doesNotMatch(read('relay/playbooks/prover-setup.ps1'), /echo igneum \| sudo -S/);
});
test('PowerShell shape: balanced braces and here-strings in the two .ps1 clients (the 5.1 parse runs in windows.yml)', () => {
for (const f of ['relay/clients/igneum-agent.ps1', 'relay/clients/send.ps1']) {
const s = read(f);
const open = (s.match(/\{/g) || []).length; const close = (s.match(/\}/g) || []).length;
assert.equal(open, close, `${f}: ${open} { against ${close} }`);
assert.equal((s.match(/@"\s*$/gm) || []).length, (s.match(/^"@\s*$/gm) || []).length, `${f}: here-string markers`);
assert.doesNotMatch(s, /\$[A-Za-z_]+:\s[a-z]/, `${f}: a "$name: text" drive-qualified reference (the 5.1 class of 4 October)`);
}
});

113
relay/test/guard.test.mjs Normal file
View file

@ -0,0 +1,113 @@
// node --test relay/test/guard.test.mjs (no dependencies, no network)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { authVia, runCanon, keygen, signRun, verifyRun, machineTag, sameTag, checkRun, wantsReboot, feedLimit, retentionCutoff, machineForSecret, secretHash, newNonce, newSecret, FEED_LIMIT_MAX, RETENTION_DAYS, POST_ALLOWED, DROP_KINDS, mayRead } from '../lib/guard.mjs';
const T = 'ABCDEFGHIJKLMNOPQRST'; // the token shape: 20 characters
const K = 'relaykeyrelaykeyrelaykeyrelaykeyrelaykeyrelayke'; // 48
const I = 'intakekeyintakekeyintakekeyinta'; // 32
const env = { RELAY_TOKEN: T, RELAY_KEY: K, LOG_INTAKE_KEY: I };
test('authVia: the header alone is the token tier (X24); the path token still works for the phone page', () => {
assert.equal(authVia({ headers: { 'x-relay-token': T } }, env), 'token');
assert.equal(authVia({ query: { token: T }, headers: {} }, env), 'token');
assert.equal(authVia({ headers: { 'x-relay-token': T.slice(0, 19) + 'x' } }, env), null);
assert.equal(authVia({ headers: {} }, env), null);
});
test('authVia: three tiers; the intake key is its own tier and RELAY_INTAKE_COMPAT=0 closes it (X23)', () => {
assert.equal(authVia({ headers: { 'x-igneum-key': K } }, env), 'key');
assert.equal(authVia({ headers: { 'x-igneum-key': I } }, env), 'intake');
assert.equal(authVia({ headers: { 'x-igneum-key': I } }, { ...env, LOG_INTAKE_KEY: '', LOG_INTAKE_KEY_NEXT: I }), 'intake');
assert.equal(authVia({ headers: { 'x-igneum-key': I } }, { ...env, RELAY_INTAKE_COMPAT: '0' }), null);
assert.equal(authVia({ headers: { 'x-igneum-key': 'wrongwrongwrongwrongwrongwrongwr' } }, env), null);
});
test('tiers: what each may post and read', () => {
assert.equal(POST_ALLOWED.token.has('task'), true);
assert.equal(POST_ALLOWED.key.has('task'), false);
assert.equal(POST_ALLOWED.key.has('secret'), false);
assert.deepEqual([...POST_ALLOWED.intake].sort(), ['drop', 'upload']);
assert.equal(DROP_KINDS.intake.has('result'), false);
assert.equal(DROP_KINDS.key.has('run'), false);
assert.equal(DROP_KINDS.token, null);
assert.deepEqual(['token', 'key', 'intake', null].map(mayRead), [true, true, false, false]);
});
test('runCanon: deterministic, names the machine, the nonce, the flags and the body hash', () => {
const a = runCanon({ to: 'PC1', nonce: 'a'.repeat(32), body: 'Write-Host hi', flags: { elevated: true } });
const b = runCanon({ to: 'PC1', nonce: 'a'.repeat(32), body: 'Write-Host hi', flags: { elevated: 1 } });
assert.equal(a, b);
assert.match(a, /^igneum-relay-run\/1\nto=PC1\nnonce=a{32}\nelevated=1\nreboot_continue=0\nreboot=0\nbody_sha256=[0-9a-f]{64}\n$/);
assert.notEqual(a, runCanon({ to: 'PC2', nonce: 'a'.repeat(32), body: 'Write-Host hi', flags: { elevated: true } }));
assert.notEqual(a, runCanon({ to: 'PC1', nonce: 'a'.repeat(32), body: 'Write-Host hi ', flags: { elevated: true } }));
assert.notEqual(a, runCanon({ to: 'PC1', nonce: 'a'.repeat(32), body: 'Write-Host hi', flags: {} }));
});
test('Ed25519: a good signature verifies; a changed byte, another key or a malformed signature does not', () => {
const { seed, pub } = keygen();
const other = keygen();
const canon = runCanon({ to: 'PC1', nonce: newNonce(), body: 'x' });
const sig = signRun(canon, seed);
assert.equal(sig.length, 128);
assert.equal(verifyRun(canon, sig, pub), true);
assert.equal(verifyRun(canon + ' ', sig, pub), false);
assert.equal(verifyRun(canon, sig, other.pub), false);
assert.equal(verifyRun(canon, sig.slice(0, 127) + (sig.endsWith('0') ? '1' : '0'), pub), false);
assert.equal(verifyRun(canon, 'nothex', pub), false);
assert.equal(verifyRun(canon, sig, 'nothex'), false);
});
test('machineTag: HMAC with the machine secret; sameTag compares in constant time and refuses malformed tags', () => {
const s = newSecret();
const canon = runCanon({ to: 'PC1', nonce: newNonce(), body: 'x' });
const t = machineTag(s, canon);
assert.equal(t.length, 64);
assert.equal(sameTag(t, machineTag(s, canon)), true);
assert.equal(sameTag(t, machineTag(newSecret(), canon)), false);
assert.equal(sameTag(t, machineTag(s, canon + 'x')), false);
assert.equal(sameTag(t, 'short'), false);
});
test('checkRun: a run without the signature, the tag or the nonce is refused; a complete one passes (X23)', () => {
const { seed, pub } = keygen();
const nonce = newNonce();
const body = 'Write-Host hi';
const flags = { elevated: true, nonce, mac: machineTag(newSecret(), runCanon({ to: 'PC1', nonce, body, flags: { elevated: true } })) };
flags.sig = signRun(runCanon({ to: 'PC1', nonce, body, flags }), seed);
assert.equal(checkRun({ to: 'PC1', body, flags }, pub), null);
assert.match(checkRun({ to: 'PC1', body, flags: {} }, pub), /nonce/);
assert.match(checkRun({ to: 'PC1', body, flags: { nonce } }, pub), /mac/);
assert.match(checkRun({ to: 'PC1', body, flags: { nonce, mac: flags.mac } }, pub), /sig/);
assert.match(checkRun({ to: 'PC1', body, flags }, ''), /RELAY_RUN_PUB/);
assert.match(checkRun({ to: 'PC1', body: body + ' ', flags }, pub), /does not verify/);
assert.match(checkRun({ to: 'PC2', body, flags }, pub), /does not verify/);
assert.match(checkRun({ to: 'PC1', body, flags: { ...flags, elevated: false } }, pub), /does not verify/);
assert.match(checkRun({ to: 'all', body, flags }, pub), /one named machine/);
});
test('wantsReboot: the marker on its own line only (X28)', () => {
assert.equal(wantsReboot('features enabled\nRELAY-REBOOT\n'), true);
assert.equal(wantsReboot('RELAY-REBOOT'), true);
assert.equal(wantsReboot('a\r\nRELAY-REBOOT\r\nb'), true);
assert.equal(wantsReboot('the script prints RELAY-REBOOT when it wants a restart\n'), false);
assert.equal(wantsReboot('RELAY-REBOOT-NOT\n'), false);
assert.equal(wantsReboot(''), false);
});
test('feedLimit and retention (X26)', () => {
assert.equal(feedLimit({}), 50);
assert.equal(feedLimit({ limit: '500' }), FEED_LIMIT_MAX);
assert.equal(feedLimit({ limit: '0' }), 50);
assert.equal(feedLimit({ limit: '7' }), 7);
assert.equal(RETENTION_DAYS, 30);
assert.equal(retentionCutoff(Date.UTC(2026, 9, 5, 22, 0, 0)), '2026-09-05T22:00:00.000Z');
});
test('machineForSecret: the stored sha256 names the machine; a wrong or malformed secret names nothing (X27)', () => {
const s = newSecret();
const rows = [{ name: 'PC1', secret_hash: secretHash(s) }, { name: 'PC2', secret_hash: secretHash(newSecret()) }, { name: 'Mac', secret_hash: null }];
assert.deepEqual(machineForSecret(s, rows), { name: 'PC1' });
assert.deepEqual(machineForSecret(newSecret(), rows), { error: 'unknown machine secret' });
assert.deepEqual(machineForSecret('short', rows), { error: 'x-machine-secret must be 64 hex' });
});

251
relay/test/handler.test.mjs Normal file
View file

@ -0,0 +1,251 @@
// node --test relay/test/handler.test.mjs (no dependencies, no database, no network: a fake Neon, fake blobs, a fake clock)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeHandler, expire, EXPIRE_EVERY_MS } from '../lib/handler.mjs';
import { authed } from '../lib/relay.mjs';
import { RateLimit } from '../lib/wake.mjs';
import { keygen, signRun, runCanon, machineTag, newNonce, newSecret, secretHash, RATE_PER_MIN, AUTH_FAIL_PER_MIN } from '../lib/guard.mjs';
const T = 'ABCDEFGHIJKLMNOPQRST';
const K = 'relaykeyrelaykeyrelaykeyrelaykeyrelaykeyrelayke';
const I = 'intakekeyintakekeyintakekeyinta';
const RUN = keygen();
const ENV = { RELAY_TOKEN: T, RELAY_KEY: K, LOG_INTAKE_KEY: I, RELAY_RUN_PUB: RUN.pub };
const BLOB = 'https://abc123.public.blob.vercel-storage.com/relay/aa/x.zip';
// The smallest Neon stand-in that answers every query lib/handler.mjs sends, matched on the query text.
function fakeDb({ nowIso = () => '2026-10-05T22:00:00.000Z' } = {}) {
const items = []; const machines = []; let seq = 0;
const flagsOf = r => (typeof r.flags === 'string' ? JSON.parse(r.flags) : r.flags || {});
const sql = async (query, params = []) => {
const Q = query.replace(/\s+/g, ' ').trim();
if (/^ALTER TABLE relay_machines/.test(Q)) return [];
if (/^SELECT name, secret_hash FROM relay_machines WHERE secret_hash IS NOT NULL/.test(Q)) return machines.filter(m => m.secret_hash).map(m => ({ name: m.name, secret_hash: m.secret_hash }));
if (/^SELECT secret_hash FROM relay_machines WHERE name = \$1/.test(Q)) return machines.filter(m => m.name === params[0]).map(m => ({ secret_hash: m.secret_hash || null }));
if (/^DELETE FROM relay_items WHERE ts < \$1 RETURNING/.test(Q)) { const gone = items.filter(i => i.ts < params[0]); for (const g of gone) items.splice(items.indexOf(g), 1); return gone.map(g => ({ id: g.id, file_url: g.file_url })); }
if (/^SELECT .* FROM relay_items WHERE NOT read AND NOT done AND kind = ANY\(\$2\)/.test(Q)) return items.filter(i => !i.read && !i.done && params[1].includes(i.kind) && (i.to_machine === params[0] || (i.to_machine === 'all' && i.kind === 'task'))).sort((a, b) => a.id - b.id).slice(0, 50);
if (/^SELECT .* FROM relay_items WHERE id = \$1/.test(Q)) return items.filter(i => i.id === params[0]);
if (/^SELECT file_name, file_url, file_b64 FROM relay_items WHERE id = \$1/.test(Q)) return items.filter(i => i.id === params[0]);
if (/^SELECT id FROM relay_items WHERE kind = 'run' AND flags->>'nonce' = \$1/.test(Q)) return items.filter(i => i.kind === 'run' && flagsOf(i).nonce === params[0]).map(i => ({ id: i.id }));
if (/^SELECT .* FROM relay_items .*ORDER BY id DESC LIMIT (\d+)/.test(Q)) { const lim = Number(Q.match(/LIMIT (\d+)/)[1]); return [...items].sort((a, b) => b.id - a.id).slice(0, lim); }
if (/^SELECT m\.name, m\.hostname/.test(Q)) return machines.map(m => ({ ...m, unread: items.filter(i => !i.read && ['task', 'run'].includes(i.kind) && (i.to_machine === m.name || (i.to_machine === 'all' && i.kind === 'task'))).length }));
if (/^SELECT name, hostname, role, named, info, last_seen, secret_hash FROM relay_machines ORDER BY name/.test(Q)) return machines.map(m => ({ ...m }));
if (/^SELECT name, role, named, secret_hash FROM relay_machines WHERE hostname = \$1/.test(Q)) return machines.filter(m => m.hostname === params[0]).map(m => ({ ...m }));
if (/^INSERT INTO relay_items/.test(Q)) {
const [from_machine, to_machine, kind, title, body, file_name, file_url, file_b64, size, flags, task_id] = params;
const row = { id: ++seq, ts: nowIso(), from_machine, to_machine, kind, title, body, file_name, file_url, file_b64, size, read: false, read_at: null, done: false, done_at: null, flags: JSON.parse(flags), task_id };
items.push(row); return [{ id: row.id, ts: row.ts }];
}
if (/^INSERT INTO relay_machines \(name, role, named, last_seen\)/.test(Q)) { const m = machines.find(x => x.name === params[0]); if (m) m.last_seen = nowIso(); else machines.push({ name: params[0], hostname: null, role: '', named: false, info: {}, last_seen: nowIso(), secret_hash: null }); return []; }
if (/^INSERT INTO relay_machines \(name, role, named, secret_hash\)/.test(Q)) { const m = machines.find(x => x.name === params[0]); if (m) { m.secret_hash = params[1]; m.named = true; } else machines.push({ name: params[0], hostname: null, role: '', named: true, info: {}, last_seen: null, secret_hash: params[1] }); return []; }
if (/^INSERT INTO relay_machines \(name, hostname, role, named, info, last_seen\)/.test(Q)) { let m = machines.find(x => x.name === params[0]); if (!m) { m = { name: params[0], hostname: params[0], role: params[1], named: false, info: JSON.parse(params[2]), last_seen: nowIso(), secret_hash: null }; machines.push(m); } else { m.hostname = params[0]; m.info = JSON.parse(params[2]); } return [{ name: m.name, role: m.role, named: m.named }]; }
if (/^UPDATE relay_machines SET hostname = \$2, last_seen = now\(\), info = \$3::jsonb WHERE name = \$1 RETURNING/.test(Q)) { const m = machines.find(x => x.name === params[0]); m.hostname = params[1]; m.info = JSON.parse(params[2]); m.last_seen = nowIso(); return [{ name: m.name, role: m.role, named: m.named }]; }
if (/^UPDATE relay_machines SET last_seen = now\(\), info = \$2::jsonb WHERE hostname = \$1/.test(Q)) { for (const m of machines) if (m.hostname === params[0]) { m.info = JSON.parse(params[1]); m.last_seen = nowIso(); } return []; }
if (/^UPDATE relay_items SET read = true, read_at = now\(\) WHERE id = ANY\(\$1\)/.test(Q)) { for (const i of items) if (params[0].includes(i.id)) { i.read = true; i.read_at = nowIso(); } return []; }
if (/^UPDATE relay_items SET done = true/.test(Q)) { const i = items.find(x => x.id === params[0]); if (i) { i.done = true; i.read = true; i.flags = { ...i.flags, ...JSON.parse(params[1]) }; } return []; }
if (/^DELETE FROM relay_items WHERE id = \$1 RETURNING file_url/.test(Q)) { const i = items.find(x => x.id === params[0]); if (!i) return []; items.splice(items.indexOf(i), 1); return [{ file_url: i.file_url }]; }
throw new Error('fake db: unexpected query ' + Q.slice(0, 120));
};
return { sql, items, machines, seed: (row) => { const r = { id: ++seq, ts: nowIso(), read: false, done: false, flags: {}, file_url: null, file_b64: null, ...row }; items.push(r); return r; } };
}
function fakeBlob() {
const deleted = [];
return { deleted, storeBuffer: async (name, buf) => ({ url: BLOB, size: buf.length }), clientUploadToken: async (name, size) => ({ token: 't', put_url: 'https://vercel.com/api/blob/?pathname=x', api_version: '11', max: 50 * 1024 * 1024, size }), deleteBlobs: async urls => { const l = urls.filter(Boolean); deleted.push(...l); return l.length; } };
}
function res() {
const r = { headers: {}, body: null, code: null };
r.setHeader = (k, v) => { r.headers[k] = v; return r; };
r.status = s => { r.code = s; return r; };
r.end = s => { r.body = s; };
return r;
}
const json = (r, status, obj) => { r.status(status); r.end(JSON.stringify(obj)); };
const reply = r => ({ code: r.code, ...(r.body ? JSON.parse(r.body) : {}) });
const req = (method, query = {}, { headers = {}, body, ip = '203.0.113.9' } = {}) => ({ method, query, headers: { 'x-forwarded-for': ip, 'content-type': 'application/json', ...headers }, body: body === undefined ? undefined : JSON.stringify(body) });
const TOKEN = { 'x-relay-token': T };
const KEY = { 'x-igneum-key': K };
const INTAKE = { 'x-igneum-key': I };
function rig(opts = {}) {
const db = fakeDb(); const blob = fakeBlob(); let t = Date.UTC(2026, 9, 5, 22, 0, 0);
const now = () => t;
const env = { ...ENV, ...(opts.env || {}) };
const state = {};
const h = makeHandler({ sql: db.sql, blob, authed: r => authed(r, env), json, env, now, state, limiter: opts.limiter, authLimiter: opts.authLimiter });
const call = async (...a) => { const r = res(); await h(req(...a), r); return reply(r); };
return { db, blob, call, state, tick: ms => { t += ms; } };
}
function signedRun(to, body, secret, flags = {}) {
const nonce = newNonce();
const f = { ...flags, nonce };
f.mac = machineTag(secret, runCanon({ to, nonce, body, flags: f }));
f.sig = signRun(runCanon({ to, nonce, body, flags: f }), RUN.seed);
return { to, title: 'job', body, kind: 'run', flags: f };
}
test('X24: the x-relay-token header with no token in the path is the token tier; no auth is 401', async () => {
const { call } = rig();
assert.equal((await call('GET', { fn: 'feed' }, { headers: TOKEN })).code, 200);
assert.equal((await call('GET', { fn: 'feed' }, { headers: { 'x-relay-token': 'wrong' } })).code, 401);
assert.equal((await call('GET', { fn: 'feed' })).code, 401);
});
test('X23: a token-only run task is refused with 401; a signed, tagged, fresh one is stored; a replayed nonce is 409', async () => {
const { call, db } = rig();
const secret = newSecret();
const bare = await call('POST', { fn: 'task' }, { headers: TOKEN, body: { to: 'PC1', title: 'x', body: 'Write-Host hi', kind: 'run', flags: { elevated: true } } });
assert.equal(bare.code, 401);
assert.match(bare.error, /nonce/);
const good = signedRun('PC1', 'Write-Host hi', secret, { elevated: true });
const r = await call('POST', { fn: 'task' }, { headers: TOKEN, body: good });
assert.equal(r.code, 200, r.error);
assert.equal(db.items[0].kind, 'run');
assert.equal(db.items[0].flags.sig, good.flags.sig);
const again = await call('POST', { fn: 'task' }, { headers: TOKEN, body: good });
assert.equal(again.code, 409);
// the path token (the phone page) is still the token tier, and still needs the signature
const viaPath = await call('POST', { fn: 'task', token: T }, { body: { to: 'PC1', title: 'x', body: 'y', kind: 'run' } });
assert.equal(viaPath.code, 401);
});
test('X23: a run signed by another key, or with a changed body, flag or target after signing, is refused', async () => {
const { call } = rig();
const secret = newSecret();
const good = signedRun('PC1', 'Write-Host hi', secret, { elevated: false });
assert.equal((await call('POST', { fn: 'task' }, { headers: TOKEN, body: { ...good, body: 'Write-Host bye' } })).code, 401);
assert.equal((await call('POST', { fn: 'task' }, { headers: TOKEN, body: { ...good, to: 'PC2' } })).code, 401);
assert.equal((await call('POST', { fn: 'task' }, { headers: TOKEN, body: { ...good, flags: { ...good.flags, elevated: true } } })).code, 401);
const other = keygen();
const forged = { ...good, flags: { ...good.flags, sig: signRun(runCanon({ to: 'PC1', nonce: good.flags.nonce, body: good.body, flags: good.flags }), other.seed) } };
assert.equal((await call('POST', { fn: 'task' }, { headers: TOKEN, body: forged })).code, 401);
});
test('X23: without RELAY_RUN_PUB on the project every run is refused', async () => {
const { call } = rig({ env: { RELAY_RUN_PUB: '' } });
const r = await call('POST', { fn: 'task' }, { headers: TOKEN, body: signedRun('PC1', 'x', newSecret()) });
assert.equal(r.code, 401);
assert.match(r.error, /RELAY_RUN_PUB/);
});
test('X23: the relay key may report and read but never queue, rename, re-role, delete or bind', async () => {
const { call } = rig();
assert.equal((await call('POST', { fn: 'task' }, { headers: KEY, body: { to: 'PC1', title: 'x', body: 'y', kind: 'task' } })).code, 403);
assert.equal((await call('POST', { fn: 'drop' }, { headers: KEY, body: { to: 'PC1', title: 'x', body: 'y', kind: 'run' } })).code, 403);
assert.equal((await call('POST', { fn: 'name' }, { headers: KEY, body: { hostname: 'h', name: 'PC9' } })).code, 403);
assert.equal((await call('POST', { fn: 'role' }, { headers: KEY, body: { name: 'PC1', role: 'miner' } })).code, 403);
assert.equal((await call('POST', { fn: 'delete' }, { headers: KEY, body: { id: 1 } })).code, 403);
assert.equal((await call('POST', { fn: 'secret' }, { headers: KEY, body: { name: 'PC1', secret_hash: 'a'.repeat(64) } })).code, 403);
assert.equal((await call('POST', { fn: 'drop' }, { headers: KEY, body: { from: 'PC1', title: 'note', body: 'hi', kind: 'text' } })).code, 200);
assert.equal((await call('GET', { fn: 'feed' }, { headers: KEY })).code, 200);
});
test('X23: the intake key (inside every package) may only upload and drop files or text; nothing else, no reads', async () => {
const { call } = rig();
assert.equal((await call('POST', { fn: 'upload' }, { headers: INTAKE, body: { name: 'a.zst', size: 10 } })).code, 200);
assert.equal((await call('POST', { fn: 'drop' }, { headers: INTAKE, body: { from: 'DESKTOP-1234', to: 'mac', kind: 'file', title: 'build-job 7 a.zst', file_name: 'a.zst', file_url: BLOB, size: 10 } })).code, 200);
assert.equal((await call('POST', { fn: 'drop' }, { headers: INTAKE, body: { from: 'PC1', kind: 'result', title: 'r', body: 'x' } })).code, 403);
assert.equal((await call('POST', { fn: 'drop' }, { headers: INTAKE, body: { to: 'PC1', kind: 'run', title: 'r', body: 'x' } })).code, 403);
assert.equal((await call('POST', { fn: 'task' }, { headers: INTAKE, body: { to: 'PC1', title: 'x', body: 'y' } })).code, 403);
assert.equal((await call('POST', { fn: 'register' }, { headers: INTAKE, body: { hostname: 'h' } })).code, 403);
assert.equal((await call('POST', { fn: 'ack' }, { headers: INTAKE, body: { ids: [1] } })).code, 403);
assert.equal((await call('GET', { fn: 'feed' }, { headers: INTAKE })).code, 403);
assert.equal((await call('GET', { fn: 'inbox', machine: 'PC1' }, { headers: INTAKE })).code, 403);
const closed = rig({ env: { RELAY_INTAKE_COMPAT: '0' } });
assert.equal((await closed.call('POST', { fn: 'upload' }, { headers: INTAKE, body: { name: 'a', size: 1 } })).code, 401);
});
test('X27: a result whose from does not match the machine secret is refused; a matching one is stored under that machine', async () => {
const { call, db } = rig();
const s1 = newSecret(); const s2 = newSecret();
assert.equal((await call('POST', { fn: 'secret' }, { headers: TOKEN, body: { name: 'PC1', secret_hash: secretHash(s1) } })).code, 200);
assert.equal((await call('POST', { fn: 'secret' }, { headers: TOKEN, body: { name: 'PC2', secret_hash: secretHash(s2) } })).code, 200);
const forged = await call('POST', { fn: 'drop' }, { headers: { ...KEY, 'x-machine-secret': s2 }, body: { from: 'PC1', kind: 'result', title: 'r', body: 'x', task_id: 3 } });
assert.equal(forged.code, 403);
assert.match(forged.error, /does not match/);
const unknown = await call('POST', { fn: 'drop' }, { headers: { ...KEY, 'x-machine-secret': newSecret() }, body: { from: 'PC1', kind: 'result', title: 'r', body: 'x' } });
assert.equal(unknown.code, 403);
const bare = await call('POST', { fn: 'drop' }, { headers: KEY, body: { from: 'PC1', kind: 'result', title: 'r', body: 'x' } });
assert.equal(bare.code, 403);
assert.match(bare.error, /need its machine secret/);
const good = await call('POST', { fn: 'drop' }, { headers: { ...KEY, 'x-machine-secret': s1 }, body: { from: 'PC1', kind: 'result', title: 'r', body: 'x' } });
assert.equal(good.code, 200);
assert.equal(db.items.at(-1).from_machine, 'PC1');
assert.equal(db.items.at(-1).flags.unbound, undefined);
// a machine with no secret yet (the compatibility window) is accepted and marked unbound
const unbound = await call('POST', { fn: 'drop' }, { headers: KEY, body: { from: 'Laptop', kind: 'result', title: 'r', body: 'x' } });
assert.equal(unbound.code, 200);
assert.equal(db.items.at(-1).flags.unbound, true);
});
test('X27: register with the secret names the machine whatever the hostname says; a bound hostname without it is refused', async () => {
const { call, db } = rig();
const s1 = newSecret();
await call('POST', { fn: 'secret' }, { headers: TOKEN, body: { name: 'PC1', secret_hash: secretHash(s1) } });
const r = await call('POST', { fn: 'register' }, { headers: { ...KEY, 'x-machine-secret': s1 }, body: { hostname: 'DESKTOP-KMCV30N', info: { user: 'someone', dir: 'C:\\secret', gpus: ['5090'] } } });
assert.deepEqual([r.code, r.name, r.bound], [200, 'PC1', true]);
assert.equal(db.machines.find(m => m.name === 'PC1').hostname, 'DESKTOP-KMCV30N');
assert.deepEqual(db.machines.find(m => m.name === 'PC1').info, { gpus: ['5090'] }); // X28: no username, no folder
const bare = await call('POST', { fn: 'register' }, { headers: KEY, body: { hostname: 'DESKTOP-KMCV30N', info: {} } });
assert.equal(bare.code, 403);
const fresh = await call('POST', { fn: 'register' }, { headers: KEY, body: { hostname: 'NEWBOX', info: { user: 'u', dir: 'd', os: 'w' } } });
assert.deepEqual([fresh.code, fresh.name, fresh.bound], [200, 'NEWBOX', false]);
assert.deepEqual(db.machines.find(m => m.name === 'NEWBOX').info, { os: 'w' });
const wrong = await call('POST', { fn: 'register' }, { headers: { ...KEY, 'x-machine-secret': newSecret() }, body: { hostname: 'NEWBOX', info: {} } });
assert.equal(wrong.code, 403);
});
test('X28: a GET inbox never acks, even with ack=1; POST inbox {ack:true} does', async () => {
const { call, db } = rig();
db.seed({ from_machine: 'Mac', to_machine: 'PC1', kind: 'task', title: 't', body: 'b' });
const peek = await call('GET', { fn: 'inbox', machine: 'PC1', ack: '1' }, { headers: KEY });
assert.deepEqual([peek.code, peek.items.length, peek.acked, db.items[0].read], [200, 1, 0, false]);
const got = await call('POST', { fn: 'inbox' }, { headers: KEY, body: { machine: 'PC1', kind: 'all', ack: true } });
assert.deepEqual([got.code, got.items.length, got.acked, db.items[0].read], [200, 1, 1, true]);
assert.equal((await call('POST', { fn: 'inbox' }, { headers: KEY, body: { machine: 'PC1', ack: true } })).items.length, 0);
});
test('X28: the rate limit answers 429 per IP, and failed authentications have their own, lower limit', async () => {
const { call } = rig({ limiter: new RateLimit({ perMinute: 3 }), authLimiter: new RateLimit({ perMinute: 2 }) });
assert.equal(RATE_PER_MIN, 120); assert.equal(AUTH_FAIL_PER_MIN, 10);
for (let i = 0; i < 3; i++) assert.equal((await call('GET', { fn: 'machines' }, { headers: TOKEN })).code, 200);
assert.equal((await call('GET', { fn: 'machines' }, { headers: TOKEN })).code, 429);
assert.equal((await call('GET', { fn: 'machines' }, { headers: TOKEN, ip: '198.51.100.2' })).code, 200);
assert.equal((await call('GET', { fn: 'machines' }, { ip: '198.51.100.3' })).code, 401);
assert.equal((await call('GET', { fn: 'machines' }, { ip: '198.51.100.3' })).code, 401);
assert.equal((await call('GET', { fn: 'machines' }, { ip: '198.51.100.3' })).code, 429);
});
test('X26: feed is capped at 100 a call; rows older than 30 days go with their blobs; delete takes the blob', async () => {
const { call, db, blob, tick, state } = rig();
for (let i = 0; i < 120; i++) db.seed({ from_machine: 'Mac', to_machine: 'all', kind: 'text', title: 't' + i, body: 'b' });
const f = await call('GET', { fn: 'feed', limit: '500' }, { headers: TOKEN });
assert.deepEqual([f.code, f.items.length, f.limit], [200, 100, 100]);
const old = db.seed({ ts: '2026-08-01T00:00:00.000Z', from_machine: 'PC1', to_machine: 'all', kind: 'file', title: 'old', body: '', file_url: BLOB + '?old' });
const recent = db.seed({ ts: '2026-10-01T00:00:00.000Z', from_machine: 'PC1', to_machine: 'all', kind: 'file', title: 'recent', body: '', file_url: BLOB + '?recent' });
await call('GET', { fn: 'feed' }, { headers: TOKEN }); // inside the 10-minute window: no second sweep yet
assert.equal(db.items.includes(old), true);
tick(EXPIRE_EVERY_MS + 1);
await call('GET', { fn: 'feed' }, { headers: TOKEN });
assert.equal(db.items.includes(old), false);
assert.equal(db.items.includes(recent), true);
assert.deepEqual(blob.deleted, [BLOB + '?old']);
assert.deepEqual(state.expired, { rows: 1, blobs: 1 });
const d = await call('POST', { fn: 'delete' }, { headers: TOKEN, body: { id: recent.id } });
assert.deepEqual([d.code, d.blobs], [200, 1]);
assert.deepEqual(blob.deleted, [BLOB + '?old', BLOB + '?recent']);
const direct = await expire(db.sql, blob, Date.UTC(2027, 0, 1));
assert.equal(direct.rows, 120);
});
test('done carries the machine the secret proves; a wrong secret is refused', async () => {
const { call, db } = rig();
const s1 = newSecret();
await call('POST', { fn: 'secret' }, { headers: TOKEN, body: { name: 'PC1', secret_hash: secretHash(s1) } });
const it = db.seed({ from_machine: 'Mac', to_machine: 'PC1', kind: 'run', title: 't', body: 'b' });
assert.equal((await call('POST', { fn: 'done' }, { headers: { ...KEY, 'x-machine-secret': newSecret() }, body: { id: it.id, exit_code: 0 } })).code, 403);
assert.equal((await call('POST', { fn: 'done' }, { headers: { ...KEY, 'x-machine-secret': s1 }, body: { id: it.id, exit_code: 0 } })).code, 200);
assert.deepEqual([it.done, it.flags.exit_code, it.flags.done_by], [true, 0, 'PC1']);
});

View file

@ -16,7 +16,7 @@
// (packaging/windows/push-inputs.sh and make-payload.sh read them there)
// igneum-app.exe -> app/igneum-app/target/x86_64-pc-windows-gnu/release/ (make-payload.sh's IGNEUM_APP_EXE default)
// igneumd, igneum-miner, igneum-app (Linux) -> infra/cross/out/ with version.txt (where infra/cross/build-linux.sh leaves them)
// Reads ~/.config/igneum/env (DATABASE_URL, the intake, as tools/jobs.mjs), relay-token, log-intake-key, dl-token.
// Reads ~/.config/igneum/env (DATABASE_URL, the intake, as tools/jobs.mjs), relay-token (or relay-key), dl-token.
// Needs zstd and python3 on the PATH. No dependencies.
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, chmodSync, statSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
@ -100,13 +100,14 @@ async function watch(id, quiet = false) {
// ---- the relay (file download by item id, feed search by title) ------------------------------------------------------
function relayApi() {
const token = cfg('relay-token'); const key = cfg('log-intake-key');
if (!token && !key) { console.error('no ~/.config/igneum/relay-token and no log-intake-key'); process.exit(1); }
const api = `${RELAY_BASE}/r/${token || '-'}/api/`;
const headers = key ? { 'x-igneum-key': key } : {};
// the token or the relay's own key, as headers (X24); the intake key reads nothing on the relay any more (X23)
const token = cfg('relay-token'); const key = cfg('relay-key');
if (!token && !key) { console.error('no ~/.config/igneum/relay-token and no relay-key'); process.exit(1); }
const api = `${RELAY_BASE}/api/relay?fn=`;
const headers = token ? { 'x-relay-token': token } : { 'x-igneum-key': key };
return {
async get(fn, q) { const r = await fetch(api + fn + (q ? '?' + new URLSearchParams(q) : ''), { headers }); const j = await r.json(); if (!r.ok || j.ok === false) throw new Error(j.error || `http ${r.status}`); return j; },
async download(id, to) { const r = await fetch(`${api}file?id=${id}`, { headers, redirect: 'follow' }); if (!r.ok) throw new Error(`download of relay item ${id}: http ${r.status}`); writeFileSync(to, Buffer.from(await r.arrayBuffer())); return statSync(to).size; },
async get(fn, q) { const r = await fetch(api + fn + (q ? '&' + new URLSearchParams(q) : ''), { headers }); const j = await r.json(); if (!r.ok || j.ok === false) throw new Error(j.error || `http ${r.status}`); return j; },
async download(id, to) { const r = await fetch(`${api}file&id=${id}`, { headers, redirect: 'follow' }); if (!r.ok) throw new Error(`download of relay item ${id}: http ${r.status}`); writeFileSync(to, Buffer.from(await r.arrayBuffer())); return statSync(to).size; },
};
}
const sha256 = p => createHash('sha256').update(readFileSync(p)).digest('hex');

35
tools/ci/commit-tz-check.sh Executable file
View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Every commit path the tooling owns runs git with TZ=UTC, so no commit carries the local offset (review round 4,
# ledger G14; docs/plans/history-rewrite.md step 7). The class check (standing rule, 5 October 2026): any script under
# tools/, packaging/, infra/ or .github/ that invokes `git commit` (shell) or `['commit'` (node) must set TZ=UTC in
# the same file, or this fails. It also prints how many commits on the current branch still carry a non-UTC offset
# (the history rewrite is the owner's date; the count is information, not a failure).
#
# bash tools/ci/commit-tz-check.sh [--self-test]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
check_tree() { # <root> -> 0 when clean; prints offenders
local root="$1" bad=0 f
while IFS= read -r f; do
if grep -Eq "git commit|git\\b.*\\['commit'|\\['commit',|\"commit\"," "$f" 2>/dev/null; then
if ! grep -Eq "TZ=UTC|TZ: 'UTC'|TZ: \"UTC\"" "$f"; then echo "commit without TZ=UTC: ${f#$root/}"; bad=1; fi
fi
done < <(find "$root/tools" "$root/packaging" "$root/infra" "$root/.github" -type f \( -name '*.sh' -o -name '*.mjs' -o -name '*.js' -o -name '*.yml' -o -name '*.yaml' -o -name '*.py' \) 2>/dev/null | grep -v '/node_modules/' | grep -v '/fixtures/')
return $bad
}
if [ "${1:-}" = "--self-test" ]; then
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
mkdir -p "$T/tools" "$T/packaging" "$T/infra" "$T/.github"
printf '#!/bin/sh\nexport TZ=UTC\ngit commit -m x\n' > "$T/tools/good.sh"
printf "const git = a => run('git', a, { env: { TZ: 'UTC' } }); git(['commit', '-m', 'x']);\n" > "$T/tools/good.mjs"
check_tree "$T" >/dev/null || { echo "self-test: the clean tree failed"; exit 1; }
printf '#!/bin/sh\ngit commit -m x\n' > "$T/packaging/bad.sh"
if check_tree "$T" >/dev/null; then echo "self-test: the bad tree passed"; exit 1; fi
echo "commit-tz-check self-test: fires on the bad case, passes the good one"
exit 0
fi
if check_tree "$ROOT"; then echo "commit-tz-check: every tooling commit path sets TZ=UTC"; else exit 1; fi
if git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
n="$(git -C "$ROOT" log --format='%ad %cd' --date=raw 2>/dev/null | grep -cvE '^\S+ \+0000 \S+ \+0000$' || true)"
echo "commits on this branch with a non-UTC offset (author or committer): $n (0 after the history rewrite, docs/plans/history-rewrite.md)"
fi

31
tools/ci/curl-header-check.sh Executable file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# No secret header on a curl command line (review round 4, ledger X29 and X24): a key passed as `-H "x-igneum-key: ..."`
# is readable by every local user in the process list for the length of the upload. Scripts pass secret headers through
# a config file (`curl -K <file>` with `header = "..."` lines) or a header file (`-H @file`). The class check (standing
# rule, 5 October 2026): any .sh, .bat, .cmd or .ps1 line that invokes curl with x-igneum-key, x-relay-token or
# x-machine-secret as a -H argument fails this.
#
# bash tools/ci/curl-header-check.sh [--self-test]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
PAT='curl[^|]*-H[[:space:]]+"?[^"]*x-(igneum-key|relay-token|machine-secret):'
check_tree() { # <root> -> 0 when clean; prints offenders
local root="$1" bad=0
while IFS= read -r hit; do echo "secret header on a curl command line: ${hit#$root/}"; bad=1; done \
< <(grep -rnE --include='*.sh' --include='*.bat' --include='*.cmd' --include='*.ps1' "$PAT" "$root" 2>/dev/null | grep -v '/node_modules/' | grep -v '/vendor/' | grep -v '/fixtures/' | grep -v 'tools/ci/curl-header-check.sh' | grep -vE '^[^:]+:[0-9]+:[[:space:]]*(printf|echo) ' || true) # fixture writers in the other checks
return $bad
}
if [ "${1:-}" = "--self-test" ]; then
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
mkdir -p "$T/a"
printf 'curl -sS -K "$cfg" -X POST "$url" -H "Content-Type: application/json" --data-binary @body\n' > "$T/a/good.sh"
check_tree "$T" >/dev/null || { echo "self-test: the clean tree failed"; exit 1; }
printf 'curl -sS -X POST "$url" -H "x-igneum-key: $KEY" --data-binary @body\n' > "$T/a/bad.sh"
if check_tree "$T" >/dev/null; then echo "self-test: the bad tree passed"; exit 1; fi
rm "$T/a/bad.sh"
printf 'curl.exe -sS -X POST "%%URL%%" -H "x-igneum-key: %%KEY%%" --data-binary "@%%OUT%%"\n' > "$T/a/bad.bat"
if check_tree "$T" >/dev/null; then echo "self-test: the bad .bat passed"; exit 1; fi
echo "curl-header-check self-test: fires on the bad cases, passes the good one"
exit 0
fi
if check_tree "$ROOT"; then echo "curl-header-check: no secret header on any curl command line"; else exit 1; fi

View file

@ -10,7 +10,7 @@
// node tools/console.mjs sync-hetzner push the newest infra/cloud-devnet/results/ summary
// node tools/console.mjs sync all three syncs
// node tools/console.mjs url the console URL
// Reads ~/.config/igneum/relay-token (the URL secret), relay-key (x-igneum-key), relay-url (optional),
// Reads ~/.config/igneum/relay-token (sent as the x-relay-token header, never in a URL: X24), relay-url (optional),
// dl-token and dlsite-dir (sync-dl). Zero dependencies.
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
@ -20,11 +20,12 @@ import { fileURLToPath } from 'node:url';
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const cfg = n => { try { return readFileSync(join(homedir(), '.config', 'igneum', n), 'utf8').trim(); } catch { return ''; } };
const TOKEN = cfg('relay-token'); const KEY = cfg('relay-key'); // the relay's own key since 4 Oct 2026 (round 4, X23)
const TOKEN = cfg('relay-token');
const BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(/\/$/, '');
if (!TOKEN) { console.error('no ~/.config/igneum/relay-token'); process.exit(1); }
const API = `${BASE}/r/${TOKEN}/c/`;
const WEB = `${BASE}/r/${TOKEN}/`;
const API = `${BASE}/api/console?fn=`; // the function itself; the token travels in the header
const WEB = `${BASE}/r/${TOKEN}/`; // the phone's page: the one place the token stays in the path
const HEADERS = { 'x-relay-token': TOKEN };
const argv = process.argv.slice(2);
const flags = {}; const pos = [];
@ -36,8 +37,8 @@ for (let i = 0; i < argv.length; i++) {
const cmd = pos[0] || 'log';
async function api(fn, { q, body } = {}) {
const r = await fetch(API + fn + (q ? '?' + new URLSearchParams(q) : ''), body === undefined ? { headers: { 'x-igneum-key': KEY } }
: { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-igneum-key': KEY }, body: JSON.stringify(body) });
const r = await fetch(API + fn + (q ? '&' + new URLSearchParams(q) : ''), body === undefined ? { headers: HEADERS }
: { method: 'POST', headers: { 'Content-Type': 'application/json', ...HEADERS }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({ ok: false, error: `http ${r.status}` }));
if (!r.ok || j.ok === false) throw new Error(j.error || `http ${r.status}`);
return j;

View file

@ -1,43 +1,52 @@
#!/usr/bin/env node
// Mac side of the Igneum relay (relay/ in this repo, https://relay.igneum.network).
// node tools/relay.mjs the feed, newest first (last 50)
// node tools/relay.mjs list [N] [--machine X] more of the feed
// node tools/relay.mjs list [N] [--machine X] more of the feed (100 a call at most)
// node tools/relay.mjs read <id> print an item; its file is downloaded to --out (default $TMPDIR/igneum-relay)
// node tools/relay.mjs drop "<text>" | <file> post a note or a file from the Mac [--to PC1] [--title "..."] [--body "..." with a file]
// node tools/relay.mjs task <machine> "title" [file] [--body "..."] a task for a person or a Claude session on that PC
// node tools/relay.mjs run <machine> "title" <script.ps1|.sh> [--elevated] [--reboot-continue] a script the igneum-agent runs
// node tools/relay.mjs run <machine> "title" <script.ps1|.sh> [--elevated] [--reboot-continue] [--reboot]
// a script the igneum-agent runs: signed with ~/.config/igneum/relay-run-key
// (Ed25519, checked by the relay) and tagged with the machine's secret
// (~/.config/igneum/relay-machines/<machine>, checked by the agent); X23
// node tools/relay.mjs keygen make the run key pair once; prints the public key for RELAY_RUN_PUB
// node tools/relay.mjs secret <machine> make that machine's secret, bind it on the relay, then make-clients.sh --machine
// node tools/relay.mjs watch [--since <id>] poll every 10 s and print new items (results included)
// node tools/relay.mjs inbox <machine> [--ack] what that machine has not read yet
// node tools/relay.mjs inbox <machine> [--ack] what that machine has not read yet (--ack marks it read, a POST)
// node tools/relay.mjs machines | role <name> <miner|prover|bench|mac|phone> | name <hostname> <name>
// node tools/relay.mjs ack <id> | done <id> | rm <id> | url
// Reads ~/.config/igneum/relay-token (the URL secret), relay-key (x-igneum-key), dl-token (for __DL_BASE__ in
// playbooks) and relay-url (optional, default https://relay.igneum.network). Zero dependencies.
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
// Reads ~/.config/igneum/relay-token (sent as the x-relay-token header, never in a URL: X24), relay-run-key,
// relay-machines/<name>, dl-token (only to refuse a body that carries it: X26) and relay-url (optional, default
// https://relay.igneum.network). Zero dependencies.
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, chmodSync } from 'node:fs';
import { homedir, tmpdir, hostname } from 'node:os';
import { basename, join, resolve } from 'node:path';
import { basename, join, resolve, dirname } from 'node:path';
import { keygen as edKeygen, signRun, runCanon, machineTag, newNonce, newSecret, secretHash } from '../relay/lib/guard.mjs';
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
const cfg = n => { try { return readFileSync(join(homedir(), '.config', 'igneum', n), 'utf8').trim(); } catch { return ''; } };
const TOKEN = cfg('relay-token'); const KEY = cfg('relay-key'); const DL = cfg('dl-token'); // relay-key is the relay's own key since 4 Oct 2026 (round 4, X23); the intake key no longer opens the relay
const CFG = join(homedir(), '.config', 'igneum');
const cfg = n => { try { return readFileSync(join(CFG, n), 'utf8').trim(); } catch { return ''; } };
const TOKEN = cfg('relay-token'); const DL = cfg('dl-token');
const BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(/\/$/, '');
if (!TOKEN) { console.error('no ~/.config/igneum/relay-token'); process.exit(1); }
const API = `${BASE}/r/${TOKEN}/api/`;
const WEB = `${BASE}/r/${TOKEN}`;
const API = `${BASE}/api/relay?fn=`; // the function itself; the token travels in the header
const WEB = `${BASE}/r/${TOKEN}`; // the phone's page: the one place the token stays in the path
const SHOWN = `${BASE}/r/<token>`; // printed in place of WEB everywhere but `url` (round 4, X24: the token in every terminal)
const HEADERS = { 'x-relay-token': TOKEN };
const argv = process.argv.slice(2);
const flags = {}; const pos = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--') && !['elevated', 'reboot-continue', 'ack', 'all'].includes(k)) { flags[k] = next; i++; } else flags[k] = true; }
if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--') && !['elevated', 'reboot-continue', 'reboot', 'ack', 'all', 'rotate'].includes(k)) { flags[k] = next; i++; } else flags[k] = true; }
else pos.push(a);
}
const cmd = pos[0] && !/^\d+$/.test(pos[0]) ? pos[0] : (pos[0] ? 'read' : 'list');
if (cmd === 'read' && /^\d+$/.test(pos[0])) pos.unshift('read');
async function api(fn, { q, body } = {}) {
const r = await fetch(API + fn + (q ? '?' + new URLSearchParams(q) : ''), body === undefined ? { headers: { 'x-igneum-key': KEY } }
: { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-igneum-key': KEY }, body: JSON.stringify(body) });
const r = await fetch(API + fn + (q ? '&' + new URLSearchParams(q) : ''), body === undefined ? { headers: HEADERS }
: { method: 'POST', headers: { 'Content-Type': 'application/json', ...HEADERS }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({ ok: false, error: `http ${r.status}` }));
if (!r.ok || j.ok === false) throw new Error(j.error || `http ${r.status}`);
return j;
@ -68,17 +77,34 @@ function printItem(it) {
}
const outDir = () => { const d = resolve(flags.out || process.env.RELAY_DOWNLOAD_DIR || join(tmpdir(), 'igneum-relay')); mkdirSync(d, { recursive: true }); return d; };
async function download(it) {
const r = await fetch(`${API}file?id=${it.id}`, { headers: { 'x-igneum-key': KEY }, redirect: 'follow' });
const r = await fetch(`${API}file&id=${it.id}`, { headers: HEADERS, redirect: 'follow' });
if (!r.ok) throw new Error(`download http ${r.status}`);
const buf = Buffer.from(await r.arrayBuffer());
const p = join(outDir(), `${it.id}-${it.file_name || 'file'}`);
writeFileSync(p, buf); return p;
}
// X26: the body is posted as written. The downloads base reaches the script as $env:RELAY_DL_BASE (RELAY_DL_BASE in
// bash), a value the agent holds; a body that still says __DL_BASE__ or carries the dl token is refused here.
function playbook(path) {
let s = readFileSync(path, 'utf8');
s = s.replace(/__DL_BASE__/g, DL ? `https://dl.igneum.network/dl/${DL}` : 'https://dl.igneum.network/dl/MISSING-DL-TOKEN');
const s = readFileSync(path, 'utf8');
if (/__DL_BASE__/.test(s)) throw new Error(`${path} uses __DL_BASE__; write $env:RELAY_DL_BASE (PowerShell) or $RELAY_DL_BASE (bash) instead, the agent fills it in`);
if (DL && s.includes(DL)) throw new Error(`${path} carries the dl token; it must never be in a task body`);
return s;
}
const RUN_KEY = join(CFG, 'relay-run-key');
const machineSecretFile = m => join(CFG, 'relay-machines', m);
// a run task: nonce, the machine's HMAC tag, the Ed25519 signature (relay/lib/guard.mjs, the same code the relay runs)
function signRunTask(o) {
const seed = cfg('relay-run-key');
if (!/^[0-9a-f]{64}$/.test(seed)) throw new Error(`no run key at ${RUN_KEY}: node tools/relay.mjs keygen (then set RELAY_RUN_PUB on the relay project)`);
let secret = '';
try { secret = readFileSync(machineSecretFile(o.to), 'utf8').trim(); } catch {}
if (!/^[0-9a-f]{64}$/.test(secret)) throw new Error(`no machine secret for ${o.to}: node tools/relay.mjs secret ${o.to} first, then relay/clients/make-clients.sh --machine ${o.to}`);
o.flags.nonce = newNonce();
o.flags.mac = machineTag(secret, runCanon({ to: o.to, nonce: o.flags.nonce, body: o.body, flags: o.flags }));
o.flags.sig = signRun(runCanon({ to: o.to, nonce: o.flags.nonce, body: o.body, flags: o.flags }), seed);
return o;
}
try {
if (cmd === 'url') { console.log(WEB); }
@ -106,13 +132,32 @@ try {
const o = { from: flags.from || 'Mac', to, title, kind: cmd, body: flags.body || '', flags: {} };
if (cmd === 'run') {
if (!file || !existsSync(file)) throw new Error('run needs a script file (.ps1 for Windows, .sh for the Mac)');
o.body = playbook(file); o.flags = { elevated: !!flags.elevated, reboot_continue: !!flags['reboot-continue'], shell: file.endsWith('.sh') ? 'bash' : 'powershell', script: basename(file) };
o.body = playbook(file); o.flags = { elevated: !!flags.elevated, reboot_continue: !!flags['reboot-continue'], reboot: !!flags.reboot || !!flags['reboot-continue'], shell: file.endsWith('.sh') ? 'bash' : 'powershell', script: basename(file) };
signRunTask(o);
} else if (file) { if (!existsSync(file)) throw new Error(`no such file ${file}`); Object.assign(o, await uploadFile(file)); }
const r = await api('task', { body: o }); console.log(`queued #${r.id} ${cmd} for ${to}: ${title}`);
}
else if (cmd === 'keygen') {
if (existsSync(RUN_KEY)) throw new Error(`${RUN_KEY} exists; not overwriting a signing key`);
const { seed, pub } = edKeygen();
mkdirSync(CFG, { recursive: true });
writeFileSync(RUN_KEY, seed + '\n', { mode: 0o600 }); chmodSync(RUN_KEY, 0o600);
writeFileSync(RUN_KEY + '.pub', pub + '\n', { mode: 0o644 });
console.log(`run key written: ${RUN_KEY} (0600) and ${RUN_KEY}.pub\npublic key ${pub}\nnext: set RELAY_RUN_PUB to that value on the Vercel project igneum-relay (relay/README.md, "Rotation"); until then every run task is refused`);
}
else if (cmd === 'secret') {
const m = pos[1]; if (!m || !/^[\w.-]{1,80}$/.test(m)) throw new Error('secret <machine>');
const f = machineSecretFile(m);
if (existsSync(f) && !flags.rotate) throw new Error(`${f} exists; pass --rotate to replace it (the old zip on that PC stops being accepted)`);
const secret = newSecret();
mkdirSync(dirname(f), { recursive: true, mode: 0o700 });
writeFileSync(f, secret + '\n', { mode: 0o600 }); chmodSync(f, 0o600);
await api('secret', { body: { name: m, secret_hash: secretHash(secret) } });
console.log(`${m}: secret written to ${f} (0600) and its sha256 bound on the relay\nnext: relay/clients/make-clients.sh --machine ${m}, carry the zip to ${m} by hand (never through the downloads host), start igneum-agent.bat there`);
}
else if (cmd === 'inbox') {
const machine = pos[1]; if (!machine) throw new Error('inbox <machine>');
const j = await api('inbox', { q: { machine, ...(flags.ack ? { ack: 1 } : {}) } });
const j = flags.ack ? await api('inbox', { body: { machine, kind: 'all', ack: true } }) : await api('inbox', { q: { machine } });
if (!j.items.length) console.log(`nothing waiting for ${machine}`); for (const it of j.items) printItem(it);
}
else if (cmd === 'machines') { for (const m of (await api('machines')).machines) console.log(`${m.name.padEnd(10)} ${(m.role || '-').padEnd(8)} ${(m.hostname || '-').padEnd(18)} ${m.named === false ? 'UNNAMED ' : ''}${m.last_seen ? 'seen ' + when(m.last_seen) : 'never seen'}${m.info && m.info.gpus ? ' gpu ' + [].concat(m.info.gpus).join(', ') : ''}${m.info && m.info.wsl ? ' wsl ' + m.info.wsl : ''}`); }

View file

@ -145,7 +145,8 @@ function runSync(cmd, args, { cwd = ROOT, env = {} } = {}) {
const r = spawnSync(cmd, args, { cwd, env: { ...process.env, PATH: `${homedir()}/.cargo/bin:/opt/homebrew/bin:${process.env.PATH}`, ...env }, encoding: 'utf8' });
return { code: r.status ?? 1, out: ((r.stdout || '') + (r.stderr || '')).trim() };
}
const git = (args, cwd = ROOT) => runSync('git', args, { cwd });
// TZ=UTC: every commit this tool makes carries +0000, never the local offset (review round 4, ledger G14)
const git = (args, cwd = ROOT) => runSync('git', args, { cwd, env: { TZ: 'UTC' } });
// git status --porcelain: the paths only ("XY path"; the first line's leading space does not survive a trim)
const gitStatusPaths = (args, cwd = ROOT) => git(['status', '--porcelain', ...args], cwd).out.split('\n').filter(Boolean).map(l => l.replace(/^\s*\S+\s+/, ''));
const has = cmd => runSync('sh', ['-c', `command -v ${cmd}`]).code === 0;