mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor: consolidate coercion contracts (#122458)
* refactor: consolidate coercion contracts Centralize exact string, record, numeric, date, Boolean, argument, and structured-error coercions while preserving call-site semantics. Migrate canonical-name collisions and deprecated internal SDK bypasses, deleting 55 net production/tooling lines. Expand declaration ownership enforcement to 101 allowed helpers and add a narrow export-completeness audit. * fix: preserve standalone script coercions Keep copied Control UI tooling self-contained and retain the trusted release harness module-relative source seam when the harness runs against an old target cwd.
This commit is contained in:
committed by
GitHub
parent
66fe424590
commit
b080dd1e76
@@ -1,4 +1,5 @@
|
||||
// ACP Core module implements meta behavior.
|
||||
import { asFiniteNumber, asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
function readMetaValue<T>(
|
||||
@@ -39,9 +40,7 @@ export function readMetadataNumber(
|
||||
meta: Record<string, unknown> | null | undefined,
|
||||
keys: string[],
|
||||
): number | undefined {
|
||||
return readMetaValue(meta, keys, (value) =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : undefined,
|
||||
);
|
||||
return readMetaValue(meta, keys, asFiniteNumber);
|
||||
}
|
||||
|
||||
/** Reads the first safe non-negative integer metadata value, preserving zero. */
|
||||
@@ -49,7 +48,5 @@ export function readNonNegativeInteger(
|
||||
meta: Record<string, unknown> | null | undefined,
|
||||
keys: string[],
|
||||
): number | undefined {
|
||||
return readMetaValue(meta, keys, (value) =>
|
||||
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined,
|
||||
);
|
||||
return readMetaValue(meta, keys, (value) => asSafeIntegerInRange(value, { min: 0 }));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ToolResultMessage,
|
||||
} from "@openclaw/llm-core";
|
||||
import type { EventStream as SourceEventStream } from "@openclaw/llm-core";
|
||||
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
|
||||
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { TranscriptNotContinuableError } from "./errors.js";
|
||||
import { uuidv7 } from "./harness/session/uuid.js";
|
||||
@@ -1332,7 +1333,7 @@ async function prepareToolCall(
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: "immediate",
|
||||
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
|
||||
result: createErrorToolResult(coerceErrorMessage(error)),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
@@ -1360,11 +1361,7 @@ async function validateToolCallForBatchAdmission(
|
||||
outcome: {
|
||||
kind: "immediate",
|
||||
result: createErrorToolResult(
|
||||
signal?.aborted
|
||||
? "Operation aborted"
|
||||
: resolution.error instanceof Error
|
||||
? resolution.error.message
|
||||
: String(resolution.error),
|
||||
signal?.aborted ? "Operation aborted" : coerceErrorMessage(resolution.error),
|
||||
),
|
||||
isError: true,
|
||||
},
|
||||
@@ -1390,7 +1387,7 @@ async function validateToolCallForBatchAdmission(
|
||||
kind: "immediate",
|
||||
outcome: {
|
||||
kind: "immediate",
|
||||
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
|
||||
result: createErrorToolResult(coerceErrorMessage(error)),
|
||||
isError: true,
|
||||
},
|
||||
};
|
||||
@@ -1404,7 +1401,7 @@ async function validateToolCallForBatchAdmission(
|
||||
kind: "immediate",
|
||||
outcome: {
|
||||
kind: "immediate",
|
||||
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
|
||||
result: createErrorToolResult(coerceErrorMessage(error)),
|
||||
isError: true,
|
||||
errorKind: "argument-validation",
|
||||
},
|
||||
@@ -1452,7 +1449,7 @@ async function prepareToolCallExecution(
|
||||
return {
|
||||
kind: "immediate",
|
||||
outcome: {
|
||||
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
|
||||
result: createErrorToolResult(coerceErrorMessage(error)),
|
||||
isError: true,
|
||||
executionStarted: false,
|
||||
},
|
||||
@@ -1508,7 +1505,7 @@ async function prepareToolCallExecution(
|
||||
throw implementationStartError.error;
|
||||
}
|
||||
return {
|
||||
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
|
||||
result: createErrorToolResult(coerceErrorMessage(error)),
|
||||
isError: true,
|
||||
executionStarted,
|
||||
...(executionStarted && signal?.aborted && error === signal.reason
|
||||
@@ -1570,11 +1567,7 @@ async function prepareToolCallExecution(
|
||||
return {
|
||||
kind: "immediate",
|
||||
outcome: {
|
||||
result: createErrorToolResult(
|
||||
internalPreparation.outcome.error instanceof Error
|
||||
? internalPreparation.outcome.error.message
|
||||
: String(internalPreparation.outcome.error),
|
||||
),
|
||||
result: createErrorToolResult(coerceErrorMessage(internalPreparation.outcome.error)),
|
||||
isError: true,
|
||||
executionStarted: false,
|
||||
},
|
||||
@@ -1627,7 +1620,7 @@ async function finalizeExecutedToolCall(
|
||||
isError = afterResult.isError ?? isError;
|
||||
}
|
||||
} catch (error) {
|
||||
result = createErrorToolResult(error instanceof Error ? error.message : String(error));
|
||||
result = createErrorToolResult(coerceErrorMessage(error));
|
||||
isError = true;
|
||||
}
|
||||
}
|
||||
@@ -1692,9 +1685,7 @@ async function finalizeToolCallOutcome(
|
||||
isError: afterResult.isError ?? finalized.isError,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorResult = createErrorToolResult(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
const errorResult = createErrorToolResult(coerceErrorMessage(error));
|
||||
return {
|
||||
...finalized,
|
||||
result: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion";
|
||||
import type { Usage } from "../types.js";
|
||||
|
||||
type AnthropicUsagePayload = {
|
||||
@@ -31,7 +32,7 @@ export type AnthropicIterationUsageResult =
|
||||
| { state: "valid"; usage: AnthropicIterationUsageSnapshot };
|
||||
|
||||
export function readAnthropicUsageTokenCount(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||
return asNonNegativeFiniteNumber(value);
|
||||
}
|
||||
|
||||
export function readAnthropicCacheWriteUsage(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* package and managed transports from drifting on token buckets, service-tier pricing, or future
|
||||
* terminal-event semantics.
|
||||
*/
|
||||
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
|
||||
import type OpenAI from "openai";
|
||||
import type { StopReason, Usage } from "../types.js";
|
||||
|
||||
@@ -53,10 +54,7 @@ export function mapResponsesTerminalUsage(
|
||||
export function readResponsesReasoningTokens(
|
||||
usage: ResponsesTerminalUsagePayload | undefined | null,
|
||||
): number | undefined {
|
||||
const reasoningTokens = usage?.output_tokens_details?.reasoning_tokens;
|
||||
return typeof reasoningTokens === "number" && Number.isFinite(reasoningTokens)
|
||||
? reasoningTokens
|
||||
: undefined;
|
||||
return asFiniteNumber(usage?.output_tokens_details?.reasoning_tokens);
|
||||
}
|
||||
|
||||
function mapResponsesTerminalStopReason(
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
* Callers canonicalize aliases before dispatch so payloads cannot carry
|
||||
* conflicting limits.
|
||||
*/
|
||||
const MAX_TOKENS_PARAM_KEYS = ["maxTokens", "max_completion_tokens", "max_tokens"] as const;
|
||||
import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion";
|
||||
|
||||
/** Return a finite non-negative max-token value, or undefined for invalid input. */
|
||||
function resolveNonNegativeMaxTokensParam(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
const MAX_TOKENS_PARAM_KEYS = ["maxTokens", "max_completion_tokens", "max_tokens"] as const;
|
||||
|
||||
/** Resolve the first supported max-token parameter present in a params object. */
|
||||
export function resolveMaxTokensParam(
|
||||
@@ -18,7 +15,7 @@ export function resolveMaxTokensParam(
|
||||
return undefined;
|
||||
}
|
||||
for (const key of MAX_TOKENS_PARAM_KEYS) {
|
||||
const resolved = resolveNonNegativeMaxTokensParam(params[key]);
|
||||
const resolved = asNonNegativeFiniteNumber(params[key]);
|
||||
if (resolved !== undefined) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type ParsedIpAddress,
|
||||
} from "@openclaw/net-policy/ip";
|
||||
|
||||
export function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
export function normalizeGatewayErrorText(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { WebSocket, type ClientOptions, type CertMeta } from "ws";
|
||||
import {
|
||||
isSensitiveUrlQueryParamName,
|
||||
normalizeFingerprint,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeGatewayErrorText,
|
||||
parseGatewayIpAddress,
|
||||
parseHostForAddressChecks,
|
||||
} from "./client-address-utils.js";
|
||||
@@ -948,7 +948,7 @@ export class GatewayClient {
|
||||
return (
|
||||
expectedProtocol === MIN_NODE_PROTOCOL_VERSION &&
|
||||
(detailCode === ConnectErrorDetailCodes.PROTOCOL_MISMATCH ||
|
||||
normalizeLowercaseStringOrEmpty(error.message).includes("protocol mismatch"))
|
||||
normalizeGatewayErrorText(error.message).includes("protocol mismatch"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -966,7 +966,7 @@ export class GatewayClient {
|
||||
return (
|
||||
expectedProtocol === PROTOCOL_VERSION &&
|
||||
(detailCode === ConnectErrorDetailCodes.PROTOCOL_MISMATCH ||
|
||||
normalizeLowercaseStringOrEmpty(error.message).includes("protocol mismatch"))
|
||||
normalizeGatewayErrorText(error.message).includes("protocol mismatch"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1228,7 +1228,7 @@ export class GatewayClient {
|
||||
private clearStaleDeviceTokenForClose(code: number, reason: string): void {
|
||||
if (
|
||||
code !== 1008 ||
|
||||
!normalizeLowercaseStringOrEmpty(reason).includes("device token mismatch") ||
|
||||
!normalizeGatewayErrorText(reason).includes("device token mismatch") ||
|
||||
this.opts.token ||
|
||||
this.opts.password ||
|
||||
!this.opts.deviceIdentity
|
||||
@@ -1283,7 +1283,7 @@ export class GatewayClient {
|
||||
if (params.error.gatewayCode !== "INVALID_REQUEST") {
|
||||
return false;
|
||||
}
|
||||
const message = normalizeLowercaseStringOrEmpty(params.error.message);
|
||||
const message = normalizeGatewayErrorText(params.error.message);
|
||||
return message.includes("invalid connect params") && message.includes("approvalruntimetoken");
|
||||
}
|
||||
|
||||
@@ -1300,7 +1300,7 @@ export class GatewayClient {
|
||||
if (params.error.gatewayCode !== "INVALID_REQUEST") {
|
||||
return false;
|
||||
}
|
||||
const message = normalizeLowercaseStringOrEmpty(params.error.message);
|
||||
const message = normalizeGatewayErrorText(params.error.message);
|
||||
return (
|
||||
message.includes("invalid connect params") && message.includes("agentruntimeidentitytoken")
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Gateway Client module implements timeouts behavior.
|
||||
function parseStrictPositiveInteger(value: string): number | undefined {
|
||||
function parsePositiveTimeoutSetting(value: string): number | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\+?\d+$/u.test(trimmed)) {
|
||||
return undefined;
|
||||
@@ -106,7 +106,7 @@ export function getConnectChallengeTimeoutMsFromEnv(
|
||||
): number | undefined {
|
||||
const raw = env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS;
|
||||
if (raw) {
|
||||
const parsed = parseStrictPositiveInteger(raw);
|
||||
const parsed = parsePositiveTimeoutSetting(raw);
|
||||
if (parsed !== undefined) {
|
||||
return resolveSafeTimeoutDelayMs(parsed);
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export function resolvePreauthHandshakeTimeoutMs(params?: {
|
||||
env.OPENCLAW_HANDSHAKE_TIMEOUT_MS ||
|
||||
(isTestRuntimeEnv(env) ? env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS : undefined);
|
||||
if (configuredTimeout) {
|
||||
const parsed = parseStrictPositiveInteger(configuredTimeout);
|
||||
const parsed = parsePositiveTimeoutSetting(configuredTimeout);
|
||||
if (parsed !== undefined) {
|
||||
return resolveSafeTimeoutDelayMs(parsed);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import { normalizeOptionalProtocolString } from "./protocol-value-normalization.js";
|
||||
|
||||
function normalizeArrayBackedTrimmedStringList(value: unknown): string[] | undefined {
|
||||
function normalizeOptionalConnectDetailStringList(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -266,7 +266,7 @@ export function normalizePairingConnectRequestId(value: unknown): string | undef
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] | undefined {
|
||||
return normalizeArrayBackedTrimmedStringList(value);
|
||||
return normalizeOptionalConnectDetailStringList(value);
|
||||
}
|
||||
|
||||
function createPairingConnectErrorDetails(params: {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Normalization core tests cover shared error coercion and formatting behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
coerceErrorMessage,
|
||||
formatErrorMessage,
|
||||
stringifyNonErrorCause,
|
||||
toErrorObject,
|
||||
toStructuredErrorObject,
|
||||
toStringifiedError,
|
||||
} from "./error-coercion.js";
|
||||
|
||||
@@ -64,6 +65,188 @@ describe("toErrorObject", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toStructuredErrorObject", () => {
|
||||
it("preserves Error identity without coercing it", () => {
|
||||
class ThrowingToStringError extends Error {
|
||||
override toString(): string {
|
||||
throw new Error("unexpected stringification");
|
||||
}
|
||||
}
|
||||
const original = new ThrowingToStringError("request failed", {
|
||||
cause: { code: "EIO" },
|
||||
});
|
||||
|
||||
expect(toStructuredErrorObject(original)).toBe(original);
|
||||
});
|
||||
|
||||
it("preserves primitive message and cause semantics", () => {
|
||||
const stringError = toStructuredErrorObject("request failed");
|
||||
|
||||
expect(stringError).toMatchObject({ message: "request failed" });
|
||||
expect(stringError).not.toHaveProperty("cause");
|
||||
for (const value of [undefined, null, 503, false, 503n, Symbol("failure")]) {
|
||||
const error = toStructuredErrorObject(value);
|
||||
expect(error.message).toBe(String(value));
|
||||
expect(Object.hasOwn(error, "cause")).toBe(true);
|
||||
expect(error.cause).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves hostile stringification failures", () => {
|
||||
const failure = {
|
||||
[Symbol.toPrimitive]() {
|
||||
throw new Error("stringification failed");
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => toStructuredErrorObject(failure)).toThrow("stringification failed");
|
||||
});
|
||||
|
||||
it("copies enumerable string and symbol details while retaining the original cause", () => {
|
||||
const detailKey = Symbol("detail");
|
||||
const throwingDetailKey = Symbol("throwing detail");
|
||||
const cause = {
|
||||
code: "EIO",
|
||||
details: { retryable: true },
|
||||
[detailKey]: "symbol detail",
|
||||
};
|
||||
Object.defineProperty(cause, "hidden", { value: "secret", enumerable: false });
|
||||
Object.defineProperty(cause, throwingDetailKey, {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new Error("unexpected symbol field read");
|
||||
},
|
||||
});
|
||||
|
||||
const error = toStructuredErrorObject(cause);
|
||||
|
||||
expect(error).not.toBe(cause);
|
||||
expect(error.message).toBe("[object Object]");
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error).toMatchObject({ code: "EIO", details: { retryable: true } });
|
||||
expect(Object.getOwnPropertyDescriptor(error, "code")).toEqual({
|
||||
value: "EIO",
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
expect(Reflect.get(error, detailKey)).toBe("symbol detail");
|
||||
expect(Object.hasOwn(error, throwingDetailKey)).toBe(false);
|
||||
expect(error).not.toHaveProperty("hidden");
|
||||
|
||||
const functionCause = Object.assign(function requestFailure() {}, {
|
||||
code: "EFUNCTION",
|
||||
[detailKey]: "function symbol detail",
|
||||
});
|
||||
const functionError = toStructuredErrorObject(functionCause);
|
||||
expect(functionError.message).toBe(String(functionCause));
|
||||
expect(functionError.cause).toBe(functionCause);
|
||||
expect(functionError).toMatchObject({ code: "EFUNCTION" });
|
||||
expect(Reflect.get(functionError, detailKey)).toBe("function symbol detail");
|
||||
});
|
||||
|
||||
it("skips fields whose definition fails and continues copying later details", () => {
|
||||
const originalDefineProperty = Object.defineProperty;
|
||||
const defineProperty = vi
|
||||
.spyOn(Object, "defineProperty")
|
||||
.mockImplementation(
|
||||
(target: unknown, key: PropertyKey, attributes: PropertyDescriptor): unknown => {
|
||||
if (target instanceof Error && key === "blocked") {
|
||||
throw new Error("definition rejected");
|
||||
}
|
||||
return originalDefineProperty(target as object, key, attributes);
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const error = toStructuredErrorObject({ before: 1, blocked: 2, after: 3 });
|
||||
expect(error).toMatchObject({ before: 1, after: 3 });
|
||||
expect(error).not.toHaveProperty("blocked");
|
||||
} finally {
|
||||
defineProperty.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips throwing fields and preserves the base Error for enumeration failures", () => {
|
||||
const throwingGetter = {
|
||||
get details(): never {
|
||||
throw new Error("unexpected structured field read");
|
||||
},
|
||||
code: "EIO",
|
||||
};
|
||||
const ownKeysFailure = new Proxy(
|
||||
{ code: "EIO" },
|
||||
{
|
||||
ownKeys() {
|
||||
throw new Error("unexpected ownKeys call");
|
||||
},
|
||||
},
|
||||
);
|
||||
const descriptorFailure = new Proxy(
|
||||
{ code: "EIO", status: 503 },
|
||||
{
|
||||
ownKeys() {
|
||||
return ["code", "status"];
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
if (key === "status") {
|
||||
throw new Error("unexpected descriptor read");
|
||||
}
|
||||
return Reflect.getOwnPropertyDescriptor(target, key);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(toStructuredErrorObject(throwingGetter)).toMatchObject({ code: "EIO" });
|
||||
for (const cause of [ownKeysFailure, descriptorFailure]) {
|
||||
const error = toStructuredErrorObject(cause);
|
||||
expect(error).toMatchObject({ name: "Error", message: "[object Object]" });
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error).not.toHaveProperty("code");
|
||||
expect(error).not.toHaveProperty("status");
|
||||
}
|
||||
});
|
||||
|
||||
it("protects Error-owned and prototype-mutating fields without reading them", () => {
|
||||
let protectedReads = 0;
|
||||
const cause = {
|
||||
get name() {
|
||||
protectedReads += 1;
|
||||
return "SpoofedError";
|
||||
},
|
||||
get message() {
|
||||
protectedReads += 1;
|
||||
return "spoofed message";
|
||||
},
|
||||
get cause() {
|
||||
protectedReads += 1;
|
||||
return "spoofed cause";
|
||||
},
|
||||
get stack() {
|
||||
protectedReads += 1;
|
||||
return "spoofed stack";
|
||||
},
|
||||
constructor: { polluted: true },
|
||||
prototype: { polluted: true },
|
||||
code: "EIO",
|
||||
};
|
||||
Object.defineProperty(cause, "__proto__", {
|
||||
value: { polluted: true },
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
const error = toStructuredErrorObject(cause);
|
||||
|
||||
expect(protectedReads).toBe(0);
|
||||
expect(error).toMatchObject({ name: "Error", message: "[object Object]", code: "EIO" });
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(Object.getPrototypeOf(error)).toBe(Error.prototype);
|
||||
expect(Object.hasOwn(error, "__proto__")).toBe(false);
|
||||
expect(Object.hasOwn(error, "constructor")).toBe(false);
|
||||
expect(Object.hasOwn(error, "prototype")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toStringifiedError", () => {
|
||||
it("preserves Error identity and stringifies every other value", () => {
|
||||
const error = new Error("boom");
|
||||
|
||||
@@ -4,6 +4,9 @@ export type FormatErrorMessageOptions = {
|
||||
redact: (text: string) => string;
|
||||
};
|
||||
|
||||
const STRUCTURED_ERROR_OWNED_FIELDS = new Set(["cause", "message", "name", "stack"]);
|
||||
const STRUCTURED_ERROR_PROTOTYPE_FIELDS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
function readProperty(value: object, key: "cause" | "code" | "status"): unknown {
|
||||
try {
|
||||
return (value as Record<string, unknown>)[key];
|
||||
@@ -125,6 +128,42 @@ export function toErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Preserves structured details while isolating hostile object field access. */
|
||||
export function toStructuredErrorObject(value: unknown): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
const message = String(value);
|
||||
if ((typeof value !== "object" || value === null) && typeof value !== "function") {
|
||||
return toErrorObject(value, message);
|
||||
}
|
||||
const error = new Error(message, { cause: value });
|
||||
try {
|
||||
const detailKeys = Reflect.ownKeys(value).filter(
|
||||
(key) =>
|
||||
(typeof key !== "string" ||
|
||||
(!STRUCTURED_ERROR_OWNED_FIELDS.has(key) &&
|
||||
!STRUCTURED_ERROR_PROTOTYPE_FIELDS.has(key))) &&
|
||||
Reflect.getOwnPropertyDescriptor(value, key)?.enumerable,
|
||||
);
|
||||
for (const key of detailKeys) {
|
||||
try {
|
||||
Object.defineProperty(error, key, {
|
||||
value: Reflect.get(value, key),
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
} catch {
|
||||
// Skip fields whose getters or property definitions reject access.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Opaque proxies may reject enumeration; preserve the original failure as the cause.
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Preserves Error values and stringifies every other value into a new Error. */
|
||||
export function toStringifiedError(value: unknown): Error {
|
||||
return value instanceof Error ? value : new Error(String(value));
|
||||
|
||||
Reference in New Issue
Block a user