// tests/kb-update/test-launchd-plist.test.mjs // Spor C / C1 Tier 2 (Sesjon 24): local OS-scheduler (launchd) for the // Claude-FREE detection pipeline. // // Tier 1 (the SessionStart hook) only runs detection when a Claude session // STARTS. Tier 2 installs a launchd LaunchAgent that runs the SAME Claude-free // entrypoint on a fixed daily schedule, independent of sessions = real // background BETWEEN sessions. It stays OUTSIDE the ToS surface because the // scheduled entrypoint structurally cannot invoke `claude` (Consumer Terms // §3.7 targets automated access to Anthropic's services, not local scripts). // // This suite proves: // (A) renderPlist emits a well-formed LaunchAgent plist with the right keys, // (B) ProgramArguments are absolute; the plist never contains a `~`, // (C) EnvironmentVariables.PATH fixes launchd's minimal PATH (incl. the // upgrade-stable /usr/local/bin so a `brew upgrade node` can't orphan it), // (D) every interpolated value is XML-escaped (a plist is XML), // (E) resolvePaths puts the agent in ~/Library/LaunchAgents and the log in // the gitignored data/ dir, // (F) launchd-plist.mjs is PURE (imports no fs/child_process — never writes), // (G) the STRUCTURAL ToS guarantee: scheduler.mjs is Claude-free — it only // ever spawns node (via process.execPath) on run-detection.mjs and // launchctl, never `claude`, // (H) scheduler.mjs --dry-run prints a plist and writes nothing, // (I) install refuses to run as root (sudo would target the wrong domain). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import { renderPlist, defaultLabel, resolvePaths, xmlEscape, } from '../../scripts/kb-update/lib/launchd-plist.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); // Generic fixtures — deliberately not the real machine paths, so the assertions // exercise the logic, not this host. nodePath dirname is what PATH prepends. const NODE = '/opt/node/v1/bin/node'; const SCRIPT = '/x/plugin/scripts/kb-update/scheduler.mjs'; const LOG = '/x/plugin/scripts/kb-update/data/detection-scheduler.log'; const WORKDIR = '/x/plugin'; function samplePlist(overrides = {}) { return renderPlist({ label: 'com.ktg.ms-ai-architect.detection', nodePath: NODE, scriptPath: SCRIPT, args: ['run'], hour: 3, minute: 0, logPath: LOG, workingDir: WORKDIR, ...overrides, }); } // =========================================================================== // (A) renderPlist — well-formed LaunchAgent with the required keys // =========================================================================== test('renderPlist — emits a valid plist with all required keys', () => { const p = samplePlist(); assert.match(p, /^<\?xml version="1\.0" encoding="UTF-8"\?>/); assert.match(p, //); for (const key of [ 'Label', 'ProgramArguments', 'StartCalendarInterval', 'EnvironmentVariables', 'RunAtLoad', 'StandardOutPath', 'StandardErrorPath', 'WorkingDirectory', ]) { assert.match(p, new RegExp(`${key}`), `missing ${key}`); } // Daily schedule => StartCalendarInterval carries Hour (+ Minute); NOT StartInterval. assert.match(p, /StartCalendarInterval<\/key>\s*[\s\S]*Hour<\/key>\s*3<\/integer>/); assert.match(p, /Minute<\/key>\s*0<\/integer>/); assert.doesNotMatch(p, /StartInterval<\/key>/, 'StartInterval skips on sleep — must not be used'); // RunAtLoad false: first run happens at the next scheduled fire, not at install. assert.match(p, /RunAtLoad<\/key>\s*/); }); test('renderPlist — Label and log paths are wired through', () => { const p = samplePlist(); assert.match(p, /Label<\/key>\s*com\.ktg\.ms-ai-architect\.detection<\/string>/); assert.match(p, /StandardOutPath<\/key>\s*\/x\/plugin\/scripts\/kb-update\/data\/detection-scheduler\.log<\/string>/); assert.match(p, /StandardErrorPath<\/key>\s*\/x\/plugin\/scripts\/kb-update\/data\/detection-scheduler\.log<\/string>/); assert.match(p, /WorkingDirectory<\/key>\s*\/x\/plugin<\/string>/); }); // =========================================================================== // (B) ProgramArguments absolute; no `~` anywhere (launchd does not expand ~) // =========================================================================== test('renderPlist — ProgramArguments are [nodePath, scriptPath, ...args], all absolute', () => { const p = samplePlist(); const m = p.match(/ProgramArguments<\/key>\s*([\s\S]*?)<\/array>/); assert.ok(m, 'ProgramArguments array present'); const argStrings = [...m[1].matchAll(/([^<]*)<\/string>/g)].map((x) => x[1]); assert.deepEqual(argStrings, [NODE, SCRIPT, 'run']); // node + script are absolute paths assert.ok(argStrings[0].startsWith('/'), 'nodePath absolute'); assert.ok(argStrings[1].startsWith('/'), 'scriptPath absolute'); }); test('renderPlist — never contains a `~` (launchd does not expand it)', () => { const p = samplePlist({ workingDir: '/Users/me/plugin', logPath: '/Users/me/plugin/x.log' }); assert.ok(!p.includes('~'), 'no tilde may appear in a launchd plist'); }); // =========================================================================== // (C) EnvironmentVariables.PATH — fixes launchd's minimal PATH // =========================================================================== test('renderPlist — PATH includes dirname(node), /usr/local/bin, and /usr/bin', () => { const p = samplePlist(); const m = p.match(/EnvironmentVariables<\/key>\s*\s*PATH<\/key>\s*([^<]*)<\/string>/); assert.ok(m, 'EnvironmentVariables.PATH present'); const pathVal = m[1]; const parts = pathVal.split(':'); // dirname(nodePath) so run-detection's child `node` spawns resolve... assert.ok(parts.includes('/opt/node/v1/bin'), 'PATH includes dirname(nodePath)'); // ...plus the upgrade-stable Homebrew symlink dir (survives `brew upgrade node`)... assert.ok(parts.includes('/usr/local/bin'), 'PATH includes /usr/local/bin'); // ...plus the system minimum. assert.ok(parts.includes('/usr/bin'), 'PATH includes /usr/bin'); assert.ok(parts.includes('/bin'), 'PATH includes /bin'); }); // =========================================================================== // (D) XML-escaping — a plist is XML // =========================================================================== test('xmlEscape — escapes &, <, > (and nothing else surprising)', () => { assert.equal(xmlEscape('a&bd'), 'a&b<c>d'); assert.equal(xmlEscape('/plain/path'), '/plain/path'); }); test('renderPlist — escapes a path containing & (no raw ampersand in output)', () => { const p = samplePlist({ workingDir: '/x/a&b/plugin' }); assert.match(p, /\/x\/a&b\/plugin<\/string>/); // No raw `&` that is not part of an entity: assert.doesNotMatch(p, /&(?!amp;|lt;|gt;|quot;|apos;)/, 'no unescaped ampersand'); }); // =========================================================================== // (E) resolvePaths — agent in ~/Library/LaunchAgents, log in gitignored data/ // =========================================================================== test('defaultLabel — stable reverse-DNS label', () => { assert.equal(defaultLabel(), 'com.ktg.ms-ai-architect.detection'); }); test('resolvePaths — agent plist under ~/Library/LaunchAgents, script + log under plugin', () => { const r = resolvePaths({ pluginRoot: '/x/plugin', homeDir: '/home/me', nodePath: NODE }); assert.equal(r.label, 'com.ktg.ms-ai-architect.detection'); assert.equal(r.agentPlistPath, '/home/me/Library/LaunchAgents/com.ktg.ms-ai-architect.detection.plist'); assert.equal(r.scriptPath, '/x/plugin/scripts/kb-update/scheduler.mjs'); assert.equal(r.logPath, '/x/plugin/scripts/kb-update/data/detection-scheduler.log'); assert.ok(r.logPath.endsWith('.log')); assert.match(r.logPath, /scripts\/kb-update\/data\//); assert.equal(r.workingDir, '/x/plugin'); // all absolute, no tilde for (const v of [r.agentPlistPath, r.scriptPath, r.logPath, r.workingDir]) { assert.ok(v.startsWith('/'), `${v} must be absolute`); assert.ok(!v.includes('~'), `${v} must not contain ~`); } }); // =========================================================================== // (F) launchd-plist.mjs is PURE — imports no fs/child_process; never writes // =========================================================================== test('launchd-plist.mjs source — pure: imports only node:path/node:url, never writes', () => { const src = readFileSync(join(PLUGIN_ROOT, 'scripts', 'kb-update', 'lib', 'launchd-plist.mjs'), 'utf8'); assert.doesNotMatch(src, /from\s+['"]node:fs['"]/, 'must not import node:fs'); assert.doesNotMatch(src, /from\s+['"]node:child_process['"]/, 'must not import child_process'); assert.doesNotMatch(src, /atomic-write|writeFileSync|saveRegistry/, 'must not reference write utilities'); }); // =========================================================================== // (G) STRUCTURAL ToS GUARANTEE — scheduler.mjs is Claude-free // =========================================================================== test('scheduler.mjs source — Claude-free; only spawns node + launchctl, delegates to run-detection.mjs', () => { const src = readFileSync(join(PLUGIN_ROOT, 'scripts', 'kb-update', 'scheduler.mjs'), 'utf8'); // Strip comments first so the Claude-free CONTRACT can be documented in prose // (the guarantee is "no claude INVOCATION", not "the word never appears"). const code = src.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); assert.doesNotMatch(code, /(execFileSync|spawnSync|spawn|exec|execSync)\(\s*['"`]claude['"`]/i, 'never spawns claude'); assert.doesNotMatch(code, /['"`]claude['"`]/i, 'no claude binary referenced in code'); assert.doesNotMatch(code, /anthropic/i, 'code must not call Anthropic'); // It delegates the actual detection to the Claude-free entrypoint... assert.match(code, /run-detection\.mjs/, 'delegates detection to run-detection.mjs'); // ...via the running node binary (process.execPath — no PATH dependency)... assert.match(code, /process\.execPath/, 'delegates via process.execPath'); // ...and every QUOTED child-process target is on the allow-list (launchctl only; // the node delegation uses the unquoted process.execPath). const quotedTargets = [...code.matchAll(/(?:execFileSync|spawnSync|spawn|execSync)\(\s*(['"`])([^'"`]+)\1/g)].map((m) => m[2]); for (const t of quotedTargets) { assert.ok(t === 'launchctl', `unexpected quoted spawn target: ${t}`); } }); // =========================================================================== // (H) --dry-run prints a plist and writes NOTHING // =========================================================================== test('scheduler.mjs --dry-run — prints a plist, writes no agent file', () => { const home = mkdtempSync(join(tmpdir(), 'sched-home-')); try { const out = execFileSync( 'node', [join(PLUGIN_ROOT, 'scripts', 'kb-update', 'scheduler.mjs'), '--dry-run'], { env: { ...process.env, HOME: home }, encoding: 'utf8' }, ); assert.match(out, //, 'dry-run prints the plist'); assert.match(out, /com\.ktg\.ms-ai-architect\.detection/, 'dry-run prints our label'); // The decisive proof: dry-run touched nothing under the temp HOME. assert.ok( !existsSync(join(home, 'Library', 'LaunchAgents')), 'dry-run must not create the LaunchAgent', ); } finally { rmSync(home, { recursive: true, force: true }); } }); // =========================================================================== // (I) install refuses to run as root (sudo targets the wrong gui domain) // =========================================================================== test('scheduler.mjs source — install refuses uid 0 (no sudo)', () => { const src = readFileSync(join(PLUGIN_ROOT, 'scripts', 'kb-update', 'scheduler.mjs'), 'utf8'); assert.match(src, /getuid/, 'checks process.getuid()'); assert.match(src, /===\s*0|!==\s*0|getuid\(\)\s*\?/, 'has a uid===0 refusal branch'); });