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
This commit is contained in:
Operator & Claude Code 2026-05-09 12:36:06 +02:00
parent 5e0bd9eb12
commit 5c54aea011
5 changed files with 332 additions and 0 deletions

View file

@ -27,6 +27,8 @@ const STEPS: Record<
ollama: () => import('./ollama.js'), ollama: () => import('./ollama.js'),
'llama-cpp': () => import('./llama-cpp.js'), 'llama-cpp': () => import('./llama-cpp.js'),
sanoid: () => import('./sanoid.js'), sanoid: () => import('./sanoid.js'),
'system-update': () => import('./system-update.js'),
'system-update-cron': () => import('./system-update-cron.js'),
service: () => import('./service.js'), service: () => import('./service.js'),
hostd: () => import('./hostd.js'), hostd: () => import('./hostd.js'),
verify: () => import('./verify.js'), verify: () => import('./verify.js'),

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
buildCronEntry,
buildWrapper,
parseArgs,
} from './system-update-cron.js';
describe('parseArgs', () => {
it('defaults to 06:50 daily and process cwd', () => {
const result = parseArgs([]);
expect(result.schedule).toBe('50 6 * * *');
expect(result.projectRoot.startsWith('/')).toBe(true);
expect(result.npxPath).toBe('/usr/local/bin/npx');
});
it('accepts a custom schedule', () => {
expect(
parseArgs(['--schedule', '30 5 * * *', '--project-root', '/opt/clawdie']).schedule,
).toBe('30 5 * * *');
});
it('rejects a malformed schedule', () => {
expect(() => parseArgs(['--schedule', 'every minute'])).toThrow();
});
it('rejects a relative project root', () => {
expect(() => parseArgs(['--project-root', 'relative/path'])).toThrow();
});
});
describe('buildWrapper', () => {
it('produces an executable shell script that exec\'s the system-update step', () => {
const wrapper = buildWrapper({
schedule: '50 6 * * *',
projectRoot: '/home/clawdie/Clawdie-AI',
npxPath: '/usr/local/bin/npx',
});
expect(wrapper.startsWith('#!/bin/sh')).toBe(true);
expect(wrapper).toContain('cd "/home/clawdie/Clawdie-AI"');
expect(wrapper).toContain('"/usr/local/bin/npx" tsx setup/system-update.ts');
});
});
describe('buildCronEntry', () => {
it('emits a single cron line at the configured schedule', () => {
const entry = buildCronEntry({
schedule: '50 6 * * *',
projectRoot: '/home/clawdie/Clawdie-AI',
npxPath: '/usr/local/bin/npx',
});
const lines = entry.split('\n').filter((line) => line && !line.startsWith('#'));
expect(lines).toHaveLength(1);
expect(lines[0]).toMatch(
/^50 6 \* \* \* root \/usr\/local\/sbin\/clawdie-system-update >> \/var\/log\/clawdie-system-update\.log 2>&1$/u,
);
});
});

161
setup/system-update-cron.ts Normal file
View file

