diff --git a/README.md b/README.md index 75fc8d9..65af0d4 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,9 @@ and the first Claude reference-fleet path: - identity-bound context-usage telemetry that separates measurements from estimates and relates cost/cache behavior to verified work; - validated system/session schedules with shell-free actions, authority, - retries, durable run evidence, and explicit approval for mutation. + retries, durable run evidence, and explicit approval for mutation; +- an owner-approved user-systemd launcher for system schedules, bound to the + exact tracked configuration digest and recorded for exact reversal. - durable Fleet evolution: the Session Manager can propose a justified new persistent domain, while owner approval gates the configuration commit, identity, prompt, branch, and worktree activation; it can also propose safe @@ -67,8 +69,8 @@ and the first Claude reference-fleet path: branch and refusing live, dirty, guarded, leased, or active work. Live mixed-provider qualification, stable self-host bootstrapping, COMBATRIG -cutover, persistent system-schedule launcher integration, and public deployment -remain staged work. +cutover, installation of the implemented system-schedule launcher on this +machine, and public deployment remain staged work. The canonical architecture and delivery plan is [docs/PORTABLE_AGENT_FLEET_SPEC.md](docs/PORTABLE_AGENT_FLEET_SPEC.md). diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index 057fe94..da2cdca 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -1133,8 +1133,13 @@ fields. The service plans without mutation, blocks session schedules while the Session Manager is offline, requires explicit approval for mutating actions, records durable run evidence, retries deterministically, and reports failures through durable blocker messages. CLI and MCP surfaces share the service. -Cron definitions currently require an external launcher; persistent timer -installation remains open and will use the normal ownership manifest. +System schedules can now use an owner-approved user-systemd launcher. The +launcher dispatches once per minute, evaluates the validated cron/interval +definitions in TORCH, suppresses duplicate minute runs, and refuses to run +after tracked configuration changes because its command is bound to the exact +configuration digest. Unit files are recorded in the installation ownership +manifest and removal refuses changed files. Installing that persistent +launcher on a real machine remains an explicit owner action. ## 23. Repository and forge operating modes diff --git a/package.json b/package.json index 42f2d43..78215ff 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "./observability": "./src/observability/snapshot.mjs", "./resources": "./src/resources/service.mjs", "./schedules": "./src/schedules/service.mjs", + "./schedules/launcher": "./src/schedules/launcher.mjs", "./runtime/lifecycle": "./src/runtime/lifecycle.mjs", "./self-host": "./src/self-host/service.mjs", "./telemetry/context": "./src/telemetry/context.mjs" diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index 038a03a..3a8a2cd 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -186,6 +186,10 @@ test_integrity_note: given: system and session schedules with different owners, behavior, retries, and authority when: schedules are planned and executed through a virtualized shell-free runner then: offline sessions and unauthorized actors are blocked, mutation needs approval, failures retry exactly and notify durably, and each run is recorded + - id: SCN-system-schedule-launcher + given: an installed Fleet with an owner-approved system schedule + when: TORCH plans, installs, dispatches, detects drift, and removes its user-systemd launcher in a temporary config home + then: units are bound to the exact tracked config digest, duplicate minute dispatch is suppressed, systemctl is virtualized, ownership is manifest-recorded, and reversal removes only unchanged TORCH units - id: SCN-cli-schedules given: an installed Fleet with an active Session Manager and a read-only session schedule when: public CLI commands list, plan, and run it diff --git a/src/cli.mjs b/src/cli.mjs index 2fa2115..cafe14a 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -25,6 +25,7 @@ import { CANDIDATE_ACCEPTANCE_SCENARIOS, createVersionService } from './self-hos import { observeProject } from './observability/snapshot.mjs'; import { ContextTelemetryService } from './telemetry/context.mjs'; import { ScheduleService } from './schedules/service.mjs'; +import { assertLauncherDigest, ScheduleLauncherService } from './schedules/launcher.mjs'; import { createFleetDesignBrief } from './design/brief.mjs'; import { createArchitectRunPlan, loadArchitectArtifact, runSessionArchitect, validateArchitectProposal, @@ -103,6 +104,7 @@ Usage: torch schedules runs [--id ] [--json] torch schedules plan --id --actor [--json] torch schedules run --id --actor [--yes] [--json] + torch schedules launcher [--yes] [--json] torch fleet changes [--state ] [--json] torch fleet get --change [--json] torch fleet propose --from session-manager --proposal [--json] @@ -747,11 +749,44 @@ export async function runCli(argv = process.argv.slice(2), { } if (command === 'schedules') { const operation = argv[1] ?? 'list'; + if (operation === 'launcher') { + const launcherOperation = argv[2] ?? 'status'; + const launcher = new ScheduleLauncherService({ + repositoryRoot: repository.root, env, + command: [process.execPath, new URL('../bin/torch.mjs', import.meta.url).pathname], + }); + if (launcherOperation === 'plan') { + const plan = launcher.plan(); + print(plan, { json }); + return plan.canProceed ? 0 : 1; + } + if (launcherOperation === 'status') { + print(launcher.status(), { json }); + return 0; + } + if (!['install', 'remove'].includes(launcherOperation)) { + throw new TorchError(`Unknown schedule launcher operation: ${launcherOperation}`, { code: 'UNKNOWN_COMMAND' }); + } + if (!argv.includes('--yes')) { + throw new TorchError('Persistent schedule launcher changes user systemd state. Review the plan, then re-run with --yes.', { + code: 'APPROVAL_REQUIRED', + }); + } + print(launcherOperation === 'install' ? launcher.install() : launcher.remove(), { json }); + return 0; + } const control = openControlPlane({ repositoryRoot: repository.root, env }); try { const schedules = new ScheduleService({ repositoryRoot: repository.root, controlPlane: control }); const scheduleId = optionValue(argv, '--id'); const actorId = optionValue(argv, '--actor') ?? 'owner'; + if (operation === 'dispatch-system') { + if (!argv.includes('--yes')) throw new TorchError('System schedule dispatch requires installed-launcher approval.', { code: 'APPROVAL_REQUIRED' }); + assertLauncherDigest(repository.root, optionValue(argv, '--launcher-digest')); + const dispatched = schedules.dispatchSystem({ actorId: 'owner', approved: true }); + print(dispatched, { json }); + return dispatched.runs.every((run) => run.result === 'succeeded') ? 0 : 1; + } if (operation === 'list') print({ schedules: schedules.list({ actorId }) }, { json }); else if (operation === 'runs') print({ runs: schedules.runs({ scheduleId }) }, { json }); else if (operation === 'plan') print(schedules.plan({ scheduleId, actorId }), { json }); diff --git a/src/kernel/install.mjs b/src/kernel/install.mjs index f5e0cd3..431597b 100644 --- a/src/kernel/install.mjs +++ b/src/kernel/install.mjs @@ -287,6 +287,9 @@ export function planUninstall({ repository, purge = false }) { const state = inspectManagedWorktree(repository.root, entry, mainBranch); if (!state.safeToRemove) problems.push({ type: 'unsafe-worktree', path: entry.path, area: entry.area, problems: state.problems }); } + if (entry.type === 'systemd-user-unit') { + problems.push({ type: 'persistent-integration-installed', path: entry.path, name: entry.name }); + } } return { action: purge ? 'purge' : 'detach', manifest, problems, diff --git a/src/schedules/launcher.mjs b/src/schedules/launcher.mjs new file mode 100644 index 0000000..9ed6421 --- /dev/null +++ b/src/schedules/launcher.mjs @@ -0,0 +1,171 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { TorchError } from '../kernel/errors.mjs'; +import { readInstallManifest, writeInstallManifest } from '../kernel/install.mjs'; +import { loadProjectConfig } from '../kernel/config.mjs'; + +function sha256(content) { + return createHash('sha256').update(content).digest('hex'); +} + +function unitQuote(value) { + return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`; +} + +function configPath(repositoryRoot) { + return join(repositoryRoot, '.torch', 'torch.yaml'); +} + +export function scheduleConfigDigest(repositoryRoot) { + return sha256(readFileSync(configPath(repositoryRoot))); +} + +function unitDirectory(env) { + return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd', 'user'); +} + +function render({ repositoryRoot, projectId, command, digest, env }) { + const base = `torch-${projectId}-schedules`; + const serviceName = `${base}.service`; + const timerName = `${base}.timer`; + const directory = unitDirectory(env); + const args = [ + ...command, 'schedules', 'dispatch-system', '--launcher-digest', digest, '--yes', '--json', + ]; + const environment = env.XDG_DATA_HOME + ? `Environment=${unitQuote(`XDG_DATA_HOME=${resolve(env.XDG_DATA_HOME)}`)}\n` : ''; + const service = [ + '[Unit]', `Description=TORCH system schedules for ${projectId}`, '', '[Service]', 'Type=oneshot', + `WorkingDirectory=${unitQuote(repositoryRoot)}`, environment.trimEnd(), + `ExecStart=${args.map(unitQuote).join(' ')}`, '', + ].filter((line) => line !== '').join('\n') + '\n'; + const timer = [ + '[Unit]', `Description=Dispatch TORCH system schedules for ${projectId}`, '', '[Timer]', + 'OnCalendar=*-*-* *:*:00', 'Persistent=true', `Unit=${serviceName}`, '', '[Install]', + 'WantedBy=timers.target', '', + ].join('\n'); + return { + serviceName, timerName, directory, + files: [ + { name: serviceName, path: join(directory, serviceName), content: service }, + { name: timerName, path: join(directory, timerName), content: timer }, + ], + }; +} + +function runSystemctl(executor, args) { + const result = executor('systemctl', ['--user', ...args], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: false, + }); + if (result.status !== 0 || result.error) { + throw new TorchError(`systemctl --user ${args.join(' ')} failed`, { + code: 'SCHEDULE_LAUNCHER_SYSTEMD_FAILED', details: result.stderr?.toString().trim() || result.error?.message, + }); + } +} + +export class ScheduleLauncherService { + constructor({ + repositoryRoot, env = process.env, command, + executor = (file, args, options) => spawnSync(file, args, options), + } = {}) { + this.repositoryRoot = resolve(repositoryRoot); + this.env = env; + this.command = command ?? [process.execPath, new URL('../../bin/torch.mjs', import.meta.url).pathname]; + this.executor = executor; + } + + plan() { + const config = loadProjectConfig(this.repositoryRoot); + const manifest = readInstallManifest(this.repositoryRoot); + const systemSchedules = config.schedules.filter((schedule) => schedule.lifetime === 'system'); + const digest = scheduleConfigDigest(this.repositoryRoot); + const rendered = render({ + repositoryRoot: this.repositoryRoot, projectId: manifest.projectId, + command: this.command, digest, env: this.env, + }); + const blockers = []; + if (!systemSchedules.length) blockers.push({ code: 'NO_SYSTEM_SCHEDULES' }); + for (const file of rendered.files) if (existsSync(file.path)) blockers.push({ code: 'LAUNCHER_PATH_EXISTS', path: file.path }); + return { + action: 'install-system-schedule-launcher', projectId: manifest.projectId, + systemSchedules: systemSchedules.map((schedule) => schedule.id), digest, + ...rendered, blockers, canProceed: blockers.length === 0, mutationPerformed: false, + }; + } + + install() { + const plan = this.plan(); + if (!plan.canProceed) throw new TorchError('System schedule launcher installation is blocked', { + code: 'SCHEDULE_LAUNCHER_BLOCKED', details: plan.blockers, + }); + const manifest = readInstallManifest(this.repositoryRoot); + const originalManifest = structuredClone(manifest); + const created = []; + try { + mkdirSync(plan.directory, { recursive: true }); + for (const file of plan.files) { + const temporary = `${file.path}.tmp-${process.pid}`; + writeFileSync(temporary, file.content, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + renameSync(temporary, file.path); + created.push(file.path); + manifest.external.push({ + type: 'systemd-user-unit', name: file.name, path: file.path, + sha256: sha256(file.content), configDigest: plan.digest, + }); + } + writeInstallManifest(this.repositoryRoot, manifest); + runSystemctl(this.executor, ['daemon-reload']); + runSystemctl(this.executor, ['enable', '--now', plan.timerName]); + return { ...plan, mutationPerformed: true, requiresCommit: ['.torch/install-manifest.json'] }; + } catch (error) { + try { runSystemctl(this.executor, ['disable', '--now', plan.timerName]); } catch { /* best-effort rollback */ } + for (const path of created.reverse()) if (existsSync(path)) rmSync(path); + writeInstallManifest(this.repositoryRoot, originalManifest); + throw error; + } + } + + status() { + const manifest = readInstallManifest(this.repositoryRoot); + const units = (manifest.external ?? []).filter((entry) => entry.type === 'systemd-user-unit'); + return { + installed: units.length > 0, + units: units.map((entry) => ({ ...entry, exists: existsSync(entry.path) })), + currentConfigDigest: scheduleConfigDigest(this.repositoryRoot), + stale: units.some((entry) => entry.configDigest !== scheduleConfigDigest(this.repositoryRoot)), + mutationPerformed: false, + }; + } + + remove() { + const manifest = readInstallManifest(this.repositoryRoot); + const units = (manifest.external ?? []).filter((entry) => entry.type === 'systemd-user-unit'); + if (!units.length) return { removed: [], changed: false, mutationPerformed: false }; + const modified = units.filter((entry) => !existsSync(entry.path) || sha256(readFileSync(entry.path)) !== entry.sha256); + if (modified.length) throw new TorchError('Refusing to remove changed or missing system schedule launcher files', { + code: 'SCHEDULE_LAUNCHER_MODIFIED', details: modified.map((entry) => entry.path), + }); + const timer = units.find((entry) => entry.name.endsWith('.timer')); + if (timer) runSystemctl(this.executor, ['disable', '--now', timer.name]); + for (const entry of units) rmSync(entry.path); + manifest.external = manifest.external.filter((entry) => entry.type !== 'systemd-user-unit'); + writeInstallManifest(this.repositoryRoot, manifest); + runSystemctl(this.executor, ['daemon-reload']); + return { + removed: units.map((entry) => entry.path), changed: true, mutationPerformed: true, + requiresCommit: ['.torch/install-manifest.json'], + }; + } +} + +export function assertLauncherDigest(repositoryRoot, expected) { + const actual = scheduleConfigDigest(repositoryRoot); + if (!expected || expected !== actual) throw new TorchError('System schedule launcher is stale for the tracked configuration', { + code: 'SCHEDULE_LAUNCHER_STALE', details: { expected: expected ?? null, actual }, + }); + return actual; +} diff --git a/src/schedules/service.mjs b/src/schedules/service.mjs index 117a300..ea6bcfa 100644 --- a/src/schedules/service.mjs +++ b/src/schedules/service.mjs @@ -3,6 +3,39 @@ import { spawnSync } from 'node:child_process'; import { loadFleetDefinition } from '../kernel/worktrees.mjs'; import { TorchError } from '../kernel/errors.mjs'; +function cronPartMatches(part, value, { sunday = false } = {}) { + return part.split(',').some((segment) => { + const [rangeText, stepText] = segment.split('/'); + const step = stepText === undefined ? 1 : Number(stepText); + if (!Number.isInteger(step) || step < 1) return false; + const normalize = (number) => (sunday && number === 7 ? 0 : number); + if (rangeText === '*') return value % step === 0; + const [startText, endText] = rangeText.split('-'); + const start = normalize(Number(startText)); + const end = endText === undefined ? start : normalize(Number(endText)); + if (!Number.isInteger(start) || !Number.isInteger(end) || value < start || value > end) return false; + return (value - start) % step === 0; + }); +} + +export function cronMatches(expression, at) { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) return false; + const [minute, hour, day, month, weekday] = parts; + if (!cronPartMatches(minute, at.getMinutes()) || !cronPartMatches(hour, at.getHours()) + || !cronPartMatches(month, at.getMonth() + 1)) return false; + const dayMatches = cronPartMatches(day, at.getDate()); + const weekdayMatches = cronPartMatches(weekday, at.getDay(), { sunday: true }); + if (day === '*' && weekday === '*') return true; + if (day === '*') return weekdayMatches; + if (weekday === '*') return dayMatches; + return dayMatches || weekdayMatches; +} + +function sameMinute(left, right) { + return left && right && left.slice(0, 16) === right.slice(0, 16); +} + function text(value, field) { if (typeof value !== 'string' || !value.trim()) { throw new TorchError(`Schedule ${field} is required`, { code: 'SCHEDULE_CONFIG_INVALID', details: { field } }); @@ -125,7 +158,10 @@ export class ScheduleService { nextAt = new Date(Date.parse(lastRun.startedAt) + definition.trigger.seconds * 1000).toISOString(); due = at.getTime() >= Date.parse(nextAt); } - if (definition.trigger.type === 'cron') due = 'external-launcher'; + if (definition.trigger.type === 'cron') { + due = cronMatches(definition.trigger.expression, at) + && !sameMinute(lastRun?.startedAt, at.toISOString()); + } return { schedule: definition, actorId, due, nextAt, blockers, canRun: blockers.length === 0, mutationPerformed: false, @@ -175,4 +211,17 @@ export class ScheduleService { list({ actorId } = {}) { return [...this.definitions.values()].map((definition) => this.plan({ scheduleId: definition.id, actorId })); } + + dispatchSystem({ actorId = 'owner', approved = false, at = this.clock() } = {}) { + const considered = [...this.definitions.values()].filter((definition) => definition.lifetime === 'system'); + const due = considered.filter((definition) => { + if (definition.trigger.type === 'manual') return false; + return this.plan({ scheduleId: definition.id, actorId, at }).due === true; + }); + const runs = due.map((definition) => this.run({ scheduleId: definition.id, actorId, approved })); + return { + at: at.toISOString(), considered: considered.map((definition) => definition.id), + due: due.map((definition) => definition.id), runs, mutationPerformed: runs.length > 0, + }; + } } diff --git a/test/fleet/schedules.test.mjs b/test/fleet/schedules.test.mjs index d8548b1..a7fc794 100644 --- a/test/fleet/schedules.test.mjs +++ b/test/fleet/schedules.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -8,8 +8,9 @@ import { openControlPlane } from '../../src/control-plane/service.mjs'; import { analyzeRepository } from '../../src/kernel/analyze.mjs'; import { proposeDomains } from '../../src/kernel/domains.mjs'; import { inspectRepository } from '../../src/kernel/git.mjs'; -import { installProject } from '../../src/kernel/install.mjs'; +import { installProject, planUninstall } from '../../src/kernel/install.mjs'; import { ScheduleService } from '../../src/schedules/service.mjs'; +import { assertLauncherDigest, ScheduleLauncherService } from '../../src/schedules/launcher.mjs'; const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname; @@ -50,7 +51,11 @@ function fixture() { proposal.review = { status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [], }; - const env = { ...process.env, XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-schedules-state-')) }; + const env = { + ...process.env, + XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-schedules-state-')), + XDG_CONFIG_HOME: mkdtempSync(join(tmpdir(), 'torch-schedules-config-')), + }; installProject({ repository, proposal, env, projectId: 'schedule-fixture' }); return { root, env, worker }; } @@ -103,3 +108,61 @@ test('SCN-cli-schedules: public commands list, plan, and run a read-only session assert.equal(executed.status, 0, executed.stderr || executed.stdout); assert.equal(JSON.parse(executed.stdout).stdout, 'healthy'); }); + +test('SCN-system-schedule-launcher: exact-config user units install, dispatch, and reverse safely', () => { + const context = fixture(); + const systemctl = []; + const launcher = new ScheduleLauncherService({ + repositoryRoot: context.root, env: context.env, + command: ['/opt/torch/node', '/opt/torch/bin/torch.mjs'], + executor: (command, args, options) => { + systemctl.push({ command, args, options }); + return { status: 0, stdout: '', stderr: '' }; + }, + }); + const plan = launcher.plan(); + assert.equal(plan.canProceed, true); + assert.deepEqual(plan.systemSchedules, ['release']); + assert.equal(plan.files.every((file) => !existsSync(file.path)), true); + + const installed = launcher.install(); + assert.equal(installed.mutationPerformed, true); + assert.equal(installed.files.every((file) => existsSync(file.path)), true); + assert.match(readFileSync(installed.files[0].path, 'utf8'), new RegExp(installed.digest)); + assert.deepEqual(systemctl.slice(0, 2).map((call) => call.args.slice(0, 2)), [ + ['--user', 'daemon-reload'], ['--user', 'enable'], + ]); + assert.equal(launcher.status().stale, false); + assert.equal(planUninstall({ repository: inspectRepository(context.root), purge: true }).problems + .some((problem) => problem.type === 'persistent-integration-installed'), true); + + const originalConfig = readFileSync(join(context.root, '.torch', 'torch.yaml'), 'utf8'); + writeFileSync(join(context.root, '.torch', 'torch.yaml'), `${originalConfig.trimEnd()} \n`); + assert.throws( + () => assertLauncherDigest(context.root, installed.digest), + (error) => error.code === 'SCHEDULE_LAUNCHER_STALE', + ); + writeFileSync(join(context.root, '.torch', 'torch.yaml'), originalConfig); + + const removed = launcher.remove(); + assert.equal(removed.mutationPerformed, true); + assert.equal(installed.files.every((file) => !existsSync(file.path)), true); + assert.equal(launcher.status().installed, false); + + const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); + let at = new Date(2026, 8, 27, 16, 0, 0); + const executed = []; + const schedules = new ScheduleService({ + repositoryRoot: context.root, controlPlane: control, clock: () => at, + executor: (command, args) => { + executed.push({ command, args }); + return { status: 0, stdout: 'released', stderr: '' }; + }, + }); + const first = schedules.dispatchSystem({ actorId: 'owner', approved: true, at }); + assert.deepEqual(first.due, ['release']); + assert.equal(executed.length, 1); + at = new Date(2026, 8, 27, 16, 0, 30); + assert.deepEqual(schedules.dispatchSystem({ actorId: 'owner', approved: true, at }).due, []); + control.close(); +});