// The managed script/plugin runner (RFC §8, M5) — what the `punktfunk-scripting` package runs: // discover the operator's units, supervise them as Effect fibers, shut down structurally. // // Units: // - **Plugins** — a file whose default export is a [`PluginDef`] (`definePlugin`), from the // scripts dir or an installed `punktfunk-plugin-*` package. Supervised: a failure restarts // it with capped exponential backoff; a clean return completes it. The Effect `main` shape // runs with the `PunktfunkHost` layer provided and is interrupted STRUCTURALLY on shutdown // (scoped finalizers run — release the preset, deregister cleanly); the async-fn shape gets // a connected facade client whose close is guaranteed by the same scope. // - **Bare scripts** — any other `.ts`/`.js` file in the scripts dir: importing it IS the run // (top-level await). One-shot: completion logs, failure logs — no restart (a bare script's // background work is invisible to supervision; export a plugin to be supervised). // // Trust model (RFC §9.4): a unit is code the operator chose to run — no sandbox is pretended. // The same sshd rule as hooks applies on BOTH platforms: a unit file a non-privileged principal // could have written is refused loudly (mode bits on Unix, owner + DACL on Windows). import { Cause, Duration, Effect, Schedule, } from "effect"; import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { pathToFileURL } from "node:url"; import { layer as hostLayer, PunktfunkHost } from "./client.js"; import { type ConnectOptions, configDir } from "./config.js"; import { connect, type PluginDef } from "./index.js"; export interface RunnerOptions { /** Where loose scripts live. Default `/scripts`. */ scriptsDir?: string; /** * Where plugin packages are installed (`/node_modules/punktfunk-plugin-*`, * i.e. the operator runs `bun add punktfunk-plugin-x` there). Default `/plugins`. */ pluginsDir?: string; /** Connection overrides handed to every unit's client/layer. */ connect?: ConnectOptions; /** Restart backoff base (test seam). Default 1 s, capped at 60 s, jittered. */ restartBase?: Duration.Input; /** Line sink. Default: stamped stdout. */ log?: (line: string) => void; } export interface Unit { /** Display name: the file stem, or the plugin package name. */ name: string; /** Absolute path of the module to import. */ file: string; } const defaultLog = (line: string) => console.log(`${new Date().toISOString()} ${line}`); // ---- unit-file trust (the sshd rule, both halves) --------------------------------------------- // SDDL access-mask bits that let a principal change the file's content or its protection: // write/append data + EAs, DELETE (delete + recreate), WRITE_DAC / WRITE_OWNER (rewrite the // protection itself), and the generic write/all bits. FILE_WRITE_ATTRIBUTES (0x100) is // deliberately NOT here: it only toggles timestamps/readonly/hidden — never content — and the // runner's own service principal legitimately holds it (bun's module loader opens unit files // requesting RX+WA; `plugins enable` grants exactly that on the plugins/scripts dirs — counting // WA as tampering would make the runner refuse every unit it is supposed to run). const SDDL_WRITE_BITS = 0x2 | 0x4 | 0x10 | 0x10000 | 0x40000 | 0x80000 | 0x10000000 | 0x40000000; // SDDL two-letter rights tokens → access-mask bits (generic, standard, file-specific, and the // low object-rights aliases hex masks sometimes render as). Anything unrecognized is treated as // write-capable — fail closed. const SDDL_RIGHT_TOKENS: Record = { GA: 0x10000000, GX: 0x20000000, GW: 0x40000000, GR: 0x80000000, RC: 0x20000, SD: 0x10000, WD: 0x40000, WO: 0x80000, FA: 0x1f01ff, FR: 0x120089, FW: 0x120116, FX: 0x1200a0, CC: 0x1, DC: 0x2, LC: 0x4, SW: 0x8, RP: 0x10, WP: 0x20, DT: 0x40, LO: 0x80, CR: 0x100, }; // SDDL two-letter account abbreviations we may meet on a unit file, → full SIDs. const SDDL_SID_ABBREV: Record = { SY: "S-1-5-18", // NT AUTHORITY\SYSTEM BA: "S-1-5-32-544", // BUILTIN\Administrators OW: "S-1-3-4", // OWNER RIGHTS CO: "S-1-3-0", // CREATOR OWNER LS: "S-1-5-19", // NT AUTHORITY\LOCAL SERVICE NS: "S-1-5-20", // NT AUTHORITY\NETWORK SERVICE BU: "S-1-5-32-545", // BUILTIN\Users AU: "S-1-5-11", // Authenticated Users IU: "S-1-5-4", // INTERACTIVE WD: "S-1-1-0", // Everyone }; const TRUSTED_OWNER_SIDS = new Set([ "S-1-5-18", // SYSTEM "S-1-5-32-544", // Administrators "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", // TrustedInstaller ]); /** An SDDL rights field as an access mask; -1 when it can't be fully understood (fail closed). */ const sddlRightsMask = (rights: string): number => { if (rights.startsWith("0x") || rights.startsWith("0X")) { const n = Number.parseInt(rights, 16); return Number.isNaN(n) ? -1 : n; } if (rights.length % 2 !== 0) return -1; let mask = 0; for (let i = 0; i < rights.length; i += 2) { const bits = SDDL_RIGHT_TOKENS[rights.slice(i, i + 2)]; if (bits === undefined) return -1; mask = (mask | bits) >>> 0; } return mask; }; /** * The Windows half of the sshd rule, as a pure function over the file's SDDL (exported for * tests). Returns `null` when the descriptor is trustworthy — owner is * SYSTEM/Administrators/TrustedInstaller (or `extraTrustedSid`, the account running the runner: * the Unix rule's "your own file is fine") and no other principal holds a write-capable allow * ACE — else a human-readable refusal reason. Unknown ACE shapes count as write-capable. */ export const windowsSddlUnsafeReason = ( sddl: string, extraTrustedSid?: string, ): string | null => { const trusted = new Set(TRUSTED_OWNER_SIDS); trusted.add("S-1-3-4"); // OWNER RIGHTS — constrained by the owner check below if (extraTrustedSid) trusted.add(extraTrustedSid); const owner = /^O:(S-[0-9-]+|[A-Z]{2})/.exec(sddl)?.[1]; const ownerSid = owner === undefined ? undefined : owner.startsWith("S-") ? owner : SDDL_SID_ABBREV[owner]; if (ownerSid === undefined || !trusted.has(ownerSid)) { return `owner ${owner ?? "unknown"} is not SYSTEM/Administrators/TrustedInstaller`; } const daclAt = sddl.indexOf("D:"); if (daclAt < 0) return "no DACL in the security descriptor"; // ACE format: (type;flags;rights;objectGuid;inheritGuid;sid[;condition]). SACL ACEs after // "S:" match the regex too but are audit types, filtered by the allow-type check. for (const [, ace] of sddl.slice(daclAt + 2).matchAll(/\(([^)]*)\)/g)) { const [type, flags = "", rights = "", , , sid = ""] = ace.split(";"); if (type !== "A" && type !== "XA") continue; // deny/audit ACEs only ever tighten // Inherit-only ACEs are templates for children; they grant nothing on this file. Flags // come in two-letter tokens — compare exactly, not by substring. const flagTokens: string[] = flags.match(/.{2}/g) ?? []; if (flagTokens.includes("IO")) continue; const mask = sddlRightsMask(rights); if (mask !== -1 && (mask & SDDL_WRITE_BITS) === 0) continue; // read-only ACE const resolved = sid.startsWith("S-") ? sid : (SDDL_SID_ABBREV[sid] ?? sid); if (!trusted.has(resolved)) { return `${sid} can write it (only SYSTEM/Administrators may)`; } } return null; }; /** The SID this process runs as, fetched once (`undefined` when it can't be determined). */ let processSidCache: string | undefined | false; const processSid = (): string | undefined => { if (processSidCache === undefined) { const res = spawnSync( windowsPowershell(), [ "-NoProfile", "-NonInteractive", "-Command", "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value", ], { encoding: "utf8", windowsHide: true, timeout: 15_000, env: windowsPowershellEnv(), }, ); const sid = res.status === 0 ? (res.stdout ?? "").trim() : ""; processSidCache = /^S-[0-9-]+$/.test(sid) ? sid : false; } return processSidCache === false ? undefined : processSidCache; }; // Full System32 path, never PATH — a planted powershell.exe must not run with our privileges // (mirrors the host CLI's powershell_path, security-review 2026-07-17). const windowsPowershell = (): string => path.join( process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe", ); // Spawn env for Windows PowerShell 5.1 with PSModulePath STRIPPED (5.1 rebuilds its default). // An inherited PSModulePath that includes a PowerShell 7 module dir (pwsh adds a machine-scope // entry) makes 5.1 fail to autoload Microsoft.PowerShell.Security — type-data conflict // ("AuditToString is already present") — so Get-Acl dies and every unit is refused. Seen live // on the .173 host box; intermittent per spawn, deterministic once stripped. const windowsPowershellEnv = (): Record => { const env: Record = { ...process.env }; delete env.PSModulePath; return env; }; /** Read a file's SDDL and apply [`windowsSddlUnsafeReason`]. Unreadable ACL ⇒ refuse. */ const windowsFileIsSafe = (file: string, log: (l: string) => void): boolean => { const escaped = file.replace(/'/g, "''"); const res = spawnSync( windowsPowershell(), [ "-NoProfile", "-NonInteractive", "-Command", `(Get-Acl -LiteralPath '${escaped}').Sddl`, ], { encoding: "utf8", windowsHide: true, timeout: 15_000, env: windowsPowershellEnv(), }, ); const sddl = res.status === 0 ? (res.stdout ?? "").trim() : ""; if (!sddl) { log(`[runner] REFUSING ${file} — could not read its ACL`); return false; } const reason = windowsSddlUnsafeReason(sddl, processSid()); if (reason !== null) { log( `[runner] REFUSING ${file} — ${reason}. Reinstall the plugin with ` + `\`punktfunk-host plugins add\`, or re-own the file to Administrators and strip ` + `non-admin write ACEs (icacls).`, ); return false; } return true; }; /** * The sshd rule (RFC §9.1/§9.4): refuse a unit file anyone less privileged than the operator * could have written — group/world-writable mode on Unix; on Windows, an owner outside * SYSTEM/Administrators/TrustedInstaller or a write-capable ACE for a non-admin principal. */ const fileIsSafe = (file: string, log: (l: string) => void): boolean => { if (process.platform === "win32") return windowsFileIsSafe(file, log); try { const mode = fs.statSync(file).mode & 0o022; if (mode !== 0) { log( `[runner] REFUSING ${file} — group/world-writable (chmod go-w it first)`, ); return false; } } catch { return false; } return true; }; const SCRIPT_EXTENSIONS = new Set([".ts", ".js", ".mjs", ".mts", ".cjs"]); /** Enumerate the operator's units: loose scripts plus installed plugin packages. */ export const discoverUnits = ( options: RunnerOptions = {}, log: (l: string) => void = options.log ?? defaultLog, ): Unit[] => { const units: Unit[] = []; const scriptsDir = options.scriptsDir ?? path.join(configDir(), "scripts"); const pluginsDir = options.pluginsDir ?? path.join(configDir(), "plugins"); try { for (const entry of fs.readdirSync(scriptsDir).sort()) { const file = path.join(scriptsDir, entry); if (!SCRIPT_EXTENSIONS.has(path.extname(entry))) continue; if (!fs.statSync(file).isFile()) continue; if (!fileIsSafe(file, log)) continue; units.push({ name: path.basename(entry, path.extname(entry)), file }); } } catch { // no scripts dir — fine } const modules = path.join(pluginsDir, "node_modules"); // The packages the operator actually installed — `bun add` records them as the plugins dir's // own `dependencies`. This is what separates a plugin from a plugin's LIBRARY: // `@punktfunk/plugin-kit` matches the `plugin-*` naming convention exactly but arrives as a // transitive dependency of every kit-built plugin, and running it as a unit is nonsense. // `undefined` only when there is no readable `package.json` at all — a hand-assembled tree then // falls back to the naming convention rather than discovering nothing. A package.json with no // `dependencies` key yields an EMPTY set, not `undefined`: `bun remove` drops the key when the // last plugin goes, and orphaned transitive packages can outlive it, so falling back there // would start running a plugin's library the moment you uninstall the last real plugin. let topLevel: Set | undefined; try { const root = JSON.parse( fs.readFileSync(path.join(pluginsDir, "package.json"), "utf8"), ) as { dependencies?: Record }; topLevel = new Set(Object.keys(root.dependencies ?? {})); } catch { // no package.json — fall back to the convention } // Read a plugin package's manifest (`module`/`main` entry) and add it as a unit. const addPlugin = (dir: string, name: string): void => { if (topLevel && !topLevel.has(name)) return; // a dependency, not an installed plugin try { const manifest = JSON.parse( fs.readFileSync(path.join(dir, "package.json"), "utf8"), ) as { main?: string; module?: string }; const rel = manifest.module ?? manifest.main ?? "index.js"; const file = path.join(dir, rel); if (!fileIsSafe(file, log)) return; units.push({ name, file }); } catch (e) { log(`[runner] skipping ${name}: unreadable package.json (${e})`); } }; try { for (const pkg of fs.readdirSync(modules).sort()) { // Unscoped convention: `punktfunk-plugin-*`. if (pkg.startsWith("punktfunk-plugin-")) { addPlugin(path.join(modules, pkg), pkg); continue; } // Scoped convention: `/plugin-*`. A scoped name resolves cleanly from a // registry scope-map, so a plugin can depend on `@punktfunk/host` + `effect` as shared // (hoisted) deps rather than bundling its own copy of each. // // ANY scope, not just `@punktfunk`: the plugin store requires catalog entries to be // scoped precisely so the scope can map to that entry's registry, so a third-party // plugin necessarily arrives as `@their-scope/plugin-*`. Limiting discovery to the // first-party scope would let such a plugin install and then never run. if (pkg.startsWith("@")) { try { for (const scoped of fs.readdirSync(path.join(modules, pkg)).sort()) { if (scoped.startsWith("plugin-")) { addPlugin(path.join(modules, pkg, scoped), `${pkg}/${scoped}`); } } } catch { // not a readable scope dir — fine } } } } catch { // no plugins dir — fine } return units; }; const isPluginDef = (v: unknown): v is PluginDef => typeof v === "object" && v !== null && typeof (v as PluginDef).name === "string" && (v as PluginDef).main !== undefined; /** One attempt at a unit: import (cache-busted per attempt) and run whatever it exports. */ const attemptUnit = ( unit: Unit, attempt: number, options: RunnerOptions, log: (l: string) => void, ): Effect.Effect<"plugin" | "script", unknown> => Effect.gen(function* () { const mod = (yield* Effect.tryPromise( () => import(`${pathToFileURL(unit.file).href}?attempt=${attempt}`), )) as { default?: unknown }; if (!isPluginDef(mod.default)) { return "script" as const; // the import WAS the run (top-level await) } const def = mod.default; if (Effect.isEffect(def.main)) { // The well-behaved shape: interruption reaches it structurally, its scoped // finalizers run on shutdown. yield* (def.main as Effect.Effect).pipe( Effect.provide(hostLayer(options.connect)), ); } else { // The simple shape: a facade client whose close is guaranteed by the scope — // on completion, failure, OR interruption (shutdown). const main = def.main as (pf: unknown) => Promise | unknown; yield* Effect.scoped( Effect.gen(function* () { const pf = yield* Effect.acquireRelease( Effect.tryPromise(() => connect(options.connect)), (client) => Effect.sync(() => client.close()), ); yield* Effect.tryPromise(async () => await main(pf)); }), ); } return "plugin" as const; }); /** * A unit under supervision: plugins restart on failure (capped exponential backoff, jittered); * a clean completion ends the unit; bare scripts are one-shot either way. Never fails the * runner — every outcome is logged. */ export const superviseUnit = ( unit: Unit, options: RunnerOptions = {}, ): Effect.Effect => { const log = options.log ?? defaultLog; // Exponential backoff, capped at 60 s (min-delay of the two schedules), then jittered. // (v4 replaced `Schedule.union` with the array-form `Schedule.min`.) const restart = Schedule.min([ Schedule.exponential(options.restartBase ?? "1 second"), Schedule.spaced("60 seconds"), ]).pipe(Schedule.jittered); let attempt = 0; const once = Effect.suspend(() => { attempt += 1; if (attempt > 1) log(`[${unit.name}] restarting (attempt ${attempt})`); return attemptUnit(unit, attempt, options, log); }); return once.pipe( Effect.tap((kind) => Effect.sync(() => log( kind === "script" ? `[${unit.name}] script completed` : `[${unit.name}] plugin completed`, ), ), ), Effect.tapCause((cause) => Effect.sync(() => log(`[${unit.name}] failed: ${Cause.pretty(cause).split("\n")[0]}`), ), ), Effect.retry(restart), Effect.catchCause((cause) => // A retry schedule that gives up (it doesn't, but stay total) — log and end. Effect.sync(() => log(`[${unit.name}] gave up: ${Cause.pretty(cause)}`)), ), Effect.asVoid, ); }; /** * The runner: discover units, supervise each as a fiber, run until interrupted — at which * point every unit is interrupted STRUCTURALLY (scoped finalizers run: facade clients close, * Effect plugins release what they acquired). */ export const runner = (options: RunnerOptions = {}): Effect.Effect => { const log = options.log ?? defaultLog; return Effect.scoped( Effect.gen(function* () { const units = discoverUnits(options, log); if (units.length === 0) { log( "[runner] nothing to run — add scripts to the scripts dir or install punktfunk-plugin-* packages", ); } for (const unit of units) { log(`[runner] starting ${unit.name} (${unit.file})`); yield* Effect.forkScoped(superviseUnit(unit, options)); } yield* Effect.never; // interruption (shutdown) collapses the scope → all units }), ); };