From 6188f8d165da90d359519b0ab54dccef9b294324 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:33:25 +0100 Subject: [PATCH 01/10] make-payload.sh: the AMD telemetry helper is taken from the unpacked inputs on CI (the worker glob missed igneum-gpu-telemetry.exe, so the 0.3.12 payload of run 37435975425 lacked it) Co-Authored-By: Claude Fable 5.1 --- packaging/windows/make-payload.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packaging/windows/make-payload.sh b/packaging/windows/make-payload.sh index 7b1398649..1d7ecce59 100755 --- a/packaging/windows/make-payload.sh +++ b/packaging/windows/make-payload.sh @@ -62,7 +62,8 @@ done NVRTC_DIR="$ROOT/proto-cuda/nvrtc" found_workers=0 if [ -n "${IGNEUM_WORKERS_DIR:-}" ] && [ -d "$IGNEUM_WORKERS_DIR" ]; then - for f in "$IGNEUM_WORKERS_DIR"/igneum-worker-*.exe "$IGNEUM_WORKERS_DIR"/nvrtc*.dll "$IGNEUM_WORKERS_DIR"/LICENSE-*.txt "$IGNEUM_WORKERS_DIR"/THIRD-PARTY.md; do + # igneum-gpu-telemetry.exe rides in the inputs too (push-inputs.sh); the worker glob does not match its name (6 October 2026, 0.3.12) + for f in "$IGNEUM_WORKERS_DIR"/igneum-worker-*.exe "$IGNEUM_WORKERS_DIR"/igneum-gpu-telemetry.exe "$IGNEUM_WORKERS_DIR"/nvrtc*.dll "$IGNEUM_WORKERS_DIR"/LICENSE-*.txt "$IGNEUM_WORKERS_DIR"/THIRD-PARTY.md; do [ -f "$f" ] && { cp "$f" "$STAGE/"; found_workers=1; } done else From 87cf66ed2dcca5f2bf7ecae58c8426108e076625 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:44:58 +0100 Subject: [PATCH 02/10] ember-tune-pc1.ps1: budget 45 min (two NVIDIA cards, 19 steps) Co-Authored-By: Claude Fable 5.1 --- relay/playbooks/ember-tune-pc1.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index a491a0293..e0d3422dd 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) From 5a261e7894f7f819318548773d8a50f4e16a2df7 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:47:08 +0100 Subject: [PATCH 03/10] ember-tune-pc1.ps1: a fresh scratch root per run (run 3 at 11:45Z: the folder an earlier elevated engine had locked refused the settings copy, the engine exited in 6 s on stale files), the installed engine preferred from 0.3.12 on, the address guard hard Co-Authored-By: Claude Fable 5.1 --- relay/playbooks/ember-tune-pc1.ps1 | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index e0d3422dd..9d73489f8 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -33,20 +33,26 @@ $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 } } +if (-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 @@ -89,7 +95,7 @@ if (Test-Path $sj) { $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 (-not $addr) { Write-Output 'RESULT TUNE error=no_address reason=the_copied_settings_carry_no_payout_address'; 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 } Remove-Item -LiteralPath (Join-Path $sApp 'app.url') -Force -ErrorAction SilentlyContinue From 53b50e81bad43b2d71b50ccbdd7e72cc57ed9180 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:50:49 +0100 Subject: [PATCH 04/10] One administrator approval, ever (Josh, 6 October 2026 11:50 UTC): the first Power control approval also registers the per-user scheduled task 'Igneum Power Helper' (RunLevel Highest, no trigger, action = the app's own exe --power-helper); every later cap and every tune's helper starts the task and writes the command file, no prompt, across restarts, updates and reboots; Power control off sends remove and the task unregisters itself; the helper runs only fixed verbs with digit-only nvidia-smi arguments (threat note in ember-tune.md 7a); 4 tests Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/engine.rs | 82 +++++++++- app/igneum-app/src/main.rs | 7 + app/igneum-app/src/powertask.rs | 265 ++++++++++++++++++++++++++++++++ docs/plans/ember-tune.md | 25 ++- 4 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 app/igneum-app/src/powertask.rs diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 790e8c2d5..bd690bdfa 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 5d7761c6e..bbb24485d 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -34,6 +34,7 @@ mod verifier; mod wslhost; mod sweep; mod ember; +mod powertask; mod watchdog; use std::io::{BufRead, Write}; @@ -53,6 +54,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"); diff --git a/app/igneum-app/src/powertask.rs b/app/igneum-app/src/powertask.rs new file mode 100644 index 000000000..dbdf81830 --- /dev/null +++ b/app/igneum-app/src/powertask.rs @@ -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/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 c55865315..4f990112f 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; 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/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 From 4629c81a149fed5857d10efdb20b8897f5c1a2ae Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:52:08 +0100 Subject: [PATCH 05/10] tools/ci/playbook-quit-check.sh: the standing rule of 5 October 2026 23:05 UTC as a gate (a playbook that reads the installed app's URL file and sends quit, pause or resume fails; the installer's own stop step is the one allowed sender; self-test on a bad and a good case); shard-test.ps1 loses its api/quit to the installed app; ember-tune-pc1.ps1's refusal guard reworded Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 ++ relay/playbooks/ember-tune-pc1.ps1 | 4 ++-- relay/playbooks/shard-test.ps1 | 9 ++------- tools/ci/playbook-quit-check.sh | 32 ++++++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 9 deletions(-) create mode 100755 tools/ci/playbook-quit-check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c7af009e..255a4fbe1 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/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index 9d73489f8..a26c844ed 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -179,8 +179,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 fbc85c34a..b6153f294 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/tools/ci/playbook-quit-check.sh b/tools/ci/playbook-quit-check.sh new file mode 100755 index 000000000..04b04c271 --- /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 From 5ca03da1959a73942ac2c2db8ab08c07a9e68da8 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:18:29 +0100 Subject: [PATCH 06/10] ember-tune-pc1.ps1: the watchdog's exits dump the engine's log tail and stderr first (run 4 left no engine line) Co-Authored-By: Claude Fable 5.1 --- relay/playbooks/ember-tune-pc1.ps1 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index a26c844ed..4b364dd2d 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -145,6 +145,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 @@ -156,12 +162,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 From 0b7f9afa7b954e107916e370e2d385bdd525877f Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:21:06 +0100 Subject: [PATCH 07/10] run 4's cause: the playbook's PowerShell JSON round trip rewrote the copied settings (big integers as doubles), the engine read the file as defaults (no payout address, every card off, 96 old remote jobs run in the scratch root). Fix: the copies are verbatim and a --sweep engine applies Settings::for_measurement in memory (remote jobs, updates, proving and Power control off, not paused, tune on, every card due and unpinned); the CI check fails any playbook that rewrites settings.json through ConvertTo-Json; unit test Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/config.rs | 34 +++++++++++++++++++++++ app/igneum-app/src/main.rs | 2 ++ relay/playbooks/ember-tune-pc1.ps1 | 43 +++++++++++------------------- relay/playbooks/sweep-5090.ps1 | 11 ++------ tools/ci/second-engine-check.sh | 3 +++ 5 files changed, 57 insertions(+), 36 deletions(-) diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index fc4fd4f5a..d55883c13 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"); diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index bbb24485d..14b96c8d1 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -101,6 +101,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/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index 4b364dd2d..fd0786c99 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -47,7 +47,11 @@ $installedVer = (& (Join-Path $installDir 'igneum-app.exe') --version 2>&1 | Out $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 -if (-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 } } } +# 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-3\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) @@ -71,33 +75,18 @@ foreach ($f in @('settings.json', 'machine-id', 'wallet.json', 'tuning.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; 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) { Write-Output 'RESULT TUNE error=no_address reason=the_copied_settings_carry_no_payout_address'; 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 diff --git a/relay/playbooks/sweep-5090.ps1 b/relay/playbooks/sweep-5090.ps1 index 29a9c7c02..846feeaf5 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/second-engine-check.sh b/tools/ci/second-engine-check.sh index da127dd0a..8f378b082 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 From d3e207b2ed86a5f042292b221dd6350dd773e292 Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:39:18 +0100 Subject: [PATCH 08/10] Why every measurement engine on PC 1 ran on defaults: lock_permissions cut the app folder's inheritance with a non-inheritable user:F, which left files COPIED in before the start (settings.json, machine-id, wallet.json) with no ACE at all, unreadable by their owner; the folder grant is now user:(OI)(CI)F with /T (unit test on the argument shape); a --sweep engine never asks for the firewall rule; the playbook copies the firewall flag; the settings fixture test Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/config.rs | 15 +++++++++++++++ app/igneum-app/src/ota.rs | 6 +++++- app/igneum-app/src/platform.rs | 30 +++++++++++++++++++++++++----- relay/playbooks/ember-tune-pc1.ps1 | 2 +- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index d55883c13..214ced9e5 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -500,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/ota.rs b/app/igneum-app/src/ota.rs index b04d7fb88..7a7f12bee 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 a3e8e6941..181b2a0de 100644 --- a/app/igneum-app/src/platform.rs +++ b/app/igneum-app/src/platform.rs @@ -166,17 +166,26 @@ 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 applied to everything +/// inside (`(OI)(CI)F`, `/T`): the old non-inheritable `user:F` cut the folder's inheritance and left any file that +/// was COPIED in before the engine started with no entry at all (6 October 2026, PC 1: a measurement engine's +/// settings.json, machine-id and wallet.json copied by a job read as nothing, so the engine ran on defaults with no +/// payout address; the engine's own files, written after the lock, got the creator's default DACL and hid it). +pub fn icacls_lock_args(dir: bool, user: &str) -> Vec { + if dir { + vec!["/inheritance:r".into(), "/grant:r".into(), format!("{user}:(OI)(CI)F"), "/T".into()] + } 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 +509,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", "/T"]); + 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/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index fd0786c99..4ed51c772 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -71,7 +71,7 @@ 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 } } From eee57dcf2ee7d67aa386f1994626453412edb63c Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:41:31 +0100 Subject: [PATCH 09/10] ember-tune-pc1.ps1: prefer ember-kit-4 (the ACL fix) Co-Authored-By: Claude Fable 5.1 --- relay/playbooks/ember-tune-pc1.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index 4ed51c772..b88328a88 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -49,7 +49,7 @@ if ($installedVer -match 'igneum-app (\d+)\.(\d+)\.(\d+)') { $installedHasEmber $ember = $null # 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-3\igneum-app-ember.exe' +$k3 = Join-Path $appDir 'jobs\ember-kit-4\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) { From 07d5a725b632c51be2097e2552b6c03f199aa6cc Mon Sep 17 00:00:00 2001 From: igneum-josh <337424239+igneum-josh@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:50:49 +0100 Subject: [PATCH 10/10] lock_permissions: the folder grant is user:(OI)(CI)F WITHOUT /T (measured on PC 1, collect ember-acl-2: /T re-applies /inheritance:r to each file after the propagation and an (OI)(CI) entry on a file is inherit-only, so the copied settings still read as nothing); the playbook prefers ember-kit-5 Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/platform.rs | 16 +++++++++------- relay/playbooks/ember-tune-pc1.ps1 | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/igneum-app/src/platform.rs b/app/igneum-app/src/platform.rs index 181b2a0de..bb41df4ff 100644 --- a/app/igneum-app/src/platform.rs +++ b/app/igneum-app/src/platform.rs @@ -173,14 +173,16 @@ pub fn lock_permissions(path: &Path, dir: bool) { } } -/// The icacls arguments that lock a path to the user. A folder gets an INHERITABLE grant applied to everything -/// inside (`(OI)(CI)F`, `/T`): the old non-inheritable `user:F` cut the folder's inheritance and left any file that -/// was COPIED in before the engine started with no entry at all (6 October 2026, PC 1: a measurement engine's -/// settings.json, machine-id and wallet.json copied by a job read as nothing, so the engine ran on defaults with no -/// payout address; the engine's own files, written after the lock, got the creator's default DACL and hid it). +/// 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"), "/T".into()] + vec!["/inheritance:r".into(), "/grant:r".into(), format!("{user}:(OI)(CI)F")] } else { vec!["/inheritance:r".into(), "/grant:r".into(), format!("{user}:F")] } @@ -514,7 +516,7 @@ 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", "/T"]); + 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"]); } diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1 index b88328a88..1a272e34f 100644 --- a/relay/playbooks/ember-tune-pc1.ps1 +++ b/relay/playbooks/ember-tune-pc1.ps1 @@ -49,7 +49,7 @@ if ($installedVer -match 'igneum-app (\d+)\.(\d+)\.(\d+)') { $installedHasEmber $ember = $null # 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-4\igneum-app-ember.exe' +$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) {