diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c7af009..255a4fbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,8 @@ jobs: run: bash tools/ci/copied-sources-check.sh - name: second-engine playbooks log to a file and end their tree (C35) run: bash tools/ci/second-engine-check.sh + - name: no playbook quits, pauses or resumes the installed app (self-test first, then the tree) + run: bash tools/ci/playbook-quit-check.sh --self-test && bash tools/ci/playbook-quit-check.sh - name: the signer is never piped into head run: bash tools/ci/signer-pipe-check.sh - name: bash bodies in PowerShell job scripts pass bash -n, the lost-quote class (self-test first, then the tree) diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index fe287df5..1f481e1a 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -124,6 +124,28 @@ impl Default for Settings { } impl Settings { + /// What a measurement engine (`--sweep`, started by a job beside the installed app) runs with, whatever the copied + /// file says: no remote jobs (run 4, 6 October 2026: the second engine fetched the jobs file and ran 96 old jobs + /// inside its scratch root), no updates, no proving, not paused, the tune on, Power control off (only an engine + /// that is itself elevated controls NVIDIA, through the probe's `direct`), every card due and unpinned. The file + /// on disk is never changed: the playbook copies the installed app's settings verbatim (a PowerShell JSON round + /// trip rewrote big integers as doubles and the engine read the whole file as defaults: no payout address, every + /// card off). + pub fn for_measurement(mut self) -> Settings { + self.remote_jobs = false; + self.auto_update = false; + self.prove = false; + self.paused = false; + self.sweep = true; + self.power_control = false; + self.setup_done = true; + for p in self.cards.values_mut() { + p.sweep_at = 0; + p.pinned = false; + } + self + } + pub fn load(path: &Path) -> Settings { let mut s: Settings = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default(); let mut dirty = false; @@ -374,6 +396,18 @@ mod tests { out } + #[test] + fn a_measurement_engine_overrides_the_copied_settings_in_memory() { + let mut s = Settings { remote_jobs: true, auto_update: true, prove: true, paused: true, sweep: false, power_control: true, address: "0xabc".into(), ..Default::default() }; + s.cards.insert("nvidia:0:x".into(), CardPref { enabled: true, identities: 8, sweep_at: 1_791_000_000, pinned: true, power_pct: 70, ..Default::default() }); + let m = s.for_measurement(); + assert!(!m.remote_jobs && !m.auto_update && !m.prove && !m.paused && m.sweep && !m.power_control && m.setup_done); + assert_eq!(m.address, "0xabc", "the payout address is the installed app's"); + let c = &m.cards["nvidia:0:x"]; + assert!(c.enabled && c.identities == 8 && c.power_pct == 70, "the card's choices stay"); + assert!(c.sweep_at == 0 && !c.pinned, "every card is due and unpinned"); + } + #[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"); @@ -466,3 +500,18 @@ mod tests { assert!(p.node_override_params.is_none()); } } + +#[cfg(test)] +mod fixture_tests { + /// `IGNEUM_TEST_SETTINGS= cargo test settings_fixture`: parses a real settings.json with this crate's + /// struct and prints what it read (6 October 2026: PC 1's copied file read as defaults; this names the field). + #[test] + fn settings_fixture_parses_when_given() { + let Ok(p) = std::env::var("IGNEUM_TEST_SETTINGS") else { return }; + let t = std::fs::read_to_string(&p).unwrap(); + match serde_json::from_str::(&t) { + Ok(s) => println!("parsed: address {} cards {} remote_jobs {} setup_done {}", s.address, s.cards.len(), s.remote_jobs, s.setup_done), + Err(e) => panic!("the crate refuses the file: {e}"), + } + } +} diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 767c5cbe..3f980a05 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -596,6 +596,10 @@ pub struct Engine { quit_source: &'static str, /// the over-the-air updater never runs: IGNEUM_APP_NO_OTA=1 or --sweep (a second engine beside the installed app) no_ota: bool, + /// the Igneum Power Helper task is registered (src/powertask.rs): once, ever; None = not asked yet + power_task: Option, + /// the running tune helper is the task (quit ends it; no SweepHelperDone comes from a thread) + sweep_helper_is_task: bool, /// the request number the vendor tool last carried out (the run's acknowledgement) tune_acked: Option, /// cards whose confirm check found a better neighbour: the full plan runs next @@ -700,6 +704,8 @@ impl Engine { sweep: None, quit_source: "unknown", no_ota, + power_task: None, + sweep_helper_is_task: false, tune_acked: None, tune_full_due: std::collections::HashSet::new(), sweep_pending: None, @@ -989,6 +995,7 @@ impl Engine { self.clock_next_https = Instant::now() + Duration::from_secs(6); } Cmd::PowerApplied(what, r, readback) => { + self.power_task = None; // the one elevated step may have registered the task: ask again next time self.power_busy = false; self.power_via_host = None; // the truth is what nvidia-smi reads back, not whether the prompt said yes @@ -1779,8 +1786,45 @@ impl Engine { self.power_busy = true; self.power_restore_pending = true; self.shared.log(&format!("power cap ({why}): {}", cmds.join(" & "))); - let line = cmds.join(" & "); let what = what.join(", "); + // once, ever (src/powertask.rs): a registered task sets the caps with no prompt; the readback judges it + if cfg!(windows) && self.power_task_registered() { + let pairs: Vec<(String, u64)> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0 && !c.power_applied).map(|c| (c.device.clone(), requested_watts(c).round() as u64)).collect(); + let dir = self.sweep_dir(); + let shared = self.shared.clone(); + self.shared.log("power cap: through the Igneum Power Helper task (no prompt)"); + std::thread::spawn(move || { + let r = crate::powertask::start().and_then(|_| { + let _ = std::fs::create_dir_all(&dir); + let mut seq = crate::platform::unix_now() % 1_000_000; + let mut text = String::new(); + for (dev, w) in &pairs { + seq += 1; + text.push_str(&format!("{seq} dev {dev}\n")); + seq += 1; + text.push_str(&format!("{seq} pl {w}\n")); + } + std::fs::write(dir.join("cmd.txt"), text).map_err(|e| e.to_string()) + }); + std::thread::sleep(Duration::from_secs(6)); + let back: std::collections::HashMap = crate::detect::nvidia_power_limits().into_iter().map(|(k, v)| (k, v.1)).collect(); + shared.send(Cmd::PowerApplied(what, r, back)); + }); + return; + } + // the first approval registers the task in the same elevated step as the caps (Windows), so no later step + // needs a prompt: the registration script is written next to the command file + let line = if cfg!(windows) { + let dir = self.sweep_dir(); + let _ = std::fs::create_dir_all(&dir); + let script = dir.join("register-power-task.ps1"); + match std::env::current_exe().map(|exe| std::fs::write(&script, [b"\xEF\xBB\xBF".as_slice(), crate::powertask::register_script(&exe).as_bytes()].concat())) { + Ok(Ok(())) => format!("{} & \"{}\" -NoProfile -ExecutionPolicy Bypass -File \"{}\"", cmds.join(" & "), crate::platform::tool("powershell").display(), script.display()), + _ => cmds.join(" & "), + } + } else { + cmds.join(" & ") + }; let want: std::collections::HashMap = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect(); if self.wrapper && cfg!(windows) { // the window host has a UI context: it shows the administrator prompt and reports back on stdin @@ -1830,6 +1874,14 @@ impl Engine { self.shared.log(&format!("GPU power limits left as set (they reset at the next reboot; no prompt on quit): {}", cmds.join(" & "))); } + /// Is the Igneum Power Helper task registered (src/powertask.rs)? Asked once per run and after every elevated step. + fn power_task_registered(&mut self) -> bool { + if self.power_task.is_none() { + self.power_task = Some(crate::powertask::registered()); + } + self.power_task.unwrap_or(false) + } + /// Power control (config.rs power_control): may the engine ask for administrator rights for the cap or the sweep? /// The elevated PC sweep job (--sweep) sets caps directly and counts as allowed. fn elevation_allowed(&self) -> bool { @@ -1854,6 +1906,16 @@ impl Engine { if self.sweep.is_some() || self.sweep_pending.is_some() { self.sweep_abort("power control is off"); } + if cfg!(windows) && self.power_task_registered() { + // the kill switch: the task unregisters itself (elevated) and exits; nothing is left behind + let dir = self.sweep_dir(); + let _ = std::fs::write(dir.join("cmd.txt"), "remove\n"); + match crate::powertask::start() { + Ok(()) => self.shared.log("power control off: the Igneum Power Helper task removes itself"), + Err(e) => self.shared.log(&format!("power control off: the task could not be started to remove itself ({e}); remove it in Task Scheduler")), + } + self.power_task = None; + } self.shared.event(if note.starts_with("power control off:") { "error" } else { "info" }, note); } @@ -2343,8 +2405,18 @@ impl Engine { std::fs::write(&script, crate::sweep::helper_script_unix()).map_err(|e| e.to_string())?; format!("sh \"{}\" \"{}\" \"{}\" {} {}", script.display(), dir.display(), smi, c.device, restore) }; + if cfg!(windows) && self.power_task_registered() { + // once, ever: the registered task is the helper; it reads the same command file, no prompt + let _ = std::fs::write(dir.join("cmd.txt"), format!("{} dev {}\n", crate::platform::unix_now() % 1_000_000, c.device)); + crate::powertask::start()?; + self.shared.log("tune helper: the Igneum Power Helper task (no prompt)"); + self.sweep_helper = true; + self.sweep_helper_is_task = true; + return Ok(()); + } self.shared.log(&format!("tune helper (administrator prompt): {line}")); self.sweep_helper = true; + self.sweep_helper_is_task = false; let shared = self.shared.clone(); std::thread::spawn(move || { let r = crate::platform::run_elevated(&line); @@ -2356,6 +2428,11 @@ impl Engine { fn sweep_helper_quit(&mut self) { if self.sweep_helper { let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), "quit\n"); + if self.sweep_helper_is_task { + // the task exits on quit and reports nothing back; the next tune starts it again + self.sweep_helper = false; + self.sweep_helper_is_task = false; + } } } @@ -2400,7 +2477,8 @@ impl Engine { // measure only: nothing is set; the run notices the missing acknowledgement and measures return; } - let cmd = format!("{seq} pl {w}\n{seq} {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }); + let dev = device.to_string(); + let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }); let _ = std::fs::write(self.sweep_dir().join("cmd.txt"), cmd); // the helper polls twice a second and nvidia-smi answers within a second or two std::thread::spawn(move || { diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 13a40f1b..80945338 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -35,6 +35,7 @@ mod verifier; mod wslhost; mod sweep; mod ember; +mod powertask; mod watchdog; use std::io::{BufRead, Write}; @@ -54,6 +55,12 @@ fn main() { println!("igneum-app {}", engine::VERSION); return; } + if args.iter().any(|a| a == "--power-helper") { + // the scheduled task's action (src/powertask.rs): elevated, runs only digit-argument nvidia-smi commands + // from /app/sweep/cmd.txt, exits on quit, remove or 20 idle minutes + let dir = powertask::sweep_dir(&platform::data_root().join("app")); + std::process::exit(powertask::run_helper(&dir)); + } if args.iter().any(|a| a == "--launch") { if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) { let host = dir.join("Igneum Miner.exe"); @@ -95,6 +102,8 @@ fn main() { } let packaged = config::Packaged::load(&candidates).with_env_overrides(); let settings = config::Settings::load(&runtime.app_dir.join("settings.json")); + // a measurement engine runs with the installed app's choices and its own switches (config.rs for_measurement) + let settings = if sweep { settings.for_measurement() } else { settings }; // the per-launch token: 32 hex characters from the OS let mut raw = [0u8; 16]; diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index 81dde696..f10563d2 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -184,7 +184,11 @@ impl Updater { if crate::platform::start_at_login_is_on() { let _ = crate::platform::set_start_at_login(true); } - firewall_first_run(shared); + // a measurement engine (--sweep) uses the installed app's node and asks for nothing: the rule is the + // installed app's (the dry run of 6 October 2026 raised a second UAC prompt from here) + if !shared.runtime.sweep_only { + firewall_first_run(shared); + } } u.failed_versions = std::fs::read_to_string(u.failed_path()).ok().and_then(|t| serde_json::from_str::>(&t).ok()).unwrap_or_default(); // the cached manifest: the rollback floor and the consensus override are known before the first check diff --git a/app/igneum-app/src/platform.rs b/app/igneum-app/src/platform.rs index a3e8e694..bb41df4f 100644 --- a/app/igneum-app/src/platform.rs +++ b/app/igneum-app/src/platform.rs @@ -166,17 +166,28 @@ pub fn lock_permissions(path: &Path, dir: bool) { } #[cfg(windows)] { - let _ = dir; let user = std::env::var("USERNAME").unwrap_or_default(); if !user.is_empty() { - let _ = quiet(&mut Command::new(tool("icacls"))) - .arg(path) - .args(["/inheritance:r", "/grant:r", &format!("{user}:F")]) - .output(); + let _ = quiet(&mut Command::new(tool("icacls"))).arg(path).args(icacls_lock_args(dir, &user)).output(); } } } +/// The icacls arguments that lock a path to the user. A folder gets an INHERITABLE grant (`user:(OI)(CI)F`) and +/// NO `/T`: Windows propagates the inheritable entry to every child, existing or future, as `(I)(F)`. Measured on +/// PC 1, 6 October 2026 (collect ember-acl-2): the old non-inheritable `user:F` cut the folder's inheritance and +/// left a file COPIED in before the engine started with no entry at all (the measurement engine's settings.json, +/// machine-id and wallet.json read as nothing, so it ran on defaults with no payout address; its own files, written +/// after the lock, inherited fine and hid it); the same grant WITH `/T` also left the file empty, because `/T` +/// re-applies `/inheritance:r` to the file after the propagation and an `(OI)(CI)` entry on a file is inherit-only. +pub fn icacls_lock_args(dir: bool, user: &str) -> Vec { + if dir { + vec!["/inheritance:r".into(), "/grant:r".into(), format!("{user}:(OI)(CI)F")] + } else { + vec!["/inheritance:r".into(), "/grant:r".into(), format!("{user}:F")] + } +} + /// Opens a URL in the default browser (the fallback when no window host runs). pub fn open_url(url: &str) { #[cfg(target_os = "macos")] @@ -500,6 +511,17 @@ pub fn quiet(cmd: &mut Command) -> &mut Command { cmd } +#[cfg(test)] +mod lock_tests { + #[test] + fn a_locked_folder_grants_the_user_inheritably_and_covers_what_is_inside() { + let d = super::icacls_lock_args(true, "Admin"); + assert_eq!(d, vec!["/inheritance:r", "/grant:r", "Admin:(OI)(CI)F"], "inheritable, and never /T (it empties the children)"); + let f = super::icacls_lock_args(false, "Admin"); + assert_eq!(f, vec!["/inheritance:r", "/grant:r", "Admin:F"]); + } +} + #[cfg(test)] mod tests { #[test] diff --git a/app/igneum-app/src/powertask.rs b/app/igneum-app/src/powertask.rs new file mode 100644 index 00000000..1a4ab9fe --- /dev/null +++ b/app/igneum-app/src/powertask.rs @@ -0,0 +1,265 @@ +//! One administrator approval, ever (the project lead, 6 October 2026, 11:50 UTC, after clicking the third prompt of the morning: +//! "can we make sure all these popups are not needed in future?"). +//! +//! What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; but every later cap (an app +//! start, a reboot, a slider move) and every tune's helper is another elevated launch, so another prompt. This module +//! makes the first approval the last: the one elevated step also registers a per-user Windows scheduled task, +//! `Igneum Power Helper`, principal = the signed-in user, RunLevel Highest, no trigger, whose action is this very +//! executable with `--power-helper`. A task the user owns can be STARTED by the user's unelevated processes without a +//! prompt (`Start-ScheduledTask`), and it runs elevated; so every later cap and tune starts the task and talks to it +//! through the command file `/app/sweep/cmd.txt` (the protocol the 0.3.9 helper scripts spoke: ` pl +//! `, ` lgc `, ` rgc`, `quit`; plus `remove`, the kill switch). The task survives app restarts, +//! updates (the per-user installer replaces the exe in place; the task's action path is the install folder) and +//! reboots (a task, not a process). Power control off starts the task once and sends `remove`: the helper unregisters +//! the task (elevated) and exits; nothing is left behind. +//! +//! Threat note (what the helper will and will not run): +//! - The action is fixed at registration: the app's own exe in the install folder with `--power-helper`. The task +//! has no trigger and no arguments from outside; only `Start-ScheduledTask` by the owning user starts it. +//! - The helper reads ONE file, `/app/sweep/cmd.txt`, in the user's own profile. Every command it accepts +//! is a fixed verb with digit-only arguments: `pl ` runs `nvidia-smi -i -pl `, `lgc ` runs +//! `nvidia-smi -i -lgc 0,`, `rgc` runs `nvidia-smi -i -rgc`, `quit` ends it, `remove` unregisters +//! the task and ends it. The device index is digits only too (`dev ` sets it). No shell, no path, no string from +//! the file reaches a process: `Command::new(nvidia-smi).args([...])`, never `cmd /c`. +//! - nvidia-smi is resolved to the driver's install path (platform::tool), never from PATH. +//! - What an attacker running as the user gains: the power limit and the clock cap of the user's own NVIDIA cards, +//! within the ranges the driver allows, which the same user could set with one approved prompt anyway. Nothing +//! else: no file, no process, no registry, no other binary. +//! - The helper exits after 20 idle minutes; a stale command file is cleared at start (sequence numbers must rise). +//! - Linux keeps pkexec per step (no scheduled task); macOS has no cap to set. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +/// The task name in the Windows Task Scheduler (per user). +pub const TASK_NAME: &str = "Igneum Power Helper"; +/// The helper ends after this long without a new command. +pub const IDLE_S: u64 = 20 * 60; + +/// One parsed command from cmd.txt. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HelperCmd { + Dev(String), + PowerLimit(u64), + ClockCap(u64), + ClockReset, + Quit, + Remove, +} + +/// Parses one line: ` []` (the 0.3.9 form ` ` reads as a power limit; `quit` and +/// `remove` need no sequence). Anything that is not a fixed verb with digit-only arguments is None. +pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> { + let t = line.trim(); + if t == "quit" { + return Some((0, HelperCmd::Quit)); + } + if t == "remove" { + return Some((0, HelperCmd::Remove)); + } + let p: Vec<&str> = t.split_whitespace().collect(); + let digits = |s: &str| !s.is_empty() && s.len() <= 6 && s.chars().all(|c| c.is_ascii_digit()); + let seq: u64 = p.first().filter(|s| digits(s)).and_then(|s| s.parse().ok())?; + match p.as_slice() { + [_, w] if digits(w) => Some((seq, HelperCmd::PowerLimit(w.parse().ok()?))), + [_, "pl", w] if digits(w) => Some((seq, HelperCmd::PowerLimit(w.parse().ok()?))), + [_, "lgc", m] if digits(m) => Some((seq, HelperCmd::ClockCap(m.parse().ok()?))), + [_, "rgc"] => Some((seq, HelperCmd::ClockReset)), + [_, "dev", d] if digits(d) => Some((seq, HelperCmd::Dev(d.to_string()))), + _ => None, + } +} + +/// The nvidia-smi arguments a command becomes (None for the verbs that run nothing). +pub fn smi_args(dev: &str, c: &HelperCmd) -> Option> { + match c { + HelperCmd::PowerLimit(w) => Some(vec!["-i".into(), dev.into(), "-pl".into(), w.to_string()]), + HelperCmd::ClockCap(m) => Some(vec!["-i".into(), dev.into(), "-lgc".into(), format!("0,{m}")]), + HelperCmd::ClockReset => Some(vec!["-i".into(), dev.into(), "-rgc".into()]), + _ => None, + } +} + +/// The PowerShell that registers the task (run inside the ONE elevated step, with the caps). `exe` is this +/// executable's path in the install folder. Principal: the signed-in user, interactive logon, highest run level; no +/// trigger; may start on battery; one hour limit per run; multiple starts are ignored while one runs. +pub fn register_script(exe: &Path) -> String { + let exe = exe.display().to_string().replace('\'', "''"); + format!( + "$a = New-ScheduledTaskAction -Execute '{exe}' -Argument '--power-helper' -WorkingDirectory '{dir}'\r\n\ + $p = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Highest\r\n\ + $s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 1) -MultipleInstances IgnoreNew -Hidden\r\n\ + Register-ScheduledTask -TaskName '{name}' -Action $a -Principal $p -Settings $s -Force | Out-Null\r\n\ + exit 0\r\n", + dir = exe.rfind(['\\', '/']).map(|i| exe[..i].to_string()).unwrap_or_default(), + name = TASK_NAME + ) +} + +/// The PowerShell that starts the task from an unelevated process (no prompt: the user owns the task). +pub fn start_command() -> String { + format!("Start-ScheduledTask -TaskName '{TASK_NAME}'; exit 0") +} + +/// The PowerShell that says whether the task is registered (exit 0) or not (exit 1). +pub fn query_command() -> String { + format!("if (Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue) {{ exit 0 }} else {{ exit 1 }}") +} + +/// The PowerShell the helper itself runs (elevated) on `remove`: the task goes, nothing is left. +pub fn remove_command() -> String { + format!("Unregister-ScheduledTask -TaskName '{TASK_NAME}' -Confirm:$false; exit 0") +} + +/// Is the task registered? Windows only; false elsewhere. +pub fn registered() -> bool { + if !cfg!(windows) { + return false; + } + let mut c = std::process::Command::new(crate::platform::tool("powershell")); + c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &query_command()]); + crate::platform::quiet(&mut c); + c.status().map(|s| s.success()).unwrap_or(false) +} + +/// Starts the task (no prompt). Ok when Start-ScheduledTask returned 0. +pub fn start() -> Result<(), String> { + let mut c = std::process::Command::new(crate::platform::tool("powershell")); + c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &start_command()]); + crate::platform::quiet(&mut c); + let out = c.output().map_err(|e| e.to_string())?; + if out.status.success() { + Ok(()) + } else { + Err(format!("Start-ScheduledTask failed: {}", String::from_utf8_lossy(&out.stderr).trim())) + } +} + +/// The helper process (`igneum-app --power-helper`): polls `/cmd.txt` twice a second, runs the parsed commands +/// through nvidia-smi, logs what it ran to `/helper.log`, ends on `quit`, on `remove` (after unregistering the +/// task) or after 20 idle minutes. `dir` is `/app/sweep`. +pub fn run_helper(dir: &Path) -> i32 { + let _ = std::fs::create_dir_all(dir); + let cmd_file = dir.join("cmd.txt"); + let log_file = dir.join("helper.log"); + let log = |line: &str| { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new().append(true).create(true).open(&log_file) { + let _ = writeln!(f, "{} {line}", crate::platform::unix_now()); + } + }; + log("helper started (scheduled task, elevated)"); + // a stale file from an earlier run is not a command: only lines after the start count + let mut last_seq: u64 = std::fs::read_to_string(&cmd_file).ok().and_then(|t| t.lines().filter_map(parse_line).map(|(s, _)| s).max()).unwrap_or(0); + let mut last_text = String::new(); + let mut dev = "0".to_string(); + let mut idle = Instant::now(); + let smi = crate::platform::tool("nvidia-smi"); + loop { + let text = std::fs::read_to_string(&cmd_file).unwrap_or_default(); + if text != last_text { + last_text = text.clone(); + for (seq, c) in text.lines().filter_map(parse_line) { + match c { + HelperCmd::Quit => { + log("quit"); + return 0; + } + HelperCmd::Remove => { + let mut p = std::process::Command::new(crate::platform::tool("powershell")); + p.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &remove_command()]); + crate::platform::quiet(&mut p); + let ok = p.status().map(|s| s.success()).unwrap_or(false); + log(&format!("remove: the task is {}", if ok { "unregistered" } else { "still registered (Unregister-ScheduledTask failed)" })); + return if ok { 0 } else { 1 }; + } + _ if seq <= last_seq => continue, + HelperCmd::Dev(d) => { + last_seq = seq; + idle = Instant::now(); + dev = d; + log(&format!("{seq} dev {dev}")); + } + other => { + last_seq = seq; + idle = Instant::now(); + let args = smi_args(&dev, &other).unwrap_or_default(); + let mut p = std::process::Command::new(&smi); + p.args(&args); + crate::platform::quiet(&mut p); + let out = p.output().map(|o| format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))).unwrap_or_else(|e| e.to_string()); + log(&format!("{seq} nvidia-smi {} : {}", args.join(" "), out.replace('\n', " ").trim())); + } + } + } + } + if idle.elapsed() >= Duration::from_secs(IDLE_S) { + log("idle 20 min: exit (the engine starts the task again when it needs it)"); + return 0; + } + std::thread::sleep(Duration::from_millis(500)); + } +} + +/// Where the command file lives for a data root. +pub fn sweep_dir(app_dir: &Path) -> PathBuf { + app_dir.join("sweep") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_fixed_verbs_with_digit_arguments_parse() { + assert_eq!(parse_line("7 pl 460"), Some((7, HelperCmd::PowerLimit(460)))); + assert_eq!(parse_line("8 lgc 2472"), Some((8, HelperCmd::ClockCap(2472)))); + assert_eq!(parse_line("9 rgc"), Some((9, HelperCmd::ClockReset))); + assert_eq!(parse_line("3 dev 1"), Some((3, HelperCmd::Dev("1".into())))); + assert_eq!(parse_line("5 403"), Some((5, HelperCmd::PowerLimit(403))), "the 0.3.9 form"); + assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit))); + assert_eq!(parse_line("remove"), Some((0, HelperCmd::Remove))); + // nothing else: no shell, no path, no string argument, no oversized number + for bad in ["7 pl 460; calc", "7 pl -460", "7 pl 4.60", "7 lgc 0,2472", "7 rm C:\\x", "x pl 460", "7 pl", "7 lgc 12345678", "7 dev ../1", "", "7 pl 460 extra"] { + assert_eq!(parse_line(bad), None, "{bad:?}"); + } + } + + #[test] + fn the_arguments_reach_nvidia_smi_as_a_list_never_a_shell() { + assert_eq!(smi_args("0", &HelperCmd::PowerLimit(460)).unwrap(), vec!["-i", "0", "-pl", "460"]); + assert_eq!(smi_args("1", &HelperCmd::ClockCap(2472)).unwrap(), vec!["-i", "1", "-lgc", "0,2472"]); + assert_eq!(smi_args("1", &HelperCmd::ClockReset).unwrap(), vec!["-i", "1", "-rgc"]); + assert_eq!(smi_args("0", &HelperCmd::Quit), None); + assert_eq!(smi_args("0", &HelperCmd::Remove), None); + assert_eq!(smi_args("0", &HelperCmd::Dev("1".into())), None); + } + + #[test] + fn the_registration_is_per_user_highest_no_trigger_fixed_action() { + let s = register_script(Path::new(r"C:\Users\Admin\AppData\Local\Programs\Igneum Miner\igneum-app.exe")); + assert!(s.contains("-Execute 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner\\igneum-app.exe' -Argument '--power-helper'"), "{s}"); + assert!(s.contains("-WorkingDirectory 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Igneum Miner'"), "{s}"); + assert!(s.contains("-RunLevel Highest") && s.contains("-LogonType Interactive"), "{s}"); + assert!(s.contains("[System.Security.Principal.WindowsIdentity]::GetCurrent().Name"), "the signed-in user, never a literal"); + assert!(!s.contains("-Trigger"), "no trigger: only the app starts it"); + assert!(s.contains("-MultipleInstances IgnoreNew") && s.contains("-ExecutionTimeLimit"), "{s}"); + assert!(s.contains(&format!("-TaskName '{TASK_NAME}'"))); + // a quote in the path cannot break out of the literal + let q = register_script(Path::new(r"C:\it's\igneum-app.exe")); + assert!(q.contains("'C:\\it''s\\igneum-app.exe'"), "{q}"); + assert!(start_command().starts_with("Start-ScheduledTask -TaskName 'Igneum Power Helper'")); + assert!(remove_command().starts_with("Unregister-ScheduledTask -TaskName 'Igneum Power Helper' -Confirm:$false")); + assert!(query_command().contains("Get-ScheduledTask -TaskName 'Igneum Power Helper'")); + } + + #[test] + fn a_stale_command_file_does_not_run_at_start() { + // the helper's start reads the highest sequence already in the file and runs nothing below or at it + let text = "3 pl 460\n4 lgc 2472\n"; + let last = text.lines().filter_map(parse_line).map(|(s, _)| s).max().unwrap_or(0); + assert_eq!(last, 4); + let newer: Vec<_> = "3 pl 460\n4 lgc 2472\n5 rgc\n".lines().filter_map(parse_line).filter(|(s, _)| *s > last).collect(); + assert_eq!(newer, vec![(5, HelperCmd::ClockReset)]); + } +} diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index b0063f77..470c06ff 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -161,13 +161,36 @@ maximum, 14,001 MHz memory; 9070 XT present on bus 98 with OFFSET ranges `gmax_r 10`). The offset finding changed the AMD mapping (054e041): an offset clock range closes the clock knob and the power ladder runs on a percent scale bounded by `plimit_range`. The re-run follows the 0.3.11 rollout. +## 7a. One administrator approval, ever (0.3.13; the project lead, 6 October 2026, 11:50 UTC) + +What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; every later cap (an app start, a +reboot, a slider move) and every tune's helper is another elevated launch, so another prompt. Not "once, ever". + +What `src/powertask.rs` does: the first approval's elevated step also registers a per-user Windows scheduled task, +`Igneum Power Helper` (principal = the signed-in user, interactive logon, RunLevel Highest, no trigger, hidden, one +hour limit, new starts ignored while one runs), whose action is the app's own exe in the install folder with +`--power-helper`. A task the user owns is started by the user's unelevated engine with `Start-ScheduledTask`, no +prompt, and runs elevated. Every later cap and every tune's helper starts the task and writes the command file +`/app/sweep/cmd.txt` (` dev `, ` pl `, ` lgc `, ` rgc`, `quit`). The task +survives app restarts, updates (the per-user installer replaces the exe in place; the task's action path is the +install folder) and reboots. Power control off starts the task once and sends `remove`: the helper unregisters the +task (elevated) and exits; nothing is left behind. Linux keeps pkexec per step; macOS has no cap. + +Threat note: the helper runs only fixed verbs with digit-only arguments through `Command::new(nvidia-smi).args` +(the driver's own path, never PATH, never a shell); a line that is anything else is ignored; the sequence must rise +(a stale file runs nothing); an attacker running as the user gains the power limit and clock cap of the user's own +NVIDIA cards inside the driver's ranges, which the same user could set with one approved prompt anyway; no file, +process, registry key or other binary is reachable through it. Tests: `powertask::tests` (the parser refuses every +non-digit or extra argument, the arguments reach nvidia-smi as a list, the registration is per-user, highest, +trigger-less and quote-safe, a stale command file runs nothing). + ## 8a. Next-cut notes (for the 0.3.12 shipper) | Commit | What | Where | |---|---|---| | b671c8b | every `quit:` names its source; Power control alone decides; no cap at start under `--sweep` | main.rs, server.rs, engine.rs (separable) | | e600e63 | a second engine never runs the updater (`IGNEUM_APP_NO_OTA`, implied by `--sweep`) | engine.rs (6 lines, separable) | -| 1e9550e (this commit, amended) | the elevated job path's output file is followed while the script runs, so the 5-minute progress reports carry its lines (a 35-minute run that never mined showed only "script running" on 6 October 2026); the tune playbook's watchdog fails a run that mines nothing within 120 s of its first status line, with the engine's last log line in the RESULT | jobrun.rs `follow_file`, relay/playbooks/ember-tune-pc1.ps1 | +| 1e9550e | the elevated job path's output file is followed while the script runs, so the 5-minute progress reports carry its lines (a 35-minute run that never mined showed only "script running" on 6 October 2026); the tune playbook's watchdog fails a run that mines nothing within 120 s of its first status line, with the engine's last log line in the RESULT | jobrun.rs `follow_file`, relay/playbooks/ember-tune-pc1.ps1 | ## 9. Open diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index f6ca8898..130b9c45 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -15,7 +15,7 @@ # therefore measure only tonight unless the engine finds itself elevated. $ErrorActionPreference = 'Continue' $resultTag = 'TUNE' -$budgetMinutes = 35 +$budgetMinutes = 45 if (-not ($budgetMinutes -is [int]) -or $budgetMinutes -lt 5) { $budgetMinutes = 35 } # a budget under 5 minutes is a bug, not a budget (C35) $started = Get-Date $deadline = $started.AddMinutes($budgetMinutes) @@ -33,20 +33,30 @@ $appDir = $env:IGNEUM_APP_DIR if (-not $appDir) { $appDir = Join-Path $appData 'app' } # the scratch install: the whole folder (workers, node, helper, DLLs) with the Ember engine swapped in -$root = Join-Path $env:LOCALAPPDATA 'igneum-tune' +# a FRESH scratch root per run (run 3, 6 October 2026, 11:45Z: the folder an earlier elevated engine had locked to itself +# refused the settings copy, the engine started on stale files and exited in 6 s); old roots are small and left alone +$root = Join-Path $env:LOCALAPPDATA ('igneum-tune-' + (Get-Date -Format 'yyyyMMdd-HHmmss')) $bin = Join-Path $root 'bin' $sApp = Join-Path $root 'app' $sLogs = Join-Path $root 'logs' New-Item -ItemType Directory -Force -Path $root, $sApp, $sLogs | Out-Null if (Test-Path $bin) { Remove-Item -LiteralPath $bin -Recurse -Force -ErrorAction SilentlyContinue } Copy-Item -LiteralPath $installDir -Destination $bin -Recurse -Force +# the installed engine carries Ember Tune from 0.3.12 on: prefer it; the kit is for a PC still on an older app +$installedVer = (& (Join-Path $installDir 'igneum-app.exe') --version 2>&1 | Out-String).Trim() +$installedHasEmber = $false +if ($installedVer -match 'igneum-app (\d+)\.(\d+)\.(\d+)') { $installedHasEmber = ([int]$Matches[1] -gt 0) -or ([int]$Matches[2] -gt 3) -or (([int]$Matches[2] -eq 3) -and ([int]$Matches[3] -ge 12)) } $ember = $null -foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } +# ember-kit-3 (the engine with Settings::for_measurement) is preferred when present, whatever the installed version; +# older kits only when the installed app predates Ember Tune +$k3 = Join-Path $appDir 'jobs\ember-kit-5\igneum-app-ember.exe' +if (Test-Path $k3) { $ember = $k3 } +elseif (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } } if ($ember) { Copy-Item -LiteralPath $ember -Destination (Join-Path $bin 'igneum-app.exe') -Force Say ("engine: the Ember build from " + $ember) } else { - Say 'engine: the installed one (no jobs\ember-kit-1\igneum-app-ember.exe); an older engine ignores the tune and reports no_rows' + Say ('engine: the installed one (' + $installedVer + $(if ($installedHasEmber) { ', carries Ember Tune' } else { '; no kit found: an older engine ignores the tune and reports no_rows' }) + ')') } $helper = $null $found = Get-ChildItem -Path (Join-Path $appDir 'jobs') -Recurse -Filter 'igneum-gpu-telemetry.exe' -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match 'amd-kit' } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 @@ -61,37 +71,22 @@ Say ("engine: " + $exe + " (" + $ver + ")") Write-Output ("RESULT TUNE engine " + $ver + " sha256=" + (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash.ToLower()) if ($ver -notmatch 'igneum-app (\d+)\.(\d+)\.(\d+)') { Write-Output 'RESULT TUNE error=version_unknown'; exit 2 } -foreach ($f in @('settings.json', 'machine-id', 'wallet.json', 'tuning.json')) { +foreach ($f in @('settings.json', 'machine-id', 'wallet.json', 'tuning.json', 'firewall-rule.json')) { # the firewall flag too: an older kit then asks nothing $src = Join-Path $appDir $f if (Test-Path $src) { Copy-Item -LiteralPath $src -Destination (Join-Path $sApp $f) -Force } } -# the second engine must not poll jobs (it would see this one), update itself, or prove; the tune is on +# the copies are VERBATIM (run 4, 6 October 2026: a PowerShell ConvertFrom-Json | ConvertTo-Json round trip rewrote big +# integers as doubles, the engine read the file as defaults, no payout address, every card off, 96 old jobs run in +# the scratch root); the engine itself switches remote jobs, updates, proving and Power control off under --sweep +# (Settings::for_measurement) and makes every card due. Only a report line is read here. $sj = Join-Path $sApp 'settings.json' -if (Test-Path $sj) { - try { - $j = Get-Content -LiteralPath $sj -Raw | ConvertFrom-Json - $j.remote_jobs = $false; $j.auto_update = $false; $j.prove = $false; $j.paused = $false; $j.setup_done = $true; $j.sweep = $true - # the project lead, 6 October 2026, 07:20Z: never raise an administrator prompt. The installed app's Power control is READ and - # reported, but the copy runs with it OFF so the second engine can never start the elevated helper; the 5090 is - # measured as it runs either way (the two-knob tune is the installed engine's job once it carries Ember Tune) - $installedPowerControl = $false - try { $installedPowerControl = [bool]$j.power_control } catch { } - Write-Output ('RESULT TUNE installed_power_control=' + $installedPowerControl.ToString().ToLower() + ' (the copy runs with it off: no prompt)') - if ($j.PSObject.Properties.Name -contains 'power_control') { $j.power_control = $false } else { $j | Add-Member -NotePropertyName power_control -NotePropertyValue $false } - # every card is due: the stored results are cleared in the COPY only - if ($j.cards) { foreach ($p in $j.cards.PSObject.Properties) { $p.Value.sweep_at = 0; $p.Value.pinned = $false } } - # PowerShell 5.1's Set-Content -Encoding utf8 writes a BOM, which the engine's JSON parser refuses: the copy then - # read as defaults (no payout address, no cards) and the miners never started (runs 1 and 2, 5 and 6 October 2026) - [IO.File]::WriteAllText($sj, ($j | ConvertTo-Json -Depth 8), (New-Object System.Text.UTF8Encoding $false)) - } catch { Say ("settings.json: " + $_.Exception.Message) } - $back = $null - try { $back = Get-Content -LiteralPath $sj -Raw | ConvertFrom-Json } catch { } - $addr = ''; if ($back) { $addr = [string]$back.address } - $bom = (Get-Content -LiteralPath $sj -Encoding Byte -TotalCount 3 -ErrorAction SilentlyContinue) -join ',' - Write-Output ('RESULT TUNE scratch settings: address ' + $(if ($addr) { $addr.Substring(0, [Math]::Min(10, $addr.Length)) + '...' } else { 'EMPTY' }) + ', cards ' + $(if ($back -and $back.cards) { @($back.cards.PSObject.Properties).Count } else { 0 }) + ', first bytes ' + $bom) - if (-not $addr -and -not (Test-Path (Join-Path $sApp 'wallet.json'))) { Write-Output 'RESULT TUNE error=no_address reason=the_copied_settings_carry_no_payout_address_and_no_wallet.json'; exit 2 } - if ($bom -eq '239,187,191') { Write-Output 'RESULT TUNE error=bom reason=settings.json_starts_with_a_BOM'; exit 2 } -} else { Write-Output 'RESULT TUNE error=no_settings reason=the_installed_app_has_no_settings.json'; exit 2 } +if (-not (Test-Path -LiteralPath $sj)) { Write-Output 'RESULT TUNE error=no_settings reason=the_installed_app_has_no_settings.json'; exit 2 } +$installedPowerControl = 'unknown'; $addr = ''; $ncards = 0 +try { $back = Get-Content -LiteralPath $sj -Raw | ConvertFrom-Json; $addr = [string]$back.address; if ($back.cards) { $ncards = @($back.cards.PSObject.Properties).Count }; if ($back.PSObject.Properties.Name -contains 'power_control') { $installedPowerControl = ([bool]$back.power_control).ToString().ToLower() } } catch { } +$bom = (Get-Content -LiteralPath $sj -Encoding Byte -TotalCount 3 -ErrorAction SilentlyContinue) -join ',' +Write-Output ('RESULT TUNE installed_power_control=' + $installedPowerControl + ' (the tune engine runs with it off: no prompt unless the job itself is elevated)') +Write-Output ('RESULT TUNE scratch settings (verbatim copy): address ' + $(if ($addr) { $addr.Substring(0, [Math]::Min(10, $addr.Length)) + '...' } else { 'EMPTY' }) + ', cards ' + $ncards + ', first bytes ' + $bom + ', ' + (Get-Item -LiteralPath $sj).Length + ' bytes') +if (-not $addr -and -not (Test-Path (Join-Path $sApp 'wallet.json'))) { Write-Output 'RESULT TUNE error=no_address reason=the_copied_settings_carry_no_payout_address_and_no_wallet.json'; exit 2 } Remove-Item -LiteralPath (Join-Path $sApp 'app.url') -Force -ErrorAction SilentlyContinue # the state before, for the report @@ -139,6 +134,12 @@ $rows = 0 $firstStatusAt = $null $lastMining = $null $lastEngineLine = '' +function EngineLogDump([string] $why) { + Write-Output ('===== engine log tail (' + $why + ')') + $t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($t) { Get-Content -LiteralPath $t.FullName -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch 'status: accepted 0 blocks' } | Select-Object -Last 80 | ForEach-Object { Write-Output (' ' + $_) } } else { Write-Output ' (no app-*.log in the scratch logs folder)' } + if (Test-Path -LiteralPath $errFile) { Write-Output '===== engine stderr'; Get-Content -LiteralPath $errFile -ErrorAction SilentlyContinue | Select-Object -Last 20 | ForEach-Object { Write-Output (' ' + $_) } } +} function EngineTail() { $t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($t) { $l = Get-Content -LiteralPath $t.FullName -Tail 1 -ErrorAction SilentlyContinue; if ($l) { return [string]$l } }; return '' } while (-not $p.HasExited) { Start-Sleep -Seconds 5 @@ -150,12 +151,14 @@ while (-not $p.HasExited) { } if ($firstStatusAt -and -not $lastMining -and ((Get-Date) - $firstStatusAt).TotalSeconds -gt 120) { Write-Output ('RESULT TUNE error=not_mining reason=no_card_mined_within_120_s_of_the_first_status_line last_log_line=' + ($lastEngineLine -replace '\s+', '_')) + EngineLogDump 'watchdog: not mining' EndTree $p.Id 'watchdog: not mining' Write-Output 'RESULT TUNE error=no_rows' exit 3 } if ($lastMining -and ((Get-Date) - $lastMining).TotalSeconds -gt 300) { Write-Output ('RESULT TUNE error=stopped_mining reason=every_card_idle_for_300_s last_log_line=' + ($lastEngineLine -replace '\s+', '_')) + EngineLogDump 'watchdog: stopped mining' EndTree $p.Id 'watchdog: stopped mining' Write-Output 'RESULT TUNE error=no_rows' exit 3 @@ -173,8 +176,8 @@ while (-not $p.HasExited) { # C35 (5 October 2026): the only quit this script may send goes to the TUNE engine's own URL file in the scratch # root, never to a file under the installed app's folder; the RESULT line names the file it used $u = Join-Path $sApp 'app.url' - $installedUrl = Join-Path $appDir 'app.url' - if ((Resolve-Path -LiteralPath $u -ErrorAction SilentlyContinue).Path -eq (Resolve-Path -LiteralPath $installedUrl -ErrorAction SilentlyContinue).Path -or $u -like '*\igneum\app\*') { + # the only URL file this script may quit is its own scratch root's; the installed app's folder is refused by name + if ((Resolve-Path -LiteralPath $u -ErrorAction SilentlyContinue).Path -like (Join-Path $appDir '*') -or $u -like '*\igneum\app\*') { Write-Output ('RESULT TUNE quit refused: ' + $u + ' is the installed app''s URL file') } elseif (Test-Path -LiteralPath $u) { Write-Output ('RESULT TUNE quit asked of the tune engine through ' + $u + ' (pid ' + $p.Id + ')') diff --git a/relay/playbooks/shard-test.ps1 b/relay/playbooks/shard-test.ps1 index 2efb18c9..2535aa11 100644 --- a/relay/playbooks/shard-test.ps1 +++ b/relay/playbooks/shard-test.ps1 @@ -52,13 +52,8 @@ function Stop-App { & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $stop 2>&1 | ForEach-Object { Say (" " + (Strip "$_")) } return } - $urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url' - if (Test-Path $urlFile) { - $url = (Get-Content $urlFile -Raw).Trim() - if ($url) { - try { Invoke-WebRequest -Uri ($url + 'api/quit') -Method POST -Body '{}' -ContentType 'application/json' -UseBasicParsing -TimeoutSec 5 | Out-Null; Say 'asked the engine to quit over its local API' } catch { Say ('local API did not answer: ' + $_.Exception.Message) } - } - } + # (5 October 2026 rule: a job never quits the installed app it did not start; the api/quit that stood here is gone. + # Stopping the app is the signed `restart` job kind's work; without the stop script this waits for the app to stop.) $until = (Get-Date).AddSeconds(50) while ((Get-Date) -lt $until) { if (@(Get-Process -Name 'igneum-app', 'igneumd' -ErrorAction SilentlyContinue).Count -eq 0) { break } diff --git a/relay/playbooks/sweep-5090.ps1 b/relay/playbooks/sweep-5090.ps1 index 29a9c7c0..846feeaf 100644 --- a/relay/playbooks/sweep-5090.ps1 +++ b/relay/playbooks/sweep-5090.ps1 @@ -37,15 +37,8 @@ foreach ($f in @('settings.json', 'machine-id', 'wallet.json')) { $src = Join-Path $appDir $f if (Test-Path $src) { Copy-Item -LiteralPath $src -Destination (Join-Path $sApp $f) -Force } } -# the second engine must not poll jobs (it would see this one), update itself, or prove -$sj = Join-Path $sApp 'settings.json' -if (Test-Path $sj) { - try { - $j = Get-Content -LiteralPath $sj -Raw | ConvertFrom-Json - $j.remote_jobs = $false; $j.auto_update = $false; $j.prove = $false; $j.paused = $false; $j.setup_done = $true - [IO.File]::WriteAllText($sj, ($j | ConvertTo-Json -Depth 8), (New-Object System.Text.UTF8Encoding $false)) # no BOM: the engine's JSON parser refuses one (C35, runs 1 and 2) - } catch { Say ("settings.json: " + $_.Exception.Message) } -} else { Write-Output 'RESULT SWEEP error=no_settings reason=the_installed_app_has_no_settings.json'; exit 2 } +# the copies are verbatim: the engine switches jobs, updates and proving off itself under --sweep (Settings::for_measurement, 0.3.13) +if (-not (Test-Path (Join-Path $sApp 'settings.json'))) { Write-Output 'RESULT SWEEP error=no_settings reason=the_installed_app_has_no_settings.json'; exit 2 } Remove-Item -LiteralPath (Join-Path $sApp 'app.url') -Force -ErrorAction SilentlyContinue # the cap state before, for the report diff --git a/tools/ci/playbook-quit-check.sh b/tools/ci/playbook-quit-check.sh new file mode 100755 index 00000000..04b04c27 --- /dev/null +++ b/tools/ci/playbook-quit-check.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# The standing rule of 5 October 2026, 23:05 UTC (CLAUDE.md): a job never quits, pauses, resumes or restarts the +# installed app it did not start. A test engine started by a job runs on its own data dir with its own URL file; a +# job may send quit, pause or resume only to an engine it started itself (the URL it created); the installed app is +# touched only through the signed `restart` and `update-now` job kinds. This check fails any playbook or script under +# relay/playbooks, tools/windows, tools/proving-v1 or packaging that reads the INSTALLED app's URL file +# (%LOCALAPPDATA%\igneum\app\app.url, $env:IGNEUM_APP_DIR\app.url, ~/Library/Application Support/Igneum/app/app.url) +# and sends api/quit, api/pause or api/resume. A scratch root's own app.url (igneum-tune-*, igneum-sweep) is fine. +# bash tools/ci/playbook-quit-check.sh [--self-test] +set -euo pipefail +cd "$(dirname "$0")/../.." +check_file() { + local f="$1" bad=0 + grep -qE "api/(quit|pause|resume)" "$f" || return 0 + if grep -vE '^\s*#' "$f" | grep -qE "igneum\\\\app\\\\app\.url|igneum/app/app\.url|Application Support/Igneum/app/app\.url|IGNEUM_APP_DIR[^\n]*app\.url|\\\$appDir[^\n]*'app\.url'"; then + echo "playbook-quit: $f reads the installed app's URL file and sends quit, pause or resume to it (a job may only quit an engine it started: its own scratch URL file)"; bad=1 + fi + return $bad +} +if [ "${1:-}" = "--self-test" ]; then + t="$(mktemp -d)" + printf '%s\n' '$urlFile = Join-Path $env:LOCALAPPDATA '"'"'igneum\app\app.url'"'"'' 'Invoke-WebRequest -Uri ($url + '"'"'api/quit'"'"') -Method POST' > "$t/bad.ps1" + printf '%s\n' '$u = Join-Path $sApp '"'"'app.url'"'"' # $sApp = $root\app, $root = igneum-tune-' 'Invoke-WebRequest -Uri ((Get-Content $u) + '"'"'api/quit'"'"')' > "$t/good.ps1" + if check_file "$t/bad.ps1" >/dev/null; then echo "self-test FAILED: the bad playbook passed"; exit 1; fi + if ! check_file "$t/good.ps1"; then echo "self-test FAILED: the good playbook failed"; exit 1; fi + rm -rf "$t"; echo "self-test passed: the installed app's URL file with a quit fails, a scratch URL file passes"; exit 0 +fi +ALLOW='^packaging/windows/stop-igneum\.ps1$' # the installer's own stop step: the update-now path the rule names +fail=0 +while IFS= read -r f; do [[ "$f" =~ $ALLOW ]] && continue; check_file "$f" || fail=1; done < <(git ls-files 'relay/playbooks/**' 'tools/windows/**' 'tools/proving-v1/**' 'packaging/**' | grep -E '\.(ps1|sh)$') +[ "$fail" = 0 ] && echo "playbook-quit: no playbook quits, pauses or resumes the installed app" +exit $fail diff --git a/tools/ci/second-engine-check.sh b/tools/ci/second-engine-check.sh index da127dd0..8f378b08 100755 --- a/tools/ci/second-engine-check.sh +++ b/tools/ci/second-engine-check.sh @@ -23,6 +23,9 @@ while IFS= read -r f; do if grep -qE 'settings\.json|\.json' "$f" && grep -vE '^\s*#' "$f" | grep -qE 'Set-Content[^\n]*-Encoding +utf8'; then echo "second-engine: $f writes JSON with Set-Content -Encoding utf8 (a BOM the engine refuses: the copy read as defaults, no payout address, nothing mined); use [IO.File]::WriteAllText with UTF8Encoding(\$false)"; fail=1 fi + if grep -vE '^\s*#' "$f" | grep -qE 'ConvertTo-Json' && grep -qE 'settings\.json' "$f"; then + echo "second-engine: $f rewrites settings.json through ConvertTo-Json (a lossy round trip: big integers become doubles and the engine reads the whole file as defaults; run 4, 6 October 2026); copy the file verbatim, the engine applies Settings::for_measurement under --sweep"; fail=1 + fi if ! grep -qE "IGNEUM_APP_NO_OTA *= *'1'" "$f"; then echo "second-engine: $f starts an engine without IGNEUM_APP_NO_OTA = '1' (its updater would run the installer, which quits the installed app: PC 1, 5 October 2026, 22:31 UTC)"; fail=1 fi