diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2f1535f..5b37b644 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,5 @@ jobs: run: bash tools/ci/copied-sources-check.sh - name: relay unit tests (parsers, secret compare, the wake endpoint) run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs + - name: miner app notice strip (ordering, keys, wording, timers) + run: node --test app/igneum-app/ui/notices.test.mjs diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index ef8b57bd..377b452e 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -13,7 +13,23 @@ # Inputs that are not in git (igneumd.exe, igneum-miner.exe from the node fork; the prebuilt GPU workers with NVIDIA's # NVRTC DLLs) come from payload-inputs.zip on the downloads host, published by packaging/windows/push-inputs.sh on the # Mac; the DL_TOKEN repository secret is the path token (gh secret set DL_TOKEN < ~/.config/igneum/dl-token). +# The zip is trusted only through payload-inputs.json and its detached Ed25519 signature, made on the Mac with the +# OTA key: the step "payload inputs" verifies the signature with the public key compiled into the app +# (igneum-ota-sign verify-inputs embedded, built by the engine step), checks the zip's sha256 and every unpacked +# file against the manifest, and checks the manifest's node commit against packaging/windows/node-source.pin in +# this checkout, all before anything is built from them (review round 4, R4.5.2, ledger G13). The verified +# manifest, its signature and the runner's record go up as the igneum-windows-inputs artifact, which +# packaging/windows/fetch-ci-artifacts.sh re-verifies on the Mac before it will sign an update manifest. # The Mac side of the loop is packaging/windows/fetch-ci-artifacts.sh (gh run download into the downloads folder). +# +# The packaged configuration (rotation phase 2, 5 October 2026; docs/plans/rotation-phase-2.md): the runner writes the +# repository secrets to the same files the Mac keeps under ~/.config/igneum, and make-payload.sh picks them exactly as +# on the Mac (packaging/mac/packaged-config.sh: a .next file wins when present). +# LOG_INTAKE_KEY required: the intake key the payload ships (gh secret set LOG_INTAKE_KEY < ~/.config/igneum/log-intake-key) +# LOG_INTAKE_KEY_NEXT optional, during a rotation: the next key; when set it is the one the payload ships +# DL_TOKEN required: the folder the inputs come from, and the manifest folder when no DL_TOKEN_NEXT +# DL_TOKEN_NEXT optional, during a rotation: the manifest folder the payload checks +# After a rotation the owner sets LOG_INTAKE_KEY and DL_TOKEN to the new values and deletes the two _NEXT secrets. name: windows-ci on: push: @@ -110,28 +126,65 @@ jobs: cargo build --release --locked ls -la target/release/igneum-app.exe - - name: payload inputs (payload-inputs.zip from the downloads host, sha256 checked) + - name: packaged configuration (the secrets as the files packaged-config.sh reads; values never echoed) + shell: bash + env: + DL_TOKEN: ${{ secrets.DL_TOKEN }} + DL_TOKEN_NEXT: ${{ secrets.DL_TOKEN_NEXT }} + LOG_INTAKE_KEY: ${{ secrets.LOG_INTAKE_KEY }} + LOG_INTAKE_KEY_NEXT: ${{ secrets.LOG_INTAKE_KEY_NEXT }} + run: | + set -euo pipefail + mkdir -p "$HOME/.config/igneum" + if [ -z "${DL_TOKEN:-}" ]; then + echo "::error::the DL_TOKEN repository secret is not set. On the Mac: tr -d '[:space:]' < ~/.config/igneum/dl-token | gh secret set DL_TOKEN --repo igneum-network/igneum" + exit 1 + fi + if [ -z "${LOG_INTAKE_KEY:-}" ] && [ -z "${LOG_INTAKE_KEY_NEXT:-}" ]; then + echo "::error::neither LOG_INTAKE_KEY nor LOG_INTAKE_KEY_NEXT is set; the payload would ship without an intake key. On the Mac: tr -d '[:space:]' < ~/.config/igneum/log-intake-key | gh secret set LOG_INTAKE_KEY --repo igneum-network/igneum" + exit 1 + fi + printf '%s' "$DL_TOKEN" > "$HOME/.config/igneum/dl-token" + [ -n "${DL_TOKEN_NEXT:-}" ] && printf '%s' "$DL_TOKEN_NEXT" > "$HOME/.config/igneum/dl-token.next" + [ -n "${LOG_INTAKE_KEY:-}" ] && printf '%s' "$LOG_INTAKE_KEY" > "$HOME/.config/igneum/log-intake-key" + [ -n "${LOG_INTAKE_KEY_NEXT:-}" ] && printf '%s' "$LOG_INTAKE_KEY_NEXT" > "$HOME/.config/igneum/log-intake-key.next" + chmod 600 "$HOME"/.config/igneum/* + echo "files: $(ls "$HOME/.config/igneum" | tr '\n' ' ')" + bash packaging/mac/packaged-config.sh --test + + - name: payload inputs (payload-inputs.zip from the downloads host, signature, hashes and node commit verified) shell: bash env: DL_TOKEN: ${{ secrets.DL_TOKEN }} run: | set -euo pipefail - if [ -z "${DL_TOKEN:-}" ]; then - echo "::error::the DL_TOKEN repository secret is not set. On the Mac: tr -d '[:space:]' < ~/.config/igneum/dl-token | gh secret set DL_TOKEN --repo igneum-network/igneum" - exit 1 - fi base="https://dl.igneum.network/dl/$DL_TOKEN" - mkdir -p build/inputs "$HOME/.config/igneum" - printf '%s' "$DL_TOKEN" > "$HOME/.config/igneum/dl-token" # make-payload.sh reads it for the update manifest URL + signer="app/igneum-app/target/release/igneum-ota-sign.exe" + [ -x "$signer" ] || { echo "::error::$signer was not built by the engine step"; exit 1; } + pin="packaging/windows/node-source.pin" + [ -s "$pin" ] || { echo "::error::$pin is missing: push-inputs.sh writes it, commit it with the inputs push"; exit 1; } + mkdir -p build/inputs # ~/.config/igneum/dl-token was written by the packaged configuration step curl -fsSL --retry 3 -o build/payload-inputs.json "$base/payload-inputs.json" - curl -fsSL --retry 3 -o build/payload-inputs.sha256 "$base/payload-inputs.sha256" + curl -fsSL --retry 3 -o build/payload-inputs.json.sig "$base/payload-inputs.json.sig" curl -fsSL --retry 3 -o build/payload-inputs.zip "$base/payload-inputs.zip" - echo "$(tr -d '[:space:]' < build/payload-inputs.sha256) build/payload-inputs.zip" | sha256sum -c - + echo "inputs manifest:"; cat build/payload-inputs.json + # 1. the signature (the key compiled into the app), the zip's sha256 and size, the pinned node commit: all before unpacking + "$signer" verify-inputs embedded build/payload-inputs.json build/payload-inputs.json.sig --zip build/payload-inputs.zip --node-commit "$pin" 7z x -y -bso0 -bsp0 -obuild/inputs-unpacked build/payload-inputs.zip mv build/inputs-unpacked/payload-inputs/* build/inputs/ - echo "inputs manifest:"; cat build/payload-inputs.json + # 2. every unpacked file by sha256 and size, and nothing in the folder the manifest does not name + "$signer" verify-inputs embedded build/payload-inputs.json build/payload-inputs.json.sig --dir build/inputs echo "inputs:"; ls -la build/inputs for f in igneumd.exe igneum-miner.exe; do [ -f "build/inputs/$f" ] || { echo "::error::payload-inputs.zip has no $f"; exit 1; }; done + # 3. the runner's record for fetch-ci-artifacts.sh, which re-verifies the signature and the pin on the Mac + fp="$("$signer" embedded | sed -n 2p)" + node_commit="$(jq -r .node_source_commit build/payload-inputs.json)" + zip_sha="$(jq -r .zip.sha256 build/payload-inputs.json)" + mkdir -p build/inputs-artifact + cp build/payload-inputs.json build/payload-inputs.json.sig build/inputs-artifact/ + printf '{ "run_id": "%s", "run_attempt": "%s", "head_sha": "%s", "key_fingerprint": "%s", "node_commit": "%s", "zip_sha256": "%s", "verified_at": "%s" }\n' \ + "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" "$GITHUB_SHA" "$fp" "$node_commit" "$zip_sha" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > build/inputs-artifact/inputs-verified.json + cat build/inputs-artifact/inputs-verified.json - name: window host (app\windows\BUILD-APP.bat, exactly as on the PC) shell: cmd @@ -211,7 +264,9 @@ jobs: printf '| %s | %s |\n' "$(basename "$f")" "$(stat -c %s "$f")" done echo - echo "inputs: $(tr -d '\n' < build/payload-inputs.json | head -c 400)" + echo "inputs (signature, hashes and node commit verified): $(tr -d '\n' < build/payload-inputs.json | head -c 400)" + echo + echo "verified: $(cat build/inputs-artifact/inputs-verified.json)" } | tee -a "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@v4 @@ -232,3 +287,9 @@ jobs: path: app/windows/dist/Igneum Miner.exe retention-days: 90 if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: igneum-windows-inputs + path: build/inputs-artifact/ + retention-days: 90 + if-no-files-found: error diff --git a/app/igneum-app/Cargo.lock b/app/igneum-app/Cargo.lock index 5b99bb3b..16aeb43c 100644 --- a/app/igneum-app/Cargo.lock +++ b/app/igneum-app/Cargo.lock @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "igneum-app" -version = "0.3.5" +version = "0.3.7" dependencies = [ "ed25519-dalek", "getrandom", diff --git a/app/igneum-app/Cargo.toml b/app/igneum-app/Cargo.toml index 1c03ca9d..5c6630a8 100644 --- a/app/igneum-app/Cargo.toml +++ b/app/igneum-app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "igneum-app" -version = "0.3.5" +version = "0.3.7" edition = "2021" description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1" license = "MIT" @@ -16,6 +16,12 @@ path = "src/main.rs" name = "igneum-ota-sign" path = "src/bin/ota-sign.rs" +# the Windows proof verifier wrapper (release 0.3.6): the node runs it as IGNEUM_PROOF_VERIFIER and it runs +# igneum-prove-host inside WSL2; shipped next to the engine by packaging/windows/make-payload.sh +[[bin]] +name = "igneum-prove-verify" +path = "src/bin/prove-verify.rs" + [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/app/igneum-app/resources/igneum-app.rc b/app/igneum-app/resources/igneum-app.rc index e6fc9030..154d5524 100644 --- a/app/igneum-app/resources/igneum-app.rc +++ b/app/igneum-app/resources/igneum-app.rc @@ -6,8 +6,8 @@ 1 ICON "igneum.ico" 1 VERSIONINFO -FILEVERSION 0,3,5,0 -PRODUCTVERSION 0,3,5,0 +FILEVERSION 0,3,7,0 +PRODUCTVERSION 0,3,7,0 FILEFLAGSMASK 0x3fL FILEFLAGS 0x0L FILEOS VOS_NT_WINDOWS32 @@ -20,12 +20,12 @@ BEGIN BEGIN VALUE "CompanyName", "Igneum" VALUE "FileDescription", "Igneum Miner engine" - VALUE "FileVersion", "0.3.5" + VALUE "FileVersion", "0.3.7" VALUE "InternalName", "igneum-app" VALUE "LegalCopyright", "Igneum contributors" VALUE "OriginalFilename", "igneum-app.exe" VALUE "ProductName", "Igneum Miner" - VALUE "ProductVersion", "0.3.5" + VALUE "ProductVersion", "0.3.7" END END BLOCK "VarFileInfo" diff --git a/app/igneum-app/src/bin/ota-sign.rs b/app/igneum-app/src/bin/ota-sign.rs index 87f71c6a..436121a9 100644 --- a/app/igneum-app/src/bin/ota-sign.rs +++ b/app/igneum-app/src/bin/ota-sign.rs @@ -9,11 +9,18 @@ //! igneum-ota-sign sha256 the file's sha256 and size, for the manifest //! igneum-ota-sign sign-jobs the remote-jobs file (src/jobs.rs), same key //! igneum-ota-sign verify-jobs +//! igneum-ota-sign sign-inputs the Windows build inputs (src/inputs.rs), same key +//! igneum-ota-sign verify-inputs +//! [--zip ] [--dir ] [--node-commit <40 hex>] +//! exit 0 only when the signature, the zip, every +//! unpacked file and the pinned commit all check #[path = "../manifest.rs"] mod manifest; #[path = "../jobs.rs"] mod jobs; +#[path = "../inputs.rs"] +mod inputs; use ed25519_dalek::{Signer, SigningKey}; use std::path::Path; @@ -115,8 +122,54 @@ fn main() { Err(e) => die(&e), } } + Some("sign-inputs") if args.len() == 3 => { + let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex")); + let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes")); + let sk = SigningKey::from_bytes(&seed); + let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2]))); + let text = std::str::from_utf8(&bytes).unwrap_or_else(|_| die("inputs manifest is not UTF-8")); + let m = inputs::parse(text).unwrap_or_else(|e| die(&format!("refusing to sign: {e}"))); + eprintln!( + "signing inputs built {} from node commit {} ({}): zip {} bytes, {} file(s)", + m.built_at, + &m.node_source_commit[..12], + m.node_source_branch, + m.zip.bytes, + m.files.len() + ); + println!("{}", manifest::hex_encode(&sk.sign(&bytes).to_bytes())); + } + Some("verify-inputs") if args.len() >= 4 => { + let pk = if args[1] == "embedded" { manifest::OTA_PUBLIC_KEY_HEX.to_string() } else { read_key_arg(&args[1]) }; + let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2]))); + let sig_text = std::fs::read_to_string(&args[3]).unwrap_or_else(|e| die(&format!("{}: {e}", args[3]))); + let sig = inputs::read_signature(&sig_text).unwrap_or_else(|e| die(&e)); + let m = inputs::verify_and_parse(&bytes, &sig, &pk).unwrap_or_else(|e| die(&format!("inputs signature: {e}"))); + let mut i = 4; + let mut checked: Vec = vec![format!("signature by {}", manifest::fingerprint(&pk))]; + while i < args.len() { + match (args[i].as_str(), args.get(i + 1)) { + ("--zip", Some(z)) => { + inputs::check_zip(&m, Path::new(z)).unwrap_or_else(|e| die(&e)); + checked.push(format!("zip {} ({} bytes)", m.zip.sha256, m.zip.bytes)); + } + ("--dir", Some(d)) => { + inputs::check_dir(&m, Path::new(d)).unwrap_or_else(|e| die(&e)); + checked.push(format!("{} unpacked file(s)", m.files.len())); + } + ("--node-commit", Some(c)) => { + let c = if Path::new(c).is_file() { std::fs::read_to_string(c).unwrap_or_default() } else { c.to_string() }; + inputs::check_node_commit(&m, &c).unwrap_or_else(|e| die(&e)); + checked.push(format!("node commit {}", m.node_source_commit)); + } + (flag, _) => die(&format!("unknown or incomplete argument {flag}")), + } + i += 2; + } + println!("ok: inputs built {} from node commit {} ({}); checked: {}", m.built_at, m.node_source_commit, m.node_source_branch, checked.join(", ")); + } _ => { - eprintln!("usage: igneum-ota-sign keygen | sign | verify | embedded | fingerprint | sha256 | sign-jobs | verify-jobs "); + eprintln!("usage: igneum-ota-sign keygen | sign | verify | embedded | fingerprint | sha256 | sign-jobs | verify-jobs | sign-inputs | verify-inputs [--zip z] [--dir d] [--node-commit c]"); std::process::exit(2); } } diff --git a/app/igneum-app/src/bin/prove-verify.rs b/app/igneum-app/src/bin/prove-verify.rs new file mode 100644 index 00000000..ea20ccef --- /dev/null +++ b/app/igneum-app/src/bin/prove-verify.rs @@ -0,0 +1,157 @@ +//! igneum-prove-verify: the Windows wrapper the node's proof pool verifier calls (spec 7.7 item 4, release 0.3.6). +//! +//! The node on a PC is a Windows exe; the SP1 host (`igneum-prove-host`) is Linux-only and lives inside WSL2. +//! The engine sets `IGNEUM_PROOF_VERIFIER=` for its node, and the node runs +//! `igneum-prove-verify.exe --mode verify --proof --statement 0x...`. This wrapper converts the proof +//! path with `wslpath -a` inside Ubuntu-24.04, finds the host in the same order the prover uses +//! (src/wslhost.rs: the payload's wsl2/bin, the setup-wsl.sh build, the old layout, /opt/igneum), runs it +//! there with the same arguments and exits with its exit code. +//! +//! igneum-prove-verify --probe prints `HOST ` and exits 0 when a host is found; exits 2 otherwise +//! igneum-prove-verify runs the host; exit 2 when there is no host or WSL did not answer +//! +//! Exit 2 is reserved for "no host": the engine probes before it sets the variable, so a node never gets a +//! verifier that cannot run. The wrapper never trusts a proof it did not verify. +//! +//! No console of its own on Windows: the node that starts it has none (the engine starts igneumd with +//! CREATE_NO_WINDOW), so a console-subsystem wrapper would open a visible window on every verification +//! (5 October 2026: a console window on both PCs). Piped stdout and the exit code still reach the caller. + +#![cfg_attr(windows, windows_subsystem = "windows")] + +#[path = "../wslhost.rs"] +mod wslhost; + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const NO_HOST: i32 = 2; + +fn wsl_exe() -> PathBuf { + #[cfg(windows)] + { + let root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into()); + PathBuf::from(format!("{root}\\System32\\wsl.exe")) + } + #[cfg(not(windows))] + { + PathBuf::from("wsl") + } +} + +fn quiet(cmd: &mut Command) -> &mut Command { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + cmd +} + +/// The drive-letter mapping (src/wslhost.rs). A `wslpath -a` round trip through wsl.exe was dropped on 5 October +/// 2026: a path with a space on that command line is split by the shell inside the distribution. +fn to_wsl(p: &Path) -> String { + wslhost::wsl_path(p) +} + +/// The script file: runs the probe or the host (`write_script` under %LOCALAPPDATA%\igneum\wsl, removed after the +/// run); exits 2 when the file cannot be written. +fn script_file(stem: &str, body: &str) -> wslhost::ScriptFile { + match wslhost::write_script(stem, body) { + Ok(f) => f, + Err(e) => { + eprintln!("igneum-prove-verify: cannot write the {stem} script under {}: {e}", wslhost::script_dir().display()); + std::process::exit(NO_HOST); + } + } +} + +/// The host's arguments with `--proof ` rewritten for WSL. Pure, so it has a test. +pub fn rewrite_args String>(args: &[String], to_wsl: F) -> Vec { + let mut out = Vec::with_capacity(args.len()); + let mut i = 0; + while i < args.len() { + let a = &args[i]; + if a == "--proof" && i + 1 < args.len() { + out.push(a.clone()); + out.push(to_wsl(Path::new(&args[i + 1]))); + i += 2; + continue; + } + if let Some(v) = a.strip_prefix("--proof=") { + out.push(format!("--proof={}", to_wsl(Path::new(v)))); + i += 1; + continue; + } + out.push(a.clone()); + i += 1; + } + out +} + +/// The script that finds the host and replaces the shell with it: the host's exit code is the script's. With no +/// host, a line on stderr naming the places looked at, and exit 2. +pub fn run_script(bin_dir: &Path) -> String { + let lookup = wslhost::lookup_script(bin_dir); + format!( + "h=$({lookup}); if [ -n \"$h\" ]; then exec \"$h\" \"$@\"; fi; echo 'igneum-prove-verify: no igneum-prove-host in WSL2 (looked at: {})' >&2; exit {NO_HOST}", + wslhost::candidates_text(bin_dir).replace('\'', "'\\''") + ) +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let bin_dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).unwrap_or_default(); + if args.iter().any(|a| a == "--version" || a == "-V") { + println!("igneum-prove-verify {}", env!("CARGO_PKG_VERSION")); + return; + } + if args.iter().any(|a| a == "--probe") { + let file = script_file("verify-probe", &wslhost::lookup_script(&bin_dir)); + let out = quiet(&mut wslhost::command(&wsl_exe(), wslhost::DISTRO, None, &file.path, true, &[])).stdin(Stdio::null()).output(); + let host = out.ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); + if host.is_empty() { + eprintln!("igneum-prove-verify: no igneum-prove-host in WSL2 ({}) (looked at: {})", wslhost::DISTRO, wslhost::candidates_text(&bin_dir)); + std::process::exit(NO_HOST); + } + println!("HOST {host}"); + return; + } + let host_args = rewrite_args(&args, to_wsl); + let file = script_file("verify-run", &run_script(&bin_dir)); + let argv: Vec<&str> = host_args.iter().map(|a| a.as_str()).collect(); + let status = quiet(&mut wslhost::command(&wsl_exe(), wslhost::DISTRO, None, &file.path, true, &argv)).stdin(Stdio::null()).status(); + match status { + Ok(st) => std::process::exit(st.code().unwrap_or(1)), + Err(e) => { + eprintln!("igneum-prove-verify: WSL2 did not start ({}): {e}", wsl_exe().display()); + std::process::exit(NO_HOST); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_proof_path_is_rewritten() { + let args: Vec = ["--mode", "verify", "--proof", "C:\\Users\\x\\p.bin", "--statement", "0xab"].iter().map(|s| s.to_string()).collect(); + let out = rewrite_args(&args, |p| wslhost::wsl_path(p)); + assert_eq!(out, vec!["--mode", "verify", "--proof", "/mnt/c/Users/x/p.bin", "--statement", "0xab"]); + let args: Vec = vec!["--proof=D:\\q.bin".into()]; + assert_eq!(rewrite_args(&args, |p| wslhost::wsl_path(p)), vec!["--proof=/mnt/d/q.bin"]); + } + + #[test] + fn the_script_execs_the_first_host_and_exits_2_without_one() { + let s = run_script(Path::new("C:\\Igneum")); + assert!(s.starts_with("h=$(for f in '/mnt/c/Igneum/wsl2/bin/igneum-prove-host' ~/igneum-prove/proving/igneum-prove/target/release/igneum-prove-host ~/igneum-prove/target/release/igneum-prove-host '/opt/igneum/igneum-prove-host'; do"), "{s}"); + assert!(s.contains("exec \"$h\" \"$@\"; fi;"), "{s}"); + // the arguments travel on the command line as $1, $2... with no double quote anywhere + let line = wslhost::bash_line(Path::new("C:\\Users\\x\\AppData\\Local\\igneum\\wsl\\verify-run-1-0.sh"), true, &["--mode", "verify", "--proof", "/mnt/c/Users/x/p.bin", "--statement", "0xab"]); + assert_eq!(line, "bash -l '/mnt/c/Users/x/AppData/Local/igneum/wsl/verify-run-1-0.sh' '--mode' 'verify' '--proof' '/mnt/c/Users/x/p.bin' '--statement' '0xab'"); + assert!(s.ends_with("exit 2")); + assert!(s.contains("looked at: /mnt/c/Igneum/wsl2/bin/igneum-prove-host, ~/igneum-prove/proving/")); + } +} diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 1b36edb5..5bdcfaf1 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -83,6 +83,10 @@ pub struct Settings { /// Lifetime dev-fee blocks this machine found (the miner's `dev-fee block` lines), carried across runs. #[serde(default)] pub fee_total: u64, + /// Devnet only: when no verifier is found next to the engine, start the node with `IGNEUM_PROOF_VERIFY=trust` + /// so it includes proof records it never verified (src/verifier.rs). Default off; a found verifier always wins. + #[serde(default)] + pub proof_verify_trust: bool, } fn one() -> u32 { @@ -94,7 +98,7 @@ fn yes() -> bool { impl Default for Settings { fn default() -> Settings { - Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, installed_at: 0, dev_fee: true, fee_total: 0 } + Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false } } } @@ -154,16 +158,100 @@ pub struct Packaged { /// height, 4 October 2026: `{"difficulty_v2_activation_daa": N}`). Absent or empty = no override file. #[serde(default)] pub node_override_params: Option, + /// Where the key and the manifest came from, for the log header: "packaged", "file " or "none". Never + /// serialised (the packaged file does not carry them; nothing sends this struct to the UI). + #[serde(skip)] + pub key_source: String, + #[serde(skip)] + pub manifest_source: String, +} + +/// The downloads host; the manifest of a folder is `/dl//igneum-app-latest.json` +/// (packaging/mac/packaged-config.sh builds the same URL). +pub const DL_HOST: &str = "https://dl.igneum.network"; +/// The intake a key file points at when the packaged file names no intake (a developer run). +pub const DEFAULT_INTAKE_URL: &str = "https://igneum-six.vercel.app/api/log"; + +/// The manifest URL for a downloads token; empty for an empty token. +pub fn manifest_url_for_token(token: &str) -> String { + let t = token.trim(); + if t.is_empty() { + String::new() + } else { + format!("{DL_HOST}/dl/{t}/igneum-app-latest.json") + } +} + +/// The downloads token inside a manifest URL of the standard shape, or None. +pub fn token_of_manifest_url(url: &str) -> Option<&str> { + let rest = url.strip_prefix(DL_HOST)?.strip_prefix("/dl/")?; + let (token, file) = rest.split_once('/')?; + (file == "igneum-app-latest.json" && !token.is_empty()).then_some(token) +} + +/// A secret from a file: trimmed, None when the file is missing or blank. +pub fn read_secret_file(path: &Path) -> Option { + let t = std::fs::read_to_string(path).ok()?; + let t = t.trim(); + (!t.is_empty()).then(|| t.to_string()) +} + +/// The first 8 hex of sha256 over a value: what logs and the console show instead of the value +/// (`tr -d '[:space:]' < file | shasum -a 256 | cut -c1-8` gives the same on the Mac). +pub fn fingerprint8(value: &str) -> String { + use sha2::Digest; + crate::manifest::hex_encode(&sha2::Sha256::digest(value.as_bytes()))[..8].to_string() } impl Packaged { pub fn load(candidates: &[PathBuf]) -> Packaged { for c in candidates { - if let Some(p) = std::fs::read_to_string(c).ok().and_then(|t| serde_json::from_str::(&t).ok()) { + if let Some(mut p) = std::fs::read_to_string(c).ok().and_then(|t| serde_json::from_str::(&t).ok()) { + p.key_source = if p.log_intake_key.is_empty() { "none".into() } else { "packaged".into() }; + p.manifest_source = if p.update_manifest.is_empty() { "none".into() } else { "packaged".into() }; return p; } } - Packaged::default() + Packaged { key_source: "none".into(), manifest_source: "none".into(), ..Packaged::default() } + } + + /// Rotation phase 2 (5 October 2026, docs/plans/rotation-phase-2.md): the same two variables the packagers honour + /// (packaging/mac/packaged-config.sh) work on a running engine, so a developer run or a build that was packaged + /// with the old values can report to the rotated intake and check the rotated folder without a repackage: + /// IGNEUM_INTAKE_KEY_FILE names a file holding the key, IGNEUM_DL_TOKEN_FILE a file holding the downloads token. + /// A variable that is unset, or names a missing or blank file, changes nothing. + pub fn with_env_overrides(self) -> Packaged { + let file = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()).map(PathBuf::from); + self.with_file_overrides(file("IGNEUM_INTAKE_KEY_FILE").as_deref(), file("IGNEUM_DL_TOKEN_FILE").as_deref()) + } + + pub fn with_file_overrides(mut self, key_file: Option<&Path>, token_file: Option<&Path>) -> Packaged { + let name = |p: &Path| p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.display().to_string()); + if let Some(key) = key_file.and_then(read_secret_file) { + self.log_intake_key = key; + if self.log_intake_url.is_empty() { + self.log_intake_url = DEFAULT_INTAKE_URL.into(); + } + self.key_source = format!("file {}", name(key_file.unwrap())); + } + if let Some(token) = token_file.and_then(read_secret_file) { + self.update_manifest = manifest_url_for_token(&token); + self.manifest_source = format!("file {}", name(token_file.unwrap())); + } + self + } + + /// The log header line: the intake URL with the key's fingerprint and the manifest URL with the folder's + /// fingerprint, each with its source; the values themselves never appear (the log is uploaded). + pub fn describe(&self) -> String { + let key = if self.log_intake_key.is_empty() { "no key".to_string() } else { format!("key {}", fingerprint8(&self.log_intake_key)) }; + let intake = if self.log_intake_url.is_empty() { "none".to_string() } else { self.log_intake_url.clone() }; + let (manifest, folder) = match token_of_manifest_url(&self.update_manifest) { + Some(t) => (self.update_manifest.replace(t, ""), format!("folder {}", fingerprint8(t))), + None if self.update_manifest.is_empty() => ("none".to_string(), "no folder".to_string()), + None => (self.update_manifest.clone(), "custom".to_string()), + }; + format!("config: intake {intake} {key} ({}); manifest {manifest} {folder} ({})", self.key_source, self.manifest_source) } } @@ -238,6 +326,107 @@ impl Runtime { mod tests { use super::*; + fn tmp(name: &str, content: &str) -> PathBuf { + // tests run in parallel: every file name is unique to its call + static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("igneum-config-test-{}-{n}-{}", std::process::id(), name)); + std::fs::write(&d, content).unwrap(); + d + } + + fn packaged(key: &str, token: &str) -> Packaged { + let json = format!(r#"{{"update_manifest":"{}","log_intake_url":"https://igneum-six.vercel.app/api/log","log_intake_key":"{}"}}"#, manifest_url_for_token(token), key); + let p = tmp("packaged.json", &json); + let out = Packaged::load(&[p.clone()]); + let _ = std::fs::remove_file(p); + out + } + + #[test] + fn manifest_url_round_trips_through_the_token() { + assert_eq!(manifest_url_for_token("abc123"), "https://dl.igneum.network/dl/abc123/igneum-app-latest.json"); + assert_eq!(manifest_url_for_token(" abc123\n"), "https://dl.igneum.network/dl/abc123/igneum-app-latest.json"); + assert_eq!(manifest_url_for_token(""), ""); + assert_eq!(token_of_manifest_url("https://dl.igneum.network/dl/abc123/igneum-app-latest.json"), Some("abc123")); + assert_eq!(token_of_manifest_url("https://dl.igneum.network/dl/abc123/other.json"), None); + assert_eq!(token_of_manifest_url("http://127.0.0.1:8080/dl/t/igneum-app-latest.json"), None); + assert_eq!(token_of_manifest_url(""), None); + } + + #[test] + fn secret_files_are_trimmed_and_blank_means_none() { + let f = tmp("key", " thekey0123456789abcdef \n"); + assert_eq!(read_secret_file(&f).as_deref(), Some("thekey0123456789abcdef")); + std::fs::write(&f, " \n").unwrap(); + assert_eq!(read_secret_file(&f), None); + let _ = std::fs::remove_file(&f); + assert_eq!(read_secret_file(Path::new("/nonexistent/igneum/key")), None); + } + + #[test] + fn fingerprint_matches_shasum() { + // printf abc | shasum -a 256 | cut -c1-8 + assert_eq!(fingerprint8("abc"), "ba7816bf"); + assert_eq!(fingerprint8("").len(), 8); + } + + #[test] + fn load_records_the_sources() { + let p = packaged("oldkey0123456789abcdef", "oldtok"); + assert_eq!(p.key_source, "packaged"); + assert_eq!(p.manifest_source, "packaged"); + let none = Packaged::load(&[PathBuf::from("/nonexistent/igneum-app.json")]); + assert_eq!(none.key_source, "none"); + assert_eq!(none.manifest_source, "none"); + assert!(none.update_manifest.is_empty() && none.log_intake_key.is_empty()); + } + + #[test] + fn file_overrides_replace_the_key_and_the_folder() { + let key = tmp("log-intake-key.next", "newkey0123456789abcdef\n"); + let tok = tmp("dl-token.next", "newtok\n"); + let p = packaged("oldkey0123456789abcdef", "oldtok").with_file_overrides(Some(&key), Some(&tok)); + assert_eq!(p.log_intake_key, "newkey0123456789abcdef"); + assert_eq!(p.update_manifest, "https://dl.igneum.network/dl/newtok/igneum-app-latest.json"); + assert_eq!(p.log_intake_url, "https://igneum-six.vercel.app/api/log"); + assert!(p.key_source.starts_with("file ") && p.key_source.ends_with("log-intake-key.next"), "{}", p.key_source); + assert!(p.manifest_source.ends_with("dl-token.next"), "{}", p.manifest_source); + // only the key: the folder stays packaged + let p = packaged("oldkey0123456789abcdef", "oldtok").with_file_overrides(Some(&key), None); + assert_eq!(p.update_manifest, "https://dl.igneum.network/dl/oldtok/igneum-app-latest.json"); + assert_eq!(p.manifest_source, "packaged"); + // a missing or blank file changes nothing + let blank = tmp("blank", "\n"); + let p = packaged("oldkey0123456789abcdef", "oldtok").with_file_overrides(Some(&blank), Some(Path::new("/nonexistent/dl-token"))); + assert_eq!(p.log_intake_key, "oldkey0123456789abcdef"); + assert_eq!(p.update_manifest, "https://dl.igneum.network/dl/oldtok/igneum-app-latest.json"); + assert_eq!(p.key_source, "packaged"); + // a developer run with no packaged file at all: the key file brings the default intake + let p = Packaged::load(&[PathBuf::from("/nonexistent/igneum-app.json")]).with_file_overrides(Some(&key), Some(&tok)); + assert_eq!(p.log_intake_url, DEFAULT_INTAKE_URL); + assert_eq!(p.update_manifest, "https://dl.igneum.network/dl/newtok/igneum-app-latest.json"); + for f in [key, tok, blank] { + let _ = std::fs::remove_file(f); + } + } + + #[test] + fn describe_never_carries_the_values() { + let p = packaged("oldkey0123456789abcdef", "oldtok"); + let d = p.describe(); + assert!(!d.contains("oldkey"), "{d}"); + assert!(!d.contains("oldtok"), "{d}"); + assert!(d.contains("/igneum-app-latest.json"), "{d}"); + assert!(d.contains(&format!("key {}", fingerprint8("oldkey0123456789abcdef"))), "{d}"); + assert!(d.contains(&format!("folder {}", fingerprint8("oldtok"))), "{d}"); + assert!(d.contains("(packaged)"), "{d}"); + let none = Packaged::load(&[PathBuf::from("/nonexistent/igneum-app.json")]).describe(); + assert!(none.contains("no key") && none.contains("no folder") && none.contains("(none)"), "{none}"); + let custom = Packaged { update_manifest: "http://127.0.0.1:9/dl/t/igneum-app-latest.json".into(), ..Packaged::default() }.describe(); + assert!(custom.contains("custom"), "{custom}"); + } + #[test] fn packaged_carries_the_node_override_params() { let p: Packaged = serde_json::from_str(r#"{"update_manifest":"","node_override_params":{"difficulty_v2_activation_daa":123456}}"#).unwrap(); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index cd6cfa4c..85075432 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -70,6 +70,9 @@ pub enum Cmd { SweepHelperDone(Result<(), String>), /// a direct `nvidia-smi -pl` for the sweep finished: what it printed SweepCapSet(String), + /// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the + /// prover found a host that was not there when the node started + RestartNode(String), Quit, } @@ -110,7 +113,7 @@ impl Shared { st.mining.accepted_total = settings.accepted_total; st.mining.fee_total = settings.fee_total; st.address = address_state(&settings, &wallet_path); - st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, dev_fee: settings.dev_fee }; + st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust }; st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() }; st.live_page = packaged.live_page.clone(); st.finality.message = "waiting for the miner".into(); @@ -265,8 +268,9 @@ impl Shared { Ok(json!({ "ok": true })) } - pub fn apply_settings(&self, identities: Option, vote: Option, login: Option, address: Option<&str>, display_name: Option<&str>, dev_fee: Option) -> Result { + pub fn apply_settings(&self, identities: Option, vote: Option, login: Option, address: Option<&str>, display_name: Option<&str>, dev_fee: Option, proof_verify_trust: Option) -> Result { let mut restart = Vec::new(); + let mut restart_node: Option = None; { let mut s = self.settings.lock().unwrap(); if let Some(n) = display_name { @@ -292,6 +296,12 @@ impl Shared { restart.push(if v { "dev fee on (1 block in 100)".into() } else { "dev fee off".into() }); } } + if let Some(v) = proof_verify_trust { + if v != s.proof_verify_trust { + s.proof_verify_trust = v; + restart_node = Some(if v { "proof trust mode on (devnet only)".into() } else { "proof trust mode off".into() }); + } + } if let Some(a) = address { let a = a.trim().to_ascii_lowercase(); if !a.is_empty() && a != s.address { @@ -310,6 +320,7 @@ impl Shared { st.settings.identities = s.identities; st.settings.vote = s.vote; st.settings.dev_fee = s.dev_fee; + st.settings.proof_verify_trust = s.proof_verify_trust; st.dev_fee.on = s.dev_fee; st.dev_fee.percent = if s.dev_fee { 1 } else { 0 }; st.address = address_state(&s, &self.wallet_path); @@ -322,7 +333,10 @@ impl Shared { if !restart.is_empty() { self.send(Cmd::RestartMiners(restart.join(", "))); } - Ok(json!({ "ok": true, "restart": !restart.is_empty() })) + if let Some(why) = restart_node.clone() { + self.send(Cmd::RestartNode(why)); + } + Ok(json!({ "ok": true, "restart": !restart.is_empty(), "restart_node": restart_node.is_some() })) } } @@ -397,6 +411,8 @@ pub struct Engine { node_restarts: u32, node_log: Option, node_last_reading: Option, + /// the proof verifier decided for the node (src/verifier.rs); None = decide at the next node start + verifier: Option, sync_prev: Option, sync_stable_since: Option, last_sync_check: Instant, @@ -491,6 +507,7 @@ impl Engine { node_restarts: 0, node_log: None, node_last_reading: None, + verifier: None, sync_prev: None, sync_stable_since: None, last_sync_check: now, @@ -576,6 +593,9 @@ impl Engine { let v = crate::detect::node_version(&self.bins.node); self.st().node.version = v.clone(); self.shared.log(&self.shared.upload_header()); + // which intake and which downloads folder this build reports to and checks (fingerprints, never the values; + // rotation phase 2 reads this line from every machine's upload: docs/plans/rotation-phase-2.md) + self.shared.log(&self.shared.packaged.describe()); // the prover service (proving v0): its own thread, idle until the setting is on crate::prover::start(self.shared.clone(), self.bins.dir.clone()); self.shared.log(&format!("node binary: {} ({v})", self.bins.node.display())); @@ -710,6 +730,15 @@ impl Engine { m.restart_at = Some(Instant::now()); } } + Cmd::RestartNode(why) => { + self.verifier = None; + if self.node_external { + self.shared.event("info", &format!("{why}; the node is external, so the app cannot restart it")); + } else if self.node.is_some() || self.node_restart_at.is_some() { + self.shared.event("info", &format!("{why}; the node restarts")); + self.restart_node(&why, Duration::from_secs(2)); + } + } Cmd::CheckUpdate => self.ota.check_now(&self.shared), Cmd::InstallUpdate => self.ota.install_now(&self.shared), Cmd::AutoUpdate(on) => self.ota.set_auto(&self.shared, on), @@ -971,6 +1000,8 @@ impl Engine { let mut st = self.st(); st.node.state = "syncing".into(); st.node.message = "external node".into(); + st.proving.verifier_reason = "external node: the app did not start it, so it set no verifier".into(); + st.proving.verifier_note = crate::verifier::note("unknown", "", "", true); } else { self.start_node(); } @@ -1037,9 +1068,11 @@ impl Engine { let seg = if self.node_starts > 1 { format!("-r{}", self.node_starts) } else { String::new() }; let log = self.shared.runtime.log_dir.join(format!("node-{}{seg}.log", self.stamp)); let args = self.node_args(); - match procs::spawn(Source::Node, &self.bins.node, &args, None, &log, &self.lines_tx, &[]) { + let verifier = self.node_verifier(); + match procs::spawn(Source::Node, &self.bins.node, &args, None, &log, &self.lines_tx, &verifier.env) { Ok(p) => { self.shared.log(&format!("igneumd started (pid {}): {}", p.pid(), p.cmdline)); + self.shared.log(&format!("node proof verifier: {} ({})", verifier.mode, verifier.detail)); let mut st = self.st(); st.node.pid = p.pid(); st.node.state = "starting".into(); @@ -1065,6 +1098,26 @@ impl Engine { } } + /// The proof verifier for this node start (spec 7.7 item 4; src/verifier.rs), decided once and kept across + /// restarts until a RestartNode command asks again. The Windows probe runs WSL, so the result is cached. + fn node_verifier(&mut self) -> crate::verifier::Verifier { + if self.verifier.is_none() { + let trust = self.shared.settings.lock().unwrap().proof_verify_trust; + let v = crate::verifier::resolve(&self.bins.dir, trust); + if v.mode == "trust" { + self.shared.event("info", "devnet only: the node trusts proof records without verifying them (Settings)"); + } + self.verifier = Some(v); + } + let v = self.verifier.clone().unwrap(); + let mut st = self.st(); + st.proving.verifier_set = v.set_text(); + st.proving.verifier_reason = if v.mode == "command" { String::new() } else { v.detail.clone() }; + let (mode, set, reason) = (st.proving.verifier_mode.clone(), st.proving.verifier_set.clone(), st.proving.verifier_reason.clone()); + st.proving.verifier_note = crate::verifier::note(&mode, &set, &reason, false); + v + } + fn stop_node(&mut self) { if let Some(mut n) = self.node.take() { self.shared.log("stopping the node"); diff --git a/app/igneum-app/src/inputs.rs b/app/igneum-app/src/inputs.rs new file mode 100644 index 00000000..08ada089 --- /dev/null +++ b/app/igneum-app/src/inputs.rs @@ -0,0 +1,281 @@ +//! The signed payload-inputs manifest (review round 4, R4.5.2, ledger G13). +//! +//! The Windows build on GitHub's runner cannot make the node, the miner or the GPU workers (they come from the +//! node fork, which is not in the repository, and from NVIDIA's redistributables). Those files travel as +//! `payload-inputs.zip` on the downloads host. Before 4 October 2026 the runner checked the zip against a sha256 +//! served beside it, which is a transfer check, not an authentication: whoever controls the host controls the +//! binaries, and the Mac then signed the update manifest over whatever the run produced. +//! +//! Now `packaging/windows/push-inputs.sh` writes `payload-inputs.json` (this format), signs it on the Mac with the +//! OTA key (`igneum-ota-sign sign-inputs`) and uploads the signature beside it. The workflow verifies the signature +//! with the public key compiled into the app (`manifest::OTA_PUBLIC_KEY_HEX`) before it builds anything, checks +//! the zip's sha256 and every unpacked file against the manifest, and checks the pinned node source commit +//! against `packaging/windows/node-source.pin` in the commit it builds. `fetch-ci-artifacts.sh` refuses to sign an +//! update manifest unless the run's verified inputs manifest re-verifies on the Mac. +//! +//! The bytes signed are the file as uploaded. `parse` refuses anything it does not understand, so a manifest the +//! signer would not sign is also one the verifier would not accept. + +use crate::manifest::{hex_decode, sha256_file, verify_signature}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::Path; + +/// The format tag every manifest must carry. +pub const FORMAT: &str = "igneum-payload-inputs/1"; + +/// Files the payload cannot do without; the verifier refuses a manifest that omits one. +pub const REQUIRED_FILES: &[&str] = &["igneumd.exe", "igneum-miner.exe"]; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FileEntry { + pub sha256: String, + pub bytes: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InputsManifest { + pub format: String, + /// When the zip was built, UTC, `YYYY-MM-DDTHH:MM:SSZ`. + pub built_at: String, + /// The node fork commit the exes were built from (40 hex), and its branch (informational). + pub node_source_commit: String, + pub node_source_branch: String, + /// The main repository commit `push-inputs.sh` ran at (40 hex; informational). + pub repo_commit: String, + /// The zip as uploaded. + pub zip: FileEntry, + /// Every file inside the zip's `payload-inputs/` folder, by name. + pub files: BTreeMap, +} + +fn is_hex(s: &str, len: usize) -> bool { + s.len() == len && s.bytes().all(|b| b.is_ascii_hexdigit()) && s.bytes().all(|b| !b.is_ascii_uppercase()) +} + +fn check_entry(name: &str, e: &FileEntry) -> Result<(), String> { + if !is_hex(&e.sha256, 64) { + return Err(format!("{name}: sha256 is not 64 lowercase hex characters")); + } + if e.bytes == 0 { + return Err(format!("{name}: bytes is 0")); + } + Ok(()) +} + +/// Parses and validates a manifest. Unknown fields, missing fields, a wrong format tag, a malformed hash or +/// commit, an empty file list or a missing required file are all refused. +pub fn parse(text: &str) -> Result { + let m: InputsManifest = serde_json::from_str(text).map_err(|e| format!("inputs manifest: {e}"))?; + if m.format != FORMAT { + return Err(format!("inputs manifest: format is {:?}, this build understands {FORMAT:?}", m.format)); + } + if m.built_at.len() != 20 || !m.built_at.ends_with('Z') || m.built_at.as_bytes()[10] != b'T' { + return Err("inputs manifest: built_at is not YYYY-MM-DDTHH:MM:SSZ".into()); + } + if !is_hex(&m.node_source_commit, 40) { + return Err("inputs manifest: node_source_commit is not a 40-character lowercase hex commit".into()); + } + if !is_hex(&m.repo_commit, 40) { + return Err("inputs manifest: repo_commit is not a 40-character lowercase hex commit".into()); + } + if m.node_source_branch.trim().is_empty() { + return Err("inputs manifest: node_source_branch is empty".into()); + } + check_entry("zip", &m.zip)?; + if m.files.is_empty() { + return Err("inputs manifest: files is empty".into()); + } + for (name, e) in &m.files { + if name.is_empty() || name.contains('/') || name.contains('\\') || name == "." || name == ".." { + return Err(format!("inputs manifest: {name:?} is not a plain file name")); + } + check_entry(name, e)?; + } + for r in REQUIRED_FILES { + if !m.files.contains_key(*r) { + return Err(format!("inputs manifest: no {r} in files")); + } + } + Ok(m) +} + +/// Verifies the detached signature over the exact bytes, then parses. +pub fn verify_and_parse(bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result { + verify_signature(bytes, sig_hex, pub_hex)?; + let text = std::str::from_utf8(bytes).map_err(|_| "inputs manifest is not UTF-8")?; + parse(text) +} + +/// The zip on disk must be the one the manifest names: same sha256, same size. +pub fn check_zip(m: &InputsManifest, zip: &Path) -> Result<(), String> { + let sum = sha256_file(zip).map_err(|e| format!("{}: {e}", zip.display()))?; + let size = std::fs::metadata(zip).map(|md| md.len()).unwrap_or(0); + if sum != m.zip.sha256 { + return Err(format!("{}: sha256 {sum} is not the manifest's {}", zip.display(), m.zip.sha256)); + } + if size != m.zip.bytes { + return Err(format!("{}: {size} bytes, the manifest says {}", zip.display(), m.zip.bytes)); + } + Ok(()) +} + +/// The unpacked folder must hold exactly the manifest's files, each with its sha256 and size. A file the manifest +/// does not name is refused too: nothing rides into the payload unsigned. +pub fn check_dir(m: &InputsManifest, dir: &Path) -> Result<(), String> { + let mut seen = 0usize; + let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for entry in entries { + let entry = entry.map_err(|e| e.to_string())?; + let name = entry.file_name().to_string_lossy().to_string(); + if name == ".DS_Store" { + continue; + } + let Some(want) = m.files.get(&name) else { + return Err(format!("{name}: in the folder but not in the signed manifest")); + }; + let p = entry.path(); + let sum = sha256_file(&p).map_err(|e| format!("{name}: {e}"))?; + let size = std::fs::metadata(&p).map(|md| md.len()).unwrap_or(0); + if sum != want.sha256 || size != want.bytes { + return Err(format!("{name}: sha256 {sum} ({size} bytes) is not the manifest's {} ({} bytes)", want.sha256, want.bytes)); + } + seen += 1; + } + if seen != m.files.len() { + let missing: Vec<&String> = m.files.keys().filter(|k| !dir.join(k).is_file()).collect(); + return Err(format!("the folder holds {seen} of the manifest's {} files; missing {:?}", m.files.len(), missing)); + } + Ok(()) +} + +/// The commit the manifest pins must be the commit the repository expects (`packaging/windows/node-source.pin`). +pub fn check_node_commit(m: &InputsManifest, expected: &str) -> Result<(), String> { + let expected = expected.trim(); + if !is_hex(expected, 40) { + return Err(format!("expected node commit {expected:?} is not a 40-character lowercase hex commit")); + } + if m.node_source_commit != expected { + return Err(format!("the manifest pins node commit {} but the repository expects {expected}", m.node_source_commit)); + } + Ok(()) +} + +/// A signature file holds 128 hex characters and nothing else of substance. +pub fn read_signature(text: &str) -> Result { + let s = text.trim(); + match hex_decode(s) { + Some(b) if b.len() == 64 => Ok(s.to_string()), + _ => Err("signature is not 128 hex characters".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::hex_encode; + use ed25519_dalek::{Signer, SigningKey}; + + const SHA: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + const COMMIT: &str = "6aa69a45364b9b30a32695e33eb66f100c9be85f"; + + fn sample() -> String { + format!( + r#"{{"format":"{FORMAT}","built_at":"2026-10-04T20:07:21Z","node_source_commit":"{COMMIT}","node_source_branch":"finality-fixes","repo_commit":"{COMMIT}","zip":{{"sha256":"{SHA}","bytes":123}},"files":{{"igneumd.exe":{{"sha256":"{SHA}","bytes":1}},"igneum-miner.exe":{{"sha256":"{SHA}","bytes":2}}}}}}"# + ) + } + + fn key() -> (SigningKey, String) { + let sk = SigningKey::from_bytes(&[7u8; 32]); + let pk = hex_encode(sk.verifying_key().as_bytes()); + (sk, pk) + } + + #[test] + fn parses_a_good_manifest() { + let m = parse(&sample()).unwrap(); + assert_eq!(m.node_source_commit, COMMIT); + assert_eq!(m.files.len(), 2); + assert_eq!(m.zip.bytes, 123); + check_node_commit(&m, COMMIT).unwrap(); + assert!(check_node_commit(&m, &COMMIT.replace('6', "7")).unwrap_err().contains("expects")); + assert!(check_node_commit(&m, "6aa69a45").unwrap_err().contains("40-character")); + } + + #[test] + fn refuses_what_the_signer_would_not_sign() { + let good = sample(); + let cases = [ + (good.replace(FORMAT, "igneum-payload-inputs/2"), "format"), + (good.replace("\"node_source_branch\":\"finality-fixes\",", ""), "missing field"), + (good.replace("\"zip\":", "\"extra\":1,\"zip\":"), "unknown field"), + (good.replace(&format!("\"node_source_commit\":\"{COMMIT}\""), "\"node_source_commit\":\"6aa69a45\""), "node_source_commit"), + (good.replace("2026-10-04T20:07:21Z", "2026-10-04 20:07:21"), "built_at"), + (good.replace("\"bytes\":123", "\"bytes\":0"), "bytes is 0"), + (good.replace("\"igneum-miner.exe\"", "\"igneum-miner.exe.bak\""), "no igneum-miner.exe"), + (good.replace("\"igneumd.exe\"", "\"../igneumd.exe\""), "plain file name"), + (good.replace(SHA, &SHA.to_uppercase()), "lowercase hex"), + ]; + for (text, why) in cases { + let err = parse(&text).unwrap_err(); + assert!(err.contains(why), "{why}: {err}"); + } + } + + #[test] + fn sign_verify_and_tamper() { + let (sk, pk) = key(); + let bytes = sample().into_bytes(); + let sig = hex_encode(&sk.sign(&bytes).to_bytes()); + assert_eq!(read_signature(&format!("{sig}\n")).unwrap(), sig); + assert!(read_signature("abc").is_err()); + let m = verify_and_parse(&bytes, &sig, &pk).unwrap(); + assert_eq!(m.node_source_commit, COMMIT); + // one byte changed anywhere: the signature no longer verifies + let mut tampered = bytes.clone(); + let i = tampered.iter().position(|b| *b == b'1').unwrap(); + tampered[i] = b'2'; + assert!(verify_and_parse(&tampered, &sig, &pk).is_err()); + // a different key: refused + let other = hex_encode(SigningKey::from_bytes(&[9u8; 32]).verifying_key().as_bytes()); + assert!(verify_and_parse(&bytes, &sig, &other).is_err()); + // the embedded OTA key refuses a signature from this test key + assert!(verify_and_parse(&bytes, &sig, crate::manifest::OTA_PUBLIC_KEY_HEX).is_err()); + } + + #[test] + fn zip_and_folder_checks() { + let dir = std::env::temp_dir().join(format!("igneum-inputs-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("unpacked")).unwrap(); + std::fs::write(dir.join("unpacked/igneumd.exe"), b"node").unwrap(); + std::fs::write(dir.join("unpacked/igneum-miner.exe"), b"miner!").unwrap(); + std::fs::write(dir.join("payload-inputs.zip"), b"zipzipzip").unwrap(); + let sha = |p: &Path| sha256_file(p).unwrap(); + let text = format!( + r#"{{"format":"{FORMAT}","built_at":"2026-10-04T20:07:21Z","node_source_commit":"{COMMIT}","node_source_branch":"finality-fixes","repo_commit":"{COMMIT}","zip":{{"sha256":"{}","bytes":9}},"files":{{"igneumd.exe":{{"sha256":"{}","bytes":4}},"igneum-miner.exe":{{"sha256":"{}","bytes":6}}}}}}"#, + sha(&dir.join("payload-inputs.zip")), + sha(&dir.join("unpacked/igneumd.exe")), + sha(&dir.join("unpacked/igneum-miner.exe")) + ); + let m = parse(&text).unwrap(); + check_zip(&m, &dir.join("payload-inputs.zip")).unwrap(); + check_dir(&m, &dir.join("unpacked")).unwrap(); + // a changed byte in the zip + std::fs::write(dir.join("payload-inputs.zip"), b"zipzipzip!").unwrap(); + assert!(check_zip(&m, &dir.join("payload-inputs.zip")).unwrap_err().contains("sha256")); + // an unlisted file in the folder + std::fs::write(dir.join("unpacked/extra.dll"), b"x").unwrap(); + assert!(check_dir(&m, &dir.join("unpacked")).unwrap_err().contains("not in the signed manifest")); + std::fs::remove_file(dir.join("unpacked/extra.dll")).unwrap(); + // a changed file + std::fs::write(dir.join("unpacked/igneumd.exe"), b"nodE").unwrap(); + assert!(check_dir(&m, &dir.join("unpacked")).unwrap_err().contains("igneumd.exe")); + // a missing file + std::fs::remove_file(dir.join("unpacked/igneumd.exe")).unwrap(); + assert!(check_dir(&m, &dir.join("unpacked")).unwrap_err().contains("missing")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/app/igneum-app/src/jobbuild.rs b/app/igneum-app/src/jobbuild.rs index 502cfa21..31da4dfd 100644 --- a/app/igneum-app/src/jobbuild.rs +++ b/app/igneum-app/src/jobbuild.rs @@ -283,6 +283,11 @@ pub fn extract_script(p: &BuildParams, job_id: &str, zip_wsl: &str) -> String { s.push_str("[ -f \"$ZIP\" ] || { echo \"RESULT extract zip missing at $ZIP\"; exit 2; }\n"); s.push_str("rm -rf \"$B/src\" && mkdir -p \"$B/src\" && cp \"$ZIP\" \"$B/inputs.zip\" || { echo \"RESULT extract cannot copy the zip into $B\"; exit 2; }\n"); s.push_str("unzip -q -o \"$B/inputs.zip\" -d \"$B/src\" || { echo \"RESULT extract unzip failed\"; exit 2; }\n"); + // every unpacked file (the zip root and the sibling igneum-pow) is stamped now: the target dir persists between + // jobs and cargo judges freshness by mtime, so sources that keep the Mac's older mtimes would be taken as unchanged + // since the last build and new callers linked against stale crates (5 October 2026: the 0.3.6 job built kaspad + // against 0.3.5's consensus-core). The packer stamps too (push-build-inputs.sh); this guard holds if it regresses. + s.push_str("find \"$B/src\" -type f -exec touch {} + || { echo \"RESULT extract cannot stamp the sources\"; exit 2; }\n"); s.push_str("[ -f \"$SRC/manifest.json\" ] || { echo \"RESULT extract no manifest.json under $SRC\"; exit 2; }\n"); // the stale-build class (5 October 2026): the target dir persists and cargo rebuilds by mtime, so every extracted // source is stamped now, else a file older than the last build links against the cached crate of the old version @@ -328,6 +333,17 @@ pub fn build_script(p: &BuildParams, job_id: &str, m: &Manifest, target: &str) - s.push_str(&format!(" if [ -f \"$CARGO_TARGET_DIR/{rel}/{name}\" ]; then cp -f \"$CARGO_TARGET_DIR/{rel}/{name}\" \"$OUT/{target}/{name}\"; echo \"RESULT {target} {name} $(stat -c %s \"$OUT/{target}/{name}\") bytes sha256 $(sha256sum \"$OUT/{target}/{name}\" | cut -c1-64)\"; else echo \"RESULT {target} {name} missing after the build\"; rc=3; fi\n")); } s.push_str("fi\n"); + if windows && u.bins.iter().any(|b| b == "igneumd") { + // the three mingw runtime DLLs from THIS toolchain go next to the exes (5 October 2026, 0.3.6: the PC's + // GCC 13 exes shipped with the Mac's GCC 16 libstdc++-6.dll, which lacks five codecvt symbols; the node + // did not start on either PC). make-payload.sh takes DLLs next to the exes first. + s.push_str("if [ \"$rc\" = 0 ]; then\n"); + s.push_str(" gccdir=$(dirname \"$(x86_64-w64-mingw32-gcc-posix -print-file-name=libstdc++-6.dll)\")\n"); + s.push_str(" for dll in \"$gccdir/libstdc++-6.dll\" \"$gccdir/libgcc_s_seh-1.dll\" /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll; do\n"); + s.push_str(&format!(" if [ -f \"$dll\" ]; then cp -f \"$dll\" \"$OUT/{target}/\"; echo \"RESULT {target} $(basename \"$dll\") $(stat -c %s \"$dll\") bytes sha256 $(sha256sum \"$dll\" | cut -c1-64) from $(dirname \"$dll\")\"; else echo \"RESULT {target} runtime dll missing: $dll\"; rc_all=1; fi\n")); + s.push_str(" done\n"); + s.push_str("fi\n"); + } if optional { s.push_str(&format!("[ \"$rc\" = 0 ] || echo \"RESULT {target} {dir} optional on {target}: not fatal\"\n", dir = u.dir)); } else { @@ -541,6 +557,10 @@ mod tests { assert!(lin.contains("cd \"$SRC/app/igneum-app\"")); assert!(lin.contains("optional on linux: not fatal")); assert!(!lin.contains("--target x86_64-pc-windows-gnu")); + // the windows stage ships the runtime DLLs of its own toolchain next to the exes; the linux stage does not + let win = build_script(&p, id, &m, "windows"); + assert!(win.contains("-print-file-name=libstdc++-6.dll") && win.contains("libgcc_s_seh-1.dll") && win.contains("libwinpthread-1.dll"), "{win}"); + assert!(!lin.contains("libstdc++-6.dll")); assert!(lin.contains("RESULT linux igneumd $(stat")); let win = build_script(&p, id, &m, "windows"); assert!(win.contains("--target x86_64-pc-windows-gnu") && win.contains("x86_64-w64-mingw32-gcc-posix") && win.contains("link-arg=-static")); @@ -554,6 +574,11 @@ mod tests { let ex = extract_script(&p, id, "/mnt/c/it's/build-inputs.zip"); assert!(ex.contains("ZIP='/mnt/c/it'\\''s/build-inputs.zip'")); assert!(ex.contains("unzip -q -o")); + // the unpacked sources are stamped after the unzip and before the manifest check (cargo's mtime freshness) + let unzip_at = ex.find("unzip -q -o").unwrap(); + let touch_at = ex.find("find \"$B/src\" -type f -exec touch {} +").expect("the extract stamps the sources"); + let manifest_at = ex.find("manifest.json").unwrap(); + assert!(unzip_at < touch_at && touch_at < manifest_at, "{ex}"); } #[test] diff --git a/app/igneum-app/src/jobrun.rs b/app/igneum-app/src/jobrun.rs index 322461ad..1d669f1c 100644 --- a/app/igneum-app/src/jobrun.rs +++ b/app/igneum-app/src/jobrun.rs @@ -833,12 +833,8 @@ fn probe(req: &str, wsl_user: &str) -> Result { users.push(DEFAULT_WSL_USER); users.push(""); for u in users { - let mut c = Command::new(&wsl); - c.args(["-d", DEFAULT_DISTRO]); - if !u.is_empty() { - c.args(["-u", u]); - } - c.args(["--", "bash", "-lc", PROVER_PROBE]); + let file = crate::wslhost::write_script("prover-probe", PROVER_PROBE).map_err(|e| format!("cannot write the WSL probe script: {e}"))?; + let mut c = crate::wslhost::command(&wsl, DEFAULT_DISTRO, Some(u), &file.path, true, &[]); let (code, out) = run_capture(&mut c, Duration::from_secs(90)); if code == Some(0) { return Ok(format!("toolchain in {DEFAULT_DISTRO}{}: {}", if u.is_empty() { " (default user)".to_string() } else { format!(" as {u}") }, short_out(&out))); @@ -1387,7 +1383,8 @@ fn run_shard_benchmark(shared: &Arc, job: &Job, sink: &Sink, data_root: let shard = fixtures[0].clone(); let blocks = fixtures[1..].join(" "); // what this run sees inside WSL: the account's own Ubuntu, so say who we are there - let (ccode, cout) = run_capture(Command::new(crate::platform::tool("wsl")).args(["-d", &distro, "-u", &user, "--", "bash", "-c", "echo \"wsl user $(id -un) uid $(id -u) home $HOME\"; nvidia-smi -L 2>&1 | head -1; ls -d \"$HOME/.sp1\" \"$HOME/igneum-prove\" 2>&1 | head -2"]), Duration::from_secs(60)); + let ctx = crate::wslhost::write_script("wsl-context", "echo \"wsl user $(id -un) uid $(id -u) home $HOME\"; nvidia-smi -L 2>&1 | head -1; ls -d \"$HOME/.sp1\" \"$HOME/igneum-prove\" 2>&1 | head -2").map_err(|e| format!("cannot write the WSL context script: {e}"))?; + let (ccode, cout) = run_capture(&mut crate::wslhost::command(&crate::platform::tool("wsl"), &distro, Some(&user), &ctx.path, false, &[]), Duration::from_secs(60)); sink.line(&format!("wsl context (-u {user}): exit {ccode:?}: {}", short_out(&cout))); // the payload ships the Linux host next to the app (wsl2\bin): no cargo, no toolchain, run it fixture by fixture; // params.build = true forces the package's prove-shard.sh (cargo build inside the distro) instead @@ -1411,8 +1408,8 @@ fn run_shard_benchmark(shared: &Arc, job: &Job, sink: &Sink, data_root: } let mode = if i == 0 { "shard --shard 0" } else { "block" }; let line = format!("export PATH=\"/usr/local/cuda/bin:$PATH\"; CUDA_DIR=$(ls -d /usr/local/cuda-12.* 2>/dev/null | sort -V | tail -1); export LD_LIBRARY_PATH=\"/usr/lib/wsl/lib:${{CUDA_DIR:+$CUDA_DIR/lib64:}}${{LD_LIBRARY_PATH:-}}\"; echo \"=== GPU run: {f} --mode {mode} (SP1_PROVER=cuda) ===\"; SP1_PROVER=cuda RUST_LOG=info '{host_wsl}' '{fixdir_wsl}/{f}.json' --mode {mode} --out '{results_wsl}/{f}-cuda-{started}.json'; rc=$?; echo \"run exit $rc at $(date -u +%FT%TZ)\"; exit $rc"); - let mut cmd = Command::new(crate::platform::tool("wsl")); - cmd.args(["-d", &distro, "-u", &user, "--", "bash", "-c", &line]); + let run = crate::wslhost::write_script("gpu-run", &line).map_err(|e| format!("cannot write the WSL run script: {e}"))?; + let mut cmd = crate::wslhost::command(&crate::platform::tool("wsl"), &distro, Some(&user), &run.path, false, &[]); cmd.current_dir(&pkg); let r = run_streamed(&mut cmd, sink, ctl, left, shared, job, started, "shard benchmark running")?; if r.code != Some(0) { @@ -1434,7 +1431,9 @@ fn run_shard_benchmark(shared: &Arc, job: &Job, sink: &Sink, data_root: }; if ran.code.is_none() { // the Linux side outlives wsl.exe: end the prover there too - let _ = run_capture(Command::new(crate::platform::tool("wsl")).args(["-d", &distro, "-u", &user, "--", "bash", "-c", "pkill -f igneum-prove-host; pkill -f prove-shard.sh; true"]), Duration::from_secs(30)); + if let Ok(kill) = crate::wslhost::write_script("prover-kill", "pkill -f igneum-prove-host; pkill -f prove-shard.sh; true") { + let _ = run_capture(&mut crate::wslhost::command(&crate::platform::tool("wsl"), &distro, Some(&user), &kill.path, false, &[]), Duration::from_secs(30)); + } } sink.stage("uploading the results"); let mut uploaded = Vec::new(); @@ -1485,7 +1484,9 @@ fn build_stage(shared: &Arc, job: &Job, sink: &Sink, ctl: &Ctl, p: &jb:: cmd.current_dir(dir); let ran = run_streamed(&mut cmd, sink, ctl, cap, shared, job, started, &format!("build: {stage}"))?; if ran.code.is_none() { - let _ = run_capture(Command::new(crate::platform::tool("wsl")).args(["-d", &p.distro, "-u", &p.wsl_user, "--", "bash", "-c", jb::kill_script()]), Duration::from_secs(30)); + if let Ok(kill) = crate::wslhost::write_script("build-kill", jb::kill_script()) { + let _ = run_capture(&mut crate::wslhost::command(&crate::platform::tool("wsl"), &p.distro, Some(&p.wsl_user), &kill.path, false, &[]), Duration::from_secs(30)); + } } sink.line(&format!("STAGE {stage} {} {} {} s exit {}", if ran.timed_out { "timeout" } else { "end" }, jobs::format_time(crate::platform::unix_now()), t0.elapsed().as_secs(), ran.code.map(|c| c.to_string()).unwrap_or_else(|| "none".into()))); Ok(ran) @@ -1562,7 +1563,8 @@ fn run_build(shared: &Arc, job: &Job, sink: &Sink, jobs_dir: &Path, ctl: let ps = format!("[math]::Floor(([IO.DriveInfo]::new('{drive}')).AvailableFreeSpace/1GB)"); let (wc, wo) = run_capture(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]), Duration::from_secs(40)); let win_free = if wc == Some(0) { jb::parse_free_gb(&wo) } else { None }; - let (lc, lo) = run_capture(Command::new(&wsl).args(["-d", &p.distro, "-u", &p.wsl_user, "--", "bash", "-c", jb::free_gb_script()]), Duration::from_secs(120)); + let free = crate::wslhost::write_script("free-gb", jb::free_gb_script()).map_err(|e| format!("cannot write the WSL free-space script: {e}"))?; + let (lc, lo) = run_capture(&mut crate::wslhost::command(&wsl, &p.distro, Some(&p.wsl_user), &free.path, false, &[]), Duration::from_secs(120)); let wsl_free = if lc == Some(0) { jb::parse_free_gb(&lo) } else { None }; sink.line(&format!("RESULT check {} drive {drive}: {} GB free, {} /root: {} GB free, floor {} GB", now(), win_free.map(|g| g.to_string()).unwrap_or_else(|| format!("unknown (powershell exit {wc:?}: {})", short_out(&wo))), p.distro, wsl_free.map(|g| g.to_string()).unwrap_or_else(|| format!("unknown (wsl exit {lc:?}: {})", short_out(&lo))), p.min_free_gb)); let Some(wf) = win_free else { return Err("cannot read the free space of the Windows drive".into()) }; diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 41e7d9ce..126291f0 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -28,6 +28,8 @@ mod jobs; mod jobrun; mod jobbuild; mod prover; +mod verifier; +mod wslhost; mod sweep; mod watchdog; @@ -54,6 +56,7 @@ fn main() { if host.exists() { let mut c = std::process::Command::new(&host); c.current_dir(&dir); + crate::platform::quiet(&mut c); // no console of our own for the window host (it is a GUI program; the flag only governs a console) if c.spawn().is_ok() { return; } @@ -86,7 +89,7 @@ fn main() { } } } - let packaged = config::Packaged::load(&candidates); + let packaged = config::Packaged::load(&candidates).with_env_overrides(); let settings = config::Settings::load(&runtime.app_dir.join("settings.json")); // the per-launch token: 32 hex characters from the OS diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index 73fd5791..1313984c 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -1107,7 +1107,7 @@ fn stage(e: &PlatformEntry, file: &Path, dir: &Path, version: &str) -> Result Result Result<(), String> { let key = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"; let out = if on { let cmd = login_command().iter().map(|a| format!("\"{a}\"")).collect::>().join(" "); - Command::new(tool("reg")).args(["add", key, "/v", "Igneum Miner", "/t", "REG_SZ", "/d", &cmd, "/f"]).output() + quiet(&mut Command::new(tool("reg"))).args(["add", key, "/v", "Igneum Miner", "/t", "REG_SZ", "/d", &cmd, "/f"]).output() } else { - Command::new(tool("reg")).args(["delete", key, "/v", "Igneum Miner", "/f"]).output() + quiet(&mut Command::new(tool("reg"))).args(["delete", key, "/v", "Igneum Miner", "/f"]).output() }; match out { Ok(o) if o.status.success() || !on => Ok(()), @@ -338,7 +338,7 @@ pub fn start_at_login_is_on() -> bool { } #[cfg(windows)] { - Command::new(tool("reg")) + quiet(&mut Command::new(tool("reg"))) .args(["query", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run", "/v", "Igneum Miner"]) .output() .map(|o| o.status.success()) diff --git a/app/igneum-app/src/prover.rs b/app/igneum-app/src/prover.rs index 0f66f72a..76cfa148 100644 --- a/app/igneum-app/src/prover.rs +++ b/app/igneum-app/src/prover.rs @@ -11,11 +11,17 @@ //! (`igneum_submitProofRecord`). The tile shows assigned, proving, submitted, paid. //! //! Where the prover runs: macOS runs the host next to the engine on the CPU (slow, shown as slow). Windows runs -//! it inside WSL2 (SP1's CUDA prover is Linux-only): the engine looks for `~/igneum-prove/target/release/ -//! igneum-prove-host` in the Ubuntu-24.04 distribution; without it the tile says "proving needs the WSL2 setup, -//! 20 minutes, Set up" and Set up runs proving/windows-wsl2/setup-wsl.sh from the payload (`wsl2/` next to the -//! engine). Linux runs the host next to the engine. Everything the prover needs on a PC is in the payload or -//! installed by that script; there is no other channel. +//! it inside WSL2 (SP1's CUDA prover is Linux-only): the engine looks for `igneum-prove-host` in the Ubuntu-24.04 +//! distribution in the order of src/wslhost.rs (the payload's wsl2/bin, the setup-wsl.sh build under +//! `~/igneum-prove/proving/igneum-prove/target/release`, the old `~/igneum-prove/target/release`, `/opt/igneum`); +//! without it the tile says "proving needs the WSL2 setup, 20 minutes, Set up" and names the paths it looked at, +//! and Set up runs proving/windows-wsl2/setup-wsl.sh from the payload (`wsl2/` next to the engine). Linux runs +//! the host next to the engine. Everything the prover needs on a PC is in the payload or installed by that +//! script; there is no other channel. +//! +//! The same thread reads the node's verifier state every 30 s (`igneum_getProvingStatus().verifier`, spec 7.7 +//! item 4) whether proving is on or off, so the tile and `/api/state` say when this node relays proof records +//! but never includes them (src/verifier.rs decides what the node spawn sets). use crate::engine::Shared; use serde_json::{json, Value}; @@ -80,15 +86,7 @@ pub fn choose(work: &[Work], attempted: &HashSet<(String, u32)>) -> Option pick(true).or_else(|| pick(false)) } -/// A Windows path as WSL sees it: `C:\Users\x\f` -> `/mnt/c/Users/x/f`. -pub fn wsl_path(p: &Path) -> String { - let s = p.display().to_string().replace('\\', "/"); - if s.len() > 2 && s.as_bytes()[1] == b':' { - format!("/mnt/{}{}", s[..1].to_ascii_lowercase(), &s[2..]) - } else { - s - } -} +pub use crate::wslhost::wsl_path; /// The identity labels this machine mines with (the vote keys the node assigns shards to): one per enabled /// card, `-`, and `-1..N` per identity when a card runs more than one. @@ -154,12 +152,14 @@ fn find_tools(bin_dir: &Path) -> Result { let miner = bin_dir.join(if cfg!(windows) { "igneum-miner.exe" } else { "igneum-miner" }); if cfg!(windows) { // the SP1 host runs inside WSL2 (Ubuntu-24.04): the Linux binaries the payload ships under wsl2\bin\ (seen - // from Ubuntu as /mnt//.../wsl2/bin), else one built there by setup-wsl.sh - let shipped = wsl_path(&bin_dir.join("wsl2").join("bin").join("igneum-prove-host")); - let script = format!("for f in '{shipped}' ~/igneum-prove/target/release/igneum-prove-host /opt/igneum/igneum-prove-host; do [ -x \"$f\" ] && {{ echo \"$f\"; break; }}; done; command -v nvidia-smi >/dev/null && echo cuda"); - let mut probe_cmd = Command::new(crate::platform::tool("wsl")); - probe_cmd.args(["-d", "Ubuntu-24.04", "--", "bash", "-lc", &script]); - let probe = crate::platform::quiet(&mut probe_cmd).output(); // hidden: this probe opened a console window on PC 2 every minute (5 October 2026) + // from Ubuntu as /mnt//.../wsl2/bin), else one built there by setup-wsl.sh, else a hand install + // (the order and the list are src/wslhost.rs, shared with igneum-prove-verify.exe) + // from a file, never inline (src/wslhost.rs: an inline script with double quotes and a path with a space + // reached bash mangled on 5 October 2026 and the app said the setup was missing) + let body = format!("{}\ncommand -v nvidia-smi >/dev/null && echo cuda\ntrue", crate::wslhost::lookup_script(bin_dir)); + let file = crate::wslhost::write_script("prove-probe", &body).map_err(|e| format!("cannot write the WSL probe script: {e}"))?; + let probe = crate::platform::quiet(&mut crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &[])).output(); + let answered = probe.is_ok(); let text = probe.map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default(); let host = text.lines().find(|l| l.contains("igneum-prove-host")).map(|l| PathBuf::from(l.trim())); let setup = bin_dir.join("wsl2").join("setup-wsl.sh"); @@ -168,7 +168,7 @@ fn find_tools(bin_dir: &Path) -> Result { let export = host.parent().map(|d| d.join("igneum-prove-export")).unwrap_or_default(); Ok(Tools { host, export, miner, wsl: true, setup_script: setup.exists().then_some(setup), cuda: text.contains("cuda") }) } - None => Err(format!("proving needs the WSL2 setup, 20 minutes, Set up{}", if setup.exists() { "" } else { " (setup script missing from the payload)" })), + None => Err(probe_message(bin_dir, answered, setup.exists())), } } else { let host = bin_dir.join("igneum-prove-host"); @@ -180,23 +180,66 @@ fn find_tools(bin_dir: &Path) -> Result { } } +/// The tile's message when no host is found inside WSL2: what to do, and the paths that were looked at. +pub fn probe_message(bin_dir: &Path, wsl_answered: bool, setup_present: bool) -> String { + let looked = crate::wslhost::candidates_text(bin_dir); + if !wsl_answered { + return format!("proving needs the WSL2 setup, 20 minutes, Set up (WSL2 with {} did not answer; no igneum-prove-host at {looked})", crate::wslhost::DISTRO); + } + format!("proving needs the WSL2 setup, 20 minutes, Set up (no igneum-prove-host at {looked}{})", if setup_present { "" } else { "; setup script missing from the payload" }) +} + +/// Reads the node's verifier state (`igneum_getProvingStatus`): the verifier word, the pool counts, the tile's +/// note. Nothing changes when the node does not answer (the mode stays as it was, "unknown" at first). +fn read_verifier(shared: &Shared) { + let external = shared.state.lock().unwrap().node.message == "external node"; + match evm_rpc(shared, "igneum_getProvingStatus", json!([]), Duration::from_secs(5)) { + Ok(v) => { + let report = v["verifier"].as_str().unwrap_or("").to_string(); + let mode = crate::verifier::mode_of_report(&report); + let count = |k: &str| v["pool"][k].as_u64().unwrap_or(0); + let (entries, verified, failed) = (count("entries"), count("verified"), count("failed")); + let mut st = shared.state.lock().unwrap(); + let changed = st.proving.verifier_mode != mode; + st.proving.verifier = report; + st.proving.verifier_mode = mode.into(); + st.proving.pool_entries = entries; + st.proving.pool_verified = verified; + st.proving.pool_failed = failed; + let (set, reason) = (st.proving.verifier_set.clone(), st.proving.verifier_reason.clone()); + st.proving.verifier_note = crate::verifier::note(mode, &set, &reason, external); + drop(st); + if changed { + shared.log(&format!("node proof verifier reported: {mode}{}", if mode == "off" { " (this node relays proof records and never includes them)" } else { "" })); + } + } + Err(_) => {} + } +} + /// Runs the host or the exporter: directly, or through WSL on Windows. Output goes to `log`; the child is polled /// every second and killed when the app quits, the setting goes off or `limit` passes (a proof must never outlive /// the app). Returns (exit ok, output). fn run_tool(shared: &Shared, t: &Tools, exe: &Path, args: &[String], env: &[(&str, &str)], limit: Duration, log: &Path) -> (bool, String) { - let mut cmd = if t.wsl { - let mut c = Command::new(crate::platform::tool("wsl")); - let envs: String = env.iter().map(|(k, v)| format!("{k}={v} ")).collect(); - let line = format!("{envs}{} {}", exe.display(), args.iter().map(|a| format!("'{a}'")).collect::>().join(" ")); - c.args(["-d", "Ubuntu-24.04", "--", "bash", "-lc", &line]); - c + // Windows: a script file exports the environment and execs the host; the arguments travel as $1, $2... (the + // single-quoted rule of src/wslhost.rs). The file lives until the run ends. + let (mut cmd, _script) = if t.wsl { + let mut body: String = env.iter().map(|(k, v)| format!("export {k}={}\n", crate::wslhost::sq(v))).collect(); + body.push_str(&format!("exec {} \"$@\"", crate::wslhost::sq(&exe.display().to_string()))); + let file = match crate::wslhost::write_script("prove-run", &body) { + Ok(f) => f, + Err(e) => return (false, format!("cannot write the WSL run script: {e}")), + }; + let argv: Vec<&str> = args.iter().map(|a| a.as_str()).collect(); + let c = crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &argv); + (c, Some(file)) } else { let mut c = Command::new(exe); c.args(args); for (k, v) in env { c.env(k, v); } - c + (c, None) }; crate::platform::quiet(&mut cmd); let Ok(file) = std::fs::File::create(log) else { return (false, format!("cannot write {}", log.display())) }; @@ -259,16 +302,23 @@ fn loop_forever(shared: Arc, bin_dir: PathBuf) { let mut tools: Option = None; let mut last_probe = Instant::now() - Duration::from_secs(600); let mut submitted: Vec<(u64, String, u32, u128)> = Vec::new(); + let mut last_verifier_read = Instant::now() - Duration::from_secs(600); + let mut asked_restart = false; loop { std::thread::sleep(Duration::from_secs(10)); let enabled = shared.settings.lock().unwrap().prove; - let (synced, quitting) = { + let (synced, quitting, node_up) = { let st = shared.state.lock().unwrap(); - (st.node.synced, st.quitting) + (st.node.synced, st.quitting, matches!(st.node.state.as_str(), "syncing" | "synced")) }; if quitting { return; } + // the node's verifier state, proving on or off: a relaying-only node must say so on the tile + if node_up && last_verifier_read.elapsed() >= Duration::from_secs(30) { + last_verifier_read = Instant::now(); + read_verifier(&shared); + } if !enabled { set(&shared, |p| { p.enabled = false; @@ -286,6 +336,13 @@ fn loop_forever(shared: Arc, bin_dir: PathBuf) { p.setup_hint = String::new(); p.backend = if t.cuda { "cuda".into() } else { "cpu".into() }; }); + // Windows: the node was started before the WSL2 host existed (Set up ran since), so it verifies + // nothing; one restart lets src/verifier.rs find the host through igneum-prove-verify.exe + let node_has_none = shared.state.lock().unwrap().proving.verifier_set.is_empty(); + if t.wsl && node_has_none && !asked_restart { + asked_restart = true; + shared.send(crate::engine::Cmd::RestartNode("the WSL2 prover is installed now; the node restarts to verify proof records".into())); + } tools = Some(t); } Err(e) => { @@ -464,9 +521,18 @@ pub fn setup(shared: &Shared) -> Result { if !script.exists() { return Err(format!("setup script missing: {}", script.display())); } - let line = format!("bash {}", wsl_path(&script)); + // cmd's `start` opens the window; the tail after `--` follows the single-quoted rule of src/wslhost.rs (the + // payload path has a space) and goes on the line as written + let line = format!("/c start \"\" {} -d {} -- {}", crate::platform::tool("wsl").display(), crate::wslhost::DISTRO, crate::wslhost::bash_line(&script, true, &[])); let mut c = Command::new(crate::platform::tool("cmd")); - c.args(["/c", "start", "", &crate::platform::tool("wsl").display().to_string(), "-d", "Ubuntu-24.04", "--", "bash", "-lc", &line]); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.raw_arg(&line); + } + #[cfg(not(windows))] + c.arg(&line); + crate::platform::quiet(&mut c); // cmd itself hidden; `start` still opens the setup's own window c.spawn().map_err(|e| e.to_string())?; shared.event("proving", "WSL2 prover setup started in its own window"); Ok(json!({ "ok": true })) @@ -499,8 +565,10 @@ mod tests { } #[test] - fn wsl_paths() { - assert_eq!(wsl_path(Path::new("C:\\Users\\[user]\\AppData\\Local\\igneum\\app\\proving\\seq.json")), "/mnt/c/Users/[user]/AppData/Local/igneum/app/proving/seq.json"); - assert_eq!(wsl_path(Path::new("/tmp/x")), "/tmp/x"); + fn the_probe_message_names_every_path_it_looked_at() { + let m = probe_message(Path::new("C:\\Igneum"), true, true); + assert!(m.starts_with("proving needs the WSL2 setup, 20 minutes, Set up (no igneum-prove-host at /mnt/c/Igneum/wsl2/bin/igneum-prove-host, ~/igneum-prove/proving/igneum-prove/target/release/igneum-prove-host, ~/igneum-prove/target/release/igneum-prove-host, /opt/igneum/igneum-prove-host)"), "{m}"); + assert!(probe_message(Path::new("C:\\Igneum"), true, false).contains("setup script missing from the payload")); + assert!(probe_message(Path::new("C:\\Igneum"), false, true).contains("Ubuntu-24.04 did not answer")); } } diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 4cf06e7c..0849d683 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -258,7 +258,8 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result shared.set_prove(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)), "/api/prove/setup" => crate::prover::setup(shared), diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 6ce9846b..faaa8199 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -147,6 +147,21 @@ pub struct ProvingState { pub last_prove_s: f64, pub last_paid: String, pub message: String, + // the node's proof verifier (src/verifier.rs; spec 7.7 item 4): a node without one relays and never includes + /// the node's own report, `igneum_getProvingStatus().verifier` (`Off`, `Trust`, `Command("...")`); empty until read + pub verifier: String, + /// off | trust | command | unknown, from the report + pub verifier_mode: String, + /// what the app passed its node: `command:`, `trust`, or empty when it set nothing + pub verifier_set: String, + /// why the app set nothing (the paths it looked at), or the trust warning + pub verifier_reason: String, + /// the sentence on the tile for the state above + pub verifier_note: String, + /// the node's proof pool: records held, verified, rejected + pub pool_entries: u64, + pub pool_verified: u64, + pub pool_failed: u64, } #[derive(Clone, Serialize, Default)] @@ -194,6 +209,8 @@ pub struct SettingsState { pub sweep: bool, /// the miner software's dev fee switch (settings; `--dev-fee 0` when off) pub dev_fee: bool, + /// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`) + pub proof_verify_trust: bool, } /// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip. diff --git a/app/igneum-app/src/verifier.rs b/app/igneum-app/src/verifier.rs new file mode 100644 index 00000000..79725555 --- /dev/null +++ b/app/igneum-app/src/verifier.rs @@ -0,0 +1,174 @@ +//! The proof verifier the engine gives its node (spec 7.7 item 4, docs/plans/release-0.3.6.md item 1). +//! +//! The node keeps a proof pool and offers only verified records to its block templates; without a verifier it +//! relays and stores records and never includes one, so proofs are never paid. The node reads two variables +//! (`VerifyMode::from_env` in the node's exec/src/proving.rs): `IGNEUM_PROOF_VERIFIER=` runs +//! ` --mode verify --proof --statement 0x..` per proof; `IGNEUM_PROOF_VERIFY=trust` treats every +//! record as verified. This module decides which, once per node start: +//! +//! macOS, Linux `igneum-prove-host` next to the engine's binaries (the DMG ships it in Contents/Resources/bin) +//! Windows `igneum-prove-verify.exe` next to the engine (src/bin/prove-verify.rs), which runs the host +//! inside WSL2; it is set only when its `--probe` finds a host, so a node never gets a verifier +//! that cannot run +//! trust never by default; only the setting `proof_verify_trust` (shown as "devnet only") and only +//! when no verifier was found, so a real verifier always wins over trust +//! +//! What was decided is on the proving tile and in `/api/state` (`proving.verifier_set`, `proving.verifier_reason`); +//! the node's own report (`igneum_getProvingStatus().verifier`) is polled beside it (src/prover.rs). + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +/// What the engine passes to the node. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Verifier { + /// "command" | "trust" | "off" + pub mode: &'static str, + /// the environment for the node spawn (empty when off) + pub env: Vec<(String, String)>, + /// for the tile: the verifier path, or why there is none + pub detail: String, +} + +impl Verifier { + /// `/api/state` `proving.verifier_set`: `command:`, `trust`, or empty. + pub fn set_text(&self) -> String { + match self.mode { + "command" => format!("command:{}", self.detail), + "trust" => "trust".into(), + _ => String::new(), + } + } +} + +/// How long the Windows probe may take: WSL's first start of the day can take several seconds. +const PROBE_LIMIT: Duration = Duration::from_secs(45); + +/// Decides the verifier for one node start. `trust` is the `proof_verify_trust` setting. +pub fn resolve(bin_dir: &Path, trust: bool) -> Verifier { + let found = find(bin_dir); + match found { + Ok(path) => Verifier { mode: "command", env: vec![("IGNEUM_PROOF_VERIFIER".into(), path.display().to_string())], detail: path.display().to_string() }, + Err(reason) if trust => Verifier { mode: "trust", env: vec![("IGNEUM_PROOF_VERIFY".into(), "trust".into())], detail: format!("devnet only: records are trusted without verification ({reason})") }, + Err(reason) => Verifier { mode: "off", env: vec![], detail: reason }, + } +} + +/// The verifier executable, or why there is none. +fn find(bin_dir: &Path) -> Result { + if cfg!(windows) { + let wrapper = bin_dir.join("igneum-prove-verify.exe"); + if !wrapper.exists() { + return Err(format!("igneum-prove-verify.exe is not next to the engine ({})", bin_dir.display())); + } + let out = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&wrapper)).arg("--probe"), None, PROBE_LIMIT); + match out { + Some(text) => match text.lines().find(|l| l.starts_with("HOST ")) { + Some(_) => Ok(wrapper), + None => Err(format!("the WSL2 prover is not installed (looked at {}); Set up on the Proving tile installs it", crate::wslhost::candidates_text(bin_dir))), + }, + None => Err(format!("igneum-prove-verify.exe --probe did not answer within {} s (is WSL2 with {} installed?)", PROBE_LIMIT.as_secs(), crate::wslhost::DISTRO)), + } + } else { + let host = bin_dir.join("igneum-prove-host"); + if host.exists() { + Ok(host) + } else { + Err(format!("igneum-prove-host is not next to the engine ({})", bin_dir.display())) + } + } +} + +/// The node's `igneum_getProvingStatus().verifier` text (`Off`, `Trust`, `Command("...")`) as a word. +pub fn mode_of_report(report: &str) -> &'static str { + let r = report.trim(); + if r.eq_ignore_ascii_case("off") { + "off" + } else if r.eq_ignore_ascii_case("trust") { + "trust" + } else if r.starts_with("Command") { + "command" + } else { + "unknown" + } +} + +/// The sentence on the proving tile for the node's reported mode and what the app set. `external` = the app did +/// not start this node. +pub fn note(report_mode: &str, set: &str, reason: &str, external: bool) -> String { + match report_mode { + "command" => "This node verifies proof records with igneum-prove-host and includes the verified ones in its blocks.".into(), + "trust" => "Devnet only: this node trusts proof records without verifying them and includes them in its blocks.".into(), + "off" => { + let why = if external { + "the app did not start this node, so it set no verifier".to_string() + } else if !set.is_empty() { + format!("the app set a verifier ({set}) but the node reports none; an older node build, or it has not restarted since") + } else if reason.is_empty() { + "no verifier was set".to_string() + } else { + reason.to_string() + }; + format!("This node relays proofs but does not verify them, so it never includes a proof record in its blocks: {why}.") + } + _ => { + if set.is_empty() && !reason.is_empty() && !external { + format!("Verifier state not read yet. The app set no verifier: {reason}.") + } else { + "Verifier state not read yet (the node has not answered).".into() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_never_trusts_by_default_and_names_the_missing_host() { + let dir = std::env::temp_dir().join(format!("igneum-verifier-test-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let v = resolve(&dir, false); + assert_eq!(v.mode, "off"); + assert!(v.env.is_empty()); + assert!(v.detail.contains("is not next to the engine"), "{}", v.detail); + assert_eq!(v.set_text(), ""); + let v = resolve(&dir, true); + assert_eq!(v.mode, "trust"); + assert_eq!(v.env, vec![("IGNEUM_PROOF_VERIFY".to_string(), "trust".to_string())]); + assert!(v.detail.starts_with("devnet only")); + assert_eq!(v.set_text(), "trust"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(not(windows))] + #[test] + fn a_host_next_to_the_engine_wins_over_trust() { + let dir = std::env::temp_dir().join(format!("igneum-verifier-host-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + std::fs::write(dir.join("igneum-prove-host"), "#!/bin/sh\nexit 0\n").unwrap(); + let v = resolve(&dir, true); + assert_eq!(v.mode, "command"); + assert_eq!(v.env.len(), 1); + assert_eq!(v.env[0].0, "IGNEUM_PROOF_VERIFIER"); + assert!(v.env[0].1.ends_with("igneum-prove-host")); + assert!(v.set_text().starts_with("command:")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn report_modes_and_notes() { + assert_eq!(mode_of_report("Off"), "off"); + assert_eq!(mode_of_report("Trust"), "trust"); + assert_eq!(mode_of_report("Command(\"/x/igneum-prove-host\")"), "command"); + assert_eq!(mode_of_report(""), "unknown"); + assert!(note("off", "", "igneum-prove-host is not next to the engine (/x)", false).ends_with("blocks: igneum-prove-host is not next to the engine (/x).")); + assert!(note("off", "", "", true).contains("the app did not start this node")); + assert!(note("off", "command:/x", "", false).contains("older node build")); + assert!(note("command", "command:/x", "", false).starts_with("This node verifies")); + assert!(note("trust", "trust", "", false).starts_with("Devnet only")); + assert!(note("unknown", "", "", false).starts_with("Verifier state not read yet")); + } +} diff --git a/app/igneum-app/src/wslhost.rs b/app/igneum-app/src/wslhost.rs new file mode 100644 index 00000000..2740eb75 --- /dev/null +++ b/app/igneum-app/src/wslhost.rs @@ -0,0 +1,251 @@ +//! Where the Linux `igneum-prove-host` lives as seen from inside WSL2 (Ubuntu-24.04) on a PC. One list, used by +//! three callers: the prover's probe (src/prover.rs), the verifier lookup the node spawn uses (src/verifier.rs) +//! and the Windows wrapper `igneum-prove-verify.exe` (src/bin/prove-verify.rs, which includes this file by path +//! because the package has no library target). +//! +//! Lookup order, first executable wins: +//! 1. the payload's `wsl2\bin\igneum-prove-host` next to the engine (`/mnt//.../wsl2/bin/...` from Ubuntu) +//! 2. `~/igneum-prove/proving/igneum-prove/target/release/igneum-prove-host`, what setup-wsl.sh builds +//! (it copies the package to `$HOME/igneum-prove` and builds in `proving/igneum-prove`) +//! 3. `~/igneum-prove/target/release/igneum-prove-host`, the layout before 5 October 2026 +//! 4. `/opt/igneum/igneum-prove-host`, a hand install (the devnet jobs put the CUDA host there) + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// The WSL distribution the host runs in. +pub const DISTRO: &str = "Ubuntu-24.04"; + +/// A Windows path as WSL sees it: `C:\Users\x\f` -> `/mnt/c/Users/x/f`. A path without a drive letter is +/// returned with forward slashes only. +pub fn wsl_path(p: &Path) -> String { + let s = p.display().to_string().replace('\\', "/"); + let s = s.strip_prefix("//?/").map(|x| x.to_string()).unwrap_or(s); + if s.len() > 2 && s.as_bytes()[1] == b':' { + format!("/mnt/{}{}", s[..1].to_ascii_lowercase(), &s[2..]) + } else { + s + } +} + +/// The candidates in lookup order. `bin_dir` is the engine's folder on Windows (the payload root); `~` is left +/// for the shell inside WSL to expand, so the list reads the same in a message. +pub fn candidates(bin_dir: &Path) -> Vec { + vec![ + wsl_path(&bin_dir.join("wsl2").join("bin").join("igneum-prove-host")), + "~/igneum-prove/proving/igneum-prove/target/release/igneum-prove-host".to_string(), + "~/igneum-prove/target/release/igneum-prove-host".to_string(), + "/opt/igneum/igneum-prove-host".to_string(), + ] +} + +/// The candidates as one line for a message. +pub fn candidates_text(bin_dir: &Path) -> String { + candidates(bin_dir).join(", ") +} + +/// A `bash -lc` script that prints the first executable candidate (its path, one line) and nothing when there is +/// none. `~` expands in the shell; the shipped path is quoted. +pub fn lookup_script(bin_dir: &Path) -> String { + let list: Vec = candidates(bin_dir).into_iter().map(|c| if c.starts_with('~') { c } else { format!("'{}'", c.replace('\'', "'\\''")) }).collect(); + format!("for f in {}; do [ -x \"$f\" ] && {{ echo \"$f\"; break; }}; done", list.join(" ")) +} + +// ---- scripts run from a file, never inline on the command line ------------------------------------------------------ +// +// 5 October 2026, PC 2 on 0.3.5: `wsl -d Ubuntu-24.04 -- bash -lc " + + +
+
+
Wallets · chain id · RPC
+

