From ac83bc7f6d23fa36a270b1d9bdb80f45e327a4a2 Mon Sep 17 00:00:00 2001 From: snowykr Date: Mon, 5 Oct 2026 18:13:02 +0900 Subject: [PATCH 1/3] fix(session): distinguish owner preflight refusal from artifact completion A task-owner refusal can precede every artifact effect. Inferring completion from its failure phase stranded retries and advanced prepared GC journals. Carry verified artifact completion explicitly and preserve pre-effect authority. Lore-id: 3d746fe9 Constraint: preserve sibling authentication and native deletion authority Confidence: high Scope-risk: narrow Reversibility: simple-revert Tested: real preflight refusal preserves artifacts and prepared owner journal --- .../internal/managed-task-owner-cleanup.ts | 1 + .../src/session/session-storage.ts | 12 ++++--- .../managed-task-owner-cleanup-api.test.ts | 31 +++++++++++++++++++ .../test/task-artifact-owner-storage.test.ts | 6 ++-- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/session/internal/managed-task-owner-cleanup.ts b/packages/coding-agent/src/session/internal/managed-task-owner-cleanup.ts index 41f550312d6..5db6d8b3e19 100644 --- a/packages/coding-agent/src/session/internal/managed-task-owner-cleanup.ts +++ b/packages/coding-agent/src/session/internal/managed-task-owner-cleanup.ts @@ -359,6 +359,7 @@ export async function persistManagedGcStorageOwnerDisposition( const evidence = parseTaskArtifactOwnerDeletionEvidence(deletion.taskArtifactOwnerDeletionEvidence); if (!target.taskArtifactOwnerDeletionEvidence || !deepSame(evidence, target.taskArtifactOwnerDeletionEvidence)) throw new Error("task_artifact_owner_continuation_evidence_mismatch"); + if (!deletion.artifactsRemoved) return { state: "pending", message: deletion.error.message }; const receipt = await publishManagedGcArtifactsRemoved(authority, target); if (!receipt) throw new Error("task_artifact_owner_continuation_state_missing"); const outcomeValue = deletion.taskArtifactOwnerRetirementOutcome; diff --git a/packages/coding-agent/src/session/session-storage.ts b/packages/coding-agent/src/session/session-storage.ts index eb85191ceee..65062e4cdf6 100644 --- a/packages/coding-agent/src/session/session-storage.ts +++ b/packages/coding-agent/src/session/session-storage.ts @@ -553,6 +553,8 @@ export type VerifiedSessionDeleteResult = | { kind: "cleanup_pending"; phase: "task_artifact_owner"; + /** True only after verified artifact completion, never inferred from the failure phase. */ + artifactsRemoved: boolean; error: Error; transcriptIdentity: SessionStorageFileIdentity; taskArtifactOwnerDeletionEvidence: TaskArtifactOwnerDeletionEvidence; @@ -2675,9 +2677,10 @@ export class FileSessionStorage implements SessionStorage { ...(ownerPayloadRetired ? { taskArtifactOwnerPayloadRetired: true as const } : {}), ...(ownerNamespaceRetained ? { taskArtifactOwnerNamespaceRetained: true as const } : {}), }); - const ownerCleanupPending = (error: Error): VerifiedSessionDeleteResult => ({ + const ownerCleanupPending = (error: Error, completedArtifacts: boolean): VerifiedSessionDeleteResult => ({ kind: "cleanup_pending", phase: "task_artifact_owner", + artifactsRemoved: completedArtifacts, error, transcriptIdentity, taskArtifactOwnerDeletionEvidence: ownerEvidence!, @@ -2685,7 +2688,7 @@ export class FileSessionStorage implements SessionStorage { }); const finishArtifactPhase = async (): Promise => { const ownerError = await retireOwnerIfRequired(); - if (ownerError && ownerEvidence) return ownerCleanupPending(ownerError); + if (ownerError && ownerEvidence) return ownerCleanupPending(ownerError, true); if (ownerTranscriptDeleted) return { kind: "deleted", ...ownerResultFields() }; return { kind: "artifacts_removed", @@ -2695,7 +2698,8 @@ export class FileSessionStorage implements SessionStorage { }; }; const ownerPreflightError = await ownerSiblingRefusal(); - if (ownerPreflightError && ownerEvidence) return ownerCleanupPending(ownerPreflightError); + if (ownerPreflightError && ownerEvidence) + return ownerCleanupPending(ownerPreflightError, artifactsRemoved === true); if (detachedArtifactsPath && !artifactsRemoved) { if ( !expectedArtifactsIdentity || @@ -3013,7 +3017,7 @@ export class FileSessionStorage implements SessionStorage { }; } const ownerError = await retireOwnerIfRequired(); - if (ownerError && ownerEvidence) return ownerCleanupPending(ownerError); + if (ownerError && ownerEvidence) return ownerCleanupPending(ownerError, true); if (ownerTranscriptDeleted) return { kind: "deleted", ...ownerResultFields() }; if (hasDetachedTranscript) { const deletion = nativeExactUnlink(cleanupTranscriptPath, { diff --git a/packages/coding-agent/test/managed-task-owner-cleanup-api.test.ts b/packages/coding-agent/test/managed-task-owner-cleanup-api.test.ts index 2289e1679e3..ebf4993cb36 100644 --- a/packages/coding-agent/test/managed-task-owner-cleanup-api.test.ts +++ b/packages/coding-agent/test/managed-task-owner-cleanup-api.test.ts @@ -24,6 +24,7 @@ import { type ManagedGcOwnerCleanupAuthority, type ManagedGcOwnerCleanupTarget, managedGcOwnerDeleteFields, + persistManagedGcStorageOwnerDisposition, prepareManagedGcOwnerTarget, retireManagedGcOwnerAfterArtifacts, } from "../src/session/internal/managed-task-owner-cleanup"; @@ -31,6 +32,7 @@ import { captureTaskArtifactOwnerDeletionEvidence, newSessionRootStore, } from "../src/session/internal/task-artifact-owner-access"; +import { FileSessionStorage } from "../src/session/session-storage"; import { OWNER_DIRECTORY, OWNER_MANIFEST, @@ -318,6 +320,35 @@ describe("managed GC owner-cleanup API", () => { }); }); + it("does not advance prepared artifact authority after a real owner preflight refusal", async () => { + const fixture = makeFixture(); + await withAuthority(fixture, async authority => { + const prepared = await prepareManagedGcOwnerTarget(authority, fixture.candidate); + const fields = await managedGcOwnerDeleteFields(authority, prepared); + fs.mkdirSync(path.join(fixture.scope.directoryPath, ".gjc-managed-session-internal.saved"), { mode: 0o700 }); + const deletion = await new FileSessionStorage().deleteSessionVerified( + { + sessionsRoot: fixture.sessionsRoot, + ...authority.bindTarget(fixture.transcriptPath), + ...fields, + plannedArtifactsPath: path.join(fixture.scope.directoryPath, ".gjc-delete-api-preflight-artifacts"), + plannedTranscriptPath: path.join(fixture.scope.directoryPath, ".gjc-delete-api-preflight-transcript"), + }, + authority.inspectProtocol, + ); + if (deletion.kind !== "cleanup_pending" || deletion.phase !== "task_artifact_owner") + throw new Error(`Expected real owner preflight refusal, got ${deletion.kind}`); + expect(deletion.artifactsRemoved).toBe(false); + const progress = await persistManagedGcStorageOwnerDisposition(authority, prepared, deletion); + expect(progress.state).toBe("pending"); + const receipt = await authority.readReceipt(fixture.transcriptPath); + expect(receipt?.state).toBe("prepared"); + expect(receipt?.artifactsRemoved).toBeUndefined(); + expect(fs.existsSync(fixture.transcriptPath)).toBe(true); + expect(fs.readFileSync(path.join(fixture.ownerPath, "payload.bin"), "utf8")).toBe("real-owner-payload"); + }); + }); + it("supplies only persisted receipt authority to native deletion", async () => { const fixture = makeFixture(); await withAuthority(fixture, async authority => { diff --git a/packages/coding-agent/test/task-artifact-owner-storage.test.ts b/packages/coding-agent/test/task-artifact-owner-storage.test.ts index 96d8ebd865b..53cd00df21d 100644 --- a/packages/coding-agent/test/task-artifact-owner-storage.test.ts +++ b/packages/coding-agent/test/task-artifact-owner-storage.test.ts @@ -441,7 +441,7 @@ describe("verified storage consumes task artifact owners", () => { expect(recoveryOpen).not.toHaveBeenCalled(); expect(retain).not.toHaveBeenCalled(); expect(result.kind).toBe("cleanup_pending"); - expect(result.kind === "cleanup_pending" ? result.phase : undefined).toBe("task_artifact_owner"); + expect(result).toMatchObject({ kind: "cleanup_pending", phase: "task_artifact_owner", artifactsRemoved: false }); expect(filesystemSnapshot(fixture.root)).toEqual(before); expect(nativeRemoval.spy).not.toHaveBeenCalled(); expect(fs.readFileSync(fixture.ownerPayloadPath, "utf8")).toBe("owner-payload"); @@ -459,7 +459,7 @@ describe("verified storage consumes task artifact owners", () => { const nativeRemoval = recordActualOwnerRemoval(); const artifactPhase = await deleteVerified(fixture, targetFor(fixture)); if (artifactPhase.kind === "cleanup_pending") { - expect(artifactPhase.phase).toBe("task_artifact_owner"); + expect(artifactPhase).toMatchObject({ phase: "task_artifact_owner", artifactsRemoved: true }); expect(artifactPhase.taskArtifactOwnerRetired).toBeUndefined(); expect(artifactPhase.taskArtifactOwnerRetirementOutcome?.nativeOutcome).toEqual(nativeRemoval.calls[0]); expect(fs.existsSync(fixture.transcriptPath)).toBe(true); @@ -616,7 +616,7 @@ describe("verified storage consumes task artifact owners", () => { targetFor(fixture, { deferTaskArtifactOwnerRetirement: true }), managedGcProtocolScopeInspectorForScope(fixture.scope), ); - expect(result).toMatchObject({ kind: "cleanup_pending", phase: "task_artifact_owner" }); + expect(result).toMatchObject({ kind: "cleanup_pending", phase: "task_artifact_owner", artifactsRemoved: false }); expect(nativeRemoval.spy).not.toHaveBeenCalled(); expect(filesystemSnapshot(fixture.root)).toEqual(before); }); From 6baf48c2fa3ed43d2d440acc385f82c51515cc24 Mon Sep 17 00:00:00 2001 From: snowykr Date: Mon, 5 Oct 2026 18:13:23 +0900 Subject: [PATCH 2/3] fix(sdk): restore bounded saved-session deletion retries Owner-free legacy deletion must not depend on an unrelated v2 scope. Initial writer capture refusals need a pre-effect retry without replacing captured authority. Publish absent owner fields consistently in memory and on disk so same-Broker reconciliation matches restart behavior. Lore-id: 614ac5b8 Constraint: never recapture owner evidence after cleanup effects Constraint: preserve transcript identity and original artifact authority Confidence: high Scope-risk: narrow Reversibility: simple-revert Tested: legacy direct and restart deletion without v2 initialization Tested: real staging and replacement writer held and released retries Tested: same-Broker protocol alias and canonical reappearance recovery Tested: byte-identical transcript replacement refuses owner recapture --- .../coding-agent/src/sdk/broker/lifecycle.ts | 169 ++++++++++---- .../sdk-task-artifact-owner-deletion.test.ts | 220 +++++++++++++++++- 2 files changed, 334 insertions(+), 55 deletions(-) diff --git a/packages/coding-agent/src/sdk/broker/lifecycle.ts b/packages/coding-agent/src/sdk/broker/lifecycle.ts index d8e5bb938ca..101a5c771b3 100644 --- a/packages/coding-agent/src/sdk/broker/lifecycle.ts +++ b/packages/coding-agent/src/sdk/broker/lifecycle.ts @@ -68,6 +68,7 @@ import { parseFirstJsonlLine } from "../../session/session-transcript-header"; import type { SessionWorkLease } from "../../session/session-work-lease"; import type { TaskArtifactOwnerDeletionEvidence, + TaskArtifactOwnerLocator, TaskArtifactOwnerRetirementOutcome, TaskArtifactOwnerStorageContext, } from "../../session/task-artifact-owner-codec"; @@ -990,7 +991,7 @@ function lifecycleLaunchKnownSecrets(launch: SessionLaunch): string[] { type CleanupEvidence = BrokerCleanupEvidence; -function brokerTaskArtifactOwnerCleanupFields(cleanup: CleanupEvidence): unknown { +function brokerTaskArtifactOwnerCleanupFields(cleanup: CleanupEvidence): Record { const fields: Record = {}; for (const [key, value] of Object.entries(cleanup)) { if (key.startsWith("taskArtifactOwner")) fields[key] = value; @@ -6412,6 +6413,9 @@ async function validateDeletePath( path.isAbsolute(transcriptRelative) ) return fail("terminal_uncertain", "Cleanup receipt does not match the current managed session authority."); + replay.target.sessionsRoot = inventory.scope.sessionsRoot; + const ownerFields = brokerTaskArtifactOwnerCleanupFields(cleanup); + if (Object.keys(ownerFields).length === 0) return replay; let ownerContext: TaskArtifactOwnerStorageContext; let ownerScope: ManagedScope; try { @@ -6426,7 +6430,7 @@ async function validateDeletePath( let owner: BrokerTaskArtifactOwnerCleanupValidation; try { owner = decodeBrokerTaskArtifactOwnerCleanupFields( - brokerTaskArtifactOwnerCleanupFields(cleanup), + ownerFields, ownerContext, id, cleanup.phase, @@ -6435,7 +6439,60 @@ async function validateDeletePath( } catch { return fail("terminal_uncertain", "Cleanup receipt contains invalid task-artifact owner state."); } - replay.target.sessionsRoot = inventory.scope.sessionsRoot; + let captureError = owner.cleanupDiagnostic; + if (!owner.deletionEvidence && captureError === "task_artifact_owner_writer_not_quiescent") { + const retainedPaths = [ + replay.target.detachedArtifactsPath, + replay.target.detachedTranscriptPath, + replay.target.retainedArtifactsSuccessorPath, + replay.target.retainedArtifactsPlaceholderPath, + replay.target.retainedArtifactsUnknownPath, + replay.target.retainedTranscriptSuccessorPath, + replay.target.retainedTranscriptPlaceholderPath, + replay.target.retainedTranscriptUnknownPath, + ]; + const plannedPaths = [ + cleanup.plannedArtifactsPath, + cleanup.artifactTree?.plannedPath, + cleanup.plannedTranscriptPath, + ]; + const occupiedPlan = plannedPaths.some(planned => { + if (!planned) return false; + return [planned, `${planned}.removing`].some(candidate => { + try { + fsSync.lstatSync(candidate); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } + }); + }); + if ( + cleanup.phase !== "artifacts" || + cleanup.artifactsRemoved === true || + Object.keys(ownerFields).some(key => key !== "taskArtifactOwnerCleanupError") || + retainedPaths.some(candidate => candidate !== undefined) || + occupiedPlan || + transcriptParentStat.dev.toString() !== replay.transcriptParentIdentity.dev || + transcriptParentStat.ino.toString() !== replay.transcriptParentIdentity.ino + ) + return fail( + "terminal_uncertain", + "Initial task-artifact owner capture cannot be retried after unverified cleanup effects.", + ); + replay.target.taskArtifactOwnerStorageContext = ownerContext; + const captured = captureTaskArtifactOwnerEvidence(replay.storage, replay.target); + if (!captured.evidence && !captured.error) + return fail( + "terminal_uncertain", + "The original task-artifact owner claim could not be restored for capture retry.", + ); + if (captured.evidence) { + replay.target.taskArtifactOwnerDeletionEvidence = captured.evidence; + replay.target.deferTaskArtifactOwnerRetirement = true; + } + captureError = captured.error; + } if (owner.deletionEvidence) { replay.target.taskArtifactOwnerStorageContext = ownerContext; replay.target.taskArtifactOwnerDeletionEvidence = owner.deletionEvidence; @@ -6451,9 +6508,7 @@ async function validateDeletePath( return { ...replay, inspectProtocol: managedGcProtocolScopeInspectorForScope(ownerScope), - ...(owner.cleanupDiagnostic && !owner.deletionEvidence - ? { taskArtifactOwnerCaptureError: owner.cleanupDiagnostic } - : {}), + ...(captureError && !owner.deletionEvidence ? { taskArtifactOwnerCaptureError: captureError } : {}), }; } const inventory = await managedCandidates(broker, cwd, "Saved"); @@ -6466,15 +6521,6 @@ async function validateDeletePath( const match = matches[0]!; if (inventory.migrationPolicy === "disabled" && match.provenance === "legacy") return fail("legacy_migration_disabled", "Saved legacy session migration is disabled for this workspace."); - let ownerContext: TaskArtifactOwnerStorageContext; - let ownerScope: ManagedScope; - try { - ownerScope = managedOwnerScopeFromInventory(inventory.scope); - ownerContext = taskArtifactOwnerStorageContextForScope(ownerScope); - } catch { - return fail("invalid_input", "Managed task-artifact-owner authority could not be established for deletion."); - } - const storage = new FileSessionStorage(); let snapshot: SessionStorageSnapshot; try { @@ -6503,6 +6549,23 @@ async function validateDeletePath( } catch { return fail("invalid_input", "session.delete transcript parent changed during authorization."); } + let ownerContext: TaskArtifactOwnerStorageContext | undefined; + let ownerScope: ManagedScope | undefined; + let ownerCaptureError: string | undefined; + let ownerLocator: TaskArtifactOwnerLocator | undefined; + try { + ownerLocator = taskArtifactOwnerLocatorFromTranscriptBytes(snapshot.bytes, id); + } catch (error) { + ownerCaptureError = taskArtifactOwnerFailureDiagnostic(error); + } + if (ownerLocator) { + try { + ownerScope = managedOwnerScopeFromInventory(inventory.scope); + ownerContext = taskArtifactOwnerStorageContextForScope(ownerScope); + } catch { + return fail("invalid_input", "Managed task-artifact-owner authority could not be established for deletion."); + } + } const target: VerifiedSessionDeleteTarget = { sessionsRoot: canonicalExistingPath(inventory.scope.sessionsRoot), transcriptPath: candidatePath, @@ -6517,9 +6580,11 @@ async function validateDeletePath( sha256: digest, }, transcriptParentIdentity: { dev: transcriptParentStat.dev, ino: transcriptParentStat.ino }, - taskArtifactOwnerStorageContext: ownerContext, + ...(ownerContext ? { taskArtifactOwnerStorageContext: ownerContext } : {}), }; - const capturedOwner = captureTaskArtifactOwnerEvidence(storage, target); + const capturedOwner: { evidence?: TaskArtifactOwnerDeletionEvidence; error?: string } = ownerCaptureError + ? { error: ownerCaptureError } + : captureTaskArtifactOwnerEvidence(storage, target); if (capturedOwner.evidence) { target.taskArtifactOwnerDeletionEvidence = capturedOwner.evidence; target.deferTaskArtifactOwnerRetirement = true; @@ -6528,7 +6593,7 @@ async function validateDeletePath( storage, target, metadataRoot: canonicalRequestedRoot, - inspectProtocol: managedGcProtocolScopeInspectorForScope(ownerScope), + ...(ownerScope ? { inspectProtocol: managedGcProtocolScopeInspectorForScope(ownerScope) } : {}), transcriptParentIdentity: { dev: transcriptParentStat.dev.toString(), ino: transcriptParentStat.ino.toString(), @@ -7849,18 +7914,35 @@ async function executeLifecycleResponse( }); return pending; }; - const cleanupWithFreshOwnerPayload = (receipt: CleanupEvidence): CleanupEvidence => ({ - ...receipt, - ...(cleanupTarget.taskArtifactOwnerDeletionEvidence - ? { taskArtifactOwnerDeletionEvidence: cleanupTarget.taskArtifactOwnerDeletionEvidence } - : {}), - taskArtifactOwnerRetirementContinuation: cleanupTarget.taskArtifactOwnerRetirementContinuation, - taskArtifactOwnerRetirementOutcome: ownerRetirementOutcome, - taskArtifactOwnerPayloadRetired: cleanupTarget.taskArtifactOwnerPayloadRetired, - taskArtifactOwnerNamespaceRetained: cleanupTarget.taskArtifactOwnerNamespaceRetained, - taskArtifactOwnerRetired: cleanupTarget.taskArtifactOwnerRetired, - taskArtifactOwnerTranscriptDeleted: ownerTranscriptDeleted, - }); + const cleanupWithFreshOwnerPayload = (receipt: CleanupEvidence): CleanupEvidence => { + const current = { ...receipt }; + delete current.taskArtifactOwnerDeletionEvidence; + delete current.taskArtifactOwnerRetirementContinuation; + delete current.taskArtifactOwnerRetirementOutcome; + delete current.taskArtifactOwnerPayloadRetired; + delete current.taskArtifactOwnerNamespaceRetained; + delete current.taskArtifactOwnerRetired; + delete current.taskArtifactOwnerTranscriptDeleted; + delete current.taskArtifactOwnerCleanupError; + return { + ...current, + ...(cleanupTarget.taskArtifactOwnerDeletionEvidence + ? { taskArtifactOwnerDeletionEvidence: cleanupTarget.taskArtifactOwnerDeletionEvidence } + : {}), + ...(cleanupTarget.taskArtifactOwnerRetirementContinuation + ? { taskArtifactOwnerRetirementContinuation: cleanupTarget.taskArtifactOwnerRetirementContinuation } + : {}), + ...(ownerRetirementOutcome ? { taskArtifactOwnerRetirementOutcome: ownerRetirementOutcome } : {}), + ...(cleanupTarget.taskArtifactOwnerPayloadRetired + ? { taskArtifactOwnerPayloadRetired: true as const } + : {}), + ...(cleanupTarget.taskArtifactOwnerNamespaceRetained + ? { taskArtifactOwnerNamespaceRetained: true as const } + : {}), + ...(cleanupTarget.taskArtifactOwnerRetired ? { taskArtifactOwnerRetired: true as const } : {}), + ...(ownerTranscriptDeleted ? { taskArtifactOwnerTranscriptDeleted: true as const } : {}), + }; + }; const publishTaskArtifactOwnerPending = async ( error: unknown, receipt: CleanupEvidence = preauthorizedCleanup, @@ -7868,18 +7950,11 @@ async function executeLifecycleResponse( const diagnostic = taskArtifactOwnerFailureDiagnostic(error); const evidence = cleanupTarget.taskArtifactOwnerDeletionEvidence; const ownerReceipt: CleanupEvidence = { - ...receipt, + ...cleanupWithFreshOwnerPayload(receipt), phase: "artifacts", artifactsRemoved: receipt.artifactsRemoved === true && evidence ? true : undefined, artifactsAbsentAtAuthorization: receipt.artifactsRemoved === true ? undefined : receipt.artifactsAbsentAtAuthorization, - ...(evidence ? { taskArtifactOwnerDeletionEvidence: evidence } : {}), - taskArtifactOwnerRetirementContinuation: cleanupTarget.taskArtifactOwnerRetirementContinuation, - taskArtifactOwnerRetirementOutcome: ownerRetirementOutcome, - taskArtifactOwnerPayloadRetired: cleanupTarget.taskArtifactOwnerPayloadRetired, - taskArtifactOwnerNamespaceRetained: cleanupTarget.taskArtifactOwnerNamespaceRetained, - taskArtifactOwnerRetired: cleanupTarget.taskArtifactOwnerRetired, - taskArtifactOwnerTranscriptDeleted: ownerTranscriptDeleted, taskArtifactOwnerCleanupError: diagnostic, }; const pending = fail( @@ -7949,19 +8024,11 @@ async function executeLifecycleResponse( ) : undefined; const ownerReceipt: CleanupEvidence = { - ...receipt, + ...cleanupWithFreshOwnerPayload(receipt), phase: "artifacts", artifactsRemoved: true, artifactsAbsentAtAuthorization: undefined, - taskArtifactOwnerDeletionEvidence: evidence, - taskArtifactOwnerRetired: outcome.kind === "completed" ? true : undefined, - taskArtifactOwnerRetirementContinuation: outcome.kind === "completed" ? undefined : outcome.continuation, - taskArtifactOwnerRetirementOutcome: ownerRetirementOutcome, - taskArtifactOwnerPayloadRetired: outcome.kind === "payload_retired" ? true : undefined, - taskArtifactOwnerNamespaceRetained: outcome.kind === "payload_retired" ? true : undefined, - taskArtifactOwnerTranscriptDeleted: - outcome.kind !== "completed" && ownerTranscriptDeleted === true ? true : undefined, - taskArtifactOwnerCleanupError: ownerError, + ...(ownerError ? { taskArtifactOwnerCleanupError: ownerError } : {}), }; const stateResponse = fail( "cleanup_pending", @@ -8269,11 +8336,10 @@ async function executeLifecycleResponse( return await publishTaskArtifactOwnerPending("task_artifact_owner_evidence_not_prepared_before_effect"); const ownerEvidence = cleanupTarget.taskArtifactOwnerDeletionEvidence; const artifactCompletionCleanup: CleanupEvidence = { - ...preauthorizedCleanup, + ...cleanupWithFreshOwnerPayload(preauthorizedCleanup), phase: ownerEvidence ? "artifacts" : "transcript", artifactsRemoved: true, artifactsAbsentAtAuthorization: undefined, - taskArtifactOwnerCleanupError: undefined, ...(ownerEvidence ? { taskArtifactOwnerDeletionEvidence: ownerEvidence } : {}), detachedArtifactsPath: undefined, retainedArtifactsSuccessorPath: undefined, @@ -8310,7 +8376,6 @@ async function executeLifecycleResponse( phase: ownerNamespaceRetained ? "artifacts" : "transcript", artifactsRemoved: true, artifactsAbsentAtAuthorization: undefined, - taskArtifactOwnerTranscriptDeleted: undefined, ...(artifactCompletionCleanup.artifactTree ? { artifactTree: { @@ -8386,6 +8451,8 @@ async function executeLifecycleResponse( ); const retainedRootArtifactsPlan = durableArtifactsPlan; if (deleted.kind === "cleanup_pending") { + if (deleted.phase === "task_artifact_owner" && !deleted.artifactsRemoved) + return await publishTaskArtifactOwnerPending(deleted.error.message); const ownerEvidence = deleted.taskArtifactOwnerDeletionEvidence ?? cleanupTarget.taskArtifactOwnerDeletionEvidence; const ownerRetired = deleted.taskArtifactOwnerRetired ?? cleanupTarget.taskArtifactOwnerRetired; diff --git a/packages/coding-agent/test/sdk-task-artifact-owner-deletion.test.ts b/packages/coding-agent/test/sdk-task-artifact-owner-deletion.test.ts index fceb6a61d7b..2d1f460f8a5 100644 --- a/packages/coding-agent/test/sdk-task-artifact-owner-deletion.test.ts +++ b/packages/coding-agent/test/sdk-task-artifact-owner-deletion.test.ts @@ -345,7 +345,30 @@ async function pauseOwnerWriter(fixture: OwnerFixture, phase: "staging" | "repla return { release }; } -function deleteRequest(fixture: OwnerFixture, sessionId = fixture.sessionId, sessionPath = fixture.transcript) { +async function createLegacyOnlyFixture(): Promise< + Pick +> { + const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "sdk-legacy-only-delete-"))); + const cwd = path.join(root, "workspace"); + const agentDir = path.join(root, "profile"); + await fs.mkdir(cwd, { mode: 0o700 }); + const encoded = cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-"); + const legacyDirectory = path.join(agentDir, "sessions", `--${encoded}--`); + await fs.mkdir(legacyDirectory, { recursive: true, mode: 0o700 }); + const sessionId = crypto.randomUUID(); + const transcript = path.join(legacyDirectory, `${sessionId}.jsonl`); + await Bun.write( + transcript, + `${JSON.stringify({ type: "session", version: 3, id: sessionId, timestamp: new Date().toISOString(), cwd })}\n`, + ); + return { root, cwd, agentDir, sessionId, transcript }; +} + +function deleteRequest( + fixture: Pick, + sessionId = fixture.sessionId, + sessionPath = fixture.transcript, +) { return { cwd: fixture.cwd, stateRoot: path.join(fixture.cwd, ".gjc", "state"), @@ -354,10 +377,109 @@ function deleteRequest(fixture: OwnerFixture, sessionId = fixture.sessionId, ses }; } -it("SDK refuses an unauthenticated protocol alias before artifact or owner payload effects", async () => { - const fixture = await createFixture(); +it.each([ + false, + true, +])("SDK deletes an owner-free legacy-only session without initializing v2 (restart=%s)", async restart => { + const { root, cwd, agentDir, sessionId, transcript: sessionPath } = await createLegacyOnlyFixture(); + const sessionsRoot = path.join(agentDir, "sessions"); + let broker = new Broker({ agentDir }); + const originalUnlink = native.exactUnlink; + const unlink = vi + .spyOn(native, "exactUnlink") + .mockImplementation((pathname, identity) => + restart && pathname === sessionPath ? { ok: false, code: "io_error" } : originalUnlink(pathname, identity), + ); + const request = { cwd, stateRoot: path.join(cwd, ".gjc", "state"), sessionId, sessionPath }; + try { + await broker.start(); + let response = await broker.handleRequest("session.delete", request, "legacy-only-delete"); + if (restart) { + expect(response).toMatchObject({ + ok: false, + error: { code: "cleanup_pending", cleanup: { phase: "transcript" } }, + }); + unlink.mockRestore(); + await broker.stop(); + broker = new Broker({ agentDir }); + await broker.start(); + response = await broker.handleRequest("session.delete", request, "legacy-only-delete"); + } + expect(response).toMatchObject({ ok: true, result: { sessionId } }); + expect(await Bun.file(sessionPath).exists()).toBe(false); + expect((await fs.readdir(sessionsRoot)).filter(name => name.startsWith("v2-"))).toEqual([]); + } finally { + unlink.mockRestore(); + await broker.stop(); + await safeRm(root, { recursive: true, force: true }); + } +}, 30_000); + +it.each([ + false, + true, +])("SDK retries same-Broker transcript cleanup after canonical artifact reappearance (owned=%s)", async owned => { + const fixture = owned ? await createFixture() : await createLegacyOnlyFixture(); const broker = new Broker({ agentDir: fixture.agentDir }); const artifacts = fixture.transcript.slice(0, -6); + const transcriptBefore = await fs.readFile(fixture.transcript); + let injected = false; + const transition = broker.ledger.transition.bind(broker.ledger); + const publish = vi.spyOn(broker.ledger, "transition").mockImplementation(async (identity, state, fields) => { + const result = await transition(identity, state, fields); + const error = record(record(fields?.response)?.error); + if ( + !injected && + error?.message === + "Saved session artifacts and owner cleanup are durably recorded; transcript cleanup is preauthorized." + ) { + injected = true; + await fs.mkdir(artifacts, { mode: 0o700 }); + await Bun.write(path.join(artifacts, "replacement.txt"), "unowned replacement"); + } + return result; + }); + try { + await broker.start(); + const request = deleteRequest(fixture); + const first = await broker.handleRequest("session.delete", request, "canonical-artifact-reappearance"); + expect(injected).toBe(true); + expect(first).toMatchObject({ + ok: false, + error: { code: "cleanup_pending", message: expect.stringContaining("canonical artifact path reappeared") }, + }); + const cleanup = cleanupOf(first); + expect( + Object.entries(cleanup).filter(([key, value]) => key.startsWith("taskArtifactOwner") && value === undefined), + ).toEqual([]); + if (!owned) expect(Object.keys(cleanup).filter(key => key.startsWith("taskArtifactOwner"))).toEqual([]); + expect(await fs.readFile(fixture.transcript)).toEqual(transcriptBefore); + expect(await Bun.file(path.join(artifacts, "replacement.txt")).text()).toBe("unowned replacement"); + publish.mockRestore(); + await safeRm(artifacts, { recursive: true, force: true }); + const replay = await broker.handleRequest("session.delete", request, "canonical-artifact-reappearance"); + if (owned && !replay.ok) expectPayloadRetired(replay, fixture.sessionId); + else expect(replay).toMatchObject({ ok: true, result: { sessionId: fixture.sessionId } }); + expect(await Bun.file(fixture.transcript).exists()).toBe(false); + if (!owned) + expect( + (await fs.readdir(path.join(fixture.agentDir, "sessions"))).filter(name => name.startsWith("v2-")), + ).toEqual([]); + } finally { + publish.mockRestore(); + await broker.stop(); + await safeRm(fixture.root, { recursive: true, force: true }); + } +}, 30_000); + +it.each([ + false, + true, +])("SDK refuses a protocol alias before artifact effects and retries after its removal (restart=%s)", async restart => { + const fixture = await createFixture(); + let broker = new Broker({ agentDir: fixture.agentDir }); + const artifacts = fixture.transcript.slice(0, -6); + const unlink = vi.spyOn(native, "exactUnlink"); await fs.mkdir(artifacts, { mode: 0o700 }); await Bun.write(path.join(artifacts, "retained.txt"), "retained artifact"); await fs.mkdir(path.join(fixture.scope.directoryPath, ".gjc-managed-session-internal.saved"), { mode: 0o700 }); @@ -376,6 +498,8 @@ it("SDK refuses an unauthenticated protocol alias before artifact or owner paylo expect(response).toMatchObject({ ok: false, error: { code: "cleanup_pending" } }); const cleanup = cleanupOf(response); expect(cleanup.phase).toBe("artifacts"); + expect(cleanup.artifactsRemoved).toBeUndefined(); + expect(cleanup.artifactTree?.snapshot).toBeDefined(); expect(cleanup.taskArtifactOwnerRetired).toBeUndefined(); expect(cleanup.taskArtifactOwnerTranscriptDeleted).toBeUndefined(); expect(await fs.readFile(fixture.transcript)).toEqual(transcriptBefore); @@ -394,7 +518,26 @@ it("SDK refuses an unauthenticated protocol alias before artifact or owner paylo }); } await preserveNewerSession(fixture); + expect(unlink.mock.calls.some(([pathname]) => pathname === artifacts)).toBe(false); + await fs.rmdir(path.join(fixture.scope.directoryPath, ".gjc-managed-session-internal.saved")); + if (restart) { + await broker.stop(); + broker = new Broker({ agentDir: fixture.agentDir }); + await broker.start(); + } + const replay = await broker.handleRequest( + "session.delete", + deleteRequest(fixture), + "unauthenticated-protocol-delete", + ); + expect(unlink.mock.calls.some(([pathname]) => pathname === artifacts)).toBe(true); + if (!replay.ok) { + expect(replay.error.code).toBe("cleanup_pending"); + expect(replay.error.message).not.toContain("canonical artifact path reappeared"); + } + await preserveNewerSession(fixture); } finally { + unlink.mockRestore(); await broker.stop(); await safeRm(fixture.root, { recursive: true, force: true }); } @@ -586,6 +729,74 @@ it.each([ } }, 30_000); +it.each([ + { phase: "staging", replaceTranscript: false, restart: false }, + { phase: "replacement", replaceTranscript: false, restart: false }, + { phase: "staging", replaceTranscript: false, restart: true }, + { phase: "replacement", replaceTranscript: false, restart: true }, + { phase: "staging", replaceTranscript: true, restart: true }, +] as const)("SDK retries initial writer capture only for its original transcript (%j)", async ({ + phase, + replaceTranscript, + restart, +}) => { + const fixture = await createFixture(); + let broker = new Broker({ agentDir: fixture.agentDir }); + let writer: PausedOwnerWriter | undefined; + const transcriptBefore = await fs.readFile(fixture.transcript); + try { + writer = await pauseOwnerWriter(fixture, phase); + await broker.start(); + const request = deleteRequest(fixture); + const first = await broker.handleRequest("session.delete", request, `initial-${phase}-capture`); + const firstCleanup = cleanupOf(first); + expect(firstCleanup).toMatchObject({ + phase: "artifacts", + taskArtifactOwnerCleanupError: "task_artifact_owner_writer_not_quiescent", + }); + expect(firstCleanup.taskArtifactOwnerDeletionEvidence).toBeUndefined(); + expect(firstCleanup.artifactsRemoved).toBeUndefined(); + expect(await fs.readFile(fixture.transcript)).toEqual(transcriptBefore); + if (restart) { + await broker.stop(); + broker = new Broker({ agentDir: fixture.agentDir }); + await broker.start(); + } + const stillWriting = await broker.handleRequest("session.delete", request, `initial-${phase}-capture`); + expect(cleanupOf(stillWriting).taskArtifactOwnerCleanupError).toBe("task_artifact_owner_writer_not_quiescent"); + expect(cleanupOf(stillWriting).taskArtifactOwnerDeletionEvidence).toBeUndefined(); + expect(await fs.readFile(fixture.transcript)).toEqual(transcriptBefore); + await writer.release(); + writer = undefined; + const ownerBeforeReplay = await regularFiles(fixture.ownerDirectory); + if (replaceTranscript) { + await fs.rename(fixture.transcript, `${fixture.transcript}.original`); + await Bun.write(fixture.transcript, transcriptBefore); + } + if (restart) { + await broker.stop(); + broker = new Broker({ agentDir: fixture.agentDir }); + await broker.start(); + } + const replay = await broker.handleRequest("session.delete", request, `initial-${phase}-capture`); + if (replaceTranscript) { + expect(cleanupOf(replay).taskArtifactOwnerCleanupError).toBe( + "task_artifact_owner_transcript_identity_mismatch", + ); + expect(await fs.readFile(fixture.transcript)).toEqual(transcriptBefore); + expect(await regularFiles(fixture.ownerDirectory)).toEqual(ownerBeforeReplay); + } else { + if (!replay.ok) expectPayloadRetired(replay, fixture.sessionId); + expect(await Bun.file(fixture.transcript).exists()).toBe(false); + } + await preserveNewerSession(fixture); + } finally { + if (writer) await writer.release(); + await broker.stop(); + await safeRm(fixture.root, { recursive: true, force: true }); + } +}, 30_000); + it("SDK deletion refuses a shared owner when a sibling transcript carries the same locator", async () => { const fixture = await createFixture(); const broker = new Broker({ agentDir: fixture.agentDir }); @@ -615,10 +826,11 @@ it("SDK deletion refuses a shared owner when a sibling transcript carries the sa const cleanup = cleanupOf(response); expect(cleanup).toMatchObject({ phase: "artifacts", - artifactsRemoved: true, + artifactsAbsentAtAuthorization: true, taskArtifactOwnerCleanupError: "task_artifact_owner_shared_with_sibling_transcript", taskArtifactOwnerDeletionEvidence: { schemaVersion: OWNER_DELETION_SCHEMA_VERSION }, }); + expect(cleanup.artifactsRemoved).toBeUndefined(); expect(await fs.readFile(fixture.transcript)).toEqual(originalTranscript); expect(await regularFiles(fixture.ownerDirectory)).toEqual(ownerBefore); expect( From 532e4746a84a9fa0b45a5e6018f4e82bbde674fd Mon Sep 17 00:00:00 2001 From: snowykr Date: Mon, 5 Oct 2026 18:13:43 +0900 Subject: [PATCH 3/3] fix(gc): authenticate empty historical retirement inventories Deleted workspaces should not make ordinary owner-free disk GC fail when an authenticated session scope contains no retirement journal. Authenticate and recheck stored namespaces without granting owner effect authority. Journal-bearing scopes retain the existing live-workspace requirement. Lore-id: 908c62d4 Constraint: never bypass malformed bindings or retirement journals Constraint: verify configured-root security and identity without repair Confidence: high Scope-risk: narrow Reversibility: simple-revert Tested: historical owner-free dry-run and actual retention pruning Tested: malformed binding and protocol symlink fail closed without mutation Tested: genuine owner journal with missing workspace still refuses Tested: external configured-root permissions and replacement preserve authority Not-tested: Windows runtime execution --- .../verified-session-cleanup-regressions.md | 6 + .../session/internal/managed-session-scope.ts | 97 ++++++++++++++ .../gc-task-artifact-owner-retirement.test.ts | 122 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 packages/coding-agent/changelog.d/verified-session-cleanup-regressions.md diff --git a/packages/coding-agent/changelog.d/verified-session-cleanup-regressions.md b/packages/coding-agent/changelog.d/verified-session-cleanup-regressions.md new file mode 100644 index 00000000000..3e07de11f59 --- /dev/null +++ b/packages/coding-agent/changelog.d/verified-session-cleanup-regressions.md @@ -0,0 +1,6 @@ +### Fixed + +- Restore SDK deletion and restart reconciliation for owner-free legacy sessions without creating a v2 scope or task-artifact owner. +- Preserve prepared artifact authority when task-owner validation refuses deletion before artifact effects, allowing safe retries after temporary protocol obstructions are removed. +- Retry initial task-owner evidence capture after a managed writer finishes, without replacing already-captured evidence or adopting changed transcripts and retained cleanup authority. +- Keep owner-free disk GC usable after a historical workspace is deleted while retaining fail-closed validation for malformed bindings, substituted protocol paths, and owner-retirement journals. diff --git a/packages/coding-agent/src/session/internal/managed-session-scope.ts b/packages/coding-agent/src/session/internal/managed-session-scope.ts index 2302cf6a926..eeec948d43c 100644 --- a/packages/coding-agent/src/session/internal/managed-session-scope.ts +++ b/packages/coding-agent/src/session/internal/managed-session-scope.ts @@ -3000,6 +3000,88 @@ export async function readManagedGcSessionRetirementReceiptReadOnly( } } +function hasManagedGcRetirementJournalWithoutWorkspace(scope: ManagedScope): boolean { + if ( + path.resolve(scope.canonicalCwd) !== scope.canonicalCwd || + scope.platform !== (process.platform === "win32" ? "win32" : "posix") || + scope.directoryName !== `v2-${scopeDigest(scope.platform, scope.canonicalCwd)}` + ) + throw new Error("managed_gc_scope_authority_mismatch"); + const identity = fs.lstatSync(scope.directoryPath, { bigint: true }); + if (!identity.isDirectory() || identity.isSymbolicLink()) throw new Error("managed_gc_scope_authority_mismatch"); + const rootPath = configuredRootPath(scope); + const rootIdentity = fs.lstatSync(rootPath, { bigint: true }); + const assertRoot = (): void => { + const security = validateNativeSecurityResult( + verifyExistingManagedScopeDirectory(rootPath), + "verify", + "directory", + ); + const current = fs.lstatSync(rootPath, { bigint: true }); + if ( + !security.ok || + !current.isDirectory() || + current.isSymbolicLink() || + current.dev !== rootIdentity.dev || + current.ino !== rootIdentity.ino + ) + throw new Error("managed_gc_scope_authority_mismatch"); + }; + assertRoot(); + const rootAuthority = managedDirectoryRoot(rootPath); + if ( + rootAuthority.dev !== BigInt.asUintN(64, rootIdentity.dev) || + rootAuthority.ino !== BigInt.asUintN(64, rootIdentity.ino) + ) + throw new Error("managed_gc_scope_authority_mismatch"); + const store = new ManagedSessionDescendantStore( + rootAuthority, + scope.directoryPath, + undefined, + scope.platform === "win32" ? "windows-existing-verify-first" : "default", + scope.agentDir, + { + canonicalPath: scope.directoryPath, + dev: BigInt.asUintN(64, identity.dev), + ino: BigInt.asUintN(64, identity.ino), + }, + "read-only", + ); + try { + store.verifyRootSecurity(); + const binding = store.readExpected(MANAGED_SESSION_BINDING_FILE); + if (!binding || validateBindingRaw(scope, binding.bytes.toString("utf8"))) + throw new Error("managed_gc_scope_authority_mismatch"); + const readNames = (): { identity: { dev: string; ino: string } | undefined; names: string[] } => { + let directoryIdentity: { dev: string; ino: string }; + try { + directoryIdentity = store.captureDirectoryIdentity(MANAGED_GC_RETIREMENT_RECEIPTS); + } catch (error) { + if (hasFsCode(error, "ENOENT")) return { identity: undefined, names: [] }; + throw error; + } + const names = fs.readdirSync(path.join(scope.directoryPath, MANAGED_GC_RETIREMENT_RECEIPTS)).sort(); + const after = store.captureDirectoryIdentity(MANAGED_GC_RETIREMENT_RECEIPTS); + if (!util.isDeepStrictEqual(directoryIdentity, after)) throw new Error("managed_gc_scope_authority_mismatch"); + return { identity: directoryIdentity, names }; + }; + const before = readNames(); + const currentBinding = store.readExpected(MANAGED_SESSION_BINDING_FILE); + if ( + !currentBinding || + !util.isDeepStrictEqual(binding.identity, currentBinding.identity) || + !binding.bytes.equals(currentBinding.bytes) || + !util.isDeepStrictEqual(before, readNames()) + ) + throw new Error("managed_gc_scope_authority_mismatch"); + store.verifyRootSecurity(); + assertRoot(); + return before.names.some(name => name.startsWith(MANAGED_GC_RETIREMENT_PREFIX)); + } finally { + store.close(); + } +} + /** Discover complete owner journals under authenticated existing v2 scopes without initializing storage. */ export async function discoverManagedGcSessionRetirementReceipts(input: { agentDir: string; @@ -3045,6 +3127,21 @@ export async function discoverManagedGcSessionRetirementReceipts(input: { if (!isBinding(bindingValue) || bindingValue.identityDigest !== entry.name.slice(3)) throw new Error("managed_gc_scope_authority_mismatch"); const resolved = resolveManagedGcScopeForRead({ cwd: bindingValue.canonicalPath, agentDir, sessionsRoot }); + if (resolved.kind === "error" && resolved.code === "cwd_missing") { + const storedScope: ManagedScope = { + apiVersion: 1, + layoutVersion: MANAGED_SESSION_LAYOUT_VERSION, + identityVersion: MANAGED_SESSION_IDENTITY_VERSION, + agentDir, + sessionsRoot, + canonicalCwd: bindingValue.canonicalPath, + legacyLexicalCwd: bindingValue.canonicalPath, + directoryName: entry.name, + directoryPath: scopePath, + platform: bindingValue.platform, + }; + if (!hasManagedGcRetirementJournalWithoutWorkspace(storedScope)) continue; + } if (resolved.kind !== "resolved" || resolved.scope.directoryPath !== scopePath) throw new Error("managed_gc_scope_authority_mismatch"); const scope = resolved.scope; diff --git a/packages/coding-agent/test/gc-task-artifact-owner-retirement.test.ts b/packages/coding-agent/test/gc-task-artifact-owner-retirement.test.ts index b55c345f4d8..a5bb8fa2f07 100644 --- a/packages/coding-agent/test/gc-task-artifact-owner-retirement.test.ts +++ b/packages/coding-agent/test/gc-task-artifact-owner-retirement.test.ts @@ -9,6 +9,8 @@ import { collectGcDiskReport, resolveGcDiskPolicy } from "../src/gjc-runtime/gc- import { Broker } from "../src/sdk/broker/broker"; import { bindManagedGcSessionRetirementTarget, + discoverManagedGcSessionRetirementReceipts, + MANAGED_SESSION_BINDING_FILE, type ManagedScope, managedGcProtocolScopeInspectorForScope, prepareManagedSessionScopeForWriteSync, @@ -68,6 +70,20 @@ function writeSession(scope: ManagedScope, id: string, cwd: string, locator?: Ta return transcriptPath; } +function makeHistoricalExternalScope() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "gc-external-historical-scope-"))); + roots.push(root); + const agentDir = path.join(root, "profile"); + const configuredRoot = path.join(root, "external"); + const sessionsRoot = path.join(configuredRoot, "sessions"); + const cwd = path.join(root, "workspace"); + for (const directory of [agentDir, configuredRoot, cwd]) fs.mkdirSync(directory, { mode: 0o700 }); + const scope = managedScope(agentDir, sessionsRoot, cwd); + writeSession(scope, crypto.randomUUID(), cwd); + fs.rmSync(cwd, { recursive: true }); + return { root, agentDir, configuredRoot, sessionsRoot }; +} + function makeFixture(): Fixture { const root = fs.mkdtempSync(path.join(os.tmpdir(), "gc-task-owner-retirement-")); roots.push(root); @@ -237,6 +253,112 @@ async function publishArtifactsRemoved(fixture: Fixture) { } describe("owner-aware disk session retirement", () => { + it.each([ + false, + true, + ])("keeps owner-free GC usable after its historical workspace is removed (prune=%s)", async prune => { + const fixture = makeFixture(); + writeSession(fixture.scope, fixture.sessionId, fixture.cwd); + await fs.promises.rm(fixture.ownerPath, { recursive: true }); + await backdate(fixture.transcriptPath, 90); + writeSession(fixture.scope, "newest-session", fixture.cwd); + await fs.promises.rm(fixture.cwd, { recursive: true }); + const report = await runGc(fixture, prune); + expect(report.errors).toEqual([]); + const oldSession = report.surfaces.sessions.records.find(record => record.path === fixture.transcriptPath); + expect(oldSession?.action).toBe(prune ? "reclaimed" : "would_reclaim"); + expect(fs.existsSync(fixture.transcriptPath)).toBe(!prune); + expect(fs.existsSync(path.join(fixture.scope.directoryPath, "newest-session.jsonl"))).toBe(true); + }); + it.each([ + "binding-bytes", + "binding-digest", + "protocol-symlink", + "journal", + ] as const)("refuses unauthenticated or journal-bearing historical scopes after workspace deletion (%s)", async obstruction => { + const fixture = makeFixture(); + const bindingPath = path.join(fixture.scope.directoryPath, MANAGED_SESSION_BINDING_FILE); + if (obstruction === "binding-bytes") { + const bytes = await Bun.file(bindingPath).text(); + await Bun.write(bindingPath, bytes.trim()); + } else if (obstruction === "binding-digest") { + const binding = await Bun.file(bindingPath).json(); + await Bun.write(bindingPath, `${JSON.stringify({ ...binding, identityDigest: "a".repeat(52) })}\n`); + } else if (obstruction === "protocol-symlink") { + const protocol = path.join(fixture.scope.directoryPath, ".gjc-managed-session-internal"); + await fs.promises.rm(protocol, { recursive: true, force: true }); + const foreign = path.join(fixture.root, "foreign-protocol"); + await fs.promises.mkdir(foreign, { mode: 0o700 }); + await fs.promises.symlink(foreign, protocol, process.platform === "win32" ? "junction" : "dir"); + } else { + await publishPrepared(fixture); + } + await fs.promises.rm(fixture.cwd, { recursive: true }); + const before = snapshotTree(fixture.scope.directoryPath); + const report = await runGc(fixture, false); + expect(JSON.stringify(report.errors)).toContain( + obstruction === "protocol-symlink" + ? "Managed descendant path escapes retained store" + : "managed_gc_scope_authority_mismatch", + ); + expect(snapshotTree(fixture.scope.directoryPath)).toEqual(before); + }); + + it("authenticates an external configured root for empty historical journal discovery without mutation", async () => { + const fixture = makeHistoricalExternalScope(); + const before = snapshotTree(fixture.configuredRoot); + expect(await discoverManagedGcSessionRetirementReceipts(fixture)).toEqual([]); + expect(snapshotTree(fixture.configuredRoot)).toEqual(before); + }); + + it.skipIf(process.platform === "win32")( + "rejects insecure POSIX configured-root permissions without repair", + async () => { + const fixture = makeHistoricalExternalScope(); + fs.chmodSync(fixture.configuredRoot, 0o777); + const before = snapshotTree(fixture.configuredRoot); + await expect(discoverManagedGcSessionRetirementReceipts(fixture)).rejects.toThrow( + "managed_gc_scope_authority_mismatch", + ); + expect(snapshotTree(fixture.configuredRoot)).toEqual(before); + }, + ); + + it("rejects configured-parent replacement even when the original sessions subtree is preserved", async () => { + const fixture = makeHistoricalExternalScope(); + const originalParent = fs.lstatSync(fixture.configuredRoot, { bigint: true }); + const originalSessions = fs.lstatSync(fixture.sessionsRoot, { bigint: true }); + let checks = 0; + let retainedSnapshot: string[] | undefined; + const replaceAfterVerification = (pathname: string): void => { + if (pathname !== fixture.configuredRoot || ++checks !== 2) return; + const retained = `${fixture.configuredRoot}.original`; + fs.renameSync(fixture.configuredRoot, retained); + fs.mkdirSync(fixture.configuredRoot, { mode: 0o700 }); + fs.renameSync(path.join(retained, "sessions"), fixture.sessionsRoot); + retainedSnapshot = snapshotTree(fixture.sessionsRoot); + }; + const originalVerify = native.verifyOwnerOnlyPathSecurity; + vi.spyOn(native, "verifyOwnerOnlyPathSecurity").mockImplementation((pathname, kind) => { + const result = originalVerify(pathname, kind); + replaceAfterVerification(pathname); + return result; + }); + const originalExpected = native.verifyOwnerOnlyPathSecurityExpected; + vi.spyOn(native, "verifyOwnerOnlyPathSecurityExpected").mockImplementation((pathname, kind, dev, ino) => { + const result = originalExpected(pathname, kind, dev, ino); + replaceAfterVerification(pathname); + return result; + }); + await expect(discoverManagedGcSessionRetirementReceipts(fixture)).rejects.toThrow( + process.platform === "win32" ? "identity_mismatch" : "managed_gc_scope_authority_mismatch", + ); + if (!retainedSnapshot) throw new Error("Configured-parent replacement did not reach the verified boundary"); + expect(fs.lstatSync(fixture.configuredRoot, { bigint: true }).ino).not.toBe(originalParent.ino); + expect(fs.lstatSync(fixture.sessionsRoot, { bigint: true }).ino).toBe(originalSessions.ino); + expect(snapshotTree(fixture.sessionsRoot)).toEqual(retainedSnapshot); + }); + for (const phase of ["prepared", "artifacts_removed"] as const) { for (const transcriptAuthority of ["retained", "absent", "fsync-empty"] as const) { it(`retains a genuine legacy SDK receipt after GC ${phase} with ${transcriptAuthority} authority`, async () => {