feat(ms-ai-architect): Sesjon 24 — C1 Tier 2 lokal launchd-scheduler (deteksjon-only, ToS-trygt) [skip-docs]

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>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-21 19:59:18 +02:00
commit 071601bb9e
6 changed files with 624 additions and 5 deletions

View file

@ -42,17 +42,35 @@ Deteksjonen kan kjøre automatisk i bakgrunnen ved sesjonsstart — **av som def
```yaml
---
scheduled_detection:
enabled: true # default false — ingenting kjører før dette er true
enabled: true # default false — ingenting kjører før dette er true (Tier 1)
interval_days: 7 # kjør deteksjon på nytt når det er ≥ N dager siden sist poll
include_skill_lifecycle: true # ta med skill-livssyklus-deteksjon (overlapp/gap/bloat)
os_scheduler_cadence: daily # Tier 2: daily (poll hver dag) | interval (throttle på interval_days)
---
```
Når `enabled: true` og det er ≥ `interval_days` siden sist poll, spawner SessionStart-hooken `scripts/kb-update/run-detection.mjs` i bakgrunnen. Den kjører **kun deteksjon** (poll → rapport → discovery → skill-livssyklus) og skriver JSON-rapporter til `data/`**aldri** til `skills/`, og kaller **aldri** Claude. Ferske signaler surfaces ved neste sesjonsstart («KB: …» + «Skill-signaler: …»), og du kjører `/architect:kb-update` manuelt for å gjennomgå + apply-e gjennom gaten.
### Tier 1 — sesjonsforankret (SessionStart-hook)
**ToS-garanti (strukturell):** `run-detection.mjs` spawner kun `node` på de allow-listede deteksjons-scriptene; den kan ikke invokere `claude`. Apply (det eneste Claude-steget) forblir manuelt og in-session. Kjør `node scripts/kb-update/run-detection.mjs --dry-run` for å se stegene uten å kjøre noe.
Når `enabled: true` og det er ≥ `interval_days` siden sist poll, spawner SessionStart-hooken `scripts/kb-update/run-detection.mjs` i bakgrunnen. Den kjører **kun deteksjon** (poll → rapport → discovery → skill-livssyklus) og skriver JSON-rapporter til `data/`**aldri** til `skills/`, og kaller **aldri** Claude. Ferske signaler surfaces ved neste sesjonsstart («KB: …» + «Skill-signaler: …»), og du kjører `/architect:kb-update` manuelt for å gjennomgå + apply-e gjennom gaten. **Begrensning:** Tier 1 kjører bare når en Claude-sesjon *starter* — starter du aldri en sesjon, kjører deteksjonen aldri.
Vil du ha ekte bakgrunnskjøring mellom sesjoner (lokal launchd/cron som kjører samme `run-detection.mjs`), kommer det som Tier 2 — den er like ToS-trygg fordi entrypointet er Claude-fritt.
### Tier 2 — lokal launchd-scheduler (ekte bakgrunn mellom sesjoner)
For deteksjon som kjører *uavhengig av sesjoner*, installer en lokal **launchd LaunchAgent** som fyrer samme Claude-frie entrypoint daglig (kl. 03:00):
```bash
node scripts/kb-update/scheduler.mjs install # skriv plist til ~/Library/LaunchAgents + last agenten
node scripts/kb-update/scheduler.mjs status # er den lastet?
node scripts/kb-update/scheduler.mjs run-now # kjør én gang nå (verifiser uten å vente til 03:00)
node scripts/kb-update/scheduler.mjs print # vis plisten uten å skrive noe (alias --dry-run)
node scripts/kb-update/scheduler.mjs uninstall # avlast + fjern plist
```
- **Kadens (`os_scheduler_cadence`):** `daily` (default) poller hver dag; `interval` throttler til `interval_days` via samme staleness-gate som Tier 1. (Onboarding setter dette senere; daglig er fornuftig default — poll av Microsoft Learn-sitemaps er billig og read-only.)
- **Pause = uninstall.** LaunchAgenten kjører uansett `enabled:`-flagget (det styrer *kun* Tier 1/hooken). Vil du stoppe Tier 2, kjør `uninstall` — det er pause-knappen.
- **Etter en major `brew upgrade node`:** kjør `install` på nytt. Node-stien bakes inn ved install-tid (launchd har minimalt `PATH`); plistens `PATH` inkluderer `/usr/local/bin` så barneprosessenes `node` overlever, men det er ryddigst å re-installere.
- **launchd, ikke cron:** Apple anbefaler launchd; cron krever Full Disk Access og er «not recommended». Vil du heller bruke cron/systemd/CI, peker du den på samme `node scripts/kb-update/run-detection.mjs`.
**ToS-garanti (strukturell, begge tiers):** entrypointet (`run-detection.mjs`, og `scheduler.mjs run` som delegerer til det) spawner kun `node` på de allow-listede deteksjons-scriptene + `launchctl` for agent-styring; det kan ikke invokere `claude`. Apply (det eneste Claude-steget) forblir manuelt og in-session. Kjør `node scripts/kb-update/run-detection.mjs --dry-run` (eller `scheduler.mjs print`) for å se hva som ville kjørt uten å kjøre noe.
## Instruksjoner til assistenten
@ -174,6 +192,9 @@ Rapporter:
## Schedulering
Pluginen schedulerer **ingenting**. Hvis du vil ha periodisk kjøring, sett opp en cron-jobb / launchd-jobb / systemd timer / GitHub Actions-workflow som kjører `node scripts/kb-update/run-weekly-update.mjs --force --discover` (uten apply-fasen) og varsler deg om å kjøre `/architect:kb-update` i en interaktiv Claude Code-sesjon.
Pluginen schedulerer **ingenting før du selv aktiverer det** (alt er opt-in). Du har tre nivåer, alle Claude-frie i deteksjonsfasen:
- **Tier 1 (sesjonsstart):** `scheduled_detection.enabled: true` i `ms-ai-architect.local.md` — se «Scheduled deteksjon» over.
- **Tier 2 (lokal launchd, ekte bakgrunn):** `node scripts/kb-update/scheduler.mjs install` — se «Scheduled deteksjon» over.
- **Egen scheduler:** vil du heller bruke cron / systemd timer / CI, pek den på `node scripts/kb-update/run-detection.mjs` (eller `run-weekly-update.mjs --force --discover` for den eldre poll-flyten) og la den varsle deg om å kjøre `/architect:kb-update` i en interaktiv sesjon.
Apply-fasen (oppdatere filer + committe) kan ikke automatiseres innenfor denne pluginen — den krever LLM-resonnering på endringene og menneskelig vurdering, og er bevisst designet for kjøring fra en åpen Claude Code-sesjon.

View file

@ -19,6 +19,11 @@ export const DEFAULT_SCHEDULE_CONFIG = Object.freeze({
enabled: false,
interval_days: 7,
include_skill_lifecycle: true,
// Tier 2 (launchd) cadence. 'daily' = poll on every (daily) fire; 'interval'
// = throttle to interval_days via the same staleness gate Tier 1 uses. Read
// ONLY by the Tier-2 entrypoint (scheduler.mjs run); the Tier-1 hook ignores
// it, so existing behavior is unchanged. Operator default: daily.
os_scheduler_cadence: 'daily',
});
// The ordered, Claude-FREE detection pipeline. Every step is a local node
@ -45,6 +50,10 @@ function coerce(key, raw) {
const n = Number.parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : undefined;
}
if (key === 'os_scheduler_cadence') {
// Only 'interval' opts out of daily; any other/garbage value => default daily.
return v === 'interval' ? 'interval' : 'daily';
}
return undefined;
}
@ -112,6 +121,22 @@ export function shouldRunDetection(config, lastPollDaysAgo, opts = {}) {
return { run: false, reason: 'fresh (within interval)' };
}
/**
* Days since the last sitemap poll, read from a parsed change-report.json.
* Pure; mirrors the SessionStart hook's computation. Missing/invalid
* last_poll => Infinity ("infinitely stale" => the 'interval' cadence runs).
* @param {object|null} report parsed change-report.json ({ last_poll?: string })
* @param {number} now Date.now()
* @returns {number}
*/
export function daysSinceLastPoll(report, now) {
const DAY_MS = 24 * 60 * 60 * 1000;
if (!report || !report.last_poll) return Infinity;
const t = new Date(report.last_poll).getTime();
if (!Number.isFinite(t)) return Infinity;
return (now - t) / DAY_MS;
}
/**
* Build a compact one-liner summarizing skill-lifecycle signals for the
* SessionStart hook. Pure; tolerant of partial/missing reports. Returns null

View file

@ -0,0 +1,106 @@
// launchd-plist.mjs — Spor C / C1 Tier 2: pure renderer for the detection
// LaunchAgent. SKRIVER ALDRI / never writes — it only builds strings and
// resolves paths. The thin imperative side (write the file, run launchctl)
// lives in scheduler.mjs, mirroring the detection-schedule.mjs (pure core) +
// run-detection.mjs (thin executor) split.
//
// Why these specific plist choices (search-first verified, 2026-06):
// - StartCalendarInterval (NOT StartInterval): a calendar trigger runs the
// job on wake if the fire time was missed during sleep; StartInterval just
// skips it. Decisive for a laptop that sleeps.
// - EnvironmentVariables.PATH: launchd starts jobs with a MINIMAL PATH, so a
// bare `node` lookup (run-detection.mjs uses execFileSync('node', ...)) would
// fail. We inject dirname(nodePath) PLUS the upgrade-stable /usr/local/bin
// symlink dir, so a `brew upgrade node` (which moves the version-pinned
// Cellar path) can't orphan the agent.
// - Absolute paths only: launchd does not expand `~`.
//
// Pure: imports only node:path/node:url. No fs, no child_process.
import { dirname } from 'node:path';
const LABEL = 'com.ktg.ms-ai-architect.detection';
/** The system PATH floor launchd should always have, plus Homebrew's stable symlink dir. */
const STABLE_PATH = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
/** Escape a value for inclusion in a plist <string> (a plist is XML). */
export function xmlEscape(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/** The stable reverse-DNS label for the detection LaunchAgent. */
export function defaultLabel() {
return LABEL;
}
/**
* Render a LaunchAgent plist that runs `<nodePath> <scriptPath> <...args>`
* daily at hour:minute. Every interpolated value is XML-escaped; the schedule
* is StartCalendarInterval (sleep-safe); RunAtLoad is false (first run at the
* next scheduled fire, not at install).
* @param {{label:string,nodePath:string,scriptPath:string,args:string[],hour:number,minute:number,logPath:string,workingDir:string}} o
* @returns {string}
*/
export function renderPlist({ label, nodePath, scriptPath, args = [], hour, minute, logPath, workingDir }) {
// PATH: dirname(node) first (so child `node` spawns resolve), then the stable floor.
const pathValue = [dirname(nodePath), ...STABLE_PATH].join(':');
const programArgs = [nodePath, scriptPath, ...args]
.map((a) => ` <string>${xmlEscape(a)}</string>`)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${xmlEscape(label)}</string>
<key>ProgramArguments</key>
<array>
${programArgs}
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>${Number(hour)}</integer>
<key>Minute</key>
<integer>${Number(minute)}</integer>
</dict>
<key>RunAtLoad</key>
<false/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>${xmlEscape(pathValue)}</string>
</dict>
<key>WorkingDirectory</key>
<string>${xmlEscape(workingDir)}</string>
<key>StandardOutPath</key>
<string>${xmlEscape(logPath)}</string>
<key>StandardErrorPath</key>
<string>${xmlEscape(logPath)}</string>
</dict>
</plist>
`;
}
/**
* Resolve the absolute paths the installer needs. All absolute (no `~`).
* The LaunchAgent lives in ~/Library/LaunchAgents; the log lives in the
* gitignored scripts/kb-update/data/ dir; launchd runs scheduler.mjs (which
* gates on cadence, then delegates to the Claude-free run-detection.mjs).
* @param {{pluginRoot:string,homeDir:string,nodePath:string}} o
*/
export function resolvePaths({ pluginRoot, homeDir }) {
const label = LABEL;
return {
label,
agentPlistPath: `${homeDir}/Library/LaunchAgents/${label}.plist`,
scriptPath: `${pluginRoot}/scripts/kb-update/scheduler.mjs`,
logPath: `${pluginRoot}/scripts/kb-update/data/detection-scheduler.log`,
workingDir: pluginRoot,
};
}

View file

@ -0,0 +1,188 @@
#!/usr/bin/env node
// scheduler.mjs — Spor C / C1 Tier 2: install/manage the local launchd
// LaunchAgent that runs the Claude-FREE detection pipeline on a daily schedule,
// independent of Claude sessions = real background BETWEEN sessions.
//
// STRUCTURAL ToS GUARANTEE: this script only ever spawns the running node
// binary (process.execPath) on the allow-listed run-detection.mjs, and the
// `launchctl` CLI for agent management. It can NEVER invoke `claude`. The
// scheduled entrypoint (`run`) gates on cadence, then delegates to
// run-detection.mjs, which is itself structurally Claude-free. Apply (the only
// Claude step) stays manual + in-session + gated. See detection-schedule.mjs
// for the ToS rationale (Consumer Terms §3.7 targets Anthropic's services, not
// local scripts).
//
// node scripts/kb-update/scheduler.mjs install # write plist + load agent
// node scripts/kb-update/scheduler.mjs uninstall # unload + remove plist
// node scripts/kb-update/scheduler.mjs status # is it loaded?
// node scripts/kb-update/scheduler.mjs run-now # trigger one run now
// node scripts/kb-update/scheduler.mjs print # show the plist (alias --dry-run)
// node scripts/kb-update/scheduler.mjs run # (the launchd entrypoint)
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { existsSync, readFileSync, mkdirSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { renderPlist, resolvePaths, defaultLabel } from './lib/launchd-plist.mjs';
import {
loadScheduleConfig,
daysSinceLastPoll,
} from './lib/detection-schedule.mjs';
import { atomicWriteSync } from './lib/atomic-write.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PLUGIN_ROOT = join(__dirname, '..', '..');
const RUN_DETECTION = join(PLUGIN_ROOT, 'scripts', 'kb-update', 'run-detection.mjs');
const CHANGE_REPORT = join(PLUGIN_ROOT, 'scripts', 'kb-update', 'data', 'change-report.json');
// Daily fire time. Not configurable yet (onboarding/C2 may expose it).
const FIRE_HOUR = 3;
const FIRE_MINUTE = 0;
/** Build renderPlist inputs from the resolved paths + the running node. */
function planAgent() {
const nodePath = process.execPath;
const paths = resolvePaths({ pluginRoot: PLUGIN_ROOT, homeDir: homedir(), nodePath });
const plist = renderPlist({
label: paths.label,
nodePath,
scriptPath: paths.scriptPath,
args: ['run'],
hour: FIRE_HOUR,
minute: FIRE_MINUTE,
logPath: paths.logPath,
workingDir: paths.workingDir,
});
return { ...paths, nodePath, plist };
}
/** gui/<uid> domain + gui/<uid>/<label> service target. */
function targets() {
const uid = process.getuid();
return { domain: `gui/${uid}`, service: `gui/${uid}/${defaultLabel()}` };
}
/**
* Run launchctl. ignoreError swallows a non-zero exit (the idempotency
* primitive a pre-install bootout of a not-yet-loaded service is expected to
* fail); silent hides its output so that expected failure isn't alarming.
*/
function launchctl(args, { ignoreError = false, silent = false } = {}) {
try {
execFileSync('launchctl', args, { stdio: silent ? 'ignore' : 'inherit' });
return true;
} catch (err) {
if (ignoreError) return false;
throw err;
}
}
// --- Subcommands -----------------------------------------------------------
function printPlist() {
// Pure preview: render and print. Writes nothing, runs no launchctl.
process.stdout.write(planAgent().plist);
}
function install() {
if (typeof process.getuid === 'function' && process.getuid() === 0) {
console.error('Refusing to install as root — run as your logged-in user (no sudo).');
console.error('A LaunchAgent in the gui/<uid> domain must target your own session.');
process.exit(1);
}
const { agentPlistPath, plist, scriptPath, logPath } = planAgent();
const { domain, service } = targets();
mkdirSync(dirname(agentPlistPath), { recursive: true });
atomicWriteSync(agentPlistPath, plist);
// Idempotent: unload any previous instance (expected to fail+silent on first install).
launchctl(['bootout', service], { ignoreError: true, silent: true });
launchctl(['bootstrap', domain, agentPlistPath]);
console.log(`Installed LaunchAgent: ${agentPlistPath}`);
console.log(` fires daily at ${String(FIRE_HOUR).padStart(2, '0')}:${String(FIRE_MINUTE).padStart(2, '0')}${scriptPath} run`);
console.log(` log: ${logPath}`);
console.log('');
console.log('Verify now: node scripts/kb-update/scheduler.mjs run-now');
console.log('Pause it: node scripts/kb-update/scheduler.mjs uninstall (the enabled: flag is Tier-1/hook only)');
console.log('After a major `brew upgrade node`, re-run install (the node path is pinned at install time).');
}
function uninstall() {
const { agentPlistPath } = planAgent();
const { service } = targets();
launchctl(['bootout', service], { ignoreError: true, silent: true });
if (existsSync(agentPlistPath)) {
rmSync(agentPlistPath);
console.log(`Removed LaunchAgent: ${agentPlistPath}`);
} else {
console.log('No LaunchAgent plist found — nothing to remove.');
}
}
function status() {
const { agentPlistPath } = planAgent();
const { service } = targets();
console.log(`plist: ${agentPlistPath} ${existsSync(agentPlistPath) ? '(present)' : '(absent)'}`);
// Non-fatal: prints the loaded job, or an error line if not loaded.
launchctl(['print', service], { ignoreError: true });
}
function runNow() {
const { service } = targets();
launchctl(['kickstart', '-k', service]);
console.log(`Kicked off ${service} — see the log for output.`);
}
/** The launchd entrypoint. Gate on cadence, then delegate to run-detection.mjs. */
function run() {
const config = loadScheduleConfig(PLUGIN_ROOT);
if (config.os_scheduler_cadence === 'interval') {
let report = null;
if (existsSync(CHANGE_REPORT)) {
try {
report = JSON.parse(readFileSync(CHANGE_REPORT, 'utf8'));
} catch {
report = null; // unreadable => treat as infinitely stale => run
}
}
const days = daysSinceLastPoll(report, Date.now());
if (days < config.interval_days) {
console.log(`fresh (${Math.floor(days)}d < ${config.interval_days}d) — skipping poll`);
return;
}
}
// daily cadence, or interval+stale: delegate to the Claude-free entrypoint
// using the running node binary (no PATH dependency for this hop).
execFileSync(process.execPath, [RUN_DETECTION], { stdio: 'inherit' });
}
// --- Dispatch --------------------------------------------------------------
const cmd = process.argv[2];
switch (cmd) {
case 'print':
case '--dry-run':
printPlist();
break;
case 'install':
install();
break;
case 'uninstall':
uninstall();
break;
case 'status':
status();
break;
case 'run-now':
runNow();
break;
case 'run':
run();
break;
default:
console.error('Usage: scheduler.mjs <install|uninstall|status|run-now|print|run>');
process.exit(2);
}

View file

@ -23,6 +23,7 @@ import {
loadScheduleConfig,
shouldRunDetection,
summarizeSkillLifecycle,
daysSinceLastPoll,
} from '../../scripts/kb-update/lib/detection-schedule.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -73,6 +74,36 @@ test('parseScheduleConfig — malformed input never throws, falls back to OFF',
'a non-boolean enabled is treated as OFF');
});
// --- Tier 2 (launchd) cadence: os_scheduler_cadence (Sesjon 24) ---
test('DEFAULT_SCHEDULE_CONFIG — os_scheduler_cadence defaults to daily', () => {
assert.equal(DEFAULT_SCHEDULE_CONFIG.os_scheduler_cadence, 'daily');
});
test('parseScheduleConfig — reads os_scheduler_cadence: interval', () => {
const cfg = parseScheduleConfig('scheduled_detection:\n os_scheduler_cadence: interval\n');
assert.equal(cfg.os_scheduler_cadence, 'interval');
});
test('parseScheduleConfig — unknown/garbage cadence falls back to daily', () => {
assert.equal(parseScheduleConfig('scheduled_detection:\n os_scheduler_cadence: weekly\n').os_scheduler_cadence, 'daily');
assert.equal(parseScheduleConfig('scheduled_detection:\n enabled: true\n').os_scheduler_cadence, 'daily',
'unspecified cadence => daily default');
});
test('daysSinceLastPoll — Infinity when report missing or last_poll absent/invalid', () => {
const now = Date.UTC(2026, 5, 21);
assert.equal(daysSinceLastPoll(null, now), Infinity);
assert.equal(daysSinceLastPoll({}, now), Infinity);
assert.equal(daysSinceLastPoll({ last_poll: 'not-a-date' }, now), Infinity);
});
test('daysSinceLastPoll — computes whole days since last_poll', () => {
const now = Date.UTC(2026, 5, 21, 0, 0, 0);
const tenDaysAgo = new Date(Date.UTC(2026, 5, 11, 0, 0, 0)).toISOString();
assert.equal(daysSinceLastPoll({ last_poll: tenDaysAgo }, now), 10);
});
test('loadScheduleConfig — missing config file => OFF default', () => {
const root = mkdtempSync(join(tmpdir(), 'sched-'));
try {

View file

@ -0,0 +1,248 @@
// 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&amp;b&lt;c&gt;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&amp;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');
});