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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
276 changed files with 1685 additions and 1663 deletions

View file

@ -24,6 +24,7 @@ import type {
SessionTranscriptTargetParams,
TranscriptTurnAdmission,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import { readNonBlankString as readNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js";
import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js";
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
@ -502,10 +503,6 @@ function readPositiveNumber(value: unknown): number | undefined {
: undefined;
}
function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
/**
* Builds OpenClaw-provided workspace prompt context for the current Codex turn.
*/

View file

@ -10,6 +10,7 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import { createDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime";
import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { isIncognitoSessionKey } from "../incognito-session.js";
@ -963,13 +964,10 @@ function isCodexThreadNotFoundError(error: unknown): boolean {
// compaction.rs asserts message.contains("thread not found")). So the message
// is the authoritative positive signal here, not the generic code. This is a
// self-heal recovery gate, not user-facing classification.
return formatCompactionError(error).toLowerCase().includes("thread not found");
return coerceErrorMessage(error).toLowerCase().includes("thread not found");
}
function formatCompactionError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
return coerceErrorMessage(error);
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */

View file

@ -2,6 +2,8 @@ import { createHmac, randomBytes } from "node:crypto";
import { resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import {
asOptionalRecord as readRecord,
normalizeOptionalString as readNonEmptyString,
normalizeTrimmedStringList,
parseBooleanValue,
} from "openclaw/plugin-sdk/string-coerce-runtime";
@ -12,11 +14,7 @@ const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStart
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
const PLAIN_DECIMAL_NUMBER_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))$/;
export function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
export { readNonEmptyString, readRecord };
export function normalizeCodexServiceTier(value: unknown): CodexServiceTier | undefined {
if (typeof value !== "string") {
@ -108,14 +106,6 @@ export function resolveArgs(configArgs: unknown, envArgs: string | undefined): s
return splitShellWords(envArgs ?? "");
}
export function readNonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
export function hashSecretForKey(value: string | undefined, label: string): string | null {
if (!value) {
return null;

View file

@ -46,6 +46,7 @@ import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import type { ImageContent, TextContent } from "openclaw/plugin-sdk/llm";
import { normalizeOpenAIToolSchemas } from "openclaw/plugin-sdk/provider-tools";
import {
asNonArrayRecord,
asOptionalRecord,
isRecord,
normalizeOptionalString,
@ -557,7 +558,7 @@ export function createCodexDynamicToolBridge(params: {
handleToolCall: async (call, options) => {
const toolEntry = toolMap.get(call.tool);
if (!toolEntry) {
const executedArguments = jsonObjectToRecord(call.arguments);
const executedArguments = asNonArrayRecord(call.arguments);
const message = registeredToolNames.has(call.tool)
? `OpenClaw tool is not available for this turn: ${call.tool}`
: `Unknown OpenClaw tool: ${call.tool}`;
@ -582,7 +583,7 @@ export function createCodexDynamicToolBridge(params: {
});
}
const { tool, name: toolName } = toolEntry;
const args = jsonObjectToRecord(call.arguments);
const args = asNonArrayRecord(call.arguments);
const startedAt = Date.now();
const signal = composeAbortSignals(params.signal, options?.signal);
let didStartExecution = false;
@ -1530,12 +1531,6 @@ function convertToolContent(
},
];
}
function jsonObjectToRecord(value: JsonValue | undefined): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
function readFirstString(record: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];

View file

@ -2,7 +2,10 @@ import {
formatToolAggregate,
formatToolProgressOutput,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
asNonArrayRecord,
readStringField as readString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { isJsonObject, type CodexThreadItem } from "./protocol.js";
@ -104,10 +107,7 @@ export function toolOutputRawEchoSignature(
}
export function normalizeToolTranscriptArguments(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
return asNonArrayRecord(value);
}
export function collectDynamicToolContentText(

View file

@ -1,14 +1,13 @@
import { normalizeUsage } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
asFiniteNumber,
asSafeIntegerInRange,
readStringField as readString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { readNonNegativeInteger } from "./event-projector-values.js";
import { isJsonObject, type JsonObject } from "./protocol.js";
function readTokenCount(record: JsonObject, key: string): number | undefined {
const value = readNonNegativeInteger(record, key);
return value !== undefined && Number.isSafeInteger(value) ? value : undefined;
return asSafeIntegerInRange(record[key], { min: 0 });
}
function readCodexThreadTokenUsage(params: JsonObject): ReturnType<typeof normalizeUsage> {

View file

@ -1,15 +1,14 @@
import { asFiniteNumber, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
asFiniteNumber,
normalizeOptionalString,
readStringField,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { isJsonObject, type CodexThreadItem, type JsonObject, type JsonValue } from "./protocol.js";
export function normalizeNonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
return value.trim() || undefined;
}
export { normalizeOptionalString as normalizeNonEmptyString };
export function readNonEmptyString(record: JsonObject, key: string): string | undefined {
return normalizeNonEmptyString(record[key]);
return normalizeOptionalString(record[key]);
}
export function readNonEmptyStringArray(record: JsonObject, key: string): string[] {
@ -19,7 +18,7 @@ export function readNonEmptyStringArray(record: JsonObject, key: string): string
}
const entries: string[] = [];
for (const entry of value) {
const normalized = normalizeNonEmptyString(entry);
const normalized = normalizeOptionalString(entry);
if (normalized) {
entries.push(normalized);
}

View file

@ -2,7 +2,7 @@
* Lists and normalizes models exposed by the Codex app-server `model/list`
* endpoint, including pagination and shared-client lease handling.
*/
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
CodexAppServerAuthRequirement,
resolveCodexAppServerAuthProfileIdForAgent,
@ -145,8 +145,8 @@ export function readModelListResult(value: unknown): CodexAppServerModelListResu
}
function readCodexModel(value: CodexModel): CodexAppServerModel {
const id = readNonEmptyString(value.id);
const model = readNonEmptyString(value.model);
const id = normalizeOptionalString(value.id);
const model = normalizeOptionalString(value.model);
if (!id || !model) {
throw new Error(
"Invalid Codex app-server model/list response: model id and name must be non-empty strings",
@ -155,37 +155,29 @@ function readCodexModel(value: CodexModel): CodexAppServerModel {
return {
id,
model,
...(readNonEmptyString(value.displayName)
? { displayName: readNonEmptyString(value.displayName) }
...(normalizeOptionalString(value.displayName)
? { displayName: normalizeOptionalString(value.displayName) }
: {}),
...(readNonEmptyString(value.description)
? { description: readNonEmptyString(value.description) }
...(normalizeOptionalString(value.description)
? { description: normalizeOptionalString(value.description) }
: {}),
hidden: value.hidden,
isDefault: value.isDefault,
inputModalities: value.inputModalities,
supportedReasoningEfforts: readReasoningEfforts(value.supportedReasoningEfforts),
...(readNonEmptyString(value.defaultReasoningEffort)
? { defaultReasoningEffort: readNonEmptyString(value.defaultReasoningEffort) }
...(normalizeOptionalString(value.defaultReasoningEffort)
? { defaultReasoningEffort: normalizeOptionalString(value.defaultReasoningEffort) }
: {}),
};
}
function readReasoningEfforts(value: CodexReasoningEffortOption[]): string[] {
const efforts = value
.map((entry) => readNonEmptyString(entry.reasoningEffort))
.map((entry) => normalizeOptionalString(entry.reasoningEffort))
.filter((entry): entry is string => entry !== undefined);
return uniqueStrings(efforts);
}
function readNonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function normalizeMaxPages(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 20;
}

View file

@ -715,17 +715,11 @@ function mergeJsonObjects(left: JsonObject, right: JsonObject): JsonObject {
for (const [key, value] of Object.entries(right)) {
const existing = merged[key];
merged[key] =
isPlainJsonObject(existing) && isPlainJsonObject(value)
? mergeJsonObjects(existing, value)
: value;
isJsonObject(existing) && isJsonObject(value) ? mergeJsonObjects(existing, value) : value;
}
return merged;
}
function isPlainJsonObject(value: JsonValue | undefined): value is JsonObject {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function fingerprintJson(value: JsonValue): string {
return crypto.createHash("sha256").update(stableStringify(value)).digest("hex");
}

View file

@ -1,3 +1,4 @@
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { CodexCommandExecParams, CodexCommandExecResponse } from "./command-exec-protocol.js";
import type {
CodexAppInfo,
@ -707,7 +708,7 @@ type CodexAppServerRequestResultMap = {
};
export function isJsonObject(value: unknown): value is JsonObject {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
return isRecord(value);
}
export function isRpcResponse(message: RpcMessage): message is RpcResponse {

View file

@ -1,4 +1,4 @@
import type { JsonValue } from "./protocol.js";
import { isJsonObject, type JsonValue } from "./protocol.js";
/** RPC error wrapper that preserves app-server error code and data. */
export class CodexAppServerRpcError extends Error {
@ -36,7 +36,3 @@ function readCodexAppServerRpcReloginDetail(data: JsonValue | undefined): string
const detail = typeof nested.detail === "string" ? nested.detail.trim() : "";
return isRelogin && detail ? detail : undefined;
}
function isJsonObject(value: unknown): value is { [key: string]: JsonValue } {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}

View file

@ -1,6 +1,6 @@
import { Buffer } from "node:buffer";
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { JsonValue } from "./protocol.js";
import { readUpstreamUserText } from "./upstream-prompt-provenance.js";
@ -18,10 +18,6 @@ type ProjectedMessageGroup = {
bytes: number;
};
function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() || undefined : undefined;
}
function readBoundedText(
value: unknown,
label: string,
@ -49,7 +45,7 @@ function responseItemBytes(item: JsonValue): number {
}
function requireCallId(value: unknown): string {
const callId = readNonEmptyString(value);
const callId = normalizeOptionalString(value);
if (!callId || callId.length > 256) {
throw new Error("Codex settled-turn projection found an invalid tool call id");
}
@ -57,7 +53,7 @@ function requireCallId(value: unknown): string {
}
function requireToolName(value: unknown): string {
const name = readNonEmptyString(value);
const name = normalizeOptionalString(value);
if (!name || !TOOL_NAME_PATTERN.test(name)) {
throw new Error("Codex settled-turn projection found an invalid tool name");
}
@ -207,7 +203,7 @@ function projectToolResult(message: Record<string, unknown>): {
throw new Error("Codex settled-turn projection found malformed tool result content");
}
if (value.type === "image") {
const mimeType = readNonEmptyString(value.mimeType) ?? "unknown type";
const mimeType = normalizeOptionalString(value.mimeType) ?? "unknown type";
// The finalizer selects by text capability. Preserve image evidence as
// metadata without embedding an executable or oversized multimodal payload.
parts.push(`[Image tool result: ${mimeType}]`);

View file

@ -2,6 +2,7 @@ import {
isActiveHarnessContextEngine,
type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
resolveCodexContextEngineProjectionMaxChars,
resolveCodexContextEngineProjectionReserveTokens,
@ -88,16 +89,12 @@ function areContextEngineProjectionBindingsCompatible(
}
function resolveContextEngineCitationsMode(config: unknown): JsonValue | undefined {
const rootConfig = isUnknownRecord(config) ? config : undefined;
const memoryConfig = isUnknownRecord(rootConfig?.memory) ? rootConfig.memory : undefined;
const rootConfig = isRecord(config) ? config : undefined;
const memoryConfig = isRecord(rootConfig?.memory) ? rootConfig.memory : undefined;
const citations = memoryConfig?.citations;
return isJsonConfigValue(citations) ? citations : undefined;
}
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function isJsonConfigValue(value: unknown): value is JsonValue {
if (value === null || typeof value === "string" || typeof value === "boolean") {
return true;
@ -108,5 +105,5 @@ function isJsonConfigValue(value: unknown): value is JsonValue {
if (Array.isArray(value)) {
return value.every(isJsonConfigValue);
}
return isUnknownRecord(value) && Object.values(value).every(isJsonConfigValue);
return isRecord(value) && Object.values(value).every(isJsonConfigValue);
}

View file

@ -8,7 +8,7 @@ import {
deleteSessionUpstreamLink,
upsertSessionUpstreamLink,
} from "openclaw/plugin-sdk/session-catalog";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { isIncognitoSessionKey } from "../incognito-session.js";
import type { CodexSessionCatalogControl } from "../session-catalog-types.js";
import { codexLastTerminalTurnId, codexUpstreamBaseline } from "../session-upstream-marker.js";
@ -32,7 +32,7 @@ function readConnectionFingerprint(ref: unknown): string | undefined {
}
function normalizeTurnId(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
return normalizeOptionalString(value);
}
export async function forkCodexUpstreamSession(

View file

@ -12,7 +12,11 @@ import {
ModelSelectionLockedError,
} from "openclaw/plugin-sdk/model-session-runtime";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
import { asBoolean, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
asBoolean,
asOptionalRecord,
asSafeIntegerInRange,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { Type } from "typebox";
import { resolveCodexBindingAppServerConnection } from "./app-server/binding-connection.js";
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
@ -112,12 +116,6 @@ type CodexThreadsToolOptions = {
request?: typeof codexControlRequest;
};
function readLimit(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100
? value
: undefined;
}
function resolveToolSession(
context: OpenClawPluginToolContext,
runtime: PluginRuntime,
@ -291,7 +289,7 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge
CODEX_CONTROL_METHODS.listThreads,
{
archived: asBoolean(params.archived) ?? false,
limit: readLimit(params.limit) ?? 20,
limit: asSafeIntegerInRange(params.limit, { min: 1, max: 100 }) ?? 20,
modelProviders: [],
sortKey: "recency_at",
sortDirection: "desc",

View file

@ -1,10 +1,8 @@
import { readNonEmptyStringPreservingWhitespace as normalizeTurnId } from "openclaw/plugin-sdk/string-coerce-runtime";
import { describe, expect, it } from "vitest";
import type { CodexThread } from "./app-server/protocol.js";
import { codexUpstreamBaseline } from "./session-upstream-marker.js";
const normalizeTurnId = (value: unknown) =>
typeof value === "string" && value ? value : undefined;
describe("codexUpstreamBaseline", () => {
it("baselines an active adoption-time turn including its current user items", () => {
const thread = {

View file

@ -6,6 +6,7 @@ import {
wrapWebContent,
} from "openclaw/plugin-sdk/provider-web-search";
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
runBoundedCodexAppServerTurn,
type CodexBoundedTurnOptions,
@ -66,7 +67,7 @@ function summarizeCodexWebSearchItem(item: CodexThreadItem): Record<string, unkn
const actionType = readNonEmptyString(action, "type");
const queries = actionType === "search" ? readNonEmptyStringArray(action, "queries") : [];
const query =
normalizeNonEmptyString(item.query) ??
normalizeOptionalString(item.query) ??
(actionType === "search" ? readNonEmptyString(action, "query") : undefined) ??
queries[0];
const url = readNonEmptyString(action, "url");
@ -81,7 +82,7 @@ function summarizeCodexWebSearchItem(item: CodexThreadItem): Record<string, unkn
}
function readNonEmptyString(record: JsonObject | undefined, key: string): string | undefined {
return record ? normalizeNonEmptyString(record[key]) : undefined;
return record ? normalizeOptionalString(record[key]) : undefined;
}
function readNonEmptyStringArray(record: JsonObject | undefined, key: string): string[] {
@ -90,11 +91,7 @@ function readNonEmptyStringArray(record: JsonObject | undefined, key: string): s
return [];
}
return value.flatMap((entry) => {
const normalized = normalizeNonEmptyString(entry);
const normalized = normalizeOptionalString(entry);
return normalized ? [normalized] : [];
});
}
function normalizeNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() || undefined : undefined;
}