From e02f5e14d082ed5ffe762a27228a08dca54b7fe3 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:28:03 +0000 Subject: [PATCH] ui-ota: the publisher (tools/ui-ota/publish.mjs), publish-manifest.sh --ui and --no-ui, the plan with the security notes publish.mjs packs the fifteen served files with one fixed mtime (reproducible; the self-test checks it), hashes, signs the entry through igneum-ota-sign sign-ui with the key in ~/.config/igneum (never read or printed here), copies the bundle into the folder's ui/ and hands ui.json to publish-manifest.sh --ui, the one writer of the signed manifest, which verifies the entry and the bundle's hash before signing; --dry-run writes nothing, --verify reads the live manifest back against dl//ui and dl/public/ui; --no-ui withdraws the channel. Both self-tests sit on the one gate. docs/plans/ui-ota.md: the shape, the engine, the security notes, the operator recipe, the tests, per tier. Co-Authored-By: Claude Fable 5.1 --- docs/plans/ui-ota.md | 121 +++++++++++++++++++++ packaging/ota/publish-manifest.sh | 34 +++++- tools/ci/pre-push.sh | 3 +- tools/ui-ota/publish.mjs | 174 ++++++++++++++++++++++++++++++ 4 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 docs/plans/ui-ota.md create mode 100644 tools/ui-ota/publish.mjs diff --git a/docs/plans/ui-ota.md b/docs/plans/ui-ota.md new file mode 100644 index 00000000..eecea7bd --- /dev/null +++ b/docs/plans/ui-ota.md @@ -0,0 +1,121 @@ +# The interface over the air: smaller UI changes without a new version + +7 October 2026, 10:5x UK. the project lead: "can we make smaller UI based updates over the air without a new version being +needed?" Branch `ui-ota`, worktree `../igneum-wt-ui-ota`, off the miner-ui-5 tip e9fe1106; the shipper takes it into +0.3.20. The dashboard (app/igneum-app/ui: index.html, app.js, app.css, live-dag.js, proof-core.js, the fonts, the +mark) is embedded in the engine binary today; from 0.3.20 a signed bundle of the same folder can replace it between +app versions. The engine, the node, the miner and the workers never move this way: a bundle is files the engine +serves on 127.0.0.1, nothing it runs. + +## 1. What a miner sees + +| Where | What | +|---|---| +| Nothing, usually | The hourly manifest check finds an interface bundle, downloads about 400 KB, checks it, swaps it in; the open window reloads itself the next second it is idle (no input focused, no sheet or update card open, not mid-setup). | +| Settings > Interface | "Interface 1.0.1, over the air, 7 Oct 2026" or "Interface 1.0.0, built in"; the help line says what is pending, refused, held back or skipped. | +| Settings > Interface, the switch | "Use the built-in interface": the embedded dashboard serves even when a bundle is active; off again takes the bundle at the next load. | +| Activity | "interface 1.0.1 is published: downloading (412 KB)", "interface 1.0.1 installed; the window takes it at its next load", or "interface 1.0.1 was refused: ... The built-in interface serves". | + +## 2. The manifest's interface channel + +The signed manifest (igneum-app-latest.json, src/manifest.rs) gains one object, covered by the manifest's own +signature like every other field: + +``` +"ui": { "version": "1.0.1", "sha256": "<64 hex>", "size": 412345, + "url": "https://dl.igneum.network/dl//ui/igneum-ui-1.0.1.tar.gz", + "min_engine": "0.3.20", "signature": "<128 hex>" } +``` + +| Field | Rule | +|---|---| +| version | the interface's own three-part line (1.0.0 is the one embedded in 0.3.20; ui/VERSION in the tree) | +| sha256, size | of the tar.gz; both checked after the download | +| url | https on the downloads host, under ui/; a bundle is never fetched from anywhere the manifest did not name | +| min_engine | the lowest app version that may serve it; an engine below it ignores the entry and says so in its log | +| signature | Ed25519 by the release key (the public half pinned in src/manifest.rs) over `igneum-ui\n\n\n\n` (`manifest::ui_sign_bytes`); the bundle stays verifiable on its own, and the shipper's point stands: the manifest signature already covers it, this one is the belt to that brace | + +The kill switch is the manifest without a `ui` object (`publish-manifest.sh --no-ui`): every engine retires its bundle +at the next check and the built-in interface serves. A bundle that fails any check is marked bad on that machine +and never applied again; the next version is a new chance. + +## 3. The engine (src/uiota.rs, src/ota.rs, src/server.rs) + +| Step | What happens | +|---|---| +| decide | `uiota::decide`: skip when the built-in interface is chosen, when min_engine is newer than the engine, when the version was marked bad, when it is the active one, or when it is the embedded one | +| download | curl with resume into `/ui/.tar.gz`; size and sha256 against the entry; the entry's signature against the pinned key | +| unpack | the system tar into `/ui/.new`; index.html, app.js and app.css must be there; the bundle's VERSION must say the manifest's version; renamed to `/ui/` | +| swap | `/ui/current.json` (written to .tmp, renamed) names the version; the server reads the pointer through `Shared::ui_dir` and serves the bundle's files for the fixed names in `uiota::SERVED` (nothing else is read from the folder; a file the bundle lacks falls back to the embedded one); the embedded files stay in the binary | +| reload | `state.ui.active_version` changes; the page compares it with the version it loaded (`View.shouldReload`) and reloads when idle | +| confirm | the first page load from an unconfirmed bundle starts a 10 s wait; the page POSTs `/api/ui/health` after its first paint (a `window.error` before that posts the error instead); a ping confirms, an error or silence rolls back | +| roll back | the pointer goes back to "embedded", the version lands in `/ui/bad.json`, one Activity line, one log line (`ui: marked bad: `) | +| state | `state.ui` {embedded_version, active_version, source, installed_at, confirmed, published_version, busy, error, bad, builtin, note}; `settings.ui_builtin` | + +Log lines the shipper reads back: `ui bundle active (installed )`, `ui bundle confirmed +by the page`, `ui: marked bad: `, `ui: no ui object in the manifest; interface retired`. + +## 4. Security notes + +- Same origin: the bundle is served by this engine on 127.0.0.1 under the per-launch token path, exactly as the + embedded files are; the page's fetches, the dashboard's POST rule (`from_dashboard`) and the token are unchanged. +- No remote scripts: the bundle is a copy of the tree's ui folder; index.html loads only its own files; the + test `ui-ota.test.mjs` fails on a `