diff --git a/README.md b/README.md index f441e00..9d15775 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,9 @@ and the first Claude reference-fleet path: - read-only health diagnostics covering installation integrity, runtime availability, worktree drift count and age, Git operations, and honest recoverability grade; -- conservative, reversible purge; +- 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; - 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 45200c3..d3705a2 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -411,6 +411,16 @@ No uninstall mode may delete or remove a worktree containing: Uninstall MUST stop before destructive cleanup and report exact remediation. +Implementation checkpoint (2026-09-27): CLI purge requires both `--purge` and +explicit `--yes` after a dry-run review. Reversal validates the manifest's +machine-local state path against the project ID and current XDG environment, +allows TORCH-owned historical database and evidence files, and fails closed on +unknown state entries, active runtime identities, local locks, resource leases, +worktree guards, in-flight landing, unsafe worktrees, unique canonical history, +or installed persistent launchers. Historical messages and receipts therefore +do not make reversal permanently impossible, while active or unowned state +still blocks deletion. + ## 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 1ae3caa..c0c6df7 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 @@ -9,7 +9,7 @@ test_integrity_note: - id: SCN-install-doctor-purge given: a reviewed fresh installation into a Git repository when: TORCH installs, diagnoses, and purges its unchanged state - then: doctor reports healthy, all owned TORCH state is removed, and project source remains unchanged + then: active runtime state blocks purge, historical durable state remains safely reversible after shutdown, all owned TORCH state is removed, and project source remains unchanged - id: SCN-existing-repository-preservation given: an existing repository with history, a long-lived branch, owner configuration, and an untracked file when: TORCH is installed and safely purged @@ -254,6 +254,7 @@ test_integrity_note: - measured and estimated context groups, cache-read ratio, and cost per verified item - schedule plan blockers, subprocess shell flag, retry count, and durable failure message - preservation of project source and modified owner configuration + - explicit purge approval, exact XDG project-state path, and active versus historical local-state classification - SQLite state after process restart - CLI and MCP output over real process boundaries - runtime command plans and execution order @@ -290,6 +291,8 @@ test_integrity_note: - doctor that reports healthy without required state - doctor hiding convergence age, an active Git operation, a missing runtime, or one-disk-only recovery - uninstall that deletes owner-modified configuration + - purge without explicit confirmation or through a redirected local-state path + - historical messages making safe reversal permanently impossible - CLI that mutates without explicit approval - in-memory-only messaging that disappears on restart - runtime session IDs masquerading as durable Fleet identities diff --git a/src/cli.mjs b/src/cli.mjs index 71ebad8..7bfad1e 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -141,7 +141,7 @@ Usage: torch fleet activate --change --by --yes [--json] torch fleet start --change [--fresh] [--dry-run] --yes [--json] torch doctor [--json] - torch uninstall [--dry-run] [--purge] [--json] + torch uninstall [--dry-run] [--purge --yes] [--json] `; function print(value, { json = false } = {}) { @@ -1149,10 +1149,16 @@ 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.', { + code: 'APPROVAL_REQUIRED', + }); + } const result = uninstallProject({ repository, purge: argv.includes('--purge'), dryRun: argv.includes('--dry-run'), + env, }); print(result, { json }); return result.canProceed === false ? 1 : 0; diff --git a/src/kernel/install.mjs b/src/kernel/install.mjs index 102b05f..37cca49 100644 --- a/src/kernel/install.mjs +++ b/src/kernel/install.mjs @@ -1,7 +1,8 @@ import { randomUUID } from 'node:crypto'; import { execFileSync } from 'node:child_process'; +import { DatabaseSync } from 'node:sqlite'; import { - existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, + existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { TorchError } from './errors.mjs'; @@ -268,15 +269,74 @@ export function installProject({ } } -function nonEmptyEntries(path, ignoredRoots = []) { - if (!existsSync(path)) return []; - return readdirSync(path, { recursive: true, withFileTypes: true }) - .filter((entry) => entry.isFile()) - .map((entry) => resolve(entry.parentPath, entry.name)) - .filter((entry) => entry !== join(resolve(path), 'project.json')) - .filter((entry) => !ignoredRoots.some((root) => - entry === resolve(root) || entry.startsWith(`${resolve(root)}${sep}`))) - .map((entry) => relative(path, entry)); +function inspectLocalState(statePath, manifest, env, problems) { + const expected = projectStatePath(manifest.projectId, env); + if (resolve(statePath) !== resolve(expected)) { + problems.push({ type: 'unsafe-local-state-path', recorded: statePath, expected }); + return; + } + try { + if (!lstatSync(statePath).isDirectory() || realpathSync(statePath) !== resolve(statePath)) { + problems.push({ type: 'unsafe-local-state-node', path: statePath }); + return; + } + } catch (error) { + problems.push({ type: 'local-state-unreadable', path: statePath, message: error.message }); + return; + } + const allowedFiles = new Set(['project.json', 'state.db', 'state.db-shm', 'state.db-wal']); + const allowedDirectories = new Set([ + 'sessions', 'messages', 'locks', 'leases', 'checks', 'integration', 'logs', 'remote.git', + ]); + const unexpected = readdirSync(statePath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() + ? !allowedDirectories.has(entry.name) : !allowedFiles.has(entry.name)) + .map((entry) => entry.name); + if (unexpected.length) problems.push({ type: 'unknown-local-state', path: statePath, entries: unexpected }); + const locksPath = join(statePath, 'locks'); + const locks = existsSync(locksPath) ? readdirSync(locksPath) : []; + if (locks.length) problems.push({ type: 'active-local-locks', path: locksPath, entries: locks }); + const databasePath = join(statePath, 'state.db'); + if (!existsSync(databasePath)) return; + let database; + try { + database = new DatabaseSync(databasePath, { readOnly: true }); + const hasTable = (name) => Boolean(database.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + ).get(name)); + if (hasTable('identities')) { + const active = database.prepare(` + SELECT area_id AS areaId, state, runtime_session_id AS runtimeSessionId + FROM identities WHERE state != 'offline' ORDER BY area_id + `).all(); + if (active.length) problems.push({ type: 'active-runtime-sessions', sessions: active }); + } + if (hasTable('resource_leases')) { + const leases = database.prepare(` + SELECT resource_id AS resourceId, area_id AS areaId, expires_at AS expiresAt + FROM resource_leases WHERE released_at IS NULL ORDER BY resource_id, acquired_at + `).all(); + if (leases.length) problems.push({ type: 'active-resource-leases', leases }); + } + if (hasTable('worktree_guards')) { + const guards = database.prepare(` + SELECT area_id AS areaId, guard_type AS guardType, reason + FROM worktree_guards WHERE released_at IS NULL ORDER BY created_at, id + `).all(); + if (guards.length) problems.push({ type: 'active-worktree-guards', guards }); + } + if (hasTable('integration_requests')) { + const landing = database.prepare(` + SELECT id, source_area AS sourceArea, source_commit AS sourceCommit + FROM integration_requests WHERE state = 'landing' ORDER BY created_at, id + `).all(); + if (landing.length) problems.push({ type: 'integration-landing-active', requests: landing }); + } + } catch (error) { + problems.push({ type: 'local-state-database-invalid', path: databasePath, message: error.message }); + } finally { + database?.close(); + } } function gitOptional(root, args) { @@ -310,7 +370,7 @@ function inspectCanonicalRemoval(repositoryRoot, entry, localStatePath) { return problems; } -export function planUninstall({ repository, purge = false }) { +export function planUninstall({ repository, purge = false, env = process.env }) { const manifest = readInstallManifest(repository.root); const problems = []; let mainBranch = repository.branch || 'main'; @@ -319,12 +379,19 @@ export function planUninstall({ repository, purge = false }) { mainBranch = config.project?.main_branch ?? mainBranch; } catch { /* created-file validation below reports the invalid config */ } const localState = (manifest.external ?? []).find((entry) => entry.type === 'local-state'); - if (localState && existsSync(join(localState.path, 'project.json'))) { + if (localState && !existsSync(join(localState.path, 'project.json'))) { + problems.push({ type: 'local-state-metadata-missing', path: join(localState.path, 'project.json') }); + } else if (localState) { try { const metadata = JSON.parse(readFileSync(join(localState.path, 'project.json'), 'utf8')); if (resolve(metadata.root) !== resolve(repository.root)) { problems.push({ type: 'not-installation-root', expected: metadata.root, actual: repository.root }); } + if (metadata.projectId !== manifest.projectId) { + problems.push({ + type: 'local-state-project-mismatch', expected: manifest.projectId, actual: metadata.projectId ?? null, + }); + } } catch { problems.push({ type: 'local-state-metadata-invalid', path: localState.path }); } @@ -336,11 +403,7 @@ export function planUninstall({ repository, purge = false }) { } for (const entry of manifest.external ?? []) { if (entry.type === 'local-state') { - const canonicalRoots = (manifest.external ?? []) - .filter((candidate) => candidate.type === 'canonical-remote') - .map((candidate) => candidate.path); - const extras = nonEmptyEntries(entry.path, canonicalRoots); - if (extras.length) problems.push({ type: 'local-state-not-empty', path: entry.path, entries: extras }); + if (existsSync(entry.path)) inspectLocalState(entry.path, manifest, env, problems); } if (entry.type === 'canonical-remote') { if (!localState?.path) problems.push({ type: 'local-state-missing-for-canonical', path: entry.path }); @@ -361,8 +424,8 @@ export function planUninstall({ repository, purge = false }) { }; } -export function uninstallProject({ repository, purge = false, dryRun = false }) { - const plan = planUninstall({ repository, purge }); +export function uninstallProject({ repository, purge = false, dryRun = false, env = process.env }) { + const plan = planUninstall({ repository, purge, env }); if (dryRun || !purge) return plan; if (!plan.canProceed) { throw new TorchError('TORCH purge stopped because managed state changed', { diff --git a/test/fleet/bootstrap-acceptance.test.mjs b/test/fleet/bootstrap-acceptance.test.mjs index 3916a98..725d535 100644 --- a/test/fleet/bootstrap-acceptance.test.mjs +++ b/test/fleet/bootstrap-acceptance.test.mjs @@ -149,7 +149,7 @@ test('SCN-bootstrap-acceptance: one installed fixture proves the complete organi assert.deepEqual(resume.actions.map((action) => action.runtimeSessionId), started.started.map((entry) => entry.runtimeSessionId)); control.close(); - const reversal = planUninstall({ repository: inspectRepository(context.root), purge: true }); + const reversal = planUninstall({ repository: inspectRepository(context.root), purge: true, env: context.env }); assert.equal(reversal.mutationPerformed, false); assert.equal(reversal.manifest.external.filter((entry) => entry.type === 'worktree').length, roster.agents.length); assert.equal(reversal.problems.some((problem) => problem.type === 'unsafe-worktree'), true, diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index 8784af6..d4391cb 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -155,6 +155,9 @@ 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 unapprovedPurge = run(root, ['uninstall', '--purge', '--json']); + assert.equal(unapprovedPurge.status, 2); + assert.equal(JSON.parse(unapprovedPurge.stdout).error, 'APPROVAL_REQUIRED'); }); test('SCN-cli-control-plane: CLI messages, acknowledgements, ownership, and status share durable state', () => { diff --git a/test/fleet/kernel-lifecycle.test.mjs b/test/fleet/kernel-lifecycle.test.mjs index fb30c88..f66e782 100644 --- a/test/fleet/kernel-lifecycle.test.mjs +++ b/test/fleet/kernel-lifecycle.test.mjs @@ -15,6 +15,7 @@ import { } from '../../src/kernel/install.mjs'; import { proposeDomains, validateApprovedProposal } from '../../src/kernel/domains.mjs'; import { createLocalCanonical } from '../../src/canonical/local.mjs'; +import { openControlPlane } from '../../src/control-plane/service.mjs'; function fixture() { const root = mkdtempSync(join(tmpdir(), 'torch-kernel-')); @@ -72,7 +73,16 @@ test('SCN-install-doctor-purge: a fresh install is healthy and exactly reversibl assert.equal(diagnosis.healthy, true); assert.equal(diagnosis.status, 'ok'); - const result = uninstallProject({ repository: inspectRepository(root), purge: true }); + const control = openControlPlane({ repositoryRoot: root, env }); + control.sendMessage({ sender: 'session-manager', recipient: proposal.domains[0].id, body: 'Historical state remains reversible.' }); + control.reportStatus({ areaId: proposal.domains[0].id, state: 'working', summary: 'Still active.' }); + control.close(); + const activePlan = planUninstall({ repository: inspectRepository(root), purge: true, env }); + assert.equal(activePlan.problems.some((problem) => problem.type === 'active-runtime-sessions'), true); + const reopened = openControlPlane({ repositoryRoot: root, env }); + reopened.reportStatus({ areaId: proposal.domains[0].id, state: 'offline', summary: 'Stopped.' }); + reopened.close(); + const result = uninstallProject({ repository: inspectRepository(root), purge: true, env }); assert.equal(result.mutationPerformed, true); assert.equal(existsSync(join(root, '.torch')), false); assert.equal(existsSync(installed.stateRoot), false); @@ -93,7 +103,7 @@ test('SCN-existing-repository-preservation: install and purge preserve history, installProject({ repository, proposal: approvedProposal(repository), env: externalEnv, projectId: 'existing-repository', }); - const purged = uninstallProject({ repository: inspectRepository(root), purge: true }); + const purged = uninstallProject({ repository: inspectRepository(root), purge: true, env: externalEnv }); assert.equal(purged.mutationPerformed, true); assert.equal(readFileSync(join(root, '.projectrc'), 'utf8'), 'owner-setting=true\n'); assert.equal(readFileSync(join(root, 'owner-notes.txt'), 'utf8'), 'untracked owner data\n'); @@ -109,13 +119,33 @@ test('SCN-purge-protects-user-change: uninstall refuses to delete modified manag writeFileSync(configPath, `${readFileSync(configPath, 'utf8')}\n# owner change\n`); assert.throws( - () => uninstallProject({ repository: inspectRepository(root), purge: true }), + () => uninstallProject({ repository: inspectRepository(root), purge: true, env }), (error) => error.code === 'UNSAFE_TO_PURGE' && error.details.some((problem) => problem.type === 'modified' && problem.path === '.torch/torch.yaml'), ); assert.equal(existsSync(configPath), true); }); +test('SCN-purge-protects-local-identity: uninstall refuses mismatched machine-local metadata', () => { + const { root, env } = fixture(); + const repository = inspectRepository(root); + const installed = installProject({ + repository, proposal: approvedProposal(repository), env, projectId: 'expected-project', + }); + const metadataPath = join(installed.stateRoot, 'project.json'); + const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')); + writeFileSync(metadataPath, `${JSON.stringify({ ...metadata, projectId: 'different-project' }, null, 2)}\n`); + + const plan = planUninstall({ repository: inspectRepository(root), purge: true, env }); + assert.equal(plan.canProceed, false); + assert.equal(plan.problems.some((problem) => problem.type === 'local-state-project-mismatch'), true); + assert.throws( + () => uninstallProject({ repository: inspectRepository(root), purge: true, env }), + (error) => error.code === 'UNSAFE_TO_PURGE', + ); + assert.equal(existsSync(installed.stateRoot), true); +}); + test('SCN-domain-approval: installation rejects stale or unapproved organizational proposals', () => { const { root, env } = fixture(); const repository = inspectRepository(root); @@ -180,15 +210,15 @@ test('SCN-canonical-reversal: owned local canonical state reverses only after un ], { encoding: 'utf8' }).trim(); execFileSync('git', ['--git-dir', canonical.path, 'update-ref', 'refs/heads/remote-only', uniqueCommit]); repository = inspectRepository(root); - assert.equal(planUninstall({ repository, purge: true }).problems.some((problem) => + assert.equal(planUninstall({ repository, purge: true, env: externalEnv }).problems.some((problem) => problem.type === 'canonical-unique-commit' && problem.commit === uniqueCommit), true); assert.throws( - () => uninstallProject({ repository, purge: true }), + () => uninstallProject({ repository, purge: true, env: externalEnv }), (error) => error.code === 'UNSAFE_TO_PURGE', ); execFileSync('git', ['--git-dir', canonical.path, 'update-ref', '-d', 'refs/heads/remote-only']); - const purged = uninstallProject({ repository: inspectRepository(root), purge: true }); + const purged = uninstallProject({ repository: inspectRepository(root), purge: true, env: externalEnv }); assert.equal(purged.mutationPerformed, true); assert.equal(existsSync(installed.stateRoot), false); assert.equal(execFileSync('git', ['-C', root, 'remote'], { encoding: 'utf8' }).includes('torch-canonical'), false); diff --git a/test/fleet/schedules.test.mjs b/test/fleet/schedules.test.mjs index a7fc794..93be022 100644 --- a/test/fleet/schedules.test.mjs +++ b/test/fleet/schedules.test.mjs @@ -133,7 +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(planUninstall({ repository: inspectRepository(context.root), purge: true }).problems + assert.equal(planUninstall({ repository: inspectRepository(context.root), purge: true, env: context.env }).problems .some((problem) => problem.type === 'persistent-integration-installed'), true); const originalConfig = readFileSync(join(context.root, '.torch', 'torch.yaml'), 'utf8'); diff --git a/test/fleet/worktree-lifecycle.test.mjs b/test/fleet/worktree-lifecycle.test.mjs index 7467842..51b10fb 100644 --- a/test/fleet/worktree-lifecycle.test.mjs +++ b/test/fleet/worktree-lifecycle.test.mjs @@ -75,7 +75,7 @@ test('SCN-worktree-bootstrap: approved identities receive isolated branches and finding.code === 'RECOVERABILITY' && finding.level === 'ONE-DISK'), true); assert.equal(diagnosis.findings.some((finding) => finding.code.startsWith('RUNTIME_')), true); unlinkSync(mergeHead); - uninstallProject({ repository: inspectRepository(fixture.root), purge: true }); + uninstallProject({ repository: inspectRepository(fixture.root), purge: true, env: fixture.env }); assert.equal(created.created.some((action) => existsSync(action.path)), false); assert.equal(readFileSync(join(fixture.root, '.git', 'info', 'exclude'), 'utf8').includes('.task'), false); }); @@ -86,19 +86,19 @@ test('SCN-worktree-purge-safety: modified or uniquely committed domain work bloc const core = created.created.find((entry) => entry.area === 'core'); writeFileSync(join(core.path, 'domain-work.txt'), 'not safe to delete\n'); - const plan = planUninstall({ repository: inspectRepository(fixture.root), purge: true }); + const plan = planUninstall({ repository: inspectRepository(fixture.root), purge: true, env: fixture.env }); assert.equal(plan.canProceed, false); assert.equal(plan.problems.some((problem) => problem.type === 'unsafe-worktree' && problem.area === 'core' && problem.problems.includes('worktree-dirty')), true); execFileSync('git', ['-C', core.path, 'add', 'domain-work.txt']); execFileSync('git', ['-C', core.path, 'commit', '-m', 'domain work']); - const committedPlan = planUninstall({ repository: inspectRepository(fixture.root), purge: true }); + const committedPlan = planUninstall({ repository: inspectRepository(fixture.root), purge: true, env: fixture.env }); assert.equal(committedPlan.problems.some((problem) => problem.type === 'unsafe-worktree' && problem.area === 'core' && problem.problems.includes('unique-commits:1')), true); assert.throws( - () => uninstallProject({ repository: inspectRepository(fixture.root), purge: true }), + () => uninstallProject({ repository: inspectRepository(fixture.root), purge: true, env: fixture.env }), (error) => error.code === 'UNSAFE_TO_PURGE', ); assert.equal(existsSync(join(core.path, 'domain-work.txt')), true);