From 9efcbee07902040c6dc436b65491aa7cbfc73206 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 4 Oct 2026 07:06:41 +0900 Subject: [PATCH 1/2] feat(session): authenticate readonly managed GC journal probes Disk dry runs and residual accounting need existing scope authority without initialization, lock creation, reaping or security repair. Bind exact roots and reuse immutable prepared history before exposing readonly discovery. Lore-id: 8240f16d Constraint: DTOs and canonical path absence never establish native retirement Confidence: high Scope-risk: narrow Reversibility: easy Tested: exact readonly API tree focused journal and owner-storage tests Tested: package vendor Biome TypeScript and fresh Bun absence discovery Not-tested: current-dev rebased cumulative consumers and activation --- .../session/internal/managed-session-scope.ts | 243 +++++++++++++++++- .../managed-gc-retirement-journal.test.ts | 145 +++++++++++ 2 files changed, 386 insertions(+), 2 deletions(-) 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 f33c0cba262..7f01f9adc85 100644 --- a/packages/coding-agent/src/session/internal/managed-session-scope.ts +++ b/packages/coding-agent/src/session/internal/managed-session-scope.ts @@ -65,6 +65,7 @@ import { ManagedSessionDescendantStore, type ManagedSessionSecurityPolicy, type ManagedStorageLock, + managedDirectoryRoot, managedSecurityFailureClassification, prepareManagedDirectoryRoot, publishManagedFileNoReplace, @@ -115,6 +116,14 @@ const managedScopeConfigurations = new WeakMap< const managedDirectoryIdentities = new WeakMap(); const managedDirectoryAuthorities = new WeakMap(); const boundManagedWriteAuthorities = new WeakMap(); +const readManagedGcScopeIdentities = new WeakMap< + ManagedScope, + { + readonly configuredRoot: { readonly path: string; readonly dev: bigint; readonly ino: bigint }; + readonly profile: { readonly path: string; readonly dev: bigint; readonly ino: bigint }; + readonly sessions: { readonly path: string; readonly dev: bigint; readonly ino: bigint }; + } +>(); function bindManagedWriteAuthority(scope: ManagedScope, authority: ManagedCandidateWriteAuthority): void { if ( @@ -714,6 +723,71 @@ export function resolveManagedScopeForWrite(input: ManagedScopeInput): ManagedSc return resolveManagedScopeInternal(input, true); } +/** Resolve only an existing, authenticated v2 scope; this path never initializes or repairs storage. */ +export function resolveManagedGcScopeForRead(input: ManagedScopeInput): ManagedScopeResolution { + const resolved = resolveManagedScope(input); + if (resolved.kind === "error") return resolved; + const scope = resolved.scope; + try { + const rootPath = configuredRootPath(scope); + if ( + path.resolve(scope.agentDir) !== scope.agentDir || + path.resolve(scope.sessionsRoot) !== scope.sessionsRoot || + path.dirname(scope.directoryPath) !== scope.sessionsRoot || + scope.directoryName !== `v2-${scopeDigest(scope.platform, scope.canonicalCwd)}` + ) + throw new Error("managed_gc_scope_authority_mismatch"); + const captureDirectory = (pathname: string) => { + const stat = fs.lstatSync(pathname, { bigint: true }); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("managed_gc_scope_authority_mismatch"); + const security = validateNativeSecurityResult( + verifyExistingManagedScopeDirectory(pathname), + "verify", + "directory", + ); + if (!security.ok) throw new Error("managed_gc_scope_authority_mismatch"); + return { path: pathname, dev: stat.dev, ino: stat.ino }; + }; + const configuredRoot = captureDirectory(rootPath); + const profile = captureDirectory(scope.agentDir); + const sessions = captureDirectory(scope.sessionsRoot); + const scopeDirectory = captureDirectory(scope.directoryPath); + const rootAuthority = managedDirectoryRoot(rootPath); + if ( + rootAuthority.canonicalPath !== rootPath || + rootAuthority.dev !== BigInt.asUintN(64, configuredRoot.dev) || + rootAuthority.ino !== BigInt.asUintN(64, configuredRoot.ino) + ) + throw new Error("managed_gc_scope_authority_mismatch"); + const bindingPath = path.join(scope.directoryPath, MANAGED_SESSION_BINDING_FILE); + const binding = captureManagedFileNoFollow(bindingPath); + if (validateBindingRaw(scope, binding.bytes.toString("utf8"))) + throw new Error("managed_gc_scope_authority_mismatch"); + for (const expected of [configuredRoot, profile, sessions, scopeDirectory]) { + const current = fs.lstatSync(expected.path, { bigint: true }); + if ( + !current.isDirectory() || + current.isSymbolicLink() || + current.dev !== expected.dev || + current.ino !== expected.ino + ) + throw new Error("managed_gc_scope_authority_mismatch"); + } + managedRoots.set(scope, rootAuthority); + managedDirectoryIdentities.set(scope, { dev: scopeDirectory.dev, ino: scopeDirectory.ino }); + managedDirectoryAuthorities.set(scope, undefined); + readManagedGcScopeIdentities.set(scope, { configuredRoot, profile, sessions }); + return resolved; + } catch (error) { + return { + kind: "error", + code: "binding_invalid", + message: "The existing managed GC read authority could not be verified.", + cause: { classification: managedScopeResidualClassification(error) ?? "managed_gc_scope_authority_mismatch" }, + }; + } +} + function legacyDirectoryNames( platform: ManagedScope["platform"], canonicalCwd: string, @@ -2148,6 +2222,31 @@ function managedGcTrustedScope(scope: ManagedScope): ManagedGcTrustedScope { assertManagedDirectoryRoot(root); managedDirectoryIdentityForScope(scope); assertRetainedManagedDirectoryIdentity(scope); + const readIdentities = readManagedGcScopeIdentities.get(scope); + if (readIdentities) { + for (const expected of [readIdentities.configuredRoot, readIdentities.profile, readIdentities.sessions]) { + const current = fs.lstatSync(expected.path, { bigint: true }); + if ( + !current.isDirectory() || + current.isSymbolicLink() || + current.dev !== expected.dev || + current.ino !== expected.ino + ) + throw new Error("managed_gc_scope_authority_mismatch"); + } + for (const pathname of [ + readIdentities.configuredRoot.path, + readIdentities.profile.path, + readIdentities.sessions.path, + ]) { + const verified = validateNativeSecurityResult( + verifyExistingManagedScopeDirectory(pathname), + "verify", + "directory", + ); + if (!verified.ok) throw new Error("managed_gc_scope_authority_mismatch"); + } + } return { root, retainedAuthority: managedDirectoryAuthorities.get(scope), @@ -2454,10 +2553,10 @@ function readManagedGcSessionRetirementReceiptUnlocked( transcriptPathValue: string, trusted: ManagedGcTrustedScope, store: ManagedSessionDescendantStore, - lock: ManagedStorageLock, + lock?: ManagedStorageLock, ): ManagedGcSessionRetirementReceipt | undefined { const transcriptPath = validateManagedGcTranscriptPath(scope, transcriptPathValue); - lock.assertOwned(); + lock?.assertOwned(); store.assertBound(); const prepared = readManagedGcPreparedAuthority(scope, transcriptPath, trusted.ownerContext, store); if (!prepared) { @@ -2634,6 +2733,146 @@ export async function readManagedGcSessionRetirementReceipt( ); } +/** Read an authenticated journal without creating lock/receipt directories or repairing security state. */ +export async function readManagedGcSessionRetirementReceiptReadOnly( + scope: ManagedScope, + transcriptPath: string, +): Promise { + const trusted = managedGcTrustedScope(scope); + validateManagedGcTranscriptPath(scope, transcriptPath); + const store = managedGcScopeStore(scope, trusted); + try { + store.verifyRootSecurity(); + store.assertBound(); + try { + store.captureDirectoryIdentity(MANAGED_GC_RETIREMENT_RECEIPTS); + } catch (error) { + if (hasFsCode(error, "ENOENT")) return undefined; + throw error; + } + return readManagedGcSessionRetirementReceiptUnlocked(scope, transcriptPath, trusted, store); + } finally { + store.close(); + } +} + +/** Discover complete owner journals under authenticated existing v2 scopes without initializing storage. */ +export async function discoverManagedGcSessionRetirementReceipts(input: { + agentDir: string; + sessionsRoot: string; +}): Promise { + const agentDir = canonicalizeTrustedPath(input.agentDir); + const sessionsRoot = canonicalizeTrustedPath(input.sessionsRoot); + const profileStat = fs.lstatSync(agentDir, { bigint: true }); + const rootStat = fs.lstatSync(sessionsRoot, { bigint: true }); + for (const [pathname, stat] of [ + [agentDir, profileStat], + [sessionsRoot, rootStat], + ] as const) { + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("managed_gc_scope_authority_mismatch"); + const verified = validateNativeSecurityResult( + verifyExistingManagedScopeDirectory(pathname), + "verify", + "directory", + ); + if (!verified.ok) throw new Error("managed_gc_scope_authority_mismatch"); + } + const inventoryBefore = { + dev: rootStat.dev, + ino: rootStat.ino, + profileDev: profileStat.dev, + profileIno: profileStat.ino, + }; + const entries = fs.readdirSync(sessionsRoot, { withFileTypes: true }); + const result: Array<{ readonly scope: ManagedScope; readonly receipt: ManagedGcSessionRetirementReceipt }> = []; + for (const entry of entries) { + if (!/^v2-[a-z2-7]{52}$/u.test(entry.name)) continue; + if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("managed_gc_scope_authority_mismatch"); + const scopePath = path.join(sessionsRoot, entry.name); + const bindingPath = path.join(scopePath, MANAGED_SESSION_BINDING_FILE); + const bindingBytes = captureManagedFileNoFollow(bindingPath).bytes; + let bindingValue: unknown; + try { + bindingValue = JSON.parse(bindingBytes.toString("utf8")); + } catch { + throw new Error("managed_gc_scope_authority_mismatch"); + } + 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 !== "resolved" || resolved.scope.directoryPath !== scopePath) + throw new Error("managed_gc_scope_authority_mismatch"); + const scope = resolved.scope; + const trusted = managedGcTrustedScope(scope); + const store = managedGcScopeStore(scope, trusted); + try { + let directoryIdentity: { dev: string; ino: string }; + try { + directoryIdentity = store.captureDirectoryIdentity(MANAGED_GC_RETIREMENT_RECEIPTS); + } catch (error) { + if (hasFsCode(error, "ENOENT")) continue; + throw error; + } + const receiptDir = path.join(scopePath, MANAGED_GC_RETIREMENT_RECEIPTS); + const receiptEntries = fs.readdirSync(receiptDir, { withFileTypes: true }); + const after = store.captureDirectoryIdentity(MANAGED_GC_RETIREMENT_RECEIPTS); + if (after.dev !== directoryIdentity.dev || after.ino !== directoryIdentity.ino) + throw new Error("task_artifact_owner_continuation_authority_mismatch"); + const names = receiptEntries.filter(item => item.name.startsWith(MANAGED_GC_RETIREMENT_PREFIX)); + const groups = new Map(); + for (const item of names) { + if ( + !item.isFile() || + item.isSymbolicLink() || + !/^gc-retirement-[a-f0-9]{64}-(?:prepared|artifacts_removed|owner_retired|owner_pending-[0-9]{8,})\.json$/u.test( + item.name, + ) + ) + throw new Error("task_artifact_owner_continuation_corrupt"); + const key = item.name.slice(MANAGED_GC_RETIREMENT_PREFIX.length, MANAGED_GC_RETIREMENT_PREFIX.length + 64); + groups.set(key, [...(groups.get(key) ?? []), item.name]); + } + for (const [key, group] of groups) { + const preparedName = `${MANAGED_GC_RETIREMENT_PREFIX}${key}-prepared.json`; + if (!group.includes(preparedName)) throw new Error("task_artifact_owner_continuation_state_missing"); + const preparedFile = store.readExpected(`${MANAGED_GC_RETIREMENT_RECEIPTS}/${preparedName}`); + if (!preparedFile) throw new Error("task_artifact_owner_continuation_state_missing"); + let preparedValue: unknown; + try { + preparedValue = JSON.parse(preparedFile.bytes.toString("utf8")); + } catch { + throw new Error("task_artifact_owner_continuation_corrupt"); + } + const transcriptPath = + preparedValue && typeof preparedValue === "object" + ? (preparedValue as Record).transcriptPath + : undefined; + if (typeof transcriptPath !== "string" || managedGcRetirementTranscriptKey(transcriptPath) !== key) + throw new Error("task_artifact_owner_continuation_authority_mismatch"); + const receipt = await readManagedGcSessionRetirementReceiptReadOnly(scope, transcriptPath); + if (!receipt) throw new Error("task_artifact_owner_continuation_state_missing"); + result.push({ scope, receipt }); + } + } finally { + store.close(); + } + } + const rootAfter = fs.lstatSync(sessionsRoot, { bigint: true }); + const profileAfter = fs.lstatSync(agentDir, { bigint: true }); + if ( + !rootAfter.isDirectory() || + rootAfter.isSymbolicLink() || + rootAfter.dev !== inventoryBefore.dev || + rootAfter.ino !== inventoryBefore.ino || + !profileAfter.isDirectory() || + profileAfter.isSymbolicLink() || + profileAfter.dev !== inventoryBefore.profileDev || + profileAfter.ino !== inventoryBefore.profileIno + ) + throw new Error("managed_gc_scope_authority_mismatch"); + return result.sort((left, right) => left.receipt.transcriptPath.localeCompare(right.receipt.transcriptPath)); +} + function managedGcReceiptForPublication( scope: ManagedScope, receipt: ManagedGcSessionRetirementReceipt, diff --git a/packages/coding-agent/test/managed-gc-retirement-journal.test.ts b/packages/coding-agent/test/managed-gc-retirement-journal.test.ts index 53faf56767e..ced5cf09f24 100644 --- a/packages/coding-agent/test/managed-gc-retirement-journal.test.ts +++ b/packages/coding-agent/test/managed-gc-retirement-journal.test.ts @@ -11,11 +11,14 @@ import { import { bindManagedGcSessionRetirementTarget, computeManagedScopeDigest, + discoverManagedGcSessionRetirementReceipts, type ManagedScope, managedDirectoryIdentityForScope, prepareManagedSessionScopeForWriteSync, publishManagedGcSessionRetirementReceipt, readManagedGcSessionRetirementReceipt, + readManagedGcSessionRetirementReceiptReadOnly, + resolveManagedGcScopeForRead, resolveManagedScopeForWrite, taskArtifactOwnerStorageContextForScope, } from "../src/session/internal/managed-session-scope"; @@ -242,6 +245,30 @@ function readReceiptSuffix(fixture: Fixture, suffix: string): unknown { } } +function snapshotTree(root: string): unknown[] { + const entries: unknown[] = []; + const visit = (pathname: string, relative: string): void => { + const stat = fs.lstatSync(pathname, { bigint: true }); + entries.push({ + relative, + dev: stat.dev.toString(), + ino: stat.ino.toString(), + mode: stat.mode.toString(), + ctimeNs: stat.ctimeNs.toString(), + kind: stat.isDirectory() ? "directory" : stat.isFile() ? "file" : "other", + }); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + for (const name of fs.readdirSync(pathname).sort()) { + visit(path.join(pathname, name), path.join(relative, name)); + } + } else if (stat.isFile()) { + entries.push({ relative: `${relative}:bytes`, bytes: fs.readFileSync(pathname).toString("base64") }); + } + }; + visit(root, "."); + return entries; +} + describe("managed GC retirement journal", () => { it("persists actual native disposition and replays pending namespaces after transcript absence", async () => { const fixture = makeFixture(); @@ -366,6 +393,9 @@ describe("managed GC retirement journal", () => { store.close(); } await expect(readManagedGcSessionRetirementReceipt(fixture.scope, fixture.transcriptPath)).rejects.toThrow(); + await expect( + readManagedGcSessionRetirementReceiptReadOnly(fixture.scope, fixture.transcriptPath), + ).rejects.toThrow(); const replacedTranscript = makeFixture(); await publishManagedGcSessionRetirementReceipt(replacedTranscript.scope, preparedReceipt(replacedTranscript)); @@ -384,6 +414,9 @@ describe("managed GC retirement journal", () => { await expect( readManagedGcSessionRetirementReceipt(replacedTranscript.scope, replacedTranscript.transcriptPath), ).rejects.toThrow(); + await expect( + readManagedGcSessionRetirementReceiptReadOnly(replacedTranscript.scope, replacedTranscript.transcriptPath), + ).rejects.toThrow(); }); it("rejects wrong profile/root scope and transcripts outside the trusted scope parent", () => { @@ -401,6 +434,118 @@ describe("managed GC retirement journal", () => { ).toThrow(); }); + it("resolves and reads owner journals without changing managed bytes, modes, ctimes, or private directories", async () => { + const fixture = makeFixture(); + await publishManagedGcSessionRetirementReceipt(fixture.scope, preparedReceipt(fixture)); + const before = snapshotTree(fixture.temporaryRoot); + const resolved = resolveManagedGcScopeForRead({ + cwd: fixture.cwd, + agentDir: fixture.agentDir, + sessionsRoot: fixture.sessionsRoot, + }); + expect(resolved.kind).toBe("resolved"); + if (resolved.kind !== "resolved") throw new Error(`readonly_scope_resolution_failed:${resolved.code}`); + expect((await readManagedGcSessionRetirementReceiptReadOnly(resolved.scope, fixture.transcriptPath))?.state).toBe( + "prepared", + ); + const discovered = await discoverManagedGcSessionRetirementReceipts({ + agentDir: fixture.agentDir, + sessionsRoot: fixture.sessionsRoot, + }); + expect(discovered.map(item => item.receipt.transcriptPath)).toContain(fixture.transcriptPath); + expect(snapshotTree(fixture.temporaryRoot)).toEqual(before); + }); + + it("discovers original prepared authority in a fresh process after transcript absence", async () => { + const fixture = makeFixture(); + await publishManagedGcSessionRetirementReceipt(fixture.scope, preparedReceipt(fixture)); + const store = openScopeStore(fixture.scope); + try { + store.removeIfExistsDescriptor(path.basename(fixture.transcriptPath)); + } finally { + store.close(); + } + const moduleUrl = new URL("../src/session/internal/managed-session-scope.ts", import.meta.url).href; + const script = + `import { discoverManagedGcSessionRetirementReceipts } from ${JSON.stringify(moduleUrl)}; +` + + ` const records = await discoverManagedGcSessionRetirementReceipts({ +` + + ` agentDir: ${JSON.stringify(fixture.agentDir)}, +` + + ` sessionsRoot: ${JSON.stringify(fixture.sessionsRoot)}, +` + + ` }); +` + + ` process.stdout.write(JSON.stringify(records.map(({ receipt }) => ({ +` + + ` transcriptPath: receipt.transcriptPath, state: receipt.state, +` + + ` })))); +`; + const child = Bun.spawnSync({ + cmd: [process.execPath, "-e", script], + cwd: fixture.temporaryRoot, + stdout: "pipe", + stderr: "pipe", + }); + expect(child.exitCode).toBe(0); + expect(JSON.parse(Buffer.from(child.stdout).toString("utf8"))).toContainEqual({ + transcriptPath: fixture.transcriptPath, + state: "prepared", + }); + expect(fs.existsSync(fixture.transcriptPath)).toBe(false); + }); + + it("rejects forged prepared target identity and transcript-key substitution", async () => { + const fixture = makeFixture(); + const key = crypto.createHash("sha256").update(path.resolve(fixture.transcriptPath), "utf8").digest("hex"); + const forged = { + schemaVersion: 1, + state: "prepared", + scope: computeManagedScopeDigest(fixture.scope.platform, fixture.scope.canonicalCwd), + transcriptPath: fixture.transcriptPath, + sessionId: fixture.target.sessionId, + cwd: fixture.cwd, + taskArtifactOwnerLocator: fixture.target.taskArtifactOwnerLocator, + transcriptIdentity: { + ...managedGcRetirementIdentityRecord(fixture.target.transcriptIdentity), + ino: (fixture.target.transcriptIdentity.ino + 1n).toString(), + }, + taskArtifactOwnerDeletionEvidence: fixture.evidence, + }; + const substitutedPath = path.join(fixture.scope.directoryPath, "substituted.jsonl"); + const substitutedKey = crypto.createHash("sha256").update(path.resolve(substitutedPath), "utf8").digest("hex"); + const store = openScopeStore(fixture.scope); + try { + for (const receiptKey of [key, substitutedKey]) { + store.publishNoReplaceSync( + `.gjc-managed-session-internal/receipts/gc-retirement-${receiptKey}-prepared.json`, + Buffer.from(`${JSON.stringify(forged)}\n`, "utf8"), + ); + } + } finally { + store.close(); + } + await expect( + readManagedGcSessionRetirementReceiptReadOnly(fixture.scope, fixture.transcriptPath), + ).rejects.toThrow(); + await expect(readManagedGcSessionRetirementReceiptReadOnly(fixture.scope, substitutedPath)).rejects.toThrow(); + expect(fs.existsSync(fixture.transcriptPath)).toBe(true); + expect( + fs.readFileSync( + path.join( + ownerAbsolutePath( + taskArtifactOwnerStorageContextForScope(fixture.scope), + fixture.target.taskArtifactOwnerLocator.ownerId, + ), + "payload.json", + ), + "utf8", + ), + ).toBe("fixture payload"); + }); + it("rejects a numbered-attempt gap and later states without prepared authority", async () => { const fixture = makeFixture(); await publishManagedGcSessionRetirementReceipt(fixture.scope, preparedReceipt(fixture)); From 47a83e64b2321329d7036d9bae8d969eed8f6105 Mon Sep 17 00:00:00 2001 From: snowykr Date: Sun, 4 Oct 2026 18:11:16 +0900 Subject: [PATCH 2/2] fix(session): separate readonly reads from recovery authority Linux retained-directory derivation and recovery-root reopening can create or reap recovery state. Journal discovery and live owner verification must use a path-backed readonly capability with the same identity/security checks, not a native recovery writer. Reject readonly mutations and retained authority exposure before effects. Lore-id: bd8036a1 Constraint: preserve full bytes/modes/ctimes snapshot assertions and strict native gates Constraint: no native redesign, unsafe unlink or snapshot-as-effect-authority Tested: readonly recovery-open/retain spies, continuation verification and mutation refusal; four focused suites; full coding-agent package check exit0 Not-tested: repaired Linux CI until fresh code event Confidence: high Scope-risk: bounded Reversibility: revert --- .../task-owner-readonly-recovery.md | 3 + .../session/internal/managed-session-scope.ts | 21 +++- .../internal/managed-session-storage.ts | 18 ++- .../internal/task-artifact-owner-access.ts | 15 ++- .../session/task-artifact-owner-retirement.ts | 5 +- .../managed-gc-retirement-journal.test.ts | 119 +++++++++++++++--- 6 files changed, 155 insertions(+), 26 deletions(-) create mode 100644 packages/coding-agent/changelog.d/task-owner-readonly-recovery.md diff --git a/packages/coding-agent/changelog.d/task-owner-readonly-recovery.md b/packages/coding-agent/changelog.d/task-owner-readonly-recovery.md new file mode 100644 index 00000000000..82849cdbb84 --- /dev/null +++ b/packages/coding-agent/changelog.d/task-owner-readonly-recovery.md @@ -0,0 +1,3 @@ +### Fixed + +- Keep managed task-owner journal discovery and live retirement verification read-only on Linux by avoiding recovery-capable native handles. Read-only stores reject mutation and authority retention while preserving existing identity and security checks. 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 7f01f9adc85..b8397d09f9d 100644 --- a/packages/coding-agent/src/session/internal/managed-session-scope.ts +++ b/packages/coding-agent/src/session/internal/managed-session-scope.ts @@ -2280,6 +2280,21 @@ function managedGcScopeStore(scope: ManagedScope, trusted: ManagedGcTrustedScope }, ); } +function managedGcScopeReader(scope: ManagedScope, trusted: ManagedGcTrustedScope): ManagedSessionDescendantStore { + return new ManagedSessionDescendantStore( + trusted.root, + scope.directoryPath, + undefined, + trusted.policy, + scope.agentDir, + { + canonicalPath: scope.directoryPath, + dev: BigInt.asUintN(64, trusted.identity.dev), + ino: BigInt.asUintN(64, trusted.identity.ino), + }, + "read-only", + ); +} function managedGcRetirementTranscriptKey(transcriptPath: string): string { return createHash("sha256").update(path.resolve(transcriptPath), "utf8").digest("hex"); } @@ -2354,7 +2369,7 @@ export function bindManagedGcSessionRetirementTarget( ): ManagedGcSessionRetirementTarget { const trusted = managedGcTrustedScope(scope); const transcriptPath = validateManagedGcTranscriptPath(scope, transcriptPathValue); - const store = managedGcScopeStore(scope, trusted); + const store = managedGcScopeReader(scope, trusted); try { store.verifyRootSecurity(); store.assertBound(); @@ -2740,7 +2755,7 @@ export async function readManagedGcSessionRetirementReceiptReadOnly( ): Promise { const trusted = managedGcTrustedScope(scope); validateManagedGcTranscriptPath(scope, transcriptPath); - const store = managedGcScopeStore(scope, trusted); + const store = managedGcScopeReader(scope, trusted); try { store.verifyRootSecurity(); store.assertBound(); @@ -2804,7 +2819,7 @@ export async function discoverManagedGcSessionRetirementReceipts(input: { throw new Error("managed_gc_scope_authority_mismatch"); const scope = resolved.scope; const trusted = managedGcTrustedScope(scope); - const store = managedGcScopeStore(scope, trusted); + const store = managedGcScopeReader(scope, trusted); try { let directoryIdentity: { dev: string; ino: string }; try { diff --git a/packages/coding-agent/src/session/internal/managed-session-storage.ts b/packages/coding-agent/src/session/internal/managed-session-storage.ts index fe0ed554bcf..3206e8c324e 100644 --- a/packages/coding-agent/src/session/internal/managed-session-storage.ts +++ b/packages/coding-agent/src/session/internal/managed-session-storage.ts @@ -1457,6 +1457,7 @@ export class ManagedSessionDescendantStore { readonly #root: ManagedDirectoryRoot; readonly #baseDir: string; readonly #policy: ManagedSessionSecurityPolicy; + readonly #access: "read-only" | "read-write"; readonly #authority: RecoveryFsRoot | undefined; #ownsAuthority = false; #closed = false; @@ -1475,12 +1476,18 @@ export class ManagedSessionDescendantStore { policy?: ManagedSessionSecurityPolicy, profileAgentDir?: string, expectedSubtreeRoot?: ManagedDirectoryRoot, + access: "read-only" | "read-write" = "read-write", ) { + if (access !== "read-only" && access !== "read-write") throw new Error("managed_store_access_invalid"); + if (access === "read-only" && retained) throw new Error("managed_read_store_cannot_borrow_authority"); + if (access === "read-only" && !expectedSubtreeRoot) + throw new Error("managed_read_store_requires_existing_identity"); managedRelativePath(root, baseDir); this.#root = root; this.#baseDir = path.resolve(baseDir); this.#policy = policy ?? "default"; + this.#access = access; this.#profileAgentDir = profileAgentDir ?? root.canonicalPath; this.#authorityBaseDir = retained?.authorityBaseDir ?? this.#baseDir; if (retained) { @@ -1558,7 +1565,7 @@ export class ManagedSessionDescendantStore { dev: canonicalFileId(subtreeStat.dev), ino: canonicalFileId(subtreeStat.ino), }); - if (process.platform === "linux") { + if (process.platform === "linux" && access === "read-write") { const before = fs.lstatSync(this.#baseDir, { bigint: true }); const authority = nativeSessionStorage().openRecoveryFsRoot(this.#baseDir); const retained = authority.identity(); @@ -1619,6 +1626,7 @@ export class ManagedSessionDescendantStore { } retainAuthority(): RecoveryFsRoot | undefined { + this.#assertWritable(); if (!this.#authority) return undefined; return this.#authority.retainManagedDirectory( "", @@ -1673,7 +1681,7 @@ export class ManagedSessionDescendantStore { captureDirectoryIdentity(relativePath: string): { dev: string; ino: string } { this.#assertBound(); const resolved = this.#resolve(relativePath); - if (!this.#authority) this.#assertPathBackedDirectoryChain(resolved); + if (this.#access === "read-only" || !this.#authority) this.#assertPathBackedDirectoryChain(resolved); const named = fs.lstatSync(resolved, { bigint: true }); if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("managed_directory_identity_unavailable"); const dev = canonicalFileId(named.dev); @@ -2019,7 +2027,12 @@ export class ManagedSessionDescendantStore { } } + #assertWritable(): void { + if (this.#access === "read-only") throw new Error("managed_store_read_only"); + } + #beforeMutation(): void { + this.#assertWritable(); this.#assertBound(); // Reaping is scheduled BEFORE reconciliation, not after. The receipt scan // throws `managed_replace_cleanup_receipt_limit_exceeded` once the bound @@ -3028,6 +3041,7 @@ export class ManagedSessionDescendantStore { sourceStoreRelativePath: string; }, ): ManagedFileSnapshot { + this.#assertWritable(); this.#assertBound(); const sourceResolved = this.#resolve(sourceRelativePath); const destinationResolved = this.#resolve(destinationRelativePath); diff --git a/packages/coding-agent/src/session/internal/task-artifact-owner-access.ts b/packages/coding-agent/src/session/internal/task-artifact-owner-access.ts index 67799678e41..fc1c4a9798c 100644 --- a/packages/coding-agent/src/session/internal/task-artifact-owner-access.ts +++ b/packages/coding-agent/src/session/internal/task-artifact-owner-access.ts @@ -26,7 +26,10 @@ export interface OwnerManifest extends TaskArtifactOwnerLocator { readonly sessionId: string; } -export function newSessionRootStore(context: TaskArtifactOwnerStorageContext): ManagedSessionDescendantStore { +function sessionRootStore( + context: TaskArtifactOwnerStorageContext, + access: "read-only" | "read-write", +): ManagedSessionDescendantStore { assertSessionRoot(context); if (path.resolve(context.profileAgentDir) !== context.profileAgentDir) throw new Error("task_artifact_owner_profile_invalid"); @@ -45,9 +48,19 @@ export function newSessionRootStore(context: TaskArtifactOwnerStorageContext): M dev: BigInt.asUintN(64, stat.dev), ino: BigInt.asUintN(64, stat.ino), }, + access, ); } +export function newSessionRootStore(context: TaskArtifactOwnerStorageContext): ManagedSessionDescendantStore { + return sessionRootStore(context, "read-write"); +} + +/** Path-backed reader with no retained or recovery-mutable native authority. */ +export function newSessionRootReaderStore(context: TaskArtifactOwnerStorageContext): ManagedSessionDescendantStore { + return sessionRootStore(context, "read-only"); +} + function parseOwnerManifest(bytes: Uint8Array): OwnerManifest { let parsed: unknown; try { diff --git a/packages/coding-agent/src/session/task-artifact-owner-retirement.ts b/packages/coding-agent/src/session/task-artifact-owner-retirement.ts index 27fcbeeffd1..c9d7c06e366 100644 --- a/packages/coding-agent/src/session/task-artifact-owner-retirement.ts +++ b/packages/coding-agent/src/session/task-artifact-owner-retirement.ts @@ -5,6 +5,7 @@ import type { ManagedSessionDescendantStore } from "./internal/managed-session-s import { captureOwnerTreeIfPresent, captureValidatedOwnerTree, + newSessionRootReaderStore, newSessionRootStore, ownerTreeHasPendingManagedPublication, } from "./internal/task-artifact-owner-access"; @@ -181,7 +182,7 @@ export function verifyTaskArtifactOwnerPhysicalRetirement( ): void { const outcome = parseTaskArtifactOwnerRetirementOutcome(context, evidence, value); if (outcome.kind !== "completed") throw new Error("task_artifact_owner_physical_retirement_unverified"); - const store = newSessionRootStore(context); + const store = newSessionRootReaderStore(context); try { if (!sameOwnerParentIdentity(store.captureDirectoryIdentity(OWNER_DIRECTORY), evidence.parentIdentity)) throw new Error("task_artifact_owner_parent_identity_mismatch"); @@ -204,7 +205,7 @@ export function verifyTaskArtifactOwnerRetirementContinuation( ): TaskArtifactOwnerRetirementContinuation { const evidence = parseTaskArtifactOwnerDeletionEvidence(evidenceValue); const continuation = parseTaskArtifactOwnerRetirementContinuation(context, evidence, value); - const store = newSessionRootStore(context); + const store = newSessionRootReaderStore(context); try { if (!sameOwnerParentIdentity(store.captureDirectoryIdentity(OWNER_DIRECTORY), evidence.parentIdentity)) throw new Error("task_artifact_owner_parent_identity_mismatch"); diff --git a/packages/coding-agent/test/managed-gc-retirement-journal.test.ts b/packages/coding-agent/test/managed-gc-retirement-journal.test.ts index ced5cf09f24..95d02ba6a5d 100644 --- a/packages/coding-agent/test/managed-gc-retirement-journal.test.ts +++ b/packages/coding-agent/test/managed-gc-retirement-journal.test.ts @@ -1,8 +1,9 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, spyOn } 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 * as native from "@gajae-code/natives"; import { type ManagedGcSessionRetirementReceipt, type ManagedGcSessionRetirementTarget, @@ -13,6 +14,7 @@ import { computeManagedScopeDigest, discoverManagedGcSessionRetirementReceipts, type ManagedScope, + managedDirectoryAuthorityForScope, managedDirectoryIdentityForScope, prepareManagedSessionScopeForWriteSync, publishManagedGcSessionRetirementReceipt, @@ -40,7 +42,11 @@ import { type TaskArtifactOwnerLocator, type TaskArtifactOwnerRetirementContinuation, } from "../src/session/task-artifact-owner-codec"; -import { retireTaskArtifactOwner } from "../src/session/task-artifact-owner-retirement"; +import { + retireTaskArtifactOwner, + verifyTaskArtifactOwnerPhysicalRetirement, + verifyTaskArtifactOwnerRetirementContinuation, +} from "../src/session/task-artifact-owner-retirement"; interface Fixture { readonly temporaryRoot: string; @@ -327,6 +333,21 @@ describe("managed GC retirement journal", () => { }; const published = await publishManagedGcSessionRetirementReceipt(fixture.scope, disposition); expect(published.state).toBe(outcome.kind === "completed" ? "owner_retired" : "owner_pending"); + if (outcome.kind === "completed") { + const openRecoveryFsRoot = spyOn(native, "openRecoveryFsRoot").mockImplementation(() => { + throw new Error("readonly_recovery_open_called"); + }); + try { + verifyTaskArtifactOwnerPhysicalRetirement( + taskArtifactOwnerStorageContextForScope(fixture.scope), + fixture.evidence, + outcome, + ); + expect(openRecoveryFsRoot).not.toHaveBeenCalled(); + } finally { + openRecoveryFsRoot.mockRestore(); + } + } if (outcome.kind === "payload_retired") { expect(outcome.nativeOutcome.payloadDurable).toBe(true); expect(outcome.nativeOutcome.ok).toBe(false); @@ -434,26 +455,88 @@ describe("managed GC retirement journal", () => { ).toThrow(); }); - it("resolves and reads owner journals without changing managed bytes, modes, ctimes, or private directories", async () => { + it("reads owner journals without recovery effects or changes to managed bytes, modes, ctimes, or private directories", async () => { const fixture = makeFixture(); await publishManagedGcSessionRetirementReceipt(fixture.scope, preparedReceipt(fixture)); const before = snapshotTree(fixture.temporaryRoot); - const resolved = resolveManagedGcScopeForRead({ - cwd: fixture.cwd, - agentDir: fixture.agentDir, - sessionsRoot: fixture.sessionsRoot, + const openRecoveryFsRoot = spyOn(native, "openRecoveryFsRoot").mockImplementation(() => { + throw new Error("readonly_recovery_open_called"); }); - expect(resolved.kind).toBe("resolved"); - if (resolved.kind !== "resolved") throw new Error(`readonly_scope_resolution_failed:${resolved.code}`); - expect((await readManagedGcSessionRetirementReceiptReadOnly(resolved.scope, fixture.transcriptPath))?.state).toBe( - "prepared", - ); - const discovered = await discoverManagedGcSessionRetirementReceipts({ - agentDir: fixture.agentDir, - sessionsRoot: fixture.sessionsRoot, - }); - expect(discovered.map(item => item.receipt.transcriptPath)).toContain(fixture.transcriptPath); - expect(snapshotTree(fixture.temporaryRoot)).toEqual(before); + const retainedAuthority = managedDirectoryAuthorityForScope(fixture.scope); + const retainManagedDirectory = retainedAuthority + ? spyOn(retainedAuthority, "retainManagedDirectory").mockImplementation(() => { + throw new Error("readonly_recovery_retain_called"); + }) + : undefined; + try { + expect( + (await readManagedGcSessionRetirementReceiptReadOnly(fixture.scope, fixture.transcriptPath))?.state, + ).toBe("prepared"); + const resolved = resolveManagedGcScopeForRead({ + cwd: fixture.cwd, + agentDir: fixture.agentDir, + sessionsRoot: fixture.sessionsRoot, + }); + expect(resolved.kind).toBe("resolved"); + if (resolved.kind !== "resolved") throw new Error(`readonly_scope_resolution_failed:${resolved.code}`); + expect( + (await readManagedGcSessionRetirementReceiptReadOnly(resolved.scope, fixture.transcriptPath))?.state, + ).toBe("prepared"); + const discovered = await discoverManagedGcSessionRetirementReceipts({ + agentDir: fixture.agentDir, + sessionsRoot: fixture.sessionsRoot, + }); + expect(discovered.map(item => item.receipt.transcriptPath)).toContain(fixture.transcriptPath); + + const context = taskArtifactOwnerStorageContextForScope(fixture.scope); + const continuation = uncertainContinuation(fixture); + expect(verifyTaskArtifactOwnerRetirementContinuation(context, fixture.evidence, continuation)).toEqual( + continuation, + ); + const identity = managedDirectoryIdentityForScope(fixture.scope); + const unboundReaderPath = path.join(fixture.scope.directoryPath, "must-not-be-initialized"); + expect( + () => + new ManagedSessionDescendantStore( + context.rootAuthority, + unboundReaderPath, + undefined, + context.securityPolicy, + context.profileAgentDir, + undefined, + "read-only", + ), + ).toThrow("managed_read_store_requires_existing_identity"); + expect(fs.existsSync(unboundReaderPath)).toBe(false); + const reader = new ManagedSessionDescendantStore( + context.rootAuthority, + fixture.scope.directoryPath, + undefined, + context.securityPolicy, + context.profileAgentDir, + { + canonicalPath: fixture.scope.directoryPath, + dev: BigInt.asUintN(64, identity.dev), + ino: BigInt.asUintN(64, identity.ino), + }, + "read-only", + ); + try { + expect(() => reader.ensureDirectory("must-not-be-created")).toThrow("managed_store_read_only"); + expect(() => reader.moveFileNoReplace("source", "destination", undefined as never)).toThrow( + "managed_store_read_only", + ); + expect(() => reader.retainAuthority()).toThrow("managed_store_read_only"); + } finally { + reader.close(); + } + expect(openRecoveryFsRoot).not.toHaveBeenCalled(); + if (retainManagedDirectory) expect(retainManagedDirectory).not.toHaveBeenCalled(); + expect(snapshotTree(fixture.temporaryRoot)).toEqual(before); + } finally { + retainManagedDirectory?.mockRestore(); + openRecoveryFsRoot.mockRestore(); + } }); it("discovers original prepared authority in a fresh process after transcript absence", async () => {