@ -0,0 +1,161 @@
/**
* 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',
});
}

View file

@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest';
import { import {
parseArgs, parseArgs,
parseFreeBSDVersion, parseFreeBSDVersion,
parsePkgAudit,
parseReleaseList, parseReleaseList,
rebootPending,
selectJailUpdateTargets, selectJailUpdateTargets,
} from './system-update.js'; } from './system-update.js';
@ -54,6 +56,47 @@ describe('parseReleaseList', () => {
}); });
}); });
describe('parsePkgAudit', () => {
it('reports clean when no problems are found', () => {
expect(parsePkgAudit('0 problem(s) found.')).toEqual({
ok: true,
vulnerableCount: 0,
summary: '0 problem(s) found.',
});
});
it('reports vulnerable count from a real-shaped audit output', () => {
const raw = [
'curl-8.7.1 is vulnerable:',
' CVE-2024-1234',
'',
'1 problem(s) found.',
].join('\n');
const result = parsePkgAudit(raw);
expect(result.ok).toBe(false);
expect(result.vulnerableCount).toBe(1);
expect(result.summary).toBe('1 problem(s) found.');
});
it('treats empty output as clean', () => {
expect(parsePkgAudit('').vulnerableCount).toBe(0);
});
});
describe('rebootPending', () => {
it('returns false when running kernel matches installed userland', () => {
expect(rebootPending('15.0-RELEASE-p4', '15.0-RELEASE-p4')).toBe(false);
});
it('returns true when patch levels differ', () => {
expect(rebootPending('15.0-RELEASE-p4', '15.0-RELEASE-p8')).toBe(true);
});
it('ignores surrounding whitespace', () => {
expect(rebootPending('15.0-RELEASE-p4\n', ' 15.0-RELEASE-p4 ')).toBe(false);
});
});
describe('selectJailUpdateTargets', () => { describe('selectJailUpdateTargets', () => {
const rows = [ const rows = [
{ {

View file

@ -97,6 +97,30 @@ export function parseReleaseList(raw: string): string[] {
.filter(Boolean); .filter(Boolean);
} }
export interface PkgAuditResult {
ok: boolean;
vulnerableCount: number;
summary: string;
}
export function parsePkgAudit(raw: string): PkgAuditResult {
const text = raw.trim();
if (!text) {
return { ok: true, vulnerableCount: 0, summary: '0 problem(s) found' };
}
const match = text.match(/(\d+)\s+problem\(s\)\s+found/iu);
const count = match ? parseInt(match[1], 10) : 0;
return {
ok: count === 0,
vulnerableCount: count,
summary: text.split('\n').slice(-1)[0] || text,
};
}
export function rebootPending(runningKernel: string, installedUserland: string): boolean {
return runningKernel.trim() !== installedUserland.trim();
}
export function selectJailUpdateTargets( export function selectJailUpdateTargets(
rows: BastilleJailRow[], rows: BastilleJailRow[],
dbRuntime: DbRuntime, dbRuntime: DbRuntime,
@ -207,6 +231,29 @@ export async function run(argv: string[]): Promise<void> {
dbJailNames, dbJailNames,
); );
// pkg audit runs first — read-only CVE scan against installed packages.
// It always runs (even in dry-run) since it never changes system state.
let pkgAudit: PkgAuditResult = { ok: true, vulnerableCount: 0, summary: 'not_run' };
try {
const auditOut = execSync('pkg audit -F', {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
pkgAudit = parsePkgAudit(auditOut);
} catch (err) {
// pkg audit exits non-zero when vulnerabilities exist — capture stdout regardless.
const e = err as { stdout?: string; message?: string };
if (typeof e.stdout === 'string') {
pkgAudit = parsePkgAudit(e.stdout);
} else {
pkgAudit = {
ok: false,
vulnerableCount: -1,
summary: e.message || 'pkg audit failed',
};
}
}
emitStatus('SYSTEM_UPDATE', { emitStatus('SYSTEM_UPDATE', {
STATUS: opts.dryRun ? 'dry-run' : 'planned', STATUS: opts.dryRun ? 'dry-run' : 'planned',
HOST_BEFORE: hostBefore.raw, HOST_BEFORE: hostBefore.raw,
@ -216,6 +263,8 @@ export async function run(argv: string[]): Promise<void> {
DB_RUNTIME: opts.dbRuntime, DB_RUNTIME: opts.dbRuntime,
DB_JAIL: targets.dbJail || '-', DB_JAIL: targets.dbJail || '-',
SKIPPED_THICK: formatJailList(targets.skippedThickJails), SKIPPED_THICK: formatJailList(targets.skippedThickJails),
PKG_AUDIT: pkgAudit.summary,
PKG_AUDIT_VULNS: String(pkgAudit.vulnerableCount),
}); });
logger.info( logger.info(
@ -258,6 +307,21 @@ export async function run(argv: string[]): Promise<void> {
? jailsAfter.find((row) => row.name === targets.dbJail)?.release || '-' ? jailsAfter.find((row) => row.name === targets.dbJail)?.release || '-'
: '-'; : '-';
// Reboot is pending when the running kernel string (uname -r) differs
// from the userland on disk (freebsd-version). The platform never auto-
// reboots — it surfaces this flag for the operator to decide.
let runningKernel = '';
let installedUserland = '';
try {
runningKernel = execSync('uname -r', { encoding: 'utf-8' }).trim();
installedUserland = execSync('freebsd-version', { encoding: 'utf-8' }).trim();
} catch {
// best-effort
}
const reboot = runningKernel && installedUserland
? rebootPending(runningKernel, installedUserland)
: false;
emitStatus('SYSTEM_UPDATE', { emitStatus('SYSTEM_UPDATE', {
STATUS: 'success', STATUS: 'success',
HOST_AFTER: hostAfter.raw, HOST_AFTER: hostAfter.raw,
@ -266,6 +330,10 @@ export async function run(argv: string[]): Promise<void> {
THIN_UPDATED: formatJailList(targets.thinJails), THIN_UPDATED: formatJailList(targets.thinJails),
DB_UPDATED: dbAfter, DB_UPDATED: dbAfter,
SKIPPED_THICK: formatJailList(targets.skippedThickJails), SKIPPED_THICK: formatJailList(targets.skippedThickJails),
PKG_AUDIT: pkgAudit.summary,
PKG_AUDIT_VULNS: String(pkgAudit.vulnerableCount),
REBOOT_PENDING: reboot ? 'yes' : 'no',
...(reboot ? { RUNNING_KERNEL: runningKernel, INSTALLED_USERLAND: installedUserland } : {}),
}); });
} }