Merge mf11-master 847211c7 into master (gate: green on 847211c7, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge)

This commit is contained in:
igneum-labs 2026-10-07 14:49:42 +00:00
commit 38fd2c4cf8
32 changed files with 2000 additions and 384 deletions

View file

@ -0,0 +1,16 @@
# Relay deploy, 7 October 2026 (the X23 tree, MF-11)
| What | Value |
|---|---|
| Deployed | 2026-10-07 15:38 BST, from branch update-return bd9b4f4e, relay/ |
| Production deployment | https://igneum-relay-iabqarnby-igneum.vercel.app (inspect KHyscUSVKueXueqxvZBRKkaUHwJR), aliased https://relay.igneum.network |
| Previous production | https://igneum-relay-bgy767z40-igneum.vercel.app |
| Env added | RELAY_RUN_PUB (the public half of ~/.config/igneum/relay-run-key, made by `node tools/relay.mjs keygen` the same hour); RELAY_INTAKE_COMPAT unset |
| Read-backs | /wake answers; fn=machines carries the bound field; the console page answers 200 and c/machines carries poll fields; a v1-shaped register by hostname (key tier) answers named:false bound:false; a signed start-app from the Mac answers 200 (item 394); an unsigned run from master's old tool is refused |
## Rollback
`cd relay && npx --yes vercel@latest --global-config ~/.config/igneum/vercel --scope igneum rollback https://igneum-relay-bgy767z40-igneum.vercel.app`
(or `vercel promote https://igneum-relay-bgy767z40-igneum.vercel.app`). Seconds; nothing to undo in the database: the X23 code adds relay_machines.secret_hash and the
table relay_wake_seen, both ignored by the old code. What a rollback loses: signed run verification (the old relay never
had it), the start-app kind, the ping; PC 2's new agent registers by hostname on either.

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,49 +53,62 @@ 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.
## The job-channel ping (MF-11, 7 October 2026)
## The agent as a logon task, start-app, and the job-channel ping (MF-11, 7 October 2026)
PC 2 lost power at 10:46Z on 7 October 2026 and nothing said so until a person read the intake. From 0.3.21 every app wake request carries `machine=<id8>&v=<version>&job=<last job id>`; `/wake` records one row per machine in `relay_wake_seen` before it holds (`relay/lib/wake.mjs`: `recordSeen`, `seenList`, `pingState`, `PING_SILENT_S` = 900 s); the console's Machines tab reads it as "job channel polled N ago, last job X" and, after 15 minutes without a poll, "job channel silent since <time>, last job X" in red, whatever the uploads say (`api/console.mjs` `poll`, `ui.html`). No secret travels: id8 is what the console already shows. Test: `node --test relay/test/wake.test.mjs`.
PC 2 lost power at 10:46Z on 7 October 2026 (Kernel-Power 41, no bugcheck) and sat dark until a hand pressed the button at 13:37Z; its relay agent had been dead since 6 October (the one-shot logon task of X25 exited at every boot behind a UAC prompt), so nothing could reach it. Three things close the class:
| Piece | What | Where |
|---|---|---|
| the agent as a logon task | `install-agent.ps1` (elevated, once per PC) registers `IgneumRelayService` from `IgneumRelayService.xml`: at this user's logon, highest run level, interactive, restarted on failure every minute up to 999 times, no time limit, one instance; its action is the hidden loop `igneum-agent-service.ps1`, which restarts `igneum-agent.ps1` 15 s after any exit and logs to `%LOCALAPPDATA%\igneum-relay\logs\agent-service.log`. The agent's own X25 disarm removes only the one-shot `IgneumRelayAgent`; the service task is never touched. `install-agent.ps1 -Remove` takes it off | `relay/clients/`, in the zip from `make-clients.sh --machine <name>` |
| `start-app` | `node tools/relay.mjs start-app PC2`: a task kind signed and tagged exactly like `run` (the relay checks `sig`, `mac` and the nonce; the token tier only), delivered in the agent's `run` inbox. An agent from v3 executes nothing from its body: its own `Start-App` starts the installed Igneum Miner as the user (an elevated agent goes through a one-off `/RL LIMITED` task, so the app never runs elevated), waits up to 90 s for an engine to answer `api/state`, and posts a result with the version and the seconds. The body is `relay/playbooks/start-app.ps1`, the same thing for an agent from before the kind. It never sends quit, pause or resume | `relay/lib/relay.mjs` (`KINDS`, `AGENT_KINDS`), `lib/handler.mjs`, `clients/igneum-agent.ps1`, `tools/relay.mjs` |
| the ping | every app wake request (0.3.21) carries `machine=<id8>&v=<version>&job=<last job id>`; `/wake` records one row per machine in `relay_wake_seen` before it holds; the console's Machines tab reads it as "job channel polled N ago, last job X" and, after 15 minutes without a poll, "job channel silent since <time>, last job X" in red, whatever the uploads say | `relay/lib/wake.mjs` (`recordSeen`, `seenList`, `pingState`, `PING_SILENT_S`), `api/console.mjs`, `ui.html` |
Tests: `node --test relay/test/service.test.mjs relay/test/handler.test.mjs relay/test/wake.test.mjs`.
## Deploy
```
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,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

@ -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

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-16"?>
<!-- The relay agent's logon task (MF-11, 7 October 2026). install-agent.ps1 fills __USER__ (DOMAIN\user) and
__AGENT_DIR__ (the unzipped client folder) and registers it with schtasks /Create /XML. Runs at that user's logon
in the interactive session (so start-app can open the app window), at the run level the installer chooses (LeastPrivilege
by default: no administrator and no UAC prompt to register or run; -Highest for a PC where elevated tasks must not prompt),
restarted on failure every minute up to 999 times, no time limit, one instance at a time, never stopped for a
battery, started late if the logon was missed. -->
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>Igneum relay agent: polls the relay for signed tasks and can start Igneum Miner; survives the app and a reboot.</Description>
<URI>\IgneumRelayService</URI>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<UserId>__USER__</UserId>
<Delay>PT20S</Delay>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>__USER__</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>__RUNLEVEL__</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>false</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
<UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
<RestartOnFailure>
<Interval>PT1M</Interval>
<Count>999</Count>
</RestartOnFailure>
</Settings>
<Actions Context="Author">
<Exec>
<Command>powershell.exe</Command>
<Arguments>-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "__AGENT_DIR__\igneum-agent-service.ps1"</Arguments>
<WorkingDirectory>__AGENT_DIR__</WorkingDirectory>
</Exec>
</Actions>
</Task>

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

@ -0,0 +1,25 @@
# The relay agent as a logon task (IgneumRelayService, registered by install-agent.ps1 from IgneumRelayService.xml).
# This loop keeps igneum-agent.ps1 running with no window: the Task Scheduler restarts this loop on failure (PT1M, 999
# times), and the loop restarts the agent 15 s after any exit, so the agent outlives the app, a crash of its own, and a
# reboot (the task fires at logon; the PC signs in by itself). MF-11, 7 October 2026: PC 2's agent had been dead since
# 6 October and its one-shot logon task exited at every boot, so neither a signed job nor a relay task could start the app.
# Output goes to %LOCALAPPDATA%\igneum-relay\logs\agent-service.log (5 MB, one rotation); the idle line is left out.
$ErrorActionPreference = 'Continue'
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
$Agent = Join-Path $Here 'igneum-agent.ps1'
$LogDir = Join-Path $env:LOCALAPPDATA 'igneum-relay\logs'
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
$log = Join-Path $LogDir 'agent-service.log'
function Note([string]$t) { Add-Content -Path $log -Value ((Get-Date -Format s) + ' service: ' + $t) }
while ($true) {
if ((Test-Path $log) -and ((Get-Item $log).Length -gt 5MB)) { Move-Item -Force -Path $log -Destination ($log + '.1') }
if (-not (Test-Path $Agent)) { Note ('no agent at ' + $Agent + '; waiting'); Start-Sleep -Seconds 60; continue }
Note 'starting igneum-agent.ps1'
try {
# one Add-Content per line, so the file is never held open: a reader (a job's Get-Content -Tail) is refused while a
# pipeline holds it (PC 2, 7 October 2026, 15:38 local: "being used by another process")
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $Agent 2>&1 | ForEach-Object { "$_" } | Where-Object { $_ -notmatch 'idle as ' } | ForEach-Object { Add-Content -Path $log -Value $_ }
Note ('agent exited with code ' + $LASTEXITCODE + '; again in 15 s')
} catch { Note ('agent failed to start: ' + $_.Exception.Message + '; again in 15 s') }
Start-Sleep -Seconds 15
}

View file

@ -1,33 +1,53 @@
# 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.
# start-app (MF-11, 7 October 2026): a task of that kind, signed and tagged like a run, starts the installed Igneum Miner
# as the signed-in user (never elevated: an elevated agent goes through a one-off limited-level task) and reports whether
# an engine answered api/state; nothing from its body is executed. The agent itself runs as the logon task
# IgneumRelayService (install-agent.ps1, restart on failure), which outlives the app and survives a reboot.
# 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 +58,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 v3' }
try { $info.os = (Get-CimInstance Win32_OperatingSystem).Caption + ' build ' + (Get-CimInstance Win32_OperatingSystem).BuildNumber } catch {}
try {
$nv = Get-Command nvidia-smi -ErrorAction SilentlyContinue
@ -56,21 +77,33 @@ function Collect-Info {
return $info
}
function Machine-Hostname {
# the hostname this PC registers under: COMPUTERNAME plus the app's per-install id (the first 8 hex of
# %LOCALAPPDATA%\igneum\app\machine-id, the id the console and the jobs channel use), so two PCs with one Windows
# hostname (both report DESKTOP-KMCV30N) are two machines on the relay; the bare hostname when the app is not installed
$f = Join-Path $env:LOCALAPPDATA 'igneum\app\machine-id'
if (Test-Path $f) {
try { $id = (Get-Content $f -Raw).Trim().ToLower(); if ($id -match '^[0-9a-f]{16}$') { return ($env:COMPUTERNAME + '-' + $id.Substring(0, 8)) } } catch { }
}
return $env:COMPUTERNAME
}
function Register-Machine {
$info = Collect-Info
$r = Api-Post 'register' @{ hostname = $env:COMPUTERNAME; info = $info }
$r = Api-Post 'register' @{ hostname = (Machine-Hostname); 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)
if (-not $r.named) { Log "this PC is not named yet. On the Mac: node tools/relay.mjs name $env:COMPUTERNAME PC2" }
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 " + (Machine-Hostname) + " 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 +111,99 @@ 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 Find-App {
foreach ($d in @((Join-Path $env:LOCALAPPDATA 'Programs\Igneum Miner'), (Join-Path $env:ProgramFiles 'Igneum Miner'))) {
if (Test-Path (Join-Path $d 'igneum-app.exe')) { return $d }
}
return ''
}
function App-Version {
# the installed app's own answer (api/state .version through its URL file), '' when nothing answers
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
if (-not (Test-Path $urlFile)) { return '' }
try { $u = (Get-Content $urlFile -Raw).Trim(); $st = Invoke-RestMethod -Uri ($u + 'api/state') -TimeoutSec 5 -UseBasicParsing; return [string]$st.version } catch { return '' }
}
function Start-App {
# MF-11: start the installed Igneum Miner and read back that an engine answers. Never elevated: an elevated agent starts it
# through a one-off scheduled task at the limited run level (the app's own rule: it runs as the user). Never a quit,
# pause or resume of the installed app (the standing rule of 5 October 2026).
$dir = Find-App
if (-not $dir) { return @{ code = 2; note = 'no installed Igneum Miner on this PC'; text = 'start-app: igneum-app.exe not found under Programs or Program Files' } }
$already = App-Version
if ($already) { return @{ code = 0; note = ('app ' + $already + ' was already up'); text = ('start-app: an engine already answers api/state (app ' + $already + '); nothing started') } }
$exe = Join-Path $dir 'igneum-app.exe'
$t0 = Get-Date
if (Is-Admin) {
& schtasks.exe /Create /F /TN 'IgneumStartApp' /SC ONCE /ST 00:00 /RL LIMITED /TR ('"' + $exe + '" --launch') 2>&1 | Out-Null
& schtasks.exe /Run /TN 'IgneumStartApp' 2>&1 | Out-Null
Start-Sleep -Seconds 3
& schtasks.exe /Delete /F /TN 'IgneumStartApp' 2>&1 | Out-Null
$how = 'through a one-off limited-level task (this agent is elevated)'
} else {
# console: igneum-app.exe is a windows-subsystem program (no console); the window host it opens is its own
Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $dir | Out-Null
$how = 'directly as this user'
}
$deadline = (Get-Date).AddSeconds(90)
$answered = ''
while ((Get-Date) -lt $deadline) {
$answered = App-Version
if ($answered) { break }
Start-Sleep -Seconds 3
}
$s = [int]((Get-Date) - $t0).TotalSeconds
if ($answered) { return @{ code = 0; note = ('app ' + $answered + ' answered in ' + $s + ' s'); text = ('start-app: started ' + $how + '; app ' + $answered + ' answered api/state after ' + $s + ' s') } }
return @{ code = 3; note = 'no engine answered in 90 s'; text = ('start-app: started ' + $how + '; no engine answered api/state inside 90 s') }
}
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 +216,34 @@ 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
if ("$($task.kind)" -eq 'start-app') {
# nothing of the body runs: the agent's own Start-App is the whole task
$r = Start-App
Log ("task #" + $id + " " + $r.text)
Add-Content -Path $log -Value $r.text
Post-Result $task ([int]$r.code) $log $r.note
try { Api-Post 'done' @{ id = $id; exit_code = [int]$r.code } | Out-Null } catch { Log ("done failed: " + $_.Exception.Message) }
return
}
$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 +264,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

@ -0,0 +1,60 @@
# Registers the relay agent as the logon task IgneumRelayService (the manifest IgneumRelayService.xml beside this file:
# runs at this user's logon in the interactive session, hidden, restarted on failure every minute up to 999 times, no
# time limit, one instance), by the agent's full installed path, then starts it. Per user and with no administrator by
# default (LeastPrivilege: a standard user may register a task for themselves, so a signed job can run this on a PC with
# nobody at the keyboard); -Highest registers it elevated, which needs an administrator shell once. Re-running replaces
# the task. Every IgneumRelayAgent* task (the one-shot reboot arms of X25) and the RunOnce key go first: on PC 2 a stale
# one pointed at a path that was not there and popped "Windows cannot find 'igneum-agent'" at every boot (7 October 2026).
# A failure here is a line on stdout and an exit code, never a dialog.
# powershell -ExecutionPolicy Bypass -File install-agent.ps1 [-Highest] [-Remove]
param([switch]$Highest, [switch]$Remove)
$ErrorActionPreference = 'Continue'
$Here = Split-Path -Parent $MyInvocation.MyCommand.Path
$TaskName = 'IgneumRelayService'
function Stale-Tasks {
# every task whose name starts with IgneumRelayAgent, from the CSV listing (the one-shot arms, and any hand-made copy)
$out = @()
try {
$rows = & schtasks.exe /Query /FO CSV /NH 2>$null | ForEach-Object { "$_" }
foreach ($r in $rows) { $n = ($r -split '","')[0].Trim('"'); if ($n -like '\IgneumRelayAgent*') { $out += $n } }
} catch { }
return $out
}
foreach ($t in (Stale-Tasks)) {
& schtasks.exe /End /TN $t 2>&1 | Out-Null
& schtasks.exe /Delete /F /TN $t 2>&1 | Out-Null
Write-Host ('removed the stale task ' + $t)
}
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; Write-Host 'removed the RunOnce key IgneumRelayAgent' }
} catch { }
if ($Remove) {
& schtasks.exe /End /TN $TaskName 2>&1 | Out-Null
& schtasks.exe /Delete /F /TN $TaskName 2>&1 | Out-Null
Write-Host ('removed the ' + $TaskName + ' task (an agent window started by hand is not touched)')
exit 0
}
foreach ($f in @('IgneumRelayService.xml', 'igneum-agent-service.ps1', 'igneum-agent.ps1', 'machine-secret.txt')) {
if (-not (Test-Path (Join-Path $Here $f))) { Write-Host ('missing ' + $f + ' beside this script (unzip the whole client zip from make-clients.sh --machine <name>)'); exit 2 }
}
$level = 'LeastPrivilege'
if ($Highest) { $level = 'HighestAvailable' }
$user = $env:USERDOMAIN + '\' + $env:USERNAME
$xml = Get-Content (Join-Path $Here 'IgneumRelayService.xml') -Raw
$xml = $xml.Replace('__USER__', $user).Replace('__AGENT_DIR__', $Here).Replace('__RUNLEVEL__', $level)
$tmp = Join-Path $env:TEMP ('IgneumRelayService-' + [guid]::NewGuid().ToString('n') + '.xml')
[IO.File]::WriteAllText($tmp, $xml, (New-Object Text.UnicodeEncoding $false, $true))
$code = 1
try {
$out = & schtasks.exe /Create /F /TN $TaskName /XML $tmp 2>&1 | ForEach-Object { "$_" }
$code = $LASTEXITCODE
if ($code -ne 0) { Write-Host ('schtasks /Create failed (' + $code + '): ' + ($out -join ' ')); exit 3 }
Write-Host ('registered ' + $TaskName + ' for ' + $user + ' at ' + $level + ' from ' + $Here)
} finally { Remove-Item -Path $tmp -Force -ErrorAction SilentlyContinue }
& schtasks.exe /Run /TN $TaskName 2>&1 | ForEach-Object { "$_" } | Write-Host
Start-Sleep -Seconds 3
& schtasks.exe /Query /TN $TaskName /V /FO LIST 2>&1 | ForEach-Object { "$_" } | Where-Object { $_ -match '^(TaskName|Status|Logon Mode|Last Run Time|Last Result|Task To Run|Run As User|Schedule Type):' } | Write-Host
Write-Host 'the agent now runs under the task; close any igneum-agent.bat window (the task copy exits while that one holds the mutex, and comes back 15 s after it closes)'
Write-Host ('its log: ' + (Join-Path $env:LOCALAPPDATA 'igneum-relay\logs\agent-service.log'))
exit 0

View file

@ -1,25 +1,52 @@
#!/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"
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"
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 igneum-agent-service.ps1 install-agent.ps1 IgneumRelayService.xml 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" -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"
# the two optional phrases are built first: an apostrophe inside ${MACHINE:+...} in a heredoc is "no closing }" to bash 3.2
FOR="${MACHINE:+ for $MACHINE}"
OWN=""; [ -n "$MACHINE" ] && OWN=" and this machine's own secret (machine-secret.txt)"
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-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.
Igneum relay clients$FOR. Unzip anywhere. send.bat "<text>" | send.bat <file> | send.bat inbox | send.bat result "<text>" | send.bat get <id>
install-agent.ps1 (right-click, Run with PowerShell; no administrator needed): registers the agent as the per-user logon task IgneumRelayService, restarted on failure, so it outlives the app and comes back after a reboot. igneum-agent.bat: the hand-started form, double-click once, leave open. CLAUDE-PC.md: paste into the Claude Code session on this PC.
These files contain the relay secret$OWN. 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 ;;

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' };
}

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

@ -0,0 +1,297 @@
// 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'|'start-app',flags:{elevated,reboot_continue,reboot,nonce,sig,mac},from}
// start-app (MF-11): the same checks as run; the agent starts the installed app instead of running the body
// 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, AGENT_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','start-app') 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 = AGENT_KINDS.includes(o.kind) ? o.kind : '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 (AGENT_KINDS.includes(kind)) {
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 IN ('run', 'start-app') 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' });
// an agent asking for `run` gets start-app too: both are its kinds, and an agent before the kind runs its body as a script
const kind = src.kind === 'run' ? AGENT_KINDS : src.kind === 'task' ? ['task'] : ['task', ...AGENT_KINDS];
// 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,16 +1,20 @@
// 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
export const MAX_BODY = 1024 * 1024; // text body per item
export const KINDS = new Set(['text', 'file', 'task', 'result', 'run']);
// start-app (MF-11, 7 October 2026): a signed, tagged task like `run`, but the agent executes nothing from its body: it
// starts the installed Igneum Miner (hidden console, as the user) and reports whether an engine answered. The body
// carries relay/playbooks/start-app.ps1 so an agent from before this kind runs the same thing as a `run`.
export const KINDS = new Set(['text', 'file', 'task', 'result', 'run', 'start-app']);
/** The two kinds the agent executes: signed by the run key, tagged with the machine secret, one nonce each. */
export const AGENT_KINDS = ['run', 'start-app'];
export const ROLES = new Set(['miner', 'prover', 'bench', 'mac', 'phone', '']);
export function neon() {
@ -31,19 +35,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 +69,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

@ -0,0 +1,123 @@
# Installs (or replaces) the relay agent on this PC as the per-user logon task IgneumRelayService, through the signed
# jobs channel with nobody at the keyboard (MF-11, PC 2, 7 October 2026). The client zip (the relay URL, key and token,
# this machine's secret) never travels through the downloads host: the Mac encrypts it (AES-256-CBC, PKCS7) and posts it
# as a relay file item; this job fetches that item with the relay values already baked into the client on this PC, checks
# its sha256, decrypts it with the key this job carries, and unzips it under the user's profile. The jobs file and the
# relay feed are two different secrets away from each other, so neither alone holds the zip. Then: every IgneumRelayAgent*
# task and the stale client folders go, install-agent.ps1 registers the task (LeastPrivilege, no UAC), starts it, and this
# job reads back the relay's machine list and the app's state. It never quits, pauses or resumes the installed app.
# Published with the four placeholders filled: __URL__ (the ciphertext's unlisted blob URL on the relay store), __SHA__
# (its sha256), __NONCE__ and __IV__ (hex). The key is sha256(<machine-id>:<nonce>); nothing of it is printed.
$ErrorActionPreference = 'Continue'
function Say([string]$m) { Write-Host ((Get-Date -Format 'HH:mm:ss') + ' ' + $m) }
function Hex-Bytes([string]$h) { $b = New-Object byte[] ($h.Length / 2); for ($i = 0; $i -lt $b.Length; $i++) { $b[$i] = [Convert]::ToByte($h.Substring($i * 2, 2), 16) }; return ,$b }
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$dest = Join-Path $env:LOCALAPPDATA 'igneum-relay\agent'
$stateDir = Join-Path $env:LOCALAPPDATA 'igneum-relay'
# 1. the key: this PC's own per-install id (%LOCALAPPDATA%\igneum\app\machine-id, 16 hex, random at the first run of the
# app, never in a package and never on the downloads host) hashed with the nonce this job carries. The jobs file alone
# (one dl token away) holds the nonce, the IV and the unlisted blob URL, not the key.
$idFile = Join-Path $env:LOCALAPPDATA 'igneum\app\machine-id'
if (-not (Test-Path $idFile)) { Say 'no machine-id on this PC; nothing installed'; exit 2 }
$mid = (Get-Content $idFile -Raw).Trim().ToLower()
if ($mid -notmatch '^[0-9a-f]{16}$') { Say 'machine-id is not 16 hex; nothing installed'; exit 2 }
$sha256 = [Security.Cryptography.SHA256]::Create()
$keyBytes = $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($mid + ':' + '__NONCE__'))
$sha256.Dispose()
# 2. the encrypted zip from its unlisted blob URL, sha256 checked, decrypted, unzipped under the profile
$enc = Join-Path $env:TEMP 'igneum-relay-client.zip.enc'
$zip = Join-Path $env:TEMP 'igneum-relay-client.zip'
$unz = Join-Path $env:TEMP 'igneum-relay-client-unzip'
try {
Invoke-WebRequest -Uri '__URL__' -OutFile $enc -UseBasicParsing -MaximumRedirection 5 -TimeoutSec 120
} catch { Say ('could not fetch the client zip: ' + $_.Exception.Message); exit 3 }
$have = (Get-FileHash -Path $enc -Algorithm SHA256).Hash.ToLower()
if ($have -ne '__SHA__') { Say ('the fetched zip has sha256 ' + $have + ', not the one this job carries; nothing installed'); Remove-Item $enc -Force -ErrorAction SilentlyContinue; exit 4 }
Say ('encrypted zip fetched, ' + (Get-Item $enc).Length + ' bytes, sha256 verified')
try {
$aes = [Security.Cryptography.Aes]::Create()
$aes.Mode = [Security.Cryptography.CipherMode]::CBC
$aes.Padding = [Security.Cryptography.PaddingMode]::PKCS7
$aes.Key = $keyBytes
$aes.IV = Hex-Bytes '__IV__'
$bytes = [IO.File]::ReadAllBytes($enc)
$plain = $aes.CreateDecryptor().TransformFinalBlock($bytes, 0, $bytes.Length)
[IO.File]::WriteAllBytes($zip, $plain)
$aes.Dispose()
} catch { Say ('decrypt failed (the key is this machine-id with the nonce; another PC cannot open it): ' + $_.Exception.Message); Remove-Item $enc -Force -ErrorAction SilentlyContinue; exit 5 }
Remove-Item $enc -Force -ErrorAction SilentlyContinue
if (Test-Path $unz) { Remove-Item $unz -Recurse -Force -ErrorAction SilentlyContinue }
try { Expand-Archive -Path $zip -DestinationPath $unz -Force } catch { Say ('unzip failed: ' + $_.Exception.Message); Remove-Item $zip -Force -ErrorAction SilentlyContinue; exit 6 }
Remove-Item $zip -Force -ErrorAction SilentlyContinue
$src = Get-ChildItem -Path $unz -Recurse -Filter 'install-agent.ps1' -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $src) { Say 'the zip carries no install-agent.ps1; nothing installed'; exit 7 }
New-Item -ItemType Directory -Force -Path $dest | Out-Null
& robocopy.exe $src.DirectoryName $dest /MIR /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
if ($LASTEXITCODE -ge 8) { Say ('robocopy into ' + $dest + ' failed (' + $LASTEXITCODE + ')'); exit 8 }
Remove-Item $unz -Recurse -Force -ErrorAction SilentlyContinue
Say ('client installed under ' + $dest + ': ' + ((Get-ChildItem $dest | ForEach-Object { $_.Name }) -join ', '))
# the relay values for the read-back below come from the installed client; never printed
$t = Get-Content (Join-Path $dest 'igneum-agent.ps1') -Raw
$RelayUrl = [regex]::Match($t, '\$RelayUrl = ''([^'']+)''').Groups[1].Value
$RelayKey = [regex]::Match($t, '\$RelayKey = ''([^'']+)''').Groups[1].Value
$RelayToken = [regex]::Match($t, '\$RelayToken = ''([^'']+)''').Groups[1].Value
$Headers = @{ 'x-relay-token' = $RelayToken; 'x-igneum-key' = $RelayKey }
# earlier client folders on this PC (another machine's secret, or an old copy): found for the clean-up below
$clients = @()
$roots = @((Join-Path $env:USERPROFILE 'Downloads'), (Join-Path $env:USERPROFILE 'Desktop'))
try {
$tr = & schtasks.exe /Query /TN IgneumRelayAgent /V /FO LIST 2>$null | ForEach-Object { "$_" } | Where-Object { $_ -match '^Task To Run:' }
if ($tr) { $m = [regex]::Match("$tr", '([A-Za-z]:\\[^"]*?igneum-agent\.bat)'); if ($m.Success) { $roots = @((Split-Path -Parent $m.Groups[1].Value)) + $roots; Say ('the stale task points at ' + $m.Groups[1].Value) } }
} catch { }
foreach ($root in $roots) {
if ($root -and (Test-Path $root)) { $clients += @(Get-ChildItem -Path $root -Recurse -Depth 4 -Filter 'igneum-agent.ps1' -ErrorAction SilentlyContinue) }
}
# 3. the stale one-shot tasks (install-agent.ps1 removes IgneumRelayAgent* itself) and the stale client folders that
# carried another machine's secret; the state folder's machine.txt goes so the new registration names this PC
foreach ($c in $clients) {
$dir = $c.DirectoryName
if ($dir -like ($dest + '*')) { continue }
try { Remove-Item -Path $dir -Recurse -Force -ErrorAction Stop; Say ('removed the stale client folder ' + $dir) } catch { Say ('could not remove ' + $dir + ': ' + $_.Exception.Message) }
}
Get-ChildItem -Path (Join-Path $env:USERPROFILE 'Downloads') -Filter 'igneum-relay-clients*.zip' -ErrorAction SilentlyContinue | ForEach-Object { Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue; Say ('removed ' + $_.Name) }
Remove-Item (Join-Path $stateDir 'machine.txt') -Force -ErrorAction SilentlyContinue
# 4. register and start the task (per user, LeastPrivilege, no prompt; a failure is a line here, never a dialog)
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $dest 'install-agent.ps1') 2>&1 | ForEach-Object { "$_" } | ForEach-Object { Say ('install-agent: ' + $_) }
Say ('install-agent exit ' + $LASTEXITCODE)
& schtasks.exe /Query /FO CSV /NH 2>$null | ForEach-Object { "$_" } | Where-Object { $_ -match 'IgneumRelay' } | ForEach-Object { Say ('task: ' + $_) }
# 5. the relay's machine list: this PC must be seen inside a minute
$seen = ''
$deadline = (Get-Date).AddSeconds(75)
while ((Get-Date) -lt $deadline) {
try {
$m = Invoke-RestMethod -Uri ($RelayUrl + '/api/relay?fn=machines') -Headers $Headers -TimeoutSec 30
$mine = @($m.machines | Where-Object { $_.hostname -and ($_.hostname -like ($env:COMPUTERNAME + '*')) })
$fresh = @($mine | Where-Object { $_.last_seen -and (([DateTime]$_.last_seen).ToUniversalTime() -gt (Get-Date).ToUniversalTime().AddSeconds(-90)) })
if ($fresh.Count -gt 0) { $seen = ($fresh | ForEach-Object { 'RESULT machine ' + $_.name + ' hostname ' + $_.hostname + ' role ' + $_.role + ' last_seen ' + $_.last_seen }) -join "`n"; break }
} catch { Say ('machines: ' + $_.Exception.Message) }
Start-Sleep -Seconds 5
}
if ($seen) { Write-Host $seen } else { Say 'RESULT machine: this PC was not seen on the relay inside 75 s (read the agent-service.log lines below)' }
$slog = Join-Path $stateDir 'logs\agent-service.log'
if (Test-Path $slog) { Say '--- agent-service.log (last 25)'; Get-Content $slog -Tail 25 }
# 6. the app's state (api/state read only) and what its window is
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
if (Test-Path $urlFile) {
try {
$st = Invoke-RestMethod -Uri ((Get-Content $urlFile -Raw).Trim() + 'api/state') -TimeoutSec 5 -UseBasicParsing
Say ('RESULT app version ' + $st.version + ' phase ' + $st.phase + ' mining ' + $st.mining.state + ' node ' + $st.node.state + ' blocks ' + $st.node.blocks + ' peers ' + $st.node.peers + ' up ' + $st.uptime_s + ' s')
} catch { Say ('app state: ' + $_.Exception.Message) }
}
foreach ($p in @(Get-Process -Name 'Igneum Miner', 'igneum-app', 'igneumd' -ErrorAction SilentlyContinue)) { Say ('RESULT process ' + $p.ProcessName + ' pid ' + $p.Id + ' started ' + $p.StartTime.ToString('o') + ' window "' + $p.MainWindowTitle + '"') }
try {
$wv = Get-ItemProperty -Path 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' -ErrorAction SilentlyContinue
if ($wv) { Say ('RESULT webview2 runtime ' + $wv.pv) } else { Say 'RESULT webview2 runtime: not found in the registry (the dashboard would open in the browser and the window would stay dark)' }
} catch { }
Say 'agent install done'
exit 0

