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
@@ -1,6 +1,6 @@
/** Tests live model switching behavior in active agent command sessions. */
import { expectDefined } from "@openclaw/normalization-core";
import { expectDefined, toStringifiedError } from "@openclaw/normalization-core";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
@@ -274,8 +274,7 @@ vi.mock("../acp/policy.js", () => ({
}));
vi.mock("../acp/runtime/errors.js", () => ({
toAcpRuntimeError: ({ error }: { error: unknown }) =>
error instanceof Error ? error : new Error(String(error)),
toAcpRuntimeError: ({ error }: { error: unknown }) => toStringifiedError(error),
}));
vi.mock("@openclaw/acp-core/runtime/session-identifiers", () => ({
+2 -5
View File
@@ -5,6 +5,7 @@
*/
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { readNonBlankString } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { coerceSecretRef } from "../../config/types.secrets.js";
import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
@@ -56,11 +57,7 @@ function isRetainedUsageStatsId(
// Persisted credential normalization accepts old field names and SecretRef-ish
// values, then emits the current credential discriminated union.
function normalizeOptionalCredentialString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? value : undefined;
return readNonBlankString(value);
}
function normalizeExpiryField(value: unknown): number | undefined {
@@ -1,4 +1,5 @@
/** Prepares exec workdir and environment facts before policy and host dispatch. */
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeChatChannelId } from "../channels/ids.js";
import type { ExecHost } from "../infra/exec-approvals.js";
@@ -96,7 +97,7 @@ function buildChannelContextEnv(
}
function isExecToolArgsObject(value: unknown): value is ExecToolArgs {
return typeof value === "object" && value !== null && !Array.isArray(value);
return isRecord(value);
}
function filterPluginExecEnv(rawEnv: Record<string, string>): Record<string, string> | undefined {
+2 -5
View File
@@ -1,3 +1,4 @@
import { asPositiveFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
createReasoningTagTextPartitioner,
@@ -470,11 +471,7 @@ function readThinkingProgressTokens(delta: Record<string, unknown>): number | un
if (delta.type !== "thinking_delta" || delta.thinking !== "") {
return undefined;
}
const estimatedTokens = delta.estimated_tokens;
if (typeof estimatedTokens !== "number" || !Number.isFinite(estimatedTokens)) {
return undefined;
}
return estimatedTokens > 0 ? estimatedTokens : undefined;
return asPositiveFiniteNumber(delta.estimated_tokens);
}
function emitClaudeThinkingProgress(
@@ -11,6 +11,7 @@ import { stableStringify } from "@openclaw/normalization-core";
import {
asDateTimestampMs,
isFutureDateTimestampMs,
parseDateStringTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
@@ -203,10 +204,7 @@ async function appendGooglePromptCacheEntry(
}
function parseExpireTimeMs(expireTime: string | undefined): number | null {
if (!expireTime) {
return null;
}
return asDateTimestampMs(Date.parse(expireTime)) ?? null;
return parseDateStringTimestampMs(expireTime) ?? null;
}
function convertManagedGoogleTools(tools: NonNullable<GooglePromptCacheContext["tools"]>) {
@@ -1,6 +1,7 @@
/**
* Reads normalized context-token metadata from resolved model definitions.
*/
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import type { Model } from "../../llm/types.js";
/**
@@ -14,5 +15,5 @@ type AgentModelWithOptionalContextTokens = Model & {
/** Prefer contextTokens, then contextWindow, when present on model metadata. */
export function readAgentModelContextTokens(model: Model | null | undefined): number | undefined {
const value = (model as AgentModelWithOptionalContextTokens | null | undefined)?.contextTokens;
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
return asFiniteNumber(value);
}
@@ -1,4 +1,5 @@
import { finiteSecondsToTimerSafeMilliseconds } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { Api, Model } from "../../llm/types.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
@@ -272,11 +273,7 @@ export function resolveProviderTransport(params: {
}
export function normalizeTransportBaseUrl(baseUrl: unknown): string | undefined {
if (typeof baseUrl !== "string") {
return undefined;
}
const trimmed = baseUrl.trim();
return trimmed ? trimmed : undefined;
return normalizeOptionalString(baseUrl);
}
export function resolveProviderRequestTimeoutMs(timeoutSeconds: unknown): number | undefined {
@@ -71,7 +71,7 @@ function hasNonEmptyAssistantText(texts: string[]): boolean {
return texts.some((text) => text.trim().length > 0);
}
function hasNonEmptyString(values: string[]): boolean {
function hasAnyNonBlankString(values: string[]): boolean {
return values.some((value) => value.trim().length > 0);
}
@@ -82,8 +82,8 @@ function hasCommittedMessagingDeliveryEvidence(
>,
): boolean {
return (
hasNonEmptyString(params.messagingToolSentTexts) ||
hasNonEmptyString(params.messagingToolSentMediaUrls) ||
hasAnyNonBlankString(params.messagingToolSentTexts) ||
hasAnyNonBlankString(params.messagingToolSentMediaUrls) ||
params.messagingToolSentTargets.length > 0
);
}
+2 -1
View File
@@ -5,6 +5,7 @@
* before-finalize retry/finalize decisions with bounded retry accounting.
*/
import { createHash } from "node:crypto";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString as normalizeTrimmedString } from "@openclaw/normalization-core/string-coerce";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
@@ -240,5 +241,5 @@ function readBeforeAgentFinalizeRetryCandidates(
function isBeforeAgentFinalizeRetry(
value: unknown,
): value is NonNullable<PluginHookBeforeAgentFinalizeResult["retry"]> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
return isRecord(value);
}
@@ -1,4 +1,5 @@
import path from "node:path";
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { resolveStateDir } from "../../config/paths.js";
import {
listConfiguredSessionStoreAgentIds,
@@ -64,9 +65,7 @@ export function normalizeStringSet(values: Iterable<string> | undefined): Set<st
return normalized;
}
export function normalizeFiniteTimestamp(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
export const normalizeFiniteTimestamp = asFiniteNumber;
export function hasCurrentProcessOwner(params: {
activeSessionIds: Set<string>;
+3 -2
View File
@@ -2,6 +2,7 @@
* Resolves MCP transport command, environment, and timeout configuration.
*/
import {
asPositiveFiniteNumber,
clampPositiveTimerTimeoutMs,
resolvePositiveTimerTimeoutMs,
} from "@openclaw/normalization-core/number-coercion";
@@ -68,8 +69,8 @@ function getPositiveNumber(rawServer: unknown, keys: readonly string[]): number
}
const record = rawServer as Record<string, unknown>;
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
const value = asPositiveFiniteNumber(record[key]);
if (value !== undefined) {
return value;
}
}
+3 -1
View File
@@ -1,7 +1,9 @@
import { toStringifiedError } from "@openclaw/normalization-core/error-coercion";
export class PreparedModelRuntimeOwnerNotPublishedError extends Error {}
export class PreparedModelRuntimePublicationSupersededError extends PreparedModelRuntimeOwnerNotPublishedError {}
export function toPreparedModelRuntimeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
return toStringifiedError(error);
}
+11 -10
View File
@@ -3,6 +3,7 @@
*/
import type { KeyId } from "@earendil-works/pi-tui";
import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion";
import type { ImageContent, Model } from "../../../llm/types.js";
import { interactiveAgentTheme as theme, type Theme } from "../../modes/interactive/theme/theme.js";
import type { AgentMessage } from "../../runtime/index.js";
@@ -335,7 +336,7 @@ export class ExtensionRunner {
this.emitError({
extensionPath,
event: "register_provider",
error: err instanceof Error ? err.message : String(err),
error: coerceErrorMessage(err),
stack: err instanceof Error ? err.stack : undefined,
});
}
@@ -734,7 +735,7 @@ export class ExtensionRunner {
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -782,7 +783,7 @@ export class ExtensionRunner {
currentMessage = handlerResult.message;
modified = true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -830,7 +831,7 @@ export class ExtensionRunner {
modified = true;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -894,7 +895,7 @@ export class ExtensionRunner {
return handlerResult as UserBashEventResult;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -934,7 +935,7 @@ export class ExtensionRunner {
currentMessages = (handlerResult as ContextEventResult).messages!;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -970,7 +971,7 @@ export class ExtensionRunner {
currentPayload = handlerResult;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -1031,7 +1032,7 @@ export class ExtensionRunner {
}
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -1094,7 +1095,7 @@ export class ExtensionRunner {
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = coerceErrorMessage(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
@@ -1140,7 +1141,7 @@ export class ExtensionRunner {
this.emitError({
extensionPath: ext.path,
event: "input",
error: err instanceof Error ? err.message : String(err),
error: coerceErrorMessage(err),
stack: err instanceof Error ? err.stack : undefined,
});
}
+2 -3
View File
@@ -9,6 +9,7 @@ import {
asSafeIntegerInRange,
parseStrictFiniteNumber,
} from "@openclaw/normalization-core/number-coercion";
import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import type { TSchema } from "typebox";
import { readLocalFileSafely } from "../../infra/fs-safe.js";
@@ -70,9 +71,7 @@ export type AnyAgentTool = Omit<AgentTool, "execute"> &
};
export function asToolParamsRecord(params: unknown): Record<string, unknown> {
return params && typeof params === "object" && !Array.isArray(params)
? (params as Record<string, unknown>)
: {};
return asNonArrayRecord(params);
}
type StringParamOptions = {
+2 -1
View File
@@ -3,6 +3,7 @@
*
* Implements only the Gateway calls needed by session tools and rejects unsupported methods.
*/
import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion";
import { normalizeFastMode, type FastMode } from "@openclaw/normalization-core/string-coerce";
import type {
SessionsListParams,
@@ -145,7 +146,7 @@ function readChatHistoryMessageSeq(message: unknown): number | undefined {
return undefined;
}
const seq = (metadata as Record<string, unknown>).seq;
return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined;
return asPositiveSafeInteger(seq);
}
function resolveChatHistoryNextOffset(params: {
@@ -3,7 +3,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { coerceErrorMessage as formatLiveError, expectDefined } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it } from "vitest";
import type { ModelApi } from "../../config/types.models.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -110,10 +110,6 @@ function readJpegDimensions(buffer: Buffer): { width: number; height: number } {
throw new Error("JPEG dimensions not found");
}
function formatLiveError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isSkippableLiveError(error: unknown): boolean {
const message = formatLiveError(error);
return (
+2 -1
View File
@@ -3,6 +3,7 @@
*
* Reads bounded, redacted session transcript history after session visibility filtering.
*/
import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion";
import { Type } from "typebox";
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -216,7 +217,7 @@ function readHistoryMessageSeq(message: unknown): number | undefined {
return undefined;
}
const seq = (meta as Record<string, unknown>).seq;
return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined;
return asPositiveSafeInteger(seq);
}
function readHistoryMessageId(message: unknown): string | undefined {