602 lines
19 KiB
TypeScript
602 lines
19 KiB
TypeScript
import type { JsonValue } from "./jsonValue.js";
|
|
import type {
|
|
BrowserObservationFailureReason,
|
|
BrowserObservationOperation,
|
|
} from "./browserObservationErrors.js";
|
|
|
|
export type { BrowserObservationOperation } from "./browserObservationErrors.js";
|
|
import type {
|
|
MissingPermissionScope,
|
|
PermissionRequest,
|
|
PermissionScope,
|
|
} from "./permissionPolicy.js";
|
|
import {
|
|
hopperStartupFailure,
|
|
type HopperStartupDiagnostic,
|
|
type HopperStartupFailureDiagnostic,
|
|
type HopperStartupFailureCode,
|
|
} from "./hopperStartupFailure.js";
|
|
export {
|
|
hopperStartupFailure,
|
|
type HopperStartupDiagnostic,
|
|
type HopperStartupFailureDiagnostic,
|
|
type HopperStartupFailureCode,
|
|
};
|
|
export { projectAnalysisError } from "./analysisErrorProjection.js";
|
|
|
|
/** Stable tags exposed by safe analysis-error projections. */
|
|
const ANALYSIS_ERROR_TAGS = [
|
|
"AnalysisProtocolError",
|
|
"AnalysisInputError",
|
|
"AnalysisOutputError",
|
|
"AnalysisCapabilityUnavailableError",
|
|
"AnalysisCancelledError",
|
|
"AnalysisTimeoutError",
|
|
"ProviderSelectionError",
|
|
"ProviderAdapterError",
|
|
"BrowserObservationError",
|
|
"ArtifactOperationError",
|
|
"ProcessCaptureError",
|
|
"EvidenceIntegrityError",
|
|
"EvidenceLimitError",
|
|
"EvidenceFileError",
|
|
"WorkspaceStorageError",
|
|
"UnknownRegistryError",
|
|
"HopperTimeoutError",
|
|
"HopperCancelledError",
|
|
"HopperProtocolError",
|
|
"HopperRemoteError",
|
|
"HopperProcessError",
|
|
"HopperStartError",
|
|
"ConfigurationError",
|
|
"NoBinaryOpenError",
|
|
"BinaryTargetError",
|
|
"PermissionRequiredError",
|
|
"ReplayPlanStaleError",
|
|
] as const;
|
|
|
|
/** Stable tag for an expected analysis failure. */
|
|
export type AnalysisErrorTag = (typeof ANALYSIS_ERROR_TAGS)[number];
|
|
|
|
/** Base class for expected analysis, provider, and session failures. */
|
|
export abstract class AnalysisError extends Error {
|
|
abstract readonly _tag: AnalysisErrorTag;
|
|
readonly userMessage: string | undefined = undefined;
|
|
readonly userCategory: "permission_required" | "cancelled" | undefined =
|
|
undefined;
|
|
readonly cleanupIncomplete: boolean = false;
|
|
readonly cleanupResources: readonly string[] = [];
|
|
}
|
|
|
|
/** Base class for failures produced specifically by the Hopper provider. */
|
|
export abstract class HopperError extends AnalysisError {}
|
|
|
|
/** Provider-neutral invalid analysis input or output at an application boundary. */
|
|
export class AnalysisProtocolError extends AnalysisError {
|
|
readonly _tag = "AnalysisProtocolError";
|
|
}
|
|
|
|
/** Caller input failed provider-neutral application parsing. */
|
|
export class AnalysisInputError extends AnalysisError {
|
|
readonly _tag = "AnalysisInputError";
|
|
|
|
constructor(
|
|
readonly operation: string,
|
|
options?: ErrorOptions,
|
|
readonly issues: readonly AnalysisInputIssue[] = [],
|
|
) {
|
|
super(`Invalid analysis input for ${operation}`, options);
|
|
}
|
|
}
|
|
|
|
/** Secret-safe correction metadata for one rejected caller argument. */
|
|
export interface AnalysisInputIssue {
|
|
readonly path: readonly (string | number)[];
|
|
readonly reason:
|
|
| "unknown_argument"
|
|
| "missing_argument"
|
|
| "invalid_type"
|
|
| "out_of_range"
|
|
| "invalid_value"
|
|
| "invalid_format";
|
|
/** Schema-authored correction guidance for cross-field or custom checks. */
|
|
readonly message?: string;
|
|
readonly expected?: JsonValue;
|
|
readonly minimum?: number;
|
|
readonly maximum?: number;
|
|
}
|
|
|
|
/** Provider output failed provider-neutral application parsing. */
|
|
export class AnalysisOutputError extends AnalysisError {
|
|
readonly _tag = "AnalysisOutputError";
|
|
|
|
constructor(
|
|
readonly operation: string,
|
|
readonly reason: string,
|
|
options?: ErrorOptions,
|
|
) {
|
|
super(`Invalid analysis output for ${operation}: ${reason}`, options);
|
|
}
|
|
}
|
|
|
|
/** Selected provider cannot execute a declared analysis operation. */
|
|
export class AnalysisCapabilityUnavailableError extends AnalysisError {
|
|
readonly _tag = "AnalysisCapabilityUnavailableError";
|
|
|
|
constructor(
|
|
readonly providerId: string,
|
|
readonly operation: string,
|
|
readonly reason: string,
|
|
) {
|
|
super(`Provider ${providerId} cannot execute ${operation}: ${reason}`);
|
|
}
|
|
}
|
|
|
|
/** Caller cancellation won before provider-neutral work completed. */
|
|
export class AnalysisCancelledError extends AnalysisError {
|
|
readonly _tag = "AnalysisCancelledError";
|
|
|
|
constructor(readonly operation: string) {
|
|
super(`Analysis operation was cancelled: ${operation}`);
|
|
}
|
|
}
|
|
|
|
/** Provider-neutral operation exceeded its declared execution deadline. */
|
|
export class AnalysisTimeoutError extends AnalysisError {
|
|
readonly _tag = "AnalysisTimeoutError";
|
|
|
|
constructor(
|
|
readonly operation: string,
|
|
readonly timeoutMs: number,
|
|
) {
|
|
super(
|
|
`Analysis operation timed out after ${String(timeoutMs)}ms: ${operation}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Stable failure classes for target-bound deep-provider selection. */
|
|
export type ProviderSelectionFailureReason =
|
|
| "unknown_provider"
|
|
| "provider_unavailable"
|
|
| "target_unsupported"
|
|
| "ambiguous"
|
|
| "invalid_options";
|
|
|
|
/** One actionable candidate rejection retained in caller diagnostics. */
|
|
export interface ProviderSelectionRejection {
|
|
readonly providerId: string;
|
|
readonly code: string;
|
|
readonly reason: string;
|
|
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
|
}
|
|
|
|
/** Complete typed context for one failed deep-provider selection. */
|
|
export interface ProviderSelectionErrorOptions {
|
|
readonly operation?: string;
|
|
readonly reason: ProviderSelectionFailureReason;
|
|
readonly requestedProviderId: string;
|
|
readonly candidateIds: readonly string[];
|
|
readonly rejections?: readonly ProviderSelectionRejection[];
|
|
}
|
|
|
|
/** No configured provider can satisfy a requested operation or target binding. */
|
|
export class ProviderSelectionError extends AnalysisError {
|
|
readonly _tag = "ProviderSelectionError";
|
|
readonly operation: string;
|
|
readonly reason: ProviderSelectionFailureReason;
|
|
readonly requestedProviderId: string;
|
|
readonly candidateIds: readonly string[];
|
|
readonly rejections: readonly ProviderSelectionRejection[];
|
|
override readonly userMessage: string;
|
|
|
|
constructor(options: ProviderSelectionErrorOptions) {
|
|
const operation = options.operation ?? "open_binary";
|
|
super(providerSelectionDiagnostic(options));
|
|
this.operation = operation;
|
|
this.reason = options.reason;
|
|
this.requestedProviderId = options.requestedProviderId;
|
|
this.candidateIds = [...options.candidateIds];
|
|
this.rejections = [...(options.rejections ?? [])].map((rejection) => ({
|
|
...rejection,
|
|
diagnostics: structuredClone(rejection.diagnostics),
|
|
}));
|
|
this.userMessage = providerSelectionUserMessage(options);
|
|
}
|
|
}
|
|
|
|
const providerSelectionDiagnostic = (
|
|
options: ProviderSelectionErrorOptions,
|
|
): string => {
|
|
const candidates =
|
|
options.candidateIds.length === 0
|
|
? "none"
|
|
: options.candidateIds.join(", ");
|
|
return `Analysis provider selection failed (${options.reason}) for ${options.requestedProviderId}; candidates: ${candidates}`;
|
|
};
|
|
|
|
const providerSelectionUserMessage = (
|
|
options: ProviderSelectionErrorOptions,
|
|
): string => {
|
|
switch (options.reason) {
|
|
case "ambiguous":
|
|
return "Multiple analysis providers support this target. Choose one with provider_id, --provider, or REA_ANALYSIS_PROVIDER.";
|
|
case "unknown_provider":
|
|
return `The requested analysis provider is unknown. Choose one of: ${options.candidateIds.join(", ") || "no configured providers"}.`;
|
|
case "provider_unavailable":
|
|
return "The selected analysis provider is unavailable. Review the reported local diagnostics or run `rea doctor`, then retry.";
|
|
case "target_unsupported":
|
|
return "The selected analysis provider does not support this target. Choose another provider or open a supported extracted binary.";
|
|
case "invalid_options":
|
|
return "The analysis provider selection or open options are invalid. Correct them and try again.";
|
|
}
|
|
};
|
|
|
|
/** A provider adapter failed outside its more precise typed variants. */
|
|
export interface ProviderAdapterErrorOptions extends ErrorOptions {
|
|
readonly diagnostics?: Readonly<Record<string, JsonValue>>;
|
|
}
|
|
|
|
/** A provider adapter failed outside its more precise typed variants. */
|
|
export class ProviderAdapterError extends AnalysisError {
|
|
readonly _tag = "ProviderAdapterError";
|
|
readonly diagnostics: Readonly<Record<string, JsonValue>> | undefined;
|
|
|
|
constructor(
|
|
readonly providerId: string,
|
|
readonly operation: string,
|
|
options: ProviderAdapterErrorOptions = {},
|
|
) {
|
|
super(`Provider ${providerId} adapter failed during ${operation}`, options);
|
|
this.diagnostics =
|
|
options.diagnostics === undefined
|
|
? undefined
|
|
: structuredClone(options.diagnostics);
|
|
}
|
|
}
|
|
|
|
/** A bounded passive browser observation failed at its CDP boundary. */
|
|
export class BrowserObservationError extends AnalysisError {
|
|
readonly _tag = "BrowserObservationError";
|
|
|
|
constructor(
|
|
readonly operation: BrowserObservationOperation,
|
|
readonly reason: BrowserObservationFailureReason,
|
|
options?: ErrorOptions,
|
|
) {
|
|
super(`Browser observation ${operation} failed: ${reason}`, options);
|
|
}
|
|
}
|
|
|
|
/** Artifact inventory or extraction failed a typed safety boundary. */
|
|
export class ArtifactOperationError extends AnalysisError {
|
|
readonly _tag = "ArtifactOperationError";
|
|
|
|
constructor(
|
|
readonly operation:
|
|
| "inventory_artifact"
|
|
| "inspect_artifact"
|
|
| "extract_artifact"
|
|
| "analyze_javascript_application",
|
|
readonly reason:
|
|
| "cancelled"
|
|
| "format"
|
|
| "integrity"
|
|
| "limit"
|
|
| "path"
|
|
| "policy"
|
|
| "unavailable"
|
|
| "io",
|
|
readonly artifactDetails?: Readonly<{
|
|
logicalPath: string;
|
|
declaredSha256: string | null;
|
|
calculatedSha256: string | null;
|
|
unpacked: boolean;
|
|
}>,
|
|
) {
|
|
super(
|
|
artifactDetails === undefined
|
|
? `Artifact ${operation} failed: ${reason}`
|
|
: `Artifact ${operation} failed: ${reason} at ${artifactDetails.logicalPath} (declared_sha256=${artifactDetails.declaredSha256 ?? "unavailable"}, calculated_sha256=${artifactDetails.calculatedSha256 ?? "unavailable"}, unpacked=${String(artifactDetails.unpacked)})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Evidence identity, schema, or bundle manifests failed integrity checks. */
|
|
export class EvidenceIntegrityError extends AnalysisError {
|
|
readonly _tag = "EvidenceIntegrityError";
|
|
}
|
|
|
|
/** A session Evidence reference is missing or has the wrong semantic identity. */
|
|
export class EvidenceReferenceError extends EvidenceIntegrityError {
|
|
constructor(
|
|
readonly evidenceId: string,
|
|
readonly reason: "missing" | "wrong_operation" | "wrong_predicate",
|
|
readonly expected: string,
|
|
readonly actual: string | null,
|
|
) {
|
|
super(`Evidence reference ${reason}: ${evidenceId}`);
|
|
}
|
|
}
|
|
|
|
/** A bounded evidence ledger cannot accept more records or serialized bytes. */
|
|
export class EvidenceLimitError extends AnalysisError {
|
|
readonly _tag = "EvidenceLimitError";
|
|
|
|
constructor(
|
|
readonly limit: "records" | "bytes",
|
|
readonly maximum: number,
|
|
) {
|
|
super(`Evidence ledger ${limit} limit exceeded (${String(maximum)})`);
|
|
}
|
|
}
|
|
|
|
/** Evidence bundle filesystem access failed within the configured policy. */
|
|
export class EvidenceFileError extends AnalysisError {
|
|
readonly _tag = "EvidenceFileError";
|
|
|
|
constructor(
|
|
readonly operation: "read" | "write",
|
|
readonly reason:
|
|
| "disabled"
|
|
| "outside-root"
|
|
| "not-file"
|
|
| "too-large"
|
|
| "exists"
|
|
| "invalid-json"
|
|
| "io",
|
|
options?: ErrorOptions,
|
|
) {
|
|
super(`Evidence bundle ${operation} failed: ${reason}`, options);
|
|
}
|
|
}
|
|
|
|
/** A persisted reconstruction coverage workspace could not be read or updated. */
|
|
export class WorkspaceStorageError extends AnalysisError {
|
|
readonly _tag = "WorkspaceStorageError";
|
|
|
|
constructor(
|
|
readonly operation: "read" | "update",
|
|
readonly reason:
|
|
| "disabled"
|
|
| "outside-root"
|
|
| "not-file"
|
|
| "too-large"
|
|
| "invalid-json"
|
|
| "integrity"
|
|
| "locked"
|
|
| "revision-conflict"
|
|
| "name-conflict"
|
|
| "io",
|
|
options?: ErrorOptions,
|
|
) {
|
|
super(`Workspace storage ${operation} failed: ${reason}`, options);
|
|
}
|
|
}
|
|
|
|
/** Residual-unknown mutation failed a lifecycle, reference, or CAS invariant. */
|
|
export class UnknownRegistryError extends AnalysisError {
|
|
readonly _tag = "UnknownRegistryError";
|
|
|
|
constructor(
|
|
readonly reason:
|
|
| "not-found"
|
|
| "already-exists"
|
|
| "revision-conflict"
|
|
| "invalid-transition"
|
|
| "integrity"
|
|
| "limit",
|
|
options?: ErrorOptions,
|
|
) {
|
|
super(`Residual unknown registry mutation failed: ${reason}`, options);
|
|
}
|
|
}
|
|
|
|
/** Hopper did not respond within the configured operation deadline. */
|
|
export class HopperTimeoutError extends HopperError {
|
|
readonly _tag = "HopperTimeoutError";
|
|
|
|
constructor(
|
|
readonly timeoutMs: number,
|
|
readonly operation?: string,
|
|
readonly requestId?: number,
|
|
readonly providerState: "busy" | "not_started" = "not_started",
|
|
) {
|
|
super(
|
|
`Hopper ${operation === undefined ? "startup" : operation} timed out after ${String(timeoutMs)}ms`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** The caller cancelled a Hopper operation before it completed. */
|
|
export class HopperCancelledError extends HopperError {
|
|
readonly _tag = "HopperCancelledError";
|
|
|
|
constructor() {
|
|
super("Hopper operation was cancelled");
|
|
}
|
|
}
|
|
|
|
/** Hopper returned bytes or JSON that do not satisfy the NDJSON RPC contract. */
|
|
export class HopperProtocolError extends HopperError {
|
|
readonly _tag = "HopperProtocolError";
|
|
}
|
|
|
|
export type HopperDiagnosticType =
|
|
| "remote"
|
|
| "authorization"
|
|
| "invalid_request"
|
|
| "capability_unavailable"
|
|
| "bridge_exception";
|
|
|
|
/** Hopper's JSON-RPC endpoint returned an expected remote error response. */
|
|
export interface HopperRemoteErrorContext {
|
|
readonly diagnosticType?: HopperDiagnosticType;
|
|
readonly operation?: string;
|
|
readonly requestId?: number;
|
|
}
|
|
|
|
export class HopperRemoteError extends HopperError {
|
|
readonly _tag = "HopperRemoteError";
|
|
readonly diagnosticType: HopperDiagnosticType;
|
|
readonly operation: string | undefined;
|
|
readonly requestId: number | undefined;
|
|
|
|
constructor(
|
|
readonly code: number,
|
|
readonly safeMessage: string,
|
|
context: HopperRemoteErrorContext = {},
|
|
) {
|
|
super(`Hopper request failed (${String(code)}): ${safeMessage}`);
|
|
this.diagnosticType = context.diagnosticType ?? "remote";
|
|
this.operation = context.operation;
|
|
this.requestId = context.requestId;
|
|
}
|
|
}
|
|
|
|
/** The owned Hopper bridge stopped before its client was closed. */
|
|
export class HopperProcessError extends HopperError {
|
|
readonly _tag = "HopperProcessError";
|
|
readonly failureCode: HopperStartupFailureCode | undefined;
|
|
override readonly userMessage: string | undefined;
|
|
|
|
constructor(
|
|
readonly exitCode: number | null,
|
|
readonly diagnostic?: HopperStartupFailureDiagnostic,
|
|
readonly operation?: string,
|
|
readonly requestId?: number,
|
|
) {
|
|
super(`Hopper bridge stopped unexpectedly with code ${String(exitCode)}`);
|
|
const failure = hopperStartupFailure(exitCode);
|
|
this.failureCode = failure?.code;
|
|
this.userMessage = failure?.message;
|
|
}
|
|
}
|
|
|
|
/** Hopper or the repository bridge could not be started. */
|
|
export class HopperStartError extends HopperError {
|
|
readonly _tag = "HopperStartError";
|
|
override readonly userMessage: string | undefined;
|
|
readonly ownerRunId: string | undefined;
|
|
|
|
constructor(
|
|
options?: ErrorOptions & {
|
|
readonly userMessage?: string;
|
|
readonly ownerRunId?: string;
|
|
},
|
|
) {
|
|
super("Hopper application bridge could not be started", options);
|
|
this.userMessage = options?.userMessage;
|
|
this.ownerRunId = options?.ownerRunId;
|
|
}
|
|
}
|
|
|
|
/** Runtime configuration could not be parsed safely. */
|
|
export class ConfigurationError extends AnalysisError {
|
|
readonly _tag = "ConfigurationError";
|
|
}
|
|
|
|
/** No app or binary session exists for an analysis request. */
|
|
export class NoBinaryOpenError extends AnalysisError {
|
|
readonly _tag = "NoBinaryOpenError";
|
|
constructor() {
|
|
super(
|
|
"No app is open. Ask the user which app to investigate, then call open_binary with its local path.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/** A supplied target path could not be safely opened as a supported app or binary. */
|
|
export class BinaryTargetError extends AnalysisError {
|
|
readonly _tag = "BinaryTargetError";
|
|
constructor(
|
|
readonly path: string,
|
|
reason: string,
|
|
options?: ErrorOptions,
|
|
) {
|
|
super(`Cannot open artifact: ${reason}`, options);
|
|
}
|
|
}
|
|
|
|
export interface AnalysisErrorProjection
|
|
extends Readonly<Record<string, JsonValue>> {
|
|
readonly code:
|
|
| "invalid_request"
|
|
| "unreadable_output"
|
|
| "capability_unavailable"
|
|
| "provider_unavailable"
|
|
| "provider_timeout"
|
|
| "cancelled"
|
|
| "artifact_integrity_mismatch"
|
|
| "artifact_operation_failed"
|
|
| "evidence_integrity_mismatch"
|
|
| "truncated"
|
|
| "permission_required"
|
|
| "process_capture_failed"
|
|
| "cleanup_incomplete"
|
|
| "revision_conflict"
|
|
| "outside_approved_root"
|
|
| "configuration_invalid"
|
|
| "target_unavailable"
|
|
| "execution_failure"
|
|
| "plan_stale";
|
|
readonly category:
|
|
| "invalid_input"
|
|
| "permission_required"
|
|
| "unsupported_provider"
|
|
| "integrity_mismatch"
|
|
| "truncated"
|
|
| "cancelled"
|
|
| "timeout"
|
|
| "unavailable"
|
|
| "execution_failure";
|
|
readonly message: string;
|
|
readonly retryable: boolean;
|
|
readonly remediation: Readonly<{
|
|
action: string;
|
|
restart_required: boolean;
|
|
elicitation_supported?: boolean;
|
|
}>;
|
|
readonly details?: Readonly<Record<string, JsonValue>>;
|
|
}
|
|
|
|
/** One legal recovery path for a denied permission request. */
|
|
export type PermissionRemediation = "configure" | "elicit" | "restart";
|
|
|
|
/** Exact denied authority and its legal recovery path. */
|
|
export interface PermissionRequiredContext {
|
|
readonly requested: PermissionRequest;
|
|
readonly missing: MissingPermissionScope;
|
|
readonly ceiling: PermissionScope | null;
|
|
readonly remediation: PermissionRemediation;
|
|
}
|
|
|
|
/** Exact denied authority used by both CLI and MCP remediation. */
|
|
export class PermissionRequiredError extends AnalysisError {
|
|
readonly _tag = "PermissionRequiredError" as const;
|
|
readonly requested: PermissionRequest;
|
|
readonly missing: MissingPermissionScope;
|
|
readonly ceiling: PermissionScope | null;
|
|
readonly remediation: PermissionRemediation;
|
|
|
|
constructor(context: PermissionRequiredContext) {
|
|
super(`Permission required for ${context.requested.capability}`);
|
|
this.requested = context.requested;
|
|
this.missing = context.missing;
|
|
this.ceiling = context.ceiling;
|
|
this.remediation = context.remediation;
|
|
}
|
|
}
|
|
|
|
/** Approved replay commitment no longer matches the immediately rebuilt plan. */
|
|
export class ReplayPlanStaleError extends AnalysisError {
|
|
readonly _tag = "ReplayPlanStaleError" as const;
|
|
|
|
constructor(
|
|
readonly approvedDigest: string,
|
|
readonly actualDigest: string,
|
|
) {
|
|
super("Controlled replay plan changed before execution");
|
|
}
|
|
}
|