mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -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
@@ -2,7 +2,7 @@ import { fork, type ChildProcess } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { toErrorObject } from "../infra/errors.js";
|
||||
import { toStructuredErrorObject } from "@openclaw/normalization-core/error-coercion";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import {
|
||||
confirmOpenClawAgentDatabaseIntegrity,
|
||||
@@ -25,44 +25,6 @@ export const OPENCLAW_DATABASE_VERIFY_INTERVAL_MS = 24 * 60 * 60_000;
|
||||
|
||||
const log = createSubsystemLogger("state/database-verify");
|
||||
const DATABASE_VERIFY_CHILD_ARG = "--openclaw-database-verify-child";
|
||||
const ERROR_OWNED_FIELDS = new Set(["cause", "message", "name", "stack"]);
|
||||
const PROTOTYPE_MUTATING_FIELDS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
function toDatabaseVerifyError(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;
|
||||
}
|
||||
|
||||
function resolveDatabaseVerifyWorkerUrl(currentModuleUrl = import.meta.url): URL {
|
||||
const currentPath = fileURLToPath(currentModuleUrl);
|
||||
const normalized = currentPath.replaceAll(path.sep, "/");
|
||||
@@ -104,7 +66,7 @@ export function runDatabaseVerifyWorker(
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
});
|
||||
} catch (error) {
|
||||
return Promise.reject(toDatabaseVerifyError(error));
|
||||
return Promise.reject(toStructuredErrorObject(error));
|
||||
}
|
||||
options.onWorker?.(worker);
|
||||
|
||||
@@ -156,7 +118,7 @@ export function runDatabaseVerifyWorker(
|
||||
}
|
||||
result = message;
|
||||
});
|
||||
worker.once("error", (error) => settle(() => reject(toDatabaseVerifyError(error))));
|
||||
worker.once("error", (error) => settle(() => reject(toStructuredErrorObject(error))));
|
||||
worker.once("disconnect", () => {
|
||||
disconnected = true;
|
||||
settleAfterExitAndDisconnect();
|
||||
@@ -171,7 +133,7 @@ export function runDatabaseVerifyWorker(
|
||||
return;
|
||||
}
|
||||
worker.kill();
|
||||
settle(() => reject(toDatabaseVerifyError(error)));
|
||||
settle(() => reject(toStructuredErrorObject(error)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,122 +71,13 @@ async function captureDatabaseVerifyWorkerSendFailure(failure: unknown): Promise
|
||||
}
|
||||
|
||||
describe("database verification error coercion", () => {
|
||||
it("preserves existing Error identity without invoking custom toString", async () => {
|
||||
class ThrowingToStringError extends Error {
|
||||
override toString(): string {
|
||||
throw new Error("unexpected stringification");
|
||||
}
|
||||
}
|
||||
const cause = { code: "SQLITE_IOERR" };
|
||||
const failure = new ThrowingToStringError("database failed", { cause });
|
||||
failure.name = "DatabaseFailure";
|
||||
const originalStack = failure.stack;
|
||||
it("preserves structured send failures across the database-worker boundary", async () => {
|
||||
const failure = { code: "SQLITE_IOERR", database: "state" };
|
||||
|
||||
const error = await captureDatabaseVerifyWorkerSendFailure(failure);
|
||||
|
||||
expect(error).toBe(failure);
|
||||
expect(error).toMatchObject({
|
||||
cause,
|
||||
message: "database failed",
|
||||
name: "DatabaseFailure",
|
||||
stack: originalStack,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips structured fields whose getters throw", async () => {
|
||||
const failure = {
|
||||
get details(): never {
|
||||
throw new Error("unexpected structured field read");
|
||||
},
|
||||
code: "SQLITE_IOERR",
|
||||
};
|
||||
|
||||
const error = await captureDatabaseVerifyWorkerSendFailure(failure);
|
||||
|
||||
expect(error).toMatchObject({ code: "SQLITE_IOERR" });
|
||||
expect(error).not.toHaveProperty("details");
|
||||
});
|
||||
|
||||
it("preserves the base Error when structured enumeration traps throw", async () => {
|
||||
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 failure = new Proxy({ code: "SQLITE_IOERR", status: 10 }, handler);
|
||||
const error = await captureDatabaseVerifyWorkerSendFailure(failure);
|
||||
|
||||
expect(error).toMatchObject({ name: "Error", message: "[object Object]" });
|
||||
expect(error.cause).toBe(failure);
|
||||
expect(error).not.toHaveProperty("code");
|
||||
expect(error).not.toHaveProperty("status");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves adapter-owned Error fields when structured failure fields collide", async () => {
|
||||
const detailKey = Symbol("detail");
|
||||
let reservedReads = 0;
|
||||
const failure = {
|
||||
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: "SQLITE_IOERR",
|
||||
details: { database: "state" },
|
||||
[detailKey]: "symbol detail",
|
||||
};
|
||||
|
||||
const error = await captureDatabaseVerifyWorkerSendFailure(failure);
|
||||
|
||||
expect(reservedReads).toBe(0);
|
||||
expect(error.message).toBe("[object Object]");
|
||||
expect(error).toMatchObject({ message: "[object Object]", code: "SQLITE_IOERR" });
|
||||
expect(error.cause).toBe(failure);
|
||||
expect(error.name).toBe("Error");
|
||||
expect(error.stack).toContain("Error: [object Object]");
|
||||
expect(error).toMatchObject({ code: "SQLITE_IOERR", details: { database: "state" } });
|
||||
expect(Reflect.get(error, detailKey)).toBe("symbol detail");
|
||||
});
|
||||
|
||||
it("rejects prototype-mutating structured failure fields", async () => {
|
||||
const failure = { constructor: { polluted: true }, prototype: { polluted: true } };
|
||||
Object.defineProperty(failure, "__proto__", {
|
||||
value: { polluted: true },
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
const error = await captureDatabaseVerifyWorkerSendFailure(failure);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { safeParseJsonRecord } from "@openclaw/normalization-core";
|
||||
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
|
||||
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeAgentRunTerminalReplySnapshot } from "../agents/agent-run-terminal-reply.js";
|
||||
import { selectDeliverableSessionsReply } from "../agents/tools/sessions-send-tokens.js";
|
||||
@@ -384,8 +385,7 @@ function textField(record: Record<string, unknown>, key: string): string | null
|
||||
}
|
||||
|
||||
function numberField(record: Record<string, unknown>, key: string): number | null {
|
||||
const value = record[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
return asFiniteNumber(record[key]) ?? null;
|
||||
}
|
||||
|
||||
function recordField(record: Record<string, unknown>, key: string): Record<string, unknown> | null {
|
||||
|
||||
Reference in New Issue
Block a user