View file

@ -0,0 +1,30 @@
# Was this boot a power loss or a hard reset? (MF-11, 7 October 2026.) Reads the current boot's time, the Kernel-Power 41
# and EventLog 6008 entries since it, the last clean-shutdown marks (1074, 6006) before it, and the video controllers
# (an eGPU just fitted shows here). Read-only; nothing is sent to the installed app. Every finding is a RESULT line.
$ErrorActionPreference = 'Continue'
function Say([string]$m) { Write-Host ((Get-Date -Format 'HH:mm:ss') + ' ' + $m) }
$os = Get-CimInstance Win32_OperatingSystem
$boot = $os.LastBootUpTime
$bootUtc = $boot.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.000Z')
$beforeUtc = $boot.AddHours(-6).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.000Z')
Say ('RESULT boot ' + $boot.ToUniversalTime().ToString('o') + ' UTC (local ' + $boot.ToString('o') + ')')
function Q([string]$xpath, [int]$count) {
try { & wevtutil.exe qe System ('/q:' + $xpath) /f:text ('/c:' + $count) /rd:true 2>&1 | ForEach-Object { "$_" } } catch { @('wevtutil failed: ' + $_.Exception.Message) }
}
$lines = Q ("*[System[(EventID=41 or EventID=6008) and TimeCreated[@SystemTime>='" + $bootUtc + "']]]") 4
$ids = @($lines | Where-Object { $_ -match '^\s*Event ID: (\d+)' } | ForEach-Object { $Matches[1] })
$dates = @($lines | Where-Object { $_ -match '^\s*Date: (\S+)' } | ForEach-Object { $Matches[1] })
if ($ids.Count -gt 0) { Say ('RESULT unexpected-shutdown YES: event ' + ($ids -join ', ') + ' at ' + ($dates -join ', ') + ' (this boot followed a power loss, a hard reset or a hang; no clean shutdown was recorded)') }
else { Say 'RESULT unexpected-shutdown NO: neither 41 nor 6008 since this boot (a clean shutdown or restart preceded it)' }
$clean = Q ("*[System[(EventID=1074 or EventID=6006 or EventID=13) and TimeCreated[@SystemTime>='" + $beforeUtc + "'] and TimeCreated[@SystemTime<='" + $bootUtc + "']]]") 6
$cids = @($clean | Where-Object { $_ -match '^\s*Event ID: (\d+)' } | ForEach-Object { $Matches[1] })
$cdates = @($clean | Where-Object { $_ -match '^\s*Date: (\S+)' } | ForEach-Object { $Matches[1] })
if ($cids.Count -gt 0) { Say ('RESULT clean-shutdown-before-boot: event ' + ($cids -join ', ') + ' at ' + ($cdates -join ', ')) } else { Say 'RESULT clean-shutdown-before-boot: none in the six hours before this boot' }
$who = Q ("*[System[Provider[@Name='User32'] and (EventID=1074) and TimeCreated[@SystemTime>='" + $beforeUtc + "']]]") 2
$who | Where-Object { $_ -match 'Description|process|reason|user' } | Select-Object -First 6 | ForEach-Object { Say (' 1074: ' + $_.Trim()) }
$bug = Q ("*[System[(EventID=1001) and Provider[@Name='Microsoft-Windows-WER-SystemErrorReporting'] and TimeCreated[@SystemTime>='" + $beforeUtc + "']]]") 2
if (@($bug | Where-Object { $_ -match 'Event ID: 1001' }).Count -gt 0) { Say 'RESULT bugcheck 1001 recorded since six hours before this boot' } else { Say 'RESULT bugcheck: none' }
try { Get-CimInstance Win32_VideoController | ForEach-Object { Say ('RESULT video ' + $_.Name + ' driver ' + $_.DriverVersion + ' status ' + $_.Status + ' pnp ' + $_.PNPDeviceID) } } catch { }
try { & nvidia-smi --query-gpu=index,name,pci.bus_id,driver_version --format=csv,noheader 2>&1 | ForEach-Object { Say ('RESULT nvidia ' + "$_") } } catch { }
Say ('RESULT uptime ' + [int]((Get-Date) - $boot).TotalSeconds + ' s')
exit 0

