diff --git a/packages/coding-agent/src/session/task-artifact-owner-codec.ts b/packages/coding-agent/src/session/task-artifact-owner-codec.ts new file mode 100644 index 00000000000..16485866d50 --- /dev/null +++ b/packages/coding-agent/src/session/task-artifact-owner-codec.ts @@ -0,0 +1,578 @@ +import * as crypto from "node:crypto"; +import * as path from "node:path"; +import type { NativeExactUnlinkResult } from "@gajae-code/natives"; +import type { ManagedDirectoryRoot, ManagedSessionSecurityPolicy } from "./internal/managed-session-storage"; +import type { NativeDirectoryTreeSnapshot } from "./session-storage"; + +export const OWNER_DIRECTORY = ".task-artifact-owners"; +export const OWNER_LOCK_DIRECTORY = ".task-artifact-owner-locks"; +export const OWNER_MANIFEST = ".gjc-task-artifact-owner-v1.json"; +export const OWNER_SCHEMA_VERSION = 1 as const; +export const OWNER_DELETION_SCHEMA_VERSION = 2 as const; +export const OWNER_RETIREMENT_SCHEMA_VERSION = 1 as const; +export const EMPTY_PAYLOAD_SHA256 = crypto.createHash("sha256").update("").digest("hex"); + +const TREE_ENTRY_KEYS = new Set([ + "relativePath", + "kind", + "dev", + "ino", + "nlink", + "size", + "mtimeNs", + "ctimeNs", + "sha256", +]); +const RETIREMENT_CONTINUATION_KEYS = new Set([ + "schemaVersion", + "parentIdentity", + "retainedRootPath", + "retainedTreeSnapshot", + "detachedPaths", + "nativeCodes", + "payloadDurable", + "retainedSuccessorPaths", + "retainedPlaceholderPaths", + "retainedUnknownPaths", + "windowsErrorCodes", +]); +const U64_MAX = 18_446_744_073_709_551_615n; + +type NativeDirectoryTreeEntry = NativeDirectoryTreeSnapshot["entries"][number]; + +export interface TaskArtifactOwnerLocator { + readonly schemaVersion: typeof OWNER_SCHEMA_VERSION; + readonly ownerId: string; + readonly directoryDev: string; + readonly directoryIno: string; +} + +export interface TaskArtifactOwnerStorageContext { + readonly rootAuthority: ManagedDirectoryRoot; + readonly sessionsRoot: string; + readonly securityPolicy: ManagedSessionSecurityPolicy; + readonly profileAgentDir: string; +} + +/** Exact parent directory identity authorizing direct native owner-tree removal. */ +export interface TaskArtifactOwnerParentIdentity { + readonly dev: string; + readonly ino: string; +} + +/** Immutable exact deletion input retained by managed lifecycle tombstones and retries. */ +export interface TaskArtifactOwnerDeletionEvidence { + readonly schemaVersion: typeof OWNER_DELETION_SCHEMA_VERSION; + readonly sessionId: string; + readonly locator: TaskArtifactOwnerLocator; + readonly parentIdentity: TaskArtifactOwnerParentIdentity; + readonly treeSnapshot: NativeDirectoryTreeSnapshot; +} + +/** Native retained-root and side-path state safe to persist for a later retirement attempt. */ +export interface TaskArtifactOwnerRetirementContinuation { + readonly schemaVersion: typeof OWNER_RETIREMENT_SCHEMA_VERSION; + /** Must remain equal to the immutable evidence's managed owner-parent identity. */ + readonly parentIdentity: TaskArtifactOwnerParentIdentity; + /** Only the original owner root or its native `.removing` sibling is actionable. */ + readonly retainedRootPath: string; + /** Last identity-checked tree snapshot; it may only shrink from the original evidence. */ + readonly retainedTreeSnapshot: NativeDirectoryTreeSnapshot; + /** Historical exact native detached-root roles; these paths are diagnostics, not extra authority. */ + readonly detachedPaths?: readonly string[]; + readonly nativeCodes?: readonly string[]; + readonly payloadDurable?: boolean; + readonly retainedSuccessorPaths?: readonly string[]; + readonly retainedPlaceholderPaths?: readonly string[]; + readonly retainedUnknownPaths?: readonly string[]; + readonly windowsErrorCodes?: readonly string[]; +} + +/** `completed` is decoded persisted data, not proof of physical retirement or writer closure. */ +export type TaskArtifactOwnerRetirementOutcome = + | { + readonly kind: "completed"; + readonly evidence: TaskArtifactOwnerDeletionEvidence; + readonly nativeOutcome?: NativeExactUnlinkResult; + } + | { + /** Live payload is durably destroyed; native namespace cleanup remains pending. */ + readonly kind: "payload_retired"; + readonly namespace: "retained"; + readonly evidence: TaskArtifactOwnerDeletionEvidence; + readonly continuation: TaskArtifactOwnerRetirementContinuation; + readonly nativeOutcome: NativeExactUnlinkResult; + } + | { + /** Native explicitly retained cleanup state with a validated, non-expanding continuation. */ + readonly kind: "cleanup_pending"; + readonly evidence: TaskArtifactOwnerDeletionEvidence; + readonly continuation: TaskArtifactOwnerRetirementContinuation; + readonly nativeOutcome?: NativeExactUnlinkResult; + } + | { + /** No safe completion proof exists; callers must retain evidence and continuation. */ + readonly kind: "uncertain"; + readonly evidence: TaskArtifactOwnerDeletionEvidence; + readonly continuation: TaskArtifactOwnerRetirementContinuation; + readonly reason: string; + readonly nativeOutcome?: NativeExactUnlinkResult; + }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** True only for canonical unsigned decimal strings within the native u64 range. */ +export function isCanonicalDecimal(value: unknown): value is string { + return typeof value === "string" && /^(0|[1-9][0-9]*)$/u.test(value); +} + +export function isCanonicalNativeId(value: unknown): value is string { + return isCanonicalDecimal(value) && value.length <= 20 && BigInt(value) <= U64_MAX; +} + +/** Deterministic owner identity bound to one non-empty logical session ID. */ +export function ownerIdForSession(sessionId: string): string { + if (sessionId.length === 0) throw new Error("task_artifact_owner_session_id_invalid"); + return crypto.createHash("sha256").update(sessionId, "utf8").digest("hex"); +} + +export function ownerRelativePath(ownerId: string): string { + return `${OWNER_DIRECTORY}/${ownerId}`; +} + +export function manifestRelativePath(ownerId: string): string { + return `${ownerRelativePath(ownerId)}/${OWNER_MANIFEST}`; +} + +export function assertSafeRelativePath(relativePath: string): void { + if ( + relativePath.length === 0 || + path.posix.isAbsolute(relativePath) || + relativePath.split("/").some(component => component.length === 0 || component === "." || component === "..") || + relativePath.includes("\\") + ) + throw new Error("task_artifact_owner_relative_path_invalid"); +} + +export function assertSessionRoot(context: TaskArtifactOwnerStorageContext): void { + if (path.resolve(context.sessionsRoot) !== context.sessionsRoot) throw new Error("task_artifact_owner_root_invalid"); +} + +function parseTreeSnapshot(value: unknown): NativeDirectoryTreeSnapshot { + if ( + !isRecord(value) || + Object.keys(value).length !== 3 || + !isCanonicalNativeId(value.rootDev) || + !isCanonicalNativeId(value.rootIno) + ) + throw new Error("task_artifact_owner_evidence_invalid"); + if (!Array.isArray(value.entries) || value.entries.length === 0 || value.entries.length > 50_000) + throw new Error("task_artifact_owner_evidence_invalid"); + const entries: NativeDirectoryTreeEntry[] = []; + const seen = new Set(); + for (const rawEntry of value.entries) { + if ( + !isRecord(rawEntry) || + Object.keys(rawEntry).length < 8 || + Object.keys(rawEntry).length > 9 || + typeof rawEntry.relativePath !== "string" || + (rawEntry.kind !== "directory" && rawEntry.kind !== "file") || + !isCanonicalNativeId(rawEntry.dev) || + !isCanonicalNativeId(rawEntry.ino) || + !isCanonicalNativeId(rawEntry.nlink) || + !isCanonicalNativeId(rawEntry.size) || + !isCanonicalDecimal(rawEntry.mtimeNs) || + !isCanonicalDecimal(rawEntry.ctimeNs) || + (rawEntry.sha256 !== undefined && + (typeof rawEntry.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(rawEntry.sha256))) || + Object.keys(rawEntry).some(key => !TREE_ENTRY_KEYS.has(key)) + ) + throw new Error("task_artifact_owner_evidence_invalid"); + if ( + rawEntry.relativePath.includes("\\") || + (rawEntry.relativePath !== "" && + (rawEntry.relativePath.startsWith("/") || + rawEntry.relativePath.split("/").some(part => part.length === 0 || part === "." || part === ".."))) || + seen.has(rawEntry.relativePath) + ) + throw new Error("task_artifact_owner_evidence_invalid"); + seen.add(rawEntry.relativePath); + entries.push({ + relativePath: rawEntry.relativePath, + kind: rawEntry.kind, + dev: rawEntry.dev, + ino: rawEntry.ino, + nlink: rawEntry.nlink, + size: rawEntry.size, + mtimeNs: rawEntry.mtimeNs, + ctimeNs: rawEntry.ctimeNs, + ...(typeof rawEntry.sha256 === "string" ? { sha256: rawEntry.sha256 } : {}), + }); + } + const root = entries.find(entry => entry.relativePath === ""); + if (root?.kind !== "directory" || root.dev !== value.rootDev || root.ino !== value.rootIno) + throw new Error("task_artifact_owner_evidence_invalid"); + const entryKinds = new Map(entries.map(entry => [entry.relativePath, entry.kind])); + for (const entry of entries) { + if (entry.relativePath === "") continue; + const separator = entry.relativePath.lastIndexOf("/"); + const parent = separator < 0 ? "" : entry.relativePath.slice(0, separator); + if (entryKinds.get(parent) !== "directory") throw new Error("task_artifact_owner_evidence_invalid"); + } + return { rootDev: value.rootDev, rootIno: value.rootIno, entries }; +} + +export function freezeTreeSnapshot(snapshot: NativeDirectoryTreeSnapshot): NativeDirectoryTreeSnapshot { + const canonical = parseTreeSnapshot(snapshot); + for (const entry of canonical.entries) Object.freeze(entry); + Object.freeze(canonical.entries); + return Object.freeze(canonical); +} + +export function retainedTreeDoesNotExpandAuthority( + expected: NativeDirectoryTreeSnapshot, + retained: NativeDirectoryTreeSnapshot, +): boolean { + if (expected.rootDev !== retained.rootDev || expected.rootIno !== retained.rootIno) return false; + const expectedEntries = new Map(expected.entries.map(entry => [entry.relativePath, entry])); + if (retained.entries.length > expected.entries.length) return false; + return retained.entries.every(entry => { + if (entry.relativePath === "") return entry.kind === "directory"; + const authorized = expectedEntries.get(entry.relativePath); + if ( + authorized === undefined || + authorized.kind !== entry.kind || + authorized.dev !== entry.dev || + authorized.ino !== entry.ino || + authorized.nlink !== entry.nlink + ) + return false; + if (entry.kind !== "file") return entry.size === authorized.size; + const scrubbed = entry.size === "0" && entry.sha256 === EMPTY_PAYLOAD_SHA256; + if (scrubbed) return true; + if (entry.size !== authorized.size || entry.sha256 !== authorized.sha256) return false; + return ( + entry.sha256 !== undefined || (entry.mtimeNs === authorized.mtimeNs && entry.ctimeNs === authorized.ctimeNs) + ); + }); +} + +export function sameTreeContents(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean { + const project = (snapshot: NativeDirectoryTreeSnapshot) => + snapshot.entries.map(entry => + entry.kind === "directory" + ? { relativePath: entry.relativePath, kind: entry.kind } + : { relativePath: entry.relativePath, kind: entry.kind, size: entry.size, sha256: entry.sha256 }, + ); + return JSON.stringify(project(left)) === JSON.stringify(project(right)); +} + +function parseParentIdentity(value: unknown): TaskArtifactOwnerParentIdentity { + if ( + !isRecord(value) || + Object.keys(value).length !== 2 || + !isCanonicalNativeId(value.dev) || + !isCanonicalNativeId(value.ino) + ) + throw new Error("task_artifact_owner_evidence_invalid"); + return Object.freeze({ dev: value.dev, ino: value.ino }); +} + +export function sameOwnerParentIdentity( + left: TaskArtifactOwnerParentIdentity, + right: TaskArtifactOwnerParentIdentity, +): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +export function immutableDeletionEvidence( + sessionId: string, + locatorValue: TaskArtifactOwnerLocator, + parentIdentityValue: TaskArtifactOwnerParentIdentity, + treeSnapshotValue: NativeDirectoryTreeSnapshot, +): TaskArtifactOwnerDeletionEvidence { + if (sessionId.length === 0) throw new Error("task_artifact_owner_evidence_invalid"); + const locator = parseTaskArtifactOwnerLocator(locatorValue); + if (!locator || locator.ownerId !== ownerIdForSession(sessionId)) + throw new Error("task_artifact_owner_evidence_invalid"); + const parentIdentity = parseParentIdentity(parentIdentityValue); + const treeSnapshot = freezeTreeSnapshot(treeSnapshotValue); + if (treeSnapshot.rootDev !== locator.directoryDev || treeSnapshot.rootIno !== locator.directoryIno) + throw new Error("task_artifact_owner_evidence_invalid"); + return Object.freeze({ + schemaVersion: OWNER_DELETION_SCHEMA_VERSION, + sessionId, + locator, + parentIdentity, + treeSnapshot, + }); +} + +/** Parse a persisted locator. Missing metadata returns undefined; malformed metadata fails closed. */ +export function parseTaskArtifactOwnerLocator(value: unknown): TaskArtifactOwnerLocator | undefined { + if (value === undefined) return undefined; + if ( + !isRecord(value) || + Object.keys(value).length !== 4 || + value.schemaVersion !== OWNER_SCHEMA_VERSION || + typeof value.ownerId !== "string" || + !/^[0-9a-f]{64}$/u.test(value.ownerId) || + !isCanonicalNativeId(value.directoryDev) || + !isCanonicalNativeId(value.directoryIno) + ) + throw new Error("task_artifact_owner_locator_invalid"); + return Object.freeze({ + schemaVersion: OWNER_SCHEMA_VERSION, + ownerId: value.ownerId, + directoryDev: value.directoryDev, + directoryIno: value.directoryIno, + }); +} + +/** Validate JSON-restored tombstone evidence before it is used for exact retirement. */ +export function parseTaskArtifactOwnerDeletionEvidence(value: unknown): TaskArtifactOwnerDeletionEvidence { + if ( + !isRecord(value) || + Object.keys(value).length !== 5 || + value.schemaVersion !== OWNER_DELETION_SCHEMA_VERSION || + typeof value.sessionId !== "string" + ) + throw new Error("task_artifact_owner_evidence_invalid"); + const locator = parseTaskArtifactOwnerLocator(value.locator); + if (!locator) throw new Error("task_artifact_owner_evidence_invalid"); + return immutableDeletionEvidence( + value.sessionId, + locator, + parseParentIdentity(value.parentIdentity), + parseTreeSnapshot(value.treeSnapshot), + ); +} + +export function ownerAbsolutePath(context: TaskArtifactOwnerStorageContext, ownerId: string): string { + return path.join(context.sessionsRoot, ownerRelativePath(ownerId)); +} + +export function isOwnerRetainedRoot( + context: TaskArtifactOwnerStorageContext, + ownerId: string, + pathname: string, +): boolean { + const owner = ownerAbsolutePath(context, ownerId); + return pathname === owner || pathname === `${owner}.removing`; +} + +export function isKnownNativeSidePath( + context: TaskArtifactOwnerStorageContext, + ownerId: string, + pathname: string, +): boolean { + if (!path.isAbsolute(pathname) || path.resolve(pathname) !== pathname) return false; + if (isOwnerRetainedRoot(context, ownerId, pathname)) return true; + const ownerParent = path.dirname(ownerAbsolutePath(context, ownerId)); + const sideName = path.basename(pathname); + if (path.dirname(pathname) === ownerParent && sideName.startsWith(".gjc-") && sideName.length > 5) return true; + const recoveryParent = path.join(context.sessionsRoot, ".gjc-recovery"); + const recoveryRelative = path.relative(recoveryParent, pathname); + return ( + recoveryRelative !== "" && + !path.isAbsolute(recoveryRelative) && + recoveryRelative !== ".." && + !recoveryRelative.startsWith(`..${path.sep}`) && + !recoveryRelative.includes(path.sep) + ); +} + +/** Validate JSON-restored native continuation data against immutable owner authorization. */ +export function parseTaskArtifactOwnerRetirementContinuation( + context: TaskArtifactOwnerStorageContext, + evidenceValue: TaskArtifactOwnerDeletionEvidence, + value: unknown, +): TaskArtifactOwnerRetirementContinuation { + assertSessionRoot(context); + const evidence = parseTaskArtifactOwnerDeletionEvidence(evidenceValue); + if ( + !isRecord(value) || + Object.keys(value).length < 4 || + Object.keys(value).length > 11 || + Object.keys(value).some(key => !RETIREMENT_CONTINUATION_KEYS.has(key)) || + value.schemaVersion !== OWNER_RETIREMENT_SCHEMA_VERSION || + typeof value.retainedRootPath !== "string" + ) + throw new Error("task_artifact_owner_continuation_invalid"); + const parentIdentity = parseParentIdentity(value.parentIdentity); + if (!sameOwnerParentIdentity(parentIdentity, evidence.parentIdentity)) + throw new Error("task_artifact_owner_continuation_invalid"); + if (!isOwnerRetainedRoot(context, evidence.locator.ownerId, value.retainedRootPath)) + throw new Error("task_artifact_owner_continuation_invalid"); + const retainedTreeSnapshot = freezeTreeSnapshot(parseTreeSnapshot(value.retainedTreeSnapshot)); + if (!retainedTreeDoesNotExpandAuthority(evidence.treeSnapshot, retainedTreeSnapshot)) + throw new Error("task_artifact_owner_continuation_invalid"); + const optionalStringList = ( + key: string, + maximum: number, + validate: (candidate: string) => boolean, + ): readonly string[] | undefined => { + const candidates = value[key]; + if (candidates === undefined) return undefined; + if (!Array.isArray(candidates) || candidates.length === 0 || candidates.length > maximum) + throw new Error("task_artifact_owner_continuation_invalid"); + const parsed = candidates.map(candidate => { + if (typeof candidate !== "string" || !validate(candidate)) + throw new Error("task_artifact_owner_continuation_invalid"); + return candidate; + }); + if (new Set(parsed).size !== parsed.length) throw new Error("task_artifact_owner_continuation_invalid"); + return Object.freeze(parsed); + }; + const isNativeSidePath = (candidate: string) => isKnownNativeSidePath(context, evidence.locator.ownerId, candidate); + const detachedPaths = optionalStringList("detachedPaths", 2, candidate => + isOwnerRetainedRoot(context, evidence.locator.ownerId, candidate), + ); + const nativeCodes = optionalStringList("nativeCodes", 64, candidate => /^[A-Za-z0-9_.:-]{1,128}$/u.test(candidate)); + const retainedSuccessorPaths = optionalStringList("retainedSuccessorPaths", 256, isNativeSidePath); + const retainedPlaceholderPaths = optionalStringList("retainedPlaceholderPaths", 256, isNativeSidePath); + const retainedUnknownPaths = optionalStringList("retainedUnknownPaths", 256, isNativeSidePath); + const windowsErrorCodes = optionalStringList("windowsErrorCodes", 64, candidate => + /^0x[0-9A-Fa-f]{8}$/u.test(candidate), + ); + if (value.payloadDurable !== undefined && typeof value.payloadDurable !== "boolean") + throw new Error("task_artifact_owner_continuation_invalid"); + return Object.freeze({ + schemaVersion: OWNER_RETIREMENT_SCHEMA_VERSION, + parentIdentity, + retainedRootPath: value.retainedRootPath, + retainedTreeSnapshot, + ...(detachedPaths !== undefined ? { detachedPaths } : {}), + ...(nativeCodes !== undefined ? { nativeCodes } : {}), + ...(value.payloadDurable !== undefined ? { payloadDurable: value.payloadDurable } : {}), + ...(retainedSuccessorPaths !== undefined ? { retainedSuccessorPaths } : {}), + ...(retainedPlaceholderPaths !== undefined ? { retainedPlaceholderPaths } : {}), + ...(retainedUnknownPaths !== undefined ? { retainedUnknownPaths } : {}), + ...(windowsErrorCodes !== undefined ? { windowsErrorCodes } : {}), + }); +} + +/** + * Strictly decode persisted managed-journal data. Parsing does not establish current physical retirement, + * payload destruction, or writer quiescence. + */ +export function parseTaskArtifactOwnerRetirementOutcome( + context: TaskArtifactOwnerStorageContext, + evidence: TaskArtifactOwnerDeletionEvidence, + value: unknown, +): TaskArtifactOwnerRetirementOutcome { + const invalid = (): never => { + throw new Error("task_artifact_owner_retirement_outcome_invalid"); + }; + if (!isRecord(value)) return invalid(); + const kind = value.kind; + if (kind !== "completed" && kind !== "payload_retired" && kind !== "cleanup_pending" && kind !== "uncertain") + return invalid(); + const expectedKeys = [ + "kind", + "evidence", + ...(kind !== "completed" ? ["continuation"] : []), + ...(kind === "payload_retired" ? ["namespace"] : []), + ...(kind === "uncertain" ? ["reason"] : []), + ...(value.nativeOutcome !== undefined ? ["nativeOutcome"] : []), + ]; + if (Object.keys(value).length !== expectedKeys.length || !expectedKeys.every(key => key in value)) return invalid(); + const original = parseTaskArtifactOwnerDeletionEvidence(value.evidence); + const expectedEvidence = parseTaskArtifactOwnerDeletionEvidence(evidence); + if (JSON.stringify(original) !== JSON.stringify(expectedEvidence)) return invalid(); + const continuation = + kind !== "completed" + ? parseTaskArtifactOwnerRetirementContinuation(context, expectedEvidence, value.continuation) + : undefined; + let nativeOutcome: NativeExactUnlinkResult | undefined; + if (value.nativeOutcome !== undefined) { + if (!isRecord(value.nativeOutcome)) return invalid(); + const record = value.nativeOutcome; + const allowed = [ + "ok", + "code", + "detachedPath", + "retainedSuccessorPath", + "retainedPlaceholderPath", + "retainedUnknownPath", + "payloadDurable", + "windowsErrorCode", + ]; + if (Object.keys(record).some(key => !allowed.includes(key)) || typeof record.ok !== "boolean") return invalid(); + for (const [key, paths] of [ + ["detachedPath", continuation?.detachedPaths], + ["retainedSuccessorPath", continuation?.retainedSuccessorPaths], + ["retainedPlaceholderPath", continuation?.retainedPlaceholderPaths], + ["retainedUnknownPath", continuation?.retainedUnknownPaths], + ] as const) { + const pathname = record[key]; + if (pathname !== undefined && (typeof pathname !== "string" || !paths?.includes(pathname))) return invalid(); + } + if ( + record.code !== undefined && + (record.ok || typeof record.code !== "string" || !continuation?.nativeCodes?.includes(record.code)) + ) + return invalid(); + if ( + record.payloadDurable !== undefined && + (typeof record.payloadDurable !== "boolean" || + (record.payloadDurable && (record.ok || continuation?.payloadDurable !== true))) + ) + return invalid(); + if ( + record.windowsErrorCode !== undefined && + (record.ok || + typeof record.windowsErrorCode !== "string" || + !continuation?.windowsErrorCodes?.includes(record.windowsErrorCode)) + ) + return invalid(); + nativeOutcome = Object.freeze({ + ok: record.ok, + ...(typeof record.code === "string" ? { code: record.code } : {}), + ...(typeof record.detachedPath === "string" ? { detachedPath: record.detachedPath } : {}), + ...(typeof record.retainedSuccessorPath === "string" + ? { retainedSuccessorPath: record.retainedSuccessorPath } + : {}), + ...(typeof record.retainedPlaceholderPath === "string" + ? { retainedPlaceholderPath: record.retainedPlaceholderPath } + : {}), + ...(typeof record.retainedUnknownPath === "string" ? { retainedUnknownPath: record.retainedUnknownPath } : {}), + ...(typeof record.payloadDurable === "boolean" ? { payloadDurable: record.payloadDurable } : {}), + ...(typeof record.windowsErrorCode === "string" ? { windowsErrorCode: record.windowsErrorCode } : {}), + }) as NativeExactUnlinkResult; + } + if (kind === "completed") { + if (!nativeOutcome?.ok) return invalid(); + return Object.freeze({ kind, evidence: original, nativeOutcome }); + } + if (!continuation || nativeOutcome?.ok) return invalid(); + if (kind === "payload_retired") { + if ( + value.namespace !== "retained" || + nativeOutcome?.code !== "cleanup_pending" || + nativeOutcome.payloadDurable !== true || + continuation.retainedRootPath !== `${ownerAbsolutePath(context, expectedEvidence.locator.ownerId)}.removing` || + continuation.retainedSuccessorPaths?.length || + continuation.retainedPlaceholderPaths?.length || + continuation.retainedUnknownPaths?.length || + continuation.retainedTreeSnapshot.entries.some( + entry => entry.kind === "file" && (entry.size !== "0" || entry.sha256 !== EMPTY_PAYLOAD_SHA256), + ) + ) + return invalid(); + return Object.freeze({ kind, namespace: "retained", evidence: original, continuation, nativeOutcome }); + } + if (kind === "uncertain") { + if (typeof value.reason !== "string" || value.reason.length === 0 || value.reason.length > 4096) return invalid(); + return Object.freeze({ + kind, + evidence: original, + continuation, + reason: value.reason, + ...(nativeOutcome ? { nativeOutcome } : {}), + }); + } + return Object.freeze({ kind, evidence: original, continuation, ...(nativeOutcome ? { nativeOutcome } : {}) }); +} diff --git a/packages/coding-agent/test/task-artifact-owner-codec.test.ts b/packages/coding-agent/test/task-artifact-owner-codec.test.ts new file mode 100644 index 00000000000..7d1478b94f6 --- /dev/null +++ b/packages/coding-agent/test/task-artifact-owner-codec.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from "bun:test"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + EMPTY_PAYLOAD_SHA256, + isCanonicalDecimal, + OWNER_DELETION_SCHEMA_VERSION, + OWNER_RETIREMENT_SCHEMA_VERSION, + OWNER_SCHEMA_VERSION, + ownerAbsolutePath, + ownerIdForSession, + parseTaskArtifactOwnerDeletionEvidence, + parseTaskArtifactOwnerLocator, + parseTaskArtifactOwnerRetirementContinuation, + parseTaskArtifactOwnerRetirementOutcome, + sameTreeContents, + type TaskArtifactOwnerStorageContext, +} from "../src/session/task-artifact-owner-codec"; + +const sessionId = "codec-session-7"; +const sessionsRoot = path.resolve(os.tmpdir(), "task-artifact-owner-codec"); +const locator = { + schemaVersion: OWNER_SCHEMA_VERSION, + ownerId: ownerIdForSession(sessionId), + directoryDev: "11", + directoryIno: "12", +}; +const parentIdentity = { dev: "20", ino: "21" }; +const treeSnapshot = { + rootDev: locator.directoryDev, + rootIno: locator.directoryIno, + entries: [ + { + relativePath: "", + kind: "directory", + dev: locator.directoryDev, + ino: locator.directoryIno, + nlink: "1", + size: "0", + mtimeNs: "100", + ctimeNs: "101", + }, + { + relativePath: "payload.bin", + kind: "file", + dev: "13", + ino: "14", + nlink: "1", + size: "4", + mtimeNs: "102", + ctimeNs: "103", + sha256: "a".repeat(64), + }, + ], +}; +const evidence = { + schemaVersion: OWNER_DELETION_SCHEMA_VERSION, + sessionId, + locator, + parentIdentity, + treeSnapshot, +}; +const context: TaskArtifactOwnerStorageContext = { + rootAuthority: { canonicalPath: sessionsRoot, dev: 1n, ino: 2n }, + sessionsRoot, + securityPolicy: "default", + profileAgentDir: path.resolve(os.tmpdir(), "task-artifact-owner-profile"), +}; +const ownerPath = ownerAbsolutePath(context, locator.ownerId); + +function continuation(retainedTree = treeSnapshot, extra: Record = {}): Record { + return { + schemaVersion: OWNER_RETIREMENT_SCHEMA_VERSION, + parentIdentity, + retainedRootPath: ownerPath, + retainedTreeSnapshot: retainedTree, + ...extra, + }; +} + +function scrubbedTree(): typeof treeSnapshot { + return { + ...treeSnapshot, + entries: [treeSnapshot.entries[0], { ...treeSnapshot.entries[1], size: "0", sha256: EMPTY_PAYLOAD_SHA256 }], + }; +} + +describe("task artifact owner codec", () => { + it("accepts only canonical native decimal identities and exact locator records", () => { + expect(isCanonicalDecimal("0")).toBe(true); + expect(isCanonicalDecimal("18446744073709551615")).toBe(true); + expect(isCanonicalDecimal("01")).toBe(false); + expect(isCanonicalDecimal("-1")).toBe(false); + expect(() => parseTaskArtifactOwnerLocator({ ...locator, directoryDev: "18446744073709551616" })).toThrow(); + expect(() => parseTaskArtifactOwnerLocator({ ...locator, directoryIno: "01" })).toThrow(); + expect(() => parseTaskArtifactOwnerLocator({ ...locator, extra: true })).toThrow(); + expect(parseTaskArtifactOwnerLocator(undefined)).toBeUndefined(); + }); + + it("binds immutable evidence to the owner session, root identity, and unique safe tree entries", () => { + const parsed = parseTaskArtifactOwnerDeletionEvidence(evidence); + expect(parsed.locator.ownerId).toBe(ownerIdForSession(sessionId)); + expect(Object.isFrozen(parsed)).toBe(true); + expect(Object.isFrozen(parsed.locator)).toBe(true); + expect(Object.isFrozen(parsed.treeSnapshot.entries)).toBe(true); + expect(Object.isFrozen(parsed.treeSnapshot.entries[1])).toBe(true); + expect(() => parseTaskArtifactOwnerDeletionEvidence({ ...evidence, sessionId: "different-session" })).toThrow(); + expect(() => + parseTaskArtifactOwnerDeletionEvidence({ + ...evidence, + locator: { ...locator, directoryDev: "99" }, + }), + ).toThrow(); + expect(() => + parseTaskArtifactOwnerDeletionEvidence({ + ...evidence, + extra: "unknown", + }), + ).toThrow(); + }); + + it("rejects duplicate, unsafe, malformed, and parentless tree entries", () => { + const file = treeSnapshot.entries[1]; + expect(() => + parseTaskArtifactOwnerDeletionEvidence({ + ...evidence, + treeSnapshot: { ...treeSnapshot, entries: [treeSnapshot.entries[0], file, file] }, + }), + ).toThrow("task_artifact_owner_evidence_invalid"); + for (const relativePath of ["../outside", "/absolute", "nested//file", "nested\\file"]) { + const unsafe = { ...file, relativePath }; + expect(() => + parseTaskArtifactOwnerDeletionEvidence({ + ...evidence, + treeSnapshot: { + ...treeSnapshot, + entries: [treeSnapshot.entries[0], unsafe], + }, + }), + ).toThrow(); + } + expect(() => + parseTaskArtifactOwnerDeletionEvidence({ + ...evidence, + treeSnapshot: { + ...treeSnapshot, + entries: [treeSnapshot.entries[0], { ...file, relativePath: "missing-parent/file" }], + }, + }), + ).toThrow(); + expect(() => + parseTaskArtifactOwnerDeletionEvidence({ + ...evidence, + treeSnapshot: { + ...treeSnapshot, + entries: [treeSnapshot.entries[0], { ...file, sha256: "not-a-sha256" }], + }, + }), + ).toThrow(); + }); + + it("treats entry order as content-significant while rejecting retained-tree expansion", () => { + const first = { ...treeSnapshot.entries[1], relativePath: "a.bin" }; + const second = { ...treeSnapshot.entries[1], relativePath: "b.bin", ino: "15" }; + const ordered = { ...treeSnapshot, entries: [treeSnapshot.entries[0], first, second] }; + const reversed = { ...treeSnapshot, entries: [treeSnapshot.entries[0], second, first] }; + expect(sameTreeContents(ordered, reversed)).toBe(false); + + const expanded = { + ...treeSnapshot, + entries: [...treeSnapshot.entries, { ...treeSnapshot.entries[1], relativePath: "injected.bin" }], + }; + expect(() => + parseTaskArtifactOwnerRetirementContinuation( + context, + parseTaskArtifactOwnerDeletionEvidence(evidence), + continuation(expanded), + ), + ).toThrow(); + }); + + it("rejects continuation parent changes, owner-path escape, and changed payload hashes", () => { + const originalEvidence = parseTaskArtifactOwnerDeletionEvidence(evidence); + expect(() => + parseTaskArtifactOwnerRetirementContinuation(context, originalEvidence, { + ...continuation(), + parentIdentity: { dev: parentIdentity.dev, ino: "22" }, + }), + ).toThrow(); + expect(() => + parseTaskArtifactOwnerRetirementContinuation(context, originalEvidence, { + ...continuation(), + retainedRootPath: `${ownerPath}/../outside`, + }), + ).toThrow(); + expect(() => + parseTaskArtifactOwnerRetirementContinuation( + context, + originalEvidence, + continuation({ + ...treeSnapshot, + entries: [treeSnapshot.entries[0], { ...treeSnapshot.entries[1], sha256: "b".repeat(64) }], + }), + ), + ).toThrow(); + }); + + it("binds native side paths to their exact continuation role", () => { + const unknownPath = path.join(sessionsRoot, ".task-artifact-owners", ".gjc-retained-unknown"); + const matching = { + kind: "cleanup_pending", + evidence, + continuation: continuation(treeSnapshot, { + nativeCodes: ["cleanup_pending"], + retainedUnknownPaths: [unknownPath], + }), + nativeOutcome: { ok: false, code: "cleanup_pending", retainedUnknownPath: unknownPath }, + }; + expect( + parseTaskArtifactOwnerRetirementOutcome(context, parseTaskArtifactOwnerDeletionEvidence(evidence), matching) + .kind, + ).toBe("cleanup_pending"); + expect(() => + parseTaskArtifactOwnerRetirementOutcome(context, parseTaskArtifactOwnerDeletionEvidence(evidence), { + ...matching, + nativeOutcome: { ok: false, code: "cleanup_pending", retainedSuccessorPath: unknownPath }, + }), + ).toThrow(); + expect(() => + parseTaskArtifactOwnerRetirementContinuation( + context, + parseTaskArtifactOwnerDeletionEvidence(evidence), + continuation(treeSnapshot, { retainedUnknownPaths: [path.join(sessionsRoot, "unrelated")] }), + ), + ).toThrow(); + }); + + it("round-trips each retirement outcome shape without interpreting DTOs as live proof", () => { + const parsedEvidence = parseTaskArtifactOwnerDeletionEvidence(evidence); + const durableContinuation = continuation(scrubbedTree(), { + retainedRootPath: `${ownerPath}.removing`, + nativeCodes: ["cleanup_pending"], + payloadDurable: true, + }); + const outcomes = [ + // A native-shaped `ok` value tests only the pure decoder, never physical retirement. + { kind: "completed", evidence, nativeOutcome: { ok: true } }, + { + kind: "payload_retired", + namespace: "retained", + evidence, + continuation: durableContinuation, + nativeOutcome: { ok: false, code: "cleanup_pending", payloadDurable: true }, + }, + { + kind: "cleanup_pending", + evidence, + continuation: continuation(treeSnapshot, { nativeCodes: ["cleanup_pending"] }), + nativeOutcome: { ok: false, code: "cleanup_pending" }, + }, + { + kind: "uncertain", + evidence, + continuation: continuation(), + reason: "native state unavailable", + }, + ] as const; + for (const outcome of outcomes) { + const decoded = parseTaskArtifactOwnerRetirementOutcome(context, parsedEvidence, outcome); + expect(decoded.kind).toBe(outcome.kind); + expect(decoded).toEqual(outcome); + } + }); + + it("rejects contradictory outcome claims and unknown outcome/native fields", () => { + const parsedEvidence = parseTaskArtifactOwnerDeletionEvidence(evidence); + const cleanupContinuation = continuation(treeSnapshot, { nativeCodes: ["cleanup_pending"] }); + expect(() => + parseTaskArtifactOwnerRetirementOutcome(context, parsedEvidence, { kind: "completed", evidence }), + ).toThrow(); + expect(() => + parseTaskArtifactOwnerRetirementOutcome(context, parsedEvidence, { + kind: "completed", + evidence, + nativeOutcome: { ok: false, code: "cleanup_pending" }, + }), + ).toThrow(); + expect(() => + parseTaskArtifactOwnerRetirementOutcome(context, parsedEvidence, { + kind: "payload_retired", + namespace: "retained", + evidence, + continuation: cleanupContinuation, + nativeOutcome: { ok: false, code: "cleanup_pending" }, + }), + ).toThrow(); + expect(() => + parseTaskArtifactOwnerRetirementOutcome(context, parsedEvidence, { + kind: "cleanup_pending", + evidence, + continuation: cleanupContinuation, + nativeOutcome: { ok: false, code: "cleanup_pending", fabricated: true }, + }), + ).toThrow(); + expect(() => + parseTaskArtifactOwnerRetirementOutcome(context, parsedEvidence, { + kind: "cleanup_pending", + evidence, + continuation: cleanupContinuation, + extra: true, + }), + ).toThrow(); + }); +});