feat(security): give the de-privileged runner a writable plugin-state dir

The LocalService runner cannot write anywhere under %ProgramData%\punktfunk
(the config dir is Users-read-only), so a state-writing plugin's saveCache /
config-edit / first-run mkdir all fail EPERM — proven on-glass (rom-manager
only looked fine because its state dir was pre-created by an admin run and a
0-title reconcile skipped the write).

Add the one writable grant the model was missing, keeping the split crisp —
code dirs RX+WA, secrets R, and now a dedicated state root RW:

- plugins enable / build-scripting.ps1: create %ProgramData%\punktfunk  plugin-state and grant LocalService (OI)(CI)(M); disable revokes. Users stay
  read-only, so another non-admin still can't tamper with a plugin's launch
  templates.
- SDK: export pluginStateDir(name) -> <config_dir>/plugin-state/<name>. Same
  path on Linux (the systemd --user runner owns the config dir, writable with
  no grant), so plugins use one branch-free helper.

Plugins must persist under pluginStateDir(), not straight under the config dir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 00:45:27 +02:00
co-authored by Claude Fable 5
parent 5cd6e8f572
commit 73ec6ed9ef
6 changed files with 117 additions and 1 deletions
+21
View File
@@ -155,6 +155,27 @@ export default definePlugin({
In v1 a plugin is a script you run (see below); the managed runner package is a later step.
### Persisting state — `pluginStateDir`
A plugin that keeps config or a cache must write it under `pluginStateDir("<your-name>")`, **not**
directly under the config dir:
```ts
import { pluginStateDir } from "@punktfunk/host";
import * as fs from "node:fs";
import * as path from "node:path";
const dir = pluginStateDir("rom-manager"); // <config_dir>/plugin-state/rom-manager
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "cache.json"), data);
```
This matters on Windows: the managed runner is de-privileged (`NT AUTHORITY\LocalService`) and the
config dir is locked read-only, so a write straight under it fails with `EPERM`. `punktfunk-host
plugins enable` grants the runner write on exactly `plugin-state` — the config dir and your plugin's
*code* stay read-only. On Linux the runner owns the whole config dir, so the same path is writable
with no special step.
### A plugin UI in the console — `servePluginUi`
A plugin can surface a web UI **inside the punktfunk console** — no second password or port for the
+20
View File
@@ -52,6 +52,26 @@ export const configDir = (): string => {
return path.join(base, "punktfunk");
};
/**
* The writable state directory a plugin should persist its config/cache into:
* `<config_dir>/plugin-state[/<name>]`.
*
* WHY this and not `<config_dir>/<name>` directly: on Windows the managed runner is de-privileged
* (runs as `NT AUTHORITY\LocalService`), and the config dir is locked to Users-read — so a plugin
* writing straight under it fails with EPERM. `punktfunk-host plugins enable` grants the runner
* **Modify** on exactly `plugin-state` (the config dir and the plugin *code* stay read-only), so
* this is the one place a supervised plugin can write. On Linux the runner is a `systemd --user`
* unit owning the whole config dir, so the path is writable there too — same code, no branch.
*
* `name` is a plugin's own kebab-case id; omit it for the shared root. The directory is NOT created
* here (the caller decides permissions/timing) — `fs.mkdirSync(pluginStateDir(name), {recursive:
* true})` from the runner inherits the granted ACL on Windows.
*/
export const pluginStateDir = (name?: string): string => {
const root = path.join(configDir(), "plugin-state");
return name ? path.join(root, name) : root;
};
const readIfExists = (p: string): string | undefined => {
try {
return fs.readFileSync(p, "utf8");
+2
View File
@@ -30,6 +30,8 @@ import {
export type { HostApi } from "./api.js";
export { HttpStatusError } from "./core.js";
export type { ConnectOptions } from "./config.js";
// A plugin persists its state here — the one dir the de-privileged Windows runner may write.
export { pluginStateDir } from "./config.js";
export {
type PluginUiHandle,
type PluginUiOptions,
+29
View File
@@ -0,0 +1,29 @@
// Connection/config resolution helpers. `pluginStateDir` is the writable location a supervised
// plugin persists into — the one dir the de-privileged Windows runner may write.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as path from "node:path";
import { pluginStateDir } from "../src/config.js";
describe("pluginStateDir", () => {
let saved: string | undefined;
beforeEach(() => {
saved = process.env.PUNKTFUNK_CONFIG_DIR;
});
afterEach(() => {
if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = saved;
});
test("resolves <config_dir>/plugin-state[/name] and honors the config-dir override", () => {
process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg");
expect(pluginStateDir()).toBe(path.join("/tmp", "pf-cfg", "plugin-state"));
expect(pluginStateDir("rom-manager")).toBe(
path.join("/tmp", "pf-cfg", "plugin-state", "rom-manager"),
);
});
test("the per-plugin dir is nested under the shared root", () => {
process.env.PUNKTFUNK_CONFIG_DIR = path.join("/tmp", "pf-cfg2");
expect(pluginStateDir("x").startsWith(pluginStateDir())).toBe(true);
});
});