View file

@ -0,0 +1,36 @@
# A freeze, not a power drop: what shape? (PC 2, 7 October 2026, 15:10 UK, the first boot with an RTX 5060 Ti in a
# Thunderbolt enclosure.) Reads, since 14:00Z: the display driver's TDR events (Display 4101, nvlddmkm), WHEA 17/18/19/20,
# Thunderbolt and PCI Express link events (the Thunderbolt providers, Kernel-PnP, pci: "link down"), the clean/unclean
# boot marks (41, 6008, 1074, 6006), the installed app's last lines before the freeze (the second-newest app log), and the
# GPUs with their bus ids. Read-only; nothing is sent to the installed app. Every finding is a RESULT line.
$ErrorActionPreference = 'Continue'
function Say([string]$m) { Write-Host ((Get-Date -Format 'HH:mm:ss') + ' ' + $m) }
$since = '2026-10-07T14:00:00.000Z'
$time = "TimeCreated[@SystemTime>='" + $since + "']"
function Q([string]$log, [string]$xpath, [int]$count) {
try { & wevtutil.exe qe $log ('/q:' + $xpath) /f:text ('/c:' + $count) 2>&1 | ForEach-Object { "$_" } } catch { @('wevtutil failed: ' + $_.Exception.Message) }
}
function Count([string[]]$lines) { return @($lines | Where-Object { $_ -match '^\s*Event ID: ' }).Count }
function Show([string]$label, [string[]]$lines, [int]$max) {
$n = Count $lines
Say ('RESULT ' + $label + ': ' + $n + ' event(s)')
$shown = 0
foreach ($l in $lines) { if ($l -match '^\s*(Date|Event ID|Source|Level|Description):' -or ($l -match '^\s{4}\S' -and $l.Length -lt 220)) { Say (' ' + $l.Trim()); $shown++ }; if ($shown -ge $max) { break } }
}
$os = Get-CimInstance Win32_OperatingSystem
Say ('RESULT boot ' + $os.LastBootUpTime.ToUniversalTime().ToString('o') + ' UTC, uptime ' + [int]((Get-Date) - $os.LastBootUpTime).TotalSeconds + ' s')
Show 'boot marks 41/6008/1074/6006 since 14:00Z' (Q 'System' ("*[System[" + $time + " and (EventID=41 or EventID=6008 or EventID=1074 or EventID=6006)]]") 8) 40
Show 'display TDR (Display 4101, nvlddmkm, Kernel-Power 505/506)' (Q 'System' ("*[System[" + $time + " and (Provider[@Name='Display'] or Provider[@Name='nvlddmkm'] or Provider[@Name='amdkmdag'] or EventID=4101)]]") 20) 60
Show 'WHEA 17/18/19/20/47' (Q 'System' ("*[System[" + $time + " and Provider[@Name='Microsoft-Windows-WHEA-Logger']]]") 20) 60
Show 'Thunderbolt and PCIe link (Thunderbolt providers, Kernel-PnP, pci, PCIe)' (Q 'System' ("*[System[" + $time + " and (Provider[@Name='Microsoft-Windows-Thunderbolt'] or Provider[@Name='ThunderboltService'] or Provider[@Name='Microsoft-Windows-Kernel-PnP'] or Provider[@Name='pci'] or Provider[@Name='PCI'] or Provider[@Name='Microsoft-Windows-Kernel-Pnp'] or Provider[@Name='Microsoft-Windows-Kernel-Power'])]]") 60) 120
Show 'every critical and error in System since 14:00Z' (Q 'System' ("*[System[" + $time + " and (Level=1 or Level=2)]]") 40) 120
Show 'Application errors since 14:00Z (Application Error, WER, Igneum)' (Q 'Application' ("*[System[" + $time + " and (Level=1 or Level=2)]]") 20) 60
Say 'RESULT GPUs:'
try { Get-CimInstance Win32_VideoController | ForEach-Object { Say (' video ' + $_.Name + ' driver ' + $_.DriverVersion + ' status ' + $_.Status + ' pnp ' + $_.PNPDeviceID) } } catch { }
try { & nvidia-smi --query-gpu=index,name,pci.bus_id,driver_version,pstate,temperature.gpu --format=csv,noheader 2>&1 | ForEach-Object { Say (' nvidia ' + "$_") } } catch { }
try { Get-PnpDevice -PresentOnly -ErrorAction SilentlyContinue | Where-Object { $_.FriendlyName -match 'Thunderbolt|Razer|Core X|PCI Express Root' } | ForEach-Object { Say (' pnp ' + $_.Status + ' ' + $_.Class + ' ' + $_.FriendlyName + ' ' + $_.InstanceId) } } catch { }
$logs = Join-Path $env:LOCALAPPDATA 'igneum\logs'
$files = @(Get-ChildItem $logs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 2)
if ($files.Count -ge 2) { $f = $files[1]; Say ('RESULT app log before the freeze: ' + $f.Name + ' (last write ' + $f.LastWriteTime.ToUniversalTime().ToString('o') + ' UTC), last 40 lines:'); Get-Content $f.FullName -Tail 40 -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch 'TEMPLATE|\[block\]|ACCEPTED' } | ForEach-Object { Say (' ' + $_) } }
Say 'freeze check done'
exit 0

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

