Compare commits

...

2 Commits

Author SHA1 Message Date
morluto 0f6f1600c5 fix: preserve actionable artifact diagnostics 2026-07-13 21:49:21 +00:00
morluto 092a303b0f fix(hopper): avoid blocking owned Linux shutdown 2026-07-13 21:48:31 +00:00
11 changed files with 174 additions and 66 deletions

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.

View File

@ -634,10 +634,17 @@ def _dispatch(method, params):
return {"shutdown": True, "analysis_stopped": True, "document_closed": True}
if document.backgroundProcessActive():
document.requestBackgroundProcessStop()
if REA_OWNS_PROCESS_LIFETIME:
return {
"shutdown": True,
"analysis_stopped": not document.backgroundProcessActive(),
"document_closed": False,
"cleanup_required": True,
}
if document.backgroundProcessActive():
document.waitForBackgroundProcessToEnd()
analysis_stopped = not document.backgroundProcessActive()
document.closeDocument()
analysis_stopped = not document.backgroundProcessActive()
document_closed = _session_document() is None
return {
"shutdown": True,

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),
});

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",

View File

@ -349,6 +349,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 +359,22 @@ export const projectAnalysisError = (
assertKnownTag(error._tag);
const code =
error instanceof HopperProcessError ? error.failureCode : undefined;
const details =
error instanceof ArtifactOperationError ? error.artifactDetails : undefined;
return {
category: errorCategory(error),
message: userMessage(error),
...(code === undefined ? {} : { code }),
...(details === undefined
? {}
: {
details: {
logical_path: details.logicalPath,
declared_sha256: details.declaredSha256,
calculated_sha256: details.calculatedSha256,
unpacked: details.unpacked,
},
}),
};
};

View File

@ -27,6 +27,8 @@ export interface BridgeSession {
export interface BridgeLaunch {
readonly process: ChildProcess;
readonly ownsProcessLifetime: boolean;
/** True when verified process cleanup completes bridge shutdown. */
readonly shutdownByCleanup?: boolean;
readonly cleanup?: () => Promise<ProcessCleanupResult>;
}
@ -69,11 +71,13 @@ export class HopperApplicationLauncher implements BridgeLauncher {
options: { readonly signal?: AbortSignal } = {},
): Promise<Result<BridgeLaunch, HopperStartError | HopperCancelledError>> {
const bootstrapPath = `${session.directory}/bootstrap.py`;
const ownsProcessLifetime = usesLinuxDemo(this.options);
const source = [
`REA_SOCKET = ${JSON.stringify(session.socketPath)}`,
`REA_TOKEN = ${JSON.stringify(session.token)}`,
`REA_RUN_ID = ${JSON.stringify(session.runId)}`,
`REA_TARGET_PATH = ${JSON.stringify(this.options.targetPath)}`,
`REA_OWNS_PROCESS_LIFETIME = ${ownsProcessLifetime ? "True" : "False"}`,
`exec(compile(open(${JSON.stringify(this.options.bridgeScriptPath)}, 'rb').read(), ${JSON.stringify(this.options.bridgeScriptPath)}, 'exec'))`,
"",
].join("\n");
@ -141,6 +145,7 @@ export class HopperApplicationLauncher implements BridgeLauncher {
return ok({
process: child,
ownsProcessLifetime: true,
shutdownByCleanup: ownsProcessLifetime,
cleanup: () =>
cleanupOwnedProcessGroup(
ownedProcessGroup(session, pid, ownershipCommand),
@ -163,11 +168,7 @@ const linuxDemoLaunch = (
readonly ownershipCommand: string;
}
| undefined => {
if (
process.platform !== "linux" ||
basename(options.launcherPath) !== "Hopper" ||
options.demoHelperPath === undefined
)
if (!usesLinuxDemo(options) || options.demoHelperPath === undefined)
return undefined;
return {
command: "/usr/bin/python3",
@ -185,6 +186,11 @@ const linuxDemoLaunch = (
};
};
const usesLinuxDemo = (options: HopperApplicationLauncherOptions): boolean =>
process.platform === "linux" &&
basename(options.launcherPath) === "Hopper" &&
options.demoHelperPath !== undefined;
const spawnLauncher = async (
command: string,
args: readonly string[],

View File

@ -173,7 +173,13 @@ export class HopperClient {
{},
{ timeoutMs: SHUTDOWN_TIMEOUT_MS },
).catch(() => err(new HopperProcessError(null)));
if (!shutdown.ok || !isShutdownAcknowledgement(shutdown.value))
if (
!shutdown.ok ||
!isShutdownAcknowledgement(
shutdown.value,
this.#launch?.shutdownByCleanup === true,
)
)
this.#logger.warn(
{ status: shutdown.ok ? "invalid-acknowledgement" : "failed" },
"Hopper document shutdown was not confirmed",
@ -412,13 +418,17 @@ const connectOnce = async (
});
});
const isShutdownAcknowledgement = (value: JsonValue): boolean =>
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
value.shutdown === true &&
value.analysis_stopped === true &&
value.document_closed === true;
const isShutdownAcknowledgement = (
value: JsonValue,
ownsProcessLifetime: boolean,
): boolean => {
if (typeof value !== "object" || value === null || Array.isArray(value))
return false;
if (value.shutdown !== true) return false;
if (value.analysis_stopped === true && value.document_closed === true)
return true;
return ownsProcessLifetime && value.cleanup_required === true;
};
const parseServerInfo = (
value: JsonValue,

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(() => ({

View File

@ -104,6 +104,8 @@ describe("Hopper bridge truthfulness", () => {
expect(bridgeSource).toContain("document.getDatabaseFilePath()");
expect(bridgeSource).toContain("document.backgroundProcessActive()");
expect(bridgeSource).toContain("document.requestBackgroundProcessStop()");
expect(bridgeSource).toContain("if REA_OWNS_PROCESS_LIFETIME:");
expect(bridgeSource).toContain('"cleanup_required": True');
expect(bridgeSource).toContain("document.waitForBackgroundProcessToEnd()");
expect(bridgeSource).toContain("document.closeDocument()");
expect(bridgeSource).toContain(

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";
@ -87,4 +91,28 @@ describe("CLI output boundary", () => {
'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',
});
});
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');
}
});
});

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(