mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -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,3 +1,4 @@
|
||||
import { toStructuredErrorObject } from "@openclaw/normalization-core/error-coercion";
|
||||
import type { WebSocket } from "ws";
|
||||
import type {
|
||||
WorkerConnectParams,
|
||||
@@ -6,14 +7,11 @@ import type {
|
||||
WorkerProtocolCloseReason,
|
||||
} from "../../packages/gateway-protocol/src/schema/worker-admission.js";
|
||||
import type { BackoffPolicy } from "../infra/backoff.js";
|
||||
import { toErrorObject } from "../infra/errors.js";
|
||||
|
||||
const FENCED_CLOSE_REASONS = new Set<WorkerProtocolCloseReason>([
|
||||
"credential-replaced",
|
||||
"owner-epoch-mismatch",
|
||||
]);
|
||||
const ERROR_OWNED_FIELDS = new Set(["cause", "message", "name", "stack"]);
|
||||
const PROTOTYPE_MUTATING_FIELDS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
export type WorkerFencedReason = "credential-replaced" | "owner-epoch-mismatch";
|
||||
|
||||
@@ -98,36 +96,5 @@ export function resolvePositiveTimeout(value: number | undefined, fallback: numb
|
||||
}
|
||||
|
||||
export function toWorkerConnectionError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
const message = String(error);
|
||||
if ((typeof error !== "object" || error === null) && typeof error !== "function") {
|
||||
return toErrorObject(error, message);
|
||||
}
|
||||
const normalized = toErrorObject({}, message);
|
||||
normalized.cause = error;
|
||||
try {
|
||||
const detailKeys = Reflect.ownKeys(error).filter(
|
||||
(key) =>
|
||||
(typeof key !== "string" ||
|
||||
(!ERROR_OWNED_FIELDS.has(key) && !PROTOTYPE_MUTATING_FIELDS.has(key))) &&
|
||||
Reflect.getOwnPropertyDescriptor(error, key)?.enumerable,
|
||||
);
|
||||
for (const key of detailKeys) {
|
||||
try {
|
||||
Object.defineProperty(normalized, key, {
|
||||
value: Reflect.get(error, 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 normalized;
|
||||
return toStructuredErrorObject(error);
|
||||
}
|
||||
|
||||
@@ -132,28 +132,6 @@ function installThrowingThenHealthyListeners(connection: ReturnType<typeof creat
|
||||
}
|
||||
|
||||
describe("worker connection error coercion", () => {
|
||||
it("preserves existing Error identity without invoking custom toString", () => {
|
||||
class ThrowingToStringError extends Error {
|
||||
override toString(): string {
|
||||
throw new Error("unexpected stringification");
|
||||
}
|
||||
}
|
||||
const cause = { code: "ECONNRESET" };
|
||||
const original = new ThrowingToStringError("worker failed", { cause });
|
||||
original.name = "WorkerFailure";
|
||||
const originalStack = original.stack;
|
||||
|
||||
const error = toWorkerConnectionError(original);
|
||||
|
||||
expect(error).toBe(original);
|
||||
expect(error).toMatchObject({
|
||||
cause,
|
||||
message: "worker failed",
|
||||
name: "WorkerFailure",
|
||||
stack: originalStack,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves structured non-Error causes", () => {
|
||||
const cause = { code: "ECONNRESET", status: 503 };
|
||||
|
||||
@@ -163,104 +141,6 @@ describe("worker connection error coercion", () => {
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error).toMatchObject(cause);
|
||||
});
|
||||
|
||||
it("skips structured fields whose getters throw", () => {
|
||||
const cause = {
|
||||
get details(): never {
|
||||
throw new Error("unexpected structured field read");
|
||||
},
|
||||
code: "ECONNRESET",
|
||||
};
|
||||
let error: Error | undefined;
|
||||
|
||||
expect(() => {
|
||||
error = toWorkerConnectionError(cause);
|
||||
}).not.toThrow();
|
||||
expect(error).toMatchObject({ code: "ECONNRESET" });
|
||||
expect(error).not.toHaveProperty("details");
|
||||
});
|
||||
|
||||
it("preserves the base Error when structured enumeration traps throw", () => {
|
||||
const handlers: ProxyHandler<{ code: string; status: number }>[] = [
|
||||
{
|
||||
ownKeys() {
|
||||
throw new Error("unexpected ownKeys call");
|
||||
},
|
||||
},
|
||||
{
|
||||
ownKeys() {
|
||||
return ["code", "status"];
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
if (key === "status") {
|
||||
throw new Error("unexpected descriptor read");
|
||||
}
|
||||
return Reflect.getOwnPropertyDescriptor(target, key);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const handler of handlers) {
|
||||
const cause = new Proxy({ code: "ECONNRESET", status: 503 }, handler);
|
||||
const error = toWorkerConnectionError(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("preserves adapter-owned Error fields when structured cause fields collide", () => {
|
||||
const detailKey = Symbol("detail");
|
||||
let reservedReads = 0;
|
||||
const cause = {
|
||||
get name() {
|
||||
reservedReads += 1;
|
||||
return "SpoofedError";
|
||||
},
|
||||
get message() {
|
||||
reservedReads += 1;
|
||||
return "spoofed message";
|
||||
},
|
||||
get cause() {
|
||||
reservedReads += 1;
|
||||
return "spoofed cause";
|
||||
},
|
||||
get stack() {
|
||||
reservedReads += 1;
|
||||
return "spoofed stack";
|
||||
},
|
||||
code: "ECONNRESET",
|
||||
details: { retryable: true },
|
||||
[detailKey]: "symbol detail",
|
||||
};
|
||||
|
||||
const error = toWorkerConnectionError(cause);
|
||||
|
||||
expect(reservedReads).toBe(0);
|
||||
expect(error.message).toBe("[object Object]");
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error.name).toBe("Error");
|
||||
expect(error.stack).toContain("Error: [object Object]");
|
||||
expect(error).toMatchObject({ code: "ECONNRESET", details: { retryable: true } });
|
||||
expect(Reflect.get(error, detailKey)).toBe("symbol detail");
|
||||
});
|
||||
|
||||
it("rejects prototype-mutating structured cause fields", () => {
|
||||
const cause = { constructor: { polluted: true }, prototype: { polluted: true } };
|
||||
Object.defineProperty(cause, "__proto__", {
|
||||
value: { polluted: true },
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
const error = toWorkerConnectionError(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("WorkerConnection state listener isolation", () => {
|
||||
|
||||
Reference in New Issue
Block a user