diff --git a/packages/coding-agent/src/session/task-artifact-owner-retirement.ts b/packages/coding-agent/src/session/task-artifact-owner-retirement.ts new file mode 100644 index 00000000000..27fcbeeffd1 --- /dev/null +++ b/packages/coding-agent/src/session/task-artifact-owner-retirement.ts @@ -0,0 +1,475 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { NativeDirectoryTreeSnapshot, NativeExactUnlinkResult } from "@gajae-code/natives"; +import type { ManagedSessionDescendantStore } from "./internal/managed-session-storage"; +import { + captureOwnerTreeIfPresent, + captureValidatedOwnerTree, + newSessionRootStore, + ownerTreeHasPendingManagedPublication, +} from "./internal/task-artifact-owner-access"; +import { + EMPTY_PAYLOAD_SHA256, + isKnownNativeSidePath, + isOwnerRetainedRoot, + OWNER_DIRECTORY, + OWNER_RETIREMENT_SCHEMA_VERSION, + ownerAbsolutePath, + parseTaskArtifactOwnerDeletionEvidence, + parseTaskArtifactOwnerRetirementContinuation, + parseTaskArtifactOwnerRetirementOutcome, + retainedTreeDoesNotExpandAuthority, + sameOwnerParentIdentity, + type TaskArtifactOwnerDeletionEvidence, + type TaskArtifactOwnerRetirementContinuation, + type TaskArtifactOwnerRetirementOutcome, + type TaskArtifactOwnerStorageContext, +} from "./task-artifact-owner-codec"; + +function mergeUniqueStrings( + previous: readonly string[] | undefined, + next: string | undefined, + maximum: number, +): string[] | undefined { + const values = new Set(previous ?? []); + if (next !== undefined) values.add(next); + if (values.size > maximum) throw new Error("task_artifact_owner_native_diagnostic_limit_exceeded"); + return values.size > 0 ? [...values] : undefined; +} + +function retainedSidePaths( + context: TaskArtifactOwnerStorageContext, + evidence: TaskArtifactOwnerDeletionEvidence, + previous: readonly string[] | undefined, + next: string | undefined, +): string[] | undefined { + const values = mergeUniqueStrings(previous, next, 256) ?? []; + const ownerParent = path.dirname(ownerAbsolutePath(context, evidence.locator.ownerId)); + const retained: string[] = []; + for (const pathname of values) { + if (!isKnownNativeSidePath(context, evidence.locator.ownerId, pathname)) + throw new Error("task_artifact_owner_native_side_path_unrecognized"); + if (path.dirname(pathname) !== ownerParent) { + retained.push(pathname); + continue; + } + try { + fs.lstatSync(pathname, { bigint: true }); + retained.push(pathname); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") retained.push(pathname); + } + } + return retained.length > 0 ? retained : undefined; +} + +function continuationRecord( + context: TaskArtifactOwnerStorageContext, + evidence: TaskArtifactOwnerDeletionEvidence, + retainedRootPath: string, + retainedTreeSnapshot: NativeDirectoryTreeSnapshot, + previous?: TaskArtifactOwnerRetirementContinuation, + result?: NativeExactUnlinkResult, +): TaskArtifactOwnerRetirementContinuation { + const detachedPaths = mergeUniqueStrings(previous?.detachedPaths, result?.detachedPath, 2); + if (detachedPaths?.some(pathname => !isOwnerRetainedRoot(context, evidence.locator.ownerId, pathname))) + throw new Error("task_artifact_owner_native_retained_root_unrecognized"); + const nativeCodes = mergeUniqueStrings(previous?.nativeCodes, result?.code, 64); + if (nativeCodes?.some(code => !/^[A-Za-z0-9_.:-]{1,128}$/u.test(code))) + throw new Error("task_artifact_owner_native_code_unrecognized"); + const retainedSuccessorPaths = retainedSidePaths( + context, + evidence, + previous?.retainedSuccessorPaths, + result?.retainedSuccessorPath, + ); + const retainedPlaceholderPaths = retainedSidePaths( + context, + evidence, + previous?.retainedPlaceholderPaths, + result?.retainedPlaceholderPath, + ); + const retainedUnknownPaths = retainedSidePaths( + context, + evidence, + previous?.retainedUnknownPaths, + result?.retainedUnknownPath, + ); + const windowsErrorCodes = mergeUniqueStrings(previous?.windowsErrorCodes, result?.windowsErrorCode, 64); + if (windowsErrorCodes?.some(code => !/^0x[0-9A-Fa-f]{8}$/u.test(code))) + throw new Error("task_artifact_owner_native_windows_code_unrecognized"); + const payloadDurable = + previous?.payloadDurable === true || result?.payloadDurable === true + ? true + : (result?.payloadDurable ?? previous?.payloadDurable); + return parseTaskArtifactOwnerRetirementContinuation(context, evidence, { + schemaVersion: OWNER_RETIREMENT_SCHEMA_VERSION, + parentIdentity: evidence.parentIdentity, + retainedRootPath, + retainedTreeSnapshot, + ...(detachedPaths !== undefined ? { detachedPaths } : {}), + ...(nativeCodes !== undefined ? { nativeCodes } : {}), + ...(payloadDurable !== undefined ? { payloadDurable } : {}), + ...(retainedSuccessorPaths !== undefined ? { retainedSuccessorPaths } : {}), + ...(retainedPlaceholderPaths !== undefined ? { retainedPlaceholderPaths } : {}), + ...(retainedUnknownPaths !== undefined ? { retainedUnknownPaths } : {}), + ...(windowsErrorCodes !== undefined ? { windowsErrorCodes } : {}), + }); +} + +function defaultContinuation( + context: TaskArtifactOwnerStorageContext, + evidence: TaskArtifactOwnerDeletionEvidence, + previous?: TaskArtifactOwnerRetirementContinuation, +): TaskArtifactOwnerRetirementContinuation { + return ( + previous ?? + continuationRecord( + context, + evidence, + `${ownerAbsolutePath(context, evidence.locator.ownerId)}.removing`, + evidence.treeSnapshot, + ) + ); +} + +function nativeResultSidePathsRemain( + context: TaskArtifactOwnerStorageContext, + rootStore: ManagedSessionDescendantStore, + evidence: TaskArtifactOwnerDeletionEvidence, + continuation: TaskArtifactOwnerRetirementContinuation, +): boolean { + const ownerParent = path.dirname(ownerAbsolutePath(context, evidence.locator.ownerId)); + try { + if (!sameOwnerParentIdentity(rootStore.captureDirectoryIdentity(OWNER_DIRECTORY), evidence.parentIdentity)) + return true; + } catch { + return true; + } + let remains = false; + for (const pathname of [ + ...(continuation.retainedSuccessorPaths ?? []), + ...(continuation.retainedPlaceholderPaths ?? []), + ...(continuation.retainedUnknownPaths ?? []), + ]) { + if (path.dirname(pathname) !== ownerParent) { + remains = true; + continue; + } + try { + fs.lstatSync(pathname, { bigint: true }); + remains = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") remains = true; + } + } + try { + return ( + remains || + !sameOwnerParentIdentity(rootStore.captureDirectoryIdentity(OWNER_DIRECTORY), evidence.parentIdentity) + ); + } catch { + return true; + } +} + +/** Confirm physical completion from a managed native proof, never from canonical absence alone. */ +export function verifyTaskArtifactOwnerPhysicalRetirement( + context: TaskArtifactOwnerStorageContext, + evidence: TaskArtifactOwnerDeletionEvidence, + value: unknown, +): void { + const outcome = parseTaskArtifactOwnerRetirementOutcome(context, evidence, value); + if (outcome.kind !== "completed") throw new Error("task_artifact_owner_physical_retirement_unverified"); + const store = newSessionRootStore(context); + try { + if (!sameOwnerParentIdentity(store.captureDirectoryIdentity(OWNER_DIRECTORY), evidence.parentIdentity)) + throw new Error("task_artifact_owner_parent_identity_mismatch"); + const canonical = ownerAbsolutePath(context, evidence.locator.ownerId); + if ( + captureOwnerTreeIfPresent(context, store, evidence.locator.ownerId, canonical) || + captureOwnerTreeIfPresent(context, store, evidence.locator.ownerId, `${canonical}.removing`) + ) + throw new Error("task_artifact_owner_physical_retirement_unverified"); + } finally { + store.close(); + } +} + +/** Revalidate a persisted remnant through managed authority without mutation or new payload proof. */ +export function verifyTaskArtifactOwnerRetirementContinuation( + context: TaskArtifactOwnerStorageContext, + evidenceValue: TaskArtifactOwnerDeletionEvidence, + value: unknown, +): TaskArtifactOwnerRetirementContinuation { + const evidence = parseTaskArtifactOwnerDeletionEvidence(evidenceValue); + const continuation = parseTaskArtifactOwnerRetirementContinuation(context, evidence, value); + const store = newSessionRootStore(context); + try { + if (!sameOwnerParentIdentity(store.captureDirectoryIdentity(OWNER_DIRECTORY), evidence.parentIdentity)) + throw new Error("task_artifact_owner_parent_identity_mismatch"); + const snapshot = captureOwnerTreeIfPresent( + context, + store, + evidence.locator.ownerId, + continuation.retainedRootPath, + ); + if (!snapshot || !retainedTreeDoesNotExpandAuthority(continuation.retainedTreeSnapshot, snapshot)) + throw new Error("task_artifact_owner_retained_tree_mismatch"); + if ( + continuation.payloadDurable === true && + snapshot.entries.some( + entry => entry.kind === "file" && (entry.size !== "0" || entry.sha256 !== EMPTY_PAYLOAD_SHA256), + ) + ) + throw new Error("task_artifact_owner_retired_payload_changed"); + return continuation; + } finally { + store.close(); + } +} + +/** Continue exact native removal without widening the original owner-tree authority. */ +export function retireTaskArtifactOwner( + context: TaskArtifactOwnerStorageContext, + evidenceValue: TaskArtifactOwnerDeletionEvidence, + continuationValue?: unknown, +): TaskArtifactOwnerRetirementOutcome { + const evidence = parseTaskArtifactOwnerDeletionEvidence(evidenceValue); + const previous = + continuationValue === undefined + ? undefined + : parseTaskArtifactOwnerRetirementContinuation(context, evidence, continuationValue); + const fallback = defaultContinuation(context, evidence, previous); + const rootStore = newSessionRootStore(context); + try { + try { + rootStore.verifyRootSecurity(); + } catch { + return { + kind: "uncertain", + evidence, + continuation: fallback, + reason: "task_artifact_owner_namespace_unverified", + }; + } + let currentParent: { readonly dev: string; readonly ino: string }; + try { + currentParent = rootStore.captureDirectoryIdentity(OWNER_DIRECTORY); + } catch { + return { + kind: "uncertain", + evidence, + continuation: fallback, + reason: "task_artifact_owner_parent_unavailable", + }; + } + if (!sameOwnerParentIdentity(currentParent, evidence.parentIdentity)) + return { + kind: "uncertain", + evidence, + continuation: fallback, + reason: "task_artifact_owner_parent_identity_changed", + }; + + // Publication markers can hold writable descriptors; scrubbing cannot revoke them. + if (ownerTreeHasPendingManagedPublication(evidence.treeSnapshot)) + return { + kind: "uncertain", + evidence, + continuation: fallback, + reason: "task_artifact_owner_writer_not_quiescent", + }; + + const ownerPath = ownerAbsolutePath(context, evidence.locator.ownerId); + const removingPath = `${ownerPath}.removing`; + const baseline = previous?.retainedTreeSnapshot ?? evidence.treeSnapshot; + const candidates = [ + ...new Set( + [previous?.retainedRootPath, removingPath, ownerPath].filter( + (pathname): pathname is string => pathname !== undefined, + ), + ), + ]; + let retainedRootPath: string | undefined; + let retainedTreeSnapshot: NativeDirectoryTreeSnapshot | undefined; + for (const candidate of candidates) { + let snapshot: NativeDirectoryTreeSnapshot | undefined; + try { + snapshot = captureOwnerTreeIfPresent(context, rootStore, evidence.locator.ownerId, candidate); + } catch { + return { + kind: "uncertain", + evidence, + continuation: fallback, + reason: "task_artifact_owner_retained_tree_unavailable", + }; + } + if (snapshot && ownerTreeHasPendingManagedPublication(snapshot)) + return { + kind: "uncertain", + evidence, + continuation: fallback, + reason: "task_artifact_owner_writer_not_quiescent", + }; + if ( + !snapshot || + snapshot.rootDev !== evidence.locator.directoryDev || + snapshot.rootIno !== evidence.locator.directoryIno || + !retainedTreeDoesNotExpandAuthority(baseline, snapshot) + ) + continue; + if (candidate === ownerPath && JSON.stringify(snapshot) === JSON.stringify(evidence.treeSnapshot)) { + try { + const validated = captureValidatedOwnerTree(context, rootStore, evidence.sessionId, evidence.locator); + if (JSON.stringify(validated) !== JSON.stringify(evidence.treeSnapshot)) + return { + kind: "uncertain", + evidence, + continuation: fallback, + reason: "task_artifact_owner_changed_since_capture", + }; + } catch { + return { + kind: "uncertain", + evidence, + continuation: fallback, + reason: "task_artifact_owner_identity_unverified", + }; + } + } + retainedRootPath = candidate; + retainedTreeSnapshot = snapshot; + break; + } + if (!retainedRootPath || !retainedTreeSnapshot) + return { + kind: "uncertain", + evidence, + continuation: fallback, + reason: "task_artifact_owner_retained_root_not_found_or_unverified", + }; + + let nativeOutcome: NativeExactUnlinkResult; + try { + nativeOutcome = rootStore.removeTreeExpectedWithParentIdentity( + path.relative(context.sessionsRoot, retainedRootPath).split(path.sep).join("/"), + retainedTreeSnapshot, + { dev: BigInt(evidence.parentIdentity.dev), ino: BigInt(evidence.parentIdentity.ino) }, + ); + } catch { + return { + kind: "uncertain", + evidence, + continuation: fallback, + reason: "task_artifact_owner_native_removal_uncertain", + }; + } + let parentStillBound = false; + try { + parentStillBound = sameOwnerParentIdentity( + rootStore.captureDirectoryIdentity(OWNER_DIRECTORY), + evidence.parentIdentity, + ); + } catch { + parentStillBound = false; + } + if (!parentStillBound) + return { + kind: "uncertain", + evidence, + continuation: fallback, + reason: "task_artifact_owner_parent_identity_changed_after_removal", + nativeOutcome, + }; + + const reportedRoot = nativeOutcome.detachedPath; + const reportedRootKnown = + reportedRoot === undefined || isOwnerRetainedRoot(context, evidence.locator.ownerId, reportedRoot); + const nextRoot = reportedRootKnown && reportedRoot !== undefined ? reportedRoot : retainedRootPath; + let residualTree: NativeDirectoryTreeSnapshot | undefined; + let residualValid = reportedRootKnown; + try { + residualTree = captureOwnerTreeIfPresent(context, rootStore, evidence.locator.ownerId, nextRoot); + if ( + residualTree && + (residualTree.rootDev !== evidence.locator.directoryDev || + residualTree.rootIno !== evidence.locator.directoryIno || + !retainedTreeDoesNotExpandAuthority(retainedTreeSnapshot, residualTree)) + ) + residualValid = false; + } catch { + residualValid = false; + } + const nextTree = residualValid && residualTree ? residualTree : retainedTreeSnapshot; + let nextContinuation: TaskArtifactOwnerRetirementContinuation; + try { + nextContinuation = continuationRecord(context, evidence, nextRoot, nextTree, previous, nativeOutcome); + } catch { + return { + kind: "uncertain", + evidence, + continuation: continuationRecord(context, evidence, retainedRootPath, retainedTreeSnapshot, previous), + reason: "task_artifact_owner_native_retained_state_unrecognized", + nativeOutcome, + }; + } + if (!residualValid) + return { + kind: "uncertain", + evidence, + continuation: nextContinuation, + reason: "task_artifact_owner_native_retained_state_unverified", + nativeOutcome, + }; + + let ownerRootRemains = false; + for (const candidate of [ownerPath, removingPath]) { + let snapshot: NativeDirectoryTreeSnapshot | undefined; + try { + snapshot = captureOwnerTreeIfPresent(context, rootStore, evidence.locator.ownerId, candidate); + } catch { + ownerRootRemains = true; + break; + } + if (snapshot) { + ownerRootRemains = true; + break; + } + } + const sidePathsRemain = nativeResultSidePathsRemain(context, rootStore, evidence, nextContinuation); + if (nativeOutcome.ok && !ownerRootRemains && !sidePathsRemain) + return { kind: "completed", evidence, nativeOutcome }; + if ( + !nativeOutcome.ok && + nativeOutcome.code === "cleanup_pending" && + nativeOutcome.payloadDurable === true && + residualTree && + nextRoot === removingPath && + !sidePathsRemain && + residualTree.entries.every( + entry => entry.kind === "directory" || (entry.size === "0" && entry.sha256 === EMPTY_PAYLOAD_SHA256), + ) + ) + return { + kind: "payload_retired", + namespace: "retained", + evidence, + continuation: nextContinuation, + nativeOutcome, + }; + if (!nativeOutcome.ok && nativeOutcome.code === "cleanup_pending") + return { kind: "cleanup_pending", evidence, continuation: nextContinuation, nativeOutcome }; + return { + kind: "uncertain", + evidence, + continuation: nextContinuation, + reason: nativeOutcome.ok + ? "task_artifact_owner_native_success_retained_authority" + : `task_artifact_owner_native_removal_${nativeOutcome.code ?? "uncertain"}`, + nativeOutcome, + }; + } finally { + rootStore.close(); + } +} diff --git a/packages/coding-agent/test/task-artifact-owner-retirement.test.ts b/packages/coding-agent/test/task-artifact-owner-retirement.test.ts new file mode 100644 index 00000000000..71e5231bf2c --- /dev/null +++ b/packages/coding-agent/test/task-artifact-owner-retirement.test.ts @@ -0,0 +1,345 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { NativeExactUnlinkResult } from "@gajae-code/natives"; +import * as native from "@gajae-code/natives"; +import { + type ManagedDirectoryRoot, + ManagedSessionDescendantStore, + prepareManagedDirectoryRoot, +} from "../src/session/internal/managed-session-storage"; +import { captureTaskArtifactOwnerDeletionEvidence } from "../src/session/internal/task-artifact-owner-access"; +import { + OWNER_DIRECTORY, + OWNER_MANIFEST, + OWNER_RETIREMENT_SCHEMA_VERSION, + OWNER_SCHEMA_VERSION, + ownerAbsolutePath, + ownerIdForSession, + ownerRelativePath, + parseTaskArtifactOwnerRetirementOutcome, + type TaskArtifactOwnerDeletionEvidence, + type TaskArtifactOwnerLocator, + type TaskArtifactOwnerRetirementContinuation, + type TaskArtifactOwnerStorageContext, +} from "../src/session/task-artifact-owner-codec"; +import { + retireTaskArtifactOwner, + verifyTaskArtifactOwnerPhysicalRetirement, + verifyTaskArtifactOwnerRetirementContinuation, +} from "../src/session/task-artifact-owner-retirement"; + +interface OwnerFixture { + readonly root: string; + readonly sessionId: string; + readonly context: TaskArtifactOwnerStorageContext; + readonly locator: TaskArtifactOwnerLocator; + readonly ownerPath: string; + readonly evidence: TaskArtifactOwnerDeletionEvidence; +} + +const fixtureRoots: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const root of fixtureRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function expectedDirectoryRoot(pathname: string): ManagedDirectoryRoot { + const stat = fs.lstatSync(pathname, { bigint: true }); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("fixture_directory_invalid"); + return { + canonicalPath: path.resolve(pathname), + dev: BigInt.asUintN(64, stat.dev), + ino: BigInt.asUintN(64, stat.ino), + }; +} + +async function makeOwnerFixture(): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "task-owner-retirement-")); + fixtureRoots.push(root); + const profileAgentDir = path.resolve(root); + const sessionsRoot = path.join(profileAgentDir, "sessions"); + fs.mkdirSync(sessionsRoot, { mode: 0o700 }); + const securityPolicy = process.platform === "win32" ? "windows-existing-verify-first" : "default"; + const context: TaskArtifactOwnerStorageContext = { + rootAuthority: prepareManagedDirectoryRoot(profileAgentDir, securityPolicy), + sessionsRoot, + securityPolicy, + profileAgentDir, + }; + const sessionId = `retirement-session-${crypto.randomUUID()}`; + const ownerId = ownerIdForSession(sessionId); + const rootStore = new ManagedSessionDescendantStore( + context.rootAuthority, + sessionsRoot, + undefined, + securityPolicy, + profileAgentDir, + expectedDirectoryRoot(sessionsRoot), + ); + let ownerStore: ManagedSessionDescendantStore | undefined; + let locator: TaskArtifactOwnerLocator; + let ownerPath: string; + let ownerRoot: ManagedDirectoryRoot; + try { + rootStore.verifyRootSecurity(); + rootStore.ensureDirectory(OWNER_DIRECTORY); + ownerRoot = rootStore.ensureDirectory(ownerRelativePath(ownerId)); + locator = { + schemaVersion: OWNER_SCHEMA_VERSION, + ownerId, + directoryDev: ownerRoot.dev.toString(), + directoryIno: ownerRoot.ino.toString(), + }; + ownerPath = ownerAbsolutePath(context, ownerId); + ownerStore = new ManagedSessionDescendantStore( + context.rootAuthority, + ownerPath, + undefined, + securityPolicy, + profileAgentDir, + ownerRoot, + ); + await ownerStore.publishNoReplace( + OWNER_MANIFEST, + Buffer.from(`${JSON.stringify({ ...locator, sessionId })}\n`, "utf8"), + ); + await ownerStore.publishNoReplace("artifact.bin", Buffer.from("original-owner-payload", "utf8")); + } finally { + ownerStore?.close(); + rootStore.close(); + } + const evidence = captureTaskArtifactOwnerDeletionEvidence(context, sessionId, locator); + if (!evidence) throw new Error("fixture_owner_evidence_missing"); + return { root, sessionId, context, locator, ownerPath, evidence }; +} + +function continuationFor( + fixture: OwnerFixture, + retainedRootPath = `${fixture.ownerPath}.removing`, +): TaskArtifactOwnerRetirementContinuation { + return { + schemaVersion: OWNER_RETIREMENT_SCHEMA_VERSION, + parentIdentity: fixture.evidence.parentIdentity, + retainedRootPath, + retainedTreeSnapshot: fixture.evidence.treeSnapshot, + }; +} + +function observeActualNativeRemoval() { + const directRemove = native.exactRemoveDirectoryTree; + const calls: NativeExactUnlinkResult[] = []; + const spy = vi.spyOn(native, "exactRemoveDirectoryTree").mockImplementation((pathname, snapshot, parent) => { + const result = directRemove(pathname, snapshot, parent); + calls.push(result); + return result; + }); + return { calls, spy }; +} + +describe("task artifact owner native retirement", () => { + it("forwards the actual native outcome and only verifies a completed physical removal", async () => { + const fixture = await makeOwnerFixture(); + const { calls, spy } = observeActualNativeRemoval(); + const outcome = retireTaskArtifactOwner(fixture.context, fixture.evidence); + expect(spy).toHaveBeenCalledTimes(1); + expect(["completed", "payload_retired", "cleanup_pending", "uncertain"]).toContain(outcome.kind); + if (calls.length > 0) expect(outcome.nativeOutcome).toBe(calls[0]); + if (outcome.kind === "completed") { + expect(calls[0]?.ok).toBe(true); + verifyTaskArtifactOwnerPhysicalRetirement(fixture.context, fixture.evidence, outcome); + } else if (outcome.kind === "payload_retired") { + expect(calls[0]?.ok).toBe(false); + expect(calls[0]?.code).toBe("cleanup_pending"); + expect(calls[0]?.payloadDurable).toBe(true); + expect(outcome.continuation.payloadDurable).toBe(true); + expect(outcome.continuation.retainedRootPath).toBe(`${fixture.ownerPath}.removing`); + } else if (outcome.kind === "cleanup_pending") { + expect(calls[0]?.ok).toBe(false); + expect(calls[0]?.code).toBe("cleanup_pending"); + } + }); + it("continues an actually retained .removing tree without expanding the captured authority", async () => { + const fixture = await makeOwnerFixture(); + const removingPath = `${fixture.ownerPath}.removing`; + fs.renameSync(fixture.ownerPath, removingPath); + const previous = continuationFor(fixture, removingPath); + expect(verifyTaskArtifactOwnerRetirementContinuation(fixture.context, fixture.evidence, previous)).toEqual( + previous, + ); + + const { calls, spy } = observeActualNativeRemoval(); + const outcome = retireTaskArtifactOwner(fixture.context, fixture.evidence, previous); + expect(spy).toHaveBeenCalledTimes(1); + if (calls.length > 0) expect(outcome.nativeOutcome).toBe(calls[0]); + if (outcome.kind === "completed") + verifyTaskArtifactOwnerPhysicalRetirement(fixture.context, fixture.evidence, outcome); + else { + expect(outcome.continuation.retainedRootPath).toBe(removingPath); + if (fs.existsSync(removingPath)) + expect( + verifyTaskArtifactOwnerRetirementContinuation(fixture.context, fixture.evidence, outcome.continuation) + .retainedRootPath, + ).toBe(removingPath); + } + }); + it("refuses restored staging and replacement writer markers before any native call", async () => { + for (const phase of ["staging", "replacement"] as const) { + const fixture = await makeOwnerFixture(); + const marker = `.${phase}.${crypto.randomUUID()}.${phase}`; + const markerPath = path.join(fixture.ownerPath, marker); + fs.writeFileSync(markerPath, "restored managed writer marker", { mode: 0o600 }); + const { spy } = observeActualNativeRemoval(); + + const outcome = retireTaskArtifactOwner(fixture.context, fixture.evidence); + expect(outcome.kind).toBe("uncertain"); + if (outcome.kind !== "uncertain") throw new Error("writer marker unexpectedly retired"); + expect(outcome.reason).toBe("task_artifact_owner_writer_not_quiescent"); + expect(spy).not.toHaveBeenCalled(); + expect(fs.readFileSync(markerPath, "utf8")).toBe("restored managed writer marker"); + vi.restoreAllMocks(); + } + }); + it("preserves changed payload hashes and replacement manifests without calling native removal", async () => { + for (const replacement of ["payload", "manifest"] as const) { + const fixture = await makeOwnerFixture(); + const pathname = path.join(fixture.ownerPath, replacement === "payload" ? "artifact.bin" : OWNER_MANIFEST); + const bytes = Buffer.from(replacement === "payload" ? "changed-owner-payload" : "replaced-manifest", "utf8"); + fs.writeFileSync(pathname, bytes); + const { spy } = observeActualNativeRemoval(); + + const outcome = retireTaskArtifactOwner(fixture.context, fixture.evidence); + expect(outcome.kind).toBe("uncertain"); + expect(spy).not.toHaveBeenCalled(); + expect(fs.readFileSync(pathname)).toEqual(bytes); + vi.restoreAllMocks(); + } + }); + + it("preserves a replaced owner root and owner parent instead of repairing either namespace", async () => { + const rootFixture = await makeOwnerFixture(); + const displacedOwner = `${rootFixture.ownerPath}.original-${crypto.randomUUID()}`; + const replacementOwner = `${rootFixture.ownerPath}.replacement-${crypto.randomUUID()}`; + fs.mkdirSync(replacementOwner, { mode: 0o700 }); + const replacementBytes = Buffer.from("untrusted replacement payload", "utf8"); + fs.writeFileSync( + path.join(replacementOwner, OWNER_MANIFEST), + `${JSON.stringify({ ...rootFixture.locator, sessionId: rootFixture.sessionId })}\n`, + { mode: 0o600 }, + ); + fs.writeFileSync(path.join(replacementOwner, "artifact.bin"), replacementBytes, { mode: 0o600 }); + fs.renameSync(rootFixture.ownerPath, displacedOwner); + fs.renameSync(replacementOwner, rootFixture.ownerPath); + const rootSpy = observeActualNativeRemoval().spy; + try { + const outcome = retireTaskArtifactOwner(rootFixture.context, rootFixture.evidence); + expect(outcome.kind).toBe("uncertain"); + expect(rootSpy).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(rootFixture.ownerPath, "artifact.bin"))).toEqual(replacementBytes); + } finally { + fs.renameSync(rootFixture.ownerPath, replacementOwner); + fs.renameSync(displacedOwner, rootFixture.ownerPath); + vi.restoreAllMocks(); + } + + const parentFixture = await makeOwnerFixture(); + const parent = path.join(parentFixture.context.sessionsRoot, OWNER_DIRECTORY); + const displacedParent = `${parent}.original-${crypto.randomUUID()}`; + fs.renameSync(parent, displacedParent); + fs.mkdirSync(parent, { mode: 0o700 }); + const sentinel = path.join(parent, "foreign-parent-sentinel"); + fs.writeFileSync(sentinel, "untouched parent occupant", { mode: 0o600 }); + const parentSpy = observeActualNativeRemoval().spy; + try { + const outcome = retireTaskArtifactOwner(parentFixture.context, parentFixture.evidence); + expect(outcome.kind).toBe("uncertain"); + expect(parentSpy).not.toHaveBeenCalled(); + expect(fs.readFileSync(sentinel, "utf8")).toBe("untouched parent occupant"); + } finally { + fs.rmSync(parent, { recursive: true, force: true }); + fs.renameSync(displacedParent, parent); + vi.restoreAllMocks(); + } + }); + + it("rejects forged completion, contradictory native side roles, and expanding historical authority", async () => { + const fixture = await makeOwnerFixture(); + const forgedCompletion = { + kind: "completed", + evidence: fixture.evidence, + nativeOutcome: { ok: true }, + }; + expect(() => + verifyTaskArtifactOwnerPhysicalRetirement(fixture.context, fixture.evidence, forgedCompletion), + ).toThrow("task_artifact_owner_physical_retirement_unverified"); + + const sidePath = path.join(path.dirname(fixture.ownerPath), ".gjc-native-side-role"); + expect(() => + parseTaskArtifactOwnerRetirementOutcome(fixture.context, fixture.evidence, { + kind: "completed", + evidence: fixture.evidence, + nativeOutcome: { ok: true, retainedUnknownPath: sidePath }, + }), + ).toThrow("task_artifact_owner_retirement_outcome_invalid"); + expect(() => + parseTaskArtifactOwnerRetirementOutcome(fixture.context, fixture.evidence, { + kind: "cleanup_pending", + evidence: fixture.evidence, + continuation: continuationFor(fixture), + nativeOutcome: { ok: false, code: "cleanup_pending", retainedUnknownPath: sidePath }, + }), + ).toThrow("task_artifact_owner_retirement_outcome_invalid"); + + const root = fixture.evidence.treeSnapshot.entries.find(entry => entry.relativePath === ""); + if (!root) throw new Error("fixture owner snapshot root missing"); + const expandedTree = { + ...fixture.evidence.treeSnapshot, + entries: [ + ...fixture.evidence.treeSnapshot.entries, + { + relativePath: "unowned.bin", + kind: "file" as const, + dev: root.dev, + ino: (BigInt(root.ino) + 1n).toString(), + nlink: "1", + size: "1", + mtimeNs: "0", + ctimeNs: "0", + sha256: crypto.createHash("sha256").update("x").digest("hex"), + }, + ], + }; + expect(() => + verifyTaskArtifactOwnerRetirementContinuation(fixture.context, fixture.evidence, { + ...continuationFor(fixture, fixture.ownerPath), + payloadDurable: true, + retainedTreeSnapshot: fixture.evidence.treeSnapshot, + }), + ).toThrow("task_artifact_owner_retired_payload_changed"); + expect(() => + verifyTaskArtifactOwnerRetirementContinuation(fixture.context, fixture.evidence, { + ...continuationFor(fixture, fixture.ownerPath), + retainedTreeSnapshot: expandedTree, + }), + ).toThrow("task_artifact_owner_continuation_invalid"); + }); + + it("does not treat canonical absence or historical payloadDurable as physical or fresh payload proof", async () => { + const fixture = await makeOwnerFixture(); + const historical = { ...continuationFor(fixture), payloadDurable: true }; + fs.renameSync(fixture.ownerPath, historical.retainedRootPath); + expect(() => + verifyTaskArtifactOwnerRetirementContinuation(fixture.context, fixture.evidence, historical), + ).toThrow("task_artifact_owner_retired_payload_changed"); + expect(() => + verifyTaskArtifactOwnerPhysicalRetirement(fixture.context, fixture.evidence, { + kind: "completed", + evidence: fixture.evidence, + nativeOutcome: { ok: true }, + }), + ).toThrow("task_artifact_owner_physical_retirement_unverified"); + }); +});