diff --git a/packages/memory-host-sdk/src/host/embeddings-remote-fetch.ts b/packages/memory-host-sdk/src/host/embeddings-remote-fetch.ts index d259e65568fe..3387661b5185 100644 --- a/packages/memory-host-sdk/src/host/embeddings-remote-fetch.ts +++ b/packages/memory-host-sdk/src/host/embeddings-remote-fetch.ts @@ -1,16 +1,10 @@ // Memory Host SDK module implements embeddings remote fetch behavior. +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { postJson } from "./post-json.js"; import type { SsrFPolicy } from "./ssrf-policy.js"; // Fetches and validates OpenAI-compatible embedding responses. -/** Narrow unknown JSON payloads to plain objects. */ -function asRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - /** Build the common malformed embedding response error. */ function malformedEmbeddingResponse(errorPrefix: string): Error { return new Error(`${errorPrefix}: malformed JSON response`); @@ -31,7 +25,7 @@ function readEmbeddingVector(value: unknown, errorPrefix: string): number[] { /** Resolve expected response count from the request body when input is an array. */ function resolveExpectedEmbeddingCount(body: unknown): number | undefined { - const input = asRecord(body)?.input; + const input = asOptionalRecord(body)?.input; return Array.isArray(input) ? input.length : undefined; } @@ -54,7 +48,7 @@ export async function fetchRemoteEmbeddingVectors(params: { body: params.body, errorPrefix: params.errorPrefix, parse: (payload) => { - const root = asRecord(payload); + const root = asOptionalRecord(payload); if (!root || !Array.isArray(root.data)) { throw malformedEmbeddingResponse(params.errorPrefix); } @@ -63,7 +57,7 @@ export async function fetchRemoteEmbeddingVectors(params: { throw malformedEmbeddingResponse(params.errorPrefix); } return root.data.map((entry) => { - const record = asRecord(entry); + const record = asOptionalRecord(entry); if (!record) { throw malformedEmbeddingResponse(params.errorPrefix); } diff --git a/packages/memory-host-sdk/src/host/qmd-query-parser.ts b/packages/memory-host-sdk/src/host/qmd-query-parser.ts index 3b9cd1c45d1e..cd702c5653ba 100644 --- a/packages/memory-host-sdk/src/host/qmd-query-parser.ts +++ b/packages/memory-host-sdk/src/host/qmd-query-parser.ts @@ -1,4 +1,5 @@ // Memory Host SDK module implements qmd query parser behavior. +import { asPositiveSafeInteger as parseQmdLineNumber } from "@openclaw/normalization-core/number-coercion"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { formatErrorMessage } from "./error-utils.js"; @@ -124,11 +125,6 @@ function parseQmdQueryResultArray(raw: string): QmdQueryResult[] | null { } } -/** Normalize qmd line numbers, rejecting zero, negative, and non-integer values. */ -function parseQmdLineNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; -} - /** Extract the first complete, standalone JSON result array from noisy stdout. */ function extractFirstJsonArray(raw: string): string | null { let start = -1; diff --git a/packages/memory-host-sdk/src/host/secret-input-utils.ts b/packages/memory-host-sdk/src/host/secret-input-utils.ts index 5d482d2a5e55..0e4bc66ba1d5 100644 --- a/packages/memory-host-sdk/src/host/secret-input-utils.ts +++ b/packages/memory-host-sdk/src/host/secret-input-utils.ts @@ -1,6 +1,9 @@ // Secret input parsing shared by memory provider config and gateway-resolved snapshots. import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { hasNonEmptyString } from "@openclaw/normalization-core/string-coerce"; +import { + hasNonEmptyString, + normalizeOptionalString, +} from "@openclaw/normalization-core/string-coerce"; /** Supported secret reference backing stores. */ type SecretRefSource = "env" | "file" | "exec"; @@ -18,15 +21,6 @@ const LEGACY_SECRETREF_ENV_MARKER_PREFIX = "secretref-env:"; const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/; const SECRET_REF_SOURCES = new Set(["env", "file", "exec"]); -/** Normalize literal secret strings and reject empty placeholders. */ -function normalizeSecretInputString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - /** Narrow a string to a supported SecretRef source. */ function hasSecretRefSource(value: unknown): value is SecretRefSource { return typeof value === "string" && SECRET_REF_SOURCES.has(value as SecretRefSource); @@ -111,7 +105,7 @@ function coerceSecretRef(value: unknown): SecretRef | null { /** Return true when a secret input has either a literal value or resolvable reference shape. */ export function hasConfiguredMemorySecretInputValue(value: unknown): boolean { - if (normalizeSecretInputString(value)) { + if (normalizeOptionalString(value)) { return true; } return coerceSecretRef(value) !== null; @@ -139,7 +133,7 @@ export function normalizeResolvedMemorySecretInputString(params: { value: unknown; path: string; }): string | undefined { - const normalized = normalizeSecretInputString(params.value); + const normalized = normalizeOptionalString(params.value); if (normalized) { return normalized; } @@ -152,5 +146,5 @@ export function normalizeResolvedMemorySecretInputString(params: { /** Normalize env-provided secret values before use. */ export function normalizeEnvSecretInputString(value: unknown): string | undefined { - return normalizeSecretInputString(value); + return normalizeOptionalString(value); } diff --git a/packages/model-catalog-core/src/model-catalog-normalize.ts b/packages/model-catalog-core/src/model-catalog-normalize.ts index f91e3703ecc4..1ccdc917dc5a 100644 --- a/packages/model-catalog-core/src/model-catalog-normalize.ts +++ b/packages/model-catalog-core/src/model-catalog-normalize.ts @@ -1,4 +1,5 @@ // Model Catalog Core helper module supports model catalog normalize behavior. +import { asFiniteNumber as normalizeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { @@ -127,10 +128,6 @@ function normalizeNonNegativeNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; } -function normalizeFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function normalizeStringOrNumber(value: unknown): string | number | undefined { return normalizeOptionalString(value) ?? normalizeFiniteNumber(value); } diff --git a/packages/net-policy/package.json b/packages/net-policy/package.json index f0308632ec29..c64ca6a9c171 100644 --- a/packages/net-policy/package.json +++ b/packages/net-policy/package.json @@ -44,6 +44,7 @@ "build": "tsdown src/index.ts src/ip.ts src/ipv4.ts src/redact-sensitive-url.ts src/url-protocol.ts src/url-userinfo.ts --no-config --platform node --format esm --dts --out-dir dist --clean" }, "dependencies": { + "@openclaw/normalization-core": "workspace:*", "ipaddr.js": "2.4.0" } } diff --git a/packages/net-policy/src/ip.ts b/packages/net-policy/src/ip.ts index 591429ecd67f..23922e9c26ae 100644 --- a/packages/net-policy/src/ip.ts +++ b/packages/net-policy/src/ip.ts @@ -1,18 +1,10 @@ // Network Policy module implements ip behavior. +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "@openclaw/normalization-core/string-coerce"; import ipaddr from "ipaddr.js"; -function normalizeOptionalString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed || undefined; -} - -function normalizeLowercaseStringOrEmpty(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - /** Parsed IP address value returned by the net-policy parsing helpers. */ export type ParsedIpAddress = ipaddr.IPv4 | ipaddr.IPv6; type Ipv4Range = ReturnType; diff --git a/packages/net-policy/src/redact-sensitive-url.ts b/packages/net-policy/src/redact-sensitive-url.ts index ddbd02f9536f..e98b9aff8b8e 100644 --- a/packages/net-policy/src/redact-sensitive-url.ts +++ b/packages/net-policy/src/redact-sensitive-url.ts @@ -1,12 +1,10 @@ // Network Policy module implements redact sensitive url behavior. +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; + type ConfigUiHintTags = { tags?: string[]; }; -function normalizeLowercaseStringOrEmpty(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - /** Config UI hint tag for URL-like values that may embed credentials or tokens. */ export const SENSITIVE_URL_HINT_TAG = "url-secret"; diff --git a/packages/normalization-core/src/utf16-slice.test.ts b/packages/normalization-core/src/utf16-slice.test.ts index cb2669b44d66..4149a372e6b4 100644 --- a/packages/normalization-core/src/utf16-slice.test.ts +++ b/packages/normalization-core/src/utf16-slice.test.ts @@ -4,6 +4,7 @@ import { avoidTrailingHighSurrogateBreak, sliceUtf16Safe, truncateUtf16Safe, + truncateWithMarker, } from "./utf16-slice.js"; describe("avoidTrailingHighSurrogateBreak", () => { @@ -105,3 +106,52 @@ describe("truncateUtf16Safe", () => { expect(truncateUtf16Safe(input, 1)).toBe(""); }); }); + +describe("truncateWithMarker", () => { + it.each([ + { + name: "returns values at the boundary unchanged", + value: "hello", + max: 5, + options: { marker: "...", reserve: 3, trimEnd: false }, + expected: "hello", + }, + { + name: "reserves marker width", + value: "hello world", + max: 8, + options: { marker: "...", reserve: 3, trimEnd: false }, + expected: "hello...", + }, + { + name: "supports markers outside the limit", + value: "hello world", + max: 5, + options: { marker: "...", reserve: 0, trimEnd: false }, + expected: "hello...", + }, + { + name: "trims only the truncated prefix", + value: "hello world", + max: 9, + options: { marker: "...", reserve: 3, trimEnd: true }, + expected: "hello...", + }, + { + name: "keeps surrogate pairs well formed", + value: "ab🚀tail", + max: 4, + options: { marker: "…", reserve: 1, trimEnd: false }, + expected: "ab…", + }, + { + name: "preserves marker output at zero limits", + value: "hello", + max: 0, + options: { marker: "…", reserve: 1, trimEnd: false }, + expected: "…", + }, + ] as const)("$name", ({ value, max, options, expected }) => { + expect(truncateWithMarker(value, max, options)).toBe(expected); + }); +}); diff --git a/packages/normalization-core/src/utf16-slice.ts b/packages/normalization-core/src/utf16-slice.ts index e62c1d045bdf..8dc757cc3bf6 100644 --- a/packages/normalization-core/src/utf16-slice.ts +++ b/packages/normalization-core/src/utf16-slice.ts @@ -62,3 +62,16 @@ export function truncateUtf16Safe(input: string, maxLen: number): string { } return sliceUtf16Safe(input, 0, limit); } + +/** Truncates text and appends a marker while preserving the caller's reserved width contract. */ +export function truncateWithMarker( + value: string, + max: number, + options: { marker: string; reserve: number; trimEnd: boolean }, +): string { + if (value.length <= max) { + return value; + } + const prefix = truncateUtf16Safe(value, max - options.reserve); + return `${options.trimEnd ? prefix.trimEnd() : prefix}${options.marker}`; +} diff --git a/packages/plugin-package-contract/src/index.ts b/packages/plugin-package-contract/src/index.ts index 0dec6966bc3e..befdffa97631 100644 --- a/packages/plugin-package-contract/src/index.ts +++ b/packages/plugin-package-contract/src/index.ts @@ -1,4 +1,6 @@ // External code plugin package.json compatibility and validation contracts. +import { isRecord } from "../../normalization-core/src/record-coerce.js"; +import { normalizeOptionalString } from "../../normalization-core/src/string-coerce.js"; /** JSON object shape accepted by package contract helpers. */ export type JsonObject = Record; @@ -29,20 +31,6 @@ export const EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS = [ "openclaw.build.openclawVersion", ] as const; -/** Narrow unknown values to plain records. */ -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** Normalize optional package metadata strings. */ -function normalizeOptionalString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - /** Read OpenClaw package.json blocks without trusting caller input shape. */ function readOpenClawBlock(packageJson: unknown) { const root = isRecord(packageJson) ? packageJson : undefined; diff --git a/packages/sdk/package.json b/packages/sdk/package.json index aa14895a117b..938cecc514c9 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -19,6 +19,7 @@ "build": "tsdown src/index.ts --no-config --platform node --format esm --dts --out-dir dist --clean" }, "dependencies": { - "@openclaw/gateway-client": "workspace:*" + "@openclaw/gateway-client": "workspace:*", + "@openclaw/normalization-core": "workspace:*" } } diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 4e00c4c92141..074a39711418 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,5 +1,6 @@ // OpenClaw SDK module implements client behavior. import { randomUUID } from "node:crypto"; +import { asRecord } from "@openclaw/normalization-core/record-coerce"; import { EventHub } from "./event-hub.js"; import { normalizeGatewayEvent } from "./normalize.js"; import { GatewayClientTransport, isConnectableTransport } from "./transport.js"; @@ -222,10 +223,6 @@ type ChatProjection = { payload: Record; }; -function asRecord(value: unknown): Record { - return typeof value === "object" && value !== null ? (value as Record) : {}; -} - function hasArtifactQueryScope(params: unknown): params is ArtifactQuery { const record = asRecord(params); return [record.sessionKey, record.runId, record.taskId].some( diff --git a/packages/sdk/src/normalize.ts b/packages/sdk/src/normalize.ts index 5e56c375eb91..e2d62e8120ef 100644 --- a/packages/sdk/src/normalize.ts +++ b/packages/sdk/src/normalize.ts @@ -1,19 +1,12 @@ // OpenClaw SDK helper module supports normalize behavior. +import { asFiniteNumber as readNumber } from "@openclaw/normalization-core/number-coercion"; +import { asRecord } from "@openclaw/normalization-core/record-coerce"; import type { GatewayEvent, JsonObject, OpenClawEvent, OpenClawEventType } from "./types.js"; -// Normalize raw Gateway events into stable SDK event types and common metadata. -function asRecord(value: unknown): JsonObject { - return typeof value === "object" && value !== null ? (value as JsonObject) : {}; -} - function readString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function readNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function readLowerString(value: unknown): string | undefined { return readString(value)?.toLowerCase(); } diff --git a/packages/tool-call-repair/src/promote.ts b/packages/tool-call-repair/src/promote.ts index 84873e43e77e..2653484540d7 100644 --- a/packages/tool-call-repair/src/promote.ts +++ b/packages/tool-call-repair/src/promote.ts @@ -1,3 +1,4 @@ +import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import type { PlainTextToolCallProtectedRangeResolver } from "./contracts.js"; // Tool Call Repair module implements promote behavior. import { parseStandalonePlainTextToolCallBlocks, type PlainTextToolCallBlock } from "./payload.js"; @@ -45,10 +46,6 @@ export function createPromotedPlainTextToolCallBlock( }; } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - /** Emits the complete provider-neutral lifecycle for promoted tool-call blocks. */ export function createPromotedPlainTextToolCallEvents( message: Record, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a69d755ba74..8bd322585ead 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2306,6 +2306,9 @@ importers: packages/net-policy: dependencies: + '@openclaw/normalization-core': + specifier: workspace:* + version: link:../normalization-core ipaddr.js: specifier: 2.4.0 version: 2.4.0 @@ -2330,6 +2333,9 @@ importers: '@openclaw/gateway-client': specifier: workspace:* version: link:../gateway-client + '@openclaw/normalization-core': + specifier: workspace:* + version: link:../normalization-core packages/session-url-contract: {} diff --git a/src/acp/server.ts b/src/acp/server.ts index c8ddc154035f..979b2f458cff 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -10,6 +10,7 @@ import { type AnyMessage, } from "@agentclientprotocol/sdk"; import type { AcpServerOptions } from "@openclaw/acp-core/types"; +import { isRecord as isJsonObject } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { GATEWAY_CLIENT_CAPS, @@ -289,10 +290,6 @@ function normalizeAcpInitializeProtocolVersion(message: AnyMessage): AnyMessage } as AnyMessage; } -function isJsonObject(value: unknown): value is JsonObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isUint16Integer(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 0xffff; } diff --git a/src/acp/translator.ts b/src/acp/translator.ts index a9737d8cf162..a55abb0dfc95 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -35,6 +35,7 @@ import { defaultAcpSessionStore, type AcpSessionStore } from "@openclaw/acp-core import { toAcpSessionLineageMeta } from "@openclaw/acp-core/session-lineage-meta"; import type { AcpServerOptions } from "@openclaw/acp-core/types"; import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; +import { normalizeLowercaseStringOrEmpty as normalizedChatSendAckStatus } from "@openclaw/normalization-core/string-coerce"; import { normalizeFastMode, normalizeOptionalString, @@ -113,10 +114,6 @@ type ChatSendAck = { status?: unknown; }; -function normalizedChatSendAckStatus(status: unknown): string { - return typeof status === "string" ? status.trim().toLowerCase() : ""; -} - function isTerminalChatSendAckFailure(status: unknown): boolean { const normalized = normalizedChatSendAckStatus(status); return normalized === "timeout" || normalized === "error"; diff --git a/src/agents/acp-spawn-parent-stream.ts b/src/agents/acp-spawn-parent-stream.ts index 62ef7cfb9173..075e5b60c040 100644 --- a/src/agents/acp-spawn-parent-stream.ts +++ b/src/agents/acp-spawn-parent-stream.ts @@ -1,7 +1,12 @@ /** Relays child ACP session stream updates back into the requester parent session. */ import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; +import { asOptionalRecord as asObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { + sliceUtf16Safe, + truncateUtf16Safe, + truncateWithMarker, +} from "@openclaw/normalization-core/utf16-slice"; import { isAcpTagVisible, resolveAcpProjectionSettings, @@ -58,7 +63,7 @@ function truncate(value: string, maxChars: number): string { if (maxChars <= 1) { return truncateUtf16Safe(value, maxChars); } - return `${truncateUtf16Safe(value, maxChars - 1)}…`; + return truncateWithMarker(value, maxChars, { marker: "…", reserve: 1, trimEnd: false }); } function normalizeStringArray(value: unknown): string[] { @@ -75,12 +80,6 @@ function formatProxyEnvSummary(keys: string[]): string { return `proxy env: ${keys.join(", ")}`; } -function asObjectRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function mergeStreamingConfig(base: unknown, override: unknown): unknown { const baseRecord = asObjectRecord(base); const overrideRecord = asObjectRecord(override); diff --git a/src/agents/agent-command-restart-recovery.ts b/src/agents/agent-command-restart-recovery.ts index a99b0765d4f8..899d38b1670f 100644 --- a/src/agents/agent-command-restart-recovery.ts +++ b/src/agents/agent-command-restart-recovery.ts @@ -1,3 +1,4 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { ReplyPayload } from "../auto-reply/reply-payload.js"; import type { RestartRecoveryTerminalDeliveryEvidenceResult } from "../config/sessions/restart-recovery-types.js"; import type { SessionEntry } from "../config/sessions/types.js"; @@ -13,10 +14,6 @@ import { } from "./embedded-agent-runner/delivery-evidence.js"; import { mergeAttemptToolMediaPayloads } from "./embedded-agent-runner/run/tool-media-payloads.js"; -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function normalizeOptionalThreadId(value: unknown): string | undefined { return ( normalizeOptionalString(value) ?? diff --git a/src/agents/agent-run-terminal-outcome.ts b/src/agents/agent-run-terminal-outcome.ts index 5f5450bc7424..7bae70a567fd 100644 --- a/src/agents/agent-run-terminal-outcome.ts +++ b/src/agents/agent-run-terminal-outcome.ts @@ -1,4 +1,5 @@ /** Normalizes agent run wait/liveness/timeout metadata into sticky terminal outcomes. */ +import { asFiniteNumber as asFiniteTimestamp } from "@openclaw/normalization-core/number-coercion"; import { formatAbandonedLivenessError, formatBlockedLivenessError, @@ -461,10 +462,6 @@ export const AGENT_RUN_TERMINAL_RETRY_GRACE_MS = 15_000; const HARD_TIMEOUT_PHASES = new Set(["preflight", "provider", "post_turn"]); -function asFiniteTimestamp(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function asNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } diff --git a/src/agents/auth-profiles/read-only-availability.ts b/src/agents/auth-profiles/read-only-availability.ts index 82c614cdd7a9..be62a103d2df 100644 --- a/src/agents/auth-profiles/read-only-availability.ts +++ b/src/agents/auth-profiles/read-only-availability.ts @@ -1,4 +1,5 @@ /** Pure, non-resolving credential availability checks shared by status and route selection. */ +import { hasNonEmptyString as hasSecret } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { isSecretRef, @@ -20,10 +21,6 @@ import type { AuthProfileCredential } from "./types.js"; type ReadOnlyCredentialAvailability = boolean | undefined; -function hasSecret(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - export function hasMalformedSecretInputSyntax(value: unknown): boolean { if (typeof value !== "string") { return false; diff --git a/src/agents/auth-profiles/sqlite.ts b/src/agents/auth-profiles/sqlite.ts index dcfcaecf1d24..4f9975a4c432 100644 --- a/src/agents/auth-profiles/sqlite.ts +++ b/src/agents/auth-profiles/sqlite.ts @@ -6,6 +6,7 @@ import fs from "node:fs"; import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; +import { safeParseJson } from "@openclaw/normalization-core"; import { sha256HexPrefix } from "../../infra/crypto-digest.js"; import { clearNodeSqliteKyselyCacheForDatabase, @@ -85,11 +86,7 @@ function parseJsonCell(raw: string | null | undefined): unknown { if (!raw) { return null; } - try { - return JSON.parse(raw) as unknown; - } catch { - return null; - } + return safeParseJson(raw) ?? null; } type PersistedAuthProfileStoreInspection = diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts index 0f2ba65e883f..d4b77b942942 100644 --- a/src/agents/auth-profiles/usage.ts +++ b/src/agents/auth-profiles/usage.ts @@ -12,6 +12,7 @@ import { resolveExpiresAtMsFromEpochSeconds, } from "@openclaw/normalization-core/number-coercion"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { cancelUnreadResponseBody } from "../../infra/http-body.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { readProviderJsonResponse } from "../provider-http-errors.js"; import { resolveProviderRequestHeaders } from "../provider-request-config.js"; @@ -253,12 +254,6 @@ function applyWhamCooldownResult(params: { }; } -async function cancelUnreadResponseBody(response: Response): Promise { - if (!response.bodyUsed) { - await response.body?.cancel().catch(() => undefined); - } -} - async function probeWhamForCooldown( store: AuthProfileStore, profileId: string, diff --git a/src/agents/bash-process-references.ts b/src/agents/bash-process-references.ts index 15bfc2b54801..e1d40273dc37 100644 --- a/src/agents/bash-process-references.ts +++ b/src/agents/bash-process-references.ts @@ -3,7 +3,7 @@ * These references are surfaced in agent context so follow-up turns can * reconnect to prior long-running work. */ -import { truncateUtf16Safe } from "../utils.js"; +import { truncateUtf16Safe, truncateWithMarker } from "@openclaw/normalization-core/utf16-slice"; import { listRunningSessions } from "./bash-process-registry.js"; import { deriveSessionName } from "./bash-tools.shared.js"; @@ -31,7 +31,7 @@ function truncate(value: string, maxChars: number): string { if (maxChars <= 1) { return truncateUtf16Safe(value, maxChars); } - return `${truncateUtf16Safe(value, Math.max(0, maxChars - 3))}...`; + return truncateWithMarker(value, maxChars, { marker: "...", reserve: 3, trimEnd: false }); } /** List active background process sessions for one scope key, newest first. */ diff --git a/src/agents/bash-tools.exec-host-node-failure.ts b/src/agents/bash-tools.exec-host-node-failure.ts index 95f117700f74..d3d731535d56 100644 --- a/src/agents/bash-tools.exec-host-node-failure.ts +++ b/src/agents/bash-tools.exec-host-node-failure.ts @@ -1,4 +1,5 @@ import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString as readString } from "@openclaw/normalization-core/string-coerce"; import type { OperatorScope } from "../gateway/operator-scopes.js"; import { renderExecUpdateText } from "./bash-tools.exec-output.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; @@ -27,10 +28,6 @@ type NodeSystemRunInvokeResult = | { ok: true; raw: unknown } | { ok: false; failure: NodeInvokeFailure }; -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - /** Only NOT_CONNECTED plus explicit pre-dispatch provenance proves a retry cannot duplicate work. */ function classifyNodeInvokeFailure(error: unknown): NodeInvokeFailure { const errorRecord = asNullableRecord(error); diff --git a/src/agents/chutes-oauth.ts b/src/agents/chutes-oauth.ts index 348fc9925f15..bb3b3dbbfa1f 100644 --- a/src/agents/chutes-oauth.ts +++ b/src/agents/chutes-oauth.ts @@ -5,6 +5,7 @@ import { randomBytes } from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sha256Base64Url } from "../infra/crypto-digest.js"; +import { cancelUnreadResponseBody } from "../infra/http-body.js"; import { resolveExpiresAtMsFromDurationSeconds } from "../infra/parse-finite-number.js"; import type { OAuthCredentials } from "../llm/oauth.js"; import { buildOAuthRequestSignal } from "../llm/utils/oauth/abort.js"; @@ -102,12 +103,6 @@ function resolveChutesExpiresAt(value: unknown, now: number): number | undefined }); } -async function cancelUnreadResponseBody(response: Response): Promise { - if (!response.bodyUsed) { - await response.body?.cancel().catch(() => undefined); - } -} - async function fetchChutesUserInfo(params: { accessToken: string; fetchFn?: typeof fetch; diff --git a/src/agents/embedded-agent-message-tool-source-reply.ts b/src/agents/embedded-agent-message-tool-source-reply.ts index 97f2eb5d342d..b6d5e26a6867 100644 --- a/src/agents/embedded-agent-message-tool-source-reply.ts +++ b/src/agents/embedded-agent-message-tool-source-reply.ts @@ -3,7 +3,7 @@ */ import { safeParseJson } from "@openclaw/normalization-core"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; -import { readStringValue } from "@openclaw/normalization-core/string-coerce"; +import { hasNonEmptyString, readStringValue } from "@openclaw/normalization-core/string-coerce"; import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; import { isMessageToolConversationCreateActionName, @@ -32,29 +32,22 @@ const BROADCAST_SEND_ENVELOPE_KEYS = ["payload", "result", "sendResult", "toolRe const PARTIAL_DELIVERY_ENVELOPE_KEYS = [...RESULT_ENVELOPE_KEYS, "error", "cause"]; const SESSIONS_SEND_DELIVERY_STATUSES = new Set(["accepted", "ok"]); const BARE_OK_DELIVERY_STATUS = "ok"; -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} function resultConfirmsCurrentSourceRoute(value: unknown): boolean { - return asRecord(asRecord(value).details).sourceReplyRoute === "current-source"; -} - -function hasStringValue(value: unknown): boolean { - return typeof value === "string" && value.trim().length > 0; + return ( + (asOptionalRecord(asOptionalRecord(value)?.details) ?? {}).sourceReplyRoute === "current-source" + ); } function hasConversationIdValue(value: unknown): boolean { - return hasStringValue(value) || (typeof value === "number" && Number.isFinite(value)); + return hasNonEmptyString(value) || (typeof value === "number" && Number.isFinite(value)); } function hasExplicitMessageRoute(args: Record): boolean { - if (EXPLICIT_MESSAGE_ROUTE_KEYS.some((key) => hasStringValue(args[key]))) { + if (EXPLICIT_MESSAGE_ROUTE_KEYS.some((key) => hasNonEmptyString(args[key]))) { return true; } - return Array.isArray(args.targets) && args.targets.some((value) => hasStringValue(value)); + return Array.isArray(args.targets) && args.targets.some((value) => hasNonEmptyString(value)); } function isMessageToolSourceReplyActionName(action: unknown): boolean { @@ -73,7 +66,7 @@ function isMessageToolSourceReplyActionName(action: unknown): boolean { /** Read the visible text delivered by a source-reply message action. */ export function readMessageToolSourceReplyText(args: unknown): string | undefined { - const record = asRecord(args); + const record = asOptionalRecord(args) ?? {}; if (!isMessageToolSourceReplyActionName(record.action)) { return undefined; } @@ -110,7 +103,7 @@ function recordHasDeliveredMessageId(record: Record): boolean { const normalized = normalizeStatus(value); return Boolean(normalized && !NON_DELIVERY_MESSAGE_IDS.has(normalized)); }; - const message = asRecord(record.message); + const message = asOptionalRecord(record.message) ?? {}; if ( hasDeliveredId(record.messageId) || hasDeliveredId(record.pollId) || @@ -504,7 +497,7 @@ export function isDeliveredMessagingToolResult(params: { hookResult?: unknown; isError?: boolean; }): boolean { - const args = asRecord(params.args); + const args = asOptionalRecord(params.args) ?? {}; const action = normalizeStatus(args.action); if ( args.dryRun === true || @@ -595,7 +588,7 @@ export function isDeliveredMessageToolOnlySourceReplyResult(params: { if (normalizeToolName(params.toolName) !== MESSAGE_TOOL_NAME) { return false; } - const args = asRecord(params.args); + const args = asOptionalRecord(params.args) ?? {}; const sourceRouteReplyAction = (params.allowExplicitSourceRoute === true || confirmedCurrentSourceRoute) && isMessageToolSourceReplyActionName(args.action); diff --git a/src/agents/embedded-agent-runner/delivery-evidence.ts b/src/agents/embedded-agent-runner/delivery-evidence.ts index e59d69b35220..64bf927337b5 100644 --- a/src/agents/embedded-agent-runner/delivery-evidence.ts +++ b/src/agents/embedded-agent-runner/delivery-evidence.ts @@ -1,3 +1,4 @@ +import { hasNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { normalizeMediaReferenceForComparison } from "../../media/media-reference-comparison.js"; /** * Extracts visible delivery evidence from embedded-agent run results. @@ -97,10 +98,6 @@ export function hasCompletedTerminalDeliveryEvidence( ); } -function hasNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - function hasNonEmptyArray(value: unknown): boolean { return Array.isArray(value) && value.length > 0; } diff --git a/src/agents/embedded-agent-runner/extensions.ts b/src/agents/embedded-agent-runner/extensions.ts index fbabee42d2e8..98afe315c2d4 100644 --- a/src/agents/embedded-agent-runner/extensions.ts +++ b/src/agents/embedded-agent-runner/extensions.ts @@ -2,6 +2,7 @@ * Builds extension factories available to embedded-agent runtime sessions. */ import { randomUUID } from "node:crypto"; +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { normalizeAcceptedSessionSpawnResult } from "../accepted-session-spawn.js"; @@ -31,14 +32,8 @@ type AgentToolResultEvent = { isError?: boolean; }; -function recordFromUnknown(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function snapshotToolSendReceipt(details: unknown): unknown { - const toolSend = recordFromUnknown(details).toolSend; + const toolSend = (asOptionalRecord(details) ?? {}).toolSend; return toolSend && typeof toolSend === "object" && !Array.isArray(toolSend) ? { ...(toolSend as Record) } : toolSend; @@ -66,7 +61,7 @@ function buildAgentToolResultMiddlewareFactory( }); return (agent) => { agent.on("tool_result", async (rawEvent: unknown, ctx: { cwd?: string }) => { - const event = recordFromUnknown(rawEvent) as AgentToolResultEvent; + const event = (asOptionalRecord(rawEvent) ?? {}) as AgentToolResultEvent; if (!event.toolName) { return undefined; } @@ -94,7 +89,7 @@ function buildAgentToolResultMiddlewareFactory( turnId: event.turnId, toolCallId, toolName: event.toolName, - args: recordFromUnknown(adjustedInput ?? event.input), + args: asOptionalRecord(adjustedInput ?? event.input) ?? {}, cwd: ctx.cwd, isError: event.isError, result: current, diff --git a/src/agents/embedded-agent-runner/google-prompt-cache.ts b/src/agents/embedded-agent-runner/google-prompt-cache.ts index d143102c8d4d..7a96aa5df8bc 100644 --- a/src/agents/embedded-agent-runner/google-prompt-cache.ts +++ b/src/agents/embedded-agent-runner/google-prompt-cache.ts @@ -16,7 +16,7 @@ import { import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { parseGeminiAuth } from "../../infra/gemini-auth.js"; import { normalizeGoogleApiBaseUrl } from "../../infra/google-api-base-url.js"; -import { readResponseWithLimit } from "../../infra/http-body.js"; +import { cancelUnreadResponseBody, readResponseWithLimit } from "../../infra/http-body.js"; import { streamWithPayloadPatch } from "../../llm/providers/stream-wrappers/stream-payload-utils.js"; import type { Model } from "../../llm/types.js"; import { isSecretValueRegisteredForRedaction } from "../../logging/secret-redaction-registry.js"; @@ -286,12 +286,6 @@ function buildManagedContextForCachedContent(context: GooglePromptCacheContext) }; } -async function cancelUnreadResponseBody(response: Response | undefined): Promise { - if (response && !response.bodyUsed) { - await response.body?.cancel().catch(() => undefined); - } -} - /** * Reads a Google cachedContents JSON body under a byte cap and parses it. * Streams through the shared limiter so an oversized response is cancelled diff --git a/src/agents/embedded-agent-runner/message-visibility.ts b/src/agents/embedded-agent-runner/message-visibility.ts index 4eb4d1c4869f..4badea381b01 100644 --- a/src/agents/embedded-agent-runner/message-visibility.ts +++ b/src/agents/embedded-agent-runner/message-visibility.ts @@ -1,3 +1,4 @@ +import { hasNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { isSilentReplyPayloadText, isSilentReplyText, @@ -24,10 +25,6 @@ type PayloadVisibilityOptions = { includeSilentReplyPayloads?: boolean; }; -function hasNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - function hasNonEmptyStringArray(value: unknown): boolean { return Array.isArray(value) && value.some(hasNonEmptyString); } diff --git a/src/agents/embedded-agent-runner/model.configured-overrides.ts b/src/agents/embedded-agent-runner/model.configured-overrides.ts index e7e644dc2fee..4666b51bbd3f 100644 --- a/src/agents/embedded-agent-runner/model.configured-overrides.ts +++ b/src/agents/embedded-agent-runner/model.configured-overrides.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord as readModelParams } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { ModelCompatConfig, ModelMediaInputConfig } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -228,13 +229,6 @@ export function hasConfiguredFallbackSurface(params: { return Boolean(params.providerConfig?.baseUrl?.trim()); } -function readModelParams(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - return value as Record; -} - function mergeModelParams( ...entries: Array | undefined> ): Record | undefined { diff --git a/src/agents/embedded-agent-runner/openrouter-model-capabilities.ts b/src/agents/embedded-agent-runner/openrouter-model-capabilities.ts index 384310c802b4..f98dc3994ac9 100644 --- a/src/agents/embedded-agent-runner/openrouter-model-capabilities.ts +++ b/src/agents/embedded-agent-runner/openrouter-model-capabilities.ts @@ -19,7 +19,7 @@ */ import { formatErrorMessage } from "../../infra/errors.js"; -import { readResponseWithLimit } from "../../infra/http-body.js"; +import { cancelUnreadResponseBody, readResponseWithLimit } from "../../infra/http-body.js"; import { resolveProxyFetchFromEnv } from "../../infra/net/proxy-fetch.js"; import { parseStrictFiniteNumber } from "../../infra/parse-finite-number.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -177,12 +177,6 @@ function parseModel(model: OpenRouterApiModel): OpenRouterModelCapabilities { }; } -async function cancelUnreadResponseBody(response: Response | undefined): Promise { - if (response && !response.bodyUsed) { - await response.body?.cancel().catch(() => undefined); - } -} - // --------------------------------------------------------------------------- // API fetch // --------------------------------------------------------------------------- diff --git a/src/agents/embedded-agent-runner/replay-history.ts b/src/agents/embedded-agent-runner/replay-history.ts index 1dbd038c99c7..ac6d69d5dae4 100644 --- a/src/agents/embedded-agent-runner/replay-history.ts +++ b/src/agents/embedded-agent-runner/replay-history.ts @@ -2,6 +2,7 @@ * Sanitizes and validates replayed session history before model calls. */ import { isDeepStrictEqual } from "node:util"; +import { asFiniteNumber as toFiniteCostNumber } from "@openclaw/normalization-core/number-coercion"; import { stripInternalMetadataForDisplay } from "../../auto-reply/reply/display-text-sanitize.js"; import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -522,10 +523,6 @@ function normalizeAssistantUsageCost(usage: unknown): AssistantUsageSnapshot["co return { input, output, cacheRead, cacheWrite, total, ...(totalOrigin ? { totalOrigin } : {}) }; } -function toFiniteCostNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function ensureAssistantUsageSnapshots(messages: AgentMessage[]): AgentMessage[] { if (messages.length === 0) { return messages; diff --git a/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts index 750698f2efb7..770458979787 100644 --- a/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts +++ b/src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.ts @@ -2,7 +2,10 @@ * Normalizes tool-call names, ids, and standalone text calls for providers. */ import { randomUUID } from "node:crypto"; -import { normalizeLowercaseStringOrEmpty } from "../../../../packages/normalization-core/src/string-coerce.js"; +import { + hasNonEmptyString as replayToolCallNonEmptyString, + normalizeLowercaseStringOrEmpty, +} from "../../../../packages/normalization-core/src/string-coerce.js"; import { normalizeStringEntries } from "../../../../packages/normalization-core/src/string-normalization.js"; import { createPromotedPlainTextToolCallEvents, @@ -336,10 +339,6 @@ function collectFollowingToolResults( return { ids, displaced }; } -function replayToolCallNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - function resolveReplayToolCallName( rawName: string, rawId: string, diff --git a/src/agents/embedded-agent-runner/stream-resolution.ts b/src/agents/embedded-agent-runner/stream-resolution.ts index e3e88a5fa443..5f09d38c7045 100644 --- a/src/agents/embedded-agent-runner/stream-resolution.ts +++ b/src/agents/embedded-agent-runner/stream-resolution.ts @@ -4,6 +4,7 @@ import type { LlmRuntime } from "@openclaw/ai"; import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; import { createBoundaryAwareStreamFnForModel } from "@openclaw/ai/transports"; +import { hasNonEmptyString as hasResolvedRuntimeApiKey } from "@openclaw/normalization-core/string-coerce"; import { getStreamLlmRuntime } from "../../llm/model-runtime-binding.js"; import "../ai-transport-runtime-host.js"; import { createAnthropicVertexStreamFnForModel } from "../anthropic-vertex-stream.js"; @@ -69,10 +70,6 @@ function isDefaultOpenClawStreamFnForModel( return streamFn === provider?.streamSimple || streamFn === provider?.stream; } -function hasResolvedRuntimeApiKey(apiKey: string | undefined): boolean { - return typeof apiKey === "string" && apiKey.trim().length > 0; -} - function isOpenAICodexResponsesModel(model: EmbeddedRunAttemptParams["model"]): boolean { return model.provider === "openai" && model.api === "openai-chatgpt-responses"; } diff --git a/src/agents/harness/native-hook-relay-utils.ts b/src/agents/harness/native-hook-relay-utils.ts index b99b3324710e..8b36ae775183 100644 --- a/src/agents/harness/native-hook-relay-utils.ts +++ b/src/agents/harness/native-hook-relay-utils.ts @@ -1,4 +1,4 @@ -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { truncateUtf16Safe, truncateWithMarker } from "@openclaw/normalization-core/utf16-slice"; import type { JsonValue, NativeHookRelayEvent, @@ -213,8 +213,5 @@ function snapshotString(value: string, state: { remainingStringLength: number }) } export function truncateText(value: string, maxLength: number): string { - if (value.length <= maxLength) { - return value; - } - return `${truncateUtf16Safe(value, Math.max(0, maxLength - 3))}...`; + return truncateWithMarker(value, maxLength, { marker: "...", reserve: 3, trimEnd: false }); } diff --git a/src/agents/harness/support.ts b/src/agents/harness/support.ts index a997767a2593..1ad50c2ad56a 100644 --- a/src/agents/harness/support.ts +++ b/src/agents/harness/support.ts @@ -1,4 +1,5 @@ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { normalizeOptionalString as readStringParam } from "@openclaw/normalization-core/string-coerce"; import { resolveMergedModelProviderConfig, resolveMergedModelProviderModels, @@ -258,10 +259,6 @@ function isSupportedHarness(entry: { return entry.support.supported; } -function readStringParam(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function normalizeModelId(provider: string, modelId: string): string { const trimmed = modelId.trim(); const slashIndex = trimmed.indexOf("/"); diff --git a/src/agents/model-auth-availability.ts b/src/agents/model-auth-availability.ts index d72b8cafc044..aaca26d823b1 100644 --- a/src/agents/model-auth-availability.ts +++ b/src/agents/model-auth-availability.ts @@ -4,6 +4,7 @@ import { normalizeProviderId, normalizeProviderIdForAuth, } from "@openclaw/model-catalog-core/provider-id"; +import { hasNonEmptyString as hasSecret } from "@openclaw/normalization-core/string-coerce"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import { resolveMergedModelProviderConfig } from "../config/model-provider-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -128,10 +129,6 @@ type AuthSourceEvaluation = Pick< "availability" | "selectedAuthMode" | "evidence" | "selectedProfileId" >; -function hasSecret(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - function modeAllowed(provider: string, target: AuthTarget, mode: string | undefined): boolean { const requirement = resolveProviderModelRouteAuthRequirement(mode); return target.authRequirement diff --git a/src/agents/model-compat-catalog.ts b/src/agents/model-compat-catalog.ts index 3ec2e90f1414..853a4a8f1152 100644 --- a/src/agents/model-compat-catalog.ts +++ b/src/agents/model-compat-catalog.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty as normalizeApi } from "@openclaw/normalization-core/string-coerce"; import type { ModelCompatConfig } from "../config/types.models.js"; type ModelTransportRoute = { @@ -5,10 +6,6 @@ type ModelTransportRoute = { baseUrl?: unknown; }; -function normalizeApi(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - function normalizeBaseUrl(value: unknown): string { if (typeof value !== "string") { return ""; diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index 73bbad5b0ddb..0acf39dad833 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -20,6 +20,7 @@ import { import pMap from "p-map"; import { Type } from "typebox"; import { formatErrorMessage } from "../infra/errors.js"; +import { cancelUnreadResponseBody } from "../infra/http-body.js"; /** * Scans remote provider model catalogs for configured providers. */ @@ -284,9 +285,7 @@ async function fetchOpenRouterModels( "OpenRouter model scan", ); } finally { - if (res && !res.bodyUsed) { - await res.body?.cancel().catch(() => undefined); - } + await cancelUnreadResponseBody(res); } } diff --git a/src/agents/subagent-registry.store.sqlite.ts b/src/agents/subagent-registry.store.sqlite.ts index 5798650799af..411a264446f9 100644 --- a/src/agents/subagent-registry.store.sqlite.ts +++ b/src/agents/subagent-registry.store.sqlite.ts @@ -3,6 +3,8 @@ * store preserves typed columns for hot delivery state while retaining the * normalized payload JSON for forward-compatible record hydration. */ +import { safeParseJson } from "@openclaw/normalization-core"; +import { asFiniteNumber as normalizeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { sql, type Insertable, type Selectable, type Updateable } from "kysely"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; @@ -82,21 +84,13 @@ function parseJson(raw: string | null): unknown { if (!raw) { return undefined; } - try { - return JSON.parse(raw); - } catch { - return undefined; - } + return safeParseJson(raw); } function boolToSqlite(value: boolean | undefined): number | null { return value === undefined ? null : value ? 1 : 0; } -function normalizeFiniteNumber(value: number | null): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - /** Rehydrates one sqlite row into the normalized subagent run record shape. */ function rowToSubagentRunRecord(row: SubagentRunSqliteRow): SubagentRunRecord | null { const payload = parseJson(row.payload_json); diff --git a/src/agents/tool-schema-quarantine-health.ts b/src/agents/tool-schema-quarantine-health.ts index 30ce3ed5a1b2..8367d6ea676c 100644 --- a/src/agents/tool-schema-quarantine-health.ts +++ b/src/agents/tool-schema-quarantine-health.ts @@ -1,6 +1,7 @@ // Persists runtime tool-schema quarantines in the shared SQLite-backed core // plugin-state store so health surfaces can see failures from any live // runtime process. +import { hasNonEmptyString as isNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { createRuntimeHealthRecordEnvelope, createRuntimeHealthStore, @@ -20,10 +21,6 @@ type PersistedRuntimeToolSchemaQuarantineRecord = RuntimeHealthRecordEnvelope & reason: string; }; -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - const quarantineStore = createRuntimeHealthStore({ ownerId: "core:runtime-tool-quarantine-health", namespace: "schema-quarantines", diff --git a/src/agents/tools/cron-tool-canonicalize.ts b/src/agents/tools/cron-tool-canonicalize.ts index 341ba144cdf2..086a5b511c6b 100644 --- a/src/agents/tools/cron-tool-canonicalize.ts +++ b/src/agents/tools/cron-tool-canonicalize.ts @@ -4,6 +4,7 @@ * Recovers flat or partial model/tool inputs into the structured cron job/patch shape. */ import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; +import { hasNonEmptyString as isNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { isRecord } from "../../utils.js"; import { isStringOption } from "../../utils/string-readers.js"; @@ -75,10 +76,6 @@ function isCronPayloadKind(value: unknown): value is (typeof CRON_PAYLOAD_KINDS) return value === "systemEvent" || value === "agentTurn" || value === "script"; } -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - function isStringArrayOrNull(value: unknown): boolean { return ( value === null || (Array.isArray(value) && value.every((entry) => typeof entry === "string")) diff --git a/src/agents/tools/cron-tool-context.ts b/src/agents/tools/cron-tool-context.ts index 34a36321bfd6..8b97db252241 100644 --- a/src/agents/tools/cron-tool-context.ts +++ b/src/agents/tools/cron-tool-context.ts @@ -1,7 +1,7 @@ +import { truncateWithMarker } from "@openclaw/normalization-core/utf16-slice"; /** Reminder-context projection for cron tool job creation. */ import { getRuntimeConfig } from "../../config/config.js"; import { extractTextFromChatContent } from "../../shared/chat-content.js"; -import { truncateUtf16Safe } from "../../utils.js"; import { REMINDER_CONTEXT_MESSAGES_MAX } from "./cron-tool-schema.js"; import type { ChatMessage, GatewayToolCaller } from "./cron-tool.types.js"; import type { GatewayCallOptions } from "./gateway.js"; @@ -20,11 +20,7 @@ export function stripExistingContext(text: string) { } function truncateText(input: string, maxLen: number) { - if (input.length <= maxLen) { - return input; - } - const truncated = truncateUtf16Safe(input, Math.max(0, maxLen - 3)).trimEnd(); - return `${truncated}...`; + return truncateWithMarker(input, maxLen, { marker: "...", reserve: 3, trimEnd: true }); } function extractMessageText(message: ChatMessage): { role: string; text: string } | null { diff --git a/src/agents/tools/transcripts-tool.ts b/src/agents/tools/transcripts-tool.ts index 3704ecc89716..2abdc42c7531 100644 --- a/src/agents/tools/transcripts-tool.ts +++ b/src/agents/tools/transcripts-tool.ts @@ -4,6 +4,7 @@ * Manages live capture, manual import, summarization, and process-local transcript sessions. */ import path from "node:path"; +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { Type } from "typebox"; import { resolveStateDir } from "../../config/paths.js"; @@ -59,12 +60,6 @@ function ownsTranscriptSession( return ctx.agentId === "main"; } -function asParamsRecord(params: unknown): Record { - return params && typeof params === "object" && !Array.isArray(params) - ? (params as Record) - : {}; -} - const TranscriptsSchema = Type.Object( { action: Type.String({ @@ -356,7 +351,7 @@ export function createTranscriptsTool(options?: { if (!config.enabled) { throw new Error("transcripts are disabled"); } - const params = asParamsRecord(rawParams); + const params = asOptionalRecord(rawParams) ?? {}; const action = readStringParam(params, "action", { required: true, trim: true }); const store = createStore(ctx); switch (action) { diff --git a/src/agents/tools/web-search-output.ts b/src/agents/tools/web-search-output.ts index d0d115bab889..3af25e19dad2 100644 --- a/src/agents/tools/web-search-output.ts +++ b/src/agents/tools/web-search-output.ts @@ -7,6 +7,7 @@ * re-wrapped here unconditionally, so no provider-controlled metadata can * spoof the trust marker and transport-specific extras never reach the model. */ +import { asFiniteNumber as readFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { Static } from "typebox"; @@ -119,10 +120,6 @@ function unwrapEnvelopes(value: string): string { return value.replace(ENVELOPE_OPEN_RE, "").replace(ENVELOPE_END_RE, "").trim(); } -function readFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - // URLs are emitted canonicalized (percent-encoded), so whitespace or readable // prose smuggled into a URL slot cannot ride outside the envelope as-is. function toHttpUrl(value: string): string | undefined { diff --git a/src/agents/utils/tools-manager.ts b/src/agents/utils/tools-manager.ts index 182877c5f608..1a32500d202c 100644 --- a/src/agents/utils/tools-manager.ts +++ b/src/agents/utils/tools-manager.ts @@ -22,6 +22,7 @@ import type { ReadableStream as NodeReadableStream } from "node:stream/web"; import chalk from "chalk"; import { extractArchive } from "../../infra/archive.js"; import { isTruthyEnvValue } from "../../infra/env.js"; +import { cancelUnreadResponseBody } from "../../infra/http-body.js"; import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; import { APP_NAME, getBinDir } from "../config.js"; import { readProviderJsonResponse } from "../provider-http-errors.js"; @@ -36,12 +37,6 @@ const ARCHIVE_EXTRACT_TIMEOUT_MS = 60_000; const CONTENT_LENGTH_RE = /^\d+$/; const GITHUB_RELEASE_JSON_MAX_BYTES = 1024 * 1024; -async function cancelUnreadResponseBody(response: Response): Promise { - if (!response.bodyUsed) { - await response.body?.cancel().catch(() => undefined); - } -} - function isOfflineModeEnabled(): boolean { return isTruthyEnvValue(process.env.OPENCLAW_OFFLINE); } diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 2bd148cd467e..b639726900f4 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -2,7 +2,6 @@ // unintentionally breaking on newlines. Using [\s\S] keeps newlines inside // the chunk so messages are only split when they truly exceed the limit. -import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; import { findFenceSpanAt, isSafeFenceBreak, @@ -16,6 +15,7 @@ import { normalizeAccountId } from "../routing/session-key.js"; import { avoidTrailingHighSurrogateBreak, chunkTextByBreakResolver, + normalizeChunkLimit, } from "../shared/text-chunking.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js"; @@ -33,11 +33,6 @@ export type ChunkMode = "length" | "newline"; const DEFAULT_CHUNK_LIMIT = 4000; const DEFAULT_CHUNK_MODE: ChunkMode = "length"; -function normalizeChunkLimit(limit: number): number { - // String slicing truncates fractional indexes, so positive limits need an integer progress step. - return Number.isFinite(limit) && limit > 0 ? resolveIntegerOption(limit, 1, { min: 1 }) : limit; -} - type ProviderChunkConfig = { textChunkLimit?: number; streaming?: unknown; diff --git a/src/auto-reply/reply/acp-projector.ts b/src/auto-reply/reply/acp-projector.ts index a17c8ceab91a..f13301290a3f 100644 --- a/src/auto-reply/reply/acp-projector.ts +++ b/src/auto-reply/reply/acp-projector.ts @@ -4,7 +4,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { truncateUtf16Safe, truncateWithMarker } from "@openclaw/normalization-core/utf16-slice"; import { resolveAcpToolTerminalOutcome } from "../../acp/tool-status.js"; import { EmbeddedBlockChunker } from "../../agents/embedded-agent-block-chunker.js"; import { formatToolSummary, resolveToolDisplay } from "../../agents/tool-display.js"; @@ -53,7 +53,7 @@ function truncateText(input: string, maxChars: number): string { if (maxChars <= 1) { return truncateUtf16Safe(input, maxChars); } - return `${truncateUtf16Safe(input, maxChars - 1)}…`; + return truncateWithMarker(input, maxChars, { marker: "…", reserve: 1, trimEnd: false }); } function hashText(text: string): string { diff --git a/src/auto-reply/reply/agent-runner-command-output.ts b/src/auto-reply/reply/agent-runner-command-output.ts index 7875616cd2ba..a8d2e3d13e16 100644 --- a/src/auto-reply/reply/agent-runner-command-output.ts +++ b/src/auto-reply/reply/agent-runner-command-output.ts @@ -1,3 +1,5 @@ +import { asFiniteNumber as readFiniteNumberValue } from "@openclaw/normalization-core/number-coercion"; +import { asOptionalRecord as readRecordValue } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty, readStringValue, @@ -5,12 +7,6 @@ import { import { inferToolMetaFromArgs } from "../../agents/embedded-agent-utils.js"; import type { GetReplyOptions } from "../types.js"; -function readRecordValue(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - /** * CLI backends report a tool result as its raw content: a string, or the text * blocks the harness streamed. Structured runners send a record instead, so the @@ -32,10 +28,6 @@ function readToolResultText(value: unknown): string | undefined { return text || undefined; } -function readFiniteNumberValue(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function readNullableNumberValue(value: unknown): number | null | undefined { if (value === null) { return null; diff --git a/src/auto-reply/reply/commands-acp/shared.ts b/src/auto-reply/reply/commands-acp/shared.ts index 83aad19c4e95..aeb2ee0bfe74 100644 --- a/src/auto-reply/reply/commands-acp/shared.ts +++ b/src/auto-reply/reply/commands-acp/shared.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { toAcpRuntimeErrorText } from "@openclaw/acp-core/runtime/error-text"; import type { AcpRuntimeSessionMode } from "@openclaw/acp-core/runtime/types"; +import type { Result } from "@openclaw/normalization-core/result"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -183,7 +184,7 @@ function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnTh export function parseSpawnInput( params: HandleCommandsParams, tokens: string[], -): { ok: true; value: ParsedSpawnInput } | { ok: false; error: string } { +): Result { const normalizedTokens = tokens.map((token) => normalizeAcpOptionToken(token)); let mode: AcpRuntimeSessionMode = "persistent"; let thread = resolveDefaultSpawnThreadMode(params); @@ -323,9 +324,7 @@ export function parseSpawnInput( }; } -export function parseSteerInput( - tokens: string[], -): { ok: true; value: ParsedSteerInput } | { ok: false; error: string } { +export function parseSteerInput(tokens: string[]): Result { const normalizedTokens = tokens.map((token) => normalizeAcpOptionToken(token)); let sessionToken: string | undefined; const instructionTokens: string[] = []; @@ -372,7 +371,7 @@ export function parseSteerInput( export function parseSingleValueCommandInput( tokens: string[], usage: string, -): { ok: true; value: ParsedSingleValueCommandInput } | { ok: false; error: string } { +): Result { const value = normalizeOptionalString(tokens[0]) ?? ""; if (!value) { return { ok: false, error: usage }; @@ -390,9 +389,7 @@ export function parseSingleValueCommandInput( }; } -export function parseSetCommandInput( - tokens: string[], -): { ok: true; value: ParsedSetCommandInput } | { ok: false; error: string } { +export function parseSetCommandInput(tokens: string[]): Result { const key = normalizeOptionalString(tokens[0]) ?? ""; const value = normalizeOptionalString(tokens[1]) ?? ""; if (!key || !value) { diff --git a/src/auto-reply/usage-bar/template.ts b/src/auto-reply/usage-bar/template.ts index 66c3a8b14251..6ad21d1b49e9 100644 --- a/src/auto-reply/usage-bar/template.ts +++ b/src/auto-reply/usage-bar/template.ts @@ -1,6 +1,7 @@ import { type FSWatcher, readFileSync, watch } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, resolve } from "node:path"; +import { isRecord as isPlainObject } from "@openclaw/normalization-core/record-coerce"; import { createDedupeCache } from "../../infra/dedupe.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { DEFAULT_USAGE_BAR_TEMPLATE } from "./default-template.js"; @@ -31,10 +32,6 @@ function expandPath(p: string): string { return isAbsolute(p) ? p : resolve(p); } -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function hasPieces(value: unknown): boolean { return Array.isArray(value) && value.some(isPlainObject); } diff --git a/src/auto-reply/usage-bar/translator.ts b/src/auto-reply/usage-bar/translator.ts index 578ecb8db970..6b21091384ab 100644 --- a/src/auto-reply/usage-bar/translator.ts +++ b/src/auto-reply/usage-bar/translator.ts @@ -1,15 +1,13 @@ import { asSafeIntegerInRange, expectDefined, + isRecord as isObject, parseStrictInteger, } from "@openclaw/normalization-core"; export type UsageBarTemplate = Record; export type UsageContract = Record; type Vocab = Record; -const isObject = (v: unknown): v is Record => - typeof v === "object" && v !== null && !Array.isArray(v); - function toGlyphs(scale: unknown): string[] { if (Array.isArray(scale)) { return scale.filter((g): g is string => typeof g === "string"); diff --git a/src/channels/plugins/dm-access.ts b/src/channels/plugins/dm-access.ts index 5fb4d347bbac..4f3ab23e9d82 100644 --- a/src/channels/plugins/dm-access.ts +++ b/src/channels/plugins/dm-access.ts @@ -3,6 +3,7 @@ * * Reads, writes, migrates, and normalizes direct-message policy and allowFrom fields. */ +import { asNullableRecord as asObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; /** @@ -52,12 +53,6 @@ export function normalizeChannelDmPolicy(value: string | undefined): ChannelDmPo : undefined; } -function asObjectRecord(value: unknown): DmAccessRecord | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as DmAccessRecord) - : null; -} - function cloneDm(entry: DmAccessRecord): DmAccessRecord | null { const dm = asObjectRecord(entry.dm); return dm ? { ...dm } : null; diff --git a/src/channels/streaming.ts b/src/channels/streaming.ts index 42538401ba81..28ab7d181dc3 100644 --- a/src/channels/streaming.ts +++ b/src/channels/streaming.ts @@ -1,5 +1,6 @@ import { expectDefined } from "@openclaw/normalization-core"; // Channel streaming config normalization and progress-draft formatting helpers. +import { asNullableRecord as asObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import { @@ -40,12 +41,6 @@ export type { SlackChannelStreamingConfig } from "../config/types.slack.js"; // Runtime reads are nested-only; doctor migrates legacy streaming spellings. -function asObjectRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function asInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) ? value : undefined; } diff --git a/src/cli/capability-cli/tts-runtime.ts b/src/cli/capability-cli/tts-runtime.ts index 59374fd0734c..bfda0d2ee55f 100644 --- a/src/cli/capability-cli/tts-runtime.ts +++ b/src/cli/capability-cli/tts-runtime.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { isRecord as isObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -401,10 +402,6 @@ function buildTtsConfigWithHydratedProvider(params: { return tts; } -function isObjectRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function ttsProviderConfigHasApiKey(value: unknown): boolean { return isObjectRecord(value) && "apiKey" in value; } diff --git a/src/cli/nodes-camera.ts b/src/cli/nodes-camera.ts index 6055c798d85f..6448f8329255 100644 --- a/src/cli/nodes-camera.ts +++ b/src/cli/nodes-camera.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import { canonicalizeBase64, estimateBase64DecodedBytes } from "@openclaw/media-core/base64"; import { parseMediaContentLength } from "@openclaw/media-core/content-length"; import { toErrorObject } from "../infra/errors.js"; +import { cancelUnreadResponseBody } from "../infra/http-body.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { normalizeHostname } from "../infra/net/hostname.js"; import { resolveCliName } from "./cli-name.js"; @@ -81,12 +82,6 @@ type CameraClipPayload = { hasAudio: boolean; }; -async function cancelIgnoredResponseBody(response: Response | undefined): Promise { - if (response?.bodyUsed !== true) { - await response?.body?.cancel().catch(() => undefined); - } -} - /** Validate and normalize an unknown camera still-image payload. */ export function parseCameraSnapPayload(value: unknown): CameraSnapPayload { const obj = asRecord(value); @@ -170,13 +165,13 @@ async function writeUrlToFile(filePath: string, url: string, opts: { expectedHos const res = guarded.response; const finalUrl = new URL(guarded.finalUrl); if (normalizeHostname(finalUrl.hostname) !== expectedHost) { - await cancelIgnoredResponseBody(res); + await cancelUnreadResponseBody(res); throw new Error( `writeUrlToFile: redirect host ${finalUrl.hostname} must match node host ${opts.expectedHost}`, ); } if (!res.ok) { - await cancelIgnoredResponseBody(res); + await cancelUnreadResponseBody(res); throw new Error(`failed to download ${url}: ${res.status} ${res.statusText}`); } @@ -184,11 +179,11 @@ async function writeUrlToFile(filePath: string, url: string, opts: { expectedHos try { contentLength = parseMediaContentLength(res.headers.get("content-length")); } catch (err) { - await cancelIgnoredResponseBody(res); + await cancelUnreadResponseBody(res); throw err; } if (contentLength !== null && contentLength > MAX_CAMERA_URL_DOWNLOAD_BYTES) { - await cancelIgnoredResponseBody(res); + await cancelUnreadResponseBody(res); throw new Error( `writeUrlToFile: content-length ${contentLength} exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`, ); @@ -196,7 +191,7 @@ async function writeUrlToFile(filePath: string, url: string, opts: { expectedHos const body = res.body; if (!body) { - await cancelIgnoredResponseBody(res); + await cancelUnreadResponseBody(res); throw new Error(`failed to download ${url}: empty response body`); } diff --git a/src/commands/commitments.ts b/src/commands/commitments.ts index 7363abded041..a27b9dc8230e 100644 --- a/src/commands/commitments.ts +++ b/src/commands/commitments.ts @@ -2,7 +2,7 @@ import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { truncateWithMarker } from "@openclaw/normalization-core/utf16-slice"; import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { isRich, theme } from "../../packages/terminal-core/src/theme.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -24,7 +24,7 @@ const STATUS_VALUES = new Set([ ]); function truncate(value: string, maxChars: number): string { - return value.length <= maxChars ? value : `${truncateUtf16Safe(value, maxChars - 1)}…`; + return truncateWithMarker(value, maxChars, { marker: "…", reserve: 1, trimEnd: false }); } function safe(value: string): string { diff --git a/src/commands/doctor/cron/scheduled-tool-policy-migration.ts b/src/commands/doctor/cron/scheduled-tool-policy-migration.ts index 42015778e829..d12f558c15a8 100644 --- a/src/commands/doctor/cron/scheduled-tool-policy-migration.ts +++ b/src/commands/doctor/cron/scheduled-tool-policy-migration.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { createAccountCronScheduledToolPolicy, @@ -36,12 +37,6 @@ export function createScheduledToolPolicyMigrationCollector() { }; } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function usesToolRuntime(raw: Record): boolean { const payload = readRecord(raw.payload); const trigger = readRecord(raw.trigger); diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.refs.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.refs.ts index 9eadb01d6b7b..3043a32025b1 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.refs.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.refs.ts @@ -1,5 +1,6 @@ import { isDeepStrictEqual } from "node:util"; import { normalizeConfiguredProviderCatalogModelId } from "@openclaw/model-catalog-core/provider-model-id-normalization"; +import { normalizeLowercaseStringOrEmpty as normalizeString } from "@openclaw/normalization-core/string-coerce"; import { splitTrailingAuthProfile } from "../../../agents/model-ref-profile.js"; import { ensureRecord, getRecord } from "../../../config/legacy.shared.js"; import { normalizeAgentModelRefForConfig } from "../../../config/model-input.js"; @@ -14,10 +15,6 @@ export function hasOwnDefinedProperty(record: Record, key: stri return Object.hasOwn(record, key) && record[key] !== undefined; } -function normalizeString(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - function preferredClaudeSeparator(provider: string | undefined): "." | "-" { return provider === "github-copilot" || provider === "copilot-proxy" ? "." : "-"; } diff --git a/src/commands/doctor/shared/object.ts b/src/commands/doctor/shared/object.ts index 0d29d402de12..6a4c2fa56d5c 100644 --- a/src/commands/doctor/shared/object.ts +++ b/src/commands/doctor/shared/object.ts @@ -1,7 +1,2 @@ // Shared nullable record guard for doctor config walkers. -export function asObjectRecord(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - return value as Record; -} +export { asNullableRecord as asObjectRecord } from "@openclaw/normalization-core/record-coerce"; diff --git a/src/commands/flows.ts b/src/commands/flows.ts index 82f5d71d9d63..eb13de129cf1 100644 --- a/src/commands/flows.ts +++ b/src/commands/flows.ts @@ -1,7 +1,7 @@ /** CLI commands for listing, inspecting, and cancelling TaskFlow records. */ import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { truncateUtf16Safe, truncateWithMarker } from "@openclaw/normalization-core/utf16-slice"; import { truncateToVisibleWidth, visibleWidth } from "../../packages/terminal-core/src/ansi.js"; import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { isRich, theme } from "../../packages/terminal-core/src/theme.js"; @@ -38,7 +38,7 @@ function truncate(value: string, maxChars: number) { if (maxChars <= 1) { return truncateUtf16Safe(value, maxChars); } - return `${truncateUtf16Safe(value, maxChars - 1)}…`; + return truncateWithMarker(value, maxChars, { marker: "…", reserve: 1, trimEnd: false }); } function safeFlowDisplayText(value: string | undefined, maxChars?: number): string { diff --git a/src/commands/models/list.persisted-catalog.ts b/src/commands/models/list.persisted-catalog.ts index a6a1a6428994..74da7a985bc2 100644 --- a/src/commands/models/list.persisted-catalog.ts +++ b/src/commands/models/list.persisted-catalog.ts @@ -1,6 +1,7 @@ /** Reads persisted generated catalogs without constructing a model registry. */ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString as readString } from "@openclaw/normalization-core/string-coerce"; import type { ModelCatalogEntry, ModelInputType } from "../../agents/model-catalog.types.js"; import { filterGeneratedPluginModelCatalogProviders, @@ -13,10 +14,6 @@ import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snaps const modelApis = new Set(MODEL_APIS); const modelInputs = new Set(["text", "image", "audio", "video", "document"]); -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readPositiveNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; } diff --git a/src/commands/sessions-tail.ts b/src/commands/sessions-tail.ts index 215207d8b3e7..06190ceac344 100644 --- a/src/commands/sessions-tail.ts +++ b/src/commands/sessions-tail.ts @@ -4,6 +4,7 @@ * It selects active or requested sessions, renders recent trajectory events, * and can follow newly appended SQLite trajectory rows. */ +import { normalizeOptionalString as toOptionalString } from "@openclaw/normalization-core/string-coerce"; import { readAcpSessionMeta } from "../acp/runtime/session-meta.js"; import { getRuntimeConfig } from "../config/config.js"; import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js"; @@ -79,10 +80,6 @@ function parseTailCount(value: string | number | undefined): number | null { return parseStrictNonNegativeInteger(value) ?? null; } -function toOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function formatTimestamp(ts: string): string { const date = new Date(ts); if (Number.isNaN(date.getTime())) { diff --git a/src/commands/status-all/gateway.ts b/src/commands/status-all/gateway.ts index 8d217022c0ac..7f06ea92802f 100644 --- a/src/commands/status-all/gateway.ts +++ b/src/commands/status-all/gateway.ts @@ -1,6 +1,7 @@ // Gateway log-tail helpers for status diagnostics. // Summaries compact repeated auth/runtime failures while preserving enough context for operators. +import { safeParseJson } from "@openclaw/normalization-core"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { classifyOAuthRefreshFailureReason } from "../../agents/auth-profiles/oauth-refresh-failure.js"; @@ -108,15 +109,9 @@ export function summarizeLogTail(rawLines: string[], opts?: { maxLines?: number const block = consumeJsonBlock(lines, i); if (block) { i = block.endIndex; - const parsed = (() => { - try { - return JSON.parse(block.json) as { - error?: { code?: string; message?: string }; - }; - } catch { - return null; - } - })(); + const parsed = (safeParseJson(block.json) ?? null) as { + error?: { code?: string; message?: string }; + } | null; const code = normalizeOptionalString(parsed?.error?.code) ?? null; const msg = normalizeOptionalString(parsed?.error?.message) ?? null; const refreshReason = classifyOAuthRefreshFailureReason(msg ?? ""); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index c24350917f90..62dae4e660d9 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -3,7 +3,7 @@ import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { truncateWithMarker } from "@openclaw/normalization-core/utf16-slice"; import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { isRich, theme } from "../../packages/terminal-core/src/theme.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -206,7 +206,9 @@ function truncate(value: string, maxChars: number) { if (value.length <= maxChars) { return value; } - return maxChars <= 0 ? "" : `${truncateUtf16Safe(value, maxChars - 1)}…`; + return maxChars <= 0 + ? "" + : truncateWithMarker(value, maxChars, { marker: "…", reserve: 1, trimEnd: false }); } function shortToken(value: string | undefined, maxChars = ID_PAD): string { diff --git a/src/config/channel-compat-normalization.ts b/src/config/channel-compat-normalization.ts index 1664262fd79f..f8190b2a5c71 100644 --- a/src/config/channel-compat-normalization.ts +++ b/src/config/channel-compat-normalization.ts @@ -1,10 +1,12 @@ // Normalizes channel config compatibility fields during config loading. +import { asNullableRecord as asObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLegacyDmAliases, type CompatMutationResult, } from "../channels/plugins/dm-access.js"; export { normalizeLegacyDmAliases }; +export { asObjectRecord }; export type { CompatMutationResult }; /** Resolved streaming values a channel doctor supplies while migrating legacy aliases. */ @@ -41,13 +43,6 @@ export type RetiredChannelKeyRemoval = { pathPrefix: string; }; -/** Narrows unknown config JSON values to mutable object records. */ -export function asObjectRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function parseAliasStreamingMode(value: unknown): "off" | "partial" | "block" | "progress" | null { if (typeof value !== "string") { return null; diff --git a/src/config/mcp-config-normalize.ts b/src/config/mcp-config-normalize.ts index cb0ec741ca94..757a82a4aab4 100644 --- a/src/config/mcp-config-normalize.ts +++ b/src/config/mcp-config-normalize.ts @@ -1,4 +1,5 @@ // Normalizes MCP config records into canonical runtime shape. +import { normalizeLowercaseStringOrEmpty as normalizeMcpString } from "@openclaw/normalization-core/string-coerce"; import { isRecord } from "../utils.js"; type ConfigMcpServers = Record>; @@ -11,10 +12,6 @@ const CLI_MCP_TYPE_TO_OPENCLAW_TRANSPORT: Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function hasNonEmptyRecord(value: unknown): boolean { const record = readRecord(value); return record !== undefined && Object.keys(record).length > 0; diff --git a/src/config/sessions/conversation-identity.ts b/src/config/sessions/conversation-identity.ts index 6045bdf88cc6..4bc7358cffb2 100644 --- a/src/config/sessions/conversation-identity.ts +++ b/src/config/sessions/conversation-identity.ts @@ -1,3 +1,4 @@ +import { normalizeOptionalString as normalizeText } from "@openclaw/normalization-core/string-coerce"; import type { MsgContext } from "../../auto-reply/templating.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import { resolveConversationLabel } from "../../channels/conversation-label.js"; @@ -35,10 +36,6 @@ export type ConversationIdentity = { metadata?: Record; }; -function normalizeText(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function normalizeThreadId(value: unknown): string | undefined { if (typeof value === "number" && Number.isFinite(value)) { return String(value); diff --git a/src/config/sessions/restart-recovery-state.ts b/src/config/sessions/restart-recovery-state.ts index 3a1e1e1818f4..0e8c41d50df6 100644 --- a/src/config/sessions/restart-recovery-state.ts +++ b/src/config/sessions/restart-recovery-state.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from "node:util"; +import { normalizeOptionalString as normalizeRunId } from "@openclaw/normalization-core/string-coerce"; import { normalizeDeliveryContext, type DeliveryContext, @@ -17,10 +18,6 @@ type RestartRecoveryChannelAuthority = { sourceTurnId: string; }; -function normalizeRunId(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - /** Resolves only a complete durable channel claim; session-route fallbacks carry no authority. */ export function resolveRestartRecoveryChannelAuthority( entry: SessionEntry, diff --git a/src/cron/service/normalize.ts b/src/cron/service/normalize.ts index c0121f56c1ca..bba46fdd76e2 100644 --- a/src/cron/service/normalize.ts +++ b/src/cron/service/normalize.ts @@ -1,6 +1,6 @@ +import { truncateWithMarker } from "@openclaw/normalization-core/utf16-slice"; /** Name, agent id, and payload text normalization helpers for cron service ops. */ import { normalizeOptionalAgentId } from "../../routing/session-key.js"; -import { truncateUtf16Safe } from "../../utils.js"; import type { CronPayload } from "../types.js"; /** Normalizes a required cron job name and throws the public validation error when absent. */ @@ -16,10 +16,7 @@ export function normalizeRequiredName(raw: unknown) { } function truncateText(input: string, maxLen: number) { - if (input.length <= maxLen) { - return input; - } - return `${truncateUtf16Safe(input, Math.max(0, maxLen - 1)).trimEnd()}…`; + return truncateWithMarker(input, maxLen, { marker: "…", reserve: 1, trimEnd: true }); } /** Normalizes optional cron agent ids through the canonical session-key agent id rules. */ diff --git a/src/gateway/chat-display-projection.core.ts b/src/gateway/chat-display-projection.core.ts index 4a5d4a9ad9b3..30e8fddd5ce4 100644 --- a/src/gateway/chat-display-projection.core.ts +++ b/src/gateway/chat-display-projection.core.ts @@ -1,4 +1,5 @@ import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeLowercaseStringOrEmpty as normalizeErrorSignal } from "@openclaw/normalization-core/string-coerce"; import { isContextOverflowError } from "../agents/embedded-agent-helpers/context-overflow.js"; import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js"; import { @@ -88,10 +89,6 @@ const GATEWAY_ASSISTANT_ERROR_FALLBACK_TEXT = "The agent run failed before produ const GATEWAY_ASSISTANT_CONTEXT_OVERFLOW_FALLBACK_TEXT = "Context overflow: this conversation is too large for the model. Try /compact, use /new to start a fresh session, or retry the command with a tighter output limit."; -function normalizeErrorSignal(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - function isContextOverflowErrorSignal(value: unknown): boolean { if (typeof value !== "string") { return false; diff --git a/src/gateway/gateway-cli-backend.live-probe-helpers.ts b/src/gateway/gateway-cli-backend.live-probe-helpers.ts index e73bd3f5777e..3f11348d6523 100644 --- a/src/gateway/gateway-cli-backend.live-probe-helpers.ts +++ b/src/gateway/gateway-cli-backend.live-probe-helpers.ts @@ -1,6 +1,7 @@ // CLI backend live probe helpers run cron/MCP/image probes through the gateway // CLI backend and poll for externally visible live results. import { randomUUID } from "node:crypto"; +import { asNullableRecord as asLoopbackSchemaRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { renderCatFacePngBase64 } from "../../test/helpers/live-image-probe.js"; import { AUTOMATIONS_TOOL_NAME } from "../agents/tools/automations-tool-name.js"; @@ -126,12 +127,6 @@ function parsePositiveInt(value: string | undefined, fallback: number, name: str return parsed; } -function asLoopbackSchemaRecord(schema: unknown): Record | null { - return schema && typeof schema === "object" && !Array.isArray(schema) - ? (schema as Record) - : null; -} - function assertLoopbackObjectSchemasHaveProperties(params: { tools: LoopbackToolListEntry[]; expectedSchemaProbeToolName?: string; diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 8d8e3ed4a8ed..ecae869b0e40 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -1,6 +1,7 @@ // Gateway webhook helpers for external hook dispatch into agents and wake flows. import { randomUUID } from "node:crypto"; import type { IncomingMessage } from "node:http"; +import type { Result } from "@openclaw/normalization-core/result"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -175,7 +176,7 @@ export function extractHookToken(req: IncomingMessage): string | undefined { export async function readJsonBody( req: IncomingMessage, maxBytes: number, -): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> { +): Promise> { const result = await readJsonBodyWithLimit(req, { maxBytes, emptyObjectOnEmpty: true }); if (result.ok) { return result; @@ -209,9 +210,7 @@ export function normalizeHookHeaders(req: IncomingMessage) { /** Validate a hook wake payload. */ export function normalizeWakePayload( payload: Record, -): - | { ok: true; value: { text: string; mode: "now" | "next-heartbeat" } } - | { ok: false; error: string } { +): Result<{ text: string; mode: "now" | "next-heartbeat" }, string> { const normalizedText = normalizeOptionalString(payload.text) ?? ""; if (!normalizedText) { return { ok: false, error: "text required" }; @@ -287,12 +286,10 @@ function normalizeHookAgentDelivery(params: { channel: unknown; to: unknown; accountId: unknown; -}): - | { - ok: true; - value: Pick; - } - | { ok: false; error: string } { +}): Result< + Pick, + string +> { const deliver = resolveHookDeliver(params.deliver); if (!deliver) { return { @@ -449,7 +446,7 @@ export function resolveHookSessionKey(params: { source: HookSessionKeySource; sessionKey?: string; idFactory?: () => string; -}): { ok: true; value: string } | { ok: false; error: string } { +}): Result { const requested = resolveSessionKey(params.sessionKey); if (requested) { if ( @@ -526,12 +523,9 @@ export function normalizeHookDispatchSessionKey(params: { } /** Validate and normalize a hook agent payload before policy/session resolution. */ -export function normalizeAgentPayload(payload: Record): - | { - ok: true; - value: HookAgentPayload; - } - | { ok: false; error: string } { +export function normalizeAgentPayload( + payload: Record, +): Result { const message = normalizeOptionalString(payload.message) ?? ""; if (!message) { return { ok: false, error: "message required" }; diff --git a/src/gateway/node-plugin-tool-snapshot.ts b/src/gateway/node-plugin-tool-snapshot.ts index ab0fe314392e..a515a30d8089 100644 --- a/src/gateway/node-plugin-tool-snapshot.ts +++ b/src/gateway/node-plugin-tool-snapshot.ts @@ -1,4 +1,5 @@ /** Connected node-hosted plugin tools available to agent tool resolution. */ +import { asOptionalRecord as normalizeRecord } from "@openclaw/normalization-core/record-coerce"; import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js"; import { NODE_MCP_TOOLS_CALL_COMMAND } from "../infra/node-commands.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -44,12 +45,6 @@ function normalizeString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } -function normalizeRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function defaultParameters(): Record { return { type: "object", properties: {}, additionalProperties: true }; } diff --git a/src/gateway/server-methods/chat-transcript-persistence.ts b/src/gateway/server-methods/chat-transcript-persistence.ts index 517ebe797255..c6622129bdd6 100644 --- a/src/gateway/server-methods/chat-transcript-persistence.ts +++ b/src/gateway/server-methods/chat-transcript-persistence.ts @@ -1,4 +1,5 @@ // Transcript persistence and source-reply rewrites shared by chat send and abort. +import { asOptionalRecord as transcriptEventRecord } from "@openclaw/normalization-core/record-coerce"; import { getReplyPayloadMetadata } from "../../auto-reply/reply-payload.js"; import { findTranscriptEvent, @@ -67,12 +68,6 @@ export function assistantTranscriptScope( }; } -function transcriptEventRecord(event: TranscriptEvent): Record | undefined { - return event && typeof event === "object" && !Array.isArray(event) - ? (event as Record) - : undefined; -} - function transcriptEventId(event: TranscriptEvent): string | undefined { const id = transcriptEventRecord(event)?.id; return typeof id === "string" && id.trim().length > 0 ? id : undefined; diff --git a/src/gateway/server-methods/fs.ts b/src/gateway/server-methods/fs.ts index cd972c43479a..10a3d22438d2 100644 --- a/src/gateway/server-methods/fs.ts +++ b/src/gateway/server-methods/fs.ts @@ -1,6 +1,7 @@ // Host directory browsing for the new-session folder picker. operator.admin // only (see core-descriptors): listing arbitrary host paths carries the same // trust as starting a session with an explicit cwd. +import { safeParseJson } from "@openclaw/normalization-core"; import { ErrorCodes, errorShape, @@ -14,11 +15,7 @@ import type { GatewayRequestHandlers } from "./types.js"; function parseNodePayload(payload: unknown, payloadJSON?: string | null): unknown { if (payloadJSON) { - try { - return JSON.parse(payloadJSON) as unknown; - } catch { - return undefined; - } + return safeParseJson(payloadJSON); } return payload; } diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index 73b6378cb444..7f9d5db9bd13 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -1,6 +1,7 @@ // Model list result building resolves visible model catalogs for an agent and // strips runtime-only provider params before sending the browse API payload. import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { asPositiveSafeInteger as resolvePositiveSafeInteger } from "@openclaw/normalization-core/number-coercion"; import { resolveAgentEffectiveModelPrimary, resolveAgentWorkspaceDir, @@ -79,10 +80,6 @@ function resolveModelsListView(params: Record): ModelsListView return view === "configured" || view === "provider-config" || view === "all" ? view : "default"; } -function resolvePositiveSafeInteger(value: unknown): number | undefined { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; -} - // Project explicitly onto the public protocol shape. Concrete route, base URL, // auth, and cost facts stay private; runtime intent is attached separately. function buildPublicModelProjection(entry: ModelCatalogEntry): ModelsListEntry { diff --git a/src/gateway/server-methods/terminal.ts b/src/gateway/server-methods/terminal.ts index 760f662adf49..cabbdb0b1399 100644 --- a/src/gateway/server-methods/terminal.ts +++ b/src/gateway/server-methods/terminal.ts @@ -1,3 +1,4 @@ +import { safeParseJson } from "@openclaw/normalization-core"; import { GATEWAY_CLIENT_CAPS, hasGatewayClientCap, @@ -68,11 +69,7 @@ function parseNodePayload(payload: unknown, payloadJSON?: string | null): unknow if (!payloadJSON) { return payload; } - try { - return JSON.parse(payloadJSON) as unknown; - } catch { - return undefined; - } + return safeParseJson(payloadJSON); } async function stageNodeTerminalUpload( diff --git a/src/gateway/session-lifecycle-state.ts b/src/gateway/session-lifecycle-state.ts index 9b8ec6079824..14cb773443d2 100644 --- a/src/gateway/session-lifecycle-state.ts +++ b/src/gateway/session-lifecycle-state.ts @@ -1,5 +1,6 @@ // Gateway session lifecycle state projection. // Converts agent run lifecycle events into session row/store status updates. +import { normalizeOptionalString as normalizeLifecycleRunId } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { SessionRunStatus } from "../../packages/gateway-protocol/src/schema/sessions-row.js"; import { isAgentLifecycleYieldedWaiting } from "../agents/agent-lifecycle-parent-state.js"; @@ -245,10 +246,6 @@ export function deriveGatewaySessionLifecycleProjectionPatch(params: { return patch; } -function normalizeLifecycleRunId(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - export function isRestartRecoveryLifecycleEvent(params: { entry?: Pick | null; event: Pick; diff --git a/src/gateway/watch-node-http.ts b/src/gateway/watch-node-http.ts index 5decec38825e..fbd18450ee3e 100644 --- a/src/gateway/watch-node-http.ts +++ b/src/gateway/watch-node-http.ts @@ -2,6 +2,7 @@ // Apple Watch cannot use generic WebSockets on-device, so node events use bounded HTTPS polls. import { randomBytes, randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; +import { isRecord as isStringRecord } from "@openclaw/normalization-core/record-coerce"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES, @@ -174,10 +175,6 @@ function resolveWatchClientAddress( }; } -function isStringRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function trackResponseLifecycle(res: ServerResponse): ResponseLifecycle { let aborted = false; let settled = false; diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index a04b1158ec97..cfa91a6b87b7 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -1,6 +1,7 @@ // Hook workspace helpers resolve hook roots and workspace-local hook files. import fs from "node:fs"; import path from "node:path"; +import { safeParseJson } from "@openclaw/normalization-core"; import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -44,11 +45,7 @@ function readHookPackageManifest(dir: string): HookPackageManifest | null { if (raw === null) { return null; } - try { - return JSON.parse(raw) as HookPackageManifest; - } catch { - return null; - } + return (safeParseJson(raw) as HookPackageManifest | undefined) ?? null; } function resolvePackageHooks(manifest: HookPackageManifest): string[] { diff --git a/src/infra/advertised-lan-host.ts b/src/infra/advertised-lan-host.ts index c556bb856654..a98d1a7fcb2e 100644 --- a/src/infra/advertised-lan-host.ts +++ b/src/infra/advertised-lan-host.ts @@ -1,5 +1,6 @@ // Resolves the LAN host OpenClaw should advertise to nearby devices. import { isRfc1918Ipv4Address } from "@openclaw/net-policy/ip"; +import { normalizeLowercaseStringOrEmpty as normalizeInterfaceName } from "@openclaw/normalization-core/string-coerce"; import { runCommandWithTimeout as defaultRunCommandWithTimeout } from "../process/exec.js"; import { listExternalInterfaceAddresses, @@ -56,10 +57,6 @@ type RankedWindowsRouteRow = { order: number; }; -function normalizeInterfaceName(name: unknown): string { - return typeof name === "string" ? name.trim().toLowerCase() : ""; -} - function normalizeMetric(value: unknown): number { if (typeof value === "number" && Number.isFinite(value)) { return value; diff --git a/src/infra/clawhub-skill-security.ts b/src/infra/clawhub-skill-security.ts index de5d463e9cab..6b6280dda32d 100644 --- a/src/infra/clawhub-skill-security.ts +++ b/src/infra/clawhub-skill-security.ts @@ -1,4 +1,5 @@ // Shared owner-qualified ClawHub security verdict resolution. +import { asOptionalRecord as readObject } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import pLimit from "p-limit"; import { @@ -76,12 +77,6 @@ function partitionCompatibleBatches( return batches.map((batch) => batch.items); } -function readObject(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readOptionalStringField(value: unknown, field: string): string | undefined { return normalizeOptionalString(readObject(value)?.[field]); } diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 0dfa9bd45100..fb543a9e8b2e 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; +import { isRecord as isJsonObject } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -852,10 +853,6 @@ function createClawHubBodyLimitError( ); } -function isJsonObject(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function optionalStringField( source: Record, field: string, diff --git a/src/infra/http-body.response.test.ts b/src/infra/http-body.response.test.ts index f549cfe0bb19..034377efc9f0 100644 --- a/src/infra/http-body.response.test.ts +++ b/src/infra/http-body.response.test.ts @@ -2,6 +2,7 @@ import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { + cancelUnreadResponseBody, readResponseTextPrefix, readResponseTextSnippet, readResponseWithLimit, @@ -103,6 +104,37 @@ async function expectReadResponseWithLimitFailureCase(params: { ).rejects.toThrow(params.expectedError); } +describe("cancelUnreadResponseBody", () => { + it("cancels unread bodies and ignores cancellation failures", async () => { + const cancel = vi.fn(() => { + throw new Error("already closed"); + }); + const response = new Response(makeStallingStream([], cancel)); + + await expect(cancelUnreadResponseBody(response)).resolves.toBeUndefined(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("leaves consumed and absent bodies alone", async () => { + const cancel = vi.fn(); + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("done")); + controller.close(); + }, + cancel, + }), + ); + await response.text(); + + await cancelUnreadResponseBody(response); + await cancelUnreadResponseBody(undefined); + + expect(cancel).not.toHaveBeenCalled(); + }); +}); + describe("readResponseWithLimit", () => { beforeEach(() => { vi.useRealTimers(); diff --git a/src/infra/http-body.ts b/src/infra/http-body.ts index 6c11ba205026..6cc016b208f5 100644 --- a/src/infra/http-body.ts +++ b/src/infra/http-body.ts @@ -10,6 +10,13 @@ import { parseStrictNonNegativeInteger } from "./parse-finite-number.js"; export { readChunkWithIdleTimeout } from "./http-response-body-timeout.js"; +/** Cancels a response body only when no consumer has started reading it. */ +export async function cancelUnreadResponseBody(response: Response | undefined): Promise { + if (response && !response.bodyUsed) { + await response.body?.cancel().catch(() => undefined); + } +} + export const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024; export const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000; diff --git a/src/infra/outbound/delivery-queue-media-spool.ts b/src/infra/outbound/delivery-queue-media-spool.ts index c9c9102dc6dd..2d1c0e33b8f3 100644 --- a/src/infra/outbound/delivery-queue-media-spool.ts +++ b/src/infra/outbound/delivery-queue-media-spool.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { isPassThroughRemoteMediaSource } from "@openclaw/media-core/media-source-url"; +import { hasNonEmptyString as isNonEmptyMediaSource } from "@openclaw/normalization-core/string-coerce"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { resolveDeliveryQueueMediaDir } from "../../config/paths.js"; import { @@ -38,10 +39,6 @@ function resolveArtifactExtension(source: string): string { return ARTIFACT_EXT_RE.test(extension) ? extension.toLowerCase() : ""; } -function isNonEmptyMediaSource(source: unknown): source is string { - return typeof source === "string" && Boolean(source.trim()); -} - function payloadMediaSources(payload: ReplyPayload): string[] { const sources: string[] = []; if (isNonEmptyMediaSource(payload.mediaUrl)) { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 0853ffadf675..0c17c68f3093 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -1,5 +1,6 @@ // Message-action runner normalizes tool params, resolves channel/target/media, // applies policies, and dispatches send/poll/plugin actions. +import { asOptionalRecord as asResultRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -284,12 +285,6 @@ export function getToolResult( return "toolResult" in result ? result.toolResult : undefined; } -function asResultRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function withSendNormalization( result: MessageActionRunResult, normalization?: MessageActionNormalization, diff --git a/src/infra/provider-usage.fetch.claude.ts b/src/infra/provider-usage.fetch.claude.ts index 76bd4928144d..62741e2e7582 100644 --- a/src/infra/provider-usage.fetch.claude.ts +++ b/src/infra/provider-usage.fetch.claude.ts @@ -3,9 +3,9 @@ import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; +import { cancelUnreadResponseBody } from "./http-body.js"; import { buildUsageHttpErrorSnapshot, - discardUsageResponseBody, fetchJson, parseUsageResetAt, readUsageJson, @@ -159,7 +159,7 @@ async function fetchClaudeWebUsage( fetchFn, ); if (!orgRes.ok) { - await discardUsageResponseBody(orgRes); + await cancelUnreadResponseBody(orgRes); return null; } @@ -180,7 +180,7 @@ async function fetchClaudeWebUsage( fetchFn, ); if (!usageRes.ok) { - await discardUsageResponseBody(usageRes); + await cancelUnreadResponseBody(usageRes); return null; } diff --git a/src/infra/provider-usage.fetch.codex.ts b/src/infra/provider-usage.fetch.codex.ts index e41114451ed5..0f3753905b8b 100644 --- a/src/infra/provider-usage.fetch.codex.ts +++ b/src/infra/provider-usage.fetch.codex.ts @@ -1,9 +1,9 @@ // Fetches Codex provider usage windows. import { resolveProviderRequestHeaders } from "../agents/provider-request-config.js"; +import { cancelUnreadResponseBody } from "./http-body.js"; import { parseStrictFiniteNumber } from "./parse-finite-number.js"; import { buildUsageHttpErrorSnapshot, - discardUsageResponseBody, fetchJson, readUsageJson, } from "./provider-usage.fetch.shared.js"; @@ -89,7 +89,7 @@ export async function fetchCodexUsage( ); if (!res.ok) { - await discardUsageResponseBody(res); + await cancelUnreadResponseBody(res); return buildUsageHttpErrorSnapshot({ provider: "openai", status: res.status, diff --git a/src/infra/provider-usage.fetch.deepseek.ts b/src/infra/provider-usage.fetch.deepseek.ts index 3bdeac54428d..f13b40eeb992 100644 --- a/src/infra/provider-usage.fetch.deepseek.ts +++ b/src/infra/provider-usage.fetch.deepseek.ts @@ -1,8 +1,8 @@ // Fetches and normalizes DeepSeek provider usage records. import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { cancelUnreadResponseBody } from "./http-body.js"; import { buildUsageHttpErrorSnapshot, - discardUsageResponseBody, fetchJson, parseFiniteNumber, readUsageJson, @@ -75,7 +75,7 @@ export async function fetchDeepSeekUsage( ); if (!res.ok) { - await discardUsageResponseBody(res); + await cancelUnreadResponseBody(res); return buildUsageHttpErrorSnapshot({ provider: "deepseek", status: res.status, diff --git a/src/infra/provider-usage.fetch.gemini.ts b/src/infra/provider-usage.fetch.gemini.ts index dc8b02b895dd..31930c58646d 100644 --- a/src/infra/provider-usage.fetch.gemini.ts +++ b/src/infra/provider-usage.fetch.gemini.ts @@ -2,9 +2,9 @@ import { expectDefined } from "@openclaw/normalization-core"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; // Fetches Gemini provider usage windows. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { cancelUnreadResponseBody } from "./http-body.js"; import { buildUsageHttpErrorSnapshot, - discardUsageResponseBody, fetchJson, readUsageJson, } from "./provider-usage.fetch.shared.js"; @@ -36,7 +36,7 @@ export async function fetchGeminiUsage( ); if (!res.ok) { - await discardUsageResponseBody(res); + await cancelUnreadResponseBody(res); return buildUsageHttpErrorSnapshot({ provider, status: res.status, diff --git a/src/infra/provider-usage.fetch.minimax.ts b/src/infra/provider-usage.fetch.minimax.ts index b2a4852579ed..fafaf99b5591 100644 --- a/src/infra/provider-usage.fetch.minimax.ts +++ b/src/infra/provider-usage.fetch.minimax.ts @@ -4,9 +4,9 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; import { isRecord } from "../utils.js"; import { readTrimmedStringAlias } from "../utils/string-readers.js"; +import { cancelUnreadResponseBody } from "./http-body.js"; import { buildUsageHttpErrorSnapshot, - discardUsageResponseBody, fetchJson, parseFiniteNumber, } from "./provider-usage.fetch.shared.js"; @@ -544,7 +544,7 @@ export async function fetchMinimaxUsage( ); if (!res.ok) { - await discardUsageResponseBody(res); + await cancelUnreadResponseBody(res); return buildUsageHttpErrorSnapshot({ provider: "minimax", status: res.status, diff --git a/src/infra/provider-usage.fetch.shared.test.ts b/src/infra/provider-usage.fetch.shared.test.ts index 1f264a484961..ebbd512f9ffa 100644 --- a/src/infra/provider-usage.fetch.shared.test.ts +++ b/src/infra/provider-usage.fetch.shared.test.ts @@ -5,7 +5,6 @@ import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; import { buildUsageErrorSnapshot, buildUsageHttpErrorSnapshot, - discardUsageResponseBody, fetchJson, parseFiniteNumber, readUsageJson, @@ -159,15 +158,6 @@ describe("provider usage fetch shared helpers", () => { expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS); }); - it("cancels unread response bodies when discarding usage responses", async () => { - const response = new Response("not needed", { status: 429 }); - const cancel = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined); - - await discardUsageResponseBody(response); - - expect(cancel).toHaveBeenCalledOnce(); - }); - it("maps configured status codes to token expired", () => { const snapshot = buildUsageHttpErrorSnapshot({ provider: "openai", diff --git a/src/infra/provider-usage.fetch.shared.ts b/src/infra/provider-usage.fetch.shared.ts index 2b3dd30b449b..7e1494118dbb 100644 --- a/src/infra/provider-usage.fetch.shared.ts +++ b/src/infra/provider-usage.fetch.shared.ts @@ -23,12 +23,6 @@ export async function fetchJson( return await fetchFn(url, { ...init, signal }); } -export async function discardUsageResponseBody(response: Response): Promise { - if (!response.bodyUsed) { - await response.body?.cancel().catch(() => undefined); - } -} - export function parseFiniteNumber(value: unknown): number | undefined { return parseFiniteNumberish(value); } diff --git a/src/infra/provider-usage.fetch.zai.ts b/src/infra/provider-usage.fetch.zai.ts index 4d4507f272c3..474f0eabb01a 100644 --- a/src/infra/provider-usage.fetch.zai.ts +++ b/src/infra/provider-usage.fetch.zai.ts @@ -2,9 +2,9 @@ import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { cancelUnreadResponseBody } from "./http-body.js"; import { buildUsageHttpErrorSnapshot, - discardUsageResponseBody, fetchJson, parseUsageResetAt, readUsageJson, @@ -80,7 +80,7 @@ export async function fetchZaiUsage( ); if (!res.ok) { - await discardUsageResponseBody(res); + await cancelUnreadResponseBody(res); return buildUsageHttpErrorSnapshot({ provider: "zai", status: res.status, diff --git a/src/infra/restart-sentinel-store.ts b/src/infra/restart-sentinel-store.ts index 00e0f9020a08..cde916d67d7e 100644 --- a/src/infra/restart-sentinel-store.ts +++ b/src/infra/restart-sentinel-store.ts @@ -1,4 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; +import { safeParseJson } from "@openclaw/normalization-core"; import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-coerce"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { @@ -356,11 +357,7 @@ function parseRequiredJson(value: string | null): unknown { if (value === null) { return undefined; } - try { - return JSON.parse(value) as unknown; - } catch { - return undefined; - } + return safeParseJson(value); } function decodeRestartSentinelRow(row: { diff --git a/src/infra/state-migrations.tui-last-session.ts b/src/infra/state-migrations.tui-last-session.ts index 4b533a17525f..eafa77784ea6 100644 --- a/src/infra/state-migrations.tui-last-session.ts +++ b/src/infra/state-migrations.tui-last-session.ts @@ -1,6 +1,7 @@ // Doctor-only import for the retired TUI last-session JSON store. import fs from "node:fs"; import path from "node:path"; +import { isRecord as isObjectRecord } from "@openclaw/normalization-core/record-coerce"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { openOpenClawStateDatabase, @@ -62,10 +63,6 @@ function assertLegacySourceUnchanged(sourcePath: string, expected: LegacySourceS }); } -function isObjectRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - function isHeartbeatSessionKey(sessionKey: string): boolean { return sessionKey.toLowerCase().endsWith(":heartbeat"); } diff --git a/src/infra/update-check-package-target.ts b/src/infra/update-check-package-target.ts index a295f258e0a7..fce943443ebc 100644 --- a/src/infra/update-check-package-target.ts +++ b/src/infra/update-check-package-target.ts @@ -1,3 +1,4 @@ +import { normalizeNullableString as toOptionalTrimmedString } from "@openclaw/normalization-core/string-coerce"; import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { @@ -5,6 +6,7 @@ import { type OpenClawSchemaVersions, } from "../state/openclaw-schema-versions.js"; import { buildTimeoutAbortSignal } from "../utils/fetch-timeout.js"; +import { cancelUnreadResponseBody } from "./http-body.js"; type NpmPackageTargetStatus = { target: string; @@ -28,10 +30,6 @@ export type NpmMetadataCommandRunner = ( code: number | null; }>; -function toOptionalTrimmedString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function parseNpmPackageTargetMetadata(raw: string): { version: string | null; nodeEngine: string | null; @@ -135,9 +133,7 @@ async function fetchNpmPackageTargetStatusFromRegistry(params: { } catch (err) { return { target: params.target, version: null, nodeEngine: null, error: String(err) }; } finally { - if (res?.bodyUsed !== true) { - await res?.body?.cancel().catch(() => undefined); - } + await cancelUnreadResponseBody(res); cleanup(); } } diff --git a/src/infra/windows-gateway-firewall-diagnostics.ts b/src/infra/windows-gateway-firewall-diagnostics.ts index 3c95810fd055..fec9644c021c 100644 --- a/src/infra/windows-gateway-firewall-diagnostics.ts +++ b/src/infra/windows-gateway-firewall-diagnostics.ts @@ -1,4 +1,5 @@ // Read-only diagnostics for Windows LAN Gateway reachability. +import { safeParseJson } from "@openclaw/normalization-core"; import { runCommandWithTimeout as defaultRunCommandWithTimeout } from "../process/exec.js"; import { getWindowsPowerShellExePath } from "./windows-install-roots.js"; @@ -246,11 +247,7 @@ function parseJsonPayload(stdout: string): unknown { if (!trimmed) { return null; } - try { - return JSON.parse(trimmed); - } catch { - return null; - } + return safeParseJson(trimmed) ?? null; } function stringField(row: Record, key: string): string { diff --git a/src/llm/providers/stream-wrappers/moonshot-thinking.ts b/src/llm/providers/stream-wrappers/moonshot-thinking.ts index 28eed1578874..ee79d843eb6a 100644 --- a/src/llm/providers/stream-wrappers/moonshot-thinking.ts +++ b/src/llm/providers/stream-wrappers/moonshot-thinking.ts @@ -1,4 +1,5 @@ // Moonshot thinking wrapper normalizes reasoning output from Moonshot streams. +import { asOptionalRecord as asPayloadRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import type { StreamFn } from "../../../agents/runtime/index.js"; import type { ThinkLevel } from "../../../auto-reply/thinking.js"; @@ -77,12 +78,6 @@ function isPinnedToolChoice(toolChoice: unknown): boolean { return typeValue === "tool" || typeValue === "function"; } -function asPayloadRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function ensureMoonshotToolCallReasoningContent(payloadObj: Record): void { if (!Array.isArray(payloadObj.messages)) { return; diff --git a/src/media/media-facts.ts b/src/media/media-facts.ts index cf566ad26b06..c7e0db19921f 100644 --- a/src/media/media-facts.ts +++ b/src/media/media-facts.ts @@ -5,6 +5,10 @@ import { mimeTypeFromFilePath, normalizeMimeType, } from "@openclaw/media-core/mime"; +import { + asFiniteNumberInRange, + asPositiveSafeInteger as normalizePositiveInteger, +} from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { PromptImageOrderEntry } from "./prompt-image-order.js"; @@ -37,7 +41,7 @@ export type MediaFactInput = { const RUNTIME_PROMPT_MEDIA_FACTS = Symbol.for("openclaw.runtimePromptMediaFacts"); function normalizeNonNegativeNumber(value: number | null | undefined): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; + return asFiniteNumberInRange(value, { min: 0 }); } /** Attaches facts to a runtime prompt message without changing serialized/model-visible bytes. */ @@ -321,10 +325,6 @@ type MediaFactDefaults = { transcribed?: (media: TInput, index: number) => boolean; }; -function normalizePositiveInteger(value: number | null | undefined): number | undefined { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; -} - export type MediaFactLegacyProjection = { /** @deprecated Use `media[0]?.path`. */ MediaPath?: string; diff --git a/src/media/media-probe.ts b/src/media/media-probe.ts index 50dbe889db43..88450ba4b6b9 100644 --- a/src/media/media-probe.ts +++ b/src/media/media-probe.ts @@ -1,5 +1,10 @@ import fs from "node:fs/promises"; import type { MediaKind } from "@openclaw/media-core/constants"; +import { + asPositiveSafeInteger as parsePositiveInteger, + asSafeIntegerInRange, +} from "@openclaw/normalization-core/number-coercion"; +import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce"; import { runFfprobe } from "./ffmpeg-exec.js"; export type MediaProbeKind = Extract; @@ -38,10 +43,6 @@ type MediaProbeBatchOptions = { type FfprobeSource = { kind: "fileDescriptor"; fd: number } | { kind: "buffer"; buffer: Buffer }; -function parsePositiveInteger(value: unknown): number | undefined { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; -} - function parseDurationMs(value: unknown): number | undefined { if (typeof value !== "number" && typeof value !== "string") { return undefined; @@ -53,12 +54,6 @@ function parseDurationMs(value: unknown): number | undefined { return parsePositiveInteger(Math.round(seconds * 1000)); } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function normalizeCodecName(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -68,7 +63,7 @@ function normalizeCodecName(value: unknown): string | undefined { } function parseStreamIndex(value: unknown): number | undefined { - return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; + return asSafeIntegerInRange(value, { min: 0 }); } function selectPlaybackStream( diff --git a/src/meeting-bot/node-invoke-policy.ts b/src/meeting-bot/node-invoke-policy.ts index 9afdb1d246d3..3e9996f48402 100644 --- a/src/meeting-bot/node-invoke-policy.ts +++ b/src/meeting-bot/node-invoke-policy.ts @@ -1,3 +1,4 @@ +import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawPluginNodeInvokePolicy, @@ -39,7 +40,7 @@ function readPositiveNumber(value: unknown): number | undefined { } function readOutputGeneration(value: unknown): number | undefined { - return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; + return asSafeIntegerInRange(value, { min: 0 }); } function copyCommand(command: string[] | undefined): string[] | undefined { diff --git a/src/meeting-bot/realtime-engine-support.ts b/src/meeting-bot/realtime-engine-support.ts index 2f72a1a7b0c7..312a6024c872 100644 --- a/src/meeting-bot/realtime-engine-support.ts +++ b/src/meeting-bot/realtime-engine-support.ts @@ -1,3 +1,4 @@ +import { normalizeOptionalString as readLogString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RealtimeTranscriptionProviderPlugin, @@ -94,10 +95,6 @@ export function buildMeetingSpeakExactUserMessage(text: string): string { ].join("\n"); } -function readLogString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function formatLogValue(value: string | undefined): string { const normalized = value ? truncateUtf16Safe(value.replace(/\s+/g, "_"), 180) : undefined; return normalized || "unknown"; diff --git a/src/node-host/plugin-node-host.ts b/src/node-host/plugin-node-host.ts index ee6fe8ec2d87..c514a90d1706 100644 --- a/src/node-host/plugin-node-host.ts +++ b/src/node-host/plugin-node-host.ts @@ -1,4 +1,5 @@ /** Plugin node-host bridge for loading plugin registry commands and dispatching node capabilities. */ +import { asOptionalRecord as normalizeRecord } from "@openclaw/normalization-core/record-coerce"; import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { @@ -114,12 +115,6 @@ function normalizeString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } -function normalizeRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function isProviderSafeToolName(value: string): boolean { return /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value); } diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index d071ded1c314..04a236573ad7 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -1,4 +1,5 @@ // Channel policy helpers evaluate plugin channel runtime policy and operator-facing warnings. +import { asNullableRecord as asObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeStringEntries, uniqueStrings, @@ -104,12 +105,6 @@ type StandardAllowlistScope = { account: Record; }; -function asObjectRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - /** Collect the common account, nested-DM, and group/room allowlist paths for doctor warnings. */ export function collectStandardAllowlistLists( scope: StandardAllowlistScope, diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 60608bad4d03..dd5cef9d5e9d 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -34,6 +34,7 @@ import { import { resolveManagedSecretRefRuntimeProviderAuth } from "../agents/model-auth-runtime-config.js"; import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; import type { OpenClawConfig } from "../config/config.js"; +import { cancelUnreadResponseBody } from "../infra/http-body.js"; import { logWarn } from "../logger.js"; import { DEFAULT_GITHUB_COPILOT_DOMAIN, @@ -273,12 +274,6 @@ function parseCopilotTokenResponse(value: unknown): { return { token, expiresAt: expiresAtMs }; } -async function cancelUnreadResponseBody(response: Response): Promise { - if (!response.bodyUsed) { - await response.body?.cancel().catch(() => undefined); - } -} - /** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */ export function deriveCopilotApiBaseUrlFromToken( /** Copilot API token text that may contain a `proxy-ep` attribute. */ diff --git a/src/plugin-sdk/provider-catalog-live-runtime.ts b/src/plugin-sdk/provider-catalog-live-runtime.ts index e9def65725c9..b148b49ea5dd 100644 --- a/src/plugin-sdk/provider-catalog-live-runtime.ts +++ b/src/plugin-sdk/provider-catalog-live-runtime.ts @@ -1,5 +1,5 @@ import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; -import { readResponseWithLimit } from "../infra/http-body.js"; +import { cancelUnreadResponseBody, readResponseWithLimit } from "../infra/http-body.js"; import { retainSafeHeadersForCrossOriginRedirect } from "../infra/net/redirect-headers.js"; import type { ProviderCatalogContext, @@ -198,12 +198,6 @@ function buildHeaders( return headers; } -async function cancelUnreadResponseBody(response: Response): Promise { - if (!response.bodyUsed) { - await response.body?.cancel().catch(() => undefined); - } -} - async function readLiveModelCatalogJson(response: Response, timeoutMs: number): Promise { const buffer = await readResponseWithLimit(response, LIVE_MODEL_CATALOG_BODY_MAX_BYTES, { chunkTimeoutMs: timeoutMs, diff --git a/src/plugins/installed-plugin-index-record-builder.ts b/src/plugins/installed-plugin-index-record-builder.ts index 2913fa7a6df3..91b39aee1294 100644 --- a/src/plugins/installed-plugin-index-record-builder.ts +++ b/src/plugins/installed-plugin-index-record-builder.ts @@ -1,5 +1,6 @@ /** Builds installed-index records from normalized plugin manifest registry entries. */ import path from "node:path"; +import { normalizeOptionalString as normalizeStringField } from "@openclaw/normalization-core/string-coerce"; import { normalizeSortedUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; import type { OpenClawConfig } from "../config/types.js"; import type { PluginCompatCode } from "./compat/registry.js"; @@ -169,14 +170,6 @@ function describePackageInstallSource( }); } -function normalizeStringField(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim(); - return normalized ? normalized : undefined; -} - function normalizePackageChannel( channel: PluginPackageChannel | undefined, ): InstalledPluginPackageChannelInfo | undefined { diff --git a/src/plugins/installed-plugin-index-record-reader.ts b/src/plugins/installed-plugin-index-record-reader.ts index b3875c3b0ae6..73633e3acc89 100644 --- a/src/plugins/installed-plugin-index-record-reader.ts +++ b/src/plugins/installed-plugin-index-record-reader.ts @@ -1,6 +1,7 @@ /** Reads installed-index records back into manifest registry records. */ import fs from "node:fs"; import path from "node:path"; +import { safeParseJson } from "@openclaw/normalization-core"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { tryReadJsonSync } from "../infra/json-files.js"; @@ -432,11 +433,7 @@ type InstalledPluginIndexRecordRow = { }; function parseJsonColumn(value: string): unknown { - try { - return JSON.parse(value) as unknown; - } catch { - return undefined; - } + return safeParseJson(value); } function readPersistedInstalledPluginIndexForRecords( diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 16c1583e34df..bf4fa187421b 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -1,6 +1,7 @@ /** Persists, inspects, and refreshes the installed plugin index in the state database. */ import { existsSync } from "node:fs"; import type { DatabaseSync } from "node:sqlite"; +import { safeParseJson } from "@openclaw/normalization-core"; import { z } from "zod"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { withOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js"; @@ -222,11 +223,7 @@ function assertWritableInstalledPluginIndexStoreOptions( } function parseJsonColumn(value: string): unknown { - try { - return JSON.parse(value) as unknown; - } catch { - return undefined; - } + return safeParseJson(value); } function parseInstalledPluginIndexSqliteRow( diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index 9b3ebc28005d..8754a087e1ba 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -1,5 +1,6 @@ // Structured plugin catalog and lifecycle operations shared by Gateway-facing surfaces. import path from "node:path"; +import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; @@ -415,7 +416,7 @@ function normalizeCatalogMetadata( } function normalizeFeaturedAt(value: unknown): number | undefined { - return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; + return asSafeIntegerInRange(value, { min: 0 }); } function resolveCatalogInstallAction(params: { diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts index e4cadfdbfd36..1183f91b011a 100644 --- a/src/plugins/official-external-plugin-catalog.ts +++ b/src/plugins/official-external-plugin-catalog.ts @@ -5,7 +5,7 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { normalizeClawHubSha256Integrity } from "../infra/clawhub.js"; import { formatErrorMessage } from "../infra/errors.js"; -import { readResponseWithLimit } from "../infra/http-body.js"; +import { cancelUnreadResponseBody, readResponseWithLimit } from "../infra/http-body.js"; import { isRecord } from "../utils.js"; import type { PluginManifestCatalog, @@ -1329,9 +1329,7 @@ async function loadHostedOfficialExternalPluginCatalogEntries(params?: { now: currentTime(), }); } finally { - if (response?.bodyUsed !== true) { - await response?.body?.cancel().catch(() => undefined); - } + await cancelUnreadResponseBody(response); await release?.().catch(() => undefined); } } diff --git a/src/plugins/provider-openai-chatgpt-oauth-tls.ts b/src/plugins/provider-openai-chatgpt-oauth-tls.ts index 4d859c20a892..3259f4b22bf0 100644 --- a/src/plugins/provider-openai-chatgpt-oauth-tls.ts +++ b/src/plugins/provider-openai-chatgpt-oauth-tls.ts @@ -6,6 +6,7 @@ import { asNullableObjectRecord } from "@openclaw/normalization-core/record-coer import { note } from "../../packages/terminal-core/src/note.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { cancelUnreadResponseBody } from "../infra/http-body.js"; const OPENAI_AUTH_PROBE_URL = "https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email"; @@ -112,9 +113,7 @@ export async function runOpenAIOAuthTlsPreflight(options?: { message: failure.message, }; } finally { - if (response?.bodyUsed !== true) { - await response?.body?.cancel().catch(() => undefined); - } + await cancelUnreadResponseBody(response); } } diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index 65f988ba3429..5b8bce84f288 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -27,6 +27,7 @@ import { import type { ModelDefinitionConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; // Builds setup metadata for self-hosted provider plugins. +import { cancelUnreadResponseBody } from "../infra/http-body.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -119,12 +120,6 @@ async function readSelfHostedDiscoveryJson(response: Response, label: string) }); } -async function cancelUnreadResponseBody(response: Response): Promise { - if (!response.bodyUsed) { - await response.body?.cancel().catch(() => undefined); - } -} - function resolveLlamaCppPropsUrl(baseUrl: string, modelId?: string): string { const parsed = new URL(baseUrl); const pathname = parsed.pathname.replace(/\/+$/, ""); diff --git a/src/plugins/runtime/runtime-llm.runtime.ts b/src/plugins/runtime/runtime-llm.runtime.ts index ed575355f9c9..bb31722e843a 100644 --- a/src/plugins/runtime/runtime-llm.runtime.ts +++ b/src/plugins/runtime/runtime-llm.runtime.ts @@ -4,7 +4,7 @@ import { normalizeBuiltInProviderModelId, stripSelfProviderModelPrefix, } from "@openclaw/model-catalog-core/provider-model-id-normalization"; -import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; +import { asFiniteNumber, asFiniteNumberInRange } from "@openclaw/normalization-core"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js"; import { normalizeModelRef } from "../../agents/model-ref-shared.js"; @@ -212,7 +212,7 @@ function buildMessages(params: { } function readFiniteNonNegativeNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; + return asFiniteNumberInRange(value, { min: 0 }); } function readExplicitCostUsd(raw: unknown): number | undefined { diff --git a/src/security/channel-metadata.ts b/src/security/channel-metadata.ts index c74c7df4d4d3..e3f57f324c62 100644 --- a/src/security/channel-metadata.ts +++ b/src/security/channel-metadata.ts @@ -1,6 +1,6 @@ // Extracts channel metadata used by security audit findings. import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { truncateWithMarker } from "@openclaw/normalization-core/utf16-slice"; import { wrapExternalContent } from "./external-content.js"; const DEFAULT_MAX_CHARS = 800; @@ -14,11 +14,7 @@ function truncateText(value: string, maxChars: number): string { if (maxChars <= 0) { return ""; } - if (value.length <= maxChars) { - return value; - } - const trimmed = truncateUtf16Safe(value, Math.max(0, maxChars - 3)).trimEnd(); - return `${trimmed}...`; + return truncateWithMarker(value, maxChars, { marker: "...", reserve: 3, trimEnd: true }); } /** diff --git a/src/security/install-policy.ts b/src/security/install-policy.ts index 339398465722..ddb0eee3cd32 100644 --- a/src/security/install-policy.ts +++ b/src/security/install-policy.ts @@ -1,7 +1,7 @@ // Checks install policy constraints for package and plugin operations. import fs from "node:fs/promises"; import path from "node:path"; -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { truncateWithMarker } from "@openclaw/normalization-core/utf16-slice"; import type { OpenClawConfig, SecurityConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { runCommandWithTimeout } from "../process/exec.js"; @@ -343,7 +343,7 @@ async function assertSecurePolicyScriptArg(params: { } function truncateText(value: string, maxChars: number): string { - return value.length <= maxChars ? value : `${truncateUtf16Safe(value, maxChars)}...`; + return truncateWithMarker(value, maxChars, { marker: "...", reserve: 0, trimEnd: false }); } function createPolicyChildEnv(sourceEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { diff --git a/src/sessions/session-upstream-links.ts b/src/sessions/session-upstream-links.ts index 11b33c793a9e..a8f182bfa324 100644 --- a/src/sessions/session-upstream-links.ts +++ b/src/sessions/session-upstream-links.ts @@ -1,5 +1,6 @@ /** Best-effort shared-state registry for adopted upstream sessions. */ import type { DatabaseSync } from "node:sqlite"; +import { safeParseJson } from "@openclaw/normalization-core"; import type { Selectable } from "kysely"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; import { normalizeSqliteNumber } from "../infra/sqlite-number.js"; @@ -42,11 +43,7 @@ function parseJson(value: string | null): SessionUpstreamJsonValue | null { if (value === null) { return null; } - try { - return JSON.parse(value) as SessionUpstreamJsonValue; - } catch { - return null; - } + return (safeParseJson(value) as SessionUpstreamJsonValue | undefined) ?? null; } function rowToSessionUpstreamLink(row: SessionUpstreamLinkRow): SessionUpstreamLink { diff --git a/src/shared/text-chunking.ts b/src/shared/text-chunking.ts index 300e4ed3bbf4..1d17873cdb0d 100644 --- a/src/shared/text-chunking.ts +++ b/src/shared/text-chunking.ts @@ -5,7 +5,7 @@ export { avoidTrailingHighSurrogateBreak }; const CJK_PUNCTUATION_BREAK_AFTER_RE = /[、。,.!?;:)]}〉》」』】〕〗〙]/u; -function normalizeChunkLimit(limit: number): number { +export function normalizeChunkLimit(limit: number): number { // String slicing truncates fractional indexes, so positive limits need an integer progress step. return Number.isFinite(limit) && limit > 0 ? resolveIntegerOption(limit, 1, { min: 1 }) : limit; } diff --git a/src/skills/lifecycle/clawhub-store.ts b/src/skills/lifecycle/clawhub-store.ts index b83bfddd1091..3bccd7954374 100644 --- a/src/skills/lifecycle/clawhub-store.ts +++ b/src/skills/lifecycle/clawhub-store.ts @@ -1,5 +1,6 @@ import fsSync from "node:fs"; import path from "node:path"; +import { normalizeOptionalString as normalizeOptionalStringValue } from "@openclaw/normalization-core/string-coerce"; import { CLAWHUB_SKILLS_SH_TRUST_STATE, type ClawHubDownloadResult, @@ -10,6 +11,8 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { readJsonIfExists, tryReadJson, writeJson } from "../../infra/json-files.js"; import { normalizeTrackedSkillSlug, validateRequestedSkillSlug } from "./archive-install.js"; +export { normalizeOptionalStringValue }; + const DOT_DIR = ".clawhub"; const LEGACY_DOT_DIR = ".clawdhub"; const CLAWHUB_OWNER_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,38}[a-z0-9])?$/; @@ -156,10 +159,6 @@ export function normalizeStoredRegistry(registry: string): string { return trimmed.replace(/\/+$/, "") || trimmed; } -export function normalizeOptionalStringValue(raw: unknown): string | undefined { - return typeof raw === "string" && raw.trim() ? raw.trim() : undefined; -} - export function normalizeGitHubCommitSegment(raw: unknown): string | undefined { const commit = normalizeOptionalStringValue(raw); return commit && /^[0-9a-f]{40}$/i.test(commit) ? commit : undefined; diff --git a/src/state/openclaw-agent-db-schema.ts b/src/state/openclaw-agent-db-schema.ts index 67ea81bcd589..daee43f98ebd 100644 --- a/src/state/openclaw-agent-db-schema.ts +++ b/src/state/openclaw-agent-db-schema.ts @@ -1,4 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; +import { normalizeNullableString as migratedText } from "@openclaw/normalization-core/string-coerce"; import type { SessionRunStatus } from "../../packages/gateway-protocol/src/schema/sessions-row.js"; import { ensureMemoryChunkProvenance, @@ -332,12 +334,8 @@ function migratedObjectField( : null; } -function migratedText(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function migratedNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; + return asFiniteNumber(value) ?? null; } function migratedChatType(value: unknown): "direct" | "group" | "channel" | null { diff --git a/src/state/openclaw-database-verify.impl.ts b/src/state/openclaw-database-verify.impl.ts index af2f70ba91c1..5b675f435d54 100644 --- a/src/state/openclaw-database-verify.impl.ts +++ b/src/state/openclaw-database-verify.impl.ts @@ -2,6 +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 { createSubsystemLogger } from "../logging/subsystem.js"; import { confirmOpenClawAgentDatabaseIntegrity, @@ -24,9 +25,42 @@ 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 toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(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 { diff --git a/src/state/openclaw-database-verify.test.ts b/src/state/openclaw-database-verify.test.ts index 79afe97ffd1e..f39bf6cede70 100644 --- a/src/state/openclaw-database-verify.test.ts +++ b/src/state/openclaw-database-verify.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawnSync, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -48,6 +48,148 @@ const tempDirs = useAutoCleanupTempDirTracker((cleanup) => { }); }); +async function captureDatabaseVerifyWorkerSendFailure(failure: unknown): Promise { + return await runDatabaseVerifyWorker([], { + onWorker: (worker) => { + if (!worker) { + return; + } + worker.send = ((...args: unknown[]) => { + const callback = args.at(-1); + if (typeof callback === "function") { + callback(failure); + } + return true; + }) as ChildProcess["send"]; + }, + }).then( + () => { + throw new Error("expected database verification worker failure"); + }, + (rejection: unknown) => rejection as Error, + ); +} + +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; + + 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.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); + }); +}); + function createUnsafeIndexDrift(databasePath: string): void { const { DatabaseSync } = requireNodeSqlite(); const database = new DatabaseSync(databasePath); diff --git a/src/status/summary.ts b/src/status/summary.ts index d138d7a53a18..c568a4727071 100644 --- a/src/status/summary.ts +++ b/src/status/summary.ts @@ -1,6 +1,7 @@ // Builds the status summary used by human and JSON status output. // It aggregates sessions, tasks, heartbeat, channel summary, and model/runtime metadata. +import { normalizeLowercaseStringOrEmpty as normalizeStatusModelPart } from "@openclaw/normalization-core/string-coerce"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js"; import { getRuntimeConfig, projectConfigOntoRuntimeSourceSnapshot } from "../config/config.js"; @@ -152,10 +153,6 @@ function hasUserPinnedModelSelection(entry: SessionEntry | undefined): boolean { return !hasSessionAutoModelFallbackProvenance(entry); } -function normalizeStatusModelPart(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - function resolveTrustedSessionContextTokens(params: { entry: SessionEntry | undefined; provider: string | undefined; diff --git a/src/system-agent/rescue-message.ts b/src/system-agent/rescue-message.ts index b1c3c5e6b40e..1b50454aac57 100644 --- a/src/system-agent/rescue-message.ts +++ b/src/system-agent/rescue-message.ts @@ -4,6 +4,7 @@ import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs, } from "@openclaw/normalization-core/number-coercion"; +import { hasNonEmptyString as isNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import type { CommandContext } from "../auto-reply/reply/commands-types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createCorePluginStateSyncKeyedStore } from "../plugin-state/plugin-state-store.js"; @@ -124,10 +125,6 @@ function hasOptionalString(value: Record, key: string): boolean return !Object.hasOwn(value, key) || isNonEmptyString(value[key]); } -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - function parsePendingOperation(value: unknown): SystemAgentOperation | null { if (!isPlainRecord(value) || value.version !== 1 || !isPlainRecord(value.operation)) { return null; diff --git a/src/system-agent/setup-app-recommendations.ts b/src/system-agent/setup-app-recommendations.ts index a46cedbd3455..c9e71274c48d 100644 --- a/src/system-agent/setup-app-recommendations.ts +++ b/src/system-agent/setup-app-recommendations.ts @@ -1,3 +1,4 @@ +import { safeParseJson } from "@openclaw/normalization-core"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import pLimit from "p-limit"; @@ -292,11 +293,7 @@ function parseMatcherJson(text: string): unknown { if (start === -1 || end <= start) { return null; } - try { - return JSON.parse(text.slice(start, end + 1)); - } catch { - return null; - } + return safeParseJson(text.slice(start, end + 1)) ?? null; } function buildMatcherPrompt(groups: SetupAppCandidateGroup[]): string { diff --git a/src/tasks/task-flow-registry.store.sqlite.ts b/src/tasks/task-flow-registry.store.sqlite.ts index d74b5dfdc961..dbce72b94edb 100644 --- a/src/tasks/task-flow-registry.store.sqlite.ts +++ b/src/tasks/task-flow-registry.store.sqlite.ts @@ -1,5 +1,6 @@ // Persists managed task-flow records through the OpenClaw SQLite state database. import type { DatabaseSync } from "node:sqlite"; +import { safeParseJson } from "@openclaw/normalization-core"; import type { Insertable, Selectable } from "kysely"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; import { normalizeSqliteNumber } from "../infra/sqlite-number.js"; @@ -45,11 +46,7 @@ function parseJsonValue(raw: string | null): JsonValue | undefined { if (!raw?.trim()) { return undefined; } - try { - return JSON.parse(raw) as JsonValue; - } catch { - return undefined; - } + return safeParseJson(raw) as JsonValue | undefined; } function rowToSyncMode(row: FlowRegistryRow): TaskFlowSyncMode { diff --git a/src/tasks/task-registry.sqlite.shared.ts b/src/tasks/task-registry.sqlite.shared.ts index 865b3d0de1e0..72a41f18dd7b 100644 --- a/src/tasks/task-registry.sqlite.shared.ts +++ b/src/tasks/task-registry.sqlite.shared.ts @@ -1,4 +1,5 @@ // Shares SQLite row mapping helpers between task registry persistence modules. +import { safeParseJson } from "@openclaw/normalization-core"; import { isRecord } from "../utils.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js"; import type { DeliveryContext } from "../utils/delivery-context.types.js"; @@ -8,11 +9,7 @@ function parseSqliteJsonValue(raw: string | null): T | undefined { if (!raw?.trim()) { return undefined; } - try { - return JSON.parse(raw) as T; - } catch { - return undefined; - } + return safeParseJson(raw) as T | undefined; } export function parseDeliveryContextJson(raw: string | null): DeliveryContext | undefined { diff --git a/src/tasks/task-registry.store.sqlite.ts b/src/tasks/task-registry.store.sqlite.ts index 7fbb5684915a..174e8e90917b 100644 --- a/src/tasks/task-registry.store.sqlite.ts +++ b/src/tasks/task-registry.store.sqlite.ts @@ -1,5 +1,6 @@ // Persists task registry records and events through the OpenClaw SQLite state database. import type { DatabaseSync } from "node:sqlite"; +import { safeParseJson } from "@openclaw/normalization-core"; import type { Insertable, Selectable } from "kysely"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; import { assertSqliteTableIntegrity } from "../infra/sqlite-integrity.js"; @@ -94,11 +95,7 @@ function parseJsonValue(raw: string | null): JsonValue | undefined { if (!raw?.trim()) { return undefined; } - try { - return JSON.parse(raw) as JsonValue; - } catch { - return undefined; - } + return safeParseJson(raw) as JsonValue | undefined; } function rowToTaskRecord(row: TaskRegistryRow): TaskRecord { diff --git a/src/tts/speaker.ts b/src/tts/speaker.ts index f1705d2f0953..4fe95fdef2c7 100644 --- a/src/tts/speaker.ts +++ b/src/tts/speaker.ts @@ -1,10 +1,8 @@ // Speaker-selection compatibility helpers for plugins that renamed voice fields // over time but still need one normalized config object. -type SpeakerSelectionConfig = Record; +import { normalizeOptionalString as readString } from "@openclaw/normalization-core/string-coerce"; -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} +type SpeakerSelectionConfig = Record; /** Populate canonical and legacy speaker voice fields together. */ export function withSpeakerSelectionCompat( diff --git a/src/tts/tts-synthesis-support.ts b/src/tts/tts-synthesis-support.ts index 079856db39b0..0b5678ad07fc 100644 --- a/src/tts/tts-synthesis-support.ts +++ b/src/tts/tts-synthesis-support.ts @@ -1,3 +1,4 @@ +import { normalizeOptionalString as readTtsResultString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig, ResolvedTtsPersona, TtsProvider } from "../config/types.js"; import { logVerbose } from "../globals.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -407,10 +408,6 @@ export async function executeTtsProviderAttempts(params: { return buildTtsFailureResult(errors, attemptedProviders, attempts, persona?.id); } -function readTtsResultString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function resolveTtsResultModel( providerConfig: SpeechProviderConfig, providerOverrides?: SpeechProviderOverrides, diff --git a/src/tts/voice-models.ts b/src/tts/voice-models.ts index f01dd1f76e9d..251d4eefd24e 100644 --- a/src/tts/voice-models.ts +++ b/src/tts/voice-models.ts @@ -1,5 +1,6 @@ // Voice model catalog helpers shared by TTS and realtime voice plugins. import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs"; +import { normalizeOptionalString as normalizeString } from "@openclaw/normalization-core/string-coerce"; type VoiceModelCapability = "tts" | "realtime_transcription" | "realtime_voice"; @@ -48,10 +49,6 @@ type VoiceModelConfig = timeoutMs?: unknown; }; -function normalizeString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function normalizeLowercaseString(value: unknown): string | undefined { return normalizeString(value)?.toLowerCase(); } diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index bb0a0a59b00a..1526bf640609 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { Component, OverlayHandle, SelectItem, TUI } from "@earendil-works/pi-tui"; import type { Result } from "@openclaw/normalization-core/result"; +import { normalizeLowercaseStringOrEmpty as normalizedChatSendAckStatus } from "@openclaw/normalization-core/string-coerce"; import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/index.js"; import { modelKey } from "../agents/model-ref-shared.js"; import { shouldForwardModelCommandToServer } from "../auto-reply/commands-registry.shared.js"; @@ -107,10 +108,6 @@ function isSlashStopCommand(text: string): boolean { return trimmed.startsWith("/") && isChatStopCommandText(trimmed); } -function normalizedChatSendAckStatus(status: unknown): string { - return typeof status === "string" ? status.trim().toLowerCase() : ""; -} - function isTerminalChatSendAckFailure(status: unknown): boolean { const normalized = normalizedChatSendAckStatus(status); return normalized === "timeout" || normalized === "error"; diff --git a/src/tui/tui-last-session.ts b/src/tui/tui-last-session.ts index 1a0683451341..6837ea5f93bc 100644 --- a/src/tui/tui-last-session.ts +++ b/src/tui/tui-last-session.ts @@ -1,6 +1,7 @@ // Stores and resolves the last TUI session per workspace. import { createHash } from "node:crypto"; import fs from "node:fs"; +import { normalizeLowercaseStringOrEmpty as normalizeMarker } from "@openclaw/normalization-core/string-coerce"; import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync, @@ -37,10 +38,6 @@ export function buildTuiLastSessionScopeKey(params: { .slice(0, 32); } -function normalizeMarker(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - function isHeartbeatSessionKey(sessionKey: string): boolean { return normalizeMarker(sessionKey).endsWith(":heartbeat"); } diff --git a/src/worker/worker-connection-contract.ts b/src/worker/worker-connection-contract.ts index 24c43b7c1855..71fab6e75591 100644 --- a/src/worker/worker-connection-contract.ts +++ b/src/worker/worker-connection-contract.ts @@ -6,11 +6,14 @@ 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([ "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"; @@ -95,5 +98,36 @@ export function resolvePositiveTimeout(value: number | undefined, fallback: numb } export function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(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; } diff --git a/src/worker/worker-connection.test.ts b/src/worker/worker-connection.test.ts index 6b95ac580984..b0c0b3b49237 100644 --- a/src/worker/worker-connection.test.ts +++ b/src/worker/worker-connection.test.ts @@ -13,6 +13,7 @@ import type { WorkerInferenceEventFrame, WorkerInferenceTerminalFrame, } from "../../packages/gateway-protocol/src/schema/worker-inference.js"; +import { toError } from "./worker-connection-contract.js"; import { WorkerConnectionFrameDispatcher } from "./worker-connection-frames.js"; import { createWorkerConnection, type WorkerConnectionState } from "./worker-connection.js"; @@ -130,6 +131,138 @@ function installThrowingThenHealthyListeners(connection: ReturnType throwingCalls }; } +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 = toError(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 }; + + const error = toError(cause); + + expect(error.message).toBe("[object Object]"); + 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 = toError(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 = toError(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 = toError(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 = toError(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", () => { it("settles stop and reaches later listeners when an earlier listener throws", async () => { const connection = createIdleConnection(); diff --git a/ui/src/app/custom-theme.ts b/ui/src/app/custom-theme.ts index 34a4849e1cd3..c24bf63e6d1d 100644 --- a/ui/src/app/custom-theme.ts +++ b/ui/src/app/custom-theme.ts @@ -1,3 +1,4 @@ +import { asNullableRecord as readThemeRecord } from "@openclaw/normalization-core/record-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; // Control UI module implements custom theme behavior. import { normalizeOptionalString } from "../lib/string-coerce.ts"; @@ -100,12 +101,6 @@ export type ImportedCustomTheme = { // Shape checks are intentionally shallow: normalizeStoredTokenMap and // resolveModeVar re-validate every token (presence, length, safe CSS) and // throw on anything off, so no schema library is needed at this boundary. -function readThemeRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - type TweakcnThemeResolution = { sourceUrl: string; fetchUrl: string; diff --git a/ui/src/app/question-prompt.ts b/ui/src/app/question-prompt.ts index 2ceaaa72a57c..829ec52fa9c7 100644 --- a/ui/src/app/question-prompt.ts +++ b/ui/src/app/question-prompt.ts @@ -1,5 +1,6 @@ // Control UI module owns transient operator question state. import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeNullableString as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import type { Question, QuestionAnswers, @@ -59,14 +60,6 @@ type QuestionAnswerValues = Record; const REFRESH_RETRY_DELAYS_MS = [1_000, 2_000, 4_000] as const; -function readNonEmptyString(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed ? trimmed : null; -} - function readTimestamp(value: unknown): number | null { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; } diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index ea1a4a85af91..e87ff9108254 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -30,6 +30,7 @@ type PersistedUiSettings = Omit; }; +import { safeParseJson } from "@openclaw/normalization-core"; import { DEFAULT_SIDEBAR_ENTRIES, normalizeSidebarEntries, @@ -291,11 +292,7 @@ function parsePersistedSettings(raw: string | null): PersistedUiSettings | null if (!raw) { return null; } - try { - return JSON.parse(raw) as PersistedUiSettings; - } catch { - return null; - } + return (safeParseJson(raw) as PersistedUiSettings | undefined) ?? null; } function settingsMatchGatewayTarget(parsed: PersistedUiSettings, targetUrl: string): boolean { diff --git a/ui/src/build-info-normalizers.ts b/ui/src/build-info-normalizers.ts index 9c38c5e61e85..55218f2ea855 100644 --- a/ui/src/build-info-normalizers.ts +++ b/ui/src/build-info-normalizers.ts @@ -1,5 +1,7 @@ // Shared build identity normalization for the runtime artifact and Vite config. // Vite loads this module before source-package aliases exist, so use the canonical source path. +import { asRecord } from "../../packages/normalization-core/src/record-coerce.js"; +import { normalizeNullableString } from "../../packages/normalization-core/src/string-coerce.js"; import { truncateUtf16Safe } from "../../packages/normalization-core/src/utf16-slice.js"; import type { ControlUiBuildInfo } from "./build-info-types.ts"; @@ -12,22 +14,18 @@ const FULL_GIT_SHA = /^[0-9a-f]{40}$/u; const UTC_BUILD_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u; const BUILD_ID_MAX_LENGTH = 96; -function normalizeOptionalString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function normalizeControlUiCommit(value: unknown): string | null { - const commit = normalizeOptionalString(value)?.toLowerCase() ?? null; + const commit = normalizeNullableString(value)?.toLowerCase() ?? null; return commit && FULL_GIT_SHA.test(commit) ? commit : null; } function normalizeControlUiBranch(value: unknown): string | null { - const branch = normalizeOptionalString(value); + const branch = normalizeNullableString(value); return branch && branch !== "HEAD" ? truncateUtf16Safe(branch, 100) : null; } function normalizeControlUiBuildTimestamp(value: unknown): string | null { - const timestamp = normalizeOptionalString(value); + const timestamp = normalizeNullableString(value); if (!timestamp || !UTC_BUILD_TIMESTAMP.test(timestamp)) { return null; } @@ -42,7 +40,7 @@ function normalizeControlUiBuildTimestamp(value: unknown): string | null { } function normalizeControlUiBuildId(value: unknown): string { - const normalized = normalizeOptionalString(value)?.replace(/[^a-zA-Z0-9._-]+/g, "-"); + const normalized = normalizeNullableString(value)?.replace(/[^a-zA-Z0-9._-]+/g, "-"); return normalized?.slice(0, BUILD_ID_MAX_LENGTH) || "dev"; } @@ -59,10 +57,8 @@ function deriveControlUiBuildId(info: ControlUiBuildMetadata): string { } export function normalizeControlUiBuildInfo(value: unknown): ControlUiBuildInfo { - const record = value && typeof value === "object" ? (value as Record) : {}; - const optionalString = (candidate: unknown) => - typeof candidate === "string" && candidate.trim() ? candidate.trim() : null; - const version = optionalString(record.version); + const record = asRecord(value); + const version = normalizeNullableString(record.version); const commit = normalizeControlUiCommit(record.commit); const builtAt = normalizeControlUiBuildTimestamp(record.builtAt); const release = record.release === true; diff --git a/ui/src/components/config-form.constraints.ts b/ui/src/components/config-form.constraints.ts index ad21a0c95327..3e7c090a95e4 100644 --- a/ui/src/components/config-form.constraints.ts +++ b/ui/src/components/config-form.constraints.ts @@ -3,6 +3,7 @@ import { isJsonSchemaValueValid, jsonSchemaValuesEqual, } from "@openclaw/normalization-core/json-schema"; +import { asFiniteNumber as finiteNumber } from "@openclaw/normalization-core/number-coercion"; import { decimalRational } from "./config-form.numeric.ts"; import { schemaType, type JsonSchema } from "./config-form.shared.ts"; @@ -17,10 +18,6 @@ function ownPropertySchema(schema: JsonSchema, key: string): JsonSchema | undefi return properties && Object.hasOwn(properties, key) ? properties[key] : undefined; } -function finiteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function decimalPlaces(value: number): number { const text = String(value).toLowerCase(); const [coefficient = "", exponentText] = text.split("e"); diff --git a/ui/src/lib/chat/follow-up-mode.ts b/ui/src/lib/chat/follow-up-mode.ts index 7c689bab57f2..ec6fdcec3727 100644 --- a/ui/src/lib/chat/follow-up-mode.ts +++ b/ui/src/lib/chat/follow-up-mode.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord as record } from "@openclaw/normalization-core/record-coerce"; import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js"; import { normalizeQueueMode } from "../../../../src/auto-reply/reply/queue/normalize.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../../../src/utils/message-channel-constants.js"; @@ -12,12 +13,6 @@ type ServerQueueModeSources = { sessionMode?: unknown; }; -function record(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function normalizedQueueMode(value: unknown): QueueMode | undefined { return typeof value === "string" ? normalizeQueueMode(value) : undefined; } diff --git a/ui/src/lib/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts index 40d87efc2e66..d7fe2dd59303 100644 --- a/ui/src/lib/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -3,7 +3,7 @@ */ import { mediaKindFromMime } from "@openclaw/media-core/constants"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { asRecord as asMessageRecord, isRecord } from "@openclaw/normalization-core/record-coerce"; import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js"; import { extractCanvasShortcodes } from "../../../../src/chat/canvas-render.js"; import { @@ -17,16 +17,6 @@ import { getMediaFileExtension } from "../media-file-extension.ts"; import type { NormalizedMessage, MessageContentItem } from "./chat-types.ts"; import { formatSenderLabel, normalizeSenderIdentity } from "./sender-label.ts"; -// These normalizers take `unknown` gateway/transcript data. A malformed or -// absent entry can arrive as null/undefined (e.g. a transcript row without a -// `message`), and `typeof m.role` still throws "reading 'role'" when `m` itself -// is undefined — the typeof only guards the property, not the object. Coercing -// a non-object to `{}` keeps every downstream `typeof m.` check working -// and yields role "unknown" instead of crashing the gateway event handler. -function asMessageRecord(message: unknown): Record { - return message && typeof message === "object" ? (message as Record) : {}; -} - // Older gateways baked sender labels as "name ()" into transcript // text. The UUID is machine noise in a human label but it is also the row's // only author key, so split it into display + identity instead of discarding. @@ -71,6 +61,8 @@ export function normalizeRoleForGrouping(role: string): string { } export function isToolResultMessage(message: unknown): boolean { + // Malformed transcript entries coerce to an empty record so property reads + // degrade to role "unknown" instead of crashing the event handler. const m = asMessageRecord(message); const role = typeof m.role === "string" ? m.role.toLowerCase() : ""; return role === "toolresult" || role === "tool_result"; diff --git a/ui/src/lib/chat/sender-label.ts b/ui/src/lib/chat/sender-label.ts index 573925211884..35d55345e033 100644 --- a/ui/src/lib/chat/sender-label.ts +++ b/ui/src/lib/chat/sender-label.ts @@ -1,3 +1,5 @@ +import { normalizeNullableString as normalizeLabelPart } from "@openclaw/normalization-core/string-coerce"; + export type SenderIdentity = { id?: string; name?: string; @@ -12,10 +14,6 @@ type SenderIdentityInput = { profileAvatarUrl?: unknown; }; -function normalizeLabelPart(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - /** Formats durable sender identity without assuming ids will always be email addresses. */ export function formatSenderLabel(sender: SenderIdentity | null | undefined): string | null { const displayName = normalizeLabelPart(sender?.name) ?? normalizeLabelPart(sender?.username); diff --git a/ui/src/lib/config/index.ts b/ui/src/lib/config/index.ts index fadd634d548e..27659a5e9124 100644 --- a/ui/src/lib/config/index.ts +++ b/ui/src/lib/config/index.ts @@ -1,6 +1,9 @@ // Control UI runtime config capability and shared config-domain mutations. import { ErrorCodes } from "@openclaw/gateway-client/browser"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { + asNullableRecord as asConfigRecord, + isRecord, +} from "@openclaw/normalization-core/record-coerce"; import { GatewayRequestError, type GatewayBrowserClient, @@ -422,13 +425,6 @@ function applyConfigSchema(state: ConfigState, res: ConfigSchemaResponse) { state.configSchemaVersion = res.version ?? null; } -function asConfigRecord(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - return value as Record; -} - export function resolveEditableSnapshotConfig( snapshot: ConfigSnapshot | null | undefined, ): Record | null { diff --git a/ui/src/lib/nodes/inventory.ts b/ui/src/lib/nodes/inventory.ts index 5ce1d0c543dd..0053a6485da7 100644 --- a/ui/src/lib/nodes/inventory.ts +++ b/ui/src/lib/nodes/inventory.ts @@ -1,3 +1,4 @@ +import { asFiniteNumber as optionalNumber } from "@openclaw/normalization-core/number-coercion"; import type { PresenceEntry } from "../../api/types.ts"; // Builds the unified nodes/devices inventory shown on the Nodes page. // The gateway exposes two overlapping views of the same machines: paired device @@ -68,10 +69,6 @@ const NODE_APPROVAL_STATES: ReadonlySet = new Set([ "unapproved", ]); -function optionalNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function stringList(value: unknown): string[] { if (!Array.isArray(value)) { return []; diff --git a/ui/src/lib/sessions/reconcile.ts b/ui/src/lib/sessions/reconcile.ts index 0814c20ccd71..7627ed5b67f8 100644 --- a/ui/src/lib/sessions/reconcile.ts +++ b/ui/src/lib/sessions/reconcile.ts @@ -1,4 +1,5 @@ import { asNullableRecord as recordOrNull } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString as stringValue } from "@openclaw/normalization-core/string-coerce"; import type { GatewaySessionRow, SessionRunStatus, SessionsListResult } from "../../api/types.ts"; import { isSessionRunActive } from "../session-run-state.ts"; import { @@ -215,10 +216,6 @@ function recordValue(record: Record, key: string): unknown { return Object.hasOwn(record, key) ? record[key] : undefined; } -function stringValue(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function sessionRunStatus(value: unknown): SessionRunStatus | null { return value === "running" || value === "done" || diff --git a/ui/src/lib/sessions/swarm-activity.ts b/ui/src/lib/sessions/swarm-activity.ts index 6b825957d8da..068d0237f016 100644 --- a/ui/src/lib/sessions/swarm-activity.ts +++ b/ui/src/lib/sessions/swarm-activity.ts @@ -1,4 +1,5 @@ import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString as normalizedString } from "@openclaw/normalization-core/string-coerce"; import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts"; // Lifecycle notes are transient UI state, so bound them for long-lived board tabs. @@ -13,10 +14,6 @@ type SwarmDisplayCarrier = { swarmPhase?: string; }; -function normalizedString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function setBounded(map: Map, key: K, value: V, limit: number): void { map.delete(key); map.set(key, value); diff --git a/ui/src/lib/tasks/data.ts b/ui/src/lib/tasks/data.ts index 9ce02dc88bd5..92a8b097c26b 100644 --- a/ui/src/lib/tasks/data.ts +++ b/ui/src/lib/tasks/data.ts @@ -1,4 +1,5 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString as optionalString } from "@openclaw/normalization-core/string-coerce"; import { Value } from "typebox/value"; import { TasksCancelResultSchema, @@ -20,10 +21,6 @@ type TaskEventPayload = | { action: "deleted"; taskId: string } | { action: "restored" }; -function optionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - const STATUS_LABEL_KEYS = { queued: "tasksPage.status.queued", running: "tasksPage.status.running", diff --git a/ui/src/lib/workboard/card-state.ts b/ui/src/lib/workboard/card-state.ts index 7bc7b8a7ad22..3c7e83b99e04 100644 --- a/ui/src/lib/workboard/card-state.ts +++ b/ui/src/lib/workboard/card-state.ts @@ -1,3 +1,4 @@ +import { normalizeNullableString as normalizeString } from "@openclaw/normalization-core/string-coerce"; import type { GatewaySessionRow } from "../../api/types.ts"; import type { WorkboardCard, @@ -8,6 +9,8 @@ import type { WorkboardUiState, } from "./types.ts"; +export { normalizeString }; + const WORKBOARD_STALE_SESSION_MS = 30 * 60 * 1000; export function isActiveWorkboardCard(card: WorkboardCard): boolean { @@ -179,7 +182,3 @@ export function workboardCardSessionKey(card: WorkboardCard): string | undefined export function workboardCardRunId(card: WorkboardCard): string | undefined { return card.runId ?? card.execution?.runId; } - -export function normalizeString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} diff --git a/ui/src/pages/activity/tool-activity.ts b/ui/src/pages/activity/tool-activity.ts index 5c17a7f9c7d2..22dffc80be53 100644 --- a/ui/src/pages/activity/tool-activity.ts +++ b/ui/src/pages/activity/tool-activity.ts @@ -1,4 +1,6 @@ // Control UI module implements activity model behavior. +import { asNullableObjectRecord as readRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeNullableString as toTrimmedString } from "@openclaw/normalization-core/string-coerce"; import { formatUnknownText, truncateText } from "../../lib/format.ts"; const ACTIVITY_ENTRY_LIMIT = 100; @@ -66,18 +68,6 @@ const SECRET_PATTERNS: Array<[RegExp, string]> = [ ], ]; -function toTrimmedString(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed ? trimmed : null; -} - -function readRecord(value: unknown): Record | null { - return value && typeof value === "object" ? (value as Record) : null; -} - export function parseActivityEvent( payload: unknown, receivedAt = Date.now(), diff --git a/ui/src/pages/agents/memory/dreaming.ts b/ui/src/pages/agents/memory/dreaming.ts index a80cddb366d9..88834cfcbb7a 100644 --- a/ui/src/pages/agents/memory/dreaming.ts +++ b/ui/src/pages/agents/memory/dreaming.ts @@ -1,4 +1,5 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString as normalizeTrimmedString } from "@openclaw/normalization-core/string-coerce"; import type { DoctorMemoryDreamActionPayload, DoctorMemoryDreamDiaryPayload, @@ -286,14 +287,6 @@ function buildDreamDiaryActionSuccessMessage( return t("dreaming.actions.complete"); } -function normalizeTrimmedString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - function resolveSelectedAgentId(state: DreamingState): string | null { return normalizeTrimmedString(state.selectedAgentId) ?? null; } diff --git a/ui/src/pages/chat/components/chat-composer-context.ts b/ui/src/pages/chat/components/chat-composer-context.ts index 6b8c78073821..d286d0914907 100644 --- a/ui/src/pages/chat/components/chat-composer-context.ts +++ b/ui/src/pages/chat/components/chat-composer-context.ts @@ -1,3 +1,4 @@ +import { asNullableObjectRecord as readCostRecord } from "@openclaw/normalization-core/record-coerce"; import { html, nothing } from "lit"; import type { GatewaySessionRow } from "../../../api/types.ts"; import { normalizeBasePath } from "../../../app-route-paths.ts"; @@ -34,10 +35,6 @@ type ProviderCostStats = { model: string | null; }; -function readCostRecord(value: unknown): Record | null { - return value && typeof value === "object" ? (value as Record) : null; -} - function readCostValue( cost: Record | null, key: "input" | "output" | "cacheRead" | "cacheWrite", diff --git a/ui/src/pages/chat/steered-chip.ts b/ui/src/pages/chat/steered-chip.ts index 26eb87e6b3ac..02b615ad9218 100644 --- a/ui/src/pages/chat/steered-chip.ts +++ b/ui/src/pages/chat/steered-chip.ts @@ -1,3 +1,4 @@ +import { hasNonEmptyString as hasString } from "@openclaw/normalization-core/string-coerce"; import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; type SteerState = { sendState: "steering" } | { sendState?: undefined; pendingRunId: string }; @@ -6,10 +7,6 @@ type SteeredChip = ChatQueueItem & { kind: "steered"; sendRunId: string } & Stee type InflightSteerChip = SteeredChip & { sendState: "steering" }; type AckedSteeredChip = SteeredChip & { sendState?: undefined; pendingRunId: string }; -function hasString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - export function isSteeredQueueItem(item: ChatQueueItem): item is SteeredQueueItem { return item.kind === "steered"; } diff --git a/ui/src/pages/chat/tool-stream.ts b/ui/src/pages/chat/tool-stream.ts index 11b527ca249a..f1c43297e897 100644 --- a/ui/src/pages/chat/tool-stream.ts +++ b/ui/src/pages/chat/tool-stream.ts @@ -1,4 +1,6 @@ // Control UI module implements app tool stream behavior. +import { asNullableObjectRecord as readRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeNullableString as toTrimmedString } from "@openclaw/normalization-core/string-coerce"; import { stripInlineDirectiveTagsForDelivery } from "../../../../src/utils/directive-tags.js"; import type { ExecApprovalRequest } from "../../app/exec-approval.ts"; import type { ChatQueueItem, ChatStreamSegment } from "../../lib/chat/chat-types.ts"; @@ -75,14 +77,6 @@ type ToolStreamHost = { sessions: Pick; }; -function toTrimmedString(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed ? trimmed : null; -} - function resolveModelLabel(provider: unknown, model: unknown): string | null { const modelValue = toTrimmedString(model); if (!modelValue) { @@ -212,10 +206,6 @@ function formatToolOutput(value: unknown): string | null { return `${truncated.text}\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`; } -function readRecord(value: unknown): Record | null { - return value && typeof value === "object" ? (value as Record) : null; -} - function resolveSessionStatusModelOverride(result: unknown): string | null | undefined { const details = readRecord(readRecord(result)?.details); if (!details || details.changedModel !== true) { diff --git a/ui/src/pages/config/memory-schema.ts b/ui/src/pages/config/memory-schema.ts index 531585f3c861..fc22b1be49df 100644 --- a/ui/src/pages/config/memory-schema.ts +++ b/ui/src/pages/config/memory-schema.ts @@ -164,10 +164,6 @@ export function resolveMemoryBackend(configObject: Record): Mem type JsonRecord = Record; -function asJsonRecord(value: unknown): JsonRecord | null { - return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; -} - // One narrowed schema object per (source schema, key set): the config view caches // its schema analysis by object identity, so a fresh clone per render would // re-analyze the whole tree on every update. @@ -178,9 +174,9 @@ const narrowedMemorySchemas = new WeakMap>(); * page can host several tabs over disjoint slices of the same schema section. */ export function narrowMemorySchema(schema: unknown, keys: readonly string[]): unknown { - const root = asJsonRecord(schema); - const memorySchema = asJsonRecord(asJsonRecord(root?.properties)?.memory); - const memoryProperties = asJsonRecord(memorySchema?.properties); + const root = asConfigRecord(schema); + const memorySchema = asConfigRecord(asConfigRecord(root?.properties)?.memory); + const memoryProperties = asConfigRecord(memorySchema?.properties); if (!root || !memorySchema || !memoryProperties) { return schema; } diff --git a/ui/src/pages/config/talk-schema.ts b/ui/src/pages/config/talk-schema.ts index cb0b837b62c6..e2890e5450e1 100644 --- a/ui/src/pages/config/talk-schema.ts +++ b/ui/src/pages/config/talk-schema.ts @@ -1,6 +1,8 @@ // Config-facts module for the curated Talk settings page. No lit imports: like // memory-schema.ts, settings search evaluates these facts from the startup // chunk and must not pull settings UI code in with them. +import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeNullableString as readTrimmedString } from "@openclaw/normalization-core/string-coerce"; /** Normalized model/voice pair from one `talk.realtime.providers.` entry. */ type TalkProviderEntryValues = { @@ -21,20 +23,6 @@ export type TalkRealtimeSelection = { providerEntries: Record; }; -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readTrimmedString(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed ? trimmed : null; -} - /** * Raw talk.realtime picks plus each provider entry's fallback values. Which * entry is effective depends on the catalog's active provider and alias map, diff --git a/ui/src/pages/custodian/event-nudge.ts b/ui/src/pages/custodian/event-nudge.ts index 8985acd2a0e1..6ac39ada2027 100644 --- a/ui/src/pages/custodian/event-nudge.ts +++ b/ui/src/pages/custodian/event-nudge.ts @@ -1,3 +1,4 @@ +import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import { html } from "lit"; import { GatewayRequestError, type GatewayEventFrame } from "../../api/gateway.ts"; import { t } from "../../i18n/index.ts"; @@ -143,12 +144,6 @@ const CHANNEL_AUTH_STATUS_KEYS = [ "userTokenStatus", ] as const; -function asRecord(value: unknown): UnknownRecord | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as UnknownRecord) - : null; -} - function hasUnavailableAuth(account: UnknownRecord): boolean { return CHANNEL_AUTH_STATUS_KEYS.some((key) => account[key] === "configured_unavailable"); } diff --git a/ui/src/pages/custodian/structured-question.ts b/ui/src/pages/custodian/structured-question.ts index 87fe86b46fa8..478f3c9ee19f 100644 --- a/ui/src/pages/custodian/structured-question.ts +++ b/ui/src/pages/custodian/structured-question.ts @@ -1,4 +1,5 @@ import type { SystemAgentChatQuestion } from "@openclaw/gateway-protocol"; +import { normalizeNullableString as nonEmptyString } from "@openclaw/normalization-core/string-coerce"; export type CustodianStructuredQuestion = { id: string; @@ -9,10 +10,6 @@ export type CustodianStructuredQuestion = { skipAction?: "exit"; }; -function nonEmptyString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - /** * Sanitize the typed `question` field from `openclaw.chat`. The gateway owns * the schema, but this state renders buttons that send messages, so the page diff --git a/ui/src/pages/new-session/cloud-recovery.ts b/ui/src/pages/new-session/cloud-recovery.ts index c876d6e2df78..9df270aa8f3d 100644 --- a/ui/src/pages/new-session/cloud-recovery.ts +++ b/ui/src/pages/new-session/cloud-recovery.ts @@ -1,3 +1,4 @@ +import { hasNonEmptyString as isNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import type { SessionCreateParams } from "../../lib/sessions/create.ts"; export type CloudSessionCreateParams = SessionCreateParams & { @@ -28,10 +29,6 @@ function storageKey(gatewayUrl: string, recoveryScope: string): string { return `${STORAGE_PREFIX}${gatewayUrl}:${recoveryScope}`; } -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - const CLOUD_CREATE_STRING_FIELDS = [ "model", "thinkingLevel",