From 1b0f53af5cc50f141a09b33d24f6396aef800657 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Sun, 27 Sep 2026 19:30:54 -0400 Subject: [PATCH] Implement reversible fleet uninstall --- README.md | 2 + docs/PORTABLE_AGENT_FLEET_SPEC.md | 7 +++ ...grity-note-2026-09-27-portable-kernel.yaml | 8 +++- src/cli.mjs | 48 +++++++++++++++++-- src/schedules/launcher.mjs | 21 ++++++-- test/fleet/cli-scenarios.test.mjs | 8 ++++ test/fleet/schedules.test.mjs | 7 +++ 7 files changed, 91 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9d15775..f799890 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ and the first Claude reference-fleet path: - conservative, explicitly confirmed purge that validates the exact local state path and distinguishes owned history from live sessions, locks, leases, guards, landing, persistent launchers, and unknown files; +- reversible ordinary uninstall that winds down the Fleet, safely removes + owned persistent schedule launchers, and preserves organization and history; - reviewed worktree plans with branch/path collision protection; - isolated branches and worktrees with locally ignored task markers; - purge protection for dirty and uniquely committed domain work. diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index d3705a2..ffc9205 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -421,6 +421,13 @@ or installed persistent launchers. Historical messages and receipts therefore do not make reversal permanently impossible, while active or unowned state still blocks deletion. +Ordinary CLI uninstall is also review-first and explicitly confirmed. It uses +the same safe Fleet wind-down contract as detach, removes only manifest-owned +persistent schedule launchers after verifying their content, and preserves the +tracked organization, worktrees, branches, and local state so `torch up` can +restore management. Missing or changed launcher files and active Fleet work +fail closed before mutation. + ## 10. Project analysis and bootstrap ### 10.1 Repository reconnaissance 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 c0c6df7..9fb9192 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 @@ -18,6 +18,10 @@ test_integrity_note: given: an installed TORCH project whose managed configuration was edited by its owner when: a purge is requested then: TORCH refuses destructive cleanup and preserves the modified file + - id: SCN-purge-protects-local-identity + given: an installation whose machine-local project metadata no longer matches its ownership manifest + when: a purge is planned or requested + then: TORCH refuses deletion and preserves the mismatched local state for explicit remediation - id: SCN-cli-install-approval given: a Git repository without TORCH when: install is invoked without a reviewed approval flag @@ -200,8 +204,8 @@ test_integrity_note: then: missing approval is refused and the activated identity is queryable through the ordinary Fleet CLI - id: SCN-cli-lifecycle-surface given: an approved installed Fleet with isolated worktrees - when: an operator selects runtimes, previews selected startup, reads list and brief output, captures state, and detaches - then: the documented lifecycle commands share durable state, preserve the organization and worktrees, and require approval before stopping integrations + when: an operator selects runtimes, previews selected startup, reads list and brief output, captures state, detaches, and performs ordinary uninstall + then: the documented lifecycle commands share durable state, preserve the organization and worktrees, and require approval before stopping sessions or removing owned runtime integrations - id: SCN-install-runtime-selection given: an approved Fleet proposal with declared runtime assignments when: installation selects its enabled runtime adapters diff --git a/src/cli.mjs b/src/cli.mjs index 7bfad1e..0c4ac5d 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -1149,15 +1149,55 @@ export async function runCli(argv = process.argv.slice(2), { } } if (command === 'uninstall') { - if (argv.includes('--purge') && !argv.includes('--dry-run') && !argv.includes('--yes')) { - throw new TorchError('Purging TORCH removes tracked configuration, managed worktrees, and local state. Review --dry-run, then re-run with --purge --yes.', { + const purge = argv.includes('--purge'); + const dryRun = argv.includes('--dry-run'); + if (!dryRun && !argv.includes('--yes')) { + throw new TorchError(purge + ? 'Purging TORCH removes tracked configuration, managed worktrees, and local state. Review --dry-run, then re-run with --purge --yes.' + : 'Uninstall stops the Fleet and removes owned persistent runtime integrations while preserving project state. Review --dry-run, then re-run with --yes.', { code: 'APPROVAL_REQUIRED', }); } + if (!purge) { + const control = openControlPlane({ repositoryRoot: repository.root, env }); + try { + const stoppers = runtimeStoppers(spawn); + const fleet = planFleetDetach({ + repositoryRoot: repository.root, controlPlane: control, adapters: stoppers, + }); + const launcher = new ScheduleLauncherService({ repositoryRoot: repository.root, env }); + const runtimeIntegrations = launcher.planRemoval(); + const plan = { + action: 'uninstall', projectId: fleet.projectId, fleet, runtimeIntegrations, + blockers: [...fleet.blockers, ...runtimeIntegrations.blockers], + canProceed: fleet.canProceed && runtimeIntegrations.canProceed, + preserved: ['tracked organization', 'worktrees', 'branches', 'local state'], + mutationPerformed: false, + }; + if (dryRun) { + print(plan, { json }); + return plan.canProceed ? 0 : 1; + } + if (!plan.canProceed) throw new TorchError('TORCH uninstall stopped because active work or changed runtime integrations remain', { + code: 'UNSAFE_TO_UNINSTALL', details: plan.blockers, + }); + const detached = detachFleet({ + plan: fleet, controlPlane: control, stopRuntime: (action) => stopThrough(stoppers, action), + }); + const removedRuntimeIntegrations = launcher.remove(); + print({ + ...plan, detached, removedRuntimeIntegrations, + mutationPerformed: true, + }, { json }); + return 0; + } finally { + control.close(); + } + } const result = uninstallProject({ repository, - purge: argv.includes('--purge'), - dryRun: argv.includes('--dry-run'), + purge, + dryRun, env, }); print(result, { json }); diff --git a/src/schedules/launcher.mjs b/src/schedules/launcher.mjs index 9ed6421..047f3ce 100644 --- a/src/schedules/launcher.mjs +++ b/src/schedules/launcher.mjs @@ -141,14 +141,27 @@ export class ScheduleLauncherService { }; } + planRemoval() { + const manifest = readInstallManifest(this.repositoryRoot); + const units = (manifest.external ?? []).filter((entry) => entry.type === 'systemd-user-unit'); + const blockers = units + .filter((entry) => !existsSync(entry.path) || sha256(readFileSync(entry.path)) !== entry.sha256) + .map((entry) => ({ code: 'SCHEDULE_LAUNCHER_MODIFIED', path: entry.path })); + return { + action: 'remove-system-schedule-launcher', + units: units.map((entry) => ({ name: entry.name, path: entry.path })), + blockers, canProceed: blockers.length === 0, mutationPerformed: false, + }; + } + remove() { + const plan = this.planRemoval(); + if (!plan.canProceed) throw new TorchError('Refusing to remove changed or missing system schedule launcher files', { + code: 'SCHEDULE_LAUNCHER_MODIFIED', details: plan.blockers.map((blocker) => blocker.path), + }); 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); diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index d4391cb..9739ffa 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -155,6 +155,14 @@ test('SCN-cli-lifecycle-surface: runtime selection, selective startup, capture, const doctor = run(root, ['doctor', '--json']); assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout); assert.equal(JSON.parse(doctor.stdout).findings.some((finding) => finding.code === 'FLEET_DETACHED'), true); + const uninstallPlan = run(root, ['uninstall', '--dry-run', '--json']); + assert.equal(uninstallPlan.status, 0, uninstallPlan.stderr || uninstallPlan.stdout); + assert.equal(JSON.parse(uninstallPlan.stdout).action, 'uninstall'); + assert.equal(JSON.parse(run(root, ['uninstall', '--json']).stdout).error, 'APPROVAL_REQUIRED'); + const uninstalled = run(root, ['uninstall', '--yes', '--json']); + assert.equal(uninstalled.status, 0, uninstalled.stderr || uninstalled.stdout); + assert.equal(JSON.parse(uninstalled.stdout).mutationPerformed, true); + assert.equal(existsSync(join(root, '.torch', 'torch.yaml')), true); const unapprovedPurge = run(root, ['uninstall', '--purge', '--json']); assert.equal(unapprovedPurge.status, 2); assert.equal(JSON.parse(unapprovedPurge.stdout).error, 'APPROVAL_REQUIRED'); diff --git a/test/fleet/schedules.test.mjs b/test/fleet/schedules.test.mjs index 93be022..ba309e5 100644 --- a/test/fleet/schedules.test.mjs +++ b/test/fleet/schedules.test.mjs @@ -133,6 +133,7 @@ test('SCN-system-schedule-launcher: exact-config user units install, dispatch, a ['--user', 'daemon-reload'], ['--user', 'enable'], ]); assert.equal(launcher.status().stale, false); + assert.equal(launcher.planRemoval().canProceed, true); assert.equal(planUninstall({ repository: inspectRepository(context.root), purge: true, env: context.env }).problems .some((problem) => problem.type === 'persistent-integration-installed'), true); @@ -144,6 +145,12 @@ test('SCN-system-schedule-launcher: exact-config user units install, dispatch, a ); writeFileSync(join(context.root, '.torch', 'torch.yaml'), originalConfig); + writeFileSync(installed.files[0].path, `${readFileSync(installed.files[0].path, 'utf8')}# owner change\n`); + const unsafeRemoval = launcher.planRemoval(); + assert.equal(unsafeRemoval.canProceed, false); + assert.equal(unsafeRemoval.blockers[0].code, 'SCHEDULE_LAUNCHER_MODIFIED'); + writeFileSync(installed.files[0].path, installed.files[0].content); + const removed = launcher.remove(); assert.equal(removed.mutationPerformed, true); assert.equal(installed.files.every((file) => !existsSync(file.path)), true);