@ -0,0 +1,82 @@
# What stopped PC 2 at 10:46Z on 7 October 2026 (MF-11): every stream (app, node, miner) went quiet mid-line twelve
# minutes after the 0.3.19 update returned. Read-only collection, printed to the job report: the System and
# Application event logs from 10:00Z (boot, power, bugcheck, WHEA, display drivers first), the minidump folder and
# the latest dump's bugcheck code, the app's last two logs and OTA files, the relay agent's logs and task, the GPU
# drivers and the reliability records. Queued as a signed `run` job (packaging/ota/publish-jobs.sh); it touches
# nothing and sends nothing to the installed app.
$ErrorActionPreference = 'Continue'
$sinceUtc = '2026-10-07T10:00:00.000Z'
function Section([string]$t) { Write-Host ''; Write-Host ('== ' + $t) }
function Q([string]$log, [string]$xpath, [int]$count) {
try { & wevtutil.exe qe $log ('/q:' + $xpath) /f:text ('/c:' + $count) 2>&1 | ForEach-Object { "$_" } } catch { Write-Host ('wevtutil failed: ' + $_.Exception.Message) }
}
$time = "TimeCreated[@SystemTime>='" + $sinceUtc + "']"
Section ('now ' + (Get-Date -Format o) + ' (local), ' + [DateTime]::UtcNow.ToString('o') + ' UTC')
try { Get-CimInstance Win32_OperatingSystem | Select-Object LastBootUpTime, Caption, BuildNumber | Format-List | Out-String | Write-Host } catch {}
Section 'boot and shutdown events since 10:00Z (41 Kernel-Power, 1001 BugCheck, 6005 6006 6008 Event Log, 1074 User32, 12 13 Kernel-General)'
Q 'System' ("*[System[" + $time + " and (EventID=41 or EventID=1001 or EventID=6005 or EventID=6006 or EventID=6008 or EventID=1074 or EventID=12 or EventID=13)]]") 60
Section 'System by source: Kernel-Power, BugCheck, WHEA, nvlddmkm, amdkmdag, Display, Kernel-General'
Q 'System' ("*[System[" + $time + " and Provider[@Name='Microsoft-Windows-Kernel-Power' or @Name='Microsoft-Windows-WER-SystemErrorReporting' or @Name='Microsoft-Windows-WHEA-Logger' or @Name='nvlddmkm' or @Name='amdkmdag' or @Name='Display' or @Name='Microsoft-Windows-Kernel-General' or @Name='volmgr' or @Name='disk']]]") 200
Section 'System: every critical and error since 10:00Z'
Q 'System' ("*[System[" + $time + " and (Level=1 or Level=2)]]") 200
Section 'Application: every critical and error since 10:00Z (Application Error, WER, .NET, Igneum)'
Q 'Application' ("*[System[" + $time + " and (Level=1 or Level=2)]]") 200
Section 'Application: Igneum and PowerShell sources since 10:00Z'
Q 'Application' ("*[System[" + $time + " and Provider[@Name='Igneum Miner' or @Name='igneum-app' or @Name='PowerShell' or @Name='Windows Error Reporting']]]") 100
Section 'minidumps'
$mdDir = Join-Path $env:SystemRoot 'Minidump'
try { Get-ChildItem $mdDir -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | Format-Table Name, Length, LastWriteTime -AutoSize | Out-String | Write-Host } catch {}
try { Get-Item (Join-Path $env:SystemRoot 'MEMORY.DMP') -ErrorAction SilentlyContinue | Format-Table Name, Length, LastWriteTime -AutoSize | Out-String | Write-Host } catch {}
$dump = Get-ChildItem $mdDir -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($dump) {
# the 64-bit dump header: 'PAGEDU64', the bugcheck code at 0x38 and its four parameters at 0x40, 0x48, 0x50, 0x58
try {
$fs = [IO.File]::OpenRead($dump.FullName)
$hdr = New-Object byte[] 4096
$n = $fs.Read($hdr, 0, 4096)
$fs.Close()
$sig = [Text.Encoding]::ASCII.GetString($hdr, 0, 8)
$code = [BitConverter]::ToUInt32($hdr, 0x38)
$p = @(0x40, 0x48, 0x50, 0x58) | ForEach-Object { ('0x{0:x}' -f [BitConverter]::ToUInt64($hdr, $_)) }
Write-Host ('latest dump ' + $dump.Name + ' (' + $dump.LastWriteTime.ToString('o') + '): header ' + $sig + ', bugcheck 0x' + ('{0:x}' -f $code) + ' params ' + ($p -join ' '))
} catch { Write-Host ('dump header not read: ' + $_.Exception.Message) }
} else { Write-Host 'no minidump on this PC' }
Section 'reliability records (last 40, newest first)'
try { Get-CimInstance Win32_ReliabilityRecords -ErrorAction SilentlyContinue | Sort-Object TimeGenerated -Descending | Select-Object -First 40 TimeGenerated, SourceName, EventIdentifier, ProductName, Message | Format-List | Out-String -Width 220 | Write-Host } catch { Write-Host ('no reliability records: ' + $_.Exception.Message) }
Section 'GPU drivers'
try { Get-CimInstance Win32_VideoController | Select-Object Name, DriverVersion, DriverDate, Status, VideoProcessor | Format-List | Out-String | Write-Host } catch {}
try { & nvidia-smi --query-gpu=name,driver_version,pstate,temperature.gpu --format=csv 2>&1 | ForEach-Object { "$_" } } catch {}
try { & powercfg.exe /lastwake 2>&1 | ForEach-Object { "$_" } } catch {}
try { & powercfg.exe /requests 2>&1 | ForEach-Object { "$_" } } catch {}
Section 'app logs: the last three runs (tail 80 each)'
$logs = Join-Path $env:LOCALAPPDATA 'igneum\logs'
$files = @(Get-ChildItem $logs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 3)
$files | Format-Table Name, Length, LastWriteTime -AutoSize | Out-String | Write-Host
foreach ($f in $files) { Write-Host ('--- ' + $f.Name + ' (last write ' + $f.LastWriteTime.ToString('o') + ')'); Get-Content $f.FullName -Tail 80 -ErrorAction SilentlyContinue }
Section 'OTA files in the app folder'
$app = Join-Path $env:LOCALAPPDATA 'igneum\app'
foreach ($n in @('ota-apply.log', 'ota-setup.log', 'update-pending.json', 'update-result.json', 'failed-versions.json', 'jobs-state.json')) {
$p = Join-Path $app $n
if (Test-Path $p) { Write-Host ('--- ' + $n + ' (' + (Get-Item $p).LastWriteTime.ToString('o') + ')'); Get-Content $p -Tail 40 -ErrorAction SilentlyContinue }
}
Write-Host ('start at login: ' + (Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty 'Igneum Miner' -ErrorAction SilentlyContinue))
Section 'relay agent: logs, scheduled task, running shells'
$rl = Join-Path $env:LOCALAPPDATA 'igneum-relay'
try { Get-ChildItem (Join-Path $rl 'logs') -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 6 Name, Length, LastWriteTime | Format-Table -AutoSize | Out-String | Write-Host } catch {}
foreach ($n in @('state.json', 'machine.txt')) { $p = Join-Path $rl $n; if (Test-Path $p) { Write-Host ('--- ' + $n); Get-Content $p -ErrorAction SilentlyContinue } }
try { & schtasks.exe /Query /TN IgneumRelayAgent /V /FO LIST 2>&1 | Select-Object -First 14 | ForEach-Object { "$_" } } catch {}
try { & schtasks.exe /Query /TN IgneumRelayService /V /FO LIST 2>&1 | Select-Object -First 14 | ForEach-Object { "$_" } } catch {}
try { Get-Process -Name powershell, cmd, 'Igneum Miner', igneum-app, igneumd -ErrorAction SilentlyContinue | Select-Object Id, ProcessName, StartTime | Format-Table -AutoSize | Out-String | Write-Host } catch {}
Write-Host 'collection done'

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

