fix: preserve actionable artifact diagnostics (#119)

This commit is contained in:
morluto
2026-07-14 06:28:10 +08:00
committed by GitHub
parent fb51874bb9
commit 73852202d4
7 changed files with 136 additions and 55 deletions
+2
View File
@@ -13,6 +13,8 @@ Prioritize:
Installers must not install or upgrade Homebrew, Node.js, npm, or other unrelated software. `rea setup` must print its planned changes and require approval before writing files or installing Hopper.
REA is a local-only tool; do not sanitize actionable local diagnostics such as artifact paths, digests, mismatch locations, or analysis metadata, while continuing to redact genuine secrets such as credentials and authorization headers.
## Project Structure & Module Organization
REA is a layered ESM TypeScript application. Dependencies flow inward: `domain` ← `contracts` ← `hopper` ← `application` ← `server` ← adapters.
+8 -6
View File
@@ -3,6 +3,7 @@ import { Cli, z } from "incur";
import { runCrossVersionInvestigation } from "./application/CrossVersionInvestigation.js";
import { logCliCommand } from "./cliLogging.js";
import { parseConfig } from "./config.js";
import { projectAnalysisError, type AnalysisError } from "./domain/errors.js";
import type { Logger } from "./logger.js";
const investigationOptionsSchema = z.object({
@@ -74,8 +75,7 @@ const runInvestigation = async (
message: "Persistent workspace writes require explicit --yes approval.",
};
const config = parseConfig(process.env);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
if (!config.ok) return cliError(config.error);
const investigated = await runCrossVersionInvestigation(
{
approved: true,
@@ -102,8 +102,10 @@ const runInvestigation = async (
);
return investigated.ok
? investigated.value.evidence
: {
error: investigated.error._tag,
message: investigated.error.message,
};
: cliError(investigated.error);
};
const cliError = (error: AnalysisError) => ({
error: "Analysis failed",
...projectAnalysisError(error),
});
+9
View File
@@ -191,6 +191,15 @@ const analysisErrorProjectionSchema = z
"execution_failure",
]),
message: z.string(),
details: z
.object({
logical_path: z.string(),
declared_sha256: z.string().nullable(),
calculated_sha256: z.string().nullable(),
unpacked: z.boolean(),
})
.strict()
.optional(),
code: z
.enum([
"private_display_unavailable",
+13 -2
View File
@@ -3,8 +3,7 @@ import {
hopperStartupFailure,
type HopperStartupFailureCode,
} from "./hopperStartupFailure.js";
export { hopperStartupFailure } from "./hopperStartupFailure.js";
export type { HopperStartupFailureCode } from "./hopperStartupFailure.js";
export { hopperStartupFailure, type HopperStartupFailureCode };
/** Stable tags exposed by safe analysis-error projections. */
const ANALYSIS_ERROR_TAGS = [
@@ -349,6 +348,7 @@ export interface AnalysisErrorProjection
| "execution_failure";
readonly message: string;
readonly code?: HopperStartupFailureCode;
readonly details?: Readonly<Record<string, JsonValue>>;
}
/** Project expected failures into exhaustive, secret-safe caller fields. */
@@ -358,10 +358,21 @@ export const projectAnalysisError = (
assertKnownTag(error._tag);
const code =
error instanceof HopperProcessError ? error.failureCode : undefined;
const artifact =
error instanceof ArtifactOperationError && error.artifactDetails;
const details = artifact
? {
logical_path: artifact.logicalPath,
declared_sha256: artifact.declaredSha256,
calculated_sha256: artifact.calculatedSha256,
unpacked: artifact.unpacked,
}
: undefined;
return {
category: errorCategory(error),
message: userMessage(error),
...(code === undefined ? {} : { code }),
...(details === undefined ? {} : { details }),
};
};
+68 -44
View File
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -17,51 +17,75 @@ import { createServer } from "../src/server/createServer.js";
import { observed } from "./fixtures/analysisExecution.js";
describe("artifact graph MCP integration", () => {
it("returns a safe ASAR integrity error without paths or hashes", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-asar-integrity-mcp-"));
const source = join(root, "source");
const original = "console.log('ok');\n";
const changed = "changed();\n";
await mkdir(source);
await writeFile(join(source, "main.js"), original);
const archive = join(root, "fixture.asar");
await createPackageWithOptions(source, archive, { unpack: "*.js" });
await writeFile(join(`${archive}.unpacked`, "main.js"), changed);
it.each([
{ unpacked: false, label: "embedded" },
{ unpacked: true, label: "unpacked" },
])(
"returns actionable local details for an $label ASAR integrity error",
async ({ unpacked }) => {
const root = await mkdtemp(join(tmpdir(), "rea-asar-integrity-mcp-"));
const source = join(root, "source");
const original = "console.log('ok');\n";
const changed = "console.log('no');\n";
await mkdir(source);
await writeFile(join(source, "main.js"), original);
const archive = join(root, "fixture.asar");
await createPackageWithOptions(
source,
archive,
unpacked ? { unpack: "*.js" } : {},
);
if (unpacked) {
await writeFile(join(`${archive}.unpacked`, "main.js"), changed);
} else {
const bytes = await readFile(archive);
const contentOffset = bytes.indexOf(original);
expect(contentOffset).toBeGreaterThanOrEqual(0);
bytes.write(changed, contentOffset, "utf8");
await writeFile(archive, bytes);
}
const session = new BinarySession(new ArtifactProvider());
const server = createServer(session, session);
const client = new Client({ name: "asar-integrity-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: archive },
});
expect(opened.isError).not.toBe(true);
const session = new BinarySession(new ArtifactProvider());
const server = createServer(session, session);
const client = new Client({ name: "asar-integrity-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const opened = await client.callTool({
name: "open_binary",
arguments: { path: archive },
});
expect(opened.isError).not.toBe(true);
const result = await client.callTool({
name: "inventory_artifact",
arguments: {},
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
error: {
category: "integrity_mismatch",
message:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
},
});
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
});
const result = await client.callTool({
name: "inventory_artifact",
arguments: {},
});
expect(result.isError).toBe(true);
expect(result.structuredContent).toEqual({
error: {
category: "integrity_mismatch",
message:
"Artifact is invalid or has changed. Get a fresh copy and try again.",
details: {
logical_path: "main.js",
declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
calculated_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
unpacked,
},
},
});
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
},
);
it("rejects altered payloads that reuse session Evidence IDs", async () => {
const session = new BinarySession(() => ({
+29 -1
View File
@@ -1,6 +1,10 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { createPackageWithOptions } from "@electron/asar";
import { describe, expect, it } from "vitest";
import { sanitizeCliOutput } from "../src/cliOutput.js";
@@ -86,5 +90,29 @@ describe("CLI output boundary", () => {
stdout:
'ok: false\nerror:\n code: VALIDATION_ERROR\n message: "REA could not read the command arguments. Run `rea --help`, correct the arguments, then try again."\n',
});
});
}, 20_000);
it("preserves artifact diagnostics in ordinary and full JSON output", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-cli-diagnostics-"));
const source = join(root, "source");
await mkdir(source);
await writeFile(join(source, "main.js"), "console.log('ok');\n");
const archive = join(root, "fixture.asar");
await createPackageWithOptions(source, archive, { unpack: "*.js" });
await writeFile(join(`${archive}.unpacked`, "main.js"), "changed();\n");
for (const flags of [["--json"], ["--full-output", "--json"]]) {
const { stdout } = await execFileAsync(process.execPath, [
"scripts/rea.mjs",
...flags,
"inventory-artifact",
archive,
]);
const output = JSON.stringify(JSON.parse(stdout) as unknown);
expect(output).toContain('"logical_path":"main.js"');
expect(output).toMatch(/"declared_sha256":"[a-f0-9]{64}"/u);
expect(output).toMatch(/"calculated_sha256":"[a-f0-9]{64}"/u);
expect(output).toContain('"unpacked":true');
}
}, 20_000);
});
+7 -2
View File
@@ -58,7 +58,7 @@ describe("analysis error projection", () => {
}
});
it("projects every typed failure without internal details", () => {
it("projects every typed failure without secrets", () => {
const secretCause = new Error("secret-token");
const byTag = {
AnalysisProtocolError: new AnalysisProtocolError("protocol failed"),
@@ -132,7 +132,6 @@ describe("analysis error projection", () => {
"fixture",
"overview",
"AnalysisCapabilityUnavailableError",
"main.js",
"declaredSha256",
])
expect(serialized).not.toContain(hidden);
@@ -152,6 +151,12 @@ describe("analysis error projection", () => {
),
).toMatchObject({
category: "integrity_mismatch",
details: {
logical_path: "main.js",
declared_sha256: "a".repeat(64),
calculated_sha256: "b".repeat(64),
unpacked: true,
},
});
expect(projected.every(({ message }) => message.length > 0)).toBe(true);
expect(