Merge power-helper-24 f2b52b6e into release-0.3.24 (a deferred install takes the task's unattended form through the registered helper itself, no click; the S4U proof and reprobe playbooks)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
36f2ec00d3
3 changed files with 147 additions and 1 deletions
|
|
@ -248,13 +248,64 @@ pub fn webview2_script(install_dir: &Path) -> String {
|
|||
}
|
||||
|
||||
/// The installer's step. Compares, asks once when something is missing, writes the manifest. Ok(prompted).
|
||||
/// The rights the registered Igneum Power Helper can take by itself, elevated and with no prompt, through its `reregister`
|
||||
/// verb (the helper re-registers its own task from the running build's script: src/powertask.rs). 0.3.24: the task's
|
||||
/// unattended form is such a right, so an update by job on a PC with nobody at the desk (PC 2, 7 October 2026) takes it
|
||||
/// without a click, where the deferred rule alone would have left it to "the next interactive start".
|
||||
pub const HELPER_TAKEABLE: &[&str] = &["power-helper-task@unattended"];
|
||||
|
||||
pub fn helper_can_take(missing: &[String], task_registered: bool) -> bool {
|
||||
task_registered && !missing.is_empty() && missing.iter().all(|m| HELPER_TAKEABLE.contains(&m.as_str()))
|
||||
}
|
||||
|
||||
/// The helper's answer to `<seq> reregister` in its log: Some(true) on "ok", Some(false) on "failed", None while no line.
|
||||
pub fn reregister_ack(log: &str, seq: u64) -> Option<bool> {
|
||||
let ok = format!(" {seq} reregister ok");
|
||||
let failed = format!(" {seq} reregister failed");
|
||||
log.lines().rev().find_map(|l| if l.contains(&ok) { Some(true) } else if l.contains(&failed) { Some(false) } else { None })
|
||||
}
|
||||
|
||||
/// Takes the helper-takeable rights through the running Power Helper task: heartbeat first, then the reregister line, then
|
||||
/// the helper's own ok line within 20 s. Windows only (elsewhere the task does not exist).
|
||||
fn take_through_helper(missing: &[String]) -> Result<(), String> {
|
||||
if !cfg!(windows) {
|
||||
return Err("the Power Helper task is Windows only".into());
|
||||
}
|
||||
let dir = crate::powertask::helper_dir();
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
crate::powertask::ensure_running(&dir, std::time::Duration::from_secs(12))?;
|
||||
let seq = crate::powertask::wire_seq() + 1;
|
||||
std::fs::write(dir.join("cmd.txt"), format!("{seq} reregister\n")).map_err(|e| e.to_string())?;
|
||||
let until = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let log = std::fs::read_to_string(dir.join("helper.log")).unwrap_or_default();
|
||||
match reregister_ack(&log, seq) {
|
||||
Some(true) => return Ok(()),
|
||||
Some(false) => return Err(format!("the helper's reregister failed (its log names why); {} not taken", missing.join(", "))),
|
||||
None if std::time::Instant::now() >= until => return Err(format!("the helper did not answer sequence {seq} reregister within 20 s; {} not taken", missing.join(", "))),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install(exe: &Path, install_dir: &Path, data_root: &Path, app_dir: &Path, version: &str) -> Result<bool, String> {
|
||||
let installed = Manifest::load(app_dir);
|
||||
match step(installed.as_ref(), RIGHTS, std::env::var("SESSIONNAME").ok().as_deref(), in_job_env()) {
|
||||
Step::Nothing => return Ok(false),
|
||||
Step::Deferred => {
|
||||
let miss = missing(installed.as_ref(), RIGHTS);
|
||||
// 0.3.24: what the registered helper can take itself, it takes now, elevated, with no prompt (PC 2 by job)
|
||||
if helper_can_take(&miss, cfg!(windows) && crate::powertask::registered()) {
|
||||
take_through_helper(&miss)?;
|
||||
let mut rights: Vec<String> = installed.as_ref().map(|m| m.rights.clone()).unwrap_or_default();
|
||||
rights.extend(miss.iter().cloned());
|
||||
let _ = std::fs::create_dir_all(app_dir);
|
||||
Manifest { version: version.to_string(), rights, at: crate::platform::unix_now() }.save(app_dir).map_err(|e| format!("cannot write the rights manifest: {e}"))?;
|
||||
return Ok(true);
|
||||
}
|
||||
// a job's or a session-less install never raises a prompt (the project lead, 7 October 2026); the next interactive start asks once
|
||||
return Err(format!("rights: deferred ({} missing: {}); no prompt in a job's or a session-less install, the next interactive start of the app asks once", missing(installed.as_ref(), RIGHTS).len(), missing(installed.as_ref(), RIGHTS).join(", ")));
|
||||
return Err(format!("rights: deferred ({} missing: {}); no prompt in a job's or a session-less install, the next interactive start of the app asks once", miss.len(), miss.join(", ")));
|
||||
}
|
||||
Step::Asked => {}
|
||||
}
|
||||
|
|
@ -347,6 +398,33 @@ mod tests {
|
|||
assert!(s.contains("webview2: ") && s.contains(" ok (minimum "), "one log line either way");
|
||||
}
|
||||
|
||||
/// Known-failed first (PC 2 by job, 7 October 2026): the deferred rule left every new right to "the next interactive
|
||||
/// start", which on a PC with nobody at the desk never comes. The task's unattended form is a right the registered
|
||||
/// helper takes itself through `reregister`, elevated, no prompt; anything else stays deferred.
|
||||
#[test]
|
||||
fn a_job_install_takes_the_task_form_through_the_registered_helper_and_defers_the_rest() {
|
||||
let one = vec!["power-helper-task@unattended".to_string()];
|
||||
assert!(helper_can_take(&one, true));
|
||||
assert!(!helper_can_take(&one, false), "no task registered: nothing can take it, deferred");
|
||||
assert!(!helper_can_take(&[], true), "nothing missing: nothing to take");
|
||||
assert!(!helper_can_take(&["power-helper-task@unattended".to_string(), "firewall-node".to_string()], true), "a firewall rule is not the helper's to take");
|
||||
assert!(!helper_can_take(&["boot-task".to_string()], true));
|
||||
assert_eq!(reregister_ack("1791409581 helper started\n1791409590 427400 reregister ok: the task now runs C:\\x\\igneum-app.exe --power-helper\n", 427400), Some(true));
|
||||
assert_eq!(reregister_ack("1791409590 427400 reregister failed: the task now runs \n", 427400), Some(false));
|
||||
assert_eq!(reregister_ack("1791409590 427399 reregister ok: ...\n", 427400), None, "another sequence's answer is not this one's");
|
||||
assert_eq!(reregister_ack("", 427400), None);
|
||||
// the install path: the deferred branch tries the helper before it defers, and only for what the helper can take
|
||||
let s = include_str!("rights.rs");
|
||||
let i = s.find("Step::Deferred => {").unwrap();
|
||||
let body = &s[i..i + 1500];
|
||||
let take = body.find("helper_can_take(&miss").expect("the helper is asked first");
|
||||
let defer = body.find("rights: deferred").expect("then the deferral");
|
||||
assert!(take < defer);
|
||||
assert!(body.contains("take_through_helper(&miss)?") && body.contains("rights.extend(miss.iter().cloned())"), "the taken rights go into the manifest");
|
||||
// the helper's reregister writes the running build's script: the S4U form (src/powertask.rs register_script)
|
||||
assert!(crate::powertask::register_script(Path::new("C:\\p\\igneum-app.exe")).contains("-LogonType S4U -RunLevel Highest"));
|
||||
}
|
||||
|
||||
/// 0.3.24: an install that holds every 0.3.23 right lacks exactly the unattended task form; the update asks once.
|
||||
#[test]
|
||||
fn the_unattended_task_form_is_one_new_right_for_a_0_3_23_install() {
|
||||
|
|
|
|||
33
relay/playbooks/pc1-helper-reprobe.ps1
Normal file
33
relay/playbooks/pc1-helper-reprobe.ps1
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# PC 1: does the Igneum Power Helper answer again after the app took the 0.3.23 kit through its own update path?
|
||||
# (7 October 2026, main's order; the 0.3.20 helper read every same-count rewrite of cmd.txt as present at start, fixed in
|
||||
# 0.3.21 by effective_skip.) Unelevated, by signed job. The probe is the one command that touches no card: a `dev` line,
|
||||
# which the helper only logs ("<seq> dev <n>"). Order, as 0.3.21+ engines do it: start the task, wait for a fresh
|
||||
# heartbeat (helper.alive within 4 s), THEN write the line (a line present at the helper's start is never a command),
|
||||
# and read helper.log for the sequence within 15 s. Reads the installed version first; nothing is quit, paused or resumed.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
function Say([string] $s) { Write-Output ('[' + (Get-Date -Format 'HH:mm:ss') + '] ' + $s) }
|
||||
$sweep = Join-Path $env:LOCALAPPDATA 'igneum\app\sweep'
|
||||
$cmd = Join-Path $sweep 'cmd.txt'
|
||||
$hlog = Join-Path $sweep 'helper.log'
|
||||
$alive = Join-Path $sweep 'helper.alive'
|
||||
$exe = Join-Path $env:LOCALAPPDATA 'Programs\Igneum Miner\igneum-app.exe'
|
||||
$ver = if (Test-Path -LiteralPath $exe) { (& $exe --version 2>&1 | Select-Object -First 1) } else { 'missing' }
|
||||
Say ("installed exe: " + $ver + " (mtime " + (Get-Item -LiteralPath $exe -ErrorAction SilentlyContinue).LastWriteTimeUtc.ToString('o') + ")")
|
||||
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
|
||||
$running = ''
|
||||
if (Test-Path -LiteralPath $urlFile) { try { $u = (Get-Content -LiteralPath $urlFile -Raw).Trim(); $running = [string](Invoke-RestMethod -Uri ($u + 'api/state') -TimeoutSec 5 -UseBasicParsing).version } catch { } }
|
||||
Say ("running app answers version: " + $(if ($running) { $running } else { 'nothing' }))
|
||||
$t = Get-ScheduledTask -TaskName 'Igneum Power Helper' -ErrorAction SilentlyContinue
|
||||
if (-not $t) { Write-Output 'RESULT helper not_registered'; exit 0 }
|
||||
Say ("task: " + $t.Actions[0].Execute + " " + $t.Actions[0].Arguments + ", LogonType " + $t.Principal.LogonType + ", state " + $t.State)
|
||||
function Fresh() { if (-not (Test-Path -LiteralPath $alive)) { return $false }; $v = 0; if ([long]::TryParse((Get-Content -LiteralPath $alive -Raw).Trim(), [ref]$v)) { return (([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $v) -le 4) } else { return $false } }
|
||||
if (-not (Fresh)) { try { Start-ScheduledTask -TaskName 'Igneum Power Helper' } catch { Say ("Start-ScheduledTask: " + $_) } }
|
||||
$deadline = (Get-Date).AddSeconds(12)
|
||||
while (-not (Fresh) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 250 }
|
||||
if (-not (Fresh)) { $i = Get-ScheduledTaskInfo -TaskName 'Igneum Power Helper'; Write-Output ('RESULT helper silent no heartbeat within 12 s of the start; LastTaskResult 0x' + ('{0:X8}' -f $i.LastTaskResult) + ' LastRunTime ' + $i.LastRunTime + ' installed ' + $ver); Get-Content -LiteralPath $hlog -Tail 6 -ErrorAction SilentlyContinue | ForEach-Object { Say ('helper.log: ' + $_) }; exit 0 }
|
||||
$seq = ([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() % 999990) + 7
|
||||
Set-Content -LiteralPath $cmd -Value ("$seq dev 0") -Encoding ascii
|
||||
$deadline = (Get-Date).AddSeconds(15)
|
||||
$hit = $null
|
||||
while (-not $hit -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 500; $hit = Get-Content -LiteralPath $hlog -Tail 20 -ErrorAction SilentlyContinue | Where-Object { $_ -match (" $seq dev 0") } | Select-Object -Last 1 }
|
||||
if ($hit) { Write-Output ('RESULT helper answers "' + $hit.Trim() + '" installed ' + $ver) } else { Write-Output ('RESULT helper silent heartbeat fresh but no line for sequence ' + $seq + ' within 15 s; installed ' + $ver); Get-Content -LiteralPath $hlog -Tail 6 -ErrorAction SilentlyContinue | ForEach-Object { Say ('helper.log: ' + $_) } }
|
||||
35
relay/playbooks/pc2-s4u-proof.ps1
Normal file
35
relay/playbooks/pc2-s4u-proof.ps1
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# 0.3.24 Power Helper: the scheduler proof on PC 2, by job, UNELEVATED (7 October 2026, main's order after 0.3.23 take 3).
|
||||
# Question: does a task registered -LogonType S4U start from a job's session (no interactive logon of its own) and run?
|
||||
# A probe task in the exact S4U form (the user's own token, no stored password) at RunLevel Limited is registered (the
|
||||
# only form an unelevated process may register; RunLevel Highest needs the installer's elevated rights step, so the
|
||||
# Highest half is read from the real Igneum Power Helper's registration below), started with Start-ScheduledTask from
|
||||
# THIS session, and its run is read three ways: the stamp file its action writes (who ran, in which session), the
|
||||
# scheduler's LastTaskResult, and LastRunTime. The probe is unregistered at the end. The real task is only READ.
|
||||
# Nothing of the app is quit, paused or resumed.
|
||||
$ErrorActionPreference = 'Continue'
|
||||
function Say([string] $s) { Write-Output ('[' + (Get-Date -Format 'HH:mm:ss') + '] ' + $s) }
|
||||
$probe = 'Igneum S4U probe'
|
||||
$stamp = Join-Path $env:LOCALAPPDATA 'igneum\app\sweep\s4u-probe.txt'
|
||||
Remove-Item -LiteralPath $stamp -Force -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $probe -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Say ("this session: " + (Get-Process -Id $PID).SessionId + ", user " + [System.Security.Principal.WindowsIdentity]::GetCurrent().Name)
|
||||
$cmd = "`$p = (Get-Process -Id `$PID); `"`$(Get-Date -Format o) ran as `$([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) session `$(`$p.SessionId) elevated `$(([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole('Administrators'))`" | Out-File -LiteralPath '$stamp' -Encoding utf8"
|
||||
$enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd))
|
||||
$a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ('-NoProfile -WindowStyle Hidden -EncodedCommand ' + $enc)
|
||||
$pr = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType S4U -RunLevel Limited
|
||||
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 5) -MultipleInstances IgnoreNew -Hidden
|
||||
try { Register-ScheduledTask -TaskName $probe -Action $a -Principal $pr -Settings $s -Force | Out-Null; Say 'probe registered (S4U, Limited)' } catch { Write-Output ('RESULT s4u register_failed ' + $_); exit 1 }
|
||||
$t = Get-ScheduledTask -TaskName $probe
|
||||
Say ("probe principal: LogonType " + $t.Principal.LogonType + ", RunLevel " + $t.Principal.RunLevel + ", user " + $t.Principal.UserId)
|
||||
try { Start-ScheduledTask -TaskName $probe; Say 'Start-ScheduledTask returned' } catch { Write-Output ('RESULT s4u start_failed ' + $_) }
|
||||
$deadline = (Get-Date).AddSeconds(30)
|
||||
while (-not (Test-Path -LiteralPath $stamp) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 500 }
|
||||
$info = Get-ScheduledTaskInfo -TaskName $probe
|
||||
Say ("probe LastRunTime " + $info.LastRunTime + ", LastTaskResult " + $info.LastTaskResult + " (0x" + ('{0:X8}' -f $info.LastTaskResult) + "), state " + (Get-ScheduledTask -TaskName $probe).State)
|
||||
if (Test-Path -LiteralPath $stamp) { Write-Output ('RESULT s4u answers ' + (Get-Content -LiteralPath $stamp -Raw).Trim()) } else { Write-Output ('RESULT s4u silent no stamp within 30 s; LastTaskResult 0x' + ('{0:X8}' -f $info.LastTaskResult)) }
|
||||
Unregister-ScheduledTask -TaskName $probe -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $stamp -Force -ErrorAction SilentlyContinue
|
||||
# the real task, read only: its form today (0.3.23 registers Interactive + Highest; 0.3.24's rights step makes it S4U + Highest)
|
||||
$h = Get-ScheduledTask -TaskName 'Igneum Power Helper' -ErrorAction SilentlyContinue
|
||||
if ($h) { $hi = Get-ScheduledTaskInfo -TaskName 'Igneum Power Helper'; Write-Output ('RESULT helper_task LogonType ' + $h.Principal.LogonType + ' RunLevel ' + $h.Principal.RunLevel + ' action "' + $h.Actions[0].Execute + ' ' + $h.Actions[0].Arguments + '" state ' + $h.State + ' LastTaskResult 0x' + ('{0:X8}' -f $hi.LastTaskResult) + ' LastRunTime ' + $hi.LastRunTime) } else { Write-Output 'RESULT helper_task not_registered' }
|
||||
Say 'done'
|
||||
Loading…
Reference in a new issue