diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6f149d56508..8544c4ef54e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -619,6 +619,11 @@ jobs: runtime: "node", task: "max-lines-ratchet", }, + { + check_name: "checks-fast-coercion-helpers", + runtime: "node", + task: "coercion-helpers", + }, ] : []; if (runNodeFull) { @@ -1759,6 +1764,9 @@ jobs: ci-routing) pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope*.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-changed-node-test-plan.test.ts test/scripts/ci-run-node-test-shard.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts ;; + coercion-helpers) + pnpm check:coercion-helpers + ;; max-lines-ratchet) if ! has_package_script "check:max-lines-ratchet"; then echo "Current CI targets must provide check:max-lines-ratchet." >&2 diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 126fa664c34d..71c7c61dbc1a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"6b0a039cf6246df1ec52695441900203343ddc3862ef0c9c100f08cab004bfe7","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"f0501615d366cab1ceffabfcbb580b9a9f1008bbd4e24a46e1fc10d73e9cd2a3","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index d3d3ae4bf5b5..e3fc3b4c1b86 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"817cf688ac6ab0bcde36b59ba9d20e6511fdc82f58f727557cd33097c49b1940","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"ae5e7178a886a346e7a7d88e80344b84c84679e156d08eb863c8a5e2bbffe03c","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index 3dff70f5ecb4..5e5890b10ebb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"f38505150fe39946aa11b2963190d3c4838783086be6c76bbca9194447afaa63","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"2f175fa56f7836b6dc666ef556b964ecfa83f6f81f683340907c7456c8f301ea","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 4ecf74aad9e7..4bbb9985508a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"3d6ce8758b33237aad8417ec2731c2677cabc092f3b71671b4df28663f21c2f7","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"9360377bb569d5f8f53151152e4b1e27e860ebc38d176ee3d71cbf3461c79ca8","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index af0b9b27dd08..d1e6b9de9651 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"c7f13d09cb45ae35ce5855ed7576905ca2ab4cca11b589647e8bbf84ce8d8ded","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"5e756ed6e46b362058d9ca438cf858c463bb01fd55269b0b019341fe0075bcda","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index 8204b599222d..108063a33236 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"d4b4c9c5f145aa60c35f928f090f74959c6c3f1745fe164bb8146b6d341e8d88","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"3523cddf2dad5146687de9564879903e20224b140f4a9756ba57e76034c6490a","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 4021685b18c2..65c7d2254a98 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"d2e3782705a71a08616f0c59ac1be8d8e03b1ec004c604bcc80a17567613dd71","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"5091fb6b03b3e1b3cb6b51323cb7bd991f8022d1aac479b4a67982993dec79cf","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 7ae0b4238c81..6f371bc230e3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"7a6122d34d21e433c7a1a0996879c0964b3a22f508a793a53ffe6404ddf4b5ef","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"33c41deadb29159ecaf9e40c0053d43a81531ca48145f11217f811e4232c5cf2","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 8d04a7ccde64..4e8c702958aa 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"f4a7174aefe70f8bf04bfb293ca92ab9f0f99b6073a8149eee45ce902b99b5ee","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"c6e8524442d54875e1ce5ec211394472c2e2734fbfbebe320b069346374b922d","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 525c19dbdc99..9d2cb97284e4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"c5756c96150425246ed16313fdfc11e6aa780634018a57e920fa5d7d43fcd469","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"1edb0e67f51c958fff1d3ace6e47debb831555720208ebfb732cf65de2bb333a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 10ba4c612a46..4f6f6c131724 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"d01d8e0dd8da33679bf50faa64244e5c2c95f2c133ffc73f587e0b43176c43dd","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"243be23ce58934cc502148cde9b747c4e8d26d176526e99dda383f16f20fc124","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 1c7a55a32c70..9a9a3c0975ba 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"be64b90e0781977ab81a78be2238569ea7d51555b8784a2be600cac60f8422e0","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"a27b168ddc12affa72e1d646d36ea8ef65f18f2de7a7397f0f9e816659e54c0e","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 36990d85f135..96516c4eccbe 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"d2fd18514981c62a3f5e2937b8fc42f9679d7cec9c133198754dfacf54bdfd33","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"336c432da880945a8dccc3f6e0c96cc062b4afdd96b73b35382ffedc537762be","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 867be85f536b..a7e51e3b973c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"4474179d9e5549947f7ef57a950be9ad426193c8ce9f23187d5d7c535f3d4af5","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"66b344dc4d56bdb7d3c1ddccb64899a013f98e1dda7f734e6b75de49c41ba0fc","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 3bf619186d9a..241749c84cc8 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"91fce99cea0d4ed66d7971f246ab66e0ec2c139caeb78c89b21a434908e2ca9f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"e7788890d54d5784f4bce552c7143a0083cd1cdd46f126b98d66ad8357327e51","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index bf98f2596ab9..38cba3b93660 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"927ae41bc0eeb5a07685238f4732c0caa639e28e10358555eba1096d22121172","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"4b0295b65533f821a02e3399298424b35c2c75082e4d53d57ea88ff2f84d74ad","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/string-coerce-runtime.json b/docs/.generated/plugin-sdk-api-baseline/string-coerce-runtime.json index fb12b398ad45..cf862ba6de31 100644 --- a/docs/.generated/plugin-sdk-api-baseline/string-coerce-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/string-coerce-runtime.json @@ -1 +1 @@ -{"contentHash":"e7d1347fa4d9bbc2715c95473eada52b6637ff538946e52f3cfd8ef2bf4cdc7c","entrypoint":"string-coerce-runtime","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime"} +{"contentHash":"14d56ffc88afba3e8c83ace0894f0187860a45b739d562b96e290d2235c45294","entrypoint":"string-coerce-runtime","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index b334dd770a14..ac70d1ff0f26 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"7b9e970e85cb5ac7d407bd5bf70e85b38407ba9d26b66a5bb8d830623cf806d7","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"c489a3d36e88b55b29c9019df733662acb67ec95e2fd0a99d2b208ac123801f5","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 8b1d167e7823..4320c98ae04a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"5cfa7051b82c9d49810529f1522f584f28bc45b184416b8a898017423ebba415","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"73b1700f912dfb34554f7a9e72bca0ff9e1ea08360f32c981519488a25023f2c","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/extensions/acpx/src/pi-session-catalog.ts b/extensions/acpx/src/pi-session-catalog.ts index f306a3bcd5a8..c2db9069a635 100644 --- a/extensions/acpx/src/pi-session-catalog.ts +++ b/extensions/acpx/src/pi-session-catalog.ts @@ -4,7 +4,10 @@ import type { SessionCatalogTranscriptItem, SessionsCatalogReadResult, } from "openclaw/plugin-sdk/session-catalog"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeBoundedOptionalString as optionalPiString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { listPiSummaryPage, readPiSessionById } from "./pi-session-store.js"; import { parsePiSessionTimestampMs } from "./pi-session-timestamp.js"; @@ -20,14 +23,6 @@ const SESSION_ID_PATTERN = /^(?!-)[A-Za-z0-9._:-]{1,256}$/u; export type PiSessionPage = { sessions: SessionCatalogSession[]; nextCursor?: string }; -function optionalPiString(value: unknown, maxLength: number): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed && trimmed.length <= maxLength ? trimmed : undefined; -} - function boundedLimit(value: unknown, fallback = DEFAULT_PAGE_LIMIT): number { if (value === undefined) { return fallback; diff --git a/extensions/acpx/src/pi-session-paths.ts b/extensions/acpx/src/pi-session-paths.ts index 1ef8e2357129..d09b582052fb 100644 --- a/extensions/acpx/src/pi-session-paths.ts +++ b/extensions/acpx/src/pi-session-paths.ts @@ -1,15 +1,10 @@ import { readFileSync, statSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; - -function readBoundedString(value: unknown, maxLength: number): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed && trimmed.length <= maxLength ? trimmed : undefined; -} +import { + isRecord, + normalizeBoundedOptionalString as readBoundedString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; function piHome(env: NodeJS.ProcessEnv): string { const configured = process.platform === "win32" ? env.USERPROFILE?.trim() : env.HOME?.trim(); diff --git a/extensions/acpx/src/pi-session-store.ts b/extensions/acpx/src/pi-session-store.ts index 7bad3921f03d..43390b66c335 100644 --- a/extensions/acpx/src/pi-session-store.ts +++ b/extensions/acpx/src/pi-session-store.ts @@ -2,7 +2,10 @@ import { createReadStream } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { SessionCatalogSession } from "openclaw/plugin-sdk/session-catalog"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeBoundedOptionalString as readBoundedString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { piAcpSessionStoreRoot, piSessionStore } from "./pi-session-paths.js"; import { parsePiSessionTimestampMs } from "./pi-session-timestamp.js"; @@ -85,14 +88,6 @@ function cacheSummary(file: string, value: CachedSummary): void { } } -function readBoundedString(value: unknown, maxLength: number): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed && trimmed.length <= maxLength ? trimmed : undefined; -} - async function discoverPiSessionFiles( env: NodeJS.ProcessEnv, ): Promise<{ root: string; files: string[] }> { diff --git a/extensions/acpx/src/pi-session-timestamp.ts b/extensions/acpx/src/pi-session-timestamp.ts index b25b98ec5cdb..9e1358bb5cf0 100644 --- a/extensions/acpx/src/pi-session-timestamp.ts +++ b/extensions/acpx/src/pi-session-timestamp.ts @@ -1,10 +1,6 @@ -import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { parseDateFirstTimestampMs } from "openclaw/plugin-sdk/number-runtime"; /** Preserve Pi JSONL's date-first string contract while accepting numeric millisecond values. */ export function parsePiSessionTimestampMs(value: unknown): number | undefined { - if (typeof value === "string") { - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; - } - return asFiniteNumber(value); + return parseDateFirstTimestampMs(value); } diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index f2a47c16ec76..32d42adf840b 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; import { @@ -5882,17 +5883,4 @@ describe("active-memory plugin", () => { }); }); -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/anthropic/session-catalog-transcript.ts b/extensions/anthropic/session-catalog-transcript.ts index fda4b4dcdda9..8cfcea2a2054 100644 --- a/extensions/anthropic/session-catalog-transcript.ts +++ b/extensions/anthropic/session-catalog-transcript.ts @@ -58,7 +58,7 @@ export function collectTranscriptText(value: unknown, fragments: string[]): void export function parseTranscriptLine( line: Buffer, - optionalString: (value: unknown, maxLength?: number) => string | undefined, + optionalString: (value: unknown, maxLength: number) => string | undefined, ): ClaudeTranscriptItem | undefined { let raw: unknown; try { diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index b577840cb325..4d27b07fa84b 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; +import { parseDateFirstTimestampMs } from "openclaw/plugin-sdk/number-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import { withTimeout } from "openclaw/plugin-sdk/security-runtime"; @@ -12,7 +13,10 @@ import type { SessionCatalogPullRequestSummary, SessionCatalogTranscriptItem, } from "openclaw/plugin-sdk/session-catalog"; -import { asFiniteNumber, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeBoundedOptionalString as readBoundedString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { readClaudeDesktopCustomGroups } from "./claude-desktop-groups.js"; import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_MODEL_REF } from "./cli-constants.js"; import { @@ -222,14 +226,6 @@ function cacheCatalogDiscovery(filePath: string, entry: CatalogDiscoveryCacheEnt setBoundedCache(catalogDiscoveryCache, filePath, entry, MAX_CATALOG_DISCOVERY_CACHE_ENTRIES); } -function readBoundedString(value: unknown, maxLength = MAX_STRING_LENGTH): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed && trimmed.length <= maxLength ? trimmed : undefined; -} - function pullRequestState(value: unknown): SessionCatalogPullRequestSummary["state"] | undefined { if (typeof value !== "string") { return undefined; @@ -326,7 +322,7 @@ function isCliEntrypoint(value: unknown): value is string { // Claude's persisted string timestamps are date expressions, including numeric-looking years. // Numeric fields are already millisecond values, so preserve that distinct mixed-input contract. function parseClaudeCatalogTimestampMs(value: unknown): number | undefined { - return typeof value === "string" ? asFiniteNumber(Date.parse(value)) : asFiniteNumber(value); + return parseDateFirstTimestampMs(value); } function isWithin(root: string, candidate: string): boolean { @@ -644,7 +640,7 @@ async function readIndexRecords(context: ClaudeSessionScanContext): Promise<{ records.set(sessionId, { threadId: sessionId, name: summary ?? firstPrompt ?? null, - cwd: readBoundedString(entry.projectPath), + cwd: readBoundedString(entry.projectPath, MAX_STRING_LENGTH), status: "stored", ...(createdAt !== undefined ? { createdAt } : {}), ...(updatedAt !== undefined ? { updatedAt, recencyAt: updatedAt } : {}), @@ -822,7 +818,7 @@ async function discoverCliRecords( records.set(sessionId, { threadId: sessionId, name: aiTitle ?? firstPrompt ?? null, - cwd: readBoundedString(raw.cwd), + cwd: readBoundedString(raw.cwd, MAX_STRING_LENGTH), status: "stored", ...(createdAt !== undefined ? { createdAt } : {}), updatedAt: stat.mtimeMs, @@ -947,7 +943,9 @@ async function scanClaudeSessions( }), name: readBoundedString(metadata.title, 500) ?? existing?.name ?? null, cwd: - readBoundedString(metadata.cwd) ?? readBoundedString(metadata.originCwd) ?? existing?.cwd, + readBoundedString(metadata.cwd, MAX_STRING_LENGTH) ?? + readBoundedString(metadata.originCwd, MAX_STRING_LENGTH) ?? + existing?.cwd, ...(createdAt !== undefined ? { createdAt } : {}), ...(updatedAt !== undefined ? { updatedAt, recencyAt: updatedAt } : {}), ...(customGroup ? { customGroup } : {}), diff --git a/extensions/baseten/models.ts b/extensions/baseten/models.ts index 610a1857dbb1..df2512ddb3d1 100644 --- a/extensions/baseten/models.ts +++ b/extensions/baseten/models.ts @@ -178,10 +178,7 @@ function projectLiveModel( const hasLiveFeatures = Array.isArray(row.supported_features); const features = new Set(readStringArray(row.supported_features)); - const pricing = - row.pricing && typeof row.pricing === "object" && !Array.isArray(row.pricing) - ? (row.pricing as Record) - : {}; + const pricing = asNonArrayRecord(row.pricing); const inputPrice = readPerTokenPrice(pricing.prompt); const outputPrice = readPerTokenPrice(pricing.completion); const cacheReadPrice = readPerTokenPrice(pricing.input_cache_read); @@ -259,3 +256,4 @@ export function resolveBasetenDynamicModel(modelId: string) { compat: buildBasetenModelCompat(id), }; } +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; diff --git a/extensions/baseten/stream.ts b/extensions/baseten/stream.ts index 3ded1f858063..3f317d688eb6 100644 --- a/extensions/baseten/stream.ts +++ b/extensions/baseten/stream.ts @@ -36,15 +36,11 @@ export function createBasetenThinkingWrapper( if (!usesBasetenChatTemplateThinking(model.id)) { return; } - const existing = - payload.chat_template_args && - typeof payload.chat_template_args === "object" && - !Array.isArray(payload.chat_template_args) - ? (payload.chat_template_args as Record) - : {}; + const existing = asNonArrayRecord(payload.chat_template_args); payload.chat_template_args = { ...existing, enable_thinking: isThinkingEnabled(ctx.thinkingLevel), }; }); } +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; diff --git a/extensions/beam/src/types.ts b/extensions/beam/src/types.ts index 71196f3da5d1..b146dd84396a 100644 --- a/extensions/beam/src/types.ts +++ b/extensions/beam/src/types.ts @@ -1,4 +1,7 @@ -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeBoundedOptionalString as readBoundedString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; export const BEAM_HOST_ID = "gateway"; export const BEAM_MAX_BODY_BYTES = 56 * 1024; @@ -47,14 +50,6 @@ function hasOnlyKeys(value: Record, allowed: Set): bool return Object.keys(value).every((key) => allowed.has(key)); } -function readBoundedString(value: unknown, maxLength: number): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed && trimmed.length <= maxLength ? trimmed : undefined; -} - function isIsoTimestamp(value: string): boolean { const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|([+-])(\d{2}):(\d{2}))$/.exec( diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index f6009be15c35..1ea58a69e89b 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -229,13 +229,15 @@ vi.mock("./browser-tool.runtime.js", async () => { const { wrapExternalContent } = await vi.importActual( "./sdk-security-runtime.js", ); - const readStringValue = (value: unknown) => (typeof value === "string" ? value : undefined); + const readRawStringValue = (value: unknown) => (typeof value === "string" ? value : undefined); + const normalizeMockOptionalString = (value: unknown) => + readRawStringValue(value)?.trim() || undefined; const readStringParam = ( params: Record, key: string, opts?: { required?: boolean; label?: string }, ) => { - const value = readStringValue(params[key])?.trim(); + const value = readRawStringValue(params[key])?.trim(); if (value) { return value; } @@ -277,7 +279,7 @@ vi.mock("./browser-tool.runtime.js", async () => { details: result, }), listNodes: nodesUtilsMocks.listNodes, - normalizeOptionalString: (value: unknown) => readStringValue(value)?.trim() || undefined, + normalizeOptionalString: normalizeMockOptionalString, persistBrowserProxyFiles: vi.fn(async () => new Map()), readPositiveIntegerParam: ( params: Record, @@ -300,7 +302,7 @@ vi.mock("./browser-tool.runtime.js", async () => { return value; }, readStringParam, - readStringValue, + readStringValue: readRawStringValue, resolveExistingUploadPaths: pathValidationMocks.resolveExistingUploadPaths, resolveNodeIdFromList: (nodes: Array>, requested: string) => { const node = nodes.find( diff --git a/extensions/browser/src/browser/routes/utils.ts b/extensions/browser/src/browser/routes/utils.ts index 5f0a1e54252d..1dca24afc2fc 100644 --- a/extensions/browser/src/browser/routes/utils.ts +++ b/extensions/browser/src/browser/routes/utils.ts @@ -4,6 +4,7 @@ * Profile lookup, JSON errors, and route value coercion shared across browser * control endpoints. */ +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { BrowserProfileUnavailableError, type BrowserErrorResponse } from "../errors.js"; import { type BrowserRouteContext, @@ -13,10 +14,6 @@ import { import { isProfileRestartRequiredError } from "../server-context.lifecycle.js"; import type { BrowserRequest, BrowserResponse } from "./types.js"; -function normalizeOptionalString(value: string): string | undefined { - return value.trim() || undefined; -} - /** * Extract profile name from query string or body and get profile context. * Query string takes precedence over body for consistency with GET routes. diff --git a/extensions/browser/src/sdk-config.ts b/extensions/browser/src/sdk-config.ts index 415a80baddf6..b530bfd0bf09 100644 --- a/extensions/browser/src/sdk-config.ts +++ b/extensions/browser/src/sdk-config.ts @@ -1,7 +1,7 @@ /** * Browser-local SDK config bridge plus Browser-specific default port helpers. */ -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { parseBooleanValue } from "openclaw/plugin-sdk/string-coerce-runtime"; export { getRuntimeConfig, @@ -64,41 +64,5 @@ export function deriveDefaultBrowserCdpPortRange(browserControlPort: number): Po }; } -type BooleanParseOptions = { - truthy?: string[]; - falsy?: string[]; -}; - -const DEFAULT_TRUTHY = ["true", "1", "yes", "on"] as const; -const DEFAULT_FALSY = ["false", "0", "no", "off"] as const; - -function matchesBooleanToken(value: string, tokens: readonly string[]): boolean { - return tokens.includes(value); -} - /** Parses common string booleans with optional custom truthy/falsy tokens. */ -export function parseBooleanValue( - value: unknown, - options: BooleanParseOptions = {}, -): boolean | undefined { - if (typeof value === "boolean") { - return value; - } - if (typeof value !== "string") { - return undefined; - } - const normalized = normalizeOptionalLowercaseString(value); - if (!normalized) { - return undefined; - } - const candidates: Array<[boolean, readonly string[]]> = [ - [true, options.truthy ?? DEFAULT_TRUTHY], - [false, options.falsy ?? DEFAULT_FALSY], - ]; - for (const [parsed, tokens] of candidates) { - if (matchesBooleanToken(normalized, tokens)) { - return parsed; - } - } - return undefined; -} +export { parseBooleanValue }; diff --git a/extensions/canvas/index.ts b/extensions/canvas/index.ts index fa05c4684636..bb80864874cb 100644 --- a/extensions/canvas/index.ts +++ b/extensions/canvas/index.ts @@ -117,10 +117,7 @@ export default definePluginEntry({ defaultPlatforms: ["ios", "android", "macos", "windows", "linux", "unknown"], foregroundRestrictedOnIos: true, handle: async (ctx) => { - const params = - ctx.params && typeof ctx.params === "object" && !Array.isArray(ctx.params) - ? (ctx.params as Record) - : {}; + const params = asNonArrayRecord(ctx.params); // Native nodes also accept JSONL under `push` when messages[] is absent. // Validate that fallback here so callers cannot bypass the JSONL policy. const usesJsonl = @@ -168,3 +165,4 @@ export default definePluginEntry({ ); }, }); +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; diff --git a/extensions/codex/src/app-server/config-utils.ts b/extensions/codex/src/app-server/config-utils.ts index 3f5cc54fb837..a0313586d16f 100644 --- a/extensions/codex/src/app-server/config-utils.ts +++ b/extensions/codex/src/app-server/config-utils.ts @@ -1,7 +1,10 @@ import { createHmac, randomBytes } from "node:crypto"; import { resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; -import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeTrimmedStringList, + parseBooleanValue, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawExecAsk, OpenClawExecSecurity } from "./config-contracts.js"; import type { CodexServiceTier } from "./protocol.js"; @@ -73,17 +76,7 @@ export function normalizeStringList(value: unknown): string[] { } export function readBooleanEnv(value: string | undefined): boolean | undefined { - if (value === undefined) { - return undefined; - } - const normalized = value.trim().toLowerCase(); - if (["1", "true", "yes", "on"].includes(normalized)) { - return true; - } - if (["0", "false", "no", "off"].includes(normalized)) { - return false; - } - return undefined; + return parseBooleanValue(value); } export function readExecSecurity(value: unknown): OpenClawExecSecurity | undefined { diff --git a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts index 9c4138904636..ec67596cb516 100644 --- a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts +++ b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts @@ -9,6 +9,7 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { openFileBackedSessionManagerForTest } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { initializeGlobalHookRunner } from "openclaw/plugin-sdk/hook-runtime"; import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints"; import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime"; @@ -1961,17 +1962,4 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { }); }); -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/codex/src/app-server/thread-archive-guard.ts b/extensions/codex/src/app-server/thread-archive-guard.ts index a4a1cd534a4d..177e613b248b 100644 --- a/extensions/codex/src/app-server/thread-archive-guard.ts +++ b/extensions/codex/src/app-server/thread-archive-guard.ts @@ -1,3 +1,4 @@ +import { normalizeBoundedOptionalString as readBoundedId } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isJsonObject, type CodexThreadListParams } from "./protocol.js"; import type { CodexAppServerBindingStore } from "./session-binding.js"; @@ -6,14 +7,6 @@ const MAX_DESCENDANT_PAGES = 100; const MAX_THREAD_ID_LENGTH = 256; const MAX_CURSOR_LENGTH = 4096; -function readBoundedId(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim(); - return normalized && normalized.length <= MAX_THREAD_ID_LENGTH ? normalized : undefined; -} - function readNextCursor(value: unknown): string | undefined { if (value === undefined || value === null) { return undefined; @@ -34,7 +27,7 @@ export async function assertCodexArchiveDescendantsUnowned(params: { listPage: (request: CodexThreadListParams) => Promise; assertDescendantIdle: (threadId: string) => Promise; }): Promise { - const ancestorThreadId = readBoundedId(params.threadId); + const ancestorThreadId = readBoundedId(params.threadId, MAX_THREAD_ID_LENGTH); if (!ancestorThreadId) { throw new Error("cannot verify Codex archive descendants for an invalid thread id"); } @@ -64,7 +57,7 @@ export async function assertCodexArchiveDescendantsUnowned(params: { if (!isJsonObject(value)) { throw new Error("Codex app-server returned an invalid descendant thread"); } - const descendantThreadId = readBoundedId(value.id); + const descendantThreadId = readBoundedId(value.id, MAX_THREAD_ID_LENGTH); if (!descendantThreadId) { throw new Error("Codex app-server returned a descendant without a valid thread id"); } diff --git a/extensions/codex/src/app-server/web-search.ts b/extensions/codex/src/app-server/web-search.ts index 9745e8065270..d801657b960e 100644 --- a/extensions/codex/src/app-server/web-search.ts +++ b/extensions/codex/src/app-server/web-search.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { JsonObject } from "./protocol.js"; export type CodexWebSearchPlan = { @@ -14,10 +15,6 @@ const CODEX_NATIVE_WEB_SEARCH_DISABLED_CONFIG: JsonObject = { web_search: "disabled", }; -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" ? value.trim() || undefined : undefined; -} - function normalizeUniqueStrings(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; diff --git a/extensions/copilot/src/attempt-config.ts b/extensions/copilot/src/attempt-config.ts index 2592271a2f87..a7540b56b3e2 100644 --- a/extensions/copilot/src/attempt-config.ts +++ b/extensions/copilot/src/attempt-config.ts @@ -6,6 +6,7 @@ import { resolveUserPath, TRANSCRIPT_CREDENTIAL_SAFETY_PROMPT, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readNonEmptyStringPreservingWhitespace as readNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { COPILOT_ASK_USER_AVAILABLE_TOOLS, COPILOT_SETTLED_FINALIZATION_SYSTEM_MESSAGE, @@ -334,9 +335,7 @@ export function createSystemMessageContent( export function isRawCopilotModelRun(params: AttemptParamsLike): boolean { return params.modelRun === true || params.promptMode === "none"; } -export function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} +export { readNonEmptyString }; export function readResolvedAttemptPath(value: unknown): string | undefined { const raw = readNonEmptyString(value)?.trim(); if (!raw) { diff --git a/extensions/copilot/src/attempt.test.ts b/extensions/copilot/src/attempt.test.ts index 5ece5432594e..4c7d8fe16e99 100644 --- a/extensions/copilot/src/attempt.test.ts +++ b/extensions/copilot/src/attempt.test.ts @@ -14,6 +14,7 @@ import { type AgentMessage, } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; import { initializeGlobalHookRunner, @@ -4903,17 +4904,4 @@ describe("runCopilotAttempt", () => { }); }); -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/copilot/src/auth-bridge.ts b/extensions/copilot/src/auth-bridge.ts index b111e126de9b..176c6065dbcd 100755 --- a/extensions/copilot/src/auth-bridge.ts +++ b/extensions/copilot/src/auth-bridge.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { homedir as osHomedir } from "node:os"; import { join, resolve } from "node:path"; +import { readNonEmptyStringPreservingWhitespace as readNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; /** * Pure functional auth resolver for the copilot agent runtime. @@ -331,7 +332,3 @@ export function tokenFingerprint(token: string): string { const hex = createHash("sha256").update(token).digest("hex").slice(0, 12); return `sha256:${hex}`; } - -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} diff --git a/extensions/copilot/src/event-bridge-transcript.ts b/extensions/copilot/src/event-bridge-transcript.ts index ffd0cc5d2158..1c161c72e9c9 100644 --- a/extensions/copilot/src/event-bridge-transcript.ts +++ b/extensions/copilot/src/event-bridge-transcript.ts @@ -1,6 +1,8 @@ import type { Attachment, SessionEvent } from "@github/copilot-sdk"; import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; import { sanitizeToolResult } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { parseDateStringTimestampMs } from "openclaw/plugin-sdk/number-runtime"; +import { readNonEmptyStringPreservingWhitespace as readNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildCopilotAssistantUsage, type CopilotUsageSnapshot } from "./usage-bridge.js"; export type AssistantMessage = Extract; @@ -83,8 +85,7 @@ export function resolveAssistantUsage( } export function resolveEventTimestamp(timestamp: string, now: () => number): number { - const parsed = Date.parse(timestamp); - return Number.isFinite(parsed) ? parsed : now(); + return parseDateStringTimestampMs(timestamp) ?? now(); } export function hasOwnKeys(value: unknown): value is Record { @@ -161,7 +162,3 @@ export function sanitizeToolDetailText(text: string): string { const value = sanitized.content?.[0]?.text; return typeof value === "string" ? value : ""; } - -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} diff --git a/extensions/copilot/src/event-bridge.ts b/extensions/copilot/src/event-bridge.ts index 732973eea72b..0118102e20ad 100644 --- a/extensions/copilot/src/event-bridge.ts +++ b/extensions/copilot/src/event-bridge.ts @@ -5,6 +5,7 @@ import type { AgentMessage, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { toErrorObject } from "openclaw/plugin-sdk/error-runtime"; +import { readNonEmptyStringPreservingWhitespace as readNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildAssistantMessage, hasOwnKeys, @@ -969,10 +970,6 @@ function splitPlanText(text: string | undefined): string[] { .filter((line) => line.length > 0); } -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - function registerListener( session: SessionLike, unsubscribeFns: Array<() => void>, diff --git a/extensions/copilot/src/runtime.test.ts b/extensions/copilot/src/runtime.test.ts index 10e153f3e2d2..695fe2fe4821 100644 --- a/extensions/copilot/src/runtime.test.ts +++ b/extensions/copilot/src/runtime.test.ts @@ -1,6 +1,7 @@ // Copilot tests cover runtime plugin behavior. import { normalize, resolve, sep } from "node:path"; import type { CopilotClient, CopilotClientOptions } from "@github/copilot-sdk"; +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ClientCreateOptions, PoolKey } from "./runtime.js"; @@ -483,17 +484,3 @@ describe("createCopilotClientPool", () => { expect(String(sdk.ctorCalls[0]?.baseDirectory)).toBe(normalizedHome); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/extensions/copilot/src/workspace-bootstrap.ts b/extensions/copilot/src/workspace-bootstrap.ts index 4c92730e1ac7..fe3b582e290c 100644 --- a/extensions/copilot/src/workspace-bootstrap.ts +++ b/extensions/copilot/src/workspace-bootstrap.ts @@ -8,6 +8,7 @@ import { resolveBootstrapContextForRun, resolveUserPath, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { hasNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; // Filenames the Copilot SDK already loads natively from the working // directory / instructionDirectories (per @@ -235,7 +236,7 @@ function getCopilotContextFileBasename(filePath: string): string { } function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; + return hasNonEmptyString(value) ? value : undefined; } function readResolvedWorkspacePath(value: unknown): string | undefined { diff --git a/extensions/diagnostics-otel/src/service-exporter.ts b/extensions/diagnostics-otel/src/service-exporter.ts index 4bf3f08c5f23..a1bc2c13441d 100644 --- a/extensions/diagnostics-otel/src/service-exporter.ts +++ b/extensions/diagnostics-otel/src/service-exporter.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import nodePath from "node:path"; import { normalizeDiagnosticValue } from "openclaw/plugin-sdk/diagnostic-runtime"; +import { collectErrorGraphCandidates } from "openclaw/plugin-sdk/error-runtime"; import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime"; import { OTEL_EXPORTER_OTLP_CERTIFICATE_ENV, @@ -199,49 +200,6 @@ export function errorCategory(err: unknown): string { } } -function collectNestedErrorCandidates(err: unknown): unknown[] { - const queue: unknown[] = [err]; - const seen = new Set(); - const candidates: unknown[] = []; - - while (queue.length > 0) { - const current = queue.shift(); - if (current == null || seen.has(current)) { - continue; - } - seen.add(current); - candidates.push(current); - - if (Array.isArray(current)) { - for (const item of current) { - if (item != null && !seen.has(item)) { - queue.push(item); - } - } - continue; - } - if (typeof current !== "object") { - continue; - } - - const record = current as Record; - for (const nested of [record.cause, record.reason, record.original, record.error]) { - if (nested != null && !seen.has(nested)) { - queue.push(nested); - } - } - if (Array.isArray(record.errors)) { - for (const nested of record.errors) { - if (nested != null && !seen.has(nested)) { - queue.push(nested); - } - } - } - } - - return candidates; -} - function readErrorName(err: unknown): string | undefined { if (!err || typeof err !== "object") { return undefined; @@ -259,7 +217,17 @@ export function readErrorCode(err: unknown): string | number | undefined { } export function findOtlpExporterError(reason: unknown): object | undefined { - for (const candidate of collectNestedErrorCandidates(reason)) { + for (const candidate of collectErrorGraphCandidates(reason, (current) => + Array.isArray(current) + ? current + : [ + current.cause, + current.reason, + current.original, + current.error, + ...(Array.isArray(current.errors) ? current.errors : []), + ], + )) { if ( readErrorName(candidate) === "OTLPExporterError" && candidate && diff --git a/extensions/diffs/src/language-hints.ts b/extensions/diffs/src/language-hints.ts index 9a301216d286..4c013a95c860 100644 --- a/extensions/diffs/src/language-hints.ts +++ b/extensions/diffs/src/language-hints.ts @@ -22,19 +22,20 @@ const BASE_LANGUAGE_ALIASES = new Map( ); type DiffPayloadFile = FileContents | FileDiffMetadata; -function normalizeOptionalString(value: unknown): string | undefined { +// The curated viewer bundles this module outside Plugin SDK package resolution. +function normalizeLanguageHint(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; } const normalized = value.trim().toLowerCase(); - return normalized ? normalized : undefined; + return normalized || undefined; } export async function normalizeSupportedLanguageHint( value?: string, options: { languagePackAvailable?: boolean } = {}, ): Promise { - const normalized = normalizeOptionalString(value); + const normalized = normalizeLanguageHint(value); if (!normalized) { return undefined; } diff --git a/extensions/discord/src/monitor/format.ts b/extensions/discord/src/monitor/format.ts index 070d05832884..cec8f5ed5aa3 100644 --- a/extensions/discord/src/monitor/format.ts +++ b/extensions/discord/src/monitor/format.ts @@ -38,9 +38,6 @@ export function formatDiscordUserTag(user: User) { } export function resolveTimestampMs(timestamp?: string | null) { - if (!timestamp) { - return undefined; - } - const parsed = Date.parse(timestamp); - return Number.isNaN(parsed) ? undefined : parsed; + return parseDateStringTimestampMs(timestamp); } +import { parseDateStringTimestampMs } from "openclaw/plugin-sdk/number-runtime"; diff --git a/extensions/discord/src/monitor/provider.startup.test.ts b/extensions/discord/src/monitor/provider.startup.test.ts index c14d0b8d2f82..1604d0c19122 100644 --- a/extensions/discord/src/monitor/provider.startup.test.ts +++ b/extensions/discord/src/monitor/provider.startup.test.ts @@ -33,15 +33,16 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ danger: (value: string) => value, })); -vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => ({ - normalizeOptionalString: (value: string | null | undefined) => { +vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => { + const normalizeMockOptionalString = (value: string | null | undefined) => { if (typeof value !== "string") { return undefined; } const normalized = value.trim(); return normalized.length > 0 ? normalized : undefined; - }, -})); + }; + return { normalizeOptionalString: normalizeMockOptionalString }; +}); vi.mock("../proxy-request-client.js", () => ({ DISCORD_REST_TIMEOUT_MS: 15_000, diff --git a/extensions/feishu/src/card-test-helpers.ts b/extensions/feishu/src/card-test-helpers.ts index 665d347b1243..c690e055ff51 100644 --- a/extensions/feishu/src/card-test-helpers.ts +++ b/extensions/feishu/src/card-test-helpers.ts @@ -1,4 +1,4 @@ -import { asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; // Feishu helper module supports card test helpers behavior. import { expect } from "vitest"; @@ -10,6 +10,10 @@ function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } +function readFeishuObjectRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" ? asRecord(value) : undefined; +} + export function expectFirstSentCardUsesFillWidthOnly(sendCardMock: { mock: { calls: unknown[][] }; }) { @@ -33,17 +37,17 @@ export function expectFirstSentCardUsesFillWidthOnly(sendCardMock: { export function expectSentCardHasP2pAction(sendCardMock: MockCalls) { const hasP2pAction = sendCardMock.mock.calls.some(([arg]) => { - const card = asOptionalObjectRecord(asOptionalObjectRecord(arg)?.card); - const body = asOptionalObjectRecord(card?.body); + const card = readFeishuObjectRecord(readFeishuObjectRecord(arg)?.card); + const body = readFeishuObjectRecord(card?.body); return asArray(body?.elements).some((element) => { - const elementRecord = asOptionalObjectRecord(element); + const elementRecord = readFeishuObjectRecord(element); if (elementRecord?.tag !== "action") { return false; } return asArray(elementRecord.actions).some((action) => { - const actionRecord = asOptionalObjectRecord(action); - const value = asOptionalObjectRecord(actionRecord?.value); - const command = asOptionalObjectRecord(value?.c); + const actionRecord = readFeishuObjectRecord(action); + const value = readFeishuObjectRecord(actionRecord?.value); + const command = readFeishuObjectRecord(value?.c); return command?.t === "p2p"; }); }); diff --git a/extensions/file-transfer/src/tools/file-write-tool.ts b/extensions/file-transfer/src/tools/file-write-tool.ts index fda64062e848..1bde8b41ee8b 100644 --- a/extensions/file-transfer/src/tools/file-write-tool.ts +++ b/extensions/file-transfer/src/tools/file-write-tool.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness-runtime"; import { readMediaBuffer } from "openclaw/plugin-sdk/media-store"; -import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asBoolean, asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { appendFileTransferAudit } from "../shared/audit.js"; import { inspectStrictBase64 } from "../shared/base64.js"; import { humanSize } from "../shared/params.js"; @@ -66,10 +66,7 @@ export function createFileWriteTool(): AnyAgentTool { return { ...FILE_WRITE_TOOL_DESCRIPTOR, async execute(_toolCallId, params) { - const raw: Record = - params && typeof params === "object" && !Array.isArray(params) - ? (params as Record) - : {}; + const raw = asNonArrayRecord(params); const { node: nodeQuery, requestedPath: filePath } = readRequiredNodePath(raw); const contentBase64 = typeof raw.contentBase64 === "string" ? raw.contentBase64 : undefined; diff --git a/extensions/google-meet/src/meet-url.ts b/extensions/google-meet/src/meet-url.ts index 3899e1c9c0c9..6ae1ef54fd3f 100644 --- a/extensions/google-meet/src/meet-url.ts +++ b/extensions/google-meet/src/meet-url.ts @@ -1,12 +1,5 @@ // Google Meet plugin module implements shared Meet URL contracts. - -function normalizeOptionalString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed || undefined; -} +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; export function normalizeMeetUrl(input: unknown): string { const raw = normalizeOptionalString(input); diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index 501b6ac4d027..8df8726e00e7 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -87,14 +87,6 @@ async function attachDocumentText { }); }, 120_000); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index e2705a84ed02..4b89dbfe9002 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { gzipSync } from "node:zlib"; import { expectDefined } from "@openclaw/normalization-core"; +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import type { Model } from "openclaw/plugin-sdk/llm"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resetGoogleVertexAdcState } from "./google-oauth.test-support.js"; @@ -2894,17 +2895,4 @@ describe("google transport stream", () => { }); }); -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index a59cc2162fa1..4e041923099b 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -67,14 +67,6 @@ function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | return undefined; } -function resolveGoogleChatTimestampMs(eventTime?: string): number | undefined { - if (!eventTime) { - return undefined; - } - const parsed = Date.parse(eventTime); - return Number.isFinite(parsed) ? parsed : undefined; -} - function resolveGoogleChatBotLoopProtection(params: { allowBots: boolean; isBotSender: boolean; @@ -588,3 +580,4 @@ export function resolveGoogleChatWebhookPath(params: { defaultPath: "/googlechat", }); } +import { parseDateStringTimestampMs as resolveGoogleChatTimestampMs } from "openclaw/plugin-sdk/number-runtime"; diff --git a/extensions/linux-canvas/src/commands.ts b/extensions/linux-canvas/src/commands.ts index 5c86e761e615..7e5ef6f90e3b 100644 --- a/extensions/linux-canvas/src/commands.ts +++ b/extensions/linux-canvas/src/commands.ts @@ -57,10 +57,7 @@ function cleanToken(value: unknown, fallback: string): string { } function buildActionMessage(action: unknown, sessionKey?: string): string { - const value = - action && typeof action === "object" && !Array.isArray(action) - ? (action as Record) - : {}; + const value = asNonArrayRecord(action); const actionName = cleanToken(value.name, "unknown"); const surface = cleanToken(value.surfaceId, "main"); const component = cleanToken(value.sourceComponentId, "unknown"); @@ -188,3 +185,4 @@ export function createLinuxCanvasCommands( return registration; }); } +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; diff --git a/extensions/linux-node/src/command-utils.ts b/extensions/linux-node/src/command-utils.ts index de4d519a1de9..e73b0e789a46 100644 --- a/extensions/linux-node/src/command-utils.ts +++ b/extensions/linux-node/src/command-utils.ts @@ -1,5 +1,6 @@ import type { OpenClawPluginNodeHostCommandAvailabilityContext } from "openclaw/plugin-sdk/plugin-entry"; import type { CommandOptions, SpawnResult } from "openclaw/plugin-sdk/process-runtime"; +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { resolveLinuxNodePluginConfigFromHost, @@ -14,9 +15,7 @@ export function parseParams(paramsJSON: string | null | undefined): Record) - : {}; + return asNonArrayRecord(parsed); } catch { return {}; } diff --git a/extensions/llama-cpp/src/embedding-provider.ts b/extensions/llama-cpp/src/embedding-provider.ts index 537175e140b3..96b4b761e550 100644 --- a/extensions/llama-cpp/src/embedding-provider.ts +++ b/extensions/llama-cpp/src/embedding-provider.ts @@ -14,6 +14,7 @@ import { type MemoryEmbeddingProviderCreateOptions, type MemoryEmbeddingProviderCreateResult, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatLlamaCppSetupError, resolveNodeLlamaCppImportUrl } from "./node-llama.runtime.js"; type LlamaCppLocalOptions = { @@ -42,10 +43,6 @@ type LlamaCppModelIdentity = { }>; }; -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readLocalOptions(options: { local?: unknown }): LlamaCppLocalOptions { const local = options.local as LlamaCppLocalOptions | undefined; return local ?? {}; diff --git a/extensions/llama-cpp/src/inference-provider.ts b/extensions/llama-cpp/src/inference-provider.ts index 22096b9e5559..1399706d2aa8 100644 --- a/extensions/llama-cpp/src/inference-provider.ts +++ b/extensions/llama-cpp/src/inference-provider.ts @@ -82,9 +82,7 @@ function extractText(content: unknown): string { } function normalizeArguments(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; + return asNonArrayRecord(value); } async function resolveLlamaCppResponseGrammar(params: { @@ -710,3 +708,4 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") { mapToolsToLlamaFunctions, }); } +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; diff --git a/extensions/lobster/src/lobster-runner.test.ts b/extensions/lobster/src/lobster-runner.test.ts index c44fe38ef4d4..e17f5a953d93 100644 --- a/extensions/lobster/src/lobster-runner.test.ts +++ b/extensions/lobster/src/lobster-runner.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; // Lobster tests cover lobster runner plugin behavior. +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createEmbeddedLobsterRunner, resolveLobsterCwd } from "./lobster-runner.js"; @@ -584,17 +585,3 @@ describe("createEmbeddedLobsterRunner", () => { ).rejects.toThrow(/timed out|aborted/); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/extensions/lobster/src/lobster-runner.ts b/extensions/lobster/src/lobster-runner.ts index 15a4808927ec..776c62e82f3d 100644 --- a/extensions/lobster/src/lobster-runner.ts +++ b/extensions/lobster/src/lobster-runner.ts @@ -2,6 +2,7 @@ import { stat } from "node:fs/promises"; import path from "node:path"; import { Readable, Writable } from "node:stream"; +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; export type LobsterEnvelope = | { @@ -281,17 +282,3 @@ export function createEmbeddedLobsterRunner(options?: { }, }; } - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/extensions/matrix/src/matrix/sdk/client-verification.ts b/extensions/matrix/src/matrix/sdk/client-verification.ts index 8853244bcbdc..2ab5a89a8241 100644 --- a/extensions/matrix/src/matrix/sdk/client-verification.ts +++ b/extensions/matrix/src/matrix/sdk/client-verification.ts @@ -17,7 +17,7 @@ import { LogService } from "./logger.js"; import { isRepairableSecretStorageAccessError } from "./recovery-key-store.js"; import type { MatrixCryptoBootstrapApi, MatrixDeviceVerificationStatusLike } from "./types.js"; -const normalizeOptionalString = normalizeNullableString; +const normalizeNullableVerificationString = normalizeNullableString; export abstract class MatrixClientVerification extends MatrixClientCore { async getRoomKeyBackupStatus(): Promise { @@ -218,7 +218,7 @@ export abstract class MatrixClientVerification extends MatrixClientCore { return null; } const version = await crypto.getActiveSessionBackupVersion().catch(() => null); - return normalizeOptionalString(version); + return normalizeNullableVerificationString(version); } protected async resolveCachedRoomKeyBackupDecryptionKey( @@ -275,7 +275,7 @@ export abstract class MatrixClientVerification extends MatrixClientCore { let matchesDecryptionKey: boolean | null = null; if (typeof crypto.getKeyBackupInfo === "function") { const info = await crypto.getKeyBackupInfo().catch(() => null); - serverVersion = normalizeOptionalString(info?.version) ?? serverVersion; + serverVersion = normalizeNullableVerificationString(info?.version) ?? serverVersion; if (info && typeof crypto.isKeyBackupTrusted === "function") { const trustInfo = await crypto.isKeyBackupTrusted(info).catch(() => null); trusted = typeof trustInfo?.trusted === "boolean" ? trustInfo.trusted : null; @@ -304,7 +304,7 @@ export abstract class MatrixClientVerification extends MatrixClientCore { const response = (await this.doRequest("GET", "/_matrix/client/v3/room_keys/version")) as { version?: string; }; - return normalizeOptionalString(response.version); + return normalizeNullableVerificationString(response.version); } catch { return null; } diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index 71ab707e044b..09fd181d9bff 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -1,5 +1,6 @@ // Mattermost plugin module implements client behavior. import { createChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound"; +import { collectErrorGraphCandidates } from "openclaw/plugin-sdk/error-runtime"; import { buildTimeoutAbortSignal } from "openclaw/plugin-sdk/extension-shared"; import { responseWithRelease } from "openclaw/plugin-sdk/fetch-runtime"; import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; @@ -513,7 +514,11 @@ export async function createMattermostDirectChannelWithRetry( } function isRetryableError(error: Error): boolean { - const candidates = collectErrorCandidates(error); + const candidates = collectErrorGraphCandidates(error, (current) => [ + current.cause, + current.reason, + ...(Array.isArray(current.errors) ? current.errors : []), + ]); const messages = candidates .map((candidate) => normalizeLowercaseStringOrEmpty(readErrorMessage(candidate))) .filter((message): message is string => Boolean(message)); @@ -592,39 +597,6 @@ function isRetryableError(error: Error): boolean { ); } -function collectErrorCandidates(error: unknown): unknown[] { - const queue: unknown[] = [error]; - let queueIndex = 0; - const seen = new Set(); - const candidates: unknown[] = []; - - while (queueIndex < queue.length) { - const current = queue[queueIndex]; - queueIndex += 1; - if (!current || seen.has(current)) { - continue; - } - seen.add(current); - candidates.push(current); - - if (typeof current !== "object") { - continue; - } - - const nested = current as { - cause?: unknown; - reason?: unknown; - errors?: unknown; - }; - queue.push(nested.cause, nested.reason); - if (Array.isArray(nested.errors)) { - queue.push(...nested.errors); - } - } - - return candidates; -} - function readErrorMessage(error: unknown): string | undefined { if (!error || typeof error !== "object") { return undefined; diff --git a/extensions/memory-core/src/migration/doctor-memory-sidecar.ts b/extensions/memory-core/src/migration/doctor-memory-sidecar.ts index e50a2f5fc91d..a4dc410cc003 100644 --- a/extensions/memory-core/src/migration/doctor-memory-sidecar.ts +++ b/extensions/memory-core/src/migration/doctor-memory-sidecar.ts @@ -11,7 +11,6 @@ import { legacyStateFileExists, type PluginDoctorStateMigration, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; -import { asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; // sqlite-runtime re-exports the agent-db/kysely graph; keep it lazy so doctor // enumeration does not cold-load it with this closure. import { @@ -27,12 +26,19 @@ function formatLegacyVectorRows(count: number | undefined): string { type MemoryFtsTokenizer = "unicode61" | "trigram"; +// This doctor closure must stay dependency-light while accepting legacy array-backed objects. +function readLegacyObjectRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" + ? (value as Record) + : undefined; +} + function resolveConfiguredAgentIds(config: unknown): string[] { const cfg = config as { agents?: { entries?: unknown; list?: unknown } }; - const entries = asOptionalObjectRecord(cfg.agents?.entries); + const entries = readLegacyObjectRecord(cfg.agents?.entries); const listedIds = Array.isArray(cfg.agents?.list) ? cfg.agents.list.flatMap((entry) => { - const id = asOptionalObjectRecord(entry)?.id; + const id = readLegacyObjectRecord(entry)?.id; return typeof id === "string" ? [id] : []; }) : []; @@ -44,40 +50,40 @@ function readAgentMemorySearch( config: unknown, agentId: string, ): Record | undefined { - const agents = asOptionalObjectRecord(asOptionalObjectRecord(config)?.agents); - const keyedEntries = asOptionalObjectRecord(agents?.entries); + const agents = readLegacyObjectRecord(readLegacyObjectRecord(config)?.agents); + const keyedEntries = readLegacyObjectRecord(agents?.entries); const keyedEntry = keyedEntries ? Object.entries(keyedEntries).find(([id]) => normalizeAgentId(id) === agentId)?.[1] : undefined; - const keyedSearch = asOptionalObjectRecord( - asOptionalObjectRecord(asOptionalObjectRecord(keyedEntry)?.memory)?.search, + const keyedSearch = readLegacyObjectRecord( + readLegacyObjectRecord(readLegacyObjectRecord(keyedEntry)?.memory)?.search, ); if (keyedSearch) { return keyedSearch; } const entries = Array.isArray(agents?.list) ? agents.list : []; const entry = entries - .map(asOptionalObjectRecord) + .map(readLegacyObjectRecord) .find( (candidate) => normalizeAgentId(typeof candidate?.id === "string" ? candidate.id : undefined) === agentId, ); - return asOptionalObjectRecord(asOptionalObjectRecord(entry?.memory)?.search); + return readLegacyObjectRecord(readLegacyObjectRecord(entry?.memory)?.search); } function readMemorySearchLayers(config: unknown, agentId: string): Record[] { - const cfg = asOptionalObjectRecord(config); + const cfg = readLegacyObjectRecord(config); return [ readAgentMemorySearch(config, agentId), - asOptionalObjectRecord(asOptionalObjectRecord(cfg?.memory)?.search), + readLegacyObjectRecord(readLegacyObjectRecord(cfg?.memory)?.search), // Doctor still inspects the retired root shape to migrate its persisted sidecar path. - asOptionalObjectRecord(cfg?.memorySearch), + readLegacyObjectRecord(cfg?.memorySearch), ].filter((value): value is Record => value !== undefined); } function readStoreLayers(config: unknown, agentId: string): Record[] { return readMemorySearchLayers(config, agentId).flatMap((search) => { - const store = asOptionalObjectRecord(search.store); + const store = readLegacyObjectRecord(search.store); return store ? [store] : []; }); } @@ -92,7 +98,7 @@ function readNestedStoreLayers( key: string, ): Record[] { return readStoreLayers(config, agentId).flatMap((store) => { - const nested = asOptionalObjectRecord(store[key]); + const nested = readLegacyObjectRecord(store[key]); return nested ? [nested] : []; }); } diff --git a/extensions/memory-core/src/short-term-promotion-utils.ts b/extensions/memory-core/src/short-term-promotion-utils.ts index a7289076ddfc..a9eebe0ad3d4 100644 --- a/extensions/memory-core/src/short-term-promotion-utils.ts +++ b/extensions/memory-core/src/short-term-promotion-utils.ts @@ -441,11 +441,7 @@ export function normalizeShortTermRecallStore(raw: unknown, nowIso: string): Sho } export function parseStoreTimestampMs(value: string | undefined): number { - if (!value) { - return Number.NEGATIVE_INFINITY; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY; + return parseDateStringTimestampMs(value) ?? Number.NEGATIVE_INFINITY; } export function compareStoreTimestampDesc( @@ -606,3 +602,4 @@ export function parseEntryRangeFromKey( } return { startLine: 1, endLine: 1 }; } +import { parseDateStringTimestampMs } from "openclaw/plugin-sdk/number-runtime"; diff --git a/extensions/memory-wiki/src/claim-health.ts b/extensions/memory-wiki/src/claim-health.ts index b048ce4b53ee..46ec6457c7a6 100644 --- a/extensions/memory-wiki/src/claim-health.ts +++ b/extensions/memory-wiki/src/claim-health.ts @@ -50,11 +50,7 @@ export type WikiPageContradictionCluster = { }; function parseTimestamp(value?: string): number | null { - if (!value?.trim()) { - return null; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : null; + return parseDateStringTimestampMs(value) ?? null; } function clampDaysSinceTouch(daysSinceTouch: number): number { @@ -251,3 +247,4 @@ export function buildPageContradictionClusters( })) .toSorted((left, right) => left.label.localeCompare(right.label)); } +import { parseDateStringTimestampMs } from "openclaw/plugin-sdk/number-runtime"; diff --git a/extensions/migrate-hermes/auth-source.ts b/extensions/migrate-hermes/auth-source.ts index 216a74141cca..268b4d97e226 100644 --- a/extensions/migrate-hermes/auth-source.ts +++ b/extensions/migrate-hermes/auth-source.ts @@ -1,5 +1,6 @@ // Hermes-native auth discovery and reauthentication planning. import { createMigrationManualItem } from "openclaw/plugin-sdk/migration"; +import { parseDateStringTimestampMs as readTimestamp } from "openclaw/plugin-sdk/number-runtime"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { readText } from "./helpers.js"; @@ -32,14 +33,6 @@ const HERMES_REAUTH_SOURCE_PROVIDERS = new Set( HERMES_REAUTH_PROVIDER_MAPPINGS.map((entry) => entry.sourceProvider), ); -function readTimestamp(value: unknown): number | undefined { - if (typeof value !== "string" || !value.trim()) { - return undefined; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - function readHermesProviderCandidate( auth: Record, sourcePath: string, diff --git a/extensions/migrate-hermes/config-provider-contract.ts b/extensions/migrate-hermes/config-provider-contract.ts index 783599c40553..2cda8a2b0687 100644 --- a/extensions/migrate-hermes/config-provider-contract.ts +++ b/extensions/migrate-hermes/config-provider-contract.ts @@ -1,4 +1,5 @@ // Hermes provider config contract parsing and normalization. +import { asPositiveFiniteNumber as readPositiveNumber } from "openclaw/plugin-sdk/number-runtime"; import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { MCP_ENV_REFERENCE_RE, @@ -163,9 +164,7 @@ export function resolveHermesImplicitBaseUrl(providerId: string | undefined): st : undefined; } -export function readPositiveNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; -} +export { readPositiveNumber }; export function resolveProviderApi( raw: Record, diff --git a/extensions/migrate-hermes/helpers.ts b/extensions/migrate-hermes/helpers.ts index 6a251a332a36..dd3f2e8f8cf3 100644 --- a/extensions/migrate-hermes/helpers.ts +++ b/extensions/migrate-hermes/helpers.ts @@ -10,7 +10,7 @@ import { } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; import { appendRegularFile, pathExists } from "openclaw/plugin-sdk/security-runtime"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { parse as parseYaml } from "yaml"; const HOME_SHORTHAND_RE = /^~(?=$|[\\/])/u; @@ -59,7 +59,7 @@ export function childRecord( key: string, ): Record { const value = root?.[key]; - return isRecord(value) ? value : {}; + return asNonArrayRecord(value); } export function readStringArray(value: unknown): string[] { diff --git a/extensions/moonshot/moonshot.live.test.ts b/extensions/moonshot/moonshot.live.test.ts index 1da2cb4cfaef..3aeb8a566f20 100644 --- a/extensions/moonshot/moonshot.live.test.ts +++ b/extensions/moonshot/moonshot.live.test.ts @@ -1,4 +1,5 @@ // Moonshot tests cover moonshot plugin behavior. +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { streamSimple, type AssistantMessage, @@ -438,17 +439,3 @@ describeModelLive("moonshot K3 live", () => { throw toLintErrorObject(lastAuthError, "Moonshot K3 rejected the API key in both regions"); }, 180_000); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/extensions/msteams/src/attachments/graph.test.ts b/extensions/msteams/src/attachments/graph.test.ts index 1e43d87e4f76..a389e4bcd820 100644 --- a/extensions/msteams/src/attachments/graph.test.ts +++ b/extensions/msteams/src/attachments/graph.test.ts @@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // Mock shared.js to avoid transitive runtime-api imports that pull in uninstalled packages. vi.mock("./shared.js", async (importOriginal) => { const actual = await importOriginal(); + const isMockRecord = (value: unknown) => + typeof value === "object" && value !== null && !Array.isArray(value); return { ...actual, applyAuthorizationHeaderForUrl: vi.fn(), @@ -11,7 +13,7 @@ vi.mock("./shared.js", async (importOriginal) => { resolveMSTeamsMediaKind: vi.fn(({ contentType }: { contentType?: string }) => contentType?.startsWith("image/") ? "image" : "document", ), - isRecord: (v: unknown) => typeof v === "object" && v !== null && !Array.isArray(v), + isRecord: isMockRecord, isUrlAllowed: vi.fn(() => true), normalizeContentType: vi.fn((ct: string | null | undefined) => ct ?? undefined), resolveMediaSsrfPolicy: vi.fn(() => undefined), diff --git a/extensions/msteams/src/conversation-store-helpers.ts b/extensions/msteams/src/conversation-store-helpers.ts index 7063a959118b..ae6adc88c7be 100644 --- a/extensions/msteams/src/conversation-store-helpers.ts +++ b/extensions/msteams/src/conversation-store-helpers.ts @@ -1,4 +1,5 @@ // Msteams helper module supports conversation store helpers behavior. +import { parseDateStringTimestampMs } from "openclaw/plugin-sdk/number-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { MSTeamsConversationStoreEntry, @@ -10,14 +11,7 @@ export function normalizeStoredConversationId(raw: string): string { } export function parseStoredConversationTimestamp(value: string | undefined): number | null { - if (!value) { - return null; - } - const parsed = Date.parse(value); - if (!Number.isFinite(parsed)) { - return null; - } - return parsed; + return parseDateStringTimestampMs(value) ?? null; } export function toConversationStoreEntries( diff --git a/extensions/msteams/src/polls.ts b/extensions/msteams/src/polls.ts index 55d7177cba4d..ed3021acd30f 100644 --- a/extensions/msteams/src/polls.ts +++ b/extensions/msteams/src/polls.ts @@ -262,11 +262,7 @@ function createPollVoteBucketStateStore(params?: MSTeamsPollStoreStateOptions) { } function parseTimestamp(value?: string): number | null { - if (!value) { - return null; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : null; + return parseDateStringTimestampMs(value) ?? null; } function pruneExpired( @@ -477,3 +473,4 @@ export function createMSTeamsPollStoreState( return { createPoll, getPoll, recordVote }; } +import { parseDateStringTimestampMs } from "openclaw/plugin-sdk/number-runtime"; diff --git a/extensions/ollama/src/discovery-shared.ts b/extensions/ollama/src/discovery-shared.ts index 1cc0da453d9b..a5cc56995670 100644 --- a/extensions/ollama/src/discovery-shared.ts +++ b/extensions/ollama/src/discovery-shared.ts @@ -40,7 +40,7 @@ type OllamaDiscoveryContext = { }; }; -function readStringValue(value: unknown): string | undefined { +function readOllamaStringValue(value: unknown): string | undefined { if (typeof value === "string") { return normalizeOptionalString(value); } @@ -247,7 +247,7 @@ function hasMeaningfulExplicitOllamaConfig( if (baseUrl) { return resolveOllamaApiBase(baseUrl) !== OLLAMA_DEFAULT_BASE_URL; } - if (readStringValue(providerConfig.apiKey)) { + if (readOllamaStringValue(providerConfig.apiKey)) { return true; } if (providerConfig.auth) { @@ -308,8 +308,8 @@ export async function resolveOllamaDiscoveryResult(params: { ollamaKey.trim() !== OLLAMA_DEFAULT_API_KEY; const explicitApiKeyRef = coerceSecretRef(explicit?.apiKey); const explicitApiKey = explicitApiKeyRef - ? readStringValue(ollamaDiscoveryKey) - : readStringValue(explicit?.apiKey); + ? readOllamaStringValue(ollamaDiscoveryKey) + : readOllamaStringValue(explicit?.apiKey); // apiKey can be an env-name or managed marker; only discoveryApiKey proves // an explicit SecretRef resolved. Never replace its owner with local auth. if (explicitApiKeyRef && !explicitApiKey) { diff --git a/extensions/onepassword/src/tool.ts b/extensions/onepassword/src/tool.ts index f91155cc75dd..5a2f0d1068c6 100644 --- a/extensions/onepassword/src/tool.ts +++ b/extensions/onepassword/src/tool.ts @@ -70,7 +70,7 @@ export function redactPersistedOnePasswordResult( if (!hasSecretValue) { return undefined; } - const safeDetails = isRecord(details) ? details : {}; + const safeDetails = asNonArrayRecord(details); const persisted = { ok: true, redacted: true, @@ -117,3 +117,4 @@ export function createOnePasswordTool( }, }; } +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; diff --git a/extensions/opencode-go/stream-termination.ts b/extensions/opencode-go/stream-termination.ts index 76af4d49cff1..0f4b0479ee1e 100644 --- a/extensions/opencode-go/stream-termination.ts +++ b/extensions/opencode-go/stream-termination.ts @@ -43,10 +43,6 @@ function isOpencodeGoModel(model: unknown, providerId: string): boolean { : false; } -function validTimeoutMs(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; -} - function resolveTimeoutMs(model: unknown, fallbackMs: number): number { return validTimeoutMs((model as { requestTimeoutMs?: unknown })?.requestTimeoutMs) ?? fallbackMs; } @@ -342,3 +338,4 @@ export function createOpencodeGoStalledStreamWrapper( return output; }; } +import { asPositiveFiniteNumber as validTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; diff --git a/extensions/opencode/session-catalog.ts b/extensions/opencode/session-catalog.ts index d39ab7566a81..f94858d8bffa 100644 --- a/extensions/opencode/session-catalog.ts +++ b/extensions/opencode/session-catalog.ts @@ -5,7 +5,10 @@ import type { SessionCatalogTranscriptItem, SessionsCatalogReadResult, } from "openclaw/plugin-sdk/session-catalog"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeBoundedOptionalString as optionalOpenCodeString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { materializeWindowsSpawnProgram, @@ -93,14 +96,6 @@ type OpenCodeReadParams = { cursor?: string; }; -function optionalOpenCodeString(value: unknown, maxLength: number): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed && trimmed.length <= maxLength ? trimmed : undefined; -} - function boundedLimit(value: unknown, fallback = DEFAULT_PAGE_LIMIT): number { if (value === undefined) { return fallback; diff --git a/extensions/openrouter/stream.ts b/extensions/openrouter/stream.ts index dae7d19407ff..d5201751b425 100644 --- a/extensions/openrouter/stream.ts +++ b/extensions/openrouter/stream.ts @@ -8,7 +8,7 @@ import { normalizeOpenAICompatibleReasoningReplay, } from "openclaw/plugin-sdk/provider-stream-shared"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; -import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asNonArrayRecord, readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isOpenRouterDeepSeekV4ModelId, normalizeOpenRouterModelFamilyId } from "./models.js"; import { isOpenRouterProxyReasoningUnsupportedModel, @@ -243,10 +243,7 @@ function applyOpenRouterDeepSeekV4ReasoningEffort( delete payload.reasoning; return false; } - const reasoning = - payload.reasoning && typeof payload.reasoning === "object" && !Array.isArray(payload.reasoning) - ? (payload.reasoning as Record) - : {}; + const reasoning = asNonArrayRecord(payload.reasoning); reasoning.effort = effort; payload.reasoning = reasoning; return true; diff --git a/extensions/policy/src/policy-state-core.ts b/extensions/policy/src/policy-state-core.ts index 251213ee4caf..518303c7b7b9 100644 --- a/extensions/policy/src/policy-state-core.ts +++ b/extensions/policy/src/policy-state-core.ts @@ -1,6 +1,6 @@ // Policy plugin channel, model, MCP, and network evidence. import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asNonArrayRecord, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { ocPathSegment, readBooleanPath } from "./policy-state-helpers.js"; import { RESERVED_CHANNEL_CONFIG_KEYS } from "./policy-state-types.js"; import type { @@ -128,11 +128,11 @@ export function scanPolicyNetwork(cfg: Record): readonly Policy } export function configuredChannels(cfg: Record): Record { - return isRecord(cfg.channels) ? cfg.channels : {}; + return asNonArrayRecord(cfg.channels); } function configuredMcpServers(cfg: Record): Record { - return isRecord(cfg.mcp) && isRecord(cfg.mcp.servers) ? cfg.mcp.servers : {}; + return asNonArrayRecord(asNonArrayRecord(cfg.mcp).servers); } function mcpServerTransport(value: unknown): PolicyMcpServerEvidence["transport"] { @@ -161,7 +161,7 @@ function redactMcpUrlForEvidence(raw: string): string { } function configuredModelProviders(cfg: Record): Record { - return isRecord(cfg.models) && isRecord(cfg.models.providers) ? cfg.models.providers : {}; + return asNonArrayRecord(asNonArrayRecord(cfg.models).providers); } function networkBooleanEvidence( diff --git a/extensions/policy/src/policy-state-data.ts b/extensions/policy/src/policy-state-data.ts index 4daaadf3596c..e8bb092a8ceb 100644 --- a/extensions/policy/src/policy-state-data.ts +++ b/extensions/policy/src/policy-state-data.ts @@ -1,7 +1,11 @@ // Policy plugin data, secret, and auth evidence. import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input"; -import { isRecord, asBoolean as readBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean as readBoolean, + asNonArrayRecord, + isRecord, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { ocPathSegment } from "./policy-state-helpers.js"; import type { PolicyAuthProfileEvidence, @@ -20,8 +24,8 @@ export function scanPolicySecrets(cfg: Record): readonly Policy export function scanPolicyAuthProfiles( cfg: Record, ): readonly PolicyAuthProfileEvidence[] { - const auth = isRecord(cfg.auth) ? cfg.auth : {}; - const profiles = isRecord(auth.profiles) ? auth.profiles : {}; + const auth = asNonArrayRecord(cfg.auth); + const profiles = asNonArrayRecord(auth.profiles); return Object.entries(profiles) .toSorted(([a], [b]) => a.localeCompare(b)) .map(([id, value]) => { @@ -64,8 +68,8 @@ export function scanPolicyDataHandling( explicit: true, }); - const diagnostics = isRecord(cfg.diagnostics) ? cfg.diagnostics : {}; - const otel = isRecord(diagnostics.otel) ? diagnostics.otel : {}; + const diagnostics = asNonArrayRecord(cfg.diagnostics); + const otel = asNonArrayRecord(diagnostics.otel); const otelEnabled = diagnostics.enabled !== false && otel.enabled === true; const tracesEnabled = otelEnabled && otel.traces !== false; const logsEnabled = otelEnabled && otel.logs === true; @@ -84,8 +88,8 @@ export function scanPolicyDataHandling( explicit: otel.captureContent !== undefined, }); - const session = isRecord(cfg.session) ? cfg.session : {}; - const maintenance = isRecord(session.maintenance) ? session.maintenance : {}; + const session = asNonArrayRecord(cfg.session); + const maintenance = asNonArrayRecord(session.maintenance); const retentionMode = typeof maintenance.mode === "string" ? maintenance.mode : "enforce"; entries.push({ id: "session-maintenance-mode", @@ -130,8 +134,8 @@ function pushMemorySessionTranscriptIndexing( entries: PolicyDataHandlingEvidence[], cfg: Record, ): void { - const memory = isRecord(cfg.memory) ? cfg.memory : {}; - const defaultsMemorySearch = isRecord(memory.search) ? memory.search : {}; + const memory = asNonArrayRecord(cfg.memory); + const defaultsMemorySearch = asNonArrayRecord(memory.search); const defaultSessionMemory = memorySearchSessionTranscriptIndexing(defaultsMemorySearch); if (defaultSessionMemory !== undefined) { const defaultExperimental = isRecord(defaultsMemorySearch.experimental) @@ -151,7 +155,7 @@ function pushMemorySessionTranscriptIndexing( }); } - const agents = isRecord(cfg.agents) ? cfg.agents : {}; + const agents = asNonArrayRecord(cfg.agents); const agentEntries = isRecord(agents.entries) ? Object.entries(agents.entries).map(([entryId, value]) => ({ agentId: entryId, @@ -193,7 +197,7 @@ function pushMemorySessionTranscriptIndexing( return; } const explicit = memorySearchSessionTranscriptIndexingHasLocalConfig(memorySearch); - const experimental = isRecord(memorySearch?.experimental) ? memorySearch.experimental : {}; + const experimental = asNonArrayRecord(memorySearch?.experimental); const pathSegment = container === "list" ? `#${pathId}` : ocPathSegment(pathId); entries.push({ id: `${agentId}-memory-session-transcripts`, @@ -219,10 +223,10 @@ function memorySearchSessionTranscriptIndexing( if (!isRecord(memorySearch)) { return undefined; } - const inherited = isRecord(inheritedMemorySearch) ? inheritedMemorySearch : {}; + const inherited = asNonArrayRecord(inheritedMemorySearch); const enabled = readBoolean(memorySearch.enabled) ?? readBoolean(inherited.enabled) ?? true; - const experimental = isRecord(memorySearch.experimental) ? memorySearch.experimental : {}; - const inheritedExperimental = isRecord(inherited.experimental) ? inherited.experimental : {}; + const experimental = asNonArrayRecord(memorySearch.experimental); + const inheritedExperimental = asNonArrayRecord(inherited.experimental); const rememberAcrossConversations = readBoolean(memorySearch.rememberAcrossConversations) ?? readBoolean(experimental.sessionMemory) ?? @@ -271,8 +275,8 @@ function memorySearchSourcesIncludeSessions(memorySearch: unknown): boolean | un } function scanPolicySecretProviders(cfg: Record): readonly PolicySecretEvidence[] { - const secrets = isRecord(cfg.secrets) ? cfg.secrets : {}; - const providers = isRecord(secrets.providers) ? secrets.providers : {}; + const secrets = asNonArrayRecord(cfg.secrets); + const providers = asNonArrayRecord(secrets.providers); return Object.entries(providers).map(([id, value]) => { const insecure = secretProviderInsecureFlags(value); const entry: { @@ -298,7 +302,7 @@ function scanPolicySecretProviders(cfg: Record): readonly Polic function scanPolicySecretInputs(cfg: Record): readonly PolicySecretEvidence[] { const entries: PolicySecretEvidence[] = []; - const secrets = isRecord(cfg.secrets) ? cfg.secrets : {}; + const secrets = asNonArrayRecord(cfg.secrets); collectSecretInputs(entries, cfg, [], secretRefDefaults(secrets.defaults)); return entries; } diff --git a/extensions/policy/src/policy-state-exec-approvals.ts b/extensions/policy/src/policy-state-exec-approvals.ts index 3343b1cd24c9..5c5aec4ba19f 100644 --- a/extensions/policy/src/policy-state-exec-approvals.ts +++ b/extensions/policy/src/policy-state-exec-approvals.ts @@ -1,5 +1,6 @@ // Policy plugin exec approval evidence. import { + asNonArrayRecord, isRecord, asBoolean as readBoolean, normalizeOptionalString as readString, @@ -21,7 +22,7 @@ export function scanPolicyExecApprovals(raw: string): readonly PolicyExecApprova return []; } const evidence: PolicyExecApprovalEvidence[] = []; - const defaults = isRecord(parsed.defaults) ? parsed.defaults : {}; + const defaults = asNonArrayRecord(parsed.defaults); evidence.push( execApprovalPostureEvidence( "defaults", diff --git a/extensions/policy/src/policy-state-gateway.ts b/extensions/policy/src/policy-state-gateway.ts index e5ffb14f5fbb..d42beddd8f1c 100644 --- a/extensions/policy/src/policy-state-gateway.ts +++ b/extensions/policy/src/policy-state-gateway.ts @@ -1,18 +1,18 @@ // Policy plugin gateway exposure evidence. -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asNonArrayRecord, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { ocPathSegment } from "./policy-state-helpers.js"; import type { PolicyGatewayExposureEvidence } from "./policy-state-types.js"; export function scanPolicyGatewayExposure( cfg: Record, ): readonly PolicyGatewayExposureEvidence[] { - const gateway = isRecord(cfg.gateway) ? cfg.gateway : {}; + const gateway = asNonArrayRecord(cfg.gateway); const entries: PolicyGatewayExposureEvidence[] = []; const bind = typeof gateway.bind === "string" ? gateway.bind : undefined; const customBindHost = typeof gateway.customBindHost === "string" ? gateway.customBindHost : undefined; const hasCustomBindHost = customBindHost !== undefined && customBindHost.trim() !== ""; - const tailscale = isRecord(gateway.tailscale) ? gateway.tailscale : {}; + const tailscale = asNonArrayRecord(gateway.tailscale); const tailscaleForcesLoopback = tailscale.mode === "serve" || tailscale.mode === "funnel"; entries.push({ id: bind === undefined ? "gateway-bind-default" : "gateway-bind", @@ -37,7 +37,7 @@ export function scanPolicyGatewayExposure( }); } - const auth = isRecord(gateway.auth) ? gateway.auth : {}; + const auth = asNonArrayRecord(gateway.auth); entries.push({ id: "gateway-auth-mode", kind: "auth", @@ -53,7 +53,7 @@ export function scanPolicyGatewayExposure( explicit: isRecord(auth.rateLimit), }); - const controlUi = isRecord(gateway.controlUi) ? gateway.controlUi : {}; + const controlUi = asNonArrayRecord(gateway.controlUi); pushGatewayBooleanEvidence( entries, "gateway-control-ui-enabled", @@ -100,7 +100,7 @@ export function scanPolicyGatewayExposure( }); } - const remote = isRecord(gateway.remote) ? gateway.remote : {}; + const remote = asNonArrayRecord(gateway.remote); if (gateway.mode === "remote") { entries.push({ id: "gateway-mode-remote", @@ -118,11 +118,11 @@ export function scanPolicyGatewayExposure( } } - const http = isRecord(gateway.http) ? gateway.http : {}; - const endpoints = isRecord(http.endpoints) ? http.endpoints : {}; + const http = asNonArrayRecord(gateway.http); + const endpoints = asNonArrayRecord(http.endpoints); pushGatewayHttpEndpointEvidence(entries, endpoints, "chatCompletions"); pushGatewayHttpEndpointEvidence(entries, endpoints, "responses"); - const nodes = isRecord(gateway.nodes) ? gateway.nodes : {}; + const nodes = asNonArrayRecord(gateway.nodes); pushGatewayNodeCommandEvidence(entries, nodes); return entries.toSorted((a, b) => a.source.localeCompare(b.source)); } diff --git a/extensions/policy/src/policy-state-ingress.ts b/extensions/policy/src/policy-state-ingress.ts index b73d58cb2452..65fc4f12fc77 100644 --- a/extensions/policy/src/policy-state-ingress.ts +++ b/extensions/policy/src/policy-state-ingress.ts @@ -1,5 +1,6 @@ // Policy plugin ingress evidence. import { + asNonArrayRecord, isRecord, asBoolean as readBoolean, normalizeOptionalString as readString, @@ -25,11 +26,11 @@ const OPEN_GROUPS_DEFAULT_TO_NO_MENTION_CHANNELS = new Set(["feishu", "qa-channe export function scanPolicyIngress(cfg: Record): readonly PolicyIngressEvidence[] { const channels = configuredChannels(cfg); - const channelDefaults = isRecord(channels.defaults) ? channels.defaults : {}; + const channelDefaults = asNonArrayRecord(channels.defaults); const inheritedChannelDefaults = pickSupportedIngressDefaults(channelDefaults); const channelDefaultsSource = "oc://openclaw.config/channels/defaults"; const entries: PolicyIngressEvidence[] = []; - const session = isRecord(cfg.session) ? cfg.session : {}; + const session = asNonArrayRecord(cfg.session); const dmScope = readString(session.dmScope)?.toLowerCase(); entries.push({ id: "session-dm-scope", @@ -44,7 +45,7 @@ export function scanPolicyIngress(cfg: Record): readonly Policy continue; } const channelSource = `oc://openclaw.config/channels/${ocPathSegment(channel)}`; - const accounts = isRecord(value.accounts) ? value.accounts : {}; + const accounts = asNonArrayRecord(value.accounts); const configuredAccounts = Object.entries(accounts).filter( (entry): entry is [string, Record] => isRecord(entry[1]), ); @@ -413,7 +414,7 @@ function channelDmPolicy(config: Record): { readonly sourceSuffix?: string; readonly disabledByEnabled?: boolean; } { - const dm = isRecord(config.dm) ? config.dm : {}; + const dm = asNonArrayRecord(config.dm); if (dm.enabled === false) { return { value: "disabled", sourceSuffix: "dm/enabled", disabledByEnabled: true }; } diff --git a/extensions/policy/src/policy-state-sandbox.ts b/extensions/policy/src/policy-state-sandbox.ts index 7717d8734784..a521da47b26d 100644 --- a/extensions/policy/src/policy-state-sandbox.ts +++ b/extensions/policy/src/policy-state-sandbox.ts @@ -1,5 +1,6 @@ // Policy plugin sandbox posture evidence. import { + asNonArrayRecord, isRecord, asBoolean as readBoolean, normalizeOptionalString as readString, @@ -13,9 +14,9 @@ const DEFAULT_POLICY_SANDBOX_BROWSER_NETWORK = "openclaw-sandbox-browser"; export function scanPolicySandboxPosture( cfg: Record, ): readonly PolicySandboxPostureEvidence[] { - const agents = isRecord(cfg.agents) ? cfg.agents : {}; - const defaults = isRecord(agents.defaults) ? agents.defaults : {}; - const defaultSandbox = isRecord(defaults.sandbox) ? defaults.sandbox : {}; + const agents = asNonArrayRecord(cfg.agents); + const defaults = asNonArrayRecord(agents.defaults); + const defaultSandbox = asNonArrayRecord(defaults.sandbox); const entries: PolicySandboxPostureEvidence[] = []; pushSandboxPostureEvidence(entries, { id: "agents-defaults", @@ -33,7 +34,7 @@ export function scanPolicySandboxPosture( } const agentId = typeof agent.id === "string" && agent.id.trim() !== "" ? agent.id.trim() : undefined; - const sandbox = isRecord(agent.sandbox) ? agent.sandbox : {}; + const sandbox = asNonArrayRecord(agent.sandbox); pushSandboxPostureEvidence(entries, { id: agentId ?? `agent-${index}`, scope: "agent", @@ -97,8 +98,7 @@ function pushSandboxDockerPosture( entries: PolicySandboxPostureEvidence[], params: SandboxPostureParams, ): void { - const localDocker = - !params.sharedSandboxScope && isRecord(params.sandbox.docker) ? params.sandbox.docker : {}; + const localDocker = !params.sharedSandboxScope ? asNonArrayRecord(params.sandbox.docker) : {}; const inheritedDocker = isRecord(params.inheritedSandbox.docker) ? params.inheritedSandbox.docker : {}; @@ -183,8 +183,7 @@ function pushSandboxBrowserPosture( entries: PolicySandboxPostureEvidence[], params: SandboxPostureParams, ): void { - const localBrowser = - !params.sharedSandboxScope && isRecord(params.sandbox.browser) ? params.sandbox.browser : {}; + const localBrowser = !params.sharedSandboxScope ? asNonArrayRecord(params.sandbox.browser) : {}; const inheritedBrowser = isRecord(params.inheritedSandbox.browser) ? params.inheritedSandbox.browser : {}; @@ -242,8 +241,7 @@ function pushSandboxBrowserPosture( surface: "browser", }); } else if (params.effectiveBackend !== "docker" && params.effectiveBackend !== "podman") { - const localDocker = - !params.sharedSandboxScope && isRecord(params.sandbox.docker) ? params.sandbox.docker : {}; + const localDocker = !params.sharedSandboxScope ? asNonArrayRecord(params.sandbox.docker) : {}; const inheritedDocker = isRecord(params.inheritedSandbox.docker) ? params.inheritedSandbox.docker : {}; diff --git a/extensions/policy/src/policy-state-tool-posture.ts b/extensions/policy/src/policy-state-tool-posture.ts index 909a02905e48..944f1c025ee5 100644 --- a/extensions/policy/src/policy-state-tool-posture.ts +++ b/extensions/policy/src/policy-state-tool-posture.ts @@ -1,4 +1,5 @@ import { + asNonArrayRecord, isRecord, asBoolean as readBoolean, normalizeOptionalString as readString, @@ -9,10 +10,10 @@ import type { PolicyToolPostureEvidence } from "./policy-state-types.js"; export function scanPolicyToolPosture( cfg: Record, ): readonly PolicyToolPostureEvidence[] { - const globalTools = isRecord(cfg.tools) ? cfg.tools : {}; - const agents = isRecord(cfg.agents) ? cfg.agents : {}; - const defaults = isRecord(agents.defaults) ? agents.defaults : {}; - const defaultSandbox = isRecord(defaults.sandbox) ? defaults.sandbox : {}; + const globalTools = asNonArrayRecord(cfg.tools); + const agents = asNonArrayRecord(cfg.agents); + const defaults = asNonArrayRecord(agents.defaults); + const defaultSandbox = asNonArrayRecord(defaults.sandbox); const entries: PolicyToolPostureEvidence[] = []; pushToolPostureEvidence(entries, { id: "tools", @@ -36,9 +37,9 @@ export function scanPolicyToolPosture( id: agentId ?? `agent-${index}`, scope: "agent", agentId, - tools: isRecord(agent.tools) ? agent.tools : {}, + tools: asNonArrayRecord(agent.tools), inheritedTools: globalTools, - sandbox: isRecord(agent.sandbox) ? agent.sandbox : {}, + sandbox: asNonArrayRecord(agent.sandbox), inheritedSandbox: defaultSandbox, sourceBase: `oc://openclaw.config/agents/list/#${index}/tools`, inheritedSourceBase: "oc://openclaw.config/tools", @@ -81,8 +82,8 @@ function pushToolPostureEvidence( } function pushToolFsPosture(entries: PolicyToolPostureEvidence[], params: ToolPostureParams): void { - const localFs = isRecord(params.tools.fs) ? params.tools.fs : {}; - const inheritedFs = isRecord(params.inheritedTools.fs) ? params.inheritedTools.fs : {}; + const localFs = asNonArrayRecord(params.tools.fs); + const inheritedFs = asNonArrayRecord(params.inheritedTools.fs); const localWorkspaceOnly = readBoolean(localFs.workspaceOnly); const inheritedWorkspaceOnly = readBoolean(inheritedFs.workspaceOnly); pushToolPostureValue(entries, params, { @@ -98,8 +99,8 @@ function pushToolExecPosture( entries: PolicyToolPostureEvidence[], params: ToolPostureParams, ): void { - const localExec = isRecord(params.tools.exec) ? params.tools.exec : {}; - const inheritedExec = isRecord(params.inheritedTools.exec) ? params.inheritedTools.exec : {}; + const localExec = asNonArrayRecord(params.tools.exec); + const inheritedExec = asNonArrayRecord(params.inheritedTools.exec); const localHost = readString(localExec.host); const inheritedHost = readString(inheritedExec.host); const host = localHost ?? inheritedHost ?? "auto"; @@ -142,7 +143,7 @@ function pushToolElevatedPosture( entries: PolicyToolPostureEvidence[], params: ToolPostureParams, ): void { - const localElevated = isRecord(params.tools.elevated) ? params.tools.elevated : {}; + const localElevated = asNonArrayRecord(params.tools.elevated); const inheritedElevated = isRecord(params.inheritedTools.elevated) ? params.inheritedTools.elevated : {}; @@ -160,7 +161,7 @@ function pushToolElevatedPosture( (localEnabled === undefined && inheritedEnabled !== undefined), }); - const localAllowFrom = isRecord(localElevated.allowFrom) ? localElevated.allowFrom : {}; + const localAllowFrom = asNonArrayRecord(localElevated.allowFrom); const inheritedAllowFrom = isRecord(inheritedElevated.allowFrom) ? inheritedElevated.allowFrom : {}; diff --git a/extensions/policy/src/policy-state-workspace.ts b/extensions/policy/src/policy-state-workspace.ts index 7f154479f882..68e2c6a475d9 100644 --- a/extensions/policy/src/policy-state-workspace.ts +++ b/extensions/policy/src/policy-state-workspace.ts @@ -1,5 +1,6 @@ // Policy plugin agent workspace evidence. import { + asNonArrayRecord, isRecord, normalizeOptionalString as readString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -10,10 +11,10 @@ import { toolListCoversTool } from "./tool-policy-conformance.js"; export function scanPolicyAgentWorkspace( cfg: Record, ): readonly PolicyAgentWorkspaceEvidence[] { - const agents = isRecord(cfg.agents) ? cfg.agents : {}; - const defaults = isRecord(agents.defaults) ? agents.defaults : {}; - const defaultSandbox = isRecord(defaults.sandbox) ? defaults.sandbox : {}; - const defaultTools = isRecord(cfg.tools) ? cfg.tools : {}; + const agents = asNonArrayRecord(cfg.agents); + const defaults = asNonArrayRecord(agents.defaults); + const defaultSandbox = asNonArrayRecord(defaults.sandbox); + const defaultTools = asNonArrayRecord(cfg.tools); const entries: PolicyAgentWorkspaceEvidence[] = []; pushAgentWorkspaceEvidence(entries, { id: "agents-defaults", @@ -35,8 +36,8 @@ export function scanPolicyAgentWorkspace( } const agentId = typeof agent.id === "string" && agent.id.trim() !== "" ? agent.id.trim() : undefined; - const sandbox = isRecord(agent.sandbox) ? agent.sandbox : {}; - const tools = isRecord(agent.tools) ? agent.tools : {}; + const sandbox = asNonArrayRecord(agent.sandbox); + const tools = asNonArrayRecord(agent.tools); pushAgentWorkspaceEvidence(entries, { id: agentId ?? `agent-${index}`, scope: "agent", @@ -160,7 +161,7 @@ function agentWorkspaceToolDenyEvidence( function configuredSandboxToolDenyEntries( tools: Record, ): readonly string[] | undefined { - const sandbox = isRecord(tools.sandbox) ? tools.sandbox : {}; - const sandboxTools = isRecord(sandbox.tools) ? sandbox.tools : {}; + const sandbox = asNonArrayRecord(tools.sandbox); + const sandboxTools = asNonArrayRecord(sandbox.tools); return Array.isArray(sandboxTools.deny) ? readStringArray(sandboxTools.deny) : undefined; } diff --git a/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts b/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts index 11e021f9e99a..88c16fa242f3 100644 --- a/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts +++ b/extensions/qa-lab/src/mantis/slack-desktop-smoke.runtime.ts @@ -1464,7 +1464,7 @@ export async function runMantisSlackDesktopSmoke( timer.updatePhaseStatus("crabbox.remote_run", "accepted"); } if (remoteRunError && !gatewaySetupCompleted && !slackQaCompleted) { - throw toErrorObject(remoteRunError); + throw toMantisError(remoteRunError); } if (gatewaySetup && !gatewaySetupCompleted) { throw new Error("Slack desktop gateway setup did not report a live OpenClaw gateway."); @@ -1580,7 +1580,7 @@ export async function runMantisSlackDesktopSmoke( } } -function toErrorObject(error: unknown): Error { +function toMantisError(error: unknown): Error { return error instanceof Error ? error : new Error(formatErrorMessage(error)); } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.ts b/extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.ts index 36cfae4ee34e..c1d7bee10337 100644 --- a/extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.ts +++ b/extensions/qa-lab/src/mantis/telegram-desktop-builder.runtime.ts @@ -727,7 +727,7 @@ export async function runMantisTelegramDesktopBuilder( timer.updatePhaseStatus("crabbox.remote_run", "accepted"); } if (remoteRunError && !gatewaySetupCompleted) { - throw toErrorObject(remoteRunError); + throw toMantisError(remoteRunError); } if (gatewaySetup && !gatewaySetupCompleted) { throw new Error("Telegram desktop builder did not report a live OpenClaw gateway."); @@ -832,7 +832,7 @@ export async function runMantisTelegramDesktopBuilder( } } -function toErrorObject(error: unknown): Error { +function toMantisError(error: unknown): Error { return error instanceof Error ? error : new Error(formatErrorMessage(error)); } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/qa-lab/src/mantis/visual-task.runtime.ts b/extensions/qa-lab/src/mantis/visual-task.runtime.ts index d038284f4726..32afa022709b 100644 --- a/extensions/qa-lab/src/mantis/visual-task.runtime.ts +++ b/extensions/qa-lab/src/mantis/visual-task.runtime.ts @@ -229,11 +229,11 @@ async function runCommandWithExternalOutput(params: { }, }); if (deferredError) { - throw toErrorObject(deferredError); + throw toMantisError(deferredError); } } -function toErrorObject(error: unknown): Error { +function toMantisError(error: unknown): Error { return error instanceof Error ? error : new Error(formatErrorMessage(error)); } diff --git a/extensions/qa-lab/src/progress-format.ts b/extensions/qa-lab/src/progress-format.ts index 615ffca4af57..f3c3d6fb576f 100644 --- a/extensions/qa-lab/src/progress-format.ts +++ b/extensions/qa-lab/src/progress-format.ts @@ -1,15 +1,7 @@ +import { parseBooleanValue } from "openclaw/plugin-sdk/string-coerce-runtime"; + export function parseQaProgressBooleanEnv(value: string | undefined): boolean | undefined { - const normalized = value?.trim().toLowerCase(); - if (!normalized) { - return undefined; - } - if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") { - return true; - } - if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") { - return false; - } - return undefined; + return parseBooleanValue(value); } export function sanitizeQaProgressValue(value: string): string { diff --git a/extensions/qa-lab/src/qa-credentials-admin.runtime.ts b/extensions/qa-lab/src/qa-credentials-admin.runtime.ts index 21b2cab8ff51..8959f401d456 100644 --- a/extensions/qa-lab/src/qa-credentials-admin.runtime.ts +++ b/extensions/qa-lab/src/qa-credentials-admin.runtime.ts @@ -133,7 +133,7 @@ function parsePositiveIntegerEnv(env: NodeJS.ProcessEnv, key: string, fallback: env, key, fallback, - toError: (message) => + createError: (message) => new QaCredentialAdminError({ code: "INVALID_ENV", message, @@ -145,7 +145,7 @@ function normalizeConvexSiteUrl(raw: string, env: NodeJS.ProcessEnv): string { return normalizeQaCredentialConvexSiteUrl({ raw, env, - toError: (message) => + createError: (message) => new QaCredentialAdminError({ code: "INVALID_SITE_URL", message, @@ -160,7 +160,7 @@ function normalizeEndpointPrefix(value: string | undefined): string { invalidAbsoluteMessage: '--endpoint-prefix must be an absolute path like "/qa-credentials/v1" (not //host).', invalidSegmentsMessage: '--endpoint-prefix must not contain backslashes or ".." path segments.', - toError: (message) => + createError: (message) => new QaCredentialAdminError({ code: "INVALID_ARGUMENT", message, diff --git a/extensions/qa-lab/src/qa-credentials-common.runtime.ts b/extensions/qa-lab/src/qa-credentials-common.runtime.ts index c067a4970609..99b516962ae8 100644 --- a/extensions/qa-lab/src/qa-credentials-common.runtime.ts +++ b/extensions/qa-lab/src/qa-credentials-common.runtime.ts @@ -15,7 +15,7 @@ export function parseQaCredentialPositiveIntegerEnv(params: { env: NodeJS.ProcessEnv; fallback: number; key: string; - toError?: ErrorFactory; + createError?: ErrorFactory; }): number { const raw = params.env[params.key]?.trim(); if (!raw) { @@ -23,7 +23,7 @@ export function parseQaCredentialPositiveIntegerEnv(params: { } const value = parseStrictPositiveInteger(raw); if (value === undefined) { - throw (params.toError ?? makeError)(`${params.key} must be a positive integer.`); + throw (params.createError ?? makeError)(`${params.key} must be a positive integer.`); } return value; } @@ -36,14 +36,14 @@ export function isQaCredentialTruthyOptIn(value: string | undefined) { export function normalizeQaCredentialConvexSiteUrl(params: { env: NodeJS.ProcessEnv; raw: string; - toError?: ErrorFactory; + createError?: ErrorFactory; }): string { - const toError = params.toError ?? makeError; + const createError = params.createError ?? makeError; let url: URL; try { url = new URL(params.raw); } catch { - throw toError( + throw createError( `OPENCLAW_QA_CONVEX_SITE_URL must be a valid URL, got "${params.raw || ""}".`, ); } @@ -52,13 +52,13 @@ export function normalizeQaCredentialConvexSiteUrl(params: { return text.endsWith("/") ? text.slice(0, -1) : text; } if (url.protocol !== "http:") { - throw toError("OPENCLAW_QA_CONVEX_SITE_URL must use https://."); + throw createError("OPENCLAW_QA_CONVEX_SITE_URL must use https://."); } const allowInsecureHttp = isQaCredentialTruthyOptIn( params.env[QA_CREDENTIALS_ALLOW_INSECURE_HTTP_ENV_KEY], ); if (!allowInsecureHttp || !isLoopbackHost(url.hostname)) { - throw toError( + throw createError( `OPENCLAW_QA_CONVEX_SITE_URL must use https://. http:// is only allowed for loopback hosts when ${QA_CREDENTIALS_ALLOW_INSECURE_HTTP_ENV_KEY}=1.`, ); } @@ -70,7 +70,7 @@ export function normalizeQaCredentialEndpointPrefix(params: { fallback?: string; invalidAbsoluteMessage: string; invalidSegmentsMessage: string; - toError?: ErrorFactory; + createError?: ErrorFactory; value: string | undefined; }): string { const trimmed = params.value?.trim(); @@ -79,12 +79,12 @@ export function normalizeQaCredentialEndpointPrefix(params: { } const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; const normalized = prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed; - const toError = params.toError ?? makeError; + const createError = params.createError ?? makeError; if (!normalized.startsWith("/") || normalized.startsWith("//")) { - throw toError(params.invalidAbsoluteMessage); + throw createError(params.invalidAbsoluteMessage); } if (normalized.includes("\\") || normalized.split("/").some((segment) => segment === "..")) { - throw toError(params.invalidSegmentsMessage); + throw createError(params.invalidSegmentsMessage); } return normalized; } diff --git a/extensions/qqbot/src/engine/config/credentials.ts b/extensions/qqbot/src/engine/config/credentials.ts index 4e0a62a8098c..f7815065690b 100644 --- a/extensions/qqbot/src/engine/config/credentials.ts +++ b/extensions/qqbot/src/engine/config/credentials.ts @@ -7,7 +7,7 @@ * and stay framework-agnostic. */ -import { asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { readQqbotObjectRecord as asOptionalObjectRecord } from "../object-record.js"; import { DEFAULT_ACCOUNT_ID } from "./resolve.js"; // ---- Logout: clear all credential fields for an account ---- diff --git a/extensions/qqbot/src/engine/config/group.ts b/extensions/qqbot/src/engine/config/group.ts index 1684c781b041..a9a394db6d87 100644 --- a/extensions/qqbot/src/engine/config/group.ts +++ b/extensions/qqbot/src/engine/config/group.ts @@ -1,6 +1,7 @@ // Qqbot plugin module implements group behavior. import { resolveScopeRequireMention, type ScopeTree } from "openclaw/plugin-sdk/channel-policy"; -import { asBoolean, asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { readQqbotObjectRecord as asOptionalObjectRecord } from "../object-record.js"; import { resolveAccountBase } from "./resolve.js"; interface GroupConfig { diff --git a/extensions/qqbot/src/engine/config/resolve.ts b/extensions/qqbot/src/engine/config/resolve.ts index 7a1210c99afb..4f4762e549f4 100644 --- a/extensions/qqbot/src/engine/config/resolve.ts +++ b/extensions/qqbot/src/engine/config/resolve.ts @@ -9,13 +9,13 @@ */ import { - asOptionalObjectRecord, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringifiedEntries, readStringField, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getPlatformAdapter } from "../adapter/index.js"; +import { readQqbotObjectRecord as asOptionalObjectRecord } from "../object-record.js"; /** * Default account ID, used for the unnamed top-level account. diff --git a/extensions/qqbot/src/engine/object-record.ts b/extensions/qqbot/src/engine/object-record.ts new file mode 100644 index 000000000000..c78f59c7dbcf --- /dev/null +++ b/extensions/qqbot/src/engine/object-record.ts @@ -0,0 +1,6 @@ +import { asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; + +/** Reads QQBot config objects, including array-backed legacy values. */ +export function readQqbotObjectRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" ? asRecord(value) : undefined; +} diff --git a/extensions/qqbot/src/engine/utils/stt.ts b/extensions/qqbot/src/engine/utils/stt.ts index 247c666bf241..6281a1ee00a7 100644 --- a/extensions/qqbot/src/engine/utils/stt.ts +++ b/extensions/qqbot/src/engine/utils/stt.ts @@ -14,11 +14,11 @@ import { } from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { - asOptionalObjectRecord, normalizeOptionalString, readStringField, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { readQqbotObjectRecord as asOptionalObjectRecord } from "../object-record.js"; import { sanitizeFileName } from "./string-normalize.js"; const STT_ERROR_BODY_LIMIT_BYTES = 8 * 1024; diff --git a/extensions/signal/src/monitor.tool-result.autostart.test.ts b/extensions/signal/src/monitor.tool-result.autostart.test.ts index 0633019bb303..ceda87eb9070 100644 --- a/extensions/signal/src/monitor.tool-result.autostart.test.ts +++ b/extensions/signal/src/monitor.tool-result.autostart.test.ts @@ -1,5 +1,6 @@ // Signal tests cover monitor.tool result.autostart plugin behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { describe, expect, it, vi } from "vitest"; import type { SignalDaemonHandle } from "./daemon.js"; import { @@ -314,17 +315,3 @@ describe("monitorSignalProvider autostart", () => { expect(settled).toBe(true); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/extensions/slack/src/data-table.ts b/extensions/slack/src/data-table.ts index 3385a203d679..834255496206 100644 --- a/extensions/slack/src/data-table.ts +++ b/extensions/slack/src/data-table.ts @@ -4,7 +4,7 @@ import { renderMessagePresentationTableFallbackText, type MessagePresentationTableBlock, } from "openclaw/plugin-sdk/interactive-runtime"; -import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asOptionalRecord, hasNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; import { renderSlackMessagePresentationTableFallbackText } from "./presentation-fallback.js"; @@ -45,7 +45,7 @@ type ParsedSlackDataTable = { }; function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; + return hasNonEmptyString(value) ? value : undefined; } function countCharacters(value: string): number { diff --git a/extensions/slack/src/monitor/assistant-thread-context.ts b/extensions/slack/src/monitor/assistant-thread-context.ts index fa7c8be44672..02b7c33c0517 100644 --- a/extensions/slack/src/monitor/assistant-thread-context.ts +++ b/extensions/slack/src/monitor/assistant-thread-context.ts @@ -48,9 +48,9 @@ export function parseSlackAssistantThreadMetadata(value: unknown) { } const record = payload as Record; return { - channelId: readStringField(record, "channel_id"), - teamId: readStringField(record, "team_id"), - enterpriseId: readStringField(record, "enterprise_id"), + channelId: readNonBlankStringField(record, "channel_id"), + teamId: readNonBlankStringField(record, "team_id"), + enterpriseId: readNonBlankStringField(record, "enterprise_id"), }; } @@ -101,7 +101,7 @@ export function createSlackAssistantThreadContextStore(params: { accountId: stri return { get, save }; } -function readStringField(record: Record, key: string) { +function readNonBlankStringField(record: Record, key: string) { const raw = record[key]; return typeof raw === "string" && raw.trim() ? raw.trim() : undefined; } diff --git a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts index 0cb9b9aa051b..07a2f10191d9 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts @@ -870,12 +870,17 @@ vi.mock("openclaw/plugin-sdk/security-runtime", () => ({ resolvePinnedMainDmOwnerFromAllowlist: () => mockedPinnedMainDmOwner, })); -vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => ({ - isRecord: (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value), - normalizeOptionalLowercaseString: (value?: string) => value?.toLowerCase(), - normalizeOptionalString: (value?: string) => value, -})); +vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => { + const isMockRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + const normalizeMockLowercaseString = (value?: string) => value?.toLowerCase(); + const readMockOptionalString = (value?: string) => value; + return { + isRecord: isMockRecord, + normalizeOptionalLowercaseString: normalizeMockLowercaseString, + normalizeOptionalString: readMockOptionalString, + }; +}); vi.mock("../../actions.js", () => ({ reactSlackMessage: reactSlackMessageMock, diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index d6b723bd065f..bc0a0d470aa5 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -24,6 +24,7 @@ import { } from "openclaw/plugin-sdk/runtime-env"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { + asNonArrayRecord, normalizeOptionalString, normalizeStringEntries, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -253,10 +254,7 @@ function resolveSlackRelayConfig(params: { relay: unknown; accountId: string }): authToken: string; gatewayId: string; } { - const relay = - params.relay && typeof params.relay === "object" && !Array.isArray(params.relay) - ? (params.relay as Record) - : {}; + const relay = asNonArrayRecord(params.relay); const url = normalizeOptionalString(relay.url); const authToken = normalizeResolvedSecretInputString({ value: relay.authToken, diff --git a/extensions/telegram/src/audit.test.ts b/extensions/telegram/src/audit.test.ts index ed0c34da0f10..caab7ab97021 100644 --- a/extensions/telegram/src/audit.test.ts +++ b/extensions/telegram/src/audit.test.ts @@ -11,17 +11,21 @@ vi.mock("openclaw/plugin-sdk/text-utility-runtime", () => ({ fetchWithTimeout: fetchWithTimeoutMock, })); -vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => ({ - isRecord: (value: unknown): value is Record => - typeof value === "object" && value !== null, - normalizeOptionalString: (value: unknown) => { +vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => { + const isMockRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + const normalizeMockOptionalString = (value: unknown) => { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed ? trimmed : undefined; - }, -})); + }; + return { + isRecord: isMockRecord, + normalizeOptionalString: normalizeMockOptionalString, + }; +}); function mockGetChatMemberStatus(status: string) { fetchWithTimeoutMock.mockResolvedValueOnce( diff --git a/extensions/telegram/src/bot.fetch-abort.test.ts b/extensions/telegram/src/bot.fetch-abort.test.ts index 3fef33cb6bdd..6b9b9934266a 100644 --- a/extensions/telegram/src/bot.fetch-abort.test.ts +++ b/extensions/telegram/src/bot.fetch-abort.test.ts @@ -1,4 +1,5 @@ // Telegram tests cover bot.fetch abort plugin behavior. +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { describe, expect, it, vi } from "vitest"; import { isTelegramPollingNetworkError } from "./network-errors.js"; @@ -394,17 +395,3 @@ describe("createTelegramBot fetch abort", () => { expect(isTelegramPollingNetworkError(frozenError)).toBe(false); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 883a57bf7c8b..63a3f0129d02 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -13,7 +13,7 @@ import type { } from "openclaw/plugin-sdk/channel-contract"; import type { TelegramActionConfig } from "openclaw/plugin-sdk/config-contracts"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; -import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asNonArrayRecord, readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; import { inspectTelegramAccount } from "./account-inspect.js"; import { @@ -88,10 +88,7 @@ function prepareTelegramSendPayload({ return payload; } const rawTelegramData = payload.channelData?.telegram; - const telegramData = - rawTelegramData && typeof rawTelegramData === "object" && !Array.isArray(rawTelegramData) - ? (rawTelegramData as Record) - : {}; + const telegramData = asNonArrayRecord(rawTelegramData); return { ...payload, channelData: { diff --git a/extensions/telegram/src/lane-delivery-text-deliverer.ts b/extensions/telegram/src/lane-delivery-text-deliverer.ts index 8ff5783b5057..a7c0802a65ca 100644 --- a/extensions/telegram/src/lane-delivery-text-deliverer.ts +++ b/extensions/telegram/src/lane-delivery-text-deliverer.ts @@ -177,10 +177,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams): ) { return payload; } - const telegramRest = - telegramData && typeof telegramData === "object" && !Array.isArray(telegramData) - ? (telegramData as Record) - : {}; + const telegramRest = asNonArrayRecord(telegramData); return { ...payload, channelData: { @@ -537,3 +534,4 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams): return delivered ? result("sent") : result("skipped"); }; } +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts index 27a39663b79c..9b125ab45726 100644 --- a/extensions/telegram/src/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -1,4 +1,5 @@ // Telegram tests cover monitor plugin behavior. +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; type MonitorTelegramOpts = import("./monitor.types.js").MonitorTelegramOpts; @@ -1087,17 +1088,3 @@ describe("monitorTelegramProvider (grammY)", () => { expect(runSpy).not.toHaveBeenCalled(); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/extensions/telegram/src/polling-session.test.ts b/extensions/telegram/src/polling-session.test.ts index 1ba56420364f..b2d967655351 100644 --- a/extensions/telegram/src/polling-session.test.ts +++ b/extensions/telegram/src/polling-session.test.ts @@ -7,6 +7,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { Bot } from "grammy"; import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract"; import { DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS as TELEGRAM_SPOOLED_RETRY_MAX_ATTEMPTS } from "openclaw/plugin-sdk/channel-outbound"; +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { isIngressClaimOwnedByOtherLiveProcess as isTelegramSpooledUpdateClaimOwnedByOtherLiveProcess, resolveIngressRetryDelayMs, @@ -5164,17 +5165,4 @@ describe("TelegramPollingSession", () => { }); }); -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/telegram/src/send.proxy.test.ts b/extensions/telegram/src/send.proxy.test.ts index d2a08e72fcc9..00b2d1a636c7 100644 --- a/extensions/telegram/src/send.proxy.test.ts +++ b/extensions/telegram/src/send.proxy.test.ts @@ -1,4 +1,5 @@ // Telegram tests cover send.proxy plugin behavior. +import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { botApi, botCtorSpy } = vi.hoisted(() => ({ @@ -564,17 +565,3 @@ describe("telegram proxy client", () => { vi.useRealTimers(); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/extensions/telegram/src/status-issues.ts b/extensions/telegram/src/status-issues.ts index b3cc09fb1616..602866275bbd 100644 --- a/extensions/telegram/src/status-issues.ts +++ b/extensions/telegram/src/status-issues.ts @@ -41,7 +41,7 @@ type TelegramGroupMembershipAuditSummary = { }>; }; -function asFiniteNumber(value: unknown): number | null { +function asFiniteNumberOrNull(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } @@ -69,8 +69,8 @@ function collectTelegramPollingRuntimeIssues(params: { return; } - const lastStartAt = asFiniteNumber(account.lastStartAt); - const lastTransportActivityAt = asFiniteNumber(account.lastTransportActivityAt); + const lastStartAt = asFiniteNumberOrNull(account.lastStartAt); + const lastTransportActivityAt = asFiniteNumberOrNull(account.lastTransportActivityAt); const fix = `Run: ${formatCliCommand("openclaw channels status --probe")} (or restart the gateway). Check the bot token, proxy/network settings, and logs if it persists.`; if (account.connected === false) { @@ -129,7 +129,7 @@ function collectTelegramWebhookRuntimeIssues(params: { return; } - const lastStartAt = asFiniteNumber(account.lastStartAt); + const lastStartAt = asFiniteNumberOrNull(account.lastStartAt); const withinStartupGrace = lastStartAt != null && now - lastStartAt < TELEGRAM_WEBHOOK_CONNECT_GRACE_MS; if (withinStartupGrace) { diff --git a/extensions/vault/vault-secret-ref-resolver.js b/extensions/vault/vault-secret-ref-resolver.js index 0e861724a8ab..5ebc086fd7ac 100644 --- a/extensions/vault/vault-secret-ref-resolver.js +++ b/extensions/vault/vault-secret-ref-resolver.js @@ -55,7 +55,7 @@ function normalizeVaultAddress() { return address; } -function normalizeOptionalString(value) { +function normalizeVaultOptionalString(value) { return value?.trim() || undefined; } @@ -74,7 +74,7 @@ function readVaultCredentialFile(filePath, label, emptyMessage) { } function resolveVaultAuthMethod() { - const method = normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_METHOD) ?? "token"; + const method = normalizeVaultOptionalString(process.env.OPENCLAW_VAULT_AUTH_METHOD) ?? "token"; if ( method === "token" || method === "token_file" || @@ -95,7 +95,7 @@ function resolveVaultTokenEnv() { } function resolveVaultTokenFile() { - const tokenFile = normalizeOptionalString(process.env.VAULT_TOKEN_FILE); + const tokenFile = normalizeVaultOptionalString(process.env.VAULT_TOKEN_FILE); if (!tokenFile) { throw new Error("VAULT_TOKEN_FILE is required."); } @@ -229,7 +229,7 @@ function resolveVaultAuthMount(method) { } function resolveVaultAuthRole(method) { - const role = normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_ROLE); + const role = normalizeVaultOptionalString(process.env.OPENCLAW_VAULT_AUTH_ROLE); if (!role) { throw new Error(`OPENCLAW_VAULT_AUTH_ROLE is required for ${method} auth.`); } @@ -238,7 +238,7 @@ function resolveVaultAuthRole(method) { function resolveVaultJwt(method) { const jwtFile = - normalizeOptionalString(process.env.OPENCLAW_VAULT_JWT_FILE) ?? + normalizeVaultOptionalString(process.env.OPENCLAW_VAULT_JWT_FILE) ?? (method === "kubernetes" ? KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH : undefined); if (!jwtFile) { throw new Error("OPENCLAW_VAULT_JWT_FILE is required for jwt auth."); diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 25a897bdf647..d0cd4ead215d 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -4,6 +4,7 @@ import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime"; import { resolveGlobalSingleton } from "openclaw/plugin-sdk/global-singleton"; import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; import { + asNonArrayRecord, asOptionalRecord, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -88,9 +89,7 @@ const VoiceCallToolSchema = Type.Union([ ]); function asParamRecord(params: unknown): Record { - return params && typeof params === "object" && !Array.isArray(params) - ? (params as Record) - : {}; + return asNonArrayRecord(params); } function isCliOnlyProcess(): boolean { diff --git a/extensions/workboard/src/tools.ts b/extensions/workboard/src/tools.ts index 7eb1890ed3a5..e5eb2f033dad 100644 --- a/extensions/workboard/src/tools.ts +++ b/extensions/workboard/src/tools.ts @@ -979,10 +979,7 @@ export function createWorkboardTools(params: { { additionalProperties: false }, ), execute: async (_toolCallId, rawParams) => { - const record = - rawParams && typeof rawParams === "object" && !Array.isArray(rawParams) - ? (rawParams as Record) - : {}; + const record = asNonArrayRecord(rawParams); const result = await store.dispatch({ boardId: record.boardId }); return jsonResult({ ...result, @@ -1036,3 +1033,4 @@ export function createWorkboardTools(params: { ]; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ +import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; diff --git a/extensions/xai/image-generation-provider.test.ts b/extensions/xai/image-generation-provider.test.ts index 9af74b38a20b..12142dde3ac3 100644 --- a/extensions/xai/image-generation-provider.test.ts +++ b/extensions/xai/image-generation-provider.test.ts @@ -72,12 +72,19 @@ vi.mock("openclaw/plugin-sdk/provider-http", async () => { }; }); -vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => ({ - normalizeOptionalString: (v: unknown) => (typeof v === "string" ? v.trim() : undefined), - normalizeOptionalLowercaseString: (v: unknown) => - typeof v === "string" ? v.trim().toLowerCase() : undefined, - readStringValue: (v: unknown) => (typeof v === "string" ? v.trim() : undefined), -})); +vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => { + const normalizeMockOptionalString = (value: unknown) => + typeof value === "string" ? value.trim() : undefined; + const normalizeMockOptionalLowercaseString = (value: unknown) => + typeof value === "string" ? value.trim().toLowerCase() : undefined; + const readMockStringValue = (value: unknown) => + typeof value === "string" ? value.trim() : undefined; + return { + normalizeOptionalString: normalizeMockOptionalString, + normalizeOptionalLowercaseString: normalizeMockOptionalLowercaseString, + readStringValue: readMockStringValue, + }; +}); function jsonResponse(payload: unknown): Response { return new Response(JSON.stringify(payload), { diff --git a/extensions/xai/realtime-voice-config.ts b/extensions/xai/realtime-voice-config.ts index 7b7d12093eb3..a034717f1872 100644 --- a/extensions/xai/realtime-voice-config.ts +++ b/extensions/xai/realtime-voice-config.ts @@ -9,7 +9,7 @@ import type { import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { asFiniteNumber, - asOptionalObjectRecord, + asRecord, normalizeOptionalString, parseBooleanValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -18,6 +18,10 @@ import { XAI_BASE_URL } from "./model-definitions.js"; type XaiRealtimeVoice = "eve" | "ara" | "rex" | "sal" | "leo"; type XaiRealtimeReasoningEffort = "high" | "none"; +function readXaiObjectRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" ? asRecord(value) : undefined; +} + type XaiRealtimeVoiceProviderConfig = { apiKey?: string; baseUrl?: string; @@ -146,9 +150,9 @@ export function serializeXaiRealtimeToolResult(result: unknown): string { } function readNestedXaiConfig(rawConfig: RealtimeVoiceProviderConfig) { - const raw = asOptionalObjectRecord(rawConfig); - const providers = asOptionalObjectRecord(raw?.providers); - return asOptionalObjectRecord(providers?.xai ?? raw?.xai ?? raw) ?? {}; + const raw = readXaiObjectRecord(rawConfig); + const providers = readXaiObjectRecord(raw?.providers); + return readXaiObjectRecord(providers?.xai ?? raw?.xai ?? raw) ?? {}; } export function normalizeXaiRealtimeBaseUrl(value?: string): string { @@ -214,7 +218,7 @@ export function readXaiRealtimeErrorDetail(error: unknown): string { if (typeof error === "string" && error) { return error; } - const record = asOptionalObjectRecord(error); + const record = readXaiObjectRecord(error); return ( normalizeOptionalString(record?.message) ?? normalizeOptionalString(record?.code) ?? diff --git a/packages/agent-core/src/harness/messages.ts b/packages/agent-core/src/harness/messages.ts index 2933c427ba5d..eec60e55fab0 100644 --- a/packages/agent-core/src/harness/messages.ts +++ b/packages/agent-core/src/harness/messages.ts @@ -1,5 +1,6 @@ // Agent Core module implements messages behavior. import type { ImageContent, Message, TextContent } from "@openclaw/llm-core"; +import { parseDateStringTimestampMs as parseSessionTimestampMs } from "@openclaw/normalization-core/number-coercion"; import type { AgentMessage, BashExecutionMessage, @@ -29,14 +30,6 @@ export function asAgentMessage(message: HarnessMessage): AgentMessage { return message as AgentMessage; } -function parseSessionTimestampMs(value: unknown): number | undefined { - if (typeof value !== "string" || !value.trim()) { - return undefined; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - function requireSessionTimestampMs(value: string, label: string): number { const parsed = parseSessionTimestampMs(value); if (parsed === undefined) { diff --git a/packages/ai/package.json b/packages/ai/package.json index 02501e73c090..b7748a8b048c 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -101,6 +101,7 @@ "@anthropic-ai/sdk": "0.115.0", "@google/genai": "2.13.0", "@mistralai/mistralai": "2.5.0", + "@openclaw/normalization-core": "workspace:*", "openai": "6.49.0", "partial-json": "0.1.7", "typebox": "1.3.6" diff --git a/packages/ai/src/package-dependencies.test.ts b/packages/ai/src/package-dependencies.test.ts new file mode 100644 index 000000000000..3fccc9c2206f --- /dev/null +++ b/packages/ai/src/package-dependencies.test.ts @@ -0,0 +1,58 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const PACKAGE_ROOT = path.resolve(import.meta.dirname, ".."); + +async function listProductionSources(directory: string): Promise { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const sources: string[] = []; + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + sources.push(...(await listProductionSources(entryPath))); + } else if ( + entry.name.endsWith(".ts") && + !entry.name.includes(".test.") && + !entry.name.includes(".test-") + ) { + sources.push(entryPath); + } + } + return sources; +} + +async function productionImportsPackage(packageName: string): Promise { + const sources = await listProductionSources(path.join(PACKAGE_ROOT, "src")); + for (const sourcePath of sources) { + const source = await fs.readFile(sourcePath, "utf8"); + const sourceFile = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.Latest, true); + for (const statement of sourceFile.statements) { + if ( + (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) && + (statement.moduleSpecifier.text === packageName || + statement.moduleSpecifier.text.startsWith(`${packageName}/`)) + ) { + return true; + } + } + } + return false; +} + +describe("@openclaw/ai source dependency contract", () => { + it("declares normalization-core while production source imports it", async () => { + const manifest = JSON.parse( + await fs.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8"), + ) as { + dependencies?: Record; + devDependencies?: Record; + }; + + expect(await productionImportsPackage("@openclaw/normalization-core")).toBe(true); + expect(manifest.dependencies?.["@openclaw/normalization-core"]).toBe("workspace:*"); + }); +}); diff --git a/packages/gateway-protocol/src/client-info.ts b/packages/gateway-protocol/src/client-info.ts index a80e66248e39..415ef7c49bda 100644 --- a/packages/gateway-protocol/src/client-info.ts +++ b/packages/gateway-protocol/src/client-info.ts @@ -6,7 +6,7 @@ */ import { normalizeOptionalProtocolString } from "./protocol-value-normalization.js"; -function normalizeOptionalLowercaseString(raw?: string | null): string | undefined { +function normalizeOptionalProtocolLowercaseString(raw?: string | null): string | undefined { return normalizeOptionalProtocolString(raw)?.toLowerCase(); } @@ -100,7 +100,7 @@ const GATEWAY_CLIENT_MODE_SET = new Set(Object.values(GATEWAY export function normalizeGatewayClientId(raw?: string | null): GatewayClientId | undefined { // Handshake input is intentionally case-insensitive, but policy decisions use // the canonical lowercase ids from the closed registry above. - const normalized = normalizeOptionalLowercaseString(raw); + const normalized = normalizeOptionalProtocolLowercaseString(raw); if (!normalized) { return undefined; } @@ -116,7 +116,7 @@ export function normalizeGatewayClientName(raw?: string | null): GatewayClientNa /** Normalizes untrusted client modes and rejects unknown values. */ export function normalizeGatewayClientMode(raw?: string | null): GatewayClientMode | undefined { - const normalized = normalizeOptionalLowercaseString(raw); + const normalized = normalizeOptionalProtocolLowercaseString(raw); if (!normalized) { return undefined; } diff --git a/packages/llm-core/src/validation.ts b/packages/llm-core/src/validation.ts index 114e78cf4062..8f2acbdf72fc 100644 --- a/packages/llm-core/src/validation.ts +++ b/packages/llm-core/src/validation.ts @@ -18,12 +18,12 @@ interface JsonSchemaObject { oneOf?: JsonSchemaObject[]; } -function isRecord(value: unknown): value is Record { +function isObjectBackedRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function isJsonSchemaObject(value: unknown): value is JsonSchemaObject { - return isRecord(value); + return isObjectBackedRecord(value); } function getSchemaTypes(schema: JsonSchemaObject): string[] { @@ -51,14 +51,14 @@ function matchesJsonType(value: unknown, type: string): boolean { case "array": return Array.isArray(value); case "object": - return isRecord(value) && !Array.isArray(value); + return isObjectBackedRecord(value) && !Array.isArray(value); default: return false; } } function isValidatorSchema(value: unknown): value is Tool["parameters"] { - return isRecord(value); + return isObjectBackedRecord(value); } const JSON_NUMBER_TOKEN_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:e[+-]?\d+)?$/iu; @@ -293,7 +293,11 @@ function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown } } - if (schemaTypes.includes("object") && isRecord(nextValue) && !Array.isArray(nextValue)) { + if ( + schemaTypes.includes("object") && + isObjectBackedRecord(nextValue) && + !Array.isArray(nextValue) + ) { applySchemaObjectCoercion(nextValue, schema); } @@ -348,7 +352,7 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): unknown { // mirror the provider-facing coercions so model-emitted string numbers validate. const coerced = coerceWithJsonSchema(args, tool.parameters); if (coerced !== args) { - if (isRecord(args) && isRecord(coerced)) { + if (isObjectBackedRecord(args) && isObjectBackedRecord(coerced)) { for (const key of Object.keys(args)) { delete args[key]; } diff --git a/packages/model-catalog-core/src/configured-model-refs.ts b/packages/model-catalog-core/src/configured-model-refs.ts index 129e90c506ee..1e4d2e18c195 100644 --- a/packages/model-catalog-core/src/configured-model-refs.ts +++ b/packages/model-catalog-core/src/configured-model-refs.ts @@ -1,5 +1,5 @@ // Collects configured model references from OpenClaw config-shaped objects. -import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { asNonArrayRecord, isRecord } from "@openclaw/normalization-core/record-coerce"; /** One configured model reference plus its config path. */ export type ConfiguredModelRef = { @@ -71,7 +71,7 @@ export function collectConfiguredModelRefs( for (const key of AGENT_MODEL_CONFIG_KEYS) { collectModelConfig(`${path}.${key}`, agent[key]); } - const mediaModels = isRecord(agent.mediaModels) ? agent.mediaModels : {}; + const mediaModels = asNonArrayRecord(agent.mediaModels); for (const capability of ["image", "video", "music"] as const) { collectModelConfig(`${path}.mediaModels.${capability}`, mediaModels[capability]); } @@ -96,8 +96,8 @@ export function collectConfiguredModelRefs( } } if (includeEntrySelectors) { - const tools = isRecord(agent.tools) ? agent.tools : {}; - const exec = isRecord(tools.exec) ? tools.exec : {}; + const tools = asNonArrayRecord(agent.tools); + const exec = asNonArrayRecord(tools.exec); collectModelConfig( `${path}.tools.exec.reviewer.model`, isRecord(exec.reviewer) ? exec.reviewer.model : undefined, @@ -109,21 +109,21 @@ export function collectConfiguredModelRefs( } }; - const root = isRecord(config) ? config : {}; - const tools = isRecord(root.tools) ? root.tools : {}; - const exec = isRecord(tools.exec) ? tools.exec : {}; + const root = asNonArrayRecord(config); + const tools = asNonArrayRecord(root.tools); + const exec = asNonArrayRecord(tools.exec); collectModelConfig( "tools.exec.reviewer.model", isRecord(exec.reviewer) ? exec.reviewer.model : undefined, ); - const media = isRecord(tools.media) ? tools.media : {}; + const media = asNonArrayRecord(tools.media); for (const capability of ["image", "audio", "video"] as const) { pushModelRef( `tools.media.${capability}.preferredModel`, isRecord(media[capability]) ? media[capability].preferredModel : undefined, ); } - const agents = isRecord(root.agents) ? root.agents : {}; + const agents = asNonArrayRecord(root.agents); collectFromAgent("agents.defaults", agents.defaults); if (Object.hasOwn(agents, "entries")) { if (isRecord(agents.entries)) { @@ -137,8 +137,8 @@ export function collectConfiguredModelRefs( } } if (options.includeChannelModelOverrides !== false) { - const channels = isRecord(root.channels) ? root.channels : {}; - const modelByChannel = isRecord(channels.modelByChannel) ? channels.modelByChannel : {}; + const channels = asNonArrayRecord(root.channels); + const modelByChannel = asNonArrayRecord(channels.modelByChannel); for (const [channelId, channelMap] of Object.entries(modelByChannel)) { if (!isRecord(channelMap)) { continue; @@ -148,7 +148,7 @@ export function collectConfiguredModelRefs( } } } - const hooks = isRecord(root.hooks) ? root.hooks : {}; + const hooks = asNonArrayRecord(root.hooks); if (Array.isArray(hooks.mappings)) { for (const [index, mapping] of hooks.mappings.entries()) { pushModelRef(`hooks.mappings.${index}.model`, isRecord(mapping) ? mapping.model : undefined); @@ -156,10 +156,9 @@ export function collectConfiguredModelRefs( } pushModelRef("hooks.gmail.model", isRecord(hooks.gmail) ? hooks.gmail.model : undefined); pushModelRef("tts.summaryModel", isRecord(root.tts) ? root.tts.summaryModel : undefined); - const discord = - isRecord(root.channels) && isRecord(root.channels.discord) ? root.channels.discord : {}; + const discord = asNonArrayRecord(asNonArrayRecord(root.channels).discord); const collectDiscordVoice = (path: string, value: unknown) => { - const voice = isRecord(value) ? value : {}; + const voice = asNonArrayRecord(value); pushModelRef(`${path}.model`, voice.model); pushModelRef( `${path}.tts.summaryModel`, diff --git a/packages/model-catalog-core/src/model-catalog-normalize.ts b/packages/model-catalog-core/src/model-catalog-normalize.ts index 1ccdc917dc5a..581bf7f3f0f8 100644 --- a/packages/model-catalog-core/src/model-catalog-normalize.ts +++ b/packages/model-catalog-core/src/model-catalog-normalize.ts @@ -1,5 +1,9 @@ // Model Catalog Core helper module supports model catalog normalize behavior. -import { asFiniteNumber as normalizeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; +import { + asFiniteNumber as normalizeFiniteNumber, + asNonNegativeFiniteNumber as normalizeNonNegativeNumber, + asPositiveFiniteNumber as normalizePositiveNumber, +} from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { @@ -124,18 +128,10 @@ function normalizeModelCatalogInputs(value: unknown): ModelCatalogInput[] | unde return inputs.length > 0 ? inputs : undefined; } -function normalizeNonNegativeNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} - function normalizeStringOrNumber(value: unknown): string | number | undefined { return normalizeOptionalString(value) ?? normalizeFiniteNumber(value); } -function normalizePositiveNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; -} - function normalizePositiveInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } diff --git a/packages/normalization-core/src/error-coercion.test.ts b/packages/normalization-core/src/error-coercion.test.ts index 3605cf40068c..3ed8b17400a4 100644 --- a/packages/normalization-core/src/error-coercion.test.ts +++ b/packages/normalization-core/src/error-coercion.test.ts @@ -1,6 +1,11 @@ // Normalization core tests cover shared error coercion and formatting behavior. import { describe, expect, it } from "vitest"; -import { formatErrorMessage, stringifyNonErrorCause, toErrorObject } from "./error-coercion.js"; +import { + coerceErrorMessage, + formatErrorMessage, + stringifyNonErrorCause, + toErrorObject, +} from "./error-coercion.js"; const keepText = (text: string): string => text; const format = (value: unknown): string => formatErrorMessage(value, { redact: keepText }); @@ -58,6 +63,15 @@ describe("toErrorObject", () => { }); }); +describe("coerceErrorMessage", () => { + it("preserves Error messages exactly and stringifies other values", () => { + expect(coerceErrorMessage(new Error(""))).toBe(""); + expect(coerceErrorMessage(new Error(" boom "))).toBe(" boom "); + expect(coerceErrorMessage("failure")).toBe("failure"); + expect(coerceErrorMessage(null)).toBe("null"); + }); +}); + describe("stringifyNonErrorCause", () => { it("renders primitive and structured values", () => { expect(stringifyNonErrorCause(null)).toBe("null"); diff --git a/packages/normalization-core/src/error-coercion.ts b/packages/normalization-core/src/error-coercion.ts index 38c384a3ed16..0887e2441777 100644 --- a/packages/normalization-core/src/error-coercion.ts +++ b/packages/normalization-core/src/error-coercion.ts @@ -125,6 +125,11 @@ export function toErrorObject(value: unknown, fallbackMessage: string): Error { return error; } +/** Reads Error messages unchanged and stringifies every other value. */ +export function coerceErrorMessage(value: unknown): string { + return value instanceof Error ? value.message : String(value); +} + /** Renders a non-Error cause as useful text without throwing. */ export function stringifyNonErrorCause(value: unknown): string { if (value === null) { diff --git a/packages/normalization-core/src/number-coercion.test.ts b/packages/normalization-core/src/number-coercion.test.ts index 4798e5b0b1c2..ff5e24f454eb 100644 --- a/packages/normalization-core/src/number-coercion.test.ts +++ b/packages/normalization-core/src/number-coercion.test.ts @@ -4,6 +4,8 @@ import { asDateTimestampMs, asFiniteNumber, asFiniteNumberInRange, + asNonNegativeFiniteNumber, + asPositiveFiniteNumber, asSafeIntegerInRange, addTimerTimeoutGraceMs, clampPositiveTimerTimeoutMs, @@ -14,7 +16,8 @@ import { MAX_TIMER_TIMEOUT_MS, MAX_TIMER_TIMEOUT_SECONDS, nonNegativeSecondsToSafeMilliseconds, - parseDateTimestampMs, + parseDateFirstTimestampMs, + parseDateStringTimestampMs, parseFiniteNumber, positiveSecondsToSafeMilliseconds, resolveIntegerOption, @@ -44,6 +47,15 @@ describe("number-coercion", () => { expect(asFiniteNumber(Number.POSITIVE_INFINITY)).toBeUndefined(); }); + test("signed finite helpers preserve finite fractional values", () => { + expect(asPositiveFiniteNumber(0.5)).toBe(0.5); + expect(asPositiveFiniteNumber(0)).toBeUndefined(); + expect(asPositiveFiniteNumber(Number.POSITIVE_INFINITY)).toBeUndefined(); + expect(asNonNegativeFiniteNumber(0)).toBe(0); + expect(asNonNegativeFiniteNumber(-0.5)).toBeUndefined(); + expect(asNonNegativeFiniteNumber("1")).toBeUndefined(); + }); + test("asFiniteNumberInRange enforces inclusive and exclusive bounds", () => { expect(asFiniteNumberInRange(0.5, { min: 0.5, max: 2 })).toBe(0.5); expect(asFiniteNumberInRange(2, { min: 0.5, max: 2 })).toBe(2); @@ -142,32 +154,26 @@ describe("number-coercion", () => { }); test.each([ - { value: 0, expected: 0 }, - { value: 1_700_000_000_000, expected: 1_700_000_000_000 }, - { value: "0", expected: 0 }, - { value: " 1e3 ", expected: 1_000 }, + { value: "0", expected: Date.parse("0") }, + { value: "2026", expected: Date.parse("2026") }, { value: "2026-07-13T10:00:00.000Z", expected: 1_783_936_800_000 }, - { value: -MAX_DATE_TIMESTAMP_MS, expected: -MAX_DATE_TIMESTAMP_MS }, - { value: String(MAX_DATE_TIMESTAMP_MS), expected: MAX_DATE_TIMESTAMP_MS }, - ])("parseDateTimestampMs parses $value as milliseconds", ({ value, expected }) => { - expect(parseDateTimestampMs(value)).toBe(expected); + { value: 0, expected: undefined }, + { value: "123ms", expected: undefined }, + { value: "+275761-01-01T00:00:00.000Z", expected: undefined }, + ])("parseDateStringTimestampMs parses $value", ({ value, expected }) => { + expect(parseDateStringTimestampMs(value)).toBe(expected); }); test.each([ - new Date("2026-07-13T10:00:00.000Z"), - true, - false, - "", - " ", - "123ms", - Number.NaN, - Number.POSITIVE_INFINITY, - Number.NEGATIVE_INFINITY, - MAX_DATE_TIMESTAMP_MS + 1, - String(MAX_DATE_TIMESTAMP_MS + 1), - "+275761-01-01T00:00:00.000Z", - ])("parseDateTimestampMs rejects %o", (value) => { - expect(parseDateTimestampMs(value)).toBeUndefined(); + { value: 0, expected: 0 }, + { value: 1_700_000_000_000, expected: 1_700_000_000_000 }, + { value: "0", expected: Date.parse("0") }, + { value: "2026", expected: Date.parse("2026") }, + { value: Number.POSITIVE_INFINITY, expected: undefined }, + { value: MAX_DATE_TIMESTAMP_MS + 1, expected: MAX_DATE_TIMESTAMP_MS + 1 }, + { value: true, expected: undefined }, + ])("parseDateFirstTimestampMs parses $value", ({ value, expected }) => { + expect(parseDateFirstTimestampMs(value)).toBe(expected); }); test("future timestamp helper rejects invalid Date timestamps", () => { diff --git a/packages/normalization-core/src/number-coercion.ts b/packages/normalization-core/src/number-coercion.ts index 55d23d99a5c1..ffa1f9b36561 100644 --- a/packages/normalization-core/src/number-coercion.ts +++ b/packages/normalization-core/src/number-coercion.ts @@ -1,6 +1,18 @@ /** Returns a number only when the input is already finite. */ export function asFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return Number.isFinite(value as number) ? (value as number) : undefined; +} + +/** Returns a finite number only when it is greater than zero. */ +export function asPositiveFiniteNumber(value: unknown): number | undefined { + const number = asFiniteNumber(value); + return number && number > 0 ? number : undefined; +} + +/** Returns a finite number only when it is zero or greater. */ +export function asNonNegativeFiniteNumber(value: unknown): number | undefined { + const number = asFiniteNumber(value); + return number && number < 0 ? undefined : number; } /** Returns a finite number only when it satisfies the supplied inclusive/exclusive bounds. */ @@ -97,7 +109,7 @@ export function parseStrictFiniteNumber(value: unknown): number | undefined { /** Returns positive safe integers without string coercion. */ export function asPositiveSafeInteger(value: unknown): number | undefined { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; + return Number.isSafeInteger(value) && (value as number) > 0 ? (value as number) : undefined; } /** Conservative upper bound for Node timer delays. */ @@ -117,16 +129,14 @@ export function asDateTimestampMs(value: unknown): number | undefined { }); } -/** Parses Date-valid timestamps; numeric values and tokens are always milliseconds. */ -export function parseDateTimestampMs(value: unknown): number | undefined { - const numeric = parseFiniteNumber(value); - if (numeric !== undefined) { - return asDateTimestampMs(numeric); - } - if (typeof value !== "string") { - return undefined; - } - return asDateTimestampMs(Date.parse(value)); +/** Parses Date-valid timestamp strings using JavaScript date-string semantics. */ +export function parseDateStringTimestampMs(value: unknown): number | undefined { + return typeof value === "string" ? asDateTimestampMs(Date.parse(value)) : undefined; +} + +/** Reads finite numbers as milliseconds and parses strings using date-string semantics. */ +export function parseDateFirstTimestampMs(value: unknown): number | undefined { + return typeof value === "number" ? asFiniteNumber(value) : parseDateStringTimestampMs(value); } /** Checks whether a Date-valid timestamp is after the supplied/current time. */ diff --git a/packages/normalization-core/src/record-coerce.test.ts b/packages/normalization-core/src/record-coerce.test.ts index a41b6a44e74e..e8edc04cdafd 100644 --- a/packages/normalization-core/src/record-coerce.test.ts +++ b/packages/normalization-core/src/record-coerce.test.ts @@ -1,6 +1,11 @@ // Normalization Core tests cover record coerce behavior. import { describe, expect, it } from "vitest"; -import { asNullableRecord, asOptionalRecord } from "./record-coerce.js"; +import { + asNonArrayRecord, + asNullableRecord, + asOptionalRecord, + isStringRecord, +} from "./record-coerce.js"; describe("record-coerce", () => { it("keeps record coercion behavior for optional and nullable variants", () => { @@ -11,4 +16,42 @@ describe("record-coerce", () => { expect(asNullableRecord(null)).toBeNull(); expect(asNullableRecord([{ ok: true }])).toBeNull(); }); + + it("preserves accepted record identity and returns fresh ordinary fallbacks", () => { + class ExampleRecord { + marker = true; + } + const record = Object.create(null) as Record; + const date = new Date(); + const instance = new ExampleRecord(); + const firstFallback = asNonArrayRecord(null); + const secondFallback = asNonArrayRecord([]); + + expect(asNonArrayRecord(record)).toBe(record); + expect(asNonArrayRecord(date)).toBe(date); + expect(asNonArrayRecord(instance)).toBe(instance); + expect(firstFallback).toEqual({}); + expect(Object.getPrototypeOf(firstFallback)).toBe(Object.prototype); + expect(secondFallback).toEqual({}); + expect(secondFallback).not.toBe(firstFallback); + }); + + it("preserves the canonical proxy trap behavior", () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + expect(() => asNonArrayRecord(proxy)).toThrow(TypeError); + expect(() => isStringRecord(proxy)).toThrow(TypeError); + }); + + it.each([ + { value: {}, expected: true }, + { value: { first: "one", second: "two" }, expected: true }, + { value: Object.assign(Object.create(null), { first: "one" }), expected: true }, + { value: new Date(), expected: true }, + { value: { first: "one", second: 2 }, expected: false }, + { value: ["one"], expected: false }, + { value: null, expected: false }, + ])("validates all-or-nothing string records", ({ value, expected }) => { + expect(isStringRecord(value)).toBe(expected); + }); }); diff --git a/packages/normalization-core/src/record-coerce.ts b/packages/normalization-core/src/record-coerce.ts index c3e205356201..7bf47cc56c1f 100644 --- a/packages/normalization-core/src/record-coerce.ts +++ b/packages/normalization-core/src/record-coerce.ts @@ -22,6 +22,11 @@ export function asOptionalRecord(value: unknown): Record | unde return isRecord(value) ? value : undefined; } +/** Returns a non-array record or a fresh ordinary empty record. */ +export function asNonArrayRecord(value: unknown): Record { + return asOptionalRecord(value) ?? {}; +} + /** Returns a non-array record or null. */ export function asNullableRecord(value: unknown): Record | null { return isRecord(value) ? value : null; @@ -36,3 +41,8 @@ export function asOptionalObjectRecord(value: unknown): Record export function asNullableObjectRecord(value: unknown): Record | null { return value && typeof value === "object" ? (value as Record) : null; } + +/** Checks that every enumerable own value in a non-array record is a string. */ +export function isStringRecord(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string"); +} diff --git a/packages/normalization-core/src/string-coerce.test.ts b/packages/normalization-core/src/string-coerce.test.ts index 014fcbd8f47c..b74d6e42374d 100644 --- a/packages/normalization-core/src/string-coerce.test.ts +++ b/packages/normalization-core/src/string-coerce.test.ts @@ -1,4 +1,9 @@ -import { normalizeStringifiedEntries } from "@openclaw/normalization-core/string-coerce"; +import { + normalizeBoundedOptionalString, + normalizeStringifiedEntries, + readNonBlankString, + readNonEmptyStringPreservingWhitespace, +} from "@openclaw/normalization-core/string-coerce"; // Normalization Core tests cover string coerce behavior. import { describe, expect, it } from "vitest"; @@ -12,4 +17,25 @@ describe("normalization-core/string-coerce", () => { ]); expect(normalizeStringifiedEntries(undefined)).toEqual([]); }); + + it.each([ + { value: " value ", maxLength: 5, expected: "value" }, + { value: " 😀 ", maxLength: 2, expected: "😀" }, + { value: "😀", maxLength: 1, expected: undefined }, + { value: " value ", maxLength: 4, expected: undefined }, + { value: " ", maxLength: 3, expected: undefined }, + { value: 42, maxLength: 2, expected: undefined }, + { value: "value", maxLength: -1, expected: undefined }, + { value: "value", maxLength: 1.5, expected: undefined }, + ])("normalizes bounded optional strings", ({ value, maxLength, expected }) => { + expect(normalizeBoundedOptionalString(value, maxLength)).toBe(expected); + }); + + it("distinguishes non-blank and non-empty whitespace-preserving reads", () => { + expect(readNonBlankString(" value ")).toBe(" value "); + expect(readNonBlankString(" ")).toBeUndefined(); + expect(readNonEmptyStringPreservingWhitespace(" ")).toBe(" "); + expect(readNonEmptyStringPreservingWhitespace("")).toBeUndefined(); + expect(readNonEmptyStringPreservingWhitespace(null)).toBeUndefined(); + }); }); diff --git a/packages/normalization-core/src/string-coerce.ts b/packages/normalization-core/src/string-coerce.ts index 22f8e00bc793..6f960a6a3cb8 100644 --- a/packages/normalization-core/src/string-coerce.ts +++ b/packages/normalization-core/src/string-coerce.ts @@ -17,6 +17,28 @@ export function normalizeOptionalString(value: unknown): string | undefined { return normalizeNullableString(value) ?? undefined; } +/** Trims bounded string input and rejects blank, non-string, or overlong values. */ +export function normalizeBoundedOptionalString( + value: unknown, + maxLength: number, +): string | undefined { + if (!Number.isInteger(maxLength) || maxLength < 0) { + return undefined; + } + const normalized = normalizeOptionalString(value); + return normalized && normalized.length <= maxLength ? normalized : undefined; +} + +/** Requires non-blank string input while preserving its original whitespace. */ +export function readNonBlankString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +/** Rejects only empty or non-string values while preserving whitespace. */ +export function readNonEmptyStringPreservingWhitespace(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + /** Stringifies primitive ids/flags before applying optional string normalization. */ export function normalizeStringifiedOptionalString(value: unknown): string | undefined { if (typeof value === "string") { diff --git a/packages/retry/src/index.ts b/packages/retry/src/index.ts index c7b7211400cd..071b40e70653 100644 --- a/packages/retry/src/index.ts +++ b/packages/retry/src/index.ts @@ -164,12 +164,8 @@ const defaultSleep = (ms: number) => setTimeout(resolve, ms); }); -function asFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function clampNumber(value: unknown, fallback: number, min?: number, max?: number): number { - const next = asFiniteNumber(value); + const next = Number.isFinite(value as number) ? (value as number) : undefined; if (next === undefined) { return fallback; } @@ -177,12 +173,13 @@ function clampNumber(value: unknown, fallback: number, min?: number, max?: numbe } function resolveAttemptCount(value: unknown, fallback: number): number { - return Math.max(1, Math.round(asFiniteNumber(value) ?? fallback)); + const attemptCount = Number.isFinite(value as number) ? (value as number) : fallback; + return Math.max(1, Math.round(attemptCount)); } function resolveRetryDelayMs(value: number): number { const finite = - value === Number.POSITIVE_INFINITY ? MAX_TIMER_TIMEOUT_MS : (asFiniteNumber(value) ?? 0); + value === Number.POSITIVE_INFINITY ? MAX_TIMER_TIMEOUT_MS : Number.isFinite(value) ? value : 0; return Math.min(Math.max(Math.round(finite), 0), MAX_TIMER_TIMEOUT_MS); } @@ -190,7 +187,7 @@ function resolveJitterConfig(value: unknown, fallback: number | "full"): number if (value === "full") { return "full"; } - const fraction = asFiniteNumber(value); + const fraction = Number.isFinite(value as number) ? (value as number) : undefined; return fraction === undefined ? fallback : Math.min(Math.max(fraction, 0), 1); } diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index b81fabb3aaf9..2267b424e900 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -1,6 +1,7 @@ // OpenClaw SDK module implements client behavior. import { randomUUID } from "node:crypto"; import { asRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonEmptyStringPreservingWhitespace as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { EventHub } from "./event-hub.js"; import { normalizeGatewayEvent } from "./normalize.js"; import { GatewayClientTransport, isConnectableTransport } from "./transport.js"; @@ -132,10 +133,6 @@ function runStatusFromWaitPayload(payload: unknown): RunResult["status"] { return "failed"; } -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - function readOptionalTimestamp(value: unknown): RunTimestamp | undefined { if (typeof value === "string" && value.length > 0) { return value; diff --git a/packages/sdk/src/normalize.ts b/packages/sdk/src/normalize.ts index baccac0c16d2..3698515d6f22 100644 --- a/packages/sdk/src/normalize.ts +++ b/packages/sdk/src/normalize.ts @@ -1,12 +1,9 @@ // OpenClaw SDK helper module supports normalize behavior. import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonEmptyStringPreservingWhitespace as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import type { GatewayEvent, JsonObject, OpenClawEvent, OpenClawEventType } from "./types.js"; -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - function readLowerString(value: unknown): string | undefined { return readNonEmptyString(value)?.toLowerCase(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11b5a8da69f0..dc69e89ec2b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2201,6 +2201,9 @@ importers: '@mistralai/mistralai': specifier: 2.5.0 version: 2.5.0(@opentelemetry/api@1.9.1) + '@openclaw/normalization-core': + specifier: workspace:* + version: link:../normalization-core openai: specifier: 6.49.0 version: 6.49.0(@aws-sdk/credential-provider-node@3.972.72)(@smithy/hash-node@4.4.14)(@smithy/signature-v4@5.6.10)(ws@8.21.1)(zod@4.4.3) @@ -2619,6 +2622,9 @@ packages: '@aws-sdk/core@3.977.1': resolution: {integrity: sha512-KVtQRtc00ES/y+Sc3vYXeP6pCIcNlBJCZOwvqSy8ZpVGmbM5+IG+AfhuTKQ2oXmIVqZJewaGMMpzPkywC6xg0w==} engines: {node: '>=20.0.0'} + deprecated: |- + Deprecated due to Document number parsing bug in JSON, see + https://github.com/aws/aws-sdk-js-v3/issues/8246. Newer version available. '@aws-sdk/credential-provider-cognito-identity@3.972.60': resolution: {integrity: sha512-16Fxa3veKIO6nHJr+vN76z3BkSU7725lzruc5S2SQSTUTbK4TXtmi8033WQljZj4k1+383UsMaNOHN6B76fK5A==} @@ -6279,6 +6285,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-functions-list@3.3.3: resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} diff --git a/scripts/android-release-signing.mjs b/scripts/android-release-signing.mjs index b42bc7885298..281c4e6e5f5d 100644 --- a/scripts/android-release-signing.mjs +++ b/scripts/android-release-signing.mjs @@ -99,11 +99,6 @@ function requireString(value, key) { return value.trim(); } -// This release entrypoint runs before dependencies are installed. -function asRecord(value) { - return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {}; -} - function requireGradlePropertyNames(value) { if ( !Array.isArray(value) || @@ -119,7 +114,8 @@ function requireGradlePropertyNames(value) { } function readManifest(manifestPath) { - const parsed = asRecord(JSON.parse(fs.readFileSync(manifestPath, "utf8"))); + const value = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + const parsed = isRecord(value) ? value : {}; const manifest = { signingRepo: requireString(parsed.signingRepo, "signingRepo"), signingBranch: requireString(parsed.signingBranch, "signingBranch"), @@ -484,3 +480,4 @@ try { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); } +import { isRecord } from "./lib/record-shared.mjs"; diff --git a/scripts/changed-lanes.mts b/scripts/changed-lanes.mts index 01c5bfb212b6..0c67762ba70a 100644 --- a/scripts/changed-lanes.mts +++ b/scripts/changed-lanes.mts @@ -4,16 +4,12 @@ import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mts"; import { getChangedPathFacts, normalizeChangedPath } from "./lib/changed-path-facts.mjs"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs"; +import { isRecord } from "./lib/record-shared.mjs"; const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024; const IMPLAUSIBLE_NO_MERGE_BASE_DIFF_PATHS = 200; const RAW_SYNC_CHANGED_LANES_ENV = "OPENCLAW_CHANGED_LANES_RAW_SYNC"; -// The CLI is invoked from temporary Git repositories, outside workspace package resolution. -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - // Source files knip's production scan reads. Any edit to one of these can orphan // an export -- including an import-only edit that drops a barrel re-export's last // consumer -- so the scan is selected by path, not by inspecting changed lines. diff --git a/scripts/check-coercion-helper-declarations.mts b/scripts/check-coercion-helper-declarations.mts index 9a1932f9bbfa..5ed768710294 100644 --- a/scripts/check-coercion-helper-declarations.mts +++ b/scripts/check-coercion-helper-declarations.mts @@ -10,56 +10,51 @@ import { runWithFailedTrailer } from "./lib/failed-trailer.mts"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { toLine, unwrapExpression } from "./lib/ts-guard-utils.mts"; -const BANNED_HELPER_NAMES = new Set([ +export const BANNED_COERCION_HELPER_NAMES = [ "asObject", + "asFiniteNumber", + "asNonArrayRecord", + "asNonNegativeFiniteNumber", + "asNullableRecord", + "asOptionalRecord", + "asPositiveFiniteNumber", "asRecord", "asString", + "coerceErrorMessage", "isRecord", + "isStringRecord", + "normalizeBoundedOptionalString", + "normalizeOptionalLowercaseString", + "normalizeOptionalString", "normalizeString", "optionalString", + "parseBooleanValue", + "parseDateFirstTimestampMs", + "parseDateStringTimestampMs", + "parseFiniteNumber", "readBoolean", + "readNonBlankString", + "readNonEmptyStringPreservingWhitespace", "readNumber", "readOptionalString", "readString", + "readStringField", + "readStringValue", "timestampMs", "toError", -]); -const SCAN_ROOTS = [ - "apps", - "deploy", - "examples", - "extensions", - "packages", - "qa", - "scripts", - "security", - "src", - "test", - "ui", -]; + "toLintErrorObject", + "toErrorObject", +] as const; +export type BannedCoercionHelperName = (typeof BANNED_COERCION_HELPER_NAMES)[number]; +const BANNED_HELPER_NAMES: ReadonlySet = new Set(BANNED_COERCION_HELPER_NAMES); +// One tracked-tree scan covers root configs plus config, Actions, skills, apps, plugins, and packages. +const SCAN_ROOTS = ["."]; const GENERATED_OR_FIXTURE_PATH_RE = - /(?:^|\/)(?:\.generated|__generated__|build|coverage|dist|generated|fixtures|node_modules|test-fixtures|vendor)(?:\/|$)|(?:^|\/)[^/]*(?:test-)?fixtures?\.[cm]?[jt]sx?$|\.generated\.[^/]+$|\.(?:bundle|min)\.[cm]?[jt]sx?$/u; -const GENERATED_BROWSER_RUNTIME_PATHS = new Set([ - "extensions/browser/chrome-extension/modules/copilot-runtime.js", -]); - -export type BannedCoercionHelperName = - | "asObject" - | "asRecord" - | "asString" - | "isRecord" - | "normalizeString" - | "optionalString" - | "readBoolean" - | "readNumber" - | "readOptionalString" - | "readString" - | "timestampMs" - | "toError"; + /(?:^|\/)(?:\.generated|__generated__|build|coverage|dist|generated|fixtures|node_modules|test-fixtures|vendor)(?:\/|$)|\.generated\.[^/]+$|\.(?:bundle|min)\.[cm]?[jt]sx?$/u; export type CoercionHelperDeclaration = { file: string; - kind: "function" | "variable"; + kind: "field" | "function" | "method" | "property" | "variable"; line: number; name: BannedCoercionHelperName; }; @@ -71,56 +66,65 @@ export type CoercionHelperCarveOut = { reason: string; }; +function canonicalOwnerCarveOuts( + file: string, + names: readonly BannedCoercionHelperName[], +): CoercionHelperCarveOut[] { + return names.map((name) => ({ + file, + name, + count: 1, + reason: "Canonical coercion helper owned by this module.", + })); +} + export const COERCION_HELPER_CARVE_OUTS: readonly CoercionHelperCarveOut[] = [ - { - file: "packages/normalization-core/src/record-coerce.ts", - name: "asRecord", - count: 1, - reason: "Canonical object-to-record fallback coercion owned by normalization-core.", - }, - { - file: "packages/normalization-core/src/record-coerce.ts", - name: "isRecord", - count: 1, - reason: "Canonical non-array record predicate owned by normalization-core.", - }, - { - file: "packages/llm-core/src/validation.ts", - name: "isRecord", - count: 1, - reason: - "Dependency-free validator intentionally accepts arrays before JSON type-specific checks.", - }, + ...canonicalOwnerCarveOuts("packages/normalization-core/src/string-coerce.ts", [ + "normalizeBoundedOptionalString", + "normalizeOptionalLowercaseString", + "normalizeOptionalString", + "readNonBlankString", + "readNonEmptyStringPreservingWhitespace", + "readStringValue", + ]), + ...canonicalOwnerCarveOuts("packages/normalization-core/src/number-coercion.ts", [ + "asFiniteNumber", + "asNonNegativeFiniteNumber", + "asPositiveFiniteNumber", + "parseDateFirstTimestampMs", + "parseDateStringTimestampMs", + "parseFiniteNumber", + ]), + ...canonicalOwnerCarveOuts("packages/normalization-core/src/record-coerce.ts", [ + "asNonArrayRecord", + "asNullableRecord", + "asOptionalRecord", + "asRecord", + "isRecord", + "isStringRecord", + "readStringField", + ]), + ...canonicalOwnerCarveOuts("packages/normalization-core/src/error-coercion.ts", [ + "coerceErrorMessage", + "toErrorObject", + ]), + ...canonicalOwnerCarveOuts("scripts/lib/error-format.mts", [ + "coerceErrorMessage", + "toErrorObject", + ]), + ...canonicalOwnerCarveOuts("src/utils/boolean.ts", ["parseBooleanValue"]), { file: "ui/src/test-helpers/control-ui-e2e.ts", name: "isRecord", count: 1, reason: "Serialized mock Gateway closure cannot capture module imports.", }, - { - file: "scripts/android-release-signing.mjs", - name: "asRecord", - count: 1, - reason: "Release signing entrypoint runs before workspace dependencies are installed.", - }, - { - file: "scripts/changed-lanes.mts", - name: "isRecord", - count: 1, - reason: "Changed-lane classification also runs in temporary repositories.", - }, { file: "scripts/check-built-plugin-control-plane-modules.mts", name: "isRecord", count: 1, reason: "Copied standalone build guard cannot rely on workspace package resolution.", }, - { - file: "scripts/close-duplicate-prs-after-merge.mjs", - name: "isRecord", - count: 1, - reason: "Plain-Node Actions entrypoint runs in checkout-only jobs without dependencies.", - }, { file: "scripts/copy-bundled-plugin-metadata.mts", name: "isRecord", @@ -151,12 +155,6 @@ export const COERCION_HELPER_CARVE_OUTS: readonly CoercionHelperCarveOut[] = [ count: 1, reason: "Copied standalone asset closure cannot rely on workspace package resolution.", }, - { - file: "scripts/periphery-intersection.mjs", - name: "isRecord", - count: 1, - reason: "Plain-Node Actions entrypoint runs in checkout-only jobs without dependencies.", - }, { file: "scripts/pr-lib/process-group-runner.mjs", name: "toError", @@ -164,6 +162,24 @@ export const COERCION_HELPER_CARVE_OUTS: readonly CoercionHelperCarveOut[] = [ reason: "Bootstrap process supervisor preserves fallback errors without workspace dependencies.", }, + { + file: "scripts/lib/bounded-response.mjs", + name: "toLintErrorObject", + count: 1, + reason: "Standalone copied response reader cannot resolve workspace packages.", + }, + { + file: "scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs", + name: "toLintErrorObject", + count: 1, + reason: "Installed-image runtime smoke runs as a copied standalone closure.", + }, + { + file: "scripts/e2e/lib/openai-web-search-minimal/client.mjs", + name: "toLintErrorObject", + count: 1, + reason: "Minimal copied E2E client runs without workspace package resolution.", + }, { file: "scripts/stage-bundled-plugin-runtime.mts", name: "isRecord", @@ -200,6 +216,30 @@ function unwrapCallableInitializer(expression: ts.Expression) { return current; } +/** Returns true for tracked source files governed by the declaration guard. */ +export function isGovernedCoercionHelperPath(filePath: string) { + return ( + isCodeFile(filePath) && + !/\.d\.[cm]?ts$/u.test(filePath) && + !GENERATED_OR_FIXTURE_PATH_RE.test(filePath) + ); +} + +function propertyNameText(name: ts.PropertyName | undefined): string | undefined { + if (!name) { + return undefined; + } + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text; + } + return undefined; +} + +function isCallableInitializer(expression: ts.Expression): boolean { + const initializer = unwrapCallableInitializer(expression); + return ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer); +} + function unwrapDirectAliasInitializer(expression: ts.Expression): ts.Expression | undefined { let current = expression; while (true) { @@ -218,17 +258,7 @@ function unwrapDirectAliasInitializer(expression: ts.Expression): ts.Expression } } -/** Returns true for tracked source files governed by the declaration guard. */ -export function isGovernedCoercionHelperPath(filePath: string) { - return ( - isCodeFile(filePath) && - !/\.d\.[cm]?ts$/u.test(filePath) && - !GENERATED_BROWSER_RUNTIME_PATHS.has(filePath) && - !GENERATED_OR_FIXTURE_PATH_RE.test(filePath) - ); -} - -/** Finds banned function and callable-variable declarations in one source file. */ +/** Finds banned callable declarations in one source file. */ export function findBannedCoercionHelperDeclarations( source: string, file = "source.ts", @@ -253,11 +283,9 @@ export function findBannedCoercionHelperDeclarations( BANNED_HELPER_NAMES.has(node.name.text) && node.initializer ) { - const initializer = unwrapCallableInitializer(node.initializer); const aliasInitializer = unwrapDirectAliasInitializer(node.initializer); if ( - ts.isArrowFunction(initializer) || - ts.isFunctionExpression(initializer) || + isCallableInitializer(node.initializer) || (aliasInitializer !== undefined && (ts.isIdentifier(aliasInitializer) || ts.isPropertyAccessExpression(aliasInitializer))) ) { @@ -268,6 +296,36 @@ export function findBannedCoercionHelperDeclarations( name: node.name.text as BannedCoercionHelperName, }); } + } else if (ts.isMethodDeclaration(node)) { + const name = propertyNameText(node.name); + if (name && BANNED_HELPER_NAMES.has(name)) { + declarations.push({ + file, + kind: "method", + line: toLine(sourceFile, node.name), + name: name as BannedCoercionHelperName, + }); + } + } else if (ts.isPropertyDeclaration(node) && node.initializer) { + const name = propertyNameText(node.name); + if (name && BANNED_HELPER_NAMES.has(name) && isCallableInitializer(node.initializer)) { + declarations.push({ + file, + kind: "field", + line: toLine(sourceFile, node.name), + name: name as BannedCoercionHelperName, + }); + } + } else if (ts.isPropertyAssignment(node)) { + const name = propertyNameText(node.name); + if (name && BANNED_HELPER_NAMES.has(name) && isCallableInitializer(node.initializer)) { + declarations.push({ + file, + kind: "property", + line: toLine(sourceFile, node.name), + name: name as BannedCoercionHelperName, + }); + } } ts.forEachChild(node, visit); }; @@ -405,7 +463,15 @@ export function runCoercionHelperDeclarationGuard( } writeLine( io.stderr, - "Use @openclaw/normalization-core coercion subpaths in core/packages/UI/scripts, or openclaw/plugin-sdk/string-coerce-runtime and error-runtime in plugin production code.", + "Core/package/UI/workspace-script code: use the matching @openclaw/normalization-core coercion subpath.", + ); + writeLine( + io.stderr, + "Plugin production code: use openclaw/plugin-sdk/string-coerce-runtime, number-runtime, or error-runtime.", + ); + writeLine( + io.stderr, + "Dependency-free, copied, generated, or serialized code: use an existing dependency-light seam or a precise semantic name with an exact reasoned carve-out.", ); return 1; } diff --git a/scripts/check-extension-package-tsc-boundary.mts b/scripts/check-extension-package-tsc-boundary.mts index 2565c2f53bde..7ea98db27731 100644 --- a/scripts/check-extension-package-tsc-boundary.mts +++ b/scripts/check-extension-package-tsc-boundary.mts @@ -16,6 +16,7 @@ import { createRequire } from "node:module"; import os from "node:os"; import path, { dirname, join, resolve } from "node:path"; import pMap from "p-map"; +import { toErrorObject } from "./lib/error-format.mts"; import { parsePositiveInt } from "./lib/numeric-options.mjs"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { @@ -24,6 +25,10 @@ import { shouldUseDetachedVitestProcessGroup, } from "./vitest-process-group.mts"; +function coerceBoundaryError(value: unknown, fallbackMessage: string): Error { + return toErrorObject(value, fallbackMessage); +} + type BoundaryMode = "all" | "compile" | "canary"; type StepOutputCapture = { text: string; truncatedChars: number }; type CompileTiming = { extensionId: string; elapsedMs: number }; @@ -562,7 +567,7 @@ export function runNodeStepAsync( signalChild("SIGKILL"); await waitAfterForceKill(); rejectPromise( - toLintErrorObject( + coerceBoundaryError( attachStepFailureMetadata(new Error(`${label} canceled after sibling failure`), label, { kind: "canceled", elapsedMs: Date.now() - startedAt, @@ -621,7 +626,7 @@ export function runNodeStepAsync( ); onFailure?.(error); abortSiblingSteps(abortController); - rejectPromise(toLintErrorObject(error, "Step timed out")); + rejectPromise(coerceBoundaryError(error, "Step timed out")); })(); }, resolvedTimeoutMs); @@ -666,7 +671,7 @@ export function runNodeStepAsync( ); onFailure?.(failure); abortSiblingSteps(abortController); - rejectPromise(toLintErrorObject(failure, "Step spawn failed")); + rejectPromise(coerceBoundaryError(failure, "Step spawn failed")); }); child.on("close", (code) => { if (settled) { @@ -715,7 +720,7 @@ export function runNodeStepAsync( ); onFailure?.(error); abortSiblingSteps(abortController); - rejectPromise(toLintErrorObject(error, "Step failed")); + rejectPromise(coerceBoundaryError(error, "Step failed")); }); }); } @@ -750,7 +755,7 @@ export async function runNodeStepsWithConcurrency(steps: BoundaryStep[], concurr { concurrency, stopOnError: false }, ); if (firstFailure) { - throw toLintErrorObject(firstFailure, "Non-Error thrown"); + throw coerceBoundaryError(firstFailure, "Non-Error thrown"); } } @@ -1108,17 +1113,3 @@ export async function main(argv: string[] = process.argv.slice(2)) { if (import.meta.main) { await main(); } - -function toLintErrorObject(value: unknown, fallbackMessage: string) { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/scripts/close-duplicate-prs-after-merge.mjs b/scripts/close-duplicate-prs-after-merge.mjs index 9b8ba2620b10..18e35583161b 100644 --- a/scripts/close-duplicate-prs-after-merge.mjs +++ b/scripts/close-duplicate-prs-after-merge.mjs @@ -1,9 +1,7 @@ // Finds duplicate PRs after merge and closes overlapping candidates. import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; -function isRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +import { isRecord } from "./lib/record-shared.mjs"; function normalizeStringifiedOptionalString(value) { if ( typeof value !== "string" && diff --git a/scripts/control-ui-i18n.ts b/scripts/control-ui-i18n.ts index cee6089ea8e8..5bd68416bcfa 100644 --- a/scripts/control-ui-i18n.ts +++ b/scripts/control-ui-i18n.ts @@ -32,6 +32,7 @@ import { type TranslationBatchItem, type TranslationMap, } from "./lib/control-ui-i18n-sync-plan.ts"; +import { toErrorObject as toLintErrorObject } from "./lib/error-format.mts"; import { sleep } from "./lib/sleep.mjs"; import { resolveWindowsTaskkillPath } from "./lib/windows-taskkill.mjs"; @@ -1348,17 +1349,3 @@ if (isCliEntrypoint()) { process.exit(1); }); } - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/scripts/dev/realtime-talk-live-smoke.ts b/scripts/dev/realtime-talk-live-smoke.ts index 6a53a12c8f3b..3a02b35c4892 100644 --- a/scripts/dev/realtime-talk-live-smoke.ts +++ b/scripts/dev/realtime-talk-live-smoke.ts @@ -9,6 +9,7 @@ import { previewForDevToolLog, redactJsonValueForDevToolLog, } from "../lib/dev-tooling-safety.ts"; +import { toErrorObject as toLintErrorObject } from "../lib/error-format.mts"; const OPENAI_REALTIME_MODEL = process.env.OPENCLAW_REALTIME_OPENAI_MODEL?.trim() || "gpt-realtime-2.1"; @@ -1170,17 +1171,3 @@ export const testing = { transcriptIncludesMarker, usage, }; - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mts b/scripts/e2e/kitchen-sink-rpc-walk.mts index a1cc46660613..26572e5cce85 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mts +++ b/scripts/e2e/kitchen-sink-rpc-walk.mts @@ -17,6 +17,7 @@ import { createBoundedResponseTooLargeError, readBoundedResponseText, } from "../lib/bounded-response.mjs"; +import { toErrorObject as coerceKitchenSinkError } from "../lib/error-format.mts"; import { resolveWindowsPowerShellPath, resolveWindowsSystem32Path, @@ -331,7 +332,11 @@ async function findAvailableLoopbackPort(options: { createServer?: typeof net.cr return await new Promise((resolve, reject) => { const fail = (error: unknown) => { server.close?.(() => {}); - reject(toLintErrorObject(error, "Unable to reserve Kitchen Sink RPC loopback port")); + const reservationError: Error = coerceKitchenSinkError( + error, + "Unable to reserve Kitchen Sink RPC loopback port", + ); + reject(reservationError); }; server.once("error", fail); server.listen(0, "127.0.0.1", () => { @@ -340,7 +345,11 @@ async function findAvailableLoopbackPort(options: { createServer?: typeof net.cr const port = typeof address === "object" && address ? address.port : 0; server.close((error) => { if (error) { - reject(toLintErrorObject(error, "Unable to close Kitchen Sink RPC loopback port")); + const closeError: Error = coerceKitchenSinkError( + error, + "Unable to close Kitchen Sink RPC loopback port", + ); + reject(closeError); return; } if (!Number.isSafeInteger(port) || port <= 0) { @@ -582,9 +591,8 @@ export function runCommand( clearTimeout(forceKillTimer); forceKillAt = undefined; releaseCommandChild(child); - void stopResourceSampling().finally(() => - reject(toLintErrorObject(error, "Command failed before exit")), - ); + const commandError: Error = coerceKitchenSinkError(error, "Command failed before exit"); + void stopResourceSampling().finally(() => reject(commandError)); }); child.on("close", (status, signal) => { clearTimeout(timer); @@ -1182,7 +1190,7 @@ async function retryRpcCall(method: string, params: unknown, options: RpcCallOpt await delay(500); } } - throw toLintErrorObject( + throw coerceKitchenSinkError( lastError ?? new Error(`gateway RPC ${method} timed out before retry`), "Non-Error thrown", ); @@ -1297,7 +1305,7 @@ export async function fetchJson(url: string | URL, options: FetchJsonOptions = { } } } - throw toLintErrorObject(lastError ?? new Error(`fetch ${url} failed`), "Non-Error thrown"); + throw coerceKitchenSinkError(lastError ?? new Error(`fetch ${url} failed`), "Non-Error thrown"); } function getExternalAbortReason(signal: AbortSignal) { @@ -2997,17 +3005,3 @@ function isGatewayChild(value: unknown): value is GatewayChild { async function settlePendingSample(pending: Promise | null) { await pending?.catch(() => {}); } - -function toLintErrorObject(value: unknown, fallbackMessage: string) { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/scripts/embedded-run-abort-leak.ts b/scripts/embedded-run-abort-leak.ts index c534a8cf910c..d3725c390b99 100644 --- a/scripts/embedded-run-abort-leak.ts +++ b/scripts/embedded-run-abort-leak.ts @@ -20,6 +20,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as v8 from "node:v8"; import { expectDefined } from "../packages/normalization-core/src/expect.js"; +import { toErrorObject as toLintErrorObject } from "./lib/error-format.mts"; type Mode = "production" | "closure-extracted" | "closure-inline" | "synthetic-leak"; type Abortable = (signal: AbortSignal, promise: Promise) => Promise; @@ -417,17 +418,3 @@ main().catch((err: unknown) => { process.stderr.write(`harness crashed: ${String(err)}\n${(err as Error)?.stack ?? ""}\n`); process.exit(2); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/scripts/generate-prompt-snapshots.ts b/scripts/generate-prompt-snapshots.ts index 8f9d135036d4..59602892c4cc 100644 --- a/scripts/generate-prompt-snapshots.ts +++ b/scripts/generate-prompt-snapshots.ts @@ -9,6 +9,7 @@ import { CODEX_RUNTIME_HAPPY_PATH_PROMPT_SNAPSHOT_DIR, createHappyPathPromptSnapshotFiles, } from "../test/helpers/agents/happy-path-prompt-snapshots.js"; +import { coerceErrorMessage as describeError } from "./lib/error-format.mts"; import { deleteStalePromptSnapshotFiles, listCommittedSnapshotArtifactPaths, @@ -33,10 +34,6 @@ type CodexDynamicToolSnapshotOverrides = { const CODEX_DYNAMIC_TOOL_SNAPSHOT_PREFIX = "codex-dynamic-tools."; const CODEX_DYNAMIC_TOOL_BASE_SNAPSHOT = `${CODEX_DYNAMIC_TOOL_SNAPSHOT_PREFIX}telegram-direct.json`; -function describeError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - async function writeSnapshotFiles(root: string, files: PromptSnapshotFile[]) { await Promise.all( files.map(async (file) => { diff --git a/scripts/lib/cross-os-release-checks/shared.ts b/scripts/lib/cross-os-release-checks/shared.ts index fa345e0a67f5..5e2d348d11e7 100644 --- a/scripts/lib/cross-os-release-checks/shared.ts +++ b/scripts/lib/cross-os-release-checks/shared.ts @@ -1,6 +1,9 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { truncateUtf16Safe } from "../../../packages/normalization-core/src/utf16-slice.ts"; +import { toErrorObject as toLintErrorObject } from "../error-format.mts"; + +export { toLintErrorObject }; export function resolveCommandPath(command: string) { const pathValue = process.env.PATH ?? ""; @@ -44,17 +47,3 @@ export function sleep(ms: number) { setTimeout(resolvePromise, ms); }); } - -export function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/scripts/lib/error-format.mts b/scripts/lib/error-format.mts index 309a3310f4d4..ca608ce13e20 100644 --- a/scripts/lib/error-format.mts +++ b/scripts/lib/error-format.mts @@ -6,3 +6,23 @@ export function formatErrorMessage(error: unknown): string { } return String(error); } + +/** Read Error messages unchanged and stringify every other value. */ +export function coerceErrorMessage(value: unknown): string { + return value instanceof Error ? value.message : String(value); +} + +/** Preserve structured non-Error failures without requiring built workspace packages. */ +export function toErrorObject(value: unknown, fallbackMessage: string): Error { + if (value instanceof Error) { + return value; + } + if (typeof value === "string") { + return new Error(value); + } + const error = new Error(fallbackMessage, { cause: value }); + if ((typeof value === "object" && value !== null) || typeof value === "function") { + Object.assign(error, value); + } + return error; +} diff --git a/scripts/package-openclaw-for-docker.mts b/scripts/package-openclaw-for-docker.mts index d81d1d50ede8..e57001d1c114 100644 --- a/scripts/package-openclaw-for-docker.mts +++ b/scripts/package-openclaw-for-docker.mts @@ -27,6 +27,20 @@ const DEFAULT_CAPTURED_STDOUT_MAX_BYTES = 1024 * 1024; const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; const AI_RUNTIME_PACKAGE = "@openclaw/ai"; const AI_RUNTIME_BACKUP_DIR = ".openclaw-ai-package-backup"; + +function coercePackageError(value: unknown, fallbackMessage: string): Error { + if (value instanceof Error) { + return value; + } + if (typeof value === "string") { + return new Error(value); + } + const error = new Error(fallbackMessage, { cause: value }); + if ((typeof value === "object" && value !== null) || typeof value === "function") { + Object.assign(error, value); + } + return error; +} type KillChild = (signal: NodeJS.Signals) => void; type RunOptions = { captureStdout?: boolean; @@ -356,7 +370,7 @@ function run(command: string, args: string[], cwd: string, options: RunOptions = process.exit(forwardedSignalExitCode); } if (error) { - reject(toLintErrorObject(error, "Non-Error rejection")); + reject(coercePackageError(error, "Non-Error rejection")); return; } resolve(value); @@ -711,7 +725,7 @@ export async function prepareBundledAiRuntimePackage( originalAiRuntimeMoved = false; packedAiTarballs = []; if (cleanupError) { - throw toLintErrorObject(cleanupError, "Package cleanup failed."); + throw coercePackageError(cleanupError, "Package cleanup failed."); } }; @@ -764,11 +778,16 @@ export async function prepareBundledAiRuntimePackage( if (typeof version !== "string") { throw new Error(`packed @openclaw/ai dependency ${name} must declare a string version`); } - if (packageJson.dependencies?.[name] !== version) { + if (version === "0.0.0-private") { + continue; + } + const rootVersion = packageJson.dependencies?.[name]; + if (rootVersion !== version && rootVersion !== `workspace:${version}`) { throw new Error( `root package.json must declare ${name}@${version} to bundle @openclaw/ai without duplicate dependencies`, ); } + packageJson.dependencies![name] = version; } // Root owns these exact dependencies. Removing them from the staged copy keeps npm from // recursively bundling duplicate packages alongside the one private workspace runtime. @@ -1031,17 +1050,3 @@ if ( process.exit(typeof exitCode === "number" && Number.isInteger(exitCode) ? exitCode : 1); }); } - -function toLintErrorObject(value: unknown, fallbackMessage: string) { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/scripts/periphery-intersection.mjs b/scripts/periphery-intersection.mjs index 84061d9904d4..7223908f0f2e 100644 --- a/scripts/periphery-intersection.mjs +++ b/scripts/periphery-intersection.mjs @@ -2,10 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; +import { isRecord } from "./lib/record-shared.mjs"; -function isRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); -} const SHARED_LOCATION_PREFIX = "../shared/OpenClawKit/Sources/"; const SHARED_SOURCE_ROOT = "apps/shared/OpenClawKit/Sources"; const BARE_PERIPHERY_IGNORE_COMMENT = /\/\/\/?\s*periphery:ignore(?![:\w])/; diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index f1fdc8b16f46..6c903efe2bf9 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -269,7 +269,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // including the side-question compatibility split. // +1: add the account-aware native approval request selector. // +3: add canonical coercion exports while retaining the shipped asString compatibility name. - 4866, + // +2: add high-use coercion primitives while retaining shipped object-record exports. + 4868, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -331,7 +332,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +1: channel-account-bound native approval request selection. // +1: add the account-aware native approval request selector. // +3: add canonical coercion exports while retaining the shipped asString compatibility name. - 2922, + // +2: add high-use callable coercion primitives while retaining shipped object-record exports. + 2924, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( @@ -355,7 +357,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // -1: text-runtime now names its global-singleton exports explicitly. // -1: infra-runtime now names its error exports explicitly. // -1: infra-runtime excludes the internal system-event receipt API. - 79, + // -2: text-runtime names record and string coercion compatibility exports explicitly. + 77, env, ), }; diff --git a/scripts/release-preflight.mts b/scripts/release-preflight.mts index 1387d9044ff1..70af506dc182 100644 --- a/scripts/release-preflight.mts +++ b/scripts/release-preflight.mts @@ -2,6 +2,7 @@ // Checks or refreshes generated release artifacts before a release publish. import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { coerceErrorMessage as formatError } from "./lib/error-format.mts"; import { runManagedCommand } from "./lib/managed-child-process.mts"; import { parseReleaseVersion } from "./lib/release-version.mjs"; @@ -253,10 +254,6 @@ function readPlistString(infoPlist: string, key: string) { return { value: matches[0]![1]?.trim() ?? "" }; } -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - async function runTaskGraph({ commandKey, jobs, diff --git a/scripts/resolve-openclaw-package-candidate.mts b/scripts/resolve-openclaw-package-candidate.mts index cd1131d5e728..e6f10c44a821 100644 --- a/scripts/resolve-openclaw-package-candidate.mts +++ b/scripts/resolve-openclaw-package-candidate.mts @@ -18,9 +18,14 @@ import path from "node:path"; import { pipeline } from "node:stream/promises"; import { fileURLToPath } from "node:url"; import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mts"; +import { toErrorObject } from "./lib/error-format.mts"; import { resolveNpmJsonEntries } from "./lib/npm-json-output.mts"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; +function coercePackageCandidateError(value: unknown, fallbackMessage: string): Error { + return toErrorObject(value, fallbackMessage); +} + function isJsonRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -393,7 +398,7 @@ function run(command: string, args: readonly string[], options: RunOptions = {}) } child.on("error", (error: Error) => { ACTIVE_CHILD_KILLERS.delete(killChild); - reject(toLintErrorObject(error, "Non-Error rejection")); + reject(coercePackageCandidateError(error, "Non-Error rejection")); }); child.on("close", (status: number | null, signal: ChildSignal) => { if (timeout) { @@ -1567,7 +1572,7 @@ async function* limitWebResponseBody( const next = reader.read(); const { done, value } = timeoutRead ? await Promise.race([next, timeoutRead]) : await next; if (timedOut) { - throw toLintErrorObject(timeoutFailure, "package_url download timed out"); + throw coercePackageCandidateError(timeoutFailure, "package_url download timed out"); } if (done) { return; @@ -1930,17 +1935,3 @@ function isPropertyContainer(value: unknown): value is { code?: unknown; name?: function isWebResponseBody(body: PackageResponseBody): body is WebResponseBody { return "getReader" in body && typeof body.getReader === "function"; } - -function toLintErrorObject(value: unknown, fallbackMessage: string) { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/scripts/test-group-report.mts b/scripts/test-group-report.mts index 41349b4d8798..f52bd851b32f 100644 --- a/scripts/test-group-report.mts +++ b/scripts/test-group-report.mts @@ -6,6 +6,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import pMap from "p-map"; +import { coerceErrorMessage as formatSpawnError } from "./lib/error-format.mts"; import { parsePositiveInt } from "./lib/numeric-options.mjs"; import { buildGroupedTestComparison, @@ -334,10 +335,6 @@ function parseMaxRssBytes(output: string) { return null; } -function formatSpawnError(error: unknown) { - return error instanceof Error ? error.message : String(error); -} - function hasErrorCode(error: unknown, code: string) { return isRecord(error) && error.code === code; } diff --git a/scripts/test-perf-budget.mts b/scripts/test-perf-budget.mts index 877cb919d1b4..33eef7a2cf87 100644 --- a/scripts/test-perf-budget.mts +++ b/scripts/test-perf-budget.mts @@ -7,6 +7,7 @@ import { parseBudgetNumber, readBudgetEnvNumber, } from "./lib/budget-number-args.mts"; +import { coerceErrorMessage as formatErrorMessage } from "./lib/error-format.mts"; import { formatMs } from "./lib/vitest-report-cli-utils.mts"; import { readJsonFile, runVitestJsonReport } from "./test-report-utils.mts"; @@ -78,10 +79,6 @@ function parseArgs(argv: readonly string[], env = process.env) { return opts; } -function formatErrorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error); -} - function collectPerfReportStats(reportPath: string) { let report: unknown; try { diff --git a/scripts/watch-node.mts b/scripts/watch-node.mts index dc8d7edfd2e0..4d13cb454ab7 100644 --- a/scripts/watch-node.mts +++ b/scripts/watch-node.mts @@ -6,6 +6,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; +import { toErrorObject } from "./lib/error-format.mts"; import { sleep } from "./lib/sleep.mjs"; import { createRunNodePathClassifier, runNodeWatchedPaths } from "./run-node-watch-paths.mts"; @@ -24,6 +25,10 @@ const AUTO_DOCTOR_DISABLE_VALUES = new Set(["0", "false", "no", "off"]); type ProcessSignal = `SIG${string}`; type TimerHandle = ReturnType; +function coerceWatchError(value: unknown, fallbackMessage: string): Error { + return toErrorObject(value, fallbackMessage); +} + type WatchChild = { pid?: number; kill(signal?: ProcessSignal | number): boolean | void; @@ -538,7 +543,7 @@ export async function runWatchMain(params: WatchMainParams = {}): Promise { @@ -761,20 +766,6 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { }); } -function toLintErrorObject(value: unknown, fallbackMessage: string) { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} - function errorCode(error: unknown): unknown { return typeof error === "object" && error !== null && "code" in error ? error.code : undefined; } diff --git a/src/agents/agent-hooks/compaction-safeguard.ts b/src/agents/agent-hooks/compaction-safeguard.ts index 636e1a1ff647..641bb2a80a66 100644 --- a/src/agents/agent-hooks/compaction-safeguard.ts +++ b/src/agents/agent-hooks/compaction-safeguard.ts @@ -107,8 +107,7 @@ function prependPreviousSummaryForRedistill(params: { } function coerceTimestamp(value: unknown): number { - const timestamp = typeof value === "string" ? Date.parse(value) : value; - return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : 0; + return parseDateFirstTimestampMs(value) ?? 0; } function sessionBranchEntryToMessage(entry: Record): unknown { @@ -1283,3 +1282,4 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") { testing; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ +import { parseDateFirstTimestampMs } from "@openclaw/normalization-core/number-coercion"; diff --git a/src/agents/agent-run-terminal-outcome.ts b/src/agents/agent-run-terminal-outcome.ts index 73d80c6a64ef..6052d7a04dfa 100644 --- a/src/agents/agent-run-terminal-outcome.ts +++ b/src/agents/agent-run-terminal-outcome.ts @@ -1,5 +1,6 @@ /** Normalizes agent run wait/liveness/timeout metadata into sticky terminal outcomes. */ import { asFiniteNumber as asFiniteTimestamp } from "@openclaw/normalization-core/number-coercion"; +import { readNonBlankString as asNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { formatAbandonedLivenessError, formatBlockedLivenessError, @@ -458,10 +459,6 @@ export const AGENT_RUN_TERMINAL_RETRY_GRACE_MS = 15_000; const HARD_TIMEOUT_PHASES = new Set(["preflight", "provider", "post_turn"]); -function asNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - /** True when a timeout phase should be treated as a hard agent-run timeout. */ function isHardAgentRunTimeoutPhase(value: unknown): value is AgentRunTimeoutPhase { const phase = normalizeAgentRunTimeoutPhase(value); diff --git a/src/agents/agent-tools.before-tool-call.approval.ts b/src/agents/agent-tools.before-tool-call.approval.ts index 4681c5eedd0a..887cbc6bf956 100644 --- a/src/agents/agent-tools.before-tool-call.approval.ts +++ b/src/agents/agent-tools.before-tool-call.approval.ts @@ -357,10 +357,11 @@ async function requestPluginToolApproval(params: { let onAbort: (() => void) | undefined; const abortPromise = new Promise((_, reject) => { if (params.signal!.aborted) { - reject(toLintErrorObject(params.signal!.reason, "Non-Error rejection")); + reject(toApprovalErrorObject(params.signal!.reason, "Non-Error rejection")); return; } - onAbort = () => reject(toLintErrorObject(params.signal!.reason, "Non-Error rejection")); + onAbort = () => + reject(toApprovalErrorObject(params.signal!.reason, "Non-Error rejection")); params.signal!.addEventListener("abort", onAbort, { once: true }); }); try { @@ -574,7 +575,7 @@ export async function resolveSkillWorkshopApprovalForFinalParams(params: { // Success output schemas do not describe policy-layer terminal results. Track // identity so catalog boundaries can reject them without trusting spoofable status fields. -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { +function toApprovalErrorObject(value: unknown, fallbackMessage: string): Error { if (value instanceof Error) { return value; } diff --git a/src/agents/api-key-rotation.ts b/src/agents/api-key-rotation.ts index f5c0814ef045..bbcc9b57e6b2 100644 --- a/src/agents/api-key-rotation.ts +++ b/src/agents/api-key-rotation.ts @@ -3,6 +3,7 @@ * Runs provider calls across configured keys on rate-limit failures and keeps * same-key transient retries separate from key rotation. */ +import { toErrorObject as toLintErrorObject } from "@openclaw/normalization-core/error-coercion"; import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; import { sleepWithAbort } from "../infra/backoff.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -107,19 +108,3 @@ export async function executeWithApiKeyRotation( } throw toLintErrorObject(lastError, "Non-Error thrown"); } - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - // Preserve thrown object properties for callers/tests while still satisfying - // Error-only throw lint expectations. - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/src/agents/compaction-usage.ts b/src/agents/compaction-usage.ts index e4993ce034f2..add95cac732d 100644 --- a/src/agents/compaction-usage.ts +++ b/src/agents/compaction-usage.ts @@ -6,8 +6,7 @@ import type { AgentMessage } from "./runtime/index.js"; import { makeZeroUsageSnapshot } from "./usage.js"; function parseCompactionUsageTimestamp(value: unknown): number | null { - const timestamp = typeof value === "string" ? Date.parse(value) : value; - return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : null; + return parseDateFirstTimestampMs(value) ?? null; } export function stripStaleAssistantUsageBeforeLatestCompaction( @@ -60,3 +59,4 @@ export function stripStaleAssistantUsageBeforeLatestCompaction { expect((timeoutError as Error).message).toMatch(/LLM idle timeout/); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - // Abort reasons can be arbitrary values; normalize them into Error objects - // so rejection assertions and provider wrappers see a stable shape. - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/src/agents/embedded-agent-runner/thinking.ts b/src/agents/embedded-agent-runner/thinking.ts index 7abf950f8d89..b04a80635d3d 100644 --- a/src/agents/embedded-agent-runner/thinking.ts +++ b/src/agents/embedded-agent-runner/thinking.ts @@ -95,16 +95,7 @@ function buildOmittedAssistantReasoningContent(): AssistantContentBlock[] { } function parseTimestampMs(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Date.parse(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return null; + return parseDateFirstTimestampMs(value) ?? null; } function stripSignatureFieldsFromThinkingBlock( @@ -765,3 +756,4 @@ export function wrapAnthropicStreamWithRecovery( return createRecoveryStream(stream, requestMeta, retry, notify); }; } +import { parseDateFirstTimestampMs } from "@openclaw/normalization-core/number-coercion"; diff --git a/src/agents/harness/native-hook-relay-utils.ts b/src/agents/harness/native-hook-relay-utils.ts index e1934ce1094a..e69e8ba6ab52 100644 --- a/src/agents/harness/native-hook-relay-utils.ts +++ b/src/agents/harness/native-hook-relay-utils.ts @@ -1,3 +1,4 @@ +import { readNonEmptyStringPreservingWhitespace as readOptionalNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe, truncateWithMarker } from "@openclaw/normalization-core/utf16-slice"; import type { JsonValue, @@ -66,9 +67,7 @@ export function readNonEmptyString(value: unknown, name: string): string { throw new Error(`native hook relay ${name} is required`); } -export function readOptionalNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} +export { readOptionalNonEmptyString }; export function readOptionalBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; diff --git a/src/agents/session-activity-notes.ts b/src/agents/session-activity-notes.ts index 89b0a0241727..d381f09033db 100644 --- a/src/agents/session-activity-notes.ts +++ b/src/agents/session-activity-notes.ts @@ -1,3 +1,4 @@ +import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../auto-reply/heartbeat.js"; import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; @@ -327,10 +328,6 @@ export function noteSessionActivityEvent( } } -function readNonBlankString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - export function readFiniteNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } diff --git a/src/agents/subagents/announce/subagent-announce.live.test.ts b/src/agents/subagents/announce/subagent-announce.live.test.ts index d9667aa4492c..273e90e08e76 100644 --- a/src/agents/subagents/announce/subagent-announce.live.test.ts +++ b/src/agents/subagents/announce/subagent-announce.live.test.ts @@ -3,6 +3,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { toErrorObject as toLintErrorObject } from "@openclaw/normalization-core/error-coercion"; import { afterEach, describe, expect, it } from "vitest"; import { clearRuntimeConfigSnapshot, type OpenClawConfig } from "../../../config/config.js"; import { callGateway as realCallGateway } from "../../../gateway/call.js"; @@ -797,17 +798,3 @@ describeLive("subagent announce live", () => { 12 * 60_000, ); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/src/audit/agent-event-audit.ts b/src/audit/agent-event-audit.ts index 34f382b3bc43..d1194879199f 100644 --- a/src/audit/agent-event-audit.ts +++ b/src/audit/agent-event-audit.ts @@ -1,7 +1,10 @@ /** Redaction-safe projection from live agent events into durable audit metadata. */ import { createHash } from "node:crypto"; import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; -import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { + normalizeOptionalLowercaseString, + readNonEmptyStringPreservingWhitespace as nonEmptyString, +} from "@openclaw/normalization-core/string-coerce"; import { AGENT_RUN_TERMINAL_RETRY_GRACE_MS, buildAgentRunTerminalOutcomeFromLifecycleEvent, @@ -30,10 +33,6 @@ export type AgentEventAuditRecorder = { stop: () => Promise; }; -function nonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - function auditToolName(value: unknown): string | undefined { const toolName = nonEmptyString(value)?.trim(); if (!toolName) { diff --git a/src/auto-reply/reply-payload.ts b/src/auto-reply/reply-payload.ts index 251f077876eb..300cfdb46ccb 100644 --- a/src/auto-reply/reply-payload.ts +++ b/src/auto-reply/reply-payload.ts @@ -1,3 +1,5 @@ +import { asPositiveFiniteNumber as normalizePairingQrExpiresAtMs } from "@openclaw/normalization-core/number-coercion"; +import { readNonBlankString as normalizeTtsSupplementSpokenText } from "@openclaw/normalization-core/string-coerce"; import type { OutboundLocation } from "../channels/location.js"; /** Reply payload contracts and metadata helpers shared by dispatch and channel renderers. */ import type { ReplyToMode } from "../config/types.base.js"; @@ -108,10 +110,6 @@ function normalizePairingQrSetupCode(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } -function normalizePairingQrExpiresAtMs(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; -} - export function readPairingQrReplyChannelData( payload: Pick, ): PairingQrReplyChannelData | undefined { @@ -157,10 +155,6 @@ export function appendReplyMediaFailureWarning(text: string | undefined): string return `${text}\n${REPLY_MEDIA_FAILURE_WARNING}`; } -function normalizeTtsSupplementSpokenText(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - function hasReplyPayloadMedia(payload: Pick): boolean { return Boolean(payload.mediaUrl?.trim() || payload.mediaUrls?.some((url) => url.trim())); } diff --git a/src/cli/devices-cli.runtime.ts b/src/cli/devices-cli.runtime.ts index 1f49c944cd51..3363af9e2b48 100644 --- a/src/cli/devices-cli.runtime.ts +++ b/src/cli/devices-cli.runtime.ts @@ -1,4 +1,5 @@ // Device pairing runtime commands for gateway and loopback-local fallback operations. +import { coerceErrorMessage as normalizeErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -275,13 +276,6 @@ async function findQueryPendingNodeApprovalNotices( ); } -function normalizeErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - function isDevicePairingApprovalDenied(error: unknown): boolean { return normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error)).includes( "device pairing approval denied", diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index 41cd61f50c43..fdf8003042c9 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -1,6 +1,7 @@ // Gateway logs CLI with RPC tailing, local file fallback, and systemd journal fallback. import { setTimeout as delay } from "node:timers/promises"; import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; +import { coerceErrorMessage as normalizeErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; @@ -204,13 +205,6 @@ async function fetchLogs( } } -function normalizeErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - function normalizeError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index ec2972a5e223..9dbbfc8a3336 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -273,11 +273,12 @@ vi.mock("../process/exec.js", () => ({ vi.mock("../utils.js", async (importOriginal) => { const actual = await importOriginal(); + const isMockRecord = (value: unknown) => + typeof value === "object" && value !== null && !Array.isArray(value); return { ...actual, displayString: (input: string) => input, - isRecord: (value: unknown) => - typeof value === "object" && value !== null && !Array.isArray(value), + isRecord: isMockRecord, pathExists: (...args: unknown[]) => pathExists(...args), resolveConfigDir: () => "/tmp/openclaw-config", sleep: vi.fn(async () => undefined), diff --git a/src/commands/doctor-auth-flat-profiles.ts b/src/commands/doctor-auth-flat-profiles.ts index beac8c9cac30..4bd3618b3e79 100644 --- a/src/commands/doctor-auth-flat-profiles.ts +++ b/src/commands/doctor-auth-flat-profiles.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonBlankString as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; import { resolveAgentDir, resolveDefaultAgentDir, listAgentIds } from "../agents/agent-scope.js"; import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js"; @@ -114,10 +115,6 @@ type LegacyFlatAuthProfileRepairResult = { const UNSAFE_LEGACY_AUTH_PROFILE_KEYS = new Set(["__proto__", "constructor", "prototype"]); -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - function isSafeLegacyProviderKey(key: string): boolean { return key.trim().length > 0 && !UNSAFE_LEGACY_AUTH_PROFILE_KEYS.has(key); } diff --git a/src/commands/doctor-auth-migration-receipts.ts b/src/commands/doctor-auth-migration-receipts.ts index a639a2ac1a45..2b6b1f24154d 100644 --- a/src/commands/doctor-auth-migration-receipts.ts +++ b/src/commands/doctor-auth-migration-receipts.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { isStringRecord as isRecordOfStrings } from "@openclaw/normalization-core/record-coerce"; import { acquireFileLockSyncWithRetry } from "../infra/file-lock-sync.js"; import { executeSqliteQuerySync, @@ -429,15 +430,6 @@ export function resumePendingAuthProfileMigrationArchives(env?: NodeJS.ProcessEn return changes; } -function isRecordOfStrings(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value).every((entry) => typeof entry === "string") - ); -} - export function hasTerminalAuthProfileMigrationReceipt( sourceKey: string, env?: NodeJS.ProcessEnv, diff --git a/src/commands/doctor-auth-oauth-sidecar.ts b/src/commands/doctor-auth-oauth-sidecar.ts index 5a20da547c29..1ba7741b448c 100644 --- a/src/commands/doctor-auth-oauth-sidecar.ts +++ b/src/commands/doctor-auth-oauth-sidecar.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonBlankString as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; import { listAgentIds, resolveAgentDir, resolveDefaultAgentDir } from "../agents/agent-scope.js"; import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js"; @@ -50,10 +51,6 @@ type LegacyOAuthSidecarRepairResult = { warnings: string[]; }; -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - function addCandidate( candidates: Map, agentDir: string | undefined, diff --git a/src/commands/doctor-plugin-host-links.ts b/src/commands/doctor-plugin-host-links.ts index 795d70691d46..c96bcb8c1c3a 100644 --- a/src/commands/doctor-plugin-host-links.ts +++ b/src/commands/doctor-plugin-host-links.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { coerceErrorMessage as formatPackageReadFailure } from "@openclaw/normalization-core/error-coercion"; import { note } from "../../packages/terminal-core/src/note.js"; import { formatCliCommand } from "../cli/command-format.js"; import { @@ -35,9 +36,6 @@ type PluginHostLinkAudit = { registeredPackageReadFailures: PluginPackageReadFailure[]; }; -const formatPackageReadFailure = (error: unknown): string => - error instanceof Error ? error.message : String(error); - function resolveRegisteredPluginExtensionsRoot( params: InstalledPluginIndexRecordStoreOptions, ): string { diff --git a/src/commands/doctor/shared/legacy-oauth-sidecar.ts b/src/commands/doctor/shared/legacy-oauth-sidecar.ts index af803679bee5..eea4bd80a0af 100644 --- a/src/commands/doctor/shared/legacy-oauth-sidecar.ts +++ b/src/commands/doctor/shared/legacy-oauth-sidecar.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonBlankString as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { authProfilesLog } from "../../../agents/auth-profiles/constants.js"; import { LEGACY_OAUTH_REF_PROVIDER } from "../../../agents/auth-profiles/legacy-oauth-ref.js"; @@ -40,10 +41,6 @@ type LegacyOAuthEncryptedPayload = { ciphertext: string; }; -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - /** Resolve the legacy OAuth sidecar JSON path for an auth profile ref. */ export function resolveLegacyOAuthSidecarPath( ref: LegacyOAuthRef, diff --git a/src/commands/doctor/shared/pristine-startup-state.ts b/src/commands/doctor/shared/pristine-startup-state.ts index 786088720a30..7c5102617d71 100644 --- a/src/commands/doctor/shared/pristine-startup-state.ts +++ b/src/commands/doctor/shared/pristine-startup-state.ts @@ -1,7 +1,7 @@ // Proves when a new state root cannot contain legacy state migration work. import fs from "node:fs"; import path from "node:path"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { isRecord, isStringRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveConfigPath, resolveLegacyStateDirs, @@ -86,9 +86,7 @@ function hasOnlyMigrationSafeInternalHooks(config: Record): boo if (entry.env === undefined) { return true; } - return ( - isRecord(entry.env) && Object.values(entry.env).every((value) => typeof value === "string") - ); + return isStringRecord(entry.env); }); } diff --git a/src/commands/export-trajectory.ts b/src/commands/export-trajectory.ts index 33f3ab074496..102aabc0894a 100644 --- a/src/commands/export-trajectory.ts +++ b/src/commands/export-trajectory.ts @@ -1,5 +1,6 @@ /** CLI command for exporting a session transcript as a trajectory artifact. */ import path from "node:path"; +import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; import { formatCliCommand } from "../cli/command-format.js"; import { getRuntimeConfig } from "../config/config.js"; import { resolveStorePath } from "../config/sessions/paths.js"; @@ -36,10 +37,6 @@ type EncodedExportTrajectoryRequest = { const ENCODED_EXPORT_REQUEST_RE = /^[A-Za-z0-9_-]{1,65536}$/u; -function readNonBlankString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; -} - function decodeExportTrajectoryRequest(encoded: string): Partial { if (!ENCODED_EXPORT_REQUEST_RE.test(encoded)) { throw new Error("Encoded trajectory export request is invalid"); diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 23192f9603c2..88a90b522865 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { toErrorObject as toLintErrorObject } from "@openclaw/normalization-core/error-coercion"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; @@ -829,17 +830,3 @@ describe("models list/status", () => { expect(row.available).toBeNull(); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/src/commands/models/list.persisted-catalog.ts b/src/commands/models/list.persisted-catalog.ts index 74da7a985bc2..a872195eda3d 100644 --- a/src/commands/models/list.persisted-catalog.ts +++ b/src/commands/models/list.persisted-catalog.ts @@ -1,5 +1,6 @@ /** Reads persisted generated catalogs without constructing a model registry. */ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { asPositiveFiniteNumber as readPositiveNumber } from "@openclaw/normalization-core/number-coercion"; 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"; @@ -14,10 +15,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 readPositiveNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; -} - function readModelApi(value: unknown): ModelApi | undefined { const api = readString(value); return api && modelApis.has(api) ? (api as ModelApi) : undefined; diff --git a/src/config/sessions/transcript-tree.ts b/src/config/sessions/transcript-tree.ts index 81f8faa7a8db..311f2321fee9 100644 --- a/src/config/sessions/transcript-tree.ts +++ b/src/config/sessions/transcript-tree.ts @@ -1,5 +1,6 @@ // Transcript tree helpers keep append-only leaf controls consistent across readers. import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonBlankString as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; type TranscriptRecord = Record; @@ -27,10 +28,6 @@ export type SessionTranscriptTree = { hasInvalidLeafControl: boolean; }; -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; -} - function isCanonicalSessionEntryType(value: unknown): boolean { switch (value) { case "message": diff --git a/src/cron/isolated-agent/run-finalize.ts b/src/cron/isolated-agent/run-finalize.ts index 4c5c5874f5bf..e9a42c9dfece 100644 --- a/src/cron/isolated-agent/run-finalize.ts +++ b/src/cron/isolated-agent/run-finalize.ts @@ -1,4 +1,5 @@ /** Final persistence, telemetry, and delivery for an isolated cron run. */ +import { asPositiveFiniteNumber as resolvePositiveContextTokens } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { hasAcceptedSessionSpawn } from "../../agents/accepted-session-spawn.js"; import { hasCommittedMessagingToolDeliveryEvidence } from "../../agents/embedded-agent-runner/delivery-evidence.js"; @@ -39,10 +40,6 @@ type CronExecutionResult = Awaited import("./run-context.runtime.js")); -function resolvePositiveContextTokens(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; -} - export async function finalizeCronRun(params: { prepared: PreparedCronRunContext; execution: CronExecutionResult; diff --git a/src/fleet/containers.runtime.ts b/src/fleet/containers.runtime.ts index f7ed119cc6ed..bdc0464deaea 100644 --- a/src/fleet/containers.runtime.ts +++ b/src/fleet/containers.runtime.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { isRecord, isStringRecord } from "@openclaw/normalization-core/record-coerce"; import { attachChildProcessBridge } from "../process/child-process-bridge.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { @@ -164,13 +164,10 @@ function readStringRecord(value: unknown): Record { if (value === undefined || value === null) { return {}; } - const record = requireRecord(value); - for (const entry of Object.values(record)) { - if (typeof entry !== "string") { - throw new InvalidInspectOutputError(); - } + if (!isStringRecord(value)) { + throw new InvalidInspectOutputError(); } - return record as Record; + return value; } function readStringArray(value: unknown): string[] { diff --git a/src/gateway/agent-turn/agent-job.ts b/src/gateway/agent-turn/agent-job.ts index 06cc7fad6f73..94c33894226e 100644 --- a/src/gateway/agent-turn/agent-job.ts +++ b/src/gateway/agent-turn/agent-job.ts @@ -2,6 +2,7 @@ // Gateway dedupe retains response payloads only for idempotent RPC replay. import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; import { normalizeAgentRunTerminalDeliverySnapshot, type AgentRunTerminalDeliverySnapshot, @@ -380,10 +381,6 @@ function ensureAgentRunListener() { }); } -function readNonBlankString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - function parseDedupeObservation(entry: DedupeEntry): DedupeObservation { const payload = entry.payload as | { diff --git a/src/gateway/managed-image-attachments.ts b/src/gateway/managed-image-attachments.ts index a3fdf886137a..512bc66e7f3e 100644 --- a/src/gateway/managed-image-attachments.ts +++ b/src/gateway/managed-image-attachments.ts @@ -9,6 +9,7 @@ import { mimeTypeFromFilePath } from "@openclaw/media-core/mime"; import { expectDefined } from "@openclaw/normalization-core"; import { asDateTimestampMs, + asNonNegativeFiniteNumber, resolveTimestampMsToIsoString, } from "@openclaw/normalization-core/number-coercion"; import pLimit from "p-limit"; @@ -316,10 +317,6 @@ function maxBytesForManagedMediaKind( return kind === "image" ? imageLimits.maxBytes : maxBytesForKind(kind); } -function asNonNegativeFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} - function createManagedMediaByteLimitError(params: { kind: ManagedMediaKind; label: string; diff --git a/src/gateway/server-methods/agent.test-harness.ts b/src/gateway/server-methods/agent.test-harness.ts index 010bfa3c4442..73dd98b25360 100644 --- a/src/gateway/server-methods/agent.test-harness.ts +++ b/src/gateway/server-methods/agent.test-harness.ts @@ -1,6 +1,7 @@ // Agent method tests cover run/steer/reset/wait behavior, task/subagent state, // approval followups, lifecycle hooks, and emitted gateway events. import { expectDefined } from "@openclaw/normalization-core"; +import { toErrorObject as toLintErrorObject } from "@openclaw/normalization-core/error-coercion"; import { expect, vi } from "vitest"; import type { readAcpSessionMeta } from "../../acp/runtime/session-meta.js"; import type { AgentInternalEvent } from "../../agents/internal-events.js"; @@ -910,20 +911,6 @@ export async function invokeAgentIdentityGet( return respond; } -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} - /** * Keep subagent registry dependencies deterministic across gateway tests. * Real ended-run hooks load a plugin bundle in the background, which can diff --git a/src/gateway/server-methods/attachment-normalize.ts b/src/gateway/server-methods/attachment-normalize.ts index 45869c3492ed..a02837f36470 100644 --- a/src/gateway/server-methods/attachment-normalize.ts +++ b/src/gateway/server-methods/attachment-normalize.ts @@ -1,5 +1,6 @@ // Attachment normalization accepts permissive RPC attachment payloads and turns // them into the bounded chat attachment shape used by gateway chat methods. +import { asNonNegativeFiniteNumber as normalizeAttachmentNumber } from "@openclaw/normalization-core/number-coercion"; import type { ChatAttachment } from "../chat-attachments.js"; /** RPC attachment payload shape accepted by chat-like gateway methods. */ @@ -30,10 +31,6 @@ function normalizeAttachmentContent(content: unknown): string | undefined { return undefined; } -function normalizeAttachmentNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} - /** Convert permissive RPC attachment payloads into the bounded chat attachment shape. */ export function normalizeRpcAttachmentsToChatAttachments( attachments: RpcAttachmentInput[] | undefined, diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index b5ce13630069..4a1355dec4d3 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; +import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import { @@ -320,11 +321,7 @@ const DREAMING_ENTRY_LIST_LIMIT = 8; // Keep malformed persisted timestamps behind valid entries; returning NaN here // makes Array.sort preserve arbitrary input order and can hide valid diagnostics. function parseDreamingTimestampMs(value: string | undefined): number { - if (!value) { - return Number.NEGATIVE_INFINITY; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY; + return parseDateStringTimestampMs(value) ?? Number.NEGATIVE_INFINITY; } function compareDreamingEntryByRecency( diff --git a/src/gateway/server-methods/system-changes.ts b/src/gateway/server-methods/system-changes.ts index b93b5fba4686..ba53012d5820 100644 --- a/src/gateway/server-methods/system-changes.ts +++ b/src/gateway/server-methods/system-changes.ts @@ -1,4 +1,5 @@ // Reads the bounded system/config journals as one admin-facing change history. +import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { ErrorCodes, errorShape, @@ -117,8 +118,7 @@ function transitionKey(before: string | null | undefined, after: string | null | } function recordTime(value: string, fallback: number): number { - const timestamp = Date.parse(value); - return Number.isFinite(timestamp) ? timestamp : fallback; + return parseDateStringTimestampMs(value) ?? fallback; } function classifyConfigWriteSource(record: Extract) { diff --git a/src/gateway/session-transcript-json.ts b/src/gateway/session-transcript-json.ts index 9971d82be76e..f818819b1839 100644 --- a/src/gateway/session-transcript-json.ts +++ b/src/gateway/session-transcript-json.ts @@ -1,9 +1,10 @@ // Shared bounded JSONL metadata parsing for gateway transcript readers. +import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; import { escapeRegExp } from "../shared/regexp.js"; /** Reads a nonblank transcript field while preserving its original whitespace. */ export function readNonBlankStringPreservingWhitespace(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; + return readNonBlankString(value); } // Transcript readers repeatedly extract a fixed set of metadata fields from diff --git a/src/gateway/session-transcript-readers.ts b/src/gateway/session-transcript-readers.ts index 62fc8a1642db..ddc24b361187 100644 --- a/src/gateway/session-transcript-readers.ts +++ b/src/gateway/session-transcript-readers.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { parseDateFirstTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { isSessionTranscriptProjectionUnavailableError, readRecentSessionTranscriptMessageEvents, @@ -96,10 +97,7 @@ function archivedTranscriptReader(target: ResolvedTranscriptReadTarget): Archive } function readTranscriptRecordTimestampMs(event: Record): number | undefined { - const raw = event.timestamp; - const timestampMs = - typeof raw === "string" ? Date.parse(raw) : typeof raw === "number" ? raw : Number.NaN; - return Number.isFinite(timestampMs) ? timestampMs : undefined; + return parseDateFirstTimestampMs(event.timestamp); } function extractMessageRecord( diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index f778bbec1a1c..99b7615813da 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import readline from "node:readline"; import { expectDefined } from "@openclaw/normalization-core"; import { + asPositiveFiniteNumber as resolvePositiveUsageNumber, resolveIntegerOption, resolveNonNegativeIntegerOption, } from "@openclaw/normalization-core/number-coercion"; @@ -971,10 +972,6 @@ function extractTranscriptUsageCost(raw: unknown): number | undefined { return typeof total === "number" && Number.isFinite(total) && total >= 0 ? total : undefined; } -function resolvePositiveUsageNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; -} - function extractTranscriptContentEstimatedChars(content: unknown): number { if (typeof content === "string") { const normalized = stripInlineDirectiveTagsForDisplay(content).text.trim(); diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index 569957d55c67..673ecbe04584 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -1,6 +1,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, + readNonBlankString, } from "@openclaw/normalization-core/string-coerce"; import type { FinalizedMsgContext } from "../auto-reply/templating.js"; import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; @@ -101,10 +102,6 @@ type CanonicalSentMessageHookContext = { groupId?: string; }; -function readNonBlankString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; -} - function assignRemoteMediaStagingMetadata( target: Record, canonical: CanonicalInboundMessageHookContext, diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 1852cf981336..c39fd6e30c31 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -1,3 +1,4 @@ +import { toErrorObject as toLintErrorObject } from "@openclaw/normalization-core/error-coercion"; // Guarded fetch SSRF tests cover redirect hardening, pinned dispatcher setup, // trusted proxy modes, and safe header retention. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; @@ -2489,17 +2490,4 @@ describe("fetchWithSsrFGuard hardening", () => { }); }); -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/infra/provider-usage.fetch.shared.ts b/src/infra/provider-usage.fetch.shared.ts index 11fe36777f86..c5606dacac72 100644 --- a/src/infra/provider-usage.fetch.shared.ts +++ b/src/infra/provider-usage.fetch.shared.ts @@ -4,7 +4,6 @@ import { resolveTimerTimeoutMs, } from "@openclaw/normalization-core/number-coercion"; import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; -import { parseFiniteNumber as parseFiniteNumberish } from "./parse-finite-number.js"; import { providerUsageLabel } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageProviderId } from "./provider-usage.types.js"; @@ -23,9 +22,7 @@ export async function fetchJson( return await fetchFn(url, { ...init, signal }); } -export function parseFiniteNumber(value: unknown): number | undefined { - return parseFiniteNumberish(value); -} +export { parseFiniteNumber } from "./parse-finite-number.js"; /** Parses a provider reset-time string without leaking an invalid Date timestamp. */ export function parseUsageResetAt(value: unknown): number | undefined { diff --git a/src/infra/state-migrations.managed-outgoing-images.ts b/src/infra/state-migrations.managed-outgoing-images.ts index e3a19f6c93ef..55c7f0eb5a49 100644 --- a/src/infra/state-migrations.managed-outgoing-images.ts +++ b/src/infra/state-migrations.managed-outgoing-images.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonBlankString as optionalNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { managedImageRecordFromRow, managedImageRecordsEqual, @@ -127,10 +128,6 @@ function readLegacySourceSnapshot(sourcePath: string): LegacySourceSnapshot { }); } -function optionalNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - function nullableNonNegativeInteger(value: unknown): number | null | undefined { if (value === null) { return null; diff --git a/src/media-understanding/file-extraction-limits.ts b/src/media-understanding/file-extraction-limits.ts index 2ae7736c8940..c3bd67924a41 100644 --- a/src/media-understanding/file-extraction-limits.ts +++ b/src/media-understanding/file-extraction-limits.ts @@ -1,4 +1,5 @@ // Resolves inbound attachment text-extraction limits for media-understanding. +import { asPositiveFiniteNumber as positiveExtractionLimit } from "@openclaw/normalization-core/number-coercion"; import type { OpenClawConfig } from "../config/types.js"; import { type InputFileLimits, @@ -28,10 +29,6 @@ export type FileExtractionLimits = InputFileLimits & { allowedMimesConfigured: boolean; }; -function positiveExtractionLimit(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; -} - function resolveInboundFileExtractionMaxBytes( defaults: InboundFileExtractionDefaults | undefined, ): number { diff --git a/src/meeting-bot/node-host.ts b/src/meeting-bot/node-host.ts index ff0400284cde..b6c3f4500b37 100644 --- a/src/meeting-bot/node-host.ts +++ b/src/meeting-bot/node-host.ts @@ -1,6 +1,8 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { asPositiveFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonEmptyStringPreservingWhitespace as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { formatErrorMessage } from "../infra/errors.js"; import type { MeetingAudioBackendSelection, MeetingAudioRuntime } from "./audio-backend.js"; import { decodeMeetingAudioBase64 } from "./audio-base64.js"; @@ -80,12 +82,8 @@ export type MeetingNodeHostOptions = { }; }; -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - function readPositiveNumberOr(value: unknown, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; + return asPositiveFiniteNumber(value) ?? fallback; } function readOutputGeneration(value: unknown): number | undefined { diff --git a/src/meeting-bot/node-invoke-policy.ts b/src/meeting-bot/node-invoke-policy.ts index 051e391e4d8b..06f1b9285d7d 100644 --- a/src/meeting-bot/node-invoke-policy.ts +++ b/src/meeting-bot/node-invoke-policy.ts @@ -1,5 +1,9 @@ -import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion"; +import { + asPositiveFiniteNumber as readPositiveNumber, + asSafeIntegerInRange, +} from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonEmptyStringPreservingWhitespace as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawPluginNodeInvokePolicy, OpenClawPluginNodeInvokePolicyResult, @@ -38,14 +42,6 @@ type PolicyDecision = | { approved: true; params: Record } | { approved: false; result: OpenClawPluginNodeInvokePolicyResult }; -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function readPositiveNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; -} - function readOutputGeneration(value: unknown): number | undefined { return asSafeIntegerInRange(value, { min: 0 }); } diff --git a/src/meeting-bot/plugin-config.ts b/src/meeting-bot/plugin-config.ts index a92629febfdc..433d6299bf38 100644 --- a/src/meeting-bot/plugin-config.ts +++ b/src/meeting-bot/plugin-config.ts @@ -1,4 +1,7 @@ -import { resolvePositiveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; +import { + asPositiveFiniteNumber, + resolvePositiveTimerTimeoutMs, +} from "@openclaw/normalization-core/number-coercion"; import { asRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString, @@ -65,7 +68,7 @@ function resolveBoolean(value: unknown, fallback: boolean): boolean { } function resolvePositiveNumber(value: unknown, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; + return asPositiveFiniteNumber(value) ?? fallback; } function resolveTimer(value: unknown, fallback: number): number { diff --git a/src/meeting-bot/realtime-node-audio-transport.ts b/src/meeting-bot/realtime-node-audio-transport.ts index fcd0208b26c2..a8045ca21012 100644 --- a/src/meeting-bot/realtime-node-audio-transport.ts +++ b/src/meeting-bot/realtime-node-audio-transport.ts @@ -1,4 +1,5 @@ import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; import { formatErrorMessage } from "../infra/errors.js"; import type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; import { decodeMeetingAudioBase64 } from "./audio-base64.js"; @@ -10,10 +11,6 @@ const NODE_OUTPUT_GENERATION_CAPABILITY = Symbol.for( "openclaw.internal.meeting-node-output-generation.v1", ); -function readNonBlankString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; diff --git a/src/pairing/pairing-store-sqlite.ts b/src/pairing/pairing-store-sqlite.ts index 4716de83a131..8ae3bdae7da1 100644 --- a/src/pairing/pairing-store-sqlite.ts +++ b/src/pairing/pairing-store-sqlite.ts @@ -1,4 +1,5 @@ // Internal SQLite persistence for channel pairing requests and allow entries. +import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; @@ -31,11 +32,7 @@ type ChannelPairingState = { }; function parseTimestamp(value: string | undefined): number | null { - if (!value) { - return null; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : null; + return parseDateStringTimestampMs(value) ?? null; } function normalizePersistedPairingMeta(value: unknown): Record | undefined { diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index e6b021ea2b75..a1be32dfb20e 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -1,5 +1,6 @@ // Persists pairing challenges and approved channel account bindings in shared SQLite state. import crypto from "node:crypto"; +import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { normalizeLowercaseStringOrEmpty, normalizeNullableString, @@ -48,11 +49,7 @@ export function resolveChannelPairingRequestId( } function parseTimestamp(value: string | undefined): number | null { - if (!value) { - return null; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : null; + return parseDateStringTimestampMs(value) ?? null; } function isExpired(entry: PairingRequest, nowMs: number): boolean { diff --git a/src/plugin-sdk/number-runtime.ts b/src/plugin-sdk/number-runtime.ts index 9cacf6b50df1..c042b30af48a 100644 --- a/src/plugin-sdk/number-runtime.ts +++ b/src/plugin-sdk/number-runtime.ts @@ -3,10 +3,13 @@ export { formatByteSize } from "../../packages/normalization-core/src/format.js"; export { asDateTimestampMs, + asNonNegativeFiniteNumber, + asPositiveFiniteNumber, asFiniteNumberInRange, asSafeIntegerInRange, isFutureDateTimestampMs, - parseDateTimestampMs, + parseDateFirstTimestampMs, + parseDateStringTimestampMs, parseFiniteNumber, clampTimerTimeoutMs, clampPositiveTimerTimeoutMs, diff --git a/src/plugin-sdk/session-transcript-runtime.ts b/src/plugin-sdk/session-transcript-runtime.ts index 466214a1dc50..c29aff9a0052 100644 --- a/src/plugin-sdk/session-transcript-runtime.ts +++ b/src/plugin-sdk/session-transcript-runtime.ts @@ -1,4 +1,5 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonBlankString as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { redactTranscriptMessage } from "../agents/transcript-redact.js"; import { appendTranscriptMessage, @@ -525,10 +526,6 @@ function isDeliveryMirrorAssistantMessage(message: SessionTranscriptAssistantMes return message.provider === "openclaw" && message.model === "delivery-mirror"; } -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; -} - function isAgentMessageRecord(value: unknown): value is AgentMessage & Record { return isRecord(value) && readNonEmptyString(value.role) !== undefined; } diff --git a/src/plugin-sdk/speech-core.ts b/src/plugin-sdk/speech-core.ts index 9f3db2900b35..588591099f55 100644 --- a/src/plugin-sdk/speech-core.ts +++ b/src/plugin-sdk/speech-core.ts @@ -46,7 +46,7 @@ export { export { resolveEffectiveTtsConfig } from "../tts/tts-config.js"; export type { TtsConfigResolutionContext } from "../tts/tts-config.js"; export { normalizeTtsAutoMode, TTS_AUTO_MODES } from "../tts/tts-auto-mode.js"; -// SDK compatibility: preserve the public `asObject` export name. +// Public compatibility: preserve the established `asObject` export name. export { asOptionalRecord as asObject } from "@openclaw/normalization-core/record-coerce"; export { asBoolean, diff --git a/src/plugin-sdk/speech.ts b/src/plugin-sdk/speech.ts index c2ddbae46c4c..72ecd1579c29 100644 --- a/src/plugin-sdk/speech.ts +++ b/src/plugin-sdk/speech.ts @@ -36,7 +36,7 @@ export { normalizeSpeechProviderId, } from "../tts/provider-registry.js"; export { normalizeTtsAutoMode, TTS_AUTO_MODES } from "../tts/tts-auto-mode.js"; -// SDK compatibility: preserve the public `asObject` export name. +// Public compatibility: preserve the established `asObject` export name. export { asOptionalRecord as asObject } from "@openclaw/normalization-core/record-coerce"; export { asBoolean, diff --git a/src/plugin-sdk/string-coerce-runtime.ts b/src/plugin-sdk/string-coerce-runtime.ts index af3e6a71a378..d94bb4ac999c 100644 --- a/src/plugin-sdk/string-coerce-runtime.ts +++ b/src/plugin-sdk/string-coerce-runtime.ts @@ -5,6 +5,7 @@ export { localeLowercasePreservingWhitespace, lowercasePreservingWhitespace, normalizeFastMode, + normalizeBoundedOptionalString, normalizeLowercaseStringOrEmpty, normalizeNullableString, normalizeOptionalLowercaseString, @@ -12,6 +13,7 @@ export { normalizeOptionalStringifiedId, normalizeStringifiedEntries, normalizeStringifiedOptionalString, + readNonEmptyStringPreservingWhitespace, readStringValue, } from "../../packages/normalization-core/src/string-coerce.js"; export { @@ -19,7 +21,6 @@ export { asFiniteNumber, asPositiveSafeInteger, asSafeIntegerInRange, - parseDateTimestampMs, parseFiniteNumber, parseStrictFiniteNumber, parseStrictInteger, @@ -29,10 +30,11 @@ export { export { asBoolean, parseBooleanValue } from "../utils/boolean.js"; export { asRecord, - asNullableRecord, + asNonArrayRecord, asNullableObjectRecord, - asOptionalRecord, + asNullableRecord, asOptionalObjectRecord, + asOptionalRecord, isRecord, readStringField, } from "../../packages/normalization-core/src/record-coerce.js"; diff --git a/src/plugin-sdk/text-runtime.ts b/src/plugin-sdk/text-runtime.ts index 10962cbe8bc5..c615eec67e40 100644 --- a/src/plugin-sdk/text-runtime.ts +++ b/src/plugin-sdk/text-runtime.ts @@ -13,10 +13,34 @@ export * from "../../packages/markdown-core/src/render-aware-chunking.js"; export * from "../../packages/markdown-core/src/render.js"; export * from "../../packages/markdown-core/src/tables.js"; export { resolveGlobalMap, resolveGlobalSingleton } from "../shared/global-singleton.js"; -// Public compatibility: this wildcard intentionally keeps isRecord on text-runtime. -export * from "../../packages/normalization-core/src/record-coerce.js"; +// Public compatibility: this explicit list intentionally keeps isRecord on text-runtime. +export { + asNullableObjectRecord, + asNullableRecord, + asOptionalObjectRecord, + asOptionalRecord, + asRecord, + isRecord, + readStringField, +} from "../../packages/normalization-core/src/record-coerce.js"; export * from "../shared/scoped-expiring-id-cache.js"; -export * from "../../packages/normalization-core/src/string-coerce.js"; +export { + hasNonEmptyString, + localeLowercasePreservingWhitespace, + lowercasePreservingWhitespace, + normalizeFastMode, + normalizeLowercaseStringOrEmpty, + normalizeNullableString, + normalizeOptionalLowercaseString, + normalizeOptionalString, + normalizeOptionalStringifiedId, + normalizeOptionalThreadValue, + normalizeStringifiedEntries, + normalizeStringifiedOptionalString, + readStringValue, + resolvePrimaryStringValue, + type FastMode, +} from "../../packages/normalization-core/src/string-coerce.js"; export * from "../../packages/normalization-core/src/string-normalization.js"; export * from "../shared/string-sample.js"; export * from "../shared/text/assistant-visible-text.js"; @@ -31,17 +55,6 @@ export * from "../utils/chunk-items.js"; export * from "../utils/fetch-timeout.js"; export * from "../utils/reaction-level.js"; export * from "../utils/with-timeout.js"; -export { - hasNonEmptyString, - localeLowercasePreservingWhitespace, - lowercasePreservingWhitespace, - normalizeLowercaseStringOrEmpty, - normalizeNullableString, - normalizeOptionalLowercaseString, - normalizeOptionalString, - normalizeStringifiedOptionalString, - readStringValue, -} from "../../packages/normalization-core/src/string-coerce.js"; export { CONFIG_DIR, clamp, diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index def854b023b6..7f78ef4d4748 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -1,6 +1,7 @@ // Bundles MCP metadata exposed by plugins for package output. import fs from "node:fs"; import path from "node:path"; +import { isStringRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveMcpTransportConfig } from "../agents/mcp-transport-config.js"; import { applyMergePatch } from "../config/merge-patch.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -239,10 +240,6 @@ function hasOnlyKeys(raw: Record, allowed: ReadonlySet) return Object.keys(raw).every((key) => allowed.has(key)); } -function isStringRecord(raw: unknown): raw is Record { - return isRecord(raw) && Object.values(raw).every((value) => typeof value === "string"); -} - function isPathWithin(baseDir: string, targetPath: string): boolean { const relative = path.relative(baseDir, targetPath); return ( diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 674fb991da6b..3c89e4eae3ad 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import { toErrorObject as toLintErrorObject } from "@openclaw/normalization-core/error-coercion"; import { beforeAll, describe, expect, it } from "vitest"; import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js"; import { listGitTrackedFiles, toRepoRelativePath } from "../test-utils/repo-files.js"; @@ -1119,17 +1120,4 @@ describe("bundled plugin metadata", () => { }); }); -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/plugins/openai-compatible-embedding-provider.ts b/src/plugins/openai-compatible-embedding-provider.ts index c7e9d99ca51d..9b1ee25b2e4c 100644 --- a/src/plugins/openai-compatible-embedding-provider.ts +++ b/src/plugins/openai-compatible-embedding-provider.ts @@ -1,6 +1,7 @@ // Builds OpenAI-compatible embedding provider entries for plugins. import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { asOptionalRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; import type { @@ -112,11 +113,6 @@ function normalizeOptionalInputType(value: string | undefined): string | undefin return inputType ? inputType : undefined; } -function normalizeOptionalString(value: string | undefined): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; -} - function chooseSecretInputOverride( override: T | undefined, fallback: T | undefined, diff --git a/src/skills/lifecycle/upload-store.test.ts b/src/skills/lifecycle/upload-store.test.ts index 0a2b252fcb90..16c1d0b3b4e5 100644 --- a/src/skills/lifecycle/upload-store.test.ts +++ b/src/skills/lifecycle/upload-store.test.ts @@ -3,6 +3,7 @@ import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { toErrorObject as toLintErrorObject } from "@openclaw/normalization-core/error-coercion"; import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { createTempDirTracker } from "../../../test/helpers/temp-dir.js"; @@ -921,17 +922,3 @@ describe("skill upload store", () => { expect(uploadCount(databasePath)).toBe(1); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/src/utils/fetch-timeout.test.ts b/src/utils/fetch-timeout.test.ts index 47d15ac7a17c..e6f1cf7f849c 100644 --- a/src/utils/fetch-timeout.test.ts +++ b/src/utils/fetch-timeout.test.ts @@ -1,3 +1,4 @@ +import { toErrorObject as toLintErrorObject } from "@openclaw/normalization-core/error-coercion"; // Fetch timeout tests cover abort handling and streamed response timeouts. import { Stream } from "openai/streaming"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -445,17 +446,3 @@ describe("buildTimeoutAbortSignal", () => { } }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index 6909ce138df9..fe1b2c8c2a4f 100644 --- a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -472,7 +472,7 @@ describe("package-openclaw-for-docker", () => { const packageJsonPath = path.join(sourceDir, "package.json"); const originalPackageJson = `${JSON.stringify( { - dependencies: { "@openclaw/ai": "workspace:*", "dep-a": "1.2.3" }, + dependencies: { "@openclaw/ai": "workspace:*", "dep-a": "workspace:1.2.3" }, devDependencies: { "@openclaw/session-url-contract": "workspace:*" }, files: ["dist"], name: "openclaw", @@ -506,7 +506,10 @@ describe("package-openclaw-for-docker", () => { fs.writeFileSync( path.join(destination, "package.json"), `${JSON.stringify({ - dependencies: { "dep-a": "1.2.3" }, + dependencies: { + "@openclaw/private-runtime": "0.0.0-private", + "dep-a": "1.2.3", + }, name: "@openclaw/ai", version: "2026.6.17", })}\n`, @@ -522,6 +525,8 @@ describe("package-openclaw-for-docker", () => { devDependencies?: Record; }; expect(packageJson.dependencies["@openclaw/ai"]).toBe("2026.6.17"); + expect(packageJson.dependencies["@openclaw/private-runtime"]).toBeUndefined(); + expect(packageJson.dependencies["dep-a"]).toBe("1.2.3"); expect(packageJson.devDependencies?.["@openclaw/session-url-contract"]).toBe("workspace:*"); expect(packageJson.bundleDependencies).toContain("@openclaw/ai"); expect(fs.existsSync(path.join(installedAiPath, "original-marker"))).toBe(false); diff --git a/test/scripts/android-release-signing.test.ts b/test/scripts/android-release-signing.test.ts index 880817a87265..79c2e884d657 100644 --- a/test/scripts/android-release-signing.test.ts +++ b/test/scripts/android-release-signing.test.ts @@ -15,6 +15,7 @@ const ZERO_INSTALL_FILES = [ "scripts/android-release-signing.mjs", "scripts/lib/android-release-signing-process.mjs", "scripts/lib/arg-utils.runtime.mjs", + "scripts/lib/record-shared.mjs", "scripts/lib/repo-root.mjs", "apps/android/Config/ReleaseSigning.json", ] as const; diff --git a/test/scripts/check-coercion-helper-declarations.test.ts b/test/scripts/check-coercion-helper-declarations.test.ts index 49aa24600495..4e9f8bcb1946 100644 --- a/test/scripts/check-coercion-helper-declarations.test.ts +++ b/test/scripts/check-coercion-helper-declarations.test.ts @@ -1,14 +1,20 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { auditCoercionHelperDeclarations, findBannedCoercionHelperDeclarations, isGovernedCoercionHelperPath, + runCoercionHelperDeclarationGuard, type CoercionHelperCarveOut, type CoercionHelperDeclaration, } from "../../scripts/check-coercion-helper-declarations.mts"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); describe("coercion helper declaration AST guard", () => { - it("finds nested, exported, async, and callable-variable declarations", () => { + it("finds functions, callable variables, methods, fields, and object properties", () => { const source = [ "export async function readString() {}", "if (true) {", @@ -28,6 +34,18 @@ describe("coercion helper declaration AST guard", () => { "const readOptionalString = normalizeOptionalString;", "const optionalString = helpers.readStringValue;", "const asObject = helpers.asOptionalRecord;", + "class Example {", + " readString() {}", + " toError = () => new Error();", + " asRecord = function () { return {}; };", + "}", + "const object = {", + " optionalString() {},", + " readBoolean: () => true,", + " readNumber: function () { return 1; },", + "};", + "function normalizeOptionalString() {}", + "const parseDateFirstTimestampMs = () => 0;", ].join("\n"); expect(findBannedCoercionHelperDeclarations(source, "src/example.ts")).toEqual([ @@ -44,19 +62,42 @@ describe("coercion helper declaration AST guard", () => { { file: "src/example.ts", kind: "function", line: 13, name: "normalizeString" }, { file: "src/example.ts", kind: "variable", line: 14, name: "asString" }, { file: "src/example.ts", kind: "function", line: 15, name: "asObject" }, - { file: "src/example.ts", kind: "variable", line: 16, name: "readOptionalString" }, + { + file: "src/example.ts", + kind: "variable", + line: 16, + name: "readOptionalString", + }, { file: "src/example.ts", kind: "variable", line: 17, name: "optionalString" }, { file: "src/example.ts", kind: "variable", line: 18, name: "asObject" }, + { file: "src/example.ts", kind: "method", line: 20, name: "readString" }, + { file: "src/example.ts", kind: "field", line: 21, name: "toError" }, + { file: "src/example.ts", kind: "field", line: 22, name: "asRecord" }, + { file: "src/example.ts", kind: "method", line: 25, name: "optionalString" }, + { file: "src/example.ts", kind: "property", line: 26, name: "readBoolean" }, + { file: "src/example.ts", kind: "property", line: 27, name: "readNumber" }, + { + file: "src/example.ts", + kind: "function", + line: 29, + name: "normalizeOptionalString", + }, + { + file: "src/example.ts", + kind: "variable", + line: 30, + name: "parseDateFirstTimestampMs", + }, ]); }); - it("ignores imports, aliases, methods, properties, callbacks, and inert text", () => { + it("ignores imports, non-callable properties, shorthand aliases, callback names, and inert text", () => { const source = [ 'import { isRecord, readString as importedReadString } from "./helpers.js";', "const alias = isRecord;", "const { asRecord } = helpers;", - "class Example { isRecord() {} readString = () => true; }", - "const object = { optionalString() {}, toError: () => new Error() };", + "const object = { isRecord, readString: 42, toError: importedToError };", + "const shorthand = { optionalString };", "values.map(function readString(value) { return value; });", "const aliasWithInternalName = function isRecord(value) { return value; };", "const asRecord = raw as Record;", @@ -112,20 +153,61 @@ describe("coercion helper declaration AST guard", () => { }); }); - it("excludes declarations, fixtures, generated sources, and browser bundles", () => { + it("excludes structural fixtures and generated sources without hiding authored fixture-named files", () => { expect(isGovernedCoercionHelperPath("src/runtime.ts")).toBe(true); expect(isGovernedCoercionHelperPath("extensions/demo/runtime.jsx")).toBe(true); expect(isGovernedCoercionHelperPath("src/runtime.d.ts")).toBe(false); expect(isGovernedCoercionHelperPath("scripts/runtime.d.mts")).toBe(false); expect(isGovernedCoercionHelperPath("test/fixtures/example.ts")).toBe(false); expect(isGovernedCoercionHelperPath("extensions/demo/dist/index.js")).toBe(false); - expect(isGovernedCoercionHelperPath("src/example.test-fixtures.ts")).toBe(false); + expect(isGovernedCoercionHelperPath("src/example.test-fixtures.ts")).toBe(true); + expect(isGovernedCoercionHelperPath("extensions/demo/runtime-tool-fixture.ts")).toBe(true); expect(isGovernedCoercionHelperPath("src/schema.generated.ts")).toBe(false); expect(isGovernedCoercionHelperPath("ui/src/vendor.bundle.js")).toBe(false); expect( isGovernedCoercionHelperPath( "extensions/browser/chrome-extension/modules/copilot-runtime.js", ), - ).toBe(false); + ).toBe(true); + expect(isGovernedCoercionHelperPath("root.config.ts")).toBe(true); + expect(isGovernedCoercionHelperPath(".github/actions/example/index.ts")).toBe(true); + }); + + it("scans a temporary repository and reports sorted, owner-specific diagnostics", () => { + const repoRoot = tempDirs.make("coercion-helper-guard-"); + fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "extensions", "demo"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "config"), { recursive: true }); + fs.writeFileSync(path.join(repoRoot, "src", "z.ts"), "function readString() {}\n"); + fs.writeFileSync( + path.join(repoRoot, "extensions", "demo", "a.ts"), + "const asRecord = () => ({});\n", + ); + fs.writeFileSync( + path.join(repoRoot, "config", "root.ts"), + "class Config { normalizeOptionalString() {} }\n", + ); + const stdout: string[] = []; + const stderr: string[] = []; + expect( + runCoercionHelperDeclarationGuard({ + carveOuts: [], + repoRoot, + io: { + stdout: { write: (value) => stdout.push(value) }, + stderr: { write: (value) => stderr.push(value) }, + }, + }), + ).toBe(1); + + expect(stdout).toEqual([]); + const output = stderr.join(""); + expect(output.indexOf("config/root.ts:1")).toBeLessThan( + output.indexOf("extensions/demo/a.ts:1"), + ); + expect(output.indexOf("extensions/demo/a.ts:1")).toBeLessThan(output.indexOf("src/z.ts:1")); + expect(output).toContain("Core/package/UI/workspace-script code"); + expect(output).toContain("Plugin production code"); + expect(output).toContain("Dependency-free, copied, generated, or serialized code"); }); }); diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index a98942cfc8d7..22f88c6d4e2c 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -5003,6 +5003,8 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" "matrix.task == 'max-lines-ratchet' && github.event_name == 'workflow_dispatch' && inputs.release_gate", ); expect(checksFastRun.run).toContain("max-lines-ratchet)"); + expect(checksFastRun.run).toContain("coercion-helpers)"); + expect(checksFastRun.run).toContain("pnpm check:coercion-helpers"); expect(checksFastRun.run).toContain('has_package_script "check:max-lines-ratchet"'); expect(checksFastRun.env.RATCHET_PR_HEAD_SHA).toBe( "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}", @@ -5094,6 +5096,11 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" runtime: "node", task: "max-lines-ratchet", }, + { + check_name: "checks-fast-coercion-helpers", + runtime: "node", + task: "coercion-helpers", + }, ]); }); diff --git a/test/scripts/pnpm-audit-prod.test.ts b/test/scripts/pnpm-audit-prod.test.ts index da1c7f5899b0..9f1fbb921a62 100644 --- a/test/scripts/pnpm-audit-prod.test.ts +++ b/test/scripts/pnpm-audit-prod.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { toErrorObject as toLintErrorObject } from "@openclaw/normalization-core/error-coercion"; import { describe, expect, it } from "vitest"; import { collectProdResolvedPackagesFromLockfile, @@ -485,17 +486,3 @@ snapshots: } }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/test/scripts/real-behavior-proof-policy.test.ts b/test/scripts/real-behavior-proof-policy.test.ts index 8c5b8049e011..e955f0030b7e 100644 --- a/test/scripts/real-behavior-proof-policy.test.ts +++ b/test/scripts/real-behavior-proof-policy.test.ts @@ -1,5 +1,6 @@ // PR Context And Evidence Policy tests cover GitHub PR-body policy behavior. import { readFileSync } from "node:fs"; +import { toErrorObject as toLintErrorObject } from "@openclaw/normalization-core/error-coercion"; import { describe, expect, it, vi } from "vitest"; import { NEEDS_PR_CONTEXT_LABEL, @@ -689,17 +690,3 @@ describe("readBoundedGitHubApiJson", () => { }); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/test/scripts/resolve-openclaw-package-candidate.test.ts b/test/scripts/resolve-openclaw-package-candidate.test.ts index b4070e45f182..b6017a0d6df4 100644 --- a/test/scripts/resolve-openclaw-package-candidate.test.ts +++ b/test/scripts/resolve-openclaw-package-candidate.test.ts @@ -5,6 +5,7 @@ import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promise import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { toErrorObject as toLintErrorObject } from "@openclaw/normalization-core/error-coercion"; import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveWindowsTaskkillPath } from "../../scripts/lib/windows-taskkill.mjs"; import { @@ -1433,17 +1434,3 @@ describe("resolve-openclaw-package-candidate", () => { ); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/ui/config/control-ui-chunking.ts b/ui/config/control-ui-chunking.ts index 917102dd6f1a..9f0474e9dfae 100644 --- a/ui/config/control-ui-chunking.ts +++ b/ui/config/control-ui-chunking.ts @@ -83,10 +83,9 @@ export const controlUiCodeSplitting = { normalizeModuleId(id).includes("/ui/src/") ? "control-ui-core" : "control-ui-foundation", tags: ["$initial"] as ["$initial"], priority: 10, - // 512 KiB packs the grown core graph into fewer chunks; the previous - // 448 KiB boundary split one core chunk in two, costing ~1.9 KiB startup - // gzip (same tradeoff as the earlier 400->448 bump). - maxSize: 512 * 1024, + // 576 KiB keeps the shared normalization graph in one chunk; the previous + // 512 KiB boundary split it in two, adding a startup request and ~700 B gzip. + maxSize: 576 * 1024, }, ], }; diff --git a/ui/src/app/control-ui-chunking.test.ts b/ui/src/app/control-ui-chunking.test.ts index 52171e0f28b5..5923cc60af4f 100644 --- a/ui/src/app/control-ui-chunking.test.ts +++ b/ui/src/app/control-ui-chunking.test.ts @@ -47,7 +47,7 @@ describe("Control UI build chunking", () => { expect(controlUiCodeSplitting.includeDependenciesRecursively).toBe(false); expect(controlUiCodeSplitting.groups[1]).toMatchObject({ tags: ["$initial"], - maxSize: 512 * 1024, + maxSize: 576 * 1024, }); }); diff --git a/ui/src/lib/chat/outbox-store-codec.ts b/ui/src/lib/chat/outbox-store-codec.ts index 69584abb5897..7a35abbe8087 100644 --- a/ui/src/lib/chat/outbox-store-codec.ts +++ b/ui/src/lib/chat/outbox-store-codec.ts @@ -1,3 +1,4 @@ +import { readNonBlankString as normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeAgentId } from "../sessions/session-key.ts"; import type { ChatAttachment, ChatQueueItem } from "./chat-types.ts"; import { normalizeSenderIdentity } from "./sender-label.ts"; @@ -18,10 +19,6 @@ export type StoredComposerSession = { updatedAt: number; }; -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - function normalizeOptionalBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } diff --git a/ui/src/lib/chat/tool-call-patch.ts b/ui/src/lib/chat/tool-call-patch.ts index 3b9b2c88cd28..2388a2a79c4f 100644 --- a/ui/src/lib/chat/tool-call-patch.ts +++ b/ui/src/lib/chat/tool-call-patch.ts @@ -1,4 +1,5 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; import { MAX_DIFF_RENDER_LINES, type DiffLine, @@ -36,10 +37,6 @@ type PatchViewData = { move?: { from: string; to: string }; }; -function readNonBlankString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - function splitLines(text: string): string[] { if (text === "") { return []; diff --git a/ui/src/lib/chat/tool-call-view.ts b/ui/src/lib/chat/tool-call-view.ts index f63f85820b66..b17b6ee59825 100644 --- a/ui/src/lib/chat/tool-call-view.ts +++ b/ui/src/lib/chat/tool-call-view.ts @@ -7,6 +7,7 @@ */ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; import { buildWriteDiffLines, computeLineDiff, @@ -57,10 +58,6 @@ const SEARCH_TOOL_NAMES = new Set(["grep", "find", "glob", "ls", "list", "codeba const FETCH_TOOL_NAMES = new Set(["web_fetch", "webfetch", "fetch"]); const PATCH_TOOL_NAMES = new Set(["apply_patch", "applypatch", "patch"]); -function readNonBlankString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - function resolvePathArg(args: Record | null): string | undefined { if (!args) { return undefined; diff --git a/ui/src/pages/agents/memory/view.ts b/ui/src/pages/agents/memory/view.ts index 1f26f6ca574c..f21830d19337 100644 --- a/ui/src/pages/agents/memory/view.ts +++ b/ui/src/pages/agents/memory/view.ts @@ -1,6 +1,7 @@ // Control UI view renders dreaming screen content. import "../../../styles/lobster-pet.css"; import { expectDefined } from "@openclaw/normalization-core"; +import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { html, nothing } from "lit"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { renderHubTabs } from "../../../components/hub-tabs.ts"; @@ -72,8 +73,7 @@ function parseDiaryEntries(raw: string): DiaryEntry[] { } function parseDiaryTimestamp(date: string): number | null { - const parsed = Date.parse(date); - return Number.isFinite(parsed) ? parsed : null; + return parseDateStringTimestampMs(date) ?? null; } function formatDiaryChipLabel(date: string): string { @@ -443,8 +443,8 @@ function formatRange(path: string, startLine: number, endLine: number): string { } function formatCompactDateTime(value: string): string { - const parsed = Date.parse(value); - if (!Number.isFinite(parsed)) { + const parsed = parseDateStringTimestampMs(value); + if (parsed === undefined) { return value; } return new Date(parsed).toLocaleString([], { @@ -684,11 +684,7 @@ function renderDiarySubtabExplainer(activeDiarySubTab: DreamingViewState["active } function parseSortableTimestamp(value?: string): number { - if (!value) { - return Number.NEGATIVE_INFINITY; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY; + return parseDateStringTimestampMs(value) ?? Number.NEGATIVE_INFINITY; } function compareWaitingEntryByRecency(a: DreamingEntry, b: DreamingEntry): number { diff --git a/ui/src/pages/chat/chat-send-ack.ts b/ui/src/pages/chat/chat-send-ack.ts index 41648181e74a..afed156baee8 100644 --- a/ui/src/pages/chat/chat-send-ack.ts +++ b/ui/src/pages/chat/chat-send-ack.ts @@ -1,6 +1,7 @@ // Leaf contract for chat.send acknowledgment shapes and timing records. // Kept import-free of chat-page modules so lifecycle/steer/history layers // can consume ack types without forming import cycles. +import { asNonNegativeFiniteNumber as normalizeAckTimingValue } from "@openclaw/normalization-core/number-coercion"; import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; type ChatSendAckStatus = "started" | "in_flight" | "ok" | "timeout" | "error"; @@ -17,10 +18,6 @@ export type ChatSendAck = { serverTiming?: ChatSendAckServerTiming; }; -function normalizeAckTimingValue(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} - function normalizeChatSendAckServerTiming(value: unknown): ChatSendAckServerTiming | undefined { if (!value || typeof value !== "object") { return undefined; diff --git a/ui/src/pages/chat/chat-send-timing.ts b/ui/src/pages/chat/chat-send-timing.ts index 9aca8045fa17..8175ee56486c 100644 --- a/ui/src/pages/chat/chat-send-timing.ts +++ b/ui/src/pages/chat/chat-send-timing.ts @@ -1,3 +1,4 @@ +import { asNonNegativeFiniteNumber as readChatSendTimingNumber } from "@openclaw/normalization-core/number-coercion"; import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { visibleSessionMatches, type SessionScopeHost } from "../../lib/sessions/index.ts"; import { readChatQueueForScope } from "./chat-queue.ts"; @@ -90,10 +91,6 @@ function readChatSendServerTimingPhase(value: unknown): ChatSendServerTimingPhas : null; } -function readChatSendTimingNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} - export function recordChatSendServerTiming(host: ChatSendTimingHost, payload: unknown) { if (!payload || typeof payload !== "object") { return; diff --git a/ui/src/pages/config/updates.ts b/ui/src/pages/config/updates.ts index 3dc4f1201ca0..5bb931636543 100644 --- a/ui/src/pages/config/updates.ts +++ b/ui/src/pages/config/updates.ts @@ -1,5 +1,6 @@ // Curated Updates settings presentation. The existing update config remains // the source of authored policy; the Gateway schedule DTO owns runtime status. +import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce"; import { html, nothing, type TemplateResult } from "lit"; import type { UpdateAvailable, UpdateScheduleState } from "../../api/types.ts"; @@ -71,11 +72,7 @@ function readUpdatesSettings( } function parseTimestampMs(value: string | null): number | null { - if (!value) { - return null; - } - const timestampMs = Date.parse(value); - return Number.isFinite(timestampMs) ? timestampMs : null; + return parseDateStringTimestampMs(value) ?? null; } function renderTimestamp(timestampMs: number, nowMs = Date.now()) { diff --git a/ui/src/pages/new-session/discovery.test.ts b/ui/src/pages/new-session/discovery.test.ts index 2ea6a49bcc44..453f775c6fcc 100644 --- a/ui/src/pages/new-session/discovery.test.ts +++ b/ui/src/pages/new-session/discovery.test.ts @@ -1,6 +1,34 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { readDraftCloudProfiles } from "./discovery.ts"; +import { readDraftCloudProfiles, readDraftNodes } from "./discovery.ts"; + +describe("readDraftNodes", () => { + it("ignores non-record array entries without throwing", () => { + expect( + readDraftNodes([ + null, + undefined, + 42, + "node", + [], + [[{ nodeId: "nested", connected: true, commands: ["system.run"] }]], + { nodeId: " valid ", connected: true, commands: ["system.run", "fs.listDir"] }, + ]), + ).toEqual([ + { + nodeId: "valid", + displayName: "valid", + platform: undefined, + deviceFamily: undefined, + modelIdentifier: undefined, + remoteIp: undefined, + connected: true, + canExec: true, + canBrowse: true, + }, + ]); + }); +}); describe("readDraftCloudProfiles", () => { it("keeps closed profile summaries in stable order", () => { diff --git a/ui/src/pages/new-session/discovery.ts b/ui/src/pages/new-session/discovery.ts index 1a8fd240d42b..d8ff8f454286 100644 --- a/ui/src/pages/new-session/discovery.ts +++ b/ui/src/pages/new-session/discovery.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "../../lib/string-coerce.ts"; export type DraftBranches = { @@ -37,6 +38,9 @@ export function readDraftNodes(value: unknown): DraftNode[] { const rawNodes = Array.isArray(value) ? value : []; return rawNodes .flatMap((raw) => { + if (!isRecord(raw)) { + return []; + } const node = raw as { nodeId?: unknown; displayName?: unknown; diff --git a/ui/src/pages/skill-workshop/proposals.ts b/ui/src/pages/skill-workshop/proposals.ts index cc0262373e24..fafc2e12d79a 100644 --- a/ui/src/pages/skill-workshop/proposals.ts +++ b/ui/src/pages/skill-workshop/proposals.ts @@ -1,4 +1,5 @@ // Control UI controller manages skill workshop gateway state. +import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; import type { AgentSelectionCapability } from "../../app/agent-selection.ts"; import type { ApplicationGateway } from "../../app/context.ts"; import { t } from "../../i18n/index.ts"; @@ -142,11 +143,7 @@ function resetSkillWorkshopAgentScope(state: SkillWorkshopState, agentId: string } function parseDateMs(value: string | undefined): number { - if (!value) { - return Date.now(); - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : Date.now(); + return parseDateStringTimestampMs(value) ?? Date.now(); } function startOfLocalDay(ms: number): number {