From b080dd1e765a295a93087eca7e66bad00a983f4d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 23:26:37 -0700 Subject: [PATCH] refactor: consolidate coercion contracts (#122458) * refactor: consolidate coercion contracts Centralize exact string, record, numeric, date, Boolean, argument, and structured-error coercions while preserving call-site semantics. Migrate canonical-name collisions and deprecated internal SDK bypasses, deleting 55 net production/tooling lines. Expand declaration ownership enforcement to 101 allowed helpers and add a narrow export-completeness audit. * fix: preserve standalone script coercions Keep copied Control UI tooling self-contained and retain the trusted release harness module-relative source seam when the harness runs against an old target cwd. --- extensions/active-memory/config.ts | 10 +- .../anthropic/session-catalog-history.ts | 4 +- extensions/anthropic/session-catalog.ts | 5 +- .../anthropic/session-upstream-activity.ts | 4 +- extensions/browser/src/browser/act-policy.ts | 3 +- extensions/clickclack/src/access.ts | 6 +- .../codex/media-understanding-provider.ts | 12 +- .../codex/src/app-server/attempt-context.ts | 5 +- extensions/codex/src/app-server/compact.ts | 8 +- .../codex/src/app-server/config-utils.ts | 16 +- .../codex/src/app-server/dynamic-tools.ts | 11 +- .../app-server/event-projector-tool-output.ts | 10 +- .../src/app-server/event-projector-usage.ts | 5 +- .../src/app-server/event-projector-values.ts | 17 +- extensions/codex/src/app-server/models.ts | 28 +-- .../src/app-server/plugin-thread-config.ts | 8 +- extensions/codex/src/app-server/protocol.ts | 3 +- extensions/codex/src/app-server/rpc-error.ts | 6 +- .../src/app-server/settled-turn-projection.ts | 12 +- .../src/app-server/thread-context-engine.ts | 11 +- .../src/app-server/upstream-session-fork.ts | 4 +- extensions/codex/src/native-thread-tool.ts | 14 +- .../codex/src/session-upstream-marker.test.ts | 4 +- .../codex/src/web-search-provider.runtime.ts | 11 +- extensions/copilot/src/replay-shim.ts | 8 +- .../diagnostics-prometheus/src/service.ts | 5 +- .../discord/src/monitor/gateway-plugin.ts | 4 +- .../src/monitor/provider.startup.test.ts | 11 - extensions/feishu/src/conversation-id.ts | 13 +- extensions/feishu/src/monitor.transport.ts | 3 +- extensions/file-transfer/src/shared/policy.ts | 6 +- .../fish-audio-speech/speech-provider.ts | 15 +- extensions/github-copilot/auth.test.ts | 16 +- extensions/imessage/src/accounts.ts | 9 +- extensions/imessage/src/actions-chat-guid.ts | 5 +- .../imessage/src/approval-reaction-poller.ts | 3 +- extensions/imessage/src/monitor/dm-history.ts | 6 +- extensions/imessage/src/monitor/ingress.ts | 4 +- extensions/imessage/src/send.ts | 5 +- extensions/imessage/src/state-migrations.ts | 4 +- extensions/logbook/src/node-host.ts | 9 +- extensions/matrix/src/plugin-entry.runtime.js | 4 +- .../mattermost/src/gateway-auth-bypass.ts | 13 +- .../memory-core/src/memory/embeddings.test.ts | 3 +- .../msteams/src/attachments/bot-framework.ts | 15 +- .../msteams/src/attachments/download.ts | 5 +- .../msteams/src/attachments/graph.test.ts | 5 +- extensions/msteams/src/attachments/graph.ts | 17 +- ...r.conversation-allowlist.lifecycle.test.ts | 30 +-- .../msteams/src/qa/bot-framework-server.ts | 3 +- extensions/msteams/src/sdk.ts | 3 +- extensions/msteams/src/token.test.ts | 16 +- extensions/nostr/doctor-contract-api.ts | 11 +- .../ollama/src/embedding-provider.test.ts | 3 +- extensions/ollama/src/node-inference.ts | 10 +- extensions/ollama/src/stream.runtime.ts | 8 +- extensions/openai/openai.live.test.ts | 5 +- extensions/openai/provider-policy-api.ts | 9 +- extensions/openai/speech-provider.test.ts | 3 +- .../policy/src/doctor/automatic-repairs.ts | 5 +- .../src/doctor/review-required-repairs.ts | 5 +- extensions/qa-lab/src/cli.runtime.ts | 19 +- extensions/qa-lab/src/coverage-report.ts | 9 +- .../matrix/substrate/config.ts | 2 +- .../slack/slack-live.config.ts | 6 +- ...nario-catalog-memory-ranking-proof.test.ts | 4 +- ...enario-catalog-memory-recall-proof.test.ts | 4 +- ...talog-model-switch-follow-up-proof.test.ts | 4 +- ...model-switch-tool-continuity-proof.test.ts | 4 +- ...nario-flow-runner-character-safety.test.ts | 4 +- .../qa-lab/src/scenario-flow-runner.test.ts | 17 +- extensions/qa-lab/src/suite-summary.ts | 4 +- extensions/qwen/stream.ts | 7 +- extensions/signal/src/signal-ingress.ts | 9 +- extensions/slack/src/accounts.ts | 9 +- .../events/interactions.block-actions.ts | 10 +- .../dispatch.preview-fallback.test.ts | 9 +- extensions/synology-chat/src/client.test.ts | 3 +- extensions/teams-meetings/index.ts | 4 +- extensions/telegram/src/audit.test.ts | 13 +- .../bot-handlers.callback-router-controls.ts | 7 +- .../src/bot-handlers.message-context.ts | 3 +- ...egram-bot.media-group-skip-warning.test.ts | 3 +- .../bot/delivery.resolve-media-local.test.ts | 3 +- .../bot/delivery.resolve-media-retry.test.ts | 3 +- .../telegram/src/network-config.test.ts | 5 +- extensions/telegram/src/reply-parameters.ts | 3 +- extensions/telegram/src/status-issues.ts | 12 +- .../telegram/src/update-offset-persistence.ts | 6 +- extensions/tlon/src/monitor/discovery.ts | 2 +- extensions/tlon/src/monitor/history.ts | 3 +- extensions/tlon/src/monitor/index.ts | 2 +- extensions/tlon/src/monitor/utils.ts | 3 - extensions/together/together.live.test.ts | 8 +- .../src/monitor-inbox.test-harness.ts | 3 +- .../whatsapp/src/qa-driver.runtime.test.ts | 3 +- packages/acp-core/src/meta.ts | 9 +- packages/agent-core/src/agent-loop.ts | 29 +-- packages/ai/src/providers/anthropic-usage.ts | 3 +- .../openai-responses-terminal-usage.ts | 6 +- .../src/transports/model-max-tokens-params.ts | 9 +- .../src/client-address-utils.ts | 2 +- packages/gateway-client/src/client.ts | 12 +- packages/gateway-client/src/timeouts.ts | 6 +- .../src/connect-error-details.ts | 4 +- .../src/error-coercion.test.ts | 185 +++++++++++++- .../normalization-core/src/error-coercion.ts | 39 +++ scripts/bench-agent-concurrency-worker.ts | 9 +- scripts/bench-agent-concurrency.ts | 11 +- scripts/bench-gateway-concurrency.ts | 13 +- scripts/bench-task-registry-sqlite-worker.ts | 9 +- scripts/bench-task-registry-sqlite.ts | 11 +- .../check-coercion-helper-declarations.mts | 236 +++++++++++++++++- scripts/check-openclaw-package-tarball.mts | 19 +- scripts/close-duplicate-prs-after-merge.mjs | 4 +- scripts/e2e/kitchen-sink-rpc-walk.mts | 6 +- scripts/e2e/lib/openwebui/http-probe.mjs | 4 +- .../lib/plugin-lifecycle-matrix/measure.mjs | 8 +- scripts/e2e/npm-telegram-live-runner.ts | 6 +- scripts/e2e/openwebui-probe.mjs | 10 +- scripts/e2e/telegram-user-crabbox-proof.ts | 18 +- scripts/ensure-playwright-chromium.mts | 12 +- ...enerate-bundled-channel-config-metadata.ts | 3 +- scripts/lib/arg-utils.runtime.d.mts | 69 +++++ scripts/lib/arg-utils.runtime.mjs | 54 ++++ scripts/lib/cross-os-release-checks/config.ts | 9 +- .../lib/cross-os-release-checks/process.ts | 4 +- scripts/lib/deprecated-plugin-sdk-usage.mts | 4 + scripts/lib/dev-tooling-safety.ts | 11 +- scripts/lib/error-format.mts | 5 + scripts/lib/vitest-local-scheduling.mts | 11 +- .../openclaw-performance-source-summary.mts | 2 +- .../openclaw-release-clawhub-runtime-state.ts | 17 +- scripts/package-openclaw-for-docker.mts | 9 +- scripts/pre-commit/pnpm-audit-prod.mjs | 6 +- .../resolve-openclaw-package-candidate.mts | 14 +- scripts/run-vitest.mts | 12 +- scripts/test-docker-all.mts | 11 +- scripts/test-live-shard.mts | 23 +- scripts/test-projects.test-support.mts | 4 +- scripts/verify-pr-hosted-gates.mts | 4 +- security/opengrep/check-rule-metadata.mjs | 8 +- .../agent-command.live-model-switch.test.ts | 5 +- src/agents/auth-profiles/persisted.ts | 7 +- .../bash-tools.exec-request-preparation.ts | 3 +- src/agents/cli-output-events.ts | 7 +- .../google-prompt-cache.ts | 6 +- .../model-context-tokens.ts | 3 +- .../model.provider-hooks.ts | 7 +- .../run/attempt-trajectory-status.ts | 6 +- src/agents/harness/lifecycle-hook-helpers.ts | 3 +- .../main-session-restart-recovery-shared.ts | 5 +- src/agents/mcp-transport-config.ts | 5 +- src/agents/prepared-model-runtime.errors.ts | 4 +- src/agents/sessions/extensions/runner.ts | 21 +- src/agents/tools/common.ts | 5 +- src/agents/tools/embedded-gateway-stub.ts | 3 +- .../tools/image-tool.providers.live.test.ts | 6 +- src/agents/tools/sessions-history-tool.ts | 3 +- src/auto-reply/dispatch.ts | 27 +- src/auto-reply/reply-payload.ts | 11 +- src/channels/config-presence.ts | 6 +- .../message/ingress-retry-policy.test.ts | 5 +- src/channels/thread-bindings-policy.ts | 13 +- src/claws/add.ts | 28 +-- src/claws/cron-update.ts | 17 +- src/claws/cron.ts | 5 +- src/claws/doctor.ts | 6 +- src/claws/export.ts | 6 +- src/claws/lifecycle-delete-support.ts | 5 +- src/claws/lifecycle-mcp-removal.ts | 3 +- src/claws/lifecycle-state.ts | 4 +- src/claws/mcp-update.ts | 7 +- src/claws/mcp.ts | 6 +- src/claws/package-remove.ts | 3 +- src/claws/package-update.ts | 10 +- src/claws/packages.ts | 6 +- src/claws/project.ts | 5 +- src/claws/update-apply.ts | 129 +++------- src/claws/workspace-update.ts | 5 +- src/claws/workspace.ts | 3 +- src/cli/config-cli-path.ts | 2 +- src/cli/daemon-cli/status.gather.ts | 6 +- src/cli/logs-cli.ts | 15 +- src/cli/run-main.exit.test.ts | 2 - src/cli/run-main.profile-env.test.ts | 5 +- src/cli/update-cli.test.ts | 9 +- src/commands/backup-verify.ts | 7 +- src/commands/doctor-config-flow.test.ts | 46 ++-- src/commands/doctor-db-bloat.ts | 4 +- .../doctor-legacy-config.migrations.test.ts | 9 +- src/config/doc-baseline.ts | 6 +- src/config/io.write-safety.ts | 3 +- ...official-external-channel-secret-schema.ts | 5 +- .../session-accessor.sqlite-archive.ts | 9 +- .../session-accessor.sqlite-session-row.ts | 3 +- .../sessions/session-transcript-reconcile.ts | 13 +- .../transcript-append.test-support.ts | 13 +- .../sessions/transcript-recent-window.ts | 6 +- src/config/sessions/types.ts | 7 +- src/config/types.secrets.ts | 7 +- src/context-engine/runtime-settings.ts | 9 +- src/cron/run-diagnostics-normalize.ts | 6 +- src/cron/service/failure-alerts.ts | 21 +- .../chat-display-projection.history.ts | 8 +- src/gateway/control-ui-github-api.ts | 4 +- src/gateway/gateway-acp-bind.live.test.ts | 9 +- src/gateway/live-agent-probes.ts | 11 +- src/gateway/server-methods/attach.ts | 8 +- .../server-methods/chat-history-pages.ts | 4 +- src/gateway/server-methods/plugins.test.ts | 5 +- src/gateway/server-methods/record-shared.ts | 13 +- src/gateway/server-methods/restart.ts | 3 +- src/gateway/server-methods/sessions-files.ts | 28 +-- src/gateway/server-runtime-services.test.ts | 5 - src/gateway/server-startup-post-attach.ts | 3 +- src/gateway/session-utils-core.ts | 7 +- src/gateway/session-utils.fs.ts | 3 +- src/infra/clawhub-client.ts | 3 +- src/infra/clawhub-skill-security.ts | 4 +- src/infra/device-identity-store.ts | 3 +- src/infra/exec-approval-session-target.ts | 8 +- src/infra/outbound/channel-target.ts | 5 +- src/infra/pairing-files.ts | 6 +- src/infra/restart-handoff.ts | 3 +- src/infra/restart-intent.ts | 3 +- src/infra/sqlite-integrity.ts | 7 +- ...tate-migrations.managed-outgoing-images.ts | 3 +- src/infra/update-control-plane-sentinel.ts | 21 +- src/logging/diagnostic-support-export.ts | 20 +- src/meeting-bot/session-runtime.test.ts | 3 +- src/memory-host-sdk/dreaming.ts | 21 +- src/plugin-sdk/file-lock.ts | 5 +- .../provider-catalog-live-runtime.ts | 5 +- src/plugin-sdk/provider-onboard.ts | 3 +- .../provider-discovery-contract.ts | 10 +- src/plugins/bundled-plugin-naming.test.ts | 17 +- .../inventory/bundled-capability-metadata.ts | 63 +++-- src/plugins/contracts/registry.ts | 62 +++-- .../shared-upstream-model.contract.test.ts | 7 +- src/plugins/contracts/shared.ts | 2 +- src/plugins/loader-records.ts | 5 +- .../websocket-session.ts | 12 +- src/security/audit-channel.ts | 8 +- src/state/openclaw-database-verify.impl.ts | 46 +--- src/state/openclaw-database-verify.test.ts | 115 +-------- .../openclaw-state-db-legacy-backfills.ts | 4 +- src/system-agent/rescue-channel.live.test.ts | 9 +- src/talk/event-metrics.ts | 6 +- src/trajectory/runtime-store.sqlite.ts | 4 +- src/tui/gateway-chat.ts | 11 +- src/web/provider-runtime-shared.ts | 14 +- src/worker/worker-connection-contract.ts | 37 +-- src/worker/worker-connection.test.ts | 120 --------- test/e2e/qa-lab/config/cli-channel-picker.ts | 5 +- .../media/hosted-media-provider-live.ts | 5 +- .../clawhub-release-candidate-install.ts | 5 +- .../browser-plugin-profiles-packaged.ts | 5 +- .../qa-lab/runtime/docker-artifact-proof.ts | 5 +- .../runtime/gateway-protocol-artifacts.ts | 5 +- ...gateway-service-lifecycle-product-proof.ts | 5 +- .../update-run-package-self-upgrade.ts | 5 +- .../qa-lab/tui/tui-pty-evidence-producer.ts | 5 +- test/helpers/openai-long-context-live.ts | 3 +- test/scripts/arg-utils.test.ts | 71 ++++++ ...check-coercion-helper-declarations.test.ts | 64 +++++ .../check-deprecated-api-usage.test.ts | 5 +- ui/src/app/question-prompt.ts | 3 +- ui/src/components/browser/browser-client.ts | 17 +- ui/src/components/config-form.analyze.ts | 6 +- ui/src/components/github-link-hovercard.ts | 14 +- ui/src/lib/workboard/task-links.ts | 5 +- ui/src/pages/chat/chat-thread-items.ts | 4 +- .../chat/components/chat-composer-context.ts | 4 +- .../chat/components/chat-message-media.ts | 4 +- ui/src/pages/chat/stream-reconciliation.ts | 9 +- 276 files changed, 1685 insertions(+), 1663 deletions(-) create mode 100644 scripts/lib/arg-utils.runtime.d.mts diff --git a/extensions/active-memory/config.ts b/extensions/active-memory/config.ts index 43bcb6c5d05c..bb92fae1bd4b 100644 --- a/extensions/active-memory/config.ts +++ b/extensions/active-memory/config.ts @@ -9,6 +9,7 @@ import { isPathInside } from "openclaw/plugin-sdk/security-runtime"; import { asOptionalRecord, normalizeLowercaseStringOrEmpty, + normalizeOptionalString, normalizeStringEntries, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { @@ -132,11 +133,6 @@ function resolveToolsAllow(params: { pluginToolsAllow: unknown; cfg?: OpenClawCo ); } -function normalizePromptConfigText(value: unknown): string | undefined { - const text = typeof value === "string" ? value.trim() : ""; - return text ? text : undefined; -} - function hasDeprecatedModelFallbackPolicy(pluginConfig: unknown): boolean { const raw = asOptionalRecord(pluginConfig); return raw ? Object.hasOwn(raw, "modelFallbackPolicy") : false; @@ -239,8 +235,8 @@ function normalizePluginConfig( fastMode: normalizeActiveMemoryFastMode(raw.fastMode), promptStyle: resolvePromptStyle(raw.promptStyle, raw.queryMode), toolsAllow: resolveToolsAllow({ pluginToolsAllow: raw.toolsAllow, cfg }), - promptOverride: normalizePromptConfigText(raw.promptOverride), - promptAppend: normalizePromptConfigText(raw.promptAppend), + promptOverride: normalizeOptionalString(raw.promptOverride), + promptAppend: normalizeOptionalString(raw.promptAppend), timeoutMs: clampInt( parseOptionalPositiveInt(raw.timeoutMs, DEFAULT_TIMEOUT_MS), DEFAULT_TIMEOUT_MS, diff --git a/extensions/anthropic/session-catalog-history.ts b/extensions/anthropic/session-catalog-history.ts index e6ecef2fdeb2..38d103e21f5a 100644 --- a/extensions/anthropic/session-catalog-history.ts +++ b/extensions/anthropic/session-catalog-history.ts @@ -1,5 +1,6 @@ import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { parseDateStringTimestampMs } from "openclaw/plugin-sdk/number-runtime"; import { withSessionTranscriptWriteLock } from "openclaw/plugin-sdk/session-transcript-runtime"; import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js"; import type { ClaudeTranscriptItem } from "./session-catalog-transcript.js"; @@ -8,8 +9,7 @@ function importedClaudeMessage( item: ClaudeTranscriptItem, fallbackTimestamp: number, ): AgentMessage | undefined { - const parsedTimestamp = item.timestamp ? Date.parse(item.timestamp) : Number.NaN; - const timestamp = Number.isFinite(parsedTimestamp) ? parsedTimestamp : fallbackTimestamp; + const timestamp = parseDateStringTimestampMs(item.timestamp) ?? fallbackTimestamp; const importedText = item.text?.trim(); if (!importedText && item.type === "reasoning") { return undefined; diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index 4d27b07fa84b..ff9d79795bb1 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -14,6 +14,7 @@ import type { SessionCatalogTranscriptItem, } from "openclaw/plugin-sdk/session-catalog"; import { + asPositiveSafeInteger as pullRequestNumber, isRecord, normalizeBoundedOptionalString as readBoundedString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -241,10 +242,6 @@ function pullRequestState(value: unknown): SessionCatalogPullRequestSummary["sta } } -function pullRequestNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; -} - // Desktop retains historical PRs in order and marks hidden ones as dismissed; // the top-level pair identifies the current PR whose state labels the row. function desktopPullRequestSummary( diff --git a/extensions/anthropic/session-upstream-activity.ts b/extensions/anthropic/session-upstream-activity.ts index f6da4c1abb9e..de05458cab9b 100644 --- a/extensions/anthropic/session-upstream-activity.ts +++ b/extensions/anthropic/session-upstream-activity.ts @@ -7,7 +7,7 @@ import { type SessionUpstreamActivity, type SessionUpstreamProbe, } from "openclaw/plugin-sdk/session-catalog"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asSafeIntegerInRange, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ClaudeTranscriptItem } from "./session-catalog-transcript.js"; const MAX_CLAUDE_UPSTREAM_SCAN_BYTES = 1024 * 1024; @@ -119,7 +119,7 @@ function readMarkerOffset(probe: SessionUpstreamProbe): number | undefined { return undefined; } const offset = probe.marker.offset ?? probe.marker.size; - return Number.isSafeInteger(offset) && (offset as number) >= 0 ? (offset as number) : undefined; + return asSafeIntegerInRange(offset, { min: 0 }); } async function checkClaudeSessionUpstreamActivity( diff --git a/extensions/browser/src/browser/act-policy.ts b/extensions/browser/src/browser/act-policy.ts index ffa23ad12216..0417db4f6930 100644 --- a/extensions/browser/src/browser/act-policy.ts +++ b/extensions/browser/src/browser/act-policy.ts @@ -11,6 +11,7 @@ import { parseStrictInteger, resolveTimerTimeoutMs, } from "openclaw/plugin-sdk/number-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { BrowserActRequest } from "./client-actions.types.js"; import { DEFAULT_BROWSER_ACTION_TIMEOUT_MS } from "./constants.js"; @@ -110,7 +111,7 @@ function addNavigationGraceMs(durationMs: number, count = 1): number { } function isActionObject(value: unknown): value is BrowserActRequest { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); + return isRecord(value); } function resolveLeafExecutionBudgetMs( diff --git a/extensions/clickclack/src/access.ts b/extensions/clickclack/src/access.ts index 476634489c53..f5dab44dab1a 100644 --- a/extensions/clickclack/src/access.ts +++ b/extensions/clickclack/src/access.ts @@ -8,6 +8,7 @@ import { type StableChannelIngressIdentityParams, } from "openclaw/plugin-sdk/channel-ingress-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { parseDateStringTimestampMs } from "openclaw/plugin-sdk/number-runtime"; import { normalizeAgentId, type ResolvedAgentRoute, @@ -249,6 +250,7 @@ export async function resolveClickClackInboundAccess(params: { preparedRoute, }; } + const botLoopNowMs = parseDateStringTimestampMs(params.message.created_at); const botLoopProtection = isBotAuthor && params.message.author_id !== params.account.botUserId && params.account.botUserId ? { @@ -263,9 +265,7 @@ export async function resolveClickClackInboundAccess(params: { senderId: params.message.author_id, receiverId: params.account.botUserId, eventId: params.message.id, - ...(Number.isFinite(Date.parse(params.message.created_at)) - ? { nowMs: Date.parse(params.message.created_at) } - : {}), + ...(botLoopNowMs !== undefined ? { nowMs: botLoopNowMs } : {}), config: effectiveBotPolicy.botLoopProtection, defaultsConfig: cfg.channels?.defaults?.botLoopProtection, defaultEnabled: true, diff --git a/extensions/codex/media-understanding-provider.ts b/extensions/codex/media-understanding-provider.ts index 00586275e6dc..c1ce4b2d930c 100644 --- a/extensions/codex/media-understanding-provider.ts +++ b/extensions/codex/media-understanding-provider.ts @@ -2,10 +2,7 @@ * Codex-backed media understanding provider for bounded image description and * structured extraction turns. */ -import { - type JsonSchemaObject, - validateJsonSchemaValue, -} from "openclaw/plugin-sdk/json-schema-runtime"; +import { validateJsonSchemaValue } from "openclaw/plugin-sdk/json-schema-runtime"; import type { ImagesDescriptionRequest, ImagesDescriptionResult, @@ -13,6 +10,7 @@ import type { StructuredExtractionRequest, StructuredExtractionResult, } from "openclaw/plugin-sdk/media-understanding"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { runBoundedCodexAppServerTurn, type CodexBoundedTurnOptions, @@ -179,10 +177,6 @@ function buildStructuredExtractionPrompt(req: StructuredExtractionRequest): stri .join("\n\n"); } -function isJsonSchemaObject(value: unknown): value is JsonSchemaObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function normalizeStructuredExtractionResult(params: { text: string; model: string; @@ -201,7 +195,7 @@ function normalizeStructuredExtractionResult(params: { } catch { throw new Error("Codex structured extraction returned invalid JSON."); } - if (isJsonSchemaObject(params.req.jsonSchema)) { + if (isRecord(params.req.jsonSchema)) { const validation = validateJsonSchemaValue({ schema: params.req.jsonSchema, cacheKey: "codex.media-understanding.extractStructured", diff --git a/extensions/codex/src/app-server/attempt-context.ts b/extensions/codex/src/app-server/attempt-context.ts index 4c180ef866bb..ddc1eb78b444 100644 --- a/extensions/codex/src/app-server/attempt-context.ts +++ b/extensions/codex/src/app-server/attempt-context.ts @@ -24,6 +24,7 @@ import type { SessionTranscriptTargetParams, TranscriptTurnAdmission, } from "openclaw/plugin-sdk/session-transcript-runtime"; +import { readNonBlankString as readNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js"; import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js"; import { flattenCodexDynamicToolFunctions } from "./protocol.js"; @@ -502,10 +503,6 @@ function readPositiveNumber(value: unknown): number | undefined { : undefined; } -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; -} - /** * Builds OpenClaw-provided workspace prompt context for the current Codex turn. */ diff --git a/extensions/codex/src/app-server/compact.ts b/extensions/codex/src/app-server/compact.ts index d020459e3b0a..90b469f6f37a 100644 --- a/extensions/codex/src/app-server/compact.ts +++ b/extensions/codex/src/app-server/compact.ts @@ -10,6 +10,7 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime"; import { createDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime"; +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isIncognitoSessionKey } from "../incognito-session.js"; @@ -963,13 +964,10 @@ function isCodexThreadNotFoundError(error: unknown): boolean { // compaction.rs asserts message.contains("thread not found")). So the message // is the authoritative positive signal here, not the generic code. This is a // self-heal recovery gate, not user-facing classification. - return formatCompactionError(error).toLowerCase().includes("thread not found"); + return coerceErrorMessage(error).toLowerCase().includes("thread not found"); } function formatCompactionError(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); + return coerceErrorMessage(error); } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/codex/src/app-server/config-utils.ts b/extensions/codex/src/app-server/config-utils.ts index a0313586d16f..dcf581b670eb 100644 --- a/extensions/codex/src/app-server/config-utils.ts +++ b/extensions/codex/src/app-server/config-utils.ts @@ -2,6 +2,8 @@ import { createHmac, randomBytes } from "node:crypto"; import { resolvePositiveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { + asOptionalRecord as readRecord, + normalizeOptionalString as readNonEmptyString, normalizeTrimmedStringList, parseBooleanValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -12,11 +14,7 @@ const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStart const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret(); const PLAIN_DECIMAL_NUMBER_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))$/; -export function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} +export { readNonEmptyString, readRecord }; export function normalizeCodexServiceTier(value: unknown): CodexServiceTier | undefined { if (typeof value !== "string") { @@ -108,14 +106,6 @@ export function resolveArgs(configArgs: unknown, envArgs: string | undefined): s return splitShellWords(envArgs ?? ""); } -export function readNonEmptyString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed || undefined; -} - export function hashSecretForKey(value: string | undefined, label: string): string | null { if (!value) { return null; diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index 83240ede03ef..78809584d24d 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -46,6 +46,7 @@ import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; import type { ImageContent, TextContent } from "openclaw/plugin-sdk/llm"; import { normalizeOpenAIToolSchemas } from "openclaw/plugin-sdk/provider-tools"; import { + asNonArrayRecord, asOptionalRecord, isRecord, normalizeOptionalString, @@ -557,7 +558,7 @@ export function createCodexDynamicToolBridge(params: { handleToolCall: async (call, options) => { const toolEntry = toolMap.get(call.tool); if (!toolEntry) { - const executedArguments = jsonObjectToRecord(call.arguments); + const executedArguments = asNonArrayRecord(call.arguments); const message = registeredToolNames.has(call.tool) ? `OpenClaw tool is not available for this turn: ${call.tool}` : `Unknown OpenClaw tool: ${call.tool}`; @@ -582,7 +583,7 @@ export function createCodexDynamicToolBridge(params: { }); } const { tool, name: toolName } = toolEntry; - const args = jsonObjectToRecord(call.arguments); + const args = asNonArrayRecord(call.arguments); const startedAt = Date.now(); const signal = composeAbortSignals(params.signal, options?.signal); let didStartExecution = false; @@ -1530,12 +1531,6 @@ function convertToolContent( }, ]; } -function jsonObjectToRecord(value: JsonValue | undefined): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return {}; - } - return value as Record; -} function readFirstString(record: Record, keys: string[]): string | undefined { for (const key of keys) { const value = record[key]; diff --git a/extensions/codex/src/app-server/event-projector-tool-output.ts b/extensions/codex/src/app-server/event-projector-tool-output.ts index 9f9117224b82..c23bb5b34328 100644 --- a/extensions/codex/src/app-server/event-projector-tool-output.ts +++ b/extensions/codex/src/app-server/event-projector-tool-output.ts @@ -2,7 +2,10 @@ import { formatToolAggregate, formatToolProgressOutput, } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asNonArrayRecord, + readStringField as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { isJsonObject, type CodexThreadItem } from "./protocol.js"; @@ -104,10 +107,7 @@ export function toolOutputRawEchoSignature( } export function normalizeToolTranscriptArguments(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return {}; - } - return value as Record; + return asNonArrayRecord(value); } export function collectDynamicToolContentText( diff --git a/extensions/codex/src/app-server/event-projector-usage.ts b/extensions/codex/src/app-server/event-projector-usage.ts index 5caa688b0bb3..215d9143b398 100644 --- a/extensions/codex/src/app-server/event-projector-usage.ts +++ b/extensions/codex/src/app-server/event-projector-usage.ts @@ -1,14 +1,13 @@ import { normalizeUsage } from "openclaw/plugin-sdk/agent-harness-runtime"; import { asFiniteNumber, + asSafeIntegerInRange, readStringField as readString, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { readNonNegativeInteger } from "./event-projector-values.js"; import { isJsonObject, type JsonObject } from "./protocol.js"; function readTokenCount(record: JsonObject, key: string): number | undefined { - const value = readNonNegativeInteger(record, key); - return value !== undefined && Number.isSafeInteger(value) ? value : undefined; + return asSafeIntegerInRange(record[key], { min: 0 }); } function readCodexThreadTokenUsage(params: JsonObject): ReturnType { diff --git a/extensions/codex/src/app-server/event-projector-values.ts b/extensions/codex/src/app-server/event-projector-values.ts index 9c0bbe04c11a..17610308462c 100644 --- a/extensions/codex/src/app-server/event-projector-values.ts +++ b/extensions/codex/src/app-server/event-projector-values.ts @@ -1,15 +1,14 @@ -import { asFiniteNumber, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asFiniteNumber, + normalizeOptionalString, + readStringField, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { isJsonObject, type CodexThreadItem, type JsonObject, type JsonValue } from "./protocol.js"; -export function normalizeNonEmptyString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - return value.trim() || undefined; -} +export { normalizeOptionalString as normalizeNonEmptyString }; export function readNonEmptyString(record: JsonObject, key: string): string | undefined { - return normalizeNonEmptyString(record[key]); + return normalizeOptionalString(record[key]); } export function readNonEmptyStringArray(record: JsonObject, key: string): string[] { @@ -19,7 +18,7 @@ export function readNonEmptyStringArray(record: JsonObject, key: string): string } const entries: string[] = []; for (const entry of value) { - const normalized = normalizeNonEmptyString(entry); + const normalized = normalizeOptionalString(entry); if (normalized) { entries.push(normalized); } diff --git a/extensions/codex/src/app-server/models.ts b/extensions/codex/src/app-server/models.ts index d409d8fdd783..ef537fa19a26 100644 --- a/extensions/codex/src/app-server/models.ts +++ b/extensions/codex/src/app-server/models.ts @@ -2,7 +2,7 @@ * Lists and normalizes models exposed by the Codex app-server `model/list` * endpoint, including pagination and shared-client lease handling. */ -import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CodexAppServerAuthRequirement, resolveCodexAppServerAuthProfileIdForAgent, @@ -145,8 +145,8 @@ export function readModelListResult(value: unknown): CodexAppServerModelListResu } function readCodexModel(value: CodexModel): CodexAppServerModel { - const id = readNonEmptyString(value.id); - const model = readNonEmptyString(value.model); + const id = normalizeOptionalString(value.id); + const model = normalizeOptionalString(value.model); if (!id || !model) { throw new Error( "Invalid Codex app-server model/list response: model id and name must be non-empty strings", @@ -155,37 +155,29 @@ function readCodexModel(value: CodexModel): CodexAppServerModel { return { id, model, - ...(readNonEmptyString(value.displayName) - ? { displayName: readNonEmptyString(value.displayName) } + ...(normalizeOptionalString(value.displayName) + ? { displayName: normalizeOptionalString(value.displayName) } : {}), - ...(readNonEmptyString(value.description) - ? { description: readNonEmptyString(value.description) } + ...(normalizeOptionalString(value.description) + ? { description: normalizeOptionalString(value.description) } : {}), hidden: value.hidden, isDefault: value.isDefault, inputModalities: value.inputModalities, supportedReasoningEfforts: readReasoningEfforts(value.supportedReasoningEfforts), - ...(readNonEmptyString(value.defaultReasoningEffort) - ? { defaultReasoningEffort: readNonEmptyString(value.defaultReasoningEffort) } + ...(normalizeOptionalString(value.defaultReasoningEffort) + ? { defaultReasoningEffort: normalizeOptionalString(value.defaultReasoningEffort) } : {}), }; } function readReasoningEfforts(value: CodexReasoningEffortOption[]): string[] { const efforts = value - .map((entry) => readNonEmptyString(entry.reasoningEffort)) + .map((entry) => normalizeOptionalString(entry.reasoningEffort)) .filter((entry): entry is string => entry !== undefined); return uniqueStrings(efforts); } -function readNonEmptyString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed || undefined; -} - function normalizeMaxPages(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 20; } diff --git a/extensions/codex/src/app-server/plugin-thread-config.ts b/extensions/codex/src/app-server/plugin-thread-config.ts index 5eefd09cd754..6054a5194efd 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.ts @@ -715,17 +715,11 @@ function mergeJsonObjects(left: JsonObject, right: JsonObject): JsonObject { for (const [key, value] of Object.entries(right)) { const existing = merged[key]; merged[key] = - isPlainJsonObject(existing) && isPlainJsonObject(value) - ? mergeJsonObjects(existing, value) - : value; + isJsonObject(existing) && isJsonObject(value) ? mergeJsonObjects(existing, value) : value; } return merged; } -function isPlainJsonObject(value: JsonValue | undefined): value is JsonObject { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function fingerprintJson(value: JsonValue): string { return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); } diff --git a/extensions/codex/src/app-server/protocol.ts b/extensions/codex/src/app-server/protocol.ts index 00047cfd23ed..e1811af3c1c7 100644 --- a/extensions/codex/src/app-server/protocol.ts +++ b/extensions/codex/src/app-server/protocol.ts @@ -1,3 +1,4 @@ +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CodexCommandExecParams, CodexCommandExecResponse } from "./command-exec-protocol.js"; import type { CodexAppInfo, @@ -707,7 +708,7 @@ type CodexAppServerRequestResultMap = { }; export function isJsonObject(value: unknown): value is JsonObject { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); + return isRecord(value); } export function isRpcResponse(message: RpcMessage): message is RpcResponse { diff --git a/extensions/codex/src/app-server/rpc-error.ts b/extensions/codex/src/app-server/rpc-error.ts index 531ad06b4a9e..c143aa581489 100644 --- a/extensions/codex/src/app-server/rpc-error.ts +++ b/extensions/codex/src/app-server/rpc-error.ts @@ -1,4 +1,4 @@ -import type { JsonValue } from "./protocol.js"; +import { isJsonObject, type JsonValue } from "./protocol.js"; /** RPC error wrapper that preserves app-server error code and data. */ export class CodexAppServerRpcError extends Error { @@ -36,7 +36,3 @@ function readCodexAppServerRpcReloginDetail(data: JsonValue | undefined): string const detail = typeof nested.detail === "string" ? nested.detail.trim() : ""; return isRelogin && detail ? detail : undefined; } - -function isJsonObject(value: unknown): value is { [key: string]: JsonValue } { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} diff --git a/extensions/codex/src/app-server/settled-turn-projection.ts b/extensions/codex/src/app-server/settled-turn-projection.ts index 0a9a0649455b..fc8e1dd6da01 100644 --- a/extensions/codex/src/app-server/settled-turn-projection.ts +++ b/extensions/codex/src/app-server/settled-turn-projection.ts @@ -1,6 +1,6 @@ import { Buffer } from "node:buffer"; import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { JsonValue } from "./protocol.js"; import { readUpstreamUserText } from "./upstream-prompt-provenance.js"; @@ -18,10 +18,6 @@ type ProjectedMessageGroup = { bytes: number; }; -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" ? value.trim() || undefined : undefined; -} - function readBoundedText( value: unknown, label: string, @@ -49,7 +45,7 @@ function responseItemBytes(item: JsonValue): number { } function requireCallId(value: unknown): string { - const callId = readNonEmptyString(value); + const callId = normalizeOptionalString(value); if (!callId || callId.length > 256) { throw new Error("Codex settled-turn projection found an invalid tool call id"); } @@ -57,7 +53,7 @@ function requireCallId(value: unknown): string { } function requireToolName(value: unknown): string { - const name = readNonEmptyString(value); + const name = normalizeOptionalString(value); if (!name || !TOOL_NAME_PATTERN.test(name)) { throw new Error("Codex settled-turn projection found an invalid tool name"); } @@ -207,7 +203,7 @@ function projectToolResult(message: Record): { throw new Error("Codex settled-turn projection found malformed tool result content"); } if (value.type === "image") { - const mimeType = readNonEmptyString(value.mimeType) ?? "unknown type"; + const mimeType = normalizeOptionalString(value.mimeType) ?? "unknown type"; // The finalizer selects by text capability. Preserve image evidence as // metadata without embedding an executable or oversized multimodal payload. parts.push(`[Image tool result: ${mimeType}]`); diff --git a/extensions/codex/src/app-server/thread-context-engine.ts b/extensions/codex/src/app-server/thread-context-engine.ts index adbd1499a5cc..77f97e3f9885 100644 --- a/extensions/codex/src/app-server/thread-context-engine.ts +++ b/extensions/codex/src/app-server/thread-context-engine.ts @@ -2,6 +2,7 @@ import { isActiveHarnessContextEngine, type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCodexContextEngineProjectionMaxChars, resolveCodexContextEngineProjectionReserveTokens, @@ -88,16 +89,12 @@ function areContextEngineProjectionBindingsCompatible( } function resolveContextEngineCitationsMode(config: unknown): JsonValue | undefined { - const rootConfig = isUnknownRecord(config) ? config : undefined; - const memoryConfig = isUnknownRecord(rootConfig?.memory) ? rootConfig.memory : undefined; + const rootConfig = isRecord(config) ? config : undefined; + const memoryConfig = isRecord(rootConfig?.memory) ? rootConfig.memory : undefined; const citations = memoryConfig?.citations; return isJsonConfigValue(citations) ? citations : undefined; } -function isUnknownRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function isJsonConfigValue(value: unknown): value is JsonValue { if (value === null || typeof value === "string" || typeof value === "boolean") { return true; @@ -108,5 +105,5 @@ function isJsonConfigValue(value: unknown): value is JsonValue { if (Array.isArray(value)) { return value.every(isJsonConfigValue); } - return isUnknownRecord(value) && Object.values(value).every(isJsonConfigValue); + return isRecord(value) && Object.values(value).every(isJsonConfigValue); } diff --git a/extensions/codex/src/app-server/upstream-session-fork.ts b/extensions/codex/src/app-server/upstream-session-fork.ts index 6b26d736c98e..e536655c87b1 100644 --- a/extensions/codex/src/app-server/upstream-session-fork.ts +++ b/extensions/codex/src/app-server/upstream-session-fork.ts @@ -8,7 +8,7 @@ import { deleteSessionUpstreamLink, upsertSessionUpstreamLink, } from "openclaw/plugin-sdk/session-catalog"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isIncognitoSessionKey } from "../incognito-session.js"; import type { CodexSessionCatalogControl } from "../session-catalog-types.js"; import { codexLastTerminalTurnId, codexUpstreamBaseline } from "../session-upstream-marker.js"; @@ -32,7 +32,7 @@ function readConnectionFingerprint(ref: unknown): string | undefined { } function normalizeTurnId(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; + return normalizeOptionalString(value); } export async function forkCodexUpstreamSession( diff --git a/extensions/codex/src/native-thread-tool.ts b/extensions/codex/src/native-thread-tool.ts index fda1c0fa7f85..2751e01550c3 100644 --- a/extensions/codex/src/native-thread-tool.ts +++ b/extensions/codex/src/native-thread-tool.ts @@ -12,7 +12,11 @@ import { ModelSelectionLockedError, } from "openclaw/plugin-sdk/model-session-runtime"; import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; -import { asBoolean, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean, + asOptionalRecord, + asSafeIntegerInRange, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; import { resolveCodexBindingAppServerConnection } from "./app-server/binding-connection.js"; import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js"; @@ -112,12 +116,6 @@ type CodexThreadsToolOptions = { request?: typeof codexControlRequest; }; -function readLimit(value: unknown): number | undefined { - return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100 - ? value - : undefined; -} - function resolveToolSession( context: OpenClawPluginToolContext, runtime: PluginRuntime, @@ -291,7 +289,7 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge CODEX_CONTROL_METHODS.listThreads, { archived: asBoolean(params.archived) ?? false, - limit: readLimit(params.limit) ?? 20, + limit: asSafeIntegerInRange(params.limit, { min: 1, max: 100 }) ?? 20, modelProviders: [], sortKey: "recency_at", sortDirection: "desc", diff --git a/extensions/codex/src/session-upstream-marker.test.ts b/extensions/codex/src/session-upstream-marker.test.ts index 2b975d0ca649..7aa0c4160eac 100644 --- a/extensions/codex/src/session-upstream-marker.test.ts +++ b/extensions/codex/src/session-upstream-marker.test.ts @@ -1,10 +1,8 @@ +import { readNonEmptyStringPreservingWhitespace as normalizeTurnId } from "openclaw/plugin-sdk/string-coerce-runtime"; import { describe, expect, it } from "vitest"; import type { CodexThread } from "./app-server/protocol.js"; import { codexUpstreamBaseline } from "./session-upstream-marker.js"; -const normalizeTurnId = (value: unknown) => - typeof value === "string" && value ? value : undefined; - describe("codexUpstreamBaseline", () => { it("baselines an active adoption-time turn including its current user items", () => { const thread = { diff --git a/extensions/codex/src/web-search-provider.runtime.ts b/extensions/codex/src/web-search-provider.runtime.ts index 6c2a1d5ff82c..cca5e346b992 100644 --- a/extensions/codex/src/web-search-provider.runtime.ts +++ b/extensions/codex/src/web-search-provider.runtime.ts @@ -6,6 +6,7 @@ import { wrapWebContent, } from "openclaw/plugin-sdk/provider-web-search"; import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { runBoundedCodexAppServerTurn, type CodexBoundedTurnOptions, @@ -66,7 +67,7 @@ function summarizeCodexWebSearchItem(item: CodexThreadItem): Record { - const normalized = normalizeNonEmptyString(entry); + const normalized = normalizeOptionalString(entry); return normalized ? [normalized] : []; }); } - -function normalizeNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" ? value.trim() || undefined : undefined; -} diff --git a/extensions/copilot/src/replay-shim.ts b/extensions/copilot/src/replay-shim.ts index 1520a49d68e7..3b3d4f6b1f0b 100755 --- a/extensions/copilot/src/replay-shim.ts +++ b/extensions/copilot/src/replay-shim.ts @@ -20,6 +20,8 @@ // - `src/agents/pi-embedded-runner/run/types.ts` — // `AgentHarnessAttemptResult.replayMetadata` field requirement. +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; + type ReplayDecision = | { readonly action: "resume"; @@ -38,11 +40,7 @@ interface ReplayShimInput { } function normalizeSdkSessionId(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; + return normalizeOptionalString(value); } /** diff --git a/extensions/diagnostics-prometheus/src/service.ts b/extensions/diagnostics-prometheus/src/service.ts index 916bf6ddc6f1..3c49243230ae 100644 --- a/extensions/diagnostics-prometheus/src/service.ts +++ b/extensions/diagnostics-prometheus/src/service.ts @@ -4,6 +4,7 @@ import { normalizeDiagnosticValue, normalizeDiagnosticLane, } from "openclaw/plugin-sdk/diagnostic-runtime"; +import { asNonNegativeFiniteNumber as numericValue } from "openclaw/plugin-sdk/number-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { DiagnosticEventMetadata, @@ -56,10 +57,6 @@ const RATIO_BUCKETS = [0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 1, 2, 4, 8, 16]; const MAX_PROMETHEUS_SERIES = 2048; const DROPPED_SERIES_COUNTER_NAME = "openclaw_prometheus_series_dropped_total"; -function numericValue(value: number | undefined): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} - function seconds(ms: number | undefined): number | undefined { const value = numericValue(ms); return value === undefined ? undefined : value / 1000; diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index fd3c6a65a7aa..a6a3ceb40cdc 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -11,6 +11,7 @@ import { } from "openclaw/plugin-sdk/proxy-capture"; import { danger, warn } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import * as ws from "ws"; import * as discordGateway from "../internal/gateway.js"; @@ -78,8 +79,7 @@ function readStringProperty(value: object, key: string): string | undefined { } function readNumberProperty(value: object, key: string): number | undefined { - const property = (value as Record)[key]; - return typeof property === "number" && Number.isFinite(property) ? property : undefined; + return asFiniteNumber((value as Record)[key]); } function describeDiscordGatewayTransportError(error: Error): DiscordGatewayTransportErrorDetails { diff --git a/extensions/discord/src/monitor/provider.startup.test.ts b/extensions/discord/src/monitor/provider.startup.test.ts index 1604d0c19122..42dd6ece013b 100644 --- a/extensions/discord/src/monitor/provider.startup.test.ts +++ b/extensions/discord/src/monitor/provider.startup.test.ts @@ -33,17 +33,6 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ danger: (value: string) => value, })); -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, createDiscordRequestClient: vi.fn(() => ({ diff --git a/extensions/feishu/src/conversation-id.ts b/extensions/feishu/src/conversation-id.ts index 4842652ea0d9..d0cc1f1c7f5c 100644 --- a/extensions/feishu/src/conversation-id.ts +++ b/extensions/feishu/src/conversation-id.ts @@ -1,5 +1,8 @@ // Feishu plugin module implements conversation id behavior. -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString as normalizeText, +} from "openclaw/plugin-sdk/string-coerce-runtime"; export type FeishuGroupSessionScope = | "group" @@ -26,14 +29,6 @@ export function resolveConfiguredFeishuGroupSessionScope(params: { ); } -function normalizeText(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed || undefined; -} - export function buildFeishuConversationId(params: { chatId: string; scope: FeishuGroupSessionScope; diff --git a/extensions/feishu/src/monitor.transport.ts b/extensions/feishu/src/monitor.transport.ts index f4d96fdb3d68..81b94a7f8cbf 100644 --- a/extensions/feishu/src/monitor.transport.ts +++ b/extensions/feishu/src/monitor.transport.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto"; import * as http from "node:http"; import * as Lark from "@larksuiteoapi/node-sdk"; import { channelBlockedPatch, channelReadyPatch } from "openclaw/plugin-sdk/gateway-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { waitForAbortableDelay } from "./async.js"; import { createFeishuWSClient } from "./client.js"; @@ -56,7 +57,7 @@ const FEISHU_WS_AUTORECONNECT_DISABLED_ERROR = "WebSocket connect failed and autoReconnect is disabled"; function isFeishuWebhookPayload(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); + return isRecord(value); } const BLOCKED_FEISHU_WEBHOOK_PAYLOAD_KEYS = new Set([ diff --git a/extensions/file-transfer/src/shared/policy.ts b/extensions/file-transfer/src/shared/policy.ts index 25ad940db210..1281137f8241 100644 --- a/extensions/file-transfer/src/shared/policy.ts +++ b/extensions/file-transfer/src/shared/policy.ts @@ -50,6 +50,7 @@ import path from "node:path"; import { minimatch } from "minimatch"; import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation"; import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export type FilePolicyKind = "read" | "write"; type FilePolicyAskMode = "off" | "on-miss" | "always"; @@ -85,10 +86,7 @@ type NodeFilePolicyConfig = { type FilePolicyConfig = Record; function asFilePolicyConfig(value: unknown): FilePolicyConfig | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - return value as FilePolicyConfig; + return asNullableRecord(value) as FilePolicyConfig | null; } function readFilePolicyConfigFromPluginConfig(pluginConfig: unknown): FilePolicyConfig | null { diff --git a/extensions/fish-audio-speech/speech-provider.ts b/extensions/fish-audio-speech/speech-provider.ts index e70d46068bf3..3fdb28ede028 100644 --- a/extensions/fish-audio-speech/speech-provider.ts +++ b/extensions/fish-audio-speech/speech-provider.ts @@ -15,7 +15,11 @@ import { resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; -import { asFiniteNumberInRange, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asFiniteNumberInRange, + asOptionalRecord, + parseBooleanValue, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { FISH_AUDIO_STREAM_MAX_BYTES, type FishAudioFormat, @@ -207,12 +211,9 @@ function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext) { if (!ctx.policy.allowNormalization) { return { handled: true }; } - const value = ctx.value.trim().toLowerCase(); - if (["true", "1", "yes", "on"].includes(value)) { - return { handled: true, overrides: { ...ctx.currentOverrides, normalize: true } }; - } - if (["false", "0", "no", "off"].includes(value)) { - return { handled: true, overrides: { ...ctx.currentOverrides, normalize: false } }; + const normalize = parseBooleanValue(ctx.value); + if (normalize !== undefined) { + return { handled: true, overrides: { ...ctx.currentOverrides, normalize } }; } return { handled: true, warnings: [`invalid Fish Audio normalize "${ctx.value}"`] }; } diff --git a/extensions/github-copilot/auth.test.ts b/extensions/github-copilot/auth.test.ts index 9ed0d5e0f046..742c1a38e489 100644 --- a/extensions/github-copilot/auth.test.ts +++ b/extensions/github-copilot/auth.test.ts @@ -7,13 +7,15 @@ const coerceSecretRefMock = vi.hoisted(() => vi.fn()); const resolveConfiguredSecretInputWithFallbackMock = vi.hoisted(() => vi.fn()); const resolveRequiredConfiguredSecretRefInputStringMock = vi.hoisted(() => vi.fn()); -vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ - coerceSecretRef: coerceSecretRefMock, - ensureAuthProfileStore: ensureAuthProfileStoreMock, - listProfilesForProvider: listProfilesForProviderMock, - normalizeOptionalSecretInput: (value: unknown) => - typeof value === "string" && value.trim() ? value.trim() : undefined, -})); +vi.mock("openclaw/plugin-sdk/provider-auth", async () => { + const { normalizeOptionalString } = await import("openclaw/plugin-sdk/string-coerce-runtime"); + return { + coerceSecretRef: coerceSecretRefMock, + ensureAuthProfileStore: ensureAuthProfileStoreMock, + listProfilesForProvider: listProfilesForProviderMock, + normalizeOptionalSecretInput: normalizeOptionalString, + }; +}); vi.mock("openclaw/plugin-sdk/secret-input-runtime", () => ({ resolveConfiguredSecretInputWithFallback: resolveConfiguredSecretInputWithFallbackMock, diff --git a/extensions/imessage/src/accounts.ts b/extensions/imessage/src/accounts.ts index c44095f31d00..2644e2f0983a 100644 --- a/extensions/imessage/src/accounts.ts +++ b/extensions/imessage/src/accounts.ts @@ -6,7 +6,10 @@ import { normalizeAccountId, type OpenClawConfig } from "openclaw/plugin-sdk/acc // Imessage plugin module implements accounts behavior. import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; import { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { IMessageAccountConfig } from "./account-types.js"; import { expandIMessageUserPath, @@ -45,9 +48,7 @@ function resolveIMessageAccountConfig( type IMessageStreamingConfig = NonNullable; function asStreamingConfigObject(value: unknown): IMessageStreamingConfig | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as IMessageStreamingConfig) - : undefined; + return asOptionalRecord(value) as IMessageStreamingConfig | undefined; } function mergeIMessageStreamingConfig( diff --git a/extensions/imessage/src/actions-chat-guid.ts b/extensions/imessage/src/actions-chat-guid.ts index dfe7a95a417c..64fef6c1d0a1 100644 --- a/extensions/imessage/src/actions-chat-guid.ts +++ b/extensions/imessage/src/actions-chat-guid.ts @@ -4,6 +4,7 @@ import { parseStrictInteger, resolveExpiresAtMsFromDurationMs, } from "openclaw/plugin-sdk/number-runtime"; +import { normalizeOptionalString as stringFromUnknown } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { IMessageActionTransportOptions } from "./actions-rpc.js"; import { normalizeDirectChatIdentifier } from "./chat-context.js"; import { createIMessageRpcClient } from "./client.js"; @@ -41,10 +42,6 @@ function numberFromUnknown(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : parseStrictInteger(value); } -function stringFromUnknown(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function chatListCacheKey(options: IMessageActionTransportOptions): string { return `${options.cliPath}\0${options.dbPath ?? ""}\0${options.remoteHost ?? ""}`; } diff --git a/extensions/imessage/src/approval-reaction-poller.ts b/extensions/imessage/src/approval-reaction-poller.ts index 0873c2ae1658..7b6cdf2554ad 100644 --- a/extensions/imessage/src/approval-reaction-poller.ts +++ b/extensions/imessage/src/approval-reaction-poller.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { asDateTimestampMs, + asPositiveFiniteNumber, resolveExpiresAtMsFromDurationMs, } from "openclaw/plugin-sdk/number-runtime"; import type { IMessageApprovalGatewayRuntime } from "./approval-gateway-types.js"; @@ -38,7 +39,7 @@ type HistoryMessage = IMessagePayload & { }; function normalizeChatId(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; + return asPositiveFiniteNumber(value) ?? null; } function listTargetChatIds( diff --git a/extensions/imessage/src/monitor/dm-history.ts b/extensions/imessage/src/monitor/dm-history.ts index 87a620fa67a0..53c2748939c4 100644 --- a/extensions/imessage/src/monitor/dm-history.ts +++ b/extensions/imessage/src/monitor/dm-history.ts @@ -3,6 +3,7 @@ import { formatInboundEnvelope, type resolveEnvelopeFormatOptions, } from "openclaw/plugin-sdk/channel-inbound"; +import { parseDateStringTimestampMs } from "openclaw/plugin-sdk/number-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { IMessageRpcClient } from "../client.js"; import { normalizeIMessageHandle } from "../targets.js"; @@ -87,8 +88,7 @@ function historyEntryFromMessage(message: IMessagePayload, fallbackSender: strin if (!body) { return null; } - const timestamp = - typeof message.created_at === "string" ? Date.parse(message.created_at) : Number.NaN; + const timestamp = parseDateStringTimestampMs(message.created_at); return { sender: message.is_from_me === true @@ -96,7 +96,7 @@ function historyEntryFromMessage(message: IMessagePayload, fallbackSender: strin : normalizeIMessageHandle(normalizeOptionalString(message.sender) ?? fallbackSender) || fallbackSender, body, - ...(Number.isFinite(timestamp) ? { timestamp } : {}), + ...(timestamp !== undefined ? { timestamp } : {}), }; } diff --git a/extensions/imessage/src/monitor/ingress.ts b/extensions/imessage/src/monitor/ingress.ts index 12d8da68246f..79c07d5e75b0 100644 --- a/extensions/imessage/src/monitor/ingress.ts +++ b/extensions/imessage/src/monitor/ingress.ts @@ -10,6 +10,7 @@ import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { asSafeIntegerInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getIMessageRuntime } from "../runtime.js"; import { parseIMessageNotification } from "./parse-notification.js"; import type { IMessagePayload } from "./types.js"; @@ -62,8 +63,7 @@ function rawMessageRecord(raw: unknown): Record | null { } function rawRowid(raw: unknown): number | null { - const rowid = rawMessageRecord(raw)?.id; - return typeof rowid === "number" && Number.isSafeInteger(rowid) && rowid >= 0 ? rowid : null; + return asSafeIntegerInRange(rawMessageRecord(raw)?.id, { min: 0 }) ?? null; } /** Read only stable transport metadata; payload normalization waits for dispatch. */ diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index 99ba55547a7e..15a56a249c29 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -22,6 +22,7 @@ import { import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; import { sleep as delay } from "openclaw/plugin-sdk/runtime-env"; import { openNodeSqliteDatabase } from "openclaw/plugin-sdk/sqlite-runtime"; +import { normalizeOptionalString as stringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path"; import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking"; import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-chunking"; @@ -518,10 +519,6 @@ async function runIMessageCliJson( }); } -function stringValue(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function resultService(value: unknown): Exclude | undefined { const normalized = stringValue(value)?.toLowerCase(); return normalized === "imessage" || normalized === "sms" ? normalized : undefined; diff --git a/extensions/imessage/src/state-migrations.ts b/extensions/imessage/src/state-migrations.ts index c9d027eab11a..4923b107c8d6 100644 --- a/extensions/imessage/src/state-migrations.ts +++ b/extensions/imessage/src/state-migrations.ts @@ -6,7 +6,7 @@ import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channe import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { fileExists } from "openclaw/plugin-sdk/security-runtime"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; -import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumber, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, @@ -131,7 +131,7 @@ function readReplyCounterValue(value: unknown): number | null { return null; } const counter = (value as { counter?: unknown }).counter; - return typeof counter === "number" && Number.isFinite(counter) ? counter : null; + return asFiniteNumber(counter) ?? null; } function shouldReplaceReplyCounter(existingValue: unknown, incomingValue: unknown): boolean { diff --git a/extensions/logbook/src/node-host.ts b/extensions/logbook/src/node-host.ts index fa346bb57938..21f0adf48f14 100644 --- a/extensions/logbook/src/node-host.ts +++ b/extensions/logbook/src/node-host.ts @@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto"; import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { runExec } from "openclaw/plugin-sdk/process-runtime"; +import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; type LogbookSnapshotParams = { @@ -22,11 +23,11 @@ function readParams(value: unknown): LogbookSnapshotParams { return {}; } const record = value as Record; - const num = (key: string) => { - const candidate = record[key]; - return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : undefined; + return { + screenIndex: asFiniteNumber(record.screenIndex), + maxWidth: asFiniteNumber(record.maxWidth), + quality: asFiniteNumber(record.quality), }; - return { screenIndex: num("screenIndex"), maxWidth: num("maxWidth"), quality: num("quality") }; } export async function handleLogbookSnapshot(rawParams: unknown): Promise { diff --git a/extensions/matrix/src/plugin-entry.runtime.js b/extensions/matrix/src/plugin-entry.runtime.js index 76892e6b86eb..9c54880f5931 100644 --- a/extensions/matrix/src/plugin-entry.runtime.js +++ b/extensions/matrix/src/plugin-entry.runtime.js @@ -17,7 +17,7 @@ function readPackageJson(packageRoot) { } } -function normalizeLowercaseStringOrEmpty(value) { +function lowercaseStringOrEmptyWithoutTrim(value) { return typeof value === "string" ? value.toLowerCase() : ""; } @@ -29,7 +29,7 @@ function hasTrustedOpenClawRootIndicator(packageRoot, packageJson) { const hasCliEntryExport = Object.hasOwn(packageExports, "./cli-entry"); const hasOpenClawBin = (typeof packageJson?.bin === "string" && - normalizeLowercaseStringOrEmpty(packageJson.bin).includes("openclaw")) || + lowercaseStringOrEmptyWithoutTrim(packageJson.bin).includes("openclaw")) || (typeof packageJson?.bin === "object" && packageJson.bin !== null && typeof packageJson.bin.openclaw === "string"); diff --git a/extensions/mattermost/src/gateway-auth-bypass.ts b/extensions/mattermost/src/gateway-auth-bypass.ts index 81e9aa055afd..da61ffb531db 100644 --- a/extensions/mattermost/src/gateway-auth-bypass.ts +++ b/extensions/mattermost/src/gateway-auth-bypass.ts @@ -1,4 +1,9 @@ // Mattermost plugin module implements gateway auth bypass behavior. +import { + asOptionalRecord, + normalizeOptionalString as readTrimmedString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; + const DEFAULT_SLASH_CALLBACK_PATH = "/api/channels/mattermost/command"; type MattermostSlashCommandConfigInput = { @@ -14,10 +19,6 @@ type MattermostConfigInput = MattermostAccountConfigInput & { accounts?: Record; }; -function readTrimmedString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function normalizeCallbackPath(value: unknown): string { const trimmed = readTrimmedString(value); if (!trimmed) { @@ -27,9 +28,7 @@ function normalizeCallbackPath(value: unknown): string { } function readMattermostCommands(value: unknown): MattermostSlashCommandConfigInput | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as MattermostSlashCommandConfigInput) - : undefined; + return asOptionalRecord(value) as MattermostSlashCommandConfigInput | undefined; } function isMattermostBypassPath(path: string): boolean { diff --git a/extensions/memory-core/src/memory/embeddings.test.ts b/extensions/memory-core/src/memory/embeddings.test.ts index f90bdd5c818a..f5ca8e5b8564 100644 --- a/extensions/memory-core/src/memory/embeddings.test.ts +++ b/extensions/memory-core/src/memory/embeddings.test.ts @@ -1,6 +1,7 @@ // Memory Core tests cover embeddings plugin behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { EmbeddingProviderAdapter } from "openclaw/plugin-sdk/embedding-providers"; +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { MemoryEmbeddingProviderAdapter } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -105,7 +106,7 @@ function createMissingCredentialsAdapter( id: "bedrock", transport: "remote", autoSelectPriority: 60, - formatSetupError: (err) => (err instanceof Error ? err.message : String(err)), + formatSetupError: coerceErrorMessage, shouldContinueAutoSelection: (err) => err instanceof Error && err.message.includes("No API key found for provider"), create: async () => { diff --git a/extensions/msteams/src/attachments/bot-framework.ts b/extensions/msteams/src/attachments/bot-framework.ts index bb9e261739f3..581c23711210 100644 --- a/extensions/msteams/src/attachments/bot-framework.ts +++ b/extensions/msteams/src/attachments/bot-framework.ts @@ -1,4 +1,5 @@ // Msteams plugin module implements bot framework behavior. +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime"; import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import { @@ -108,7 +109,7 @@ async function fetchBotFrameworkAttachmentInfo(params: { }); } catch (err) { params.logger?.warn?.("msteams botFramework attachmentInfo fetch failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), }); return undefined; } @@ -126,7 +127,7 @@ async function fetchBotFrameworkAttachmentInfo(params: { ); } catch (err) { params.logger?.warn?.("msteams botFramework attachmentInfo parse failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), }); return undefined; } @@ -168,7 +169,7 @@ async function saveBotFrameworkAttachmentView(params: { }); } catch (err) { params.logger?.warn?.("msteams botFramework attachmentView fetch failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), }); return undefined; } @@ -185,7 +186,7 @@ async function saveBotFrameworkAttachmentView(params: { } catch (err) { await response.body?.cancel().catch(() => undefined); params.logger?.warn?.("msteams botFramework attachmentView invalid content-length", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), }); return undefined; } @@ -204,7 +205,7 @@ async function saveBotFrameworkAttachmentView(params: { }); } catch (err) { params.logger?.warn?.("msteams botFramework attachmentView save failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), }); return undefined; } finally { @@ -256,7 +257,7 @@ async function downloadMSTeamsBotFrameworkAttachment(params: { }); } catch (err) { params.logger?.warn?.("msteams botFramework token acquisition failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), }); return undefined; } @@ -401,7 +402,7 @@ export async function downloadMSTeamsBotFrameworkAttachments(params: { } catch (err) { media.push({ kind: "document", sourceId: attachmentId }); params.logger?.warn?.("msteams botFramework attachment download failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), attachmentId, }); } diff --git a/extensions/msteams/src/attachments/download.ts b/extensions/msteams/src/attachments/download.ts index 5e956bb16082..19162c7c3d7e 100644 --- a/extensions/msteams/src/attachments/download.ts +++ b/extensions/msteams/src/attachments/download.ts @@ -1,4 +1,5 @@ // Msteams plugin module implements download behavior. +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -334,7 +335,7 @@ export async function downloadMSTeamsAttachments(params: { } catch (err) { out.push(withSourceId({ kind: candidate.mediaKind }, candidate.sourceId)); params.logger?.warn?.("msteams inline attachment decode failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), }); } continue; @@ -370,7 +371,7 @@ export async function downloadMSTeamsAttachments(params: { out.push(withSourceId(media, candidate.sourceId)); } catch (err) { out.push(withSourceId({ kind: candidate.mediaKind }, candidate.sourceId)); - const msg = err instanceof Error ? err.message : String(err); + const msg = coerceErrorMessage(err); params.logger?.warn?.( `msteams attachment download failed host=${safeHostForLog(candidate.url)} error=${msg}`, ); diff --git a/extensions/msteams/src/attachments/graph.test.ts b/extensions/msteams/src/attachments/graph.test.ts index a389e4bcd820..bdc4826489ff 100644 --- a/extensions/msteams/src/attachments/graph.test.ts +++ b/extensions/msteams/src/attachments/graph.test.ts @@ -4,8 +4,7 @@ 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); + const { isRecord } = await import("openclaw/plugin-sdk/string-coerce-runtime"); return { ...actual, applyAuthorizationHeaderForUrl: vi.fn(), @@ -13,7 +12,7 @@ vi.mock("./shared.js", async (importOriginal) => { resolveMSTeamsMediaKind: vi.fn(({ contentType }: { contentType?: string }) => contentType?.startsWith("image/") ? "image" : "document", ), - isRecord: isMockRecord, + isRecord, isUrlAllowed: vi.fn(() => true), normalizeContentType: vi.fn((ct: string | null | undefined) => ct ?? undefined), resolveMediaSsrfPolicy: vi.fn(() => undefined), diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts index ecc1f37ccbde..1ff932787bdf 100644 --- a/extensions/msteams/src/attachments/graph.ts +++ b/extensions/msteams/src/attachments/graph.ts @@ -1,4 +1,5 @@ // Msteams plugin module implements graph behavior. +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { readProviderJsonArrayFieldResponse, readProviderJsonResponse, @@ -188,7 +189,7 @@ async function downloadGraphHostedContent(params: { })) as { status: number; items: GraphHostedContent[] }; } catch (err) { params.logger?.warn?.("msteams graph hostedContents fetch failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), }); return { media: [], count: 0 }; } @@ -240,7 +241,7 @@ async function downloadGraphHostedContent(params: { } catch (err) { out.push(createGraphHostedContentFact(item)); params.logger?.warn?.("msteams graph hostedContent value fetch failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), }); continue; } @@ -324,10 +325,10 @@ export async function downloadMSTeamsGraphMedia(params: { } catch (err) { params.logger?.debug?.("graph media message parse failed", { messageUrl, - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), }); params.logger?.warn?.("msteams graph message parse failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), messageUrl, }); msgData = {}; @@ -349,10 +350,10 @@ export async function downloadMSTeamsGraphMedia(params: { } catch (err) { params.logger?.debug?.("graph media message fetch failed", { messageUrl, - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), }); params.logger?.warn?.("msteams graph message fetch failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), }); } @@ -423,7 +424,7 @@ export async function downloadMSTeamsGraphMedia(params: { } catch (err) { sharePointMedia.push(unavailableMedia); params.logger?.warn?.("msteams SharePoint reference download failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), name, }); } @@ -472,7 +473,7 @@ export async function downloadMSTeamsGraphMedia(params: { }); } catch (err) { params.logger?.warn?.("msteams graph attachment download failed", { - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), messageUrl, }); } diff --git a/extensions/msteams/src/monitor.conversation-allowlist.lifecycle.test.ts b/extensions/msteams/src/monitor.conversation-allowlist.lifecycle.test.ts index 69c08dc91f28..bab588072f3f 100644 --- a/extensions/msteams/src/monitor.conversation-allowlist.lifecycle.test.ts +++ b/extensions/msteams/src/monitor.conversation-allowlist.lifecycle.test.ts @@ -45,20 +45,22 @@ const keepHttpServerTaskAliveMock = vi.hoisted(() => }), ); -vi.mock("../runtime-api.js", () => ({ - DEFAULT_WEBHOOK_MAX_BODY_BYTES: 1024 * 1024, - isDangerousNameMatchingEnabled, - normalizeSecretInputString: (value: unknown) => - typeof value === "string" && value.trim() ? value.trim() : undefined, - hasConfiguredSecretInput: (value: unknown) => - typeof value === "string" && value.trim().length > 0, - normalizeResolvedSecretInputString: (params: { value?: unknown }) => - typeof params?.value === "string" && params.value.trim() ? params.value.trim() : undefined, - keepHttpServerTaskAlive: keepHttpServerTaskAliveMock, - mergeAllowlist: (params: { existing?: string[]; additions: string[] }) => - Array.from(new Set([...(params.existing ?? []), ...params.additions])), - summarizeMapping: vi.fn(), -})); +vi.mock("../runtime-api.js", async () => { + const { normalizeOptionalString } = await import("openclaw/plugin-sdk/string-coerce-runtime"); + return { + DEFAULT_WEBHOOK_MAX_BODY_BYTES: 1024 * 1024, + isDangerousNameMatchingEnabled, + normalizeSecretInputString: normalizeOptionalString, + hasConfiguredSecretInput: (value: unknown) => + typeof value === "string" && value.trim().length > 0, + normalizeResolvedSecretInputString: (params: { value?: unknown }) => + typeof params?.value === "string" && params.value.trim() ? params.value.trim() : undefined, + keepHttpServerTaskAlive: keepHttpServerTaskAliveMock, + mergeAllowlist: (params: { existing?: string[]; additions: string[] }) => + Array.from(new Set([...(params.existing ?? []), ...params.additions])), + summarizeMapping: vi.fn(), + }; +}); vi.mock("express", () => ({ default: () => { diff --git a/extensions/msteams/src/qa/bot-framework-server.ts b/extensions/msteams/src/qa/bot-framework-server.ts index 58133f63317c..aebaf8d1ddbf 100644 --- a/extensions/msteams/src/qa/bot-framework-server.ts +++ b/extensions/msteams/src/qa/bot-framework-server.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { once } from "node:events"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; type MSTeamsQaOutboundActivity = { activity: Record; @@ -74,7 +75,7 @@ export async function startMSTeamsQaBotFrameworkServer(options: ServerOptions) { sendJson(response, 200, { id: activityId }); })().catch((error: unknown) => { sendJson(response, 500, { - error: error instanceof Error ? error.message : String(error), + error: coerceErrorMessage(error), }); }); }); diff --git a/extensions/msteams/src/sdk.ts b/extensions/msteams/src/sdk.ts index 6de69e609a7f..f62b7fbf612e 100644 --- a/extensions/msteams/src/sdk.ts +++ b/extensions/msteams/src/sdk.ts @@ -1,4 +1,5 @@ // Msteams plugin module implements sdk behavior. +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { readSecretFile } from "openclaw/plugin-sdk/secret-file"; import { normalizeBotFrameworkServiceUrl } from "./bot-framework-service-url.js"; @@ -333,7 +334,7 @@ async function createFederatedApp( try { privateKey = await readSecretFile(creds.certificatePath, "Microsoft Teams certificate"); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = coerceErrorMessage(err); throw new Error(`Failed to read certificate file at '${creds.certificatePath}': ${msg}`, { cause: err, }); diff --git a/extensions/msteams/src/token.test.ts b/extensions/msteams/src/token.test.ts index 97396af0a55b..e64830034c89 100644 --- a/extensions/msteams/src/token.test.ts +++ b/extensions/msteams/src/token.test.ts @@ -26,13 +26,15 @@ vi.mock("./oauth.token.js", () => ({ refreshMSTeamsDelegatedTokens: oauthTokenMocks.refreshMSTeamsDelegatedTokens, })); -vi.mock("./secret-input.js", () => ({ - normalizeSecretInputString: (v: unknown) => - typeof v === "string" && v.trim() ? v.trim() : undefined, - normalizeResolvedSecretInputString: (opts: { value: unknown; path: string }) => - typeof opts.value === "string" && opts.value.trim() ? opts.value.trim() : undefined, - hasConfiguredSecretInput: (v: unknown) => typeof v === "string" && v.trim().length > 0, -})); +vi.mock("./secret-input.js", async () => { + const { normalizeOptionalString } = await import("openclaw/plugin-sdk/string-coerce-runtime"); + return { + normalizeSecretInputString: normalizeOptionalString, + normalizeResolvedSecretInputString: (opts: { value: unknown; path: string }) => + typeof opts.value === "string" && opts.value.trim() ? opts.value.trim() : undefined, + hasConfiguredSecretInput: (v: unknown) => typeof v === "string" && v.trim().length > 0, + }; +}); const ENV_KEYS = [ "MSTEAMS_APP_ID", diff --git a/extensions/nostr/doctor-contract-api.ts b/extensions/nostr/doctor-contract-api.ts index 1489da84ea0f..e17750466217 100644 --- a/extensions/nostr/doctor-contract-api.ts +++ b/extensions/nostr/doctor-contract-api.ts @@ -6,6 +6,7 @@ import { archiveLegacyStateSource, type PluginDoctorStateMigration, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; +import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeNostrStateAccountId } from "./src/state-account-id.js"; type NostrBusState = { @@ -26,10 +27,6 @@ const BUS_STATE_NAMESPACE = "bus-state"; const PROFILE_STATE_NAMESPACE = "profile-state"; const MAX_NOSTR_STATE_ENTRIES = 256; -function finiteNumberOrNull(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - function parseBusState(value: unknown): NostrBusState | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -40,8 +37,8 @@ function parseBusState(value: unknown): NostrBusState | null { } return { version: 2, - lastProcessedAt: finiteNumberOrNull(parsed.lastProcessedAt), - gatewayStartedAt: finiteNumberOrNull(parsed.gatewayStartedAt), + lastProcessedAt: asFiniteNumber(parsed.lastProcessedAt) ?? null, + gatewayStartedAt: asFiniteNumber(parsed.gatewayStartedAt) ?? null, recentEventIds: parsed.version === 2 && Array.isArray(parsed.recentEventIds) ? parsed.recentEventIds.filter((entry): entry is string => typeof entry === "string") @@ -68,7 +65,7 @@ function parseProfileState(value: unknown): NostrProfileState | null { } return { version: 1, - lastPublishedAt: finiteNumberOrNull(parsed.lastPublishedAt), + lastPublishedAt: asFiniteNumber(parsed.lastPublishedAt) ?? null, lastPublishedEventId: typeof parsed.lastPublishedEventId === "string" ? parsed.lastPublishedEventId : null, lastPublishResults: diff --git a/extensions/ollama/src/embedding-provider.test.ts b/extensions/ollama/src/embedding-provider.test.ts index 4d12b444eb3d..4c2cd17df3eb 100644 --- a/extensions/ollama/src/embedding-provider.test.ts +++ b/extensions/ollama/src/embedding-provider.test.ts @@ -1,3 +1,4 @@ +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; // Ollama tests cover embedding provider plugin behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -14,7 +15,7 @@ const { fetchConfiguredLocalOriginWithSsrFGuardMock } = vi.hoisted(() => ({ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: vi.fn(), - formatErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), + formatErrorMessage: coerceErrorMessage, ssrfPolicyFromHttpBaseUrlAllowedOrigin: (baseUrl: string) => { const parsed = new URL(baseUrl); return { allowedOrigins: [parsed.origin] }; diff --git a/extensions/ollama/src/node-inference.ts b/extensions/ollama/src/node-inference.ts index 4c0e96b97ce9..bb13f51564f0 100644 --- a/extensions/ollama/src/node-inference.ts +++ b/extensions/ollama/src/node-inference.ts @@ -18,7 +18,7 @@ import { readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumber, asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; import { DEFAULT_INFERENCE_TIMEOUT_MS, @@ -101,10 +101,6 @@ function durationMs(value: unknown): number | undefined { return Math.round((value / 1_000_000) * 100) / 100; } -function optionalNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - async function requestOllamaJson(params: { baseUrl: string; path: string; @@ -300,8 +296,8 @@ async function runOllamaNodeChat(params: { `Ollama stopped after reaching maxTokens (${params.maxTokens}); retry with a larger maxTokens value`, ); } - const promptTokens = optionalNumber(data.prompt_eval_count); - const completionTokens = optionalNumber(data.eval_count); + const promptTokens = asFiniteNumber(data.prompt_eval_count); + const completionTokens = asFiniteNumber(data.eval_count); const loadMs = durationMs(data.load_duration); const totalMs = durationMs(data.total_duration); return { diff --git a/extensions/ollama/src/stream.runtime.ts b/extensions/ollama/src/stream.runtime.ts index 7c9f1e454724..a3dd7a0f7475 100644 --- a/extensions/ollama/src/stream.runtime.ts +++ b/extensions/ollama/src/stream.runtime.ts @@ -18,7 +18,11 @@ import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; import { createPlainTextToolCallCompatWrapper } from "openclaw/plugin-sdk/provider-stream-shared"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { isRecord, readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeOptionalString, + readStringValue, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { estimateStringChars, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; import { normalizeOllamaWireModelId } from "./model-id.js"; @@ -663,7 +667,7 @@ type OllamaAssistantMessageBuildOptions = OllamaToolCallNameOptions & { }; function readOllamaToolCallId(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; + return normalizeOptionalString(value); } function extractToolCalls( diff --git a/extensions/openai/openai.live.test.ts b/extensions/openai/openai.live.test.ts index 0a5d4a523d1b..26eb98b062f8 100644 --- a/extensions/openai/openai.live.test.ts +++ b/extensions/openai/openai.live.test.ts @@ -6,6 +6,7 @@ import OpenAI from "openai"; import type { ResolvedTtsConfig } from "openclaw/plugin-sdk/agent-runtime"; import { AuthStorage, ModelRegistry } from "openclaw/plugin-sdk/agent-sessions"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { coerceErrorMessage as formatLiveOpenAIError } from "openclaw/plugin-sdk/error-runtime"; import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime"; import { registerProviderPlugin, @@ -81,10 +82,6 @@ function createReferencePng(): Buffer { return encodePngRgba(buf, width, height); } -function formatLiveOpenAIError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function resolveLiveOpenAISkipReason(error: unknown): string | null { const message = formatLiveOpenAIError(error); if (isTimeoutErrorMessage(message) || /timed out|operation was aborted/i.test(message)) { diff --git a/extensions/openai/provider-policy-api.ts b/extensions/openai/provider-policy-api.ts index 79c2ef507d67..ab2c5a727ed8 100644 --- a/extensions/openai/provider-policy-api.ts +++ b/extensions/openai/provider-policy-api.ts @@ -11,6 +11,7 @@ import type { ProviderResponseModelEquivalenceContext, ProviderResolveModelRoutesContext, } from "openclaw/plugin-sdk/provider-model-types"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { classifyOpenAIBaseUrl, isOpenAICodexBaseUrl, @@ -44,11 +45,7 @@ type OpenAIResolveSingleModelRouteContext = Omit< }; function normalizeOptionalRouteApi(value: ModelApi | null | undefined): ModelApi | undefined { - return typeof value === "string" && value.trim() ? (value.trim() as ModelApi) : undefined; -} - -function normalizeOptionalRouteBaseUrl(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; + return normalizeOptionalString(value) as ModelApi | undefined; } /** Canonical logical id for OpenAI catalog projection. */ @@ -138,7 +135,7 @@ function firstRouteBaseUrl(...values: unknown[]): unknown { } function concreteBaseUrl(value: unknown, fallback: string): string { - return normalizeOptionalRouteBaseUrl(value) ?? fallback; + return normalizeOptionalString(value) ?? fallback; } function resolveOpenAIEnvironmentBaseUrl( diff --git a/extensions/openai/speech-provider.test.ts b/extensions/openai/speech-provider.test.ts index 189340bb4fdb..8c39f23cbb6e 100644 --- a/extensions/openai/speech-provider.test.ts +++ b/extensions/openai/speech-provider.test.ts @@ -1,5 +1,6 @@ // Openai tests cover speech provider plugin behavior. import { createServer } from "node:http"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildOpenAISpeechProvider } from "./speech-provider.js"; @@ -26,7 +27,7 @@ function isSpeechRequestBody(value: unknown): value is { speed?: number; response_format?: string; } { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); + return isRecord(value); } function parseRequestBody(init: RequestInit | undefined): { diff --git a/extensions/policy/src/doctor/automatic-repairs.ts b/extensions/policy/src/doctor/automatic-repairs.ts index 85c4332d0d41..bc773ac5ef7d 100644 --- a/extensions/policy/src/doctor/automatic-repairs.ts +++ b/extensions/policy/src/doctor/automatic-repairs.ts @@ -6,6 +6,7 @@ import type { HealthRepairResult, OpenClawConfig, } from "openclaw/plugin-sdk/health"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { CHECK_IDS, type POLICY_CHECK_IDS } from "./check-ids.js"; import { POLICY_FIX_METADATA_BY_CHECK_ID } from "./fix-metadata.js"; @@ -447,7 +448,3 @@ function ensureRecord(parent: ConfigRecord, key: string): ConfigRecord { parent[key] = next; return next; } - -function uniqueStrings(values: readonly string[]): readonly string[] { - return [...new Set(values)]; -} diff --git a/extensions/policy/src/doctor/review-required-repairs.ts b/extensions/policy/src/doctor/review-required-repairs.ts index aba1a1845662..ff9c47d04ead 100644 --- a/extensions/policy/src/doctor/review-required-repairs.ts +++ b/extensions/policy/src/doctor/review-required-repairs.ts @@ -5,6 +5,7 @@ import type { HealthRepairEffect, HealthRepairResult, } from "openclaw/plugin-sdk/health"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { CHECK_IDS, type POLICY_CHECK_IDS } from "./check-ids.js"; import { POLICY_FIX_METADATA_BY_CHECK_ID } from "./fix-metadata.js"; @@ -121,10 +122,6 @@ function previewGatewayNodeDenyCommand( ]; } -function uniqueStrings(values: readonly string[]): readonly string[] { - return [...new Set(values)]; -} - function uniqueEffects(values: readonly HealthRepairEffect[]): readonly HealthRepairEffect[] { const seen = new Set(); return values.filter((value) => { diff --git a/extensions/qa-lab/src/cli.runtime.ts b/extensions/qa-lab/src/cli.runtime.ts index 993ffd724457..7d5ade04925b 100644 --- a/extensions/qa-lab/src/cli.runtime.ts +++ b/extensions/qa-lab/src/cli.runtime.ts @@ -8,7 +8,7 @@ import { } from "@openclaw/crabline"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; -import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { parseBooleanValue, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildQaAgenticParityComparison, buildQaRuntimeParityReport, @@ -238,20 +238,11 @@ function parseQaModelThinkingOverrides(entries: readonly string[] | undefined) { } function parseQaBooleanModelOption(label: string, value: string) { - switch (value.trim().toLowerCase()) { - case "1": - case "on": - case "true": - case "yes": - return true; - case "0": - case "false": - case "no": - case "off": - return false; - default: - throw new Error(`${label} fast must be one of true, false, on, off, yes, no, 1, 0`); + const parsed = parseBooleanValue(value); + if (parsed === undefined) { + throw new Error(`${label} fast must be one of true, false, on, off, yes, no, 1, 0`); } + return parsed; } function parseQaPositiveIntegerOption(label: string, value: number | undefined) { diff --git a/extensions/qa-lab/src/coverage-report.ts b/extensions/qa-lab/src/coverage-report.ts index 2fd69c836017..39b95f162d50 100644 --- a/extensions/qa-lab/src/coverage-report.ts +++ b/extensions/qa-lab/src/coverage-report.ts @@ -1,5 +1,8 @@ // Qa Lab plugin module implements coverage report behavior. -import { normalizeStringEntriesLower } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeOptionalString as stringifyConfigValue, + normalizeStringEntriesLower, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { QaSeedScenarioWithSource } from "./scenario-catalog.js"; import { readQaScorecardTaxonomyReport, @@ -134,10 +137,6 @@ function scenarioSearchText(scenario: QaSeedScenarioWithSource) { ); } -function stringifyConfigValue(value: unknown) { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function summarizeScenarioSearchMatch(scenario: QaSeedScenarioWithSource): QaScenarioSearchMatch { const config = scenario.execution.config ?? {}; return { diff --git a/extensions/qa-lab/src/live-transports/matrix/substrate/config.ts b/extensions/qa-lab/src/live-transports/matrix/substrate/config.ts index 79e0d2bc80f3..ddea8cf1e190 100644 --- a/extensions/qa-lab/src/live-transports/matrix/substrate/config.ts +++ b/extensions/qa-lab/src/live-transports/matrix/substrate/config.ts @@ -287,7 +287,7 @@ function resolveMatrixQaStreamingMode( function isMatrixQaStreamingConfig( value: MatrixQaConfigOverrides["streaming"], ): value is MatrixQaStreamingConfig { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); + return isRecord(value); } function resolveMatrixQaStreamingPreviewToolProgress( diff --git a/extensions/qa-lab/src/live-transports/slack/slack-live.config.ts b/extensions/qa-lab/src/live-transports/slack/slack-live.config.ts index 32dcc7b780a2..68fc8eee2e2e 100644 --- a/extensions/qa-lab/src/live-transports/slack/slack-live.config.ts +++ b/extensions/qa-lab/src/live-transports/slack/slack-live.config.ts @@ -1,7 +1,7 @@ // QA Lab Slack credentials, instrumentation, and channel config. import type { WebClient } from "@slack/web-api"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asNonArrayRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { type SlackQaRuntimeEnv, type SlackQaConfigOverrides, @@ -52,9 +52,7 @@ export function parseSlackQaCredentialPayload(payload: unknown): SlackQaRuntimeE } export function asPlainRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; + return asNonArrayRecord(value); } type SlackQaPostMessageAttempt = { diff --git a/extensions/qa-lab/src/scenario-catalog-memory-ranking-proof.test.ts b/extensions/qa-lab/src/scenario-catalog-memory-ranking-proof.test.ts index 93fb1817a27f..4a39368c566d 100644 --- a/extensions/qa-lab/src/scenario-catalog-memory-ranking-proof.test.ts +++ b/extensions/qa-lab/src/scenario-catalog-memory-ranking-proof.test.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { describe, expect, it, vi } from "vitest"; import { createQaBusState } from "./bus-state.js"; import { runLoadedScenarioFlow } from "./scenario-flow-runner.test-support.js"; @@ -168,8 +169,7 @@ async function runSessionMemoryRankingFlow(params: { seedQaSessionTranscript: async () => undefined, forceMemoryIndex, runAgentPrompt, - normalizeLowercaseStringOrEmpty: (value: unknown) => - typeof value === "string" ? value.trim().toLowerCase() : "", + normalizeLowercaseStringOrEmpty, fetchJson, }, }); diff --git a/extensions/qa-lab/src/scenario-catalog-memory-recall-proof.test.ts b/extensions/qa-lab/src/scenario-catalog-memory-recall-proof.test.ts index 47aaebd45281..74244b296799 100644 --- a/extensions/qa-lab/src/scenario-catalog-memory-recall-proof.test.ts +++ b/extensions/qa-lab/src/scenario-catalog-memory-recall-proof.test.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { describe, expect, it } from "vitest"; import { createQaBusState } from "./bus-state.js"; import { runLoadedScenarioFlow } from "./scenario-flow-runner.test-support.js"; @@ -19,8 +20,7 @@ async function runMemoryRecallScenario(recallReply?: string) { fs: { rm: async () => undefined }, path, formatMemoryDreamingDay: () => "2026-08-05", - normalizeLowercaseStringOrEmpty: (value: unknown) => - typeof value === "string" ? value.trim().toLowerCase() : "", + normalizeLowercaseStringOrEmpty, runAgentPrompt: async (_env: unknown, params: { message: string }) => { turnCount += 1; state.addInboundMessage({ diff --git a/extensions/qa-lab/src/scenario-catalog-model-switch-follow-up-proof.test.ts b/extensions/qa-lab/src/scenario-catalog-model-switch-follow-up-proof.test.ts index a57337e4f59d..25c2f791c35c 100644 --- a/extensions/qa-lab/src/scenario-catalog-model-switch-follow-up-proof.test.ts +++ b/extensions/qa-lab/src/scenario-catalog-model-switch-follow-up-proof.test.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { describe, expect, it, vi } from "vitest"; import { createQaBusState } from "./bus-state.js"; import { runLoadedScenarioFlow } from "./scenario-flow-runner.test-support.js"; @@ -118,8 +119,7 @@ async function runFollowUp(params?: { runAgentPrompt, splitModelRef, normalizeModelRef, - normalizeLowercaseStringOrEmpty: (value: unknown) => - typeof value === "string" ? value.trim().toLowerCase() : "", + normalizeLowercaseStringOrEmpty, resolveQaLiveTurnTimeoutMs: (_env: unknown, timeoutMs: number) => timeoutMs, }, }); diff --git a/extensions/qa-lab/src/scenario-catalog-model-switch-tool-continuity-proof.test.ts b/extensions/qa-lab/src/scenario-catalog-model-switch-tool-continuity-proof.test.ts index f880c27bef76..433e15cae3ae 100644 --- a/extensions/qa-lab/src/scenario-catalog-model-switch-tool-continuity-proof.test.ts +++ b/extensions/qa-lab/src/scenario-catalog-model-switch-tool-continuity-proof.test.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { describe, expect, it, vi } from "vitest"; import { createQaBusState } from "./bus-state.js"; import { hasModelSwitchContinuitySignal } from "./model-switch-eval.js"; @@ -103,8 +104,7 @@ async function runToolContinuity( }, splitModelRef, normalizeModelRef, - normalizeLowercaseStringOrEmpty: (value: unknown) => - typeof value === "string" ? value.trim().toLowerCase() : "", + normalizeLowercaseStringOrEmpty, resolveQaLiveTurnTimeoutMs: (_env: unknown, timeoutMs: number) => timeoutMs, hasModelSwitchContinuitySignal, runAgentPrompt, diff --git a/extensions/qa-lab/src/scenario-flow-runner-character-safety.test.ts b/extensions/qa-lab/src/scenario-flow-runner-character-safety.test.ts index 26e6f98f342a..44ffce0d23a6 100644 --- a/extensions/qa-lab/src/scenario-flow-runner-character-safety.test.ts +++ b/extensions/qa-lab/src/scenario-flow-runner-character-safety.test.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { describe, expect, it } from "vitest"; import { createQaBusState } from "./bus-state.js"; import { readQaScenarioById } from "./scenario-catalog.js"; @@ -44,8 +45,7 @@ function createCharacterScenarioApi( writeFile: async () => undefined, }, path: { join }, - normalizeLowercaseStringOrEmpty: (value: unknown) => - typeof value === "string" ? value.trim().toLowerCase() : "", + normalizeLowercaseStringOrEmpty, resolveQaLiveTurnTimeoutMs: () => 10, waitForOutboundMessage: async ( state: ReturnType, diff --git a/extensions/qa-lab/src/scenario-flow-runner.test.ts b/extensions/qa-lab/src/scenario-flow-runner.test.ts index d70d58e77c2e..5938fd3d1108 100644 --- a/extensions/qa-lab/src/scenario-flow-runner.test.ts +++ b/extensions/qa-lab/src/scenario-flow-runner.test.ts @@ -1,4 +1,6 @@ // Qa Lab tests cover scenario flow runner plugin behavior. +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { describe, expect, it } from "vitest"; import { createQaBusState } from "./bus-state.js"; import { QaSuiteScenarioSkipError } from "./errors.js"; @@ -60,10 +62,8 @@ async function runWebchatTranscriptWait( } throw new Error("test condition was not met"); }, - normalizeLowercaseStringOrEmpty: (value: unknown) => - typeof value === "string" ? value.trim().toLowerCase() : "", - formatErrorMessage: (error: unknown) => - error instanceof Error ? error.message : String(error), + normalizeLowercaseStringOrEmpty, + formatErrorMessage: coerceErrorMessage, liveTurnTimeoutMs: (_env: unknown, timeoutMs: number) => timeoutMs, }, }); @@ -284,8 +284,7 @@ function runPlanningEvidenceFixture( return summary; }, resolveQaLiveTurnTimeoutMs: (_env: unknown, timeoutMs: number) => timeoutMs, - normalizeLowercaseStringOrEmpty: (value: unknown) => - typeof value === "string" ? value.trim().toLowerCase() : "", + normalizeLowercaseStringOrEmpty, runAgentPrompt: async () => ({ started: { runId: "current-run" }, waited: { status: "ok" } }), }, }); @@ -397,8 +396,7 @@ describe("scenario-flow-runner", () => { throw new Error("goal artifact has not been written"); }, }, - normalizeLowercaseStringOrEmpty: (value: unknown) => - typeof value === "string" ? value.trim().toLowerCase() : "", + normalizeLowercaseStringOrEmpty, }, onWaitForOutboundMessage: ({ waitCount, state: currentState }) => { const currentInbound = currentState @@ -562,8 +560,7 @@ describe("scenario-flow-runner", () => { runLoadedScenarioFlow(id, { state, api: { - normalizeLowercaseStringOrEmpty: (value: unknown) => - typeof value === "string" ? value.trim().toLowerCase() : "", + normalizeLowercaseStringOrEmpty, runAgentPrompt: async () => { turnCount += 1; state.addOutboundMessage({ diff --git a/extensions/qa-lab/src/suite-summary.ts b/extensions/qa-lab/src/suite-summary.ts index 456bb431bb8d..2f772a006547 100644 --- a/extensions/qa-lab/src/suite-summary.ts +++ b/extensions/qa-lab/src/suite-summary.ts @@ -1,7 +1,7 @@ // Qa Lab plugin module implements suite summary behavior. import fs from "node:fs/promises"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asSafeIntegerInRange, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { QaSuiteArtifactError } from "./errors.js"; import type { QaEvidenceSummaryJson, QaEvidenceTiming } from "./evidence-summary.js"; import type { QaProviderMode } from "./model-selection.js"; @@ -110,7 +110,7 @@ async function readQaSuiteSummaryFile(summaryPath: string): Promise { } function readNonNegativeCount(value: unknown): number | null { - return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; + return asSafeIntegerInRange(value, { min: 0 }) ?? null; } function assertQaSuiteSummaryHasExecutedScenarios( diff --git a/extensions/qwen/stream.ts b/extensions/qwen/stream.ts index 3e8d21b53f24..af133e220db7 100644 --- a/extensions/qwen/stream.ts +++ b/extensions/qwen/stream.ts @@ -9,6 +9,7 @@ import { normalizeOpenAICompatibleReasoningReplay, setQwenChatTemplateThinking, } from "openclaw/plugin-sdk/provider-stream-shared"; +import { asOptionalRecord as asPayloadRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isQwenTokenPlanDeepSeekV4ModelId, isQwenTokenPlanGlmModelId, @@ -26,12 +27,6 @@ type QwenTokenPlanThinkingContract = | { family: "kimi" } | { family: "glm"; supportsMax: boolean }; -function asPayloadRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function resolveQwenThinkingLevel( thinkingLevel: QwenThinkingLevel, options: Parameters[2], diff --git a/extensions/signal/src/signal-ingress.ts b/extensions/signal/src/signal-ingress.ts index 9bbe1aa5e2af..3e0daeb5f942 100644 --- a/extensions/signal/src/signal-ingress.ts +++ b/extensions/signal/src/signal-ingress.ts @@ -7,8 +7,11 @@ import { type ChannelIngressMonitorLifecycle, } from "openclaw/plugin-sdk/channel-outbound"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { normalizeNullableString as normalizeRawString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asPositiveSafeInteger, + isRecord, + normalizeNullableString as normalizeRawString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { SignalSseEvent } from "./client-adapter.js"; import { getOptionalSignalRuntime } from "./runtime.js"; @@ -52,7 +55,7 @@ const SignalIngressPermanentError = createChannelIngressError< >("SignalIngressPermanentError", { withReason: true }); function normalizeTimestamp(value: unknown): number | null { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null; + return asPositiveSafeInteger(value) ?? null; } function parseReceiveEnvelope(event: SignalSseEvent): SignalIngressEnvelope | null { diff --git a/extensions/slack/src/accounts.ts b/extensions/slack/src/accounts.ts index fc85db2ee8f4..c033e3cc2e0a 100644 --- a/extensions/slack/src/accounts.ts +++ b/extensions/slack/src/accounts.ts @@ -12,7 +12,10 @@ import { type ChannelDmPolicy, } from "openclaw/plugin-sdk/channel-config-helpers"; import { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import type { SlackAccountConfig } from "./runtime-api.js"; import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js"; @@ -123,9 +126,7 @@ type SlackStreamingConfig = NonNullable; type SlackStreamingConfigValue = SlackStreamingConfig | boolean | string; function asStreamingConfigObject(value: unknown): SlackStreamingConfig | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as SlackStreamingConfig) - : undefined; + return asOptionalRecord(value) as SlackStreamingConfig | undefined; } function asLegacyStreamingScalar(value: unknown): boolean | string | undefined { diff --git a/extensions/slack/src/monitor/events/interactions.block-actions.ts b/extensions/slack/src/monitor/events/interactions.block-actions.ts index aa861381ab1f..e74e421d4ba1 100644 --- a/extensions/slack/src/monitor/events/interactions.block-actions.ts +++ b/extensions/slack/src/monitor/events/interactions.block-actions.ts @@ -11,6 +11,7 @@ import { timestampMsToIsoString, } from "openclaw/plugin-sdk/number-runtime"; import { + asOptionalRecord, normalizeOptionalString, normalizeUniqueTrimmedStringList, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -189,13 +190,6 @@ function summarizeRichTextPreview(value: unknown): string | undefined { return joined.length <= max ? joined : truncateSlackText(joined, max); } -function readInteractionAction(raw: unknown) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return undefined; - } - return raw as Record; -} - export function summarizeAction(action: Record): SlackActionSummary { const typed = action as { type?: string; @@ -431,7 +425,7 @@ function parseSlackBlockAction(params: { log?: (message: string) => void; }): ParsedSlackBlockAction | null { const typedBody = params.body as SlackBlockActionBody; - const typedAction = readInteractionAction(params.action); + const typedAction = asOptionalRecord(params.action); if (!typedAction) { params.log?.( `slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${ 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 c178257db9ed..01e316a864f8 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 @@ -871,13 +871,14 @@ vi.mock("openclaw/plugin-sdk/security-runtime", () => ({ resolvePinnedMainDmOwnerFromAllowlist: () => mockedPinnedMainDmOwner, })); -vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => { - const isMockRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); +vi.mock("openclaw/plugin-sdk/string-coerce-runtime", async (importOriginal) => { + const { asOptionalRecord, isRecord } = + await importOriginal(); const normalizeMockLowercaseString = (value?: string) => value?.toLowerCase(); const readMockOptionalString = (value?: string) => value; return { - isRecord: isMockRecord, + asOptionalRecord, + isRecord, normalizeOptionalLowercaseString: normalizeMockLowercaseString, normalizeOptionalString: readMockOptionalString, }; diff --git a/extensions/synology-chat/src/client.test.ts b/extensions/synology-chat/src/client.test.ts index a585ee3599c9..8cf51cc29216 100644 --- a/extensions/synology-chat/src/client.test.ts +++ b/extensions/synology-chat/src/client.test.ts @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"; import type { ClientRequest, IncomingMessage, RequestOptions } from "node:http"; import { PassThrough } from "node:stream"; +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; const ssrfMocks = { @@ -26,7 +27,7 @@ vi.mock("node:http", async () => { }); vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - formatErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)), + formatErrorMessage: coerceErrorMessage, resolvePinnedHostnameWithPolicy: ssrfMocks.resolvePinnedHostnameWithPolicy, })); diff --git a/extensions/teams-meetings/index.ts b/extensions/teams-meetings/index.ts index 639e5e261ff6..49870e10f322 100644 --- a/extensions/teams-meetings/index.ts +++ b/extensions/teams-meetings/index.ts @@ -1,5 +1,6 @@ import { MeetingPlatformAdapter } from "openclaw/plugin-sdk/meeting-runtime"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; import { teamsMeetingsConfig } from "./src/config.js"; import { TeamsMeetingsInvalidRequestError, teamsMeetingsInvalidRequest } from "./src/errors.js"; @@ -26,8 +27,7 @@ export default MeetingPlatformAdapter.createPluginShellEntry({ message: Type.Optional(Type.String({ description: "Instructions to speak" })), }), resolveGatewayTimeoutMs: teamsMeetingsConfig.resolveGatewayOperationTimeoutMs, - normalizeRequesterSessionKey: (value) => - typeof value === "string" && value.trim() ? value.trim() : undefined, + normalizeRequesterSessionKey: normalizeOptionalString, normalizeToolAgentId: (agentId) => (agentId ? normalizeAgentId(agentId) : undefined), resolveToolRuntime: async (api, agentId) => { const trustedRouting = Boolean(agentId && agentId !== "main"); diff --git a/extensions/telegram/src/audit.test.ts b/extensions/telegram/src/audit.test.ts index caab7ab97021..e6a5e195ebcc 100644 --- a/extensions/telegram/src/audit.test.ts +++ b/extensions/telegram/src/audit.test.ts @@ -11,19 +11,14 @@ vi.mock("openclaw/plugin-sdk/text-utility-runtime", () => ({ fetchWithTimeout: fetchWithTimeoutMock, })); -vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => { +vi.mock("openclaw/plugin-sdk/string-coerce-runtime", async (importOriginal) => { + const { normalizeOptionalString } = + await importOriginal(); 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, + normalizeOptionalString, }; }); diff --git a/extensions/telegram/src/bot-handlers.callback-router-controls.ts b/extensions/telegram/src/bot-handlers.callback-router-controls.ts index df36b7430907..ccdbaf39ff34 100644 --- a/extensions/telegram/src/bot-handlers.callback-router-controls.ts +++ b/extensions/telegram/src/bot-handlers.callback-router-controls.ts @@ -12,6 +12,7 @@ import { } from "openclaw/plugin-sdk/conversation-runtime"; import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime"; import { logVerbose, sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { TelegramApprovalCallback } from "./approval-callback-data.js"; import { buildTelegramCanonicalApprovalTerminalText, @@ -420,11 +421,7 @@ const updateMultiSelectKeyboard = ( ); const resolvePluginCallbackSubmitText = (submitText: unknown): string | undefined => { - if (typeof submitText !== "string") { - return undefined; - } - const trimmed = submitText.trim(); - return trimmed ? trimmed : undefined; + return normalizeOptionalString(submitText); }; const isReplySessionInitConflictError = (err: unknown): boolean => diff --git a/extensions/telegram/src/bot-handlers.message-context.ts b/extensions/telegram/src/bot-handlers.message-context.ts index da0b7b5267d9..3e17fe09210b 100644 --- a/extensions/telegram/src/bot-handlers.message-context.ts +++ b/extensions/telegram/src/bot-handlers.message-context.ts @@ -10,6 +10,7 @@ import { resolveAmbientTranscriptWatermarkKey, type SessionEntry, } from "openclaw/plugin-sdk/session-store-runtime"; +import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-chunking"; import { resolveDefaultModelForAgent } from "./bot-handlers.agent.runtime.js"; import type { RegisterTelegramHandlerParams } from "./bot-handlers.types.js"; @@ -98,7 +99,7 @@ export type ResolvePromptContextAmbientWatermarkParams = { }; export const normalizePromptContextMinTimestampMs = (timestampMs?: number) => - typeof timestampMs === "number" && Number.isFinite(timestampMs) ? timestampMs : undefined; + asFiniteNumber(timestampMs); export function promptContextBoundaryOptions( timestampMs?: number, diff --git a/extensions/telegram/src/bot.create-telegram-bot.media-group-skip-warning.test.ts b/extensions/telegram/src/bot.create-telegram-bot.media-group-skip-warning.test.ts index ed226ec26668..91e9701c9144 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.media-group-skip-warning.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.media-group-skip-warning.test.ts @@ -1,5 +1,6 @@ // Telegram tests cover bot.create telegram bot.media group skip warning plugin behavior. import { setTimeout as delay } from "node:timers/promises"; +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js"; @@ -21,7 +22,7 @@ vi.mock("./bot/delivery.resolve-media.runtime.js", async () => { ); return { readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBuffer(...args), - formatErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)), + formatErrorMessage: coerceErrorMessage, logVerbose: () => {}, MediaFetchError: actual.MediaFetchError, resolveTelegramApiBase: (apiRoot?: string) => diff --git a/extensions/telegram/src/bot/delivery.resolve-media-local.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-local.test.ts index 3a6931db6a9f..144e48b14cab 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-local.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-local.test.ts @@ -1,5 +1,6 @@ import { GrammyError } from "grammy"; import type { Message } from "grammy/types"; +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; // Telegram tests cover delivery.resolve media retry plugin behavior. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; @@ -56,7 +57,7 @@ vi.mock("./delivery.resolve-media.runtime.js", () => { } return { readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBuffer(...args), - formatErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)), + formatErrorMessage: coerceErrorMessage, logVerbose: () => {}, MediaFetchError, resolveTelegramApiBase: (apiRoot?: string) => diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 1b7e87d1b55c..1e6dea50cc86 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -1,4 +1,5 @@ import type { Message } from "grammy/types"; +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; // Telegram tests cover delivery.resolve media retry plugin behavior. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; @@ -55,7 +56,7 @@ vi.mock("./delivery.resolve-media.runtime.js", () => { } return { readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBuffer(...args), - formatErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)), + formatErrorMessage: coerceErrorMessage, logVerbose: () => {}, MediaFetchError, resolveTelegramApiBase: (apiRoot?: string) => diff --git a/extensions/telegram/src/network-config.test.ts b/extensions/telegram/src/network-config.test.ts index ace6c8a88135..9945028b0835 100644 --- a/extensions/telegram/src/network-config.test.ts +++ b/extensions/telegram/src/network-config.test.ts @@ -2,9 +2,8 @@ import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/config-contracts"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ - isTruthyEnvValue: (value: string | undefined) => - typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim()), +vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => ({ + ...(await importOriginal()), isWSL2Sync: vi.fn(() => false), })); diff --git a/extensions/telegram/src/reply-parameters.ts b/extensions/telegram/src/reply-parameters.ts index 4a2180eabfd4..9cd159b7caf4 100644 --- a/extensions/telegram/src/reply-parameters.ts +++ b/extensions/telegram/src/reply-parameters.ts @@ -2,6 +2,7 @@ import { GrammyError } from "grammy"; import type { MessageEntity } from "grammy/types"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js"; import { normalizeTelegramReplyToMessageId } from "./outbound-params.js"; @@ -118,7 +119,7 @@ export function getTelegramNativeQuoteReplyMessageId( return undefined; } const messageId = (replyParameters as { message_id?: unknown }).message_id; - return typeof messageId === "number" && Number.isFinite(messageId) ? messageId : undefined; + return asFiniteNumber(messageId); } export function isTelegramQuoteParamError(err: unknown): boolean { diff --git a/extensions/telegram/src/status-issues.ts b/extensions/telegram/src/status-issues.ts index 602866275bbd..98b5887795ab 100644 --- a/extensions/telegram/src/status-issues.ts +++ b/extensions/telegram/src/status-issues.ts @@ -11,7 +11,7 @@ import { resolveEnabledConfiguredAccountId, type AccountStatusSnapshot, } from "openclaw/plugin-sdk/status-helpers"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumber, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; const TELEGRAM_POLLING_CONNECT_GRACE_MS = 120_000; const TELEGRAM_POLLING_STALE_TRANSPORT_MS = 30 * 60_000; @@ -41,10 +41,6 @@ type TelegramGroupMembershipAuditSummary = { }>; }; -function asFiniteNumberOrNull(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - function appendTelegramRuntimeError(message: string, lastError: unknown): string { const error = normalizeOptionalString(lastError); return error ? `${message}: ${error}` : message; @@ -69,8 +65,8 @@ function collectTelegramPollingRuntimeIssues(params: { return; } - const lastStartAt = asFiniteNumberOrNull(account.lastStartAt); - const lastTransportActivityAt = asFiniteNumberOrNull(account.lastTransportActivityAt); + const lastStartAt = asFiniteNumber(account.lastStartAt) ?? null; + const lastTransportActivityAt = asFiniteNumber(account.lastTransportActivityAt) ?? null; 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 +125,7 @@ function collectTelegramWebhookRuntimeIssues(params: { return; } - const lastStartAt = asFiniteNumberOrNull(account.lastStartAt); + const lastStartAt = asFiniteNumber(account.lastStartAt) ?? null; const withinStartupGrace = lastStartAt != null && now - lastStartAt < TELEGRAM_WEBHOOK_CONNECT_GRACE_MS; if (withinStartupGrace) { diff --git a/extensions/telegram/src/update-offset-persistence.ts b/extensions/telegram/src/update-offset-persistence.ts index fd8e3ee43ca2..95a17c0fbfe0 100644 --- a/extensions/telegram/src/update-offset-persistence.ts +++ b/extensions/telegram/src/update-offset-persistence.ts @@ -4,6 +4,7 @@ import { sleepWithAbort, type BackoffPolicy, } from "openclaw/plugin-sdk/runtime-env"; +import { asSafeIntegerInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; const OFFSET_PERSIST_RETRY_POLICY: BackoffPolicy = { initialMs: 250, @@ -21,10 +22,7 @@ type TelegramUpdateOffsetPersistenceOptions = { }; export function normalizeTelegramUpdateId(value: number | null): number | null { - if (value === null || !Number.isSafeInteger(value) || value < 0) { - return null; - } - return value; + return asSafeIntegerInRange(value, { min: 0 }) ?? null; } export function createTelegramUpdateOffsetPersistence( diff --git a/extensions/tlon/src/monitor/discovery.ts b/extensions/tlon/src/monitor/discovery.ts index 5b1854abab8a..aa9c4bcd2e1a 100644 --- a/extensions/tlon/src/monitor/discovery.ts +++ b/extensions/tlon/src/monitor/discovery.ts @@ -1,8 +1,8 @@ // Tlon plugin module implements discovery behavior. +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { asNullableRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { Foreigns } from "../urbit/foreigns.js"; -import { formatErrorMessage } from "./utils.js"; interface InitData { channels: string[]; diff --git a/extensions/tlon/src/monitor/history.ts b/extensions/tlon/src/monitor/history.ts index 80899e9f001b..16ca5e18f6c3 100644 --- a/extensions/tlon/src/monitor/history.ts +++ b/extensions/tlon/src/monitor/history.ts @@ -1,7 +1,8 @@ // Tlon plugin module implements history behavior. +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { asNullableRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { extractMessageText, formatErrorMessage } from "./utils.js"; +import { extractMessageText } from "./utils.js"; /** * Format a number as @ud (with dots every 3 digits from the right) diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 79d9c49fa259..ad41c9fc8022 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -4,6 +4,7 @@ import { bindIngressLifecycleToReplyOptions, waitUntilAbort, } from "openclaw/plugin-sdk/channel-outbound"; +import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; @@ -44,7 +45,6 @@ import { shouldMigrateTlonSetting, } from "./settings-helpers.js"; import { createActiveSnapshotTracker, createParticipatedThreadTracker } from "./tracking.js"; -import { formatErrorMessage } from "./utils.js"; import { extractMessageText, formatModelName, diff --git a/extensions/tlon/src/monitor/utils.ts b/extensions/tlon/src/monitor/utils.ts index fbd790c02d7b..b3cec5e5c698 100644 --- a/extensions/tlon/src/monitor/utils.ts +++ b/extensions/tlon/src/monitor/utils.ts @@ -11,7 +11,6 @@ import { type StableChannelIngressIdentityParams, } from "openclaw/plugin-sdk/channel-ingress-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { formatErrorMessage as sharedFormatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; // Tlon helper module supports utils behavior. import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; import { asNullableRecord, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -243,8 +242,6 @@ export async function resolveAuthorizedMessageText(params: { return citedContent + rawText; } -export const formatErrorMessage = sharedFormatErrorMessage; - // Helper to recursively extract text from inline content function renderInlineItem( item: unknown, diff --git a/extensions/together/together.live.test.ts b/extensions/together/together.live.test.ts index 03d82d37e799..91be04ae136f 100644 --- a/extensions/together/together.live.test.ts +++ b/extensions/together/together.live.test.ts @@ -1,13 +1,13 @@ // Together tests cover together plugin behavior. import { completeSimple, type Model } from "openclaw/plugin-sdk/llm"; +import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; import { describe, expect, it } from "vitest"; import { TOGETHER_BASE_URL, TOGETHER_MODEL_CATALOG } from "./models.js"; const TOGETHER_KEY = process.env.TOGETHER_API_KEY ?? ""; -const LIVE = ["LIVE", "OPENCLAW_LIVE_TEST", "TOGETHER_LIVE_TEST"].some((name) => { - const value = process.env[name]?.trim().toLowerCase(); - return value === "1" || value === "true" || value === "yes" || value === "on"; -}); +const LIVE = ["LIVE", "OPENCLAW_LIVE_TEST", "TOGETHER_LIVE_TEST"].some((name) => + isTruthyEnvValue(process.env[name]), +); const TOGETHER_LIVE_TIMEOUT_MS = 45_000; const describeLive = LIVE && TOGETHER_KEY ? describe : describe.skip; diff --git a/extensions/whatsapp/src/monitor-inbox.test-harness.ts b/extensions/whatsapp/src/monitor-inbox.test-harness.ts index e12b8c8f1c24..f9a08e64118e 100644 --- a/extensions/whatsapp/src/monitor-inbox.test-harness.ts +++ b/extensions/whatsapp/src/monitor-inbox.test-harness.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { createChannelIngressQueueForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { afterEach, beforeEach, expect, vi } from "vitest"; @@ -238,7 +239,7 @@ vi.mock("./session.js", async () => { }), waitForWaConnection: vi.fn().mockResolvedValue(undefined), getStatusCode: vi.fn(() => 500), - formatError: (err: unknown) => (err instanceof Error ? err.message : String(err)), + formatError: coerceErrorMessage, }; }); diff --git a/extensions/whatsapp/src/qa-driver.runtime.test.ts b/extensions/whatsapp/src/qa-driver.runtime.test.ts index 07130c12d081..66285c96aee4 100644 --- a/extensions/whatsapp/src/qa-driver.runtime.test.ts +++ b/extensions/whatsapp/src/qa-driver.runtime.test.ts @@ -1,6 +1,7 @@ // Whatsapp tests cover qa driver plugin behavior. import { EventEmitter } from "node:events"; import type { proto, WAMessage } from "baileys"; +import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { startWhatsAppQaDriverSession, type WhatsAppQaDriverSession } from "./qa-driver.runtime.js"; import { DEFAULT_WHATSAPP_SOCKET_TIMING } from "./socket-timing.js"; @@ -22,7 +23,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("./session.js", () => ({ createWaSocket: mocks.createWaSocket, - formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)), + formatError: coerceErrorMessage, getStatusCode: (error: unknown) => (error as { output?: { statusCode?: number } } | undefined)?.output?.statusCode, waitForWaConnection: mocks.waitForWaConnection, diff --git a/packages/acp-core/src/meta.ts b/packages/acp-core/src/meta.ts index 4ce22b1478d1..a4a92b41c2a1 100644 --- a/packages/acp-core/src/meta.ts +++ b/packages/acp-core/src/meta.ts @@ -1,4 +1,5 @@ // ACP Core module implements meta behavior. +import { asFiniteNumber, asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; function readMetaValue( @@ -39,9 +40,7 @@ export function readMetadataNumber( meta: Record | null | undefined, keys: string[], ): number | undefined { - return readMetaValue(meta, keys, (value) => - typeof value === "number" && Number.isFinite(value) ? value : undefined, - ); + return readMetaValue(meta, keys, asFiniteNumber); } /** Reads the first safe non-negative integer metadata value, preserving zero. */ @@ -49,7 +48,5 @@ export function readNonNegativeInteger( meta: Record | null | undefined, keys: string[], ): number | undefined { - return readMetaValue(meta, keys, (value) => - typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined, - ); + return readMetaValue(meta, keys, (value) => asSafeIntegerInRange(value, { min: 0 })); } diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts index 026b5e2cb83b..e9a12cb5a903 100644 --- a/packages/agent-core/src/agent-loop.ts +++ b/packages/agent-core/src/agent-loop.ts @@ -9,6 +9,7 @@ import type { ToolResultMessage, } from "@openclaw/llm-core"; import type { EventStream as SourceEventStream } from "@openclaw/llm-core"; +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { TranscriptNotContinuableError } from "./errors.js"; import { uuidv7 } from "./harness/session/uuid.js"; @@ -1332,7 +1333,7 @@ async function prepareToolCall( } catch (error) { return { kind: "immediate", - result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + result: createErrorToolResult(coerceErrorMessage(error)), isError: true, }; } @@ -1360,11 +1361,7 @@ async function validateToolCallForBatchAdmission( outcome: { kind: "immediate", result: createErrorToolResult( - signal?.aborted - ? "Operation aborted" - : resolution.error instanceof Error - ? resolution.error.message - : String(resolution.error), + signal?.aborted ? "Operation aborted" : coerceErrorMessage(resolution.error), ), isError: true, }, @@ -1390,7 +1387,7 @@ async function validateToolCallForBatchAdmission( kind: "immediate", outcome: { kind: "immediate", - result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + result: createErrorToolResult(coerceErrorMessage(error)), isError: true, }, }; @@ -1404,7 +1401,7 @@ async function validateToolCallForBatchAdmission( kind: "immediate", outcome: { kind: "immediate", - result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + result: createErrorToolResult(coerceErrorMessage(error)), isError: true, errorKind: "argument-validation", }, @@ -1452,7 +1449,7 @@ async function prepareToolCallExecution( return { kind: "immediate", outcome: { - result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + result: createErrorToolResult(coerceErrorMessage(error)), isError: true, executionStarted: false, }, @@ -1508,7 +1505,7 @@ async function prepareToolCallExecution( throw implementationStartError.error; } return { - result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + result: createErrorToolResult(coerceErrorMessage(error)), isError: true, executionStarted, ...(executionStarted && signal?.aborted && error === signal.reason @@ -1570,11 +1567,7 @@ async function prepareToolCallExecution( return { kind: "immediate", outcome: { - result: createErrorToolResult( - internalPreparation.outcome.error instanceof Error - ? internalPreparation.outcome.error.message - : String(internalPreparation.outcome.error), - ), + result: createErrorToolResult(coerceErrorMessage(internalPreparation.outcome.error)), isError: true, executionStarted: false, }, @@ -1627,7 +1620,7 @@ async function finalizeExecutedToolCall( isError = afterResult.isError ?? isError; } } catch (error) { - result = createErrorToolResult(error instanceof Error ? error.message : String(error)); + result = createErrorToolResult(coerceErrorMessage(error)); isError = true; } } @@ -1692,9 +1685,7 @@ async function finalizeToolCallOutcome( isError: afterResult.isError ?? finalized.isError, }; } catch (error) { - const errorResult = createErrorToolResult( - error instanceof Error ? error.message : String(error), - ); + const errorResult = createErrorToolResult(coerceErrorMessage(error)); return { ...finalized, result: { diff --git a/packages/ai/src/providers/anthropic-usage.ts b/packages/ai/src/providers/anthropic-usage.ts index ed50716c21e9..a350f2a5b356 100644 --- a/packages/ai/src/providers/anthropic-usage.ts +++ b/packages/ai/src/providers/anthropic-usage.ts @@ -1,3 +1,4 @@ +import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import type { Usage } from "../types.js"; type AnthropicUsagePayload = { @@ -31,7 +32,7 @@ export type AnthropicIterationUsageResult = | { state: "valid"; usage: AnthropicIterationUsageSnapshot }; export function readAnthropicUsageTokenCount(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; + return asNonNegativeFiniteNumber(value); } export function readAnthropicCacheWriteUsage( diff --git a/packages/ai/src/providers/openai-responses-terminal-usage.ts b/packages/ai/src/providers/openai-responses-terminal-usage.ts index 4179a9d364a2..425bec2155eb 100644 --- a/packages/ai/src/providers/openai-responses-terminal-usage.ts +++ b/packages/ai/src/providers/openai-responses-terminal-usage.ts @@ -6,6 +6,7 @@ * package and managed transports from drifting on token buckets, service-tier pricing, or future * terminal-event semantics. */ +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import type OpenAI from "openai"; import type { StopReason, Usage } from "../types.js"; @@ -53,10 +54,7 @@ export function mapResponsesTerminalUsage( export function readResponsesReasoningTokens( usage: ResponsesTerminalUsagePayload | undefined | null, ): number | undefined { - const reasoningTokens = usage?.output_tokens_details?.reasoning_tokens; - return typeof reasoningTokens === "number" && Number.isFinite(reasoningTokens) - ? reasoningTokens - : undefined; + return asFiniteNumber(usage?.output_tokens_details?.reasoning_tokens); } function mapResponsesTerminalStopReason( diff --git a/packages/ai/src/transports/model-max-tokens-params.ts b/packages/ai/src/transports/model-max-tokens-params.ts index 4bc985928189..a25767a7dd82 100644 --- a/packages/ai/src/transports/model-max-tokens-params.ts +++ b/packages/ai/src/transports/model-max-tokens-params.ts @@ -3,12 +3,9 @@ * Callers canonicalize aliases before dispatch so payloads cannot carry * conflicting limits. */ -const MAX_TOKENS_PARAM_KEYS = ["maxTokens", "max_completion_tokens", "max_tokens"] as const; +import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; -/** Return a finite non-negative max-token value, or undefined for invalid input. */ -function resolveNonNegativeMaxTokensParam(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} +const MAX_TOKENS_PARAM_KEYS = ["maxTokens", "max_completion_tokens", "max_tokens"] as const; /** Resolve the first supported max-token parameter present in a params object. */ export function resolveMaxTokensParam( @@ -18,7 +15,7 @@ export function resolveMaxTokensParam( return undefined; } for (const key of MAX_TOKENS_PARAM_KEYS) { - const resolved = resolveNonNegativeMaxTokensParam(params[key]); + const resolved = asNonNegativeFiniteNumber(params[key]); if (resolved !== undefined) { return resolved; } diff --git a/packages/gateway-client/src/client-address-utils.ts b/packages/gateway-client/src/client-address-utils.ts index 48fa7d7345ec..a8031339271e 100644 --- a/packages/gateway-client/src/client-address-utils.ts +++ b/packages/gateway-client/src/client-address-utils.ts @@ -4,7 +4,7 @@ import { type ParsedIpAddress, } from "@openclaw/net-policy/ip"; -export function normalizeLowercaseStringOrEmpty(value: unknown): string { +export function normalizeGatewayErrorText(value: unknown): string { return typeof value === "string" ? value.trim().toLowerCase() : ""; } diff --git a/packages/gateway-client/src/client.ts b/packages/gateway-client/src/client.ts index 7b376c995730..677e3b0b46b3 100644 --- a/packages/gateway-client/src/client.ts +++ b/packages/gateway-client/src/client.ts @@ -24,7 +24,7 @@ import { WebSocket, type ClientOptions, type CertMeta } from "ws"; import { isSensitiveUrlQueryParamName, normalizeFingerprint, - normalizeLowercaseStringOrEmpty, + normalizeGatewayErrorText, parseGatewayIpAddress, parseHostForAddressChecks, } from "./client-address-utils.js"; @@ -948,7 +948,7 @@ export class GatewayClient { return ( expectedProtocol === MIN_NODE_PROTOCOL_VERSION && (detailCode === ConnectErrorDetailCodes.PROTOCOL_MISMATCH || - normalizeLowercaseStringOrEmpty(error.message).includes("protocol mismatch")) + normalizeGatewayErrorText(error.message).includes("protocol mismatch")) ); } @@ -966,7 +966,7 @@ export class GatewayClient { return ( expectedProtocol === PROTOCOL_VERSION && (detailCode === ConnectErrorDetailCodes.PROTOCOL_MISMATCH || - normalizeLowercaseStringOrEmpty(error.message).includes("protocol mismatch")) + normalizeGatewayErrorText(error.message).includes("protocol mismatch")) ); } @@ -1228,7 +1228,7 @@ export class GatewayClient { private clearStaleDeviceTokenForClose(code: number, reason: string): void { if ( code !== 1008 || - !normalizeLowercaseStringOrEmpty(reason).includes("device token mismatch") || + !normalizeGatewayErrorText(reason).includes("device token mismatch") || this.opts.token || this.opts.password || !this.opts.deviceIdentity @@ -1283,7 +1283,7 @@ export class GatewayClient { if (params.error.gatewayCode !== "INVALID_REQUEST") { return false; } - const message = normalizeLowercaseStringOrEmpty(params.error.message); + const message = normalizeGatewayErrorText(params.error.message); return message.includes("invalid connect params") && message.includes("approvalruntimetoken"); } @@ -1300,7 +1300,7 @@ export class GatewayClient { if (params.error.gatewayCode !== "INVALID_REQUEST") { return false; } - const message = normalizeLowercaseStringOrEmpty(params.error.message); + const message = normalizeGatewayErrorText(params.error.message); return ( message.includes("invalid connect params") && message.includes("agentruntimeidentitytoken") ); diff --git a/packages/gateway-client/src/timeouts.ts b/packages/gateway-client/src/timeouts.ts index 3ab214b6dd5a..e26b385f8e57 100644 --- a/packages/gateway-client/src/timeouts.ts +++ b/packages/gateway-client/src/timeouts.ts @@ -1,5 +1,5 @@ // Gateway Client module implements timeouts behavior. -function parseStrictPositiveInteger(value: string): number | undefined { +function parsePositiveTimeoutSetting(value: string): number | undefined { const trimmed = value.trim(); if (!/^\+?\d+$/u.test(trimmed)) { return undefined; @@ -106,7 +106,7 @@ export function getConnectChallengeTimeoutMsFromEnv( ): number | undefined { const raw = env.OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS; if (raw) { - const parsed = parseStrictPositiveInteger(raw); + const parsed = parsePositiveTimeoutSetting(raw); if (parsed !== undefined) { return resolveSafeTimeoutDelayMs(parsed); } @@ -155,7 +155,7 @@ export function resolvePreauthHandshakeTimeoutMs(params?: { env.OPENCLAW_HANDSHAKE_TIMEOUT_MS || (isTestRuntimeEnv(env) ? env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS : undefined); if (configuredTimeout) { - const parsed = parseStrictPositiveInteger(configuredTimeout); + const parsed = parsePositiveTimeoutSetting(configuredTimeout); if (parsed !== undefined) { return resolveSafeTimeoutDelayMs(parsed); } diff --git a/packages/gateway-protocol/src/connect-error-details.ts b/packages/gateway-protocol/src/connect-error-details.ts index 6bfb1f528016..e77822cadef5 100644 --- a/packages/gateway-protocol/src/connect-error-details.ts +++ b/packages/gateway-protocol/src/connect-error-details.ts @@ -6,7 +6,7 @@ */ import { normalizeOptionalProtocolString } from "./protocol-value-normalization.js"; -function normalizeArrayBackedTrimmedStringList(value: unknown): string[] | undefined { +function normalizeOptionalConnectDetailStringList(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; } @@ -266,7 +266,7 @@ export function normalizePairingConnectRequestId(value: unknown): string | undef } function normalizeStringArray(value: unknown): string[] | undefined { - return normalizeArrayBackedTrimmedStringList(value); + return normalizeOptionalConnectDetailStringList(value); } function createPairingConnectErrorDetails(params: { diff --git a/packages/normalization-core/src/error-coercion.test.ts b/packages/normalization-core/src/error-coercion.test.ts index a9e0d6f5868f..1cf021d13588 100644 --- a/packages/normalization-core/src/error-coercion.test.ts +++ b/packages/normalization-core/src/error-coercion.test.ts @@ -1,10 +1,11 @@ // Normalization core tests cover shared error coercion and formatting behavior. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { coerceErrorMessage, formatErrorMessage, stringifyNonErrorCause, toErrorObject, + toStructuredErrorObject, toStringifiedError, } from "./error-coercion.js"; @@ -64,6 +65,188 @@ describe("toErrorObject", () => { }); }); +describe("toStructuredErrorObject", () => { + it("preserves Error identity without coercing it", () => { + class ThrowingToStringError extends Error { + override toString(): string { + throw new Error("unexpected stringification"); + } + } + const original = new ThrowingToStringError("request failed", { + cause: { code: "EIO" }, + }); + + expect(toStructuredErrorObject(original)).toBe(original); + }); + + it("preserves primitive message and cause semantics", () => { + const stringError = toStructuredErrorObject("request failed"); + + expect(stringError).toMatchObject({ message: "request failed" }); + expect(stringError).not.toHaveProperty("cause"); + for (const value of [undefined, null, 503, false, 503n, Symbol("failure")]) { + const error = toStructuredErrorObject(value); + expect(error.message).toBe(String(value)); + expect(Object.hasOwn(error, "cause")).toBe(true); + expect(error.cause).toBe(value); + } + }); + + it("preserves hostile stringification failures", () => { + const failure = { + [Symbol.toPrimitive]() { + throw new Error("stringification failed"); + }, + }; + + expect(() => toStructuredErrorObject(failure)).toThrow("stringification failed"); + }); + + it("copies enumerable string and symbol details while retaining the original cause", () => { + const detailKey = Symbol("detail"); + const throwingDetailKey = Symbol("throwing detail"); + const cause = { + code: "EIO", + details: { retryable: true }, + [detailKey]: "symbol detail", + }; + Object.defineProperty(cause, "hidden", { value: "secret", enumerable: false }); + Object.defineProperty(cause, throwingDetailKey, { + enumerable: true, + get() { + throw new Error("unexpected symbol field read"); + }, + }); + + const error = toStructuredErrorObject(cause); + + expect(error).not.toBe(cause); + expect(error.message).toBe("[object Object]"); + expect(error.cause).toBe(cause); + expect(error).toMatchObject({ code: "EIO", details: { retryable: true } }); + expect(Object.getOwnPropertyDescriptor(error, "code")).toEqual({ + value: "EIO", + writable: true, + enumerable: true, + configurable: true, + }); + expect(Reflect.get(error, detailKey)).toBe("symbol detail"); + expect(Object.hasOwn(error, throwingDetailKey)).toBe(false); + expect(error).not.toHaveProperty("hidden"); + + const functionCause = Object.assign(function requestFailure() {}, { + code: "EFUNCTION", + [detailKey]: "function symbol detail", + }); + const functionError = toStructuredErrorObject(functionCause); + expect(functionError.message).toBe(String(functionCause)); + expect(functionError.cause).toBe(functionCause); + expect(functionError).toMatchObject({ code: "EFUNCTION" }); + expect(Reflect.get(functionError, detailKey)).toBe("function symbol detail"); + }); + + it("skips fields whose definition fails and continues copying later details", () => { + const originalDefineProperty = Object.defineProperty; + const defineProperty = vi + .spyOn(Object, "defineProperty") + .mockImplementation( + (target: unknown, key: PropertyKey, attributes: PropertyDescriptor): unknown => { + if (target instanceof Error && key === "blocked") { + throw new Error("definition rejected"); + } + return originalDefineProperty(target as object, key, attributes); + }, + ); + + try { + const error = toStructuredErrorObject({ before: 1, blocked: 2, after: 3 }); + expect(error).toMatchObject({ before: 1, after: 3 }); + expect(error).not.toHaveProperty("blocked"); + } finally { + defineProperty.mockRestore(); + } + }); + + it("skips throwing fields and preserves the base Error for enumeration failures", () => { + const throwingGetter = { + get details(): never { + throw new Error("unexpected structured field read"); + }, + code: "EIO", + }; + const ownKeysFailure = new Proxy( + { code: "EIO" }, + { + ownKeys() { + throw new Error("unexpected ownKeys call"); + }, + }, + ); + const descriptorFailure = new Proxy( + { code: "EIO", status: 503 }, + { + ownKeys() { + return ["code", "status"]; + }, + getOwnPropertyDescriptor(target, key) { + if (key === "status") { + throw new Error("unexpected descriptor read"); + } + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }, + ); + + expect(toStructuredErrorObject(throwingGetter)).toMatchObject({ code: "EIO" }); + for (const cause of [ownKeysFailure, descriptorFailure]) { + const error = toStructuredErrorObject(cause); + expect(error).toMatchObject({ name: "Error", message: "[object Object]" }); + expect(error.cause).toBe(cause); + expect(error).not.toHaveProperty("code"); + expect(error).not.toHaveProperty("status"); + } + }); + + it("protects Error-owned and prototype-mutating fields without reading them", () => { + let protectedReads = 0; + const cause = { + get name() { + protectedReads += 1; + return "SpoofedError"; + }, + get message() { + protectedReads += 1; + return "spoofed message"; + }, + get cause() { + protectedReads += 1; + return "spoofed cause"; + }, + get stack() { + protectedReads += 1; + return "spoofed stack"; + }, + constructor: { polluted: true }, + prototype: { polluted: true }, + code: "EIO", + }; + Object.defineProperty(cause, "__proto__", { + value: { polluted: true }, + enumerable: true, + }); + + const error = toStructuredErrorObject(cause); + + expect(protectedReads).toBe(0); + expect(error).toMatchObject({ name: "Error", message: "[object Object]", code: "EIO" }); + expect(error.cause).toBe(cause); + expect(Object.getPrototypeOf(error)).toBe(Error.prototype); + expect(Object.hasOwn(error, "__proto__")).toBe(false); + expect(Object.hasOwn(error, "constructor")).toBe(false); + expect(Object.hasOwn(error, "prototype")).toBe(false); + }); +}); + describe("toStringifiedError", () => { it("preserves Error identity and stringifies every other value", () => { const error = new Error("boom"); diff --git a/packages/normalization-core/src/error-coercion.ts b/packages/normalization-core/src/error-coercion.ts index 725550cf6c79..63673f987ee9 100644 --- a/packages/normalization-core/src/error-coercion.ts +++ b/packages/normalization-core/src/error-coercion.ts @@ -4,6 +4,9 @@ export type FormatErrorMessageOptions = { redact: (text: string) => string; }; +const STRUCTURED_ERROR_OWNED_FIELDS = new Set(["cause", "message", "name", "stack"]); +const STRUCTURED_ERROR_PROTOTYPE_FIELDS = new Set(["__proto__", "constructor", "prototype"]); + function readProperty(value: object, key: "cause" | "code" | "status"): unknown { try { return (value as Record)[key]; @@ -125,6 +128,42 @@ export function toErrorObject(value: unknown, fallbackMessage: string): Error { return error; } +/** Preserves structured details while isolating hostile object field access. */ +export function toStructuredErrorObject(value: unknown): Error { + if (value instanceof Error) { + return value; + } + const message = String(value); + if ((typeof value !== "object" || value === null) && typeof value !== "function") { + return toErrorObject(value, message); + } + const error = new Error(message, { cause: value }); + try { + const detailKeys = Reflect.ownKeys(value).filter( + (key) => + (typeof key !== "string" || + (!STRUCTURED_ERROR_OWNED_FIELDS.has(key) && + !STRUCTURED_ERROR_PROTOTYPE_FIELDS.has(key))) && + Reflect.getOwnPropertyDescriptor(value, key)?.enumerable, + ); + for (const key of detailKeys) { + try { + Object.defineProperty(error, key, { + value: Reflect.get(value, key), + writable: true, + enumerable: true, + configurable: true, + }); + } catch { + // Skip fields whose getters or property definitions reject access. + } + } + } catch { + // Opaque proxies may reject enumeration; preserve the original failure as the cause. + } + return error; +} + /** Preserves Error values and stringifies every other value into a new Error. */ export function toStringifiedError(value: unknown): Error { return value instanceof Error ? value : new Error(String(value)); diff --git a/scripts/bench-agent-concurrency-worker.ts b/scripts/bench-agent-concurrency-worker.ts index 264408667b79..1434a4d344b4 100644 --- a/scripts/bench-agent-concurrency-worker.ts +++ b/scripts/bench-agent-concurrency-worker.ts @@ -11,6 +11,7 @@ import { type WorkerResult, type WorkerScenario, } from "./bench-agent-concurrency.js"; +import { classifyBoundedUnsignedDecimal } from "./lib/arg-utils.mts"; type WorkerOptions = { scenario: WorkerScenario; @@ -33,14 +34,14 @@ const SCENARIOS = new Set([ ]); function parseInteger(raw: string | undefined, flag: string, min: number, max: number): number { - if (!raw || !/^\d+$/u.test(raw)) { + const result = classifyBoundedUnsignedDecimal(raw, min, max); + if (result.kind === "syntax") { throw new Error(`${flag} must be an integer`); } - const value = Number(raw); - if (value < min || value > max) { + if (result.kind !== "value") { throw new Error(`${flag} must be between ${min} and ${max}`); } - return value; + return result.value; } function parseOptions(argv: string[]): WorkerOptions { diff --git a/scripts/bench-agent-concurrency.ts b/scripts/bench-agent-concurrency.ts index 1a33462a6004..e554d9371479 100644 --- a/scripts/bench-agent-concurrency.ts +++ b/scripts/bench-agent-concurrency.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { classifyBoundedUnsignedDecimal } from "./lib/arg-utils.mts"; const DEFAULT_FANOUT = [1, 8, 32, 64]; const DEFAULT_SWEEP_ROWS = [32, 128, 512]; @@ -144,17 +145,17 @@ Options: } function parseInteger(raw: string, flag: string, min: number, max: number): number { - if (!/^\d+$/u.test(raw)) { + const result = classifyBoundedUnsignedDecimal(raw, min, max); + if (result.kind === "syntax") { throw new Error(`${flag} must be an integer`); } - const value = Number(raw); - if (value < min) { + if (result.kind === "below") { throw new Error(`${flag} must be at least ${min}`); } - if (value > max) { + if (result.kind === "above") { throw new Error(`${flag} must be at most ${max}`); } - return value; + return result.value; } function parseList(raw: string, flag: string, max: number): number[] { diff --git a/scripts/bench-gateway-concurrency.ts b/scripts/bench-gateway-concurrency.ts index 74d9ab6fb4f7..e830641f20a2 100644 --- a/scripts/bench-gateway-concurrency.ts +++ b/scripts/bench-gateway-concurrency.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { performance } from "node:perf_hooks"; import { pathToFileURL } from "node:url"; import { PROTOCOL_VERSION } from "../packages/gateway-protocol/src/version.ts"; +import { asFiniteNumber } from "../packages/normalization-core/src/number-coercion.ts"; import { applyMockOpenAiModelConfig } from "./e2e/lib/fixtures/mock-openai-config.mjs"; import { delay, stopChild } from "./lib/gateway-bench-child.ts"; import { getFreePort } from "./lib/gateway-bench-probes.ts"; @@ -299,10 +300,6 @@ async function requestHttp(params: { }); } -function numberOrNull(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - function describeProbeError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); return message.slice(0, 500); @@ -600,10 +597,10 @@ async function sampleGateway(params: { ok: readyz.ok && readyz.status === 200, status: readyz.status, degraded: typeof eventLoop?.degraded === "boolean" ? eventLoop.degraded : null, - degradedSinceMs: numberOrNull(eventLoop?.degradedSinceMs), - delayP99Ms: numberOrNull(eventLoop?.delayP99Ms), - utilization: numberOrNull(eventLoop?.utilization), - cpuCoreRatio: numberOrNull(eventLoop?.cpuCoreRatio), + degradedSinceMs: asFiniteNumber(eventLoop?.degradedSinceMs) ?? null, + delayP99Ms: asFiniteNumber(eventLoop?.delayP99Ms) ?? null, + utilization: asFiniteNumber(eventLoop?.utilization) ?? null, + cpuCoreRatio: asFiniteNumber(eventLoop?.cpuCoreRatio) ?? null, }, sessionsList: { atMs, diff --git a/scripts/bench-task-registry-sqlite-worker.ts b/scripts/bench-task-registry-sqlite-worker.ts index 524d00883bf1..e8bc20974807 100644 --- a/scripts/bench-task-registry-sqlite-worker.ts +++ b/scripts/bench-task-registry-sqlite-worker.ts @@ -11,6 +11,7 @@ import { type RetainedMemoryMetrics, type WorkerResult, } from "./bench-task-registry-sqlite.js"; +import { classifyBoundedUnsignedDecimal } from "./lib/arg-utils.mts"; type WorkerOptions = { size: number; @@ -44,14 +45,14 @@ type TaskRegistryQueryApi = Pick< >; function parseInteger(raw: string | undefined, flag: string, min: number, max: number): number { - if (!raw || !/^\d+$/u.test(raw)) { + const result = classifyBoundedUnsignedDecimal(raw, min, max); + if (result.kind === "syntax") { throw new Error(`${flag} must be an integer`); } - const value = Number(raw); - if (value < min || value > max) { + if (result.kind !== "value") { throw new Error(`${flag} must be between ${min} and ${max}`); } - return value; + return result.value; } function parseOptions(argv: string[]): WorkerOptions { diff --git a/scripts/bench-task-registry-sqlite.ts b/scripts/bench-task-registry-sqlite.ts index e1b83bc4eb27..c5f0aa82dfe6 100644 --- a/scripts/bench-task-registry-sqlite.ts +++ b/scripts/bench-task-registry-sqlite.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { classifyBoundedUnsignedDecimal } from "./lib/arg-utils.mts"; const DEFAULT_SIZES = [24, 64, 128]; const WORKER_TIMEOUT_MS = 300_000; @@ -127,17 +128,17 @@ Options: } function parseInteger(raw: string, flag: string, min: number, max: number): number { - if (!/^\d+$/u.test(raw)) { + const result = classifyBoundedUnsignedDecimal(raw, min, max); + if (result.kind === "syntax") { throw new Error(`${flag} must be an integer`); } - const value = Number(raw); - if (value < min) { + if (result.kind === "below") { throw new Error(`${flag} must be at least ${min}`); } - if (value > max) { + if (result.kind === "above") { throw new Error(`${flag} must be at most ${max}`); } - return value; + return result.value; } function parseList(raw: string, flag: string): number[] { diff --git a/scripts/check-coercion-helper-declarations.mts b/scripts/check-coercion-helper-declarations.mts index 2c951c4d21b1..af1c519befdd 100644 --- a/scripts/check-coercion-helper-declarations.mts +++ b/scripts/check-coercion-helper-declarations.mts @@ -34,14 +34,18 @@ export const CANONICAL_COERCION_HELPER_OWNERS = [ file: "packages/normalization-core/src/string-coerce.ts", kind: "function", names: [ + "hasNonEmptyString", "lowercasePreservingWhitespace", "localeLowercasePreservingWhitespace", "normalizeBoundedOptionalString", "normalizeFastMode", + "normalizeLowercaseStringOrEmpty", "normalizeNullableString", "normalizeOptionalLowercaseString", "normalizeOptionalString", "normalizeOptionalStringifiedId", + "normalizeOptionalThreadValue", + "normalizeStringifiedOptionalString", "normalizeStringifiedEntries", "readNonBlankString", "readNonEmptyStringPreservingWhitespace", @@ -52,7 +56,27 @@ export const CANONICAL_COERCION_HELPER_OWNERS = [ { file: "packages/normalization-core/src/string-normalization.ts", kind: "function", - names: ["filterStringEntries"], + names: [ + "filterStringEntries", + "normalizeArrayBackedTrimmedStringList", + "normalizeAtHashSlug", + "normalizeCsvOrLooseStringList", + "normalizeHyphenSlug", + "normalizeOptionalTrimmedStringList", + "normalizeSingleOrTrimmedStringList", + "normalizeSortedUniqueStringEntries", + "normalizeSortedUniqueTrimmedStringList", + "normalizeStringEntries", + "normalizeStringEntriesLower", + "normalizeTrimmedStringList", + "normalizeUniqueSingleOrTrimmedStringList", + "normalizeUniqueStringEntries", + "normalizeUniqueStringEntriesLower", + "normalizeUniqueTrimmedStringList", + "sortUniqueStrings", + "uniqueStrings", + "uniqueValues", + ], }, { file: "packages/normalization-core/src/number-coercion.ts", @@ -66,6 +90,7 @@ export const CANONICAL_COERCION_HELPER_OWNERS = [ "asPositiveFiniteNumber", "asPositiveSafeInteger", "asSafeIntegerInRange", + "clampTimerTimeoutMs", "clampPositiveTimerTimeoutMs", "finiteSecondsToTimerSafeMilliseconds", "isFutureDateTimestampMs", @@ -76,6 +101,7 @@ export const CANONICAL_COERCION_HELPER_OWNERS = [ "parseStrictFiniteNumber", "parseStrictInteger", "parseStrictNonNegativeInteger", + "parseStrictPositiveInteger", "positiveSecondsToSafeMilliseconds", "resolveDateTimestampMs", "resolveExpiresAtMsFromDurationMs", @@ -86,11 +112,17 @@ export const CANONICAL_COERCION_HELPER_OWNERS = [ "resolveNonNegativeIntegerOption", "resolveOptionalIntegerOption", "resolvePositiveTimerTimeoutMs", + "resolveTimerTimeoutMs", "resolveTimestampMsToIsoString", "timestampMsToIsoFileStamp", "timestampMsToIsoString", ], }, + { + file: "packages/normalization-core/src/boolean-coercion.ts", + kind: "function", + names: ["parseBoolean"], + }, { file: "packages/normalization-core/src/record-coerce.ts", kind: "function", @@ -110,22 +142,37 @@ export const CANONICAL_COERCION_HELPER_OWNERS = [ { file: "packages/normalization-core/src/json-coercion.ts", kind: "function", - names: ["safeParseJsonRecord"], + names: ["safeParseJson", "safeParseJsonRecord"], }, { file: "packages/normalization-core/src/error-coercion.ts", kind: "function", - names: ["coerceErrorMessage", "stringifyNonErrorCause", "toErrorObject", "toStringifiedError"], + names: [ + "coerceErrorMessage", + "stringifyNonErrorCause", + "toErrorObject", + "toStringifiedError", + "toStructuredErrorObject", + ], }, { file: "scripts/lib/error-format.mts", kind: "function", - names: ["coerceErrorMessage", "toErrorObject"], + names: ["coerceErrorMessage", "toErrorObject", "toStringifiedError"], + }, + { + file: "scripts/lib/arg-utils.runtime.mjs", + kind: "function", + names: [ + "classifyBoundedUnsignedDecimal", + "parsePermissiveBooleanToken", + "parseStrictBooleanArg", + ], }, { file: "src/utils/boolean.ts", kind: "function", - names: ["parseBooleanValue"], + names: ["asBoolean", "parseBooleanValue"], }, ] as const satisfies readonly { file: string; @@ -133,6 +180,31 @@ export const CANONICAL_COERCION_HELPER_OWNERS = [ names: readonly string[]; }[]; +export const CANONICAL_COERCION_MODULES = [ + "packages/normalization-core/src/string-coerce.ts", + "packages/normalization-core/src/string-normalization.ts", + "packages/normalization-core/src/number-coercion.ts", + "packages/normalization-core/src/record-coerce.ts", + "packages/normalization-core/src/json-coercion.ts", + "packages/normalization-core/src/error-coercion.ts", + "packages/normalization-core/src/boolean-coercion.ts", + "scripts/lib/error-format.mts", + "src/utils/boolean.ts", +] as const; + +export const DEFERRED_CANONICAL_COERCION_EXPORTS = [ + { + file: "packages/normalization-core/src/error-coercion.ts", + name: "formatErrorMessage", + reason: "Structural formatter shares its public name with redacting owner adapters.", + }, + { + file: "scripts/lib/error-format.mts", + name: "formatErrorMessage", + reason: "Dependency-light scripts retain a deliberately smaller formatting policy.", + }, +] as const satisfies readonly { file: string; name: string; reason: string }[]; + const EXCEPTIONAL_COERCION_HELPER_CARVE_OUTS = [ { file: "ui/src/test-helpers/control-ui-e2e.ts", @@ -208,6 +280,19 @@ export type CoercionHelperDeclaration = { name: BannedCoercionHelperName; }; +export type CanonicalCoercionExportClassification = { + file: string; + name: string; + reason?: string; + status: "deferred" | "enforced"; +}; + +export type CanonicalCoercionExportAudit = { + invalidClassifications: string[]; + staleClassifications: CanonicalCoercionExportClassification[]; + unclassifiedExports: Array<{ file: string; name: string }>; +}; + export type CoercionHelperCarveOut = { count: number; file: string; @@ -385,6 +470,98 @@ export function findBannedCoercionHelperDeclarations( return declarations; } +function hasExportModifier(node: ts.Node) { + return (ts.canHaveModifiers(node) ? (ts.getModifiers(node) ?? []) : []).some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ); +} + +/** Finds directly declared callable exports in one selected canonical module. */ +export function findExportedCallableNames(source: string, file = "source.ts") { + const scriptKind = file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind); + const callableLocals = new Set(); + const exportedNames = new Set(); + + for (const statement of sourceFile.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name) { + callableLocals.add(statement.name.text); + if (hasExportModifier(statement)) { + exportedNames.add(statement.name.text); + } + continue; + } + if (!ts.isVariableStatement(statement)) { + continue; + } + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) { + continue; + } + const alias = unwrapDirectAliasInitializer(declaration.initializer); + if ( + !isCallableInitializer(declaration.initializer) && + (!alias || (!ts.isIdentifier(alias) && !ts.isPropertyAccessExpression(alias))) + ) { + continue; + } + callableLocals.add(declaration.name.text); + if (hasExportModifier(statement)) { + exportedNames.add(declaration.name.text); + } + } + } + + for (const statement of sourceFile.statements) { + if ( + !ts.isExportDeclaration(statement) || + statement.moduleSpecifier || + !statement.exportClause || + !ts.isNamedExports(statement.exportClause) + ) { + continue; + } + for (const element of statement.exportClause.elements) { + const localName = element.propertyName?.text ?? element.name.text; + if (!element.isTypeOnly && callableLocals.has(localName)) { + exportedNames.add(element.name.text); + } + } + } + return [...exportedNames].toSorted(); +} + +/** Requires every selected callable export to be enforced or explicitly deferred. */ +export function auditCanonicalCoercionExports( + exportsByFile: ReadonlyMap, + classifications: readonly CanonicalCoercionExportClassification[], +): CanonicalCoercionExportAudit { + const invalidClassifications: string[] = []; + const byKey = new Map(); + for (const classification of classifications) { + const key = `${classification.file}\0${classification.name}`; + if (byKey.has(key)) { + invalidClassifications.push( + `${classification.file} [${classification.name}] is classified more than once`, + ); + continue; + } + if (classification.status === "deferred" && !classification.reason?.trim()) { + invalidClassifications.push( + `${classification.file} [${classification.name}] needs a non-empty deferred reason`, + ); + } + byKey.set(key, classification); + } + const unclassifiedExports = [...exportsByFile].flatMap(([file, names]) => + names.flatMap((name) => (byKey.has(`${file}\0${name}`) ? [] : [{ file, name }])), + ); + const staleClassifications = classifications.filter( + ({ file, name }) => !(exportsByFile.get(file) ?? []).includes(name), + ); + return { invalidClassifications, staleClassifications, unclassifiedExports }; +} + /** Checks exact file/name/count carve-outs and rejects stale or excess entries. */ export function auditCoercionHelperDeclarations( declarations: readonly CoercionHelperDeclaration[], @@ -458,6 +635,28 @@ function writeLine(stream: ScriptIo["stdout"] | ScriptIo["stderr"], value: strin stream.write(`${value}\n`); } +function auditDefaultCanonicalExports(repoRoot: string): CanonicalCoercionExportAudit { + const canonicalModules = new Set(CANONICAL_COERCION_MODULES); + const exportsByFile = new Map( + CANONICAL_COERCION_MODULES.map((file) => { + const source = fs.readFileSync(path.join(repoRoot, file), "utf8"); + return [file, findExportedCallableNames(source, file)] as const; + }), + ); + const classifications: CanonicalCoercionExportClassification[] = [ + ...CANONICAL_COERCION_HELPER_OWNERS.filter(({ file }) => canonicalModules.has(file)).flatMap( + ({ file, names }) => names.map((name) => ({ file, name, status: "enforced" as const })), + ), + ...DEFERRED_CANONICAL_COERCION_EXPORTS.map(({ file, name, reason }) => ({ + file, + name, + reason, + status: "deferred" as const, + })), + ]; + return auditCanonicalCoercionExports(exportsByFile, classifications); +} + /** Runs the full tracked-source declaration guard. */ export function runCoercionHelperDeclarationGuard( options: { @@ -481,10 +680,17 @@ export function runCoercionHelperDeclarationGuard( return findBannedCoercionHelperDeclarations(fs.readFileSync(absolutePath, "utf8"), file); }); const audit = auditCoercionHelperDeclarations(declarations, carveOuts); + const exportAudit = + options.carveOuts === undefined + ? auditDefaultCanonicalExports(repoRoot) + : { invalidClassifications: [], staleClassifications: [], unclassifiedExports: [] }; const failed = audit.excessDeclarations.length > 0 || audit.invalidCarveOuts.length > 0 || - audit.staleCarveOuts.length > 0; + audit.staleCarveOuts.length > 0 || + exportAudit.invalidClassifications.length > 0 || + exportAudit.staleClassifications.length > 0 || + exportAudit.unclassifiedExports.length > 0; if (!failed) { writeLine( io.stdout, @@ -517,6 +723,24 @@ export function runCoercionHelperDeclarationGuard( ); } } + if (exportAudit.invalidClassifications.length > 0) { + writeLine(io.stderr, "Invalid canonical-export classifications:"); + for (const message of exportAudit.invalidClassifications) { + writeLine(io.stderr, `- ${message}`); + } + } + if (exportAudit.unclassifiedExports.length > 0) { + writeLine(io.stderr, "Unclassified canonical callable exports:"); + for (const entry of exportAudit.unclassifiedExports) { + writeLine(io.stderr, `- ${entry.file} [${entry.name}]`); + } + } + if (exportAudit.staleClassifications.length > 0) { + writeLine(io.stderr, "Stale canonical-export classifications:"); + for (const entry of exportAudit.staleClassifications) { + writeLine(io.stderr, `- ${entry.file} [${entry.name}] (${entry.status})`); + } + } writeLine( io.stderr, "Core/package/UI/workspace-script code: use the matching @openclaw/normalization-core coercion subpath.", diff --git a/scripts/check-openclaw-package-tarball.mts b/scripts/check-openclaw-package-tarball.mts index ebb35210014b..61444e2f7621 100644 --- a/scripts/check-openclaw-package-tarball.mts +++ b/scripts/check-openclaw-package-tarball.mts @@ -9,6 +9,7 @@ import path from "node:path"; import { performance } from "node:perf_hooks"; import { pathToFileURL } from "node:url"; import { gte as semverGte, valid as validSemver } from "semver"; +import { coerceErrorMessage } from "./lib/error-format.mts"; import { LOCAL_BUILD_METADATA_DIST_PATHS } from "./lib/local-build-metadata-paths.mts"; import { collectPackageDistImports, @@ -77,7 +78,7 @@ let cliArgs: ReturnType; try { cliArgs = parseArgs(process.argv.slice(2)); } catch (error) { - fail(error instanceof Error ? error.message : String(error)); + fail(coerceErrorMessage(error)); } if (cliArgs.help) { console.log(usage()); @@ -209,11 +210,7 @@ function collectBundledPackageRuntimeErrors({ try { bundledPackageJson = JSON.parse(readText(manifestPath)) as Record; } catch (error) { - errors.push( - `unreadable bundled ${name} package.json: ${ - error instanceof Error ? error.message : String(error) - }`, - ); + errors.push(`unreadable bundled ${name} package.json: ${coerceErrorMessage(error)}`); return errors; } if (bundledPackageJson.name !== name) { @@ -587,9 +584,7 @@ if (shouldValidateShrinkwrap) { ); } } catch (error) { - errors.push( - `unreadable npm-shrinkwrap.json: ${error instanceof Error ? error.message : String(error)}`, - ); + errors.push(`unreadable npm-shrinkwrap.json: ${coerceErrorMessage(error)}`); } } if (!entrySet.has(PACKAGE_INSTALL_GUARD_RELATIVE_PATH)) { @@ -682,11 +677,7 @@ if (entrySet.has("dist/postinstall-inventory.json")) { } } } catch (error) { - errors.push( - `unreadable dist/postinstall-inventory.json: ${ - error instanceof Error ? error.message : String(error) - }`, - ); + errors.push(`unreadable dist/postinstall-inventory.json: ${coerceErrorMessage(error)}`); } } diff --git a/scripts/close-duplicate-prs-after-merge.mjs b/scripts/close-duplicate-prs-after-merge.mjs index 18e35583161b..92928bfe1cbe 100644 --- a/scripts/close-duplicate-prs-after-merge.mjs +++ b/scripts/close-duplicate-prs-after-merge.mjs @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; import { isRecord } from "./lib/record-shared.mjs"; -function normalizeStringifiedOptionalString(value) { +function normalizeDuplicatePrListInput(value) { if ( typeof value !== "string" && typeof value !== "number" && @@ -29,7 +29,7 @@ each duplicate has either a shared referenced issue or overlapping changed hunks * Parses comma-separated PR numbers from CLI/env input. */ export function parsePrNumberList(value) { - const text = normalizeStringifiedOptionalString(value) ?? ""; + const text = normalizeDuplicatePrListInput(value) ?? ""; return [ ...new Set( text diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mts b/scripts/e2e/kitchen-sink-rpc-walk.mts index 26572e5cce85..18e1362a9ad6 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mts +++ b/scripts/e2e/kitchen-sink-rpc-walk.mts @@ -2274,9 +2274,9 @@ function parsePosixProcessRows(stdout: string) { ) { continue; } - const processId = parseStrictPositiveInteger(pidRaw); + const processId = parsePositivePosixProcessToken(pidRaw); const parentProcessId = parseStrictUnsignedInteger(ppidRaw); - const rssKb = parseStrictPositiveInteger(rssKbRaw); + const rssKb = parsePositivePosixProcessToken(rssKbRaw); const cpuPercent = parseStrictNonNegativeDecimal(cpuRaw); if ( !Number.isInteger(processId) || @@ -2320,7 +2320,7 @@ function parseStrictUnsignedInteger(raw: string | undefined) { return Number.isSafeInteger(parsed) ? parsed : null; } -function parseStrictPositiveInteger(raw: string | undefined) { +function parsePositivePosixProcessToken(raw: string | undefined) { const parsed = parseStrictUnsignedInteger(raw); return parsed && parsed > 0 ? parsed : null; } diff --git a/scripts/e2e/lib/openwebui/http-probe.mjs b/scripts/e2e/lib/openwebui/http-probe.mjs index 9c2fea2f5016..61750ee26c4e 100644 --- a/scripts/e2e/lib/openwebui/http-probe.mjs +++ b/scripts/e2e/lib/openwebui/http-probe.mjs @@ -11,7 +11,7 @@ function parseExpectedStatus(raw) { return Number(raw); } -function resolveTimerTimeoutMs(valueMs, fallbackMs) { +function resolveOpenWebUiHttpProbeTimeoutMs(valueMs, fallbackMs) { const value = Number.isFinite(valueMs) ? valueMs : fallbackMs; return Math.min(Math.max(Math.floor(value), 1), MAX_TIMER_TIMEOUT_MS); } @@ -27,7 +27,7 @@ export async function probeHttpStatus({ throw new Error("usage: http-probe.mjs [status|lt500]"); } const expectedStatus = expectedRaw === "lt500" ? undefined : parseExpectedStatus(expectedRaw); - const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 30_000); + const resolvedTimeoutMs = resolveOpenWebUiHttpProbeTimeoutMs(timeoutMs, 30_000); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), resolvedTimeoutMs); let res; diff --git a/scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs b/scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs index fea2dd49eb92..4ca29d71f21d 100644 --- a/scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs +++ b/scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs @@ -50,17 +50,17 @@ function readPositiveNumberEnv(name, fallback) { const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; -function clampTimerTimeoutMs(valueMs) { +function clampPluginLifecycleTimerMs(valueMs) { return Math.min(Math.max(Math.floor(valueMs), 1), MAX_TIMER_TIMEOUT_MS); } -const pollMs = clampTimerTimeoutMs( +const pollMs = clampPluginLifecycleTimerMs( readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_METRIC_POLL_MS", 100), ); -const timeoutMs = clampTimerTimeoutMs( +const timeoutMs = clampPluginLifecycleTimerMs( readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_PHASE_TIMEOUT_MS", 300000), ); -const timeoutKillGraceMs = clampTimerTimeoutMs( +const timeoutKillGraceMs = clampPluginLifecycleTimerMs( readPositiveIntEnv("OPENCLAW_PLUGIN_LIFECYCLE_TIMEOUT_KILL_GRACE_MS", 2000), ); const maxRssKbThreshold = readPositiveIntEnv( diff --git a/scripts/e2e/npm-telegram-live-runner.ts b/scripts/e2e/npm-telegram-live-runner.ts index 9b9aa84c4436..eb88f0b93c93 100644 --- a/scripts/e2e/npm-telegram-live-runner.ts +++ b/scripts/e2e/npm-telegram-live-runner.ts @@ -9,7 +9,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { QaProviderMode } from "../../extensions/qa-lab/src/run-config.ts"; import type { QaSuiteRoundTripProbe } from "../../extensions/qa-lab/src/suite-round-trip.ts"; -function parseBoolean(value: string | undefined) { +function isTruthyNpmTelegramEnvValue(value: string | undefined) { const normalized = value?.trim().toLowerCase(); return normalized === "1" || normalized === "true" || normalized === "yes"; } @@ -147,7 +147,7 @@ async function shouldFailPackageTelegramRun( result: { summaryPath: string }, env: NodeJS.ProcessEnv = process.env, ) { - if (parseBoolean(env.OPENCLAW_NPM_TELEGRAM_ALLOW_FAILURES)) { + if (isTruthyNpmTelegramEnvValue(env.OPENCLAW_NPM_TELEGRAM_ALLOW_FAILURES)) { return false; } const { readQaSuiteFailedOrSkippedScenarioCountFromFile } = @@ -224,7 +224,7 @@ async function main() { providerMode, primaryModel, alternateModel: process.env.OPENCLAW_NPM_TELEGRAM_ALT_MODEL, - fastMode: parseBoolean(process.env.OPENCLAW_NPM_TELEGRAM_FAST), + fastMode: isTruthyNpmTelegramEnvValue(process.env.OPENCLAW_NPM_TELEGRAM_FAST), scenarioIds, resolvedScenarioIds: prioritizeRoundTripProbeScenario(resolvedScenarioIds, rttOptions), roundTripProbe: createRoundTripProbe(rttOptions), diff --git a/scripts/e2e/openwebui-probe.mjs b/scripts/e2e/openwebui-probe.mjs index 086d04301324..26d4b6c34c67 100644 --- a/scripts/e2e/openwebui-probe.mjs +++ b/scripts/e2e/openwebui-probe.mjs @@ -70,18 +70,18 @@ function readNonNegativeInt(name, fallback) { return parsed; } -function clampTimerTimeoutMs(valueMs, minMs = 1) { +function clampOpenWebUiTimerTimeoutMs(valueMs, minMs = 1) { const min = Math.max(0, Math.floor(minMs)); const value = Number.isFinite(valueMs) ? valueMs : min; return Math.min(Math.max(Math.floor(value), min), MAX_TIMER_TIMEOUT_MS); } function readPositiveTimerMs(name, fallback) { - return clampTimerTimeoutMs(readPositiveInt(name, fallback)); + return clampOpenWebUiTimerTimeoutMs(readPositiveInt(name, fallback)); } function readNonNegativeTimerMs(name, fallback) { - return clampTimerTimeoutMs(readNonNegativeInt(name, fallback), 0); + return clampOpenWebUiTimerTimeoutMs(readNonNegativeInt(name, fallback), 0); } function createTimeoutError(label, timeoutMs) { @@ -91,7 +91,7 @@ function createTimeoutError(label, timeoutMs) { } async function withRequestTimeout(label, timeoutMs, run) { - const resolvedTimeoutMs = clampTimerTimeoutMs(timeoutMs); + const resolvedTimeoutMs = clampOpenWebUiTimerTimeoutMs(timeoutMs); const controller = new AbortController(); const timeoutError = createTimeoutError(label, resolvedTimeoutMs); let timer; @@ -156,7 +156,7 @@ function buildAuthHeaders(token, cookie) { function sleep(ms) { return new Promise((resolve) => { - setTimeout(resolve, clampTimerTimeoutMs(ms, 0)); + setTimeout(resolve, clampOpenWebUiTimerTimeoutMs(ms, 0)); }); } diff --git a/scripts/e2e/telegram-user-crabbox-proof.ts b/scripts/e2e/telegram-user-crabbox-proof.ts index f305940bd197..19fcb3d33c6f 100644 --- a/scripts/e2e/telegram-user-crabbox-proof.ts +++ b/scripts/e2e/telegram-user-crabbox-proof.ts @@ -14,6 +14,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { clampTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { parseStrictBooleanArg } from "../lib/arg-utils.mts"; +import { coerceErrorMessage } from "../lib/error-format.mts"; import { sleep } from "../lib/sleep.mjs"; import { resolveWindowsTaskkillPath } from "../lib/windows-taskkill.mjs"; import { createPnpmRunnerSpawnSpec } from "../pnpm-runner.mts"; @@ -303,16 +305,6 @@ function parseTcpPort(value: string, label: string) { return parsed; } -function parseBoolean(value: string, label: string) { - if (value === "true") { - return true; - } - if (value === "false") { - return false; - } - throw new Error(`${label} must be true or false.`); -} - function createTelegramProofRunId() { return `${new Date().toISOString().replace(/[:.]/gu, "-")}-${randomUUID().slice(0, 8)}`; } @@ -417,7 +409,7 @@ export function parseArgs(argvInput: string[]): Options { } else if (arg === "--keep-box") { opts.keepBox = true; } else if (arg === "--link-preview") { - opts.linkPreview = parseBoolean(readValue(), "--link-preview"); + opts.linkPreview = parseStrictBooleanArg(readValue(), "--link-preview"); } else if (arg === "--mock-port") { opts.mockPort = parseTcpPort(readValue(), "--mock-port"); } else if (arg === "--mock-response-file") { @@ -1656,9 +1648,7 @@ function destroyLocalSutRuntime(sut: { containerName?: string; tempRoot?: string } function cleanupFailureMessage(message: string, cleanupErrors: unknown[]) { - const details = cleanupErrors.map((error) => - error instanceof Error ? error.message : String(error), - ); + const details = cleanupErrors.map(coerceErrorMessage); return [message, ...details.map((detail) => `Cleanup failure: ${detail}`)].join("\n"); } diff --git a/scripts/ensure-playwright-chromium.mts b/scripts/ensure-playwright-chromium.mts index cd82e9384921..e95b107f059b 100644 --- a/scripts/ensure-playwright-chromium.mts +++ b/scripts/ensure-playwright-chromium.mts @@ -5,6 +5,7 @@ import { existsSync as existsSyncImpl, realpathSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; +import { parsePermissiveBooleanToken } from "./lib/arg-utils.mts"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { resolvePnpmRunner, type PnpmRunnerParams } from "./pnpm-runner.mts"; @@ -91,11 +92,6 @@ export function resolvePlaywrightInstallRunner(options: PlaywrightRunnerOptions }); } -function isTruthyEnvFlag(value: unknown) { - const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; - return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; -} - /** * Reports whether Linux system dependencies should be installed with Chromium. */ @@ -112,9 +108,9 @@ export function shouldInstallPlaywrightSystemDependencies( return true; } return ( - isTruthyEnvFlag(env.CI) || - isTruthyEnvFlag(env.GITHUB_ACTIONS) || - isTruthyEnvFlag(env.OPENCLAW_TESTBOX) + parsePermissiveBooleanToken(env.CI) === true || + parsePermissiveBooleanToken(env.GITHUB_ACTIONS) === true || + parsePermissiveBooleanToken(env.OPENCLAW_TESTBOX) === true ); } diff --git a/scripts/generate-bundled-channel-config-metadata.ts b/scripts/generate-bundled-channel-config-metadata.ts index 6e962bfb94d7..9adcffd06ae6 100644 --- a/scripts/generate-bundled-channel-config-metadata.ts +++ b/scripts/generate-bundled-channel-config-metadata.ts @@ -2,6 +2,7 @@ // Generate Bundled Channel Config Metadata script supports OpenClaw repository automation. import fs from "node:fs"; import path from "node:path"; +import { asFiniteNumber } from "../packages/normalization-core/src/number-coercion.ts"; import { loadBundledPluginPublicArtifactModuleSync } from "../src/plugins/public-surface-loader.js"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; import { loadChannelConfigSurfaceModule } from "./load-channel-config-surface.ts"; @@ -157,7 +158,7 @@ function resolveRootAliases(source: BundledPluginSource, channelId: string): str function resolveRootOrder(source: BundledPluginSource, channelId: string): number | undefined { const channelMeta = resolvePackageChannelMeta(source); const order = channelMeta?.id === channelId ? channelMeta.order : undefined; - return typeof order === "number" && Number.isFinite(order) ? order : undefined; + return asFiniteNumber(order); } function resolveRootConfigurable(source: BundledPluginSource, channelId: string): boolean { diff --git a/scripts/lib/arg-utils.runtime.d.mts b/scripts/lib/arg-utils.runtime.d.mts new file mode 100644 index 000000000000..9b2390430fe0 --- /dev/null +++ b/scripts/lib/arg-utils.runtime.d.mts @@ -0,0 +1,69 @@ +type StringOptions = { + allowEmpty?: boolean; + allowInline?: boolean; + missingValueMessage?: string; + rejectShortOptions?: boolean; + repeatable?: boolean; + transform?: (value: string) => unknown; +}; + +type ConsumedFlag> = { + flag?: string; + nextIndex: number; + repeatable?: boolean; + apply(target: T): void; +}; + +type FlagSpec> = { + consume(argv: readonly string[], index: number, args: T): ConsumedFlag | null; +}; + +type ParseOptions> = { + allowUnknownOptions?: boolean; + duplicateOptionMessage?: (flag: string) => string; + ignoreDoubleDash?: boolean; + onUnhandledArg?: (arg: string, args: T) => "handled" | void; +}; + +export type BoundedUnsignedDecimalResult = + | { kind: "syntax" } + | { kind: "below" } + | { kind: "above" } + | { kind: "value"; value: number }; + +export function readFlagValue(args: readonly string[], name: string): string | undefined; +export function stripLeadingPackageManagerSeparator(argv: string[]): string[]; +export function parseStrictBooleanArg(value: unknown, label: string): boolean; +export function classifyBoundedUnsignedDecimal( + value: unknown, + min: number, + max: number, +): BoundedUnsignedDecimalResult; +export function parsePermissiveBooleanToken(value: unknown): boolean | undefined; +export function stringFlag>( + flag: string, + key: string, + options?: StringOptions, +): FlagSpec; +export function stringListFlag>( + flag: string, + key: string, + options?: Omit, +): FlagSpec; +export function intFlag>( + flag: string, + key: string, + options?: { min?: number }, +): FlagSpec; +export function booleanFlag>( + flag: string, + key: string, + value?: unknown, + options?: { repeatable?: boolean }, +): FlagSpec; +export function parseFlagArgs>( + argv: readonly string[], + args: T, + specs: readonly FlagSpec[], + options?: ParseOptions, +): T; diff --git a/scripts/lib/arg-utils.runtime.mjs b/scripts/lib/arg-utils.runtime.mjs index c09fa93ef04b..c0c00b55c0d9 100644 --- a/scripts/lib/arg-utils.runtime.mjs +++ b/scripts/lib/arg-utils.runtime.mjs @@ -47,6 +47,9 @@ * onUnhandledArg?: (arg: string, args: T) => "handled" | void, * }} ParseOptions */ +/** + * @typedef {{ kind: "syntax" } | { kind: "below" } | { kind: "above" } | { kind: "value", value: number }} BoundedUnsignedDecimalResult + */ /** @param {string} message */ function failFlagParse(message) { throw new Error(message); @@ -170,6 +173,57 @@ function readFlagOptionValue(argv, index, flag) { } return { nextIndex: index + 1, value }; } +/** + * Parse the exact lowercase Boolean language used by strict script arguments. + * @param {unknown} value + * @param {string} label + */ +export function parseStrictBooleanArg(value, label) { + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + throw new Error(`${label} must be true or false.`); +} +/** + * Classify an ASCII unsigned-decimal token against inclusive bounds. + * @param {unknown} value + * @param {number} min + * @param {number} max + * @returns {BoundedUnsignedDecimalResult} + */ +export function classifyBoundedUnsignedDecimal(value, min, max) { + if (typeof value !== "string" || !/^\d+$/u.test(value)) { + return { kind: "syntax" }; + } + const parsed = Number(value); + if (parsed < min) { + return { kind: "below" }; + } + if (parsed > max) { + return { kind: "above" }; + } + return { kind: "value", value: parsed }; +} +const PERMISSIVE_BOOLEAN_TRUE_TOKENS = new Set(["1", "on", "true", "yes"]); +const PERMISSIVE_BOOLEAN_FALSE_TOKENS = new Set(["0", "false", "no", "off"]); +/** + * Parse the normalized Boolean token language shared by repository scripts. + * @param {unknown} value + * @returns {boolean | undefined} + */ +export function parsePermissiveBooleanToken(value) { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (!normalized) { + return undefined; + } + if (PERMISSIVE_BOOLEAN_TRUE_TOKENS.has(normalized)) { + return true; + } + return PERMISSIVE_BOOLEAN_FALSE_TOKENS.has(normalized) ? false : undefined; +} /** * @param {string} raw * @param {string} flag diff --git a/scripts/lib/cross-os-release-checks/config.ts b/scripts/lib/cross-os-release-checks/config.ts index 062a5397ddc1..c66da3ba4579 100644 --- a/scripts/lib/cross-os-release-checks/config.ts +++ b/scripts/lib/cross-os-release-checks/config.ts @@ -1,5 +1,6 @@ import type { ChildProcess } from "node:child_process"; import { basename, dirname, resolve, win32 as pathWin32 } from "node:path"; +import { parsePermissiveBooleanToken } from "../arg-utils.mts"; import { trimForSummary } from "./shared.ts"; type CrossOsSuite = "packaged-fresh" | "installer-fresh" | "packaged-upgrade" | "dev-update"; @@ -314,11 +315,9 @@ function parseBooleanEnv(name: string, fallback: boolean, env = process.env): bo if (!raw) { return fallback; } - if (/^(1|true|yes|on)$/iu.test(raw)) { - return true; - } - if (/^(0|false|no|off)$/iu.test(raw)) { - return false; + const parsed = parsePermissiveBooleanToken(raw); + if (parsed !== undefined) { + return parsed; } throw new Error(`${name} must be a boolean. Got: ${JSON.stringify(raw)}`); } diff --git a/scripts/lib/cross-os-release-checks/process.ts b/scripts/lib/cross-os-release-checks/process.ts index 199fee85f3fb..ac3e0ba065e0 100644 --- a/scripts/lib/cross-os-release-checks/process.ts +++ b/scripts/lib/cross-os-release-checks/process.ts @@ -15,6 +15,7 @@ import { import { dirname } from "node:path"; import { StringDecoder } from "node:string_decoder"; import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../../windows-cmd-helpers.mjs"; +import { toStringifiedError } from "../error-format.mts"; import { resolveWindowsTaskkillPath } from "../windows-taskkill.mjs"; import type { Cleanup, @@ -559,8 +560,7 @@ export async function startStaticFileServer(params: { server.close((error) => { void (async () => { const closeLogError = await finishStaticFileServerLog(logStream, logStreamError).catch( - (logError: unknown): Error => - logError instanceof Error ? logError : new Error(String(logError)), + (logError: unknown): Error => toStringifiedError(logError), ); if (error) { rejectPromise(error); diff --git a/scripts/lib/deprecated-plugin-sdk-usage.mts b/scripts/lib/deprecated-plugin-sdk-usage.mts index cb4b0f2b3c0a..706f09f51949 100644 --- a/scripts/lib/deprecated-plugin-sdk-usage.mts +++ b/scripts/lib/deprecated-plugin-sdk-usage.mts @@ -51,4 +51,8 @@ export const BANNED_INTERNAL_PLUGIN_SDK_FACADE_MODULES: BannedInternalPluginSdkF modulePath: "src/plugin-sdk/inbound-envelope", canonical: "openclaw/plugin-sdk/channel-inbound", }, + { + modulePath: "src/plugin-sdk/text-runtime", + canonical: "the focused typed public Plugin SDK subpath for the imported helper", + }, ]; diff --git a/scripts/lib/dev-tooling-safety.ts b/scripts/lib/dev-tooling-safety.ts index 610124aae7ac..163a30efa7c3 100644 --- a/scripts/lib/dev-tooling-safety.ts +++ b/scripts/lib/dev-tooling-safety.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { redactSensitiveText } from "../../src/logging/redact.js"; +import { parsePermissiveBooleanToken } from "./arg-utils.mts"; export { parseStrictIntegerOption } from "./strict-integer-option.ts"; @@ -50,15 +51,13 @@ export function parseBooleanEnv(params: { name: string; raw: string | undefined; }): boolean { - const raw = params.raw?.trim().toLowerCase(); + const raw = params.raw?.trim(); if (!raw) { return params.fallback; } - if (["1", "true", "yes", "on"].includes(raw)) { - return true; - } - if (["0", "false", "no", "off"].includes(raw)) { - return false; + const parsed = parsePermissiveBooleanToken(raw); + if (parsed !== undefined) { + return parsed; } throw new Error( `${params.name} must be one of 1,0,true,false,yes,no,on,off; got ${JSON.stringify(params.raw)}`, diff --git a/scripts/lib/error-format.mts b/scripts/lib/error-format.mts index ca608ce13e20..e28ac6ddded7 100644 --- a/scripts/lib/error-format.mts +++ b/scripts/lib/error-format.mts @@ -12,6 +12,11 @@ export function coerceErrorMessage(value: unknown): string { return value instanceof Error ? value.message : String(value); } +/** Preserve Error values and stringify every other value without workspace dependencies. */ +export function toStringifiedError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + /** Preserve structured non-Error failures without requiring built workspace packages. */ export function toErrorObject(value: unknown, fallbackMessage: string): Error { if (value instanceof Error) { diff --git a/scripts/lib/vitest-local-scheduling.mts b/scripts/lib/vitest-local-scheduling.mts index 89bbf20db486..1d9ac80911cd 100644 --- a/scripts/lib/vitest-local-scheduling.mts +++ b/scripts/lib/vitest-local-scheduling.mts @@ -11,9 +11,9 @@ export type LocalVitestScheduling = { }; import os from "node:os"; +import { parsePermissiveBooleanToken } from "./arg-utils.mts"; const MAX_LOCAL_FULL_SUITE_PARALLELISM = 10; -const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on"]); const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); @@ -37,13 +37,12 @@ function isSystemThrottleDisabled(env: Record) { return normalized === "1" || normalized === "true"; } -function isTruthyEnvValue(value: string | undefined) { - return TRUTHY_ENV_VALUES.has(value?.trim().toLowerCase() ?? ""); -} - /** @internal Shared repository-script contract. */ export function isCiLikeEnv(env: Record = process.env) { - return isTruthyEnvValue(env.CI) || isTruthyEnvValue(env.GITHUB_ACTIONS); + return ( + parsePermissiveBooleanToken(env.CI) === true || + parsePermissiveBooleanToken(env.GITHUB_ACTIONS) === true + ); } /** @internal Shared repository-script contract. */ diff --git a/scripts/openclaw-performance-source-summary.mts b/scripts/openclaw-performance-source-summary.mts index 93092eceada9..8ed300a99fb3 100644 --- a/scripts/openclaw-performance-source-summary.mts +++ b/scripts/openclaw-performance-source-summary.mts @@ -30,7 +30,7 @@ function parseJson(source: string): JsonValue { } function isJsonObject(value: JsonValue | undefined): value is JsonObject { - return value !== null && typeof value === "object" && !Array.isArray(value); + return isRecord(value); } function valueAt(value: JsonValue | undefined, ...keys: string[]): JsonValue | undefined { diff --git a/scripts/openclaw-release-clawhub-runtime-state.ts b/scripts/openclaw-release-clawhub-runtime-state.ts index 8721d26b9dbd..cdf5d61c4012 100755 --- a/scripts/openclaw-release-clawhub-runtime-state.ts +++ b/scripts/openclaw-release-clawhub-runtime-state.ts @@ -1,16 +1,7 @@ #!/usr/bin/env -S node --import tsx +import { parseStrictBooleanArg } from "./lib/arg-utils.mts"; import { buildOpenClawReleaseClawHubRuntimeState } from "./lib/openclaw-release-clawhub-plan.ts"; -function parseBoolean(value: string, label: string): boolean { - if (value === "true") { - return true; - } - if (value === "false") { - return false; - } - throw new Error(`${label} must be true or false.`); -} - function parseArgs(argv: string[]) { const values = [...argv]; if (values[0] === "--") { @@ -40,10 +31,10 @@ function parseArgs(argv: string[]) { repository = next(); break; case "--wait-for-clawhub": - waitForClawHub = parseBoolean(next(), "--wait-for-clawhub"); + waitForClawHub = parseStrictBooleanArg(next(), "--wait-for-clawhub"); break; case "--force-skip-clawhub": - forceSkipClawHub = parseBoolean(next(), "--force-skip-clawhub"); + forceSkipClawHub = parseStrictBooleanArg(next(), "--force-skip-clawhub"); break; case "--normal-run-id": normalRunId = next(); @@ -52,7 +43,7 @@ function parseArgs(argv: string[]) { bootstrapRunId = next(); break; case "--bootstrap-completed": - bootstrapCompleted = parseBoolean(next(), "--bootstrap-completed"); + bootstrapCompleted = parseStrictBooleanArg(next(), "--bootstrap-completed"); break; default: throw new Error(`Unknown argument: ${arg}`); diff --git a/scripts/package-openclaw-for-docker.mts b/scripts/package-openclaw-for-docker.mts index 3800f112820e..51dc9b1151e0 100644 --- a/scripts/package-openclaw-for-docker.mts +++ b/scripts/package-openclaw-for-docker.mts @@ -166,7 +166,10 @@ function numericTimerValueMs(valueMs: unknown) { return Number.isFinite(value) ? Math.floor(value) : undefined; } -function resolveTimerTimeoutMs(valueMs: unknown, fallbackMs: unknown = MAX_TIMER_TIMEOUT_MS) { +function resolvePackageBuildTimeoutMs( + valueMs: unknown, + fallbackMs: unknown = MAX_TIMER_TIMEOUT_MS, +) { const value = numericTimerValueMs(valueMs) ?? numericTimerValueMs(fallbackMs); return Math.min(Math.max(value ?? MAX_TIMER_TIMEOUT_MS, 1), MAX_TIMER_TIMEOUT_MS); } @@ -175,7 +178,7 @@ function resolveOptionalTimerTimeoutMs(valueMs: unknown) { if (valueMs === undefined) { return undefined; } - return resolveTimerTimeoutMs(valueMs, 1); + return resolvePackageBuildTimeoutMs(valueMs, 1); } function readOptionValue(argv: string[], index: number, optionName: string) { @@ -304,7 +307,7 @@ export function parseArgs(argv: string[]) { function run(command: string, args: string[], cwd: string, options: RunOptions = {}) { return new Promise((resolve, reject) => { const resolvedTimeoutMs = resolveOptionalTimerTimeoutMs(options.timeoutMs); - const resolvedKillAfterMs = resolveTimerTimeoutMs( + const resolvedKillAfterMs = resolvePackageBuildTimeoutMs( options.killAfterMs, DEFAULT_TIMEOUT_KILL_AFTER_MS, ); diff --git a/scripts/pre-commit/pnpm-audit-prod.mjs b/scripts/pre-commit/pnpm-audit-prod.mjs index b830daa17439..dae6b0ea53b9 100644 --- a/scripts/pre-commit/pnpm-audit-prod.mjs +++ b/scripts/pre-commit/pnpm-audit-prod.mjs @@ -708,7 +708,7 @@ function parsePositiveIntegerEnv(name, fallback) { } function resolveBulkAdvisoryRequestTimeoutMs() { - return clampTimerTimeoutMs( + return clampBulkAdvisoryTimeoutMs( parsePositiveIntegerEnv( "OPENCLAW_PNPM_AUDIT_BULK_TIMEOUT_MS", BULK_ADVISORY_REQUEST_TIMEOUT_MS, @@ -723,13 +723,13 @@ function resolveBulkAdvisoryResponseBodyMaxBytes() { ); } -function clampTimerTimeoutMs(valueMs) { +function clampBulkAdvisoryTimeoutMs(valueMs) { const value = Number.isFinite(valueMs) ? valueMs : BULK_ADVISORY_REQUEST_TIMEOUT_MS; return Math.min(Math.max(Math.floor(value), 1), MAX_TIMER_TIMEOUT_MS); } async function withBulkAdvisoryTimeout({ label, timeoutMs, run }) { - const resolvedTimeoutMs = clampTimerTimeoutMs(timeoutMs); + const resolvedTimeoutMs = clampBulkAdvisoryTimeoutMs(timeoutMs); const controller = new AbortController(); let timeout; const timeoutPromise = new Promise((_resolve, reject) => { diff --git a/scripts/resolve-openclaw-package-candidate.mts b/scripts/resolve-openclaw-package-candidate.mts index 1ac641b3ccac..7d4389e4c70c 100644 --- a/scripts/resolve-openclaw-package-candidate.mts +++ b/scripts/resolve-openclaw-package-candidate.mts @@ -325,7 +325,10 @@ function numericTimerValueMs(valueMs: unknown) { return Number.isFinite(value) ? Math.floor(value) : undefined; } -function resolveTimerTimeoutMs(valueMs: unknown, fallbackMs: unknown = MAX_TIMER_TIMEOUT_MS) { +function resolvePackageCandidateTimeoutMs( + valueMs: unknown, + fallbackMs: unknown = MAX_TIMER_TIMEOUT_MS, +) { const value = numericTimerValueMs(valueMs) ?? numericTimerValueMs(fallbackMs); return Math.min(Math.max(value ?? MAX_TIMER_TIMEOUT_MS, 1), MAX_TIMER_TIMEOUT_MS); } @@ -334,13 +337,13 @@ function resolveOptionalTimerTimeoutMs(valueMs: unknown) { if (valueMs === undefined) { return undefined; } - return resolveTimerTimeoutMs(valueMs, 1); + return resolvePackageCandidateTimeoutMs(valueMs, 1); } function run(command: string, args: readonly string[], options: RunOptions = {}) { return new Promise((resolve, reject) => { const resolvedTimeoutMs = resolveOptionalTimerTimeoutMs(options.timeoutMs); - const resolvedKillAfterMs = resolveTimerTimeoutMs( + const resolvedKillAfterMs = resolvePackageCandidateTimeoutMs( options.killAfterMs, COMMAND_TIMEOUT_KILL_AFTER_MS, ); @@ -1505,7 +1508,10 @@ async function openHttpsPackageDownloadResponse( async function openPackageDownloadResponse(url: string, options: PackageDownloadOptions) { const lookupHost = options.lookupHost ?? defaultLookupHost; - const timeoutMs = resolveTimerTimeoutMs(options.timeoutMs, PACKAGE_URL_DOWNLOAD_TIMEOUT_MS); + const timeoutMs = resolvePackageCandidateTimeoutMs( + options.timeoutMs, + PACKAGE_URL_DOWNLOAD_TIMEOUT_MS, + ); const maxRedirects = options.maxRedirects ?? PACKAGE_URL_MAX_REDIRECTS; const trustedSource = options.trustedSource; let parsed = new URL(url); diff --git a/scripts/run-vitest.mts b/scripts/run-vitest.mts index 2e7e32c3ccfa..4fffeff8a8ee 100644 --- a/scripts/run-vitest.mts +++ b/scripts/run-vitest.mts @@ -10,6 +10,7 @@ import { embeddedAgentVitestProjectOwners } from "../test/vitest/vitest.agents-p import { toolingIsolatedTestFiles } from "../test/vitest/vitest.tooling-isolated-paths.mjs"; import { isUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs"; import { boundaryTestFiles } from "../test/vitest/vitest.unit-paths.mjs"; +import { parsePermissiveBooleanToken } from "./lib/arg-utils.mts"; import { runWithFailedTrailer, writeFailedTrailer } from "./lib/failed-trailer.mts"; import { createGatewayServerTestTargetChunks } from "./lib/gateway-server-test-plan.mts"; import { signalExitCode } from "./lib/managed-child-process.mts"; @@ -35,7 +36,6 @@ type WatchdogStream = { off(event: string, listener: (...args: unknown[]) => void): unknown; }; -const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on"]); const ANSI_CSI_PREFIX = `${String.fromCharCode(27)}[`; const ANSI_CSI_SUFFIX_RE = /^[0-?]*[ -/]*[@-~]/u; const SUPPRESSED_VITEST_STDERR_PATTERNS = ["[PLUGIN_TIMINGS]"]; @@ -154,10 +154,6 @@ const UNBOUNDED_CONFIG_ONLY_OPTIONS = [ const require = createRequire(import.meta.url); const repoRoot = resolveRepoRoot(import.meta.url); -function isTruthyEnvValue(value: string | undefined): boolean { - return TRUTHY_ENV_VALUES.has(value?.trim().toLowerCase() ?? ""); -} - function parsePositiveInt(value: string | undefined): number | null { const text = value?.trim(); if (!text || !/^\d+$/u.test(text)) { @@ -171,7 +167,7 @@ function parsePositiveInt(value: string | undefined): number | null { * Resolves default Node flags for Vitest, including the local Maglev opt-in. */ export function resolveVitestNodeArgs(env: NodeJS.ProcessEnv = process.env): string[] { - if (isTruthyEnvValue(env.OPENCLAW_VITEST_ENABLE_MAGLEV)) { + if (parsePermissiveBooleanToken(env.OPENCLAW_VITEST_ENABLE_MAGLEV) === true) { return []; } @@ -484,7 +480,7 @@ export function resolveRunVitestSpawnEnv( if (explicitMode === "watch") { return baseEnv; } - if (explicitMode !== "run" && !isTruthyEnvValue(baseEnv.CI)) { + if (explicitMode !== "run" && parsePermissiveBooleanToken(baseEnv.CI) !== true) { return baseEnv; } const defaultTimeoutMs = resolveDefaultVitestNoOutputTimeoutMs(argv); @@ -587,7 +583,7 @@ export function resolveBoundedVitestInvocations( if ( !matchesVitestConfigPath(normalizedConfig, GATEWAY_SERVER_VITEST_CONFIG) || mode === "watch" || - (mode !== "run" && !isTruthyEnvValue(env.CI)) || + (mode !== "run" && parsePermissiveBooleanToken(env.CI) !== true) || hasNonRunVitestSubcommand(argv) || hasAlternateVitestRootArg(argv) || collectExplicitProjectRouterTargetArgs(argv, cwd).length > 0 || diff --git a/scripts/test-docker-all.mts b/scripts/test-docker-all.mts index 67af4b1a9b93..c22cc0efd6ca 100644 --- a/scripts/test-docker-all.mts +++ b/scripts/test-docker-all.mts @@ -255,7 +255,10 @@ function numericTimerValueMs(valueMs: unknown) { return Number.isFinite(value) ? Math.floor(value) : undefined; } -function resolveTimerTimeoutMs(valueMs: unknown, fallbackMs: unknown = MAX_TIMER_TIMEOUT_MS) { +function resolveDockerSchedulerTimeoutMs( + valueMs: unknown, + fallbackMs: unknown = MAX_TIMER_TIMEOUT_MS, +) { const value = numericTimerValueMs(valueMs) ?? numericTimerValueMs(fallbackMs); return Math.min(Math.max(value ?? MAX_TIMER_TIMEOUT_MS, 1), MAX_TIMER_TIMEOUT_MS); } @@ -265,7 +268,7 @@ function resolveOptionalTimerTimeoutMs(valueMs: unknown) { if (value === undefined || value <= 0) { return undefined; } - return resolveTimerTimeoutMs(value); + return resolveDockerSchedulerTimeoutMs(value); } function resourceLimitsSummary(resourceLimits: Record) { @@ -819,7 +822,7 @@ export function runShellCommand({ return new Promise((resolve) => { const resolvedTimeoutMs = resolveOptionalTimerTimeoutMs(timeoutMs); const resolvedNoOutputTimeoutMs = resolveOptionalTimerTimeoutMs(noOutputTimeoutMs); - const resolvedTimeoutKillGraceMs = resolveTimerTimeoutMs( + const resolvedTimeoutKillGraceMs = resolveDockerSchedulerTimeoutMs( timeoutKillGraceMs, SHELL_TIMEOUT_KILL_GRACE_MS, ); @@ -951,7 +954,7 @@ export function runShellCaptureCommand({ } return new Promise((resolve) => { const resolvedTimeoutMs = resolveOptionalTimerTimeoutMs(timeoutMs); - const resolvedTimeoutKillGraceMs = resolveTimerTimeoutMs( + const resolvedTimeoutKillGraceMs = resolveDockerSchedulerTimeoutMs( timeoutKillGraceMs, SHELL_TIMEOUT_KILL_GRACE_MS, ); diff --git a/scripts/test-live-shard.mts b/scripts/test-live-shard.mts index a58896732862..acc137b99ffb 100644 --- a/scripts/test-live-shard.mts +++ b/scripts/test-live-shard.mts @@ -4,7 +4,9 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { asSafeIntegerInRange } from "../packages/normalization-core/src/number-coercion.ts"; import { isRecord as isUnknownRecord } from "../packages/normalization-core/src/record-coerce.ts"; +import { parsePermissiveBooleanToken } from "./lib/arg-utils.mts"; import { spawnPnpmRunner, type PnpmRunnerParams } from "./pnpm-runner.mts"; import { createVitestProcessCompletion, @@ -444,18 +446,6 @@ function collectReportedLiveTestFiles(payload: unknown, repoRoot = process.cwd() ); } -function readOptionalNonNegativeInt(value: unknown) { - return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; -} - -function isTruthyEnvValue(value: string | undefined) { - if (typeof value !== "string") { - return false; - } - const normalized = value.trim().toLowerCase(); - return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; -} - function isDisabledOptInAssertion(assertion: Record) { if (assertion.status !== "passed") { return false; @@ -496,8 +486,8 @@ function buildFilePassEvidence(result: Record) { return evidence; } evidence.passed = - readOptionalNonNegativeInt(result.numPassingTests) ?? - readOptionalNonNegativeInt(result.numPassedTests) ?? + asSafeIntegerInRange(result.numPassingTests, { min: 0 }) ?? + asSafeIntegerInRange(result.numPassedTests, { min: 0 }) ?? 0; return evidence; } @@ -538,7 +528,10 @@ function isDisabledOptionalLiveShardFile( env: NodeJS.ProcessEnv = process.env, ) { const requiredEnvNames = OPTIONAL_LIVE_SHARD_FILE_ENVS.get(file); - if (!requiredEnvNames || requiredEnvNames.some((name) => isTruthyEnvValue(env[name]))) { + if ( + !requiredEnvNames || + requiredEnvNames.some((name) => parsePermissiveBooleanToken(env[name]) === true) + ) { return false; } const statuses = evidence?.statuses ?? []; diff --git a/scripts/test-projects.test-support.mts b/scripts/test-projects.test-support.mts index 756cc53195db..3331ec29516b 100644 --- a/scripts/test-projects.test-support.mts +++ b/scripts/test-projects.test-support.mts @@ -84,6 +84,7 @@ import { detectChangedLanes, listChangedPathsFromGit as listChangedPathsFromGitSource, } from "./changed-lanes.mts"; +import { parsePermissiveBooleanToken } from "./lib/arg-utils.mts"; import { getChangedPathFacts } from "./lib/changed-path-facts.mjs"; import { createExtensionTestProcessTargetChunks } from "./lib/extension-test-plan.mts"; import { @@ -2901,8 +2902,7 @@ function resolveToolingTestTargets(changedPath: string, cwd = process.cwd()) { } function shouldUseBroadChangedTargets(env = process.env) { - const value = env[BROAD_CHANGED_ENV_KEY]?.trim().toLowerCase(); - return ["1", "true", "yes", "on"].includes(value ?? ""); + return parsePermissiveBooleanToken(env[BROAD_CHANGED_ENV_KEY]) === true; } function isRoutableChangedTarget(changedPath: string) { diff --git a/scripts/verify-pr-hosted-gates.mts b/scripts/verify-pr-hosted-gates.mts index f66bf77b574a..84320c717776 100644 --- a/scripts/verify-pr-hosted-gates.mts +++ b/scripts/verify-pr-hosted-gates.mts @@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { isRecord, readStringField } from "@openclaw/normalization-core/record-coerce"; import { minimatch } from "minimatch"; import { parse } from "yaml"; @@ -288,8 +289,7 @@ function latestRun(runs: WorkflowRun[]) { } function runUpdatedAtMs(run: Pick | undefined) { - const value = Date.parse(run?.updated_at ?? ""); - return Number.isFinite(value) ? value : null; + return parseDateStringTimestampMs(run?.updated_at) ?? null; } function isRecentRun(run: WorkflowRun | undefined, nowMs: number) { diff --git a/security/opengrep/check-rule-metadata.mjs b/security/opengrep/check-rule-metadata.mjs index d7df9fb6863c..8af45b425a52 100644 --- a/security/opengrep/check-rule-metadata.mjs +++ b/security/opengrep/check-rule-metadata.mjs @@ -30,7 +30,7 @@ export async function readRules(rulepackPath) { return data.rules; } -function hasNonEmptyString(value) { +function hasRuleMetadataText(value) { return typeof value === "string" && value.trim().length > 0; } @@ -68,7 +68,7 @@ export function validateRuleMetadata(rules) { const advisoryId = String(metadata["advisory-id"] ?? metadata.ghsa ?? "") .trim() .toUpperCase(); - if (!hasNonEmptyString(advisoryId)) { + if (!hasRuleMetadataText(advisoryId)) { violations.push(`${label}: missing metadata.advisory-id or metadata.ghsa`); } else if (idMatch && idMatch[1] !== sanitizeSourceIdComponent(advisoryId)) { violations.push( @@ -88,7 +88,7 @@ export function validateRuleMetadata(rules) { const expectedGhsaUrl = GHSA_RE.test(advisoryId) ? `https://github.com/openclaw/openclaw/security/advisories/${advisoryId}` : ""; - if (!hasNonEmptyString(advisoryUrl)) { + if (!hasRuleMetadataText(advisoryUrl)) { violations.push(`${label}: missing metadata.advisory-url`); } else if (expectedGhsaUrl && advisoryUrl !== expectedGhsaUrl) { violations.push(`${label}: metadata.advisory-url must be ${expectedGhsaUrl}`); @@ -97,7 +97,7 @@ export function validateRuleMetadata(rules) { if (metadata["detector-bucket"] !== "precise") { violations.push(`${label}: metadata.detector-bucket must be precise`); } - if (!hasNonEmptyString(metadata["source-rule-id"])) { + if (!hasRuleMetadataText(metadata["source-rule-id"])) { violations.push(`${label}: missing metadata.source-rule-id`); } } diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 70964035e03f..179f3083e171 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -1,6 +1,6 @@ /** Tests live model switching behavior in active agent command sessions. */ -import { expectDefined } from "@openclaw/normalization-core"; +import { expectDefined, toStringifiedError } from "@openclaw/normalization-core"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; @@ -274,8 +274,7 @@ vi.mock("../acp/policy.js", () => ({ })); vi.mock("../acp/runtime/errors.js", () => ({ - toAcpRuntimeError: ({ error }: { error: unknown }) => - error instanceof Error ? error : new Error(String(error)), + toAcpRuntimeError: ({ error }: { error: unknown }) => toStringifiedError(error), })); vi.mock("@openclaw/acp-core/runtime/session-identifiers", () => ({ diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index 9e40a3d777e3..13a98ad9d8a1 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -5,6 +5,7 @@ */ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { coerceSecretRef } from "../../config/types.secrets.js"; import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; @@ -56,11 +57,7 @@ function isRetainedUsageStatsId( // Persisted credential normalization accepts old field names and SecretRef-ish // values, then emits the current credential discriminated union. function normalizeOptionalCredentialString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? value : undefined; + return readNonBlankString(value); } function normalizeExpiryField(value: unknown): number | undefined { diff --git a/src/agents/bash-tools.exec-request-preparation.ts b/src/agents/bash-tools.exec-request-preparation.ts index ea5eb6f607d8..ae490701ff91 100644 --- a/src/agents/bash-tools.exec-request-preparation.ts +++ b/src/agents/bash-tools.exec-request-preparation.ts @@ -1,4 +1,5 @@ /** Prepares exec workdir and environment facts before policy and host dispatch. */ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeChatChannelId } from "../channels/ids.js"; import type { ExecHost } from "../infra/exec-approvals.js"; @@ -96,7 +97,7 @@ function buildChannelContextEnv( } function isExecToolArgsObject(value: unknown): value is ExecToolArgs { - return typeof value === "object" && value !== null && !Array.isArray(value); + return isRecord(value); } function filterPluginExecEnv(rawEnv: Record): Record | undefined { diff --git a/src/agents/cli-output-events.ts b/src/agents/cli-output-events.ts index 9e55c96ac5be..726a7d7db5c1 100644 --- a/src/agents/cli-output-events.ts +++ b/src/agents/cli-output-events.ts @@ -1,3 +1,4 @@ +import { asPositiveFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { createReasoningTagTextPartitioner, @@ -470,11 +471,7 @@ function readThinkingProgressTokens(delta: Record): number | un if (delta.type !== "thinking_delta" || delta.thinking !== "") { return undefined; } - const estimatedTokens = delta.estimated_tokens; - if (typeof estimatedTokens !== "number" || !Number.isFinite(estimatedTokens)) { - return undefined; - } - return estimatedTokens > 0 ? estimatedTokens : undefined; + return asPositiveFiniteNumber(delta.estimated_tokens); } function emitClaudeThinkingProgress( diff --git a/src/agents/embedded-agent-runner/google-prompt-cache.ts b/src/agents/embedded-agent-runner/google-prompt-cache.ts index fd93bd8189e4..763c77040183 100644 --- a/src/agents/embedded-agent-runner/google-prompt-cache.ts +++ b/src/agents/embedded-agent-runner/google-prompt-cache.ts @@ -11,6 +11,7 @@ import { stableStringify } from "@openclaw/normalization-core"; import { asDateTimestampMs, isFutureDateTimestampMs, + parseDateStringTimestampMs, resolveExpiresAtMsFromDurationMs, } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; @@ -203,10 +204,7 @@ async function appendGooglePromptCacheEntry( } function parseExpireTimeMs(expireTime: string | undefined): number | null { - if (!expireTime) { - return null; - } - return asDateTimestampMs(Date.parse(expireTime)) ?? null; + return parseDateStringTimestampMs(expireTime) ?? null; } function convertManagedGoogleTools(tools: NonNullable) { diff --git a/src/agents/embedded-agent-runner/model-context-tokens.ts b/src/agents/embedded-agent-runner/model-context-tokens.ts index af9035606d8c..7c6ac2e362a1 100644 --- a/src/agents/embedded-agent-runner/model-context-tokens.ts +++ b/src/agents/embedded-agent-runner/model-context-tokens.ts @@ -1,6 +1,7 @@ /** * Reads normalized context-token metadata from resolved model definitions. */ +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import type { Model } from "../../llm/types.js"; /** @@ -14,5 +15,5 @@ type AgentModelWithOptionalContextTokens = Model & { /** Prefer contextTokens, then contextWindow, when present on model metadata. */ export function readAgentModelContextTokens(model: Model | null | undefined): number | undefined { const value = (model as AgentModelWithOptionalContextTokens | null | undefined)?.contextTokens; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } diff --git a/src/agents/embedded-agent-runner/model.provider-hooks.ts b/src/agents/embedded-agent-runner/model.provider-hooks.ts index 7cc4cc5aac27..62096020b99b 100644 --- a/src/agents/embedded-agent-runner/model.provider-hooks.ts +++ b/src/agents/embedded-agent-runner/model.provider-hooks.ts @@ -1,4 +1,5 @@ import { finiteSecondsToTimerSafeMilliseconds } from "@openclaw/normalization-core/number-coercion"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { Api, Model } from "../../llm/types.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; @@ -272,11 +273,7 @@ export function resolveProviderTransport(params: { } export function normalizeTransportBaseUrl(baseUrl: unknown): string | undefined { - if (typeof baseUrl !== "string") { - return undefined; - } - const trimmed = baseUrl.trim(); - return trimmed ? trimmed : undefined; + return normalizeOptionalString(baseUrl); } export function resolveProviderRequestTimeoutMs(timeoutSeconds: unknown): number | undefined { diff --git a/src/agents/embedded-agent-runner/run/attempt-trajectory-status.ts b/src/agents/embedded-agent-runner/run/attempt-trajectory-status.ts index ce02e694e43b..8292f9f5935f 100644 --- a/src/agents/embedded-agent-runner/run/attempt-trajectory-status.ts +++ b/src/agents/embedded-agent-runner/run/attempt-trajectory-status.ts @@ -71,7 +71,7 @@ function hasNonEmptyAssistantText(texts: string[]): boolean { return texts.some((text) => text.trim().length > 0); } -function hasNonEmptyString(values: string[]): boolean { +function hasAnyNonBlankString(values: string[]): boolean { return values.some((value) => value.trim().length > 0); } @@ -82,8 +82,8 @@ function hasCommittedMessagingDeliveryEvidence( >, ): boolean { return ( - hasNonEmptyString(params.messagingToolSentTexts) || - hasNonEmptyString(params.messagingToolSentMediaUrls) || + hasAnyNonBlankString(params.messagingToolSentTexts) || + hasAnyNonBlankString(params.messagingToolSentMediaUrls) || params.messagingToolSentTargets.length > 0 ); } diff --git a/src/agents/harness/lifecycle-hook-helpers.ts b/src/agents/harness/lifecycle-hook-helpers.ts index 34c433e0b3e0..e2d249f9426b 100644 --- a/src/agents/harness/lifecycle-hook-helpers.ts +++ b/src/agents/harness/lifecycle-hook-helpers.ts @@ -5,6 +5,7 @@ * before-finalize retry/finalize decisions with bounded retry accounting. */ import { createHash } from "node:crypto"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString as normalizeTrimmedString } from "@openclaw/normalization-core/string-coerce"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; @@ -240,5 +241,5 @@ function readBeforeAgentFinalizeRetryCandidates( function isBeforeAgentFinalizeRetry( value: unknown, ): value is NonNullable { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); + return isRecord(value); } diff --git a/src/agents/main-session-recovery/main-session-restart-recovery-shared.ts b/src/agents/main-session-recovery/main-session-restart-recovery-shared.ts index 9288da02030f..79639308a48d 100644 --- a/src/agents/main-session-recovery/main-session-restart-recovery-shared.ts +++ b/src/agents/main-session-recovery/main-session-restart-recovery-shared.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { resolveStateDir } from "../../config/paths.js"; import { listConfiguredSessionStoreAgentIds, @@ -64,9 +65,7 @@ export function normalizeStringSet(values: Iterable | undefined): Set; diff --git a/src/agents/mcp-transport-config.ts b/src/agents/mcp-transport-config.ts index 92de90a0db33..e4d3608ef76d 100644 --- a/src/agents/mcp-transport-config.ts +++ b/src/agents/mcp-transport-config.ts @@ -2,6 +2,7 @@ * Resolves MCP transport command, environment, and timeout configuration. */ import { + asPositiveFiniteNumber, clampPositiveTimerTimeoutMs, resolvePositiveTimerTimeoutMs, } from "@openclaw/normalization-core/number-coercion"; @@ -68,8 +69,8 @@ function getPositiveNumber(rawServer: unknown, keys: readonly string[]): number } const record = rawServer as Record; for (const key of keys) { - const value = record[key]; - if (typeof value === "number" && Number.isFinite(value) && value > 0) { + const value = asPositiveFiniteNumber(record[key]); + if (value !== undefined) { return value; } } diff --git a/src/agents/prepared-model-runtime.errors.ts b/src/agents/prepared-model-runtime.errors.ts index 1c97d5717b75..01bf8abd0f36 100644 --- a/src/agents/prepared-model-runtime.errors.ts +++ b/src/agents/prepared-model-runtime.errors.ts @@ -1,7 +1,9 @@ +import { toStringifiedError } from "@openclaw/normalization-core/error-coercion"; + export class PreparedModelRuntimeOwnerNotPublishedError extends Error {} export class PreparedModelRuntimePublicationSupersededError extends PreparedModelRuntimeOwnerNotPublishedError {} export function toPreparedModelRuntimeError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); + return toStringifiedError(error); } diff --git a/src/agents/sessions/extensions/runner.ts b/src/agents/sessions/extensions/runner.ts index 6930175c8036..28514ee0932c 100644 --- a/src/agents/sessions/extensions/runner.ts +++ b/src/agents/sessions/extensions/runner.ts @@ -3,6 +3,7 @@ */ import type { KeyId } from "@earendil-works/pi-tui"; +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import type { ImageContent, Model } from "../../../llm/types.js"; import { interactiveAgentTheme as theme, type Theme } from "../../modes/interactive/theme/theme.js"; import type { AgentMessage } from "../../runtime/index.js"; @@ -335,7 +336,7 @@ export class ExtensionRunner { this.emitError({ extensionPath, event: "register_provider", - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), stack: err instanceof Error ? err.stack : undefined, }); } @@ -734,7 +735,7 @@ export class ExtensionRunner { } } } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = coerceErrorMessage(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, @@ -782,7 +783,7 @@ export class ExtensionRunner { currentMessage = handlerResult.message; modified = true; } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = coerceErrorMessage(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, @@ -830,7 +831,7 @@ export class ExtensionRunner { modified = true; } } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = coerceErrorMessage(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, @@ -894,7 +895,7 @@ export class ExtensionRunner { return handlerResult as UserBashEventResult; } } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = coerceErrorMessage(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, @@ -934,7 +935,7 @@ export class ExtensionRunner { currentMessages = (handlerResult as ContextEventResult).messages!; } } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = coerceErrorMessage(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, @@ -970,7 +971,7 @@ export class ExtensionRunner { currentPayload = handlerResult; } } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = coerceErrorMessage(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, @@ -1031,7 +1032,7 @@ export class ExtensionRunner { } } } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = coerceErrorMessage(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, @@ -1094,7 +1095,7 @@ export class ExtensionRunner { ); } } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = coerceErrorMessage(err); const stack = err instanceof Error ? err.stack : undefined; this.emitError({ extensionPath: ext.path, @@ -1140,7 +1141,7 @@ export class ExtensionRunner { this.emitError({ extensionPath: ext.path, event: "input", - error: err instanceof Error ? err.message : String(err), + error: coerceErrorMessage(err), stack: err instanceof Error ? err.stack : undefined, }); } diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 015001cb1ea7..46829a19f079 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -9,6 +9,7 @@ import { asSafeIntegerInRange, parseStrictFiniteNumber, } from "@openclaw/normalization-core/number-coercion"; +import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import type { TSchema } from "typebox"; import { readLocalFileSafely } from "../../infra/fs-safe.js"; @@ -70,9 +71,7 @@ export type AnyAgentTool = Omit & }; export function asToolParamsRecord(params: unknown): Record { - return params && typeof params === "object" && !Array.isArray(params) - ? (params as Record) - : {}; + return asNonArrayRecord(params); } type StringParamOptions = { diff --git a/src/agents/tools/embedded-gateway-stub.ts b/src/agents/tools/embedded-gateway-stub.ts index 1913a45a62be..69810d9b357f 100644 --- a/src/agents/tools/embedded-gateway-stub.ts +++ b/src/agents/tools/embedded-gateway-stub.ts @@ -3,6 +3,7 @@ * * Implements only the Gateway calls needed by session tools and rejects unsupported methods. */ +import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion"; import { normalizeFastMode, type FastMode } from "@openclaw/normalization-core/string-coerce"; import type { SessionsListParams, @@ -145,7 +146,7 @@ function readChatHistoryMessageSeq(message: unknown): number | undefined { return undefined; } const seq = (metadata as Record).seq; - return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined; + return asPositiveSafeInteger(seq); } function resolveChatHistoryNextOffset(params: { diff --git a/src/agents/tools/image-tool.providers.live.test.ts b/src/agents/tools/image-tool.providers.live.test.ts index 1c5dbb6fbe54..f1b8a056f63f 100644 --- a/src/agents/tools/image-tool.providers.live.test.ts +++ b/src/agents/tools/image-tool.providers.live.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; +import { coerceErrorMessage as formatLiveError, expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it } from "vitest"; import type { ModelApi } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -110,10 +110,6 @@ function readJpegDimensions(buffer: Buffer): { width: number; height: number } { throw new Error("JPEG dimensions not found"); } -function formatLiveError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function isSkippableLiveError(error: unknown): boolean { const message = formatLiveError(error); return ( diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index ca3bcdee91d9..a84065478855 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -3,6 +3,7 @@ * * Reads bounded, redacted session transcript history after session visibility filtering. */ +import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion"; import { Type } from "typebox"; import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -216,7 +217,7 @@ function readHistoryMessageSeq(message: unknown): number | undefined { return undefined; } const seq = (meta as Record).seq; - return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined; + return asPositiveSafeInteger(seq); } function readHistoryMessageId(message: unknown): string | undefined { diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts index 9501a5dd3eff..32bf8948048e 100644 --- a/src/auto-reply/dispatch.ts +++ b/src/auto-reply/dispatch.ts @@ -1,4 +1,5 @@ /** Auto-reply dispatch orchestration, hook composition, and foreground delivery fencing. */ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeChatType } from "../channels/chat-type.js"; import { isChannelPartialDeliveryError } from "../channels/turn/delivery-result.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -76,25 +77,17 @@ function applyRuntimeToolsAllow( }; } -function normalizeForegroundReplyFencePart(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - function resolveForegroundReplyFenceKey(finalized: FinalizedMsgContext): string | undefined { - const sessionKey = normalizeForegroundReplyFencePart(finalized.SessionKey); + const sessionKey = normalizeOptionalString(finalized.SessionKey); const channel = - normalizeForegroundReplyFencePart(finalized.OriginatingChannel) ?? - normalizeForegroundReplyFencePart(finalized.Surface) ?? - normalizeForegroundReplyFencePart(finalized.Provider); + normalizeOptionalString(finalized.OriginatingChannel) ?? + normalizeOptionalString(finalized.Surface) ?? + normalizeOptionalString(finalized.Provider); const target = - normalizeForegroundReplyFencePart(finalized.OriginatingTo) ?? - normalizeForegroundReplyFencePart(finalized.NativeChannelId) ?? - normalizeForegroundReplyFencePart(finalized.From) ?? - normalizeForegroundReplyFencePart(finalized.To); + normalizeOptionalString(finalized.OriginatingTo) ?? + normalizeOptionalString(finalized.NativeChannelId) ?? + normalizeOptionalString(finalized.From) ?? + normalizeOptionalString(finalized.To); if (!sessionKey || !channel || !target) { return undefined; @@ -104,7 +97,7 @@ function resolveForegroundReplyFenceKey(finalized: FinalizedMsgContext): string return JSON.stringify([ "foreground", channel, - normalizeForegroundReplyFencePart(finalized.AccountId) ?? "default", + normalizeOptionalString(finalized.AccountId) ?? "default", sessionKey, normalizeChatType(finalized.ChatType) ?? "unknown", target, diff --git a/src/auto-reply/reply-payload.ts b/src/auto-reply/reply-payload.ts index ef678862e2d2..090739f73b3d 100644 --- a/src/auto-reply/reply-payload.ts +++ b/src/auto-reply/reply-payload.ts @@ -1,5 +1,8 @@ import { asPositiveFiniteNumber as normalizePairingQrExpiresAtMs } from "@openclaw/normalization-core/number-coercion"; -import { readNonBlankString as normalizeTtsSupplementSpokenText } from "@openclaw/normalization-core/string-coerce"; +import { + readNonBlankString, + 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"; @@ -117,10 +120,6 @@ type PairingQrReplyChannelData = { expiresAtMs: number; }; -function normalizePairingQrSetupCode(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - export function readPairingQrReplyChannelData( payload: Pick, ): PairingQrReplyChannelData | undefined { @@ -129,7 +128,7 @@ export function readPairingQrReplyChannelData( return undefined; } const record = raw as Record; - const setupCode = normalizePairingQrSetupCode(record.setupCode); + const setupCode = readNonBlankString(record.setupCode); const expiresAtMs = normalizePairingQrExpiresAtMs(record.expiresAtMs); return setupCode && expiresAtMs ? { setupCode, expiresAtMs } : undefined; } diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 3fabe1524f18..b6c0088ecc4b 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -5,7 +5,10 @@ */ import fs from "node:fs"; import os from "node:os"; -import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { + hasNonEmptyString, + normalizeOptionalLowercaseString, +} from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { hasBundledChannelPersistedAuthState, @@ -13,7 +16,6 @@ import { } from "../channels/plugins/persisted-auth-state.js"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { hasNonEmptyString } from "../infra/outbound/channel-target.js"; import type { PluginDiscoveryResult } from "../plugins/discovery.js"; import { listOfficialExternalChannelEnvVars } from "../plugins/official-external-plugin-catalog.js"; import { isRecord } from "../utils.js"; diff --git a/src/channels/message/ingress-retry-policy.test.ts b/src/channels/message/ingress-retry-policy.test.ts index 7de898ee1ea7..ea83545a0db2 100644 --- a/src/channels/message/ingress-retry-policy.test.ts +++ b/src/channels/message/ingress-retry-policy.test.ts @@ -1,4 +1,5 @@ // Retry policy: backoff, attempt floor + age gate for dead-letter. +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { describe, expect, it } from "vitest"; import { DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS, @@ -141,7 +142,7 @@ describe("ingress retry policy", () => { receivedAt, attempts: DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS - 1, }, - formatError: (err) => (err instanceof Error ? err.message : String(err)), + formatError: coerceErrorMessage, now: receivedAt + DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS - 1, }); expect(young.kind).toBe("release"); @@ -153,7 +154,7 @@ describe("ingress retry policy", () => { receivedAt, attempts: DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS - 1, }, - formatError: (err) => (err instanceof Error ? err.message : String(err)), + formatError: coerceErrorMessage, now: receivedAt + DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS, }); expect(aged).toMatchObject({ diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 03ddc9e09338..0271d38658fc 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -1,5 +1,8 @@ // Thread-binding policy resolution for channel/account session spawning. -import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion"; +import { + asNonNegativeFiniteNumber, + MAX_DATE_TIMESTAMP_MS, +} from "@openclaw/normalization-core/number-coercion"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAccountId } from "../routing/session-key.js"; @@ -89,13 +92,7 @@ function normalizeBoolean(value: unknown): boolean | undefined { } function normalizeThreadBindingHours(raw: unknown): number | undefined { - if (typeof raw !== "number" || !Number.isFinite(raw)) { - return undefined; - } - if (raw < 0) { - return undefined; - } - return raw; + return asNonNegativeFiniteNumber(raw); } function resolveThreadBindingHoursMs(raw: unknown, fallbackHours: number): number { diff --git a/src/claws/add.ts b/src/claws/add.ts index 9f4055d3f7f0..7072f1e8e386 100644 --- a/src/claws/add.ts +++ b/src/claws/add.ts @@ -2,7 +2,7 @@ import type { Stats } from "node:fs"; import { lstat, mkdir, rmdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; -import { stableStringify } from "@openclaw/normalization-core"; +import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core"; import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js"; import { listAgentEntries } from "../agents/agent-scope.js"; import { transformConfigFileWithRetry } from "../config/config.js"; @@ -312,7 +312,7 @@ export async function applyClawAddPlan( ? error : new ClawPackageInstallError( "package_install_failed", - error instanceof Error ? error.message : String(error), + coerceErrorMessage(error), packages, ); const installStatus = preserveRecordedPhaseOrMarkPartial(); @@ -435,7 +435,7 @@ export async function applyClawAddPlan( installStatus, error: { code: error instanceof ClawBootstrapWriteError ? error.code : "bootstrap_write_failed", - message: error instanceof Error ? error.message : String(error), + message: coerceErrorMessage(error), }, nowMs: options.nowMs, }); @@ -523,7 +523,7 @@ export async function applyClawAddPlan( installStatus, error: { code: error instanceof ClawAddMutationError ? error.code : "config_commit_failed", - message: error instanceof Error ? error.message : String(error), + message: coerceErrorMessage(error), }, nowMs: options.nowMs, }); @@ -544,7 +544,7 @@ export async function applyClawAddPlan( code: "workspace_file_io_error", phase: "mutation", path: "$.workspace", - message: error instanceof Error ? error.message : String(error), + message: coerceErrorMessage(error), }, ], workspaceFiles, @@ -597,11 +597,7 @@ export async function applyClawAddPlan( const packageError = error instanceof ClawPackageInstallError ? error - : new ClawPackageInstallError( - "package_install_failed", - error instanceof Error ? error.message : String(error), - [], - ); + : new ClawPackageInstallError("package_install_failed", coerceErrorMessage(error), []); return partialResult({ plan, installRecord, @@ -623,11 +619,7 @@ export async function applyClawAddPlan( const mcpError = error instanceof ClawMcpInstallError ? error - : new ClawMcpInstallError( - "mcp_install_failed", - error instanceof Error ? error.message : String(error), - mcpServers, - ); + : new ClawMcpInstallError("mcp_install_failed", coerceErrorMessage(error), mcpServers); markInstallStatus(plan.agent.finalId, "config_committed", ["config_committed"], options); return partialResult({ plan, @@ -650,11 +642,7 @@ export async function applyClawAddPlan( const cronError = error instanceof ClawCronInstallError ? error - : new ClawCronInstallError( - "cron_install_failed", - error instanceof Error ? error.message : String(error), - cronJobs, - ); + : new ClawCronInstallError("cron_install_failed", coerceErrorMessage(error), cronJobs); markInstallStatus(plan.agent.finalId, "config_committed", ["config_committed"], options); return partialResult({ plan, diff --git a/src/claws/cron-update.ts b/src/claws/cron-update.ts index e4e3689efa33..d7a8f18ff422 100644 --- a/src/claws/cron-update.ts +++ b/src/claws/cron-update.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { stableStringify } from "@openclaw/normalization-core"; +import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core"; import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; import { CLAW_CRON_REF_SCHEMA_VERSION, @@ -94,7 +94,7 @@ export async function applyClawCronUpdate( try { raw = await gateway.add(clawCronGatewayInput(updatePlan.agentId, ref)); } catch (error) { - throw new ClawCronUpdateError(error instanceof Error ? error.message : String(error), true); + throw new ClawCronUpdateError(coerceErrorMessage(error), true); } const result = clawCronSchedulerJobFromResult(raw); if (!result) { @@ -108,7 +108,7 @@ export async function applyClawCronUpdate( try { await revert(); } catch (error) { - failures.push(error instanceof Error ? error.message : String(error)); + failures.push(coerceErrorMessage(error)); } } if (failures.length > 0) { @@ -142,10 +142,7 @@ export async function applyClawCronUpdate( try { await gateway.remove(previous.schedulerJobId); } catch (error) { - throw new ClawCronUpdateError( - error instanceof Error ? error.message : String(error), - true, - ); + throw new ClawCronUpdateError(coerceErrorMessage(error), true); } undo.push(async () => { const restoredId = await add(previous); @@ -174,7 +171,7 @@ export async function applyClawCronUpdate( } } catch (error) { throw new ClawCronUpdateError( - `cron.add did not converge and cleanup failed: ${error instanceof Error ? error.message : String(error)}`, + `cron.add did not converge and cleanup failed: ${coerceErrorMessage(error)}`, true, ); } @@ -200,12 +197,12 @@ export async function applyClawCronUpdate( await rollback(); } catch (rollbackError) { throw new ClawCronUpdateError( - `${error instanceof Error ? error.message : String(error)}; rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + `${coerceErrorMessage(error)}; rollback failed: ${coerceErrorMessage(rollbackError)}`, true, ); } throw new ClawCronUpdateError( - error instanceof Error ? error.message : String(error), + coerceErrorMessage(error), error instanceof ClawCronUpdateError && error.partial, ); } diff --git a/src/claws/cron.ts b/src/claws/cron.ts index 35e549af2778..1c9fc1e8b49f 100644 --- a/src/claws/cron.ts +++ b/src/claws/cron.ts @@ -1,3 +1,4 @@ +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { resolveCronJobConfigRevision } from "../cron/config-revision.js"; import { normalizeCronJobCreate } from "../cron/normalize.js"; import { createTrustedCronScheduledToolPolicy } from "../cron/scheduled-tool-policy.js"; @@ -340,7 +341,7 @@ export async function installClawCronJobs( throw new Error("cron.add returned no scheduler job id"); } } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = coerceErrorMessage(error); refs[refs.length - 1] = updateRef(pending, { status: "pending", error: message }, options); throw new ClawCronInstallError("cron_install_failed", message, refs); } @@ -351,7 +352,7 @@ export async function installClawCronJobs( options, ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = coerceErrorMessage(error); throw new ClawCronInstallError( "cron_provenance_failed", `cron.add succeeded, but its scheduler id could not be persisted: ${message}`, diff --git a/src/claws/doctor.ts b/src/claws/doctor.ts index 2f2502496bf0..4e09bed0d276 100644 --- a/src/claws/doctor.ts +++ b/src/claws/doctor.ts @@ -1,7 +1,7 @@ // Claw doctor diagnostics project the lifecycle ownership ledger into health findings. import { createHash } from "node:crypto"; import type { DatabaseSync } from "node:sqlite"; -import { stableStringify } from "@openclaw/normalization-core"; +import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core"; import { listConfiguredMcpServers } from "../config/mcp-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveDefaultCronStaggerMs } from "../cron/stagger.js"; @@ -369,7 +369,7 @@ export async function collectClawStateHealthFindings( } catch (error) { cronInventory = { ok: false, - error: error instanceof Error ? error.message : String(error), + error: coerceErrorMessage(error), }; } } @@ -384,7 +384,7 @@ export async function collectClawStateHealthFindings( return [ finding({ severity: "error", - message: `Could not inspect Claw lifecycle state: ${error instanceof Error ? error.message : String(error)}`, + message: `Could not inspect Claw lifecycle state: ${coerceErrorMessage(error)}`, requirement: "Claw doctor diagnostics require readable lifecycle state", }), ]; diff --git a/src/claws/export.ts b/src/claws/export.ts index c35a04369307..bcf1784fe854 100644 --- a/src/claws/export.ts +++ b/src/claws/export.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { closeSync } from "node:fs"; import { mkdir, realpath, rm } from "node:fs/promises"; import { basename, dirname, relative, resolve, sep } from "node:path"; +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { stringify as stringifyYaml } from "yaml"; import { listAgentEntries, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { openLocalAgentAvatarFile } from "../agents/identity-avatar-file.js"; @@ -606,10 +607,7 @@ export async function exportClawAgent( if (error instanceof ClawExportError) { throw error; } - throw new ClawExportError( - "export_write_failed", - error instanceof Error ? error.message : String(error), - ); + throw new ClawExportError("export_write_failed", coerceErrorMessage(error)); } return { schemaVersion: CLAW_EXPORT_RESULT_SCHEMA_VERSION, diff --git a/src/claws/lifecycle-delete-support.ts b/src/claws/lifecycle-delete-support.ts index ad65570d9e24..dde6a6060727 100644 --- a/src/claws/lifecycle-delete-support.ts +++ b/src/claws/lifecycle-delete-support.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js"; import { listAgentEntries, resolveAgentDir } from "../agents/agent-scope.js"; import { MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES } from "../agents/workspace-bootstrap-read.js"; @@ -268,7 +269,7 @@ export async function cleanupClawAgentFilesystem(params: { } deleteWorkspaceState(statePlan); } catch (error) { - errors.push(error instanceof Error ? error.message : String(error)); + errors.push(coerceErrorMessage(error)); } } else { errors.push(`Could not trash workspace ${params.targets.workspaceDir}.`); @@ -347,7 +348,7 @@ async function inspectDigestOwnedWorkspaceFile( } return { state: "unsafe", - message: error instanceof Error ? error.message : String(error), + message: coerceErrorMessage(error), }; } } diff --git a/src/claws/lifecycle-mcp-removal.ts b/src/claws/lifecycle-mcp-removal.ts index 2e64d31e58b4..e1d76e8fbc12 100644 --- a/src/claws/lifecycle-mcp-removal.ts +++ b/src/claws/lifecycle-mcp-removal.ts @@ -1,3 +1,4 @@ +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { unsetConfiguredMcpServer } from "../agents/mcp-config-mutation.js"; import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js"; import { listConfiguredMcpServers } from "../config/mcp-config.js"; @@ -75,7 +76,7 @@ export async function removeClawMcpServers(params: { deleteClawMcpServerRef(params.agentId, server.name, params.options); mcpServers.push({ name: server.name, action: result.removed ? "removed" : "missing" }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = coerceErrorMessage(error); mcpServers.push({ name: server.name, action: "error", message }); return { mcpServers, error: message }; } diff --git a/src/claws/lifecycle-state.ts b/src/claws/lifecycle-state.ts index a7d1351e5947..bf8e47b50856 100644 --- a/src/claws/lifecycle-state.ts +++ b/src/claws/lifecycle-state.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { stableStringify } from "@openclaw/normalization-core"; +import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core"; import { unsetConfiguredMcpServer } from "../agents/mcp-config-mutation.js"; import { getRuntimeConfig } from "../config/config.js"; import { listConfiguredMcpServers } from "../config/mcp-config.js"; @@ -565,7 +565,7 @@ export async function applyClawRemovePlan( action: "removed", }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = coerceErrorMessage(error); cronJobs.push({ manifestId: cron.manifestId, schedulerJobId: cron.schedulerJobId, diff --git a/src/claws/mcp-update.ts b/src/claws/mcp-update.ts index c7e0ee92a81d..1f633b8ad779 100644 --- a/src/claws/mcp-update.ts +++ b/src/claws/mcp-update.ts @@ -1,3 +1,4 @@ +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { setConfiguredMcpServer, unsetConfiguredMcpServer } from "../agents/mcp-config-mutation.js"; import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -72,7 +73,7 @@ export async function applyClawMcpUpdate( try { await revert(); } catch (error) { - failures.push(error instanceof Error ? error.message : String(error)); + failures.push(coerceErrorMessage(error)); } } if (failures.length > 0) { @@ -201,12 +202,12 @@ export async function applyClawMcpUpdate( await rollback(); } catch (rollbackError) { throw new ClawMcpUpdateError( - `${error instanceof Error ? error.message : String(error)}; rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + `${coerceErrorMessage(error)}; rollback failed: ${coerceErrorMessage(rollbackError)}`, true, ); } throw new ClawMcpUpdateError( - error instanceof Error ? error.message : String(error), + coerceErrorMessage(error), configMutationUncertain || (error instanceof ClawMcpUpdateError && error.partial), ); } diff --git a/src/claws/mcp.ts b/src/claws/mcp.ts index 06285cfcf7d0..1a2d2f26261c 100644 --- a/src/claws/mcp.ts +++ b/src/claws/mcp.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { stableStringify } from "@openclaw/normalization-core"; +import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core"; import { setConfiguredMcpServer } from "../agents/mcp-config-mutation.js"; import { canonicalizeConfiguredMcpServer } from "../config/mcp-config-normalize.js"; import { listConfiguredMcpServers } from "../config/mcp-config.js"; @@ -257,7 +257,7 @@ export async function installClawMcpServers( recordIndependentOwner: false, }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = coerceErrorMessage(error); throw new ClawMcpInstallError("mcp_install_uncertain", message, refs); } if (!result.ok) { @@ -271,7 +271,7 @@ export async function installClawMcpServers( try { refs[refs.length - 1] = updateRef(pending, { status: "complete" }, options); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = coerceErrorMessage(error); throw new ClawMcpInstallError( "mcp_provenance_failed", `MCP server was configured, but ownership could not be persisted: ${message}`, diff --git a/src/claws/package-remove.ts b/src/claws/package-remove.ts index 8638280f7af1..dd4d11b2362c 100644 --- a/src/claws/package-remove.ts +++ b/src/claws/package-remove.ts @@ -1,3 +1,4 @@ +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { runPluginUninstallCommand } from "../cli/plugins-uninstall-command.js"; import { normalizeClawHubSha256Integrity } from "../infra/clawhub-artifacts.js"; import { resolveInstalledClawHubPlugin } from "../plugins/plugin-install-preflight.js"; @@ -578,7 +579,7 @@ async function applyClawPackageRemovalsUnlocked( results.push({ ...base, action: "error", - reason: error instanceof Error ? error.message : String(error), + reason: coerceErrorMessage(error), }); } finally { try { diff --git a/src/claws/package-update.ts b/src/claws/package-update.ts index 26ef645c065d..0ff5501b99ee 100644 --- a/src/claws/package-update.ts +++ b/src/claws/package-update.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { stableStringify } from "@openclaw/normalization-core"; +import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core"; import { preflightPluginInstall } from "../plugins/plugin-install-preflight.js"; import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; import { @@ -77,7 +77,7 @@ export async function applyClawPackageUpdate( try { await revert(); } catch (error) { - failures.push(error instanceof Error ? error.message : String(error)); + failures.push(coerceErrorMessage(error)); } } if (externalMutations.length > 0) { @@ -271,7 +271,7 @@ export async function applyClawPackageUpdate( } catch (error) { if (externalMutations.length > 0) { throw new ClawPackageUpdateError( - `${error instanceof Error ? error.message : String(error)}; package artifact outcome requires reconciliation`, + `${coerceErrorMessage(error)}; package artifact outcome requires reconciliation`, true, ); } @@ -279,12 +279,12 @@ export async function applyClawPackageUpdate( await rollback(); } catch (rollbackError) { throw new ClawPackageUpdateError( - `${error instanceof Error ? error.message : String(error)}; rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + `${coerceErrorMessage(error)}; rollback incomplete: ${coerceErrorMessage(rollbackError)}`, externalMutations.length > 0, ); } throw new ClawPackageUpdateError( - error instanceof Error ? error.message : String(error), + coerceErrorMessage(error), error instanceof ClawPackageUpdateError ? error.partial : false, ); } diff --git a/src/claws/packages.ts b/src/claws/packages.ts index f0d503368c5a..73a6b2bf17a9 100644 --- a/src/claws/packages.ts +++ b/src/claws/packages.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { stableStringify } from "@openclaw/normalization-core"; +import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core"; import { runPluginInstallCommand } from "../cli/plugins-install-command.js"; import { runPluginUninstallCommand } from "../cli/plugins-uninstall-command.js"; import { normalizeClawHubSha256Integrity } from "../infra/clawhub-artifacts.js"; @@ -674,7 +674,7 @@ async function installClawPackagesUnlocked( ); } catch (rollbackError) { rollbackErrors.push( - `could not remove plugin ${installedPlugin.installId}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + `could not remove plugin ${installedPlugin.installId}: ${coerceErrorMessage(rollbackError)}`, ); continue; } finally { @@ -685,7 +685,7 @@ async function installClawPackagesUnlocked( } } } - const message = error instanceof Error ? error.message : String(error); + const message = coerceErrorMessage(error); if (rollbackErrors.length > 0) { throw new ClawPackageInstallError( "package_rollback_failed", diff --git a/src/claws/project.ts b/src/claws/project.ts index a81feb37e409..c6d6114db21c 100644 --- a/src/claws/project.ts +++ b/src/claws/project.ts @@ -1,5 +1,6 @@ import { lstat, mkdir, readdir, realpath, rmdir, unlink, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, parse, relative, resolve, sep } from "node:path"; +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { root as fsSafeRoot } from "../infra/fs-safe.js"; import { readClawManifestFile } from "./reader.js"; import { isCanonicalClawHubPackageName, portableClawPathKey } from "./schema-portability.js"; @@ -282,7 +283,7 @@ export async function validateClawProject( diagnostic( error instanceof ClawProjectError ? error.code : "project_discovery_failed", "$", - error instanceof Error ? error.message : String(error), + coerceErrorMessage(error), ), ], }; @@ -434,7 +435,7 @@ export async function validateClawProject( diagnostic( error instanceof ClawProjectError ? error.code : "project_enumeration_failed", "$", - error instanceof Error ? error.message : String(error), + coerceErrorMessage(error), ), ], }; diff --git a/src/claws/update-apply.ts b/src/claws/update-apply.ts index c6ad8df25f80..8c0ac3b60241 100644 --- a/src/claws/update-apply.ts +++ b/src/claws/update-apply.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { stableStringify } from "@openclaw/normalization-core"; +import { coerceErrorMessage, stableStringify } from "@openclaw/normalization-core"; import { listAgentEntries } from "../agents/agent-scope.js"; import { transformConfigFileWithRetry } from "../config/config.js"; import type { AgentConfig } from "../config/types.agents.js"; @@ -288,10 +288,7 @@ export async function applyClawUpdatePlan( if (error instanceof ClawPackageUpdateError && error.partial) { throw partialMutation(error.message); } - throw new ClawUpdateMutationError( - "package_update_failed", - error instanceof Error ? error.message : String(error), - ); + throw new ClawUpdateMutationError("package_update_failed", coerceErrorMessage(error)); } const retainedRequirementMutation = requirementExecution.appliedIds.length > 0; @@ -305,13 +302,10 @@ export async function applyClawUpdatePlan( } if (retainedRequirementMutation) { throw partialMutation( - `${error instanceof Error ? error.message : String(error)}; successfully realized shared requirements were retained`, + `${coerceErrorMessage(error)}; successfully realized shared requirements were retained`, ); } - throw new ClawUpdateMutationError( - "workspace_update_failed", - error instanceof Error ? error.message : String(error), - ); + throw new ClawUpdateMutationError("workspace_update_failed", coerceErrorMessage(error)); } const applyMcp = options.applyMcp ?? applyClawMcpUpdate; @@ -324,7 +318,7 @@ export async function applyClawUpdatePlan( await workspaceExecution.rollback(); } catch (rollbackError) { throw partialMutation( - `${error instanceof Error ? error.message : String(error)}; workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + `${coerceErrorMessage(error)}; workspace rollback failed: ${coerceErrorMessage(rollbackError)}`, ); } if (partial) { @@ -332,13 +326,10 @@ export async function applyClawUpdatePlan( } if (retainedRequirementMutation) { throw partialMutation( - `${error instanceof Error ? error.message : String(error)}; successfully realized shared requirements were retained`, + `${coerceErrorMessage(error)}; successfully realized shared requirements were retained`, ); } - throw new ClawUpdateMutationError( - "mcp_update_failed", - error instanceof Error ? error.message : String(error), - ); + throw new ClawUpdateMutationError("mcp_update_failed", coerceErrorMessage(error)); } let packageExecution: ClawPackageUpdateExecution; @@ -349,34 +340,25 @@ export async function applyClawUpdatePlan( try { await mcpExecution.rollback(); } catch (rollbackError) { - rollbackFailures.push( - `MCP rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`MCP rollback failed: ${coerceErrorMessage(rollbackError)}`); } try { await workspaceExecution.rollback(); } catch (rollbackError) { - rollbackFailures.push( - `workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`workspace rollback failed: ${coerceErrorMessage(rollbackError)}`); } if (error instanceof ClawPackageUpdateError && error.partial) { rollbackFailures.unshift("package artifact rollback is unavailable"); } if (rollbackFailures.length > 0) { - throw partialMutation( - `${error instanceof Error ? error.message : String(error)}; ${rollbackFailures.join("; ")}`, - ); + throw partialMutation(`${coerceErrorMessage(error)}; ${rollbackFailures.join("; ")}`); } if (retainedRequirementMutation) { throw partialMutation( - `${error instanceof Error ? error.message : String(error)}; successfully realized shared requirements were retained`, + `${coerceErrorMessage(error)}; successfully realized shared requirements were retained`, ); } - throw new ClawUpdateMutationError( - "package_update_failed", - error instanceof Error ? error.message : String(error), - ); + throw new ClawUpdateMutationError("package_update_failed", coerceErrorMessage(error)); } const agentAction = fresh.actions.find((action) => action.kind === "agent"); @@ -445,48 +427,35 @@ export async function applyClawUpdatePlan( try { await rollbackAgent(); } catch (rollbackError) { - rollbackFailures.push( - `agent rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`agent rollback failed: ${coerceErrorMessage(rollbackError)}`); } try { await packageExecution.rollback(); } catch (rollbackError) { - rollbackFailures.push( - `package rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`package rollback incomplete: ${coerceErrorMessage(rollbackError)}`); } try { await mcpExecution.rollback(); } catch (rollbackError) { - rollbackFailures.push( - `MCP rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`MCP rollback failed: ${coerceErrorMessage(rollbackError)}`); } try { await workspaceExecution.rollback(); } catch (rollbackError) { - rollbackFailures.push( - `workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`workspace rollback failed: ${coerceErrorMessage(rollbackError)}`); } if (rollbackFailures.length > 0) { - throw partialMutation( - `${error instanceof Error ? error.message : String(error)}; ${rollbackFailures.join("; ")}`, - ); + throw partialMutation(`${coerceErrorMessage(error)}; ${rollbackFailures.join("; ")}`); } if (retainedRequirementMutation) { throw partialMutation( - `${error instanceof Error ? error.message : String(error)}; successfully realized shared requirements were retained`, + `${coerceErrorMessage(error)}; successfully realized shared requirements were retained`, ); } if (error instanceof ClawUpdateMutationError) { throw error; } - throw new ClawUpdateMutationError( - "agent_update_failed", - error instanceof Error ? error.message : String(error), - ); + throw new ClawUpdateMutationError("agent_update_failed", coerceErrorMessage(error)); } } @@ -505,7 +474,7 @@ export async function applyClawUpdatePlan( }); } catch (persistError) { throw partialMutation( - `${error.message}; cron gateway mutation outcome is uncertain; provenance update failed: ${persistError instanceof Error ? persistError.message : String(persistError)}`, + `${error.message}; cron gateway mutation outcome is uncertain; provenance update failed: ${coerceErrorMessage(persistError)}`, ); } throw partialMutation(`${error.message}; cron gateway mutation outcome is uncertain`); @@ -514,45 +483,32 @@ export async function applyClawUpdatePlan( try { await rollbackAgent(); } catch (rollbackError) { - rollbackFailures.push( - `agent rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`agent rollback failed: ${coerceErrorMessage(rollbackError)}`); } try { await packageExecution.rollback(); } catch (rollbackError) { - rollbackFailures.push( - `package rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`package rollback incomplete: ${coerceErrorMessage(rollbackError)}`); } try { await mcpExecution.rollback(); } catch (rollbackError) { - rollbackFailures.push( - `MCP rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`MCP rollback failed: ${coerceErrorMessage(rollbackError)}`); } try { await workspaceExecution.rollback(); } catch (rollbackError) { - rollbackFailures.push( - `workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`workspace rollback failed: ${coerceErrorMessage(rollbackError)}`); } if (rollbackFailures.length > 0) { - throw partialMutation( - `${error instanceof Error ? error.message : String(error)}; ${rollbackFailures.join("; ")}`, - ); + throw partialMutation(`${coerceErrorMessage(error)}; ${rollbackFailures.join("; ")}`); } if (retainedRequirementMutation) { throw partialMutation( - `${error instanceof Error ? error.message : String(error)}; successfully realized shared requirements were retained`, + `${coerceErrorMessage(error)}; successfully realized shared requirements were retained`, ); } - throw new ClawUpdateMutationError( - "cron_update_failed", - error instanceof Error ? error.message : String(error), - ); + throw new ClawUpdateMutationError("cron_update_failed", coerceErrorMessage(error)); } let installRecord: PersistedClawInstall; @@ -566,52 +522,37 @@ export async function applyClawUpdatePlan( try { await rollbackAgent(); } catch (rollbackError) { - rollbackFailures.push( - `agent rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`agent rollback failed: ${coerceErrorMessage(rollbackError)}`); } try { await packageExecution.rollback(); } catch (rollbackError) { - rollbackFailures.push( - `package rollback incomplete: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`package rollback incomplete: ${coerceErrorMessage(rollbackError)}`); } try { await cronExecution.rollback(); } catch (rollbackError) { - rollbackFailures.push( - `cron rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`cron rollback failed: ${coerceErrorMessage(rollbackError)}`); } try { await mcpExecution.rollback(); } catch (rollbackError) { - rollbackFailures.push( - `MCP rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`MCP rollback failed: ${coerceErrorMessage(rollbackError)}`); } try { await workspaceExecution.rollback(); } catch (rollbackError) { - rollbackFailures.push( - `workspace rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, - ); + rollbackFailures.push(`workspace rollback failed: ${coerceErrorMessage(rollbackError)}`); } if (rollbackFailures.length > 0) { - throw partialMutation( - `${error instanceof Error ? error.message : String(error)}; ${rollbackFailures.join("; ")}`, - ); + throw partialMutation(`${coerceErrorMessage(error)}; ${rollbackFailures.join("; ")}`); } if (retainedRequirementMutation) { throw partialMutation( - `${error instanceof Error ? error.message : String(error)}; successfully realized shared requirements were retained`, + `${coerceErrorMessage(error)}; successfully realized shared requirements were retained`, ); } - throw new ClawUpdateMutationError( - "provenance_update_failed", - error instanceof Error ? error.message : String(error), - ); + throw new ClawUpdateMutationError("provenance_update_failed", coerceErrorMessage(error)); } return { schemaVersion: CLAW_UPDATE_RESULT_SCHEMA_VERSION, diff --git a/src/claws/workspace-update.ts b/src/claws/workspace-update.ts index 254c17133346..bf8633c8a40a 100644 --- a/src/claws/workspace-update.ts +++ b/src/claws/workspace-update.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { resolve, sep } from "node:path"; +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { root as fsSafeRoot } from "../infra/fs-safe.js"; import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; import type { ClawAddPlan } from "./types.js"; @@ -74,7 +75,7 @@ export async function applyClawWorkspaceUpdate( try { await revert(); } catch (error) { - failures.push(error instanceof Error ? error.message : String(error)); + failures.push(coerceErrorMessage(error)); } } if (failures.length > 0) { @@ -194,7 +195,7 @@ export async function applyClawWorkspaceUpdate( await rollback(); } catch (rollbackError) { throw new ClawWorkspaceUpdateError( - `${error instanceof Error ? error.message : String(error)}; rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + `${coerceErrorMessage(error)}; rollback failed: ${coerceErrorMessage(rollbackError)}`, true, ); } diff --git a/src/claws/workspace.ts b/src/claws/workspace.ts index 3aae656cdf0f..278049e1676a 100644 --- a/src/claws/workspace.ts +++ b/src/claws/workspace.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { realpath } from "node:fs/promises"; import { isAbsolute, relative, resolve, sep } from "node:path"; +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { root as fsSafeRoot, FsSafeError, type Root } from "../infra/fs-safe.js"; import { openOpenClawStateDatabase, @@ -499,7 +500,7 @@ export async function createClawWorkspaceFiles( ? `workspace_file_${error.code}` : "workspace_file_io_error"; throw new ClawWorkspaceWriteError( - [diagnostic(action, code, error instanceof Error ? error.message : String(error))], + [diagnostic(action, code, coerceErrorMessage(error))], createdFiles, ); } diff --git a/src/cli/config-cli-path.ts b/src/cli/config-cli-path.ts index 4edd343d6a7d..98b1e292fd71 100644 --- a/src/cli/config-cli-path.ts +++ b/src/cli/config-cli-path.ts @@ -223,7 +223,7 @@ export function formatConfigUnsetMissingPathMessage(params: { } function isSchemaRecord(value: unknown): value is JsonSchemaRecord { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); + return isPlainRecord(value); } function schemaTypes(schema: JsonSchemaRecord): Set { diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index ababf2cef82a..8d3a99fed8c7 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -1,5 +1,6 @@ // Collects daemon status from service files, config snapshots, ports, probes, and plugin drift. import fs from "node:fs/promises"; +import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import JSON5 from "json5"; import type { classifyGatewayConnectFailure } from "../../../packages/gateway-protocol/src/connect-error-details.js"; @@ -174,10 +175,7 @@ function resolveSnapshotRuntimeConfig(snapshot: ConfigFileSnapshot | null): Open } function coerceStatusConfig(value: unknown): OpenClawConfig { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return {}; - } - return value as OpenClawConfig; + return asNonArrayRecord(value) as OpenClawConfig; } function hasOwnKey(value: unknown, key: string): boolean { diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index fdf8003042c9..70bfa59a93d8 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -1,7 +1,10 @@ // 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 { + coerceErrorMessage as normalizeErrorMessage, + toStringifiedError, +} 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"; @@ -205,10 +208,6 @@ async function fetchLogs( } } -function normalizeError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - function shouldUseLocalLogsFallback(opts: LogsCliOptions, error: unknown): boolean { // Fallback reads local files only for implicit loopback Gateway RPC failures. if (!isLocalGatewayRpcUnavailableError(error)) { @@ -612,9 +611,9 @@ export function registerLogsCli(program: Command) { return { payload: result.payload, gatewayPollStartedAt: result.startedAt }; } if (!shouldUseLocalLogsFallback(opts, result.error)) { - throw normalizeError(result.error); + throw toStringifiedError(result.error); } - fallbackError = normalizeError(result.error); + fallbackError = toStringifiedError(result.error); } const activeProbe = gatewayRecovery.kind === "probing" ? gatewayRecovery.promise : undefined; @@ -633,7 +632,7 @@ export function registerLogsCli(program: Command) { if (result.ok) { return { payload: result.payload, gatewayPollStartedAt: result.startedAt }; } - throw normalizeError(result.error); + throw toStringifiedError(result.error); } throw fallbackError ?? new Error("Active systemd journal unavailable for logs follow"); }; diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 24aab109ab32..9bed689e146f 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -273,8 +273,6 @@ vi.mock("./one-shot-exit.js", () => ({ vi.mock("../infra/env.js", async (importOriginal) => ({ ...(await importOriginal()), - isTruthyEnvValue: (value?: string) => - typeof value === "string" && ["1", "on", "true", "yes"].includes(value.trim().toLowerCase()), normalizeEnv: normalizeEnvMock, })); diff --git a/src/cli/run-main.profile-env.test.ts b/src/cli/run-main.profile-env.test.ts index a35ad15c429d..a71506e094b5 100644 --- a/src/cli/run-main.profile-env.test.ts +++ b/src/cli/run-main.profile-env.test.ts @@ -42,9 +42,8 @@ vi.mock("./dotenv.js", () => ({ loadCliDotEnv: dotenvState.loadDotEnv, })); -vi.mock("../infra/env.js", () => ({ - isTruthyEnvValue: (value?: string) => - typeof value === "string" && ["1", "on", "true", "yes"].includes(value.trim().toLowerCase()), +vi.mock("../infra/env.js", async (importOriginal) => ({ + ...(await importOriginal()), normalizeEnv: vi.fn(), })); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 25be9fd838c1..1a73ecd69c85 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -272,13 +272,14 @@ 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); + const [actual, { isRecord }] = await Promise.all([ + importOriginal(), + import("@openclaw/normalization-core/record-coerce"), + ]); return { ...actual, displayString: (input: string) => input, - isRecord: isMockRecord, + isRecord, pathExists: (...args: unknown[]) => pathExists(...args), resolveConfigDir: () => "/tmp/openclaw-config", sleep: vi.fn(async () => undefined), diff --git a/src/commands/backup-verify.ts b/src/commands/backup-verify.ts index 2a742ebe95d6..19438177c7dc 100644 --- a/src/commands/backup-verify.ts +++ b/src/commands/backup-verify.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; +import { toStringifiedError } from "@openclaw/normalization-core/error-coercion"; import { readStringValue } from "@openclaw/normalization-core/string-coerce"; import * as tar from "tar"; import { loadSqliteVecExtension } from "../../packages/memory-host-sdk/src/engine-storage.js"; @@ -236,11 +237,7 @@ async function extractManifest(params: { manifestContentPromise = entry.size > MAX_MANIFEST_BYTES ? Promise.resolve(limitError) - : entry - .concat() - .catch((error: unknown) => - error instanceof Error ? error : new Error(String(error)), - ); + : entry.concat().catch((error: unknown) => toStringifiedError(error)); }, }); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index b8804e96d3cb..6ff8d42400a9 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -32,12 +32,9 @@ const noteImplicitFallbackClobberWarningsMock = vi.hoisted(() => } }), ); -const legacyConfigMigrationForTest = vi.hoisted(() => { - function readNullableRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; - } +const legacyConfigMigrationForTest = await vi.hoisted(async () => { + const { asNullableRecord: readNullableRecord } = + await import("@openclaw/normalization-core/record-coerce"); function ensureRecord(parent: Record, key: string): Record { const current = readNullableRecord(parent[key]); @@ -341,7 +338,9 @@ vi.mock("../config/validation.js", () => ({ validateConfigObjectWithPlugins: vi.fn((config: unknown) => ({ ok: true, config })), })); -vi.mock("../config/legacy.js", () => { +vi.mock("../config/legacy.js", async () => { + const { asNullableRecord: readNullableRecord } = + await import("@openclaw/normalization-core/record-coerce"); type LegacyRule = { path: string[]; message: string; @@ -349,12 +348,6 @@ vi.mock("../config/legacy.js", () => { requireSourceLiteral?: boolean; }; - function readNullableRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; - } - function getPathValue(root: Record, pathParts: readonly string[]): unknown { let cursor: unknown = root; for (const part of pathParts) { @@ -867,12 +860,9 @@ vi.mock("./doctor/channel-capabilities.js", () => { }; }); -vi.mock("../plugins/doctor-contract-registry.js", () => { - function readNullableRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; - } +vi.mock("../plugins/doctor-contract-registry.js", async () => { + const { asNullableRecord: readNullableRecord } = + await import("@openclaw/normalization-core/record-coerce"); function hasLegacyTalkFields(value: unknown): boolean { const talk = readNullableRecord(value); @@ -1068,12 +1058,9 @@ vi.mock("../plugins/setup-registry.js", () => ({ })), })); -vi.mock("./doctor/shared/channel-doctor.js", () => { - function readNullableRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; - } +vi.mock("./doctor/shared/channel-doctor.js", async () => { + const { asNullableRecord: readNullableRecord } = + await import("@openclaw/normalization-core/record-coerce"); function hasOwnStringArray(value: unknown): boolean { return Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry); @@ -1258,12 +1245,9 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { }; }); -vi.mock("./doctor/shared/preview-warnings.js", () => { - function readNullableRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; - } +vi.mock("./doctor/shared/preview-warnings.js", async () => { + const { asNullableRecord: readNullableRecord } = + await import("@openclaw/normalization-core/record-coerce"); function hasStringEntries(value: unknown): boolean { return Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry); diff --git a/src/commands/doctor-db-bloat.ts b/src/commands/doctor-db-bloat.ts index d89109fef1a4..a47b0b08435f 100644 --- a/src/commands/doctor-db-bloat.ts +++ b/src/commands/doctor-db-bloat.ts @@ -3,6 +3,7 @@ // (multi-hundred-MB stores, blocking vacuums) surfaced only after user harm. import fs from "node:fs"; import type { DatabaseSync } from "node:sqlite"; +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { note } from "../../packages/terminal-core/src/note.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; @@ -58,8 +59,7 @@ function readPragmaNumber( pragma: string, ): number | null { const row = db.prepare(`PRAGMA ${pragma}`).get() as Record | undefined; - const value = row?.[pragma]; - return typeof value === "number" && Number.isFinite(value) ? value : null; + return asFiniteNumber(row?.[pragma]) ?? null; } function describeBloat(label: string, stats: SqliteBloatStats): string | null { diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 086f90c4aee6..9aeebe22ca71 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -78,7 +78,9 @@ vi.mock("./doctor/shared/channel-legacy-config-migrate.js", () => ({ }), })); -vi.mock("../secrets/target-registry.js", () => { +vi.mock("../secrets/target-registry.js", async () => { + const { asNullableRecord: readRecord } = + await import("@openclaw/normalization-core/record-coerce"); const entry = { id: "channels.discord.token", targetType: "channels.discord.token", @@ -91,11 +93,6 @@ vi.mock("../secrets/target-registry.js", () => { includeInAudit: true, }; - const readRecord = (value: unknown): Record | null => - value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; - return { discoverConfigSecretTargets: (cfg: OpenClawConfig) => { const targets: Array<{ diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts index 7c6a4e63658a..ceba9fb4d59e 100644 --- a/src/config/doc-baseline.ts +++ b/src/config/doc-baseline.ts @@ -4,6 +4,7 @@ import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { replaceFileAtomicSync } from "../infra/replace-file.js"; @@ -184,10 +185,7 @@ function normalizeEnumValues(values: unknown[] | undefined): JsonValue[] | undef } function asSchemaObject(value: unknown): JsonSchemaObject | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - return value as JsonSchemaObject; + return asNullableRecord(value) as JsonSchemaObject | null; } function splitHintLookupPath(pathResult: string): string[] { diff --git a/src/config/io.write-safety.ts b/src/config/io.write-safety.ts index cc02cc7273e4..2172dc16f82f 100644 --- a/src/config/io.write-safety.ts +++ b/src/config/io.write-safety.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { replaceFileAtomic } from "../infra/replace-file.js"; import { isRecord } from "../utils.js"; import { stampConfigWriteMetadata } from "./io.meta.js"; @@ -111,7 +112,7 @@ export async function rollbackConfigFileWriteIfUnchanged(params: { } function normalizeStatNumber(value: number | null | undefined): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; + return asFiniteNumber(value) ?? null; } function normalizeStatId(value: number | bigint | null | undefined): string | null { diff --git a/src/config/official-external-channel-secret-schema.ts b/src/config/official-external-channel-secret-schema.ts index d7ce09b039a8..2b8cbe66d2dd 100644 --- a/src/config/official-external-channel-secret-schema.ts +++ b/src/config/official-external-channel-secret-schema.ts @@ -1,4 +1,5 @@ /** Widens official external channel schemas for host-resolved SecretRef fields. */ +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { getOfficialExternalChannelHostSchemaAllOf, getOfficialExternalChannelSecretContract, @@ -20,9 +21,7 @@ const SECRET_REF_SCHEMA = SecretRefSchema.toJSONSchema({ }) as JsonSchemaObject; function asSchemaObject(value: unknown): JsonSchemaObject | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as JsonSchemaObject) - : undefined; + return asOptionalRecord(value) as JsonSchemaObject | undefined; } function widenProperties( diff --git a/src/config/sessions/session-accessor.sqlite-archive.ts b/src/config/sessions/session-accessor.sqlite-archive.ts index eaa300fab69a..72f8ecb4aa39 100644 --- a/src/config/sessions/session-accessor.sqlite-archive.ts +++ b/src/config/sessions/session-accessor.sqlite-archive.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Worker } from "node:worker_threads"; +import { toStringifiedError } from "@openclaw/normalization-core/error-coercion"; import { syncDirectoryBestEffortSync } from "../../infra/directory-durability.js"; import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; import { @@ -205,10 +206,6 @@ function resolveSourceWorkerExecArgv(): string[] { return ["--import", `data:text/javascript,${encodeURIComponent(registerTsx)}`]; } -function normalizeArchiveWorkerError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - function spawnSqliteTranscriptArchiveWorker( plans: readonly TranscriptArchiveWorkerPlan[], ): Promise { @@ -223,7 +220,7 @@ function spawnSqliteTranscriptArchiveWorker( execArgv: sourceWorkerExecArgv, }); } catch (error) { - return Promise.reject(normalizeArchiveWorkerError(error)); + return Promise.reject(toStringifiedError(error)); } return new Promise((resolve, reject) => { @@ -235,7 +232,7 @@ function spawnSqliteTranscriptArchiveWorker( worker.once("error", (error) => { // An uncaught Worker error is followed by exit. Wait for that event so // callers never race the Worker's SQLite/file handles on Windows. - workerError = normalizeArchiveWorkerError(error); + workerError = toStringifiedError(error); }); worker.once("exit", (code) => { worker.removeAllListeners(); diff --git a/src/config/sessions/session-accessor.sqlite-session-row.ts b/src/config/sessions/session-accessor.sqlite-session-row.ts index fad46fc8de4f..79ead255cf00 100644 --- a/src/config/sessions/session-accessor.sqlite-session-row.ts +++ b/src/config/sessions/session-accessor.sqlite-session-row.ts @@ -1,3 +1,4 @@ +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { deliveryContextFromSession, sessionDeliveryChannel, @@ -164,7 +165,7 @@ function resolveSqliteSessionCreatedAt(entry: SessionEntry, updatedAt: number): } function finiteSqliteNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; + return asFiniteNumber(value) ?? null; } function resolveSqliteSessionChannel(entry: SessionEntry): string | null { diff --git a/src/config/sessions/session-transcript-reconcile.ts b/src/config/sessions/session-transcript-reconcile.ts index e316a53af961..ff483ffac1a4 100644 --- a/src/config/sessions/session-transcript-reconcile.ts +++ b/src/config/sessions/session-transcript-reconcile.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Worker, type WorkerOptions } from "node:worker_threads"; +import { toStringifiedError } from "@openclaw/normalization-core/error-coercion"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { openOpenClawAgentDatabase, @@ -92,10 +93,6 @@ function nextProjectionClaimId(): number { return -randomInt(1, 2 ** 47); } -function normalizeReconcileError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - // Node Worker messages take a transfer list, unlike Window.postMessage. // Keep the empty list explicit so the platform contract stays unambiguous. function continueProjectionWorker(worker: Worker, accepted: boolean): void { @@ -242,7 +239,7 @@ export async function reconcileSessionTranscriptIndexes( { workerData: input, execArgv: sourceWorkerExecArgv }, ); } catch (error) { - throw normalizeReconcileError(error); + throw toStringifiedError(error); } return new Promise((resolve, reject) => { @@ -282,7 +279,7 @@ export async function reconcileSessionTranscriptIndexes( (database) => deleteOrphanedTranscriptIndexRowsInTransaction(database.db), ); } catch (error) { - settle(() => reject(normalizeReconcileError(error)), true); + settle(() => reject(toStringifiedError(error)), true); return; } settle(() => resolve({ reconciledSessions }), false); @@ -321,14 +318,14 @@ export async function reconcileSessionTranscriptIndexes( } continueProjectionWorker(worker, owned); } catch (error) { - settle(() => reject(normalizeReconcileError(error)), true); + settle(() => reject(toStringifiedError(error)), true); } }; worker.on("message", (message: SessionTranscriptReconcileWorkerMessage) => { void handleMessage(message); }); worker.once("error", (error) => { - settle(() => reject(normalizeReconcileError(error)), true); + settle(() => reject(toStringifiedError(error)), true); }); worker.once("exit", (code) => { if (doneReceived && code === 0) { diff --git a/src/config/sessions/transcript-append.test-support.ts b/src/config/sessions/transcript-append.test-support.ts index 112384c7f61c..e5512bcb68c6 100644 --- a/src/config/sessions/transcript-append.test-support.ts +++ b/src/config/sessions/transcript-append.test-support.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { resolveTimestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; +import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; import type { AgentMessage } from "../../agents/runtime/index.js"; import { redactTranscriptMessage } from "../../agents/transcript-redact.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -118,7 +119,7 @@ function readTranscriptLineInfo(line: string): TranscriptLineInfo { if (parsed.type === "session") { return { isNonSessionEntry: false, hasParentLinkedEntry: false }; } - const entryId = normalizeEntryId(parsed.id); + const entryId = readNonBlankString(parsed.id); if (!entryId) { return { isNonSessionEntry: true, hasParentLinkedEntry: false }; } @@ -134,13 +135,13 @@ function readTranscriptLineInfo(line: string): TranscriptLineInfo { }; } if (parsed.type === "leaf") { - const targetId = parsed.targetId === null ? null : normalizeEntryId(parsed.targetId); + const targetId = parsed.targetId === null ? null : readNonBlankString(parsed.targetId); const appendParentId = parsed.appendParentId === undefined ? undefined : parsed.appendParentId === null ? null - : normalizeEntryId(parsed.appendParentId); + : readNonBlankString(parsed.appendParentId); if ( (parsed.targetId !== null && targetId === undefined) || (parsed.appendParentId !== undefined && appendParentId === undefined) || @@ -179,10 +180,6 @@ function readTranscriptLineInfo(line: string): TranscriptLineInfo { } } -function normalizeEntryId(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value : undefined; -} - function generateEntryId(existingIds: Set): string { for (let attempt = 0; attempt < 100; attempt += 1) { const id = randomUUID().slice(0, 8); @@ -390,7 +387,7 @@ async function migrateLinearTranscriptToParentLinked(transcriptPath: string): Pr output.push(serializeJsonlLine({ ...record, version: CURRENT_SESSION_VERSION })); continue; } - const id = normalizeEntryId(record.id) ?? generateEntryId(existingIds); + const id = readNonBlankString(record.id) ?? generateEntryId(existingIds); existingIds.add(id); record.id = id; if (!Object.hasOwn(record, "parentId")) { diff --git a/src/config/sessions/transcript-recent-window.ts b/src/config/sessions/transcript-recent-window.ts index 7b2d8028fbbc..b978b98707c0 100644 --- a/src/config/sessions/transcript-recent-window.ts +++ b/src/config/sessions/transcript-recent-window.ts @@ -1,6 +1,6 @@ -export function normalizeTranscriptTimestamp(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; + +export const normalizeTranscriptTimestamp = asFiniteNumber; export function isWithinTranscriptWindow( timestamp: number | undefined, diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 4b9f77ce1313..b003a38aa830 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -5,6 +5,7 @@ import type { SessionAcpIdentity, SessionAcpMeta, } from "@openclaw/acp-core/types"; +import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce"; import type { QueueMode } from "../../../packages/gateway-protocol/src/schema/logs-chat.js"; import type { SessionRunStatus } from "../../../packages/gateway-protocol/src/schema/sessions-row.js"; @@ -810,11 +811,7 @@ export function mergeSessionEntryPreserveActivity( } export function resolveSessionTotalTokens(entry?: Pick | null) { - const total = entry?.totalTokens; - if (typeof total !== "number" || !Number.isFinite(total) || total < 0) { - return undefined; - } - return total; + return asNonNegativeFiniteNumber(entry?.totalTokens); } export function resolveFreshSessionTotalTokens( diff --git a/src/config/types.secrets.ts b/src/config/types.secrets.ts index 9347dddfad27..bea6be202812 100644 --- a/src/config/types.secrets.ts +++ b/src/config/types.secrets.ts @@ -1,5 +1,6 @@ import { expectDefined } from "@openclaw/normalization-core"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; // Defines secret reference and resolution configuration types. /** Supported secret reference backends in config. */ @@ -204,11 +205,7 @@ export function hasConfiguredSecretInput(value: unknown, defaults?: SecretDefaul /** Trim a literal secret input string while leaving non-string inputs unresolved. */ export function normalizeSecretInputString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; + return normalizeOptionalString(value); } function formatSecretRefLabel(ref: SecretRef): string { diff --git a/src/context-engine/runtime-settings.ts b/src/context-engine/runtime-settings.ts index 5c2ad151f4b6..6460e1d3876b 100644 --- a/src/context-engine/runtime-settings.ts +++ b/src/context-engine/runtime-settings.ts @@ -1,3 +1,4 @@ +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce"; import type { ContextEngineHostSupport } from "./host-compat.js"; import type { @@ -25,10 +26,6 @@ const RUNTIME_REASON_PATTERNS: Array<[ContextEngineRuntimeReasonCode, RegExp]> = ["provider_unavailable", /provider|primary|unavailable/iu], ]; -function normalizeNullableNumber(value: number | null | undefined): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - function normalizeReasonCode(value: OptionalString): ContextEngineRuntimeReasonCode | null { const normalized = normalizeNullableString(value); if (!normalized) { @@ -95,8 +92,8 @@ export function buildContextEngineRuntimeSettings(params: { label: normalizeNullableString(params.contextEngineHost.label), }, limits: { - promptTokenBudget: normalizeNullableNumber(params.promptTokenBudget), - maxOutputTokens: normalizeNullableNumber(params.maxOutputTokens), + promptTokenBudget: asFiniteNumber(params.promptTokenBudget) ?? null, + maxOutputTokens: asFiniteNumber(params.maxOutputTokens) ?? null, }, diagnostics: { fallbackReason, diff --git a/src/cron/run-diagnostics-normalize.ts b/src/cron/run-diagnostics-normalize.ts index 4a2b2c80ce33..8335a8db650d 100644 --- a/src/cron/run-diagnostics-normalize.ts +++ b/src/cron/run-diagnostics-normalize.ts @@ -1,4 +1,5 @@ /** Dependency-light normalization helpers for stored cron run diagnostics. */ +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; @@ -76,10 +77,7 @@ export function normalizeDiagnosticToolName(value: unknown): string | undefined } export function normalizeExitCode(value: unknown): number | null | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - return value === null ? null : undefined; + return asFiniteNumber(value) ?? (value === null ? null : undefined); } export function tailText(value: string, maxChars: number): string { diff --git a/src/cron/service/failure-alerts.ts b/src/cron/service/failure-alerts.ts index 3557acd3231a..d7bd70dd7004 100644 --- a/src/cron/service/failure-alerts.ts +++ b/src/cron/service/failure-alerts.ts @@ -1,5 +1,8 @@ /** Resolves and emits cron failure-alert notifications. */ -import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { classifyOAuthRefreshFailure } from "../../agents/auth-profiles/oauth-refresh-failure.js"; import type { FailoverReason } from "../../agents/failover/signal.js"; @@ -62,14 +65,6 @@ function normalizeFailureAlertRecipient(channel: CronMessageChannel, to: string) } } -function normalizeTo(input: unknown): string | undefined { - if (typeof input !== "string") { - return undefined; - } - const to = input.trim(); - return to ? to : undefined; -} - function clampPositiveInt(value: unknown, fallback: number): number { if (typeof value !== "number" || !Number.isFinite(value)) { return fallback; @@ -104,9 +99,11 @@ export function resolveFailureAlert( const mode = jobConfig?.mode ?? globalConfig?.mode; const inheritsGlobalMode = !jobConfig?.mode || jobConfig.mode === (globalConfig?.mode ?? "announce"); - const jobTo = normalizeTo(jobConfig?.to); + const jobTo = normalizeOptionalString(jobConfig?.to); const jobChannel = resolveFailureAlertChannel(jobConfig?.channel, jobTo); - const configuredGlobalTo = inheritsGlobalMode ? normalizeTo(globalConfig?.to) : undefined; + const configuredGlobalTo = inheritsGlobalMode + ? normalizeOptionalString(globalConfig?.to) + : undefined; const globalChannel = inheritsGlobalMode ? resolveFailureAlertChannel(globalConfig?.channel, configuredGlobalTo) : undefined; @@ -115,7 +112,7 @@ export function resolveFailureAlert( const inheritsGlobalRoute = inheritsGlobalMode && (mode === "webhook" || !jobChannel || jobChannel === globalChannel); const globalTo = inheritsGlobalRoute ? configuredGlobalTo : undefined; - const deliveryTo = normalizeTo(job.delivery?.to); + const deliveryTo = normalizeOptionalString(job.delivery?.to); const deliveryChannel = resolveFailureAlertChannel(job.delivery?.channel, deliveryTo); const channel = jobChannel ?? globalChannel ?? deliveryChannel ?? "last"; const inheritsDeliveryChannel = diff --git a/src/gateway/chat-display-projection.history.ts b/src/gateway/chat-display-projection.history.ts index b83678357b98..7fa32e57bc44 100644 --- a/src/gateway/chat-display-projection.history.ts +++ b/src/gateway/chat-display-projection.history.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { expectDefined } from "@openclaw/normalization-core"; +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { OPENCLAW_RUNTIME_CONTEXT_CUSTOM_TYPE } from "../agents/internal-runtime-context.js"; @@ -174,12 +175,7 @@ function isSubagentAnnounceInterSessionUserMessage(message: Record, key: string): number | undefined { - const value = record[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(record[key]); } export function githubApiToken(env: NodeJS.ProcessEnv = process.env): string | undefined { diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts index 9d438ab87a8b..27ae7f696d4d 100644 --- a/src/gateway/gateway-acp-bind.live.test.ts +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import net from "node:net"; import os from "node:os"; import path from "node:path"; +import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce"; import { describe, expect, it } from "vitest"; import { renderCatFacePngBase64 } from "../../test/helpers/live-image-probe.js"; import { getAcpRuntimeBackend } from "../acp/runtime/registry.js"; @@ -196,12 +197,6 @@ function resolveLiveParentModel(): string { ); } -function resolveModelObject(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - async function prepareCodexHomeForLiveBindTest(tempRoot: string): Promise { const home = process.env.HOME?.trim(); const sourceCodexHome = process.env.CODEX_HOME?.trim() || (home ? path.join(home, ".codex") : ""); @@ -681,7 +676,7 @@ describeLive("gateway live (ACP bind)", () => { defaults: { ...cfg.agents?.defaults, model: { - ...resolveModelObject(cfg.agents?.defaults?.model), + ...asNonArrayRecord(cfg.agents?.defaults?.model), primary: parentModel, }, models: { diff --git a/src/gateway/live-agent-probes.ts b/src/gateway/live-agent-probes.ts index 250d7a95985c..0161a04e31dc 100644 --- a/src/gateway/live-agent-probes.ts +++ b/src/gateway/live-agent-probes.ts @@ -8,6 +8,7 @@ import { resolveTimestampMsToIsoString, } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { isTruthyEnvValue } from "../infra/env.js"; import { runExec } from "../process/exec.js"; const LIVE_CRON_PROBE_DELAY_SECONDS = 7 * 24 * 60 * 60; @@ -61,15 +62,7 @@ export function assertLiveImageProbeReply(text: string): void { export function shouldRunLiveImageProbe(params: { agent: string; override?: string }): boolean { const override = params.override?.trim(); if (override) { - switch (normalizeOptionalLowercaseString(override)) { - case "1": - case "on": - case "true": - case "yes": - return true; - default: - return false; - } + return isTruthyEnvValue(override); } return normalizeOptionalLowercaseString(params.agent) !== "opencode"; } diff --git a/src/gateway/server-methods/attach.ts b/src/gateway/server-methods/attach.ts index 3361d2198a52..4b60bc29dfed 100644 --- a/src/gateway/server-methods/attach.ts +++ b/src/gateway/server-methods/attach.ts @@ -1,3 +1,4 @@ +import { asPositiveFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; @@ -16,11 +17,6 @@ import { } from "../mcp-http.loopback-runtime.js"; import type { GatewayRequestHandlers } from "./types.js"; -function readPositiveNumber(params: Record, key: string): number | undefined { - const value = params[key]; - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; -} - export const attachHandlers: GatewayRequestHandlers = { "attach.grant": async ({ params, respond, context }) => { const grantParams = asRecord(params); @@ -56,7 +52,7 @@ export const attachHandlers: GatewayRequestHandlers = { const grant = mintAttachGrant({ sessionKey, ...(agentId ? { agentId } : {}), - ttlMs: readPositiveNumber(grantParams, "ttlMs"), + ttlMs: asPositiveFiniteNumber(grantParams.ttlMs), }); respond(true, { sessionKey: grant.sessionKey, diff --git a/src/gateway/server-methods/chat-history-pages.ts b/src/gateway/server-methods/chat-history-pages.ts index 167298cb4356..1475059d6875 100644 --- a/src/gateway/server-methods/chat-history-pages.ts +++ b/src/gateway/server-methods/chat-history-pages.ts @@ -1,3 +1,4 @@ +import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveSessionTranscriptActiveLeafEntryId } from "../../config/sessions/session-accessor.js"; import { @@ -30,8 +31,7 @@ export function readChatHistoryMessageId(message: unknown): string | undefined { export function readChatHistoryMessageSeq(message: unknown): number | undefined { const metadata = asOptionalRecord(asOptionalRecord(message)?.["__openclaw"]); - const seq = metadata?.seq; - return typeof seq === "number" && Number.isSafeInteger(seq) && seq > 0 ? seq : undefined; + return asPositiveSafeInteger(metadata?.seq); } type ChatHistoryPage = { diff --git a/src/gateway/server-methods/plugins.test.ts b/src/gateway/server-methods/plugins.test.ts index 5c07d33cc29b..8430dc300639 100644 --- a/src/gateway/server-methods/plugins.test.ts +++ b/src/gateway/server-methods/plugins.test.ts @@ -1,6 +1,6 @@ // Plugin management Gateway handler tests cover DTO mapping, trust errors, and reload planning. -import { expectDefined } from "@openclaw/normalization-core"; +import { coerceErrorMessage, expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; const managementMocks = vi.hoisted(() => { @@ -38,8 +38,7 @@ const searchMock = vi.hoisted(() => vi.fn()); vi.mock("../../plugins/management-service.js", () => ({ ManagedPluginLifecycleError: managementMocks.ManagedPluginLifecycleError, - formatManagedPluginLifecycleError: (error: unknown) => - error instanceof Error ? error.message : String(error), + formatManagedPluginLifecycleError: coerceErrorMessage, installManagedPlugin: (...args: unknown[]) => managementMocks.install(...args), listManagedPlugins: (...args: unknown[]) => managementMocks.list(...args), setManagedPluginEnabled: (...args: unknown[]) => managementMocks.setEnabled(...args), diff --git a/src/gateway/server-methods/record-shared.ts b/src/gateway/server-methods/record-shared.ts index 44c8ca212e9f..4b5768582ee4 100644 --- a/src/gateway/server-methods/record-shared.ts +++ b/src/gateway/server-methods/record-shared.ts @@ -1,11 +1,2 @@ -/** - * Small normalization helpers shared by gateway request handlers. - */ -/** Returns a non-empty trimmed string, or `undefined` for non-string input. */ -export function normalizeTrimmedString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} +/** Non-empty trimmed-string normalization shared by gateway request handlers. */ +export { normalizeOptionalString as normalizeTrimmedString } from "@openclaw/normalization-core/string-coerce"; diff --git a/src/gateway/server-methods/restart.ts b/src/gateway/server-methods/restart.ts index 9eddf9d022cc..a2a9339c739d 100644 --- a/src/gateway/server-methods/restart.ts +++ b/src/gateway/server-methods/restart.ts @@ -1,5 +1,6 @@ // Gateway RPC handlers for safe gateway restart requests and preflight state. import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import { readActiveGatewayLockIdentity } from "../../infra/gateway-lock.js"; @@ -12,7 +13,7 @@ import { requestGatewayRestartWithSignalAdmission } from "../../infra/restart.js import type { GatewayRequestHandlers } from "./types.js"; function isRestartRequestParams(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return isRecord(value); } function normalizeReason(value: unknown): string | undefined { diff --git a/src/gateway/server-methods/sessions-files.ts b/src/gateway/server-methods/sessions-files.ts index b64110a7806f..ebc36a2ecd80 100644 --- a/src/gateway/server-methods/sessions-files.ts +++ b/src/gateway/server-methods/sessions-files.ts @@ -144,20 +144,12 @@ function sessionFilesError(type: string, message: string, details?: Record): string | undefined { return ( - normalizePathValue(args.path) ?? - normalizePathValue(args.file_path) ?? - normalizePathValue(args.filePath) ?? - normalizePathValue(args.file) + normalizeOptionalString(args.path) ?? + normalizeOptionalString(args.file_path) ?? + normalizeOptionalString(args.filePath) ?? + normalizeOptionalString(args.file) ); } @@ -196,11 +188,11 @@ function addStructuredPatchFiles(files: Map, changes: unkno } for (const changeValue of changes) { const change = asOptionalObjectRecord(changeValue); - addTouchedFile(files, normalizePathValue(change?.path), "modified"); + addTouchedFile(files, normalizeOptionalString(change?.path), "modified"); const kind = asOptionalObjectRecord(change?.kind); addTouchedFile( files, - normalizePathValue(kind?.move_path) ?? normalizePathValue(kind?.movePath), + normalizeOptionalString(kind?.move_path) ?? normalizeOptionalString(kind?.movePath), "modified", ); } @@ -536,12 +528,12 @@ function loadSessionFileRoot(params: { sessionKey: string; agentId?: string }) { parseAgentSessionKey(params.sessionKey)?.agentId ?? resolveDefaultAgentId(loaded.cfg), ); - const spawnedCwd = normalizePathValue(loaded.entry.spawnedCwd); - const spawnedWorkspaceDir = normalizePathValue(loaded.entry.spawnedWorkspaceDir); + const spawnedCwd = normalizeOptionalString(loaded.entry.spawnedCwd); + const spawnedWorkspaceDir = normalizeOptionalString(loaded.entry.spawnedWorkspaceDir); const configuredWorkspaceDir = spawnedCwd || spawnedWorkspaceDir ? undefined - : normalizePathValue(resolveAgentWorkspaceDir(loaded.cfg, agentId)); + : normalizeOptionalString(resolveAgentWorkspaceDir(loaded.cfg, agentId)); // Keep this cwd precedence aligned with sessions.diff so the advertised // checkout state cannot disagree with the panel's fallback result. const diffCwd = spawnedCwd ?? spawnedWorkspaceDir ?? configuredWorkspaceDir; @@ -669,7 +661,7 @@ async function buildBrowserResult(params: { if (!params.root) { return undefined; } - const search = normalizePathValue(params.search); + const search = normalizeOptionalString(params.search); const relevance = buildSessionRelevanceMap(params.files, params.root, params.fileRoot); if (search) { const result = await searchBrowserEntries({ diff --git a/src/gateway/server-runtime-services.test.ts b/src/gateway/server-runtime-services.test.ts index 173e37f0ba57..dfa778d378bc 100644 --- a/src/gateway/server-runtime-services.test.ts +++ b/src/gateway/server-runtime-services.test.ts @@ -70,11 +70,6 @@ vi.mock("../sessions/session-upstream-monitor.js", () => ({ startSessionUpstreamMonitor: hoisted.startSessionUpstreamMonitor, })); -vi.mock("../infra/env.js", () => ({ - isTruthyEnvValue: (value?: string) => - ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? ""), -})); - vi.mock("../infra/outbound/deliver.js", () => ({ deliverOutboundPayloads: hoisted.deliverOutboundPayloads, deliverOutboundPayloadsInternal: hoisted.deliverOutboundPayloads, diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 5f82ab511df7..b6ac0cdb1241 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -119,8 +119,7 @@ function shouldCheckRestartSentinel(env: NodeJS.ProcessEnv = process.env): boole } function shouldSkipStartupModelPrewarm(env: NodeJS.ProcessEnv = process.env): boolean { - const raw = env[SKIP_STARTUP_MODEL_PREWARM_ENV]?.trim().toLowerCase(); - return raw === "1" || raw === "true" || raw === "yes" || raw === "on"; + return isTruthyEnvValue(env[SKIP_STARTUP_MODEL_PREWARM_ENV]); } function schedulePostAttachUpdateSentinelRefresh(params: { diff --git a/src/gateway/session-utils-core.ts b/src/gateway/session-utils-core.ts index 2071ca214f2e..18adc1093bb3 100644 --- a/src/gateway/session-utils-core.ts +++ b/src/gateway/session-utils-core.ts @@ -1,4 +1,7 @@ -import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; +import { + asNonNegativeFiniteNumber, + asPositiveFiniteNumber, +} from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { countActiveDescendantRuns, @@ -79,7 +82,7 @@ export function deriveSessionTitle( } export function resolvePositiveNumber(value: number | null | undefined): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; + return asPositiveFiniteNumber(value); } export function deriveSessionUnread( diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index a345d4e3e0b4..c039b38bde6e 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 { + asNonNegativeFiniteNumber, asPositiveFiniteNumber as resolvePositiveUsageNumber, resolveIntegerOption, resolveNonNegativeIntegerOption, @@ -903,7 +904,7 @@ function extractTranscriptUsageCost(raw: unknown): number | undefined { return undefined; } const total = (cost as { total?: unknown }).total; - return typeof total === "number" && Number.isFinite(total) && total >= 0 ? total : undefined; + return asNonNegativeFiniteNumber(total); } function extractTranscriptContentEstimatedChars(content: unknown): number { diff --git a/src/infra/clawhub-client.ts b/src/infra/clawhub-client.ts index be57ad9cb97e..105cb03ddd34 100644 --- a/src/infra/clawhub-client.ts +++ b/src/infra/clawhub-client.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { retryClawHubRead } from "./clawhub-retry.js"; +import { isTruthyEnvValue } from "./env.js"; import { readResponseTextSnippet, readResponseWithLimit } from "./http-body.js"; import { parseStrictNonNegativeInteger } from "./parse-finite-number.js"; @@ -464,5 +465,5 @@ export function isClawHubTelemetryDisabled(): boolean { if (!raw) { return false; } - return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase()); + return isTruthyEnvValue(raw); } diff --git a/src/infra/clawhub-skill-security.ts b/src/infra/clawhub-skill-security.ts index d326cd4d7fb5..1d102edc3db5 100644 --- a/src/infra/clawhub-skill-security.ts +++ b/src/infra/clawhub-skill-security.ts @@ -1,4 +1,5 @@ // Shared owner-qualified ClawHub security verdict resolution. +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord as readObject } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import pLimit from "p-limit"; @@ -82,8 +83,7 @@ function readOptionalStringField(value: unknown, field: string): string | undefi } function readOptionalNumberField(value: unknown, field: string): number | undefined { - const raw = readObject(value)?.[field]; - return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined; + return asFiniteNumber(readObject(value)?.[field]); } function normalizeReason(reason: string | null | undefined): string { diff --git a/src/infra/device-identity-store.ts b/src/infra/device-identity-store.ts index da3b978c3e70..2a543729b2aa 100644 --- a/src/infra/device-identity-store.ts +++ b/src/infra/device-identity-store.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion"; import type { Insertable, Selectable } from "kysely"; import { withOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; @@ -109,7 +110,7 @@ function keyPairMatches(publicKeyPem: string, privateKeyPem: string): boolean { } function parseCreatedAtMs(value: unknown): number | null { - return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; + return asSafeIntegerInRange(value, { min: 0 }) ?? null; } /** Validate persisted key material and return the canonical runtime shape. */ diff --git a/src/infra/exec-approval-session-target.ts b/src/infra/exec-approval-session-target.ts index 1073338735bc..78cc8ceb34a9 100644 --- a/src/infra/exec-approval-session-target.ts +++ b/src/infra/exec-approval-session-target.ts @@ -43,7 +43,9 @@ type ApprovalRequestOriginTargetResolver = { resolveFallbackTarget?: (request: ApprovalRequestLike) => TTarget | null; }; -function normalizeOptionalThreadValue(value?: string | number | null): string | number | undefined { +function normalizeExecApprovalThreadValue( + value?: string | number | null, +): string | number | undefined { if (typeof value === "number") { return Number.isFinite(value) ? value : undefined; } @@ -140,7 +142,7 @@ export function resolveExecApprovalSessionTarget(params: { turnSourceChannel: normalizeOptionalString(params.turnSourceChannel), turnSourceTo: normalizeOptionalString(params.turnSourceTo), turnSourceAccountId: normalizeOptionalString(params.turnSourceAccountId), - turnSourceThreadId: normalizeOptionalThreadValue(params.turnSourceThreadId), + turnSourceThreadId: normalizeExecApprovalThreadValue(params.turnSourceThreadId), }); if (!target.to) { return null; @@ -150,7 +152,7 @@ export function resolveExecApprovalSessionTarget(params: { channel: normalizeOptionalString(target.channel), to: target.to, accountId: normalizeOptionalString(target.accountId), - threadId: normalizeOptionalThreadValue(target.threadId), + threadId: normalizeExecApprovalThreadValue(target.threadId), }; } diff --git a/src/infra/outbound/channel-target.ts b/src/infra/outbound/channel-target.ts index 663167fc3d32..65e7bc1bf235 100644 --- a/src/infra/outbound/channel-target.ts +++ b/src/infra/outbound/channel-target.ts @@ -1,14 +1,11 @@ // Message-action target helpers bridge canonical `target` params into legacy // per-action fields while rejecting mixed destination arguments. import { - hasNonEmptyString as sharedHasNonEmptyString, + hasNonEmptyString, normalizeOptionalString, } from "../../../packages/normalization-core/src/string-coerce.js"; import { MESSAGE_ACTION_TARGET_MODE } from "./message-action-spec.js"; -/** Shared non-empty string guard for message-action target params. */ -export const hasNonEmptyString = sharedHasNonEmptyString; - /** Human-readable description for a single message-action destination. */ export const CHANNEL_TARGET_DESCRIPTION = "Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack/Mattermost , or iMessage handle/chat_id"; diff --git a/src/infra/pairing-files.ts b/src/infra/pairing-files.ts index 3b5cb3cfcb92..42ff599517ce 100644 --- a/src/infra/pairing-files.ts +++ b/src/infra/pairing-files.ts @@ -1,5 +1,6 @@ // Shared JSON state helpers for pairing namespaces. import path from "node:path"; +import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveStateDir } from "../config/paths.js"; export { createAsyncLock, readJsonIfExists } from "./json-files.js"; @@ -17,10 +18,7 @@ export function resolvePairingPaths(baseDir: string | undefined, subdir: string) /** Coerce persisted pairing maps, treating malformed arrays/scalars as empty state. */ export function coercePairingStateRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return {}; - } - return value as Record; + return asNonArrayRecord(value) as Record; } /** Remove pending requests older than the caller's pairing TTL. */ diff --git a/src/infra/restart-handoff.ts b/src/infra/restart-handoff.ts index 0feed2163b6f..44f7908c8a69 100644 --- a/src/infra/restart-handoff.ts +++ b/src/infra/restart-handoff.ts @@ -1,6 +1,7 @@ // Persists short-lived gateway restart handoff metadata. import { randomUUID } from "node:crypto"; import type { DatabaseSync } from "node:sqlite"; +import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; @@ -136,7 +137,7 @@ export function formatGatewayRestartHandoffDiagnostic( } function normalizePid(pid: number | undefined): number | null { - return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 ? pid : null; + return asPositiveSafeInteger(pid) ?? null; } function normalizeText(value: unknown, maxLength: number): string | undefined { diff --git a/src/infra/restart-intent.ts b/src/infra/restart-intent.ts index e2123224d76e..d48da1b683c9 100644 --- a/src/infra/restart-intent.ts +++ b/src/infra/restart-intent.ts @@ -1,4 +1,5 @@ // Persists short-lived gateway restart intent for supervisor SIGTERM handoff. +import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; @@ -34,7 +35,7 @@ export type GatewayRestartIntent = { }; function normalizeRestartIntentPid(pid: number | undefined): number | null { - return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 ? pid : null; + return asPositiveSafeInteger(pid) ?? null; } export function normalizeRestartIntentReason(reason: string | undefined): string | undefined { diff --git a/src/infra/sqlite-integrity.ts b/src/infra/sqlite-integrity.ts index 9a5e6911fca8..ef3b53fd0c44 100644 --- a/src/infra/sqlite-integrity.ts +++ b/src/infra/sqlite-integrity.ts @@ -1,4 +1,5 @@ import type { DatabaseSync } from "node:sqlite"; +import { toStringifiedError } from "@openclaw/normalization-core/error-coercion"; import { openNodeSqliteDatabase } from "./node-sqlite.js"; import { readStableSqliteFileGeneration, @@ -142,7 +143,7 @@ function bindSqliteIntegrityConfirmation( } function failedSqliteIntegrityConfirmation(error: unknown): UnboundSqliteIntegrityConfirmation { - const normalized = error instanceof Error ? error : new Error(String(error)); + const normalized = toStringifiedError(error); return { status: "failed", error: normalized, @@ -151,7 +152,7 @@ function failedSqliteIntegrityConfirmation(error: unknown): UnboundSqliteIntegri } function unboundSqliteIntegrityFailure(error: unknown): SqliteIntegrityConfirmation { - const normalized = error instanceof Error ? error : new Error(String(error)); + const normalized = toStringifiedError(error); return { status: "failed", error: normalized, terminal: false }; } @@ -160,7 +161,7 @@ function closeSqliteDatabase(database: DatabaseSync): Error | undefined { database.close(); return undefined; } catch (error) { - return error instanceof Error ? error : new Error(String(error)); + return toStringifiedError(error); } } diff --git a/src/infra/state-migrations.managed-outgoing-images.ts b/src/infra/state-migrations.managed-outgoing-images.ts index 55c7f0eb5a49..d1246798e3f4 100644 --- a/src/infra/state-migrations.managed-outgoing-images.ts +++ b/src/infra/state-migrations.managed-outgoing-images.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { readNonBlankString as optionalNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { @@ -132,7 +133,7 @@ function nullableNonNegativeInteger(value: unknown): number | null | undefined { if (value === null) { return null; } - return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined; + return asSafeIntegerInRange(value, { min: 0 }); } function parseLegacyManagedImageRecord(params: { diff --git a/src/infra/update-control-plane-sentinel.ts b/src/infra/update-control-plane-sentinel.ts index 61840f5ff707..9c91f357e8e1 100644 --- a/src/infra/update-control-plane-sentinel.ts +++ b/src/infra/update-control-plane-sentinel.ts @@ -1,6 +1,7 @@ // Persists update-control-plane sentinel files used by updater coordination. import fs from "node:fs/promises"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; import { markUpdateRestartSentinelFailure, writeRestartSentinel, @@ -57,24 +58,22 @@ export function isPendingControlPlaneUpdateRestartSentinel( ); } -function normalizeText(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; -} - function normalizeMeta(value: unknown): UpdateRestartSentinelMeta | null { if (!isRecord(value)) { return null; } - const sessionKey = normalizeText(value.sessionKey); - const threadId = normalizeText(value.threadId); - const handoffId = normalizeText(value.handoffId); - const root = normalizeText(value.root); + const sessionKey = readNonBlankString(value.sessionKey); + const threadId = readNonBlankString(value.threadId); + const handoffId = readNonBlankString(value.handoffId); + const root = readNonBlankString(value.root); const channel = isRecord(value.deliveryContext) - ? normalizeText(value.deliveryContext.channel) + ? readNonBlankString(value.deliveryContext.channel) + : undefined; + const to = isRecord(value.deliveryContext) + ? readNonBlankString(value.deliveryContext.to) : undefined; - const to = isRecord(value.deliveryContext) ? normalizeText(value.deliveryContext.to) : undefined; const accountId = isRecord(value.deliveryContext) - ? normalizeText(value.deliveryContext.accountId) + ? readNonBlankString(value.deliveryContext.accountId) : undefined; const deliveryContext = channel || to || accountId diff --git a/src/logging/diagnostic-support-export.ts b/src/logging/diagnostic-support-export.ts index 52ef9b2b0449..1fce3edfaf90 100644 --- a/src/logging/diagnostic-support-export.ts +++ b/src/logging/diagnostic-support-export.ts @@ -10,6 +10,7 @@ import { buildConfigSchemaCore } from "../config/schema.js"; import { isMissingPathError } from "../infra/errors.js"; import { resolveHomeRelativePath } from "../infra/home-dir.js"; import { readRegularFileSync } from "../infra/regular-file.js"; +import { parseBooleanValue } from "../utils/boolean.js"; import { VERSION } from "../version.js"; import { readDiagnosticStabilityBundleFileSync, @@ -209,24 +210,15 @@ function safeScalar(value: unknown): unknown { function resolveBonjourEnvOverride( env: NodeJS.ProcessEnv, ): NonNullable["bonjourEnvOverride"] { - const raw = env.OPENCLAW_DISABLE_BONJOUR?.trim().toLowerCase(); + const raw = env.OPENCLAW_DISABLE_BONJOUR?.trim(); if (!raw) { return "unset"; } - switch (raw) { - case "1": - case "true": - case "yes": - case "on": - return "force-disabled"; - case "0": - case "false": - case "no": - case "off": - return "force-enabled"; - default: - return "unrecognized"; + const disabled = parseBooleanValue(raw); + if (disabled === true) { + return "force-disabled"; } + return disabled === false ? "force-enabled" : "unrecognized"; } function sortedObjectKeys(value: unknown): string[] { diff --git a/src/meeting-bot/session-runtime.test.ts b/src/meeting-bot/session-runtime.test.ts index 021138dec573..b31f3eb1c16c 100644 --- a/src/meeting-bot/session-runtime.test.ts +++ b/src/meeting-bot/session-runtime.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { coerceErrorMessage } from "@openclaw/normalization-core/error-coercion"; import { afterEach, describe, expect, it, vi } from "vitest"; import { TranscriptsStore } from "../transcripts/store.js"; import { createMeetingSession } from "./session-factory.js"; @@ -134,7 +135,7 @@ function createTestRuntime(params: { >({ logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, logScope: "[meeting-test]", - formatError: (error) => (error instanceof Error ? error.message : String(error)), + formatError: coerceErrorMessage, messages: { previousBrowserLeaveFailed: "previous leave failed", reassignedSessionNote: "reassigned", diff --git a/src/memory-host-sdk/dreaming.ts b/src/memory-host-sdk/dreaming.ts index f970825eb0f4..5b9b46b21094 100644 --- a/src/memory-host-sdk/dreaming.ts +++ b/src/memory-host-sdk/dreaming.ts @@ -10,6 +10,7 @@ import { lowercasePreservingWhitespace, normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, + normalizeOptionalString, normalizeStringifiedOptionalString, } from "@openclaw/normalization-core/string-coerce"; import { @@ -176,14 +177,6 @@ const DEFAULT_MEMORY_DEEP_DREAMING_SOURCES: MemoryDeepDreamingSource[] = [ ]; const DEFAULT_MEMORY_REM_DREAMING_SOURCES: MemoryRemDreamingSource[] = ["memory", "daily", "deep"]; -function normalizeTrimmedString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - function normalizeNonNegativeInt(value: unknown, fallback: number): number { // Config integers are decimal-only; Number() would accept hex/exponent forms. return parseStrictNonNegativeInteger(value) ?? fallback; @@ -283,7 +276,7 @@ function resolveExecutionConfig( typeof temperatureRaw === "number" && Number.isFinite(temperatureRaw) && temperatureRaw >= 0 ? Math.min(2, temperatureRaw) : undefined; - const model = normalizeTrimmedString(record?.model) ?? fallback.model; + const model = normalizeOptionalString(record?.model) ?? fallback.model; return { speed: normalizeSpeed(record?.speed) ?? fallback.speed, @@ -315,7 +308,7 @@ export function resolveMemoryDreamingPluginId( const root = asNullableRecord(cfg); const plugins = asNullableRecord(root?.plugins); const slots = asNullableRecord(plugins?.slots); - const configuredSlot = normalizeTrimmedString(slots?.memory); + const configuredSlot = normalizeOptionalString(slots?.memory); if (configuredSlot && normalizeLowercaseStringOrEmpty(configuredSlot) !== "none") { return configuredSlot; } @@ -339,15 +332,15 @@ export function resolveMemoryDreamingConfig(params: { }): MemoryDreamingConfig { const dreaming = asNullableRecord(params.pluginConfig?.dreaming); const frequency = - normalizeTrimmedString(dreaming?.frequency) ?? DEFAULT_MEMORY_DREAMING_FREQUENCY; + normalizeOptionalString(dreaming?.frequency) ?? DEFAULT_MEMORY_DREAMING_FREQUENCY; const timezone = - normalizeTrimmedString(dreaming?.timezone) ?? - normalizeTrimmedString(params.cfg?.agents?.defaults?.userTimezone) ?? + normalizeOptionalString(dreaming?.timezone) ?? + normalizeOptionalString(params.cfg?.agents?.defaults?.userTimezone) ?? DEFAULT_MEMORY_DREAMING_TIMEZONE; const storage = asNullableRecord(dreaming?.storage); const execution = asNullableRecord(dreaming?.execution); const phases = asNullableRecord(dreaming?.phases); - const topLevelModel = normalizeTrimmedString(dreaming?.model); + const topLevelModel = normalizeOptionalString(dreaming?.model); const defaultExecution = resolveExecutionConfig(execution?.defaults, { speed: DEFAULT_MEMORY_DREAMING_SPEED, diff --git a/src/plugin-sdk/file-lock.ts b/src/plugin-sdk/file-lock.ts index be9665b461a6..bcd41d223b9e 100644 --- a/src/plugin-sdk/file-lock.ts +++ b/src/plugin-sdk/file-lock.ts @@ -6,6 +6,7 @@ import { drainFileLockManagerForTest, resetFileLockManagerForTest, } from "@openclaw/fs-safe/file-lock"; +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { isLockOwnerDefinitelyStale, shouldRemoveDeadOwnerOrExpiredLock, @@ -86,9 +87,7 @@ function createCurrentProcessLockPayload(): Record { } function asLockPayload(payload: unknown): Record | null { - return payload && typeof payload === "object" && !Array.isArray(payload) - ? (payload as Record) - : null; + return asNullableRecord(payload); } function sameStatValue(left: number | bigint, right: number | bigint): boolean { diff --git a/src/plugin-sdk/provider-catalog-live-runtime.ts b/src/plugin-sdk/provider-catalog-live-runtime.ts index b148b49ea5dd..cf5f35493b6f 100644 --- a/src/plugin-sdk/provider-catalog-live-runtime.ts +++ b/src/plugin-sdk/provider-catalog-live-runtime.ts @@ -1,3 +1,4 @@ +import { normalizeOptionalString as readLiveModelCatalogString } from "../../packages/normalization-core/src/string-coerce.js"; import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { cancelUnreadResponseBody, readResponseWithLimit } from "../infra/http-body.js"; import { retainSafeHeadersForCrossOriginRedirect } from "../infra/net/redirect-headers.js"; @@ -209,10 +210,6 @@ async function readLiveModelCatalogJson(response: Response, timeoutMs: number): return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer)); } -function readLiveModelCatalogString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function readLiveModelCatalogNextUrl(body: unknown): string | undefined { const record = readLiveModelCatalogRecord(body); if (!record) { diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts index a747aaa0007c..7ded9e3086d6 100644 --- a/src/plugin-sdk/provider-onboard.ts +++ b/src/plugin-sdk/provider-onboard.ts @@ -5,6 +5,7 @@ import { findNormalizedProviderKey, normalizeProviderId, } from "@openclaw/model-catalog-core/provider-id"; +import { isRecord } from "../../packages/normalization-core/src/record-coerce.js"; import { resolvePrimaryStringValue } from "../../packages/normalization-core/src/string-coerce.js"; import { ensureStaticModelAllowlistEntry } from "../agents/model-allowlist-entry.js"; import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js"; @@ -294,7 +295,7 @@ export function createAliasOnlyPresetAppliers(params: { function isMergeableProviderConfig( value: ModelProviderConfig | undefined, ): value is ModelProviderConfig { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); + return isRecord(value); } function mergeOnboardProviderRequest( diff --git a/src/plugin-sdk/test-helpers/provider-discovery-contract.ts b/src/plugin-sdk/test-helpers/provider-discovery-contract.ts index 955ed1474fd6..cf04fb213de1 100644 --- a/src/plugin-sdk/test-helpers/provider-discovery-contract.ts +++ b/src/plugin-sdk/test-helpers/provider-discovery-contract.ts @@ -1,4 +1,6 @@ // Provider discovery contract helpers define reusable discovery tests for provider plugins. +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { runProviderCatalog } from "../../plugins/provider-discovery.js"; import { @@ -162,10 +164,7 @@ function installDiscoveryHooks(state: DiscoveryState, options: DiscoveryContract "Editor-Version": "vscode/1.96.2", "User-Agent": "GitHubCopilotChat/0.26.7", })), - coerceSecretRef: (value: unknown) => - value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null, + coerceSecretRef: asNullableRecord, ensureApiKeyFromOptionEnvOrPrompt: vi.fn(), ensureAuthProfileStore: ensureAuthProfileStoreMock, listProfilesForProvider: listProfilesForProviderMock, @@ -176,8 +175,7 @@ function installDiscoveryHooks(state: DiscoveryState, options: DiscoveryContract ? trimmed : "github.com"; }, - normalizeOptionalSecretInput: (value: unknown) => - typeof value === "string" && value.trim() ? value.trim() : undefined, + normalizeOptionalSecretInput: normalizeOptionalString, resolveNonEnvSecretRefApiKeyMarker: (source: unknown) => typeof source === "string" ? source : "", upsertAuthProfile: vi.fn(), diff --git a/src/plugins/bundled-plugin-naming.test.ts b/src/plugins/bundled-plugin-naming.test.ts index 67999d7fbe67..91ec0bd3aa2f 100644 --- a/src/plugins/bundled-plugin-naming.test.ts +++ b/src/plugins/bundled-plugin-naming.test.ts @@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { describe, expect, it } from "vitest"; import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js"; import { listGitTrackedFiles, toRepoRelativePath } from "../test-utils/repo-files.js"; @@ -50,14 +51,6 @@ function readJsonFile(filePath: string): unknown { return JSON.parse(fs.readFileSync(filePath, "utf8")); } -function normalizeText(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed || undefined; -} - function listBundledPluginDirs(): string[] { const externalDirs = listExternalBundledPluginDirs(); if (externalDirs) { @@ -146,8 +139,8 @@ function readBundledPluginRecords(): BundledPluginRecord[] { const manifest = readJsonFile(manifestPath) as PluginManifestShape; const pkg = readJsonFile(packagePath) as OpenClawPackageShape; - const manifestId = normalizeText(manifest.id); - const packageName = normalizeText(pkg.name); + const manifestId = normalizeOptionalString(manifest.id); + const packageName = normalizeOptionalString(pkg.name); if (!manifestId || !packageName) { return []; } @@ -157,8 +150,8 @@ function readBundledPluginRecords(): BundledPluginRecord[] { dirName, packageName, manifestId, - installNpmSpec: normalizeText(pkg.openclaw?.install?.npmSpec), - channelId: normalizeText(pkg.openclaw?.channel?.id), + installNpmSpec: normalizeOptionalString(pkg.openclaw?.install?.npmSpec), + channelId: normalizeOptionalString(pkg.openclaw?.channel?.id), }, ]; }); diff --git a/src/plugins/contracts/inventory/bundled-capability-metadata.ts b/src/plugins/contracts/inventory/bundled-capability-metadata.ts index d1789df53acc..46f56a1eaa4c 100644 --- a/src/plugins/contracts/inventory/bundled-capability-metadata.ts +++ b/src/plugins/contracts/inventory/bundled-capability-metadata.ts @@ -14,7 +14,7 @@ import { type PluginManifest, } from "../../manifest.js"; import { resolveLoaderPackageRoot } from "../../sdk-alias.js"; -import { uniqueStrings } from "../shared.js"; +import { normalizeContractStringValues } from "../shared.js"; // Build/test inventory only. // Runtime code should prefer manifest/runtime registry queries instead of these snapshots. @@ -111,7 +111,7 @@ function normalizeSetupProviderEnvVars(setup: PluginManifest["setup"]): Record [ provider.id.trim(), - uniqueStrings(provider.envVars ?? [], (value) => + normalizeContractStringValues(provider.envVars ?? [], (value) => typeof value === "string" ? value.trim() : "", ), ] as const, @@ -126,57 +126,68 @@ function buildBundledPluginContractSnapshot( ): BundledPluginContractSnapshot { return { pluginId: manifest.id, - cliBackendIds: uniqueStrings(manifest.cliBackends, (value) => value.trim()), - providerIds: uniqueStrings(manifest.providers, (value) => value.trim()), + cliBackendIds: normalizeContractStringValues(manifest.cliBackends, (value) => value.trim()), + providerIds: normalizeContractStringValues(manifest.providers, (value) => value.trim()), providerEnvVars: normalizeSetupProviderEnvVars(manifest.setup), - workerProviderIds: uniqueStrings(manifest.contracts?.workerProviders, (value) => value.trim()), - embeddingProviderIds: uniqueStrings(manifest.contracts?.embeddingProviders, (value) => + workerProviderIds: normalizeContractStringValues(manifest.contracts?.workerProviders, (value) => value.trim(), ), - speechProviderIds: uniqueStrings(manifest.contracts?.speechProviders, (value) => value.trim()), - realtimeTranscriptionProviderIds: uniqueStrings( + embeddingProviderIds: normalizeContractStringValues( + manifest.contracts?.embeddingProviders, + (value) => value.trim(), + ), + speechProviderIds: normalizeContractStringValues(manifest.contracts?.speechProviders, (value) => + value.trim(), + ), + realtimeTranscriptionProviderIds: normalizeContractStringValues( manifest.contracts?.realtimeTranscriptionProviders, (value) => value.trim(), ), - realtimeVoiceProviderIds: uniqueStrings(manifest.contracts?.realtimeVoiceProviders, (value) => - value.trim(), + realtimeVoiceProviderIds: normalizeContractStringValues( + manifest.contracts?.realtimeVoiceProviders, + (value) => value.trim(), ), - mediaUnderstandingProviderIds: uniqueStrings( + mediaUnderstandingProviderIds: normalizeContractStringValues( manifest.contracts?.mediaUnderstandingProviders, (value) => value.trim(), ), - transcriptSourceProviderIds: uniqueStrings( + transcriptSourceProviderIds: normalizeContractStringValues( manifest.contracts?.transcriptSourceProviders, (value) => value.trim(), ), - documentExtractorIds: uniqueStrings(manifest.contracts?.documentExtractors, (value) => - value.trim(), + documentExtractorIds: normalizeContractStringValues( + manifest.contracts?.documentExtractors, + (value) => value.trim(), ), - imageGenerationProviderIds: uniqueStrings( + imageGenerationProviderIds: normalizeContractStringValues( manifest.contracts?.imageGenerationProviders, (value) => value.trim(), ), - videoGenerationProviderIds: uniqueStrings( + videoGenerationProviderIds: normalizeContractStringValues( manifest.contracts?.videoGenerationProviders, (value) => value.trim(), ), - musicGenerationProviderIds: uniqueStrings( + musicGenerationProviderIds: normalizeContractStringValues( manifest.contracts?.musicGenerationProviders, (value) => value.trim(), ), - webContentExtractorIds: uniqueStrings(manifest.contracts?.webContentExtractors, (value) => - value.trim(), + webContentExtractorIds: normalizeContractStringValues( + manifest.contracts?.webContentExtractors, + (value) => value.trim(), ), - webFetchProviderIds: uniqueStrings(manifest.contracts?.webFetchProviders, (value) => - value.trim(), + webFetchProviderIds: normalizeContractStringValues( + manifest.contracts?.webFetchProviders, + (value) => value.trim(), ), - webSearchProviderIds: uniqueStrings(manifest.contracts?.webSearchProviders, (value) => - value.trim(), + webSearchProviderIds: normalizeContractStringValues( + manifest.contracts?.webSearchProviders, + (value) => value.trim(), ), - migrationProviderIds: uniqueStrings(manifest.contracts?.migrationProviders, (value) => - value.trim(), + migrationProviderIds: normalizeContractStringValues( + manifest.contracts?.migrationProviders, + (value) => value.trim(), ), - toolNames: uniqueStrings(manifest.contracts?.tools, (value) => value.trim()), + toolNames: normalizeContractStringValues(manifest.contracts?.tools, (value) => value.trim()), }; } diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 8067af20b4be..f5159fb6ed95 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -10,7 +10,7 @@ import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS, type BundledPluginContractSnapshot, } from "./inventory/bundled-capability-metadata.js"; -import { uniqueStrings } from "./shared.js"; +import { normalizeContractStringValues } from "./shared.js"; type BundledCapabilityRuntimeRegistry = ReturnType; type CapabilityContractEntry = { @@ -34,7 +34,7 @@ function normalizeProviderEnvVars( return Object.fromEntries( Object.entries(providerEnvVars ?? {}).map(([providerId, envVars]) => [ providerId, - uniqueStrings(envVars), + normalizeContractStringValues(envVars), ]), ); } @@ -44,7 +44,7 @@ function resolvePluginProviderEnvVars(plugin: { }): Record { const envVars: Record = {}; for (const provider of plugin.setup?.providers ?? []) { - envVars[provider.id] = uniqueStrings(provider.envVars ?? []); + envVars[provider.id] = normalizeContractStringValues(provider.envVars ?? []); } return normalizeProviderEnvVars(envVars); } @@ -99,29 +99,49 @@ function resolveBundledManifestContracts(): PluginRegistrationContractEntry[] { ) .map((plugin) => ({ pluginId: plugin.id, - cliBackendIds: uniqueStrings(plugin.cliBackends), - providerIds: uniqueStrings(plugin.providers), + cliBackendIds: normalizeContractStringValues(plugin.cliBackends), + providerIds: normalizeContractStringValues(plugin.providers), providerEnvVars: resolvePluginProviderEnvVars(plugin), - workerProviderIds: uniqueStrings(plugin.contracts?.workerProviders ?? []), - embeddingProviderIds: uniqueStrings(plugin.contracts?.embeddingProviders ?? []), - speechProviderIds: uniqueStrings(plugin.contracts?.speechProviders ?? []), - realtimeTranscriptionProviderIds: uniqueStrings( + workerProviderIds: normalizeContractStringValues(plugin.contracts?.workerProviders ?? []), + embeddingProviderIds: normalizeContractStringValues( + plugin.contracts?.embeddingProviders ?? [], + ), + speechProviderIds: normalizeContractStringValues(plugin.contracts?.speechProviders ?? []), + realtimeTranscriptionProviderIds: normalizeContractStringValues( plugin.contracts?.realtimeTranscriptionProviders ?? [], ), - realtimeVoiceProviderIds: uniqueStrings(plugin.contracts?.realtimeVoiceProviders ?? []), - mediaUnderstandingProviderIds: uniqueStrings( + realtimeVoiceProviderIds: normalizeContractStringValues( + plugin.contracts?.realtimeVoiceProviders ?? [], + ), + mediaUnderstandingProviderIds: normalizeContractStringValues( plugin.contracts?.mediaUnderstandingProviders ?? [], ), - transcriptSourceProviderIds: uniqueStrings(plugin.contracts?.transcriptSourceProviders ?? []), - documentExtractorIds: uniqueStrings(plugin.contracts?.documentExtractors ?? []), - imageGenerationProviderIds: uniqueStrings(plugin.contracts?.imageGenerationProviders ?? []), - videoGenerationProviderIds: uniqueStrings(plugin.contracts?.videoGenerationProviders ?? []), - musicGenerationProviderIds: uniqueStrings(plugin.contracts?.musicGenerationProviders ?? []), - webContentExtractorIds: uniqueStrings(plugin.contracts?.webContentExtractors ?? []), - webFetchProviderIds: uniqueStrings(plugin.contracts?.webFetchProviders ?? []), - webSearchProviderIds: uniqueStrings(plugin.contracts?.webSearchProviders ?? []), - migrationProviderIds: uniqueStrings(plugin.contracts?.migrationProviders ?? []), - toolNames: uniqueStrings(plugin.contracts?.tools ?? []), + transcriptSourceProviderIds: normalizeContractStringValues( + plugin.contracts?.transcriptSourceProviders ?? [], + ), + documentExtractorIds: normalizeContractStringValues( + plugin.contracts?.documentExtractors ?? [], + ), + imageGenerationProviderIds: normalizeContractStringValues( + plugin.contracts?.imageGenerationProviders ?? [], + ), + videoGenerationProviderIds: normalizeContractStringValues( + plugin.contracts?.videoGenerationProviders ?? [], + ), + musicGenerationProviderIds: normalizeContractStringValues( + plugin.contracts?.musicGenerationProviders ?? [], + ), + webContentExtractorIds: normalizeContractStringValues( + plugin.contracts?.webContentExtractors ?? [], + ), + webFetchProviderIds: normalizeContractStringValues(plugin.contracts?.webFetchProviders ?? []), + webSearchProviderIds: normalizeContractStringValues( + plugin.contracts?.webSearchProviders ?? [], + ), + migrationProviderIds: normalizeContractStringValues( + plugin.contracts?.migrationProviders ?? [], + ), + toolNames: normalizeContractStringValues(plugin.contracts?.tools ?? []), })); } diff --git a/src/plugins/contracts/shared-upstream-model.contract.test.ts b/src/plugins/contracts/shared-upstream-model.contract.test.ts index 4dfc9de9dd59..8fb56cbd18c1 100644 --- a/src/plugins/contracts/shared-upstream-model.contract.test.ts +++ b/src/plugins/contracts/shared-upstream-model.contract.test.ts @@ -1,6 +1,7 @@ // Shared upstream model contract tests keep capability flags aligned across bundled catalogs. import fs from "node:fs"; import path from "node:path"; +import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce"; import { describe, expect, it } from "vitest"; import { listGitTrackedFiles } from "../../test-utils/repo-files.js"; @@ -57,12 +58,6 @@ function normalizeSharedModelId(modelId: string): string { return (separator === -1 ? modelId : modelId.slice(separator + 1)).toLowerCase(); } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function collectCatalogEntries(): CatalogEntry[] { const entries: CatalogEntry[] = []; for (const manifest of readBundledManifests()) { diff --git a/src/plugins/contracts/shared.ts b/src/plugins/contracts/shared.ts index 43360ab74083..0e58fa574b59 100644 --- a/src/plugins/contracts/shared.ts +++ b/src/plugins/contracts/shared.ts @@ -1,5 +1,5 @@ /** Returns unique normalized string values while preserving first-seen order. */ -export function uniqueStrings( +export function normalizeContractStringValues( values: readonly string[] | undefined, normalize: (value: string) => string = (value) => value, ): string[] { diff --git a/src/plugins/loader-records.ts b/src/plugins/loader-records.ts index 92fd1b3d5049..09e2f0bade01 100644 --- a/src/plugins/loader-records.ts +++ b/src/plugins/loader-records.ts @@ -1,5 +1,5 @@ /** Converts loaded plugin registries into stable plugin records for status and diagnostics. */ -import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { parseBooleanValue } from "../utils/boolean.js"; import type { PluginCompatCode } from "./compat/registry.js"; import type { PluginActivationState } from "./config-state.js"; import type { PluginBundleFormat, PluginDiagnosticCode, PluginFormat } from "./manifest-types.js"; @@ -204,8 +204,7 @@ export function formatPluginFailureSummary(failedPlugins: PluginRecord[]): strin } function isPluginLoadDebugEnabled(env: NodeJS.ProcessEnv): boolean { - const normalized = normalizeLowercaseStringOrEmpty(env.OPENCLAW_PLUGIN_LOAD_DEBUG); - return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; + return parseBooleanValue(env.OPENCLAW_PLUGIN_LOAD_DEBUG) === true; } function describePluginModuleExportShape( diff --git a/src/realtime-transcription/websocket-session.ts b/src/realtime-transcription/websocket-session.ts index 0db5a2d8d0c6..821463f18e33 100644 --- a/src/realtime-transcription/websocket-session.ts +++ b/src/realtime-transcription/websocket-session.ts @@ -1,5 +1,6 @@ // Realtime transcription websocket session streams audio to transcription providers. import { randomUUID } from "node:crypto"; +import { toStringifiedError } from "@openclaw/normalization-core/error-coercion"; import WebSocket from "ws"; import { RetrySupervisor } from "../../packages/retry/src/index.js"; import { sleepWithAbort } from "../infra/backoff.js"; @@ -188,9 +189,6 @@ class WebSocketRealtimeTranscriptionSession implements RealtimeTranscript const ownsGeneration = () => generation === this.connectionGeneration; const ownsSocket = () => ownsGeneration() && this.ws === socket; - const normalizeError = (error: unknown) => - error instanceof Error ? error : new Error(String(error)); - const clearConnectTimeout = () => { if (connectTimeout) { clearTimeout(connectTimeout); @@ -297,7 +295,7 @@ class WebSocketRealtimeTranscriptionSession implements RealtimeTranscript try { connection = await this.resolveConnection(); } catch (error) { - failConnect(normalizeError(error)); + failConnect(toStringifiedError(error)); return; } if (settled) { @@ -319,7 +317,7 @@ class WebSocketRealtimeTranscriptionSession implements RealtimeTranscript this.ws = socket; this.transport = transport; } catch (error) { - failConnect(normalizeError(error)); + failConnect(toStringifiedError(error)); return; } @@ -336,7 +334,7 @@ class WebSocketRealtimeTranscriptionSession implements RealtimeTranscript finishConnect(); } } catch (error) { - failConnect(normalizeError(error)); + failConnect(toStringifiedError(error)); } }); @@ -361,7 +359,7 @@ class WebSocketRealtimeTranscriptionSession implements RealtimeTranscript if (!ownsSocket()) { return; } - const normalized = normalizeError(error); + const normalized = toStringifiedError(error); this.captureError(normalized); if (!opened || !settled) { failConnect(normalized); diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index 124f91a07f4a..eee26476c7e9 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -1,3 +1,4 @@ +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; // Audits channel configuration for exposure, auth, and trust risks. import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; @@ -155,11 +156,6 @@ export async function collectChannelSecurityFindingsCore(params: { }); }; - const asAccountRecord = (value: unknown): Record | null => - value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; - const resolveChannelAuditAccount = async ( plugin: (typeof params.plugins)[number], accountId: string, @@ -207,7 +203,7 @@ export async function collectChannelSecurityFindingsCore(params: { ); const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount; const selectedInspection = useSourceUnavailableAccount ? sourceInspection : resolvedInspection; - const accountRecord = asAccountRecord(account); + const accountRecord = asNullableRecord(account); let enabled = typeof selectedInspection?.enabled === "boolean" ? selectedInspection.enabled diff --git a/src/state/openclaw-database-verify.impl.ts b/src/state/openclaw-database-verify.impl.ts index 10ee83e98cbe..c0206199c23c 100644 --- a/src/state/openclaw-database-verify.impl.ts +++ b/src/state/openclaw-database-verify.impl.ts @@ -2,7 +2,7 @@ import { fork, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { toErrorObject } from "../infra/errors.js"; +import { toStructuredErrorObject } from "@openclaw/normalization-core/error-coercion"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { confirmOpenClawAgentDatabaseIntegrity, @@ -25,44 +25,6 @@ export const OPENCLAW_DATABASE_VERIFY_INTERVAL_MS = 24 * 60 * 60_000; const log = createSubsystemLogger("state/database-verify"); const DATABASE_VERIFY_CHILD_ARG = "--openclaw-database-verify-child"; -const ERROR_OWNED_FIELDS = new Set(["cause", "message", "name", "stack"]); -const PROTOTYPE_MUTATING_FIELDS = new Set(["__proto__", "constructor", "prototype"]); - -function toDatabaseVerifyError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - const message = String(error); - if ((typeof error !== "object" || error === null) && typeof error !== "function") { - return toErrorObject(error, message); - } - const normalized = toErrorObject({}, message); - normalized.cause = error; - try { - const detailKeys = Reflect.ownKeys(error).filter( - (key) => - (typeof key !== "string" || - (!ERROR_OWNED_FIELDS.has(key) && !PROTOTYPE_MUTATING_FIELDS.has(key))) && - Reflect.getOwnPropertyDescriptor(error, key)?.enumerable, - ); - for (const key of detailKeys) { - try { - Object.defineProperty(normalized, key, { - value: Reflect.get(error, key), - writable: true, - enumerable: true, - configurable: true, - }); - } catch { - // Skip fields whose getters or property definitions reject access. - } - } - } catch { - // Opaque proxies may reject enumeration; preserve the original failure as the cause. - } - return normalized; -} - function resolveDatabaseVerifyWorkerUrl(currentModuleUrl = import.meta.url): URL { const currentPath = fileURLToPath(currentModuleUrl); const normalized = currentPath.replaceAll(path.sep, "/"); @@ -104,7 +66,7 @@ export function runDatabaseVerifyWorker( stdio: ["ignore", "ignore", "ignore", "ipc"], }); } catch (error) { - return Promise.reject(toDatabaseVerifyError(error)); + return Promise.reject(toStructuredErrorObject(error)); } options.onWorker?.(worker); @@ -156,7 +118,7 @@ export function runDatabaseVerifyWorker( } result = message; }); - worker.once("error", (error) => settle(() => reject(toDatabaseVerifyError(error)))); + worker.once("error", (error) => settle(() => reject(toStructuredErrorObject(error)))); worker.once("disconnect", () => { disconnected = true; settleAfterExitAndDisconnect(); @@ -171,7 +133,7 @@ export function runDatabaseVerifyWorker( return; } worker.kill(); - settle(() => reject(toDatabaseVerifyError(error))); + settle(() => reject(toStructuredErrorObject(error))); }); }); } diff --git a/src/state/openclaw-database-verify.test.ts b/src/state/openclaw-database-verify.test.ts index f39bf6cede70..07833c6035b9 100644 --- a/src/state/openclaw-database-verify.test.ts +++ b/src/state/openclaw-database-verify.test.ts @@ -71,122 +71,13 @@ async function captureDatabaseVerifyWorkerSendFailure(failure: unknown): Promise } describe("database verification error coercion", () => { - it("preserves existing Error identity without invoking custom toString", async () => { - class ThrowingToStringError extends Error { - override toString(): string { - throw new Error("unexpected stringification"); - } - } - const cause = { code: "SQLITE_IOERR" }; - const failure = new ThrowingToStringError("database failed", { cause }); - failure.name = "DatabaseFailure"; - const originalStack = failure.stack; + it("preserves structured send failures across the database-worker boundary", async () => { + const failure = { code: "SQLITE_IOERR", database: "state" }; const error = await captureDatabaseVerifyWorkerSendFailure(failure); - expect(error).toBe(failure); - expect(error).toMatchObject({ - cause, - message: "database failed", - name: "DatabaseFailure", - stack: originalStack, - }); - }); - - it("skips structured fields whose getters throw", async () => { - const failure = { - get details(): never { - throw new Error("unexpected structured field read"); - }, - code: "SQLITE_IOERR", - }; - - const error = await captureDatabaseVerifyWorkerSendFailure(failure); - - expect(error).toMatchObject({ code: "SQLITE_IOERR" }); - expect(error).not.toHaveProperty("details"); - }); - - it("preserves the base Error when structured enumeration traps throw", async () => { - const handlers: ProxyHandler<{ code: string; status: number }>[] = [ - { - ownKeys() { - throw new Error("unexpected ownKeys call"); - }, - }, - { - ownKeys() { - return ["code", "status"]; - }, - getOwnPropertyDescriptor(target, key) { - if (key === "status") { - throw new Error("unexpected descriptor read"); - } - return Reflect.getOwnPropertyDescriptor(target, key); - }, - }, - ]; - - for (const handler of handlers) { - const failure = new Proxy({ code: "SQLITE_IOERR", status: 10 }, handler); - const error = await captureDatabaseVerifyWorkerSendFailure(failure); - - expect(error).toMatchObject({ name: "Error", message: "[object Object]" }); - expect(error.cause).toBe(failure); - expect(error).not.toHaveProperty("code"); - expect(error).not.toHaveProperty("status"); - } - }); - - it("preserves adapter-owned Error fields when structured failure fields collide", async () => { - const detailKey = Symbol("detail"); - let reservedReads = 0; - const failure = { - get name() { - reservedReads += 1; - return "SpoofedError"; - }, - get message() { - reservedReads += 1; - return "spoofed message"; - }, - get cause() { - reservedReads += 1; - return "spoofed cause"; - }, - get stack() { - reservedReads += 1; - return "spoofed stack"; - }, - code: "SQLITE_IOERR", - details: { database: "state" }, - [detailKey]: "symbol detail", - }; - - const error = await captureDatabaseVerifyWorkerSendFailure(failure); - - expect(reservedReads).toBe(0); - expect(error.message).toBe("[object Object]"); + expect(error).toMatchObject({ message: "[object Object]", code: "SQLITE_IOERR" }); expect(error.cause).toBe(failure); - expect(error.name).toBe("Error"); - expect(error.stack).toContain("Error: [object Object]"); - expect(error).toMatchObject({ code: "SQLITE_IOERR", details: { database: "state" } }); - expect(Reflect.get(error, detailKey)).toBe("symbol detail"); - }); - - it("rejects prototype-mutating structured failure fields", async () => { - const failure = { constructor: { polluted: true }, prototype: { polluted: true } }; - Object.defineProperty(failure, "__proto__", { - value: { polluted: true }, - enumerable: true, - }); - - const error = await captureDatabaseVerifyWorkerSendFailure(failure); - - expect(Object.getPrototypeOf(error)).toBe(Error.prototype); - expect(Object.hasOwn(error, "__proto__")).toBe(false); - expect(Object.hasOwn(error, "constructor")).toBe(false); - expect(Object.hasOwn(error, "prototype")).toBe(false); }); }); diff --git a/src/state/openclaw-state-db-legacy-backfills.ts b/src/state/openclaw-state-db-legacy-backfills.ts index cad73dc6adb8..97e5761881fd 100644 --- a/src/state/openclaw-state-db-legacy-backfills.ts +++ b/src/state/openclaw-state-db-legacy-backfills.ts @@ -1,5 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; import { safeParseJsonRecord } from "@openclaw/normalization-core"; +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeAgentRunTerminalReplySnapshot } from "../agents/agent-run-terminal-reply.js"; import { selectDeliverableSessionsReply } from "../agents/tools/sessions-send-tokens.js"; @@ -384,8 +385,7 @@ function textField(record: Record, key: string): string | null } function numberField(record: Record, key: string): number | null { - const value = record[key]; - return typeof value === "number" && Number.isFinite(value) ? value : null; + return asFiniteNumber(record[key]) ?? null; } function recordField(record: Record, key: string): Record | null { diff --git a/src/system-agent/rescue-channel.live.test.ts b/src/system-agent/rescue-channel.live.test.ts index fe5be670a2fa..b00f51764689 100644 --- a/src/system-agent/rescue-channel.live.test.ts +++ b/src/system-agent/rescue-channel.live.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { CommandContext } from "../auto-reply/reply/commands-types.js"; import { clearConfigCache } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { resetPluginStateStoreForTests } from "../plugin-state/plugin-state-store.js"; import { withTestDir } from "../test-helpers/temp-dir.js"; import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js"; @@ -14,13 +15,9 @@ import { runSystemAgentRescueMessage } from "./rescue-message.js"; const originalStateDir = process.env.OPENCLAW_STATE_DIR; const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH; -function truthy(value: string | undefined): boolean { - return /^(1|true|yes|on)$/i.test(value?.trim() ?? ""); -} - const runLive = - truthy(process.env.OPENCLAW_LIVE_TEST) && - truthy(process.env.OPENCLAW_LIVE_SYSTEM_AGENT_RESCUE_CHANNEL); + isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST) && + isTruthyEnvValue(process.env.OPENCLAW_LIVE_SYSTEM_AGENT_RESCUE_CHANNEL); const describeLive = runLive ? describe : describe.skip; function commandContext(channel = process.env.OPENCLAW_LIVE_SYSTEM_AGENT_CHANNEL ?? "whatsapp") { diff --git a/src/talk/event-metrics.ts b/src/talk/event-metrics.ts index 798b6e11286b..b5ced5acbf2a 100644 --- a/src/talk/event-metrics.ts +++ b/src/talk/event-metrics.ts @@ -4,6 +4,8 @@ * Talk event payloads are provider-owned JSON blobs, so callers must coerce * records and read only bounded numeric counters that are safe to export. */ +import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; + /** Read the first non-negative finite number from a provider payload record. */ export function firstFiniteTalkEventNumber( record: Record | undefined, @@ -13,8 +15,8 @@ export function firstFiniteTalkEventNumber( return undefined; } for (const key of keys) { - const value = record[key]; - if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + const value = asNonNegativeFiniteNumber(record[key]); + if (value !== undefined) { // Reject negative, NaN, and Infinity values before diagnostics/logging so // provider bugs cannot poison aggregate Talk metrics. return value; diff --git a/src/trajectory/runtime-store.sqlite.ts b/src/trajectory/runtime-store.sqlite.ts index 20183ac7037c..bb0de319fdd2 100644 --- a/src/trajectory/runtime-store.sqlite.ts +++ b/src/trajectory/runtime-store.sqlite.ts @@ -1,5 +1,6 @@ // SQLite trajectory runtime store owns session-scoped runtime event rows. +import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; import { executeSqliteQuerySync, @@ -359,8 +360,7 @@ function trajectoryJsonlRowBytes(eventJson: string): number { } function readTrajectoryEventTimestamp(event: TrajectoryEvent): number | undefined { - const parsed = Date.parse(event.ts); - return Number.isFinite(parsed) ? parsed : undefined; + return parseDateStringTimestampMs(event.ts); } function normalizeSqliteNumber(value: number | bigint): number { diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index c376f0da93b6..394f98fcc07e 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -1,5 +1,6 @@ // Bridges TUI chat requests to gateway session APIs. import { randomUUID } from "node:crypto"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { gatewayOriginScope } from "../../packages/gateway-client/src/gateway-origin-scope.js"; import { GATEWAY_CLIENT_CAPS, @@ -114,10 +115,6 @@ function resolveStartupRetryDelayMs(err: GatewayClientRequestError): number { return Math.min(Math.max(retryAfterMs, 100), STARTUP_CHAT_HISTORY_MAX_RETRY_MS); } -function nonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function hasStoredOriginDeviceAuth(deviceAuthScope: string): boolean { try { const identity = loadDeviceIdentityIfPresent(); @@ -306,8 +303,8 @@ export class GatewayChatClient implements TuiBackend { timeoutMs: opts.timeoutMs, idempotencyKey: runId, }); - const acceptedRunId = nonEmptyString(response?.runId) ?? runId; - const status = nonEmptyString(response?.status); + const acceptedRunId = normalizeOptionalString(response?.runId) ?? runId; + const status = normalizeOptionalString(response?.status); return status ? { runId: acceptedRunId, status } : { runId: acceptedRunId }; } @@ -576,7 +573,7 @@ async function resolveGatewayConnection( resolveTlsFingerprint: async ({ urlSource, explicitTlsFingerprint }) => explicitTlsFingerprint ?? (urlSource === "config gateway.remote.url" - ? nonEmptyString(config.gateway?.remote?.tlsFingerprint) + ? normalizeOptionalString(config.gateway?.remote?.tlsFingerprint) : undefined), }); const hasStoredOriginAuth = Boolean( diff --git a/src/web/provider-runtime-shared.ts b/src/web/provider-runtime-shared.ts index b8ec1ff10c00..fd8ea4cebdbc 100644 --- a/src/web/provider-runtime-shared.ts +++ b/src/web/provider-runtime-shared.ts @@ -1,5 +1,9 @@ // Shared web provider config, credential, and definition resolution. -import { coerceSecretRef, isLegacySecretRefEnvMarker } from "../config/types.secrets.js"; +import { + coerceSecretRef, + isLegacySecretRefEnvMarker, + normalizeSecretInputString, +} from "../config/types.secrets.js"; type WebProviderConfigSource = { tools?: { @@ -23,14 +27,6 @@ type ProviderWithCredential = { type WebContentProcessEnv = Record; -function normalizeSecretInputString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - function normalizeSecretInput(value: unknown): string { if (typeof value !== "string") { return ""; diff --git a/src/worker/worker-connection-contract.ts b/src/worker/worker-connection-contract.ts index 1512ee2c852c..22f4e44eddbd 100644 --- a/src/worker/worker-connection-contract.ts +++ b/src/worker/worker-connection-contract.ts @@ -1,3 +1,4 @@ +import { toStructuredErrorObject } from "@openclaw/normalization-core/error-coercion"; import type { WebSocket } from "ws"; import type { WorkerConnectParams, @@ -6,14 +7,11 @@ import type { WorkerProtocolCloseReason, } from "../../packages/gateway-protocol/src/schema/worker-admission.js"; import type { BackoffPolicy } from "../infra/backoff.js"; -import { toErrorObject } from "../infra/errors.js"; const FENCED_CLOSE_REASONS = new Set([ "credential-replaced", "owner-epoch-mismatch", ]); -const ERROR_OWNED_FIELDS = new Set(["cause", "message", "name", "stack"]); -const PROTOTYPE_MUTATING_FIELDS = new Set(["__proto__", "constructor", "prototype"]); export type WorkerFencedReason = "credential-replaced" | "owner-epoch-mismatch"; @@ -98,36 +96,5 @@ export function resolvePositiveTimeout(value: number | undefined, fallback: numb } export function toWorkerConnectionError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - const message = String(error); - if ((typeof error !== "object" || error === null) && typeof error !== "function") { - return toErrorObject(error, message); - } - const normalized = toErrorObject({}, message); - normalized.cause = error; - try { - const detailKeys = Reflect.ownKeys(error).filter( - (key) => - (typeof key !== "string" || - (!ERROR_OWNED_FIELDS.has(key) && !PROTOTYPE_MUTATING_FIELDS.has(key))) && - Reflect.getOwnPropertyDescriptor(error, key)?.enumerable, - ); - for (const key of detailKeys) { - try { - Object.defineProperty(normalized, key, { - value: Reflect.get(error, key), - writable: true, - enumerable: true, - configurable: true, - }); - } catch { - // Skip fields whose getters or property definitions reject access. - } - } - } catch { - // Opaque proxies may reject enumeration; preserve the original failure as the cause. - } - return normalized; + return toStructuredErrorObject(error); } diff --git a/src/worker/worker-connection.test.ts b/src/worker/worker-connection.test.ts index 05a6e26f5ec2..8a7d6155667c 100644 --- a/src/worker/worker-connection.test.ts +++ b/src/worker/worker-connection.test.ts @@ -132,28 +132,6 @@ function installThrowingThenHealthyListeners(connection: ReturnType { - it("preserves existing Error identity without invoking custom toString", () => { - class ThrowingToStringError extends Error { - override toString(): string { - throw new Error("unexpected stringification"); - } - } - const cause = { code: "ECONNRESET" }; - const original = new ThrowingToStringError("worker failed", { cause }); - original.name = "WorkerFailure"; - const originalStack = original.stack; - - const error = toWorkerConnectionError(original); - - expect(error).toBe(original); - expect(error).toMatchObject({ - cause, - message: "worker failed", - name: "WorkerFailure", - stack: originalStack, - }); - }); - it("preserves structured non-Error causes", () => { const cause = { code: "ECONNRESET", status: 503 }; @@ -163,104 +141,6 @@ describe("worker connection error coercion", () => { expect(error.cause).toBe(cause); expect(error).toMatchObject(cause); }); - - it("skips structured fields whose getters throw", () => { - const cause = { - get details(): never { - throw new Error("unexpected structured field read"); - }, - code: "ECONNRESET", - }; - let error: Error | undefined; - - expect(() => { - error = toWorkerConnectionError(cause); - }).not.toThrow(); - expect(error).toMatchObject({ code: "ECONNRESET" }); - expect(error).not.toHaveProperty("details"); - }); - - it("preserves the base Error when structured enumeration traps throw", () => { - const handlers: ProxyHandler<{ code: string; status: number }>[] = [ - { - ownKeys() { - throw new Error("unexpected ownKeys call"); - }, - }, - { - ownKeys() { - return ["code", "status"]; - }, - getOwnPropertyDescriptor(target, key) { - if (key === "status") { - throw new Error("unexpected descriptor read"); - } - return Reflect.getOwnPropertyDescriptor(target, key); - }, - }, - ]; - - for (const handler of handlers) { - const cause = new Proxy({ code: "ECONNRESET", status: 503 }, handler); - const error = toWorkerConnectionError(cause); - - expect(error).toMatchObject({ name: "Error", message: "[object Object]" }); - expect(error.cause).toBe(cause); - expect(error).not.toHaveProperty("code"); - expect(error).not.toHaveProperty("status"); - } - }); - - it("preserves adapter-owned Error fields when structured cause fields collide", () => { - const detailKey = Symbol("detail"); - let reservedReads = 0; - const cause = { - get name() { - reservedReads += 1; - return "SpoofedError"; - }, - get message() { - reservedReads += 1; - return "spoofed message"; - }, - get cause() { - reservedReads += 1; - return "spoofed cause"; - }, - get stack() { - reservedReads += 1; - return "spoofed stack"; - }, - code: "ECONNRESET", - details: { retryable: true }, - [detailKey]: "symbol detail", - }; - - const error = toWorkerConnectionError(cause); - - expect(reservedReads).toBe(0); - expect(error.message).toBe("[object Object]"); - expect(error.cause).toBe(cause); - expect(error.name).toBe("Error"); - expect(error.stack).toContain("Error: [object Object]"); - expect(error).toMatchObject({ code: "ECONNRESET", details: { retryable: true } }); - expect(Reflect.get(error, detailKey)).toBe("symbol detail"); - }); - - it("rejects prototype-mutating structured cause fields", () => { - const cause = { constructor: { polluted: true }, prototype: { polluted: true } }; - Object.defineProperty(cause, "__proto__", { - value: { polluted: true }, - enumerable: true, - }); - - const error = toWorkerConnectionError(cause); - - expect(Object.getPrototypeOf(error)).toBe(Error.prototype); - expect(Object.hasOwn(error, "__proto__")).toBe(false); - expect(Object.hasOwn(error, "constructor")).toBe(false); - expect(Object.hasOwn(error, "prototype")).toBe(false); - }); }); describe("WorkerConnection state listener isolation", () => { diff --git a/test/e2e/qa-lab/config/cli-channel-picker.ts b/test/e2e/qa-lab/config/cli-channel-picker.ts index 9f21dc981570..cff536690089 100644 --- a/test/e2e/qa-lab/config/cli-channel-picker.ts +++ b/test/e2e/qa-lab/config/cli-channel-picker.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { pathToFileURL } from "node:url"; import { stripAnsiSequences } from "../../../../packages/terminal-core/src/ansi.js"; +import { coerceErrorMessage as formatErrorMessage } from "../../../../scripts/lib/error-format.mts"; import { createQaScriptEvidenceWriter } from "../runtime/script-evidence.js"; const SCENARIO_ID = "cli-channel-picker"; @@ -18,10 +19,6 @@ type ProducerOptions = { timeoutMs: number; }; -function formatErrorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error); -} - function sanitizePickerTranscript(transcript: string) { return stripAnsiSequences(transcript).replaceAll( /123456(?:(?::|…)[A-Za-z0-9_…-]*)?/gu, diff --git a/test/e2e/qa-lab/media/hosted-media-provider-live.ts b/test/e2e/qa-lab/media/hosted-media-provider-live.ts index 53593fb2b363..f8a9bf05b0ac 100644 --- a/test/e2e/qa-lab/media/hosted-media-provider-live.ts +++ b/test/e2e/qa-lab/media/hosted-media-provider-live.ts @@ -6,6 +6,7 @@ import { QA_EVIDENCE_FILENAME, type QaEvidenceSummaryJson, } from "../../../../extensions/qa-lab/api.js"; +import { coerceErrorMessage as formatErrorMessage } from "../../../../scripts/lib/error-format.mts"; import { spawnPnpmRunner as _spawnPnpmRunner } from "../../../../scripts/pnpm-runner.mts"; import { createQaScriptBlockedStatusTracker, @@ -170,10 +171,6 @@ function formatProviderList(providers: Iterable): string { return [...providers].toSorted().join(", "); } -function formatErrorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error); -} - function spawnLivePnpm(params: { pnpmArgs: string[]; env: NodeJS.ProcessEnv }) { return _spawnPnpmRunner({ pnpmArgs: params.pnpmArgs, diff --git a/test/e2e/qa-lab/plugins/clawhub-release-candidate-install.ts b/test/e2e/qa-lab/plugins/clawhub-release-candidate-install.ts index 73942e185677..d5a50eea8a65 100644 --- a/test/e2e/qa-lab/plugins/clawhub-release-candidate-install.ts +++ b/test/e2e/qa-lab/plugins/clawhub-release-candidate-install.ts @@ -8,6 +8,7 @@ import { QA_EVIDENCE_FILENAME, type QaEvidenceSummaryJson, } from "../../../../extensions/qa-lab/api.js"; +import { coerceErrorMessage as formatErrorMessage } from "../../../../scripts/lib/error-format.mts"; import { createBoundedChildOutput } from "../../../helpers/bounded-child-output.js"; import { createQaScriptBlockedStatusTracker, @@ -165,10 +166,6 @@ async function writeJson(filePath: string, value: unknown) { await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } -function formatErrorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error); -} - async function resolveCandidateTarball(options: ProducerOptions) { const explicitTarball = process.env[options.tarballEnv]?.trim(); if (explicitTarball) { diff --git a/test/e2e/qa-lab/runtime/browser-plugin-profiles-packaged.ts b/test/e2e/qa-lab/runtime/browser-plugin-profiles-packaged.ts index 93987b6c8332..2f303d57541a 100644 --- a/test/e2e/qa-lab/runtime/browser-plugin-profiles-packaged.ts +++ b/test/e2e/qa-lab/runtime/browser-plugin-profiles-packaged.ts @@ -5,6 +5,7 @@ import { QA_EVIDENCE_FILENAME, type QaEvidenceSummaryJson, } from "../../../../extensions/qa-lab/api.js"; +import { coerceErrorMessage as formatError } from "../../../../scripts/lib/error-format.mts"; import { createQaScriptEvidenceWriter } from "./script-evidence.js"; const SCENARIO_ID = "browser-plugin-profiles-packaged"; @@ -20,10 +21,6 @@ type DockerOutcome = { signal: NodeJS.Signals | null; }; -function formatError(error: unknown) { - return error instanceof Error ? error.message : String(error); -} - export function parseBrowserPluginProfilesOptions(args: string[]): ProducerOptions { if (args.length !== 2 || args[0] !== "--artifact-base" || !args[1]) { throw new Error("usage: --artifact-base "); diff --git a/test/e2e/qa-lab/runtime/docker-artifact-proof.ts b/test/e2e/qa-lab/runtime/docker-artifact-proof.ts index 29d36066a1b2..20b9367260ac 100644 --- a/test/e2e/qa-lab/runtime/docker-artifact-proof.ts +++ b/test/e2e/qa-lab/runtime/docker-artifact-proof.ts @@ -7,6 +7,7 @@ import { QA_EVIDENCE_FILENAME, type QaEvidenceSummaryJson, } from "../../../../extensions/qa-lab/api.js"; +import { coerceErrorMessage as formatErrorMessage } from "../../../../scripts/lib/error-format.mts"; import { createQaScriptEvidenceWriter } from "./script-evidence.js"; const SOURCE_PATH = "test/e2e/qa-lab/runtime/docker-artifact-proof.ts"; @@ -42,10 +43,6 @@ type ArtifactIdentity = { scenarioId: string; }; -function formatErrorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error); -} - function isProofLane(value: string): value is DockerArtifactProofLane { return Object.hasOwn(PROOFS, value); } diff --git a/test/e2e/qa-lab/runtime/gateway-protocol-artifacts.ts b/test/e2e/qa-lab/runtime/gateway-protocol-artifacts.ts index 7d10dafebe2c..81cf77632ea5 100644 --- a/test/e2e/qa-lab/runtime/gateway-protocol-artifacts.ts +++ b/test/e2e/qa-lab/runtime/gateway-protocol-artifacts.ts @@ -11,6 +11,7 @@ import { type QaEvidenceSummaryJson, } from "../../../../extensions/qa-lab/api.js"; import { ProtocolSchemas } from "../../../../packages/gateway-protocol/src/schema/protocol-schemas.js"; +import { coerceErrorMessage as formatErrorMessage } from "../../../../scripts/lib/error-format.mts"; import { listCoreGatewayMethodMetadata } from "../../../../src/gateway/methods/core-descriptors.js"; import { createQaScriptEvidenceWriter } from "./script-evidence.js"; @@ -187,10 +188,6 @@ export function buildPortableSwiftAnyCodableSource(source: string) { return source.includes("import CoreFoundation") ? source : `import CoreFoundation\n${source}`; } -function formatErrorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error); -} - export function parseGatewayProtocolArtifactOptions( args: readonly string[], cwd = process.cwd(), diff --git a/test/e2e/qa-lab/runtime/managed-gateway-service-lifecycle-product-proof.ts b/test/e2e/qa-lab/runtime/managed-gateway-service-lifecycle-product-proof.ts index 4696f5db93b0..6988747afeb6 100644 --- a/test/e2e/qa-lab/runtime/managed-gateway-service-lifecycle-product-proof.ts +++ b/test/e2e/qa-lab/runtime/managed-gateway-service-lifecycle-product-proof.ts @@ -7,6 +7,7 @@ import { type QaEvidenceSummaryJson, validateQaEvidenceSummaryJson, } from "../../../../extensions/qa-lab/api.js"; +import { coerceErrorMessage as formatErrorMessage } from "../../../../scripts/lib/error-format.mts"; import { createQaScriptEvidenceWriter } from "./script-evidence.js"; const SCENARIO_ID = "managed-gateway-service-lifecycle"; @@ -91,10 +92,6 @@ if (process.platform === "darwin") { }); } -function formatErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function parseOptions(args: string[]): ProducerOptions { if (args.length !== 2 || args[0] !== "--artifact-base" || !args[1]) { throw new Error("usage: --artifact-base "); diff --git a/test/e2e/qa-lab/runtime/update-run-package-self-upgrade.ts b/test/e2e/qa-lab/runtime/update-run-package-self-upgrade.ts index 28c876a7686f..87c64909bfdf 100644 --- a/test/e2e/qa-lab/runtime/update-run-package-self-upgrade.ts +++ b/test/e2e/qa-lab/runtime/update-run-package-self-upgrade.ts @@ -7,6 +7,7 @@ import { QA_EVIDENCE_FILENAME, type QaEvidenceSummaryJson, } from "../../../../extensions/qa-lab/api.js"; +import { coerceErrorMessage as formatErrorMessage } from "../../../../scripts/lib/error-format.mts"; import { createQaScriptEvidenceWriter } from "./script-evidence.js"; const SOURCE_PATH = "test/e2e/qa-lab/runtime/update-run-package-self-upgrade.ts"; @@ -44,10 +45,6 @@ type UpdateRunSelfUpgradeSummary = { }; }; -function formatErrorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error); -} - export function parseUpdateRunSelfUpgradeOptions(args: string[]): ProducerOptions { let artifactBase: string | undefined; for (let index = 0; index < args.length; index += 1) { diff --git a/test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts b/test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts index 48af752569d4..8d81dfe2d9c7 100644 --- a/test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts +++ b/test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts @@ -11,6 +11,7 @@ import { type QaEvidenceSummaryJson, type QaSeedScenarioWithSource, } from "../../../../extensions/qa-lab/api.js"; +import { coerceErrorMessage as formatErrorMessage } from "../../../../scripts/lib/error-format.mts"; import { createQaScriptEvidenceWriter } from "../runtime/script-evidence.js"; const SOURCE_PATH = "test/e2e/qa-lab/tui/tui-pty-evidence-producer.ts"; @@ -89,10 +90,6 @@ type ProofMatrixCase = TuiPtyCase & { matchedAssertions: string[]; }; -function formatErrorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error); -} - function readOptionValue(argv: readonly string[], index: number, option: string) { const value = argv[index + 1]; if (!value || value.startsWith("--")) { diff --git a/test/helpers/openai-long-context-live.ts b/test/helpers/openai-long-context-live.ts index ed5d9755c8c3..44b7ee535e6d 100644 --- a/test/helpers/openai-long-context-live.ts +++ b/test/helpers/openai-long-context-live.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { SessionManager } from "../../src/agents/sessions/session-manager.js"; import type { OpenClawConfig } from "../../src/config/config.js"; import { resolveAgentModelPrimaryValue } from "../../src/config/model-input.js"; @@ -672,7 +673,7 @@ type UsageRecord = { }; function finite(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; + return asFiniteNumber(value) ?? null; } type OpenAILongContextTurnMetric = { diff --git a/test/scripts/arg-utils.test.ts b/test/scripts/arg-utils.test.ts index 520d272775e5..61f6b708eec5 100644 --- a/test/scripts/arg-utils.test.ts +++ b/test/scripts/arg-utils.test.ts @@ -2,13 +2,84 @@ import { describe, expect, it } from "vitest"; import { booleanFlag, + classifyBoundedUnsignedDecimal, intFlag, parseFlagArgs, + parsePermissiveBooleanToken, + parseStrictBooleanArg, readFlagValue, stringFlag, stringListFlag, } from "../../scripts/lib/arg-utils.runtime.mjs"; +describe("scripts/lib/arg-utils strict scalar grammars", () => { + it.each([ + { input: "true", expected: true }, + { input: "false", expected: false }, + { input: "", error: "--enabled must be true or false." }, + { input: " ", error: "--enabled must be true or false." }, + { input: " true", error: "--enabled must be true or false." }, + { input: "false ", error: "--enabled must be true or false." }, + { input: "TRUE", error: "--enabled must be true or false." }, + { input: "False", error: "--enabled must be true or false." }, + { input: "1", error: "--enabled must be true or false." }, + { input: "0", error: "--enabled must be true or false." }, + { input: "yes", error: "--enabled must be true or false." }, + { input: true, error: "--enabled must be true or false." }, + { input: 1, error: "--enabled must be true or false." }, + ])("parses strict Boolean token %#", ({ input, expected, error }) => { + if (error) { + expect(() => parseStrictBooleanArg(input, "--enabled")).toThrow(error); + return; + } + expect(parseStrictBooleanArg(input, "--enabled")).toBe(expected); + }); + + it.each([ + { input: "0", min: 0, max: 10, expected: { kind: "value", value: 0 } }, + { input: "001", min: 1, max: 10, expected: { kind: "value", value: 1 } }, + { input: "10", min: 0, max: 10, expected: { kind: "value", value: 10 } }, + { input: "0", min: 1, max: 10, expected: { kind: "below" } }, + { input: "11", min: 0, max: 10, expected: { kind: "above" } }, + { input: "9".repeat(400), min: 0, max: 10, expected: { kind: "above" } }, + { input: "", min: 0, max: 10, expected: { kind: "syntax" } }, + { input: " ", min: 0, max: 10, expected: { kind: "syntax" } }, + { input: " 1", min: 0, max: 10, expected: { kind: "syntax" } }, + { input: "1 ", min: 0, max: 10, expected: { kind: "syntax" } }, + { input: "+1", min: 0, max: 10, expected: { kind: "syntax" } }, + { input: "-1", min: 0, max: 10, expected: { kind: "syntax" } }, + { input: "1.0", min: 0, max: 10, expected: { kind: "syntax" } }, + { input: "1e1", min: 0, max: 10, expected: { kind: "syntax" } }, + { input: "0x10", min: 0, max: 10, expected: { kind: "syntax" } }, + { input: "0b10", min: 0, max: 10, expected: { kind: "syntax" } }, + { input: "1ms", min: 0, max: 10, expected: { kind: "syntax" } }, + ])("classifies bounded unsigned decimal %#", ({ input, min, max, expected }) => { + expect(classifyBoundedUnsignedDecimal(input, min, max)).toEqual(expected); + }); +}); + +describe("scripts/lib/arg-utils permissive Boolean tokens", () => { + it.each([ + { input: "true", expected: true }, + { input: "1", expected: true }, + { input: "yes", expected: true }, + { input: "on", expected: true }, + { input: "false", expected: false }, + { input: "0", expected: false }, + { input: "no", expected: false }, + { input: "off", expected: false }, + { input: " TRUE ", expected: true }, + { input: " Off ", expected: false }, + { input: "", expected: undefined }, + { input: " ", expected: undefined }, + { input: "enabled", expected: undefined }, + { input: true, expected: undefined }, + { input: 1, expected: undefined }, + ])("parses $input as $expected", ({ input, expected }) => { + expect(parsePermissiveBooleanToken(input)).toBe(expected); + }); +}); + describe("scripts/lib/arg-utils parseFlagArgs", () => { it("uses the last value when a flag is repeated", () => { expect(readFlagValue(["-p", "first.json", "-p", "second.json"], "-p")).toBe("second.json"); diff --git a/test/scripts/check-coercion-helper-declarations.test.ts b/test/scripts/check-coercion-helper-declarations.test.ts index ba566eedbc3e..d473de576d91 100644 --- a/test/scripts/check-coercion-helper-declarations.test.ts +++ b/test/scripts/check-coercion-helper-declarations.test.ts @@ -3,8 +3,10 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + auditCanonicalCoercionExports, auditCoercionHelperDeclarations, findBannedCoercionHelperDeclarations, + findExportedCallableNames, isGovernedCoercionHelperPath, runCoercionHelperDeclarationGuard, type CoercionHelperCarveOut, @@ -240,6 +242,68 @@ describe("coercion helper declaration AST guard", () => { expect(isGovernedCoercionHelperPath(".github/actions/example/index.ts")).toBe(true); }); + it("finds directly exported callable declarations and export aliases", () => { + const source = [ + "export function canonical() {}", + "function local() {}", + "export { local as alias };", + "export const VALUE = 1;", + ].join("\n"); + + expect(findExportedCallableNames(source, "src/owner.ts")).toEqual(["alias", "canonical"]); + }); + + it.each([ + ["classified", ["kept"], [{ file: "src/owner.ts", name: "kept", status: "enforced" }]], + [ + "deferred", + ["format"], + [ + { + file: "src/owner.ts", + name: "format", + status: "deferred", + reason: "Meaningful public collision.", + }, + ], + ], + ] as const)("accepts a %s canonical export", (_label, names, classifications) => { + expect( + auditCanonicalCoercionExports(new Map([["src/owner.ts", names]]), classifications), + ).toEqual({ + invalidClassifications: [], + staleClassifications: [], + unclassifiedExports: [], + }); + }); + + it("reports unclassified exports and stale, duplicate, or blank deferred entries", () => { + const kept = { file: "src/owner.ts", name: "kept", status: "enforced" } as const; + const removed = { + file: "src/owner.ts", + name: "removed", + status: "deferred", + reason: "Removed owner.", + } as const; + const blank = { + file: "src/other.ts", + name: "unknown", + status: "deferred", + reason: "", + } as const; + const audit = auditCanonicalCoercionExports( + new Map([["src/owner.ts", ["kept", "newHelper"]]]), + [kept, kept, removed, blank], + ); + + expect(audit.invalidClassifications).toEqual([ + "src/owner.ts [kept] is classified more than once", + "src/other.ts [unknown] needs a non-empty deferred reason", + ]); + expect(audit.unclassifiedExports).toEqual([{ file: "src/owner.ts", name: "newHelper" }]); + expect(audit.staleClassifications).toEqual([removed, blank]); + }); + 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 }); diff --git a/test/scripts/check-deprecated-api-usage.test.ts b/test/scripts/check-deprecated-api-usage.test.ts index 9e0b1a33338e..b5682700141f 100644 --- a/test/scripts/check-deprecated-api-usage.test.ts +++ b/test/scripts/check-deprecated-api-usage.test.ts @@ -65,7 +65,7 @@ describe("scripts/check-deprecated-api-usage", () => { } }); - it("bans internal imports of every deprecated reply facade", () => { + it("bans internal imports of every deprecated facade", () => { const modulePaths = new Set( BANNED_INTERNAL_PLUGIN_SDK_FACADE_MODULES.map((ban) => ban.modulePath), ); @@ -74,6 +74,7 @@ describe("scripts/check-deprecated-api-usage", () => { "src/plugin-sdk/channel-message", "src/plugin-sdk/channel-reply-pipeline", "src/plugin-sdk/inbound-reply-dispatch", + "src/plugin-sdk/text-runtime", ]) { expect(modulePaths.has(facade), facade).toBe(true); } @@ -95,6 +96,7 @@ describe("scripts/check-deprecated-api-usage", () => { 'import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";', 'export { runChannelInboundEvent } from "../plugin-sdk/inbound-reply-dispatch.js";', 'const facade = await import ("../plugin-sdk/channel-message.js", { with: {} });', + 'const text = require("@openclaw/plugin-sdk/text-runtime");', ].join("\n"), }); @@ -106,6 +108,7 @@ describe("scripts/check-deprecated-api-usage", () => { "src/channels/probe.ts:2: ../plugin-sdk/inbound-reply-dispatch.js", ); expect(result.stderr).toContain("src/channels/probe.ts:3: ../plugin-sdk/channel-message.js"); + expect(result.stderr).toContain("src/channels/probe.ts:4: @openclaw/plugin-sdk/text-runtime"); }); it("allows canonical compat re-exports and test files", () => { diff --git a/ui/src/app/question-prompt.ts b/ui/src/app/question-prompt.ts index 829ec52fa9c7..1fd58350b152 100644 --- a/ui/src/app/question-prompt.ts +++ b/ui/src/app/question-prompt.ts @@ -1,4 +1,5 @@ // Control UI module owns transient operator question state. +import { asSafeIntegerInRange } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeNullableString as readNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import type { @@ -61,7 +62,7 @@ type QuestionAnswerValues = Record; const REFRESH_RETRY_DELAYS_MS = [1_000, 2_000, 4_000] as const; function readTimestamp(value: unknown): number | null { - return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; + return asSafeIntegerInRange(value, { min: 0 }) ?? null; } const MAX_HEADER_GRAPHEMES = 12; diff --git a/ui/src/components/browser/browser-client.ts b/ui/src/components/browser/browser-client.ts index 307ceb4b0443..6e4749798866 100644 --- a/ui/src/components/browser/browser-client.ts +++ b/ui/src/components/browser/browser-client.ts @@ -4,6 +4,7 @@ // that is dispatched against the browser plugin's control routes, either // locally or via a browser-capable node. This module narrows the handful of // routes the browser panel needs and keeps route-path knowledge in one place. +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import { readStringValue } from "@openclaw/normalization-core/string-coerce"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; @@ -71,10 +72,6 @@ function stringOrEmpty(value: unknown): string { return readStringValue(value) ?? ""; } -function asNullableFiniteNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} - function normalizeTab(value: unknown): BrowserPanelTab | null { const record = asRecord(value); const targetId = stringOrEmpty(record?.targetId); @@ -236,8 +233,8 @@ export async function readBrowserPageMetrics( fn: "() => ({ cssWidth: window.innerWidth, cssHeight: window.innerHeight, title: document.title, url: location.href })", }), ); - const cssWidth = asNullableFiniteNumber(result?.cssWidth); - const cssHeight = asNullableFiniteNumber(result?.cssHeight); + const cssWidth = asFiniteNumber(result?.cssWidth); + const cssHeight = asFiniteNumber(result?.cssHeight); if (!cssWidth || !cssHeight || cssWidth <= 0 || cssHeight <= 0) { return null; } @@ -293,10 +290,10 @@ export async function inspectBrowserElementAt( role: stringOrEmpty(result.role), name: stringOrEmpty(result.name), rect: { - x: asNullableFiniteNumber(rect?.x) ?? 0, - y: asNullableFiniteNumber(rect?.y) ?? 0, - width: asNullableFiniteNumber(rect?.width) ?? 0, - height: asNullableFiniteNumber(rect?.height) ?? 0, + x: asFiniteNumber(rect?.x) ?? 0, + y: asFiniteNumber(rect?.y) ?? 0, + width: asFiniteNumber(rect?.width) ?? 0, + height: asFiniteNumber(rect?.height) ?? 0, }, focusable: result.focusable === true, }; diff --git a/ui/src/components/config-form.analyze.ts b/ui/src/components/config-form.analyze.ts index 31a01f529277..b1e51ee8ff90 100644 --- a/ui/src/components/config-form.analyze.ts +++ b/ui/src/components/config-form.analyze.ts @@ -73,10 +73,10 @@ function isAnySchema(schema: JsonSchema): boolean { function normalizeEnum(values: unknown[]): { enumValues: unknown[]; nullable: boolean } { const filtered = values.filter((value) => value != null); const nullable = filtered.length !== values.length; - return { enumValues: uniqueValues(filtered), nullable }; + return { enumValues: uniqueSchemaValues(filtered), nullable }; } -function uniqueValues(values: unknown[]): unknown[] { +function uniqueSchemaValues(values: unknown[]): unknown[] { const unique: unknown[] = []; for (const value of values) { if (!unique.some((existing) => Object.is(existing, value))) { @@ -591,7 +591,7 @@ function normalizeUnion( return { schema: { ...schema, - enum: uniqueValues(literals), + enum: uniqueSchemaValues(literals), nullable, enumIncludesNull: nullable, anyOf: undefined, diff --git a/ui/src/components/github-link-hovercard.ts b/ui/src/components/github-link-hovercard.ts index db8854384315..c375e6277835 100644 --- a/ui/src/components/github-link-hovercard.ts +++ b/ui/src/components/github-link-hovercard.ts @@ -1,4 +1,5 @@ import { initialState, Task } from "@lit/task"; +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { ReactiveElement } from "lit"; import type { ControlUiGitHubPreview } from "../../../src/gateway/control-ui-contract.js"; @@ -49,11 +50,6 @@ function readOptionalGitHubString( return typeof value === "string" && value.trim() ? value : undefined; } -function optionalNumber(record: Record, key: string): number | undefined { - const value = record[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function parseGitHubIssueOrPullRequestLink(href: string): GitHubLinkTarget | null { let url: URL; try { @@ -97,13 +93,13 @@ function parsePreviewResponse(target: GitHubLinkTarget, value: unknown): GitHubP } return { ...target, - additions: optionalNumber(value, "additions"), + additions: asFiniteNumber(value.additions), avatarDataUrl: safeAvatarDataUrl(value.avatarDataUrl), - changedFiles: optionalNumber(value, "changedFiles"), + changedFiles: asFiniteNumber(value.changedFiles), closedAt: readOptionalGitHubString(value, "closedAt"), - comments: optionalNumber(value, "comments"), + comments: asFiniteNumber(value.comments), createdAt: requiredString(value, "createdAt"), - deletions: optionalNumber(value, "deletions"), + deletions: asFiniteNumber(value.deletions), draft: typeof value.draft === "boolean" ? value.draft : undefined, kind: target.kind, login: readOptionalGitHubString(value, "login") ?? "ghost", diff --git a/ui/src/lib/workboard/task-links.ts b/ui/src/lib/workboard/task-links.ts index 540ff3a2eb82..7bd8cd6c38ea 100644 --- a/ui/src/lib/workboard/task-links.ts +++ b/ui/src/lib/workboard/task-links.ts @@ -1,3 +1,4 @@ +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts"; import type { GatewaySessionRow } from "../../api/types.ts"; @@ -50,9 +51,7 @@ export function taskLifecycleSourceUpdatedAt(task: WorkboardTaskSummary): number } export function sessionUpdatedAtValue(session: GatewaySessionRow): number | undefined { - return typeof session.updatedAt === "number" && Number.isFinite(session.updatedAt) - ? session.updatedAt - : undefined; + return asFiniteNumber(session.updatedAt); } export function taskSessionKeyMatchesCardSession( diff --git a/ui/src/pages/chat/chat-thread-items.ts b/ui/src/pages/chat/chat-thread-items.ts index ee4a262e4d50..ebe183111740 100644 --- a/ui/src/pages/chat/chat-thread-items.ts +++ b/ui/src/pages/chat/chat-thread-items.ts @@ -1,4 +1,5 @@ import { readSessionMessageIdentity } from "@openclaw/gateway-client/browser"; +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import { resolveToolUseId } from "../../../../src/chat/tool-content.js"; import { escapeRegExp } from "../../../../src/shared/regexp.js"; @@ -574,8 +575,7 @@ export function queuedSendThreadMessage(item: ChatQueueItem): Record | null, key: "input" | "output" | "cacheRead" | "cacheWrite", ) { - const value = cost?.[key]; - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; + return asNonNegativeFiniteNumber(cost?.[key]); } function latestProviderCostStats(messages: unknown[] | undefined): ProviderCostStats | null { diff --git a/ui/src/pages/chat/components/chat-message-media.ts b/ui/src/pages/chat/components/chat-message-media.ts index 6b3410c3fb8d..cc1a015187dc 100644 --- a/ui/src/pages/chat/components/chat-message-media.ts +++ b/ui/src/pages/chat/components/chat-message-media.ts @@ -1,3 +1,4 @@ +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import type { ImageLightboxItem } from "../../../components/image-lightbox.ts"; import { t } from "../../../i18n/index.ts"; import type { MessageContentItem } from "../../../lib/chat/chat-types.ts"; @@ -486,8 +487,7 @@ export function extractImages(message: unknown): ImageBlock[] { } function readPairingQrExpiresAtMs(block: Record): number | undefined { - const expiresAtMs = block.expiresAtMs; - return typeof expiresAtMs === "number" && Number.isFinite(expiresAtMs) ? expiresAtMs : undefined; + return asFiniteNumber(block.expiresAtMs); } function isExpiredPairingQrBlock(block: Record, nowMs = Date.now()): boolean { diff --git a/ui/src/pages/chat/stream-reconciliation.ts b/ui/src/pages/chat/stream-reconciliation.ts index 1893db3e13c4..7985a418da83 100644 --- a/ui/src/pages/chat/stream-reconciliation.ts +++ b/ui/src/pages/chat/stream-reconciliation.ts @@ -1,4 +1,5 @@ // Control UI chat module implements stream reconciliation behavior. +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { streamSegmentHasItemId, streamSegmentUsesAccumulatedText, @@ -505,12 +506,8 @@ function messageTimestampMs(message: unknown): number | null { if (!message || typeof message !== "object") { return null; } - const timestamp = (message as { timestamp?: unknown; ts?: unknown }).timestamp; - if (typeof timestamp === "number" && Number.isFinite(timestamp)) { - return timestamp; - } - const ts = (message as { timestamp?: unknown; ts?: unknown }).ts; - return typeof ts === "number" && Number.isFinite(ts) ? ts : null; + const record = message as { timestamp?: unknown; ts?: unknown }; + return asFiniteNumber(record.timestamp) ?? asFiniteNumber(record.ts) ?? null; } function timestampForInsertedVisibleStream(