release 0.3.13: merge ember-tune 07d5a72 (the Power Helper: one approval registers a per-user elevated scheduled task, no prompt after; the folder-lock ACL; the verbatim settings copy; the watchdog; no firewall prompt for a sweep engine; the jobrun follow_file)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-josh 2026-10-06 16:03:27 +01:00
commit 71f08d5b9c
13 changed files with 536 additions and 58 deletions

View file

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

View file

@ -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=<path> 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::<super::Settings>(&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}"),
}
}
}

View file

@ -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<bool>,
/// 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<u64>,
/// 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<String, f64> = 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<String, f64> = 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 || {

View file

@ -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 data>/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];

View file

@ -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::<Vec<String>>(&t).ok()).unwrap_or_default();
// the cached manifest: the rollback floor and the consensus override are known before the first check

View file

@ -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<String> {
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]

View file

@ -0,0 +1,265 @@
//! One administrator approval, ever (Josh, 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 data>/app/sweep/cmd.txt` (the protocol the 0.3.9 helper scripts spoke: `<seq> pl
//! <watts>`, `<seq> lgc <MHz>`, `<seq> 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 data>/app/sweep/cmd.txt`, in the user's own profile. Every command it accepts
//! is a fixed verb with digit-only arguments: `pl <watts>` runs `nvidia-smi -i <dev> -pl <watts>`, `lgc <MHz>` runs
//! `nvidia-smi -i <dev> -lgc 0,<MHz>`, `rgc` runs `nvidia-smi -i <dev> -rgc`, `quit` ends it, `remove` unregisters
//! the task and ends it. The device index is digits only too (`dev <n>` 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: `<seq> <verb> [<digits>]` (the 0.3.9 form `<seq> <watts>` 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<Vec<String>> {
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 `<dir>/cmd.txt` twice a second, runs the parsed commands
/// through nvidia-smi, logs what it ran to `<dir>/helper.log`, ends on `quit`, on `remove` (after unregistering the
/// task) or after 20 idle minutes. `dir` is `<app data>/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)]);
}
}

View file

@ -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; Josh, 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 data>/app/sweep/cmd.txt` (`<seq> dev <n>`, `<seq> pl <W>`, `<seq> lgc <MHz>`, `<seq> 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

View file

@ -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
# Josh, 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 + ')')

View file

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

View file

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

32
tools/ci/playbook-quit-check.sh Executable file
View file

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

View file

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