@ -0,0 +1,35 @@
# The body of a start-app task (tools/relay.mjs start-app <machine>; MF-11, 7 October 2026). An agent from v3 on never
# runs this text: its own Start-App does the same thing. An agent from before the kind runs it as a `run` task, which is
# why it is here. Starts the installed Igneum Miner as this user and reads back that an engine answers api/state.
# It never sends quit, pause or resume to the installed app (the standing rule of 5 October 2026).
$ErrorActionPreference = 'Continue'
$dir = ''
foreach ($d in @((Join-Path $env:LOCALAPPDATA 'Programs\Igneum Miner'), (Join-Path $env:ProgramFiles 'Igneum Miner'))) {
if (Test-Path (Join-Path $d 'igneum-app.exe')) { $dir = $d; break }
}
if (-not $dir) { Write-Host 'start-app: igneum-app.exe not found'; exit 2 }
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
function App-Version {
if (-not (Test-Path $urlFile)) { return '' }
try { $u = (Get-Content $urlFile -Raw).Trim(); $st = Invoke-RestMethod -Uri ($u + 'api/state') -TimeoutSec 5 -UseBasicParsing; return [string]$st.version } catch { return '' }
}
$v = App-Version
if ($v) { Write-Host ('start-app: an engine already answers (app ' + $v + '); nothing started'); exit 0 }
$exe = Join-Path $dir 'igneum-app.exe'
$admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if ($admin) {
& schtasks.exe /Create /F /TN 'IgneumStartApp' /SC ONCE /ST 00:00 /RL LIMITED /TR ('"' + $exe + '" --launch') 2>&1 | Out-Null
& schtasks.exe /Run /TN 'IgneumStartApp' 2>&1 | Out-Null
Start-Sleep -Seconds 3
& schtasks.exe /Delete /F /TN 'IgneumStartApp' 2>&1 | Out-Null
} else {
# console: igneum-app.exe is a windows-subsystem program (no console); the window host it opens is its own
Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $dir | Out-Null
}
$t0 = Get-Date
$deadline = (Get-Date).AddSeconds(90)
while ((Get-Date) -lt $deadline) { $v = App-Version; if ($v) { break }; Start-Sleep -Seconds 3 }
$s = [int]((Get-Date) - $t0).TotalSeconds
if ($v) { Write-Host ('start-app: app ' + $v + ' answered api/state after ' + $s + ' s'); exit 0 }
Write-Host 'start-app: no engine answered api/state inside 90 s'
exit 3

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

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

