Tier 1 (SessionStart-hook) kjører deteksjon kun når en Claude-sesjon starter.
Tier 2 installerer en lokal launchd LaunchAgent som fyrer SAMME Claude-frie
entrypoint daglig (kl. 03:00), uavhengig av sesjoner = ekte bakgrunn mellom
sesjoner. ToS-trygt: entrypointet kan strukturelt ikke invokere claude.
Søk-først-verifisert (2026-06): launchd > cron (Apple-anbefalt; cron krever
Full Disk Access); StartCalendarInterval fanger opp etter dvale; launchd har
minimalt PATH + ekspanderer ikke ~; process.execPath = Cellar-sti som dør ved
`brew upgrade node` → plistens PATH inkluderer /usr/local/bin.
Levert (TDD, test FØR kode):
- lib/launchd-plist.mjs (REN, SKRIVER ALDRI): renderPlist (XML-escape,
StartCalendarInterval, PATH-fix), defaultLabel, resolvePaths.
- scheduler.mjs (TYNN CLI): install|uninstall|status|run-now|print|run.
Strukturelt Claude-fri (kun process.execPath på run-detection.mjs +
launchctl; kilde-grep-testet). run gater på os_scheduler_cadence →
delegerer til run-detection.mjs. uid-0-nekt; idempotent bootout.
- detection-schedule.mjs (additiv, Tier-1 urørt): os_scheduler_cadence
('daily' default | 'interval') + daysSinceLastPoll ren helper.
- commands/kb-update.md: Tier-1/Tier-2-doc + caveats (uninstall=pause;
re-install etter node-upgrade).
Operatør-valg: daglig default, kadens egen innstilling (settes i onboarding/C2).
Verifisert: launchd-plist 13/13 · detection-schedule 21/21 · kb-update 173 ·
kb-eval 100 · validate 239/0 · test-hooks 6/6 · plutil -lint OK · live
install→status→idempotent→uninstall på maskinen (gui/501, rent).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
248 lines
12 KiB
JavaScript
248 lines
12 KiB
JavaScript
// 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, /<!DOCTYPE plist PUBLIC/);
|
|
assert.match(p, /<plist version="1\.0">/);
|
|
for (const key of [
|
|
'Label',
|
|
'ProgramArguments',
|
|
'StartCalendarInterval',
|
|
'EnvironmentVariables',
|
|
'RunAtLoad',
|
|
'StandardOutPath',
|
|
'StandardErrorPath',
|
|
'WorkingDirectory',
|
|
]) {
|
|
assert.match(p, new RegExp(`<key>${key}</key>`), `missing <key>${key}`);
|
|
}
|
|
// Daily schedule => StartCalendarInterval carries Hour (+ Minute); NOT StartInterval.
|
|
assert.match(p, /<key>StartCalendarInterval<\/key>\s*<dict>[\s\S]*<key>Hour<\/key>\s*<integer>3<\/integer>/);
|
|
assert.match(p, /<key>Minute<\/key>\s*<integer>0<\/integer>/);
|
|
assert.doesNotMatch(p, /<key>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, /<key>RunAtLoad<\/key>\s*<false\/>/);
|
|
});
|
|
|
|
test('renderPlist — Label and log paths are wired through', () => {
|
|
const p = samplePlist();
|
|
assert.match(p, /<key>Label<\/key>\s*<string>com\.ktg\.ms-ai-architect\.detection<\/string>/);
|
|
assert.match(p, /<key>StandardOutPath<\/key>\s*<string>\/x\/plugin\/scripts\/kb-update\/data\/detection-scheduler\.log<\/string>/);
|
|
assert.match(p, /<key>StandardErrorPath<\/key>\s*<string>\/x\/plugin\/scripts\/kb-update\/data\/detection-scheduler\.log<\/string>/);
|
|
assert.match(p, /<key>WorkingDirectory<\/key>\s*<string>\/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(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
assert.ok(m, 'ProgramArguments array present');
|
|
const argStrings = [...m[1].matchAll(/<string>([^<]*)<\/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(/<key>EnvironmentVariables<\/key>\s*<dict>\s*<key>PATH<\/key>\s*<string>([^<]*)<\/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&b<c>d'), '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, /<string>\/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, /<plist version="1\.0">/, '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');
|
|
});
|