clawdie-ai/setup/system-update-cron.ts
Operator & Claude Code 5c54aea011 Wire system-update into orchestrator + daily cron (Sam & Claude)
system-update was complete but unwired — only callable directly via
`npx tsx setup/system-update.ts`. This commit:

- Adds it to setup/index.ts STEPS so `npm run setup -- --step system-update`
  works the same as the other lifecycle steps.
- Adds a sibling `system-update-cron` step that drops a managed wrapper
  at /usr/local/sbin/clawdie-system-update and a cron entry at
  /etc/cron.d/clawdie-system-update. Default schedule is 06:50 daily so
  the morning patch state lands before the 08:00 operator status report.
- Folds `pkg audit -F` into the system-update run — read-only CVE scan
  that always executes (even in dry-run) and surfaces vulnerable count
  in the structured status.
- Adds a reboot-pending detector that compares running kernel (uname -r)
  to installed userland (freebsd-version). When a kernel patch lands,
  REBOOT_PENDING=yes appears in the status; the platform never reboots
  itself — the operator decides.

Cadence is daily, not weekly: freebsd-update fetches are cheap, security
patches benefit from same-day rollout, and pairing with the morning
report makes the result legible. Heavier `pkg upgrade` (full userland
refresh, not just CVE scan) is a separate question for later.

Tests cover the new pure helpers (parsePkgAudit, rebootPending) plus
the cron entry/wrapper builders. The orchestrator wiring is mechanical.

---
Targeted tests pass (system-update + system-update-cron, 21 tests).
Codex to validate end-to-end on host: install the cron module, confirm
/etc/cron.d/clawdie-system-update lands, confirm the wrapper is exec'd
on the next 06:50, and confirm the structured status reaches the 08:00
report pipeline.

---
Build: FAIL | Tests: FAIL — 16 failed

---
Build: FAIL | Tests: FAIL — 16 failed
2026-05-09 12:40:39 +02:00

161 lines
4.7 KiB
TypeScript

/**
* Step: system-update-cron — Install /etc/cron.d/clawdie-system-update so
* the platform fetches FreeBSD security patches, audits installed packages,
* and patches every thin jail every morning before the 8am operator report.
*
* The platform never auto-reboots. If a kernel patch lands, system-update
* surfaces REBOOT_PENDING in the structured status; the operator decides.
*
* Schedule: 06:50 daily. Output of the run is appended to the system-update
* log so the morning report can pick it up.
*
* Must run on the FreeBSD host as root.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { logger } from '../src/logger.js';
import { commandExists, getPlatform, isRoot } from './platform.js';
import { emitStatus } from './status.js';
const CRON_PATH = '/etc/cron.d/clawdie-system-update';
const WRAPPER_PATH = '/usr/local/sbin/clawdie-system-update';
const LOG_PATH = '/var/log/clawdie-system-update.log';
const DEFAULT_SCHEDULE = '50 6 * * *';
export interface CronArgs {
schedule: string;
projectRoot: string;
npxPath: string;
}
export function parseArgs(args: string[]): CronArgs {
let schedule = process.env.SYSTEM_UPDATE_CRON || DEFAULT_SCHEDULE;
let projectRoot = process.cwd();
let npxPath = '/usr/local/bin/npx';
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--schedule') {
schedule = (args[i + 1] || '').trim();
i++;
continue;
}
if (arg === '--project-root') {
projectRoot = (args[i + 1] || '').trim();
i++;
continue;
}
if (arg === '--npx') {
npxPath = (args[i + 1] || '').trim();
i++;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
if (!schedule || schedule.split(/\s+/u).filter(Boolean).length !== 5) {
throw new Error(`Invalid cron schedule: '${schedule}' (need 5 fields)`);
}
if (!path.isAbsolute(projectRoot)) {
throw new Error(`--project-root must be absolute: ${projectRoot}`);
}
return { schedule, projectRoot, npxPath };
}
export function buildWrapper(args: CronArgs): string {
return [
'#!/bin/sh',
'# clawdie-system-update — managed by setup/system-update-cron.ts',
'# Do not edit by hand; regenerate via npx tsx setup/index.ts --step system-update-cron',
'set -eu',
`cd "${args.projectRoot}" || exit 1`,
`exec "${args.npxPath}" tsx setup/system-update.ts "$@"`,
'',
].join('\n');
}
export function buildCronEntry(args: CronArgs): string {
return [
'# Clawdie — daily system update (managed by setup/system-update-cron.ts)',
'# Runs before the 08:00 operator status report so the morning summary',
'# reflects the night\'s patch state. Never auto-reboots.',
`${args.schedule} root ${WRAPPER_PATH} >> ${LOG_PATH} 2>&1`,
'',
].join('\n');
}
function writeIfChanged(filePath: string, content: string, mode: number): boolean {
const existing = fs.existsSync(filePath)
? fs.readFileSync(filePath, 'utf-8')
: '';
if (existing === content) return false;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, { mode });
return true;
}
export async function run(argv: string[]): Promise<void> {
if (getPlatform() !== 'freebsd') {
emitStatus('SETUP_SYSTEM_UPDATE_CRON', {
STATUS: 'skipped',
REASON: 'not_freebsd',
});
return;
}
if (!isRoot()) {
emitStatus('SETUP_SYSTEM_UPDATE_CRON', {
STATUS: 'failed',
ERROR: 'root_required',
});
process.exit(1);
}
const args = parseArgs(argv);
// Sanity-check that npx is reachable.
if (!fs.existsSync(args.npxPath) && !commandExists('npx')) {
emitStatus('SETUP_SYSTEM_UPDATE_CRON', {
STATUS: 'failed',
ERROR: `npx_not_found_at_${args.npxPath}`,
});
process.exit(1);
}
const wrapper = buildWrapper(args);
const cron = buildCronEntry(args);
const wrapperChanged = writeIfChanged(WRAPPER_PATH, wrapper, 0o755);
if (wrapperChanged) {
logger.info({ path: WRAPPER_PATH }, 'system-update wrapper written');
}
const cronChanged = writeIfChanged(CRON_PATH, cron, 0o644);
if (cronChanged) {
logger.info({ path: CRON_PATH }, 'system-update cron entry written');
}
// Touch the log file so cron's append works on first run.
if (!fs.existsSync(LOG_PATH)) {
fs.writeFileSync(LOG_PATH, '', { mode: 0o640 });
try {
execSync(`chown root:wheel ${LOG_PATH}`, { stdio: 'ignore' });
} catch {
// best-effort
}
}
emitStatus('SETUP_SYSTEM_UPDATE_CRON', {
STATUS: 'success',
SCHEDULE: args.schedule,
CRON_PATH,
WRAPPER_PATH,
LOG_PATH,
PROJECT_ROOT: args.projectRoot,
WRAPPER_CHANGED: wrapperChanged ? 'yes' : 'no',
CRON_CHANGED: cronChanged ? 'yes' : 'no',
});
}