fix: persist state under plugin-state/ (runner de-privilege)
CI / exporter (push) Successful in 12s
CI / build (push) Successful in 23s
CI / publish (push) Successful in 19s

The managed runner is de-privileged on Windows now (runs as LocalService),
and %ProgramData%\punktfunk is locked read-only to it — so writing config/
cache straight under the config dir fails EPERM. Move the plugin's state to
<config_dir>/plugin-state/playnite, which `punktfunk-host plugins enable`
grants the runner write on. On Linux the runner owns the config dir, so the
path is writable there too. First release, so no migration needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 00:15:14 +02:00
parent 9988729aab
commit 1eb0f650ab
2 changed files with 49 additions and 3 deletions
+13 -3
View File
@@ -1,6 +1,6 @@
// Where the plugin's own files live. The host config-dir resolution mirrors the SDK's `configDir`
// (`@punktfunk/host` does not re-export it) so we always land in the same place the host and runner
// use; the plugin owns a `playnite/` subtree under it, created 0700.
// use; the plugin owns a `plugin-state/playnite/` subtree under it, created 0700.
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -19,8 +19,18 @@ export const hostConfigDir = (): string => {
return path.join(base, "punktfunk");
};
/** This plugin's private directory: `<config_dir>/playnite`. */
export const pluginDir = (): string => path.join(hostConfigDir(), "playnite");
/**
* This plugin's private directory: `<config_dir>/plugin-state/playnite`.
*
* The state lives under `plugin-state/`, not straight under the config dir, because the managed
* runner is de-privileged on Windows (`NT AUTHORITY\LocalService`) and the config dir is locked
* read-only there — `punktfunk-host plugins enable` grants the runner write on exactly
* `plugin-state`. (Mirrors `@punktfunk/host`'s `pluginStateDir`; reimplemented locally like
* `hostConfigDir`, since we don't gate on an SDK version bump.) On Linux the runner owns the config
* dir, so the same path is writable with no special step.
*/
export const pluginDir = (): string =>
path.join(hostConfigDir(), "plugin-state", "playnite");
/** `<config_dir>/playnite/config.json` — operator-editable, atomic-written. */
export const configPath = (): string => path.join(pluginDir(), "config.json");
+36
View File
@@ -0,0 +1,36 @@
// State persistence lands under `<config_dir>/plugin-state/playnite` — the one dir the
// de-privileged Windows runner (LocalService) may write. A regression here (writing straight under
// the config dir) would EPERM under the runner and lose the operator's config.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { loadConfig, saveRawConfig } from "../src/state.js";
let root: string;
let saved: string | undefined;
beforeEach(() => {
saved = process.env.PUNKTFUNK_CONFIG_DIR;
root = fs.mkdtempSync(path.join(os.tmpdir(), "pn-state-"));
process.env.PUNKTFUNK_CONFIG_DIR = root;
});
afterEach(() => {
if (saved === undefined) delete process.env.PUNKTFUNK_CONFIG_DIR;
else process.env.PUNKTFUNK_CONFIG_DIR = saved;
fs.rmSync(root, { recursive: true, force: true });
});
describe("state location", () => {
test("persists under plugin-state/playnite and round-trips", () => {
saveRawConfig({ playniteDir: "/games/playnite" });
expect(
fs.existsSync(path.join(root, "plugin-state", "playnite", "config.json")),
).toBe(true);
// Not written straight under the config dir (the LocalService-unwritable location).
expect(fs.existsSync(path.join(root, "playnite", "config.json"))).toBe(
false,
);
expect(loadConfig().playniteDir).toBe("/games/playnite");
});
});