fix(hopper): preserve startup failures and recover denied launches

Preserve the primary Hopper startup failure and launcher diagnostics while retaining resources when cleanup cannot be confirmed. Reauthenticate matching external application sessions before document cleanup, keep the Python bridge available for bounded reconnects, and expose the provider-owned Hopper startup deadline.

Real headless Linux Hopper semantic analysis and 65 focused configuration, handshake, cleanup, and bridge tests passed. macOS authorization/crash causes and saved-HOP behavior require their own real-provider verification.

Refs #1808

Co-authored-by: N0zoM1z0 <[email protected]>
Co-authored-by: Kaoru0822 <[email protected]>
This commit is contained in:
morluto
2026-10-11 22:35:01 +08:00
committed by GitHub
co-authored by N0zoM1z0 Kaoru0822
parent ee5275cd9f
commit 069308bbe6
25 changed files with 1074 additions and 166 deletions
+2 -2
View File
@@ -181,8 +181,8 @@ Native formats and host support vary by provider. See
[IDA guide](docs/ida-provider.md), and
[experimental Windows Ghidra support](docs/windows-ghidra-p0.md).
Ghidra also supports [16-bit DOS analysis](docs/ghidra-dos.md).
For large binaries, raise its startup deadline with
`REA_GHIDRA_STARTUP_TIMEOUT_MS`.
For large binaries, raise the provider's startup deadline with
`REA_GHIDRA_STARTUP_TIMEOUT_MS` or `REA_HOPPER_STARTUP_TIMEOUT_MS`.
For provider selection, see the [CLI guide](docs/cli.md#choose-a-provider).
Check [release availability](docs/installation.md#released-package-and-main)
for features added since the latest npm release.
+89 -66
View File
@@ -1016,73 +1016,87 @@ def _dispatch(method, params):
def _serve_connection(connection):
"""Serve one capability-authenticated NDJSON connection."""
file = connection.makefile("rwb")
while True:
line = file.readline()
if not line:
break
request_id = None
authenticated = False
should_stop = False
try:
should_stop = False
try:
while True:
line = file.readline()
if not line:
break
request_id = None
authenticated = False
should_stop = False
try:
request = json.loads(line.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise InvalidRequestError("Invalid bridge request JSON") from error
if not isinstance(request, dict) or set(request) != {"id", "token", "method", "params"}:
raise InvalidRequestError("Invalid bridge request shape")
request_id = request["id"]
if isinstance(request_id, bool) or not isinstance(request_id, int) or request_id < 0:
raise InvalidRequestError("Invalid bridge request id")
if (
not isinstance(request["method"], str) or not request["method"]
or not isinstance(request["params"], dict)
):
raise InvalidRequestError("Invalid bridge method or parameters")
if (
not isinstance(request["token"], str) or not request["token"].isascii()
or not hmac.compare_digest(request["token"], REA_TOKEN)
):
raise PermissionError("Invalid bridge capability")
authenticated = True
_write_message(file, {"id": request_id, "event": {
"type": "progress",
"phase": "hopper_bridge",
"completed": 0,
"total": 1,
"message": "Hopper bridge started request",
}})
result = _dispatch(request["method"], request["params"])
should_stop = request["method"] == "shutdown_document" or (
request["method"] == "shutdown" and not result.get("cleanup_required", False)
)
safe_result = _json_safe(result)
_write_message(file, {"id": request_id, "event": {
"type": "progress",
"phase": "hopper_bridge",
"completed": 1,
"total": 1,
"message": "Hopper bridge completed request",
"terminal": True,
}})
response = {"id": request_id, "result": safe_result}
except Exception as error:
diagnostic = _safe_diagnostic(error)
if authenticated:
try:
request = json.loads(line.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise InvalidRequestError("Invalid bridge request JSON") from error
if not isinstance(request, dict) or set(request) != {"id", "token", "method", "params"}:
raise InvalidRequestError("Invalid bridge request shape")
request_id = request["id"]
if isinstance(request_id, bool) or not isinstance(request_id, int) or request_id < 0:
raise InvalidRequestError("Invalid bridge request id")
if (
not isinstance(request["method"], str) or not request["method"]
or not isinstance(request["params"], dict)
):
raise InvalidRequestError("Invalid bridge method or parameters")
if (
not isinstance(request["token"], str) or not request["token"].isascii()
or not hmac.compare_digest(request["token"], REA_TOKEN)
):
raise PermissionError("Invalid bridge capability")
authenticated = True
_write_message(file, {"id": request_id, "event": {
"type": "diagnostic",
"error": diagnostic,
"type": "progress",
"phase": "hopper_bridge",
"completed": 0,
"total": 1,
"message": "Hopper bridge started request",
}})
response_id = request_id if (
isinstance(request_id, int)
and not isinstance(request_id, bool)
and request_id >= 0
) else 0
response = {"id": response_id, "error": diagnostic}
_write_message(file, response)
if should_stop:
break
file.close()
connection.close()
result = _dispatch(request["method"], request["params"])
should_stop = request["method"] in ("shutdown", "shutdown_document") and (
result.get("shutdown") is True and (
(result.get("document_closed") is True and result.get("analysis_stopped") is True)
or (result.get("document_closed") is False and result.get("document_retained") is True)
)
)
safe_result = _json_safe(result)
_write_message(file, {"id": request_id, "event": {
"type": "progress",
"phase": "hopper_bridge",
"completed": 1,
"total": 1,
"message": "Hopper bridge completed request",
"terminal": True,
}})
response = {"id": request_id, "result": safe_result}
except Exception as error:
diagnostic = _safe_diagnostic(error)
if authenticated:
_write_message(file, {"id": request_id, "event": {
"type": "diagnostic",
"error": diagnostic,
}})
response_id = request_id if (
isinstance(request_id, int)
and not isinstance(request_id, bool)
and request_id >= 0
) else 0
response = {"id": response_id, "error": diagnostic}
_write_message(file, response)
if should_stop:
break
except (ConnectionError, OSError):
# A lost client cannot confirm document closure or revoke this run.
pass
finally:
try:
file.close()
except OSError:
# Closing a failed writer can repeat the same disconnected-pipe error.
pass
connection.close()
return should_stop
def _write_message(file, message):
@@ -1124,8 +1138,17 @@ def _run():
os.chmod(REA_SOCKET, 0o600)
server.listen(1)
try:
connection, _ = server.accept()
_serve_connection(connection)
while True:
try:
connection, _ = server.accept()
except socket.timeout:
break
connection.settimeout(None)
if _serve_connection(connection):
break
# Match REA's cleanup handshake budget without pinning Hopper's
# Python thread indefinitely after a client disappears.
server.settimeout(REA_RECONNECT_TIMEOUT_SECONDS)
finally:
server.close()
if os.path.exists(REA_SOCKET):
+19
View File
@@ -531,6 +531,25 @@ export HOPPER_LAUNCHER_PATH=/absolute/path/to/Hopper
rea doctor --provider hopper --json
```
Hopper startup allows 120,000 ms for launch, initial analysis, and authenticated
bridge readiness. For large targets, set `REA_HOPPER_STARTUP_TIMEOUT_MS` to a
decimal integer from 1 to 2,147,483,647 milliseconds in the environment that
starts REA. CLI and MCP use the same setting; invalid values fail configuration
validation. An MCP client's own request timeout must also allow enough time.
If macOS denies Apple Events, allow the application that starts REA under
**System Settings → Privacy & Security → Automation**, then retry. REA releases
the target reservation when the native helper reports its complete authorization
rejection and its arguments cannot alter the helper's generated AppleScript.
Arguments containing quotes, backslashes, or line breaks remain unchanged but
cannot establish that a failed handoff opened no document. Other failed handoffs
retain ownership until document closure is confirmed. If a timed-out bridge
becomes ready later, `close_binary` connects to that run, authenticates its
identity, and requests document shutdown. After a connection drops, the bridge
allows 30 seconds for an authenticated cleanup reconnect, then releases Hopper's
Python execution thread. Expiry does not establish document closure, so REA
retains unconfirmed ownership. Retry close while the bridge remains available.
If a Linux launcher exists but cannot start, inspect its shared libraries:
```bash
+1
View File
@@ -204,6 +204,7 @@ These deadlines have different owners:
| Deadline | Owner and effect |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MCP initialize | The client bounds transport/REA connection startup, before any target query. |
| Hopper startup | REA allows 120,000 ms by default; `REA_HOPPER_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid values fail configuration validation. Startup failure is reported by the first provider query. |
| Ghidra startup | REA allows 330,000 ms by default; `REA_GHIDRA_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid supplied values fail configuration validation. Startup failure is reported by the first provider query. |
| Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. |
+35
View File
@@ -10,6 +10,7 @@ describe("runtime configuration", () => {
if (empty.ok) {
expect(empty.value.hopperTargetPath).toBeUndefined();
expect(empty.value.analysisProvider).toBe("auto");
expect(empty.value.hopperStartupTimeoutMs).toBe(120_000);
}
const result = parseConfig({ HOPPER_TARGET_PATH: "/usr/bin/true" });
expect(result.ok).toBe(true);
@@ -170,6 +171,40 @@ describe("runtime collection configuration", () => {
});
describe("configuration failure diagnostics", () => {
it.each(["1", "900000", "2147483647"])(
"accepts Hopper startup deadline %s without changing Ghidra's deadline",
(value) => {
expect(
parseConfig({ REA_HOPPER_STARTUP_TIMEOUT_MS: value }),
).toMatchObject({
ok: true,
value: {
hopperStartupTimeoutMs: Number(value),
ghidraStartupTimeoutMs: 330_000,
},
});
},
);
it.each(["", "0", "-1", "1.5", "1e3", "invalid", "2147483648"])(
"rejects malformed Hopper startup deadline %j with its setting",
(value) => {
const result = parseConfig({ REA_HOPPER_STARTUP_TIMEOUT_MS: value });
if (result.ok) throw new Error("Expected invalid startup deadline");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "configuration_invalid",
details: {
settings: [
{
setting: "REA_HOPPER_STARTUP_TIMEOUT_MS",
constraint: expect.any(String),
},
],
},
});
},
);
it.each([
"",
" ",
+11 -2
View File
@@ -8,7 +8,11 @@ import {
ghidraCompilerSpecIdSchema,
ghidraLanguageIdSchema,
} from "./ghidraLanguageOverride.js";
import { ghidraStartupTimeoutSchema } from "./ghidraStartupTimeout.js";
import {
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
providerStartupTimeoutSchema,
} from "./providerStartupTimeout.js";
import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js";
import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js";
@@ -36,7 +40,12 @@ const environmentSchema = z.object({
.min(1)
.refine(isAbsolute, "REA_GHIDRA_NATIVEAOT_JAR must be absolute")
.optional(),
REA_GHIDRA_STARTUP_TIMEOUT_MS: ghidraStartupTimeoutSchema,
REA_GHIDRA_STARTUP_TIMEOUT_MS: providerStartupTimeoutSchema.default(
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
),
REA_HOPPER_STARTUP_TIMEOUT_MS: providerStartupTimeoutSchema.default(
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
),
REA_GHIDRA_LANGUAGE_ID: ghidraLanguageIdSchema.optional(),
REA_GHIDRA_COMPILER_SPEC_ID: ghidraCompilerSpecIdSchema.optional(),
REA_GHIDRA_SEED_FILE: z
+1
View File
@@ -98,6 +98,7 @@ export const parseConfig = (
process.platform,
homeDirectoryFromEnvironment(environment, process.platform),
),
hopperStartupTimeoutMs: env.REA_HOPPER_STARTUP_TIMEOUT_MS,
hopperTargetPath: env.HOPPER_TARGET_PATH,
hopperTargetKind: env.HOPPER_TARGET_KIND,
hopperLoaderArgs: loaderArgs.value,
@@ -3,13 +3,15 @@ import { z } from "zod";
/** Default absolute deadline for Ghidra import, analysis, bridge, and readiness. */
export const DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS = 330_000;
/** Default absolute deadline for Hopper launch, analysis, bridge, and readiness. */
export const DEFAULT_HOPPER_STARTUP_TIMEOUT_MS = 120_000;
// Node schedules delays above this signed 32-bit boundary as a 1 ms timer.
const MAX_TIMER_DELAY_MS = 2_147_483_647;
/** Parse the optional setting without reading the ambient process environment. */
export const ghidraStartupTimeoutSchema = z
export const providerStartupTimeoutSchema = z
.string()
.regex(/^[1-9]\d*$/u, "must be a positive decimal integer")
.transform(Number)
.pipe(z.number().int().min(1).max(MAX_TIMER_DELAY_MS))
.default(DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS);
.pipe(z.number().int().min(1).max(MAX_TIMER_DELAY_MS));
+1
View File
@@ -18,6 +18,7 @@ export interface AppConfig {
readonly ghidraNativeAotJar?: string;
readonly ilspyCmdPath: string | undefined;
readonly hopperLauncherPath: string;
readonly hopperStartupTimeoutMs: number;
readonly hopperTargetPath: string | undefined;
readonly hopperTargetKind: "executable" | "database";
readonly hopperLoaderArgs: readonly string[];
+1 -1
View File
@@ -16,7 +16,7 @@ import {
type ProviderProcessSnapshot,
ProviderProcessSupervisor,
} from "../process/ProviderProcess.js";
import { DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS } from "../config/ghidraStartupTimeout.js";
import { DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS } from "../config/providerStartupTimeout.js";
import type {
GhidraClientOptions,
GhidraRequestOptions,
+13
View File
@@ -32,6 +32,7 @@ import {
acquireLinuxHopperApplicationLease,
} from "./HopperTargetLease.js";
import type { HopperTargetLease } from "./HopperTargetLease.js";
import { HOPPER_SHUTDOWN_TIMEOUT_MS } from "./HopperSessionValues.js";
const execFileAsync = promisify(execFile);
const HOPPER_BACKGROUND_STARTUP_MS = 5_000;
@@ -46,6 +47,8 @@ export interface BridgeSession {
/** Process handle returned by a bridge launcher. */
export type BridgeLaunch = {
/** Only literal native-helper arguments support a pre-handoff denial conclusion. */
readonly authorizationDenialBeforeHandoff?: true;
/** Release application/target exclusivity only after verified document and process cleanup. */
readonly releaseLease?: () => Promise<void>;
/** Exact launch command retained in local ownership metadata. */
@@ -254,6 +257,15 @@ export class HopperApplicationLauncher implements BridgeLauncher {
...ownedLauncher,
providerLifetime: "external-application",
shutdownMode: "bridge-request",
// The vendor helper interpolates these values into AppleScript
// literals without escaping. Preserve them, but withhold the
// pre-handoff conclusion when they can alter that script.
...((this.dependencies.platform ?? process.platform) === "darwin" &&
![this.options.launcherPath, ...argumentsForTarget].some((value) =>
/["\\\r\n]/u.test(value),
)
? { authorizationDenialBeforeHandoff: true as const }
: {}),
});
} catch (cause: unknown) {
return err(hopperLaunchFailure(cause, signal));
@@ -370,6 +382,7 @@ const bridgeBootstrapSource = (
`REA_RUN_ID = ${JSON.stringify(session.runId)}`,
`REA_TARGET_PATH = ${JSON.stringify(loadedPath)}`,
`REA_OWNS_PROCESS_LIFETIME = ${ownsProcessLifetime ? "True" : "False"}`,
`REA_RECONNECT_TIMEOUT_SECONDS = ${HOPPER_SHUTDOWN_TIMEOUT_MS / 1_000}`,
`exec(compile(open(${JSON.stringify(options.bridgeScriptPath)}, 'rb').read(), ${JSON.stringify(options.bridgeScriptPath)}, 'exec'))`,
"",
].join("\n");
+104 -35
View File
@@ -50,10 +50,15 @@ import {
} from "./HopperCleanup.js";
import {
parseHopperServerInfo,
HOPPER_SHUTDOWN_TIMEOUT_MS,
type HopperServerInfo,
} from "./HopperSessionValues.js";
import { connectHopperSocketOnce } from "./HopperSocketConnection.js";
import { hopperLauncherFailureDiagnostic } from "./HopperProcessDiagnostic.js";
import {
hopperLauncherFailureDiagnostic,
nativeHopperLaunchWasDenied,
} from "./HopperProcessDiagnostic.js";
import { DEFAULT_HOPPER_STARTUP_TIMEOUT_MS } from "../config/providerStartupTimeout.js";
import {
HopperRequestQueue,
type HopperRequestActivity,
@@ -68,7 +73,6 @@ import {
/** Aggregate in-memory retention budget for this provider's launcher output. */
export const HOPPER_PROCESS_DIAGNOSTIC_BYTES = 8 * 1024 * 1024;
const SHUTDOWN_TIMEOUT_MS = 30_000;
const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir();
/** Dependencies, deadlines, and redacted diagnostics for one bridge client. */
@@ -114,6 +118,8 @@ export class HopperClient {
#launcherFailureDiagnostic: HopperStartupFailureDiagnostic | undefined;
#operationFailure: OperationFailureRecord | undefined;
#startupController: AbortController | undefined;
#startupFailureResult: AnalysisError | undefined;
#runId: string | undefined;
#startPromise: Promise<Result<HopperServerInfo, AnalysisError>> | undefined;
#closePromise: Promise<Result<null, AnalysisError>> | undefined;
readonly #onSocketData = (chunk: string): void => {
@@ -169,7 +175,8 @@ export class HopperClient {
this.#logger = options.logger ?? silentLogger;
this.#options = {
...options,
startupTimeoutMs: options.startupTimeoutMs ?? 120_000,
startupTimeoutMs:
options.startupTimeoutMs ?? DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
};
this.#requests = new HopperRequestQueue(
({ id, method, params }, failed) => {
@@ -311,14 +318,18 @@ export class HopperClient {
signal?: AbortSignal,
): Promise<Result<HopperServerInfo, AnalysisError>> {
if (isAborted(signal)) return err(new HopperCancelledError());
this.#operationFailure = undefined;
if (
this.#socket !== undefined ||
this.#resources.launch !== undefined ||
this.#resources.runtimeRoot !== undefined
) {
return err(new HopperProtocolError("Hopper client is already started"));
return err(
this.#startupFailureResult ??
new HopperProtocolError("Hopper client is already started"),
);
}
this.#operationFailure = undefined;
this.#startupFailureResult = undefined;
this.#resources.shutdownConfirmed = false;
const deadline = new ProviderStartupDeadline(
this.#options.startupTimeoutMs,
@@ -343,7 +354,7 @@ export class HopperClient {
return err(new HopperStartError({ cause }));
}
if (deadline.signal.aborted) {
return this.#startupFailure(startupInterruption(deadline));
return this.#startupFailure(await this.#startupInterruption(deadline));
}
const socketPath = join(this.#resources.runtimeRoot.path, "bridge.sock");
this.#token = randomBytes(32).toString("hex");
@@ -351,6 +362,7 @@ export class HopperClient {
this.#launcherExitCode = undefined;
this.#launcherFailureDiagnostic = undefined;
const runId = this.#options.runId ?? randomUUID();
this.#runId = runId;
const launched: Result<
BridgeLaunch,
HopperStartError | HopperCancelledError | HopperProcessError
@@ -368,7 +380,7 @@ export class HopperClient {
if (!launched.ok) {
return this.#startupFailure(
deadline.signal.aborted
? startupInterruption(deadline)
? await this.#startupInterruption(deadline)
: launched.error,
);
}
@@ -399,9 +411,7 @@ export class HopperClient {
}
const remainingMs = deadline.remainingMs();
if (remainingMs <= 0) {
return this.#startupFailure(
new HopperTimeoutError(this.#options.startupTimeoutMs),
);
return this.#startupFailure(await this.#startupInterruption(deadline));
}
const health = await this.#request(
"health",
@@ -413,7 +423,9 @@ export class HopperClient {
);
if (!health.ok) {
return this.#startupFailure(
deadline.signal.aborted ? startupInterruption(deadline) : health.error,
deadline.signal.aborted
? await this.#startupInterruption(deadline)
: health.error,
);
}
const parsed = parseHopperServerInfo(health.value, runId);
@@ -426,22 +438,20 @@ export class HopperClient {
primary: AnalysisError,
): Promise<Result<never, AnalysisError>> {
this.#rememberStartupFailure(primary);
// Failed readiness never authorized this connection to mutate a document.
this.#releaseTransport();
const closed = await this.#cleanup();
return err(
closed.ok
? primary
: analysisErrorWithCleanupFailure(primary, closed.error, "open_binary"),
);
const failure = closed.ok
? primary
: analysisErrorWithCleanupFailure(primary, closed.error, "open_binary");
this.#startupFailureResult = failure;
return err(failure);
}
#rememberStartupFailure(failure: AnalysisError): void {
if (failure instanceof HopperProcessError)
this.#rememberProcessFailure(failure, []);
else if (
this.#operationFailure === undefined &&
(failure instanceof HopperTimeoutError ||
failure instanceof HopperStartError)
)
else if (this.#operationFailure === undefined)
this.#operationFailure = {
state:
failure instanceof HopperTimeoutError ? "not_started" : "unknown",
@@ -500,6 +510,7 @@ export class HopperClient {
// best-effort cleanup: a rejected startup must not mask close/cleanup;
// #cleanup reports the authoritative close result.
await starting?.catch(() => undefined);
await this.#reconnectForCleanup();
return await this.#cleanup(options);
} finally {
if (this.#startPromise === starting) this.#startPromise = undefined;
@@ -510,6 +521,51 @@ export class HopperClient {
}
}
/** Reauthenticate this run after late readiness or a lost transport. */
async #reconnectForCleanup(): Promise<void> {
if (this.#socket?.destroyed === true) this.#releaseTransport();
const root = this.#resources.runtimeRoot;
const runId = this.#runId;
if (
this.#socket !== undefined ||
root === undefined ||
runId === undefined ||
this.#token === undefined ||
this.#resources.shutdownConfirmed ||
this.#resources.launch?.providerLifetime !== "external-application"
)
return;
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
HOPPER_SHUTDOWN_TIMEOUT_MS,
);
this.#closing = true;
try {
const connected = await connectHopperSocketOnce(
join(root.path, "bridge.sock"),
controller.signal,
);
if (!connected.ok) return;
const socket = connected.value;
this.#socket = socket;
this.#attachSocket(socket);
const health = await this.#request(
"health",
{},
{
signal: controller.signal,
timeoutMs: HOPPER_SHUTDOWN_TIMEOUT_MS,
},
);
if (health.ok && parseHopperServerInfo(health.value, runId).ok) return;
this.#releaseTransport();
} finally {
this.#closing = false;
clearTimeout(timer);
}
}
async #cleanup(
options: HopperClientCloseOptions = {},
): Promise<Result<null, AnalysisError>> {
@@ -523,18 +579,18 @@ export class HopperClient {
logger: this.#logger,
onDiagnostic: this.#options.onDiagnostic,
request: (method) =>
this.#request(method, {}, { timeoutMs: SHUTDOWN_TIMEOUT_MS }),
this.#request(method, {}, { timeoutMs: HOPPER_SHUTDOWN_TIMEOUT_MS }),
retainDocument: options.retainDocument === true,
releaseTransport: (socket) => {
this.#failAll(new HopperProcessError(null));
if (socket !== undefined) this.#detachSocket(socket);
socket?.destroy();
},
releaseTransport: () => this.#releaseTransport(),
});
} finally {
if (this.#socket?.destroyed === true) this.#socket = undefined;
if (this.#socket === undefined) this.#token = undefined;
// Retained external launches may publish their authenticated socket later.
if (this.#resources.runtimeRoot === undefined) {
this.#token = undefined;
this.#runId = undefined;
}
if (this.#socket === undefined) this.#responses.reset();
if (this.#resources.processSupervisor === undefined) {
this.#launcherExitCode = undefined;
@@ -662,6 +718,17 @@ export class HopperClient {
"launch",
launcherFailure,
);
if (
this.#resources.launch?.authorizationDenialBeforeHandoff === true &&
nativeHopperLaunchWasDenied(launcherFailure)
) {
this.#resources.shutdownConfirmed = true;
return new HopperStartError({
userMessage:
"macOS denied permission to send Apple Events to Hopper. Allow the application that starts REA under System Settings > Privacy & Security > Automation, then retry opening the target.",
launcherFailure,
});
}
return new HopperStartError({
userMessage:
"Hopper's launcher helper failed before bridge readiness. Review details.launcher and complete Hopper setup before retrying.",
@@ -757,6 +824,15 @@ export class HopperClient {
socket.off("close", this.#onSocketClose);
}
#releaseTransport(): void {
const socket = this.#socket;
this.#socket = undefined;
this.#failAll(new HopperProcessError(null));
if (socket !== undefined) this.#detachSocket(socket);
socket?.destroy();
this.#responses.reset();
}
#attachLauncher(launch: BridgeLaunch): void {
this.#resources.processSupervisor = new ProviderProcessSupervisor(launch, {
maxDiagnosticBytes: HOPPER_PROCESS_DIAGNOSTIC_BYTES,
@@ -927,10 +1003,3 @@ const mergeFailedRequests = (
};
const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true;
const startupInterruption = (
deadline: ProviderStartupDeadline,
): HopperCancelledError | HopperTimeoutError =>
deadline.interruption === "cancelled"
? new HopperCancelledError()
: new HopperTimeoutError(deadline.timeoutMs);
@@ -0,0 +1,54 @@
import { expect, it } from "vitest";
import type { HopperLauncherOutcome } from "../domain/hopperErrors.js";
import { nativeHopperLaunchWasDenied } from "./HopperProcessDiagnostic.js";
// Captured from Hopper 6.1.0-demo and 6.6.0-demo's unmodified helpers with native
// AppleScript error -1743 before open dispatch. The target script never ran.
const denied: HopperLauncherOutcome = {
exit_code: 2,
signal: null,
stdout: { text: "", bytes: 0, retained_bytes: 0 },
stderr: {
text: "Cannot launch Hopper!\nNot authorized to send Apple events to current application.\n",
bytes: 82,
retained_bytes: 82,
},
output_closed: true,
diagnostic_truncated: false,
};
it("recognizes the native helper's complete authorization rejection", () => {
expect(nativeHopperLaunchWasDenied(denied)).toBe(true);
});
it.each([
{ output_closed: false },
{ diagnostic_truncated: true },
{ signal: "SIGTERM", exit_code: null },
{ signal: "SIGTERM", exit_code: 2 },
{ exit_code: 0 },
{
stderr: {
...denied.stderr,
text: "Cannot launch Hopper!\nExpected end of line, etc. but found identifier.\n",
},
},
{
stderr: {
...denied.stderr,
text: `${denied.stderr.text}another failure\n`,
},
},
{
stderr: {
...denied.stderr,
text: "Not authorized to send Apple events to current application.\n",
},
},
])(
"preserves unknown document ownership for incomplete or different failures: %j",
(change) => {
expect(nativeHopperLaunchWasDenied({ ...denied, ...change })).toBe(false);
},
);
+19
View File
@@ -4,6 +4,25 @@ import {
} from "../domain/hopperStartupFailure.js";
import type { ProviderProcessDiagnostic } from "../process/ProviderProcess.js";
import { parseLinuxPrivateDisplayDiagnostic } from "./LinuxPrivateDisplayDiagnostic.js";
import type { HopperLauncherOutcome } from "../domain/hopperErrors.js";
/**
* Hopper's native helper sends activate before its open-document Apple event.
* Recognize its explicit authorization rejection. The launcher must separately
* establish that caller arguments could not alter the generated AppleScript.
* Other script errors, signals, and incomplete output leave ownership unknown.
*/
export const nativeHopperLaunchWasDenied = (
outcome: HopperLauncherOutcome | undefined,
): outcome is HopperLauncherOutcome =>
outcome !== undefined &&
outcome.exit_code === 2 &&
outcome.signal === null &&
outcome.output_closed &&
!outcome.diagnostic_truncated &&
/^Cannot launch Hopper!\r?\nNot authorized to send Apple events to [^\r\n]+\.\r?\n?$/u.test(
outcome.stderr.text,
);
/** Extract only a matching Linux adapter failure record. */
export const hopperLauncherFailureDiagnostic = (
+1
View File
@@ -237,6 +237,7 @@ export class HopperProvider implements AnalysisProviderCandidate {
: { launchMode: "native" as const }),
}),
...(context === undefined ? {} : { runId: context.runId }),
startupTimeoutMs: this.config.hopperStartupTimeoutMs,
logger: this.logger.child({ layer: "bridge" }),
onDiagnostic: (diagnostic) =>
this.logger.info(diagnostic, "Hopper bridge reported a diagnostic"),
+3
View File
@@ -2,6 +2,9 @@ import { HopperProtocolError } from "../domain/hopperErrors.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
/** Bound cleanup handshakes and the bridge's idle reconnection wait. */
export const HOPPER_SHUTDOWN_TIMEOUT_MS = 30_000;
/** Authenticated identity returned by the Hopper bridge health handshake. */
export interface HopperServerInfo {
readonly name: string;
@@ -0,0 +1,304 @@
import { spawn, type ChildProcess } from "node:child_process";
import { access, copyFile, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { expect, onTestFinished, vi } from "vitest";
import { z } from "zod";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js";
const statusSchema = z.object({ result: z.object({ open: z.boolean() }) });
const test = workspaceCliTest.skipIf(process.platform !== "darwin");
test("retains a denial-shaped failure when caller arguments can alter the helper's AppleScript", async ({
workspace,
}) => {
for (const source of ["target", "loader"] as const) {
const target = workspace.path(
source === "target" ? 'quoted"target' : "loader-target",
);
await copyFile("/usr/bin/true", target);
const mode = await workspace.write(
`${source}-mode`,
"denied-after-handoff",
);
const receipt = workspace.path(`${source}-bootstrap`);
const loaderArgs = [
resolve("tests/fixtures/hopperLaunchAdmission.mjs"),
mode,
receipt,
...(source === "loader" ? ['caller"loader'] : []),
];
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("dist/main.js")],
stderr: "pipe",
env: {
...workspace.environment,
PATH: process.env.PATH ?? "",
REA_ANALYSIS_PROVIDER: "hopper",
HOPPER_LAUNCHER_PATH: process.execPath,
HOPPER_LOADER_ARGS_JSON: JSON.stringify(loaderArgs),
REA_LOG_LEVEL: "silent",
},
});
const mcp = new Client({ name: "hopper-literal-boundary", version: "1" });
let runtime: string | undefined;
onTestFinished(async () => {
await mcp.close();
await transport.close();
// The fixture created only this marker, never a native GUI document.
if (runtime !== undefined)
await rm(runtime, { recursive: true, force: true });
const acquired = await acquireHopperTargetLease({
targetPath: target,
targetKind: "executable",
loaderArgs,
runId: "literal-boundary-test-cleanup",
});
if (!acquired.acquired) throw new Error("Fixture lease remained live");
await acquired.lease.release();
});
await mcp.connect(transport);
const call = (name: string, arguments_: Record<string, string> = {}) =>
mcp.callTool({ name, arguments: arguments_ });
expect((await call("open_binary", { path: target })).isError).not.toBe(
true,
);
const first = parseMcpToolError(await call("current_document"));
runtime = dirname(await readFile(receipt, "utf8"));
expect(first.error).toMatchObject({
code: "cleanup_incomplete",
details: {
diagnostics: {
primary_error: {
code: "provider_unavailable",
details: {
launcher: {
exit_code: 2,
stderr: {
text: expect.stringContaining(
"Not authorized to send Apple events",
),
},
},
},
},
},
},
});
await access(`${receipt}.document-created`);
await access(runtime);
expect(parseMcpToolError(await call("list_procedures"))).toEqual(first);
expect(parseMcpToolError(await call("close_binary")).error.code).toBe(
"cleanup_incomplete",
);
}
});
test("recovers a denied Hopper launch through the same stdio MCP session and CLI", async ({
workspace,
cli,
}) => {
const target = workspace.path("target");
await copyFile("/usr/bin/true", target);
const mode = await workspace.write("mode", "denied");
const receipt = workspace.path("bootstrap-path");
const environment = {
...workspace.environment,
PATH: process.env.PATH ?? "",
REA_ANALYSIS_PROVIDER: "hopper",
HOPPER_LAUNCHER_PATH: process.execPath,
HOPPER_LOADER_ARGS_JSON: JSON.stringify([
resolve("tests/fixtures/hopperLaunchAdmission.mjs"),
mode,
receipt,
]),
REA_LOG_LEVEL: "silent",
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("dist/main.js")],
env: environment,
stderr: "pipe",
});
const mcp = new Client({ name: "hopper-startup-recovery", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
onTestFinished(async () => {
await mcp.close();
await transport.close();
});
await mcp.connect(transport);
const call = (name: string, arguments_: Record<string, string> = {}) =>
mcp.callTool({ name, arguments: arguments_ }).catch((cause: unknown) => {
throw new Error(`${name}: ${stderr}`, { cause });
});
expect((await call("open_binary", { path: target })).isError).not.toBe(true);
for (const operation of ["current_document", "list_procedures"]) {
const { error } = parseMcpToolError(await call(operation));
expect(error).toMatchObject({
code: "provider_unavailable",
details: {
stage: "launch",
launcher: {
exit_code: 2,
stderr: {
text: expect.stringContaining(
"Not authorized to send Apple events",
),
},
},
},
});
expect(error.message).toContain("Automation");
const bootstrap = await readFile(receipt, "utf8");
await expect(access(dirname(bootstrap))).rejects.toMatchObject({
code: "ENOENT",
});
}
expect((await call("close_binary")).isError).not.toBe(true);
const deniedCli = await cli.run({
arguments: ["analyze", target, "--provider", "hopper", "--json"],
environment,
timeoutMs: 15_000,
});
expect(deniedCli.exitCode).toBe(1);
expect(deniedCli.json).toMatchObject({
code: "provider_unavailable",
details: { launcher: { exit_code: 2 } },
});
await writeFile(mode, "bridge");
expect((await call("open_binary", { path: target })).isError).not.toBe(true);
expect((await call("current_document")).isError).not.toBe(true);
expect((await call("close_binary")).isError).not.toBe(true);
expect(
statusSchema.parse((await call("binary_session")).structuredContent).result
.open,
).toBe(false);
});
test("preserves a timed-out handoff and closes only a late bridge with matching run identity", async ({
workspace,
}) => {
const target = workspace.path("target");
await copyFile("/usr/bin/true", target);
const mode = await workspace.write("mode", "handoff");
const receipt = workspace.path("bootstrap-path");
const transport = new StdioClientTransport({
command: process.execPath,
args: [resolve("dist/main.js")],
stderr: "pipe",
env: {
...workspace.environment,
PATH: process.env.PATH ?? "",
REA_ANALYSIS_PROVIDER: "hopper",
HOPPER_LAUNCHER_PATH: process.execPath,
HOPPER_LOADER_ARGS_JSON: JSON.stringify([
resolve("tests/fixtures/hopperLaunchAdmission.mjs"),
mode,
receipt,
]),
REA_HOPPER_STARTUP_TIMEOUT_MS: "2000",
REA_LOG_LEVEL: "silent",
},
});
const mcp = new Client({ name: "hopper-late-readiness", version: "1" });
const children: ChildProcess[] = [];
let runtime: string | undefined;
const stop = async (child: ChildProcess) => {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = new Promise<void>((resolveExit) =>
child.once("exit", () => resolveExit()),
);
child.kill("SIGKILL");
await exited;
};
onTestFinished(async () => {
await Promise.all(children.map(stop));
await mcp.close();
await transport.close();
if (runtime !== undefined)
await rm(runtime, { recursive: true, force: true });
});
await mcp.connect(transport);
const call = (name: string, arguments_: Record<string, string> = {}) =>
mcp.callTool({ name, arguments: arguments_ });
expect((await call("open_binary", { path: target })).isError).not.toBe(true);
const initial = parseMcpToolError(await call("current_document"));
expect(initial.error).toMatchObject({
code: "cleanup_incomplete",
details: {
diagnostics: {
primary_error: {
code: "provider_timeout",
details: { timeout_ms: 2000, launcher: { exit_code: 0 } },
},
},
},
});
expect(parseMcpToolError(await call("list_procedures"))).toEqual(initial);
const status = z.object({
result: z.object({
provider_operation_health: z.object({
state: z.literal("not_started"),
stage: z.literal("launch"),
}),
}),
});
status.parse((await call("binary_session")).structuredContent);
expect(parseMcpToolError(await call("close_binary")).error.code).toBe(
"cleanup_incomplete",
);
const bootstrap = await readFile(await readFile(receipt, "utf8"), "utf8");
const setting = (name: string): string => {
const line = bootstrap
.split("\n")
.find((value) => value.startsWith(`${name} = `));
if (line === undefined)
throw new Error(`Missing bootstrap setting ${name}`);
return z.string().parse(JSON.parse(line.slice(name.length + 3)));
};
const socket = setting("REA_SOCKET");
const token = setting("REA_TOKEN");
const runId = setting("REA_RUN_ID");
runtime = dirname(socket);
const lateBridge = async (identity: string, credential = token) => {
const child = spawn(
process.execPath,
[resolve("tests/fixtures/fakeHopper.mjs"), socket, credential, identity],
{ stdio: "ignore" },
);
children.push(child);
await vi.waitFor(() => access(socket));
return child;
};
for (const [identity, credential] of [
["different-run", token],
[runId, "different-token"],
] as const) {
const wrong = await lateBridge(identity, credential);
expect(parseMcpToolError(await call("close_binary")).error.code).toBe(
"cleanup_incomplete",
);
expect(wrong.exitCode).toBeNull();
await access(runtime);
await stop(wrong);
await rm(socket, { force: true });
}
const owned = await lateBridge(runId);
expect((await call("close_binary")).isError).not.toBe(true);
await vi.waitFor(() => expect(owned.exitCode).toBe(0));
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
await writeFile(mode, "bridge");
expect((await call("open_binary", { path: target })).isError).not.toBe(true);
expect((await call("current_document")).isError).not.toBe(true);
expect((await call("close_binary")).isError).not.toBe(true);
});
@@ -1,16 +1,14 @@
import { chmod, copyFile, mkdir, readdir, rm, rmdir } from "node:fs/promises";
import { createConnection } from "node:net";
import { join } from "node:path";
import { chmod, copyFile, rm } from "node:fs/promises";
import { describe, expect, onTestFinished } from "vitest";
import { z } from "zod";
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
import { HOPPER_TARGET_LEASE_DIRECTORY } from "../../../src/hopper/HopperTargetLease.js";
import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js";
const FAILED_LAUNCHER = `#!/bin/sh
printf 'Cannot launch Hopper!\\nNot authorized to send Apple events to Hopper Disassembler.\\n' >&2
printf 'Cannot launch Hopper!\\nDocument state is unknown.\\n' >&2
exit 2
`;
@@ -27,41 +25,6 @@ const RetainedRuntime = z.object({
}),
});
const listLeases = async (): Promise<ReadonlySet<string>> =>
new Set(await readdir(HOPPER_TARGET_LEASE_DIRECTORY).catch(() => []));
const refusesConnections = (socketPath: string): Promise<boolean> =>
new Promise((resolve) => {
const socket = createConnection(socketPath);
socket.once("connect", () => {
socket.destroy();
resolve(false);
});
socket.once("error", (error: NodeJS.ErrnoException) =>
resolve(error.code === "ECONNREFUSED"),
);
});
/**
* Remove a lease socket only when its owner has exited, holding the same
* recovery reservation as production so a concurrent acquirer is never
* unlinked. Live or busy leases are left for their owners.
*/
const removeStaleLease = async (socketPath: string): Promise<void> => {
const recoveryPath = `${socketPath}.recovery`;
try {
await mkdir(recoveryPath, { mode: 0o700 });
} catch {
return;
}
try {
if (await refusesConnections(socketPath))
await rm(socketPath, { force: true });
} finally {
await rmdir(recoveryPath);
}
};
describe("Hopper launcher failure through the one-shot CLI", () => {
// Hopper target leases are acquired only for the macOS launcher route.
workspaceCliTest.skipIf(process.platform !== "darwin")(
@@ -74,11 +37,16 @@ describe("Hopper launcher failure through the one-shot CLI", () => {
await chmod(launcherPath, 0o700);
const targetPath = workspace.path("probe");
await copyFile("/usr/bin/true", targetPath);
const leasesBefore = await listLeases();
const loaderArgs = ["--probe"];
onTestFinished(async () => {
for (const name of await listLeases())
if (!leasesBefore.has(name) && name.endsWith(".sock"))
await removeStaleLease(join(HOPPER_TARGET_LEASE_DIRECTORY, name));
const recovered = await acquireHopperTargetLease({
targetPath,
targetKind: "executable",
loaderArgs,
runId: "failed-cli-test-cleanup",
});
expect(recovered.acquired).toBe(true);
if (recovered.acquired) await recovered.lease.release();
});
for (const attempt of ["first", "retry"]) {
@@ -87,6 +55,7 @@ describe("Hopper launcher failure through the one-shot CLI", () => {
environment: {
...workspace.environment,
HOPPER_LAUNCHER_PATH: launcherPath,
HOPPER_LOADER_ARGS_JSON: JSON.stringify(loaderArgs),
REA_LOG_LEVEL: "silent",
},
timeoutMs: 30_000,
@@ -113,7 +82,7 @@ describe("Hopper launcher failure through the one-shot CLI", () => {
exit_code: 2,
stderr: {
text: expect.stringContaining(
"Not authorized to send Apple events",
"Document state is unknown",
),
},
},
@@ -0,0 +1,63 @@
import { execFile } from "node:child_process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, it, onTestFinished } from "vitest";
import { z } from "zod";
import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js";
const execute = promisify(execFile);
it.skipIf(process.platform === "win32")(
"the shipped bridge reauthenticates reconnects and releases its execution thread after expiry",
async () => {
const runtime = await PrivateRuntimeRoot.create({ parent: "/tmp" });
onTestFinished(() => runtime.close());
const { stdout } = await execute("python3", [
fileURLToPath(
new URL(
"../../../fixtures/hopperBridgeReconnectProbe.py",
import.meta.url,
),
),
fileURLToPath(
new URL("../../../../bridge/hopper_bridge.py", import.meta.url),
),
runtime.path,
]);
const rows = z
.array(
z.object({
mode: z.string(),
requests: z.array(z.string()),
listener_removed: z.literal(true),
thread_released: z.literal(true),
}),
)
.parse(JSON.parse(stdout));
expect(rows).toEqual([
{
mode: "unauthorized",
requests: ["health", "shutdown_document", "shutdown_document"],
listener_removed: true,
thread_released: true,
},
...["disconnect", "lost-reply"].map((mode) => ({
mode,
requests: [
"health",
"health",
"shutdown_document",
"shutdown_document",
],
listener_removed: true,
thread_released: true,
})),
{
mode: "expiry",
requests: ["health"],
listener_removed: true,
thread_released: true,
},
]);
},
);
@@ -210,7 +210,15 @@ it("blocks a fresh launch while cleanup remains unverified", async () => {
const { client, child } = await failedStartup(launcher);
await expect(client.start()).resolves.toMatchObject({
ok: false,
error: { _tag: "HopperProtocolError" },
error: {
cleanupIncomplete: true,
cause: { _tag: "HopperTimeoutError" },
diagnostics: { primary_error: { code: "provider_timeout" } },
},
});
expect(client.operationHealth()).toMatchObject({
state: "not_started",
stage: "launch",
});
expect(launcher.processes).toEqual([child]);
launcher.allowCleanup = true;
@@ -309,7 +317,10 @@ it("retries a failed lease release after document, process, and runtime cleanup
});
await expect(client.start()).resolves.toMatchObject({
ok: false,
error: { _tag: "HopperProtocolError" },
error: {
cleanupIncomplete: true,
diagnostics: { primary_error: { code: "provider_timeout" } },
},
});
releaseAllowed = true;
await expect(client.close()).resolves.toEqual({ ok: true, value: null });
@@ -35,6 +35,7 @@ export class HopperFixtureLauncher implements BridgeLauncher {
constructor(
readonly tokenOverride?: string,
readonly diagnosticMode: "quiet" | "noisy" | "noisy_no_socket" = "quiet",
readonly healthMode: "ready" | "gated" = "ready",
) {}
launch(session: BridgeSession) {
@@ -51,6 +52,7 @@ export class HopperFixtureLauncher implements BridgeLauncher {
session.runId,
"acknowledge",
this.diagnosticMode,
this.healthMode,
],
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
);
@@ -0,0 +1,179 @@
import { spawn, type ChildProcess } from "node:child_process";
import { access, rm } from "node:fs/promises";
import { expect, it, onTestFinished, vi } from "vitest";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { ok } from "../../../../src/domain/result.js";
import type {
BridgeLauncher,
BridgeSession,
} from "../../../../src/hopper/BridgeLauncher.js";
import { HopperClient } from "../../../../src/hopper/HopperClient.js";
import { HopperFixtureLauncher } from "./hopperClient.fixture.js";
const stop = async (child: ChildProcess): Promise<void> => {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = new Promise<void>((resolve) =>
child.once("exit", () => resolve()),
);
child.kill("SIGKILL");
await exited;
};
// The GUI is a sibling owned by the test, never a descendant owned by REA's helper.
class ExternalBridgeLauncher implements BridgeLauncher {
readonly fixture: HopperFixtureLauncher;
readonly sessions: BridgeSession[] = [];
readonly helpers: ChildProcess[] = [];
constructor(
readonly identity: "matching" | "wrong-run" | "wrong-token" = "matching",
healthMode: "ready" | "gated" = "ready",
) {
this.fixture = new HopperFixtureLauncher(
identity === "wrong-token" ? "foreign-token" : undefined,
"quiet",
healthMode,
);
}
async launch(session: BridgeSession) {
this.sessions.push(session);
await this.fixture.launch({
...session,
runId: this.identity === "wrong-run" ? "foreign-run" : session.runId,
});
const helper = spawn(process.execPath, ["-e", "process.exit(0)"], {
stdio: "ignore",
});
this.helpers.push(helper);
return ok({
process: helper,
ownsProcessLifetime: false as const,
providerLifetime: "external-application" as const,
shutdownMode: "bridge-request" as const,
});
}
}
const setup = (
identity?: "matching" | "wrong-run" | "wrong-token",
healthMode?: "ready" | "gated",
) => {
const launcher = new ExternalBridgeLauncher(identity, healthMode);
const client = new HopperClient({ launcher, startupTimeoutMs: 2_000 });
onTestFinished(async () => {
await Promise.all(
[...launcher.helpers, ...launcher.fixture.processes].map(stop),
);
await client.close();
await Promise.all(
launcher.sessions.map(({ directory }) =>
rm(directory, { recursive: true, force: true }),
),
);
});
return { client, launcher };
};
it.each(["wrong-run", "wrong-token"] as const)(
"never requests document shutdown after the first handshake rejects %s",
async (identity) => {
const { client, launcher } = setup(identity);
const started = await client.start();
if (started.ok) throw new Error("Expected a rejected handshake");
expect(projectAnalysisError(started.error)).toMatchObject({
code: "cleanup_incomplete",
});
await launcher.fixture.waitForRequest("health");
expect(launcher.fixture.requests.map(({ method }) => method)).toEqual([
"health",
]);
expect(client.operationHealth()).toMatchObject({
state: "unknown",
stage: "launch",
});
const closed = await client.close();
if (closed.ok)
throw new Error("Expected retained foreign bridge ownership");
expect(projectAnalysisError(closed.error)).toMatchObject({
code: "cleanup_incomplete",
});
expect(
launcher.fixture.requests.every(({ method }) => method === "health"),
).toBe(true);
expect(launcher.fixture.processes[0]?.exitCode).toBeNull();
const session = launcher.sessions[0];
if (session === undefined) throw new Error("Fixture did not launch");
await access(session.directory);
},
);
it("reconnects for authenticated cleanup after a previously valid bridge transport fails", async () => {
const { client, launcher } = setup();
const started = await client.start();
if (!started.ok) throw started.error;
const malformed = await client.callTool("malformed");
expect(malformed).toMatchObject({
ok: false,
error: { _tag: "HopperProtocolError" },
});
const closed = await client.close();
if (!closed.ok) throw closed.error;
expect(launcher.fixture.requests.map(({ method }) => method)).toEqual([
"health",
"malformed",
"health",
"shutdown_document",
]);
await vi.waitFor(() =>
expect(launcher.fixture.processes[0]?.exitCode).toBe(0),
);
const session = launcher.sessions[0];
if (session === undefined) throw new Error("Fixture did not launch");
await expect(access(session.directory)).rejects.toMatchObject({
code: "ENOENT",
});
});
it("preserves launcher diagnostics after a health deadline and reauthenticates for cleanup", async () => {
const { client, launcher } = setup("matching", "gated");
const starting = client.start();
const first = await launcher.fixture.waitForRequest("health");
const started = await starting;
if (started.ok) throw new Error("Expected a health deadline");
expect(projectAnalysisError(started.error)).toMatchObject({
code: "cleanup_incomplete",
details: {
diagnostics: {
primary_error: {
code: "provider_timeout",
details: {
timeout_ms: 2_000,
stage: "startup",
launcher: { exit_code: 0, output_closed: true },
},
},
},
},
});
expect(client.requestActivity()).toBeNull();
const closing = client.close();
const next = await vi.waitFor(() => {
const request = launcher.fixture.requests.find(
({ method, id }) => method === "health" && id !== first.id,
);
if (request === undefined)
throw new Error("Waiting for recovery handshake");
return request;
});
await launcher.fixture.release(next);
const closed = await closing;
if (!closed.ok) throw closed.error;
expect(launcher.fixture.requests.map(({ method }) => method)).toEqual([
"health",
"health",
"shutdown_document",
]);
});
+15 -10
View File
@@ -6,6 +6,7 @@ const [
runId,
shutdownMode = "acknowledge",
diagnosticMode = "quiet",
healthMode = "ready",
] = process.argv.slice(2);
let diagnosticOutputEmitted = false;
const heldReplies = new Map();
@@ -201,17 +202,21 @@ const server = createServer((socket) => {
send({ id: request.id, error: { code: -32001, message: "bad token" } });
} else if (request.method === "health") {
emitDiagnosticOutput();
send(
{
id: request.id,
result: {
name: "REA Hopper bridge",
version: "1.0.0",
run_id: runId,
const reply = () =>
send(
{
id: request.id,
result: {
name: "REA Hopper bridge",
version: "1.0.0",
run_id: runId,
},
},
},
true,
);
true,
);
if (healthMode === "gated") heldReplies.set(request.id, reply);
else if (healthMode === "ready") reply();
else throw new Error(`Unknown health fixture mode: ${healthMode}`);
} else if (
request.method === "shutdown" ||
request.method === "shutdown_document"
+97
View File
@@ -0,0 +1,97 @@
"""Exercise the shipped socket lifecycle with a recording native dispatch port."""
import json
from pathlib import Path
import socket
import sys
import threading
import time
from hopperBridgeFacadeProbe import load_bridge
def probe(path, socket_path, mode):
bridge = load_bridge(path)
bridge.update(REA_SOCKET=socket_path, REA_TOKEN="owned-token", REA_RUN_ID="owned-run",
REA_RECONNECT_TIMEOUT_SECONDS=0.2)
requests = []
entered = threading.Event()
release = threading.Event()
health = bridge["_dispatch"]
def dispatch(method, params):
requests.append(method)
if method == "health":
if mode == "lost-reply" and requests == ["health"]:
entered.set()
assert release.wait(2), "Probe never released the blocked reply"
return health(method, params)
assert method == "shutdown_document", method
closed = not params.get("unconfirmed", False)
return {"shutdown": True, "analysis_stopped": True, "document_closed": closed}
bridge["_dispatch"] = dispatch
failures = []
def serve():
try:
bridge["_run"]()
except Exception as error:
failures.append(repr(error))
worker = threading.Thread(target=serve, daemon=True)
worker.start()
deadline = time.monotonic() + 2
while not Path(socket_path).exists():
assert time.monotonic() < deadline, failures
time.sleep(0.005)
def connect():
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.settimeout(2)
client.connect(socket_path)
return client, client.makefile("rwb")
def send(file, method, token="owned-token", params=None):
file.write((json.dumps({"id": 1, "method": method, "token": token, "params": params or {}}) + "\n").encode())
file.flush()
def reply(file):
while True:
line = file.readline()
assert line, "Bridge closed before its reply"
value = json.loads(line)
if "event" not in value:
return value
client, file = connect()
send(file, "health", "foreign-token" if mode == "unauthorized" else "owned-token")
if mode == "lost-reply":
assert entered.wait(2)
elif mode == "unauthorized":
assert reply(file)["error"]["type"] == "authorization"
else:
assert reply(file)["result"]["run_id"] == "owned-run"
file.close()
client.close()
release.set()
if mode != "expiry":
client, file = connect()
send(file, "health")
assert reply(file)["result"]["run_id"] == "owned-run"
send(file, "shutdown_document", params={"unconfirmed": True})
assert reply(file)["result"]["document_closed"] is False
send(file, "shutdown_document")
assert reply(file)["result"]["document_closed"] is True
file.close()
client.close()
worker.join(2)
assert not worker.is_alive(), "Bridge pinned its execution thread"
assert not failures, failures
assert not Path(socket_path).exists(), "Bridge retained its closed listener"
assert requests.count("shutdown_document") == (0 if mode == "expiry" else 2)
return {"mode": mode, "requests": requests, "listener_removed": True, "thread_released": True}
if __name__ == "__main__":
print(json.dumps([probe(sys.argv[1], str(Path(sys.argv[2]) / (mode + ".sock")), mode)
for mode in ("unauthorized", "disconnect", "lost-reply", "expiry")]))
+28
View File
@@ -0,0 +1,28 @@
import { readFile, writeFile } from "node:fs/promises";
// Model the native helper's external GUI handoff independently of its lifetime.
const [modePath, receiptPath] = process.argv.slice(2, 4);
const mode = await readFile(modePath, "utf8");
const bootstrapIndex = process.argv.indexOf("-Y") + 1;
if (bootstrapIndex === 0) throw new Error("Missing Hopper bootstrap argument");
const bootstrapPath = process.argv[bootstrapIndex];
await writeFile(receiptPath, bootstrapPath);
if (mode === "denied" || mode === "denied-after-handoff") {
process.stderr.write(
"Cannot launch Hopper!\nNot authorized to send Apple events to Hopper Disassembler.\n",
async () => {
if (mode === "denied-after-handoff") {
// A real native helper can create a document before emitting this same
// diagnostic when caller literals alter its AppleScript construction.
await writeFile(`${receiptPath}.document-created`, "unconfirmed");
}
process.exit(2);
},
);
} else if (mode === "handoff") {
process.exit(0);
} else if (mode === "bridge") {
await import("./fakeLauncher.mjs");
} else {
throw new Error(`Unknown Hopper launch admission fixture mode: ${mode}`);
}