Files
rea/tests/boundary/mcp/asarUnpackedExtractionMcp.test.ts
T
曾亮andmorluto 4d8d76b253 fix(electron): support packaged app.asar pages and rewritten unpacked companions (#1400)
* fix(electron): resolve pages served from inside app.asar

Packaged Electron applications load their renderer pages from file URLs
inside app.asar. The host filesystem cannot stat those paths, so
list_electron_targets excluded every such page as unsupported_url and
inspect_electron_page could not select it.

Resolve these URLs the way Electron's file protocol does: the first .asar
path component that is a regular file is the archive, and the remainder
must name a regular file in its header. Directories, links, and missing
members stay rejected. Parsed headers are cached by archive identity.

* fix(artifacts): extract and explain rewritten ASAR unpacked companions

Packaging tools commonly sign or strip .asar.unpacked native binaries after
writing the archive header, so their declared hashes no longer match.
analyze_javascript_application already accepts integrity_policy, but
extract_artifact did not, and inspect_artifact and extract_artifact still
told callers to "get a fresh copy", which cannot help for a shipped app.

- Accept integrity_policy on extract_artifact (MCP and CLI). The default
  still fails closed; record-and-continue writes the observed bytes and
  returns the extracted files' integrity_contradictions records.
- Name the unpacked entry and the post-packaging cause in integrity
  failures, and point every operation that accepts integrity_policy at
  record-and-continue.
- Report an active unpacked entry whose companion file is absent as
  unavailable with its logical path instead of a format failure.
- Share one integrity_policy schema across the inventory, extraction, and
  JavaScript application inputs.

* fix(electron): bound ASAR headers before allocation

---------

Co-authored-by: morluto <[email protected]>
2026-10-10 23:01:54 +08:00

96 lines
3.9 KiB
TypeScript

import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createStrippedAsarAddon } from "../../fixtures/strippedAsarAddon.js";
const openArchive = async (archive: string): Promise<Client> => {
const session = createTestBinarySession(new ArtifactProvider(process.env));
const server = createServer({ kind: "session", session });
const client = new Client({ name: "asar-unpacked-extraction", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
onTestFinished(async () => {
await Promise.allSettled([client.close(), server.close(), session.close()]);
});
const opened = await client.callTool({
name: "open_binary",
arguments: { path: archive },
});
expect(opened.isError).not.toBe(true);
return client;
};
it("extracts a rewritten unpacked companion only when the caller records the mismatch", async () => {
const { archive, addon, stripped } = await createStrippedAsarAddon();
const client = await openArchive(archive);
const strict = await client.callTool({
name: "extract_artifact",
arguments: {},
});
expect(parseMcpToolError(strict).error).toMatchObject({
code: "artifact_integrity_mismatch",
message: expect.stringContaining(
`Declared ASAR integrity for unpacked entry ${addon} contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive. If expected, rerun extract_artifact with integrity_policy=record-and-continue`,
),
details: { logical_path: addon, unpacked: true },
});
const continued = await client.callTool({
name: "extract_artifact",
arguments: { integrity_policy: "record-and-continue" },
});
expect(continued.isError, JSON.stringify(continued)).not.toBe(true);
const evidence = parseEvidence(continued.structuredContent);
expect(evidence.parameters).toMatchObject({
integrity_policy: "record-and-continue",
});
const result = z
.object({
output_root: z.string(),
integrity_contradictions: z.array(
z.object({ logical_path: z.string(), trust: z.string() }),
),
limitations: z.array(z.string()),
})
.parse(evidence.normalized_result);
onTestFinished(() =>
rm(result.output_root, { recursive: true, force: true }),
);
expect(await readFile(join(result.output_root, addon))).toEqual(stripped);
expect(result.integrity_contradictions).toEqual([
{ logical_path: addon, trust: "observed-untrusted" },
]);
expect(result.limitations).toContain(
"1 extracted file(s) contradict declared integrity metadata; their bytes are observed-untrusted.",
);
});
it("names a missing unpacked companion instead of reporting a changed artifact", async () => {
const { archive, addon } = await createStrippedAsarAddon();
await rm(join(`${archive}.unpacked`, addon));
const client = await openArchive(archive);
const extracted = await client.callTool({
name: "extract_artifact",
arguments: {},
});
expect(parseMcpToolError(extracted).error).toMatchObject({
code: "artifact_operation_failed",
category: "unavailable",
message: `ASAR unpacked companion bytes are unavailable for ${addon}. Select the archive in place with its .unpacked directory beside it, then retry.`,
details: { logical_path: addon, unpacked: true },
});
});