From 346275525ead4c6ac1d914cec2d23d6f070abca2 Mon Sep 17 00:00:00 2001 From: Codex <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:01:13 +0800 Subject: [PATCH] fix(artifacts): bind session inventories to target identity --- src/artifacts/ArtifactProvider.ts | 8 +++++- src/artifacts/inventory/ArtifactInventory.ts | 12 ++++++++- src/artifacts/inventory/types.ts | 2 ++ tests/acceptance/inesContainer.test.ts | 25 +++---------------- .../artifactProvider.cleanupRetry.test.ts | 13 +++++----- .../artifactProvider.extraction.test.ts | 11 ++++---- ...actProvider.extractionCompleteness.test.ts | 5 ++-- .../artifactProvider.extractionFormat.test.ts | 13 +++++----- ...rtifactProvider.extractionIdentity.test.ts | 7 +++--- tests/fixtures/artifactGraphRootSha256.ts | 19 ++++++++++++++ 10 files changed, 70 insertions(+), 45 deletions(-) create mode 100644 tests/fixtures/artifactGraphRootSha256.ts diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index a3fe16a11..1e8acc248 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -188,7 +188,10 @@ class ArtifactClient implements AnalysisClient { const result = await extractArtifact( { inputPath: this.target.sourcePath ?? this.target.path, - expectedRootSha256: this.target.sha256, + ...(this.target.sourcePath === undefined || + this.target.sourcePath === this.target.path + ? { expectedRootSha256: this.target.sha256 } + : {}), outputRoot: parsed.output_root, environment: this.environment, integrityPolicy: parsed.integrity_policy, @@ -391,6 +394,9 @@ class ArtifactClient implements AnalysisClient { environment: this.environment, ...(options?.signal === undefined ? {} : { signal: options.signal }), integrity: { mode: parsed.integrity_policy }, + ...(this.target.kind === "archive" && this.target.format === "ines" + ? { expectedRootSha256: this.target.sha256 } + : {}), }); } } diff --git a/src/artifacts/inventory/ArtifactInventory.ts b/src/artifacts/inventory/ArtifactInventory.ts index 378aa2324..c19b028f3 100644 --- a/src/artifacts/inventory/ArtifactInventory.ts +++ b/src/artifacts/inventory/ArtifactInventory.ts @@ -5,6 +5,7 @@ import { type ArtifactInventoryOutput, } from "../../domain/artifactGraph.js"; import { abortIfNeeded } from "../ArtifactHash.js"; +import { ArtifactReaderFailure } from "../ArtifactReader.js"; import { scanCanonicalArtifactInventoryInScope } from "./scanCanonical.js"; import type { ArtifactInventoryOptions } from "./types.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; @@ -33,5 +34,14 @@ export const scanArtifactInventoryInScope = async ( ): Promise => { abortIfNeeded(options.signal); const path = await realpath(inputPath); - return scanCanonicalArtifactInventoryInScope(path, options); + const snapshot = await scanCanonicalArtifactInventoryInScope(path, options); + if ( + options.expectedRootSha256 !== undefined && + snapshot.manifest.root_sha256 !== options.expectedRootSha256 + ) + throw new ArtifactReaderFailure( + "integrity", + `Active artifact digest changed: expected ${options.expectedRootSha256}, observed ${snapshot.manifest.root_sha256}`, + ); + return snapshot; }; diff --git a/src/artifacts/inventory/types.ts b/src/artifacts/inventory/types.ts index 709611514..660b41dee 100644 --- a/src/artifacts/inventory/types.ts +++ b/src/artifacts/inventory/types.ts @@ -13,6 +13,8 @@ export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = { /** Options shared by artifact inventory scans. */ export interface ArtifactInventoryOptions { readonly resourceScope: ArtifactResourceScope; + /** Bind a session-owned leaf inventory to the admitted root digest. */ + readonly expectedRootSha256?: string | undefined; readonly signal?: AbortSignal | undefined; readonly integrity?: ArtifactIntegrityPolicy | undefined; readonly environment?: Readonly | undefined; diff --git a/tests/acceptance/inesContainer.test.ts b/tests/acceptance/inesContainer.test.ts index 405e2828e..2663da992 100644 --- a/tests/acceptance/inesContainer.test.ts +++ b/tests/acceptance/inesContainer.test.ts @@ -23,7 +23,7 @@ import { toolContract } from "../../src/contracts/toolContracts.js"; import { buildBinary } from "plist"; cliTest( - "extracts replacement iNES bytes from an already open plist through MCP", + "refuses extraction after an already open plist is replaced through MCP", async () => { const root = await createTestTempDirectory("rea-ines-plist-refresh-"); const path = join(root, "source.bin"); @@ -40,27 +40,10 @@ cliTest( const bytes = inesFixture(); await writeFile(path, bytes); const extracted = await call("extract_artifact", {}); - expect( - extracted.isError, - JSON.stringify(extracted.structuredContent), - ).not.toBe(true); - const result = artifactExtractionResultSchema.parse( - parseEvidence(extracted.structuredContent).normalized_result, + expect(extracted.isError).toBe(true); + expect(JSON.stringify(parseMcpToolError(extracted))).toContain( + "Active artifact digest changed", ); - try { - expect(result.manifest.root_format).toBe("ines"); - expect( - Buffer.concat( - await Promise.all( - ["header.bin", "prg/000.bin", "prg/001.bin", "chr/000.bin"].map( - (part) => readFile(join(result.output_root, part)), - ), - ), - ), - ).toEqual(bytes); - } finally { - await rm(result.output_root, { recursive: true, force: true }); - } await call("close_binary", {}); }, ); diff --git a/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts b/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts index 58356c00c..442d9567c 100644 --- a/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts +++ b/tests/boundary/providers/native/artifactProvider.cleanupRetry.test.ts @@ -10,6 +10,7 @@ import { AsarArtifactReader } from "../../../../src/artifacts/AsarArtifactReader import { SafeOutputTree } from "../../../../src/artifacts/SafeOutputTree.js"; import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { artifactGraphRootSha256 } from "../../../fixtures/artifactGraphRootSha256.js"; const deferred = () => { let resolve: () => void = () => {}; @@ -29,11 +30,11 @@ const createArchive = async () => { return { root, archive }; }; -const createClient = (path: string) => +const createClient = async (path: string) => new ArtifactProvider(process.env).createClient({ path, sourcePath: path, - sha256: "0".repeat(64), + sha256: await artifactGraphRootSha256(path), kind: "archive", format: "asar", }); @@ -41,7 +42,7 @@ const createClient = (path: string) => it("retains a failed reader close, blocks new work, and retries the same reader", async () => { const { root, archive } = await createArchive(); const output = join(root, "output"); - const client = createClient(archive); + const client = await createClient(archive); const originalClose = AsarArtifactReader.prototype.close; const closeOwners: AsarArtifactReader[] = []; let readerCloseBlocked = true; @@ -107,7 +108,7 @@ it("retains a failed reader close, blocks new work, and retries the same reader" it("retains a failed rollback owner and preserves the materialization failure", async () => { const { root, archive } = await createArchive(); const output = join(root, "output"); - const client = createClient(archive); + const client = await createClient(archive); const originalWrite = SafeOutputTree.prototype.write; const originalRollback = SafeOutputTree.prototype.rollback; const rollbackOwners: SafeOutputTree[] = []; @@ -174,7 +175,7 @@ it.skipIf(process.platform === "win32")( const { root, archive } = await createArchive(); const output = join(root, "output"); const blocker = join(output, "unowned.txt"); - const client = createClient(archive); + const client = await createClient(archive); const prototypeHandle = await open(join(root, "prototype-handle"), "w"); const handlePrototype = Object.getPrototypeOf(prototypeHandle) as Pick< typeof prototypeHandle, @@ -215,7 +216,7 @@ it.skipIf(process.platform === "win32")( it("waits for admitted extraction before close and rejects later work", async () => { const { root, archive } = await createArchive(); const output = join(root, "output"); - const client = createClient(archive); + const client = await createClient(archive); const originalWrite = SafeOutputTree.prototype.write; const started = deferred(); const finish = deferred(); diff --git a/tests/boundary/providers/native/artifactProvider.extraction.test.ts b/tests/boundary/providers/native/artifactProvider.extraction.test.ts index 6488af65f..2b47833e9 100644 --- a/tests/boundary/providers/native/artifactProvider.extraction.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extraction.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { artifactGraphRootSha256 } from "../../../fixtures/artifactGraphRootSha256.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js"; @@ -21,7 +22,7 @@ describe("artifact extraction", () => { await writeFile(join(source, "payload.txt"), "committed\n"); const output = join(root, "output"); const client = new ArtifactProvider(process.env).createClient( - target(source, "directory"), + await target(source, "directory"), ); const result = await client.execute("extract_artifact", { @@ -40,7 +41,7 @@ describe("artifact extraction", () => { await mkdir(join(source, "assets"), { recursive: true }); await writeFile(join(source, "assets", "selected.js"), "selected();\n"); await writeFile(join(source, "assets", "ignored.js"), "ignored();\n"); - const targetValue = target(source, "directory"); + const targetValue = await target(source, "directory"); const graph = await inventory(targetValue); expect( graph.occurrences.some( @@ -136,13 +137,13 @@ const inventory = async (targetValue: BinaryTarget) => { return artifactInventoryResultSchema.parse(result.value.result); }; -const target = ( +const target = async ( path: string, format: Extract["format"] | "directory", -): BinaryTarget => ({ +): Promise => ({ path, sourcePath: path, - sha256: "0".repeat(64), + sha256: await artifactGraphRootSha256(path), kind: "archive", format: format === "directory" ? "asar" : format, }); diff --git a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts index 44f5ea1ca..2b99547b0 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts @@ -13,6 +13,7 @@ import { import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { artifactGraphRootSha256 } from "../../../fixtures/artifactGraphRootSha256.js"; import { DirectoryArtifactReader } from "../../../../src/artifacts/DirectoryArtifactReader.js"; it.each(["file", "directory"] as const)( @@ -180,12 +181,12 @@ const inventoriedExtraction = async (source: string, output: string) => { ); }; -const extract = (source: string, output: string) => +const extract = async (source: string, output: string) => new ArtifactProvider(process.env) .createClient({ path: source, sourcePath: source, - sha256: "0".repeat(64), + sha256: await artifactGraphRootSha256(source), kind: "archive", format: "asar", }) diff --git a/tests/boundary/providers/native/artifactProvider.extractionFormat.test.ts b/tests/boundary/providers/native/artifactProvider.extractionFormat.test.ts index 0b52565e4..b6bb8b56c 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionFormat.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionFormat.test.ts @@ -7,6 +7,7 @@ import { buildBinary } from "plist"; import { writeOrderedZip } from "../../../fixtures/artifactEntryOrder.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { artifactGraphRootSha256 } from "../../../fixtures/artifactGraphRootSha256.js"; import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { inesFixture } from "../../../fixtures/ines.js"; @@ -122,9 +123,9 @@ describe("artifact extraction format support", () => { const alias = join(root, "Alias.dmg"); await symlink(image, alias); const output = join(root, "output"); - const extract = (signal?: AbortSignal) => + const extract = async (signal?: AbortSignal) => new ArtifactProvider(process.env) - .createClient(archiveTarget(alias, "dmg")) + .createClient(await archiveTarget(alias, "dmg")) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ output_root: output }), @@ -158,7 +159,7 @@ describe("artifact extraction format support", () => { ); await writeOrderedZip(archive, ["payload.txt"]); const result = await new ArtifactProvider(process.env) - .createClient(archiveTarget(archive, format)) + .createClient(await archiveTarget(archive, format)) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ @@ -176,13 +177,13 @@ describe("artifact extraction format support", () => { ); }); -const archiveTarget = ( +const archiveTarget = async ( path: string, format: Extract["format"], -): BinaryTarget => ({ +): Promise => ({ path, sourcePath: path, - sha256: "0".repeat(64), + sha256: await artifactGraphRootSha256(path), kind: "archive", format, }); diff --git a/tests/boundary/providers/native/artifactProvider.extractionIdentity.test.ts b/tests/boundary/providers/native/artifactProvider.extractionIdentity.test.ts index 7f47b030c..8360e8d0c 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionIdentity.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionIdentity.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { writeOrderedZip } from "../../../fixtures/artifactEntryOrder.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { artifactGraphRootSha256 } from "../../../fixtures/artifactGraphRootSha256.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; import { artifactExtractionExecutionSchema } from "../../../../src/contracts/artifactToolContracts.js"; @@ -16,7 +17,7 @@ describe("artifact extraction identity", () => { const source = join(root, "source.zip"); await writeOrderedZip(source, ["รค.js", "a.js", "Z.js"]); const result = await new ArtifactProvider(process.env) - .createClient(archiveTarget(source)) + .createClient(await archiveTarget(source)) .execute( "extract_artifact", artifactExtractionExecutionSchema.parse({ @@ -34,10 +35,10 @@ describe("artifact extraction identity", () => { }); }); -const archiveTarget = (path: string): BinaryTarget => ({ +const archiveTarget = async (path: string): Promise => ({ path, sourcePath: path, - sha256: "0".repeat(64), + sha256: await artifactGraphRootSha256(path), kind: "archive", format: "zip", }); diff --git a/tests/fixtures/artifactGraphRootSha256.ts b/tests/fixtures/artifactGraphRootSha256.ts new file mode 100644 index 000000000..12ab6358b --- /dev/null +++ b/tests/fixtures/artifactGraphRootSha256.ts @@ -0,0 +1,19 @@ +import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; +import { scanArtifactInventory } from "../../src/artifacts/inventory/ArtifactInventory.js"; + +/** Compute the root identity used by artifact-provider extraction fixtures. */ +export const artifactGraphRootSha256 = async ( + path: string, +): Promise => { + const resources = new ArtifactResourceScope(); + try { + const snapshot = await scanArtifactInventory(path, { + resourceScope: resources, + environment: process.env, + integrity: { mode: "fail" }, + }); + return snapshot.manifest.root_sha256; + } finally { + await resources.close(); + } +};