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:
Peter Steinberger
2026-08-11 23:26:37 -07:00
committed by GitHub
parent 66fe424590
commit b080dd1e76
276 changed files with 1685 additions and 1663 deletions
+3 -6
View File
@@ -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 }));
}
+10 -19
View File
@@ -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: {
+2 -1
View File
@@ -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() : "";
}
+6 -6
View File
@@ -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")
);
+3 -3
View File
@@ -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));