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 cf7b3f06977..fe0ed554bcf 100644 --- a/packages/coding-agent/src/session/internal/managed-session-storage.ts +++ b/packages/coding-agent/src/session/internal/managed-session-storage.ts @@ -1474,6 +1474,7 @@ export class ManagedSessionDescendantStore { retained?: { authority: RecoveryFsRoot; authorityBaseDir: string }, policy?: ManagedSessionSecurityPolicy, profileAgentDir?: string, + expectedSubtreeRoot?: ManagedDirectoryRoot, ) { managedRelativePath(root, baseDir); @@ -1512,13 +1513,46 @@ export class ManagedSessionDescendantStore { }); } this.#authority = retained.authority; + if ( + expectedSubtreeRoot && + (expectedSubtreeRoot.canonicalPath !== this.#baseDir || + expectedSubtreeRoot.dev !== this.#subtreeRoot.dev || + expectedSubtreeRoot.ino !== this.#subtreeRoot.ino) + ) + throw new Error("Managed subtree authority changed during establishment"); this.#assertBound(); return; } assertManagedDirectoryRoot(root); - ensureManagedDirectory(this.#baseDir, root, this.#policy); + if (expectedSubtreeRoot) { + if (expectedSubtreeRoot.canonicalPath !== this.#baseDir) + throw new Error("Managed subtree authority path mismatch"); + assertManagedDirectoryRoot(expectedSubtreeRoot); + const verified = validateNativeSecurityResult( + process.platform === "win32" + ? nativeSessionStorage().verifyOwnerOnlyPathSecurityExpected( + this.#baseDir, + "directory", + expectedSubtreeRoot.dev, + expectedSubtreeRoot.ino, + ) + : nativeSessionStorage().verifyOwnerOnlyPathSecurity(this.#baseDir, "directory"), + "verify", + "directory", + ); + if (!verified.ok) throw securityError(this.#baseDir, verified); + assertManagedDirectoryRoot(expectedSubtreeRoot); + } else { + ensureManagedDirectory(this.#baseDir, root, this.#policy); + } const subtreeStat = fs.lstatSync(this.#baseDir, { bigint: true }); + if ( + expectedSubtreeRoot && + (canonicalFileId(subtreeStat.dev) !== expectedSubtreeRoot.dev || + canonicalFileId(subtreeStat.ino) !== expectedSubtreeRoot.ino) + ) + throw new Error("Managed subtree authority changed during establishment"); this.#subtreeRoot = Object.freeze({ canonicalPath: this.#baseDir, dev: canonicalFileId(subtreeStat.dev), @@ -1634,6 +1668,50 @@ export class ManagedSessionDescendantStore { } this.#assertBound(); } + + /** Capture a directory identity through this store's retained managed root without repairing it. */ + captureDirectoryIdentity(relativePath: string): { dev: string; ino: string } { + this.#assertBound(); + const resolved = this.#resolve(relativePath); + if (!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); + const ino = canonicalFileId(named.ino); + if (dev !== this.#subtreeRoot.dev) throw new Error("managed_directory_identity_unavailable"); + if (this.#authority) { + const retainedRelative = this.#relative(resolved); + if (retainedRelative === "") { + if (dev !== this.#subtreeRoot.dev || ino !== this.#subtreeRoot.ino) + throw new Error("Managed descendant root binding changed"); + } else { + const retained = this.#authority.retainManagedDirectory(retainedRelative, dev.toString(), ino.toString()); + try { + const identity = retained.identity(); + if ( + !identity.ok || + !identity.identity || + identity.identity.dev !== dev.toString() || + identity.identity.ino !== ino.toString() + ) + throw new Error(identity.code ?? "managed_directory_identity_unavailable"); + } finally { + retained.close(); + } + } + } + const after = fs.lstatSync(resolved, { bigint: true }); + if ( + !after.isDirectory() || + after.isSymbolicLink() || + canonicalFileId(after.dev) !== dev || + canonicalFileId(after.ino) !== ino + ) + throw new Error("managed_directory_identity_changed"); + this.#assertBound(); + return { dev: dev.toString(), ino: ino.toString() }; + } + #assertBound(): void { if (!this.#authority) { const named = fs.statSync(this.#baseDir, { bigint: true }); @@ -3019,6 +3097,34 @@ export class ManagedSessionDescendantStore { if (!removed.ok) throw new Error(removed.code ?? "managed_remove_failed"); this.#assertBound(); } + + /** Remove one exact managed tree through the parent-identity-bound native protocol. */ + removeTreeExpectedWithParentIdentity( + relativePath: string, + expected: NativeDirectoryTreeSnapshot, + parentIdentity: { dev: bigint; ino: bigint }, + ): NativeExactUnlinkResult { + this.#beforeMutation(); + this.#assertBound(); + const resolved = this.#resolve(relativePath); + const parentPath = path.dirname(resolved); + const parentRelative = path.relative(this.#baseDir, parentPath); + if (path.isAbsolute(parentRelative) || parentRelative === ".." || parentRelative.startsWith(`..${path.sep}`)) + throw new Error("managed_remove_parent_outside_store"); + const currentParent = this.captureDirectoryIdentity(parentRelative); + if ( + currentParent.dev !== canonicalFileId(parentIdentity.dev).toString() || + currentParent.ino !== canonicalFileId(parentIdentity.ino).toString() + ) + throw new Error("managed_remove_parent_identity_mismatch"); + const removed = nativeSessionStorage().exactRemoveDirectoryTree(resolved, expected, { + dev: BigInt(currentParent.dev), + ino: BigInt(currentParent.ino), + }); + this.#assertBound(); + return removed; + } + fsyncTree(): NativeDirectoryTreeSnapshot { this.#beforeMutation(); this.#assertBound(); 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 new file mode 100644 index 00000000000..67799678e41 --- /dev/null +++ b/packages/coding-agent/src/session/internal/task-artifact-owner-access.ts @@ -0,0 +1,229 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { NativeDirectoryTreeSnapshot } from "@gajae-code/natives"; +import { + assertSafeRelativePath, + assertSessionRoot, + freezeTreeSnapshot, + immutableDeletionEvidence, + isOwnerRetainedRoot, + manifestRelativePath, + OWNER_DIRECTORY, + OWNER_MANIFEST, + OWNER_SCHEMA_VERSION, + ownerIdForSession, + ownerRelativePath, + parseTaskArtifactOwnerLocator, + sameOwnerParentIdentity, + type TaskArtifactOwnerDeletionEvidence, + type TaskArtifactOwnerLocator, + type TaskArtifactOwnerParentIdentity, + type TaskArtifactOwnerStorageContext, +} from "../task-artifact-owner-codec"; +import { ManagedSessionDescendantStore } from "./managed-session-storage"; + +export interface OwnerManifest extends TaskArtifactOwnerLocator { + readonly sessionId: string; +} + +export function newSessionRootStore(context: TaskArtifactOwnerStorageContext): ManagedSessionDescendantStore { + assertSessionRoot(context); + if (path.resolve(context.profileAgentDir) !== context.profileAgentDir) + throw new Error("task_artifact_owner_profile_invalid"); + const profile = fs.lstatSync(context.profileAgentDir, { bigint: true }); + if (!profile.isDirectory() || profile.isSymbolicLink()) throw new Error("task_artifact_owner_profile_invalid"); + const stat = fs.lstatSync(context.sessionsRoot, { bigint: true }); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("task_artifact_owner_root_invalid"); + return new ManagedSessionDescendantStore( + context.rootAuthority, + context.sessionsRoot, + undefined, + context.securityPolicy, + context.profileAgentDir, + { + canonicalPath: context.sessionsRoot, + dev: BigInt.asUintN(64, stat.dev), + ino: BigInt.asUintN(64, stat.ino), + }, + ); +} + +function parseOwnerManifest(bytes: Uint8Array): OwnerManifest { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(bytes).toString("utf8")); + } catch { + throw new Error("task_artifact_owner_manifest_invalid"); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || Object.keys(parsed).length !== 5) + throw new Error("task_artifact_owner_manifest_invalid"); + const record = parsed as Record; + if (typeof record.sessionId !== "string" || record.sessionId.length === 0) + throw new Error("task_artifact_owner_manifest_invalid"); + let locator: TaskArtifactOwnerLocator | undefined; + try { + locator = parseTaskArtifactOwnerLocator({ + schemaVersion: record.schemaVersion, + ownerId: record.ownerId, + directoryDev: record.directoryDev, + directoryIno: record.directoryIno, + }); + } catch { + throw new Error("task_artifact_owner_manifest_invalid"); + } + if (!locator) throw new Error("task_artifact_owner_manifest_invalid"); + return { ...locator, sessionId: record.sessionId }; +} + +export function readOwnerManifest( + store: ManagedSessionDescendantStore, + locator: TaskArtifactOwnerLocator, + relativePath = manifestRelativePath(locator.ownerId), +): OwnerManifest { + const manifest = store.readExpected(relativePath); + if (!manifest) throw new Error("task_artifact_owner_manifest_missing"); + const parsed = parseOwnerManifest(manifest.bytes); + if ( + parsed.ownerId !== locator.ownerId || + parsed.directoryDev !== locator.directoryDev || + parsed.directoryIno !== locator.directoryIno + ) + throw new Error("task_artifact_owner_manifest_mismatch"); + return parsed; +} + +export function assertOwnerDirectoryExists(context: TaskArtifactOwnerStorageContext, ownerId: string): void { + const parent = path.join(context.sessionsRoot, OWNER_DIRECTORY); + const owner = path.join(parent, ownerId); + for (const candidate of [parent, owner]) { + let stat: fs.BigIntStats; + try { + stat = fs.lstatSync(candidate, { bigint: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error("task_artifact_owner_missing"); + throw error; + } + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("task_artifact_owner_replaced"); + } +} + +export function assertOwnerIdentity(store: ManagedSessionDescendantStore, locator: TaskArtifactOwnerLocator): void { + const identity = store.subtreeRootAuthority; + if (identity.dev.toString() !== locator.directoryDev || identity.ino.toString() !== locator.directoryIno) + throw new Error("task_artifact_owner_identity_mismatch"); +} + +export function openOwnerStore( + context: TaskArtifactOwnerStorageContext, + rootStore: ManagedSessionDescendantStore, + locator: TaskArtifactOwnerLocator, + sessionId: string, +): ManagedSessionDescendantStore { + if (locator.ownerId !== ownerIdForSession(sessionId)) throw new Error("task_artifact_owner_session_mismatch"); + assertOwnerDirectoryExists(context, locator.ownerId); + const manifest = readOwnerManifest(rootStore, locator); + if (manifest.sessionId !== sessionId) throw new Error("task_artifact_owner_session_mismatch"); + const identity = rootStore.captureDirectoryIdentity(ownerRelativePath(locator.ownerId)); + if (identity.dev !== locator.directoryDev || identity.ino !== locator.directoryIno) + throw new Error("task_artifact_owner_identity_mismatch"); + const ownerPath = path.join(context.sessionsRoot, ownerRelativePath(locator.ownerId)); + const ownerStore = new ManagedSessionDescendantStore( + context.rootAuthority, + ownerPath, + undefined, + context.securityPolicy, + context.profileAgentDir, + { + canonicalPath: ownerPath, + dev: BigInt(locator.directoryDev), + ino: BigInt(locator.directoryIno), + }, + ); + try { + assertOwnerIdentity(ownerStore, locator); + readOwnerManifest(ownerStore, locator, OWNER_MANIFEST); + return ownerStore; + } catch (error) { + ownerStore.close(); + throw error; + } +} + +export function locatorFromManifest(manifest: OwnerManifest): TaskArtifactOwnerLocator { + return { + schemaVersion: OWNER_SCHEMA_VERSION, + ownerId: manifest.ownerId, + directoryDev: manifest.directoryDev, + directoryIno: manifest.directoryIno, + }; +} + +export function captureValidatedOwnerTree( + context: TaskArtifactOwnerStorageContext, + rootStore: ManagedSessionDescendantStore, + sessionId: string, + locator: TaskArtifactOwnerLocator, +): NativeDirectoryTreeSnapshot { + if (locator.ownerId !== ownerIdForSession(sessionId)) throw new Error("task_artifact_owner_session_mismatch"); + assertOwnerDirectoryExists(context, locator.ownerId); + const manifest = readOwnerManifest(rootStore, locator); + if (manifest.sessionId !== sessionId) throw new Error("task_artifact_owner_session_mismatch"); + const tree = rootStore.captureTree(ownerRelativePath(locator.ownerId)); + if (tree.rootDev !== locator.directoryDev || tree.rootIno !== locator.directoryIno) + throw new Error("task_artifact_owner_identity_mismatch"); + return tree; +} + +export function captureOwnerTreeIfPresent( + context: TaskArtifactOwnerStorageContext, + rootStore: ManagedSessionDescendantStore, + ownerId: string, + pathname: string, +): NativeDirectoryTreeSnapshot | undefined { + if (!isOwnerRetainedRoot(context, ownerId, pathname)) + throw new Error("task_artifact_owner_retained_root_unrecognized"); + let named: fs.BigIntStats; + try { + named = fs.lstatSync(pathname, { bigint: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + if (!named.isDirectory() || named.isSymbolicLink()) throw new Error("task_artifact_owner_retained_root_replaced"); + const relative = path.relative(context.sessionsRoot, pathname).split(path.sep).join("/"); + assertSafeRelativePath(relative); + return freezeTreeSnapshot(rootStore.captureTree(relative)); +} + +export function ownerTreeHasPendingManagedPublication(snapshot: NativeDirectoryTreeSnapshot): boolean { + return snapshot.entries.some(entry => + /^\..+\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(?:staging|replacement)$/u.test( + path.posix.basename(entry.relativePath), + ), + ); +} + +/** Capture exact owner evidence without repairing its security, timestamps, or namespace. */ +export function captureTaskArtifactOwnerDeletionEvidence( + context: TaskArtifactOwnerStorageContext, + sessionId: string, + locatorValue: unknown, +): TaskArtifactOwnerDeletionEvidence | undefined { + const locator = parseTaskArtifactOwnerLocator(locatorValue); + if (!locator) return undefined; + const rootStore = newSessionRootStore(context); + try { + rootStore.verifyRootSecurity(); + const parentIdentity: TaskArtifactOwnerParentIdentity = rootStore.captureDirectoryIdentity(OWNER_DIRECTORY); + const treeSnapshot = captureValidatedOwnerTree(context, rootStore, sessionId, locator); + // This refuses to capture during publication; it does not revoke independent open descriptors. + if (ownerTreeHasPendingManagedPublication(treeSnapshot)) + throw new Error("task_artifact_owner_writer_not_quiescent"); + const parentAfter = rootStore.captureDirectoryIdentity(OWNER_DIRECTORY); + if (!sameOwnerParentIdentity(parentIdentity, parentAfter)) + throw new Error("task_artifact_owner_parent_changed_during_capture"); + return immutableDeletionEvidence(sessionId, locator, parentIdentity, treeSnapshot); + } finally { + rootStore.close(); + } +} diff --git a/packages/coding-agent/test/fixtures/task-owner-access-writer.ts b/packages/coding-agent/test/fixtures/task-owner-access-writer.ts new file mode 100644 index 00000000000..ef8a514c004 --- /dev/null +++ b/packages/coding-agent/test/fixtures/task-owner-access-writer.ts @@ -0,0 +1,184 @@ +import { vi } from "bun:test"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import { + captureManagedFileNoFollow, + type ManagedDirectoryRoot, + ManagedSessionDescendantStore, + prepareManagedDirectoryRoot, + publishManagedFileNoReplace, + replaceManagedFileSync, +} from "../../src/session/internal/managed-session-storage"; + +interface WriterInput { + readonly profileRoot: string; + readonly ownerRoot: string; + readonly filename: string; + readonly ready: string; + readonly release: string; + readonly phase: "staging" | "replacement"; +} + +function parseInput(value: unknown): WriterInput { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new Error("task_owner_writer_input_invalid"); + const record = value as Record; + if ( + typeof record.profileRoot !== "string" || + typeof record.ownerRoot !== "string" || + typeof record.filename !== "string" || + path.basename(record.filename) !== record.filename || + typeof record.ready !== "string" || + typeof record.release !== "string" || + (record.phase !== "staging" && record.phase !== "replacement") + ) + throw new Error("task_owner_writer_input_invalid"); + return { + profileRoot: record.profileRoot, + ownerRoot: record.ownerRoot, + filename: record.filename, + ready: record.ready, + release: record.release, + phase: record.phase, + }; +} + +const inputValue: unknown = JSON.parse(process.env.GJC_TASK_OWNER_ACCESS_WRITER_INPUT ?? "null"); +const input = parseInput(inputValue); +const securityPolicy = process.platform === "win32" ? "windows-existing-verify-first" : "default"; +const ownerStat = fs.lstatSync(input.ownerRoot, { bigint: true }); +if (!ownerStat.isDirectory() || ownerStat.isSymbolicLink()) throw new Error("task_owner_writer_owner_invalid"); +const expectedOwner: ManagedDirectoryRoot = { + canonicalPath: path.resolve(input.ownerRoot), + dev: BigInt.asUintN(64, ownerStat.dev), + ino: BigInt.asUintN(64, ownerStat.ino), +}; +const store = new ManagedSessionDescendantStore( + prepareManagedDirectoryRoot(input.profileRoot, securityPolicy), + input.ownerRoot, + undefined, + securityPolicy, + input.profileRoot, + expectedOwner, +); + +function publishReady(phase: WriterInput["phase"], markerPath: string, dev: bigint, ino: bigint): void { + const fd = fs.openSync(input.ready, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600); + try { + fs.writeSync( + fd, + Buffer.from( + JSON.stringify({ + pid: process.pid, + phase, + marker: path.basename(markerPath), + dev: dev.toString(), + ino: ino.toString(), + }), + "utf8", + ), + ); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } +} + +function waitForReleaseSync(): void { + const deadline = Date.now() + 10_000; + const wait = new Int32Array(new SharedArrayBuffer(4)); + while (!fs.existsSync(input.release)) { + if (Date.now() >= deadline) throw new Error("task_owner_writer_release_timeout"); + Atomics.wait(wait, 0, 0, 10); + } +} + +async function waitForRelease(): Promise { + const deadline = Date.now() + 10_000; + while (!fs.existsSync(input.release)) { + if (Date.now() >= deadline) throw new Error("task_owner_writer_release_timeout"); + await Bun.sleep(10); + } +} + +try { + if (input.phase === "replacement") { + let held = false; + const write = new Proxy(fs.writeSync, { + apply(target, receiver: unknown, args: unknown[]): unknown { + const fd = args[0]; + if (!held && typeof fd === "number") { + const descriptor = fs.fstatSync(fd, { bigint: true }); + const replacement = fs.readdirSync(input.ownerRoot).find(name => { + if (!name.endsWith(".replacement")) return false; + const named = fs.lstatSync(path.join(input.ownerRoot, name), { bigint: true }); + return named.dev === descriptor.dev && named.ino === descriptor.ino; + }); + if (replacement) { + held = true; + publishReady("replacement", path.join(input.ownerRoot, replacement), descriptor.dev, descriptor.ino); + waitForReleaseSync(); + } + } + return Reflect.apply(target, receiver, args); + }, + }); + const spy = vi.spyOn(fs, "writeSync").mockImplementation(write); + try { + const destination = path.join(input.ownerRoot, input.filename); + const predecessor = captureManagedFileNoFollow(destination); + replaceManagedFileSync( + destination, + Buffer.from("independent-managed-replacement", "utf8"), + expectedOwner, + securityPolicy, + undefined, + predecessor.identity, + ); + if (!held) throw new Error("task_owner_writer_replacement_boundary_missing"); + } finally { + spy.mockRestore(); + } + } else { + const originalOpen = fsp.open.bind(fsp); + let held = false; + const open = vi.spyOn(fsp, "open").mockImplementation(async (pathname, flags, mode) => { + const handle = await originalOpen(pathname, flags, mode); + if ( + !held && + typeof pathname === "string" && + path.dirname(pathname) === input.ownerRoot && + path.basename(pathname).endsWith(".staging") + ) { + held = true; + const descriptor = await handle.stat({ bigint: true }); + const delayedWrite = new Proxy(handle.write, { + apply(target, receiver: unknown, args: unknown[]): Promise { + publishReady("staging", pathname, descriptor.dev, descriptor.ino); + return waitForRelease().then(() => Reflect.apply(target, receiver, args)); + }, + }); + vi.spyOn(handle, "write").mockImplementation(delayedWrite); + } + return handle; + }); + try { + // Retained Linux stores publish natively without the path API's .staging window. + // Exercise the actual managed path publisher on every platform; no native authority is mocked. + await publishManagedFileNoReplace( + path.join(input.ownerRoot, input.filename), + Buffer.from("independent-managed-staging", "utf8"), + undefined, + expectedOwner, + securityPolicy, + ); + if (!held) throw new Error("task_owner_writer_staging_boundary_missing"); + } finally { + open.mockRestore(); + } + } + process.stdout.write(`${JSON.stringify({ pid: process.pid, phase: input.phase, status: "acknowledged" })}\n`); +} finally { + store.close(); +} diff --git a/packages/coding-agent/test/task-artifact-owner-access.test.ts b/packages/coding-agent/test/task-artifact-owner-access.test.ts new file mode 100644 index 00000000000..45acf5e39cd --- /dev/null +++ b/packages/coding-agent/test/task-artifact-owner-access.test.ts @@ -0,0 +1,482 @@ +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 * as native from "@gajae-code/natives"; +import { + type ManagedDirectoryRoot, + ManagedSessionDescendantStore, + prepareManagedDirectoryRoot, +} from "../src/session/internal/managed-session-storage"; +import { + captureTaskArtifactOwnerDeletionEvidence, + newSessionRootStore, + openOwnerStore, +} from "../src/session/internal/task-artifact-owner-access"; +import { + OWNER_DIRECTORY, + OWNER_MANIFEST, + OWNER_SCHEMA_VERSION, + ownerIdForSession, + ownerRelativePath, + type TaskArtifactOwnerLocator, + type TaskArtifactOwnerStorageContext, +} from "../src/session/task-artifact-owner-codec"; + +interface OwnerFixture { + readonly root: string; + readonly sessionId: string; + readonly context: TaskArtifactOwnerStorageContext; + readonly locator: TaskArtifactOwnerLocator; + readonly ownerPath: string; + readonly ownerRoot: ManagedDirectoryRoot; +} + +const fixtureRoots: string[] = []; + +afterEach(() => { + 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-access-")); + 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 = `access-session-${crypto.randomUUID()}`; + const locator: TaskArtifactOwnerLocator = { + schemaVersion: OWNER_SCHEMA_VERSION, + ownerId: ownerIdForSession(sessionId), + directoryDev: "0", + directoryIno: "0", + }; + const rootStore = new ManagedSessionDescendantStore( + context.rootAuthority, + sessionsRoot, + undefined, + securityPolicy, + profileAgentDir, + expectedDirectoryRoot(sessionsRoot), + ); + let ownerStore: ManagedSessionDescendantStore | undefined; + try { + rootStore.verifyRootSecurity(); + rootStore.ensureDirectory(OWNER_DIRECTORY); + const ownerRoot = rootStore.ensureDirectory(ownerRelativePath(locator.ownerId)); + const boundLocator: TaskArtifactOwnerLocator = { + ...locator, + directoryDev: ownerRoot.dev.toString(), + directoryIno: ownerRoot.ino.toString(), + }; + const ownerPath = path.join(sessionsRoot, ownerRelativePath(locator.ownerId)); + ownerStore = new ManagedSessionDescendantStore( + context.rootAuthority, + ownerPath, + undefined, + securityPolicy, + profileAgentDir, + ownerRoot, + ); + await ownerStore.publishNoReplace( + OWNER_MANIFEST, + Buffer.from(`${JSON.stringify({ ...boundLocator, sessionId })}\n`, "utf8"), + ); + await ownerStore.publishNoReplace("artifact.bin", Buffer.from("owner-payload", "utf8")); + return { root, sessionId, context, locator: boundLocator, ownerPath, ownerRoot }; + } finally { + ownerStore?.close(); + rootStore.close(); + } +} + +function openFixtureOwner(fixture: OwnerFixture): ManagedSessionDescendantStore { + return new ManagedSessionDescendantStore( + fixture.context.rootAuthority, + fixture.ownerPath, + undefined, + fixture.context.securityPolicy, + fixture.context.profileAgentDir, + fixture.ownerRoot, + ); +} + +async function replaceManifest(fixture: OwnerFixture, value: Uint8Array): Promise { + const ownerStore = openFixtureOwner(fixture); + try { + await ownerStore.replace(OWNER_MANIFEST, value); + } finally { + ownerStore.close(); + } +} + +describe("task artifact owner read-only access", () => { + it("captures immutable evidence from a complete managed owner and tolerates only a missing locator", async () => { + const fixture = await makeOwnerFixture(); + const evidence = captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, fixture.locator); + if (!evidence) throw new Error("valid owner locator unexpectedly absent"); + const rootStore = newSessionRootStore(fixture.context); + try { + expect(evidence.locator).toEqual(fixture.locator); + expect(evidence.sessionId).toBe(fixture.sessionId); + expect(evidence.treeSnapshot.rootDev).toBe(fixture.locator.directoryDev); + expect(evidence.treeSnapshot.rootIno).toBe(fixture.locator.directoryIno); + expect(evidence.parentIdentity).toEqual(rootStore.captureDirectoryIdentity(OWNER_DIRECTORY)); + expect(Object.isFrozen(evidence)).toBe(true); + expect(Object.isFrozen(evidence.treeSnapshot.entries)).toBe(true); + } finally { + rootStore.close(); + } + expect(captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, undefined)).toBeUndefined(); + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, { + ...fixture.locator, + ownerId: "bad", + }), + ).toThrow("task_artifact_owner_locator_invalid"); + }); + + it("rejects a noncanonical profile and session or manifest identity substitutions", async () => { + const fixture = await makeOwnerFixture(); + const badProfile = { ...fixture.context, profileAgentDir: "relative-profile" }; + expect(() => captureTaskArtifactOwnerDeletionEvidence(badProfile, fixture.sessionId, fixture.locator)).toThrow( + "task_artifact_owner_profile_invalid", + ); + const fileProfilePath = path.join(fixture.root, "not-a-profile-directory"); + fs.writeFileSync(fileProfilePath, "untouched profile occupant", { mode: 0o600 }); + expect(() => + captureTaskArtifactOwnerDeletionEvidence( + { ...fixture.context, profileAgentDir: fileProfilePath }, + fixture.sessionId, + fixture.locator, + ), + ).toThrow("task_artifact_owner_profile_invalid"); + expect(fs.readFileSync(fileProfilePath, "utf8")).toBe("untouched profile occupant"); + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, `${fixture.sessionId}-other`, fixture.locator), + ).toThrow("task_artifact_owner_session_mismatch"); + const changedLocator = { + ...fixture.locator, + directoryDev: (BigInt(fixture.locator.directoryDev) + 1n).toString(), + }; + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, changedLocator), + ).toThrow("task_artifact_owner_manifest_mismatch"); + }); + + it("rejects malformed and session-mismatched owner manifests", async () => { + const fixture = await makeOwnerFixture(); + await replaceManifest(fixture, Buffer.from("{", "utf8")); + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, fixture.locator), + ).toThrow("task_artifact_owner_manifest_invalid"); + + const second = await makeOwnerFixture(); + await replaceManifest( + second, + Buffer.from(`${JSON.stringify({ ...second.locator, sessionId: "another-owner-session" })}\n`, "utf8"), + ); + expect(() => captureTaskArtifactOwnerDeletionEvidence(second.context, second.sessionId, second.locator)).toThrow( + "task_artifact_owner_session_mismatch", + ); + + const third = await makeOwnerFixture(); + await replaceManifest( + third, + Buffer.from(`${JSON.stringify({ ...third.locator, sessionId: third.sessionId, extra: true })}\n`, "utf8"), + ); + expect(() => captureTaskArtifactOwnerDeletionEvidence(third.context, third.sessionId, third.locator)).toThrow( + "task_artifact_owner_manifest_invalid", + ); + }); + + it("rejects a replaced parent directory without following or repairing it", async () => { + const fixture = await makeOwnerFixture(); + const parent = path.join(fixture.context.sessionsRoot, OWNER_DIRECTORY); + const retainedParent = `${parent}.retained-${crypto.randomUUID()}`; + fs.renameSync(parent, retainedParent); + fs.writeFileSync(parent, "foreign parent", { mode: 0o600 }); + try { + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, fixture.locator), + ).toThrow("managed_directory_identity_unavailable"); + expect(await Bun.file(parent).text()).toBe("foreign parent"); + } finally { + fs.unlinkSync(parent); + fs.renameSync(retainedParent, parent); + } + }); + + it("fails closed when a valid locator names a missing owner directory", async () => { + const fixture = await makeOwnerFixture(); + const displacedOwner = `${fixture.ownerPath}.displaced-${crypto.randomUUID()}`; + fs.renameSync(fixture.ownerPath, displacedOwner); + try { + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, fixture.locator), + ).toThrow("task_artifact_owner_missing"); + expect(fs.existsSync(fixture.ownerPath)).toBe(false); + } finally { + fs.renameSync(displacedOwner, fixture.ownerPath); + } + }); + + it("rejects an owner directory replaced beneath an otherwise valid manifest", async () => { + const fixture = await makeOwnerFixture(); + const displacedOwner = `${fixture.ownerPath}.displaced-${crypto.randomUUID()}`; + const replacementOwner = `${fixture.ownerPath}.replacement-${crypto.randomUUID()}`; + fs.mkdirSync(replacementOwner, { mode: 0o700 }); + fs.writeFileSync( + path.join(replacementOwner, OWNER_MANIFEST), + `${JSON.stringify({ ...fixture.locator, sessionId: fixture.sessionId })}\n`, + { mode: 0o600 }, + ); + fs.writeFileSync(path.join(replacementOwner, "artifact.bin"), "foreign-payload", { mode: 0o600 }); + fs.renameSync(fixture.ownerPath, displacedOwner); + fs.renameSync(replacementOwner, fixture.ownerPath); + try { + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, fixture.locator), + ).toThrow("task_artifact_owner_identity_mismatch"); + expect(await Bun.file(path.join(fixture.ownerPath, "artifact.bin")).text()).toBe("foreign-payload"); + } finally { + fs.renameSync(fixture.ownerPath, replacementOwner); + fs.renameSync(displacedOwner, fixture.ownerPath); + } + }); + + it("rejects both real cross-process managed publication windows and acknowledges release", async () => { + for (const phase of ["staging", "replacement"] as const) { + const fixture = await makeOwnerFixture(); + const readyPath = path.join(fixture.root, `writer-${phase}.ready`); + const releasePath = path.join(fixture.root, `writer-${phase}.release`); + const fixturePath = path.join(import.meta.dir, "fixtures", "task-owner-access-writer.ts"); + const child = Bun.spawn([process.execPath, fixturePath], { + cwd: path.resolve(import.meta.dir, ".."), + env: { + ...process.env, + GJC_TASK_OWNER_ACCESS_WRITER_INPUT: JSON.stringify({ + profileRoot: fixture.context.profileAgentDir, + ownerRoot: fixture.ownerPath, + filename: phase === "replacement" ? "artifact.bin" : `child-${phase}.bin`, + ready: readyPath, + release: releasePath, + phase, + }), + }, + stdout: "pipe", + stderr: "pipe", + }); + const stdoutPromise = new Response(child.stdout).text(); + const stderrPromise = new Response(child.stderr).text(); + try { + const deadline = Date.now() + 10_000; + while (!(await Bun.file(readyPath).exists())) { + if (Date.now() >= deadline) throw new Error(`writer ${phase} readiness timed out`); + const exited = await Promise.race([ + child.exited.then(code => ({ done: true as const, code })), + Bun.sleep(10).then(() => ({ done: false as const })), + ]); + if (exited.done) + throw new Error(`writer ${phase} exited before readiness (${exited.code}): ${await stderrPromise}`); + } + + const readyValue: unknown = JSON.parse(await Bun.file(readyPath).text()); + if (typeof readyValue !== "object" || readyValue === null || Array.isArray(readyValue)) + throw new Error(`writer ${phase} readiness record invalid`); + const record = readyValue as Record; + expect(record.pid).toEqual(expect.any(Number)); + expect(record.pid).not.toBe(process.pid); + expect(record.phase).toBe(phase); + expect(typeof record.marker).toBe("string"); + expect(typeof record.dev).toBe("string"); + expect(typeof record.ino).toBe("string"); + if (typeof record.marker !== "string" || typeof record.dev !== "string" || typeof record.ino !== "string") + throw new Error(`writer ${phase} descriptor identity missing`); + expect(record.marker).toMatch( + new RegExp(`^\\..+\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.${phase}$`, "u"), + ); + const markerPath = path.join(fixture.ownerPath, record.marker); + const markerStat = fs.lstatSync(markerPath, { bigint: true }); + expect(markerStat.isFile()).toBe(true); + expect(markerStat.dev.toString()).toBe(record.dev); + expect(markerStat.ino.toString()).toBe(record.ino); + + const destinationBefore = await Bun.file(path.join(fixture.ownerPath, "artifact.bin")).text(); + expect(() => + captureTaskArtifactOwnerDeletionEvidence(fixture.context, fixture.sessionId, fixture.locator), + ).toThrow("task_artifact_owner_writer_not_quiescent"); + expect(await Bun.file(path.join(fixture.ownerPath, "artifact.bin")).text()).toBe(destinationBefore); + expect(fs.lstatSync(markerPath, { bigint: true }).ino.toString()).toBe(record.ino); + } finally { + await Bun.write(releasePath, "release"); + const exitCode = await child.exited; + const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); + expect(exitCode, `${phase} writer failed: ${stderr}`).toBe(0); + expect(stdout).toContain(`"phase":"${phase}"`); + expect(stdout).toContain('"status":"acknowledged"'); + } + if (phase === "replacement") + expect(await Bun.file(path.join(fixture.ownerPath, "artifact.bin")).text()).toBe( + "independent-managed-replacement", + ); + else + expect(await Bun.file(path.join(fixture.ownerPath, `child-${phase}.bin`)).text()).toBe( + "independent-managed-staging", + ); + } + }); + + it("returns the real native exact-removal result only for the captured parent identity", async () => { + const fixture = await makeOwnerFixture(); + const rootStore = newSessionRootStore(fixture.context); + const directRemove = native.exactRemoveDirectoryTree; + let nativeResult: native.NativeExactUnlinkResult | undefined; + const removalSpy = vi + .spyOn(native, "exactRemoveDirectoryTree") + .mockImplementation((pathname, snapshot, parent) => { + nativeResult = directRemove(pathname, snapshot, parent); + return nativeResult; + }); + try { + const snapshot = rootStore.captureTree(ownerRelativePath(fixture.locator.ownerId)); + const parent = rootStore.captureDirectoryIdentity(OWNER_DIRECTORY); + expect(() => + rootStore.removeTreeExpectedWithParentIdentity(ownerRelativePath(fixture.locator.ownerId), snapshot, { + dev: BigInt(parent.dev), + ino: BigInt(parent.ino) + 1n, + }), + ).toThrow("managed_remove_parent_identity_mismatch"); + const result = rootStore.removeTreeExpectedWithParentIdentity( + ownerRelativePath(fixture.locator.ownerId), + snapshot, + { + dev: BigInt(parent.dev), + ino: BigInt(parent.ino), + }, + ); + expect(removalSpy).toHaveBeenCalledTimes(1); + if (!nativeResult) throw new Error("Expected the real native removal call"); + expect(result).toBe(nativeResult); + // Durable native scrub may retain a namespace; do not demand POSIX physical reclamation. + if (!result.ok) expect(result.code).toBeDefined(); + } finally { + removalSpy.mockRestore(); + rootStore.close(); + } + }); + + it("rejects a substituted directory in the read-only expected-identity security window", async () => { + const fixture = await makeOwnerFixture(); + const candidate = path.join(fixture.root, "substitute-session-root"); + const displaced = path.join(fixture.root, "original-session-root"); + fs.mkdirSync(candidate, { mode: 0o700 }); + fs.writeFileSync(path.join(candidate, "sentinel"), "unmodified foreign bytes", { mode: 0o600 }); + const expected = expectedDirectoryRoot(fixture.context.sessionsRoot); + const nativeBindings = native; + let swapped = false; + let verifiedWithoutMutation = false; + let restoreVerifier: (() => void) | undefined; + const swapAndFingerprint = (): { dev: bigint; ino: bigint; mode: bigint; ctimeNs: bigint; bytes: string } => { + if (!swapped) { + fs.renameSync(fixture.context.sessionsRoot, displaced); + fs.renameSync(candidate, fixture.context.sessionsRoot); + swapped = true; + } + const stat = fs.lstatSync(fixture.context.sessionsRoot, { bigint: true }); + return { + dev: stat.dev, + ino: stat.ino, + mode: stat.mode & 0o777n, + ctimeNs: stat.ctimeNs, + bytes: fs.readFileSync(path.join(fixture.context.sessionsRoot, "sentinel"), "utf8"), + }; + }; + + try { + if (process.platform === "win32") { + const original = nativeBindings.verifyOwnerOnlyPathSecurityExpected.bind(nativeBindings); + const spy = vi + .spyOn(nativeBindings, "verifyOwnerOnlyPathSecurityExpected") + .mockImplementation((pathname, kind, dev, ino) => { + if (pathname !== fixture.context.sessionsRoot) return original(pathname, kind, dev, ino); + const before = swapAndFingerprint(); + expect(before.ino).not.toBe(expected.ino); + const result = original(pathname, kind, dev, ino); + expect(swapAndFingerprint()).toEqual(before); + verifiedWithoutMutation = true; + return result; + }); + restoreVerifier = () => spy.mockRestore(); + } else { + const original = nativeBindings.verifyOwnerOnlyPathSecurity.bind(nativeBindings); + const spy = vi.spyOn(nativeBindings, "verifyOwnerOnlyPathSecurity").mockImplementation((pathname, kind) => { + if (pathname !== fixture.context.sessionsRoot) return original(pathname, kind); + const before = swapAndFingerprint(); + expect(before.ino).not.toBe(expected.ino); + const result = original(pathname, kind); + expect(swapAndFingerprint()).toEqual(before); + verifiedWithoutMutation = true; + return result; + }); + restoreVerifier = () => spy.mockRestore(); + } + expect( + () => + new ManagedSessionDescendantStore( + fixture.context.rootAuthority, + fixture.context.sessionsRoot, + undefined, + fixture.context.securityPolicy, + fixture.context.profileAgentDir, + expected, + ), + ).toThrow(); + expect(verifiedWithoutMutation).toBe(true); + expect(fs.readFileSync(path.join(fixture.context.sessionsRoot, "sentinel"), "utf8")).toBe( + "unmodified foreign bytes", + ); + } finally { + restoreVerifier?.(); + if (swapped) { + fs.renameSync(fixture.context.sessionsRoot, candidate); + fs.renameSync(displaced, fixture.context.sessionsRoot); + } + } + }); + + it("opens a complete owner only after validating its locator and manifest", async () => { + const fixture = await makeOwnerFixture(); + const rootStore = newSessionRootStore(fixture.context); + let ownerStore: ManagedSessionDescendantStore | undefined; + try { + ownerStore = openOwnerStore(fixture.context, rootStore, fixture.locator, fixture.sessionId); + expect(ownerStore.subtreeRootAuthority).toEqual(fixture.ownerRoot); + expect(ownerStore.readExpected(OWNER_MANIFEST)?.bytes.byteLength).toBeGreaterThan(0); + expect(() => + openOwnerStore(fixture.context, rootStore, fixture.locator, `${fixture.sessionId}-wrong`), + ).toThrow("task_artifact_owner_session_mismatch"); + } finally { + ownerStore?.close(); + rootStore.close(); + } + }); +});