mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -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,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