mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 18:52:48 +00:00
A hardened-runtime client without the Apple Events automation entitlement got generic Automation advice, and the projection kept retryable: true, so agents retried from the same unauthorized client. The denial is now a typed launch rejection whose message and recovery name the entitlement and the Automation setting, with retry turned off. Fixes #9879 Squashed from #9916 and rebased onto main. Co-Authored-By: morluto <76467478+morluto@users.noreply.github.com> Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
1839 lines
61 KiB
TypeScript
1839 lines
61 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisUnsupportedTargetError,
|
|
AnalysisInputError,
|
|
AnalysisOutputError,
|
|
AnalysisCancelledError,
|
|
AnalysisTimeoutError,
|
|
AnalysisResourceConstraintError,
|
|
} from "./analysisErrorCore.js";
|
|
import { ArtifactOperationError } from "./artifactOperationError.js";
|
|
import {
|
|
BinaryTargetError,
|
|
ConfigurationError,
|
|
} from "./configurationErrors.js";
|
|
import { BrowserObservationError } from "./browserObservationError.js";
|
|
import { EvidenceFileError, EvidenceIntegrityError } from "./evidenceErrors.js";
|
|
import {
|
|
HOPPER_STARTUP_TIMEOUT_REMEDIATION,
|
|
HopperProcessError,
|
|
HopperRemoteError,
|
|
HopperStartError,
|
|
HopperTimeoutError,
|
|
} from "./hopperErrors.js";
|
|
import {
|
|
DEFAULT_HOPPER_DEMO_DIALOG_TIMEOUT_MS,
|
|
HOPPER_DEMO_DIALOG_TIMEOUT_SETTING,
|
|
MAXIMUM_HOPPER_DEMO_DIALOG_TIMEOUT_MS,
|
|
MINIMUM_HOPPER_DEMO_DIALOG_TIMEOUT_MS,
|
|
type DialogTimeoutRejection,
|
|
type HopperStartupFailureDiagnostic,
|
|
} from "./hopperStartupFailure.js";
|
|
import { ProviderAdapterError } from "./providerAdapterError.js";
|
|
import { UnknownRegistryError } from "./unknownRegistryError.js";
|
|
import {
|
|
childFailureDiagnostics,
|
|
projectAnalysisError,
|
|
} from "./analysisErrorProjection.js";
|
|
import {
|
|
analysisErrorRemediationAction,
|
|
analysisErrorUserMessage,
|
|
} from "./analysisErrorPresentation.js";
|
|
import { ProviderSelectionError } from "./providerSelectionError.js";
|
|
import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js";
|
|
import { nativeCallPartialObservationSchema } from "./native/nativeCallPartialObservation.js";
|
|
import { analysisSettingLimit } from "./analysisSettingDefinitions.js";
|
|
import { redactExplicitFailure } from "./explicitSensitiveFailure.js";
|
|
import { redactExplicitText } from "./explicitSensitiveValues.js";
|
|
import { analysisErrorWithCleanupFailure } from "./analysisErrorCleanup.js";
|
|
import type { AnalysisCallerLifetime } from "./analysisErrorBase.js";
|
|
import { ProviderCleanupError } from "./providerCleanupError.js";
|
|
|
|
it("projects nested workflow-publication recovery for the actual caller lifetime", () => {
|
|
const child = new UnknownRegistryError(
|
|
"limit",
|
|
).retainWorkflowUnknownPublication({
|
|
evidence_id: `ev_${"a".repeat(64)}`,
|
|
evidence_retention: "retained",
|
|
total_questions: 3,
|
|
created_questions: 1,
|
|
existing_questions: 0,
|
|
next_question_index: 1,
|
|
});
|
|
const error = new AnalysisCancelledError(
|
|
"trace_feature",
|
|
childFailureDiagnostics({ child: { error: child } }),
|
|
);
|
|
const continuing = projectAnalysisError(error);
|
|
const invocation = projectAnalysisError(error, "invocation");
|
|
const childOf = (value: typeof continuing) => {
|
|
const diagnostics = value.details?.diagnostics;
|
|
if (
|
|
typeof diagnostics !== "object" ||
|
|
diagnostics === null ||
|
|
Array.isArray(diagnostics)
|
|
)
|
|
throw new Error("Expected child diagnostics");
|
|
return analysisErrorProjectionSchema.parse(diagnostics.child);
|
|
};
|
|
expect(
|
|
childOf(continuing).details?.workflow_unknown_publication,
|
|
).toMatchObject({
|
|
evidence_retention: "retained",
|
|
created_questions: 1,
|
|
next_question_index: 1,
|
|
});
|
|
expect(childOf(continuing).remediation.action).toContain(
|
|
"get_evidence_bundle",
|
|
);
|
|
expect(
|
|
childOf(invocation).details?.workflow_unknown_publication,
|
|
).toMatchObject({
|
|
evidence_retention: "invocation-only",
|
|
created_questions: 1,
|
|
next_question_index: 1,
|
|
});
|
|
expect(childOf(invocation).remediation.action).not.toContain(
|
|
"get_evidence_bundle",
|
|
);
|
|
});
|
|
|
|
describe("agent recovery metadata", () => {
|
|
it("does not mark deterministic input validation as retryable", () => {
|
|
const projected = projectAnalysisError(
|
|
new AnalysisInputError("extract_firmware", undefined, [
|
|
{ path: ["range", "length"], reason: "missing_argument" },
|
|
]),
|
|
);
|
|
|
|
expect(projected).toMatchObject({
|
|
code: "invalid_request",
|
|
retryable: false,
|
|
remediation: {
|
|
action: expect.stringContaining("Do not repeat the unchanged call."),
|
|
},
|
|
});
|
|
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
|
|
});
|
|
|
|
it("distinguishes deterministic duplicate identities from stale update revisions", () => {
|
|
const duplicate = projectAnalysisError(
|
|
new UnknownRegistryError("already-exists"),
|
|
);
|
|
expect(duplicate).toMatchObject({
|
|
code: "invalid_request",
|
|
category: "invalid_input",
|
|
retryable: false,
|
|
details: { reason: "already-exists" },
|
|
remediation: {
|
|
action: expect.stringContaining("do not repeat record_unknown"),
|
|
},
|
|
});
|
|
expect(analysisErrorProjectionSchema.parse(duplicate)).toEqual(duplicate);
|
|
expect(
|
|
projectAnalysisError(new UnknownRegistryError("revision-conflict")),
|
|
).toMatchObject({ code: "revision_conflict", retryable: true });
|
|
});
|
|
|
|
it.each(["history_gap", "revision_chain"] as const)(
|
|
"preserves %s as an integrity failure with producer facts",
|
|
(kind) => {
|
|
const constraint = { kind, unknown_id: `unk_${"0".repeat(64)}` };
|
|
const projected = projectAnalysisError(
|
|
new UnknownRegistryError(constraint, {
|
|
cause: new Error("Unrelated private diagnostic"),
|
|
}),
|
|
);
|
|
expect(projected).toMatchObject({
|
|
code: "evidence_integrity_mismatch",
|
|
retryable: false,
|
|
details: { reason: "integrity", constraint },
|
|
});
|
|
expect(projected.message).toContain(constraint.unknown_id);
|
|
expect(JSON.stringify(projected)).not.toContain(
|
|
"Unrelated private diagnostic",
|
|
);
|
|
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
|
|
},
|
|
);
|
|
|
|
it("keeps retryability for provider timeouts", () => {
|
|
expect(
|
|
projectAnalysisError(
|
|
new AnalysisTimeoutError("inspect_binary_layout", 2500),
|
|
),
|
|
).toMatchObject({ code: "provider_timeout", retryable: true });
|
|
});
|
|
|
|
it("names the input contract for malformed analysis arguments", () => {
|
|
const projected = projectAnalysisError(
|
|
new AnalysisInputError("inspect_analysis_view", undefined, [
|
|
{
|
|
path: ["view", "kind"],
|
|
reason: "invalid_value",
|
|
expected: ["summary", "module"],
|
|
},
|
|
]),
|
|
);
|
|
|
|
expect(projected).toMatchObject({
|
|
recovery: {
|
|
kind: "inspect_input_contract",
|
|
operation: "inspect_analysis_view",
|
|
},
|
|
remediation: {
|
|
action: expect.stringContaining("inspect_analysis_view"),
|
|
},
|
|
});
|
|
expect(projected.remediation.action).toContain("tools/list");
|
|
expect(projected.remediation.action).toMatch(/correct.*fields.*retry/iu);
|
|
expect(projected.remediation.action).toMatch(/not repeat.*unchanged/iu);
|
|
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
|
|
});
|
|
|
|
it("omits schema recovery when a conforming request lacks a target fact", () => {
|
|
const issue = {
|
|
path: ["address"],
|
|
reason: "invalid_value",
|
|
message: "Address 0x2000 is in uninitialized memory block .bss",
|
|
observed: { address: "0x2000", file_mapping: "uninitialized" },
|
|
} as const;
|
|
const projected = projectAnalysisError(
|
|
new AnalysisInputError(
|
|
"address_to_file_offset",
|
|
{
|
|
userMessage: "No exact original-file offset exists.",
|
|
remediationAction: "Select an address inside a file-backed mapping.",
|
|
conformsToInputContract: true,
|
|
},
|
|
[issue],
|
|
),
|
|
);
|
|
|
|
expect(projected).toEqual({
|
|
code: "invalid_request",
|
|
category: "invalid_input",
|
|
message: "No exact original-file offset exists.",
|
|
retryable: false,
|
|
remediation: {
|
|
action: "Select an address inside a file-backed mapping.",
|
|
},
|
|
details: { operation: "address_to_file_offset", issues: [issue] },
|
|
});
|
|
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
|
|
});
|
|
|
|
it("separates timeout-state inspection from client deadline recovery", () => {
|
|
const projected = projectAnalysisError(
|
|
new AnalysisTimeoutError("inspect_binary_layout", 2_500),
|
|
);
|
|
|
|
expect(projected).toMatchObject({
|
|
code: "provider_timeout",
|
|
recovery: {
|
|
kind: "inspect_timeout_state",
|
|
operation: "inspect_binary_layout",
|
|
timeout_ms: 2_500,
|
|
},
|
|
message: expect.stringContaining("server/provider deadline"),
|
|
remediation: {
|
|
action: expect.stringContaining("provider diagnostics"),
|
|
},
|
|
});
|
|
expect(projected.message).toContain("not a client-side timeout");
|
|
expect(projected.remediation.action).not.toContain(
|
|
"raise its tool deadline",
|
|
);
|
|
expect(projected.remediation.action).not.toMatch(
|
|
/declared timeout controls|larger server\/provider budget|without assuming adjustable limits|do not assume adjustable/u,
|
|
);
|
|
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
|
|
});
|
|
});
|
|
|
|
describe("capability failure retry disposition", () => {
|
|
it.each([
|
|
{ options: {}, retryable: false, category: "unsupported_provider" },
|
|
{
|
|
options: { retryable: false },
|
|
retryable: false,
|
|
category: "unsupported_provider",
|
|
},
|
|
{ options: { retryable: true }, retryable: true, category: "unavailable" },
|
|
] as const)(
|
|
"preserves the producer's $retryable disposition",
|
|
({ options, retryable, category }) => {
|
|
const reason = "Native self-test failed with a sharing violation";
|
|
const error = new AnalysisCapabilityUnavailableError(
|
|
"fixture-provider",
|
|
"inspect",
|
|
reason,
|
|
options,
|
|
);
|
|
const projected = projectAnalysisError(error);
|
|
expect(projected).toMatchObject({
|
|
code: "capability_unavailable",
|
|
category,
|
|
retryable,
|
|
details: { reason },
|
|
});
|
|
if (retryable) {
|
|
expect(projected.message).toBe(
|
|
"Required runtime controls are unavailable for this attempt.",
|
|
);
|
|
expect(projected.remediation.action).toBe(
|
|
analysisErrorRemediationAction(error),
|
|
);
|
|
}
|
|
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
|
|
true,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("preserves a caller-selected message for a retryable failure", () => {
|
|
expect(
|
|
projectAnalysisError(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"fixture-provider",
|
|
"inspect",
|
|
"temporary failure",
|
|
{
|
|
retryable: true,
|
|
userMessage: "Selected runtime diagnostic",
|
|
},
|
|
),
|
|
).message,
|
|
).toBe("Selected runtime diagnostic");
|
|
});
|
|
|
|
it("uses generic recovery only when no more specific recovery exists", () => {
|
|
const project = (options: {
|
|
fallbackRemediationAction?: string;
|
|
remediationAction?: string;
|
|
retryable?: boolean;
|
|
}) =>
|
|
projectAnalysisError(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"fixture",
|
|
"inspect",
|
|
"unavailable",
|
|
options,
|
|
),
|
|
);
|
|
expect(
|
|
project({ fallbackRemediationAction: "Generic" }).remediation.action,
|
|
).toBe("Generic");
|
|
expect(
|
|
project({
|
|
fallbackRemediationAction: "Generic",
|
|
remediationAction: "Specific",
|
|
}).remediation.action,
|
|
).toBe("Specific");
|
|
const retry = project({ retryable: true });
|
|
const retryWithFallback = project({
|
|
fallbackRemediationAction: "Generic",
|
|
retryable: true,
|
|
});
|
|
expect(retryWithFallback.retryable).toBe(true);
|
|
expect(retryWithFallback.remediation.action).toBe(retry.remediation.action);
|
|
});
|
|
|
|
it("retains cleanup ownership before allowing a transient operation to retry", () => {
|
|
expect(
|
|
projectAnalysisError(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"fixture-provider",
|
|
"inspect",
|
|
"temporary failure",
|
|
{
|
|
retryable: true,
|
|
cleanup: {
|
|
reason: "owned handle is still live",
|
|
resources: ["native-owner"],
|
|
},
|
|
},
|
|
),
|
|
),
|
|
).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
retryable: false,
|
|
details: {
|
|
cleanup: "incomplete",
|
|
resources: ["native-owner"],
|
|
execution_failure: "capability_unavailable",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
it("projects a published file receipt alongside retained cleanup failure", () => {
|
|
const projected = projectAnalysisError(
|
|
new EvidenceFileError("write", "io", {
|
|
path: "/tmp/evidence.json",
|
|
published: { path: "/tmp/evidence.json", bytes: 42 },
|
|
cleanup: {
|
|
reason: "staging directory is still owned",
|
|
resources: ["/tmp/.rea-write-abc"],
|
|
},
|
|
cause: new Error("cleanup incomplete"),
|
|
}),
|
|
);
|
|
expect(projected).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
message: expect.stringMatching(/published.*staging cleanup.*incomplete/iu),
|
|
remediation: {
|
|
action: expect.stringMatching(
|
|
/published.*details\.published_path.*cleanup/iu,
|
|
),
|
|
},
|
|
details: {
|
|
operation: "write",
|
|
path: "/tmp/evidence.json",
|
|
published: true,
|
|
published_path: "/tmp/evidence.json",
|
|
bytes: 42,
|
|
resources: ["/tmp/.rea-write-abc"],
|
|
},
|
|
});
|
|
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
|
|
});
|
|
|
|
const nativeCallPartialObservation = nativeCallPartialObservationSchema.parse({
|
|
kind: "native-call-observation",
|
|
target: {
|
|
path: "/tmp/selected-app",
|
|
sha256: "a".repeat(64),
|
|
architecture: "arm64",
|
|
arguments: [],
|
|
environment: {},
|
|
working_directory: null,
|
|
},
|
|
process: {
|
|
pid: 4242,
|
|
stdout: {
|
|
text: "partial stdout",
|
|
bytes: 13,
|
|
truncated: false,
|
|
complete: false,
|
|
},
|
|
stderr: null,
|
|
other_stops: [],
|
|
},
|
|
debugger: { version: null },
|
|
events: [],
|
|
coverage: { status: "partial", reason: "cancelled" },
|
|
limitations: ["Observation ended before the requested window completed."],
|
|
});
|
|
|
|
const retainedOutput = {
|
|
stdout: "selected output\u0000",
|
|
stderr: "upstream warning",
|
|
truncated: true,
|
|
};
|
|
|
|
it("distinguishes unsupported target formats from unavailable provider capabilities", () => {
|
|
const projected = projectAnalysisError(
|
|
new AnalysisUnsupportedTargetError(
|
|
"inspect_evm_interface",
|
|
"/selected/runtime.hex",
|
|
"EOF-style EF00 container is unsupported.",
|
|
{ capturedOutput: retainedOutput },
|
|
),
|
|
);
|
|
expect(projected).toMatchObject({
|
|
code: "unsupported_target",
|
|
category: "unsupported_target",
|
|
retryable: false,
|
|
details: {
|
|
operation: "inspect_evm_interface",
|
|
path: "/selected/runtime.hex",
|
|
reason: "EOF-style EF00 container is unsupported.",
|
|
captured_output: retainedOutput,
|
|
},
|
|
});
|
|
expect(projected.message).toContain("EF00");
|
|
expect(projected.remediation.action).toContain("target format");
|
|
expect(projected.remediation.action).not.toContain("doctor");
|
|
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
|
|
});
|
|
|
|
it("keeps a refusing provider's recovery on a target selection failure", () => {
|
|
const selection = {
|
|
reason: "target_unsupported" as const,
|
|
requestedProviderId: "ghidra",
|
|
candidateIds: ["ghidra"],
|
|
userMessage: "Unsupported target: the configured extension is x86-64 only.",
|
|
};
|
|
expect(
|
|
projectAnalysisError(
|
|
new ProviderSelectionError({
|
|
...selection,
|
|
remediationAction: "Omit the extension setting for this target.",
|
|
}),
|
|
),
|
|
).toMatchObject({
|
|
code: "capability_unavailable",
|
|
message: selection.userMessage,
|
|
remediation: { action: "Omit the extension setting for this target." },
|
|
details: { selection_reason: "target_unsupported" },
|
|
});
|
|
expect(
|
|
projectAnalysisError(new ProviderSelectionError(selection)).remediation
|
|
.action,
|
|
).toBe(selection.userMessage);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"marks occupied capacity retryable only while cleanup is confirmed: %s",
|
|
(cleanupIncomplete) => {
|
|
const projected = projectAnalysisError(
|
|
new AnalysisResourceConstraintError(
|
|
"inspect_sqlite_database",
|
|
"capacity",
|
|
"Inspection slots are occupied",
|
|
{ max_concurrent_inspections: 2 },
|
|
cleanupIncomplete
|
|
? {
|
|
cleanup: {
|
|
reason: "Snapshot removal denied",
|
|
resources: ["/private/snapshot"],
|
|
},
|
|
}
|
|
: undefined,
|
|
),
|
|
);
|
|
expect(projected.retryable).toBe(!cleanupIncomplete);
|
|
expect(projected.code).toBe(
|
|
cleanupIncomplete ? "cleanup_incomplete" : "resource_constraint",
|
|
);
|
|
expect(projected.details).toMatchObject({
|
|
resource: "capacity",
|
|
reported_limits: { max_concurrent_inspections: 2 },
|
|
});
|
|
expect(projected.remediation.action).toContain("Wait");
|
|
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
|
|
true,
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["memory", null],
|
|
["memory", { address_space_bytes: 67108864 }],
|
|
["cpu", null],
|
|
["cpu", { cpu_seconds: 1 }],
|
|
["file-size", null],
|
|
["file-size", { file_size_bytes: 1024 }],
|
|
["transport", { result_budget_bytes: 10485760 }],
|
|
] as const)(
|
|
"projects reported %s constraints with observed or unknown limits: %j",
|
|
(resource, limits) => {
|
|
const projected = projectAnalysisError(
|
|
new AnalysisResourceConstraintError(
|
|
"inspect_binary_layout",
|
|
resource,
|
|
resource === "memory"
|
|
? "Memory allocation failed; exact cause unknown."
|
|
: "Observed SIGXCPU; exact signal cause unknown.",
|
|
limits,
|
|
{ capturedOutput: retainedOutput },
|
|
),
|
|
);
|
|
expect(projected).toMatchObject({
|
|
code: "resource_constraint",
|
|
category: "resource_constraint",
|
|
retryable: false,
|
|
details: {
|
|
resource,
|
|
reported_limits: limits,
|
|
captured_output: retainedOutput,
|
|
},
|
|
});
|
|
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
|
|
true,
|
|
);
|
|
},
|
|
);
|
|
it.each([
|
|
new AnalysisInputError("inspect", { capturedOutput: retainedOutput }),
|
|
new AnalysisOutputError("inspect", "invalid reply", {
|
|
capturedOutput: retainedOutput,
|
|
}),
|
|
new AnalysisCapabilityUnavailableError(
|
|
"provider",
|
|
"inspect",
|
|
"unsupported profile",
|
|
{ capturedOutput: retainedOutput },
|
|
),
|
|
new AnalysisCancelledError("inspect", { capturedOutput: retainedOutput }),
|
|
new AnalysisTimeoutError("inspect", 30, { capturedOutput: retainedOutput }),
|
|
new ProviderSelectionError({
|
|
operation: "inspect",
|
|
reason: "provider_unavailable",
|
|
requestedProviderId: "provider",
|
|
candidateIds: [],
|
|
capturedOutput: retainedOutput,
|
|
}),
|
|
])(
|
|
"retains captured output without changing the typed primary failure: $._tag",
|
|
(failure) => {
|
|
expect(projectAnalysisError(failure).details?.captured_output).toEqual(
|
|
retainedOutput,
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
new AnalysisCancelledError("observe_native_calls", {
|
|
cleanup: {
|
|
reason: "target process termination could not be verified",
|
|
resources: ["native-target:4242"],
|
|
},
|
|
}),
|
|
new AnalysisTimeoutError("observe_native_calls", 65_000, {
|
|
cleanup: {
|
|
reason: "target process identity was unavailable",
|
|
resources: ["native-target:4242"],
|
|
},
|
|
}),
|
|
])("retains incomplete cleanup for lifecycle failures: $._tag", (failure) => {
|
|
expect(projectAnalysisError(failure)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
cleanup: "incomplete",
|
|
cleanup_reason: expect.any(String),
|
|
resources: ["native-target:4242"],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("retains the original failure tag and code when cleanup is incomplete", () => {
|
|
const error = new EvidenceIntegrityError(
|
|
"The launched native target did not match its selected digest",
|
|
{
|
|
cleanup: {
|
|
reason: "target survivor identity could not be verified",
|
|
resources: ["native-target:4242"],
|
|
},
|
|
},
|
|
);
|
|
expect(error._tag).toBe("EvidenceIntegrityError");
|
|
expect(projectAnalysisError(error)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
category: "integrity_mismatch",
|
|
details: {
|
|
cleanup: "incomplete",
|
|
cleanup_reason: "target survivor identity could not be verified",
|
|
resources: ["native-target:4242"],
|
|
execution_failure: "evidence_integrity_mismatch",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("projects native partial observations through healthy cancellation failures", () => {
|
|
const projected = projectAnalysisError(
|
|
new AnalysisCancelledError("observe_native_calls", {
|
|
partialObservation: nativeCallPartialObservation,
|
|
}),
|
|
);
|
|
|
|
expect(projected).toMatchObject({
|
|
code: "cancelled",
|
|
details: {
|
|
operation: "observe_native_calls",
|
|
cleanup: "complete",
|
|
partial_observation: nativeCallPartialObservation,
|
|
},
|
|
});
|
|
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
|
|
});
|
|
|
|
it("retains native partial observations and cleanup uncertainty on provider failures", () => {
|
|
const projected = projectAnalysisError(
|
|
new ProviderAdapterError("native-lldb", "observe_native_calls", {
|
|
partialObservation: nativeCallPartialObservation,
|
|
cleanup: {
|
|
reason: "target process termination could not be verified",
|
|
resources: ["native-target:4242"],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(projected).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
provider_id: "native-lldb",
|
|
operation: "observe_native_calls",
|
|
cleanup: "incomplete",
|
|
cleanup_reason: "target process termination could not be verified",
|
|
resources: ["native-target:4242"],
|
|
execution_failure: "execution_failure",
|
|
partial_observation: nativeCallPartialObservation,
|
|
},
|
|
});
|
|
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
|
|
});
|
|
|
|
describe("analysis error projection: browser provider failures", () => {
|
|
it("explains the service-worker policy refusal and names its recovery", () => {
|
|
const projected = projectAnalysisError(
|
|
new BrowserObservationError(
|
|
"capture_browser_scenario",
|
|
"service_worker_blocked",
|
|
),
|
|
);
|
|
|
|
expect(projected).toMatchObject({
|
|
code: "target_unavailable",
|
|
category: "unavailable",
|
|
retryable: false,
|
|
message: expect.stringContaining("service-worker policy is block"),
|
|
remediation: {
|
|
action: expect.stringContaining("environment.service_workers to allow"),
|
|
},
|
|
details: {
|
|
operation: "capture_browser_scenario",
|
|
reason: "service_worker_blocked",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("projects the primary browser failure alongside incomplete cleanup", () => {
|
|
const projected = projectAnalysisError(
|
|
new BrowserObservationError(
|
|
"observe_javascript_runtime",
|
|
"cleanup_failed",
|
|
{
|
|
cause: new AggregateError([
|
|
new BrowserObservationError(
|
|
"observe_javascript_runtime",
|
|
"target_changed",
|
|
),
|
|
new Error("socket close failed"),
|
|
]),
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(projected).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
operation: "observe_javascript_runtime",
|
|
reason: "cleanup_failed",
|
|
cleanup: "incomplete",
|
|
resources: ["browser_transport"],
|
|
primary_reason: "target_changed",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("keeps producer facts when a browser failure is attributed to another operation", () => {
|
|
const partial = { kind: "browser-test-partial" } as never;
|
|
const original = new BrowserObservationError(
|
|
"inspect_web_page",
|
|
"cleanup_failed",
|
|
{
|
|
detail: "Target page-1 closed while REA released its session.",
|
|
remediationAction: "Release session page-1, then retry.",
|
|
cleanup: { reason: "socket close failed", resources: ["page-1"] },
|
|
diagnostics: { endpoint: "loopback" },
|
|
partialObservation: partial,
|
|
cause: new AggregateError([
|
|
new BrowserObservationError("inspect_web_page", "target_changed"),
|
|
new Error("socket close failed"),
|
|
]),
|
|
},
|
|
);
|
|
const relabelled = original.forOperation("capture_web_screenshot");
|
|
|
|
expect(relabelled).toMatchObject({
|
|
operation: "capture_web_screenshot",
|
|
reason: "cleanup_failed",
|
|
cause: original.cause,
|
|
diagnostics: { endpoint: "loopback" },
|
|
partialObservation: partial,
|
|
});
|
|
const { details, ...projected } = projectAnalysisError(relabelled);
|
|
const { details: originalDetails, ...originalProjected } =
|
|
projectAnalysisError(original);
|
|
expect(projected).toEqual(originalProjected);
|
|
expect(projected).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
message: "Target page-1 closed while REA released its session.",
|
|
remediation: { action: "Release session page-1, then retry." },
|
|
});
|
|
expect(details).toEqual({
|
|
...originalDetails,
|
|
operation: "capture_web_screenshot",
|
|
});
|
|
expect(details).toMatchObject({
|
|
resources: ["page-1"],
|
|
primary_reason: "target_changed",
|
|
});
|
|
});
|
|
|
|
it("derives default browser guidance for the attributed operation", () => {
|
|
const original = new BrowserObservationError(
|
|
"inspect_web_page",
|
|
"target_not_found",
|
|
);
|
|
expect(original.forOperation("inspect_web_page")).toBe(original);
|
|
|
|
const relabelled = original.forOperation("discover_webmcp_tools");
|
|
const projected = projectAnalysisError(relabelled);
|
|
expect(projected).toMatchObject({
|
|
code: "target_unavailable",
|
|
message: expect.stringMatching(/^discover_webmcp_tools could not find/),
|
|
details: {
|
|
operation: "discover_webmcp_tools",
|
|
reason: "target_not_found",
|
|
},
|
|
});
|
|
expect(projected.message).not.toContain("inspect_web_page");
|
|
expect(
|
|
projectAnalysisError(
|
|
new BrowserObservationError("inspect_web_page", "timeout").forOperation(
|
|
"observe_web_session",
|
|
),
|
|
),
|
|
).toMatchObject({
|
|
code: projectAnalysisError(
|
|
new BrowserObservationError("observe_web_session", "timeout"),
|
|
).code,
|
|
details: { operation: "observe_web_session", reason: "timeout" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("analysis error projection: Hopper provider failures", () => {
|
|
it("reports a timed-out active Hopper request and actionable retry guidance", () => {
|
|
const projected = projectAnalysisError(
|
|
new HopperTimeoutError(30_000, "trace_feature", 42, "busy"),
|
|
);
|
|
|
|
expect(projected).toMatchObject({
|
|
code: "provider_timeout",
|
|
category: "timeout",
|
|
retryable: true,
|
|
details: {
|
|
stage: "analysis",
|
|
operation: "trace_feature",
|
|
request_id: 42,
|
|
provider_state: "busy",
|
|
timeout_ms: 30_000,
|
|
},
|
|
});
|
|
expect(projected.message).toContain("binary_session.analysis_activity");
|
|
expect(projected.remediation.action).toContain(
|
|
"wait for the active Hopper request",
|
|
);
|
|
});
|
|
|
|
it("identifies the REA session that already owns a Hopper target", () => {
|
|
const error = new HopperStartError({
|
|
ownerRunId: "session-owner",
|
|
userMessage:
|
|
"This Hopper target is already open in REA session session-owner. Use that REA session or close it before opening this target again.",
|
|
});
|
|
expect(projectAnalysisError(error)).toMatchObject({
|
|
category: "unavailable",
|
|
message: expect.stringContaining("session-owner"),
|
|
remediation: {
|
|
action: expect.stringContaining(
|
|
"Use the active REA session session-owner",
|
|
),
|
|
},
|
|
});
|
|
});
|
|
|
|
it("projects an Apple Events denial as unavailable and not retryable from the same client", () => {
|
|
const projected = projectAnalysisError(
|
|
new HopperStartError({
|
|
launchRejection: {
|
|
boundary: "apple_events_authorization",
|
|
reason: "not_authorized",
|
|
},
|
|
launcherFailure: {
|
|
exit_code: 2,
|
|
signal: null,
|
|
stdout: { text: "", bytes: 0, retained_bytes: 0 },
|
|
stderr: {
|
|
text: "Cannot launch Hopper!\nNot authorized to send Apple events to Hopper Disassembler.\n",
|
|
bytes: 90,
|
|
retained_bytes: 90,
|
|
},
|
|
output_closed: true,
|
|
diagnostic_truncated: false,
|
|
},
|
|
}),
|
|
);
|
|
expect(projected).toMatchObject({
|
|
code: "provider_unavailable",
|
|
category: "unavailable",
|
|
retryable: false,
|
|
details: {
|
|
stage: "launch",
|
|
provider_state: "unknown",
|
|
retry_action: "unknown",
|
|
launch_rejection: {
|
|
boundary: "apple_events_authorization",
|
|
reason: "not_authorized",
|
|
},
|
|
launcher: { exit_code: 2, signal: null },
|
|
},
|
|
});
|
|
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
|
|
});
|
|
|
|
it("keeps an ordinary Hopper start failure retryable", () => {
|
|
const projected = projectAnalysisError(
|
|
new HopperStartError({
|
|
userMessage: "Hopper's launcher helper failed before bridge readiness.",
|
|
}),
|
|
);
|
|
expect(projected).toMatchObject({
|
|
code: "provider_unavailable",
|
|
category: "unavailable",
|
|
retryable: true,
|
|
details: {
|
|
stage: "launch",
|
|
provider_state: "unknown",
|
|
retry_action: "retry",
|
|
},
|
|
});
|
|
expect(projected.details).not.toHaveProperty("launch_rejection");
|
|
});
|
|
|
|
it("retains the Hopper request and provider diagnostic on a remote failure", () => {
|
|
expect(
|
|
projectAnalysisError(
|
|
new HopperRemoteError(
|
|
7,
|
|
"Hopper analysis failed in the selected document",
|
|
{
|
|
diagnosticType: "bridge_exception",
|
|
operation: "trace_feature",
|
|
requestId: 43,
|
|
},
|
|
),
|
|
),
|
|
).toMatchObject({
|
|
code: "execution_failure",
|
|
details: {
|
|
stage: "analysis",
|
|
provider_code: 7,
|
|
diagnostic_type: "bridge_exception",
|
|
operation: "trace_feature",
|
|
request_id: 43,
|
|
},
|
|
message: expect.stringContaining("Hopper analysis failed"),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("analysis error projection: detached provider diagnostics", () => {
|
|
it.each(["adapter", "cancelled", "timeout"])(
|
|
"preserves detached, actionable diagnostics for %s errors",
|
|
(kind) => {
|
|
const diagnostics = {
|
|
runtime_root: "/tmp/rea-ghidra-fixture",
|
|
profile_digest: "a".repeat(64),
|
|
exit_code: 1,
|
|
};
|
|
const error =
|
|
kind === "adapter"
|
|
? new ProviderAdapterError("ghidra", "health", { diagnostics })
|
|
: kind === "cancelled"
|
|
? new AnalysisCancelledError("health", { diagnostics })
|
|
: new AnalysisTimeoutError("health", 250, { diagnostics });
|
|
|
|
expect(error.diagnostics).not.toBe(diagnostics);
|
|
expect(projectAnalysisError(error)).toMatchObject({
|
|
details: {
|
|
...(kind === "adapter" ? { provider_id: "ghidra" } : {}),
|
|
operation: "health",
|
|
diagnostics,
|
|
},
|
|
});
|
|
diagnostics.exit_code = 2;
|
|
expect(error.diagnostics?.exit_code).toBe(1);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("analysis error projection: Hopper startup failures", () => {
|
|
it("maps the Linux startup compatibility exit codes without host internals", () => {
|
|
const expected = [
|
|
[70, "private_display_unavailable"],
|
|
[71, "x11_authorization_failed"],
|
|
[72, "unsupported_hopper_build"],
|
|
[73, "invalid_launch_command"],
|
|
[74, "process_ownership_mismatch"],
|
|
[75, "hopper_exited_during_startup"],
|
|
[76, "unsupported_demo_dialog"],
|
|
[77, "unexpected_display_geometry"],
|
|
[78, "x11_input_failed"],
|
|
[79, "runtime_dependency_unavailable"],
|
|
[80, "x11_socket_directory_unusable"],
|
|
[81, "demo_dialog_absent"],
|
|
[82, "invalid_dialog_timeout"],
|
|
] as const;
|
|
|
|
for (const [exitCode, code] of expected) {
|
|
const projected = projectAnalysisError(new HopperProcessError(exitCode));
|
|
expect(projected).toMatchObject({
|
|
code: "provider_unavailable",
|
|
details: { failure_code: code, exit_code: exitCode },
|
|
});
|
|
expect(projected.details).not.toHaveProperty("launch_rejection");
|
|
expect(projected.message).toMatch(/\S/u);
|
|
expect(JSON.stringify(projected)).not.toContain("/proc/");
|
|
}
|
|
});
|
|
|
|
it("does not attribute an absent post-authentication dialog to publisher verification", () => {
|
|
const projected = projectAnalysisError(new HopperProcessError(81));
|
|
expect(projected).toMatchObject({
|
|
code: "provider_unavailable",
|
|
details: {
|
|
stage: "launch",
|
|
failure_code: "demo_dialog_absent",
|
|
exit_code: 81,
|
|
},
|
|
});
|
|
expect(projected.details).not.toHaveProperty("launch_rejection");
|
|
expect(projected.message).toContain("REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS");
|
|
});
|
|
|
|
it("reports a rejected demo dialog timeout through its setting fields", () => {
|
|
const dialogTimeout = {
|
|
setting: HOPPER_DEMO_DIALOG_TIMEOUT_SETTING,
|
|
value: "-5",
|
|
source: "setting",
|
|
minimum_ms: MINIMUM_HOPPER_DEMO_DIALOG_TIMEOUT_MS,
|
|
maximum_ms: MAXIMUM_HOPPER_DEMO_DIALOG_TIMEOUT_MS,
|
|
default_ms: DEFAULT_HOPPER_DEMO_DIALOG_TIMEOUT_MS,
|
|
} satisfies DialogTimeoutRejection;
|
|
const projected = projectAnalysisError(
|
|
new HopperProcessError(82, {
|
|
diagnostic: {
|
|
component: "hopper_private_display",
|
|
operation: "launch",
|
|
reason: "invalid_dialog_timeout",
|
|
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: "direct",
|
|
fallback_reason: null,
|
|
xvfb_stderr_bytes: 0,
|
|
status: "error",
|
|
failure_code: "invalid_dialog_timeout",
|
|
dialog_timeout: dialogTimeout,
|
|
} as HopperStartupFailureDiagnostic,
|
|
}),
|
|
);
|
|
expect(projected).toMatchObject({
|
|
code: "provider_unavailable",
|
|
details: {
|
|
stage: "launch",
|
|
failure_code: "invalid_dialog_timeout",
|
|
exit_code: 82,
|
|
diagnostics: { dialog_timeout: dialogTimeout },
|
|
},
|
|
});
|
|
expect(projected.details).not.toHaveProperty("launch_rejection");
|
|
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("analysis error projection: retry guidance", () => {
|
|
it.each([
|
|
[
|
|
"unreadable output",
|
|
new AnalysisOutputError("inspect_firmware_regions", "report missing"),
|
|
"unreadable_output",
|
|
],
|
|
[
|
|
"adapter failure",
|
|
new ProviderAdapterError("firmware", "inspect_firmware_regions"),
|
|
"execution_failure",
|
|
],
|
|
[
|
|
"Hopper remote failure",
|
|
new HopperRemoteError(7, "Hopper analysis failed", {
|
|
diagnosticType: "bridge_exception",
|
|
operation: "trace_feature",
|
|
}),
|
|
"execution_failure",
|
|
],
|
|
])(
|
|
"does not recommend an unchanged retry for non-retryable %s",
|
|
(_name, error, code) => {
|
|
const projected = projectAnalysisError(error);
|
|
|
|
expect(projected).toMatchObject({ code, retryable: false });
|
|
for (const text of [projected.message, projected.remediation.action])
|
|
expect(text).not.toMatch(/\bretry\b/iu);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("analysis error projection: caller contract", () => {
|
|
it("preserves the reported output constraint without exposing its internal cause", () => {
|
|
const projected = projectAnalysisError(
|
|
new AnalysisOutputError(
|
|
"extract_firmware",
|
|
"Report exceeded the declared byte budget",
|
|
{ cause: new Error("private internal cause") },
|
|
),
|
|
);
|
|
expect(projected).toMatchObject({
|
|
code: "unreadable_output",
|
|
details: {
|
|
operation: "extract_firmware",
|
|
reason: "Report exceeded the declared byte budget",
|
|
},
|
|
});
|
|
expect(JSON.stringify(projected)).not.toContain("private internal cause");
|
|
});
|
|
it("maps representative failures without exposing causes", () => {
|
|
const secretCause = new Error("secret-token");
|
|
const projected = [
|
|
projectAnalysisError(
|
|
new AnalysisInputError("overview", { cause: secretCause }),
|
|
),
|
|
projectAnalysisError(
|
|
new AnalysisCapabilityUnavailableError("fixture", "overview", "absent"),
|
|
),
|
|
projectAnalysisError(
|
|
new BinaryTargetError("/local/targets/app", "invalid", {
|
|
cause: secretCause,
|
|
}),
|
|
),
|
|
];
|
|
|
|
expect(projected.map(({ code }) => code)).toEqual([
|
|
"invalid_request",
|
|
"capability_unavailable",
|
|
"target_unavailable",
|
|
]);
|
|
expect(projected[1]).toMatchObject({
|
|
category: "unsupported_provider",
|
|
message: expect.stringMatching(/unavailable.*target/iu),
|
|
details: {
|
|
provider_id: "fixture",
|
|
operation: "overview",
|
|
reason: "absent",
|
|
},
|
|
});
|
|
expect(projected[2]).toMatchObject({
|
|
details: { path: "/local/targets/app" },
|
|
});
|
|
expect(JSON.stringify(projected)).not.toContain("secret-token");
|
|
});
|
|
|
|
it.each(["EACCES", "EPERM"] as const)(
|
|
"reports a target the host refused to read (%s) as access_denied",
|
|
(systemCode) => {
|
|
const denied = Object.assign(new Error("denied"), { code: systemCode });
|
|
const reason = `permission denied while reading target: ${systemCode}`;
|
|
expect(
|
|
projectAnalysisError(
|
|
new BinaryTargetError("/local/targets/app", reason, {
|
|
cause: denied,
|
|
}),
|
|
),
|
|
).toMatchObject({
|
|
code: "access_denied",
|
|
category: "unavailable",
|
|
retryable: false,
|
|
remediation: {
|
|
action: expect.stringMatching(
|
|
/read access.*selected path.*readable local file/iu,
|
|
),
|
|
},
|
|
details: {
|
|
path: "/local/targets/app",
|
|
reason,
|
|
system_code: systemCode,
|
|
boundary: "filesystem-read",
|
|
},
|
|
});
|
|
expect(
|
|
projectAnalysisError(
|
|
new BinaryTargetError("/local/targets/app", "missing", {
|
|
cause: Object.assign(new Error("missing"), { code: "ENOENT" }),
|
|
}),
|
|
),
|
|
).toMatchObject({ code: "target_unavailable" });
|
|
},
|
|
);
|
|
|
|
it("uses explicit capability recovery while retaining the constraint", () => {
|
|
const projected = projectAnalysisError(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"fixture",
|
|
"capture",
|
|
"host constraint",
|
|
{
|
|
userMessage: "Use the supported host workflow.",
|
|
cause: new Error("private internal cause"),
|
|
},
|
|
),
|
|
);
|
|
expect(projected).toMatchObject({
|
|
code: "capability_unavailable",
|
|
category: "unsupported_provider",
|
|
message: "Use the supported host workflow.",
|
|
details: {
|
|
provider_id: "fixture",
|
|
operation: "capture",
|
|
reason: "host constraint",
|
|
},
|
|
});
|
|
expect(JSON.stringify(projected)).not.toContain("private internal cause");
|
|
});
|
|
|
|
it("preserves exact artifact-integrity coordinates", () => {
|
|
expect(
|
|
projectAnalysisError(
|
|
new ArtifactOperationError("inventory_artifact", "integrity", {
|
|
logicalPath: "main.js",
|
|
declaredSha256: "a".repeat(64),
|
|
calculatedSha256: "b".repeat(64),
|
|
unpacked: true,
|
|
}),
|
|
),
|
|
).toMatchObject({
|
|
category: "integrity_mismatch",
|
|
details: {
|
|
logical_path: "main.js",
|
|
declared_sha256: "a".repeat(64),
|
|
calculated_sha256: "b".repeat(64),
|
|
unpacked: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("gives missing residual unknowns lookup-specific remediation", () => {
|
|
expect(
|
|
projectAnalysisError(new UnknownRegistryError("not-found")),
|
|
).toMatchObject({
|
|
code: "execution_failure",
|
|
message: expect.stringMatching(
|
|
/residual unknown.*does not exist.*unknown_id/iu,
|
|
),
|
|
remediation: {
|
|
action: expect.stringMatching(/unknown_id.*session.*retry/iu),
|
|
},
|
|
details: { reason: "not-found" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("analysis error projection: operational diagnostics", () => {
|
|
it("distinguishes an observed Hopper exit from an undetermined provider failure", () => {
|
|
const exitedError = new HopperProcessError(7, {
|
|
operation: "search_strings",
|
|
requestId: 4,
|
|
});
|
|
const exited = projectAnalysisError(exitedError);
|
|
expect(exited).toMatchObject({
|
|
code: "provider_unavailable",
|
|
retryable: true,
|
|
details: {
|
|
stage: "analysis",
|
|
provider_state: "exited",
|
|
retry_action: "restart_provider",
|
|
operation: "search_strings",
|
|
request_id: 4,
|
|
exit_code: 7,
|
|
},
|
|
});
|
|
expect(exited.message).toBe(analysisErrorUserMessage(exitedError));
|
|
expect(exited.remediation.action).toBe(
|
|
analysisErrorRemediationAction(exitedError),
|
|
);
|
|
expect(exited.message).toMatch(
|
|
/Hopper exited.*search_strings.*exit code 7/iu,
|
|
);
|
|
expect(exited.remediation.action).toMatch(/close_binary.*open.*retry/iu);
|
|
|
|
const unknownError = new HopperProcessError(null, {
|
|
operation: "search_strings",
|
|
requestId: 5,
|
|
});
|
|
const unknown = projectAnalysisError(unknownError);
|
|
expect(unknown.details).toMatchObject({
|
|
stage: "analysis",
|
|
provider_state: "unknown",
|
|
retry_action: "unknown",
|
|
operation: "search_strings",
|
|
request_id: 5,
|
|
exit_code: null,
|
|
});
|
|
expect(unknown.message).toBe(analysisErrorUserMessage(unknownError));
|
|
expect(unknown.remediation.action).toBe(
|
|
analysisErrorRemediationAction(unknownError),
|
|
);
|
|
expect(unknown.message).toMatch(
|
|
/could not determine.*busy, exited, or unreachable/iu,
|
|
);
|
|
expect(unknown.remediation.action).toMatch(
|
|
/provider_operation_health.*before retrying or restarting/iu,
|
|
);
|
|
|
|
const unreachableError = new HopperProcessError(null, {
|
|
operation: "search_strings",
|
|
providerState: "unreachable",
|
|
});
|
|
const unreachable = projectAnalysisError(unreachableError);
|
|
expect(unreachable).toMatchObject({
|
|
code: "provider_unavailable",
|
|
details: {
|
|
provider_state: "unreachable",
|
|
retry_action: "restart_provider",
|
|
operation: "search_strings",
|
|
exit_code: null,
|
|
},
|
|
message: analysisErrorUserMessage(unreachableError),
|
|
remediation: { action: analysisErrorRemediationAction(unreachableError) },
|
|
});
|
|
expect(unreachable.message).toMatch(
|
|
/unreachable.*process exit was not observed/iu,
|
|
);
|
|
expect(unreachable.remediation.action).toMatch(
|
|
/close_binary.*Evidence and Unknown history.*export_evidence_bundle/iu,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("analysis error projection: evidence files", () => {
|
|
it.each([
|
|
["read", "missing", "invalid_request", "invalid_input"],
|
|
["read", "not-file", "invalid_request", "invalid_input"],
|
|
["write", "missing", "invalid_request", "invalid_input"],
|
|
["write", "exists", "invalid_request", "invalid_input"],
|
|
["write", "path-limit", "resource_constraint", "resource_constraint"],
|
|
[
|
|
"read",
|
|
"invalid-json",
|
|
"evidence_integrity_mismatch",
|
|
"execution_failure",
|
|
],
|
|
["read", "io", "execution_failure", "execution_failure"],
|
|
] as const)(
|
|
"classifies a %s %s evidence file by who can correct it",
|
|
(operation, reason, code, category) => {
|
|
const projected = projectAnalysisError(
|
|
new EvidenceFileError(operation, reason, { path: "/selected.json" }),
|
|
);
|
|
expect(projected).toMatchObject({
|
|
code,
|
|
category,
|
|
details: { operation, reason, path: "/selected.json" },
|
|
});
|
|
expect(analysisErrorProjectionSchema.parse(projected)).toEqual(projected);
|
|
},
|
|
);
|
|
});
|
|
|
|
it("retains the primary failure and both cleanup reports after a secondary failure", () => {
|
|
const callerReason = { caller: "stop" };
|
|
const primary = new AnalysisCancelledError("execute_rizin_command", {
|
|
cause: callerReason,
|
|
capturedOutput: {
|
|
stdout: "partial",
|
|
stderr: "diagnostic",
|
|
truncated: false,
|
|
},
|
|
cleanup: { reason: "writer remains", resources: ["writer"] },
|
|
});
|
|
const secondary = new AnalysisOutputError("open_binary", "cannot rebind", {
|
|
cleanup: { reason: "provider remains", resources: ["provider", "writer"] },
|
|
});
|
|
const enriched = primary.retainSecondaryFailure(secondary, {
|
|
rebind_error: projectAnalysisError(secondary),
|
|
});
|
|
expect(enriched).toBe(primary);
|
|
expect(enriched._tag).toBe("AnalysisCancelledError");
|
|
expect(enriched.cause).toBe(callerReason);
|
|
expect(enriched.secondaryFailures).toEqual([secondary]);
|
|
expect(projectAnalysisError(enriched)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
execution_failure: "cancelled",
|
|
resources: ["writer"],
|
|
cleanup_reason: "writer remains",
|
|
captured_output: { stdout: "partial", stderr: "diagnostic" },
|
|
diagnostics: { rebind_error: { code: "cleanup_incomplete" } },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("retains secondary failures when the primary error has no cause", () => {
|
|
const primary = new AnalysisCancelledError("execute_rizin_command");
|
|
const secondary = new AnalysisOutputError("open_binary", "cannot rebind");
|
|
|
|
primary.retainSecondaryFailure(secondary, {});
|
|
|
|
expect(primary.cause).toBeUndefined();
|
|
expect(primary.secondaryFailures).toEqual([secondary]);
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"preserves producer capability recovery with retryable=%s",
|
|
(retryable) => {
|
|
const recovery =
|
|
"Choose an explicit disposition, then retry the same session.";
|
|
const projected = projectAnalysisError(
|
|
new AnalysisCapabilityUnavailableError("fixture", "close", "live state", {
|
|
retryable,
|
|
remediationAction: recovery,
|
|
}),
|
|
);
|
|
expect(projected).toMatchObject({
|
|
code: "capability_unavailable",
|
|
retryable,
|
|
remediation: { action: recovery },
|
|
details: {
|
|
provider_id: "fixture",
|
|
operation: "close",
|
|
reason: "live state",
|
|
},
|
|
});
|
|
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
|
|
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" },
|
|
});
|
|
});
|
|
|
|
it("does not offer entry-policy retries for integrity refusals without a declared-versus-observed entry contradiction", () => {
|
|
const native = projectAnalysisError(
|
|
new ArtifactOperationError(
|
|
"inspect_artifact",
|
|
"integrity",
|
|
undefined,
|
|
"hdiutil verify failed: invalid checksum",
|
|
),
|
|
);
|
|
expect(native.category).toBe("integrity_mismatch");
|
|
expect(native.details).toMatchObject({
|
|
reason: "integrity",
|
|
detail: "hdiutil verify failed: invalid checksum",
|
|
});
|
|
expect(native.message).not.toContain("record-and-continue");
|
|
expect(native.remediation.action).not.toContain("record-and-continue");
|
|
});
|
|
|
|
it("preserves ordinary configuration recovery and projects a producer-owned session repair", () => {
|
|
const settings = [{ setting: "REA_EXAMPLE", constraint: "must be positive" }];
|
|
const original = projectAnalysisError(
|
|
new ConfigurationError("Invalid setting", { settings }),
|
|
);
|
|
const repairing = projectAnalysisError(
|
|
new ConfigurationError("Invalid setting", {
|
|
settings,
|
|
remediationAction: "Repair the running session, then retry.",
|
|
}),
|
|
);
|
|
expect(original.remediation.action).toMatch(
|
|
/correct.*REA_EXAMPLE.*environment.*shell.*MCP.*registration.*rerun/iu,
|
|
);
|
|
expect(repairing).toEqual({
|
|
...original,
|
|
remediation: { action: "Repair the running session, then retry." },
|
|
});
|
|
expect(repairing.code).toBe("configuration_invalid");
|
|
});
|
|
|
|
const continuingOnly = {
|
|
userMessage: "The fixture session remains usable.",
|
|
remediationAction: "Update the fixture runtime setting on this session.",
|
|
};
|
|
|
|
describe("analysis error projection: caller lifetime", () => {
|
|
it.each([
|
|
new AnalysisTimeoutError("procedure_pseudo_code", 5000, {
|
|
userMessage: "Only this request stopped.",
|
|
remediationAction: "Rerun with a longer launch deadline.",
|
|
continuingCallerGuidance: continuingOnly,
|
|
}),
|
|
new ProviderAdapterError("fixture", "procedure_pseudo_code", {
|
|
userMessage: "Only this request stopped.",
|
|
remediationAction: "Rerun with a longer launch deadline.",
|
|
continuingCallerGuidance: continuingOnly,
|
|
}),
|
|
new AnalysisResourceConstraintError(
|
|
"search_strings",
|
|
"cpu",
|
|
"Only this request stopped.",
|
|
null,
|
|
{
|
|
remediationAction: "Rerun with a longer launch deadline.",
|
|
continuingCallerGuidance: continuingOnly,
|
|
},
|
|
),
|
|
])(
|
|
"states continuing-session guidance only to a continuing caller ($_tag)",
|
|
(error) => {
|
|
expect(projectAnalysisError(error)).toMatchObject({
|
|
message:
|
|
"Only this request stopped. The fixture session remains usable.",
|
|
remediation: {
|
|
action: "Update the fixture runtime setting on this session.",
|
|
},
|
|
});
|
|
const invocation = projectAnalysisError(error, "invocation");
|
|
expect(invocation).toMatchObject({
|
|
code: projectAnalysisError(error).code,
|
|
message: "Only this request stopped.",
|
|
remediation: { action: "Rerun with a longer launch deadline." },
|
|
});
|
|
expect(analysisErrorUserMessage(error, "invocation")).toBe(
|
|
"Only this request stopped.",
|
|
);
|
|
expect(analysisErrorRemediationAction(error, "invocation")).toBe(
|
|
"Rerun with a longer launch deadline.",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("re-scopes a limit for a finished invocation and keeps its enforced facts", () => {
|
|
const limit = analysisSettingLimit(
|
|
"regex.matching_deadline_ms",
|
|
250,
|
|
"setting",
|
|
);
|
|
const error = new AnalysisResourceConstraintError(
|
|
"search_strings",
|
|
"cpu",
|
|
"Regex matching exceeded its deadline.",
|
|
null,
|
|
{ limit },
|
|
);
|
|
expect(projectAnalysisError(error).details?.limit).toEqual(limit);
|
|
expect(projectAnalysisError(error, "invocation").details?.limit).toEqual({
|
|
setting: "regex.matching_deadline_ms",
|
|
effective_value: 250,
|
|
source: "setting",
|
|
unit: "ms",
|
|
minimum: limit.minimum,
|
|
maximum: limit.maximum,
|
|
agent_editable: true,
|
|
update_mode: "runtime",
|
|
pending_restart: false,
|
|
applies_at: "next_invocation",
|
|
scope: "rea-invocation",
|
|
environment: "REA_REGEX_MATCHING_DEADLINE_MS",
|
|
});
|
|
// The error keeps its runtime facts for other projections.
|
|
expect(error.limit).toEqual(limit);
|
|
});
|
|
|
|
it("names a per-call parameter, not an environment, when a setting has no launch seed", () => {
|
|
const error = new AnalysisTimeoutError("inspect_wasm_artifact", 100, {
|
|
limit: analysisSettingLimit("wasm.command_timeout_ms", 100, "request"),
|
|
});
|
|
expect(projectAnalysisError(error, "invocation").details?.limit).toEqual(
|
|
expect.objectContaining({
|
|
setting: "wasm.command_timeout_ms",
|
|
source: "request",
|
|
update_mode: "parameter",
|
|
parameter: "command_timeout_ms",
|
|
applies_at: "next_invocation",
|
|
scope: "rea-invocation",
|
|
}),
|
|
);
|
|
expect(
|
|
analysisSettingLimit("wasm.command_timeout_ms", 100, "runtime"),
|
|
).toMatchObject({ source: "runtime", update_mode: "runtime" });
|
|
expect(
|
|
analysisSettingLimit("javascript.max_heap_mb", 128, "request"),
|
|
).toMatchObject({
|
|
source: "request",
|
|
update_mode: "parameter",
|
|
parameter: "max_heap_mb",
|
|
});
|
|
expect(
|
|
analysisSettingLimit("javascript.analysis_timeout_ms", 1, "default"),
|
|
).toMatchObject({ source: "default", update_mode: "runtime" });
|
|
expect(
|
|
projectAnalysisError(error, "invocation").details?.limit,
|
|
).not.toHaveProperty("environment");
|
|
});
|
|
|
|
it("delivers each lifetime's Hopper startup recovery", () => {
|
|
const error = new HopperTimeoutError(
|
|
30_000,
|
|
undefined,
|
|
undefined,
|
|
"not_started",
|
|
"launch",
|
|
undefined,
|
|
{
|
|
limit: analysisSettingLimit(
|
|
"hopper.startup_timeout_ms",
|
|
30_000,
|
|
undefined,
|
|
),
|
|
},
|
|
);
|
|
expect(projectAnalysisError(error).remediation.action).toMatch(
|
|
/update_analysis_settings.*hopper\.startup_timeout_ms.*without restarting REA/u,
|
|
);
|
|
const invocation = projectAnalysisError(error, "invocation");
|
|
expect(invocation.remediation.action).toBe(
|
|
HOPPER_STARTUP_TIMEOUT_REMEDIATION,
|
|
);
|
|
expect(invocation.details?.limit).toMatchObject({
|
|
setting: "hopper.startup_timeout_ms",
|
|
effective_value: 30_000,
|
|
scope: "rea-invocation",
|
|
applies_at: "next_invocation",
|
|
environment: "REA_HOPPER_STARTUP_TIMEOUT_MS",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("analysis error projection: Hopper caller lifetime", () => {
|
|
it.each([
|
|
[
|
|
new HopperProcessError(7, { operation: "search_strings", requestId: 4 }),
|
|
"close_binary",
|
|
"exit code 7",
|
|
],
|
|
[
|
|
new HopperProcessError(null, {
|
|
operation: "search_strings",
|
|
providerState: "unreachable",
|
|
}),
|
|
"export_evidence_bundle",
|
|
"process exit was not observed",
|
|
],
|
|
[
|
|
new HopperProcessError(null, { operation: "search_strings" }),
|
|
"binary_session.provider_operation_health",
|
|
"could not determine",
|
|
],
|
|
[
|
|
new HopperTimeoutError(30_000, "search_strings", 42, "busy"),
|
|
"binary_session.analysis_activity",
|
|
"remained busy",
|
|
],
|
|
[
|
|
new HopperTimeoutError(30_000, "search_strings", 42, "not_started"),
|
|
"binary_session",
|
|
"before it started",
|
|
],
|
|
] as const)(
|
|
"keeps Hopper outcome facts and scopes request recovery to the caller",
|
|
(error, continuingTool, outcome) => {
|
|
const continuing = projectAnalysisError(error);
|
|
const invocation = projectAnalysisError(error, "invocation");
|
|
expect(continuing.message).toContain(continuingTool);
|
|
expect(continuing.remediation.action).toContain(continuingTool);
|
|
expect(invocation.message).toContain(outcome);
|
|
expect(invocation.message).not.toContain(continuingTool);
|
|
expect(invocation.remediation.action).toContain("rerunning the command");
|
|
expect(invocation.remediation.action).not.toContain(continuingTool);
|
|
expect(invocation.details).toEqual(continuing.details);
|
|
expect(invocation.code).toBe(continuing.code);
|
|
expect(invocation.category).toBe(continuing.category);
|
|
expect(invocation.retryable).toBe(continuing.retryable);
|
|
expect(analysisErrorProjectionSchema.parse(invocation)).toEqual(
|
|
invocation,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("keeps Hopper recovery scoped through composed cleanup and its history", () => {
|
|
const primary = new HopperProcessError(null, {
|
|
operation: "search_strings",
|
|
providerState: "unreachable",
|
|
});
|
|
const cleanup = new ProviderCleanupError("hopper", ["hopper-bridge"], {});
|
|
const composed = analysisErrorWithCleanupFailure(
|
|
primary,
|
|
cleanup,
|
|
).bindCleanupDependencies([cleanup]);
|
|
const invocationPrimary = projectAnalysisError(primary, "invocation");
|
|
const continuingPrimary = projectAnalysisError(primary);
|
|
const invocation = projectAnalysisError(composed, "invocation");
|
|
const continuing = projectAnalysisError(composed);
|
|
expect(invocation.message).toBe(invocationPrimary.message);
|
|
expect(continuing.message).toBe(continuingPrimary.message);
|
|
expect(invocation.details).toMatchObject({
|
|
resources: ["hopper-bridge"],
|
|
diagnostics: { primary_error: invocationPrimary },
|
|
});
|
|
expect(continuing.details).toMatchObject({
|
|
diagnostics: { primary_error: continuingPrimary },
|
|
});
|
|
|
|
cleanup.resolveCleanup();
|
|
expect(
|
|
projectAnalysisError(composed, "invocation").details?.cleanup_history,
|
|
).toMatchObject({ primary_error: invocationPrimary });
|
|
expect(
|
|
projectAnalysisError(composed).details?.cleanup_history,
|
|
).toMatchObject({ primary_error: continuingPrimary });
|
|
});
|
|
});
|
|
|
|
describe("analysis error projection: composed failures for a caller lifetime", () => {
|
|
it("projects a composed cleanup failure's children for the caller and keeps their history", () => {
|
|
const limit = analysisSettingLimit(
|
|
"regex.matching_deadline_ms",
|
|
250,
|
|
"setting",
|
|
);
|
|
const primary = new AnalysisTimeoutError("search_strings", 250, {
|
|
limit,
|
|
userMessage: "Only this request stopped.",
|
|
remediationAction: "Rerun with a longer launch deadline.",
|
|
continuingCallerGuidance: continuingOnly,
|
|
});
|
|
const cleanup = new ProviderCleanupError(
|
|
"fixture",
|
|
["fixture-process"],
|
|
{ reason: "exit unconfirmed" },
|
|
{ remediationAction: "Close the retained fixture process." },
|
|
);
|
|
const composed = analysisErrorWithCleanupFailure(
|
|
primary,
|
|
cleanup,
|
|
).bindCleanupDependencies([primary, cleanup]);
|
|
const child = (lifetime: AnalysisCallerLifetime) =>
|
|
projectAnalysisError(composed, lifetime).details;
|
|
expect(child("invocation")).toMatchObject({
|
|
limit: { scope: "rea-invocation" },
|
|
diagnostics: {
|
|
primary_error: projectAnalysisError(primary, "invocation"),
|
|
cleanup_error: { code: "cleanup_incomplete" },
|
|
},
|
|
});
|
|
expect(child("continuing")).toMatchObject({
|
|
limit: { scope: "rea-runtime" },
|
|
diagnostics: { primary_error: projectAnalysisError(primary) },
|
|
});
|
|
expect(projectAnalysisError(composed, "invocation").message).toBe(
|
|
"Only this request stopped.",
|
|
);
|
|
expect(projectAnalysisError(composed).message).toBe(
|
|
`Only this request stopped. ${continuingOnly.userMessage}`,
|
|
);
|
|
// After cleanup resolves, history keeps the composition-time snapshot.
|
|
cleanup.resolveCleanup();
|
|
for (const lifetime of ["continuing", "invocation"] as const)
|
|
expect(child(lifetime)?.cleanup_history).toMatchObject({
|
|
primary_error: projectAnalysisError(primary, lifetime),
|
|
cleanup_error: { code: "cleanup_incomplete" },
|
|
});
|
|
});
|
|
|
|
it("keeps caller lifetime for a secondary failure embedded in child diagnostics", () => {
|
|
const primary = new AnalysisInputError("read_bytes", undefined, []);
|
|
const secondary = new AnalysisTimeoutError("search_strings", 250, {
|
|
limit: analysisSettingLimit("regex.matching_deadline_ms", 250, "setting"),
|
|
continuingCallerGuidance: continuingOnly,
|
|
});
|
|
primary.retainSecondaryFailure(secondary);
|
|
const history = childFailureDiagnostics({
|
|
primary_error: { error: primary },
|
|
});
|
|
expect(history.diagnostics?.primary_error).toEqual(
|
|
projectAnalysisError(primary, "continuing"),
|
|
);
|
|
expect(history.invocationDiagnostics?.primary_error).toEqual(
|
|
projectAnalysisError(primary, "invocation"),
|
|
);
|
|
});
|
|
|
|
it("records one lifetime form when no child depends on the caller", () => {
|
|
const composed = analysisErrorWithCleanupFailure(
|
|
new AnalysisInputError("read_bytes", undefined, [
|
|
{ path: ["address"], reason: "invalid_format" },
|
|
]),
|
|
new ProviderCleanupError("fixture", ["fixture-process"], {}),
|
|
);
|
|
expect(composed.invocationDiagnostics).toBeUndefined();
|
|
expect(projectAnalysisError(composed, "invocation").details).toEqual(
|
|
projectAnalysisError(composed).details,
|
|
);
|
|
});
|
|
|
|
it("keeps continuing guidance through redaction and redacts its text", () => {
|
|
const guidance = {
|
|
userMessage: "The session for secret-token remains usable.",
|
|
remediationAction: "Retry secret-token on this session.",
|
|
};
|
|
const error = new AnalysisTimeoutError("procedure_pseudo_code", 5000, {
|
|
userMessage: "Only this request stopped.",
|
|
remediationAction: "Rerun with a longer launch deadline.",
|
|
continuingCallerGuidance: guidance,
|
|
});
|
|
const redact = (text: string) => redactExplicitText(text, ["secret-token"]);
|
|
const redacted = redactExplicitFailure(error, ["secret-token"]);
|
|
expect(projectAnalysisError(redacted)).toMatchObject({
|
|
message: `Only this request stopped. ${redact(guidance.userMessage)}`,
|
|
remediation: { action: redact(guidance.remediationAction) },
|
|
});
|
|
expect(JSON.stringify(projectAnalysisError(redacted))).not.toContain(
|
|
"secret-token",
|
|
);
|
|
expect(projectAnalysisError(redacted, "invocation")).toMatchObject({
|
|
message: "Only this request stopped.",
|
|
remediation: { action: "Rerun with a longer launch deadline." },
|
|
});
|
|
});
|
|
});
|