Add Igneum to MetaMask

+
+

Igneum runs the Ethereum virtual machine, so MetaMask and every other Ethereum wallet work unchanged. The wallet needs four things: the chain id, an RPC URL, the symbol IGN and 18 decimals. The button below sends them to the wallet with the standard wallet_addEthereumChain request; the wallet shows them to you and asks before adding anything. Nothing on this page asks for a seed phrase or a key. Nobody from Igneum will ask for your seed.

+ +
+
+ Public testnet · coming +

Igneum testnet

+

The network the one-click miner app joins at public testnet. Coins on it have no value and the chain resets with notice (testnet terms).

+
+
Network name
Igneum Testnet
+
Chain id
4462 (0x116e)
+
RPC URL
https://rpc.testnet.igneum.network (published with the testnet)
+
Symbol
IGN
+
Decimals
18
+
Explorer
published with the testnet
+
+ +

The button switches on when the public RPC is live.

+
+
+ Devnet · live now +

Igneum devnet, through your own node

+

The Igneum Miner app runs a full node on your machine and serves the Ethereum RPC on it. Point the wallet at that node. The devnet is a developer network: it resets without notice and its coins have no value.

+
+
Network name
Igneum Devnet (local node)
+
Chain id
4463 (0x116f)
+
RPC URL
http://127.0.0.1:26790
+
Symbol
IGN
+
Decimals
18
+
Explorer
none yet
+
+ +

