fix(plugins/windows): the runner can read its own bundle, so it actually starts
ci / rust (push) Canceled after 27s
ci / rust-arm64 (push) Canceled after 28s
ci / web (push) Canceled after 28s
ci / docs-site (push) Canceled after 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 4m45s
android / android (push) Successful in 4m58s
deb / build-publish-host (push) Successful in 4m14s
deb / build-publish (push) Successful in 5m51s
deb / build-publish-client-arm64 (push) Successful in 1m19s
arch / build-publish (push) Failing after 6m54s
windows-host / package (push) Failing after 10m59s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
apple / screenshots (push) Canceled after 14m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m33s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 17:23:16 +02:00
co-authored by Claude Fable 5
parent 4a540bddc8
commit 3b1485e2a1
+48
View File
@@ -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<std::path::PathBuf> {
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