diff --git a/docs/node-sea.md b/docs/node-sea.md index f8c73e7e45..927e199b58 100644 --- a/docs/node-sea.md +++ b/docs/node-sea.md @@ -17,7 +17,7 @@ Summary delivery retains the complete Evidence: use `parent_evidence_id` with Extraction returns a fresh `output_root`; analyze that extracted directory with `analyze_javascript_application`. Snapshot-only applications have no recovered main script and receive no JavaScript next probe. Neither does a main script -whose code path lacks a JavaScript source suffix, such as `main`, because the +whose logical path lacks a JavaScript source suffix, such as `main`, because the analyzer selects extracted files by suffix. ## Recognition and evidence @@ -35,11 +35,18 @@ inventoried once. Native carrier bytes keep their content hash and native classification while the root occurrence represents the SEA container. Root `format_metadata` preserves raw flags, decoded flags, code path, blob -location, exec arguments and fuse observations. Children retain their role and -original `source_path`. The main script uses its declared code path; snapshots -and code caches use `__node_sea__/snapshot` and `__node_sea__/code-cache`. -Assets retain their names. Extraction applies the shared path-containment and -collision rules, rejects unsafe names and never overwrites existing files. +location, exec arguments and fuse observations. Main and asset children retain +their role and exact declared name in `source_path`. Safe, noncolliding names +become logical paths after the shared NFC path normalization. Names that are +unsafe as relative paths, have an exact or file-prefix collision with another +entry, or occupy the reserved `__node_sea__` +namespace receive deterministic logical paths: `__node_sea__/main.cjs` for the +main CommonJS source and `__node_sea__/assets/` for assets, using their +zero-based order in the blob. The original declared name remains +in `source_path`, including an empty asset key. Snapshots and code caches use +`__node_sea__/snapshot` and `__node_sea__/code-cache`. Extraction materializes +the logical paths through the shared path-containment and collision rules and +never overwrites existing files. The fuse scan independently records `set`, `unset`, `absent`, or `conflicting`. An unset fuse with a present blob is reported as a limitation. These are static @@ -56,7 +63,7 @@ including the exec-argument extension byte. Older layouts without that byte, Carrier byte order is independent of blob byte order. Malformed magic, truncation, unknown flags/extensions, invalid UTF-8, duplicate -asset names and conflicting blob declarations fail closed with named reasons. +raw asset keys and conflicting blob declarations fail closed with named reasons. Zero padding after a complete blob is allowed. The blob read ceiling is 256 MiB; PE carriers also use the shared 512 MiB input, 65,536 directory-entry and 8 MiB resource-metadata ceilings. The decoder admits at most 100,000 assets whose @@ -74,7 +81,10 @@ code-cache bytes remain opaque. Synthetic fixtures cover Mach-O, ELF and PE inventory/extraction, malformed blobs, collisions and fuse contradictions. CLI and MCP tests exercise the same -artifact provider and advertised schemas. +artifact provider and advertised schemas. A CLI regression also generates a blob +with the pinned local Node version, an absolute main path and an empty asset +key, then inventories it inside a synthetic ELF carrier. This verifies the real +blob producer, not a real injected executable. The optional real ELF lane uses Docker's official Node v24.10.0 image and pinned postject to build a fixture, then verifies inventory and extracted script/asset diff --git a/src/artifacts/ArtifactReader.test.ts b/src/artifacts/ArtifactReader.test.ts index ef410cc0c8..0ac4f82430 100644 --- a/src/artifacts/ArtifactReader.test.ts +++ b/src/artifacts/ArtifactReader.test.ts @@ -1,5 +1,35 @@ import { expect, it } from "vitest"; -import { ArtifactReaderFailure } from "./ArtifactReader.js"; +import { + ArtifactReaderFailure, + copyArtifactEntry, + sameArtifactEntry, + type ArtifactEntry, +} from "./ArtifactReader.js"; + +it("preserves an observed empty source key separately from missing or changed keys", () => { + const entry: ArtifactEntry = { + path: "__node_sea__/assets/0", + sourcePath: "", + kind: "file", + declaredSize: 5, + compressedSize: null, + executable: false, + encrypted: false, + byteOffset: 64, + declaredIntegrity: null, + unpacked: false, + limitations: [], + adapterKey: "asset:0", + }; + const copied = copyArtifactEntry(entry); + expect(copied.sourcePath).toBe(""); + expect(sameArtifactEntry(copied, entry)).toBe(true); + expect(sameArtifactEntry({ ...copied, sourcePath: "changed" }, entry)).toBe( + false, + ); + const { sourcePath: _sourcePath, ...withoutSource } = copied; + expect(sameArtifactEntry(withoutSource, entry)).toBe(false); +}); it.each([ undefined, diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index 9967ca2c0a..45b79be9b8 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -36,7 +36,7 @@ export interface ArtifactReaderFailureOptions extends ErrorOptions { /** Archive-neutral entry metadata. Reader adapters never choose output paths. */ export interface ArtifactEntry { readonly path: string; - /** Exact reader-source spelling when its admitted path omits source syntax. */ + /** Exact reader-source path or opaque key before logical path mapping. */ readonly sourcePath?: string; readonly kind: "file" | "directory" | "symlink" | "slice" | "metadata"; readonly declaredSize: number | null; @@ -100,6 +100,7 @@ export const sameArtifactEntry = ( produced: ArtifactEntry, ): boolean => entry.path === produced.path && + entry.sourcePath === produced.sourcePath && entry.kind === produced.kind && entry.declaredSize === produced.declaredSize && entry.compressedSize === produced.compressedSize && diff --git a/src/artifacts/NodeSeaArtifactReader.test.ts b/src/artifacts/NodeSeaArtifactReader.test.ts index bdbe0ee5a2..56c7fceb5b 100644 --- a/src/artifacts/NodeSeaArtifactReader.test.ts +++ b/src/artifacts/NodeSeaArtifactReader.test.ts @@ -424,36 +424,110 @@ it("extracts the main script and assets without executing them", async () => { } }); -it.each([ - { name: "traversal", codePath: "../main.js", assets: [] }, - { - name: "duplicate asset", - codePath: "main.js", - assets: [ - { name: "data.txt", body: "a" }, - { name: "data.txt", body: "b" }, - ], - }, - { - name: "normalized collision", - codePath: "main.js", - assets: [ - { name: "a/café.txt", body: "a" }, - { name: "a/cafe\u0301.txt", body: "b" }, - ], - }, - { - name: "reserved script", - codePath: "main.js", - assets: [{ name: "main.js", body: "asset" }], - }, -])( - "rejects $name before inventory can claim readable contents", - async ({ codePath, assets }) => { - const path = await writeCarrier("unsafe", nodeSeaElf({ codePath, assets })); - await expect(scanCanonicalArtifactInventory(path)).rejects.toThrow(); - }, -); +it("rejects duplicate raw asset keys", async () => { + const path = await writeCarrier( + "duplicate-assets", + nodeSeaElf({ + assets: [ + { name: "data.txt", body: "a" }, + { name: "data.txt", body: "b" }, + ], + }), + ); + await expect(scanCanonicalArtifactInventory(path)).rejects.toThrow(); +}); + +it("maps an unsafe main path and opaque asset keys without losing their sources", async () => { + const path = await writeCarrier( + "opaque-names", + nodeSeaElf({ + codePath: "/absolute/main.js", + main: script, + assets: [ + { name: "", body: "empty" }, + { name: "__node_sea__/snapshot", body: "reserved" }, + { name: "a/café.txt", body: "composed" }, + { name: "a/cafe\u0301.txt", body: "decomposed" }, + { name: "tree", body: "parent" }, + { name: "tree/child", body: "child" }, + { name: "__node_sea__", body: "namespace" }, + ], + }), + ); + const graph = await scanCanonicalArtifactInventory(path); + const rows = graph.occurrences.filter( + (row) => row.format_metadata?.kind === "node-sea-entry", + ); + expect( + rows + .map((row) => [ + row.logical_path, + row.format_metadata?.kind === "node-sea-entry" + ? row.format_metadata.source_path + : null, + ]) + .sort(), + ).toEqual( + [ + ["__node_sea__/main.cjs", "/absolute/main.js"], + ["__node_sea__/assets/0", ""], + ["__node_sea__/assets/1", "__node_sea__/snapshot"], + ["a/café.txt", "a/café.txt"], + ["__node_sea__/assets/3", "a/cafe\u0301.txt"], + ["tree", "tree"], + ["__node_sea__/assets/5", "tree/child"], + ["__node_sea__/assets/6", "__node_sea__"], + ].sort(), + ); + expect(rows.map((row) => row.source_path).sort()).toEqual( + [ + "/absolute/main.js", + "", + "__node_sea__/snapshot", + "a/café.txt", + "a/cafe\u0301.txt", + "tree", + "tree/child", + "__node_sea__", + ].sort(), + ); +}); + +it("maps a script collision and later file-prefix asset collision", async () => { + const graph = await scanCanonicalArtifactInventory( + await writeCarrier( + "collisions", + nodeSeaElf({ + codePath: "main.js", + assets: [ + { name: "main.js", body: "same destination" }, + { name: "folder/item", body: "first" }, + { name: "folder", body: "later prefix" }, + ], + }), + ), + ); + const rows = graph.occurrences.filter( + (row) => row.format_metadata?.kind === "node-sea-entry", + ); + expect( + rows + .map((row) => [ + row.logical_path, + row.format_metadata?.kind === "node-sea-entry" + ? row.format_metadata.source_path + : null, + ]) + .sort(), + ).toEqual( + [ + ["main.js", "main.js"], + ["__node_sea__/assets/0", "main.js"], + ["folder/item", "folder/item"], + ["__node_sea__/assets/2", "folder"], + ].sort(), + ); +}); it("retains source asset spelling separately from normalized graph paths", async () => { const path = await writeCarrier( diff --git a/src/artifacts/NodeSeaArtifactReader.ts b/src/artifacts/NodeSeaArtifactReader.ts index 1ccba014b8..fc566ae9a8 100644 --- a/src/artifacts/NodeSeaArtifactReader.ts +++ b/src/artifacts/NodeSeaArtifactReader.ts @@ -6,7 +6,10 @@ import { setImmediate } from "node:timers/promises"; import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js"; import type { NodeSeaMetadata } from "../domain/artifactFormatMetadata.js"; import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js"; -import { normalizeArtifactPath } from "./ArtifactPaths.js"; +import { + ArtifactPathRegistry, + normalizeArtifactPath, +} from "./ArtifactPaths.js"; import { ArtifactReaderFailure, copyArtifactEntry, @@ -21,9 +24,7 @@ import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { NODE_SEA_BLOB_TOO_LARGE, NODE_SEA_MAX_BLOB_BYTES, - NODE_SEA_RESERVED_COLLISION, decodeNodeSeaBlob, - nodeSeaAssetCollision, type DecodedNodeSeaBlob, type NodeSeaByteRange, } from "./nodeSea/NodeSeaBlob.js"; @@ -45,6 +46,8 @@ import { const SNAPSHOT_PATH = "__node_sea__/snapshot"; const CODE_CACHE_PATH = "__node_sea__/code-cache"; +const MAIN_FALLBACK_PATH = "__node_sea__/main.cjs"; +const ASSET_FALLBACK_PREFIX = "__node_sea__/assets/"; type NodeSeaRole = "main-script" | "snapshot" | "code-cache" | "asset"; @@ -311,9 +314,10 @@ export class NodeSeaArtifactReader implements ArtifactReader { formatMetadata: { kind: "node-sea-entry" as const, role: member.role, - source_path: member.path, + source_path: member.sourcePath, index, }, + sourcePath: member.sourcePath, } satisfies ArtifactEntry; this.#entries.set(entry.adapterKey, entry); index += 1; @@ -369,46 +373,64 @@ export class NodeSeaArtifactReader implements ArtifactReader { function* seaMembers(decoded: DecodedNodeSeaBlob): Generator<{ readonly path: string; + readonly sourcePath: string; readonly role: NodeSeaRole; readonly range: NodeSeaByteRange; }> { - const claimed = new Map(); - const claim = (path: string, role: NodeSeaRole): string => { - const normalized = normalizeArtifactPath(path); - const existing = claimed.get(normalized); - if (existing !== undefined) + const registry = new ArtifactPathRegistry(); + const claimed = new Set(); + const reserve = (path: string): string => { + if (claimed.has(path)) throw new ArtifactReaderFailure( - "format", - role === "asset" && existing === "asset" - ? nodeSeaAssetCollision(normalized) - : role === "asset" || existing === "asset" - ? NODE_SEA_RESERVED_COLLISION - : `Node SEA entry path collides: ${normalized}`, + "path", + `Node SEA entry path collides: ${path}`, ); - claimed.set(normalized, role); + registry.add(path, "file"); + claimed.add(path); return path; }; + const mappedPath = (raw: string, fallback: string): string => { + try { + const normalized = normalizeArtifactPath(raw); + if ( + normalized !== "__node_sea__" && + !normalized.startsWith("__node_sea__/") + ) + return reserve(normalized); + } catch (cause) { + if (!(cause instanceof ArtifactReaderFailure) || cause.reason !== "path") + throw cause; + } + return reserve(fallback); + }; if (decoded.mainScript !== null) yield { - path: claim(decoded.codePath, "main-script"), + path: mappedPath(decoded.codePath, MAIN_FALLBACK_PATH), + sourcePath: decoded.codePath, role: "main-script", range: decoded.mainScript, }; if (decoded.snapshot !== null) yield { - path: claim(SNAPSHOT_PATH, "snapshot"), + path: reserve(SNAPSHOT_PATH), + sourcePath: SNAPSHOT_PATH, role: "snapshot", range: decoded.snapshot, }; if (decoded.codeCache !== null) yield { - path: claim(CODE_CACHE_PATH, "code-cache"), + path: reserve(CODE_CACHE_PATH), + sourcePath: CODE_CACHE_PATH, role: "code-cache", range: decoded.codeCache, }; - for (const asset of decoded.assets) + for (const [ordinal, asset] of decoded.assets.entries()) yield { - path: claim(asset.name, "asset"), + path: mappedPath( + asset.name, + `${ASSET_FALLBACK_PREFIX}${String(ordinal)}`, + ), + sourcePath: asset.name, role: "asset", range: asset.range, }; diff --git a/src/artifacts/nodeSea/NodeSeaBlob.test.ts b/src/artifacts/nodeSea/NodeSeaBlob.test.ts index ead642c556..a2f79ead7e 100644 --- a/src/artifacts/nodeSea/NodeSeaBlob.test.ts +++ b/src/artifacts/nodeSea/NodeSeaBlob.test.ts @@ -84,6 +84,13 @@ it("rejects colliding asset names", async () => { ).rejects.toThrow(nodeSeaAssetCollision("shared")); }); +it("retains an empty asset key as a raw Node SEA key", async () => { + const decoded = await decodeNodeSeaBlob( + buildNodeSeaBlob({ assets: [{ name: "", body: "value" }] }), + ); + expect(decoded.assets).toMatchObject([{ name: "", range: { length: 5 } }]); +}); + const limitOf = async (bytes: Buffer): Promise => { try { await decodeNodeSeaBlob(bytes); diff --git a/src/artifacts/nodeSea/NodeSeaBlob.ts b/src/artifacts/nodeSea/NodeSeaBlob.ts index 8193ad427c..4c8cd2d0ca 100644 --- a/src/artifacts/nodeSea/NodeSeaBlob.ts +++ b/src/artifacts/nodeSea/NodeSeaBlob.ts @@ -37,8 +37,6 @@ export const NODE_SEA_UNKNOWN_FLAGS = export const NODE_SEA_UNKNOWN_EXTENSION = "Node SEA exec argv extension is unknown"; export const NODE_SEA_TRAILING_BYTES = "Node SEA blob has trailing bytes"; -export const NODE_SEA_RESERVED_COLLISION = - "Node SEA asset name collides with a reserved entry"; export const NODE_SEA_DUPLICATE_BLOB = "Node SEA blob is declared more than once"; export const NODE_SEA_ELF_DESCRIPTOR_TRUNCATED = @@ -56,7 +54,6 @@ export const NODE_SEA_FUSE_UNSET = export const NODE_SEA_FUSE_CONFLICT = "Node SEA fuse sentinels disagree"; export const NODE_SEA_EMPTY_CODE_PATH = "Node SEA code path is empty"; export const NODE_SEA_INVALID_UTF8 = "Node SEA string is not valid UTF-8"; -export const NODE_SEA_EMPTY_ASSET_NAME = "Node SEA asset name is empty"; export const NODE_SEA_ASSET_COUNT = `Node SEA asset count exceeds the ${String(MAX_ASSET_COUNT)}-asset bound. ${NO_OVERRIDE}`; export const NODE_SEA_ASSET_NAME_BYTES = `Node SEA asset names exceed the ${String(MAX_ASSET_NAME_BYTES)}-byte name budget. ${NO_OVERRIDE}`; export const NODE_SEA_EXEC_ARGV_COUNT = `Node SEA exec argv count exceeds the ${String(MAX_EXEC_ARGV)}-argument bound. ${NO_OVERRIDE}`; @@ -276,7 +273,7 @@ class BlobCursor { await setImmediate(); assertArtifactReadActive(signal); } - const name = this.text(false, NODE_SEA_EMPTY_ASSET_NAME, charge); + const name = this.text(true, NODE_SEA_INVALID_UTF8, charge); if (seen.has(name)) throw formatFailure(nodeSeaAssetCollision(name)); seen.add(name); assets.push({ name, range: this.byteRange() }); diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index ed3b212e8a..6c596bc956 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -152,8 +152,8 @@ export const artifactOccurrenceSchema = z.object({ unpacked: z.literal(true).exactOptional(), hash_status: z.enum(["verified", "mismatched", "not-hashed", "unavailable"]), source_location: artifactByteRangeSchema.nullable(), - /** Original path within this occurrence's reader when normalization changed its spelling. */ - source_path: z.string().min(1).exactOptional(), + /** Exact reader-source path or opaque key, including an observed empty key. */ + source_path: z.string().exactOptional(), declared_integrity: artifactIntegrityClaimSchema.exactOptional(), format_metadata: artifactFormatMetadataSchema.exactOptional(), limitations: z.array(z.string()), diff --git a/tests/acceptance/nodeSeaCli.test.ts b/tests/acceptance/nodeSeaCli.test.ts index a72dfae39e..4b63a99720 100644 --- a/tests/acceptance/nodeSeaCli.test.ts +++ b/tests/acceptance/nodeSeaCli.test.ts @@ -1,6 +1,6 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { expect } from "vitest"; +import { expect, onTestFinished } from "vitest"; import { cliTest } from "../support/cli/cliFixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { @@ -83,6 +83,183 @@ cliTest.for(["elf", "mach-o", "pe"] as const)( }, ); +cliTest( + "reads a real Node-produced SEA blob with an absolute main and empty asset key", + async ({ cli, processes }) => { + const root = await createTestTempDirectory("rea-sea-node-producer-"); + const mainPath = join(root, "main.cjs"); + const assetPath = join(root, "asset.txt"); + const blobPath = join(root, "preparation.blob"); + const configPath = join(root, "sea-config.json"); + const main = 'throw new Error("must not execute");\n'; + await writeFile(mainPath, main); + await writeFile(assetPath, "empty-key asset"); + await writeFile( + configPath, + JSON.stringify({ + main: mainPath, + output: blobPath, + useSnapshot: false, + useCodeCache: false, + assets: { "": assetPath }, + }), + ); + const generated = await processes.run(process.execPath, [ + "--experimental-sea-config", + configPath, + ]); + expect(generated.exitCode, generated.stdout + generated.stderr).toBe(0); + const source = join(root, "application"); + // Node produces the blob; only its ELF carrier is synthetic. + await writeFile(source, nodeSeaElf({ blob: await readFile(blobPath) })); + const inspected = await cli.run({ + arguments: ["inspect-artifact", source, "--json"], + }); + expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0); + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(inspected.json).normalized_result, + ); + const graph = artifactInventoryResultSchema.parse( + inspection.substeps[0]?.evidence.normalized_result, + ); + expect(graph.coverage.status).toBe("complete"); + expect( + graph.occurrences.find( + (row) => row.logical_path === "__node_sea__/main.cjs", + ), + ).toMatchObject({ + source_path: mainPath, + artifact_format: "javascript-bundle", + }); + expect( + graph.occurrences.find( + (row) => row.logical_path === "__node_sea__/assets/0", + ), + ).toMatchObject({ source_path: "", format_metadata: { source_path: "" } }); + }, +); + +cliTest( + "preserves opaque SEA source keys through CLI and MCP inventory and extraction", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-sea-opaque-public-"); + const source = join(root, "application"); + const main = Buffer.from("void 0;\n"); + const assets = [ + { name: "", body: "empty key" }, + { name: "../outside.txt", body: "parent key" }, + { name: "safe/data.txt", body: "safe key" }, + { name: "__node_sea__/main.cjs", body: "reserved key" }, + { name: "data/café.txt", body: "composed key" }, + { name: "data/cafe\u0301.txt", body: "decomposed key" }, + ]; + // The SEA blob follows Node's format; nodeSeaElf supplies a synthetic carrier. + await writeFile( + source, + nodeSeaElf({ + blob: buildNodeSeaBlob({ + codePath: "/generated/main.cjs", + main, + assets, + }), + }), + ); + + const expected = [ + { + path: "__node_sea__/main.cjs", + source: "/generated/main.cjs", + body: main, + }, + { + path: "__node_sea__/assets/0", + source: "", + body: Buffer.from("empty key"), + }, + { + path: "__node_sea__/assets/1", + source: "../outside.txt", + body: Buffer.from("parent key"), + }, + { + path: "safe/data.txt", + source: "safe/data.txt", + body: Buffer.from("safe key"), + }, + { + path: "__node_sea__/assets/3", + source: "__node_sea__/main.cjs", + body: Buffer.from("reserved key"), + }, + { + path: "data/café.txt", + source: "data/café.txt", + body: Buffer.from("composed key"), + }, + { + path: "__node_sea__/assets/5", + source: "data/cafe\u0301.txt", + body: Buffer.from("decomposed key"), + }, + ]; + const checkInventory = (inspection: unknown): void => { + const parsed = artifactInspectionResultSchema.parse(inspection); + const graph = artifactInventoryResultSchema.parse( + parsed.substeps[0]?.evidence.normalized_result, + ); + expect(graph.manifest.root_format).toBe("node-sea"); + expect(graph.coverage.status).toBe("complete"); + for (const { path, source: raw } of expected) { + const row = graph.occurrences.find( + (item) => item.logical_path === path, + ); + expect(row?.source_path).toBe(raw); + expect(row?.format_metadata).toMatchObject({ source_path: raw }); + expect(row?.hash_status).toBe("verified"); + } + }; + const checkExtraction = async (extraction: unknown): Promise => { + const result = artifactExtractionResultSchema.parse(extraction); + onTestFinished(() => + rm(result.output_root, { recursive: true, force: true }), + ); + expect(result.containment_verified).toBe(true); + expect(result.artifacts.map((item) => item.relative_path).sort()).toEqual( + expected.map((item) => item.path).sort(), + ); + for (const { path, body } of expected) + expect(await readFile(join(result.output_root, path))).toEqual(body); + }; + + const inspected = await cli.run({ + arguments: ["inspect-artifact", source, "--json"], + }); + expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0); + checkInventory(parseEvidence(inspected.json).normalized_result); + const extracted = await cli.run({ + arguments: ["extract-artifact", source, "--json"], + environment: { TMPDIR: root, TMP: root, TEMP: root }, + }); + expect(extracted.exitCode, extracted.stdout + extracted.stderr).toBe(0); + await checkExtraction(parseEvidence(extracted.json).normalized_result); + + const call = await cli.sharedMcpCall?.(); + if (call === undefined) throw new Error("Expected a stdio MCP session"); + const opened = await call("open_binary", { path: source }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const mcpInspected = await call("inspect_artifact", {}); + expect(mcpInspected.isError, JSON.stringify(mcpInspected)).not.toBe(true); + checkInventory( + parseEvidence(mcpInspected.structuredContent).normalized_result, + ); + const mcpExtracted = await call("extract_artifact", {}); + expect(mcpExtracted.isError, JSON.stringify(mcpExtracted)).not.toBe(true); + await checkExtraction( + parseEvidence(mcpExtracted.structuredContent).normalized_result, + ); + }, +); + cliTest( "offers no JavaScript probe for a main script without a source suffix", async ({ cli }) => {