@ -0,0 +1,120 @@
// 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}`);
if (/substituted at publish time/.test(s)) continue; // a jobs-channel body (packaging/ota/publish-jobs.sh fills it), never a relay run
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' });
});

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

@ -0,0 +1,275 @@
// 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 IN \('run', 'start-app'\) AND flags->>'nonce' = \$1/.test(Q)) return items.filter(i => (i.kind === 'run' || i.kind === 'start-app') && 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', 'start-app'].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('MF-11: a start-app task needs the same signature, tag and fresh nonce as a run; it lands in the agent inbox beside run items', async () => {
const { call, db } = rig();
const secret = newSecret();
const bare = await call('POST', { fn: 'task' }, { headers: TOKEN, body: { to: 'PC2', title: 'start', body: '', kind: 'start-app', flags: {} } });
assert.equal(bare.code, 401);
const good = { ...signedRun('PC2', '# start-app body', secret), kind: 'start-app' };
const r = await call('POST', { fn: 'task' }, { headers: TOKEN, body: good });
assert.equal(r.code, 200, r.error);
assert.equal(db.items[0].kind, 'start-app');
assert.equal((await call('POST', { fn: 'task' }, { headers: TOKEN, body: good })).code, 409, 'a replayed nonce');
// the relay key tier may not queue it (the console token only), like run
assert.equal((await call('POST', { fn: 'task' }, { headers: KEY, body: { ...signedRun('PC2', 'x', secret), kind: 'start-app' } })).code, 403);
// the agent asks for run and gets both kinds, in order; a task item is not in that inbox
db.seed({ from_machine: 'Mac', to_machine: 'PC2', kind: 'task', title: 't', body: 'for a person' });
const inbox = await call('POST', { fn: 'inbox' }, { headers: KEY, body: { machine: 'PC2', kind: 'run', ack: true } });
assert.equal(inbox.code, 200);
assert.deepEqual(inbox.items.map(i => i.kind), ['start-app']);
assert.equal(inbox.acked, 1);
// a kind the relay does not know becomes a plain task, never an agent kind
const odd = await call('POST', { fn: 'task' }, { headers: TOKEN, body: { to: 'PC2', title: 'x', body: 'y', kind: 'reboot-now' } });
assert.equal(odd.code, 200);
assert.equal(db.items.find(i => i.title === 'x' && i.body === 'y').kind, 'task');
});
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']);
});

