From 1eb0f650aba1250d8b523cbb029d6b6b01d5f97d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 20 Jul 2026 00:15:14 +0200 Subject: [PATCH] fix: persist state under plugin-state/ (runner de-privilege) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /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 --- src/paths.ts | 16 +++++++++++++--- test/state.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 test/state.test.ts diff --git a/src/paths.ts b/src/paths.ts index 6b31094..cc2f65d 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -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: `/playnite`. */ -export const pluginDir = (): string => path.join(hostConfigDir(), "playnite"); +/** + * This plugin's private directory: `/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"); /** `/playnite/config.json` — operator-editable, atomic-written. */ export const configPath = (): string => path.join(pluginDir(), "config.json"); diff --git a/test/state.test.ts b/test/state.test.ts new file mode 100644 index 0000000..052ef0c --- /dev/null +++ b/test/state.test.ts @@ -0,0 +1,36 @@ +// State persistence lands under `/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"); + }); +});