From 3b1485e2a18be91fbf0626ca6300240a5bafb7fb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 30 Jul 2026 16:52:38 +0200 Subject: [PATCH] fix(plugins/windows): the runner can read its own bundle, so it actually starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling the plugin runner did everything right and then nothing happened. The task went Ready with LastTaskResult 1 within a second of every start, and the console kept showing it enabled-but-not-running with nothing to explain why. Task Scheduler discards the action's output, so the reason never surfaced anywhere: error: EPERM reading "C:\Program Files\punktfunk\scripting\runner-cli.js" This file already knows why. RUNNER_UNIT_DIRS carries the note that bun opens the files it loads asking for FILE_WRITE_ATTRIBUTES on top of read, so a plain (RX) grant makes them die with EPERM — found on glass when the plugin and script directories were fixed. The runner's own entry script was missed. It lives in {app}\scripting, which carries only Users:(RX), and LocalService reaches that through Authenticated Users, so bun could never open the one file it was started to run. The runner has not been able to start since it moved off SYSTEM, which is a principal that has full control everywhere and so never met this. So {app}\scripting now gets the same (OI)(CI)(RX,WA) the unit dirs get, at enable, revoked at disable like every other grant here. WA moves timestamps and the read-only bit and cannot touch content, so the three-way split the module maintains still holds: code read-only, secrets read-only, only plugin-state writable. A plugin still cannot rewrite the runner. Measured on glass on .173, same box, same task, only the ACE differing: without it Ready/lastResult=1 and no runner process; with it Running, a live runner, and GET /store/runtime answering running:true — which is exactly what the console reads. Also checked what bun does with the access: nothing. It opens the entry for write and never writes, and the directory is byte-identical afterwards. Windows-only code, so it is rustfmt-clean and type-reviewed but NOT compile-verified here — the Windows CI job is the gate. Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/plugins.rs | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/punktfunk-host/src/plugins.rs b/crates/punktfunk-host/src/plugins.rs index 9e8e829c..3811aaa2 100644 --- a/crates/punktfunk-host/src/plugins.rs +++ b/crates/punktfunk-host/src/plugins.rs @@ -569,6 +569,43 @@ fn grant_runner_secret_reads() { ); } } + // The runner's OWN bundle, in the install dir rather than under the config dir. Same reason as + // RUNNER_UNIT_DIRS: bun opens the file it is asked to run requesting FILE_WRITE_ATTRIBUTES on + // top of read, and {app}\scripting only carries Users:(RX), which LocalService reaches through + // Authenticated Users. So `bun runner-cli.js` died with + // error: EPERM reading "C:\Program Files\punktfunk\scripting\runner-cli.js" + // the task exited 1 within a second of every start, and the console showed the runner as + // enabled-but-not-running with nothing to explain it. The unit dirs were given (RX,WA) when + // that behaviour was first found on-glass; the entry script itself was missed, so the runner + // could never start at all. Verified on glass: without this the task is Ready/lastResult=1, + // with it the task is Running and `GET /store/runtime` reports running:true. + // WA touches timestamps and the read-only bit, never content, so "code is read-only — a plugin + // cannot rewrite itself" still holds for the runner's own bundle. + if let Some(dir) = runner_bundle_dir() { + let ok = Command::new(icacls_path()) + .arg(&dir) + .args(["/grant:r", &format!("{LOCAL_SERVICE_SID}:(OI)(CI)(RX,WA)")]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()); + if !ok { + eprintln!( + "warning: could not grant LocalService read on {} - the plugin runner will not \ + start (bun exits EPERM on its own entry script)", + dir.display() + ); + } + } +} + +/// `{app}\scripting` — where the installer lays down `runner-cli.js` + `scripting-run.cmd` +/// (packaging/windows/punktfunk-host.iss), resolved from the running exe like +/// [`runner_command`] does. `None` when the exe path cannot be resolved; callers treat that as +/// "nothing to grant" rather than failing the whole enable. +#[cfg(target_os = "windows")] +fn runner_bundle_dir() -> Option { + Some(std::env::current_exe().ok()?.parent()?.join("scripting")) } /// Best-effort removal of the LocalService read grants when the runner is switched off — the @@ -606,6 +643,17 @@ fn revoke_runner_secret_reads() { .stderr(std::process::Stdio::null()) .status(); } + // …and the bundle dir in the install tree, which is not under `cfg` so the loop above misses + // it. Removing the explicit ACE leaves the inherited Users:(RX) from Program Files, so it + // reverts to plain read-only rather than losing access altogether. + if let Some(dir) = runner_bundle_dir().filter(|d| d.exists()) { + let _ = Command::new(icacls_path()) + .arg(&dir) + .args(["/remove:g", LOCAL_SERVICE_SID]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } } /// Resolve icacls by full System32 path rather than PATH — same planted-binary reasoning as