Compare commits
2 Commits
agent/java
...
morlvps/is
| Author | SHA1 | Date |
|---|---|---|
|
|
0f6f1600c5 | |
|
|
092a303b0f |
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(() => ({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue