mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(providers): retain physical refusal and diagnostic facts
This commit is contained in:
@@ -1091,3 +1091,44 @@ it.each([false, true])(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains Hopper startup diagnostics beside generic and secondary context", () => {
|
||||
const diagnostic = {
|
||||
component: "hopper_private_display",
|
||||
operation: "launch",
|
||||
reason: "fixture_denial",
|
||||
socket_directory: "/tmp/.X11-unix",
|
||||
socket_directory_mode: null,
|
||||
mount_read_only: null,
|
||||
effective_socket_directory_mode: null,
|
||||
effective_mount_read_only: null,
|
||||
wsl: false,
|
||||
strategy: "unavailable",
|
||||
fallback_reason: null,
|
||||
xvfb_stderr_bytes: 0,
|
||||
status: "error",
|
||||
failure_code: "private_display_unavailable",
|
||||
} as const;
|
||||
expect(
|
||||
projectAnalysisError(new HopperProcessError(null, { diagnostic })).details
|
||||
?.diagnostics,
|
||||
).toEqual(diagnostic);
|
||||
const generic = { caller_context: "retained" };
|
||||
expect(
|
||||
projectAnalysisError(
|
||||
new HopperProcessError(null, { diagnostic, diagnostics: generic }),
|
||||
),
|
||||
).toMatchObject({
|
||||
code: "provider_unavailable",
|
||||
details: { diagnostics: generic, provider_diagnostics: diagnostic },
|
||||
});
|
||||
const secondary = new HopperProcessError(null, {
|
||||
diagnostic,
|
||||
}).retainSecondaryFailure(new HopperProcessError(null), {
|
||||
retirement_error: "retained",
|
||||
});
|
||||
expect(projectAnalysisError(secondary).details).toMatchObject({
|
||||
provider_diagnostics: diagnostic,
|
||||
diagnostics: { retirement_error: "retained" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,7 +83,14 @@ const projectError = (
|
||||
...(composed === error || composed.cleanupReport === undefined
|
||||
? {}
|
||||
: { cleanup_report: jsonValueSchema.parse(composed.cleanupReport) }),
|
||||
...(diagnostics === undefined ? {} : { diagnostics }),
|
||||
...(diagnostics === undefined
|
||||
? {}
|
||||
: {
|
||||
diagnostics,
|
||||
...(primaryDetails?.diagnostics === undefined
|
||||
? {}
|
||||
: { provider_diagnostics: primaryDetails.diagnostics }),
|
||||
}),
|
||||
...(composed === error || composed.diagnostics === undefined
|
||||
? {}
|
||||
: { cleanup_history: composed.diagnostics }),
|
||||
|
||||
@@ -348,3 +348,40 @@ it("keeps failed Mach-O preflight cleanup attached to the supplied profile owner
|
||||
await owner.close();
|
||||
expect(resolved.error.cleanupIncomplete).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves launcher fingerprint filesystem failures instead of an absent profile", async () => {
|
||||
const failure = Object.assign(
|
||||
new Error("EACCES: fixture launcher read denied"),
|
||||
{ code: "EACCES" },
|
||||
);
|
||||
const stream = new PassThrough();
|
||||
vi.mocked(createReadStream).mockReturnValueOnce(
|
||||
stream as unknown as ReturnType<typeof createReadStream>,
|
||||
);
|
||||
const resolving = resolveHopperAnalysisProfile(
|
||||
{
|
||||
path: "/fixture/input",
|
||||
sha256: "a".repeat(64),
|
||||
kind: "executable",
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
},
|
||||
{
|
||||
launcherPath: "/fixture/hopper",
|
||||
loaderArgsOverride: [],
|
||||
provider: { id: "hopper", name: "Hopper", version: null },
|
||||
resourceScope: inspectionResources.owner,
|
||||
},
|
||||
);
|
||||
stream.destroy(failure);
|
||||
const result = await resolving;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
cause: failure,
|
||||
diagnostics: { launcher_path: "/fixture/hopper", system_code: "EACCES" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,7 +129,6 @@ const resolveHopperAnalysisProfileInScope = async (
|
||||
: [...options.loaderArgsOverride];
|
||||
const launcherDigest = await sha256File(options.launcherPath, options.signal);
|
||||
if (!launcherDigest.ok) return launcherDigest;
|
||||
if (launcherDigest.value === undefined) return ok({ profile: null });
|
||||
const provider = {
|
||||
id: options.provider.id,
|
||||
name: options.provider.name,
|
||||
@@ -320,7 +319,7 @@ const readMachoMagic = async (
|
||||
const sha256File = async (
|
||||
path: string,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Result<string | undefined, AnalysisCancelledError>> => {
|
||||
): Promise<Result<string, AnalysisError>> => {
|
||||
if (signalIsAborted(signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
|
||||
@@ -341,13 +340,26 @@ const sha256File = async (
|
||||
)
|
||||
: ok(hash.digest("hex"));
|
||||
} catch (cause: unknown) {
|
||||
// Physical fingerprint failures are best-effort; only our own abort
|
||||
// sentinel turns this optional observation into caller cancellation.
|
||||
return cause === abortError
|
||||
? err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
|
||||
)
|
||||
: ok(undefined);
|
||||
: err(
|
||||
new ProviderAdapterError("hopper", "resolve_analysis_profile", {
|
||||
cause,
|
||||
userMessage: `Cannot fingerprint the selected Hopper launcher ${path}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
remediationAction:
|
||||
"Restore read access to the selected Hopper launcher, then retry provider selection.",
|
||||
diagnostics: {
|
||||
launcher_path: path,
|
||||
system_code:
|
||||
cause instanceof Error &&
|
||||
typeof Reflect.get(cause, "code") === "string"
|
||||
? (Reflect.get(cause, "code") as string)
|
||||
: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { RizinDebugSessionManager } from "./RizinDebugSessionManager.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"identifies the rejected TTY field and physical reason before spawning Rizin",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-rizin-tty-rejection-");
|
||||
const regular = join(root, "regular");
|
||||
await writeFile(regular, "not a terminal");
|
||||
const manager = new RizinDebugSessionManager(
|
||||
{},
|
||||
{
|
||||
platform: "linux",
|
||||
spawnProcess: () => {
|
||||
throw new Error("TTY refusal must precede spawn");
|
||||
},
|
||||
},
|
||||
);
|
||||
for (const [path, reason] of [
|
||||
[join(root, "missing"), "ENOENT"],
|
||||
[regular, "not a character TTY"],
|
||||
["/dev/null", "not a character TTY"],
|
||||
]) {
|
||||
const result = await manager.start({
|
||||
path: regular,
|
||||
inferior_tty: path!,
|
||||
});
|
||||
if (result.ok) throw new Error("Invalid terminal was admitted");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
issues: [
|
||||
{
|
||||
path: ["inferior_tty"],
|
||||
message: expect.stringContaining(reason!),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -295,7 +295,16 @@ export class RizinDebugSessionManager {
|
||||
>
|
||||
> {
|
||||
if (this.#closingAll)
|
||||
return err(new AnalysisInputError("start_rizin_debug_session"));
|
||||
return err(
|
||||
new AnalysisInputError("start_rizin_debug_session", undefined, [
|
||||
{
|
||||
path: ["inferior_tty"],
|
||||
reason: "missing_argument",
|
||||
message:
|
||||
"Select a persistent readable and writable Linux terminal for inferior output.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const occupied = this.#sessions.size + this.#sessionReservations;
|
||||
if (occupied >= MAX_SESSIONS)
|
||||
return err(
|
||||
@@ -376,7 +385,17 @@ export class RizinDebugSessionManager {
|
||||
}
|
||||
} catch (cause) {
|
||||
return err(
|
||||
new AnalysisInputError("start_rizin_debug_session", { cause }),
|
||||
new AnalysisInputError(
|
||||
"start_rizin_debug_session",
|
||||
{ cause, diagnostics: { inferior_tty: input.inferior_tty } },
|
||||
[
|
||||
{
|
||||
path: ["inferior_tty"],
|
||||
reason: "invalid_value",
|
||||
message: `Cannot use selected terminal ${input.inferior_tty}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
// TTY identity validation is asynchronous. Cancellation may arrive while
|
||||
|
||||
Reference in New Issue
Block a user