Commit Graph
6 Commits
Author SHA1 Message Date
enricobuehler 32cc8dd529 fix(runner): an SDK fix could never reach an installed plugin
ci / bun-nix (pull_request) Successful in 21s
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 3m5s
ci / rust (pull_request) Successful in 4m31s
nix / flake (pull_request) Failing after 11m38s
Publishing `@punktfunk/host@0.1.3` — the release that lets a library scanner register
`category`, so Lutris and Heroic stay out of the console nav — reached **no existing
install**. Measured on `.21`: the only thing that moved it was deleting `bun.lock` by
hand over ssh. A fix that needs an ssh session is not a fix.

**Why nothing reached it.** Every plugin resolves the SDK from the plugins tree, and
`bun.lock` pins it to an exact version with an integrity hash. Nothing in any
user-facing flow re-resolves that pin: installing a plugin, reinstalling it, and even
updating it to a newer release all leave the SDK alone, because the plugin's `^0.1.x`
range is already satisfied by what is locked. `bun update` does not help either — the
plugins are pinned exactly in the root manifest, so there is no direct dependency to
update through.

**Where the fix belongs.** The runner. It is bundled from this same `sdk/` at the
host's release commit (`packaging/arch/PKGBUILD` builds `src/runner-cli.ts` into the
punktfunk-scripting package), so `SDK_VERSION` is by construction the SDK matching the
host now on disk. A host upgrade is therefore the one moment that can carry an SDK fix
to already-installed plugins, and now it does — before any plugin loads, and with no
operator action at all.

**Why it re-resolves the whole lockfile** rather than pinning the SDK at the root: a
targeted `bun add @punktfunk/host@<v>` does NOT work while plugins declare the SDK in
their own `dependencies` (all six scanners do, though none import it). bun honours
their locked resolution and gives each a private nested copy that then SHADOWS the
root — measured, 5 nested copies, which is how I first "fixed" the box while leaving
every plugin still importing 0.1.2. A lockless resolve hoists one copy for everyone.
Once the plugins drop that spurious dependency this can become the targeted form.

Safety, because this runs unattended at boot on a tree the operator's plugins load
from: plugin versions are pinned exactly in the root manifest so a re-resolve cannot
move them (verified — lutris stays 0.1.0); the lockfile is backed up and restored if
the install fails or fails to deliver; and every failure is logged and swallowed, so a
dependency refresh can never stop working plugins from starting. The no-op path is the
one that runs on every healthy box, so it is tested first: same version, or no SDK at
all, touches nothing and logs nothing.

The SDK is bumped to 0.1.4 because its published content changed. Republishing 0.1.3
is impossible, and letting source drift from a published version is precisely the
defect that produced this whole chain — 0.1.2 was published before it forwarded
`category`, then the source changed underneath it without a bump. `version.test.ts`
fails if `SDK_VERSION` and `package.json` ever disagree.

Verified end to end on `.21` against a tree seeded from the operator's real pre-fix
backup: 0.1.2 → 0.1.3 automatically, one hoisted copy, no nested copies, plugin
versions preserved, and a second run is a silent no-op. SDK 79 tests pass (5 new),
typecheck clean.
2026-08-08 14:07:11 +02:00
enricobuehlerandClaude Opus 5 78926be4ac fix(store): a plugin install lands in the plugins dir, not the nearest package.json above it
ci / docs-site (push) Successful in 2m8s
ci / web (push) Successful in 1m15s
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 4m21s
apple / swift (push) Successful in 4m52s
android / android (push) Successful in 5m22s
deb / build-publish (push) Successful in 3m57s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 3m55s
ci / rust (push) Successful in 9m19s
arch / build-publish (push) Successful in 9m25s
windows-host / package (push) Successful in 14m3s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m14s
apple / screenshots (push) Successful in 20m44s
Installing any plugin from the console failed on a user's Fedora 44 host with

  plugin store job failed: @punktfunk/plugin-virtualhere is not present after install

while the same version installed fine on every box we tried. The difference turned
out to be a file in his home directory.