112
relay/test/service.test.mjs Normal file
View file

@ -0,0 +1,112 @@
// node --test relay/test/service.test.mjs The relay agent as a logon task and the start-app kind (MF-11, 7 October
// 2026). Structural checks of the manifest, the installer, the hidden loop, the agent's branch and the Mac tool; the
// PowerShell 5.1 parse of the .ps1 files is windows.yml's job.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { KINDS, AGENT_KINDS } from '../lib/relay.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = p => readFileSync(join(ROOT, p), 'utf8');
test('the service manifest: at logon, the run level the installer chooses, interactive, restarted on failure, no time limit, one instance, hidden window', () => {
const x = read('relay/clients/IgneumRelayService.xml');
assert.match(x, /<LogonTrigger>[\s\S]*<UserId>__USER__<\/UserId>[\s\S]*<\/LogonTrigger>/);
assert.match(x, /<Principal id="Author">[\s\S]*<UserId>__USER__<\/UserId>[\s\S]*<LogonType>InteractiveToken<\/LogonType>[\s\S]*<RunLevel>__RUNLEVEL__<\/RunLevel>/);
const restart = /<RestartOnFailure>\s*<Interval>PT(\d+)M<\/Interval>\s*<Count>(\d+)<\/Count>\s*<\/RestartOnFailure>/.exec(x);
assert.ok(restart, 'RestartOnFailure with Interval and Count');
assert.ok(Number(restart[1]) <= 5 && Number(restart[2]) >= 100, `restart every ${restart[1]} min, ${restart[2]} times`);
assert.match(x, /<ExecutionTimeLimit>PT0S<\/ExecutionTimeLimit>/, 'no time limit');
assert.match(x, /<MultipleInstancesPolicy>IgnoreNew<\/MultipleInstancesPolicy>/);
assert.match(x, /<DisallowStartIfOnBatteries>false<\/DisallowStartIfOnBatteries>/);
assert.match(x, /<StopIfGoingOnBatteries>false<\/StopIfGoingOnBatteries>/);
assert.match(x, /<StartWhenAvailable>true<\/StartWhenAvailable>/);
assert.match(x, /<Command>powershell\.exe<\/Command>\s*<Arguments>-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "__AGENT_DIR__\\igneum-agent-service\.ps1"<\/Arguments>/);
assert.match(x, /<URI>\\IgneumRelayService<\/URI>/);
});
test('the installer fills the manifest and registers, runs and reads back the task; it carries no secret', () => {
const s = read('relay/clients/install-agent.ps1');
assert.doesNotMatch(s, /#Requires -RunAsAdministrator/, 'a per-user task needs no administrator (PC 2 has nobody to click a prompt)');
assert.match(s, /\.Replace\('__RUNLEVEL__', \$level\)/);
assert.match(s, /\$level = 'LeastPrivilege'[\s\S]*if \(\$Highest\) \{ \$level = 'HighestAvailable' \}/);
// the stale one-shot tasks (PC 2, 7 October 2026: a logon task at a path that was not there popped a dialog at every boot) go first
assert.match(s, /IgneumRelayAgent\*/);
assert.match(s, /schtasks\.exe \/Delete \/F \/TN \$t/);
assert.match(s, /RunOnce/);
assert.match(s, /\.Replace\('__USER__', \$user\)\.Replace\('__AGENT_DIR__', \$Here\)/);
assert.match(s, /schtasks\.exe \/Create \/F \/TN \$TaskName \/XML \$tmp/);
assert.match(s, /\$TaskName = 'IgneumRelayService'/);
assert.match(s, /schtasks\.exe \/Run \/TN \$TaskName/);
assert.match(s, /schtasks\.exe \/Query \/TN \$TaskName/);
assert.match(s, /machine-secret\.txt/, 'the zip must carry the machine secret for the agent to run anything');
assert.doesNotMatch(s, /__RELAY_(URL|KEY|TOKEN)__|x-relay-token|x-igneum-key/, 'the installer touches no secret');
assert.match(s, /-Remove/);
});
test('the hidden loop keeps the agent running for ever, 15 s after any exit, logs without the idle line, and a missing agent path is a log line, never a dialog', () => {
const s = read('relay/clients/igneum-agent-service.ps1');
// the task runs the loop by its full path under -WindowStyle Hidden (the manifest), and the loop itself opens nothing:
// a missing igneum-agent.ps1 is one line in the log and a 60 s wait, no `cmd /c start`, no message box, no exit
const missing = s.slice(s.indexOf('if (-not (Test-Path $Agent))'), s.indexOf('Note \'starting igneum-agent.ps1\''));
assert.match(missing, /Note \('no agent at ' \+ \$Agent \+ '; waiting'\); Start-Sleep -Seconds 60; continue/);
assert.doesNotMatch(s, /cmd(\.exe)? \/c start|MessageBox|Read-Host|\[System\.Windows\.Forms/);
assert.match(s, /-File \$Agent/, 'the agent runs by its full path');
assert.match(s, /ForEach-Object \{ Add-Content -Path \$log -Value \$_ \}/, 'one write per line: the log is never held open');
assert.match(s, /\$Agent = Join-Path \$Here 'igneum-agent\.ps1'/);
assert.match(s, /while \(\$true\) \{/);
assert.match(s, /powershell\.exe -NoProfile -ExecutionPolicy Bypass -File \$Agent/);
assert.match(s, /Start-Sleep -Seconds 15/);
assert.match(s, /-notmatch 'idle as '/);
assert.match(s, /agent-service\.log/);
assert.doesNotMatch(s, /api\/(quit|pause|resume)/);
});
test('the agent runs a start-app task through its own Start-App after the tag check, never the body, never elevated, never a quit', () => {
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 nonce = run.indexOf('Add-Content -Path $NonceFile -Value $task.flags.nonce');
const branch = run.indexOf("if (\"$($task.kind)\" -eq 'start-app') {");
const script = run.indexOf('[IO.File]::WriteAllText($script');
assert.ok(check > 0 && nonce > check && branch > nonce && script > branch, 'tag check, nonce recorded, the start-app branch, and only then a script is written');
assert.match(run, /\$r = Start-App[\s\S]*Post-Result \$task \(\[int\]\$r\.code\) \$log \$r\.note[\s\S]*return\s*\}/);
const start = s.slice(s.indexOf('function Start-App'), s.indexOf('function Sha256-Hex'));
assert.match(start, /\/RL LIMITED/, 'an elevated agent starts the app at the limited run level');
assert.match(start, /Start-Process -FilePath \$exe -ArgumentList '--launch'/);
assert.match(start, /api\/state/);
assert.doesNotMatch(start, /api\/(quit|pause|resume|cards)/);
assert.doesNotMatch(start, /\$task\.body|Invoke-Expression/);
assert.match(s, /agent = 'igneum-agent\.ps1 v3'/);
// the registration carries the app's per-install id, so two PCs with one Windows hostname are two machines
assert.match(s, /Api-Post 'register' @\{ hostname = \(Machine-Hostname\); info = \$info \}/);
assert.match(s, /igneum\\app\\machine-id/);
assert.match(s, /\$id\.Substring\(0, 8\)/);
// the one-shot reboot arm is still the only task the agent removes: the service task is never touched
const disarm = s.slice(s.indexOf('function Disarm-Restart'), s.indexOf('function Sha256-Hex'));
assert.doesNotMatch(disarm, /IgneumRelayService/);
assert.match(disarm, /schtasks\.exe \/Delete \/F \/TN 'IgneumRelayAgent'/);
});
test('the start-app body for older agents does the same thing and never quits the installed app', () => {
const s = read('relay/playbooks/start-app.ps1');
assert.match(s, /api\/state/);
assert.match(s, /\/RL LIMITED/);
assert.doesNotMatch(s, /api\/(quit|pause|resume|cards)/);
assert.doesNotMatch(s, /__DL_BASE__/);
});
test('the kind is known to the relay, the Mac tool signs it, and the zip carries the service files', () => {
assert.ok(KINDS.has('start-app'));
assert.deepEqual(AGENT_KINDS, ['run', 'start-app']);
const m = read('tools/relay.mjs');
assert.match(m, /cmd === 'start-app'/);
assert.match(m, /kind: 'start-app', body, flags: \{ elevated: false/);
const sa = m.slice(m.indexOf("cmd === 'start-app'"), m.indexOf("cmd === 'keygen'"));
assert.match(sa, /signRunTask\(o\)/, 'start-app is signed and tagged like run');
assert.match(sa, /'start-app\.ps1'\)/);
const mk = read('relay/clients/make-clients.sh');
for (const f of ['igneum-agent-service.ps1', 'install-agent.ps1', 'IgneumRelayService.xml']) assert.match(mk, new RegExp(f.replace('.', '\\.')), `${f} is not in the zip`);
});

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');

View file

@ -11,7 +11,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';
@ -21,11 +21,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 = [];
@ -37,8 +38,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,55 @@
#!/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 start-app <machine> the agent there starts the installed Igneum Miner and reports whether an
// engine answered (MF-11); signed and tagged like run, body = relay/playbooks/start-app.ps1
// 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 { fileURLToPath } from 'node:url';
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 +80,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 +135,39 @@ 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 === 'start-app') {
const to = pos[1]; if (!to) throw new Error('start-app <machine>');
const body = playbook(join(dirname(fileURLToPath(import.meta.url)), '..', 'relay', 'playbooks', 'start-app.ps1'));
const o = { from: flags.from || 'Mac', to, title: flags.title || 'start the installed Igneum Miner', kind: 'start-app', body, flags: { elevated: false, reboot_continue: false, reboot: false, shell: 'powershell', script: 'start-app.ps1' } };
signRunTask(o);
const r = await api('task', { body: o }); console.log(`queued #${r.id} start-app for ${to}: the agent starts the installed app and reports in about a minute (node tools/relay.mjs watch)`);
}
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 : ''}`); }