Compare 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
13 changed files with 87 additions and 273 deletions
-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.
+3 -5
View File
@@ -628,13 +628,13 @@ def _dispatch(method, params):
global _selected_document
if method == "health":
return {"name": "REA Hopper bridge", "version": "1.0.0", "run_id": REA_RUN_ID}
if method in ("shutdown", "shutdown_document"):
if method == "shutdown":
document = _session_document()
if document is None:
return {"shutdown": True, "analysis_stopped": True, "document_closed": True}
if document.backgroundProcessActive():
document.requestBackgroundProcessStop()
if method == "shutdown" and REA_OWNS_PROCESS_LIFETIME:
if REA_OWNS_PROCESS_LIFETIME:
return {
"shutdown": True,
"analysis_stopped": not document.backgroundProcessActive(),
@@ -813,9 +813,7 @@ def _serve_connection(connection):
if not isinstance(request["token"], str) or not hmac.compare_digest(request["token"], REA_TOKEN):
raise PermissionError("Invalid bridge capability")
result = _dispatch(request["method"], request["params"])
should_stop = request["method"] == "shutdown_document" or (
request["method"] == "shutdown" and not result.get("cleanup_required", False)
)
should_stop = request["method"] == "shutdown"
response = {"id": request_id, "result": _json_safe(result)}
except Exception as error:
response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512], "type": _diagnostic_type(error)}}
+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 {
+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),
),
),
});
};
+14 -12
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 = [
@@ -358,21 +359,22 @@ 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;
const details =
error instanceof ArtifactOperationError ? error.artifactDetails : undefined;
return {
category: errorCategory(error),
message: userMessage(error),
...(code === undefined ? {} : { code }),
...(details === undefined ? {} : { details }),
...(details === undefined
? {}
: {
details: {
logical_path: details.logicalPath,
declared_sha256: details.declaredSha256,
calculated_sha256: details.calculatedSha256,
unpacked: details.unpacked,
},
}),
};
};
+12 -41
View File
@@ -167,7 +167,6 @@ export class HopperClient {
this.#closing = true;
try {
const socket = this.#socket;
let cleanupAttempted = false;
if (socket !== undefined && !socket.destroyed) {
const shutdown = await this.#request(
"shutdown",
@@ -175,23 +174,12 @@ export class HopperClient {
{ timeoutMs: SHUTDOWN_TIMEOUT_MS },
).catch(() => err(new HopperProcessError(null)));
if (
shutdown.ok &&
isCleanupRequired(shutdown.value) &&
this.#launch?.shutdownByCleanup === true &&
this.#launch.cleanup !== undefined
) {
cleanupAttempted = true;
const cleanup = await this.#launch.cleanup();
if (!cleanup.cleaned) {
await this.#fallbackDocumentShutdown();
const retried = await this.#launch.cleanup();
if (!retried.cleaned)
this.#logger.warn(
{ reason: retried.reason },
"Owned launcher cleanup failed closed",
);
}
} else if (!shutdown.ok || !isShutdownAcknowledgement(shutdown.value))
!shutdown.ok ||
!isShutdownAcknowledgement(
shutdown.value,
this.#launch?.shutdownByCleanup === true,
)
)
this.#logger.warn(
{ status: shutdown.ok ? "invalid-acknowledgement" : "failed" },
"Hopper document shutdown was not confirmed",
@@ -201,7 +189,7 @@ export class HopperClient {
socket?.destroy();
this.#socket = undefined;
if (this.#launch?.ownsProcessLifetime === true) {
if (this.#launch.cleanup !== undefined && !cleanupAttempted) {
if (this.#launch.cleanup !== undefined) {
const cleanup = await this.#launch.cleanup();
if (!cleanup.cleaned)
this.#logger.warn(
@@ -223,19 +211,6 @@ export class HopperClient {
}
}
async #fallbackDocumentShutdown(): Promise<void> {
const fallback = await this.#request(
"shutdown_document",
{},
{ timeoutMs: SHUTDOWN_TIMEOUT_MS },
).catch(() => err(new HopperProcessError(null)));
if (!fallback.ok || !isShutdownAcknowledgement(fallback.value))
this.#logger.warn(
{ status: fallback.ok ? "invalid-acknowledgement" : "failed" },
"Hopper document shutdown fallback was not confirmed",
);
}
async #connect(
socketPath: string,
signal?: AbortSignal,
@@ -443,22 +418,18 @@ const connectOnce = async (
});
});
const isShutdownAcknowledgement = (value: JsonValue): boolean => {
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 false;
return ownsProcessLifetime && value.cleanup_required === true;
};
const isCleanupRequired = (value: JsonValue): boolean =>
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
value.shutdown === true &&
value.cleanup_required === true;
const parseServerInfo = (
value: JsonValue,
expectedRunId: string,
+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. */
+1 -6
View File
@@ -104,13 +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 method == "shutdown" and REA_OWNS_PROCESS_LIFETIME:',
);
expect(bridgeSource).toContain("if REA_OWNS_PROCESS_LIFETIME:");
expect(bridgeSource).toContain('"cleanup_required": True');
expect(bridgeSource).toContain(
'method in ("shutdown", "shutdown_document")',
);
expect(bridgeSource).toContain("document.waitForBackgroundProcessToEnd()");
expect(bridgeSource).toContain("document.closeDocument()");
expect(bridgeSource).toContain(
-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);
});
});
+3 -55
View File
@@ -90,7 +90,7 @@ 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-"));
@@ -102,69 +102,17 @@ describe("CLI output boundary", () => {
await writeFile(join(`${archive}.unpacked`, "main.js"), "changed();\n");
for (const flags of [["--json"], ["--full-output", "--json"]]) {
const execution = execFileAsync(process.execPath, [
const { stdout } = await 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);
});
});
+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"),
});
});