+
+
+ +

Add it by hand

+

If the wallet has no one-click support, or you prefer to type: MetaMask, Settings, Networks, Add a network manually. Enter the values from the card above. Any wallet that supports custom EVM networks takes the same four fields.

+
    +
  1. Network name: Igneum Testnet (or Igneum Devnet for your own node).
  2. +
  3. RPC URL: the one on the card.
  4. +
  5. Chain id: 4462 for the testnet, 4463 for the devnet.
  6. +
  7. Currency symbol: IGN. Decimals: 18.
  8. +
+ +

For builders

+

The same request from your own page or app, so your users land on the right chain:

+
await window.ethereum.request({
+  method: 'wallet_addEthereumChain',
+  params: [{
+    chainId: '0x116e',                      // 4462, the Igneum testnet (0x116f = 4463, the devnet)
+    chainName: 'Igneum Testnet',
+    nativeCurrency: { name: 'Igneum', symbol: 'IGN', decimals: 18 },
+    rpcUrls: ['https://rpc.testnet.igneum.network'],
+    blockExplorerUrls: []
+  }]
+});
+

Igneum signs transactions with the Ethereum rules (EIP-155, EIP-1559 and legacy envelopes). A transaction signed for another chain id is refused. Gas has two dimensions on Igneum, execution and proving, and the node folds the second into the price it quotes, so eth_gasPrice and eth_estimateGas work as they do on Ethereum. The litepaper has the differences.

+ +

The app and the Igneum Wallet

+

The Igneum Miner app makes an address for your earnings and shows you its seed phrase once. That address is an ordinary Ethereum account: import the seed into MetaMask and the balance is there. An Igneum Wallet with the Apps tab and the explorer built in is in the roadmap; until it ships, MetaMask is the wallet.

+ +

Chain ids 4461 (mainnet), 4462 (testnet) and 4463 (devnet) are fixed in the node. The testnet RPC URL above is a placeholder until the testnet opens; this page is updated the day it does.

+
+ + + + + + diff --git a/site/miner.html b/site/miner.html index fca5e066..04dc8433 100644 --- a/site/miner.html +++ b/site/miner.html @@ -505,6 +505,7 @@ pre b{color:var(--molten);font-weight:500}
Follow
Live devnet Journey + Add Igneum to MetaMask GitHub, spec and vectors Get the miner diff --git a/site/miners.html b/site/miners.html index 76d34247..6eb71695 100644 --- a/site/miners.html +++ b/site/miners.html @@ -216,6 +216,7 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:
Follow
Live devnet Journey + Add Igneum to MetaMask GitHub, spec and vectors Get the miner diff --git a/site/partials/footer.html b/site/partials/footer.html index 4ca1cf0e..ca543a25 100644 --- a/site/partials/footer.html +++ b/site/partials/footer.html @@ -25,6 +25,7 @@
Follow
Live devnet Journey + Add Igneum to MetaMask GitHub, spec and vectors Get the miner diff --git a/site/sitemap.xml b/site/sitemap.xml index 5599e809..dff7730d 100644 --- a/site/sitemap.xml +++ b/site/sitemap.xml @@ -6,4 +6,6 @@ https://igneum.network/bench2026-10-04weekly0.6 https://igneum.network/evidence2026-10-04weekly0.6 https://igneum.network/miners2026-10-04weekly0.6 + https://igneum.network/wallet2026-10-05monthly0.6 + https://igneum.network/metamask2026-10-05monthly0.4 diff --git a/site/wallet.html b/site/wallet.html index d885c3d6..e548faec 100644 --- a/site/wallet.html +++ b/site/wallet.html @@ -413,6 +413,7 @@ td.num{font-variant-numeric:tabular-nums;white-space:nowrap}
Follow
Live devnet Journey + Add Igneum to MetaMask GitHub, spec and vectors Get the miner diff --git a/tools/build-job.mjs b/tools/build-job.mjs index 58c0953a..32ce14c0 100755 --- a/tools/build-job.mjs +++ b/tools/build-job.mjs @@ -157,7 +157,7 @@ async function fetchOutputs(id, finals) { if (o.target === 'linux') { try { chmodSync(plain, 0o755); } catch {} } let note = ''; if (o.target === 'windows') { - const pe = peCheck(plain); + const pe = peCheck(plain, o.name.endsWith('.dll') ? 16 * 1024 : undefined); // the runtime DLLs the PC ships next to the exes are small if (!pe.ok) { console.log(`${o.name}: PE check FAILED: ${pe.problems.join('; ')}`); bad++; continue; } note = ` PE ok (${pe.sections.join(' ')}, subsystem ${pe.subsystem})`; if (o.name === 'igneum-app.exe') { diff --git a/tools/ci/check-workflow-shell.mjs b/tools/ci/check-workflow-shell.mjs new file mode 100644 index 00000000..3ca14773 --- /dev/null +++ b/tools/ci/check-workflow-shell.mjs @@ -0,0 +1,101 @@ +// Mac-side (and CI) parse check of the shell inside .github/workflows/*.yml, so a broken `run:` block is caught before +// a Windows runner spends twenty minutes on it (4 October 2026, the signed-inputs step of windows.yml). +// +// node tools/ci/check-workflow-shell.mjs [workflow.yml ...] default: every workflow under .github/workflows +// +// For every step with a `run: |` block: `shell: bash` (or no shell on an ubuntu job) goes through `bash -n`; +// `shell: powershell` and `shell: pwsh` blocks, and every .ps1 the Windows folders hold, are checked against the one +// rule Windows PowerShell 5.1 enforces that newer parsers may not: a drive-qualified variable reference "$name: text" +// inside a double-quoted string (tools/ci/windows/check-ps51.ps1 runs the real 5.1 parser on the runner; this is the +// Mac approximation of its rule, with the same negative fixture). `shell: cmd` blocks are checked for the bare ")" +// class only when they span more than one line. Exit 1 on any finding, with file:line. +import { readFileSync, readdirSync, writeFileSync, mkdtempSync, rmSync, existsSync, statSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repo = join(here, '..', '..'); +const wfDir = join(repo, '.github', 'workflows'); +const files = process.argv.length > 2 ? process.argv.slice(2) : readdirSync(wfDir).filter(f => /\.ya?ml$/.test(f)).map(f => join(wfDir, f)); +const tmp = mkdtempSync(join(tmpdir(), 'wf-shell-')); +let findings = 0, blocks = 0, ps1 = 0; +const say = (file, line, msg) => { findings++; console.log(`${file}:${line}: ${msg}`); }; + +// The 5.1 rule: inside a double-quoted string, `$identifier:` is read as a drive-qualified variable reference +// (`$env:PATH`, `$script:node`), so when the character after the colon cannot start a variable name (a space, a +// `$`, punctuation or the closing quote) 5.1 fails with "Variable reference is not valid. ':' was not followed by a +// valid variable name character". `$env:PATH`, `$script:x`, `${name}:` and `$($name):` are fine. +const DRIVE_REF = /"(?:[^"\\]|\\.|`")*?\$[A-Za-z_][A-Za-z0-9_]*:(?![A-Za-z0-9_])(?:[^"\\]|\\.|`")*"/; +function checkPowerShell(text, file, firstLine) { + const lines = text.split('\n'); + lines.forEach((l, i) => { + const noComment = l.replace(/^\s*#.*$/, ''); + if (DRIVE_REF.test(noComment) && !/\$\{[A-Za-z_][A-Za-z0-9_]*\}:/.test(noComment)) say(file, firstLine + i, `PowerShell 5.1 rejects "$name: text" (drive-qualified variable reference): ${l.trim().slice(0, 100)}`); + }); +} +// the same negative fixture check-ps51.ps1 uses: the Mac rule must bite on it or it proves nothing +const fixture = join(repo, 'tools', 'ci', 'windows', 'fixtures', 'bad-drive-ref.ps1.txt'); +if (existsSync(fixture)) { + const before = findings; + checkPowerShell(readFileSync(fixture, 'utf8'), 'fixture', 1); + if (findings === before) { console.log('self-test failed: the Mac rule does not fire on tools/ci/windows/fixtures/bad-drive-ref.ps1.txt'); process.exit(2); } + findings = before; console.log('self-test: the 5.1 drive-reference rule fires on the fixture'); +} + +function checkBash(text, file, firstLine) { + const p = join(tmp, `block-${blocks}.sh`); + writeFileSync(p, text); + const r = spawnSync('bash', ['-n', p], { encoding: 'utf8' }); + if (r.status !== 0) say(file, firstLine, `bash -n: ${(r.stderr || '').trim().replace(p, 'block').split('\n')[0]}`); +} +function checkCmd(text, file, firstLine) { + text.split('\n').forEach((l, i) => { if (/^\s*\)\s*$/.test(l)) say(file, firstLine + i, `a bare ")" line in a cmd block (the 3 October class)`); }); +} + +for (const file of files) { + const rel = file.startsWith(repo) ? file.slice(repo.length + 1) : file; + const lines = readFileSync(file, 'utf8').split('\n'); + let runsOn = ''; + for (let i = 0; i < lines.length; i++) { + const m = /^(\s*)runs-on:\s*(\S+)/.exec(lines[i]); if (m) runsOn = m[2]; + const r = /^(\s*)run:\s*\|\s*$/.exec(lines[i]); + if (!r) continue; + const indent = r[1].length; + // the step's shell: look back to the step's "- name:" for a `shell:` key at the same indent as `run:` + let shell = ''; + for (let k = i - 1; k >= 0; k--) { + const s = /^(\s*)shell:\s*(\S+)/.exec(lines[k]); + if (s && s[1].length === indent) { shell = s[2]; break; } + if (/^\s*-\s+(name|uses|run):/.test(lines[k]) && /^\s*-/.test(lines[k]) && lines[k].search(/\S/) < indent) break; + } + // the block: every following line indented deeper than `run:` + const body = []; + let j = i + 1; + while (j < lines.length && (lines[j].trim() === '' || lines[j].search(/\S/) > indent)) { body.push(lines[j]); j++; } + while (body.length && body[body.length - 1].trim() === '') body.pop(); + const bodyIndent = Math.min(...body.filter(l => l.trim()).map(l => l.search(/\S/))); + const text = body.map(l => l.slice(bodyIndent)).join('\n') + '\n'; + const firstLine = i + 2; + blocks++; + const kind = shell || (runsOn.startsWith('windows') ? 'pwsh' : 'bash'); + if (kind === 'bash') checkBash(text, rel, firstLine); + else if (kind === 'powershell' || kind === 'pwsh') checkPowerShell(text, rel, firstLine); + else if (kind === 'cmd') checkCmd(text, rel, firstLine); + i = j - 1; + } +} +// every .ps1 the Windows folders hold, the same rule +const folders = ['proto-cuda/windows-app', 'proto-cuda/windows-miner', 'proto-cuda/windows-node', 'proving/windows-wsl2', 'relay/clients', 'relay/playbooks', 'packaging/windows', 'app/windows', 'tools/ci/windows']; +function walk(d) { if (!existsSync(d)) return []; return readdirSync(d).flatMap(f => { const p = join(d, f); return statSync(p).isDirectory() ? walk(p) : (f.endsWith('.ps1') ? [p] : []); }); } +for (const f of folders) for (const p of walk(join(repo, f))) { ps1++; checkPowerShell(readFileSync(p, 'utf8'), p.slice(repo.length + 1), 1); } +// the shell scripts the workflow and the Mac side run +for (const f of ['packaging/windows/push-inputs.sh', 'packaging/windows/fetch-ci-artifacts.sh', 'packaging/windows/inputs-manifest.sh', 'packaging/windows/test-inputs-signing.sh', 'packaging/ota/publish-manifest.sh', 'packaging/windows/make-payload.sh']) { + const p = join(repo, f); if (!existsSync(p)) continue; + const r = spawnSync('bash', ['-n', p], { encoding: 'utf8' }); + if (r.status !== 0) say(f, 1, `bash -n: ${(r.stderr || '').trim().split('\n')[0]}`); +} +rmSync(tmp, { recursive: true, force: true }); +console.log(`workflow shell: ${blocks} run blocks in ${files.length} workflow(s), ${ps1} .ps1 files, ${findings} finding(s)`); +process.exit(findings ? 1 : 0); diff --git a/tools/logs.mjs b/tools/logs.mjs index da8be16d..919af6d6 100755 --- a/tools/logs.mjs +++ b/tools/logs.mjs @@ -3,9 +3,58 @@ // node tools/logs.mjs list runs: label, machine, run_id, last received, total bytes // node tools/logs.mjs print the latest upload for that run // node tools/logs.mjs --all print every upload for that run, oldest first +// node tools/logs.mjs --rotation rotation phase 2 (docs/plans/rotation-phase-2.md): per app machine (labels mac-*, +// win-*), the version and the "config:" header line of its latest upload (the +// intake key's fingerprint and the downloads folder's fingerprint), against the +// fingerprints of ~/.config/igneum/log-intake-key.next and dl-token.next; exit 1 +// while any machine still reports with the old values +// node tools/logs.mjs --self-test the header parser on sample lines // Reads DATABASE_URL from ~/.config/igneum/env. No dependencies: Neon HTTP SQL over fetch. -import { readFileSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { homedir } from 'node:os'; +import { createHash } from 'node:crypto'; + +// the two header lines the engine logs at every start (app/igneum-app/src/engine.rs run(), config.rs describe()): +// IGNEUM-APP version=0.3.6 machine=1ccfe586 platform=windows node=... +// config: intake https://.../api/log key 477bb0ef (packaged); manifest https://.../dl//igneum-app-latest.json folder ed9c4d2e (packaged) +// The LAST occurrence wins (the restart after an OTA apply logs them again). Fields missing from the upload read ''. +export function parseRotation(lines) { + const out = { version: '', keyFp: '', keySource: '', folderFp: '', manifestSource: '' }; + for (const line of String(lines).split('\n')) { + let m = /IGNEUM-APP version=(\S+)/.exec(line); + if (m) out.version = m[1]; + m = /config: intake \S+ (?:key ([0-9a-f]{8})|no key) \((packaged|file [^)]+|none)\); manifest \S+ (?:folder ([0-9a-f]{8})|no folder|custom) \((packaged|file [^)]+|none)\)/.exec(line); + if (m) { out.keyFp = m[1] || ''; out.keySource = m[2]; out.folderFp = m[3] || ''; out.manifestSource = m[4]; } + } + return out; +} +// the first 8 hex of sha256 over the trimmed file content; '' when the file is missing (the app's config::fingerprint8) +export function fingerprintFile(path) { + if (!existsSync(path)) return ''; + const v = readFileSync(path, 'utf8').trim(); + return v ? createHash('sha256').update(v).digest('hex').slice(0, 8) : ''; +} + +if (process.argv[2] === '--self-test') { + let fails = 0; + const check = (name, ok, detail = '') => { console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ': ' + detail : ''}`); if (!ok) fails++; }; + const sample = ['1 Igneum Miner 0.3.6 on pc (machine id 1ccfe586abcdef01), devnet (run win-1ccfe586-x)', + '1 IGNEUM-APP version=0.3.5 machine=1ccfe586 platform=windows node=igneumd_0.3.5', + '1 config: intake https://igneum-six.vercel.app/api/log key e2005de8 (packaged); manifest https://dl.igneum.network/dl//igneum-app-latest.json folder df66a82c (packaged)', + '2 update: applied', '2 IGNEUM-APP version=0.3.6 machine=1ccfe586 platform=windows node=igneumd_0.3.6', + '2 config: intake https://igneum-six.vercel.app/api/log key 477bb0ef (file log-intake-key.next); manifest https://dl.igneum.network/dl//igneum-app-latest.json folder ed9c4d2e (packaged)'].join('\n'); + const r = parseRotation(sample); + check('the last header wins', r.version === '0.3.6' && r.keyFp === '477bb0ef' && r.folderFp === 'ed9c4d2e', JSON.stringify(r)); + check('sources are read', r.keySource === 'file log-intake-key.next' && r.manifestSource === 'packaged', JSON.stringify(r)); + const none = parseRotation('1 config: intake none no key (none); manifest none no folder (none)\n'); + check('a build without key or folder reads empty fingerprints', none.keyFp === '' && none.folderFp === '' && none.keySource === 'none', JSON.stringify(none)); + const custom = parseRotation('1 config: intake https://x/api/log key 01234567 (packaged); manifest http://127.0.0.1:9/dl/t/igneum-app-latest.json custom (packaged)\n'); + check('a custom manifest URL reads no folder', custom.keyFp === '01234567' && custom.folderFp === '', JSON.stringify(custom)); + check('an old upload without the config line reads version only', (() => { const o = parseRotation('1 IGNEUM-APP version=0.3.4 machine=a platform=mac node=x\n'); return o.version === '0.3.4' && o.keyFp === '' && o.keySource === ''; })()); + check('fingerprintFile of a missing file is empty', fingerprintFile('/nonexistent/igneum/key') === ''); + console.log(fails ? `${fails} check(s) failed` : 'all checks passed'); + process.exit(fails ? 1 : 0); +} process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; }); @@ -28,6 +77,30 @@ async function sql(query, params = []) { const [runId, flag] = process.argv.slice(2); +if (runId === '--rotation') { + const cfg = `${homedir()}/.config/igneum`; + const want = { key: fingerprintFile(`${cfg}/log-intake-key.next`) || fingerprintFile(`${cfg}/log-intake-key`), folder: fingerprintFile(`${cfg}/dl-token.next`) || fingerprintFile(`${cfg}/dl-token`) }; + const old = { key: fingerprintFile(`${cfg}/log-intake-key`), folder: fingerprintFile(`${cfg}/dl-token`) }; + // the latest upload per app label (mac-, win-); the header lines sit in the first bytes, the config line + // may repeat after an OTA restart, so the whole upload is parsed + const rows = await sql(` + SELECT DISTINCT ON (label) label, machine, run_id, received_at, lines + FROM miner_logs WHERE label LIKE 'mac-%' OR label LIKE 'win-%' + ORDER BY label, received_at DESC`); + if (!rows.length) { console.log('No app uploads yet.'); process.exit(1); } + let moved = 0, stale = 0; + const table = rows.map(r => { + const p = parseRotation(r.lines); + const ok = p.keyFp === want.key && p.folderFp === want.folder; + if (ok) moved++; else stale++; + return { label: r.label, machine: r.machine, version: p.version || '?', key: p.keyFp || '-', folder: p.folderFp || '-', sources: [p.keySource, p.manifestSource].filter(Boolean).join(' / ') || '-', last_received: new Date(r.received_at).toISOString().replace('T', ' ').slice(0, 19) + ' UTC', state: ok ? 'moved' : p.keyFp === old.key || p.folderFp === old.folder ? 'OLD' : 'unknown' }; + }).sort((a, b) => a.state.localeCompare(b.state) || a.label.localeCompare(b.label)); + console.table(table); + console.log(`expected: key ${want.key || '?'} folder ${want.folder || '?'} (from the .next files when they exist); old: key ${old.key || '?'} folder ${old.folder || '?'}`); + console.log(`${moved} machine(s) on the new key and folder, ${stale} not yet; a machine silent for over a day is listed by its last upload`); + process.exit(stale ? 1 : 0); +} + if (!runId) { const rows = await sql(` SELECT run_id, max(label) AS label, max(machine) AS machine, count(*)::int AS uploads, diff --git a/tools/repo/fresh-repo.sh b/tools/repo/fresh-repo.sh new file mode 100755 index 00000000..4edfd445 --- /dev/null +++ b/tools/repo/fresh-repo.sh @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# The history rewrite of docs/plans/history-rewrite.md, section 2, as one script: a fresh mirror clone, one +# git-filter-repo pass with the plan's rules, the greps that must read zero, and the commands (printed, never run) +# that create the fresh repository under the organisation and push the rewritten refs there (the owner's decision of +# 5 October 2026: option B, a fresh repository, never a force-push over the old one). +# +# tools/repo/fresh-repo.sh [--source ] [--work ] [--new-repo ] [--new-login ] +# [--public-claude-md ] [--clean] +# +# --source what to clone (default: this checkout's origin URL; a local path makes a throwaway dry run) +# --work where the clone and the report go (default: a fresh directory under $TMPDIR); never inside a checkout +# --new-repo the repository the printed commands create (default: igneum-network/igneum-core) +# --new-login the renamed GitHub login (the owner renames it first; the numeric noreply id stays): every author +# line and every file mention of the standing login is rewritten to it, and the identity grep then +# demands zero hits for the old login too. Without it the standing login stays and is reported as such +# --public-claude-md a scrubbed CLAUDE.md that replaces the file in EVERY commit (docs/fud-fixes.md section 5 step 2) +# --clean remove the work directory at the end (the default keeps it: the push runs from that clone) +# +# Reads (never prints): ~/.config/igneum/log-intake-key, log-intake-key.next, dl-token, dl-token.next (those that +# exist) for the secret rules and the secret grep; the personal identities and the second owner login are read from +# the history itself (every author or committer that is not the standing login). The rule files are written 0600 +# in a 0700 directory and removed (rm -P) as soon as the pass has run. Nothing is pushed; nothing in --source changes. +# +# Needs git-filter-repo 2.38 or later: `git filter-repo` on PATH, or IGNEUM_FILTER_REPO= +# (pip: python3 -m pip install --target git-filter-repo). TZ is forced to UTC for everything this script runs. +set -euo pipefail +export TZ=UTC +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +STANDING_LOGIN="${IGNEUM_STANDING_LOGIN:-igneum-labs}" +ORG="igneum-network" +SOURCE="" WORK="" NEW_REPO="$ORG/igneum-core" NEW_LOGIN="" PUBLIC_CLAUDE="" CLEAN=0 +while [ $# -gt 0 ]; do + case "$1" in + --source) SOURCE="$2"; shift 2 ;; + --work) WORK="$2"; shift 2 ;; + --new-repo) NEW_REPO="$2"; shift 2 ;; + --new-login) NEW_LOGIN="$2"; shift 2 ;; + --public-claude-md) PUBLIC_CLAUDE="$2"; shift 2 ;; + --clean) CLEAN=1; shift ;; + -h|--help) sed -n '2,24p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$SOURCE" ] || SOURCE="$(git -C "$ROOT" remote get-url origin)" +[ -n "$WORK" ] || WORK="$(mktemp -d "${TMPDIR:-/tmp}/igneum-fresh-repo.XXXXXX")" +case "$WORK" in /*) ;; *) WORK="$PWD/$WORK" ;; esac +mkdir -p "$WORK" +CLONE="$WORK/clone" +[ ! -e "$CLONE" ] || { echo "$CLONE exists; the pass runs on a fresh clone only (remove it or give another --work)" >&2; exit 1; } +if [ -n "$PUBLIC_CLAUDE" ]; then [ -f "$PUBLIC_CLAUDE" ] || { echo "no $PUBLIC_CLAUDE" >&2; exit 1; }; PUBLIC_CLAUDE="$(cd "$(dirname "$PUBLIC_CLAUDE")" && pwd)/$(basename "$PUBLIC_CLAUDE")"; fi +case "$NEW_LOGIN" in *[!A-Za-z0-9-]*) echo "--new-login must be a GitHub login (letters, digits, hyphens)" >&2; exit 2 ;; esac +[ "$NEW_LOGIN" != "$STANDING_LOGIN" ] || NEW_LOGIN="" + +# the filter +if [ -n "${IGNEUM_FILTER_REPO:-}" ]; then FILTER=(python3 "$IGNEUM_FILTER_REPO") +elif git filter-repo --version >/dev/null 2>&1; then FILTER=(git filter-repo) +elif python3 -c 'import git_filter_repo' 2>/dev/null; then FILTER=(python3 -m git_filter_repo) +else echo "git-filter-repo is not installed: python3 -m pip install --target git-filter-repo, then IGNEUM_FILTER_REPO=/git_filter_repo.py" >&2; exit 1; fi +command -v perl >/dev/null || { echo "perl is needed for the greps" >&2; exit 1; } + +say() { printf '%s\n' "$*" | tee -a "$WORK/report.txt"; } +: > "$WORK/report.txt" +say "fresh-repo: $(date -u +%Y-%m-%dT%H:%M:%SZ)" +say "source: $SOURCE" +say "work: $WORK" +say "filter: ${FILTER[*]} ($("${FILTER[@]}" --version 2>/dev/null | head -1 || echo '?'))" + +# ---- 1. the fresh mirror clone ------------------------------------------------------------------------------------ +git clone --quiet --mirror --no-hardlinks "$SOURCE" "$CLONE" +cd "$CLONE" + +# ---- 2. the values, read at run time, never printed ------------------------------------------------------------------ +umask 077 +RULES="$WORK/rules"; mkdir -p "$RULES"; chmod 700 "$RULES" +cleanup_rules() { if [ -d "$RULES" ]; then for f in "$RULES"/*; do [ -f "$f" ] && { rm -P "$f" 2>/dev/null || rm -f "$f"; }; done; rmdir "$RULES" 2>/dev/null || true; fi; } +trap cleanup_rules EXIT + +# the standing login's noreply address, from the history +STANDING_EMAIL="$(git log --all --format='%ae%n%ce' | grep -E "^[0-9]+\+$STANDING_LOGIN@users\.noreply\.github\.com$" | sort -u | head -1 || true)" +[ -n "$STANDING_EMAIL" ] || { echo "the history carries no commit by $STANDING_LOGIN (set IGNEUM_STANDING_LOGIN)" >&2; exit 1; } +STANDING_ID="${STANDING_EMAIL%%+*}" +if [ -n "$NEW_LOGIN" ]; then TARGET_LOGIN="$NEW_LOGIN"; else TARGET_LOGIN="$STANDING_LOGIN"; fi +TARGET_EMAIL="$STANDING_ID+$TARGET_LOGIN@users.noreply.github.com" +TARGET_IDENT="$TARGET_LOGIN <$TARGET_EMAIL>" + +# every other identity: "name|email" pairs (author and committer) +PERSONAL_PAIRS="$(git log --all --format='%an|%ae%n%cn|%ce' | grep -v "|$STANDING_EMAIL$" | sort -u || true)" +PERSONAL_EMAILS="$(printf '%s\n' "$PERSONAL_PAIRS" | awk -F'|' 'NF==2{print $2}' | sort -u)" +PERSONAL_NAMES="$(printf '%s\n' "$PERSONAL_PAIRS" | awk -F'|' 'NF==2{print $1}' | grep -v "^$STANDING_LOGIN$" | sort -u || true)" +FIRST_NAMES="$(printf '%s\n' "$PERSONAL_NAMES" | awk 'NF>=1{print $1}' | sort -u)" +LAST_NAMES="$(printf '%s\n' "$PERSONAL_NAMES" | awk 'NF>=2{print $NF}' | sort -u)" +SECOND_LOGINS="$(printf '%s\n' "$PERSONAL_EMAILS" | sed -nE 's/^[0-9]+\+([A-Za-z0-9-]+)@users\.noreply\.github\.com$/\1/p' | grep -v "^$STANDING_LOGIN$" | sort -u || true)" +# the other businesses named in the plan (brand names, not people) +OTHER_BUSINESSES='[other-business]|[other-business]|[other-business]|[other-business]|[other-business]' +# the secrets: whichever of the four files exist +SECRET_FILES=(); for n in log-intake-key log-intake-key.next dl-token dl-token.next; do [ -f "$HOME/.config/igneum/$n" ] && SECRET_FILES+=("$HOME/.config/igneum/$n"); done + +say "standing login: $STANDING_LOGIN (noreply id $STANDING_ID)${NEW_LOGIN:+ -> $NEW_LOGIN}" +say "personal identities in the history: $(printf '%s\n' "$PERSONAL_PAIRS" | grep -c . || true) (names $(printf '%s\n' "$PERSONAL_NAMES" | grep -c . || true), addresses $(printf '%s\n' "$PERSONAL_EMAILS" | grep -c . || true), second owner logins $(printf '%s\n' "$SECOND_LOGINS" | grep -c . || true))" +say "secret files for the rules: ${#SECRET_FILES[@]} of 4" + +# the rule files +REPLACE="$RULES/replace.txt"; MAILMAP="$RULES/mailmap"; IDENT="$RULES/identity.pl"; SECRETS="$RULES/secrets.pl" +: > "$REPLACE"; : > "$MAILMAP"; : > "$IDENT"; : > "$SECRETS" +for f in ${SECRET_FILES[@]+"${SECRET_FILES[@]}"}; do + v="$(tr -d '[:space:]' < "$f")"; [ ${#v} -ge 8 ] || continue + case "$(basename "$f")" in log-intake-key*) tag='***INTAKE-KEY-REMOVED***' ;; *) tag='***DL-TOKEN-REMOVED***' ;; esac + printf 'literal:%s==>%s\n' "$v" "$tag" >> "$REPLACE" + printf '%s\n' "$v" | sed 's/[][\\.*^$?+(){}|/]/\\&/g' >> "$SECRETS" # a regex for the scanner: metacharacters escaped (never \Q, which qr// does not expand from a variable) +done +while IFS='|' read -r name email; do + [ -n "$email" ] || continue + printf 'literal:%s <%s>==>%s\n' "$name" "$email" "$TARGET_IDENT" >> "$REPLACE" + printf '%s <%s> <%s>\n' "$TARGET_LOGIN" "$TARGET_EMAIL" "$email" >> "$MAILMAP" +done <<< "$PERSONAL_PAIRS" +while IFS= read -r email; do + [ -n "$email" ] || continue + printf 'literal:%s==>[removed]\n' "$email" >> "$REPLACE" + printf '%s\n' "$email" | sed 's/[][\\.*^$?+(){}|/]/\\&/g' >> "$IDENT" +done <<< "$PERSONAL_EMAILS" +if [ -n "$NEW_LOGIN" ]; then + printf 'literal:%s==>%s\n' "$STANDING_EMAIL" "$TARGET_EMAIL" >> "$REPLACE" + printf '%s <%s> <%s>\n' "$TARGET_LOGIN" "$TARGET_EMAIL" "$STANDING_EMAIL" >> "$MAILMAP" + printf 'regex:\\b%s\\b==>%s\n' "$STANDING_LOGIN" "$NEW_LOGIN" >> "$REPLACE" + printf '\\b%s\\b\n' "$STANDING_LOGIN" >> "$IDENT" +fi +while IFS= read -r first; do + [ -n "$first" ] || continue + printf 'regex:\\b%s%ss\\b==>the project lead%ss\n' "$first" "'" "'" >> "$REPLACE" + while IFS= read -r last; do [ -n "$last" ] && printf 'regex:\\b%s\\s+%s\\b==>the project lead\n' "$first" "$last" >> "$REPLACE"; done <<< "$LAST_NAMES" + printf 'regex:\\b%s\\b==>the project lead\n' "$first" >> "$REPLACE" +done <<< "$FIRST_NAMES" +while IFS= read -r last; do + [ -n "$last" ] || continue + printf 'regex:\\b%s\\b==>[removed]\n' "$last" >> "$REPLACE" + printf '\\b%s\\b\n' "$last" >> "$IDENT" +done <<< "$LAST_NAMES" +while IFS= read -r first; do + [ -n "$first" ] || continue + # the lower-case user-name form (Windows and WSL paths, the browser profile), never the standing login's suffix + printf 'regex:(?i)(?[user]\n' "${STANDING_LOGIN%%-*}" "$first" >> "$REPLACE" + printf '(?> "$IDENT" +done <<< "$FIRST_NAMES" +while IFS= read -r login; do + [ -n "$login" ] || continue + printf 'regex:(?i)\\b%s\\b==>[second-owner-login]\n' "$login" >> "$REPLACE" + printf '\\b%s\\b\n' "$login" >> "$IDENT" +done <<< "$SECOND_LOGINS" +printf 'regex:(?i)\\b(%s)\\b==>[other-business]\n' "$OTHER_BUSINESSES" >> "$REPLACE" +printf '\\b(%s)\\b\n' "$OTHER_BUSINESSES" >> "$IDENT" +say "rules: $(grep -c . "$REPLACE") replace lines, $(grep -c . "$MAILMAP") mailmap lines, $(grep -c . "$IDENT") identity patterns, $(grep -c . "$SECRETS") secret patterns (files under $RULES, removed at exit)" + +# ---- 3. the counts, before and after --------------------------------------------------------------------------------- +# every blob in the object store (reachable or not: before the pass the mirror holds everything, after it filter-repo's gc +# has pruned), one count of matching lines over a pattern file (perl regexes, case-insensitive) +scan_blobs() { + git cat-file --batch-all-objects --batch-check='%(objectname) %(objecttype)' --unordered 2>/dev/null | awk '$2 == "blob" { print $1 }' \ + | git cat-file --batch 2>/dev/null \ + | perl -ne 'BEGIN { open(P, "<", shift) or die; @p = map { chomp; qr/$_/i } grep { /\S/ }

; $n = 0 } for my $p (@p) { if ($_ =~ $p) { $n++; last } } END { print "$n\n" }' "$1" +} +scan_meta() { git log --all --format='%an%n%ae%n%cn%n%ce%n%s%n%b' | perl -ne 'BEGIN { open(P, "<", shift) or die; @p = map { chomp; qr/$_/i } grep { /\S/ }

; $n = 0 } for my $p (@p) { if ($_ =~ $p) { $n++; last } } END { print "$n\n" }' "$1"; } +DROPPED=(docs/fud-ledger.md docs/fud-fixes.md docs/review site/ledger.html) +counts() { #

] [--mac-release ] -// [--min-supported 0.3.0] [--activation-height N --deadline-note "..."] +// [--min-supported 0.3.0] [--activation-height N --deadline-note "..."] [--dl-both] // node tools/ship-app.mjs --check the six version files agree (exit 1 when they do not) // node tools/ship-app.mjs --self-test the bump, on a scratch copy of the version files // @@ -18,11 +18,21 @@ // fetch packaging/windows/fetch-ci-artifacts.sh : the installer and the payload zip into the downloads folder // dmg packaging/mac/build-dmg.sh under tools/lock/with-lock.sh build (nice 19, 4 cargo jobs) // copy the DMG into the downloads folder -// manifest packaging/ota/publish-manifest.sh --no-deploy: canonical JSON, signed, signature verified locally +// mirror --dl-both only: the version's files and the folder-level files (jobs, payload inputs, CI record) into the +// NEXT token folder, dl//, so both folders carry the same bytes +// manifest packaging/ota/publish-manifest.sh --no-deploy: canonical JSON, signed, signature verified locally; with +// --dl-both a second manifest in the NEXT folder (--dest, --base-url) carrying the same override, tuning and +// min_supported, checked field by field against the first // deploy the downloads folder with the Vercel CLI (one deploy carries the files and the manifest together) -// verify HEAD and GET of the three files (size and sha256 against the local copies), the live manifest and its signature +// verify HEAD and GET of the three files (size and sha256 against the local copies), the live manifest and its signature; +// with --dl-both the same for the NEXT folder // console one console item (tools/console.mjs post --kind build) with version, sizes and hashes, then sync-dl // +// --dl-both (rotation phase 2, 5 October 2026, docs/plans/rotation-phase-2.md): the downloads token is being rotated. +// Installed apps check the OLD folder (dl-token); the new build checks the NEW one (dl-token.next, what +// packaging/mac/packaged-config.sh packages by default while that file exists). The version is published in BOTH +// folders so the old apps find the update and the new ones find their folder; one deploy, both verified. +// // Secrets: ~/.config/igneum/dl-token, dlsite-dir, relay-token, relay-key, ota-signing-key, vercel/ are read by this // tool or by the scripts it calls and never printed; every output line is scrubbed of the tokens. State that is not a // secret (commit, run id, bump time) lives in ~/.cache/igneum/ship/.json. gh auth switch --user igneum-labs runs @@ -107,7 +117,7 @@ function checkVersionFiles(root) { } // ---- output: every line scrubbed of the tokens ------------------------------------------------------------------- -const SECRETS = ['dl-token', 'relay-token', 'relay-key', 'log-intake-key'].map(cfg).filter(s => s.length >= 8); +const SECRETS = ['dl-token', 'dl-token.next', 'relay-token', 'relay-key', 'log-intake-key', 'log-intake-key.next'].map(cfg).filter(s => s.length >= 8); const scrub = s => SECRETS.reduce((t, k) => t.split(k).join(''), String(s)); const say = (...a) => console.log(scrub(a.join(' '))); const fmtSize = n => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`; @@ -154,7 +164,7 @@ for (let i = 0; i < argv.length; i++) { if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; } else pos.push(a); } -const STEPS = ['preflight', 'bump', 'inputs', 'commit', 'ci', 'fetch', 'dmg', 'copy', 'manifest', 'deploy', 'verify', 'console']; +const STEPS = ['preflight', 'bump', 'inputs', 'commit', 'ci', 'fetch', 'dmg', 'copy', 'mirror', 'manifest', 'deploy', 'verify', 'console']; if (flags['self-test']) { process.exit(selfTest()); } if (flags.check) { @@ -166,7 +176,7 @@ if (flags.check) { } const VERSION = pos[0]; -if (!VERSION || !isVersion(VERSION)) { console.error('usage: node tools/ship-app.mjs --node [--notes "..."] [--dry-run] [--from ] [--skip-windows|--skip-mac]\n node tools/ship-app.mjs --check | --self-test'); process.exit(2); } +if (!VERSION || !isVersion(VERSION)) { console.error('usage: node tools/ship-app.mjs --node [--notes "..."] [--dry-run] [--from ] [--skip-windows|--skip-mac] [--dl-both]\n node tools/ship-app.mjs --check | --self-test'); process.exit(2); } if (!flags.node) { console.error('--node is required (the igneum-node worktree the node and miner were built from)'); process.exit(2); } if (flags.from && !STEPS.includes(flags.from)) { console.error(`--from must be one of: ${STEPS.join(', ')}`); process.exit(2); } if (flags['skip-windows'] && flags['skip-mac']) { console.error('--skip-windows and --skip-mac together leave nothing to ship'); process.exit(2); } @@ -179,6 +189,14 @@ const TOKEN = cfg('dl-token'); const DLSITE = process.env.IGNEUM_DLSITE || cfg('dlsite-dir'); const DEST = DLSITE && TOKEN ? join(DLSITE, 'dl', TOKEN) : ''; const BASE = `https://dl.igneum.network/dl/${TOKEN}`; +// --dl-both: the NEXT folder, from ~/.config/igneum/dl-token.next +const BOTH = !!flags['dl-both']; +const TOKEN_NEXT = BOTH ? cfg('dl-token.next') : ''; +const DEST_NEXT = BOTH && DLSITE && TOKEN_NEXT ? join(DLSITE, 'dl', TOKEN_NEXT) : ''; +const BASE_NEXT = `https://dl.igneum.network/dl/${TOKEN_NEXT}`; +// the folder-level files the apps and the CI read next to the manifest (jobs, the CI's inputs, the CI record, the +// WSL2 prover zip): mirrored into the NEXT folder when present in the current one +const FOLDER_FILES = ['igneum-jobs.json', 'igneum-jobs.json.sig', 'payload-inputs.zip', 'payload-inputs.json', 'payload-inputs.sha256', 'igneum-windows-ci.json', 'igneum-prove-wsl2.zip']; const DMG_NAME = `Igneum-Miner-${VERSION}.dmg`; const SETUP_NAME = `Igneum-Miner-Setup-${VERSION}.exe`; const ZIP_NAME = 'igneum-windows-app.zip'; @@ -193,7 +211,42 @@ const WIN_RELEASE = flags['win-release'] ? resolve(flags['win-release']) : first const MAC_RELEASE = flags['mac-release'] ? resolve(flags['mac-release']) : firstDir([join(NODE_DIR, 'target-integration', 'release'), join(NODE_DIR, 'target', 'release')], 'igneumd'); const WIN_INPUTS = ['igneumd.exe', 'igneum-miner.exe'].map(n => join(WIN_RELEASE, n)); const WORKERS = [join(ROOT, 'proto-cuda', 'nvrtc', 'igneum-worker-cuda.exe'), join(ROOT, 'proto-opencl', 'igneum-worker-opencl.exe')]; -const retryCmd = step => `node tools/ship-app.mjs ${VERSION} --node ${flags.node}${flags.notes ? ` --notes ${JSON.stringify(flags.notes)}` : ''}${WIN ? '' : ' --skip-windows'}${MAC ? '' : ' --skip-mac'}${flags['node-commit'] ? ` --node-commit ${flags['node-commit']}` : ''} --from ${step}`; +const retryCmd = step => `node tools/ship-app.mjs ${VERSION} --node ${flags.node}${flags.notes ? ` --notes ${JSON.stringify(flags.notes)}` : ''}${WIN ? '' : ' --skip-windows'}${MAC ? '' : ' --skip-mac'}${flags['node-commit'] ? ` --node-commit ${flags['node-commit']}` : ''}${BOTH ? ' --dl-both' : ''} --from ${step}`; + +// ---- --dl-both helpers (pure; the self-test runs them on scratch folders) ------------------------------------------- +// which of `names` must be copied from src to dst: 'copy' (missing or different bytes), 'same', or 'absent' (not in src) +function mirrorPlan(src, dst, names) { + return names.map(name => { + const a = join(src, name), b = join(dst, name); + if (!existsSync(a)) return { name, action: 'absent' }; + if (existsSync(b) && sha256(a) === sha256(b)) return { name, action: 'same' }; + return { name, action: 'copy' }; + }); +} +// the arguments the second publish-manifest.sh call takes so the NEXT folder's manifest carries what the first one +// carries (override, tuning, min_supported are otherwise carried over from the manifest already in THAT folder, which +// is older or missing): [args, tuningFile|null] +function secondManifestArgs(first, dest, base, tmpDir) { + const args = ['--dest', dest, '--base-url', base]; + const o = first.consensus && first.consensus.override; + if (o && typeof o === 'object' && Object.keys(o).length) args.push('--override', JSON.stringify(o)); + if (first.min_supported_version) args.push('--min-supported', String(first.min_supported_version)); + let tuningFile = null; + if (first.tuning && typeof first.tuning === 'object' && first.tuning.cards) { tuningFile = join(tmpDir, 'tuning.json'); writeFileSync(tuningFile, JSON.stringify(first.tuning)); args.push('--tuning', tuningFile); } + else args.push('--no-tuning'); + return [args, tuningFile]; +} +// the two manifests must agree on everything except published_at and the folder in the URLs: the differences, [] when none +function manifestDifferences(a, b, baseA, baseB) { + const diffs = []; + const norm = (m, base) => { const c = JSON.parse(JSON.stringify(m)); delete c.published_at; for (const e of Object.values(c.platforms || {})) if (typeof e.url === 'string') e.url = e.url.replace(base, ''); return c; }; + const x = norm(a, baseA), y = norm(b, baseB); + for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) { + const sx = JSON.stringify(x[k] === undefined ? null : x[k]), sy = JSON.stringify(y[k] === undefined ? null : y[k]); + if (sx !== sy) diffs.push(`${k}: ${sx.slice(0, 80)} vs ${sy.slice(0, 80)}`); + } + return diffs; +} const results = []; // the final table let dryProblems = 0; // a dry run lists preflight problems and goes on with the plan; its exit code says so const done = (step, result, detail = '') => { results.push([step, result, detail]); say(`[${step}] ${result}${detail ? ': ' + detail : ''}`); }; @@ -244,6 +297,12 @@ async function preflight() { for (const n of ['dl-token', 'dlsite-dir', 'ota-signing-key', 'ota-signing-key.pub', 'relay-token', 'relay-key']) if (!existsSync(join(CFG, n))) problems.push(`no ~/.config/igneum/${n}`); if (!existsSync(join(CFG, 'vercel'))) problems.push('no ~/.config/igneum/vercel (the Vercel login for the downloads host)'); if (!DEST || !existsSync(DEST)) problems.push(`no downloads folder at /dl/ (~/.config/igneum/dlsite-dir says ${DLSITE || 'nothing'})`); + if (BOTH) { + if (!TOKEN_NEXT) problems.push('--dl-both needs ~/.config/igneum/dl-token.next (the next downloads token)'); + else if (TOKEN_NEXT === TOKEN) problems.push('--dl-both: dl-token.next equals dl-token; nothing to rotate'); + else if (!DEST_NEXT || !existsSync(DEST_NEXT)) problems.push('--dl-both: no folder at /dl/; mkdir it first (an empty folder is fine)'); + if (!existsSync(join(CFG, 'log-intake-key.next'))) notes.push('no ~/.config/igneum/log-intake-key.next: the packagers ship the current intake key (packaged-config.sh)'); + } // gh account (a read; the real steps switch before every call) const st = runSync('gh', ['auth', 'status']).out; const active = /Logged in to github\.com account (\S+) \(keyring\)\n\s+- Active account: true/.exec(st); @@ -262,6 +321,7 @@ async function preflight() { ['mac binaries', MAC ? ['igneumd', 'igneum-miner'].map(n => existsSync(join(MAC_RELEASE, n)) ? `${n} ${fmtSize(sizeOf(join(MAC_RELEASE, n)))}` : `${n} MISSING`).join(', ') : 'skipped'], ['workers', WORKERS.map(w => existsSync(w) ? basename(w) : `${basename(w)} missing`).join(', ')], ['downloads folder', DEST ? DEST.replace(TOKEN, '') : 'none'], + ['next folder', BOTH ? (DEST_NEXT ? DEST_NEXT.replace(TOKEN_NEXT, '') : 'MISSING') : 'not used (no --dl-both)'], ['gh active', `${ghActive}${ghActive === GH_USER ? '' : ` (switched to ${GH_USER} before every call)`}`], ['live inputs', live.inputs ? `node ${live.inputs.node_source_commit} built ${live.inputs.built_at}` : 'none'], ['live ci', live.ci ? `${live.ci.installer} (${live.ci.run.split('/').pop()})` : 'none'], @@ -402,19 +462,50 @@ async function copy() { done('copy', 'ok', `${DMG_NAME} ${fmtSize(sizeOf(dst))} copied`); } +async function mirror() { + if (!BOTH) return done('mirror', 'skipped', 'no --dl-both'); + const names = [MAC && DMG_NAME, WIN && SETUP_NAME, WIN && ZIP_NAME, ...FOLDER_FILES].filter(Boolean); + const plan = mirrorPlan(DEST, DEST_NEXT, names); + const copies = plan.filter(p => p.action === 'copy'), same = plan.filter(p => p.action === 'same'), absent = plan.filter(p => p.action === 'absent'); + const summary = `${copies.length} to copy (${copies.map(p => p.name).join(', ') || 'none'}), ${same.length} same, ${absent.length} absent in the current folder${absent.length ? ` (${absent.map(p => p.name).join(', ')})` : ''}`; + if (DRY) return done('mirror', 'would', `copy into dl//: ${summary}`); + for (const name of [MAC && DMG_NAME, WIN && SETUP_NAME].filter(Boolean)) if (!existsSync(join(DEST, name))) throw new Error(`missing ${name} in the current folder; ${retryCmd(name.endsWith('.dmg') ? 'copy' : 'fetch')}`); + for (const p of copies) copyFileSync(join(DEST, p.name), join(DEST_NEXT, p.name)); + const after = mirrorPlan(DEST, DEST_NEXT, names).filter(p => p.action === 'copy'); + if (after.length) throw new Error(`still differ after the copy: ${after.map(p => p.name).join(', ')}`); + done('mirror', copies.length ? 'ok' : 'already', summary); +} + async function manifest() { const args = ['--version', VERSION, '--notes', NOTES, '--no-deploy']; if (MAC) args.push('--mac', join(DEST, DMG_NAME)); if (WIN) args.push('--win', join(DEST, SETUP_NAME)); for (const k of ['min-supported', 'activation-height', 'deadline-note', 'channel']) if (flags[k]) args.push(`--${k}`, String(flags[k])); const cmd = `packaging/ota/publish-manifest.sh ${args.map(a => a.includes(' ') ? JSON.stringify(a) : a).join(' ')}`; - if (DRY) return done('manifest', 'would', `run ${cmd.replace(TOKEN, '')} (signs, verifies the signature; ${WIN && MAC ? 'both platforms' : WIN ? 'windows entry, mac carried over when the live manifest is this version' : 'mac entry, windows carried over when the live manifest is this version'})`); + if (DRY) return done('manifest', 'would', `run ${cmd.replace(TOKEN, '')} (signs, verifies the signature; ${WIN && MAC ? 'both platforms' : WIN ? 'windows entry, mac carried over when the live manifest is this version' : 'mac entry, windows carried over when the live manifest is this version'})${BOTH ? '; then the same for dl// with --dest and --base-url, carrying this manifest\'s override, tuning and min_supported; the two compared field by field' : ''}`); for (const p of [MAC && join(DEST, DMG_NAME), WIN && join(DEST, SETUP_NAME)].filter(Boolean)) if (!existsSync(p)) throw new Error(`missing ${p.replace(TOKEN, '')}; ${retryCmd(p.endsWith('.dmg') ? 'copy' : 'fetch')}`); const r = await run('bash', [join(ROOT, 'packaging', 'ota', 'publish-manifest.sh'), ...args]); if (r.code !== 0) throw new Error(`publish-manifest.sh exited ${r.code}; retry: ${cmd.replace(TOKEN, '')}`); const m = JSON.parse(readFileSync(join(DEST, 'igneum-app-latest.json'), 'utf8')); if (m.version !== VERSION) throw new Error(`the written manifest says ${m.version}`); - done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')}, signed and verified locally`); + if (!BOTH) return done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')}, signed and verified locally`); + // the NEXT folder: the same entries from its own copies, the same override, tuning and min_supported + const tmp = mkdtempSync(join(tmpdir(), 'igneum-ship-m-')); + try { + const [extra, tuningFile] = secondManifestArgs(m, DEST_NEXT, BASE_NEXT, tmp); + const args2 = ['--version', VERSION, '--notes', NOTES, '--no-deploy', ...extra]; + if (MAC) args2.push('--mac', join(DEST_NEXT, DMG_NAME)); + if (WIN) args2.push('--win', join(DEST_NEXT, SETUP_NAME)); + for (const k of ['activation-height', 'deadline-note', 'channel']) if (flags[k]) args2.push(`--${k}`, String(flags[k])); + const cmd2 = `packaging/ota/publish-manifest.sh ${args2.map(a => a.includes(' ') || a.startsWith('{') ? JSON.stringify(a) : a).join(' ')}`; + const r2 = await run('bash', [join(ROOT, 'packaging', 'ota', 'publish-manifest.sh'), ...args2]); + if (r2.code !== 0) throw new Error(`publish-manifest.sh (next folder) exited ${r2.code}; retry: ${cmd2.replace(TOKEN_NEXT, '')}`); + const m2 = JSON.parse(readFileSync(join(DEST_NEXT, 'igneum-app-latest.json'), 'utf8')); + const diffs = manifestDifferences(m, m2, BASE, BASE_NEXT); + if (diffs.length) throw new Error(`the two manifests differ beyond the folder and the publish time:\n ${diffs.join('\n ')}`); + if (tuningFile) say(` tuning carried into the next folder (${Object.keys(m.tuning.cards).length} card model(s))`); + done('manifest', 'ok', `${VERSION} ${Object.keys(m.platforms).join('+')} in both folders, signed, verified locally, same fields`); + } finally { rmSync(tmp, { recursive: true, force: true }); } } async function deploy() { @@ -426,53 +517,62 @@ async function deploy() { done('deploy', 'ok', 'downloads folder deployed'); } -async function verify() { - const files = [MAC && DMG_NAME, WIN && SETUP_NAME, WIN && ZIP_NAME].filter(Boolean); - if (DRY) return done('verify', 'would', `HEAD and GET ${files.join(', ')} (size and sha256 against the local files), GET the manifest and its .sig, igneum-ota-sign verify`); +// one folder: HEAD and GET of the files against the local copies, the manifest's bytes and signature; the failures +async function verifyFolder(dest, base, files, label) { const rows = [['file', 'local', 'HEAD', 'sha256']]; const failures = []; const tmp = mkdtempSync(join(tmpdir(), 'igneum-ship-')); try { for (const name of files) { - const local = join(DEST, name); const want = sha256(local); const size = sizeOf(local); + const local = join(dest, name); const want = sha256(local); const size = sizeOf(local); let h, got = ''; for (let attempt = 1; attempt <= 3; attempt++) { - h = await head(`${BASE}/${name}`); + h = await head(`${base}/${name}`); if (h.status === 200 && (h.length === null || h.length === size)) { - const r = await run('curl', ['-fsSL', '--retry', '3', '-o', join(tmp, name), `${BASE}/${name}`], { quiet: true }); + const r = await run('curl', ['-fsSL', '--retry', '3', '-o', join(tmp, name), `${base}/${name}`], { quiet: true }); if (r.code === 0) { got = sha256(join(tmp, name)); if (got === want) break; } } if (attempt < 3) { say(` ${name}: not matching yet (HTTP ${h.status}, length ${h.length}, sha ${got ? got.slice(0, 12) : '-'}); again in 15 s`); await sleep(15000); } } const ok = h.status === 200 && (h.length === null || h.length === size) && got === want; rows.push([name, `${size} B ${want.slice(0, 12)}`, `${h.status} ${h.length === null ? '(no length)' : h.length + ' B'}`, got === want ? `ok ${want.slice(0, 12)}` : `MISMATCH ${got.slice(0, 12) || 'no body'}`]); - if (!ok) failures.push(name); + if (!ok) failures.push(`${label}:${name}`); state.files = state.files || {}; state.files[name] = { size, sha256: want, served: ok }; } // the manifest: bytes and signature, through the same verifier the apps use - const mr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.json'), `${BASE}/igneum-app-latest.json`], { quiet: true }); - const sr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.sig'), `${BASE}/igneum-app-latest.json.sig`], { quiet: true }); + const mr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.json'), `${base}/igneum-app-latest.json`], { quiet: true }); + const sr = await run('curl', ['-fsSL', '-o', join(tmp, 'm.sig'), `${base}/igneum-app-latest.json.sig`], { quiet: true }); let mline = 'not reachable'; if (mr.code === 0 && sr.code === 0) { const v = await run(SIGNER, ['verify', join(CFG, 'ota-signing-key.pub'), join(tmp, 'm.json'), join(tmp, 'm.sig')], { quiet: true }); const m = JSON.parse(readFileSync(join(tmp, 'm.json'), 'utf8')); - const same = readFileSync(join(tmp, 'm.json')).equals(readFileSync(join(DEST, 'igneum-app-latest.json'))); + const same = readFileSync(join(tmp, 'm.json')).equals(readFileSync(join(dest, 'igneum-app-latest.json'))); const plat = Object.entries(m.platforms || {}).map(([k, e]) => `${k} ${e.sha256.slice(0, 12)}`).join(', '); - mline = `${m.version} ${v.code === 0 ? 'signature ok' : 'SIGNATURE FAILS'}${same ? '' : ' (DIFFERS from the local manifest)'} ${plat}`; - if (v.code !== 0 || m.version !== VERSION || !same) failures.push('manifest'); - state.manifest = { version: m.version, published_at: m.published_at, platforms: m.platforms }; - } else failures.push('manifest'); + const inFolder = Object.values(m.platforms || {}).every(e => typeof e.url === 'string' && e.url.startsWith(base + '/')); + mline = `${m.version} ${v.code === 0 ? 'signature ok' : 'SIGNATURE FAILS'}${same ? '' : ' (DIFFERS from the local manifest)'}${inFolder ? '' : ' (URLS POINT OUTSIDE THIS FOLDER)'} ${plat}`; + if (v.code !== 0 || m.version !== VERSION || !same || !inFolder) failures.push(`${label}:manifest`); + if (!state.manifest || label === 'current') state.manifest = { version: m.version, published_at: m.published_at, platforms: m.platforms }; + } else failures.push(`${label}:manifest`); rows.push(['igneum-app-latest.json', '', '', mline]); } finally { rmSync(tmp, { recursive: true, force: true }); } + say(` ${label} folder: ${label === 'next' ? 'dl//' : 'dl//'}`); table(rows); + return failures; +} + +async function verify() { + const files = [MAC && DMG_NAME, WIN && SETUP_NAME, WIN && ZIP_NAME].filter(Boolean); + if (DRY) return done('verify', 'would', `HEAD and GET ${files.join(', ')} (size and sha256 against the local files), GET the manifest and its .sig, igneum-ota-sign verify${BOTH ? '; the same for dl//' : ''}`); + const failures = await verifyFolder(DEST, BASE, files, 'current'); + if (BOTH) failures.push(...await verifyFolder(DEST_NEXT, BASE_NEXT, files, 'next')); saveState(); if (failures.length) throw new Error(`not served as expected: ${failures.join(', ')}; the deploy may still be propagating. Retry: ${retryCmd('deploy')}`); - done('verify', 'ok', `${files.length} files and the manifest match the local copies`); + done('verify', 'ok', `${files.length} files and the manifest match the local copies${BOTH ? ' in both folders' : ''}`); } async function consoleStep() { const lines = Object.entries(state.files || {}).map(([n, f]) => `${n} ${f.size} B sha256 ${f.sha256}`); - const body = [NOTES, ...lines, state.runId ? `Windows CI https://github.com/${REPO}/actions/runs/${state.runId}` : '', state.sha ? `commit ${state.sha.slice(0, 12)}, node fork ${state.forkCommit || '?'}` : ''].filter(Boolean).join('\n'); + const body = [NOTES, ...lines, state.runId ? `Windows CI https://github.com/${REPO}/actions/runs/${state.runId}` : '', state.sha ? `commit ${state.sha.slice(0, 12)}, node fork ${state.forkCommit || '?'}` : '', BOTH ? 'published in both downloads folders (token rotation)' : ''].filter(Boolean).join('\n'); const meta = { version: VERSION, files: state.files || {}, run: state.runId || null, commit: state.sha || null, fork: state.forkCommit || null, manifest_published_at: state.manifest ? state.manifest.published_at : null }; if (DRY) return done('console', 'would', `tools/console.mjs post --kind build --key ship:${VERSION} --title "Igneum Miner ${VERSION} shipped" (sizes, hashes, run, commit), then sync-dl`); const r = await run('node', [join(ROOT, 'tools', 'console.mjs'), 'post', '--kind', 'build', '--key', `ship:${VERSION}`, '--title', `Igneum Miner ${VERSION} shipped (${[MAC && 'mac', WIN && 'windows'].filter(Boolean).join('+')})`, '--body', body, '--meta', JSON.stringify(meta)], { quiet: true }); @@ -482,7 +582,7 @@ async function consoleStep() { } // ---- the runner ---------------------------------------------------------------------------------------------------- -const IMPL = { preflight, bump, inputs, commit, ci, fetch: fetchStep, dmg, copy, manifest, deploy, verify, console: consoleStep }; +const IMPL = { preflight, bump, inputs, commit, ci, fetch: fetchStep, dmg, copy, mirror, manifest, deploy, verify, console: consoleStep }; async function main() { const start = flags.from ? STEPS.indexOf(flags.from) : 0; // preflight always runs: it is reads only and the later steps need its facts (fork commit, state) @@ -555,6 +655,23 @@ function selfTest() { check('1.2 is refused', refused); check('comma helpers round-trip', toComma('0.3.4') === '0,3,4,0' && fromComma('0,3,4,0') === '0.3.4' && fromComma('0,3,4,1') === '0,3,4,1'); check('version compare', cmpVersion('0.3.4', '0.3.3') > 0 && cmpVersion('0.10.0', '0.9.9') > 0 && cmpVersion('1.0.0', '1.0.0') === 0); + // 5. --dl-both helpers on two scratch folders + const a = join(dir, 'dl', 'old'), b = join(dir, 'dl', 'new'); + mkdirSync(a, { recursive: true }); mkdirSync(b, { recursive: true }); + writeFileSync(join(a, 'x.dmg'), 'dmg bytes'); writeFileSync(join(a, 'same.json'), 'same'); writeFileSync(join(b, 'same.json'), 'same'); + writeFileSync(join(a, 'differs.zip'), 'v2'); writeFileSync(join(b, 'differs.zip'), 'v1'); + const plan = Object.fromEntries(mirrorPlan(a, b, ['x.dmg', 'same.json', 'differs.zip', 'absent.sig']).map(p => [p.name, p.action])); + check('mirror plan: missing -> copy, same -> same, different -> copy, absent -> absent', plan['x.dmg'] === 'copy' && plan['same.json'] === 'same' && plan['differs.zip'] === 'copy' && plan['absent.sig'] === 'absent', JSON.stringify(plan)); + const first = { version: '0.3.6', published_at: '2026-10-05T12:00:00Z', channel: 'devnet', notes: 'n', min_supported_version: '0.3.0', platforms: { mac: { url: 'https://dl.igneum.network/dl/OLD/Igneum-Miner-0.3.6.dmg', sha256: 'aa', size: 1, kind: 'dmg' } }, consensus: { activation_height: null, deadline_note: '', override: { difficulty_v2_activation_daa: 33000 } }, tuning: { cards: { 'RTX 5090': { v: 1 } } } }; + const [args, tuningFile] = secondManifestArgs(first, '/dest', 'https://dl.igneum.network/dl/NEW', dir); + check('second manifest args carry override, min_supported and tuning', args.includes('--override') && args[args.indexOf('--override') + 1] === '{"difficulty_v2_activation_daa":33000}' && args.includes('--min-supported') && args[args.indexOf('--min-supported') + 1] === '0.3.0' && args.includes('--tuning') && tuningFile && JSON.parse(readFileSync(tuningFile, 'utf8')).cards['RTX 5090'].v === 1, args.join(' ')); + const [args0] = secondManifestArgs({ version: '0.3.6', platforms: {} }, '/dest', 'https://x', dir); + check('second manifest args without override or tuning say --no-tuning and no --override', args0.includes('--no-tuning') && !args0.includes('--override') && !args0.includes('--min-supported'), args0.join(' ')); + const second = JSON.parse(JSON.stringify(first)); second.published_at = '2026-10-05T12:01:00Z'; second.platforms.mac.url = 'https://dl.igneum.network/dl/NEW/Igneum-Miner-0.3.6.dmg'; + check('two manifests that differ only by folder and time agree', manifestDifferences(first, second, 'https://dl.igneum.network/dl/OLD', 'https://dl.igneum.network/dl/NEW').length === 0); + second.consensus.override.difficulty_v2_activation_daa = 1; delete second.tuning; + const d = manifestDifferences(first, second, 'https://dl.igneum.network/dl/OLD', 'https://dl.igneum.network/dl/NEW'); + check('a changed override and a dropped tuning are reported', d.length === 2 && d.some(x => x.startsWith('consensus')) && d.some(x => x.startsWith('tuning')), d.join(' | ')); } finally { rmSync(dir, { recursive: true, force: true }); } say(fails ? `${fails} check(s) failed` : 'all checks passed'); return fails ? 1 : 0;