`bun add` does not install into its working directory. It walks UP to the nearest
ancestor `package.json` and installs into that tree. A fresh plugins dir has no
`package.json` — the store and the runner only write `bunfig.toml` — so any stray
one above it captures the install: the packages land in `~/node_modules`, the
dependency is written to `~/package.json`, bun prints "installed …" and exits 0,
and the plugins dir stays empty. The plugins-dir `bunfig.toml` is not read in that
case either, so the outcome splits on whether `@punktfunk` resolves in the hijacked
tree: with no scope mapping there it 404s against npmjs and exits 1 ("the plugin
runner exited with status 1"); with one, it succeeds into the wrong tree and the
job dies on the presence check instead. Only the second shape looks like a broken
store, which is why this took a reproduction to find.

Seed the plugins dir with a `package.json` so it owns bun's install root. Three
call sites rather than one: the store job, the `plugins add` CLI, and the SDK's
`ensurePluginsDir`. The runner ships as its own package and can predate the host
binary, so the host cannot delegate this — the same reason `ensure_bunfig_scope`
writes the registry mapping on this side rather than passing a runner flag.

Seeding only ever touches a tree with no `node_modules`. A dir with packages and
no `package.json` is the hand-assembled/older layout that `installed_packages`
deliberately falls back to the naming convention for, and dropping an empty
`dependencies` on it reports every plugin the operator runs as uninstalled —
already pinned by `an_emptied_dependency_list_means_nothing_is_installed`.

The residual failure stops dead-ending: if the presence check fails anyway, the
error names the capturing ancestor and what to do about it.

Reproduced and fixed on glass (Nobara 44 VM, host + runner 0.22.3): with a stray
~/package.json the store reproduces the field report byte for byte — same 150 ms,
same phase sequence, same message — and with the plugins dir seeded the same
install lands correctly with nothing leaking into the home dir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:48:25 +02:00
enricobuehlerandClaude Fable 5 45c3b96907 feat(store): plugin store host module — signed catalogs, tiered trust, install jobs
The index is the verification gate: a catalog entry pins one exact version
plus that version's tarball integrity hash, so 'verified on every release'
is a property of the data rather than a promise about process. Nothing can
express 'track latest' for a catalogued plugin.

- store/index.rs   signed index parse + ed25519 verify (ring), validate-and-drop
                   per entry, semver minHost/advisory ranges
- store/sources.rs built-in unom source (compiled-in URL + two key slots for
                   rotation) + operator sources in plugin-sources.json
- store/catalog.rs https fetch with size/timeout/redirect caps, signature before
                   parse, last-good disk cache (stale-but-usable when offline)
- store/jobs.rs    single-flight install/uninstall: registry-integrity preflight
                   against the pin, spawn the runner CLI with live log capture,
                   post-install version check with rollback, provenance record,
                   runner restart (discovery is startup-only)
- store/manifest.rs install provenance; absence means CLI-installed
- mgmt/store.rs    12 routes under /api/v1/store, denied to the plugin token
                   (a plugin that can install plugins is an escalation primitive)

Also generalizes runner discovery and listInstalled from @punktfunk/plugin-*
to ANY scope's plugin-*: catalog entries must be scoped so the scope can map
to that entry's registry, so a third-party plugin necessarily arrives under
its own scope and would otherwise install but never run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:48:02 +02:00
enricobuehlerandClaude Fable 5 7b7231fdbe feat(security): gate public-registry plugin installs + sandbox the runner unit
Supply chain: resolvePackage() used to pass any punktfunk-plugin-* or
foreign-scope name straight to bun add — a typo or a squatted look-alike
on npmjs.org would install operator-privileged code. Only the @punktfunk
scope (pinned to the Gitea registry by the bunfig scope map) resolves by
default now; anything else throws with an explanation unless
--allow-public-registry is passed, and even then installs print a loud
warning. Removal never gates — uninstalling stays safe regardless of
origin.

systemd (user unit): free hardening for well-behaved plugins —
NoNewPrivileges, PrivateTmp, ProtectSystem=strict with ReadWritePaths=%h
(plugin state and download dirs under $HOME keep working), and
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6. Plugins writing
outside $HOME, and distros that restrict unprivileged user namespaces
for user units, are handled via a documented systemctl --user edit
drop-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 00:45:27 +02:00
enricobuehlerandClaude Fable 5 84c47cd0a7 feat(security): run the Windows plugin runner as LocalService, not SYSTEM
The PunktfunkScripting scheduled task ran operator-installed plugin code
as SYSTEM with -RunLevel Highest — any plugin defect was a full
compromise of the box. The principal is now NT AUTHORITY\LocalService
(minimal privileges, no password to manage, exists at boot, loopback
networking works), registered without RunLevel:

- installer: New-ScheduledTaskPrincipal -UserId 'LocalService'
- plugins enable: converges the principal idempotently (migrating tasks
  an older installer or a dev box registered as SYSTEM) BEFORE starting,
  then grants LocalService read — via icacls, full-System32-path — on
  exactly the two SYSTEM/Admins-DACL'd files the runner's connect()
  needs: the scoped plugin-token and the TLS-pin cert.pem. Never
  mgmt-token. plugins disable revokes the grants; plugins status now
  prints the task principal so the migration is verifiable.
- build-scripting.ps1 mirrors the convergence + grants on dev deploys.

The usage text also mentions the new --allow-public-registry gate that
lands with the supply-chain commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 00:45:27 +02:00
enricobuehlerandClaude Fable 5 f0c511c8fa feat(plugins): punktfunk-host plugins CLI — add/remove/list/enable/disable/status
One-liner plugin management replacing the manual scripting-dir + bunfig +
bun-add ritual: package ops forward to the bun runner (new sdk plugins
module + runner-cli subcommands, 11 tests green), enable/disable/status
drive the systemd unit on Linux and the PunktfunkScripting scheduled task
on Windows (installer support in the ISS). Docs page rewritten as .mdx
with per-platform Tabs (registered in mdx.tsx).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 16:34:43 +02:00