Compare commits

..
Author SHA1 Message Date
morluto 02fe120523 fix(hopper): complete owned shutdown cleanup 2026-07-13 21:53:09 +00:00
morluto d28336c6e5 fix(hopper): avoid blocking owned Linux shutdown 2026-07-13 21:53:08 +00:00
15 changed files with 109 additions and 342 deletions
-2
View File
@@ -13,8 +13,6 @@ 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.
-21
View File
@@ -420,27 +420,6 @@ rea mcp
REA accepts a Mac `.app` folder directly. If an agent cannot find an app by name, tell it where the app is installed.
### CLI exit status
| Status | Meaning |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `0` | The requested operation completed. Truthful unknowns, warnings, and bounded or truncated evidence remain successful results. |
| `1` | Arguments, policy, permission, provider analysis, integrity checking, cancellation, timeout, setup, diagnostics, update, uninstall, output encoding, or output writing prevented completion. Structured output identifies the failure category when REA could encode it. |
| `128 + N` | The process ended from signal `N`, where the shell or runtime preserves the conventional signal-derived status. |
`setup` returns `1` for `planned`, `needs_confirmation`, or `needs_human`
because configuration is not ready; rerun it after approval or remediation.
`doctor` returns `1` when any check is unhealthy. Output format, full envelopes,
filters, and token controls never change the operation status.
When REA feeds a shell pipeline, enable `pipefail` so a downstream formatter
cannot hide its failure:
```bash
set -o pipefail
rea inventory-artifact ./app.asar --json | jq . > inventory.json
```
## Current Hopper provider
REA starts Hopper when needed; Hopper does not need to be running first. Hopper's launcher internally activates the application, so opening a target may bring Hopper to the foreground. REA asks macOS to start Hopper hidden and in the background when possible, but cannot guarantee that it will remain behind the current application.
+14 -56
View File
@@ -114,12 +114,7 @@ try {
const cli = join(prefix, "bin", "rea");
const help = await run(cli, ["--help"], environment);
const llms = await run(cli, ["--llms"], environment);
const doctorExecution = await runWithStatus(
cli,
["doctor", "--json"],
environment,
);
const doctor = json(doctorExecution.stdout);
const doctor = json(await run(cli, ["doctor", "--json"], environment));
const supportedSetupHost =
doctor.checks?.find(({ name }) => name === "host")?.ok === true;
const expectedDoctorHealth = doctor.checks?.every(({ ok }) => ok) === true;
@@ -141,7 +136,6 @@ try {
!llms.includes("providers") ||
!llms.includes("inventory-artifact") ||
doctor.healthy !== expectedDoctorHealth ||
doctorExecution.status !== (expectedDoctorHealth ? 0 : 1) ||
doctor.checks?.find(({ name }) => name === "hopper")?.ok !== true
)
throw new Error(
@@ -334,16 +328,10 @@ try {
await writeFile(skillPath, "stale managed skill\n");
await writeFile(siblingSkillPath, "unrelated skill\n");
}
const plannedExecution = await runWithStatus(
cli,
["setup", "--json"],
environment,
);
const planned = json(plannedExecution.stdout);
const planned = json(await run(cli, ["setup", "--json"], environment));
if (supportedSetupHost) {
if (
planned.status !== "needs_confirmation" ||
plannedExecution.status !== 1 ||
planned.appliedActions.length !== 0 ||
!planned.plannedActions.some(({ kind }) => kind === "configure_client") ||
!planned.plannedActions.some(({ kind }) => kind === "install_skill") ||
@@ -353,25 +341,19 @@ try {
)
throw new Error("packaged setup plan mutated files before approval");
}
const firstExecution = await runWithStatus(
cli,
["setup", "--yes", "--json"],
environment,
const first = json(await run(cli, ["setup", "--yes", "--json"], environment));
const second = json(
await run(
cli,
["setup", "--yes", "--install-hopper", "--json"],
environment,
),
);
const first = json(firstExecution.stdout);
const secondExecution = await runWithStatus(
cli,
["setup", "--yes", "--install-hopper", "--json"],
environment,
);
const second = json(secondExecution.stdout);
if (supportedSetupHost) {
const status = process.platform === "linux" ? "needs_human" : "ready";
if (
first.status !== status ||
second.status !== status ||
firstExecution.status !== (status === "ready" ? 0 : 1) ||
secondExecution.status !== (status === "ready" ? 0 : 1) ||
second.appliedActions.length !== 0
)
throw new Error("packaged setup was not ready and idempotent");
@@ -405,29 +387,19 @@ try {
throw new Error("packaged stale-skill upgrade was not isolated");
await writeFile(cursorConfig, "malformed");
const failedExecution = await runWithStatus(
cli,
["setup", "--yes", "--json"],
environment,
const failed = json(
await run(cli, ["setup", "--yes", "--json"], environment),
);
const failed = json(failedExecution.stdout);
if (
failed.status !== "needs_human" ||
failedExecution.status !== 1 ||
(await readFile(cursorConfig, "utf8")) !== "malformed"
)
throw new Error("packaged setup failure recovery did not preserve input");
await writeFile(cursorConfig, "{}\n");
const recoveredExecution = await runWithStatus(
cli,
["setup", "--yes", "--json"],
environment,
const recovered = json(
await run(cli, ["setup", "--yes", "--json"], environment),
);
const recovered = json(recoveredExecution.stdout);
if (
recovered.status !== status ||
recoveredExecution.status !== (status === "ready" ? 0 : 1)
)
if (recovered.status !== status)
throw new Error("packaged setup did not recover");
} else if (
first.status !== "needs_human" ||
@@ -531,20 +503,6 @@ try {
async function run(command, args, env) {
return (await exec(command, args, { env })).stdout;
}
async function runWithStatus(command, args, env) {
try {
return { stdout: await run(command, args, env), status: 0 };
} catch (cause) {
if (
typeof cause === "object" &&
cause !== null &&
cause.code === 1 &&
typeof cause.stdout === "string"
)
return { stdout: cause.stdout, status: 1 };
throw cause;
}
}
function json(text) {
return JSON.parse(text);
}
+13
View File
@@ -25,6 +25,19 @@ export interface ProcessCliErrorOutput {
readonly message: string;
}
/** Distinguish a safe process-command failure from successful Evidence. */
export const isProcessCliErrorOutput = (
value: unknown,
): value is ProcessCliErrorOutput =>
typeof value === "object" &&
value !== null &&
"error" in value &&
value.error === "Process command failed" &&
"category" in value &&
typeof value.category === "string" &&
"message" in value &&
typeof value.message === "string";
/** Capture one JSON scenario through the same policy and evidence contract as MCP. */
export const captureProcessScenarioFile = async (path: string) => {
try {
+6 -8
View File
@@ -3,7 +3,6 @@ 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({
@@ -75,7 +74,8 @@ const runInvestigation = async (
message: "Persistent workspace writes require explicit --yes approval.",
};
const config = parseConfig(process.env);
if (!config.ok) return cliError(config.error);
if (!config.ok)
return { error: config.error._tag, message: config.error.message };
const investigated = await runCrossVersionInvestigation(
{
approved: true,
@@ -102,10 +102,8 @@ const runInvestigation = async (
);
return investigated.ok
? investigated.value.evidence
: cliError(investigated.error);
: {
error: investigated.error._tag,
message: investigated.error.message,
};
};
const cliError = (error: AnalysisError) => ({
error: "Analysis failed",
...projectAnalysisError(error),
});
+3 -21
View File
@@ -1,21 +1,5 @@
import type { Logger } from "./logger.js";
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
/** Identify caller-visible CLI outcomes that mean the requested operation failed. */
export const isCliOperationFailure = (value: unknown): boolean => {
if (!isRecord(value)) return false;
if (typeof value.error === "string") return true;
if (value.healthy === false) return true;
return (
value.status === "failed" ||
value.status === "needs_confirmation" ||
value.status === "needs_human" ||
value.status === "planned"
);
};
/** Log one CLI command with duration and a stable success or failure status. */
export const logCliCommand = async <Value>(
logger: Logger,
@@ -25,16 +9,14 @@ export const logCliCommand = async <Value>(
const startedAt = performance.now();
try {
const value = await execute();
const failed = isCliOperationFailure(value);
logger[failed ? "error" : "info"](
logger.info(
{
command,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
status: failed ? "error" : "ok",
status: "ok",
},
failed ? "CLI command failed" : "CLI command completed",
"CLI command completed",
);
if (failed) process.exitCode = 1;
return value;
} catch (cause: unknown) {
logger.error(
+21 -3
View File
@@ -1,12 +1,26 @@
import { Cli, z } from "incur";
import { Cli, Errors, z } from "incur";
import {
captureProcessScenarioFile,
compareProcessEvidenceFiles,
isProcessCliErrorOutput,
} from "./application/ProcessCli.js";
import { logCliCommand } from "./cliLogging.js";
import type { Logger } from "./logger.js";
const requireProcessCommandSuccess = async <Value>(
execute: () => Promise<Value>,
): Promise<Value> => {
const value = await execute();
if (isProcessCliErrorOutput(value))
throw new Errors.IncurError({
code: "PROCESS_COMMAND_FAILED",
message: value.message,
retryable: false,
});
return value;
};
/** Register Process Capture v4 one-shot commands through shared application services. */
export const registerProcessCommands = (
cli: ReturnType<typeof Cli.create>,
@@ -17,7 +31,9 @@ export const registerProcessCommands = (
args: z.object({ scenario: z.string().describe("Scenario JSON path") }),
run: ({ args }) =>
logCliCommand(logger, "capture-process", () =>
captureProcessScenarioFile(args.scenario),
requireProcessCommandSuccess(() =>
captureProcessScenarioFile(args.scenario),
),
),
});
cli.command("compare-process-captures", {
@@ -28,7 +44,9 @@ export const registerProcessCommands = (
}),
run: ({ args }) =>
logCliCommand(logger, "compare-process-captures", () =>
compareProcessEvidenceFiles(args.left, args.right),
requireProcessCommandSuccess(() =>
compareProcessEvidenceFiles(args.left, args.right),
),
),
});
};
-9
View File
@@ -191,15 +191,6 @@ 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",
+2 -13
View File
@@ -3,7 +3,8 @@ import {
hopperStartupFailure,
type HopperStartupFailureCode,
} from "./hopperStartupFailure.js";
export { hopperStartupFailure, type HopperStartupFailureCode };
export { hopperStartupFailure } from "./hopperStartupFailure.js";
export type { HopperStartupFailureCode } from "./hopperStartupFailure.js";
/** Stable tags exposed by safe analysis-error projections. */
const ANALYSIS_ERROR_TAGS = [
@@ -348,7 +349,6 @@ 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,21 +358,10 @@ 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 }),
};
};
+2 -2
View File
@@ -12,7 +12,7 @@ const logLevelSchema = z
export const parseLogLevel = (value: unknown): LogLevel =>
logLevelSchema.parse(value);
/** Create a JSON logger on stderr so protocol and CLI result stdout stay valid. */
/** Create a JSON logger whose destination cannot corrupt MCP stdout frames. */
export const createLogger = (mode: "mcp" | "cli", level: LogLevel): Logger =>
pino(
{
@@ -32,7 +32,7 @@ export const createLogger = (mode: "mcp" | "cli", level: LogLevel): Logger =>
censor: "[Redacted]",
},
},
pino.destination({ dest: 2, sync: false }),
pino.destination({ dest: mode === "mcp" ? 2 : 1, sync: false }),
);
/** Logger used by embedders that have not opted into process output. */
+44 -68
View File
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -17,75 +17,51 @@ import { createServer } from "../src/server/createServer.js";
import { observed } from "./fixtures/analysisExecution.js";
describe("artifact graph MCP integration", () => {
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);
}
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);
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.",
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(),
]);
}
},
);
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(),
]);
}
});
it("rejects altered payloads that reuse session Evidence IDs", async () => {
const session = new BinarySession(() => ({
-50
View File
@@ -1,50 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { isCliOperationFailure, logCliCommand } from "../src/cliLogging.js";
import { silentLogger } from "../src/logger.js";
const originalExitCode = process.exitCode;
afterEach(() => {
process.exitCode = originalExitCode;
});
describe("CLI operation status", () => {
it.each([
["typed error", { error: "Analysis failed", category: "timeout" }],
["unhealthy diagnostic", { healthy: false, checks: [] }],
["failed status", { status: "failed" }],
["confirmation requirement", { status: "needs_confirmation" }],
["human remediation", { status: "needs_human" }],
["unapplied plan", { status: "planned" }],
])("classifies %s as failure", (_label, value) => {
expect(isCliOperationFailure(value)).toBe(true);
});
it.each([
["ready setup", { status: "ready" }],
["complete uninstall", { status: "complete" }],
["current version", { status: "current" }],
["completed upgrade", { status: "upgraded" }],
["healthy diagnostics", { healthy: true, checks: [] }],
["bounded evidence", { evidence: [{ truncated: true }] }],
])("keeps %s successful", (_label, value) => {
expect(isCliOperationFailure(value)).toBe(false);
});
it("sets a nonzero process status without replacing structured output", async () => {
const output = {
error: "Analysis failed",
category: "integrity_mismatch",
message: "Artifact integrity check failed.",
details: { logical_path: "main.js" },
};
await expect(
logCliCommand(silentLogger, "inventory-artifact", () =>
Promise.resolve(output),
),
).resolves.toBe(output);
expect(process.exitCode).toBe(1);
});
});
+1 -81
View File
@@ -1,10 +1,6 @@
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";
@@ -90,81 +86,5 @@ 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 execution = execFileAsync(process.execPath, [
"scripts/rea.mjs",
...flags,
"inventory-artifact",
archive,
]);
const failure = await execution.catch((cause: unknown) => cause);
expect(failure).toMatchObject({ code: 1 });
const { stdout } = failure as { readonly stdout: string };
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);
it("keeps operation failure status independent of output controls", async () => {
const variants = [
["--format", "toon"],
["--format", "json"],
["--format", "yaml"],
["--format", "md"],
["--format", "jsonl"],
["--full-output", "--json"],
["--filter-output", "category", "--json"],
["--token-limit", "5", "--json"],
["--token-count", "--json"],
];
for (const flags of variants) {
await expect(
execFileAsync(process.execPath, [
"scripts/rea.mjs",
...flags,
"investigate-versions",
"/tmp/left",
"/tmp/right",
"/tmp/workspace.json",
]),
).rejects.toMatchObject({ code: 1, stdout: expect.any(String) });
}
}, 20_000);
it("keeps failure logs out of structured stdout", async () => {
const execution = execFileAsync(
process.execPath,
[
"scripts/rea.mjs",
"--json",
"investigate-versions",
"/tmp/left",
"/tmp/right",
"/tmp/workspace.json",
],
{ env: { ...process.env, REA_LOG_LEVEL: "error" } },
);
const failure = await execution.catch((cause: unknown) => cause);
expect(failure).toMatchObject({
code: 1,
stderr: expect.stringContaining('"status":"error"'),
});
const { stdout } = failure as { readonly stdout: string };
expect(JSON.parse(stdout)).toMatchObject({ error: "ApprovalRequired" });
}, 20_000);
});
});
+2 -7
View File
@@ -58,7 +58,7 @@ describe("analysis error projection", () => {
}
});
it("projects every typed failure without secrets", () => {
it("projects every typed failure without internal details", () => {
const secretCause = new Error("secret-token");
const byTag = {
AnalysisProtocolError: new AnalysisProtocolError("protocol failed"),
@@ -132,6 +132,7 @@ describe("analysis error projection", () => {
"fixture",
"overview",
"AnalysisCapabilityUnavailableError",
"main.js",
"declaredSha256",
])
expect(serialized).not.toContain(hidden);
@@ -151,12 +152,6 @@ 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(
+1 -1
View File
@@ -40,7 +40,7 @@ describe("process CLI errors", () => {
),
).rejects.toMatchObject({
code: 1,
stdout: expect.stringContaining("category: invalid_input"),
stdout: expect.stringContaining("PROCESS_COMMAND_FAILED"),
});
});