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>
188 lines
7 KiB
JavaScript
188 lines
7 KiB
JavaScript
#!/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);
|
|
}
|