feat(errors): add caller-safe typed error projections
This commit is contained in:
parent
9f3639f29f
commit
3be439cbf3
|
|
@ -4,7 +4,10 @@ import type {
|
|||
ProcessExecutionPolicy,
|
||||
ProcessScenario,
|
||||
} from "../domain/processCapture.js";
|
||||
import { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import {
|
||||
ProcessCaptureError,
|
||||
processCaptureCancelled,
|
||||
} from "./ProcessCaptureError.js";
|
||||
|
||||
const isWithin = (candidate: string, root: string): boolean =>
|
||||
candidate === root ||
|
||||
|
|
@ -41,8 +44,7 @@ export const assertRealPathAuthority = async (
|
|||
|
||||
/** Expected refusal or runtime failure from the process capture adapter. */
|
||||
export const assertNotCancelled = (signal: AbortSignal | undefined): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ProcessCaptureError("process capture was cancelled");
|
||||
if (signal?.aborted === true) throw processCaptureCancelled();
|
||||
};
|
||||
|
||||
/** Runtime availability of the native PTY adapter on this host. */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,30 @@
|
|||
import { AnalysisError } from "../domain/errors.js";
|
||||
|
||||
interface ProcessCaptureErrorOptions extends ErrorOptions {
|
||||
readonly userMessage?: string;
|
||||
readonly userCategory?: "permission_required" | "cancelled";
|
||||
}
|
||||
|
||||
/** Typed application failure produced by controlled process capture. */
|
||||
export class ProcessCaptureError extends AnalysisError {
|
||||
readonly _tag = "ProcessCaptureError";
|
||||
|
||||
override readonly userMessage: string | undefined;
|
||||
override readonly userCategory:
|
||||
| "permission_required"
|
||||
| "cancelled"
|
||||
| undefined;
|
||||
|
||||
constructor(message: string, options?: ProcessCaptureErrorOptions) {
|
||||
super(message, options);
|
||||
this.userMessage = options?.userMessage;
|
||||
this.userCategory = options?.userCategory;
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller-visible cancellation without exposing capture implementation state. */
|
||||
export const processCaptureCancelled = (): ProcessCaptureError =>
|
||||
new ProcessCaptureError("process capture was cancelled", {
|
||||
userCategory: "cancelled",
|
||||
userMessage: "Process capture was cancelled. Start it again when ready.",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
InteractionEvent,
|
||||
ProcessCapture,
|
||||
ProcessExecutionPolicy,
|
||||
ProcessPolicyDecision,
|
||||
ProcessSample,
|
||||
ProcessScenario,
|
||||
TerminalFrame,
|
||||
|
|
@ -405,7 +406,11 @@ export const prepareProcessCapture = async (
|
|||
readonly before: SnapshotResult;
|
||||
}> => {
|
||||
const decision = authorizeProcessScenario(scenario, policy);
|
||||
if (!decision.allowed) throw new ProcessCaptureError(decision.reason);
|
||||
if (!decision.allowed)
|
||||
throw new ProcessCaptureError(decision.reason, {
|
||||
userCategory: "permission_required",
|
||||
userMessage: processPolicyMessage(decision.reason),
|
||||
});
|
||||
await assertRealPathAuthority(scenario, policy);
|
||||
assertNotCancelled(signal);
|
||||
const before = await snapshotRoots(scenario, signal);
|
||||
|
|
@ -415,3 +420,23 @@ export const prepareProcessCapture = async (
|
|||
await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
|
||||
return { temporaryRoot, runId, home, before };
|
||||
};
|
||||
|
||||
const processPolicyMessage = (
|
||||
reason: Exclude<ProcessPolicyDecision, { readonly allowed: true }>["reason"],
|
||||
): string => {
|
||||
if (reason === "process capture is disabled")
|
||||
return "Process capture is disabled. Set `REA_PROCESS_CAPTURE_ENABLED=true`, configure approved roots, then restart REA.";
|
||||
if (reason === "host network access is not approved by operator policy")
|
||||
return "This capture requests host network access, but policy does not allow it. Use replayed network access or ask the operator to enable external network capture.";
|
||||
if (reason === "executable is outside approved roots")
|
||||
return "The executable is outside the approved capture directories. Choose an approved executable or add its directory to `REA_PROCESS_EXECUTABLE_ROOTS_JSON`.";
|
||||
if (reason === "working directory is outside approved roots")
|
||||
return "The working directory is outside the approved capture directories. Choose an approved directory or add it to `REA_PROCESS_WORKING_ROOTS_JSON`.";
|
||||
if (reason === "filesystem root is outside approved roots")
|
||||
return "A requested filesystem root is outside the approved capture directories. Remove it or add its directory to `REA_PROCESS_WORKING_ROOTS_JSON`.";
|
||||
if (
|
||||
reason === "scenario requests an environment variable not allowed by policy"
|
||||
)
|
||||
return "The capture requests an environment variable that policy does not allow. Remove it or add its name to `REA_PROCESS_ALLOWED_ENV_JSON`.";
|
||||
return reason satisfies never;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import type {
|
|||
} from "../domain/processCapture.js";
|
||||
import { validateProcessCapture } from "../domain/processCapture.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import {
|
||||
ProcessCaptureError,
|
||||
processCaptureCancelled,
|
||||
} from "./ProcessCaptureError.js";
|
||||
|
||||
export { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
|
||||
|
|
@ -372,8 +375,7 @@ const completeCapture = async (options: {
|
|||
const { runtime, scenario } = options;
|
||||
runtime.checkpoints.trigger("root_exit");
|
||||
const { reason } = options.exit;
|
||||
if (reason === "cancelled")
|
||||
throw new ProcessCaptureError("process capture was cancelled");
|
||||
if (reason === "cancelled") throw processCaptureCancelled();
|
||||
const settlement = await observeSettlement(
|
||||
options.runId,
|
||||
[
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ 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;
|
||||
}
|
||||
|
||||
/** Base class for failures produced specifically by the Hopper provider. */
|
||||
|
|
@ -300,6 +303,16 @@ export class BinaryTargetError extends AnalysisError {
|
|||
export interface AnalysisErrorProjection
|
||||
extends Readonly<Record<string, JsonValue>> {
|
||||
readonly tag: AnalysisErrorTag;
|
||||
readonly category:
|
||||
| "invalid_input"
|
||||
| "permission_required"
|
||||
| "unsupported_provider"
|
||||
| "integrity_mismatch"
|
||||
| "truncated"
|
||||
| "cancelled"
|
||||
| "timeout"
|
||||
| "unavailable"
|
||||
| "execution_failure";
|
||||
readonly message: string;
|
||||
readonly details: Readonly<Record<string, string | number | boolean | null>>;
|
||||
}
|
||||
|
|
@ -311,11 +324,55 @@ export const projectAnalysisError = (
|
|||
assertKnownTag(error._tag);
|
||||
return {
|
||||
tag: error._tag,
|
||||
message: error.message,
|
||||
category: errorCategory(error),
|
||||
message: userMessage(error),
|
||||
details: safeDetails(error),
|
||||
};
|
||||
};
|
||||
|
||||
const errorCategory = (
|
||||
error: AnalysisError,
|
||||
): AnalysisErrorProjection["category"] => {
|
||||
if (error._tag === "ProcessCaptureError")
|
||||
return error.userCategory ?? "execution_failure";
|
||||
if (
|
||||
error instanceof HopperRemoteError &&
|
||||
error.diagnosticType === "authorization"
|
||||
)
|
||||
return "permission_required";
|
||||
if (error instanceof ArtifactOperationError)
|
||||
return artifactErrorCategory(error.reason);
|
||||
if (error instanceof EvidenceFileError && error.reason === "disabled")
|
||||
return "unavailable";
|
||||
return STATIC_ERROR_CATEGORIES[error._tag] ?? "execution_failure";
|
||||
};
|
||||
|
||||
const artifactErrorCategory = (
|
||||
reason: ArtifactOperationError["reason"],
|
||||
): AnalysisErrorProjection["category"] => {
|
||||
if (reason === "integrity") return "integrity_mismatch";
|
||||
if (reason === "limit") return "truncated";
|
||||
if (reason === "cancelled") return "cancelled";
|
||||
if (reason === "unavailable") return "unavailable";
|
||||
return "execution_failure";
|
||||
};
|
||||
|
||||
const STATIC_ERROR_CATEGORIES: Readonly<
|
||||
Partial<Record<AnalysisErrorTag, AnalysisErrorProjection["category"]>>
|
||||
> = {
|
||||
AnalysisInputError: "invalid_input",
|
||||
AnalysisCapabilityUnavailableError: "unsupported_provider",
|
||||
ProviderSelectionError: "unsupported_provider",
|
||||
EvidenceIntegrityError: "integrity_mismatch",
|
||||
EvidenceLimitError: "truncated",
|
||||
AnalysisCancelledError: "cancelled",
|
||||
HopperCancelledError: "cancelled",
|
||||
AnalysisTimeoutError: "timeout",
|
||||
HopperTimeoutError: "timeout",
|
||||
NoBinaryOpenError: "unavailable",
|
||||
BinaryTargetError: "unavailable",
|
||||
};
|
||||
|
||||
const safeDetails = (
|
||||
error: AnalysisError,
|
||||
): Readonly<Record<string, string | number | boolean | null>> => {
|
||||
|
|
@ -360,6 +417,100 @@ const safeDetails = (
|
|||
return {};
|
||||
};
|
||||
|
||||
const userMessage = (error: AnalysisError): string => {
|
||||
if (error instanceof AnalysisInputError)
|
||||
return "Analysis input is invalid. Check the arguments and try again.";
|
||||
if (UNREADABLE_OUTPUT_TAGS.has(error._tag))
|
||||
return "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.";
|
||||
if (UNSUPPORTED_PROVIDER_TAGS.has(error._tag))
|
||||
return "This analysis is unavailable for the current target. Choose another analysis or target.";
|
||||
if (CANCELLED_TAGS.has(error._tag))
|
||||
return "Analysis was cancelled. Start it again when ready.";
|
||||
if (TIMEOUT_TAGS.has(error._tag))
|
||||
return "Analysis took too long. Try a smaller request, then run `rea doctor` if it continues.";
|
||||
if (ADAPTER_FAILURE_TAGS.has(error._tag))
|
||||
return "Analysis could not complete. Retry once; if it continues, run `rea doctor`.";
|
||||
if (START_FAILURE_TAGS.has(error._tag))
|
||||
return "Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again.";
|
||||
if (error instanceof ArtifactOperationError)
|
||||
return artifactMessage(error.reason);
|
||||
if (error instanceof EvidenceIntegrityError)
|
||||
return "Evidence is invalid or has changed. Recreate or re-import it, then try again.";
|
||||
if (error instanceof EvidenceLimitError)
|
||||
return "Evidence is too large for this session. Reduce the evidence set and try again.";
|
||||
if (error instanceof EvidenceFileError)
|
||||
return evidenceFileMessage(error.reason);
|
||||
if (error instanceof UnknownRegistryError)
|
||||
return "Evidence state changed before the update completed. Refresh the current state and try again.";
|
||||
if (error instanceof ConfigurationError)
|
||||
return "REA configuration is invalid. Run `rea doctor` and fix the reported setting.";
|
||||
if (error instanceof NoBinaryOpenError) return error.message;
|
||||
if (error instanceof BinaryTargetError)
|
||||
return "REA could not open that app or binary. Check that the path exists, is readable, and points to a supported file.";
|
||||
if (error._tag === "ProcessCaptureError")
|
||||
return (
|
||||
error.userMessage ??
|
||||
"Process capture could not complete. Run `rea doctor`, then review capture policy and try again."
|
||||
);
|
||||
return "Analysis could not complete. Run `rea doctor`, then try again.";
|
||||
};
|
||||
|
||||
const UNREADABLE_OUTPUT_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
|
||||
"AnalysisProtocolError",
|
||||
"AnalysisOutputError",
|
||||
"HopperProtocolError",
|
||||
]);
|
||||
const UNSUPPORTED_PROVIDER_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
|
||||
"AnalysisCapabilityUnavailableError",
|
||||
"ProviderSelectionError",
|
||||
]);
|
||||
const CANCELLED_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
|
||||
"AnalysisCancelledError",
|
||||
"HopperCancelledError",
|
||||
]);
|
||||
const TIMEOUT_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
|
||||
"AnalysisTimeoutError",
|
||||
"HopperTimeoutError",
|
||||
]);
|
||||
const ADAPTER_FAILURE_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
|
||||
"ProviderAdapterError",
|
||||
"HopperRemoteError",
|
||||
]);
|
||||
const START_FAILURE_TAGS: ReadonlySet<AnalysisErrorTag> = new Set([
|
||||
"HopperProcessError",
|
||||
"HopperStartError",
|
||||
]);
|
||||
|
||||
const artifactMessage = (reason: ArtifactOperationError["reason"]): string => {
|
||||
if (reason === "cancelled")
|
||||
return "Artifact operation was cancelled. Start it again when ready.";
|
||||
if (reason === "limit")
|
||||
return "Artifact is too large to process safely. Narrow the requested path or use a smaller artifact.";
|
||||
if (reason === "path")
|
||||
return "Artifact path is not allowed. Choose a path inside the artifact and try again.";
|
||||
if (reason === "unavailable")
|
||||
return "Artifact format is not available on this system. Choose another artifact or supported environment.";
|
||||
if (reason === "format" || reason === "integrity")
|
||||
return "Artifact is invalid or has changed. Get a fresh copy and try again.";
|
||||
return "Artifact could not be read or written. Check file access and try again.";
|
||||
};
|
||||
|
||||
const evidenceFileMessage = (reason: EvidenceFileError["reason"]): string => {
|
||||
if (reason === "disabled")
|
||||
return "Evidence file access is disabled. Enable an evidence directory or use inline evidence.";
|
||||
if (reason === "outside-root")
|
||||
return "Evidence path is outside the allowed directory. Choose a path inside the configured evidence directory.";
|
||||
if (reason === "not-file")
|
||||
return "Evidence path does not point to a file. Choose an evidence file and try again.";
|
||||
if (reason === "too-large")
|
||||
return "Evidence file is too large. Reduce its size and try again.";
|
||||
if (reason === "exists")
|
||||
return "Evidence file already exists. Choose another path or allow overwrite.";
|
||||
if (reason === "invalid-json")
|
||||
return "Evidence file is not valid JSON. Repair or recreate the file and try again.";
|
||||
return "Evidence file could not be accessed. Check file permissions and try again.";
|
||||
};
|
||||
|
||||
const KNOWN_ERROR_TAGS = {
|
||||
AnalysisProtocolError: true,
|
||||
AnalysisInputError: true,
|
||||
|
|
|
|||
|
|
@ -382,7 +382,16 @@ export interface ProcessExecutionPolicy {
|
|||
/** Explicit authorization result; denial reasons are safe to show callers. */
|
||||
export type ProcessPolicyDecision =
|
||||
| { readonly allowed: true }
|
||||
| { readonly allowed: false; readonly reason: string };
|
||||
| {
|
||||
readonly allowed: false;
|
||||
readonly reason:
|
||||
| "process capture is disabled"
|
||||
| "host network access is not approved by operator policy"
|
||||
| "executable is outside approved roots"
|
||||
| "working directory is outside approved roots"
|
||||
| "scenario requests an environment variable not allowed by policy"
|
||||
| "filesystem root is outside approved roots";
|
||||
};
|
||||
|
||||
const isWithin = (candidate: string, root: string): boolean =>
|
||||
resolve(candidate) === resolve(root) ||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ describe("analysis error projection", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("exhaustively projects stable tags without causes or local paths", () => {
|
||||
it("projects every typed failure without internal details", () => {
|
||||
const secretCause = new Error("secret-token");
|
||||
const byTag = {
|
||||
AnalysisProtocolError: new AnalysisProtocolError("protocol failed"),
|
||||
|
|
@ -97,17 +97,47 @@ describe("analysis error projection", () => {
|
|||
} satisfies Readonly<Record<AnalysisErrorTag, AnalysisError>>;
|
||||
const errors = Object.values(byTag);
|
||||
const projected = errors.map(projectAnalysisError);
|
||||
expect(new Set(projected.map(({ tag }) => tag)).size).toBe(errors.length);
|
||||
expect(projected).toHaveLength(23);
|
||||
expect(JSON.stringify(projected)).not.toContain("secret-token");
|
||||
expect(JSON.stringify(projected)).not.toContain("/secret/local/path");
|
||||
const serialized = JSON.stringify(projected);
|
||||
for (const hidden of ["secret-token", "/secret/local/path"])
|
||||
expect(serialized).not.toContain(hidden);
|
||||
expect(
|
||||
projected.find(({ tag }) => tag === "HopperTimeoutError"),
|
||||
).toMatchObject({ details: { timeoutMs: 100 } });
|
||||
expect(
|
||||
projected.find(({ tag }) => tag === "ProviderAdapterError"),
|
||||
projectAnalysisError(byTag.AnalysisCapabilityUnavailableError),
|
||||
).toMatchObject({
|
||||
details: { providerId: "fixture", operation: "overview" },
|
||||
tag: "AnalysisCapabilityUnavailableError",
|
||||
category: "unsupported_provider",
|
||||
details: {
|
||||
providerId: "fixture",
|
||||
operation: "overview",
|
||||
reason: "absent",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
projectAnalysisError(
|
||||
new ArtifactOperationError("inventory_artifact", "integrity", {
|
||||
logicalPath: "main.js",
|
||||
declaredSha256: "a".repeat(64),
|
||||
calculatedSha256: "b".repeat(64),
|
||||
unpacked: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
tag: "ArtifactOperationError",
|
||||
category: "integrity_mismatch",
|
||||
details: {
|
||||
logicalPath: "main.js",
|
||||
declaredSha256: "a".repeat(64),
|
||||
calculatedSha256: "b".repeat(64),
|
||||
unpacked: true,
|
||||
},
|
||||
});
|
||||
expect(projected.every(({ message }) => message.length > 0)).toBe(true);
|
||||
expect(
|
||||
projected.every(({ message }) =>
|
||||
/try again|run `rea doctor`|current target|when ready|smaller request|smaller artifact|fresh copy|re-import|reduce|inline evidence|configured evidence directory|evidence file|allow overwrite|refresh the current state|reported setting|call open_binary|supported file|review capture policy/u.test(
|
||||
message,
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue