release 0.3.13: merge ember-tune 0cba030 (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:
commit
7883ff17dd
13 changed files with 536 additions and 58 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
265
app/igneum-app/src/powertask.rs
Normal file
265
app/igneum-app/src/powertask.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
//! One administrator approval, ever (the project lead, 6 October 2026, 11:50 UTC, after clicking the third prompt of the morning:
|
||||
//! "can we make sure all these popups are not needed in future?").
|
||||
//!
|
||||
//! What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; but every later cap (an app
|
||||
//! start, a reboot, a slider move) and every tune's helper is another elevated launch, so another prompt. This module
|
||||
//! makes the first approval the last: the one elevated step also registers a per-user Windows scheduled task,
|
||||
//! `Igneum Power Helper`, principal = the signed-in user, RunLevel Highest, no trigger, whose action is this very
|
||||
//! executable with `--power-helper`. A task the user owns can be STARTED by the user's unelevated processes without a
|
||||
//! prompt (`Start-ScheduledTask`), and it runs elevated; so every later cap and tune starts the task and talks to it
|
||||
//! through the command file `<app 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)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -161,13 +161,36 @@ maximum, 14,001 MHz memory; 9070 XT present on bus 98 with OFFSET ranges `gmax_r
|
|||
10`). The offset finding changed the AMD mapping (054e041): an offset clock range closes the clock knob and the power
|
||||
ladder runs on a percent scale bounded by `plimit_range`. The re-run follows the 0.3.11 rollout.
|
||||
|
||||
## 7a. One administrator approval, ever (0.3.13; the project lead, 6 October 2026, 11:50 UTC)
|
||||
|
||||
What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; every later cap (an app start, a
|
||||
reboot, a slider move) and every tune's helper is another elevated launch, so another prompt. Not "once, ever".
|
||||
|
||||
What `src/powertask.rs` does: the first approval's elevated step also registers a per-user Windows scheduled task,
|
||||
`Igneum Power Helper` (principal = the signed-in user, interactive logon, RunLevel Highest, no trigger, hidden, one
|
||||
hour limit, new starts ignored while one runs), whose action is the app's own exe in the install folder with
|
||||
`--power-helper`. A task the user owns is started by the user's unelevated engine with `Start-ScheduledTask`, no
|
||||
prompt, and runs elevated. Every later cap and every tune's helper starts the task and writes the command file
|
||||
`<app 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
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
# therefore measure only tonight unless the engine finds itself elevated.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$resultTag = 'TUNE'
|
||||
$budgetMinutes = 35
|
||||
$budgetMinutes = 45
|
||||
if (-not ($budgetMinutes -is [int]) -or $budgetMinutes -lt 5) { $budgetMinutes = 35 } # a budget under 5 minutes is a bug, not a budget (C35)
|
||||
$started = Get-Date
|
||||
$deadline = $started.AddMinutes($budgetMinutes)
|
||||
|
|
@ -33,20 +33,30 @@ $appDir = $env:IGNEUM_APP_DIR
|
|||
if (-not $appDir) { $appDir = Join-Path $appData 'app' }
|
||||
|
||||
# the scratch install: the whole folder (workers, node, helper, DLLs) with the Ember engine swapped in
|
||||
$root = Join-Path $env:LOCALAPPDATA 'igneum-tune'
|
||||
# a FRESH scratch root per run (run 3, 6 October 2026, 11:45Z: the folder an earlier elevated engine had locked to itself
|
||||
# refused the settings copy, the engine started on stale files and exited in 6 s); old roots are small and left alone
|
||||
$root = Join-Path $env:LOCALAPPDATA ('igneum-tune-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
|
||||
$bin = Join-Path $root 'bin'
|
||||
$sApp = Join-Path $root 'app'
|
||||
$sLogs = Join-Path $root 'logs'
|
||||
New-Item -ItemType Directory -Force -Path $root, $sApp, $sLogs | Out-Null
|
||||
if (Test-Path $bin) { Remove-Item -LiteralPath $bin -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
Copy-Item -LiteralPath $installDir -Destination $bin -Recurse -Force
|
||||
# the installed engine carries Ember Tune from 0.3.12 on: prefer it; the kit is for a PC still on an older app
|
||||
$installedVer = (& (Join-Path $installDir 'igneum-app.exe') --version 2>&1 | Out-String).Trim()
|
||||
$installedHasEmber = $false
|
||||
if ($installedVer -match 'igneum-app (\d+)\.(\d+)\.(\d+)') { $installedHasEmber = ([int]$Matches[1] -gt 0) -or ([int]$Matches[2] -gt 3) -or (([int]$Matches[2] -eq 3) -and ([int]$Matches[3] -ge 12)) }
|
||||
$ember = $null
|
||||
foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } }
|
||||
# ember-kit-3 (the engine with Settings::for_measurement) is preferred when present, whatever the installed version;
|
||||
# older kits only when the installed app predates Ember Tune
|
||||
$k3 = Join-Path $appDir 'jobs\ember-kit-5\igneum-app-ember.exe'
|
||||
if (Test-Path $k3) { $ember = $k3 }
|
||||
elseif (-not $installedHasEmber) { foreach ($cand in @((Join-Path $appDir 'jobs\ember-kit-2\igneum-app-ember.exe'), (Join-Path $appDir 'jobs\ember-kit-1\igneum-app-ember.exe'))) { if (Test-Path $cand) { $ember = $cand; break } } }
|
||||
if ($ember) {
|
||||
Copy-Item -LiteralPath $ember -Destination (Join-Path $bin 'igneum-app.exe') -Force
|
||||
Say ("engine: the Ember build from " + $ember)
|
||||
} else {
|
||||
Say 'engine: the installed one (no jobs\ember-kit-1\igneum-app-ember.exe); an older engine ignores the tune and reports no_rows'
|
||||
Say ('engine: the installed one (' + $installedVer + $(if ($installedHasEmber) { ', carries Ember Tune' } else { '; no kit found: an older engine ignores the tune and reports no_rows' }) + ')')
|
||||
}
|
||||
$helper = $null
|
||||
$found = Get-ChildItem -Path (Join-Path $appDir 'jobs') -Recurse -Filter 'igneum-gpu-telemetry.exe' -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match 'amd-kit' } | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
|
|
@ -61,37 +71,22 @@ Say ("engine: " + $exe + " (" + $ver + ")")
|
|||
Write-Output ("RESULT TUNE engine " + $ver + " sha256=" + (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash.ToLower())
|
||||
if ($ver -notmatch 'igneum-app (\d+)\.(\d+)\.(\d+)') { Write-Output 'RESULT TUNE error=version_unknown'; exit 2 }
|
||||
|
||||
foreach ($f in @('settings.json', 'machine-id', 'wallet.json', 'tuning.json')) {
|
||||
foreach ($f in @('settings.json', 'machine-id', 'wallet.json', 'tuning.json', 'firewall-rule.json')) { # the firewall flag too: an older kit then asks nothing
|
||||
$src = Join-Path $appDir $f
|
||||
if (Test-Path $src) { Copy-Item -LiteralPath $src -Destination (Join-Path $sApp $f) -Force }
|
||||
}
|
||||
# the second engine must not poll jobs (it would see this one), update itself, or prove; the tune is on
|
||||
# the copies are VERBATIM (run 4, 6 October 2026: a PowerShell ConvertFrom-Json | ConvertTo-Json round trip rewrote big
|
||||
# integers as doubles, the engine read the file as defaults, no payout address, every card off, 96 old jobs run in
|
||||
# the scratch root); the engine itself switches remote jobs, updates, proving and Power control off under --sweep
|
||||
# (Settings::for_measurement) and makes every card due. Only a report line is read here.
|
||||
$sj = Join-Path $sApp 'settings.json'
|
||||
if (Test-Path $sj) {
|
||||
try {
|
||||
$j = Get-Content -LiteralPath $sj -Raw | ConvertFrom-Json
|
||||
$j.remote_jobs = $false; $j.auto_update = $false; $j.prove = $false; $j.paused = $false; $j.setup_done = $true; $j.sweep = $true
|
||||
# the project lead, 6 October 2026, 07:20Z: never raise an administrator prompt. The installed app's Power control is READ and
|
||||
# reported, but the copy runs with it OFF so the second engine can never start the elevated helper; the 5090 is
|
||||
# measured as it runs either way (the two-knob tune is the installed engine's job once it carries Ember Tune)
|
||||
$installedPowerControl = $false
|
||||
try { $installedPowerControl = [bool]$j.power_control } catch { }
|
||||
Write-Output ('RESULT TUNE installed_power_control=' + $installedPowerControl.ToString().ToLower() + ' (the copy runs with it off: no prompt)')
|
||||
if ($j.PSObject.Properties.Name -contains 'power_control') { $j.power_control = $false } else { $j | Add-Member -NotePropertyName power_control -NotePropertyValue $false }
|
||||
# every card is due: the stored results are cleared in the COPY only
|
||||
if ($j.cards) { foreach ($p in $j.cards.PSObject.Properties) { $p.Value.sweep_at = 0; $p.Value.pinned = $false } }
|
||||
# PowerShell 5.1's Set-Content -Encoding utf8 writes a BOM, which the engine's JSON parser refuses: the copy then
|
||||
# read as defaults (no payout address, no cards) and the miners never started (runs 1 and 2, 5 and 6 October 2026)
|
||||
[IO.File]::WriteAllText($sj, ($j | ConvertTo-Json -Depth 8), (New-Object System.Text.UTF8Encoding $false))
|
||||
} catch { Say ("settings.json: " + $_.Exception.Message) }
|
||||
$back = $null
|
||||
try { $back = Get-Content -LiteralPath $sj -Raw | ConvertFrom-Json } catch { }
|
||||
$addr = ''; if ($back) { $addr = [string]$back.address }
|
||||
$bom = (Get-Content -LiteralPath $sj -Encoding Byte -TotalCount 3 -ErrorAction SilentlyContinue) -join ','
|
||||
Write-Output ('RESULT TUNE scratch settings: address ' + $(if ($addr) { $addr.Substring(0, [Math]::Min(10, $addr.Length)) + '...' } else { 'EMPTY' }) + ', cards ' + $(if ($back -and $back.cards) { @($back.cards.PSObject.Properties).Count } else { 0 }) + ', first bytes ' + $bom)
|
||||
if (-not $addr -and -not (Test-Path (Join-Path $sApp 'wallet.json'))) { Write-Output 'RESULT TUNE error=no_address reason=the_copied_settings_carry_no_payout_address_and_no_wallet.json'; exit 2 }
|
||||
if ($bom -eq '239,187,191') { Write-Output 'RESULT TUNE error=bom reason=settings.json_starts_with_a_BOM'; exit 2 }
|
||||
} else { Write-Output 'RESULT TUNE error=no_settings reason=the_installed_app_has_no_settings.json'; exit 2 }
|
||||
if (-not (Test-Path -LiteralPath $sj)) { Write-Output 'RESULT TUNE error=no_settings reason=the_installed_app_has_no_settings.json'; exit 2 }
|
||||
$installedPowerControl = 'unknown'; $addr = ''; $ncards = 0
|
||||
try { $back = Get-Content -LiteralPath $sj -Raw | ConvertFrom-Json; $addr = [string]$back.address; if ($back.cards) { $ncards = @($back.cards.PSObject.Properties).Count }; if ($back.PSObject.Properties.Name -contains 'power_control') { $installedPowerControl = ([bool]$back.power_control).ToString().ToLower() } } catch { }
|
||||
$bom = (Get-Content -LiteralPath $sj -Encoding Byte -TotalCount 3 -ErrorAction SilentlyContinue) -join ','
|
||||
Write-Output ('RESULT TUNE installed_power_control=' + $installedPowerControl + ' (the tune engine runs with it off: no prompt unless the job itself is elevated)')
|
||||
Write-Output ('RESULT TUNE scratch settings (verbatim copy): address ' + $(if ($addr) { $addr.Substring(0, [Math]::Min(10, $addr.Length)) + '...' } else { 'EMPTY' }) + ', cards ' + $ncards + ', first bytes ' + $bom + ', ' + (Get-Item -LiteralPath $sj).Length + ' bytes')
|
||||
if (-not $addr -and -not (Test-Path (Join-Path $sApp 'wallet.json'))) { Write-Output 'RESULT TUNE error=no_address reason=the_copied_settings_carry_no_payout_address_and_no_wallet.json'; exit 2 }
|
||||
Remove-Item -LiteralPath (Join-Path $sApp 'app.url') -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# the state before, for the report
|
||||
|
|
@ -139,6 +134,12 @@ $rows = 0
|
|||
$firstStatusAt = $null
|
||||
$lastMining = $null
|
||||
$lastEngineLine = ''
|
||||
function EngineLogDump([string] $why) {
|
||||
Write-Output ('===== engine log tail (' + $why + ')')
|
||||
$t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if ($t) { Get-Content -LiteralPath $t.FullName -ErrorAction SilentlyContinue | Where-Object { $_ -notmatch 'status: accepted 0 blocks' } | Select-Object -Last 80 | ForEach-Object { Write-Output (' ' + $_) } } else { Write-Output ' (no app-*.log in the scratch logs folder)' }
|
||||
if (Test-Path -LiteralPath $errFile) { Write-Output '===== engine stderr'; Get-Content -LiteralPath $errFile -ErrorAction SilentlyContinue | Select-Object -Last 20 | ForEach-Object { Write-Output (' ' + $_) } }
|
||||
}
|
||||
function EngineTail() { $t = Get-ChildItem -Path $sLogs -Filter 'app-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1; if ($t) { $l = Get-Content -LiteralPath $t.FullName -Tail 1 -ErrorAction SilentlyContinue; if ($l) { return [string]$l } }; return '' }
|
||||
while (-not $p.HasExited) {
|
||||
Start-Sleep -Seconds 5
|
||||
|
|
@ -150,12 +151,14 @@ while (-not $p.HasExited) {
|
|||
}
|
||||
if ($firstStatusAt -and -not $lastMining -and ((Get-Date) - $firstStatusAt).TotalSeconds -gt 120) {
|
||||
Write-Output ('RESULT TUNE error=not_mining reason=no_card_mined_within_120_s_of_the_first_status_line last_log_line=' + ($lastEngineLine -replace '\s+', '_'))
|
||||
EngineLogDump 'watchdog: not mining'
|
||||
EndTree $p.Id 'watchdog: not mining'
|
||||
Write-Output 'RESULT TUNE error=no_rows'
|
||||
exit 3
|
||||
}
|
||||
if ($lastMining -and ((Get-Date) - $lastMining).TotalSeconds -gt 300) {
|
||||
Write-Output ('RESULT TUNE error=stopped_mining reason=every_card_idle_for_300_s last_log_line=' + ($lastEngineLine -replace '\s+', '_'))
|
||||
EngineLogDump 'watchdog: stopped mining'
|
||||
EndTree $p.Id 'watchdog: stopped mining'
|
||||
Write-Output 'RESULT TUNE error=no_rows'
|
||||
exit 3
|
||||
|
|
@ -173,8 +176,8 @@ while (-not $p.HasExited) {
|
|||
# C35 (5 October 2026): the only quit this script may send goes to the TUNE engine's own URL file in the scratch
|
||||
# root, never to a file under the installed app's folder; the RESULT line names the file it used
|
||||
$u = Join-Path $sApp 'app.url'
|
||||
$installedUrl = Join-Path $appDir 'app.url'
|
||||
if ((Resolve-Path -LiteralPath $u -ErrorAction SilentlyContinue).Path -eq (Resolve-Path -LiteralPath $installedUrl -ErrorAction SilentlyContinue).Path -or $u -like '*\igneum\app\*') {
|
||||
# the only URL file this script may quit is its own scratch root's; the installed app's folder is refused by name
|
||||
if ((Resolve-Path -LiteralPath $u -ErrorAction SilentlyContinue).Path -like (Join-Path $appDir '*') -or $u -like '*\igneum\app\*') {
|
||||
Write-Output ('RESULT TUNE quit refused: ' + $u + ' is the installed app''s URL file')
|
||||
} elseif (Test-Path -LiteralPath $u) {
|
||||
Write-Output ('RESULT TUNE quit asked of the tune engine through ' + $u + ' (pid ' + $p.Id + ')')
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
32
tools/ci/playbook-quit-check.sh
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue