diff --git a/docs/design/relay/desktop-1280-expanded.png b/docs/design/relay/desktop-1280-expanded.png new file mode 100644 index 000000000..3290546a7 Binary files /dev/null and b/docs/design/relay/desktop-1280-expanded.png differ diff --git a/docs/design/relay/desktop-1280.png b/docs/design/relay/desktop-1280.png new file mode 100644 index 000000000..89c69e256 Binary files /dev/null and b/docs/design/relay/desktop-1280.png differ diff --git a/docs/design/relay/phone-375-expanded.png b/docs/design/relay/phone-375-expanded.png new file mode 100644 index 000000000..8c997e39d Binary files /dev/null and b/docs/design/relay/phone-375-expanded.png differ diff --git a/docs/design/relay/phone-375.png b/docs/design/relay/phone-375.png new file mode 100644 index 000000000..0f75b8946 Binary files /dev/null and b/docs/design/relay/phone-375.png differ diff --git a/relay/.gitignore b/relay/.gitignore new file mode 100644 index 000000000..8ea18f287 --- /dev/null +++ b/relay/.gitignore @@ -0,0 +1,3 @@ +.vercel +.env* +node_modules diff --git a/relay/README.md b/relay/README.md new file mode 100644 index 000000000..7a36be132 --- /dev/null +++ b/relay/README.md @@ -0,0 +1,55 @@ +# Igneum relay + +Text, files and tasks between the project lead's devices without Gmail: the Mac, PC1, PC2 and the phone post to one feed and read from it. Vercel project `igneum-relay`, served at https://relay.igneum.network. Built 4 October 2026. + +## The secret is the path + +The web page lives at `/r//` and every API call sits under `/r//api/`. 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. + +## What is stored where + +| Thing | Where | Limit | +|---|---|---| +| 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/log-intake-key`; project env | never in the repo | + +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`. + +## API (all under `/r//api/`) + +| Call | Does | +|---|---| +| `GET feed?since=&before=&machine=&limit=` | items newest first (200 by default) plus every machine with its unread count | +| `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}` | +| `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 | + +## Mac + +`node tools/relay.mjs` (feed), `read `, `drop ""|`, `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 `, `url`. Playbooks live in `relay/playbooks/`; `run` fills `__DL_BASE__` in from `~/.config/igneum/dl-token`. + +## 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. + +An unknown hostname that registers appears in the feed with a "name this machine" box, or `node tools/relay.mjs name PC2`. PC1 is DESKTOP-KMCV30N. + +## 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`). DNS: `relay` CNAME `cname.vercel-dns.com` in the deSEC zone. + +## 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. diff --git a/relay/api/relay.mjs b/relay/api/relay.mjs new file mode 100644 index 000000000..739376d60 --- /dev/null +++ b/relay/api/relay.mjs @@ -0,0 +1,196 @@ +// Igneum relay: one function, dispatched on ?fn=. Reached through the rewrite /r//api/. +// Auth: the token in the path (or x-relay-token), or the intake key in x-igneum-key. Nothing else. +// GET feed ?since= | ?before= | ?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) }); + +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 }; +} + +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' }); } + + 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) }); + } +} diff --git a/relay/clients/CLAUDE-PC.md b/relay/clients/CLAUDE-PC.md new file mode 100644 index 000000000..605ca1f64 --- /dev/null +++ b/relay/clients/CLAUDE-PC.md @@ -0,0 +1,31 @@ +# Igneum relay: standing instruction for the Claude Code session on this PC + +the project lead's Mac and this PC talk through the Igneum relay (relay.igneum.network). You never need Gmail, a USB stick or the project lead to carry text or files. The relay clients live next to this file (the folder you unzipped `igneum-relay-clients.zip` into). Everything below runs in `cmd` or PowerShell from that folder. + +## Four commands + +| You want to | Type | +|---|---| +| See what the Mac has queued for you (and mark it read) | `send.bat inbox` | +| Report back (what you did, what broke, the numbers) | `send.bat result ""` | +| Send a file (log, screenshot, zip, up to 50 MB) | `send.bat ` | +| Fetch an item or its file by id | `send.bat get ` | + +Also: `send.bat ""` posts a plain note, `send.bat peek` reads without marking, `send.bat done ` closes a task. Add `-TaskId ` to a result so it links to the task it answers, `-Title "..."` for a headline, `-To Mac` to address one machine. + +## The loop you follow + +1. At the start of every session, and whenever the project lead says "check the relay": run `send.bat inbox`. Each item is a task from the Mac: a title, a body, sometimes a file. Do what it says. +2. When a task is done or blocked: `send.bat result "" -TaskId `. Keep it factual. Attach logs with `send.bat -TaskId ` rather than pasting hundreds of lines. +3. Close it: `send.bat done `. +4. Nothing waiting? Say so and carry on with what the project lead asked for locally. + +## The agent + +`igneum-agent.bat` is a separate, automatic runner. the project lead double-clicks it once (it asks for administrator rights once) and leaves the window open. From then on the Mac queues scripts (`run` tasks) that the agent executes by itself, posts the output as `result` items and reboots the PC when a script asks for it. Do not stop that window. Do not run `run` tasks yourself; `send.bat inbox` only shows the `task` items meant for you. If the agent window is closed, tell the project lead and ask him to start `igneum-agent.bat` again. + +## 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. +- 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. diff --git a/relay/clients/agent.sh b/relay/clients/agent.sh new file mode 100755 index 000000000..ec80ddca3 --- /dev/null +++ b/relay/clients/agent.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# 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). +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" +POLL="${RELAY_POLL:-20}"; TAIL=65536 +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"; } +py() { python3 -c "$@"; } +register() { + local info + info="$(py 'import json,platform,shutil,subprocess,os +gpus=[] +try: + out=subprocess.run(["system_profiler","SPDisplaysDataType"],capture_output=True,text=True,timeout=20).stdout + gpus=[l.split(":",1)[1].strip() for l in out.splitlines() if "Chipset Model" in l] +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()}}))')" + 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"])')}" + 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})" +} +post_result() { # id title code log note + local id="$1" title="$2" code="$3" logf="$4" note="${5:-}" body + body="$(tail -c "$TAIL" "$logf" 2>/dev/null | py 'import sys,json; print(json.dumps(sys.stdin.read()))')" + local t; t="$(printf '%s' "$title: exit $code${note:+ ($note)}" | py 'import sys,json; print(json.dumps(sys.stdin.read()))')" + post drop "{\"kind\":\"result\",\"from\":$(printf '%s' "$MACHINE" | py 'import sys,json; print(json.dumps(sys.stdin.read()))'),\"to\":\"all\",\"task_id\":$id,\"title\":$t,\"body\":$body,\"flags\":{\"exit_code\":$code,\"pass\":${RELAY_PASS:-1}}}" >/dev/null && log "result posted for #$id" + if [ "$(stat -f%z "$logf" 2>/dev/null || stat -c%s "$logf")" -gt "$TAIL" ]; then RELAY_MACHINE="$MACHINE" RELAY_TITLE="$title full log" bash "$HERE/send.sh" "$logf" >/dev/null || true; fi +} +run_task() { # json-of-one-item pass + local it="$1" pass="${2:-1}" id title body elevated rc + 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 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}" + 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 + RELAY_PASS="$pass" RELAY_TASK_ID="$id" RELAY_MACHINE="$MACHINE" RELAY_SEND="$HERE/send.sh" bash "$STATE/tasks/task-$id.sh" > >(tee -a "$logf") 2>&1; rc=$? + fi + wait 2>/dev/null; sleep 0.2 + echo "__RELAY_EXIT__=$rc" >> "$logf" + if grep -q 'RELAY-REBOOT' "$logf"; 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" + log "task asked for a reboot; re-run agent.sh after the restart to resume (no auto-restart on the Mac)"; return 0 + fi + fi + post_result "$id" "$title" "$rc" "$logf" + post done "{\"id\":$id,\"exit_code\":$rc}" >/dev/null +} +one_pass() { + if [ -f "$STATE/state.json" ]; then + 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 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 +} +register || exit 1 +if [ "${1:-}" = "once" ]; then one_pass; exit $?; fi +while true; do one_pass; sleep "$POLL"; done diff --git a/relay/clients/igneum-agent.bat b/relay/clients/igneum-agent.bat new file mode 100644 index 000000000..aa78e14c6 --- /dev/null +++ b/relay/clients/igneum-agent.bat @@ -0,0 +1,16 @@ +@echo off +rem Igneum relay agent. Double-click once and leave the window open; the Mac queues everything else. +rem Runs elevated (one UAC click) so tasks that need administrator never prompt again. Restarts itself if the agent ever exits. +title igneum-agent +net session >nul 2>&1 +if errorlevel 1 ( + echo Asking for administrator rights once, so queued tasks can install things without prompts... + powershell -NoProfile -Command "Start-Process cmd.exe -Verb RunAs -ArgumentList '/k','\"\"%~f0\"\"'" + exit /b 0 +) +:loop +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0igneum-agent.ps1" +echo. +echo igneum-agent stopped with code %ERRORLEVEL%. Restarting in 15 s (Ctrl+C to quit). +timeout /t 15 >nul +goto loop diff --git a/relay/clients/igneum-agent.ps1 b/relay/clients/igneum-agent.ps1 new file mode 100644 index 000000000..cc5a421fc --- /dev/null +++ b/relay/clients/igneum-agent.ps1 @@ -0,0 +1,173 @@ +# 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 +# the output and posts a `result` item (exit code, last 64 KB inline, the full log as a file) and marks the task done. +# 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. +$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 } +$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' +$PollSeconds = 20 +$TailBytes = 65536 +New-Item -ItemType Directory -Force -Path $StateDir, $TaskDir, $LogDir | Out-Null + +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-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 +} +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 } } +function Strip-Nulls([string] $s) { if ($null -eq $s) { return '' }; return ($s -replace "`0", '') } + +# One agent per machine: a named mutex stops a second copy (the scheduled task and a double-click, for instance). +$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 } + try { $info.os = (Get-CimInstance Win32_OperatingSystem).Caption + ' build ' + (Get-CimInstance Win32_OperatingSystem).BuildNumber } catch {} + try { + $nv = Get-Command nvidia-smi -ErrorAction SilentlyContinue + if ($nv) { $info.gpus = @((& nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv,noheader 2>$null) | ForEach-Object { "$_".Trim() } | Where-Object { $_ }) } + if (-not $info.gpus -or $info.gpus.Count -eq 0) { $info.gpus = @(Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }) } + } catch {} + try { + $w = Strip-Nulls (((& wsl.exe --status 2>&1) | Out-String)) + $l = Strip-Nulls (((& wsl.exe --list --verbose 2>&1) | Out-String)) + $distros = @($l -split "`n" | Select-Object -Skip 1 | ForEach-Object { $_.Trim() } | Where-Object { $_ } | ForEach-Object { ($_ -replace '^\*\s*', '') -replace '\s+', ' ' }) + $info.wsl = $(if ($distros.Count) { $distros -join '; ' } else { ($w -split "`n" | Select-Object -First 1).Trim() }) + if (-not $info.wsl) { $info.wsl = 'none' } + } catch { $info.wsl = 'none' } + $info.nvcc = [bool](Get-Command nvcc -ErrorAction SilentlyContinue) + return $info +} + +function Register-Machine { + $info = Collect-Info + $r = Api-Post 'register' @{ hostname = $env:COMPUTERNAME; info = $info } + Set-Content -Path (Join-Path $StateDir 'machine.txt') -Value $r.name -Encoding ascii + $script:Machine = $r.name; $script:Role = $r.role + Log ("registered as " + $r.name + " (role " + ($(if ($r.role) { $r.role } else { 'unset' })) + ", named " + $r.named + "); gpus: " + ($info.gpus -join ', ') + "; wsl: " + $info.wsl + "; nvcc: " + $info.nvcc) + if (-not $r.named) { Log "this PC is not named yet. On the Mac: node tools/relay.mjs name $env:COMPUTERNAME PC2" } +} + +function Arm-Restart { + # Re-arm after a reboot: a logon scheduled task with highest privileges (no UAC prompt), plus RunOnce as a fallback. + $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)' + } catch { Log ("schtasks failed: " + $_.Exception.Message) } + try { + New-Item -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' -Force | Out-Null + Set-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\RunOnce' -Name 'IgneumRelayAgent' -Value ("cmd /c start `"igneum-agent`" `"$bat`"") + } catch { Log ("RunOnce failed: " + $_.Exception.Message) } +} + +function Post-Result($task, [int] $code, [string] $logPath, [string] $note) { + $tail = '' + if (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) { + 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) } +} + +function Run-Task($task, [int] $pass) { + $id = $task.id + $script = Join-Path $TaskDir ("task-" + $id + ".ps1") + $wrap = Join-Path $TaskDir ("task-" + $id + ".wrap.ps1") + $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 { "" })) + $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 + $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' +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 } +Stop-Transcript | Out-Null +Add-Content -Path '$log' -Value ("__RELAY_EXIT__=" + `$code) +exit `$code +"@ + [IO.File]::WriteAllText($wrap, $wrapBody, (New-Object Text.UTF8Encoding $true)) + $args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$wrap`"") + $code = 1 + try { + if ($elevated -and -not (Is-Admin)) { + Log 'task needs administrator and the agent is not elevated: asking (UAC prompt on this PC)' + $p = Start-Process powershell.exe -ArgumentList $args -Verb RunAs -Wait -PassThru + } else { + $p = Start-Process powershell.exe -ArgumentList $args -NoNewWindow -Wait -PassThru + } + $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' + 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 + & 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 { '' }) + 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 + & 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 +$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) } + } + $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 +} diff --git a/relay/clients/make-clients.sh b/relay/clients/make-clients.sh new file mode 100755 index 000000000..ef4aaae62 --- /dev/null +++ b/relay/clients/make-clients.sh @@ -0,0 +1,25 @@ +#!/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] +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +CFG="$HOME/.config/igneum" +URL="$(cat "$CFG/relay-url" 2>/dev/null | tr -d '\n' || true)"; URL="${URL:-https://relay.igneum.network}" +KEY="$(tr -d '\n' < "$CFG/log-intake-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" +done +# Windows reads CRLF batch files most reliably; PowerShell is fine either way +for f in send.bat igneum-agent.bat; do perl -pi -e 's/\r?\n/\r\n/' "$STAGE/$f"; done +chmod +x "$STAGE/send.sh" "$STAGE/agent.sh" +cat > "$STAGE/README.txt" <" | send.bat | send.bat inbox | send.bat result "" | send.bat get +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. +TXT +rm -f "$OUT/igneum-relay-clients.zip" +(cd "$(dirname "$STAGE")" && zip -qr "$OUT/igneum-relay-clients.zip" igneum-relay-clients) +echo "staged: $STAGE" +echo "zip: $OUT/igneum-relay-clients.zip ($(du -h "$OUT/igneum-relay-clients.zip" | cut -f1))" diff --git a/relay/clients/send.bat b/relay/clients/send.bat new file mode 100644 index 000000000..22df77939 --- /dev/null +++ b/relay/clients/send.bat @@ -0,0 +1,5 @@ +@echo off +rem Igneum relay: send.bat "" | send.bat | send.bat inbox | send.bat result "" | send.bat get | send.bat done +rem Everything is in send.ps1 next to this file (the URL, key and token are baked in by the packager). +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0send.ps1" %* +exit /b %ERRORLEVEL% diff --git a/relay/clients/send.ps1 b/relay/clients/send.ps1 new file mode 100644 index 000000000..e65ea958a --- /dev/null +++ b/relay/clients/send.ps1 @@ -0,0 +1,111 @@ +# Igneum relay client (Windows PowerShell 5.1 or later). Driven by send.bat; the agent and the playbooks call it too. +# send.ps1 "" post a note to everyone +# send.ps1 post a file (up to 50 MB, straight to Vercel Blob) +# send.ps1 result "" [-TaskId N] [-File path] post a result (what a task produced) +# 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 print an item; download its file into the current folder +# send.ps1 done 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. +param( + [Parameter(Position = 0)] [string] $A, + [Parameter(Position = 1)] [string] $B, + [string] $To = 'all', + [string] $Title = '', + [string] $Machine = '', + [long] $TaskId = 0, + [string] $File = '', + [string] $Kind = '' +) +$ErrorActionPreference = 'Stop' +[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 } +$StateDir = Join-Path $env:LOCALAPPDATA 'igneum-relay' + +function Get-MachineName { + if ($Machine) { return $Machine } + if ($env:RELAY_MACHINE) { return $env:RELAY_MACHINE } + $f = Join-Path $StateDir 'machine.txt' + if (Test-Path $f) { $n = (Get-Content $f -Raw).Trim(); if ($n) { return $n } } + return $env:COMPUTERNAME +} +function Invoke-Relay([string] $Method, [string] $Fn, $Body) { + $uri = "$Base/$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 +} +function Send-RelayFile([string] $Path) { + $Path = (Resolve-Path $Path).Path + $name = Split-Path $Path -Leaf + $size = (Get-Item $Path).Length + $t = Invoke-Relay 'POST' 'upload' @{ name = $name; size = $size } + if ($size -gt $t.max) { throw "$name is $size bytes, over the $($t.max) byte cap" } + $h = @{ 'authorization' = "Bearer $($t.token)"; 'x-api-version' = "$($t.api_version)"; 'x-add-random-suffix' = '1'; 'x-content-type' = 'application/octet-stream' } + $r = Invoke-RestMethod -Method Put -Uri $t.put_url -Headers $h -ContentType 'application/octet-stream' -InFile $Path -TimeoutSec 600 + if (-not $r.url) { throw "blob upload failed: $($r | ConvertTo-Json -Compress)" } + return @{ file_name = $name; file_url = $r.url; size = $size } +} +function Post-Item([hashtable] $o) { + $o['from'] = Get-MachineName + if (-not $o.ContainsKey('to')) { $o['to'] = $To } + if ($Title -and -not $o['title']) { $o['title'] = $Title } + if ($TaskId) { $o['task_id'] = $TaskId } + if ($File) { $f = Send-RelayFile $File; foreach ($k in $f.Keys) { $o[$k] = $f[$k] } } + $r = Invoke-Relay 'POST' 'drop' $o + Write-Host ("sent #" + $r.id + " (" + $r.kind + ") from " + $o['from']) + return $r +} +function Show-Item($it) { + Write-Host ("===== #" + $it.id + " " + $it.kind + " from " + $it.from + " to " + $it.to + " at " + $it.ts + ($(if ($it.title) { " | " + $it.title } else { "" })) + " =====") + if ($it.body) { Write-Output $it.body } + if ($it.has_file) { Write-Host ("file: " + $it.file_name + " (" + $it.size + " bytes); send.bat get " + $it.id + " downloads it") } +} + +switch -Regex ($A) { + '^(?i)inbox$' { + $j = Invoke-RestMethod -Uri "$Base/inbox?machine=$([Uri]::EscapeDataString((Get-MachineName)))&ack=1" -Headers $Headers -TimeoutSec 60 + 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 + 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 ' } + $it = (Invoke-RestMethod -Uri "$Base/item?id=$B" -Headers $Headers -TimeoutSec 60).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 + Write-Host "downloaded: $out" + } + break + } + '^(?i)done$' { if (-not $B) { throw 'done ' }; Invoke-Relay 'POST' 'done' @{ id = [long]$B } | Out-Null; Write-Host "#$B done"; break } + '^(?i)result$' { + if (-not $B -and -not $File) { throw 'result "" [-TaskId N] [-File path]' } + Post-Item @{ kind = 'result'; body = "$B"; title = $Title } | Out-Null + break + } + default { + if (-not $A) { throw 'send.bat "" | send.bat | send.bat inbox | send.bat result "" | send.bat get | send.bat done ' } + if (Test-Path -LiteralPath $A -PathType Leaf) { + $f = Send-RelayFile $A + $o = @{ kind = 'file'; body = "$B"; title = $(if ($Title) { $Title } else { $f.file_name }) } + foreach ($k in $f.Keys) { $o[$k] = $f[$k] } + Post-Item $o | Out-Null + } else { + Post-Item @{ kind = $(if ($Kind) { $Kind } else { 'text' }); body = "$A"; title = $Title } | Out-Null + } + } +} diff --git a/relay/clients/send.sh b/relay/clients/send.sh new file mode 100755 index 000000000..71f0c2224 --- /dev/null +++ b/relay/clients/send.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Igneum relay client for Mac/Linux/WSL. Needs curl and jq (python3 fallback for jq). +# send.sh "" note to everyone send.sh file (up to 50 MB) +# send.sh inbox unread tasks for this machine, marked read send.sh peek same, not marked +# send.sh get print an item, download its file here send.sh done +# send.sh result "" [--task-id N] [--file path] +# 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). +set -euo pipefail +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" +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"; } +jstr() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' ; } +upload() { + local path="$1" name size tok + name="$(basename "$path")"; size=$(stat -f%z "$path" 2>/dev/null || stat -c%s "$path") + tok="$(post upload "{\"name\":$(printf '%s' "$name" | jstr),\"size\":$size}")" + local put url token ver + put="$(printf '%s' "$tok" | jqq -r .put_url)"; token="$(printf '%s' "$tok" | jqq -r .token)"; ver="$(printf '%s' "$tok" | jqq -r .api_version)" + url="$(curl -sS --max-time 600 -X PUT "$put" -H "authorization: Bearer $token" -H "x-api-version: $ver" -H 'x-add-random-suffix: 1' -H 'x-content-type: application/octet-stream' --data-binary "@$path" | jqq -r .url)" + [ -n "$url" ] && [ "$url" != "null" ] || { echo "blob upload failed" >&2; return 1; } + printf '"file_name":%s,"file_url":"%s","size":%s' "$(printf '%s' "$name" | jstr)" "$url" "$size" +} +show() { python3 -c ' +import json,sys; it=json.load(sys.stdin) +for x in (it if isinstance(it,list) else [it]): + print("===== #%s %s from %s to %s at %s%s =====" % (x["id"],x["kind"],x["from"],x["to"],x["ts"], (" | "+x["title"]) if x.get("title") else "")) + if x.get("body"): print(x["body"]) + 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 ;; + get) [ -n "${2:-}" ] || { echo 'get ' >&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 ;; + done) [ -n "${2:-}" ] || { echo "done " >&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 + 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 "" | send.sh | send.sh inbox | send.sh result "" | send.sh get | send.sh done ' >&2; exit 1 ;; + *) if [ -f "$cmd" ]; then post drop "{\"from\":$(printf '%s' "$M" | jstr),\"to\":\"$TO\",\"kind\":\"file\",\"title\":$(printf '%s' "${TITLE:-$(basename "$cmd")}" | jstr),\"body\":$(printf '%s' "${2:-}" | jstr),$(upload "$cmd")}" + else post drop "{\"from\":$(printf '%s' "$M" | jstr),\"to\":\"$TO\",\"kind\":\"text\",\"title\":$(printf '%s' "$TITLE" | jstr),\"body\":$(printf '%s' "$cmd" | jstr)}"; fi; echo ;; +esac diff --git a/relay/index.html b/relay/index.html new file mode 100644 index 000000000..c9876c45a --- /dev/null +++ b/relay/index.html @@ -0,0 +1 @@ +Igneum relayigneum relay diff --git a/relay/lib/relay.mjs b/relay/lib/relay.mjs new file mode 100644 index 000000000..9ee1bb6fe --- /dev/null +++ b/relay/lib/relay.mjs @@ -0,0 +1,108 @@ +// 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'; +import { randomBytes } from 'node:crypto'; + +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']); +export const ROLES = new Set(['miner', 'prover', 'bench', 'mac', 'phone', '']); + +export function neon() { + const url = process.env.DATABASE_URL; + if (!url) throw new Error('DATABASE_URL is not set'); + const host = new URL(url).hostname.replace('-pooler', ''); + return async (query, params = []) => { + const r = await fetch(`https://${host}/sql`, { + method: 'POST', + headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, params }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.message || JSON.stringify(j)); + return j.rows; + }; +} + +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 (token && typeof given === 'string' && given === token) return 'token'; + const k = req.headers['x-igneum-key']; + if (key && typeof k === 'string' && k === key) return 'key'; + return null; +} + +export async function readJson(req) { + if (req.body !== undefined && req.body !== null) { + if (typeof req.body === 'string') return req.body ? JSON.parse(req.body) : {}; + if (Buffer.isBuffer(req.body)) return req.body.length ? JSON.parse(req.body.toString('utf8')) : {}; + return req.body; + } + const chunks = []; + for await (const c of req) chunks.push(c); + const s = Buffer.concat(chunks).toString('utf8'); + return s ? JSON.parse(s) : {}; +} + +export async function readRaw(req) { + if (Buffer.isBuffer(req.body)) return req.body; + if (typeof req.body === 'string') return Buffer.from(req.body, 'utf8'); + const chunks = []; + for await (const c of req) chunks.push(c); + return Buffer.concat(chunks); +} + +export function safeName(name) { + const n = str(name, 200).replace(/[\\/]+/g, '_').replace(/[^\w.\-+ ()\[\]]/g, '_').trim(); + return n || 'file'; +} + +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 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(); }; + +export function rowOut(r) { + return { + id: Number(r.id), ts: iso(r.ts), from: r.from_machine, to: r.to_machine, kind: r.kind, title: r.title, body: r.body, + file_name: r.file_name, has_file: !!(r.file_url || r.inline), size: Number(r.size || 0), + read: !!r.read, read_at: iso(r.read_at), done: !!r.done, done_at: iso(r.done_at), flags: r.flags || {}, task_id: r.task_id == null ? null : Number(r.task_id), + }; +} + +export async function touch(sql, name) { + if (!name) return; + await sql(`INSERT INTO relay_machines (name, role, named, last_seen) VALUES ($1, '', false, now()) + ON CONFLICT (name) DO UPDATE SET last_seen = now()`, [str(name, 80)]); +} diff --git a/relay/package-lock.json b/relay/package-lock.json new file mode 100644 index 000000000..0036b313a --- /dev/null +++ b/relay/package-lock.json @@ -0,0 +1,109 @@ +{ + "name": "igneum-relay", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "igneum-relay", + "dependencies": { + "@vercel/blob": "^1.1.1" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@vercel/blob": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-1.1.1.tgz", + "integrity": "sha512-heiJGj2qt5qTv6yiShH9f6KRAoZGj+lz61GQ+lBRL4lhvUmKI9A51KYlQTnsUd9ymdFlKHBlvmPeG+yGz2Qsbg==", + "license": "Apache-2.0", + "dependencies": { + "async-retry": "^1.3.3", + "is-buffer": "^2.0.5", + "is-node-process": "^1.2.0", + "throttleit": "^2.1.0", + "undici": "^5.28.4" + }, + "engines": { + "node": ">=16.14" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + } + } +} diff --git a/relay/package.json b/relay/package.json new file mode 100644 index 000000000..e247a30d0 --- /dev/null +++ b/relay/package.json @@ -0,0 +1,6 @@ +{ + "name": "igneum-relay", + "private": true, + "type": "module", + "dependencies": { "@vercel/blob": "^1.1.1" } +} diff --git a/relay/playbooks/mac-smoke.sh b/relay/playbooks/mac-smoke.sh new file mode 100644 index 000000000..487c59657 --- /dev/null +++ b/relay/playbooks/mac-smoke.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Igneum playbook for the Mac role (bash): a smoke test of the runner. Queue: node tools/relay.mjs run Mac "mac smoke" relay/playbooks/mac-smoke.sh +echo "mac-smoke on $(hostname -s) as ${RELAY_MACHINE:-?} pass ${RELAY_PASS:-?} task ${RELAY_TASK_ID:-?}" +uname -a +sw_vers 2>/dev/null || true +echo "RESULT ok" +exit 0 diff --git a/relay/playbooks/miner-v4.ps1 b/relay/playbooks/miner-v4.ps1 new file mode 100644 index 000000000..685aa2341 --- /dev/null +++ b/relay/playbooks/miner-v4.ps1 @@ -0,0 +1,23 @@ +# Igneum playbook: fresh miner v4 install and start. Downloads igneum-windows-v4.zip, replaces C:\igneum-v4, starts START-IGNEUM.bat in its own window. +# 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' +$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" +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 +Expand-Archive -Path $zip -DestinationPath $root -Force +$start = Join-Path $root 'igneum-windows\START-IGNEUM.bat' +if (-not (Test-Path $start)) { Write-Host "ERROR: $start missing after extract"; Get-ChildItem $root -Recurse | Select-Object -First 20 | ForEach-Object { $_.FullName }; exit 2 } +Write-Host "starting $start in a new window" +Start-Process cmd.exe -ArgumentList '/k', "`"$start`"" -WorkingDirectory (Split-Path $start) +Start-Sleep 20 +Get-Process igneum-miner, igneumd -ErrorAction SilentlyContinue | ForEach-Object { Write-Host ("running: " + $_.ProcessName + " pid " + $_.Id) } +exit 0 diff --git a/relay/playbooks/oneclick-test.ps1 b/relay/playbooks/oneclick-test.ps1 new file mode 100644 index 000000000..95f2fa32b --- /dev/null +++ b/relay/playbooks/oneclick-test.ps1 @@ -0,0 +1,7 @@ +# Igneum playbook: placeholder for the one-click package test. Fill in once the package name and its checks are settled. +# 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' +Get-ChildItem C:\ -Directory -Filter 'igneum*' -ErrorAction SilentlyContinue | ForEach-Object { Write-Host ("present: " + $_.FullName) } +exit 0 diff --git a/relay/playbooks/prove-block.ps1 b/relay/playbooks/prove-block.ps1 new file mode 100644 index 000000000..44b87e30b --- /dev/null +++ b/relay/playbooks/prove-block.ps1 @@ -0,0 +1,24 @@ +# Igneum playbook: prove the fixture block on the GPU then the CPU (PROVE-BLOCK in the prover package) and post results/*.json. +# Queue: node tools/relay.mjs run PC2 "prove block" relay/playbooks/prove-block.ps1 +# Pause mining on that PC first: the prover takes the whole GPU. UNTESTED on a PC as of 4 Oct 2026. +$ErrorActionPreference = 'Continue' +$distro = 'Ubuntu-24.04' +$pkg = 'C:\igneum-prove\igneum-prove-wsl2' +$fixture = 'block-78-increment'; if ($env:FIXTURE) { $fixture = $env:FIXTURE } +function Strip([string] $s) { if ($null -eq $s) { return '' }; return ($s -replace "`0", '') } +if (-not (Test-Path (Join-Path $pkg 'prove-block.sh'))) { Write-Host "ERROR: $pkg\prove-block.sh missing; run prover-setup first"; exit 2 } +$start = Get-Date +Write-Host "proving $fixture inside $distro (first run compiles for 10 to 30 minutes, approximate)" +& wsl.exe -d $distro -u igneum -- bash /mnt/c/igneum-prove/igneum-prove-wsl2/prove-block.sh $fixture core 2>&1 | ForEach-Object { Strip "$_" } +$rc = $LASTEXITCODE +Write-Host "prove-block.sh exit $rc" +$results = Join-Path $pkg 'results' +if (Test-Path $results) { + Get-ChildItem $results -Filter '*.json' | Where-Object { $_.LastWriteTime -ge $start } | ForEach-Object { + Write-Host ("posting " + $_.Name) + & $env:RELAY_SEND -TaskId ([long]$env:RELAY_TASK_ID) -Title ("prove result " + $_.Name) $_.FullName + Get-Content $_.FullName -Raw | Select-Object -First 1 + } +} +Get-ChildItem $pkg -Filter 'prove-*.log' | Where-Object { $_.LastWriteTime -ge $start } | Select-Object -First 1 | ForEach-Object { Write-Host "RESULT lines:"; Select-String -Path $_.FullName -Pattern '^RESULT' | ForEach-Object { $_.Line } } +exit $rc diff --git a/relay/playbooks/prover-setup.ps1 b/relay/playbooks/prover-setup.ps1 new file mode 100644 index 000000000..26126e4f0 --- /dev/null +++ b/relay/playbooks/prover-setup.ps1 @@ -0,0 +1,26 @@ +# 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). +# 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' +$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" +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +Invoke-WebRequest -Uri $zipUrl -OutFile $zip -UseBasicParsing -TimeoutSec 600 +New-Item -ItemType Directory -Force -Path $root | Out-Null +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" +& wsl.exe -d $distro -u igneum -- bash -lc $cmd 2>&1 | ForEach-Object { Strip "$_" } +$rc = $LASTEXITCODE +Write-Host "setup-wsl.sh exit $rc" +& wsl.exe -d $distro -u igneum -- bash -lc 'export PATH="$HOME/.cargo/bin:$HOME/.sp1/bin:$PATH"; cargo prove --version; ls -la $HOME/igneum-prove/proving/igneum-prove/target/release/igneum-prove-host 2>/dev/null || echo "host binary not built"' 2>&1 | ForEach-Object { Strip "$_" } +exit $rc diff --git a/relay/playbooks/wsl-setup.ps1 b/relay/playbooks/wsl-setup.ps1 new file mode 100644 index 000000000..505a5ef0d --- /dev/null +++ b/relay/playbooks/wsl-setup.ps1 @@ -0,0 +1,56 @@ +# 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), +# 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' +$distro = 'Ubuntu-24.04' +$pass = 1; if ($env:RELAY_PASS) { $pass = [int]$env:RELAY_PASS } +$markerDir = Join-Path $env:ProgramData 'igneum'; New-Item -ItemType Directory -Force -Path $markerDir | Out-Null +$marker = Join-Path $markerDir 'wsl-setup.pass1' +function Strip([string] $s) { if ($null -eq $s) { return '' }; return ($s -replace "`0", '') } +Write-Host "wsl-setup pass $pass on $env:COMPUTERNAME" + +$installed = $false +try { $list = Strip (((& wsl.exe --list --quiet 2>&1) | Out-String)); if ($list -match 'Ubuntu-24\.04') { $installed = $true } } catch {} + +if ($pass -le 1 -and -not (Test-Path $marker) -and -not $installed) { + Write-Host 'pass 1: hypervisor + features' + & bcdedit.exe /set hypervisorlaunchtype auto + & dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart + & dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart + Set-Content -Path $marker -Value (Get-Date -Format o) + Write-Host 'features enabled; a restart is needed before Ubuntu can be installed' + Write-Host 'RELAY-REBOOT' + exit 0 +} + +Write-Host 'pass 2: WSL kernel, Ubuntu 24.04, the igneum user' +& wsl.exe --update 2>&1 | ForEach-Object { Strip "$_" } +& wsl.exe --set-default-version 2 2>&1 | ForEach-Object { Strip "$_" } +if (-not $installed) { + & wsl.exe --install -d $distro --no-launch 2>&1 | ForEach-Object { Strip "$_" } + Start-Sleep 5 +} +$list = Strip (((& wsl.exe --list --verbose 2>&1) | Out-String)); Write-Host $list +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; ' + + '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 + +$wslconfig = Join-Path $env:USERPROFILE '.wslconfig' +if (-not (Test-Path $wslconfig)) { + $ramGb = [math]::Floor((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB) + $give = [math]::Max(16, [math]::Floor($ramGb * 0.75)) + "[wsl2]`nmemory=${give}GB`nswap=16GB`n" | Set-Content -Path $wslconfig -Encoding ascii + Write-Host "wrote $wslconfig (memory=${give}GB of $ramGb GB)" + & wsl.exe --shutdown 2>&1 | Out-Null +} +Write-Host 'check: user, kernel, GPU inside WSL' +& wsl.exe -d $distro -- bash -lc 'id; uname -r; nvidia-smi --query-gpu=name,driver_version --format=csv,noheader 2>/dev/null || ls -l /usr/lib/wsl/lib/libcuda.so.1 2>/dev/null || echo "no GPU visible inside WSL (update the Windows NVIDIA driver, then wsl --update)"' 2>&1 | ForEach-Object { Strip "$_" } +Write-Host 'wsl-setup done' +exit 0 diff --git a/relay/robots.txt b/relay/robots.txt new file mode 100644 index 000000000..1f53798bb --- /dev/null +++ b/relay/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/relay/ui.html b/relay/ui.html new file mode 100644 index 000000000..8d231affd --- /dev/null +++ b/relay/ui.html @@ -0,0 +1,328 @@ + + + + + +Igneum relay + + + + + + + + + +
+
IGNEUMrelay
+
connecting
+
+
+
+
+
+
+ + +
+
+ + + +
+
+
+
+
loading
+ +
+ + + + diff --git a/relay/vercel.json b/relay/vercel.json new file mode 100644 index 000000000..d9cea77fb --- /dev/null +++ b/relay/vercel.json @@ -0,0 +1,17 @@ +{ + "cleanUrls": true, + "trailingSlash": false, + "rewrites": [ + { "source": "/r/:token", "destination": "/ui" }, + { "source": "/r/:token/api/:fn", "destination": "/api/relay?token=:token&fn=:fn" } + ], + "headers": [ + { "source": "/(.*)", "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "X-Robots-Tag", "value": "noindex, nofollow" }, + { "key": "Referrer-Policy", "value": "no-referrer" }, + { "key": "Cache-Control", "value": "no-store" } + ] } + ] +} diff --git a/tools/relay.mjs b/tools/relay.mjs new file mode 100644 index 000000000..28ad873d4 --- /dev/null +++ b/tools/relay.mjs @@ -0,0 +1,140 @@ +#!/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 read print an item; its file is downloaded to --out (default $TMPDIR/igneum-relay) +// node tools/relay.mjs drop "" | post a note or a file from the Mac [--to PC1] [--title "..."] +// node tools/relay.mjs task "title" [file] [--body "..."] a task for a person or a Claude session on that PC +// node tools/relay.mjs run "title" [--elevated] [--reboot-continue] a script the igneum-agent runs +// node tools/relay.mjs watch [--since ] poll every 10 s and print new items (results included) +// node tools/relay.mjs inbox [--ack] what that machine has not read yet +// node tools/relay.mjs machines | role | name +// node tools/relay.mjs ack | done | rm | url +// Reads ~/.config/igneum/relay-token (the URL secret), log-intake-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'; +import { homedir, tmpdir, hostname } from 'node:os'; +import { basename, join, resolve } from 'node:path'; + +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('log-intake-key'); 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 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; } + 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 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; +} +async function uploadFile(path) { + const buf = readFileSync(path); const name = basename(path); + const t = await api('upload', { body: { name, size: buf.length } }); + if (buf.length > t.max) throw new Error(`${name} is ${buf.length} bytes, over the ${t.max} byte cap`); + const r = await fetch(t.put_url, { method: 'PUT', body: buf, headers: { authorization: `Bearer ${t.token}`, 'x-api-version': t.api_version, 'x-add-random-suffix': '1', 'x-content-type': 'application/octet-stream' } }); + const j = await r.json().catch(() => ({})); + if (!r.ok || !j.url) throw new Error(`blob upload failed: ${r.status} ${JSON.stringify(j).slice(0, 200)}`); + return { file_name: name, file_url: j.url, size: buf.length }; +} +const fmtSize = n => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`; +const when = ts => new Date(ts).toISOString().replace('T', ' ').slice(5, 16); +function line(it) { + const route = it.to === 'all' ? it.from : `${it.from} > ${it.to}`; + const st = it.done ? ' done' : it.read && (it.kind === 'task' || it.kind === 'run') ? ' read' : (it.kind === 'task' || it.kind === 'run') ? ' NEW' : ''; + const first = (it.body || '').split('\n').find(l => l.trim()) || ''; + const file = it.has_file ? ` [${it.file_name} ${fmtSize(it.size)}]` : ''; + return `${('#' + it.id).padStart(5)} ${when(it.ts)} ${it.kind.padEnd(6)} ${route.padEnd(12)} ${it.title ? it.title + (first ? ': ' : '') : ''}${first.slice(0, 90)}${file}${st}`; +} +function printItem(it) { + console.log(`===== #${it.id} ${it.kind} from ${it.from} to ${it.to} at ${it.ts}${it.title ? ` | ${it.title}` : ''} =====`); + const fl = Object.entries(it.flags || {}).filter(([, v]) => v !== false).map(([k, v]) => v === true ? k : `${k}=${v}`).join(' '); + if (fl || it.task_id) console.log(`[${[fl, it.task_id ? `task #${it.task_id}` : '', it.done ? 'done' : it.read ? 'read' : ''].filter(Boolean).join(' | ')}]`); + if (it.body) process.stdout.write(it.body.endsWith('\n') ? it.body : it.body + '\n'); +} +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' }); + 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; +} +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'); + return s; +} + +try { + if (cmd === 'url') { console.log(WEB); } + else if (cmd === 'list') { + const n = Number(pos[1]) || 50; const q = { limit: n }; if (flags.machine) q.machine = flags.machine; + const j = await api('feed', { q }); + const waiting = j.machines.filter(m => m.unread).map(m => `${m.name} ${m.unread}`).join(', '); + console.log(`${WEB}\nmachines: ${j.machines.map(m => `${m.name}${m.role ? '/' + m.role : ''}${m.named === false ? ' (unnamed, hostname ' + m.hostname + ')' : ''}${m.last_seen ? ' seen ' + when(m.last_seen) : ''}`).join(' | ')}${waiting ? `\nwaiting: ${waiting}` : ''}`); + if (!j.items.length) console.log('no items yet'); + for (const it of j.items) console.log(line(it)); + } + else if (cmd === 'read') { + const it = (await api('item', { q: { id: pos[1] } })).item; printItem(it); + if (it.has_file) console.log(`file: ${await download(it)}`); + } + else if (cmd === 'drop') { + const what = pos[1]; if (!what) throw new Error('drop "" or drop '); + const o = { from: flags.from || 'Mac', to: flags.to || 'all', title: flags.title || '' }; + if (existsSync(what) && statSync(what).isFile()) Object.assign(o, await uploadFile(what), { kind: 'file' }); else { o.body = what; o.kind = flags.kind || 'text'; } + const r = await api('drop', { body: o }); console.log(`sent #${r.id} (${r.kind})`); + } + else if (cmd === 'task' || cmd === 'run') { + const [, to, title, file] = pos; + if (!to || !title) throw new Error(`${cmd} "title" ${cmd === 'run' ? '