From 964c8c84c18c758eca535e6872cb0ab76b5133a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 17:14:53 -0700 Subject: [PATCH] refactor: consolidate coercion ownership (#122299) * refactor: consolidate coercion ownership Centralize four canonical coercion helpers, migrate exact core and plugin duplicates through narrow Plugin SDK facades, and enforce declaration and plugin-normalization ownership boundaries. The sweep adds eight focused SDK exports while deleting more production and tooling code than it adds. User-visible behavior is unchanged except for safer equivalent object and UI parsing at existing boundaries. * fix: guard integer option ownership Register resolveIntegerOption with the canonical function owner and extend the declaration-guard fixture so future local duplicates fail validation. * fix: keep integer helpers on numeric facade Remove the unshipped duplicate string-coerce exports and route every affected plugin consumer through the existing number-runtime contract. * fix: point numeric coercion to number runtime Make boundary and declaration diagnostics recommend the canonical numeric facade, with failing-before coverage for both guidance paths. --- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../error-runtime.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../string-coerce-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- extensions/acpx/src/codex-auth-bridge.ts | 5 +- extensions/active-memory/config.ts | 10 +- extensions/anthropic-vertex/region.ts | 13 +- extensions/baseten/models.ts | 10 +- .../browser/src/browser-tool.runtime.ts | 10 +- extensions/browser/src/browser/cdp.helpers.ts | 13 +- .../browser/src/browser/chrome-mcp-options.ts | 7 +- .../browser/src/browser/chrome.graphics.ts | 20 +- extensions/clawrouter/tool-schemas.ts | 7 +- extensions/clickclack/src/gateway.ts | 4 +- extensions/cloudflare-ai-gateway/index.ts | 2 +- .../codex/src/app-server/approval-bridge.ts | 4 +- .../src/app-server/native-execution-policy.ts | 27 +- extensions/codex/src/conversation-binding.ts | 26 +- extensions/codex/src/migration/apply.ts | 5 +- .../codex/src/session-catalog-parsing.ts | 12 +- extensions/comfy/workflow-runtime.ts | 7 +- extensions/copilot/harness.ts | 9 +- extensions/copilot/src/attempt-config.ts | 6 +- extensions/copilot/src/provider-bridge.ts | 19 +- extensions/copilot/src/runtime.ts | 8 +- extensions/copilot/src/tool-bridge.ts | 5 +- extensions/copilot/src/workspace-bootstrap.ts | 14 +- .../crabbox/src/crabbox-worker-profile.ts | 11 +- .../src/service-genai-attributes.ts | 10 +- .../discord/src/actions/runtime.shared.ts | 16 +- .../discord/src/active-turn-thread-route.ts | 6 +- extensions/discord/src/activities/config.ts | 6 +- extensions/discord/src/internal/rest.ts | 11 +- .../src/monitor/provider.deploy-errors.ts | 22 +- extensions/discord/src/proxy-fetch.ts | 10 +- extensions/duckduckgo/src/config.ts | 12 +- .../realtime-transcription-provider.ts | 10 +- extensions/feishu/src/reply-dispatcher.ts | 5 +- extensions/file-transfer/src/shared/params.ts | 4 +- .../fish-audio-speech/speech-provider.ts | 6 +- extensions/google-meet/src/config.ts | 16 +- .../google-meet/src/plugin-registration.ts | 11 +- extensions/google/realtime-voice-provider.ts | 4 +- .../googlechat/src/google-auth.runtime.ts | 5 +- extensions/imessage/src/monitor/catchup.ts | 6 +- extensions/imessage/src/probe.ts | 11 +- extensions/linux-node/src/command-utils.ts | 6 +- .../matrix/src/matrix/client/env-auth.ts | 3 +- extensions/matrix/src/matrix/client/shared.ts | 5 +- .../matrix/src/matrix/sdk/crypto-bootstrap.ts | 11 +- .../mattermost/src/mattermost/model-picker.ts | 13 +- .../memory-wiki/src/import-runs-state.ts | 13 +- extensions/meta/stream.ts | 5 +- extensions/msteams/src/channel.ts | 2 +- extensions/nextcloud-talk/src/room-info.ts | 9 +- extensions/nextcloud-talk/src/setup-core.ts | 7 +- extensions/nostr/src/nostr-ingress-state.ts | 7 +- .../onepassword-secret-ref-resolver.js | 5 +- extensions/openai/embedding-batch.ts | 5 +- .../openai/image-generation-provider.ts | 12 +- .../openai/realtime-transcription-provider.ts | 18 +- extensions/openai/realtime-voice-provider.ts | 23 +- .../openrouter/image-generation-provider.ts | 6 +- extensions/openrouter/provider-catalog.ts | 6 +- extensions/openrouter/video-model-catalog.ts | 16 +- .../parallel/src/parallel-search-normalize.ts | 6 +- extensions/qa-lab/src/evidence-gallery.ts | 11 +- .../discord/adapter.runtime.ts | 3 +- .../live-transports/matrix/adapter.runtime.ts | 3 +- .../live-transports/slack/adapter.runtime.ts | 3 +- .../whatsapp/adapter.runtime.ts | 3 +- extensions/qa-lab/src/process-tree-cpu.ts | 5 +- extensions/qa-lab/src/qa-cli-process.ts | 5 +- extensions/qa-lab/src/runtime-tool-fixture.ts | 41 +- extensions/qa-lab/src/scenario-lane.ts | 5 +- extensions/reef/src/transport.ts | 5 +- extensions/searxng/src/config.ts | 13 +- extensions/slack/src/data-table.ts | 9 +- .../slack/src/monitor/thread-resolution.ts | 6 +- extensions/talk-voice/index.ts | 9 +- extensions/tlon/src/settings.ts | 25 +- extensions/tts-local-cli/speech-provider.ts | 18 +- extensions/voice-call/index.ts | 6 +- extensions/voice-call/src/config-migration.ts | 13 +- .../voice-call/src/response-generator.ts | 3 +- .../auto-reply/monitor/inbound-dispatch.ts | 7 +- extensions/workboard/src/dispatcher.ts | 13 +- extensions/workboard/src/store-normalizers.ts | 80 ++-- extensions/xai/realtime-voice-bridge.ts | 9 +- extensions/xai/realtime-voice-config.ts | 17 +- extensions/xai/stream.ts | 5 +- extensions/xai/tts.ts | 9 +- package.json | 1 + packages/agent-core/src/agent-loop.ts | 5 +- .../ai/src/providers/anthropic-refusal.ts | 9 +- .../src/providers/clean-for-llamacpp-gbnf.ts | 6 +- .../openai-responses-tool-call-tracker.ts | 13 +- .../providers/tool-schema-json-projection.ts | 7 +- .../ai/src/transports/json-unsafe-integers.ts | 13 +- .../src/transports/model-transport-debug.ts | 12 +- .../src/transports/openai-reasoning-compat.ts | 8 +- .../openai-responses-payload-policy.ts | 16 +- .../src/transports/transport-stream-shared.ts | 16 +- packages/ai/src/utils/json-parse.ts | 13 +- packages/markdown-core/src/frontmatter.ts | 3 +- .../src/render-aware-chunking.ts | 8 +- .../src/host/read-file-shared.ts | 17 +- .../src/error-coercion.test.ts | 15 + .../normalization-core/src/error-coercion.ts | 5 + .../src/json-coercion.test.ts | 29 +- .../normalization-core/src/json-coercion.ts | 7 + .../src/record-coerce.test.ts | 29 ++ .../normalization-core/src/record-coerce.ts | 11 + .../src/string-normalization.test.ts | 13 + .../src/string-normalization.ts | 7 + packages/tool-call-repair/src/payload.ts | 11 +- ...eck-built-plugin-control-plane-modules.mts | 6 +- .../check-coercion-helper-declarations.mts | 342 ++++++++------ .../check-extension-package-tsc-boundary.mts | 30 +- .../check-extension-plugin-sdk-boundary.mts | 426 +++++++++++------- scripts/check-memory-fd-repro.mts | 18 +- scripts/check-plugin-gateway-gauntlet.mts | 19 +- scripts/copy-bundled-plugin-metadata.mts | 6 +- .../runtime-smoke.mjs | 15 +- .../lib/npm-telegram-live/prepare-package.mts | 5 +- .../lib/openai-web-search-minimal/client.mjs | 16 +- scripts/full-release-validation-at-sha.mts | 5 +- scripts/generate-npm-package-lock.mts | 60 ++- scripts/lib/bounded-response.mjs | 2 +- .../lib/extension-import-boundary-checker.mts | 26 +- scripts/lib/plugin-npm-package-manifest.mts | 6 +- scripts/lib/release-beta-verifier.ts | 5 +- scripts/lib/static-extension-assets.mts | 15 +- scripts/package-openclaw-for-docker.mts | 18 +- scripts/plugin-sdk-surface-report.mts | 8 +- scripts/pr-lib/review-artifacts.mjs | 7 +- ...e-extension-package-boundary-artifacts.mts | 14 +- .../resolve-openclaw-package-candidate.mts | 13 +- scripts/run-additional-boundary-checks.mts | 20 +- scripts/stage-bundled-plugin-runtime.mts | 6 +- scripts/sync-codex-model-prompt-fixture.ts | 6 +- scripts/test-live-shard.mts | 5 +- scripts/watch-node.mts | 6 +- src/acp/runtime/session-meta.ts | 9 +- src/acp/translator.ts | 10 +- src/agents/auth-profiles/state.ts | 20 +- src/agents/command/session-store.ts | 6 +- ...mbedded-agent-message-tool-source-reply.ts | 4 +- .../embedded-agent-runner/extensions.ts | 5 +- src/agents/runtime-plan/build.ts | 5 +- src/agents/session-activity-notes.ts | 7 +- src/agents/session-transcript-repair.ts | 8 +- .../announce/subagent-yield-output.ts | 4 +- src/auto-reply/reply/session-usage.ts | 6 +- src/auto-reply/reply/strip-inbound-meta.ts | 11 +- src/channels/mention-pattern-policy.ts | 2 +- src/chat/canvas-render.ts | 4 +- .../sessions/session-accessor.transcript.ts | 10 +- src/cron/isolated-agent/run-finalize.ts | 10 +- src/cron/task-run-detail.ts | 3 +- .../chat-display-projection.message-tool.ts | 7 +- .../chat-display-projection.sanitize.ts | 8 +- src/gateway/cli-session-history.claude.ts | 7 +- .../plugin-activation-runtime-config.ts | 7 +- .../chat-transcript-persistence.ts | 5 +- src/gateway/session-observer.ts | 8 +- src/gateway/session-utils-core.ts | 6 +- src/gateway/session-utils-row.ts | 10 +- src/gateway/watch-node-http.ts | 6 +- src/infra/fixed-window-rate-limit.ts | 15 +- src/infra/outbound/reply-payload-normalize.ts | 11 +- src/infra/package-json.ts | 5 +- src/infra/provider-usage.admin.ts | 5 +- src/infra/provider-usage.fetch.shared.ts | 7 +- src/infra/state-migrations.cron-run-logs.ts | 13 +- .../state-migrations.mcp-oauth-lock-stale.ts | 10 +- src/llm/providers/stream-wrappers/openai.ts | 33 +- src/llm/providers/stream-wrappers/proxy.ts | 31 +- .../diagnostic-support-log-redaction.ts | 7 +- src/logging/diagnostic-support-redaction.ts | 8 +- src/plugin-sdk/error-runtime.ts | 4 + src/plugin-sdk/plugin-config-runtime.ts | 5 +- ...rovider-catalog-live-normalize.internal.ts | 5 +- .../provider-openai-chatgpt-auth.ts | 11 +- src/plugin-sdk/string-coerce-runtime.ts | 3 + .../test-helpers/plugin-runtime-mock.ts | 7 +- src/plugin-sdk/text-runtime.ts | 23 +- src/plugin-sdk/webhook-memory-guards.ts | 36 +- src/plugin-sdk/webhook-numeric-options.ts | 9 - src/plugin-sdk/webhook-request-guards.ts | 6 +- src/plugins/runtime/runtime-llm.runtime.ts | 8 +- src/plugins/session-catalog-history-import.ts | 4 +- src/plugins/status-snapshot.ts | 41 +- src/shared/number-coercion.test.ts | 18 - src/shared/number-coercion.ts | 4 - src/state/openclaw-agent-db-schema.ts | 16 +- .../openclaw-state-db-legacy-backfills.ts | 16 +- src/system-agent/setup-inference-persist.ts | 3 +- src/talk/agent-run-control-shared.ts | 13 +- src/tts/tts-settings.ts | 17 +- .../package-openclaw-for-docker.e2e.test.ts | 1 + test/extension-import-boundaries.test.ts | 207 ++++++++- test/package-scripts.test.ts | 6 + ...check-coercion-helper-declarations.test.ts | 134 +++++- .../run-additional-boundary-checks.test.ts | 8 + test/scripts/runtime-postbuild.test.ts | 23 + ui/src/api/gateway.node.test.ts | 8 +- ui/src/app/canvas-surface-lease.runtime.ts | 5 +- ui/src/app/cloud-session-startup.runtime.ts | 5 +- ui/src/app/update-schedule-dto.ts | 2 +- ui/src/components/config-form.node.shared.ts | 3 +- ui/src/e2e/agent-config-save.e2e.test.ts | 8 +- ...agents-set-default-persistence.e2e.test.ts | 8 +- ui/src/e2e/approval-flow.e2e.test.ts | 8 +- ui/src/e2e/chat-flow.test-support.ts | 8 +- ui/src/e2e/chat-large-paste-99213.e2e.test.ts | 8 +- ui/src/e2e/control-ui-credentials.e2e.test.ts | 8 +- ui/src/e2e/cron-filters.e2e.test.ts | 8 +- ui/src/e2e/device-token-reconnect.e2e.test.ts | 8 +- ui/src/e2e/session-management.test-support.ts | 8 +- ui/src/lib/board/settings.ts | 13 +- ui/src/lib/board/widget-bridge.ts | 10 +- ui/src/lib/chat/message-normalizer.ts | 37 +- ui/src/lib/chat/outbox-store-codec.ts | 13 +- ui/src/lib/chat/tool-cards.ts | 19 +- ui/src/lib/config-form-utils.ts | 7 +- ui/src/lib/session-pull-requests.ts | 5 +- ui/src/lib/sessions/cloud-recovery.ts | 9 +- ui/src/lib/workboard/change-payload.ts | 7 +- ui/src/lib/workboard/task-links.ts | 10 +- ui/src/pages/chat/chat-pane-session.ts | 4 +- ui/src/pages/chat/chat-state-events.ts | 17 +- .../chat/components/chat-pull-requests.ts | 3 +- .../pages/chat/components/chat-tool-cards.ts | 22 +- .../pages/chat/sidebar-layout-persistence.ts | 15 +- ui/src/pages/chat/steer-lifecycle.ts | 11 +- .../pages/chat/terminal-message-identity.ts | 6 +- ui/src/pages/chat/tool-titles.ts | 6 +- ui/src/pages/config/view-schema.ts | 3 +- ui/src/pages/labs/labs-registry.ts | 36 +- ui/src/pages/new-session/preferences.ts | 7 +- ui/src/pages/plugins/plugins.e2e.test.ts | 8 +- .../workboard-status-persistence.e2e.test.ts | 8 +- ui/src/pages/workboard/workboard.e2e.test.ts | 8 +- 258 files changed, 1774 insertions(+), 1910 deletions(-) delete mode 100644 src/plugin-sdk/webhook-numeric-options.ts delete mode 100644 src/shared/number-coercion.test.ts diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 2530bf2cdfaf..156454e1d64e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"ca54298c1a0adf9550f58fc13881d7e7f37e4c755a5f917a595bc4f0177d9e20","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"b017f177c844e59f8c9771773fd9fe13bd49777097859ecec3e90057d6cc3709","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index e71e0617eeb7..76fa3e9df474 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"00743220201e85945df77cc8e276a4842f93602c971e06459ea00dd029f063ff","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"9695a1112df503dd92e36a5502aa3930289d6828a562917ea5b8d6b7c25a0755","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index e9a455ddcbb3..4ab239a09843 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"b0d043c2fc4d36023fa7bd6472c9b9cc5f0ee17ff4375d598e59fecb45cbe85c","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"96449168bf95c5c22ff75ba7d50d21cb2d9c73666c5ceef95450d76273cd36ca","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 0a71de14a90e..91b056073041 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"555310d087ce5a7a801f6291ee837283eb182e9bf86f294e11dc0d4559c9c268","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"f1e6d92d125cdaca72317819b5ba7619cbfcb67ee72752bb009af78a8f753b38","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 21745c8b2d54..5f54cddd6ffc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"08111748e2944e6bf1571eda856a46a00c813e2d90fd3da04a9c473b033bc38c","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"1b3770f22431dbbce8ffcbffd4496a95d2afaac82172aa48b73c9c8bceac7ca5","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 9c191677ce42..6a635241420d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"4561d105811249b490dcc691888e082fbb5365b0652fee8e97d272c55f7c1eba","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"fdea77b1259c9799177f9c2b327a6d759058d0df0ef339834b6c0a6a9d04b130","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index d71fa17626da..707a539677b4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"8bef6c7c70abc4745029421fef29125646effa27bb8947541e0ca9bea4e2da9b","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"ddc4dd3f139c5858912bad00560051731ef386eaec43957343b01f76224ecca3","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index 4bb2418a5dda..90e16f9335a5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"140e69b3a97558ca94edd1fc83fc528e33565d0b3b7b16a2fb7dbf9bd9deb0be","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"8f806aa43ddd9298176cacb5971b8ddac7f2c1d25101deaa92b3c9e1f788e09a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index b0fdf422809c..e581f4e8c2aa 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"af02ae51d5c0941991928b998ecac5584639e50912532082fa0d16fe0a40e43f","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"11138710259d030a81c55a9e506254428328931945b6c6b79fc969e832d56901","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/error-runtime.json b/docs/.generated/plugin-sdk-api-baseline/error-runtime.json index feac40570c5e..e908c8712e94 100644 --- a/docs/.generated/plugin-sdk-api-baseline/error-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/error-runtime.json @@ -1 +1 @@ -{"contentHash":"e0523b4936d7c7ec043265de9e2a8f6289d32dacba0a28c3999ba3a0975dfc21","entrypoint":"error-runtime","importSpecifier":"openclaw/plugin-sdk/error-runtime"} +{"contentHash":"1f61b7d845d0172c62a5413cd96559796dbe338b38866f91782ff74485aef96e","entrypoint":"error-runtime","importSpecifier":"openclaw/plugin-sdk/error-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 1f2a5ddc6317..aa9ae29cf581 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"de31ab324cc57e8f525cac7559813e83a434c3cb114124388558c0b35e094adc","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"180d24e66df9e1242f1ba78f4aea182c79340280d022db048b227071892fbb59","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 3f6ff347022d..000067354712 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"f559df4c86dd30b151db7e159944df99f5870a8564b4ef07adfbff4826737aa4","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"5a99888e906478be43c13f67f99fb2f74c7396eb834202e7d872f0e2ee60f144","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index ef26c4bd0087..bd28a0155379 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"9f9e1c49a46f04964615be21556834f4d7d6042aedef30812973efbc2af9871c","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"1896a2342b27ee5f055bf77f9a826f76ddc6520c9dce44789d6d612cfcdd17dc","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 6d8bd87e913b..047163253053 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"b1883cb0c123488b0d8f93934cc27dca32e349d17bab1119e0d273655b368c4a","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"fcdd433db990480373e6caea357c69cbbc754bcbf492fe3371cd21491d9bd04f","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index c09ea48f5eb0..baf8a2143932 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"7923737351da1ca1c0c0b3c3ce3d105a61739f633d27da4cc8b92c957ccf397f","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"9459eb7103be5db943a12ebbf69b62f9a06b60cf5a24df9a8b72fa2bd4242dce","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/string-coerce-runtime.json b/docs/.generated/plugin-sdk-api-baseline/string-coerce-runtime.json index cf862ba6de31..d21f655cc33b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/string-coerce-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/string-coerce-runtime.json @@ -1 +1 @@ -{"contentHash":"14d56ffc88afba3e8c83ace0894f0187860a45b739d562b96e290d2235c45294","entrypoint":"string-coerce-runtime","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime"} +{"contentHash":"347f68c5497a100307cffbb43c2f88e653bed7b7451066b7eb19266dd3c16660","entrypoint":"string-coerce-runtime","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 40a20b7acb77..2fe2b0a9603d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"b2281fac79519417397a7670e25392c7e7e592a84e3ed00459b947b6bef2955b","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"1d4374297f908a74d78873c746fc0624240182a96c28a064a47f91f6260049bb","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index a3ad46011276..5c2e2fcb23b4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"14ae7f9d841c6c4e2702239ed2aeb126f89039df8434bf12773a495486a04ff4","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"f5bf0f3889cd0e4a5b1de4e110098400573d46c508c962c386db3334246bc9fe","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/extensions/acpx/src/codex-auth-bridge.ts b/extensions/acpx/src/codex-auth-bridge.ts index 0b0d737a1223..b291be5165cb 100644 --- a/extensions/acpx/src/codex-auth-bridge.ts +++ b/extensions/acpx/src/codex-auth-bridge.ts @@ -8,6 +8,7 @@ import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store"; +import { isRecord as isConfigRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { parse as parseToml, stringify as stringifyToml, @@ -802,10 +803,6 @@ function extractConfiguredAdapterArgs(params: { return undefined; } -function isConfigRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function mergeConfigRecords( base: Record, override: Record, diff --git a/extensions/active-memory/config.ts b/extensions/active-memory/config.ts index 1d59ea87eb20..43bcb6c5d05c 100644 --- a/extensions/active-memory/config.ts +++ b/extensions/active-memory/config.ts @@ -1,6 +1,9 @@ import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; +import { + parseStrictPositiveInteger, + resolveIntegerOption, +} from "openclaw/plugin-sdk/number-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { isPathInside } from "openclaw/plugin-sdk/security-runtime"; import { @@ -52,10 +55,7 @@ function parseOptionalPositiveInt(value: unknown, fallback: number): number { } function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { - if (!Number.isFinite(value)) { - return fallback; - } - return Math.max(min, Math.min(max, Math.floor(value as number))); + return resolveIntegerOption(value, fallback, { min, max }); } function normalizeTranscriptDir(value: unknown): string { diff --git a/extensions/anthropic-vertex/region.ts b/extensions/anthropic-vertex/region.ts index 3c2276d14517..8b650a62b3bb 100644 --- a/extensions/anthropic-vertex/region.ts +++ b/extensions/anthropic-vertex/region.ts @@ -7,7 +7,10 @@ import { join } from "node:path"; import type { GoogleAuthOptions } from "google-auth-library"; import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString as normalizeOptionalSecretInput, +} from "openclaw/plugin-sdk/string-coerce-runtime"; const ANTHROPIC_VERTEX_DEFAULT_REGION = "global"; const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/; @@ -19,14 +22,6 @@ type AnthropicVertexAdcCredentials = NonNullable typeof item === "string") - : []; -} - function applyLiveReasoningEffortCompat( fallbackCompat: ModelCompatConfig, supportsReasoningEffort: boolean, @@ -177,7 +171,7 @@ function projectLiveModel( } const hasLiveFeatures = Array.isArray(row.supported_features); - const features = new Set(readStringArray(row.supported_features)); + const features = new Set(filterStringEntries(row.supported_features)); const pricing = asNonArrayRecord(row.pricing); const inputPrice = readPerTokenPrice(pricing.prompt); const outputPrice = readPerTokenPrice(pricing.completion); @@ -256,4 +250,4 @@ export function resolveBasetenDynamicModel(modelId: string) { compat: buildBasetenModelCompat(id), }; } -import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asNonArrayRecord, filterStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; diff --git a/extensions/browser/src/browser-tool.runtime.ts b/extensions/browser/src/browser-tool.runtime.ts index 0aa197e9afd8..8542600e7d42 100644 --- a/extensions/browser/src/browser-tool.runtime.ts +++ b/extensions/browser/src/browser-tool.runtime.ts @@ -1,3 +1,4 @@ +import { resolveOptionalIntegerOption } from "openclaw/plugin-sdk/number-runtime"; /** * Runtime dependency barrel for the Browser agent tool. * @@ -9,11 +10,14 @@ import { getRuntimeConfig } from "./sdk-config.js"; export { getRuntimeConfig }; /** Resolve global image downscaling for screenshots returned to agent tools. */ export function resolveRuntimeImageSanitization(): { maxDimensionPx: number } | undefined { - const configured = getRuntimeConfig().agents?.defaults?.imageMaxDimensionPx; - if (typeof configured !== "number" || !Number.isFinite(configured)) { + const maxDimensionPx = resolveOptionalIntegerOption( + getRuntimeConfig().agents?.defaults?.imageMaxDimensionPx, + { min: 1 }, + ); + if (maxDimensionPx === undefined) { return undefined; } - return { maxDimensionPx: Math.max(1, Math.floor(configured)) }; + return { maxDimensionPx }; } export { callGatewayTool, diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts index e36e3a26bf05..5b624822f100 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -1,11 +1,11 @@ /** * Chrome DevTools Protocol URL, fetch, and socket helpers. - * * Handles CDP URL normalization, SSRF-guarded HTTP discovery, credential * redaction/headers, and request/response correlation over WebSocket. */ import { createHash } from "node:crypto"; import { parseBrowserHttpUrl, redactCdpUrl } from "openclaw/plugin-sdk/browser-config"; +import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; @@ -35,8 +35,7 @@ import { normalizeBrowserTimerDelayMs } from "./timer-delay.js"; const CDP_URL_IN_TEXT_RE = /\b(?:https?|wss?):\/\/[^\s"'<>`]+/gi; -export { isLoopbackHost }; -export { parseBrowserHttpUrl, redactCdpUrl }; +export { isLoopbackHost, parseBrowserHttpUrl, redactCdpUrl }; /** * Returns true when the URL uses a WebSocket protocol (ws: or wss:). @@ -529,7 +528,7 @@ function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) { } catch (err) { pending.delete(id); clearPendingTimer(entry); - reject(err instanceof Error ? err : new Error(String(err))); + reject(toStringifiedError(err)); } }); }; @@ -549,7 +548,7 @@ function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) { // non-Error branch would require synthetically emitting on the socket, // which the library treats as an unhandled error and hangs the test. /* c8 ignore next */ - closeWithError(err instanceof Error ? err : new Error(String(err))); + closeWithError(toStringifiedError(err)); }); ws.on("message", (data) => { @@ -798,7 +797,7 @@ export async function withCdpSocket( // Error from Node's `ws` library, the latter is already an Error. The // non-Error wrap is defensive and structurally unreachable. /* c8 ignore next */ - closeWithError(err instanceof Error ? err : new Error(String(err))); + closeWithError(toStringifiedError(err)); // Cancellation on the final attempt must not become a handshake error. opts?.signal?.throwIfAborted(); if (attempt >= maxHandshakeRetries || !shouldRetryCdpHandshakeError(err)) { @@ -821,7 +820,7 @@ export async function withCdpSocket( try { return await fn(send); } catch (err) { - closeWithError(err instanceof Error ? err : new Error(String(err))); + closeWithError(toStringifiedError(err)); throw err; } finally { ws.close(); diff --git a/extensions/browser/src/browser/chrome-mcp-options.ts b/extensions/browser/src/browser/chrome-mcp-options.ts index 362c9a09fc57..9872e52c3dc9 100644 --- a/extensions/browser/src/browser/chrome-mcp-options.ts +++ b/extensions/browser/src/browser/chrome-mcp-options.ts @@ -12,11 +12,6 @@ import { type NormalizedChromeMcpProfileOptions, } from "./chrome-mcp-contracts.js"; -function normalizeChromeMcpUserDataDir(userDataDir?: string): string | undefined { - const trimmed = userDataDir?.trim(); - return trimmed ? trimmed : undefined; -} - function normalizeChromeMcpStringList(values?: string[]): string[] { return Array.isArray(values) ? values.filter( @@ -35,7 +30,7 @@ export function normalizeChromeMcpOptions( const command = normalizeOptionalString(options.mcpCommand) ?? DEFAULT_CHROME_MCP_COMMAND; return { command, - userDataDir: normalizeChromeMcpUserDataDir(options.userDataDir), + userDataDir: normalizeOptionalString(options.userDataDir), browserUrl: normalizeOptionalString(options.cdpUrl), extraArgs: normalizeChromeMcpStringList(options.mcpArgs), }; diff --git a/extensions/browser/src/browser/chrome.graphics.ts b/extensions/browser/src/browser/chrome.graphics.ts index 33ba5fe98df9..0814b6d86abc 100644 --- a/extensions/browser/src/browser/chrome.graphics.ts +++ b/extensions/browser/src/browser/chrome.graphics.ts @@ -1,4 +1,10 @@ -import { asNullableRecord, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asNullableRecord, + asFiniteNumber, + filterStringEntries, + isRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; /** * Managed Chrome graphics diagnostics. * @@ -24,11 +30,11 @@ type ChromeGraphicsProbeOptions = { }; function readChromeString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; + return normalizeOptionalString(value) ?? ""; } function readChromeNumber(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) ? value : 0; + return asFiniteNumber(value) ?? 0; } function readStringRecord(value: unknown): Record { @@ -42,12 +48,6 @@ function readStringRecord(value: unknown): Record { return Object.fromEntries(entries); } -function readStringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; -} - function readSize(value: unknown): { width: number; height: number } { const size = asNullableRecord(value); return { @@ -162,7 +162,7 @@ function normalizeChromeGraphicsInfo( devices, featureStatus, disabledFeatures, - driverBugWorkarounds: readStringArray(gpu.driverBugWorkarounds), + driverBugWorkarounds: filterStringEntries(gpu.driverBugWorkarounds), videoDecoding: readVideoDecoding(gpu.videoDecoding), videoEncoding: readVideoEncoding(gpu.videoEncoding), }; diff --git a/extensions/clawrouter/tool-schemas.ts b/extensions/clawrouter/tool-schemas.ts index 97fb455fdf4b..d008d5aaa3f0 100644 --- a/extensions/clawrouter/tool-schemas.ts +++ b/extensions/clawrouter/tool-schemas.ts @@ -4,6 +4,7 @@ import type { ProviderToolSchemaDiagnostic, } from "openclaw/plugin-sdk/plugin-entry"; import { findUnsupportedSchemaKeywords } from "openclaw/plugin-sdk/provider-tools"; +import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; const PERPLEXITY_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([ "patternProperties", @@ -36,12 +37,6 @@ const SCHEMA_VALUE_KEYS = new Set([ "contentSchema", ]); -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - // JSON Schema allows `type` to be an array; a union containing "object" still // admits objects, so it needs `properties` for Perplexity too. function isObjectType(type: unknown): boolean { diff --git a/extensions/clickclack/src/gateway.ts b/extensions/clickclack/src/gateway.ts index 7220afc58047..2a84b140fc22 100644 --- a/extensions/clickclack/src/gateway.ts +++ b/extensions/clickclack/src/gateway.ts @@ -6,6 +6,7 @@ import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { channelReadyPatch, channelStoppedPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; +import { readStringField } from "openclaw/plugin-sdk/string-coerce-runtime"; import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; import type { RawData } from "ws"; import { resolveClickClackInboundAccess } from "./access.js"; @@ -28,8 +29,7 @@ import type { const CLICKCLACK_EVENT_PAGE_LIMIT = 500; function payloadString(event: ClickClackEvent, key: string): string { - const value = event.payload?.[key]; - return typeof value === "string" ? value : ""; + return readStringField(event.payload, key) ?? ""; } function eventCorrelationId(event: ClickClackEvent): string | undefined { diff --git a/extensions/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts index 1476c8163ced..95bc00078040 100644 --- a/extensions/cloudflare-ai-gateway/index.ts +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -24,7 +24,7 @@ const PROVIDER_ID = "cloudflare-ai-gateway"; const PROVIDER_ENV_VAR = "CLOUDFLARE_AI_GATEWAY_API_KEY"; const PROFILE_ID = "cloudflare-ai-gateway:default"; function readRequiredTextInput(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; + return normalizeOptionalString(value) ?? ""; } async function resolveCloudflareGatewayMetadataInteractive(ctx: { diff --git a/extensions/codex/src/app-server/approval-bridge.ts b/extensions/codex/src/app-server/approval-bridge.ts index 77962e2e095a..383fdd9ad8a9 100644 --- a/extensions/codex/src/app-server/approval-bridge.ts +++ b/extensions/codex/src/app-server/approval-bridge.ts @@ -13,6 +13,7 @@ import { type NativeHookRelayProcessResponse, type NativeHookRelayRegistrationHandle, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { coerceErrorMessage as formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { normalizeTrimmedStringList, readStringField as readString, @@ -1332,7 +1333,4 @@ function joinDescriptionLinesWithinLimit(lines: string[], maxLength: number): st return description; } -function formatErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/codex/src/app-server/native-execution-policy.ts b/extensions/codex/src/app-server/native-execution-policy.ts index 39942a617037..26c17ef68208 100644 --- a/extensions/codex/src/app-server/native-execution-policy.ts +++ b/extensions/codex/src/app-server/native-execution-policy.ts @@ -3,6 +3,7 @@ * or whether OpenClaw must keep exec/process on a configured node host. */ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { resolveSandboxRuntimeStatus } from "openclaw/plugin-sdk/sandbox"; import { getSessionEntry, type SessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; @@ -16,12 +17,6 @@ type ExecHostOverride = { type AgentEntry = NonNullable["list"]>[number]; -const DEFAULT_AGENT_ID = "main"; -const VALID_AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/i; -const INVALID_AGENT_ID_CHARS_PATTERN = /[^a-z0-9_-]+/g; -const LEADING_DASH_PATTERN = /^-+/; -const TRAILING_DASH_PATTERN = /-+$/; - /** Effective execution-host policy for the Codex app-server native tool surface. */ export type CodexNativeExecutionPolicy = { nativeToolSurfaceAllowed: boolean; @@ -197,25 +192,7 @@ function resolveDefaultPolicyAgentId(agents: AgentEntry[]): string { function normalizeAgentIdOrDefault(value?: string | null): string | undefined { const normalized = normalizeAgentId(value); - return normalized === DEFAULT_AGENT_ID && !(value ?? "").trim() ? undefined : normalized; -} - -function normalizeAgentId(value?: string | null): string { - const trimmed = (value ?? "").trim(); - if (!trimmed) { - return DEFAULT_AGENT_ID; - } - const normalized = trimmed.toLowerCase(); - if (VALID_AGENT_ID_PATTERN.test(trimmed)) { - return normalized; - } - return ( - normalized - .replace(INVALID_AGENT_ID_CHARS_PATTERN, "-") - .replace(LEADING_DASH_PATTERN, "") - .replace(TRAILING_DASH_PATTERN, "") - .slice(0, 64) || DEFAULT_AGENT_ID - ); + return normalized === "main" && !(value ?? "").trim() ? undefined : normalized; } function normalizeExecTarget(value?: string | null): ExecTarget | undefined { diff --git a/extensions/codex/src/conversation-binding.ts b/extensions/codex/src/conversation-binding.ts index 7373aab59748..f083f34aaef5 100644 --- a/extensions/codex/src/conversation-binding.ts +++ b/extensions/codex/src/conversation-binding.ts @@ -15,6 +15,7 @@ import type { PluginHookInboundClaimEvent, } from "openclaw/plugin-sdk/plugin-entry"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; +import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { getSessionEntry, resolveStorePath, @@ -122,11 +123,6 @@ import { isIncognitoSessionKey } from "./incognito-session.js"; import { resumeCodexCliSessionOnNode } from "./node-cli-sessions.js"; const DEFAULT_BOUND_TURN_TIMEOUT_MS = 20 * 60_000; -const DEFAULT_AGENT_ID = "main"; -const VALID_AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/i; -const INVALID_AGENT_ID_CHARS_PATTERN = /[^a-z0-9_-]+/g; -const LEADING_DASH_PATTERN = /^-+/; -const TRAILING_DASH_PATTERN = /-+$/; const NATIVE_CONVERSATION_INTERACTIVE_APPROVALS_UNAVAILABLE = "OpenClaw native Codex conversation binding cannot route interactive approvals yet; use the Codex harness or explicit /acp spawn codex for that workflow."; @@ -1484,25 +1480,7 @@ function resolveDefaultPolicyAgentId(config: ResolvedCodexConversationConfig): s function normalizeAgentIdOrDefault(value?: string | null): string | undefined { const normalized = normalizeAgentId(value); - return normalized === DEFAULT_AGENT_ID && !(value ?? "").trim() ? undefined : normalized; -} - -function normalizeAgentId(value?: string | null): string { - const trimmed = (value ?? "").trim(); - if (!trimmed) { - return DEFAULT_AGENT_ID; - } - const normalized = trimmed.toLowerCase(); - if (VALID_AGENT_ID_PATTERN.test(trimmed)) { - return normalized; - } - return ( - normalized - .replace(INVALID_AGENT_ID_CHARS_PATTERN, "-") - .replace(LEADING_DASH_PATTERN, "") - .replace(TRAILING_DASH_PATTERN, "") - .slice(0, 64) || DEFAULT_AGENT_ID - ); + return normalized === "main" && !(value ?? "").trim() ? undefined : normalized; } function isCodexThreadNotFoundError(error: unknown): boolean { diff --git a/extensions/codex/src/migration/apply.ts b/extensions/codex/src/migration/apply.ts index eeef7690303d..55f54ad9c965 100644 --- a/extensions/codex/src/migration/apply.ts +++ b/extensions/codex/src/migration/apply.ts @@ -1,5 +1,6 @@ // Codex plugin module implements apply behavior. import path from "node:path"; +import { coerceErrorMessage as formatCodexMigrationError } from "openclaw/plugin-sdk/error-runtime"; import { applyMigrationManualItem, markMigrationItemConflict, @@ -293,10 +294,6 @@ function isCodexPluginInventoryLoadError(error: unknown): boolean { return message.includes("codex app-server plugin/list timed out"); } -function formatCodexMigrationError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function resolveTargetCodexAppServer(ctx: MigrationProviderContext) { return resolveCodexAppServerRuntimeOptions({ pluginConfig: readCodexPluginConfig(ctx.config), diff --git a/extensions/codex/src/session-catalog-parsing.ts b/extensions/codex/src/session-catalog-parsing.ts index 7420c4482156..621adfe48215 100644 --- a/extensions/codex/src/session-catalog-parsing.ts +++ b/extensions/codex/src/session-catalog-parsing.ts @@ -1,4 +1,4 @@ -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumber, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { sanitizeTerminalText } from "openclaw/plugin-sdk/text-chunking"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { CodexThread, CodexThreadTurnsListResponse } from "./app-server/protocol.js"; @@ -305,10 +305,6 @@ export function parseJsonParams(paramsJSON?: string | null): unknown { } } -function readFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function parseOptionalCatalogString( value: unknown, field: string, @@ -385,9 +381,9 @@ function parseCatalogSession( const sessionKey = options.allowSessionKey ? parseOptionalCatalogString(value.sessionKey, "OpenClaw session key", MAX_SESSION_KEY_LENGTH) : undefined; - const createdAt = readFiniteNumber(value.createdAt); - const updatedAt = readFiniteNumber(value.updatedAt); - const recencyAt = value.recencyAt === null ? null : readFiniteNumber(value.recencyAt); + const createdAt = asFiniteNumber(value.createdAt); + const updatedAt = asFiniteNumber(value.updatedAt); + const recencyAt = value.recencyAt === null ? null : asFiniteNumber(value.recencyAt); return { threadId: value.threadId, status, diff --git a/extensions/comfy/workflow-runtime.ts b/extensions/comfy/workflow-runtime.ts index 1c9d81692a63..8d2000b5de25 100644 --- a/extensions/comfy/workflow-runtime.ts +++ b/extensions/comfy/workflow-runtime.ts @@ -124,10 +124,6 @@ if (process.env.VITEST === "true") { }); } -function readConfigBoolean(config: ComfyProviderConfig, key: string): boolean | undefined { - return asBoolean(config[key]); -} - function readConfigInteger(config: ComfyProviderConfig, key: string): number | undefined { const value = config[key]; return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; @@ -710,8 +706,7 @@ export async function runComfyWorkflow(params: { throw new Error("Comfy Cloud API key missing"); } - const explicitAllowPrivateNetwork = - readConfigBoolean(capabilityConfig, "allowPrivateNetwork") === true; + const explicitAllowPrivateNetwork = asBoolean(capabilityConfig.allowPrivateNetwork) === true; const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } = resolveProviderHttpRequestConfig({ baseUrl: normalizeOptionalString(capabilityConfig.baseUrl), diff --git a/extensions/copilot/harness.ts b/extensions/copilot/harness.ts index 65fe532f0119..3aa628911fa0 100644 --- a/extensions/copilot/harness.ts +++ b/extensions/copilot/harness.ts @@ -17,6 +17,7 @@ import { type AgentHarnessResetParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CopilotSessionConfig } from "./src/attempt.js"; import { createCopilotByokAuth, resolveCopilotAuth, tokenFingerprint } from "./src/auth-bridge.js"; import { createCopilotByokProxy } from "./src/byok-proxy.js"; @@ -454,10 +455,10 @@ function computeSessionKey( (typeof p.model === "string" ? p.model : ""); const requestTransport = p.model && typeof p.model === "object" ? getModelProviderRequestTransport(p.model) : undefined; - const requestAuthMode = readSessionString( + const requestAuthMode = normalizeOptionalString( requestTransport?.auth?.mode ?? modelObj.request?.auth?.mode, ); - const azureApiVersion = readSessionString( + const azureApiVersion = normalizeOptionalString( modelObj.azureApiVersion ?? modelObj.params?.azureApiVersion, ); // resolveCopilotAuth can throw when an explicit `auth.gitHubToken` @@ -559,10 +560,6 @@ function computeSessionKey( return parts.join("|"); } -function readSessionString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function fingerprintSessionValue(value: unknown): string { return typeof value === "string" && value ? tokenFingerprint(value) : ""; } diff --git a/extensions/copilot/src/attempt-config.ts b/extensions/copilot/src/attempt-config.ts index a7540b56b3e2..12bf0e03c1bc 100644 --- a/extensions/copilot/src/attempt-config.ts +++ b/extensions/copilot/src/attempt-config.ts @@ -1,5 +1,8 @@ import type { MessageOptions, SessionConfig, Tool as SdkTool } from "@github/copilot-sdk"; import type { AgentMessage, SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { toStringifiedError as toCopilotError } from "openclaw/plugin-sdk/error-runtime"; + +export { toCopilotError }; import { detectAndLoadAgentHarnessPromptImages, getModelProviderRequestTransport, @@ -439,9 +442,6 @@ export function resolvePoolAcquire(params: AttemptParamsLike): { provider, }; } -export function toCopilotError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} export function isSdkSendAndWaitTimeoutError(error: unknown): boolean { if (error === null || typeof error !== "object") { return false; diff --git a/extensions/copilot/src/provider-bridge.ts b/extensions/copilot/src/provider-bridge.ts index 6236d8500f03..280e4611261f 100644 --- a/extensions/copilot/src/provider-bridge.ts +++ b/extensions/copilot/src/provider-bridge.ts @@ -2,7 +2,10 @@ import type { ProviderConfig } from "@github/copilot-sdk"; import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth"; import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + filterStringRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { tokenFingerprint } from "./auth-bridge.js"; const COPILOT_BYOK_PROVIDER_ERROR = @@ -96,7 +99,7 @@ export function resolveCopilotProvider(params: { const api = normalizeOptionalString(params.model.api)?.toLowerCase() ?? "openai-responses"; const provider = resolveProviderType(api, baseUrl, params.model.azureApiVersion); const resolvedApiKey = resolveProviderCredential(params.resolvedApiKey); - const headers = resolveProviderHeaders(params.model.headers); + const headers = filterStringRecord(params.model.headers); const requestAuthMode = normalizeOptionalString(params.model.requestAuthMode)?.toLowerCase(); const usePreparedRequestAuth = requestAuthMode !== undefined && requestAuthMode !== "provider-default"; @@ -322,15 +325,3 @@ function resolveProviderCredential(value: string | undefined): string | undefine const credential = normalizeOptionalString(value); return credential && !isNonSecretApiKeyMarker(credential) ? credential : undefined; } - -function resolveProviderHeaders( - headers: Record | undefined, -): Record | undefined { - if (!headers) { - return undefined; - } - const resolved = Object.fromEntries( - Object.entries(headers).filter(([, value]) => typeof value === "string"), - ) as Record; - return Object.keys(resolved).length > 0 ? resolved : undefined; -} diff --git a/extensions/copilot/src/runtime.ts b/extensions/copilot/src/runtime.ts index 150f8dc2cb8a..cc4046f29eaa 100644 --- a/extensions/copilot/src/runtime.ts +++ b/extensions/copilot/src/runtime.ts @@ -1,6 +1,7 @@ // Copilot plugin module implements runtime behavior. import { normalize, resolve, sep } from "node:path"; import type { CopilotClient, CopilotClientOptions } from "@github/copilot-sdk"; +import { toStringifiedError as toCopilotRuntimeError } from "openclaw/plugin-sdk/error-runtime"; import { loadCopilotSdk } from "./sdk-loader.js"; // SAFETY: The pool reuses CopilotClient instances per normalized PoolKey and does not @@ -389,10 +390,3 @@ function normalizeCopilotHome(copilotHome: string): string { } return normalizedHome; } - -function toCopilotRuntimeError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - return new Error(String(error)); -} diff --git a/extensions/copilot/src/tool-bridge.ts b/extensions/copilot/src/tool-bridge.ts index d2a55d5b2bc9..3a24be5ac479 100644 --- a/extensions/copilot/src/tool-bridge.ts +++ b/extensions/copilot/src/tool-bridge.ts @@ -23,6 +23,7 @@ import { sanitizeToolResult, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent-harness-tool-runtime"; +import { toStringifiedError as toCopilotToolError } from "openclaw/plugin-sdk/error-runtime"; type CreateOpenClawCodingTools = (typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]; @@ -911,7 +912,3 @@ function findDuplicateToolNames(sourceTools: AnyAgentTool[]): string[] { .map(([name]) => name) .toSorted(); } - -function toCopilotToolError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} diff --git a/extensions/copilot/src/workspace-bootstrap.ts b/extensions/copilot/src/workspace-bootstrap.ts index fe3b582e290c..777f277b78a5 100644 --- a/extensions/copilot/src/workspace-bootstrap.ts +++ b/extensions/copilot/src/workspace-bootstrap.ts @@ -8,7 +8,7 @@ import { resolveBootstrapContextForRun, resolveUserPath, } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { hasNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { readNonBlankString } from "openclaw/plugin-sdk/string-coerce-runtime"; // Filenames the Copilot SDK already loads natively from the working // directory / instructionDirectories (per @@ -88,10 +88,10 @@ export async function resolveCopilotWorkspaceBootstrapContext(params: { const bootstrapContext = await resolveBootstrapContextForRun({ workspaceDir, config: attempt.config, - sessionKey: readNonEmptyString((attempt as { sessionKey?: unknown }).sessionKey), - sessionId: readNonEmptyString(attempt.sessionId), + sessionKey: readNonBlankString((attempt as { sessionKey?: unknown }).sessionKey), + sessionId: readNonBlankString(attempt.sessionId), chatType: attempt.chatType, - agentId: readNonEmptyString(attempt.agentId), + agentId: readNonBlankString(attempt.agentId), warn: params.warn, contextMode: attempt.bootstrapContextMode, runKind: attempt.bootstrapContextRunKind, @@ -235,12 +235,8 @@ function getCopilotContextFileBasename(filePath: string): string { return normalizeCopilotContextFilePath(filePath).split("/").pop() ?? ""; } -function readNonEmptyString(value: unknown): string | undefined { - return hasNonEmptyString(value) ? value : undefined; -} - function readResolvedWorkspacePath(value: unknown): string | undefined { - const raw = readNonEmptyString(value); + const raw = readNonBlankString(value); if (!raw) { return undefined; } diff --git a/extensions/crabbox/src/crabbox-worker-profile.ts b/extensions/crabbox/src/crabbox-worker-profile.ts index a49de4fce355..bde693c1c49b 100644 --- a/extensions/crabbox/src/crabbox-worker-profile.ts +++ b/extensions/crabbox/src/crabbox-worker-profile.ts @@ -2,6 +2,9 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { WorkerProviderError, type WorkerProfile } from "openclaw/plugin-sdk/plugin-entry"; +import { normalizeOptionalString as nonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; + +export { nonEmptyString }; const PROFILE_KEYS = new Set([ "binary", @@ -39,14 +42,6 @@ type CrabboxProfile = { type IsExecutable = (candidate: string) => boolean; -export function nonEmptyString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed || undefined; -} - function requirePositiveDuration(value: unknown, key: string): string { const duration = nonEmptyString(value); if (!duration || !isPositiveGoDuration(duration)) { diff --git a/extensions/diagnostics-otel/src/service-genai-attributes.ts b/extensions/diagnostics-otel/src/service-genai-attributes.ts index 98cc48bbcb05..b63383f76d8a 100644 --- a/extensions/diagnostics-otel/src/service-genai-attributes.ts +++ b/extensions/diagnostics-otel/src/service-genai-attributes.ts @@ -1,6 +1,7 @@ import { SpanKind } from "@opentelemetry/api"; import { GEN_AI_OPERATION_NAME_VALUE_INVOKE_AGENT } from "@opentelemetry/semantic-conventions/incubating"; import { normalizeDiagnosticValue } from "openclaw/plugin-sdk/diagnostic-runtime"; +import { asFiniteNumber, asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { DiagnosticEventPayload } from "../api.js"; import { redactSensitiveText } from "../api.js"; import { @@ -48,11 +49,11 @@ export function genAiOperationName( } export function positiveFiniteNumber(value: number | undefined): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; + return asFiniteNumberInRange(value, { min: 0, minExclusive: true }); } function nonNegativeFiniteNumber(value: number | undefined): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; + return asFiniteNumberInRange(value, { min: 0 }); } export function assignPositiveNumberAttr( @@ -88,8 +89,9 @@ function assignNumberAttr( key: string, value: number | undefined, ): void { - if (typeof value === "number" && Number.isFinite(value)) { - attrs[key] = value; + const normalized = asFiniteNumber(value); + if (normalized !== undefined) { + attrs[key] = normalized; } } diff --git a/extensions/discord/src/actions/runtime.shared.ts b/extensions/discord/src/actions/runtime.shared.ts index b87ffa664e63..63e440f28219 100644 --- a/extensions/discord/src/actions/runtime.shared.ts +++ b/extensions/discord/src/actions/runtime.shared.ts @@ -1,3 +1,4 @@ +import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; // Discord plugin module implements runtime.shared behavior. import { parseAvailableTags, @@ -45,13 +46,6 @@ export function readDiscordAutoArchiveDurationParam( return value; } -function readDiscordBooleanParam( - params: Record, - key: string, -): boolean | undefined { - return typeof params[key] === "boolean" ? params[key] : undefined; -} - export function createDiscordActionOptions< T extends Record = Record, >(params: { @@ -80,7 +74,7 @@ export function readDiscordChannelCreateParams( parentId: parentId ?? undefined, topic: readStringParam(params, "topic") ?? undefined, position: readNonNegativeIntegerParam(params, "position") ?? undefined, - nsfw: readDiscordBooleanParam(params, "nsfw"), + nsfw: asBoolean(params.nsfw), }; } @@ -92,10 +86,10 @@ export function readDiscordChannelEditParams(params: Record): D topic: readStringParam(params, "topic") ?? undefined, position: readNonNegativeIntegerParam(params, "position") ?? undefined, parentId: parentId === undefined ? undefined : parentId, - nsfw: readDiscordBooleanParam(params, "nsfw"), + nsfw: asBoolean(params.nsfw), rateLimitPerUser: readNonNegativeIntegerParam(params, "rateLimitPerUser") ?? undefined, - archived: readDiscordBooleanParam(params, "archived"), - locked: readDiscordBooleanParam(params, "locked"), + archived: asBoolean(params.archived), + locked: asBoolean(params.locked), autoArchiveDuration: readDiscordAutoArchiveDurationParam(params, "autoArchiveDuration"), availableTags: parseAvailableTags(params.availableTags), }; diff --git a/extensions/discord/src/active-turn-thread-route.ts b/extensions/discord/src/active-turn-thread-route.ts index a91cfd010c15..217809399317 100644 --- a/extensions/discord/src/active-turn-thread-route.ts +++ b/extensions/discord/src/active-turn-thread-route.ts @@ -10,11 +10,6 @@ type ActiveDiscordTurnThreadRoute = { const activeRoutes = new Map>(); -function normalizeId(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed || undefined; -} - export function beginDiscordActiveTurnThreadRoute( sessionKey: string | undefined, route: ActiveDiscordTurnThreadRoute, @@ -96,3 +91,4 @@ function findDiscordActiveTurnThreadReplyRoute(params: { (!route.accountId || !params.accountId || route.accountId === params.accountId), ); } +import { normalizeOptionalString as normalizeId } from "openclaw/plugin-sdk/string-coerce-runtime"; diff --git a/extensions/discord/src/activities/config.ts b/extensions/discord/src/activities/config.ts index 7dd604395551..fd73613acb9b 100644 --- a/extensions/discord/src/activities/config.ts +++ b/extensions/discord/src/activities/config.ts @@ -1,4 +1,5 @@ import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-contracts"; +import { normalizeOptionalString as readNonEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; type DiscordActivitiesConfigResolution = | { @@ -11,11 +12,6 @@ type DiscordActivitiesConfigResolution = reason: "not-configured" | "missing-client-secret"; }; -function readNonEmpty(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed || undefined; -} - export function resolveDiscordActivitiesConfig( account: DiscordAccountConfig, env: NodeJS.ProcessEnv = process.env, diff --git a/extensions/discord/src/internal/rest.ts b/extensions/discord/src/internal/rest.ts index 3a48ed0c14d2..040ee43c4310 100644 --- a/extensions/discord/src/internal/rest.ts +++ b/extensions/discord/src/internal/rest.ts @@ -3,7 +3,7 @@ import { inspect } from "node:util"; import { gunzipSync } from "node:zlib"; import { clampTimerTimeoutMs, - parseFiniteNumber, + resolveIntegerOption as normalizeIntegerOption, resolveTimerTimeoutMs, } from "openclaw/plugin-sdk/number-runtime"; import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; @@ -307,15 +307,6 @@ export class RequestClient { } } -function normalizeIntegerOption( - value: number | undefined, - fallback: number, - params: { min: number }, -): number { - const candidate = parseFiniteNumber(value) ?? fallback; - return Math.max(params.min, Math.floor(candidate)); -} - function normalizeRequestClientOptions( options?: RequestClientOptions, ): NormalizedRequestClientOptions { diff --git a/extensions/discord/src/monitor/provider.deploy-errors.ts b/extensions/discord/src/monitor/provider.deploy-errors.ts index 0ed4b02f35ae..9ec2a6d23a5f 100644 --- a/extensions/discord/src/monitor/provider.deploy-errors.ts +++ b/extensions/discord/src/monitor/provider.deploy-errors.ts @@ -1,11 +1,11 @@ // Discord provider module implements model/runtime integration. import { inspect } from "node:util"; -import { - parseStrictFiniteNumber, - parseStrictNonNegativeInteger, -} from "openclaw/plugin-sdk/number-runtime"; import { formatDurationSeconds } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + parseFiniteNumber as readFiniteNumber, + parseStrictNonNegativeInteger as readNonNegativeInteger, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { RateLimitError } from "../internal/discord.js"; @@ -102,20 +102,6 @@ function readDiscordDeployObjectField(value: unknown, field: string): unknown { : undefined; } -function readFiniteNumber(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string" && value.trim().length > 0) { - return parseStrictFiniteNumber(value); - } - return undefined; -} - -function readNonNegativeInteger(value: unknown): number | undefined { - return parseStrictNonNegativeInteger(value); -} - function isAbortLikeError(err: unknown): boolean { if (!err || typeof err !== "object") { return false; diff --git a/extensions/discord/src/proxy-fetch.ts b/extensions/discord/src/proxy-fetch.ts index 2669b2d9ef7a..14839d8af5a0 100644 --- a/extensions/discord/src/proxy-fetch.ts +++ b/extensions/discord/src/proxy-fetch.ts @@ -2,22 +2,18 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { makeProxyFetch } from "openclaw/plugin-sdk/fetch-runtime"; import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ResolvedDiscordAccount } from "./accounts.js"; function resolveDiscordProxyUrl( account: Pick, cfg: OpenClawConfig, ): string | undefined { - const accountProxy = account.config.proxy?.trim(); + const accountProxy = normalizeOptionalString(account.config.proxy); if (accountProxy) { return accountProxy; } - const channelProxy = cfg?.channels?.discord?.proxy; - if (typeof channelProxy !== "string") { - return undefined; - } - const trimmed = channelProxy.trim(); - return trimmed || undefined; + return normalizeOptionalString(cfg?.channels?.discord?.proxy); } function resolveDiscordProxyFetchByUrl( diff --git a/extensions/duckduckgo/src/config.ts b/extensions/duckduckgo/src/config.ts index 2781725496db..bfac8aac18c6 100644 --- a/extensions/duckduckgo/src/config.ts +++ b/extensions/duckduckgo/src/config.ts @@ -1,6 +1,9 @@ // Duckduckgo helper module supports config behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; const DEFAULT_DDG_SAFE_SEARCH = "moderate"; @@ -25,12 +28,7 @@ function resolveDdgWebSearchConfig( } export function resolveDdgRegion(config?: OpenClawConfig): string | undefined { - const region = resolveDdgWebSearchConfig(config)?.region; - if (typeof region !== "string") { - return undefined; - } - const trimmed = region.trim(); - return trimmed || undefined; + return normalizeOptionalString(resolveDdgWebSearchConfig(config)?.region); } export function resolveDdgSafeSearch(config?: OpenClawConfig): DdgSafeSearch { diff --git a/extensions/elevenlabs/realtime-transcription-provider.ts b/extensions/elevenlabs/realtime-transcription-provider.ts index ef14ac05ecce..c3d6633d977f 100644 --- a/extensions/elevenlabs/realtime-transcription-provider.ts +++ b/extensions/elevenlabs/realtime-transcription-provider.ts @@ -9,7 +9,9 @@ import { } from "openclaw/plugin-sdk/realtime-transcription"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { + asFiniteNumberInRange, asOptionalRecord as readRecord, + asSafeIntegerInRange, normalizeOptionalString, parseFiniteNumber as readFiniteNumber, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -81,19 +83,17 @@ function normalizeCommitStrategy(value: unknown): "manual" | "vad" | undefined { function normalizePositiveSafeInteger(value: unknown): number | undefined { const parsed = readFiniteNumber(value); - return parsed !== undefined && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; + return asSafeIntegerInRange(parsed, { min: 1 }); } function normalizeFiniteRange(value: unknown, min: number, max: number): number | undefined { const parsed = readFiniteNumber(value); - return parsed !== undefined && parsed >= min && parsed <= max ? parsed : undefined; + return asFiniteNumberInRange(parsed, { min, max }); } function normalizeIntegerRange(value: unknown, min: number, max: number): number | undefined { const parsed = readFiniteNumber(value); - return parsed !== undefined && Number.isSafeInteger(parsed) && parsed >= min && parsed <= max - ? parsed - : undefined; + return asSafeIntegerInRange(parsed, { min, max }); } function normalizeProviderConfig( diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 9ef31a9c11cc..00123f35f993 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -12,6 +12,7 @@ import { resolveChannelPreviewStreamMode, resolveChannelStreamingBlockEnabled, } from "openclaw/plugin-sdk/channel-outbound"; +import { toStringifiedError as toFeishuError } from "openclaw/plugin-sdk/error-runtime"; import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime"; import { getReplyPayloadTtsSupplement, @@ -77,10 +78,6 @@ function mergeStreamingFinalText( return `${previousText}\n\n${nextText}`; } -function toFeishuError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - /** Maximum age (ms) for a message to receive a typing indicator reaction. * Messages older than this are likely replays after context compaction (#30418). */ const TYPING_INDICATOR_MAX_AGE_MS = 2 * 60_000; diff --git a/extensions/file-transfer/src/shared/params.ts b/extensions/file-transfer/src/shared/params.ts index 5251eaf7fc4b..3ec525caae4f 100644 --- a/extensions/file-transfer/src/shared/params.ts +++ b/extensions/file-transfer/src/shared/params.ts @@ -3,6 +3,7 @@ import { formatByteSize } from "openclaw/plugin-sdk/number-runtime"; import { readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; type GatewayCallOptions = { gatewayUrl?: string; @@ -23,8 +24,7 @@ export function readGatewayCallOptions(params: Record): Gateway } export function readTrimmedString(params: Record, key: string): string { - const value = params[key]; - return typeof value === "string" ? value.trim() : ""; + return normalizeOptionalString(params[key]) ?? ""; } export function readClampedInt(params: { diff --git a/extensions/fish-audio-speech/speech-provider.ts b/extensions/fish-audio-speech/speech-provider.ts index 901382e0066e..e70d46068bf3 100644 --- a/extensions/fish-audio-speech/speech-provider.ts +++ b/extensions/fish-audio-speech/speech-provider.ts @@ -11,12 +11,11 @@ import type { } from "openclaw/plugin-sdk/speech"; import { asBoolean, - asFiniteNumber, parseSpeechDirectiveNumberOverride, resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; -import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumberInRange, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { FISH_AUDIO_STREAM_MAX_BYTES, type FishAudioFormat, @@ -71,8 +70,7 @@ function normalizeLatency(value: unknown): FishAudioLatency { } function normalizeNumber(value: unknown, min: number, max: number): number | undefined { - const number = asFiniteNumber(value); - return number != null && number >= min && number <= max ? number : undefined; + return asFiniteNumberInRange(value, { min, max }); } function resolveReferenceId(raw: Record | undefined): string | undefined { diff --git a/extensions/google-meet/src/config.ts b/extensions/google-meet/src/config.ts index d59c99d75e74..29b44a04f476 100644 --- a/extensions/google-meet/src/config.ts +++ b/extensions/google-meet/src/config.ts @@ -9,10 +9,12 @@ import { type RealtimeVoiceAgentConsultToolPolicy, } from "openclaw/plugin-sdk/realtime-voice"; import { + asBoolean, asRecord, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeOptionalTrimmedStringList, + parseBooleanValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; export type GoogleMeetTransport = "chrome" | "chrome-node" | "twilio"; @@ -251,7 +253,7 @@ const GOOGLE_MEET_PREVIEW_ACK_KEYS = [ ] as const; function resolveBoolean(value: unknown, fallback: boolean): boolean { - return typeof value === "boolean" ? value : fallback; + return asBoolean(value) ?? fallback; } function resolveNumber(value: unknown, fallback: number): number { @@ -289,17 +291,7 @@ function normalizeStringAllowEmpty(value: unknown): string | undefined { } function readEnvBoolean(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean | undefined { - const normalized = normalizeOptionalLowercaseString(readEnvString(env, keys)); - if (!normalized) { - return undefined; - } - if (["1", "true", "yes", "on"].includes(normalized)) { - return true; - } - if (["0", "false", "no", "off"].includes(normalized)) { - return false; - } - return undefined; + return parseBooleanValue(readEnvString(env, keys)); } function readEnvNumber(env: NodeJS.ProcessEnv, keys: readonly string[]): number | undefined { diff --git a/extensions/google-meet/src/plugin-registration.ts b/extensions/google-meet/src/plugin-registration.ts index 69c135a88d93..0196930301e0 100644 --- a/extensions/google-meet/src/plugin-registration.ts +++ b/extensions/google-meet/src/plugin-registration.ts @@ -5,7 +5,12 @@ import type { OpenClawPluginApi, OpenClawPluginNodeInvokePolicy, } from "openclaw/plugin-sdk/plugin-entry"; -import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asNonArrayRecord as asParamRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; + +export { asParamRecord }; import { isGoogleMeetBrowserManualActionError } from "./browser-manual-action-error.js"; import { resolveGoogleMeetGatewayOperationTimeoutMs, @@ -44,10 +49,6 @@ type LoadGoogleMeetNodeInvokePolicy = ( const loadGoogleMeetNodeInvokePolicy: LoadGoogleMeetNodeInvokePolicy = async (config) => (await loadGoogleMeetNodeInvokePolicyModule()).createGoogleMeetChromeNodeInvokePolicy(config); -export function asParamRecord(params: unknown): Record { - return isRecord(params) ? params : {}; -} - export function normalizeTransport(value: unknown): GoogleMeetTransport | undefined { return value === "chrome" || value === "chrome-node" || value === "twilio" ? value : undefined; } diff --git a/extensions/google/realtime-voice-provider.ts b/extensions/google/realtime-voice-provider.ts index 88ef2b89b49e..0f22aee0dc43 100644 --- a/extensions/google/realtime-voice-provider.ts +++ b/extensions/google/realtime-voice-provider.ts @@ -49,6 +49,7 @@ import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-i import { asBoolean, asFiniteNumber, + asSafeIntegerInRange, isRecord, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -209,8 +210,7 @@ function asTurnCoverage(value: unknown): GoogleRealtimeTurnCoverage | undefined } function asNonNegativeInteger(value: unknown): number | undefined { - const number = asFiniteNumber(value); - return number !== undefined && Number.isSafeInteger(number) && number >= 0 ? number : undefined; + return asSafeIntegerInRange(value, { min: 0 }); } function asGoogleRealtimeThinkingBudget(value: unknown): number | undefined { diff --git a/extensions/googlechat/src/google-auth.runtime.ts b/extensions/googlechat/src/google-auth.runtime.ts index fdceb33aac53..e99037047e71 100644 --- a/extensions/googlechat/src/google-auth.runtime.ts +++ b/extensions/googlechat/src/google-auth.runtime.ts @@ -6,6 +6,7 @@ import { buildHostnameAllowlistPolicyFromSuffixAllowlist, fetchWithSsrFGuard, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asNullableObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES } from "./google-auth-limits.js"; @@ -78,10 +79,6 @@ function installGoogleAuthHeaderCompatibilityInterceptor( return transport; } -function asNullableObjectRecord(value: unknown): Record | null { - return value !== null && typeof value === "object" ? (value as Record) : null; -} - function hasProxyAgentShape(value: unknown): value is ProxyAgentLike { const record = asNullableObjectRecord(value); return record !== null && record.proxy instanceof URL; diff --git a/extensions/imessage/src/monitor/catchup.ts b/extensions/imessage/src/monitor/catchup.ts index eb52778f5dee..65ea1ba409e1 100644 --- a/extensions/imessage/src/monitor/catchup.ts +++ b/extensions/imessage/src/monitor/catchup.ts @@ -1,6 +1,7 @@ // Imessage plugin module implements catchup behavior. import { createHash } from "node:crypto"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; +import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime"; import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; import { getIMessageRuntime } from "../runtime.js"; @@ -246,10 +247,7 @@ export type ResolvedCatchupConfig = { }; function clampInt(value: number | undefined, min: number, max: number, fallback: number): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return fallback; - } - return Math.min(max, Math.max(min, Math.floor(value))); + return resolveIntegerOption(value, fallback, { min, max }); } export function resolveCatchupConfig( diff --git a/extensions/imessage/src/probe.ts b/extensions/imessage/src/probe.ts index 961226e3647b..1a1d52967c27 100644 --- a/extensions/imessage/src/probe.ts +++ b/extensions/imessage/src/probe.ts @@ -10,6 +10,7 @@ import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { detectBinary } from "openclaw/plugin-sdk/setup"; import { + filterStringEntries, normalizeLowercaseStringOrEmpty, normalizeStringEntries, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -187,14 +188,6 @@ function selectorsFromPayload(payload: Record): Record): string[] { - const raw = payload.rpc_methods; - if (!Array.isArray(raw)) { - return []; - } - return raw.filter((entry): entry is string => typeof entry === "string"); -} - // Probe whether the installed imsg CLI accepts `--file` on the `send-rich` // subcommand (added by openclaw/imsg#114, which lets a single bridge call // combine `--reply-to` and an attachment). We grep the help output rather @@ -278,7 +271,7 @@ export async function probeIMessagePrivateApi( } const { payload, firstLineSnippet } = parseStatusPayload(result.stdout); const selectors = payload ? selectorsFromPayload(payload) : {}; - const rpcMethods = payload ? rpcMethodsFromPayload(payload) : []; + const rpcMethods = filterStringEntries(payload?.rpc_methods); const advancedFeatures = payload?.advanced_features === true; const v2Ready = payload?.v2_ready === true; // imsg explains an unavailable bridge here (SIP, library validation, macOS diff --git a/extensions/linux-node/src/command-utils.ts b/extensions/linux-node/src/command-utils.ts index e73b0e789a46..5bdc7996fd46 100644 --- a/extensions/linux-node/src/command-utils.ts +++ b/extensions/linux-node/src/command-utils.ts @@ -1,6 +1,6 @@ import type { OpenClawPluginNodeHostCommandAvailabilityContext } from "openclaw/plugin-sdk/plugin-entry"; import type { CommandOptions, SpawnResult } from "openclaw/plugin-sdk/process-runtime"; -import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumber, asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { resolveLinuxNodePluginConfigFromHost, @@ -21,9 +21,7 @@ export function parseParams(paramsJSON: string | null | undefined): Record { } catch (repairErr) { LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", repairErr); if (options.strict) { - throw repairErr instanceof Error ? repairErr : new Error(String(repairErr)); + throw toStringifiedError(repairErr); } return { ready: false, published: false }; } @@ -250,7 +251,7 @@ export class MatrixCryptoBootstrapper { { cause: err }, ); } - throw err instanceof Error ? err : new Error(String(err)); + throw toStringifiedError(err); } return { ready: false, published: false }; } @@ -300,7 +301,7 @@ export class MatrixCryptoBootstrapper { } catch (resetErr) { LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", resetErr); if (options.strict) { - throw resetErr instanceof Error ? resetErr : new Error(String(resetErr)); + throw toStringifiedError(resetErr); } return { ready: false, published: false }; } @@ -328,7 +329,7 @@ export class MatrixCryptoBootstrapper { } catch (err) { LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err); if (options.strict) { - throw err instanceof Error ? err : new Error(String(err)); + throw toStringifiedError(err); } return { ready: false, published: false }; } @@ -371,7 +372,7 @@ export class MatrixCryptoBootstrapper { } catch (err) { LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err); if (options.strict) { - throw err instanceof Error ? err : new Error(String(err)); + throw toStringifiedError(err); } } } diff --git a/extensions/mattermost/src/mattermost/model-picker.ts b/extensions/mattermost/src/mattermost/model-picker.ts index 29e7c2a967e8..6c2c3eb81581 100644 --- a/extensions/mattermost/src/mattermost/model-picker.ts +++ b/extensions/mattermost/src/mattermost/model-picker.ts @@ -9,8 +9,10 @@ import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; import { + asFiniteNumber, normalizeOptionalString, normalizeStringifiedOptionalString, + readStringField, } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { MattermostInteractiveButtonInput } from "./interactions.js"; @@ -59,19 +61,12 @@ function splitModelRef(modelRef?: string | null): { provider: string; model: str } function readContextString(context: Record, key: string, fallback = ""): string { - const value = context[key]; - return typeof value === "string" ? value : fallback; + return readStringField(context, key) ?? fallback; } function readContextNumber(context: Record, key: string): number | undefined { const value = context[key]; - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - return parseStrictInteger(value); - } - return undefined; + return asFiniteNumber(value) ?? parseStrictInteger(value); } function normalizePage(value: number | undefined): number { diff --git a/extensions/memory-wiki/src/import-runs-state.ts b/extensions/memory-wiki/src/import-runs-state.ts index 27459231718d..bec2d12b5bf8 100644 --- a/extensions/memory-wiki/src/import-runs-state.ts +++ b/extensions/memory-wiki/src/import-runs-state.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import { resolveNonNegativeIntegerOption } from "openclaw/plugin-sdk/number-runtime"; import type { OpenKeyedStoreOptions, PluginStateKeyedStore, @@ -149,10 +150,6 @@ function normalizeImportRunEntries(value: unknown): ChatGptImportRunEntry[] { }); } -function asNonNegativeInteger(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; -} - function normalizeMemoryWikiImportRunRecord(raw: unknown): ChatGptImportRunRecord | null { const record = asNullableRecord(raw); if (!record) { @@ -182,10 +179,10 @@ function normalizeMemoryWikiImportRunRecord(raw: unknown): ChatGptImportRunRecor exportPath, sourcePath, appliedAt, - conversationCount: asNonNegativeInteger(record.conversationCount), - createdCount: asNonNegativeInteger(record.createdCount), - updatedCount: asNonNegativeInteger(record.updatedCount), - skippedCount: asNonNegativeInteger(record.skippedCount), + conversationCount: resolveNonNegativeIntegerOption(record.conversationCount, 0), + createdCount: resolveNonNegativeIntegerOption(record.createdCount, 0), + updatedCount: resolveNonNegativeIntegerOption(record.updatedCount, 0), + skippedCount: resolveNonNegativeIntegerOption(record.skippedCount, 0), createdPaths: normalizeImportRunEntries(record.createdPaths), updatedPaths: normalizeImportRunEntries(record.updatedPaths), ...(rollbackStartedAt ? { rollbackStartedAt } : {}), diff --git a/extensions/meta/stream.ts b/extensions/meta/stream.ts index 0a7c60dbd2d7..860a494d6714 100644 --- a/extensions/meta/stream.ts +++ b/extensions/meta/stream.ts @@ -2,14 +2,13 @@ import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { createPayloadPatchStreamWrapper } from "openclaw/plugin-sdk/provider-stream-shared"; +import { filterStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; const META_REASONING_ENCRYPTED_CONTENT_INCLUDE = "reasoning.encrypted_content"; function ensureMetaResponsesReplayFields(payloadObj: Record): void { const existing = payloadObj.include; - const include = Array.isArray(existing) - ? existing.filter((entry): entry is string => typeof entry === "string") - : []; + const include = filterStringEntries(existing); if (!include.includes(META_REASONING_ENCRYPTED_CONTENT_INCLUDE)) { include.push(META_REASONING_ENCRYPTED_CONTENT_INCLUDE); } diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index e09b5e66b23c..f95197cd9402 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -248,7 +248,7 @@ function readOptionalTrimmedString( params: Record, key: string, ): string | undefined { - return typeof params[key] === "string" ? params[key].trim() || undefined : undefined; + return normalizeOptionalString(params[key]); } function resolveActionUploadFilePath(params: Record): string | undefined { diff --git a/extensions/nextcloud-talk/src/room-info.ts b/extensions/nextcloud-talk/src/room-info.ts index bb6731b0804d..fe1324c14e18 100644 --- a/extensions/nextcloud-talk/src/room-info.ts +++ b/extensions/nextcloud-talk/src/room-info.ts @@ -31,13 +31,6 @@ function cacheRoomInfo( pruneMapToMaxSize(roomCache, ROOM_CACHE_MAX_ENTRIES); } -function coerceRoomType(value: unknown): number | undefined { - if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) { - return value; - } - return parseStrictPositiveInteger(value); -} - function resolveRoomKindFromType(type: number | undefined): "direct" | "group" | undefined { if (!type) { return undefined; @@ -117,7 +110,7 @@ export async function resolveNextcloudTalkRoomKind(params: { const payload = await readProviderJsonResponse<{ ocs?: { data?: { type?: number | string } }; }>(response, "Nextcloud Talk room info failed"); - const type = coerceRoomType(payload.ocs?.data?.type); + const type = parseStrictPositiveInteger(payload.ocs?.data?.type); const kind = resolveRoomKindFromType(type); cacheRoomInfo(key, { fetchedAt: Date.now(), kind }); return kind; diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index a026efa87813..42cd39b61c97 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -22,7 +22,7 @@ import { import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { normalizeLowercaseStringOrEmpty, - readStringValue, + readNonEmptyStringPreservingWhitespace as readNonEmptyUntrimmedString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount } from "./accounts.js"; import type { CoreConfig } from "./types.js"; @@ -39,11 +39,6 @@ type NextcloudSetupInput = ChannelSetupInput & { password?: string; }; -function readNonEmptyUntrimmedString(value: unknown): string | undefined { - const text = readStringValue(value); - return text ? text : undefined; -} - export function normalizeNextcloudTalkBaseUrl(value: string | undefined): string { return value?.trim().replace(/\/+$/, "") ?? ""; } diff --git a/extensions/nostr/src/nostr-ingress-state.ts b/extensions/nostr/src/nostr-ingress-state.ts index ba22aff243fe..5d7c5c55a6ec 100644 --- a/extensions/nostr/src/nostr-ingress-state.ts +++ b/extensions/nostr/src/nostr-ingress-state.ts @@ -1,5 +1,8 @@ // Nostr plugin module owns durable ingress identity and legacy-state migration. import type { ChannelIngressQueue } from "openclaw/plugin-sdk/channel-outbound"; +import { isRecord as isNostrIngressRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; + +export { isNostrIngressRecord }; export const NOSTR_INGRESS_PAYLOAD_VERSION = 1; @@ -20,10 +23,6 @@ export class NostrIngressPermanentError extends Error { } } -export function isNostrIngressRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function requiredString(value: unknown, field: string): string { if (typeof value === "string" && value.trim()) { return value; diff --git a/extensions/onepassword/onepassword-secret-ref-resolver.js b/extensions/onepassword/onepassword-secret-ref-resolver.js index 96e26f947b94..f4e421b1f6a9 100644 --- a/extensions/onepassword/onepassword-secret-ref-resolver.js +++ b/extensions/onepassword/onepassword-secret-ref-resolver.js @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import { DEFAULT_SECRET_FILE_MAX_BYTES, tryReadSecretFileSync } from "@openclaw/fs-safe/secret"; import { execa } from "execa"; +import { coerceErrorMessage as errorMessage } from "openclaw/plugin-sdk/error-runtime"; import { resolveTrustedOnePasswordCli } from "./onepassword-op-path.js"; import { resolveOnePasswordSecretReference } from "./onepassword-secret-id.js"; @@ -65,10 +66,6 @@ function opMissingMessage(command) { return `1Password CLI "${command}" is not installed or cannot be executed. Install the official 1Password CLI v2, and set CLAW_1PASSWORD_OP to its absolute path.`; } -function errorMessage(error) { - return error instanceof Error ? error.message : String(error); -} - function resolveOsHome() { const home = process.platform === "win32" diff --git a/extensions/openai/embedding-batch.ts b/extensions/openai/embedding-batch.ts index 0efde34f4246..c3de1b5e8bac 100644 --- a/extensions/openai/embedding-batch.ts +++ b/extensions/openai/embedding-batch.ts @@ -1,3 +1,4 @@ +import { coerceErrorMessage as formatOpenAiBatchError } from "openclaw/plugin-sdk/error-runtime"; // Openai plugin module implements embedding batch behavior. import { applyEmbeddingBatchOutputLine, @@ -158,10 +159,6 @@ async function fetchOpenAiBatchResource(params: { }); } -function formatOpenAiBatchError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function formatOpenAiBatchDiagnostic(error: unknown): string { return formatBatchErrorDetail(formatOpenAiBatchError(error)) ?? "unknown error"; } diff --git a/extensions/openai/image-generation-provider.ts b/extensions/openai/image-generation-provider.ts index b44effc48eb4..b25bd4cdd31c 100644 --- a/extensions/openai/image-generation-provider.ts +++ b/extensions/openai/image-generation-provider.ts @@ -35,6 +35,7 @@ import { sanitizeConfiguredModelProviderRequest, } from "openclaw/plugin-sdk/provider-http"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; +import { filterStringRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { canonicalizeCodexResponsesBaseUrl, @@ -447,16 +448,7 @@ function hasChatGPTImageRouteConfig(cfg: OpenClawConfig | undefined): boolean { function resolveConfiguredOpenAIImageHeaders( cfg: OpenClawConfig | undefined, ): Record | undefined { - const headers = cfg?.models?.providers?.openai?.headers; - if (!headers) { - return undefined; - } - const stringHeaders = Object.fromEntries( - Object.entries(headers).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ); - return Object.keys(stringHeaders).length > 0 ? stringHeaders : undefined; + return filterStringRecord(cfg?.models?.providers?.openai?.headers); } function forceOpenAIImageApiKeyAuth(cfg: OpenClawConfig | undefined): OpenClawConfig | undefined { diff --git a/extensions/openai/realtime-transcription-provider.ts b/extensions/openai/realtime-transcription-provider.ts index 6599515c3009..39649ab4560f 100644 --- a/extensions/openai/realtime-transcription-provider.ts +++ b/extensions/openai/realtime-transcription-provider.ts @@ -14,7 +14,11 @@ import { type RealtimeTranscriptionWebSocketTransport, } from "openclaw/plugin-sdk/realtime-transcription"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; -import { asFiniteNumber, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asFiniteNumberInRange, + asSafeIntegerInRange, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { createOpenAIRealtimeTranscriptionClientSecret, readRealtimeErrorDetail, @@ -133,19 +137,11 @@ function normalizeProviderConfig( } function normalizeNonNegativeInteger(value: unknown): number | undefined { - const number = asFiniteNumber(value); - if (number === undefined || !Number.isSafeInteger(number) || number < 0) { - return undefined; - } - return number; + return asSafeIntegerInRange(value, { min: 0 }); } function normalizeVadThreshold(value: unknown): number | undefined { - const number = asFiniteNumber(value); - if (number === undefined || number < 0 || number > 1) { - return undefined; - } - return number; + return asFiniteNumberInRange(value, { min: 0, max: 1 }); } function buildOpenAIRealtimeTranscriptionSessionPayload( diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index ee61e1b9385b..e09162f69799 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; +import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime"; import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; import { @@ -41,6 +42,8 @@ import { } from "openclaw/plugin-sdk/secret-input"; import { asFiniteNumber, + asFiniteNumberInRange, + asSafeIntegerInRange, isRecord, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -294,13 +297,11 @@ function normalizeProviderConfig( } function asNonNegativeInteger(value: unknown): number | undefined { - const number = asFiniteNumber(value); - return number !== undefined && Number.isSafeInteger(number) && number >= 0 ? number : undefined; + return asSafeIntegerInRange(value, { min: 0 }); } function asUnitInterval(value: unknown): number | undefined { - const number = asFiniteNumber(value); - return number !== undefined && number >= 0 && number <= 1 ? number : undefined; + return asFiniteNumberInRange(value, { min: 0, max: 1 }); } type OpenAIRealtimeApiKeyResolution = @@ -995,7 +996,7 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { try { this.handleEvent(event, lifecycleConnection); } catch (error) { - const readyError = error instanceof Error ? error : new Error(String(error)); + const readyError = toStringifiedError(error); attempt.reject(readyError); this.failConnection(readyError, ws, lifecycleConnection, { code: 1011, @@ -1036,7 +1037,7 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { }, }); if (!attempt.ready) { - const startupError = error instanceof Error ? error : new Error(String(error)); + const startupError = toStringifiedError(error); rejectStartup( isDirectOpenAIRealtimeWebSocketUrl(url) && isOpenAIRealtimeStartupAuthFailure(startupError) @@ -1045,7 +1046,7 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { ); return; } - this.config.onError?.(error instanceof Error ? error : new Error(String(error))); + this.config.onError?.(toStringifiedError(error)); }); ws.on("close", (code, reasonBuffer) => { @@ -1091,7 +1092,7 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { try { connectionOrPromise = this.resolveConnectionParams(); } catch (error) { - attempt.reject(error instanceof Error ? error : new Error(String(error))); + attempt.reject(toStringifiedError(error)); return attempt.promise; } if (connectionOrPromise instanceof Promise) { @@ -1103,13 +1104,13 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { attempt.resolve(); return; } - attempt.reject(error instanceof Error ? error : new Error(String(error))); + attempt.reject(toStringifiedError(error)); }); } else { try { openWebSocket(connectionOrPromise); } catch (error) { - attempt.reject(error instanceof Error ? error : new Error(String(error))); + attempt.reject(toStringifiedError(error)); } } await attempt.promise; @@ -1277,7 +1278,7 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { if (!this.lifecycle.acceptsEvents(nextConnection)) { return; } - this.config.onError?.(error instanceof Error ? error : new Error(String(error))); + this.config.onError?.(toStringifiedError(error)); await this.attemptReconnect(reason, nextConnection); } } diff --git a/extensions/openrouter/image-generation-provider.ts b/extensions/openrouter/image-generation-provider.ts index c9b0565ca780..f0290dcdca9f 100644 --- a/extensions/openrouter/image-generation-provider.ts +++ b/extensions/openrouter/image-generation-provider.ts @@ -11,6 +11,7 @@ import { toImageDataUrl, } from "openclaw/plugin-sdk/image-generation"; import { resolveGeneratedMediaMaxBytes } from "openclaw/plugin-sdk/media-generation-runtime"; +import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime"; import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; import { @@ -171,10 +172,7 @@ function extractOpenRouterImagesFromResponse(body: unknown): GeneratedImageAsset } function resolveImageCount(count: number | undefined): number { - if (typeof count !== "number" || !Number.isFinite(count)) { - return 1; - } - return Math.max(1, Math.min(MAX_IMAGE_RESULTS, Math.trunc(count))); + return resolveIntegerOption(count, 1, { min: 1, max: MAX_IMAGE_RESULTS }); } function isGeminiImageModel(model: string): boolean { diff --git a/extensions/openrouter/provider-catalog.ts b/extensions/openrouter/provider-catalog.ts index 7a03ed26151d..021a3c6b58e9 100644 --- a/extensions/openrouter/provider-catalog.ts +++ b/extensions/openrouter/provider-catalog.ts @@ -19,6 +19,7 @@ import { import { asOptionalRecord, asPositiveSafeInteger, + filterStringEntries, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -140,10 +141,7 @@ export function buildOpenrouterProvider(): ModelProviderConfig { } function readStringArray(record: Record | undefined, key: string): string[] { - const value = record?.[key]; - return Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === "string") - : []; + return filterStringEntries(record?.[key]); } function readTokenPrice(record: Record | undefined, key: string): number { diff --git a/extensions/openrouter/video-model-catalog.ts b/extensions/openrouter/video-model-catalog.ts index 3b0883100954..1b2b5898c50b 100644 --- a/extensions/openrouter/video-model-catalog.ts +++ b/extensions/openrouter/video-model-catalog.ts @@ -57,10 +57,6 @@ type OpenRouterVideoRequestPolicyCacheKey = ReturnType< type OpenRouterVideoRequestConfig = Parameters[0]; -function normalizeStringArray(value: unknown): string[] { - return normalizeTrimmedStringList(value); -} - function normalizeNumberArray(value: unknown): number[] { return Array.isArray(value) ? value.filter((entry): entry is number => typeof entry === "number" && Number.isFinite(entry)) @@ -68,14 +64,14 @@ function normalizeNumberArray(value: unknown): number[] { } function normalizeResolutionArray(value: unknown): VideoGenerationResolution[] { - return normalizeStringArray(value).map( + return normalizeTrimmedStringList(value).map( (entry) => entry.toUpperCase() as VideoGenerationResolution, ); } function normalizeFrameImageRoles(value: unknown): Array<"first_frame" | "last_frame"> { const seen = new Set<"first_frame" | "last_frame">(); - for (const entry of normalizeStringArray(value)) { + for (const entry of normalizeTrimmedStringList(value)) { if (entry === "first_frame" || entry === "last_frame") { seen.add(entry); } @@ -136,12 +132,14 @@ function buildOpenRouterVideoModeCapabilities(params: { function buildOpenRouterVideoModelCapabilities( model: OpenRouterVideoModel, ): OpenRouterVideoModelCatalogCapabilities { - const aspectRatios = normalizeStringArray(model.supported_aspect_ratios); + const aspectRatios = normalizeTrimmedStringList(model.supported_aspect_ratios); const durations = normalizeNumberArray(model.supported_durations); const frameImages = normalizeFrameImageRoles(model.supported_frame_images); const resolutions = normalizeResolutionArray(model.supported_resolutions); - const sizes = normalizeStringArray(model.supported_sizes); - const allowedPassthroughParameters = normalizeStringArray(model.allowed_passthrough_parameters); + const sizes = normalizeTrimmedStringList(model.supported_sizes); + const allowedPassthroughParameters = normalizeTrimmedStringList( + model.allowed_passthrough_parameters, + ); const supportsAudio = typeof model.generate_audio === "boolean" ? model.generate_audio : undefined; const modeCapabilities = buildOpenRouterVideoModeCapabilities({ diff --git a/extensions/parallel/src/parallel-search-normalize.ts b/extensions/parallel/src/parallel-search-normalize.ts index 48f67469cb4a..6bba79326fd1 100644 --- a/extensions/parallel/src/parallel-search-normalize.ts +++ b/extensions/parallel/src/parallel-search-normalize.ts @@ -1,3 +1,4 @@ +import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime"; // Transport-agnostic Parallel search normalization shared by the paid REST // provider (`parallel`) and the free Search MCP provider (`parallel-free`). // Both transports return the same v1 result shape, so query/result handling @@ -53,7 +54,10 @@ export function resolveParallelSearchCount( const value = requestedCount ?? (typeof configuredCount === "number" ? configuredCount : DEFAULT_SEARCH_COUNT); - return Math.max(1, Math.min(PARALLEL_MAX_SEARCH_COUNT, Math.floor(value))); + return resolveIntegerOption(value, DEFAULT_SEARCH_COUNT, { + min: 1, + max: PARALLEL_MAX_SEARCH_COUNT, + }); } export function normalizeParallelSessionId( diff --git a/extensions/qa-lab/src/evidence-gallery.ts b/extensions/qa-lab/src/evidence-gallery.ts index 21ff145e6d0f..d2ff05002532 100644 --- a/extensions/qa-lab/src/evidence-gallery.ts +++ b/extensions/qa-lab/src/evidence-gallery.ts @@ -5,7 +5,10 @@ import path from "node:path"; import { StringDecoder } from "node:string_decoder"; import { pathToFileURL } from "node:url"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asNullableRecord as readRecord, + readStringValue, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import pLimit from "p-limit"; import type { QaEvidenceArtifactView, @@ -565,12 +568,6 @@ async function buildArtifactView(params: { }; } -function readRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function readCountRecord(value: unknown): Record { const record = readRecord(value); if (!record) { diff --git a/extensions/qa-lab/src/live-transports/discord/adapter.runtime.ts b/extensions/qa-lab/src/live-transports/discord/adapter.runtime.ts index 952b890d7b4b..01d2a144f59b 100644 --- a/extensions/qa-lab/src/live-transports/discord/adapter.runtime.ts +++ b/extensions/qa-lab/src/live-transports/discord/adapter.runtime.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; import type { QaRunnerCliRegistration } from "openclaw/plugin-sdk/qa-runner-runtime"; import { acquireQaCredentialLease, @@ -96,7 +97,7 @@ export async function createDiscordQaTransportAdapter( } })().catch((error: unknown) => { if (!stopped) { - pollingError = error instanceof Error ? error : new Error(String(error)); + pollingError = toStringifiedError(error); } }); const scenarioEnvironment = createDiscordQaScenarioEnvironment({ diff --git a/extensions/qa-lab/src/live-transports/matrix/adapter.runtime.ts b/extensions/qa-lab/src/live-transports/matrix/adapter.runtime.ts index e2ebd6b771a8..4891253daccc 100644 --- a/extensions/qa-lab/src/live-transports/matrix/adapter.runtime.ts +++ b/extensions/qa-lab/src/live-transports/matrix/adapter.runtime.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; import { buildQaTarget } from "openclaw/plugin-sdk/qa-channel-protocol"; import type { QaRunnerCliRegistration } from "openclaw/plugin-sdk/qa-runner-runtime"; import { readQaScenarioExecutionConfig } from "../../scenario-catalog.js"; @@ -324,7 +325,7 @@ export async function createMatrixQaTransportAdapter( }), ).catch((error: unknown) => { if (!stopped) { - pollingError = error instanceof Error ? error : new Error(String(error)); + pollingError = toStringifiedError(error); } }); diff --git a/extensions/qa-lab/src/live-transports/slack/adapter.runtime.ts b/extensions/qa-lab/src/live-transports/slack/adapter.runtime.ts index 49d1933fdedb..353fa8768f29 100644 --- a/extensions/qa-lab/src/live-transports/slack/adapter.runtime.ts +++ b/extensions/qa-lab/src/live-transports/slack/adapter.runtime.ts @@ -8,6 +8,7 @@ import { } from "@openclaw/slack/api.js"; import type { FetchFunction } from "@slack/web-api"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; import { acquireDebugProxyCaptureStore } from "openclaw/plugin-sdk/proxy-capture"; import type { QaRunnerCliRegistration } from "openclaw/plugin-sdk/qa-runner-runtime"; import { @@ -222,7 +223,7 @@ export async function createSlackQaTransportAdapter( } })().catch((error: unknown) => { if (!stopped) { - pollingError = error instanceof Error ? error : new Error(String(error)); + pollingError = toStringifiedError(error); } }); }; diff --git a/extensions/qa-lab/src/live-transports/whatsapp/adapter.runtime.ts b/extensions/qa-lab/src/live-transports/whatsapp/adapter.runtime.ts index be53f652b168..554357df79b7 100644 --- a/extensions/qa-lab/src/live-transports/whatsapp/adapter.runtime.ts +++ b/extensions/qa-lab/src/live-transports/whatsapp/adapter.runtime.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { WhatsAppQaDriverSession } from "@openclaw/whatsapp/api.js"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; import { buildQaTarget } from "openclaw/plugin-sdk/qa-channel-protocol"; import type { QaRunnerCliRegistration } from "openclaw/plugin-sdk/qa-runner-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; @@ -130,7 +131,7 @@ export async function createWhatsAppQaTransportAdapter( } })().catch((error: unknown) => { if (!stopped) { - pollingError = error instanceof Error ? error : new Error(String(error)); + pollingError = toStringifiedError(error); } }); diff --git a/extensions/qa-lab/src/process-tree-cpu.ts b/extensions/qa-lab/src/process-tree-cpu.ts index 185cbedee545..299d985707c0 100644 --- a/extensions/qa-lab/src/process-tree-cpu.ts +++ b/extensions/qa-lab/src/process-tree-cpu.ts @@ -1,6 +1,7 @@ // Qa Lab plugin module implements process tree cpu behavior. import { spawnSync } from "node:child_process"; import { parseStrictFiniteNumber, parseStrictInteger } from "openclaw/plugin-sdk/number-runtime"; +import { isRecord as isPlainObject } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveQaWindowsPowerShellExePath } from "./windows-system-tools.js"; type ProcessTreeSnapshot = { @@ -11,10 +12,6 @@ type ProcessTreeSnapshot = { const PROCESS_TREE_SNAPSHOT_TIMEOUT_MS = 5_000; -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function parsePositiveInteger(value: unknown): number | null { const parsed = parseStrictInteger(value); if (parsed === undefined || parsed <= 0) { diff --git a/extensions/qa-lab/src/qa-cli-process.ts b/extensions/qa-lab/src/qa-cli-process.ts index b0fdad7518fc..b1840d5225ae 100644 --- a/extensions/qa-lab/src/qa-cli-process.ts +++ b/extensions/qa-lab/src/qa-cli-process.ts @@ -2,6 +2,7 @@ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import path from "node:path"; import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; +import { isRecord as isJsonRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { appendQaChildOutput, @@ -83,10 +84,6 @@ function parseBalancedJsonPayloadStart(text: string) { } } -function isJsonRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function isStructuredDiagnosticJson(value: unknown) { if (!isJsonRecord(value)) { return false; diff --git a/extensions/qa-lab/src/runtime-tool-fixture.ts b/extensions/qa-lab/src/runtime-tool-fixture.ts index b64cc7a9557a..96fa3b79c6c1 100644 --- a/extensions/qa-lab/src/runtime-tool-fixture.ts +++ b/extensions/qa-lab/src/runtime-tool-fixture.ts @@ -394,12 +394,8 @@ async function formatRuntimePatchMutationDiagnostics(params: { ].join("; "); } -function readNonEmptyString(value: unknown) { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function normalizeToolCallId(value: unknown) { - return readNonEmptyString(value); + return normalizeOptionalString(value); } function stringifyTranscriptToolResult(value: unknown): string { @@ -436,10 +432,10 @@ function extractTranscriptText(value: unknown): string { continue; } const text = - readNonEmptyString(block.text) ?? - readNonEmptyString(block.content) ?? - readNonEmptyString(block.message) ?? - readNonEmptyString(block.error); + normalizeOptionalString(block.text) ?? + normalizeOptionalString(block.content) ?? + normalizeOptionalString(block.message) ?? + normalizeOptionalString(block.error); if (text) { parts.push(text); } @@ -457,11 +453,11 @@ function extractTranscriptToolCalls( if (!isRecord(block)) { continue; } - const type = readNonEmptyString(block.type)?.toLowerCase(); + const type = normalizeOptionalString(block.type)?.toLowerCase(); if (type !== "tool_use" && type !== "toolcall" && type !== "tool_call") { continue; } - const tool = readNonEmptyString(block.name); + const tool = normalizeOptionalString(block.name); if (!tool) { continue; } @@ -486,7 +482,8 @@ function extractTranscriptToolCalls( continue; } const functionRecord = isRecord(call.function) ? call.function : undefined; - const tool = readNonEmptyString(call.name) ?? readNonEmptyString(functionRecord?.name); + const tool = + normalizeOptionalString(call.name) ?? normalizeOptionalString(functionRecord?.name); if (!tool) { continue; } @@ -558,10 +555,10 @@ function extractTranscriptToolResults( ): QaRuntimeToolFixtureTranscriptToolResult[] { const results: QaRuntimeToolFixtureTranscriptToolResult[] = []; const tool = - readNonEmptyString(message.toolName) ?? - readNonEmptyString(message.tool_name) ?? - readNonEmptyString(message.name) ?? - readNonEmptyString(message.tool); + normalizeOptionalString(message.toolName) ?? + normalizeOptionalString(message.tool_name) ?? + normalizeOptionalString(message.name) ?? + normalizeOptionalString(message.tool); if ((message.role === "tool" || message.role === "toolResult") && message.content !== undefined) { const text = extractTranscriptText(message.content); const structuredFailure = isStructuredFailureToolResult({ @@ -598,7 +595,7 @@ function extractTranscriptToolResults( if (!isRecord(block)) { continue; } - const type = readNonEmptyString(block.type)?.toLowerCase(); + const type = normalizeOptionalString(block.type)?.toLowerCase(); if (type !== "tool_result" && type !== "toolresult" && type !== "tool_result_error") { continue; } @@ -611,10 +608,10 @@ function extractTranscriptToolResults( is_error: block.is_error, }); const blockTool = - readNonEmptyString(block.toolName) ?? - readNonEmptyString(block.tool_name) ?? - readNonEmptyString(block.name) ?? - readNonEmptyString(block.tool); + normalizeOptionalString(block.toolName) ?? + normalizeOptionalString(block.tool_name) ?? + normalizeOptionalString(block.name) ?? + normalizeOptionalString(block.tool); results.push({ id: normalizeToolCallId(block.tool_use_id) ?? @@ -703,7 +700,7 @@ async function readSessionTranscriptBytes( ) { const store = await readRawQaSessionStore(env); const entry = store[sessionKey]; - const sessionId = readNonEmptyString(entry?.sessionId); + const sessionId = normalizeOptionalString(entry?.sessionId); if (!sessionId) { throw new Error(`session transcript entry not found for ${sessionKey}`); } diff --git a/extensions/qa-lab/src/scenario-lane.ts b/extensions/qa-lab/src/scenario-lane.ts index 52c85126bd2f..f56e3929537f 100644 --- a/extensions/qa-lab/src/scenario-lane.ts +++ b/extensions/qa-lab/src/scenario-lane.ts @@ -1,3 +1,4 @@ +import { normalizeOptionalString as normalizeQaConfigString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { QaCliBackendAuthMode } from "./gateway-child.js"; import { splitQaModelRef, type QaProviderMode } from "./model-selection.js"; import { @@ -14,10 +15,6 @@ export type QaScenarioExecutionCell = { channel: string | null; }; -function normalizeQaConfigString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function resolveQaScenarioLaneChannels(params: { scenario: QaSeedScenario; channelDriver: QaScorecardChannelDriver; diff --git a/extensions/reef/src/transport.ts b/extensions/reef/src/transport.ts index 4cc4c9b22abe..26357f5d6142 100644 --- a/extensions/reef/src/transport.ts +++ b/extensions/reef/src/transport.ts @@ -1,3 +1,4 @@ +import { toStringifiedError as asError } from "openclaw/plugin-sdk/error-runtime"; import { buildTimeoutAbortSignal } from "openclaw/plugin-sdk/extension-shared"; import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; import WebSocket from "ws"; @@ -590,10 +591,6 @@ export class ReefInboxConnection { } } -function asError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - function reefInboxCloseError(event: { code?: number; reason?: string }): Error { const code = Number.isInteger(event.code) ? ` code=${event.code}` : ""; const reason = event.reason?.trim() ? ` reason=${event.reason.trim()}` : ""; diff --git a/extensions/searxng/src/config.ts b/extensions/searxng/src/config.ts index 131886395e75..547902d5c573 100644 --- a/extensions/searxng/src/config.ts +++ b/extensions/searxng/src/config.ts @@ -4,6 +4,7 @@ import { normalizeResolvedSecretInputString, normalizeSecretInput, } from "openclaw/plugin-sdk/secret-input"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; type SearxngPluginConfig = { webSearch?: { @@ -37,14 +38,6 @@ function readInlineEnvSecretRefValue(value: unknown, env: NodeJS.ProcessEnv): st return normalizeSecretInput(env[record.id]); } -function normalizeTrimmedString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed || undefined; -} - function normalizeBaseUrl(value: string | undefined): string | undefined { return value?.replace(/\/+$/u, "") || undefined; } @@ -78,9 +71,9 @@ export function resolveSearxngBaseUrl( } export function resolveSearxngCategories(config?: OpenClawConfig): string | undefined { - return normalizeTrimmedString(resolveSearxngWebSearchConfig(config)?.categories); + return normalizeOptionalString(resolveSearxngWebSearchConfig(config)?.categories); } export function resolveSearxngLanguage(config?: OpenClawConfig): string | undefined { - return normalizeTrimmedString(resolveSearxngWebSearchConfig(config)?.language); + return normalizeOptionalString(resolveSearxngWebSearchConfig(config)?.language); } diff --git a/extensions/slack/src/data-table.ts b/extensions/slack/src/data-table.ts index 834255496206..96d6b62a3a96 100644 --- a/extensions/slack/src/data-table.ts +++ b/extensions/slack/src/data-table.ts @@ -4,7 +4,10 @@ import { renderMessagePresentationTableFallbackText, type MessagePresentationTableBlock, } from "openclaw/plugin-sdk/interactive-runtime"; -import { asOptionalRecord, hasNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord, + readNonBlankString as readNonEmptyString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; import { renderSlackMessagePresentationTableFallbackText } from "./presentation-fallback.js"; @@ -44,10 +47,6 @@ type ParsedSlackDataTable = { cellCharacterCount: number; }; -function readNonEmptyString(value: unknown): string | undefined { - return hasNonEmptyString(value) ? value : undefined; -} - function countCharacters(value: string): number { return Array.from(value).length; } diff --git a/extensions/slack/src/monitor/thread-resolution.ts b/extensions/slack/src/monitor/thread-resolution.ts index 6dabacd280f1..15b9a713cddc 100644 --- a/extensions/slack/src/monitor/thread-resolution.ts +++ b/extensions/slack/src/monitor/thread-resolution.ts @@ -18,6 +18,7 @@ import { } from "openclaw/plugin-sdk/number-runtime"; import { classifyTransientNetworkErrorCode } from "openclaw/plugin-sdk/retry-runtime"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeOptionalString as normalizeThreadTs } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatSlackError } from "../errors.js"; import type { SlackMessageEvent } from "../types.js"; import type { SlackIngressTurnLifecycle } from "./ingress.js"; @@ -30,11 +31,6 @@ type ThreadTsCacheEntry = { const DEFAULT_THREAD_TS_CACHE_TTL_MS = 60_000; const DEFAULT_THREAD_TS_CACHE_MAX = 500; -const normalizeThreadTs = (threadTs?: string | null) => { - const trimmed = threadTs?.trim(); - return trimmed ? trimmed : undefined; -}; - const markAmbiguousThreadReply = (message: SlackMessageEvent): SlackMessageEvent => ({ ...message, _ambiguousThreadReply: true, diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index ad1dc0fc67b9..931cab7df4bd 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -6,6 +6,7 @@ import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, + normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveActiveTalkProviderConfig } from "openclaw/plugin-sdk/talk-config-runtime"; import { definePluginEntry, type OpenClawPluginApi } from "./api.js"; @@ -95,11 +96,7 @@ function findVoice(voices: SpeechVoiceOption[], query: string): SpeechVoiceOptio } function asTrimmedString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -function parsePositiveIntegerToken(value: unknown): number | undefined { - return parseStrictPositiveInteger(value); + return normalizeOptionalString(value) ?? ""; } function resolveCommandLabel(channel: string): string { @@ -171,7 +168,7 @@ export default definePluginEntry({ } if (action === "list") { - const limit = parsePositiveIntegerToken(tokens[1]) ?? 12; + const limit = parseStrictPositiveInteger(tokens[1]) ?? 12; try { const voices = await api.runtime.tts.listVoices({ provider: providerId, diff --git a/extensions/tlon/src/settings.ts b/extensions/tlon/src/settings.ts index 4814aaa5ceb9..eee13240d472 100644 --- a/extensions/tlon/src/settings.ts +++ b/extensions/tlon/src/settings.ts @@ -9,6 +9,7 @@ * without requiring a gateway restart. */ +import { filterStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { UrbitSSEClient } from "./urbit/sse-client.js"; /** Pending approval request stored for persistence */ @@ -112,10 +113,10 @@ function parseSettingsResponse(raw: unknown): TlonSettingsStore { return { groupChannels: Array.isArray(settings.groupChannels) - ? settings.groupChannels.filter((x): x is string => typeof x === "string") + ? filterStringEntries(settings.groupChannels) : undefined, dmAllowlist: Array.isArray(settings.dmAllowlist) - ? settings.dmAllowlist.filter((x): x is string => typeof x === "string") + ? filterStringEntries(settings.dmAllowlist) : undefined, autoDiscoverChannels: typeof settings.autoDiscoverChannels === "boolean" @@ -129,11 +130,11 @@ function parseSettingsResponse(raw: unknown): TlonSettingsStore { ? settings.autoAcceptGroupInvites : undefined, groupInviteAllowlist: Array.isArray(settings.groupInviteAllowlist) - ? settings.groupInviteAllowlist.filter((x): x is string => typeof x === "string") + ? filterStringEntries(settings.groupInviteAllowlist) : undefined, channelRules: parseChannelRules(settings.channelRules), defaultAuthorizedShips: Array.isArray(settings.defaultAuthorizedShips) - ? settings.defaultAuthorizedShips.filter((x): x is string => typeof x === "string") + ? filterStringEntries(settings.defaultAuthorizedShips) : undefined, ownerShip: typeof settings.ownerShip === "string" ? settings.ownerShip : undefined, pendingApprovals: parsePendingApprovals(settings.pendingApprovals), @@ -242,14 +243,10 @@ function applySettingsUpdate( switch (key) { case "groupChannels": - next.groupChannels = Array.isArray(value) - ? value.filter((x): x is string => typeof x === "string") - : undefined; + next.groupChannels = Array.isArray(value) ? filterStringEntries(value) : undefined; break; case "dmAllowlist": - next.dmAllowlist = Array.isArray(value) - ? value.filter((x): x is string => typeof x === "string") - : undefined; + next.dmAllowlist = Array.isArray(value) ? filterStringEntries(value) : undefined; break; case "autoDiscoverChannels": next.autoDiscoverChannels = typeof value === "boolean" ? value : undefined; @@ -264,17 +261,13 @@ function applySettingsUpdate( next.autoAcceptGroupInvites = typeof value === "boolean" ? value : undefined; break; case "groupInviteAllowlist": - next.groupInviteAllowlist = Array.isArray(value) - ? value.filter((x): x is string => typeof x === "string") - : undefined; + next.groupInviteAllowlist = Array.isArray(value) ? filterStringEntries(value) : undefined; break; case "channelRules": next.channelRules = parseChannelRules(value); break; case "defaultAuthorizedShips": - next.defaultAuthorizedShips = Array.isArray(value) - ? value.filter((x): x is string => typeof x === "string") - : undefined; + next.defaultAuthorizedShips = Array.isArray(value) ? filterStringEntries(value) : undefined; break; case "ownerShip": next.ownerShip = typeof value === "string" ? value : undefined; diff --git a/extensions/tts-local-cli/speech-provider.ts b/extensions/tts-local-cli/speech-provider.ts index 9568eda73da4..b7e17aa28978 100644 --- a/extensions/tts-local-cli/speech-provider.ts +++ b/extensions/tts-local-cli/speech-provider.ts @@ -14,7 +14,7 @@ import type { SpeechSynthesisRequest, SpeechTelephonySynthesisRequest, } from "openclaw/plugin-sdk/speech-core"; -import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asOptionalRecord, filterStringRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -41,20 +41,6 @@ function asStringArray(value: unknown): string[] | undefined { return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : undefined; } -function readStringRecord(value: unknown): Record | undefined { - const obj = asOptionalRecord(value); - if (!obj) { - return undefined; - } - const result: Record = {}; - for (const [k, v] of Object.entries(obj)) { - if (typeof v === "string") { - result[k] = v; - } - } - return Object.keys(result).length > 0 ? result : undefined; -} - function normalizeOutputFormat(value: unknown): OutputFormat { if (typeof value !== "string") { return "mp3"; @@ -82,7 +68,7 @@ function getConfig(cfg: SpeechProviderConfig): CliConfig | null { outputFormat: normalizeOutputFormat(cfg.outputFormat), timeoutMs: typeof cfg.timeoutMs === "number" ? cfg.timeoutMs : DEFAULT_TIMEOUT_MS, cwd: typeof cfg.cwd === "string" ? cfg.cwd : undefined, - env: readStringRecord(cfg.env), + env: filterStringRecord(cfg.env), }; } diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index d0cd4ead215d..c680eea57dc0 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -4,7 +4,7 @@ import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime"; import { resolveGlobalSingleton } from "openclaw/plugin-sdk/global-singleton"; import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; import { - asNonArrayRecord, + asNonArrayRecord as asParamRecord, asOptionalRecord, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -88,10 +88,6 @@ const VoiceCallToolSchema = Type.Union([ }), ]); -function asParamRecord(params: unknown): Record { - return asNonArrayRecord(params); -} - function isCliOnlyProcess(): boolean { return process.env.OPENCLAW_CLI === "1" && !process.argv.slice(2).includes("gateway"); } diff --git a/extensions/voice-call/src/config-migration.ts b/extensions/voice-call/src/config-migration.ts index 19609f7ca567..c9b161b14d50 100644 --- a/extensions/voice-call/src/config-migration.ts +++ b/extensions/voice-call/src/config-migration.ts @@ -5,11 +5,6 @@ import { readStringField, } from "openclaw/plugin-sdk/string-coerce-runtime"; -/** Read finite numeric config values. */ -function getNumber(obj: Record | undefined, key: string): number | undefined { - return asFiniteNumber(obj?.[key]); -} - /** Merge legacy provider-specific values into the canonical providers map. */ function mergeProviderConfig( providersValue: unknown, @@ -55,11 +50,11 @@ export function migrateVoiceCallLegacyConfigInput(params: { if (streamingSttModel) { legacyStreamingOpenAICompat.model = streamingSttModel; } - const streamingSilenceDurationMs = getNumber(streaming, "silenceDurationMs"); + const streamingSilenceDurationMs = asFiniteNumber(streaming?.silenceDurationMs); if (streamingSilenceDurationMs !== undefined) { legacyStreamingOpenAICompat.silenceDurationMs = streamingSilenceDurationMs; } - const streamingVadThreshold = getNumber(streaming, "vadThreshold"); + const streamingVadThreshold = asFiniteNumber(streaming?.vadThreshold); if (streamingVadThreshold !== undefined) { legacyStreamingOpenAICompat.vadThreshold = streamingVadThreshold; } @@ -138,14 +133,14 @@ export function migrateVoiceCallLegacyConfigInput(params: { `Moved ${configPathPrefix}.streaming.sttModel → ${configPathPrefix}.streaming.providers.openai.model.`, ); } - if (getNumber(streaming, "silenceDurationMs") !== undefined) { + if (asFiniteNumber(streaming?.silenceDurationMs) !== undefined) { changes.push( `Moved ${configPathPrefix}.streaming.silenceDurationMs → ${configPathPrefix}.streaming.providers.openai.silenceDurationMs.`, ); } else if (typeof streaming?.silenceDurationMs === "number") { changes.push(`Removed invalid ${configPathPrefix}.streaming.silenceDurationMs.`); } - if (getNumber(streaming, "vadThreshold") !== undefined) { + if (asFiniteNumber(streaming?.vadThreshold) !== undefined) { changes.push( `Moved ${configPathPrefix}.streaming.vadThreshold → ${configPathPrefix}.streaming.providers.openai.vadThreshold.`, ); diff --git a/extensions/voice-call/src/response-generator.ts b/extensions/voice-call/src/response-generator.ts index 99a9d34b6719..26712b2e9383 100644 --- a/extensions/voice-call/src/response-generator.ts +++ b/extensions/voice-call/src/response-generator.ts @@ -12,6 +12,7 @@ import { } from "openclaw/plugin-sdk/model-session-runtime"; import { isRecord, + filterStringEntries, normalizeLowercaseStringOrEmpty, normalizeStringEntries, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -66,7 +67,7 @@ function readExplicitToolsAllow(value: unknown): string[] | undefined { return undefined; } - return allow.filter((entry): entry is string => typeof entry === "string"); + return filterStringEntries(allow); } function resolveVoiceAgentToolsAllow(config: CoreConfig, agentId: string): string[] | undefined { diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index e86b5a5e9d50..adc52650dbd9 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -14,7 +14,10 @@ import { } from "openclaw/plugin-sdk/channel-outbound"; import { buildInboundHistoryFromEntries } from "openclaw/plugin-sdk/reply-history"; import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime"; -import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeOptionalString, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js"; import type { AdmittedWebInboundMessage } from "../../inbound/types.js"; import { @@ -147,7 +150,7 @@ function isWhatsAppVisibleDeliveryError(error: unknown): boolean { } function readTrimmedString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; + return normalizeOptionalString(value) ?? ""; } function markWhatsAppReplyDeliveryErrorVisibleAfterFlush( diff --git a/extensions/workboard/src/dispatcher.ts b/extensions/workboard/src/dispatcher.ts index 15c0fa08f082..d94a460f8049 100644 --- a/extensions/workboard/src/dispatcher.ts +++ b/extensions/workboard/src/dispatcher.ts @@ -6,7 +6,10 @@ import type { WorkboardWorkspace, } from "@openclaw/workboard-contract"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { isFutureDateTimestampMs } from "openclaw/plugin-sdk/number-runtime"; +import { + isFutureDateTimestampMs, + resolveNonNegativeIntegerOption, +} from "openclaw/plugin-sdk/number-runtime"; import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import { canonicalPathFromExistingAncestor } from "openclaw/plugin-sdk/security-runtime"; import { @@ -71,12 +74,6 @@ type WorkboardDispatchStartParams = { const pendingWorkboardDispatches = new WeakMap>(); -function normalizePositiveInteger(value: number | undefined, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) - ? Math.max(0, Math.trunc(value)) - : fallback; -} - function sanitizeSessionSegment(value: string | undefined, fallback: string): string { const sanitized = (value ?? fallback) .trim() @@ -327,7 +324,7 @@ async function runWorkboardDispatch( const now = params.options?.now ?? Date.now(); const boardId = params.options?.boardId; const dispatch = await params.store.dispatch({ now, boardId }); - const maxStarts = normalizePositiveInteger( + const maxStarts = resolveNonNegativeIntegerOption( params.options?.maxStarts, DEFAULT_DISPATCH_MAX_STARTS, ); diff --git a/extensions/workboard/src/store-normalizers.ts b/extensions/workboard/src/store-normalizers.ts index 55e23f7f237e..c577a83b8821 100644 --- a/extensions/workboard/src/store-normalizers.ts +++ b/extensions/workboard/src/store-normalizers.ts @@ -46,7 +46,8 @@ import { type WorkboardWorkerProtocol, type WorkboardWorkspace, } from "@openclaw/workboard-contract"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { resolveNonNegativeIntegerOption } from "openclaw/plugin-sdk/number-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { MAX_ATTACHMENT_BYTES, MAX_CARD_ARTIFACTS, @@ -129,10 +130,10 @@ function normalizeOrchestration( value: unknown, fallback?: WorkboardOrchestrationSettings, ): WorkboardOrchestrationSettings | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return fallback; } - const record = value as Record; + const record = value; const autoDecompose = typeof record.autoDecompose === "boolean" ? record.autoDecompose : fallback?.autoDecompose; const autoDecomposePerDispatch = @@ -340,10 +341,7 @@ export function normalizeStringList(value: unknown, fieldName: string, maxLength } export function normalizePosition(value: unknown, fallback: number): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return fallback; - } - return Math.max(0, Math.trunc(value)); + return resolveNonNegativeIntegerOption(value, fallback); } function normalizePositiveInteger(value: unknown, fieldName: string): number | undefined { @@ -360,10 +358,10 @@ function normalizeWorkspace( value: unknown, fallback?: WorkboardWorkspace, ): WorkboardWorkspace | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return fallback; } - const record = value as Record; + const record = value; const kind = record.kind === "scratch" || record.kind === "dir" || record.kind === "worktree" ? record.kind @@ -404,10 +402,10 @@ export function normalizeAutomation( value: unknown, fallback: WorkboardAutomation = {}, ): WorkboardAutomation | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return Object.keys(fallback).length ? fallback : undefined; } - const record = value as Record; + const record = value; const tenant = normalizeBoundedString(record.tenant, fallback.tenant, 80, "tenant"); const boardId = Object.hasOwn(record, "boardId") ? normalizeBoardId(record.boardId, fallback.boardId) @@ -553,10 +551,10 @@ export function normalizeTimestamp(value: unknown, fallback: number): number { } function normalizeEvent(value: unknown): WorkboardEvent | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const id = normalizeOptionalString(record.id); const kind = WORKBOARD_EVENT_KINDS.includes(record.kind as WorkboardEventKind) ? (record.kind as WorkboardEventKind) @@ -599,10 +597,10 @@ export function normalizeEvents(value: unknown): WorkboardEvent[] { } function normalizeAttempt(value: unknown): WorkboardRunAttempt | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const id = normalizeOptionalString(record.id); const startedAt = normalizeTimestamp(record.startedAt, 0); if (!id || !startedAt) { @@ -632,10 +630,10 @@ function normalizeAttempt(value: unknown): WorkboardRunAttempt | null { } function normalizeComment(value: unknown): WorkboardComment | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const id = normalizeOptionalString(record.id); const body = normalizeBoundedString(record.body, undefined, 2000, "comment body"); const createdAt = normalizeTimestamp(record.createdAt, 0); @@ -647,10 +645,10 @@ function normalizeComment(value: unknown): WorkboardComment | null { } function normalizeLink(value: unknown): WorkboardLink | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const id = normalizeOptionalString(record.id); const createdAt = normalizeTimestamp(record.createdAt, 0); if (!id || !createdAt) { @@ -677,10 +675,10 @@ function isDependencyLink(link: WorkboardLink): boolean { } function normalizeProof(value: unknown): WorkboardProof | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const id = normalizeOptionalString(record.id); const createdAt = normalizeTimestamp(record.createdAt, 0); if (!id || !createdAt) { @@ -702,10 +700,10 @@ function normalizeProof(value: unknown): WorkboardProof | null { } export function normalizeArtifact(value: unknown): WorkboardArtifact | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const id = normalizeOptionalString(record.id) ?? randomUUID(); const createdAt = normalizeTimestamp(record.createdAt, Date.now()); const label = normalizeBoundedString(record.label, undefined, 160, "artifact label"); @@ -726,10 +724,10 @@ export function normalizeArtifact(value: unknown): WorkboardArtifact | null { } function normalizeAttachment(value: unknown): WorkboardAttachment | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const id = normalizeOptionalString(record.id); const cardId = normalizeBoundedString(record.cardId, undefined, 120, "card id"); const fileName = normalizeBoundedString(record.fileName, undefined, 240, "attachment file name"); @@ -755,10 +753,10 @@ function normalizeAttachment(value: unknown): WorkboardAttachment | null { } function normalizeWorkerLog(value: unknown): WorkboardWorkerLog | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const id = normalizeOptionalString(record.id); const message = normalizeBoundedString(record.message, undefined, 800, "worker log message"); const createdAt = normalizeTimestamp(record.createdAt, 0); @@ -785,10 +783,10 @@ function normalizeWorkerProtocol( value: unknown, fallback?: WorkboardWorkerProtocol, ): WorkboardWorkerProtocol | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return fallback; } - const record = value as Record; + const record = value; const state = record.state === "idle" || record.state === "running" || @@ -855,10 +853,10 @@ export function normalizeAttachmentInput( } function normalizeClaim(value: unknown, fallback?: WorkboardClaim): WorkboardClaim | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return fallback; } - const record = value as Record; + const record = value; const ownerId = normalizeBoundedString(record.ownerId, fallback?.ownerId, 120, "claim owner"); const token = normalizeBoundedString(record.token, fallback?.token, 160, "claim token"); const claimedAt = normalizeTimestamp(record.claimedAt, fallback?.claimedAt ?? Date.now()); @@ -880,10 +878,10 @@ function normalizeClaim(value: unknown, fallback?: WorkboardClaim): WorkboardCla } function normalizeDiagnosticAction(value: unknown): WorkboardDiagnosticAction | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const kind = record.kind === "claim" || record.kind === "unblock" || @@ -897,10 +895,10 @@ function normalizeDiagnosticAction(value: unknown): WorkboardDiagnosticAction | } function normalizeDiagnostic(value: unknown): WorkboardDiagnostic | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const kind = WORKBOARD_DIAGNOSTIC_KINDS.includes(record.kind as WorkboardDiagnosticKind) ? (record.kind as WorkboardDiagnosticKind) : undefined; @@ -937,10 +935,10 @@ function normalizeDiagnostic(value: unknown): WorkboardDiagnostic | null { } function normalizeNotification(value: unknown): WorkboardNotification | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const id = normalizeOptionalString(record.id) ?? randomUUID(); const kind = WORKBOARD_NOTIFICATION_KINDS.includes(record.kind as WorkboardNotificationKind) ? (record.kind as WorkboardNotificationKind) @@ -1027,10 +1025,10 @@ export function normalizeMetadata( preserveProofId?: string; } = {}, ): WorkboardMetadata { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return trimMetadataToBudget(fallback, options); } - const record = value as Record; + const record = value; const stale = record.stale && typeof record.stale === "object" && !Array.isArray(record.stale) ? (record.stale as Record) @@ -1150,10 +1148,10 @@ export function normalizeMetadata( } export function normalizeExecution(value: unknown): WorkboardExecution | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return undefined; } - const record = value as Record; + const record = value; const now = Date.now(); // Preserve historical labels as written; old hardcoded "codex" rows cannot be inferred safely. const engine = normalizeBoundedString(record.engine, undefined, 160, "execution engine"); diff --git a/extensions/xai/realtime-voice-bridge.ts b/extensions/xai/realtime-voice-bridge.ts index 5d22667b96a3..b84300f1a128 100644 --- a/extensions/xai/realtime-voice-bridge.ts +++ b/extensions/xai/realtime-voice-bridge.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; import { captureWsEvent, createDebugProxyWebSocketAgent, @@ -272,10 +273,10 @@ export class XaiRealtimeVoiceBridge extends XaiRealtimeVoiceEvents implements Re meta: { provider: "xai", capability: "realtime-voice" }, }); if (!attempt.ready) { - rejectStartup(error instanceof Error ? error : new Error(String(error))); + rejectStartup(toStringifiedError(error)); return; } - this.config.onError?.(error instanceof Error ? error : new Error(String(error))); + this.config.onError?.(toStringifiedError(error)); }); ws.on("close", (code, reasonBuffer) => { @@ -330,7 +331,7 @@ export class XaiRealtimeVoiceBridge extends XaiRealtimeVoiceEvents implements Re attempt.resolve(); return; } - attempt.reject(error instanceof Error ? error : new Error(String(error))); + attempt.reject(toStringifiedError(error)); }); await attempt.promise; } @@ -418,7 +419,7 @@ export class XaiRealtimeVoiceBridge extends XaiRealtimeVoiceEvents implements Re ) { return; } - this.config.onError?.(error instanceof Error ? error : new Error(String(error))); + this.config.onError?.(toStringifiedError(error)); await this.attemptReconnect(reason, nextConnection); } } diff --git a/extensions/xai/realtime-voice-config.ts b/extensions/xai/realtime-voice-config.ts index a034717f1872..d894f7609ea4 100644 --- a/extensions/xai/realtime-voice-config.ts +++ b/extensions/xai/realtime-voice-config.ts @@ -8,8 +8,9 @@ import type { } from "openclaw/plugin-sdk/realtime-voice"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { - asFiniteNumber, - asRecord, + asFiniteNumberInRange, + asOptionalObjectRecord as readXaiObjectRecord, + asSafeIntegerInRange, normalizeOptionalString, parseBooleanValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -18,10 +19,6 @@ import { XAI_BASE_URL } from "./model-definitions.js"; type XaiRealtimeVoice = "eve" | "ara" | "rex" | "sal" | "leo"; type XaiRealtimeReasoningEffort = "high" | "none"; -function readXaiObjectRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" ? asRecord(value) : undefined; -} - type XaiRealtimeVoiceProviderConfig = { apiKey?: string; baseUrl?: string; @@ -171,15 +168,11 @@ function normalizeXaiRealtimeVoice(value: unknown): string | undefined { } function asXaiVadThreshold(value: unknown): number | undefined { - const number = asFiniteNumber(value); - return number !== undefined && number >= 0.1 && number <= 0.9 ? number : undefined; + return asFiniteNumberInRange(value, { min: 0.1, max: 0.9 }); } function asXaiDurationMs(value: unknown): number | undefined { - const number = asFiniteNumber(value); - return number !== undefined && Number.isSafeInteger(number) && number >= 0 && number <= 10_000 - ? number - : undefined; + return asSafeIntegerInRange(value, { min: 0, max: 10_000 }); } function asXaiReasoningEffort(value: unknown): XaiRealtimeReasoningEffort | undefined { diff --git a/extensions/xai/stream.ts b/extensions/xai/stream.ts index 595daf3d7869..5f03cd576115 100644 --- a/extensions/xai/stream.ts +++ b/extensions/xai/stream.ts @@ -8,6 +8,7 @@ import { createPlainTextToolCallCompatWrapper, createToolStreamWrapper, } from "openclaw/plugin-sdk/provider-stream-shared"; +import { filterStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { XAI_GROK_OAUTH_BASE_URL } from "./provider-catalog.js"; import { isXaiProviderId } from "./provider-id.js"; @@ -83,9 +84,7 @@ function ensureXaiResponsesEncryptedReasoningInclude( return; } const existing = payloadObj.include; - const include = Array.isArray(existing) - ? existing.filter((entry): entry is string => typeof entry === "string") - : []; + const include = filterStringEntries(existing); if (!include.includes(XAI_REASONING_ENCRYPTED_CONTENT_INCLUDE)) { include.push(XAI_REASONING_ENCRYPTED_CONTENT_INCLUDE); } diff --git a/extensions/xai/tts.ts b/extensions/xai/tts.ts index cb19d3b76ce0..4a3aee12909b 100644 --- a/extensions/xai/tts.ts +++ b/extensions/xai/tts.ts @@ -1,4 +1,5 @@ // Xai plugin module implements tts behavior. +import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime"; import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime"; import { assertOkOrThrowProviderError, @@ -257,7 +258,7 @@ export async function xaiTTSStream(params: { }, }); } catch (error) { - failConnect(error instanceof Error ? error : new Error(String(error))); + failConnect(toStringifiedError(error)); return; } @@ -276,7 +277,7 @@ export async function xaiTTSStream(params: { }); ws.once("error", (error) => { - const normalized = error instanceof Error ? error : new Error(String(error)); + const normalized = toStringifiedError(error); if (connectSettled) { failStream(normalized); return; @@ -373,7 +374,7 @@ export async function xaiTTSStream(params: { const payload = rawDataToString(data); handleServerEvent(JSON.parse(payload) as XaiTtsStreamServerEvent); } catch (error) { - failStream(error instanceof Error ? error : new Error(String(error))); + failStream(toStringifiedError(error)); } }); @@ -407,7 +408,7 @@ export async function xaiTTSStream(params: { } ws?.send(JSON.stringify({ type: "text.done" })); } catch (error) { - failStream(error instanceof Error ? error : new Error(String(error))); + failStream(toStringifiedError(error)); } resolve({ audioStream: wiredStream, release }); diff --git a/package.json b/package.json index 28d0f5c2effa..f1bcead0b630 100644 --- a/package.json +++ b/package.json @@ -1664,6 +1664,7 @@ "lint:extensions:telegram-grammy-types": "node --import tsx scripts/check-telegram-grammy-types-imports.mts", "lint:extensions": "node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.extensions.json extensions", "lint:extensions:no-guarded-wildcard-reexports": "node --import tsx scripts/check-extension-wildcard-reexports.mts", + "lint:extensions:no-normalization-core-bypass": "node --import tsx scripts/check-extension-plugin-sdk-boundary.mts --mode=normalization-core-bypass", "lint:extensions:no-plugin-sdk-internal": "node --import tsx scripts/check-extension-plugin-sdk-boundary.mts --mode=plugin-sdk-internal", "lint:extensions:no-plugin-sdk-wildcard-reexports": "node --import tsx scripts/check-plugin-sdk-wildcard-reexports.mts", "lint:extensions:no-relative-outside-package": "node --import tsx scripts/check-extension-plugin-sdk-boundary.mts --mode=relative-outside-package", diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts index 133cf39f46c9..026b5e2cb83b 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 { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { TranscriptNotContinuableError } from "./errors.js"; import { uuidv7 } from "./harness/session/uuid.js"; import { @@ -1879,9 +1880,7 @@ type TurnTaintMetadata = { function readTurnTaintMetadata(message: AgentMessage): TurnTaintMetadata | undefined { const metadata = (message as unknown as Record)["__openclaw"]; - return metadata && typeof metadata === "object" && !Array.isArray(metadata) - ? (metadata as TurnTaintMetadata) - : undefined; + return asOptionalRecord(metadata) as TurnTaintMetadata | undefined; } function toolResultTaintsTurn(message: ToolResultMessage): boolean { diff --git a/packages/ai/src/providers/anthropic-refusal.ts b/packages/ai/src/providers/anthropic-refusal.ts index 0608d9c6f45b..9263bb0e5871 100644 --- a/packages/ai/src/providers/anthropic-refusal.ts +++ b/packages/ai/src/providers/anthropic-refusal.ts @@ -1,3 +1,4 @@ +import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce"; import type { AssistantMessageDiagnostic } from "../types.js"; type AnthropicRefusalOutput = { @@ -11,18 +12,14 @@ type AnthropicRefusalDetails = { explanation: string | null; }; -function readNullableString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function readAnthropicRefusalDetails(value: unknown): AnthropicRefusalDetails { if (!value || typeof value !== "object") { return { category: null, explanation: null }; } const details = value as Record; return { - category: readNullableString(details.category), - explanation: readNullableString(details.explanation), + category: normalizeNullableString(details.category), + explanation: normalizeNullableString(details.explanation), }; } diff --git a/packages/ai/src/providers/clean-for-llamacpp-gbnf.ts b/packages/ai/src/providers/clean-for-llamacpp-gbnf.ts index 6cb6a1835228..fec5b04235ff 100644 --- a/packages/ai/src/providers/clean-for-llamacpp-gbnf.ts +++ b/packages/ai/src/providers/clean-for-llamacpp-gbnf.ts @@ -1,3 +1,5 @@ +import { isRecord as isSchemaRecord } from "@openclaw/normalization-core/record-coerce"; + /** llama.cpp rejects grammar repetitions whose expanded rule count reaches 2000. */ export const LLAMACPP_GBNF_MAX_REPETITION_THRESHOLD = 2000; @@ -27,10 +29,6 @@ const SCHEMA_CHILD_KEYS = new Set([ "unevaluatedProperties", ]); -function isSchemaRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function cleanSchemaNode(node: unknown): unknown { if (Array.isArray(node)) { let changed = false; diff --git a/packages/ai/src/providers/openai-responses-tool-call-tracker.ts b/packages/ai/src/providers/openai-responses-tool-call-tracker.ts index 87a2564c7e00..6decb1838a9e 100644 --- a/packages/ai/src/providers/openai-responses-tool-call-tracker.ts +++ b/packages/ai/src/providers/openai-responses-tool-call-tracker.ts @@ -1,3 +1,5 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; + export type ResponsesToolCallIdentity = { itemId?: string; callId?: string }; export type ResponsesToolCallState = ResponsesToolCallIdentity & { @@ -9,11 +11,6 @@ type ResponsesToolCallEvent = { item_id?: unknown; }; -function readIdentityValue(value: unknown): string | undefined { - const identity = typeof value === "string" ? value.trim() : ""; - return identity || undefined; -} - function readOutputIndex(event: ResponsesToolCallEvent): number | undefined { return typeof event.output_index === "number" && Number.isInteger(event.output_index) && @@ -23,7 +20,7 @@ function readOutputIndex(event: ResponsesToolCallEvent): number | undefined { } function readEventIdentity(event: ResponsesToolCallEvent): ResponsesToolCallIdentity { - return { itemId: readIdentityValue(event.item_id) }; + return { itemId: normalizeOptionalString(event.item_id) }; } export function readResponsesToolCallItemIdentity(item: { @@ -31,8 +28,8 @@ export function readResponsesToolCallItemIdentity(item: { call_id?: unknown; }): ResponsesToolCallIdentity { return { - itemId: readIdentityValue(item.id), - callId: readIdentityValue(item.call_id), + itemId: normalizeOptionalString(item.id), + callId: normalizeOptionalString(item.call_id), }; } diff --git a/packages/ai/src/providers/tool-schema-json-projection.ts b/packages/ai/src/providers/tool-schema-json-projection.ts index c4655557006e..ce972051c7ed 100644 --- a/packages/ai/src/providers/tool-schema-json-projection.ts +++ b/packages/ai/src/providers/tool-schema-json-projection.ts @@ -1,4 +1,5 @@ import { types as utilTypes } from "node:util"; +import { isRecord as isJsonObject } from "@openclaw/normalization-core/record-coerce"; /** JSON-safe schema value used when projecting runtime tool parameters. */ export type RuntimeToolInputSchemaJson = @@ -35,12 +36,6 @@ function isJsonValue(value: unknown): value is RuntimeToolInputSchemaJson { } } -function isJsonObject(value: RuntimeToolInputSchemaJson): value is { - [key: string]: RuntimeToolInputSchemaJson; -} { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - function isNonFiniteNumberValue(value: unknown): boolean { if (typeof value === "number") { return !Number.isFinite(value); diff --git a/packages/ai/src/transports/json-unsafe-integers.ts b/packages/ai/src/transports/json-unsafe-integers.ts index bd96d4c37c36..71427fe73fa0 100644 --- a/packages/ai/src/transports/json-unsafe-integers.ts +++ b/packages/ai/src/transports/json-unsafe-integers.ts @@ -2,6 +2,8 @@ * JSON parsing helpers that preserve integer literals larger than * Number.MAX_SAFE_INTEGER as strings before JSON.parse can round them. */ +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; + const MAX_SAFE_INTEGER_ABS_STR = String(Number.MAX_SAFE_INTEGER); function isAsciiDigit(ch: string | undefined): boolean { @@ -134,17 +136,10 @@ export function parseJsonObjectPreservingUnsafeIntegers( ): Record | null { if (typeof value === "string") { try { - const parsed = parseJsonPreservingUnsafeIntegers(value); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } + return asNullableRecord(parseJsonPreservingUnsafeIntegers(value)); } catch { return null; } - return null; } - if (value && typeof value === "object" && !Array.isArray(value)) { - return value as Record; - } - return null; + return asNullableRecord(value); } diff --git a/packages/ai/src/transports/model-transport-debug.ts b/packages/ai/src/transports/model-transport-debug.ts index b173e008932f..6c16cb66df28 100644 --- a/packages/ai/src/transports/model-transport-debug.ts +++ b/packages/ai/src/transports/model-transport-debug.ts @@ -4,6 +4,8 @@ * Model adapters share these helpers so payload, SSE, and transport diagnostics * interpret OpenClaw debug environment variables consistently. */ +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; + type SubsystemLogger = { info(message: string): void; debug(message: string): void; @@ -16,12 +18,8 @@ type ModelPayloadDebugMode = "off" | "summary" | "tools" | "full-redacted"; /** SSE debug detail levels accepted by `OPENCLAW_DEBUG_SSE`. */ type ModelSseDebugMode = "off" | "events" | "peek"; -function normalizeEnv(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - function isTruthyEnv(value: unknown): boolean { - const normalized = normalizeEnv(value); + const normalized = normalizeLowercaseStringOrEmpty(value); return ( normalized.length > 0 && normalized !== "0" && @@ -35,7 +33,7 @@ function isTruthyEnv(value: unknown): boolean { export function resolveModelPayloadDebugMode( env: ModelTransportDebugEnv = process.env, ): ModelPayloadDebugMode { - const normalized = normalizeEnv(env.OPENCLAW_DEBUG_MODEL_PAYLOAD); + const normalized = normalizeLowercaseStringOrEmpty(env.OPENCLAW_DEBUG_MODEL_PAYLOAD); if (normalized === "tools" || normalized === "full-redacted") { return normalized; } @@ -49,7 +47,7 @@ export function resolveModelPayloadDebugMode( export function resolveModelSseDebugMode( env: ModelTransportDebugEnv = process.env, ): ModelSseDebugMode { - const normalized = normalizeEnv(env.OPENCLAW_DEBUG_SSE); + const normalized = normalizeLowercaseStringOrEmpty(env.OPENCLAW_DEBUG_SSE); if (normalized === "peek") { return "peek"; } diff --git a/packages/ai/src/transports/openai-reasoning-compat.ts b/packages/ai/src/transports/openai-reasoning-compat.ts index 7e7d34b15c1b..1018a723dd9f 100644 --- a/packages/ai/src/transports/openai-reasoning-compat.ts +++ b/packages/ai/src/transports/openai-reasoning-compat.ts @@ -3,6 +3,7 @@ * * Keeps provider metadata and built-in model exceptions on one path before request payloads are built. */ +import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; /** Minimal model fields needed to resolve OpenAI reasoning effort compatibility. */ @@ -19,11 +20,8 @@ const OPENAI_MEDIUM_ONLY_REASONING_MODEL_IDS = new Set(["gpt-5.1-codex-mini"]); // Provider metadata can remap reasoning effort names. Keep only string pairs so // malformed compat data cannot poison request parameters. function readCompatReasoningEffortMap(compat: unknown): Record { - if (!compat || typeof compat !== "object") { - return {}; - } - const rawMap = (compat as { reasoningEffortMap?: unknown }).reasoningEffortMap; - if (!rawMap || typeof rawMap !== "object") { + const rawMap = asOptionalObjectRecord(asOptionalObjectRecord(compat)?.reasoningEffortMap); + if (!rawMap) { return {}; } return Object.fromEntries( diff --git a/packages/ai/src/transports/openai-responses-payload-policy.ts b/packages/ai/src/transports/openai-responses-payload-policy.ts index 57e7d4ad1433..e1cc9f84b72f 100644 --- a/packages/ai/src/transports/openai-responses-payload-policy.ts +++ b/packages/ai/src/transports/openai-responses-payload-policy.ts @@ -4,7 +4,10 @@ import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number- * Classifies endpoint capabilities and applies store, prompt-cache, * server-compaction, service-tier, and reasoning payload rules. */ -import { readStringValue } from "@openclaw/normalization-core/string-coerce"; +import { + normalizeOptionalLowercaseString, + readStringValue, +} from "@openclaw/normalization-core/string-coerce"; import { supportsOpenAIReasoningEffort } from "../providers/openai-reasoning-effort.js"; type OpenAIResponsesPayloadModel = { @@ -86,11 +89,6 @@ const MOONSHOT_NATIVE_BASE_URLS = new Set([ "https://api.moonshot.cn/v1", ]); -function normalizeLowercaseString(value: unknown): string | undefined { - const stringValue = readStringValue(value)?.trim().toLowerCase(); - return stringValue ? stringValue : undefined; -} - function normalizeComparableBaseUrl(value: unknown): string | undefined { const trimmed = readStringValue(value)?.trim(); if (!trimmed) { @@ -228,8 +226,8 @@ function readCompatPayloadBoolean( function resolveOpenAIResponsesPayloadCapabilities( model: OpenAIResponsesPayloadModel, ): OpenAIResponsesPayloadCapabilities { - const provider = normalizeLowercaseString(model.provider); - const api = normalizeLowercaseString(model.api); + const provider = normalizeOptionalLowercaseString(model.provider); + const api = normalizeOptionalLowercaseString(model.api); const isOpenAIProvider = provider === "openai"; const endpointClass = resolveBundledOpenAIResponsesEndpointClass(model.baseUrl); const isResponsesApi = isOpenAIResponsesApi(api); @@ -359,7 +357,7 @@ export function resolveOpenAIResponsesPayloadPolicy( : capabilities.allowsResponsesStore ? true : undefined; - const isResponsesApi = isOpenAIResponsesApi(normalizeLowercaseString(model.api)); + const isResponsesApi = isOpenAIResponsesApi(normalizeOptionalLowercaseString(model.api)); const shouldStripDisabledReasoningPayload = isResponsesApi && (!capabilities.usesKnownNativeOpenAIRoute || !supportsOpenAIReasoningEffort(model, "none")); diff --git a/packages/ai/src/transports/transport-stream-shared.ts b/packages/ai/src/transports/transport-stream-shared.ts index 86ebda944817..25de3e00006b 100644 --- a/packages/ai/src/transports/transport-stream-shared.ts +++ b/packages/ai/src/transports/transport-stream-shared.ts @@ -4,6 +4,7 @@ * Sanitizes provider payloads, merges metadata, and formats streamed assistant events. */ import type { Usage } from "@openclaw/llm-core"; +import { asNonArrayRecord, asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { createAssistantMessageEventStream } from "../utils/event-stream.js"; import { projectProviderError, type ProviderErrorProjection } from "../utils/provider-error.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; @@ -46,15 +47,13 @@ export function sanitizeNonEmptyTransportPayloadText( } export function coerceTransportToolCallArguments(argumentsValue: unknown): Record { - if (argumentsValue && typeof argumentsValue === "object" && !Array.isArray(argumentsValue)) { - return argumentsValue as Record; + const argumentsRecord = asOptionalRecord(argumentsValue); + if (argumentsRecord) { + return argumentsRecord; } if (typeof argumentsValue === "string") { try { - const parsed = JSON.parse(argumentsValue); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } + return asNonArrayRecord(JSON.parse(argumentsValue)); } catch { // Preserve malformed strings in stored history, but send object-shaped payloads to // providers that require structured tool-call arguments. @@ -82,10 +81,7 @@ export function mergeTransportMetadata>( if (!metadata || Object.keys(metadata).length === 0) { return payload; } - const existingMetadata = - payload.metadata && typeof payload.metadata === "object" && !Array.isArray(payload.metadata) - ? (payload.metadata as Record) - : undefined; + const existingMetadata = asOptionalRecord(payload.metadata) as Record | undefined; return { ...payload, metadata: { diff --git a/packages/ai/src/utils/json-parse.ts b/packages/ai/src/utils/json-parse.ts index c3e0917135f5..2839cd8a439f 100644 --- a/packages/ai/src/utils/json-parse.ts +++ b/packages/ai/src/utils/json-parse.ts @@ -1,4 +1,5 @@ // JSON parse helpers recover structured values from partial model output. +import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce"; import { parse as partialParse } from "partial-json"; const VALID_JSON_ESCAPES = new Set(['"', "\\", "/", "b", "f", "n", "r", "t", "u"]); @@ -113,12 +114,6 @@ function looksLikeWindowsPathPrefix(prefix: string): boolean { return /(?:^|[^A-Za-z0-9])[A-Za-z]:(?:[\\/][^"\\/:*?<>|\r\n]*)*$/.test(tail); } -function asStreamingJsonRecord(value: unknown): Record { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - /** * Attempts to parse potentially incomplete JSON during streaming. * Always returns a valid object, even if the JSON is incomplete. @@ -132,13 +127,13 @@ export function parseStreamingJson(partialJson: string | undefined): Record; @@ -124,7 +125,7 @@ function parseYamlFrontmatterOnce( } const parsed = doc.toJS() as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (!isRecord(parsed)) { return { frontmatter: fallback, issues: [{ code: "INVALID_ROOT", message: "frontmatter must be a YAML mapping" }], diff --git a/packages/markdown-core/src/render-aware-chunking.ts b/packages/markdown-core/src/render-aware-chunking.ts index 82af88d49ea2..d50e0918bd28 100644 --- a/packages/markdown-core/src/render-aware-chunking.ts +++ b/packages/markdown-core/src/render-aware-chunking.ts @@ -1,3 +1,4 @@ +import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; import { avoidTrailingHighSurrogateBreak } from "./chunk-text.js"; // Markdown Core module implements render aware chunking behavior. import { annotateAssistantTranscriptRoleMessageBoundary } from "./ir-annotations.js"; @@ -41,13 +42,6 @@ type RenderResolver = Pick< "measureRendered" | "renderChunk" >; -function resolveIntegerOption(value: number, fallback: number, opts: { min: number }): number { - if (!Number.isFinite(value)) { - return fallback; - } - return Math.max(opts.min, Math.trunc(value)); -} - function prepareChunkForMessageBoundary( options: RenderMarkdownIRChunksWithinLimitOptions, chunk: MarkdownIR, diff --git a/packages/memory-host-sdk/src/host/read-file-shared.ts b/packages/memory-host-sdk/src/host/read-file-shared.ts index e7cfae445acb..10aab3e5b942 100644 --- a/packages/memory-host-sdk/src/host/read-file-shared.ts +++ b/packages/memory-host-sdk/src/host/read-file-shared.ts @@ -1,4 +1,5 @@ // Memory Host SDK module implements read file shared behavior. +import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { MemoryReadResult } from "./types.js"; @@ -55,13 +56,6 @@ function fitLinesToCharBudget(params: { lines: string[]; maxChars: number }): { }; } -/** Normalize optional numeric config to a positive integer fallback. */ -function normalizePositiveInteger(value: number | undefined, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) - ? Math.max(1, Math.floor(value)) - : fallback; -} - /** Build a memory read result from an already-selected line slice. */ export function buildMemoryReadResultFromSlice(params: { selectedLines: string[]; @@ -71,10 +65,10 @@ export function buildMemoryReadResultFromSlice(params: { maxChars?: number; suggestReadFallback?: boolean; }): MemoryReadResult { - const start = normalizePositiveInteger(params.startLine, 1); + const start = resolveIntegerOption(params.startLine, 1, { min: 1 }); const fitted = fitLinesToCharBudget({ lines: params.selectedLines, - maxChars: normalizePositiveInteger(params.maxChars, DEFAULT_MEMORY_READ_MAX_CHARS), + maxChars: resolveIntegerOption(params.maxChars, DEFAULT_MEMORY_READ_MAX_CHARS, { min: 1 }), }); const moreSourceLinesRemain = params.moreSourceLinesRemain ?? false; const charCapTruncated = @@ -118,10 +112,11 @@ export function buildMemoryReadResult(params: { if (fileLines.at(-1) === "") { fileLines.pop(); } - const start = normalizePositiveInteger(params.from, 1); - const requestedCount = normalizePositiveInteger( + const start = resolveIntegerOption(params.from, 1, { min: 1 }); + const requestedCount = resolveIntegerOption( params.lines ?? params.defaultLines, DEFAULT_MEMORY_READ_LINES, + { min: 1 }, ); const selectedLines = fileLines.slice(start - 1, start - 1 + requestedCount); const moreSourceLinesRemain = start - 1 + selectedLines.length < fileLines.length; diff --git a/packages/normalization-core/src/error-coercion.test.ts b/packages/normalization-core/src/error-coercion.test.ts index 3ed8b17400a4..a9e0d6f5868f 100644 --- a/packages/normalization-core/src/error-coercion.test.ts +++ b/packages/normalization-core/src/error-coercion.test.ts @@ -5,6 +5,7 @@ import { formatErrorMessage, stringifyNonErrorCause, toErrorObject, + toStringifiedError, } from "./error-coercion.js"; const keepText = (text: string): string => text; @@ -63,6 +64,20 @@ describe("toErrorObject", () => { }); }); +describe("toStringifiedError", () => { + it("preserves Error identity and stringifies every other value", () => { + const error = new Error("boom"); + const objectError = toStringifiedError({ ok: true }); + + expect(toStringifiedError(error)).toBe(error); + expect(toStringifiedError("failure")).toMatchObject({ message: "failure" }); + expect(objectError).toMatchObject({ message: "[object Object]" }); + expect(objectError).not.toHaveProperty("cause"); + expect(objectError).not.toHaveProperty("ok"); + expect(toStringifiedError(null)).toMatchObject({ message: "null" }); + }); +}); + describe("coerceErrorMessage", () => { it("preserves Error messages exactly and stringifies other values", () => { expect(coerceErrorMessage(new Error(""))).toBe(""); diff --git a/packages/normalization-core/src/error-coercion.ts b/packages/normalization-core/src/error-coercion.ts index 0887e2441777..725550cf6c79 100644 --- a/packages/normalization-core/src/error-coercion.ts +++ b/packages/normalization-core/src/error-coercion.ts @@ -125,6 +125,11 @@ export function toErrorObject(value: unknown, fallbackMessage: string): Error { return error; } +/** 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)); +} + /** Reads Error messages unchanged and stringifies every other value. */ export function coerceErrorMessage(value: unknown): string { return value instanceof Error ? value.message : String(value); diff --git a/packages/normalization-core/src/json-coercion.test.ts b/packages/normalization-core/src/json-coercion.test.ts index 6a3952061269..9ab3b013eef3 100644 --- a/packages/normalization-core/src/json-coercion.test.ts +++ b/packages/normalization-core/src/json-coercion.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { safeParseJson } from "./json-coercion.js"; +import { safeParseJson, safeParseJsonRecord } from "./json-coercion.js"; describe("json-coercion", () => { it.each<[string, unknown]>([ @@ -9,4 +9,31 @@ describe("json-coercion", () => { ["null", null], ["{", undefined], ])("parses %s", (value, expected) => expect(safeParseJson(value)).toEqual(expected)); + + const ownProtoRecord = {} as Record; + Object.defineProperty(ownProtoRecord, "__proto__", { + value: { safe: true }, + enumerable: true, + }); + + it.each([ + { name: "an object", value: '{"ok":true}', expected: { ok: true } }, + { name: "null", value: "null", expected: undefined }, + { name: "an array", value: "[1]", expected: undefined }, + { name: "a scalar", value: '"text"', expected: undefined }, + { name: "malformed JSON", value: "{", expected: undefined }, + { + name: "an own __proto__ data key", + value: '{"__proto__":{"safe":true}}', + expected: ownProtoRecord, + }, + ])("parses $name as an optional record", ({ value, expected }) => { + const result = safeParseJsonRecord(value); + + expect(result).toEqual(expected); + if (Object.hasOwn(expected ?? {}, "__proto__")) { + expect(Object.hasOwn(result ?? {}, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + } + }); }); diff --git a/packages/normalization-core/src/json-coercion.ts b/packages/normalization-core/src/json-coercion.ts index e4f8448a944d..1e75317cf1c8 100644 --- a/packages/normalization-core/src/json-coercion.ts +++ b/packages/normalization-core/src/json-coercion.ts @@ -1,3 +1,5 @@ +import { asOptionalRecord } from "./record-coerce.js"; + /** Parses JSON without throwing, returning undefined for invalid input. */ export function safeParseJson(value: string): unknown { try { @@ -6,3 +8,8 @@ export function safeParseJson(value: string): unknown { return undefined; } } + +/** Parses JSON into a non-array record, returning undefined for every other result. */ +export function safeParseJsonRecord(value: string): Record | undefined { + return asOptionalRecord(safeParseJson(value)); +} diff --git a/packages/normalization-core/src/record-coerce.test.ts b/packages/normalization-core/src/record-coerce.test.ts index e8edc04cdafd..5161d5a33d6a 100644 --- a/packages/normalization-core/src/record-coerce.test.ts +++ b/packages/normalization-core/src/record-coerce.test.ts @@ -4,6 +4,7 @@ import { asNonArrayRecord, asNullableRecord, asOptionalRecord, + filterStringRecord, isStringRecord, } from "./record-coerce.js"; @@ -54,4 +55,32 @@ describe("record-coerce", () => { ])("validates all-or-nothing string records", ({ value, expected }) => { expect(isStringRecord(value)).toBe(expected); }); + + const inheritedAndHidden = Object.create({ inherited: "skip" }) as Record; + Object.defineProperty(inheritedAndHidden, "hidden", { value: "skip", enumerable: false }); + Object.assign(inheritedAndHidden, { + blank: "", + ignored: 1, + whitespace: " ", + first: "same", + second: "same", + }); + + it.each([ + { value: null, expected: undefined }, + { value: ["value"], expected: undefined }, + { value: {}, expected: undefined }, + { value: { count: 1, enabled: true }, expected: undefined }, + { + value: inheritedAndHidden, + expected: { blank: "", whitespace: " ", first: "same", second: "same" }, + }, + ])("filters string-valued record entries from $value", ({ value, expected }) => { + const result = filterStringRecord(value); + + expect(result).toEqual(expected); + expect(result ? Object.keys(result) : undefined).toEqual( + expected ? Object.keys(expected) : undefined, + ); + }); }); diff --git a/packages/normalization-core/src/record-coerce.ts b/packages/normalization-core/src/record-coerce.ts index 7bf47cc56c1f..96ea2e554428 100644 --- a/packages/normalization-core/src/record-coerce.ts +++ b/packages/normalization-core/src/record-coerce.ts @@ -46,3 +46,14 @@ export function asNullableObjectRecord(value: unknown): Record export function isStringRecord(value: unknown): value is Record { return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string"); } + +/** Retains string-valued own enumerable entries from a non-array record. */ +export function filterStringRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + const entries = Object.entries(value).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} diff --git a/packages/normalization-core/src/string-normalization.test.ts b/packages/normalization-core/src/string-normalization.test.ts index ef42e0c63cb2..d26103e2e4d0 100644 --- a/packages/normalization-core/src/string-normalization.test.ts +++ b/packages/normalization-core/src/string-normalization.test.ts @@ -1,6 +1,7 @@ // Normalization Core tests cover string normalization behavior. import { describe, expect, it } from "vitest"; import { + filterStringEntries, normalizeAtHashSlug, normalizeHyphenSlug, normalizeOptionalTrimmedStringList, @@ -18,6 +19,18 @@ import { } from "./string-normalization.js"; describe("normalization-core/string-normalization", () => { + it.each([ + { value: undefined, expected: [] }, + { value: "value", expected: [] }, + { value: { 0: "value" }, expected: [] }, + { + value: ["", " ", 1, "first", null, "first", Object("boxed"), "last\n"], + expected: ["", " ", "first", "first", "last\n"], + }, + ])("filters runtime strings from $value", ({ value, expected }) => { + expect(filterStringEntries(value)).toEqual(expected); + }); + it("normalizes mixed allow-list entries", () => { expect(normalizeStringEntries([" a ", 42, "", " ", "z"])).toEqual(["a", "42", "z"]); expect(normalizeStringEntries([" ok ", null, { toString: () => " obj " }])).toEqual([ diff --git a/packages/normalization-core/src/string-normalization.ts b/packages/normalization-core/src/string-normalization.ts index d193ea050578..1e21b5c47543 100644 --- a/packages/normalization-core/src/string-normalization.ts +++ b/packages/normalization-core/src/string-normalization.ts @@ -1,6 +1,13 @@ // Normalization Core module implements string normalization behavior. import { normalizeOptionalLowercaseString, normalizeOptionalString } from "./string-coerce.js"; +/** Retains runtime string entries from arrays without normalizing their contents. */ +export function filterStringEntries(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + /** Coerces entries to strings, trims them, and drops empty results. */ export function normalizeStringEntries(list?: ReadonlyArray) { return (list ?? []).map((entry) => normalizeOptionalString(String(entry)) ?? "").filter(Boolean); diff --git a/packages/tool-call-repair/src/payload.ts b/packages/tool-call-repair/src/payload.ts index d6a70ba9bc12..2494208a2779 100644 --- a/packages/tool-call-repair/src/payload.ts +++ b/packages/tool-call-repair/src/payload.ts @@ -1,3 +1,4 @@ +import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { isOffsetInProtectedRanges, type PlainTextToolCallNameMatcher, @@ -549,15 +550,7 @@ function parseJsonArguments( text: string, payload: PlainTextJsonToolCallSpan, ): Record | null { - let value: unknown; - try { - value = JSON.parse(text.slice(payload.start, payload.end)) as unknown; - } catch { - return null; - } - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; + return safeParseJsonRecord(text.slice(payload.start, payload.end)) ?? null; } function extractXmlishParameterValue( diff --git a/scripts/check-built-plugin-control-plane-modules.mts b/scripts/check-built-plugin-control-plane-modules.mts index 3100cdba8356..e3e9a4ac3dbb 100644 --- a/scripts/check-built-plugin-control-plane-modules.mts +++ b/scripts/check-built-plugin-control-plane-modules.mts @@ -6,13 +6,9 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import ts from "typescript"; +import { isRecord } from "./lib/record-shared.mjs"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; -// The live-updater fixture copies this script without workspace packages. -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - type BuiltPluginControlPlaneModule = { pluginId: string; kind: string; diff --git a/scripts/check-coercion-helper-declarations.mts b/scripts/check-coercion-helper-declarations.mts index 5ed768710294..2c951c4d21b1 100644 --- a/scripts/check-coercion-helper-declarations.mts +++ b/scripts/check-coercion-helper-declarations.mts @@ -10,42 +10,191 @@ import { runWithFailedTrailer } from "./lib/failed-trailer.mts"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { toLine, unwrapExpression } from "./lib/ts-guard-utils.mts"; -export const BANNED_COERCION_HELPER_NAMES = [ +const ABSOLUTE_LEGACY_COERCION_HELPER_NAMES = [ "asObject", - "asFiniteNumber", - "asNonArrayRecord", - "asNonNegativeFiniteNumber", - "asNullableRecord", - "asOptionalRecord", - "asPositiveFiniteNumber", - "asRecord", "asString", - "coerceErrorMessage", - "isRecord", - "isStringRecord", - "normalizeBoundedOptionalString", - "normalizeOptionalLowercaseString", - "normalizeOptionalString", "normalizeString", "optionalString", - "parseBooleanValue", - "parseDateFirstTimestampMs", - "parseDateStringTimestampMs", - "parseFiniteNumber", "readBoolean", - "readNonBlankString", - "readNonEmptyStringPreservingWhitespace", "readNumber", "readOptionalString", "readString", - "readStringField", - "readStringValue", "timestampMs", - "toError", - "toLintErrorObject", - "toErrorObject", ] as const; -export type BannedCoercionHelperName = (typeof BANNED_COERCION_HELPER_NAMES)[number]; + +export type CoercionHelperDeclarationKind = + | "field" + | "function" + | "method" + | "property" + | "variable"; + +export const CANONICAL_COERCION_HELPER_OWNERS = [ + { + file: "packages/normalization-core/src/string-coerce.ts", + kind: "function", + names: [ + "lowercasePreservingWhitespace", + "localeLowercasePreservingWhitespace", + "normalizeBoundedOptionalString", + "normalizeFastMode", + "normalizeNullableString", + "normalizeOptionalLowercaseString", + "normalizeOptionalString", + "normalizeOptionalStringifiedId", + "normalizeStringifiedEntries", + "readNonBlankString", + "readNonEmptyStringPreservingWhitespace", + "readStringValue", + "resolvePrimaryStringValue", + ], + }, + { + file: "packages/normalization-core/src/string-normalization.ts", + kind: "function", + names: ["filterStringEntries"], + }, + { + file: "packages/normalization-core/src/number-coercion.ts", + kind: "function", + names: [ + "addTimerTimeoutGraceMs", + "asDateTimestampMs", + "asFiniteNumber", + "asFiniteNumberInRange", + "asNonNegativeFiniteNumber", + "asPositiveFiniteNumber", + "asPositiveSafeInteger", + "asSafeIntegerInRange", + "clampPositiveTimerTimeoutMs", + "finiteSecondsToTimerSafeMilliseconds", + "isFutureDateTimestampMs", + "nonNegativeSecondsToSafeMilliseconds", + "parseDateFirstTimestampMs", + "parseDateStringTimestampMs", + "parseFiniteNumber", + "parseStrictFiniteNumber", + "parseStrictInteger", + "parseStrictNonNegativeInteger", + "positiveSecondsToSafeMilliseconds", + "resolveDateTimestampMs", + "resolveExpiresAtMsFromDurationMs", + "resolveExpiresAtMsFromDurationOrEpoch", + "resolveExpiresAtMsFromDurationSeconds", + "resolveExpiresAtMsFromEpochSeconds", + "resolveIntegerOption", + "resolveNonNegativeIntegerOption", + "resolveOptionalIntegerOption", + "resolvePositiveTimerTimeoutMs", + "resolveTimestampMsToIsoString", + "timestampMsToIsoFileStamp", + "timestampMsToIsoString", + ], + }, + { + file: "packages/normalization-core/src/record-coerce.ts", + kind: "function", + names: [ + "asNonArrayRecord", + "asNullableObjectRecord", + "asNullableRecord", + "asOptionalObjectRecord", + "asOptionalRecord", + "asRecord", + "filterStringRecord", + "isRecord", + "isStringRecord", + "readStringField", + ], + }, + { + file: "packages/normalization-core/src/json-coercion.ts", + kind: "function", + names: ["safeParseJsonRecord"], + }, + { + file: "packages/normalization-core/src/error-coercion.ts", + kind: "function", + names: ["coerceErrorMessage", "stringifyNonErrorCause", "toErrorObject", "toStringifiedError"], + }, + { + file: "scripts/lib/error-format.mts", + kind: "function", + names: ["coerceErrorMessage", "toErrorObject"], + }, + { + file: "src/utils/boolean.ts", + kind: "function", + names: ["parseBooleanValue"], + }, +] as const satisfies readonly { + file: string; + kind: CoercionHelperDeclarationKind; + names: readonly string[]; +}[]; + +const EXCEPTIONAL_COERCION_HELPER_CARVE_OUTS = [ + { + file: "ui/src/test-helpers/control-ui-e2e.ts", + name: "isRecord", + kind: "function", + count: 1, + reason: "Serialized mock Gateway closure cannot capture module imports.", + }, + { + file: "scripts/lib/kova-report-gate.mts", + name: "isRecord", + kind: "function", + count: 1, + reason: "Copied standalone report gate cannot rely on workspace package resolution.", + }, + { + file: "scripts/lib/record-shared.mjs", + name: "isRecord", + kind: "function", + count: 1, + reason: "Plain-Node shared helper serves MJS and E2E callers without package resolution.", + }, + { + file: "scripts/pr-lib/process-group-runner.mjs", + name: "toError", + kind: "function", + count: 1, + reason: + "Bootstrap process supervisor preserves fallback errors without workspace dependencies.", + }, + { + file: "scripts/lib/bounded-response.mjs", + name: "toLintErrorObject", + kind: "function", + count: 1, + reason: "Standalone copied response reader cannot resolve workspace packages.", + }, +] as const satisfies readonly { + count: number; + file: string; + kind: CoercionHelperDeclarationKind; + name: string; + reason: string; +}[]; + +type CanonicalCoercionHelperName = + (typeof CANONICAL_COERCION_HELPER_OWNERS)[number]["names"][number]; +type AbsoluteLegacyCoercionHelperName = (typeof ABSOLUTE_LEGACY_COERCION_HELPER_NAMES)[number]; +type ExceptionalCoercionHelperName = + (typeof EXCEPTIONAL_COERCION_HELPER_CARVE_OUTS)[number]["name"]; +export type BannedCoercionHelperName = + | AbsoluteLegacyCoercionHelperName + | CanonicalCoercionHelperName + | ExceptionalCoercionHelperName; + +export const BANNED_COERCION_HELPER_NAMES: readonly BannedCoercionHelperName[] = [ + ...new Set([ + ...ABSOLUTE_LEGACY_COERCION_HELPER_NAMES, + ...CANONICAL_COERCION_HELPER_OWNERS.flatMap(({ names }) => names), + ...EXCEPTIONAL_COERCION_HELPER_CARVE_OUTS.map(({ name }) => name), + ]), +]; const BANNED_HELPER_NAMES: ReadonlySet = new Set(BANNED_COERCION_HELPER_NAMES); // One tracked-tree scan covers root configs plus config, Actions, skills, apps, plugins, and packages. const SCAN_ROOTS = ["."]; @@ -54,7 +203,7 @@ const GENERATED_OR_FIXTURE_PATH_RE = export type CoercionHelperDeclaration = { file: string; - kind: "field" | "function" | "method" | "property" | "variable"; + kind: CoercionHelperDeclarationKind; line: number; name: BannedCoercionHelperName; }; @@ -62,16 +211,17 @@ export type CoercionHelperDeclaration = { export type CoercionHelperCarveOut = { count: number; file: string; + kind: CoercionHelperDeclarationKind; name: BannedCoercionHelperName; reason: string; }; function canonicalOwnerCarveOuts( - file: string, - names: readonly BannedCoercionHelperName[], + owner: (typeof CANONICAL_COERCION_HELPER_OWNERS)[number], ): CoercionHelperCarveOut[] { - return names.map((name) => ({ - file, + return owner.names.map((name) => ({ + file: owner.file, + kind: owner.kind, name, count: 1, reason: "Canonical coercion helper owned by this module.", @@ -79,118 +229,12 @@ function canonicalOwnerCarveOuts( } export const COERCION_HELPER_CARVE_OUTS: readonly CoercionHelperCarveOut[] = [ - ...canonicalOwnerCarveOuts("packages/normalization-core/src/string-coerce.ts", [ - "normalizeBoundedOptionalString", - "normalizeOptionalLowercaseString", - "normalizeOptionalString", - "readNonBlankString", - "readNonEmptyStringPreservingWhitespace", - "readStringValue", - ]), - ...canonicalOwnerCarveOuts("packages/normalization-core/src/number-coercion.ts", [ - "asFiniteNumber", - "asNonNegativeFiniteNumber", - "asPositiveFiniteNumber", - "parseDateFirstTimestampMs", - "parseDateStringTimestampMs", - "parseFiniteNumber", - ]), - ...canonicalOwnerCarveOuts("packages/normalization-core/src/record-coerce.ts", [ - "asNonArrayRecord", - "asNullableRecord", - "asOptionalRecord", - "asRecord", - "isRecord", - "isStringRecord", - "readStringField", - ]), - ...canonicalOwnerCarveOuts("packages/normalization-core/src/error-coercion.ts", [ - "coerceErrorMessage", - "toErrorObject", - ]), - ...canonicalOwnerCarveOuts("scripts/lib/error-format.mts", [ - "coerceErrorMessage", - "toErrorObject", - ]), - ...canonicalOwnerCarveOuts("src/utils/boolean.ts", ["parseBooleanValue"]), - { - file: "ui/src/test-helpers/control-ui-e2e.ts", - name: "isRecord", - count: 1, - reason: "Serialized mock Gateway closure cannot capture module imports.", - }, - { - file: "scripts/check-built-plugin-control-plane-modules.mts", - name: "isRecord", - count: 1, - reason: "Copied standalone build guard cannot rely on workspace package resolution.", - }, - { - file: "scripts/copy-bundled-plugin-metadata.mts", - name: "isRecord", - count: 1, - reason: "Copied standalone metadata closure cannot rely on workspace package resolution.", - }, - { - file: "scripts/lib/kova-report-gate.mts", - name: "isRecord", - count: 1, - reason: "Copied standalone report gate cannot rely on workspace package resolution.", - }, - { - file: "scripts/lib/plugin-npm-package-manifest.mts", - name: "isRecord", - count: 1, - reason: "Copied standalone package-manifest closure cannot resolve workspace packages.", - }, - { - file: "scripts/lib/record-shared.mjs", - name: "isRecord", - count: 1, - reason: "Plain-Node shared helper serves MJS and E2E callers without package resolution.", - }, - { - file: "scripts/lib/static-extension-assets.mts", - name: "asRecord", - count: 1, - reason: "Copied standalone asset closure cannot rely on workspace package resolution.", - }, - { - file: "scripts/pr-lib/process-group-runner.mjs", - name: "toError", - count: 1, - reason: - "Bootstrap process supervisor preserves fallback errors without workspace dependencies.", - }, - { - file: "scripts/lib/bounded-response.mjs", - name: "toLintErrorObject", - count: 1, - reason: "Standalone copied response reader cannot resolve workspace packages.", - }, - { - file: "scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs", - name: "toLintErrorObject", - count: 1, - reason: "Installed-image runtime smoke runs as a copied standalone closure.", - }, - { - file: "scripts/e2e/lib/openai-web-search-minimal/client.mjs", - name: "toLintErrorObject", - count: 1, - reason: "Minimal copied E2E client runs without workspace package resolution.", - }, - { - file: "scripts/stage-bundled-plugin-runtime.mts", - name: "isRecord", - count: 1, - reason: "Copied standalone runtime-staging closure cannot resolve workspace packages.", - }, + ...CANONICAL_COERCION_HELPER_OWNERS.flatMap(canonicalOwnerCarveOuts), + ...EXCEPTIONAL_COERCION_HELPER_CARVE_OUTS, ]; type CarveOutMismatch = CoercionHelperCarveOut & { actualCount: number; - lines: number[]; }; type CoercionHelperAudit = { @@ -204,8 +248,16 @@ type ScriptIo = { stdout: { write(value: string): unknown }; }; -function carveOutKey(entry: Pick) { - return `${entry.file}\0${entry.name}`; +const COERCION_HELPER_DECLARATION_KINDS = new Set([ + "field", + "function", + "method", + "property", + "variable", +]); + +function carveOutKey(entry: Pick) { + return `${entry.file}\0${entry.name}\0${entry.kind}`; } function unwrapCallableInitializer(expression: ts.Expression) { @@ -349,6 +401,11 @@ export function auditCoercionHelperDeclarations( if (!BANNED_HELPER_NAMES.has(carveOut.name)) { invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] is not a banned helper name`); } + if (!COERCION_HELPER_DECLARATION_KINDS.has(carveOut.kind)) { + invalidCarveOuts.push( + `${carveOut.file} [${carveOut.name}] has invalid kind ${carveOut.kind}`, + ); + } if (!Number.isInteger(carveOut.count) || carveOut.count < 1) { invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] must have a positive count`); } @@ -380,7 +437,6 @@ export function auditCoercionHelperDeclarations( ? { ...carveOut, actualCount: actual.length, - lines: actual.map((entry) => entry.line), } : null; }) @@ -457,7 +513,7 @@ export function runCoercionHelperDeclarationGuard( for (const carveOut of audit.staleCarveOuts) { writeLine( io.stderr, - `- ${carveOut.file} [${carveOut.name}] expected ${carveOut.count}, found ${carveOut.actualCount}; remove or reduce the carve-out`, + `- ${carveOut.file} [${carveOut.name}] expected ${carveOut.count} ${carveOut.kind} declaration(s), found ${carveOut.actualCount}; remove or reduce the carve-out`, ); } } diff --git a/scripts/check-extension-package-tsc-boundary.mts b/scripts/check-extension-package-tsc-boundary.mts index 7ea98db27731..12d77bfa0fa6 100644 --- a/scripts/check-extension-package-tsc-boundary.mts +++ b/scripts/check-extension-package-tsc-boundary.mts @@ -16,6 +16,10 @@ import { createRequire } from "node:module"; import os from "node:os"; import path, { dirname, join, resolve } from "node:path"; import pMap from "p-map"; +import { + MAX_TIMER_TIMEOUT_MS, + resolveTimerTimeoutMs, +} from "../packages/normalization-core/src/number-coercion.ts"; import { toErrorObject } from "./lib/error-format.mts"; import { parsePositiveInt } from "./lib/numeric-options.mjs"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; @@ -25,10 +29,6 @@ import { shouldUseDetachedVitestProcessGroup, } from "./vitest-process-group.mts"; -function coerceBoundaryError(value: unknown, fallbackMessage: string): Error { - return toErrorObject(value, fallbackMessage); -} - type BoundaryMode = "all" | "compile" | "canary"; type StepOutputCapture = { text: string; truncatedChars: number }; type CompileTiming = { extensionId: string; elapsedMs: number }; @@ -113,7 +113,6 @@ const FAILURE_OUTPUT_TAIL_LINES = 40; const STEP_OUTPUT_MAX_CHARS = 256 * 1024; const STEP_PROCESS_GROUP_EXIT_POLL_MS = 25; const STEP_POST_FORCE_KILL_WAIT_MS = 1_000; -const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; const SLOW_COMPILE_SUMMARY_LIMIT = 10; const COMPILE_INPUT_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".json"]); const ROOTDIR_BOUNDARY_CANARY_IMPORT_PATH = @@ -432,15 +431,8 @@ function writeStampFile(filePath: string) { writeFileSync(filePath, `${new Date().toISOString()}\n`, "utf8"); } -function resolveStepTimerTimeoutMs(valueMs: number) { - if (!Number.isFinite(valueMs)) { - return MAX_TIMER_TIMEOUT_MS; - } - return Math.min(Math.max(Math.floor(valueMs), 1), MAX_TIMER_TIMEOUT_MS); -} - function runNodeStep(label: string, args: string[], timeoutMs: number) { - const resolvedTimeoutMs = resolveStepTimerTimeoutMs(timeoutMs); + const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, MAX_TIMER_TIMEOUT_MS); const startedAt = Date.now(); const result = spawnSync(process.execPath, args, { cwd: repoRoot, @@ -498,7 +490,7 @@ export function runNodeStepAsync( timeoutMs: number, params: RunNodeStepParams = {}, ) { - const resolvedTimeoutMs = resolveStepTimerTimeoutMs(timeoutMs); + const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, MAX_TIMER_TIMEOUT_MS); const abortController = params.abortController; const killProcess = params.killProcess ?? process.kill.bind(process); const onFailure = params.onFailure; @@ -567,7 +559,7 @@ export function runNodeStepAsync( signalChild("SIGKILL"); await waitAfterForceKill(); rejectPromise( - coerceBoundaryError( + toErrorObject( attachStepFailureMetadata(new Error(`${label} canceled after sibling failure`), label, { kind: "canceled", elapsedMs: Date.now() - startedAt, @@ -626,7 +618,7 @@ export function runNodeStepAsync( ); onFailure?.(error); abortSiblingSteps(abortController); - rejectPromise(coerceBoundaryError(error, "Step timed out")); + rejectPromise(toErrorObject(error, "Step timed out")); })(); }, resolvedTimeoutMs); @@ -671,7 +663,7 @@ export function runNodeStepAsync( ); onFailure?.(failure); abortSiblingSteps(abortController); - rejectPromise(coerceBoundaryError(failure, "Step spawn failed")); + rejectPromise(toErrorObject(failure, "Step spawn failed")); }); child.on("close", (code) => { if (settled) { @@ -720,7 +712,7 @@ export function runNodeStepAsync( ); onFailure?.(error); abortSiblingSteps(abortController); - rejectPromise(coerceBoundaryError(error, "Step failed")); + rejectPromise(toErrorObject(error, "Step failed")); }); }); } @@ -755,7 +747,7 @@ export async function runNodeStepsWithConcurrency(steps: BoundaryStep[], concurr { concurrency, stopOnError: false }, ); if (firstFailure) { - throw coerceBoundaryError(firstFailure, "Non-Error thrown"); + throw toErrorObject(firstFailure, "Non-Error thrown"); } } diff --git a/scripts/check-extension-plugin-sdk-boundary.mts b/scripts/check-extension-plugin-sdk-boundary.mts index 694384f266d7..4c5450b9c4c4 100644 --- a/scripts/check-extension-plugin-sdk-boundary.mts +++ b/scripts/check-extension-plugin-sdk-boundary.mts @@ -19,8 +19,12 @@ import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mts"; import { runAsScript } from "./lib/ts-guard-utils.mts"; -const repoRoot = resolveRepoRoot(import.meta.url); -type BoundaryMode = "src-outside-plugin-sdk" | "plugin-sdk-internal" | "relative-outside-package"; +const DEFAULT_REPO_ROOT = resolveRepoRoot(import.meta.url); +type BoundaryMode = + | "src-outside-plugin-sdk" + | "plugin-sdk-internal" + | "relative-outside-package" + | "normalization-core-bypass"; type ModuleReference = { kind: string; line: number; specifier: string }; type BoundaryEntry = ModuleReference & { file: string; resolvedPath: string; reason: string }; type CollectedBoundaryEntry = { mode: BoundaryMode; entry: BoundaryEntry }; @@ -30,34 +34,13 @@ type BoundaryCheckIo = { stderr: { write(chunk: string): unknown }; }; -// Generated bundles are validated at their build owner; they are not bounded authored source. -const generatedExtensionAssetSources = new Set( - listGeneratedExtensionAssetSources({ rootDir: repoRoot }), -); - const MODES = new Set([ "src-outside-plugin-sdk", "plugin-sdk-internal", "relative-outside-package", + "normalization-core-bypass", ]); -const baselinePathByMode = { - "src-outside-plugin-sdk": path.join( - repoRoot, - "test", - "fixtures", - "extension-src-outside-plugin-sdk-inventory.json", - ), - "plugin-sdk-internal": path.join( - repoRoot, - "test", - "fixtures", - "extension-plugin-sdk-internal-inventory.json", - ), -} satisfies Partial>; -type BaselineBoundaryMode = keyof typeof baselinePathByMode; - -let allInventoryByModePromise: Promise | undefined; const ruleTextByMode: Record = { "src-outside-plugin-sdk": "Rule: production bundled plugins must not import src/** outside src/plugin-sdk/**", @@ -65,8 +48,76 @@ const ruleTextByMode: Record = { "Rule: production bundled plugins must not import src/plugin-sdk-internal/**", "relative-outside-package": "Rule: production bundled plugins must not use relative imports that escape their own package root", + "normalization-core-bypass": + "Rule: production bundled plugins must not import normalization-core directly; use the matching openclaw/plugin-sdk coercion runtime", }; +type BaselineBoundaryMode = "plugin-sdk-internal" | "src-outside-plugin-sdk"; +const NORMALIZATION_CORE_PACKAGE = "@openclaw/normalization-core"; +const NORMALIZATION_CORE_ROOT = "packages/normalization-core"; +const DIRECT_COERCION_OWNER_PATHS = new Set(["src/infra/errors", "src/utils/boolean"]); + +function baselinePathForMode(repoRoot: string, mode: BaselineBoundaryMode): string { + const fileName = + mode === "src-outside-plugin-sdk" + ? "extension-src-outside-plugin-sdk-inventory.json" + : "extension-plugin-sdk-internal-inventory.json"; + return path.join(repoRoot, "test", "fixtures", fileName); +} + +function stripModuleExtension(filePath: string): string { + return filePath.replace(/\.(?:[cm]?[jt]s|tsx|jsx)$/u, ""); +} + +function resolveBoundarySpecifier(repoRoot: string, specifier: string, importerFile: string) { + if (specifier === NORMALIZATION_CORE_PACKAGE) { + return `${NORMALIZATION_CORE_ROOT}/src/index.ts`; + } + if (specifier.startsWith(`${NORMALIZATION_CORE_PACKAGE}/`)) { + const subpath = specifier.slice(NORMALIZATION_CORE_PACKAGE.length + 1); + return `${NORMALIZATION_CORE_ROOT}/src/${stripModuleExtension(subpath)}.ts`; + } + return resolveRepoSpecifier(repoRoot, specifier, importerFile); +} + +function isNormalizationCoreBypass(specifier: string, resolvedPath: string | null): boolean { + if ( + specifier === NORMALIZATION_CORE_PACKAGE || + specifier.startsWith(`${NORMALIZATION_CORE_PACKAGE}/`) + ) { + return true; + } + if (!resolvedPath) { + return false; + } + const ownerPath = stripModuleExtension(resolvedPath); + return ( + ownerPath === NORMALIZATION_CORE_ROOT || + ownerPath.startsWith(`${NORMALIZATION_CORE_ROOT}/`) || + DIRECT_COERCION_OWNER_PATHS.has(ownerPath) + ); +} + +function recommendedCoercionFacade(resolvedPath: string): string | undefined { + const ownerPath = stripModuleExtension(resolvedPath); + if ( + ownerPath.endsWith("/string-coerce") || + ownerPath.endsWith("/string-normalization") || + ownerPath.endsWith("/record-coerce") || + ownerPath.endsWith("/boolean-coercion") || + ownerPath === "src/utils/boolean" + ) { + return "openclaw/plugin-sdk/string-coerce-runtime"; + } + if (ownerPath.endsWith("/number-coercion")) { + return "openclaw/plugin-sdk/number-runtime"; + } + if (ownerPath.endsWith("/error-coercion") || ownerPath === "src/infra/errors") { + return "openclaw/plugin-sdk/error-runtime"; + } + return undefined; +} + function classifyReason(mode: BoundaryMode, kind: string, resolved: string, specifier: string) { const verb = kind === "export" @@ -74,6 +125,12 @@ function classifyReason(mode: BoundaryMode, kind: string, resolved: string, spec : kind === "dynamic-import" ? "dynamically imports" : "imports"; + if (mode === "normalization-core-bypass") { + const facade = recommendedCoercionFacade(resolved); + return facade + ? `${verb} ${specifier} directly; plugin production code must use ${facade}` + : `${verb} ${specifier} directly; plugin production code must use the matching public openclaw/plugin-sdk facade, adding a narrow public SDK seam if needed`; + } if (mode === "relative-outside-package") { if (resolved.startsWith("src/plugin-sdk/")) { return `${verb} plugin-sdk via relative path; use openclaw/plugin-sdk/`; @@ -122,117 +179,19 @@ function isBoundaryEntryArray(value: unknown): value is BoundaryEntry[] { return Array.isArray(value) && value.every(isBoundaryEntry); } -const collectBoundaryEntries: NonNullable< - Parameters< - typeof createExtensionImportBoundaryChecker - >[0]["collectEntries"] -> = ({ filePath, relativeFile, references }) => { - const extensionRoot = relativeFile.split("/").slice(0, 2).join("/"); - const entries: CollectedBoundaryEntry[] = []; - for (const { kind, line, specifier } of references) { - const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath); - if (!resolvedPath) { - continue; - } - const modes: BoundaryMode[] = []; - if ( - specifier.startsWith(".") && - resolvedPath !== extensionRoot && - !resolvedPath.startsWith(extensionRoot + "/") - ) { - modes.push("relative-outside-package"); - } - if (resolvedPath.startsWith("src/") && !resolvedPath.startsWith("src/plugin-sdk/")) { - modes.push("src-outside-plugin-sdk"); - } - if (resolvedPath.startsWith("src/plugin-sdk-internal/")) { - modes.push("plugin-sdk-internal"); - } - for (const mode of modes) { - entries.push({ - mode, - entry: { - file: relativeFile, - line, - kind, - specifier, - resolvedPath, - reason: classifyReason(mode, kind, resolvedPath, specifier), - }, - }); - } - } - return entries; -}; - -const extensionBoundaryChecker = createExtensionImportBoundaryChecker({ - roots: [BUNDLED_PLUGIN_ROOT_DIR], - sourceOptions: { - fileExtensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], - includeTests: true, - skipDirectories: ["dist"], - }, - shouldSkipFile(relativeFile) { - return ( - generatedExtensionAssetSources.has(relativeFile) || - path.basename(relativeFile).includes("__rootdir_boundary_canary__") || - classifyBundledExtensionSourcePath(relativeFile).isTestLike - ); - }, - acceptSpecifier(specifier, { relativeFile, resolvedPath }) { - if (!resolvedPath) { - return false; - } - const extensionRoot = relativeFile.split("/").slice(0, 2).join("/"); - return ( - resolvedPath.startsWith("src/") || - (specifier.startsWith(".") && - resolvedPath !== extensionRoot && - !resolvedPath.startsWith(extensionRoot + "/")) - ); - }, - collectEntries: collectBoundaryEntries, - compareEntries: (left, right) => compareEntries(left.entry, right.entry), -}); - -/** Collect the current extension plugin SDK boundary inventory. */ -async function collectExtensionPluginSdkBoundaryInventory(mode: BoundaryMode) { - if (!MODES.has(mode)) { - throw new Error("Unknown mode: " + mode); - } - allInventoryByModePromise ??= extensionBoundaryChecker - .collectInventory() - .then((entries) => - Object.fromEntries( - [...MODES].map((inventoryMode) => [ - inventoryMode, - entries - .filter(({ mode: entryMode }) => entryMode === inventoryMode) - .map(({ entry }) => entry), - ]), - ), - ); - return (await allInventoryByModePromise)[mode] ?? []; -} - -/** - * Reads the checked-in expected boundary inventory. - */ -export async function readExpectedInventory(mode: BaselineBoundaryMode): Promise { +async function readExpectedInventoryAtRoot( + repoRoot: string, + mode: BaselineBoundaryMode, +): Promise { + const baselinePath = baselinePathForMode(repoRoot, mode); try { - const inventory: unknown = JSON.parse(await fs.readFile(baselinePathByMode[mode], "utf8")); + const inventory: unknown = JSON.parse(await fs.readFile(baselinePath, "utf8")); if (!isBoundaryEntryArray(inventory)) { - throw new Error(`Invalid boundary inventory: ${baselinePathByMode[mode]}`); + throw new Error(`Invalid boundary inventory: ${baselinePath}`); } return inventory; } catch (error) { - if ( - (mode === "plugin-sdk-internal" || mode === "src-outside-plugin-sdk") && - error && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { return []; } throw error; @@ -265,64 +224,183 @@ const formatInventoryHuman = (mode: BoundaryMode, inventory: BoundaryEntry[]): s inventory, ); -/** - * Runs the boundary inventory check with CLI-style inputs and outputs. - */ -async function runExtensionPluginSdkBoundaryCheck(argv?: string[], io?: BoundaryCheckIo) { - const args = argv ?? process.argv.slice(2); - const streams = io ?? { stdout: process.stdout, stderr: process.stderr }; - const json = args.includes("--json"); - const modeArg = args.find((arg) => arg.startsWith("--mode=")); - const modeValue = modeArg?.slice("--mode=".length) ?? "src-outside-plugin-sdk"; - const mode = [...MODES].find((candidate) => candidate === modeValue); - if (!mode) { - throw new Error(`Unknown mode: ${modeValue}`); +/** Creates the extension boundary guard for the repository or an isolated fixture root. */ +export function createExtensionPluginSdkBoundaryChecker(options: { repoRoot?: string } = {}) { + const repoRoot = path.resolve(options.repoRoot ?? DEFAULT_REPO_ROOT); + // Generated bundles are validated at their build owner; they are not bounded authored source. + const generatedExtensionAssetSources = new Set( + listGeneratedExtensionAssetSources({ rootDir: repoRoot }), + ); + const collectBoundaryEntries: NonNullable< + Parameters< + typeof createExtensionImportBoundaryChecker + >[0]["collectEntries"] + > = ({ filePath, relativeFile, references }) => { + const extensionRoot = relativeFile.split("/").slice(0, 2).join("/"); + const entries: CollectedBoundaryEntry[] = []; + for (const { kind, line, specifier } of references) { + const resolvedPath = resolveBoundarySpecifier(repoRoot, specifier, filePath); + if (!resolvedPath) { + continue; + } + const modes: BoundaryMode[] = []; + if ( + specifier.startsWith(".") && + resolvedPath !== extensionRoot && + !resolvedPath.startsWith(extensionRoot + "/") + ) { + modes.push("relative-outside-package"); + } + if (resolvedPath.startsWith("src/") && !resolvedPath.startsWith("src/plugin-sdk/")) { + modes.push("src-outside-plugin-sdk"); + } + if (resolvedPath.startsWith("src/plugin-sdk-internal/")) { + modes.push("plugin-sdk-internal"); + } + if (isNormalizationCoreBypass(specifier, resolvedPath)) { + modes.push("normalization-core-bypass"); + } + for (const mode of modes) { + entries.push({ + mode, + entry: { + file: relativeFile, + line, + kind, + specifier, + resolvedPath, + reason: classifyReason(mode, kind, resolvedPath, specifier), + }, + }); + } + } + return entries; + }; + const extensionBoundaryChecker = createExtensionImportBoundaryChecker({ + repoRoot, + roots: [BUNDLED_PLUGIN_ROOT_DIR], + sourceOptions: { + fileExtensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], + includeTests: true, + skipDirectories: ["dist"], + }, + shouldSkipFile(relativeFile) { + return ( + generatedExtensionAssetSources.has(relativeFile) || + path.basename(relativeFile).includes("__rootdir_boundary_canary__") || + classifyBundledExtensionSourcePath(relativeFile).isTestLike + ); + }, + acceptSpecifier(specifier, { relativeFile, resolvedPath }) { + if ( + specifier === NORMALIZATION_CORE_PACKAGE || + specifier.startsWith(`${NORMALIZATION_CORE_PACKAGE}/`) + ) { + return true; + } + if (!resolvedPath) { + return false; + } + const extensionRoot = relativeFile.split("/").slice(0, 2).join("/"); + return ( + resolvedPath.startsWith("src/") || + resolvedPath.startsWith(`${NORMALIZATION_CORE_ROOT}/`) || + (specifier.startsWith(".") && + resolvedPath !== extensionRoot && + !resolvedPath.startsWith(extensionRoot + "/")) + ); + }, + collectEntries: collectBoundaryEntries, + compareEntries: (left, right) => compareEntries(left.entry, right.entry), + }); + let allInventoryByModePromise: Promise | undefined; + + async function collectInventory(mode: BoundaryMode) { + if (!MODES.has(mode)) { + throw new Error("Unknown mode: " + mode); + } + allInventoryByModePromise ??= extensionBoundaryChecker + .collectInventory() + .then((entries) => + Object.fromEntries( + [...MODES].map((inventoryMode) => [ + inventoryMode, + entries + .filter(({ mode: entryMode }) => entryMode === inventoryMode) + .map(({ entry }) => entry), + ]), + ), + ); + return (await allInventoryByModePromise)[mode] ?? []; } - const actual = await collectExtensionPluginSdkBoundaryInventory(mode); - if (json) { - writeLine(streams.stdout, JSON.stringify(actual, null, 2)); - return 0; - } + async function run( + argv: string[] = process.argv.slice(2), + streams: BoundaryCheckIo = { stdout: process.stdout, stderr: process.stderr }, + ): Promise<0 | 1> { + const json = argv.includes("--json"); + const modeArg = argv.find((arg) => arg.startsWith("--mode=")); + const modeValue = modeArg?.slice("--mode=".length) ?? "src-outside-plugin-sdk"; + const mode = [...MODES].find((candidate) => candidate === modeValue); + if (!mode) { + throw new Error(`Unknown mode: ${modeValue}`); + } - writeLine(streams.stdout, formatInventoryHuman(mode, actual)); - if (mode === "relative-outside-package") { - if (actual.length === 0) { + const actual = await collectInventory(mode); + const strictMode = mode === "normalization-core-bypass" || mode === "relative-outside-package"; + if (json) { + writeLine(streams.stdout, JSON.stringify(actual, null, 2)); + return strictMode && actual.length > 0 ? 1 : 0; + } + + writeLine(streams.stdout, formatInventoryHuman(mode, actual)); + if (strictMode) { + if (actual.length === 0) { + return 0; + } + writeLine( + streams.stderr, + `${ruleTextByMode[mode]} violations found (${actual.length}); this strict mode has no baseline.`, + ); + return 1; + } + + const expected = await readExpectedInventoryAtRoot(repoRoot, mode); + const diff = diffInventory(expected, actual); + if (diff.missing.length === 0 && diff.unexpected.length === 0) { + writeLine(streams.stdout, `Baseline matches (${actual.length} entries).`); return 0; } - writeLine( - streams.stderr, - `Relative outside-package violations found (${actual.length}); this mode no longer uses a baseline.`, - ); + if (diff.missing.length > 0) { + writeLine(streams.stderr, `Missing baseline entries (${diff.missing.length}):`); + for (const entry of diff.missing) { + writeLine(streams.stderr, ` - ${entry.file}:${entry.line} ${entry.reason}`); + } + } + if (diff.unexpected.length > 0) { + writeLine(streams.stderr, `Unexpected inventory entries (${diff.unexpected.length}):`); + for (const entry of diff.unexpected) { + writeLine(streams.stderr, ` - ${entry.file}:${entry.line} ${entry.reason}`); + } + } return 1; } - const expected = await readExpectedInventory(mode); - const diff = diffInventory(expected, actual); - if (diff.missing.length === 0 && diff.unexpected.length === 0) { - writeLine(streams.stdout, `Baseline matches (${actual.length} entries).`); - return 0; - } - if (diff.missing.length > 0) { - writeLine(streams.stderr, `Missing baseline entries (${diff.missing.length}):`); - for (const entry of diff.missing) { - writeLine(streams.stderr, ` - ${entry.file}:${entry.line} ${entry.reason}`); - } - } - if (diff.unexpected.length > 0) { - writeLine(streams.stderr, `Unexpected inventory entries (${diff.unexpected.length}):`); - for (const entry of diff.unexpected) { - writeLine(streams.stderr, ` - ${entry.file}:${entry.line} ${entry.reason}`); - } - } - return 1; + return { collectInventory, main: run }; +} + +const defaultBoundaryChecker = createExtensionPluginSdkBoundaryChecker(); + +/** Reads the checked-in expected boundary inventory from the real repository. */ +export async function readExpectedInventory(mode: BaselineBoundaryMode): Promise { + return await readExpectedInventoryAtRoot(DEFAULT_REPO_ROOT, mode); } /** * Entrypoint wrapper for the extension plugin SDK boundary check. */ export async function main(argv?: string[], io?: BoundaryCheckIo): Promise<0 | 1> { - const exitCode = await runExtensionPluginSdkBoundaryCheck(argv, io); + const exitCode = await defaultBoundaryChecker.main(argv, io); if (!io) { process.exitCode = exitCode; } diff --git a/scripts/check-memory-fd-repro.mts b/scripts/check-memory-fd-repro.mts index 1d69d2798731..047b647d8da1 100644 --- a/scripts/check-memory-fd-repro.mts +++ b/scripts/check-memory-fd-repro.mts @@ -8,7 +8,8 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { resolveTimerTimeoutMs } from "../packages/normalization-core/src/number-coercion.ts"; +import { asNullableRecord as asRecord } from "../packages/normalization-core/src/record-coerce.ts"; import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mts"; import { readBoundedResponseText } from "./lib/bounded-response.mjs"; @@ -51,7 +52,6 @@ type InvokeResponseOptions = { httpOk: boolean; status: number; bodyText: string const ISSUE_MEMORY_FILE_COUNT = ISSUE_FILE_COUNTS.reduce((sum, [, count]) => sum + count, 0); const DEFAULT_FILE_COUNT = 512; const DEFAULT_MAX_WORKSPACE_REG_FDS = process.platform === "darwin" ? 8 : 64; -const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; /** * Maximum gateway-ready output tail retained while waiting for startup. */ @@ -159,22 +159,16 @@ function readPositiveNumberEnv(name: string, fallback: number) { return raw == null || raw.trim() === "" ? fallback : readPositiveNumber(raw, name); } -function clampTimerTimeoutMs(valueMs: number, 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 readTimerTimeoutNumber(value: unknown, label: string, minMs = 1) { const parsed = minMs > 0 ? readPositiveNumber(value, label) : parseNonNegativeInteger(value, label); - return clampTimerTimeoutMs(parsed, minMs); + return resolveTimerTimeoutMs(parsed, minMs, minMs); } function readTimerTimeoutNumberEnv(name: string, fallback: number, minMs = 1) { const raw = process.env[name]; return raw == null || raw.trim() === "" - ? clampTimerTimeoutMs(fallback, minMs) + ? resolveTimerTimeoutMs(fallback, minMs, minMs) : readTimerTimeoutNumber(raw, name, minMs); } @@ -296,7 +290,7 @@ function logStep(message: string) { function sleep(ms: number) { return new Promise((resolve) => { - setTimeout(resolve, clampTimerTimeoutMs(ms, 0)); + setTimeout(resolve, resolveTimerTimeoutMs(ms, 0, 0)); }); } @@ -742,7 +736,7 @@ export function classifyMemorySearchInvokeResponse({ } export async function invokeMemorySearch({ port, token, timeoutMs }: InvokeOptions) { - const resolvedTimeoutMs = clampTimerTimeoutMs(timeoutMs); + const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 1); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), resolvedTimeoutMs); const startedAt = Date.now(); diff --git a/scripts/check-plugin-gateway-gauntlet.mts b/scripts/check-plugin-gateway-gauntlet.mts index 8243d34fb74d..df3097470fad 100644 --- a/scripts/check-plugin-gateway-gauntlet.mts +++ b/scripts/check-plugin-gateway-gauntlet.mts @@ -7,6 +7,10 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; +import { + MAX_TIMER_TIMEOUT_MS, + resolveTimerTimeoutMs, +} from "../packages/normalization-core/src/number-coercion.ts"; import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mts"; import { parseNonNegativeInt, @@ -57,7 +61,6 @@ const SINGLE_VALUE_FLAGS = new Set([ "--wall-anomaly-multiplier", ]); const COMMAND_OUTPUT_MAX_BUFFER_BYTES = 16 * 1024 * 1024; -const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; const ANSI_PATTERN = new RegExp(String.raw`\u001B\[[0-9;]*m`, "gu"); type ProcessSignal = `SIG${string}`; @@ -550,13 +553,10 @@ function stripAnsi(value: string) { return value.replace(ANSI_PATTERN, ""); } -function resolveTimerTimeoutMs(valueMs: number) { - const value = Number.isFinite(valueMs) ? Math.floor(valueMs) : MAX_TIMER_TIMEOUT_MS; - return Math.min(Math.max(value, 1), MAX_TIMER_TIMEOUT_MS); -} - function resolveOptionalTimerTimeoutMs(valueMs: number | undefined) { - return valueMs === undefined || valueMs <= 0 ? null : resolveTimerTimeoutMs(valueMs); + return valueMs === undefined || valueMs <= 0 + ? null + : resolveTimerTimeoutMs(valueMs, MAX_TIMER_TIMEOUT_MS); } function writeCommandLog(params: { @@ -614,7 +614,10 @@ export function runMeasuredCommandLive(params: GauntletMeasuredCommandParams) { const maxBufferBytes = params.maxBufferBytes ?? COMMAND_OUTPUT_MAX_BUFFER_BYTES; const maxRelayBytes = params.consoleOutputMaxBytes ?? maxBufferBytes; const timeoutMs = resolveOptionalTimerTimeoutMs(params.timeoutMs); - const timeoutKillGraceMs = resolveTimerTimeoutMs(params.timeoutKillGraceMs ?? 5_000); + const timeoutKillGraceMs = resolveTimerTimeoutMs( + params.timeoutKillGraceMs ?? 5_000, + MAX_TIMER_TIMEOUT_MS, + ); const spawnOptions = mode === "none" ? (params.spawnOptions ?? {}) : {}; const useProcessGroup = process.platform !== "win32" && diff --git a/scripts/copy-bundled-plugin-metadata.mts b/scripts/copy-bundled-plugin-metadata.mts index 182993ba41dc..d7dbcc3d46d8 100644 --- a/scripts/copy-bundled-plugin-metadata.mts +++ b/scripts/copy-bundled-plugin-metadata.mts @@ -12,17 +12,13 @@ import { mergeGeneratedChannelConfigs, readGeneratedBundledChannelConfigs, } from "./lib/plugin-npm-package-manifest.mts"; +import { isRecord } from "./lib/record-shared.mjs"; import { removeFileIfExists, removePathIfExists, writeTextFileIfChanged, } from "./runtime-postbuild-shared.mjs"; -// The live-updater fixture copies this closure without workspace packages. -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - const GENERATED_BUNDLED_SKILLS_DIR = "bundled-skills"; const TRANSIENT_COPY_ERROR_CODES = new Set(["EEXIST", "ENOENT", "ENOTEMPTY", "EBUSY"]); const COPY_RETRY_DELAYS_MS = [10, 25, 50]; diff --git a/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs b/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs index 6913d597f387..38b02d8c5360 100644 --- a/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs +++ b/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; import { createBoundedResponseTooLargeError, readBoundedResponseText, + toLintErrorObject, } from "../../../lib/bounded-response.mjs"; import { isRecord } from "../../../lib/record-shared.mjs"; import { resolveWindowsTaskkillPath } from "../../../lib/windows-taskkill.mjs"; @@ -1550,17 +1551,3 @@ async function main(argv = process.argv.slice(2)) { if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { await main(); } - -function toLintErrorObject(value, fallbackMessage) { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/scripts/e2e/lib/npm-telegram-live/prepare-package.mts b/scripts/e2e/lib/npm-telegram-live/prepare-package.mts index af95efd2d428..972f09d7358c 100644 --- a/scripts/e2e/lib/npm-telegram-live/prepare-package.mts +++ b/scripts/e2e/lib/npm-telegram-live/prepare-package.mts @@ -1,11 +1,8 @@ // Prepares the trusted harness manifest for npm Telegram live E2E scenarios. import fs from "node:fs"; +import { isRecord as isPackageJsonRecord } from "../../../../packages/normalization-core/src/record-coerce.ts"; import { privateLocalOnlyPluginSdkEntrypoints } from "../../../lib/plugin-sdk-entries.mts"; -function isPackageJsonRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const packageJsonPaths = process.argv.slice(2); if (packageJsonPaths.length !== 1) { throw new Error("expected exactly one trusted harness package.json path"); diff --git a/scripts/e2e/lib/openai-web-search-minimal/client.mjs b/scripts/e2e/lib/openai-web-search-minimal/client.mjs index e35c3904d0c4..fd8c8b5ec8c3 100644 --- a/scripts/e2e/lib/openai-web-search-minimal/client.mjs +++ b/scripts/e2e/lib/openai-web-search-minimal/client.mjs @@ -162,25 +162,11 @@ async function main() { return; } if (!result.ok) { - throw toLintErrorObject(result.error, "Non-Error thrown"); + throw /** @type {Error} */ (result.error); } validateSuccessResult(result); } -function toLintErrorObject(value, fallbackMessage) { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} - if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { try { await main(); diff --git a/scripts/full-release-validation-at-sha.mts b/scripts/full-release-validation-at-sha.mts index 6d0270166a81..71aee09760a2 100644 --- a/scripts/full-release-validation-at-sha.mts +++ b/scripts/full-release-validation-at-sha.mts @@ -10,6 +10,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { parse as parseYaml } from "yaml"; +import { isRecord as isJsonRecord } from "../packages/normalization-core/src/record-coerce.ts"; import { execGhRead } from "./lib/plain-gh.mjs"; const WORKFLOW = "full-release-validation.yml"; @@ -52,10 +53,6 @@ type TemporaryRefParams = { evidenceVerified: boolean; }; -function isJsonRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - function stringValue(value: unknown, fallback = ""): string { return typeof value === "string" ? value : fallback; } diff --git a/scripts/generate-npm-package-lock.mts b/scripts/generate-npm-package-lock.mts index fdc0ee3ae1c5..5fd20c1b2c2a 100644 --- a/scripts/generate-npm-package-lock.mts +++ b/scripts/generate-npm-package-lock.mts @@ -17,6 +17,7 @@ import { fileURLToPath } from "node:url"; import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads"; import pMap from "p-map"; import { parse as parseYaml } from "yaml"; +import { isRecord } from "../packages/normalization-core/src/record-coerce.ts"; import { listChangedPathsFromGit, listStagedChangedPaths } from "./changed-lanes.mts"; import { resolveNpmRunner, type NpmRunnerParams } from "./npm-runner.mts"; @@ -92,7 +93,7 @@ function normalizeOverrides(overrides: unknown): OverrideMap { const dependencyName = key.slice(scopedSeparator + 1).trim(); if (parentSelector && dependencyName) { const current = normalized[parentSelector]; - const nested = isPlainObject(current) ? current : {}; + const nested = isRecord(current) ? current : {}; nested[dependencyName] = normalizeOverrideValue(value); normalized[parentSelector] = nested; continue; @@ -103,18 +104,14 @@ function normalizeOverrides(overrides: unknown): OverrideMap { return normalized; } -function isPlainObject(value: unknown): value is UnknownRecord { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - function recordAt(value: unknown, key: string) { - const nested = isPlainObject(value) ? value[key] : undefined; - return isPlainObject(nested) ? nested : undefined; + const nested = isRecord(value) ? value[key] : undefined; + return isRecord(nested) ? nested : undefined; } function parseJsonObject(text: string): UnknownRecord { const value: unknown = JSON.parse(text); - if (!isPlainObject(value)) { + if (!isRecord(value)) { throw new Error("expected a JSON object"); } return value; @@ -124,7 +121,7 @@ function readWorkspace() { const workspace: unknown = parseYaml( readFileSync(path.join(ROOT_DIR, "pnpm-workspace.yaml"), "utf8"), ); - return isPlainObject(workspace) ? workspace : {}; + return isRecord(workspace) ? workspace : {}; } function readPnpmLock() { @@ -171,7 +168,7 @@ function readPnpmLockPackages() { continue; } lockPackages.add(`${parsed.name}@${parsed.version}`); - if (isPlainObject(metadata) && typeof metadata.version === "string") { + if (isRecord(metadata) && typeof metadata.version === "string") { lockPackages.add(`${parsed.name}@${metadata.version}`); } } @@ -189,7 +186,7 @@ function readPnpmLockPackageIntegrities() { continue; } const versions = new Set([parsed.version]); - if (isPlainObject(metadata) && typeof metadata.version === "string") { + if (isRecord(metadata) && typeof metadata.version === "string") { versions.add(metadata.version); } for (const version of versions) { @@ -294,7 +291,7 @@ function expandScopedOverrideValue( return version; } const childOverrides = overrides[childSelector]; - if (!isPlainObject(childOverrides)) { + if (!isRecord(childOverrides)) { return version; } const childSeen = new Set(seen); @@ -316,7 +313,7 @@ function expandScopedOverrideChildren(overrides: OverrideMap): OverrideMap { return Object.fromEntries( Object.entries(overrides) .map<[string, unknown]>(([parentSelector, nestedOverrides]) => { - if (isPlainObject(nestedOverrides)) { + if (isRecord(nestedOverrides)) { return [ parentSelector, Object.fromEntries( @@ -335,10 +332,7 @@ function expandScopedOverrideChildren(overrides: OverrideMap): OverrideMap { return [parentSelector, nestedOverrides]; } const exactVersion = exactVersionFromOverrideSpec(nestedOverrides); - if ( - exactVersion === null || - !isPlainObject(overrides[`${parentSelector}@${exactVersion}`]) - ) { + if (exactVersion === null || !isRecord(overrides[`${parentSelector}@${exactVersion}`])) { return [parentSelector, nestedOverrides]; } return [parentSelector, expandScopedOverrideValue(overrides, parentSelector, exactVersion)]; @@ -431,7 +425,7 @@ function mergeOverrideEntry(merged: OverrideMap, name: string, spec: unknown): v merged[name] = spec; return; } - if (isPlainObject(current) && isPlainObject(spec)) { + if (isRecord(current) && isRecord(spec)) { for (const [nestedName, nestedSpec] of Object.entries(spec)) { mergeOverrideEntry(current, nestedName, nestedSpec); } @@ -439,7 +433,7 @@ function mergeOverrideEntry(merged: OverrideMap, name: string, spec: unknown): v } if ( typeof current === "string" && - isPlainObject(spec) && + isRecord(spec) && typeof spec["."] === "string" && exactOverrideVersionsMatch(current, spec["."]) ) { @@ -454,7 +448,7 @@ function mergeOverrideEntry(merged: OverrideMap, name: string, spec: unknown): v return; } if ( - isPlainObject(current) && + isRecord(current) && typeof spec === "string" && typeof current["."] === "string" && exactOverrideVersionsMatch(current["."], spec) @@ -551,7 +545,7 @@ function copyLocalFileDependencies( } for (const field of ["dependencies", "optionalDependencies"]) { const dependencies = current.manifest[field]; - for (const spec of Object.values(isPlainObject(dependencies) ? dependencies : {})) { + for (const spec of Object.values(isRecord(dependencies) ? dependencies : {})) { if (typeof spec !== "string" || !spec.startsWith("file:")) { continue; } @@ -654,7 +648,7 @@ function packageExtensionMarksOptionalPeer(packageExtension: unknown) { const peerMetadata = recordAt(packageExtension, "peerDependenciesMeta"); return ( peerMetadata !== undefined && - Object.values(peerMetadata).some((meta) => isPlainObject(meta) && meta.optional === true) + Object.values(peerMetadata).some((meta) => isRecord(meta) && meta.optional === true) ); } @@ -667,7 +661,7 @@ function shouldUseLegacyPeerDepsForNpmLock( ) { return true; } - const dependencies = isPlainObject(packageJson.dependencies) + const dependencies = isRecord(packageJson.dependencies) ? Object.keys(packageJson.dependencies) : []; if (dependencies.length === 0) { @@ -696,7 +690,7 @@ function applyPackageExtensionPeerMetadata( } for (const [lockPath, metadata] of Object.entries(packages)) { - if (!isPlainObject(metadata)) { + if (!isRecord(metadata)) { continue; } const packageName = metadata.name ?? parseLockPackagePath(lockPath).at(-1)?.name; @@ -713,13 +707,13 @@ function applyPackageExtensionPeerMetadata( continue; } for (const [peerName, peerMeta] of Object.entries(peerDependenciesMeta)) { - if (peerDependencies[peerName] === undefined || !isPlainObject(peerMeta)) { + if (peerDependencies[peerName] === undefined || !isRecord(peerMeta)) { continue; } const metadataByPeer = recordAt(metadata, "peerDependenciesMeta") ?? {}; metadata.peerDependenciesMeta = metadataByPeer; const existingPeerMeta = metadataByPeer[peerName]; - metadataByPeer[peerName] = isPlainObject(existingPeerMeta) + metadataByPeer[peerName] = isRecord(existingPeerMeta) ? { ...existingPeerMeta, ...peerMeta } : { ...peerMeta }; } @@ -808,9 +802,7 @@ function collectOverrideViolations( } const expectedVersion = overrideRules[packageName]; const actualVersion = - isPlainObject(metadata) && typeof metadata.version === "string" - ? metadata.version - : undefined; + isRecord(metadata) && typeof metadata.version === "string" ? metadata.version : undefined; if (!expectedVersion || actualVersion === expectedVersion) { continue; } @@ -838,13 +830,13 @@ function disableDependencyShrinkwrapOverrideConflictSources( const ancestors = violation.packagePath.slice(0, -1).toReversed(); const shrinkwrappedAncestor = ancestors.find((ancestor) => { const metadata = packages[ancestor.path]; - return isPlainObject(metadata) && metadata.hasShrinkwrap === true; + return isRecord(metadata) && metadata.hasShrinkwrap === true; }); if (!shrinkwrappedAncestor) { continue; } const ancestorMetadata = packages[shrinkwrappedAncestor.path]; - if (isPlainObject(ancestorMetadata)) { + if (isRecord(ancestorMetadata)) { delete ancestorMetadata.hasShrinkwrap; } disabled.add(shrinkwrappedAncestor.path); @@ -915,7 +907,7 @@ function normalizeNpmVersionDrift(lockfile: T): T { return lockfile; } for (const metadata of Object.values(packages)) { - if (!isPlainObject(metadata)) { + if (!isRecord(metadata)) { continue; } // npm versions and mutable registry metadata disagree on these package-lock @@ -999,7 +991,7 @@ function collectPnpmLockViolations( for (const [lockPath, metadata] of Object.entries(packages)) { if ( lockPath === "" || - !isPlainObject(metadata) || + !isRecord(metadata) || typeof metadata.version !== "string" || !metadata.version || metadata.link === true @@ -1249,7 +1241,7 @@ async function runPackageWorker(packageDir: string) { }, }); worker.once("message", (message: unknown) => { - if (!isPlainObject(message)) { + if (!isRecord(message)) { reject(new Error("npm-lock worker returned an invalid response")); } else if (typeof message.error === "string") { reject(new Error(message.error)); diff --git a/scripts/lib/bounded-response.mjs b/scripts/lib/bounded-response.mjs index 739636950dcf..1d91abfa8392 100644 --- a/scripts/lib/bounded-response.mjs +++ b/scripts/lib/bounded-response.mjs @@ -170,7 +170,7 @@ export async function readBoundedResponseText(response, label, maxBytes, options return new TextDecoder().decode(bytes); } -function toLintErrorObject(value, fallbackMessage) { +export function toLintErrorObject(value, fallbackMessage) { if (value instanceof Error) { return value; } diff --git a/scripts/lib/extension-import-boundary-checker.mts b/scripts/lib/extension-import-boundary-checker.mts index 81d50ecc890c..4c7deacb9377 100644 --- a/scripts/lib/extension-import-boundary-checker.mts +++ b/scripts/lib/extension-import-boundary-checker.mts @@ -1,5 +1,6 @@ // Creates reusable import-boundary guards for bundled extension source trees. import { promises as fs } from "node:fs"; +import path from "node:path"; import pMap from "p-map"; import { BUNDLED_PLUGIN_PATH_PREFIX } from "./bundled-plugin-paths.mjs"; import { @@ -45,6 +46,7 @@ type CollectEntriesContext = { type BoundaryCheckerParams = { roots: string[]; + repoRoot?: string; sourceOptions?: Record; maxSourceBytes?: unknown; boundaryLabel?: string; @@ -62,7 +64,7 @@ type BoundaryCheckerIo = { stderr: { write(chunk: string): unknown }; }; -const repoRoot = resolveRepoRoot(import.meta.url); +const DEFAULT_REPO_ROOT = resolveRepoRoot(import.meta.url); const DEFAULT_BOUNDARY_SOURCE_MAX_BYTES = 2 * 1024 * 1024; // Escaped plugin paths must reach the scanner without lexing every unrelated escaped source. const ESCAPED_BUNDLED_PLUGIN_PATH_PREFIX_RE = new RegExp( @@ -106,6 +108,7 @@ function classifyResolvedExtensionReason(kind: string, boundaryLabel: string | u } function scanImportBoundaryViolations( + repoRoot: string, references: ModuleReference[], filePath: string, boundaryLabel: string | undefined, @@ -141,7 +144,12 @@ function normalizeMaxSourceBytes(value: unknown): number { : DEFAULT_BOUNDARY_SOURCE_MAX_BYTES; } -function assertSourceFileWithinLimit(filePath: string, bytes: number, maxBytes: number): void { +function assertSourceFileWithinLimit( + repoRoot: string, + filePath: string, + bytes: number, + maxBytes: number, +): void { if (bytes <= maxBytes) { return; } @@ -153,11 +161,15 @@ function assertSourceFileWithinLimit(filePath: string, bytes: number, maxBytes: ); } -async function readBoundedSourceFile(filePath: string, maxBytes: number): Promise { +async function readBoundedSourceFile( + repoRoot: string, + filePath: string, + maxBytes: number, +): Promise { const stat = await fs.stat(filePath); - assertSourceFileWithinLimit(filePath, stat.size, maxBytes); + assertSourceFileWithinLimit(repoRoot, filePath, stat.size, maxBytes); const source = await fs.readFile(filePath, "utf8"); - assertSourceFileWithinLimit(filePath, Buffer.byteLength(source, "utf8"), maxBytes); + assertSourceFileWithinLimit(repoRoot, filePath, Buffer.byteLength(source, "utf8"), maxBytes); return source; } @@ -165,6 +177,7 @@ async function readBoundedSourceFile(filePath: string, maxBytes: number): Promis export function createExtensionImportBoundaryChecker( params: BoundaryCheckerParams, ) { + const repoRoot = path.resolve(params.repoRoot ?? DEFAULT_REPO_ROOT); const scanRoots = resolveSourceRoots(repoRoot, params.roots); const maxSourceBytes = normalizeMaxSourceBytes(params.maxSourceBytes); @@ -177,7 +190,7 @@ export function createExtensionImportBoundaryChecker( const entriesByFile = await pMap( files, async (filePath) => { - const source = await readBoundedSourceFile(filePath, maxSourceBytes); + const source = await readBoundedSourceFile(repoRoot, filePath, maxSourceBytes); const relativeFile = normalizeRepoPath(repoRoot, filePath); if ( params.skipSourcesWithoutBundledPluginPrefix && @@ -198,6 +211,7 @@ export function createExtensionImportBoundaryChecker( return params.collectEntries ? params.collectEntries({ source, filePath, relativeFile, references }) : scanImportBoundaryViolations( + repoRoot, references, filePath, params.boundaryLabel, diff --git a/scripts/lib/plugin-npm-package-manifest.mts b/scripts/lib/plugin-npm-package-manifest.mts index ca192a7cba54..88d69fd9d727 100644 --- a/scripts/lib/plugin-npm-package-manifest.mts +++ b/scripts/lib/plugin-npm-package-manifest.mts @@ -17,11 +17,7 @@ import { resolvePluginNpmRuntimeBuildPlan, } from "./plugin-npm-runtime-build.mts"; import type { PluginNpmRuntimeBuildPlan, PluginPackageJson } from "./plugin-npm-runtime-build.mts"; - -// The live-updater fixture copies this closure without workspace packages. -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} +import { isRecord } from "./record-shared.mjs"; const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA_PATH = "src/config/bundled-channel-config-metadata.generated.ts"; diff --git a/scripts/lib/release-beta-verifier.ts b/scripts/lib/release-beta-verifier.ts index 007e18081751..0114aa7cf2b9 100644 --- a/scripts/lib/release-beta-verifier.ts +++ b/scripts/lib/release-beta-verifier.ts @@ -4,6 +4,7 @@ import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { isRecord as isJsonRecord } from "../../packages/normalization-core/src/record-coerce.ts"; import { readPublicationArtifactArchive, sha256Digest } from "./actions-artifact-archive.mjs"; import { readBoundedResponseText } from "./bounded-response.mjs"; import { collectClawHubPublishablePluginPackages } from "./plugin-clawhub-release.ts"; @@ -92,10 +93,6 @@ const TRUSTED_TOOLING_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ". const NPM_VIEW_ATTEMPTS = 30; const NPM_VIEW_RETRY_MAX_DELAY_MS = 10_000; -function isJsonRecord(value: unknown): value is JsonRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function normalizeOptionalText(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } diff --git a/scripts/lib/static-extension-assets.mts b/scripts/lib/static-extension-assets.mts index 9fc908b0ef2f..fc141e714881 100644 --- a/scripts/lib/static-extension-assets.mts +++ b/scripts/lib/static-extension-assets.mts @@ -2,11 +2,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; - -// This helper is copied into standalone updater fixtures without workspace packages. -function asRecord(value: unknown): Record { - return typeof value === "object" && value !== null ? (value as Record) : {}; -} +import { isRecord } from "./record-shared.mjs"; type StaticExtensionAsset = { pluginDir?: string; @@ -27,11 +23,14 @@ function toPosixPath(value: unknown) { } function readJsonFile(filePath: string, fsImpl: typeof fs) { - return asRecord(JSON.parse(fsImpl.readFileSync(filePath, "utf8"))); + const value: unknown = JSON.parse(fsImpl.readFileSync(filePath, "utf8")); + return isRecord(value) ? value : {}; } function readPackageSection(pkg: Record, section: "assetScripts" | "build") { - return asRecord(asRecord(pkg.openclaw)[section]); + const openclaw = isRecord(pkg.openclaw) ? pkg.openclaw : {}; + const value = openclaw[section]; + return isRecord(value) ? value : {}; } function normalizePackageRelativePath(value: unknown) { @@ -134,7 +133,7 @@ function listDistExtensionPackageDirs(rootDir: string, fsImpl: typeof fs) { function readPackageStaticAssetEntries(packageJson: Record) { const entries = readPackageSection(packageJson, "build").staticAssets; - return Array.isArray(entries) ? entries.map(asRecord) : []; + return Array.isArray(entries) ? entries.filter(isRecord) : []; } function hasPackageAssetBuild(packageJson: Record) { diff --git a/scripts/package-openclaw-for-docker.mts b/scripts/package-openclaw-for-docker.mts index e57001d1c114..3800f112820e 100644 --- a/scripts/package-openclaw-for-docker.mts +++ b/scripts/package-openclaw-for-docker.mts @@ -8,6 +8,7 @@ import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { DOCKER_SELECTED_PLUGIN_BUILD_IDS_ENV } from "./lib/bundled-plugin-build-entries.mjs"; +import { toErrorObject } from "./lib/error-format.mts"; import { terminateManagedChild } from "./lib/managed-child-process.mts"; import { resolveNpmJsonEntries } from "./lib/npm-json-output.mts"; import { isRecord } from "./lib/record-shared.mjs"; @@ -28,19 +29,6 @@ const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; const AI_RUNTIME_PACKAGE = "@openclaw/ai"; const AI_RUNTIME_BACKUP_DIR = ".openclaw-ai-package-backup"; -function coercePackageError(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} type KillChild = (signal: NodeJS.Signals) => void; type RunOptions = { captureStdout?: boolean; @@ -370,7 +358,7 @@ function run(command: string, args: string[], cwd: string, options: RunOptions = process.exit(forwardedSignalExitCode); } if (error) { - reject(coercePackageError(error, "Non-Error rejection")); + reject(toErrorObject(error, "Non-Error rejection")); return; } resolve(value); @@ -725,7 +713,7 @@ export async function prepareBundledAiRuntimePackage( originalAiRuntimeMoved = false; packedAiTarballs = []; if (cleanupError) { - throw coercePackageError(cleanupError, "Package cleanup failed."); + throw toErrorObject(cleanupError, "Package cleanup failed."); } }; diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index 7ecad6aec32a..1936959291f7 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -273,7 +273,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +2: channel-neutral location and provider-update hook contracts. // +1: QQBot 2.0.1 operator-approval Gateway client compatibility export. // +2: narrow channel agent-run terminal reader and outcome contract. - 4873, + // +5: narrow string, record, and error coercion helpers. + 4878, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -338,7 +339,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +2: add high-use callable coercion primitives while retaining shipped object-record exports. // +1: QQBot 2.0.1 operator-approval Gateway client compatibility export. // +1: narrow channel agent-run terminal reader. - 2926, + // +5: narrow string, record, and error coercion helpers. + 2931, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( @@ -363,7 +365,7 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // -1: infra-runtime now names its error exports explicitly. // -1: infra-runtime excludes the internal system-event receipt API. // -2: text-runtime names record and string coercion compatibility exports explicitly. - 77, + 76, env, ), }; diff --git a/scripts/pr-lib/review-artifacts.mjs b/scripts/pr-lib/review-artifacts.mjs index 1a10f2a42363..3b7132c313c2 100644 --- a/scripts/pr-lib/review-artifacts.mjs +++ b/scripts/pr-lib/review-artifacts.mjs @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { isDirectRunUrl } from "../lib/direct-run.mjs"; +import { isRecord as isObject } from "../lib/record-shared.mjs"; const REVIEW_ARTIFACT_ENUMS = Object.freeze({ recommendation: Object.freeze([ @@ -97,10 +98,6 @@ function createReviewArtifactTemplate({ number, headSha }) { }; } -function isObject(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - function isNonEmptyString(value) { return typeof value === "string" && value.trim().length > 0; } @@ -165,7 +162,7 @@ function validateReviewArtifacts({ review, reviewMarkdown, prMeta }) { (stamp.number !== prMeta.number || stamp.headSha !== prMeta.headRefOid) ) { add( - `Review artifact identity mismatch in .local/review.json: authored for PR #${stamp.number} at ${stamp.headSha}, but .local/pr-meta.json describes PR #${prMeta.number} at ${prMeta.headRefOid}; re-run scripts/pr review-artifacts-init`, + `Review artifact identity mismatch in .local/review.json: authored for PR #${String(stamp.number)} at ${String(stamp.headSha)}, but .local/pr-meta.json describes PR #${String(prMeta.number)} at ${String(prMeta.headRefOid)}; re-run scripts/pr review-artifacts-init`, ); } if (prMetaIdentifiesHead) { diff --git a/scripts/prepare-extension-package-boundary-artifacts.mts b/scripts/prepare-extension-package-boundary-artifacts.mts index 925d66318d57..6e418396ab3e 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mts +++ b/scripts/prepare-extension-package-boundary-artifacts.mts @@ -11,6 +11,10 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path, { resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { + MAX_TIMER_TIMEOUT_MS, + resolveTimerTimeoutMs, +} from "../packages/normalization-core/src/number-coercion.ts"; import { ensureRepoToolNodeModulesLink, isLocalCheckEnabled, @@ -34,7 +38,6 @@ const ROOT_SHIMS_MAX_OLD_SPACE_SIZE = const ROOT_SHIMS_NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ""} --max-old-space-size=${ROOT_SHIMS_MAX_OLD_SPACE_SIZE}`.trim(); const DEFAULT_NODE_STEP_ABORT_KILL_GRACE_MS = 1_000; -const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; type NodeStepSignal = "SIGHUP" | "SIGINT" | "SIGKILL" | "SIGTERM"; const NODE_STEP_PARENT_SIGNALS = ["SIGHUP", "SIGINT", "SIGTERM"] satisfies NodeStepSignal[]; const NODE_STEP_PARENT_SIGNAL_EXIT_CODES = new Map([ @@ -611,13 +614,6 @@ function installNodeStepParentSignalForwarders() { }); } -function resolveNodeStepTimerTimeoutMs(valueMs: number) { - if (!Number.isFinite(valueMs)) { - return MAX_TIMER_TIMEOUT_MS; - } - return Math.min(Math.max(Math.floor(valueMs), 1), MAX_TIMER_TIMEOUT_MS); -} - /** * Runs one artifact step with timeout, abort propagation, and prefixed output. */ @@ -627,7 +623,7 @@ export function runNodeStep( timeoutMs: number, params: NodeStepParams = {}, ) { - const resolvedTimeoutMs = resolveNodeStepTimerTimeoutMs(timeoutMs); + const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, MAX_TIMER_TIMEOUT_MS); const abortKillGraceMs = Math.max( 0, Math.floor(params.abortKillGraceMs ?? DEFAULT_NODE_STEP_ABORT_KILL_GRACE_MS), diff --git a/scripts/resolve-openclaw-package-candidate.mts b/scripts/resolve-openclaw-package-candidate.mts index e6f10c44a821..1ac641b3ccac 100644 --- a/scripts/resolve-openclaw-package-candidate.mts +++ b/scripts/resolve-openclaw-package-candidate.mts @@ -17,18 +17,11 @@ import os from "node:os"; import path from "node:path"; import { pipeline } from "node:stream/promises"; import { fileURLToPath } from "node:url"; +import { isRecord as isJsonRecord } from "../packages/normalization-core/src/record-coerce.ts"; import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mts"; import { toErrorObject } from "./lib/error-format.mts"; import { resolveNpmJsonEntries } from "./lib/npm-json-output.mts"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; - -function coercePackageCandidateError(value: unknown, fallbackMessage: string): Error { - return toErrorObject(value, fallbackMessage); -} - -function isJsonRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} import { resolveWindowsTaskkillPath } from "./lib/windows-taskkill.mjs"; import { resolveNpmRunner } from "./npm-runner.mts"; import { createPrepublishPluginRegistryArtifact } from "./prepublish-plugin-registry-artifact.mjs"; @@ -398,7 +391,7 @@ function run(command: string, args: readonly string[], options: RunOptions = {}) } child.on("error", (error: Error) => { ACTIVE_CHILD_KILLERS.delete(killChild); - reject(coercePackageCandidateError(error, "Non-Error rejection")); + reject(toErrorObject(error, "Non-Error rejection")); }); child.on("close", (status: number | null, signal: ChildSignal) => { if (timeout) { @@ -1572,7 +1565,7 @@ async function* limitWebResponseBody( const next = reader.read(); const { done, value } = timeoutRead ? await Promise.race([next, timeoutRead]) : await next; if (timedOut) { - throw coercePackageCandidateError(timeoutFailure, "package_url download timed out"); + throw toErrorObject(timeoutFailure, "package_url download timed out"); } if (done) { return; diff --git a/scripts/run-additional-boundary-checks.mts b/scripts/run-additional-boundary-checks.mts index cd7fda8cd44f..6534534dda0e 100644 --- a/scripts/run-additional-boundary-checks.mts +++ b/scripts/run-additional-boundary-checks.mts @@ -5,6 +5,10 @@ import { spawn, type ChildProcess } from "node:child_process"; import { performance } from "node:perf_hooks"; import pMap from "p-map"; import prettyMilliseconds from "pretty-ms"; +import { + MAX_TIMER_TIMEOUT_MS, + resolveTimerTimeoutMs, +} from "../packages/normalization-core/src/number-coercion.ts"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000; @@ -13,7 +17,6 @@ const DEFAULT_OUTPUT_MAX_BYTES = 512 * 1024; const TIMEOUT_KILL_GRACE_MS = 250; const PROCESS_GROUP_EXIT_POLL_MS = 25; const POST_FORCE_KILL_WAIT_MS = 250; -const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; type ProcessSignal = `SIG${string}`; type TimerHandle = ReturnType; @@ -92,6 +95,11 @@ export const BOUNDARY_CHECKS = ( "pnpm", ["run", "lint:extensions:no-src-outside-plugin-sdk"], ], + [ + "extension-normalization-core-bypass-boundary", + "pnpm", + ["run", "lint:extensions:no-normalization-core-bypass"], + ], [ "extension-plugin-sdk-internal-boundary", "pnpm", @@ -150,14 +158,6 @@ export function resolvePositiveInteger(value: unknown, fallback: number, label = return parsed; } -function resolveTimerTimeoutMs(valueMs: number) { - const value = valueMs; - if (!Number.isFinite(value)) { - return MAX_TIMER_TIMEOUT_MS; - } - return Math.min(Math.max(Math.floor(value), 1), MAX_TIMER_TIMEOUT_MS); -} - /** * Parses one N/TOTAL shard selector into zero-based index form. */ @@ -443,7 +443,7 @@ export function runSingleCheck( }: RunSingleCheckOptions, ) { return new Promise((resolve) => { - const resolvedCheckTimeoutMs = resolveTimerTimeoutMs(checkTimeoutMs); + const resolvedCheckTimeoutMs = resolveTimerTimeoutMs(checkTimeoutMs, MAX_TIMER_TIMEOUT_MS); const startedAt = performance.now(); const child = spawn(check.command, check.args, { cwd, diff --git a/scripts/stage-bundled-plugin-runtime.mts b/scripts/stage-bundled-plugin-runtime.mts index c392e6b8ea1f..f3bfb65de376 100644 --- a/scripts/stage-bundled-plugin-runtime.mts +++ b/scripts/stage-bundled-plugin-runtime.mts @@ -4,13 +4,9 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; +import { isRecord } from "./lib/record-shared.mjs"; import { removePathIfExists } from "./runtime-postbuild-shared.mjs"; -// The live-updater fixture copies this closure without workspace packages. -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - type SymlinkType = Parameters[2]; type PluginSdkFileParams = { pluginSdkDir: string; repoRoot: string }; diff --git a/scripts/sync-codex-model-prompt-fixture.ts b/scripts/sync-codex-model-prompt-fixture.ts index b20e33058e13..06bbac111f92 100644 --- a/scripts/sync-codex-model-prompt-fixture.ts +++ b/scripts/sync-codex-model-prompt-fixture.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { isRecord as isJsonObject } from "../packages/normalization-core/src/record-coerce.ts"; import { CODEX_MODEL_PROMPT_FIXTURE_DIR } from "../test/helpers/agents/prompt-snapshot-paths.js"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -10,7 +11,6 @@ const PERSONALITY_PLACEHOLDER = "{{ personality }}"; export { CODEX_MODEL_PROMPT_FIXTURE_DIR }; -type JsonObject = Record; type CodexPromptPersonality = "default" | "friendly" | "pragmatic"; type CodexModelCatalogModel = { @@ -43,10 +43,6 @@ type WritableOutput = { write(chunk: string): unknown; }; -function isJsonObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function isCodexModel(value: unknown): value is CodexModelCatalogModel { return isJsonObject(value) && typeof value.slug === "string"; } diff --git a/scripts/test-live-shard.mts b/scripts/test-live-shard.mts index f7399f950d70..a58896732862 100644 --- a/scripts/test-live-shard.mts +++ b/scripts/test-live-shard.mts @@ -4,6 +4,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { isRecord as isUnknownRecord } from "../packages/normalization-core/src/record-coerce.ts"; import { spawnPnpmRunner, type PnpmRunnerParams } from "./pnpm-runner.mts"; import { createVitestProcessCompletion, @@ -797,7 +798,3 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me }, ); } - -function isUnknownRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/scripts/watch-node.mts b/scripts/watch-node.mts index 4d13cb454ab7..01e111e8c015 100644 --- a/scripts/watch-node.mts +++ b/scripts/watch-node.mts @@ -25,10 +25,6 @@ const AUTO_DOCTOR_DISABLE_VALUES = new Set(["0", "false", "no", "off"]); type ProcessSignal = `SIG${string}`; type TimerHandle = ReturnType; -function coerceWatchError(value: unknown, fallbackMessage: string): Error { - return toErrorObject(value, fallbackMessage); -} - type WatchChild = { pid?: number; kill(signal?: ProcessSignal | number): boolean | void; @@ -543,7 +539,7 @@ export async function runWatchMain(params: WatchMainParams = {}): Promise { diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts index c40a210b5a5a..55b897263a11 100644 --- a/src/acp/runtime/session-meta.ts +++ b/src/acp/runtime/session-meta.ts @@ -1,7 +1,6 @@ /** SQLite-backed ACP session metadata storage keyed through session-store entries. */ import type { DatabaseSync } from "node:sqlite"; -import { safeParseJson } from "@openclaw/normalization-core"; -import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; +import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { Insertable, Selectable } from "kysely"; import { getRuntimeConfig } from "../../config/config.js"; @@ -57,10 +56,8 @@ function getAcpSessionKysely(db: DatabaseSync) { } function rowToAcpSessionMeta(row: AcpSessionRow): SessionAcpMeta { - const identity = asOptionalRecord(safeParseJson(row.identity_json ?? "")) as - | SessionAcpIdentity - | undefined; - const runtimeOptions = asOptionalRecord(safeParseJson(row.runtime_options_json ?? "")) as + const identity = safeParseJsonRecord(row.identity_json ?? "") as SessionAcpIdentity | undefined; + const runtimeOptions = safeParseJsonRecord(row.runtime_options_json ?? "") as | AcpSessionRuntimeOptions | undefined; return { diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 34929a4f2c2d..3143bf6652f7 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -26,12 +26,10 @@ import type { } from "@agentclientprotocol/sdk"; import { defaultAcpSessionStore, type AcpSessionStore } from "@openclaw/acp-core/session"; import type { AcpServerOptions } from "@openclaw/acp-core/types"; +import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; import type { EventFrame } from "../../packages/gateway-protocol/src/index.js"; import type { GatewayClient } from "../gateway/client.js"; -import { - createFixedWindowBudget, - resolveFixedWindowRateLimitInteger, -} from "../infra/fixed-window-rate-limit.js"; +import { createFixedWindowBudget } from "../infra/fixed-window-rate-limit.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { createInMemoryAcpEventLedger, type AcpEventLedger } from "./event-ledger.js"; import type { AcpPendingApprovalRelay } from "./translator.prompt-state.js"; @@ -90,12 +88,12 @@ export class AcpGatewayAgent implements Agent { this.log, ); const sessionCreateRateLimiter = createFixedWindowBudget({ - maxRequests: resolveFixedWindowRateLimitInteger( + maxRequests: resolveIntegerOption( opts.sessionCreateRateLimit?.maxRequests, SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS, { min: 1 }, ), - windowMs: resolveFixedWindowRateLimitInteger( + windowMs: resolveIntegerOption( opts.sessionCreateRateLimit?.windowMs, SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS, { min: 1_000 }, diff --git a/src/agents/auth-profiles/state.ts b/src/agents/auth-profiles/state.ts index 0f84962c6561..a3327decb010 100644 --- a/src/agents/auth-profiles/state.ts +++ b/src/agents/auth-profiles/state.ts @@ -38,12 +38,6 @@ const AUTH_FAILURE_REASONS = new Set([ const AUTH_BLOCKED_REASONS = new Set(["subscription_limit"]); const AUTH_BLOCKED_SOURCES = new Set(["codex_rate_limits", "wham"]); -// Runtime auth state is operator-controlled durability. Coerce every persisted -// field through closed enums/numbers so bad rows do not poison auth selection. -function normalizeFiniteNumber(value: unknown): number | undefined { - return asFiniteNumber(value); -} - function normalizeEnumValue(value: unknown, allowed: Set): T | undefined { if (typeof value !== "string") { return undefined; @@ -113,21 +107,21 @@ function normalizeUsageStatsEntry(raw: unknown): ProfileUsageStats | undefined { return undefined; } const stats: ProfileUsageStats = { - lastUsed: normalizeFiniteNumber(raw.lastUsed), - blockedUntil: normalizeFiniteNumber(raw.blockedUntil), + lastUsed: asFiniteNumber(raw.lastUsed), + blockedUntil: asFiniteNumber(raw.blockedUntil), blockedReason: normalizeEnumValue(raw.blockedReason, AUTH_BLOCKED_REASONS), blockedSource: normalizeEnumValue(raw.blockedSource, AUTH_BLOCKED_SOURCES), blockedModel: normalizeOptionalString(raw.blockedModel), blockedScope: raw.blockedScope === "model" ? "model" : undefined, - cooldownUntil: normalizeFiniteNumber(raw.cooldownUntil), + cooldownUntil: asFiniteNumber(raw.cooldownUntil), cooldownReason: normalizeEnumValue(raw.cooldownReason, AUTH_FAILURE_REASONS), cooldownModel: normalizeOptionalString(raw.cooldownModel), - disabledUntil: normalizeFiniteNumber(raw.disabledUntil), + disabledUntil: asFiniteNumber(raw.disabledUntil), disabledReason: normalizeEnumValue(raw.disabledReason, AUTH_FAILURE_REASONS), - errorCount: normalizeFiniteNumber(raw.errorCount), + errorCount: asFiniteNumber(raw.errorCount), failureCounts: normalizeFailureCounts(raw.failureCounts), - lastFailureAt: normalizeFiniteNumber(raw.lastFailureAt), - lastProbeAt: normalizeFiniteNumber(raw.lastProbeAt), + lastFailureAt: asFiniteNumber(raw.lastFailureAt), + lastProbeAt: asFiniteNumber(raw.lastProbeAt), }; for (const key of Object.keys(stats) as Array) { if (stats[key] === undefined) { diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 8b337aa71f9f..6ae227e6b2a1 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -1,6 +1,7 @@ /** * Updates persisted session metadata after agent command runs. */ +import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { SESSION_TOTAL_TOKENS_VERSION, @@ -12,7 +13,6 @@ import { projectSessionSnapshotChanges } from "../../config/sessions/session-sna import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; -import { resolveNonNegativeNumber } from "../../shared/number-coercion.js"; import { clearCliSession, getCliSessionBinding, @@ -212,7 +212,7 @@ export async function updateSessionStoreAfterAgentRun(params: { contextTokens, promptTokens, }); - const runEstimatedCostUsd = resolveNonNegativeNumber( + const runEstimatedCostUsd = asNonNegativeFiniteNumber( estimateUsageCost({ usage, cost: resolveModelCostConfig({ @@ -521,7 +521,7 @@ export async function recordCliCompactionInStore(params: { new Set([...(entry.usageFamilySessionIds ?? []), entry.sessionId, newSessionId]), ); } - const tokensAfterCompaction = resolveNonNegativeNumber(params.tokensAfter); + const tokensAfterCompaction = asNonNegativeFiniteNumber(params.tokensAfter); next.contextBudgetStatus = undefined; if (tokensAfterCompaction !== undefined) { next.totalTokens = Math.floor(tokensAfterCompaction); diff --git a/src/agents/embedded-agent-message-tool-source-reply.ts b/src/agents/embedded-agent-message-tool-source-reply.ts index c0e18b3df837..84a79c5ec9ad 100644 --- a/src/agents/embedded-agent-message-tool-source-reply.ts +++ b/src/agents/embedded-agent-message-tool-source-reply.ts @@ -1,7 +1,7 @@ /** * Detects message-tool sends that delivered a visible reply to the current source. */ -import { safeParseJson } from "@openclaw/normalization-core"; +import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { hasNonEmptyString, readStringValue } from "@openclaw/normalization-core/string-coerce"; import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js"; @@ -100,7 +100,7 @@ function isBareSentDeliveryStatus(value: unknown): boolean { } function parseJsonRecord(value: string): Record | undefined { - return asOptionalRecord(safeParseJson(value)); + return safeParseJsonRecord(value); } function recordHasDeliveredMessageId(record: Record): boolean { diff --git a/src/agents/embedded-agent-runner/extensions.ts b/src/agents/embedded-agent-runner/extensions.ts index 69ea6ee42426..5d57b404c382 100644 --- a/src/agents/embedded-agent-runner/extensions.ts +++ b/src/agents/embedded-agent-runner/extensions.ts @@ -34,9 +34,8 @@ type AgentToolResultEvent = { function snapshotToolSendReceipt(details: unknown): unknown { const toolSend = (asOptionalRecord(details) ?? {}).toolSend; - return toolSend && typeof toolSend === "object" && !Array.isArray(toolSend) - ? { ...(toolSend as Record) } - : toolSend; + const toolSendRecord = asOptionalRecord(toolSend); + return toolSendRecord ? { ...toolSendRecord } : toolSend; } function buildAgentToolResultMiddlewareFactory( diff --git a/src/agents/runtime-plan/build.ts b/src/agents/runtime-plan/build.ts index 555be57f0215..f118734c0226 100644 --- a/src/agents/runtime-plan/build.ts +++ b/src/agents/runtime-plan/build.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; /** * Builds prepared runtime plans consumed by embedded agent runs. A plan * centralizes provider hooks, auth, tool schema policy, transcript policy, @@ -46,9 +47,7 @@ function formatResolvedRef(params: { provider: string; modelId: string }): strin } function asOpenClawConfig(value: unknown): OpenClawConfig | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as OpenClawConfig) - : undefined; + return asOptionalRecord(value) as OpenClawConfig | undefined; } function asProviderRuntimeModel( diff --git a/src/agents/session-activity-notes.ts b/src/agents/session-activity-notes.ts index d381f09033db..6a275cf4884f 100644 --- a/src/agents/session-activity-notes.ts +++ b/src/agents/session-activity-notes.ts @@ -1,3 +1,4 @@ +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { readNonBlankString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../auto-reply/heartbeat.js"; @@ -241,7 +242,7 @@ export function noteSessionActivityEvent( return; } const title = readNonBlankString(data.title) ?? readNonBlankString(data.name) ?? "command"; - const exitCode = readFiniteNumber(data.exitCode); + const exitCode = asFiniteNumber(data.exitCode); const status = readNonBlankString(data.status) ?? (exitCode === 0 ? "completed" : "failed"); addActivityNote( state, @@ -328,10 +329,6 @@ export function noteSessionActivityEvent( } } -export function readFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - export function terminalHealthFor(event: AgentEventPayload): "done" | "failed" { const phase = event.data.phase; const outcome = buildAgentRunTerminalOutcomeFromLifecycleEvent({ diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index 9c3e26de3c3e..0bf56d92287d 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -4,6 +4,7 @@ import type { AgentMessage } from "@openclaw/agent-core"; * * Normalizes raw tool-call blocks and synthesizes missing tool results without rewriting trusted local payloads. */ +import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { hasNonEmptyString as hasNonEmptyStringField, normalizeLowercaseStringOrEmpty, @@ -74,12 +75,7 @@ function hasPartialJson( } function isCompleteJsonObject(value: string): boolean { - try { - const parsed: unknown = JSON.parse(value); - return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed); - } catch { - return false; - } + return safeParseJsonRecord(value) !== undefined; } function isFinalizedOpenAIResponsesToolCall( diff --git a/src/agents/subagents/announce/subagent-yield-output.ts b/src/agents/subagents/announce/subagent-yield-output.ts index daca4420c839..e7628c2a9607 100644 --- a/src/agents/subagents/announce/subagent-yield-output.ts +++ b/src/agents/subagents/announce/subagent-yield-output.ts @@ -3,7 +3,7 @@ * * Accepts provider-specific tool-call and tool-result shapes used by transcript repair and announce capture. */ -import { safeParseJson } from "@openclaw/normalization-core"; +import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { readTrimmedStringAlias } from "../../../utils/string-readers.js"; @@ -61,7 +61,7 @@ function parseJsonObject(text: string): Record | undefined { if (!trimmed.startsWith("{")) { return undefined; } - return asOptionalRecord(safeParseJson(trimmed)); + return safeParseJsonRecord(trimmed); } function readStructuredToolPayload(content: unknown): Record | undefined { diff --git a/src/auto-reply/reply/session-usage.ts b/src/auto-reply/reply/session-usage.ts index cb8e558cae73..08b2afdb5ba3 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -1,4 +1,5 @@ /** Persists usage, cost, model, and CLI session metadata after reply runs. */ +import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { clearCliSession, setCliSessionBinding, @@ -19,7 +20,6 @@ import { import { updateSessionEntry } from "../../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; -import { resolveNonNegativeNumber } from "../../shared/number-coercion.js"; import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js"; function applyCliSessionIdToSessionPatch( @@ -70,7 +70,7 @@ function applyCliSessionIdToSessionPatch( } function resolveNonNegativeTokenCount(value: number | undefined): number | undefined { - const resolved = resolveNonNegativeNumber(value); + const resolved = asNonNegativeFiniteNumber(value); return resolved === undefined ? undefined : Math.floor(resolved); } @@ -88,7 +88,7 @@ function estimateSessionRunCostUsd(params: { model: params.modelUsed, config: params.cfg, }); - return resolveNonNegativeNumber(estimateUsageCost({ usage: params.usage, cost })); + return asNonNegativeFiniteNumber(estimateUsageCost({ usage: params.usage, cost })); } /** Persists usage accounting and selected runtime metadata to the session store. */ diff --git a/src/auto-reply/reply/strip-inbound-meta.ts b/src/auto-reply/reply/strip-inbound-meta.ts index e5727618ff1e..66834c78d004 100644 --- a/src/auto-reply/reply/strip-inbound-meta.ts +++ b/src/auto-reply/reply/strip-inbound-meta.ts @@ -21,6 +21,7 @@ * structured-context over-strip (arbitrary plugin labels are now recognized). */ +import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { MESSAGE_TOOL_DELIVERY_HINTS } from "./delivery-hints.js"; import { INBOUND_CONTEXT_MARKER } from "./inbound-context-marker.js"; @@ -88,15 +89,7 @@ function restoreNeutralizedMarkdownFences(value: unknown): unknown { } function parseJsonObjectRecord(jsonText: string): Record | null { - try { - const parsed: unknown = JSON.parse(jsonText); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - return parsed as Record; - } catch { - return null; - } + return safeParseJsonRecord(jsonText) ?? null; } function parseInboundMetaBlock( diff --git a/src/channels/mention-pattern-policy.ts b/src/channels/mention-pattern-policy.ts index 477e996faff6..beb6cc37c27f 100644 --- a/src/channels/mention-pattern-policy.ts +++ b/src/channels/mention-pattern-policy.ts @@ -41,7 +41,7 @@ function normalizeIdList(values?: string[]): Set { } function isMentionPatternsPolicyConfig(value: unknown): value is MentionPatternsPolicyConfig { - return value != null && typeof value === "object" && !Array.isArray(value); + return isRecord(value); } function resolveProviderMentionPatternsPolicy( diff --git a/src/chat/canvas-render.ts b/src/chat/canvas-render.ts index 9d37c85f40e4..42cce392d199 100644 --- a/src/chat/canvas-render.ts +++ b/src/chat/canvas-render.ts @@ -1,5 +1,5 @@ // Renders chat canvas payloads into text and metadata for transcript output. -import { expectDefined, safeParseJson } from "@openclaw/normalization-core"; +import { expectDefined, safeParseJsonRecord } from "@openclaw/normalization-core"; import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { parseFenceSpans } from "../../packages/markdown-core/src/fences.js"; @@ -259,7 +259,7 @@ export function extractCanvasFromText( outputText: string | undefined, _toolName?: string, ): CanvasPreview | undefined { - const parsed = outputText ? asOptionalRecord(safeParseJson(outputText)) : undefined; + const parsed = outputText ? safeParseJsonRecord(outputText) : undefined; return coerceCanvasPreview(parsed); } diff --git a/src/config/sessions/session-accessor.transcript.ts b/src/config/sessions/session-accessor.transcript.ts index bfa91628a679..ff12a477b74c 100644 --- a/src/config/sessions/session-accessor.transcript.ts +++ b/src/config/sessions/session-accessor.transcript.ts @@ -1,3 +1,4 @@ +import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { readTranscriptRawDelta } from "./session-accessor.sqlite-delta.js"; import { resolveSessionKeyBySessionId as resolveTranscriptSessionKeyBySessionId } from "./session-accessor.sqlite-entry.js"; import { publishTranscriptUpdate } from "./session-accessor.sqlite-events.js"; @@ -117,14 +118,7 @@ export async function trimSessionTranscriptForManualCompact( } function parseManualCompactTranscriptRecord(line: string): Record | null { - try { - const parsed = JSON.parse(line) as unknown; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : null; - } catch { - return null; - } + return safeParseJsonRecord(line) ?? null; } function normalizeManualCompactTranscriptLines( diff --git a/src/cron/isolated-agent/run-finalize.ts b/src/cron/isolated-agent/run-finalize.ts index e9a42c9dfece..49d697f19b7d 100644 --- a/src/cron/isolated-agent/run-finalize.ts +++ b/src/cron/isolated-agent/run-finalize.ts @@ -1,5 +1,8 @@ /** Final persistence, telemetry, and delivery for an isolated cron run. */ -import { asPositiveFiniteNumber as resolvePositiveContextTokens } from "@openclaw/normalization-core/number-coercion"; +import { + asNonNegativeFiniteNumber, + asPositiveFiniteNumber as resolvePositiveContextTokens, +} from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { hasAcceptedSessionSpawn } from "../../agents/accepted-session-spawn.js"; import { hasCommittedMessagingToolDeliveryEvidence } from "../../agents/embedded-agent-runner/delivery-evidence.js"; @@ -13,7 +16,6 @@ import { } from "../../infra/diagnostic-trace-context.js"; import { resolveSourceDeliveryOutcome } from "../../infra/outbound/source-delivery-plan.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; -import { resolveNonNegativeNumber } from "../../shared/number-coercion.js"; import { createCronRunDiagnosticsFromAgentResult, createCronRunDiagnosticsFromError, @@ -139,7 +141,7 @@ export async function finalizeCronRun(params: { model: modelUsed, config: prepared.cfgWithAgentDefaults, }); - const runEstimatedCostUsd = resolveNonNegativeNumber( + const runEstimatedCostUsd = asNonNegativeFiniteNumber( estimateUsageCost({ usage, cost: costConfig }), ); prepared.cronSession.sessionEntry.inputTokens = input; @@ -196,7 +198,7 @@ export async function finalizeCronRun(params: { diagnosticUsage?.output !== undefined || diagnosticUsage?.cacheRead !== undefined || diagnosticUsage?.cacheWrite !== undefined; - const diagnosticEstimatedCostUsd = resolveNonNegativeNumber( + const diagnosticEstimatedCostUsd = asNonNegativeFiniteNumber( estimateUsageCost({ usage: diagnosticUsage, cost: costConfig }), ); const contextUsedTokens = deriveContextPromptTokens({ diff --git a/src/cron/task-run-detail.ts b/src/cron/task-run-detail.ts index fcd153c2e22f..368c30487e4b 100644 --- a/src/cron/task-run-detail.ts +++ b/src/cron/task-run-detail.ts @@ -5,6 +5,7 @@ import { asSafeIntegerInRange, MAX_DATE_TIMESTAMP_MS, } from "@openclaw/normalization-core/number-coercion"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { FAILOVER_REASONS, @@ -29,7 +30,7 @@ function toJsonValue(value: unknown): JsonValue | undefined { } function isJsonObject(value: unknown): value is { [key: string]: JsonValue } { - return value !== null && typeof value === "object" && !Array.isArray(value); + return isRecord(value); } function normalizeTimestamp(value: unknown): number | undefined { diff --git a/src/gateway/chat-display-projection.message-tool.ts b/src/gateway/chat-display-projection.message-tool.ts index b58135824c79..189ca4ec38b1 100644 --- a/src/gateway/chat-display-projection.message-tool.ts +++ b/src/gateway/chat-display-projection.message-tool.ts @@ -1,3 +1,4 @@ +import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion"; import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; @@ -30,11 +31,7 @@ function normalizeToolHistoryType(value: unknown): string | undefined { } function parseJsonRecord(value: string): Record | undefined { - try { - return readRecord(JSON.parse(value)); - } catch { - return undefined; - } + return safeParseJsonRecord(value); } function readMaybeJsonRecord(value: unknown): Record | undefined { diff --git a/src/gateway/chat-display-projection.sanitize.ts b/src/gateway/chat-display-projection.sanitize.ts index 8440bbc0a037..c6a2e67b845a 100644 --- a/src/gateway/chat-display-projection.sanitize.ts +++ b/src/gateway/chat-display-projection.sanitize.ts @@ -278,10 +278,6 @@ function projectAssistantMixedToolContent( return hasVisibleText ? { content: projectedContent, changed: true } : null; } -function toFiniteNumber(x: unknown): number | undefined { - return asFiniteNumber(x); -} - function sanitizeCost(raw: unknown): Record | undefined { if (!raw || typeof raw !== "object") { return undefined; @@ -289,7 +285,7 @@ function sanitizeCost(raw: unknown): Record | undefined { const c = raw as Record; const out: Record = {}; for (const key of ["input", "output", "cacheRead", "cacheWrite", "total"] as const) { - const value = toFiniteNumber(c[key]); + const value = asFiniteNumber(c[key]); if (value !== undefined) { out[key] = value; } @@ -324,7 +320,7 @@ function sanitizeUsage(raw: unknown): Record | undefined { ]; for (const k of knownFields) { - const n = toFiniteNumber(u[k]); + const n = asFiniteNumber(u[k]); if (n !== undefined) { out[k] = n; } diff --git a/src/gateway/cli-session-history.claude.ts b/src/gateway/cli-session-history.claude.ts index 18cf37068035..6d02c199135c 100644 --- a/src/gateway/cli-session-history.claude.ts +++ b/src/gateway/cli-session-history.claude.ts @@ -3,7 +3,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; +import { + asFiniteNumber, + parseDateStringTimestampMs, +} from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { hashCliReseedPrompt, parseCliReseedPrompt } from "../agents/cli-runner/reseed-envelope.js"; import type { AgentMessage } from "../agents/runtime/index.js"; @@ -69,7 +72,7 @@ export function resolveClaudeCliBindingSessionId( } export function resolveClaudeCliTimestampMs(value: unknown): number | undefined { - return typeof value === "string" ? asFiniteNumber(Date.parse(value)) : undefined; + return parseDateStringTimestampMs(value); } function resolveClaudeCliUsage(raw: ClaudeCliUsage) { diff --git a/src/gateway/plugin-activation-runtime-config.ts b/src/gateway/plugin-activation-runtime-config.ts index 3656b6375f2e..bf7688a5ce32 100644 --- a/src/gateway/plugin-activation-runtime-config.ts +++ b/src/gateway/plugin-activation-runtime-config.ts @@ -10,9 +10,6 @@ import { isRecord } from "../utils.js"; // Activation config carries only operator-controlled enable/allow surfaces into // runtime config. Other runtime fields stay canonical to avoid stale activation // state overriding live config reloads. -function hasOwnValue(record: Record, key: string): boolean { - return Object.hasOwn(record, key); -} function mergeChannelActivationSections(params: { runtimeConfig: OpenClawConfig; @@ -29,7 +26,7 @@ function mergeChannelActivationSections(params: { let nextChannels: Record | undefined; for (const [channelId, activationChannel] of Object.entries(activationChannels)) { - if (!isRecord(activationChannel) || !hasOwnValue(activationChannel, "enabled")) { + if (!isRecord(activationChannel) || !Object.hasOwn(activationChannel, "enabled")) { continue; } const runtimeChannel = runtimeChannels[channelId]; @@ -74,7 +71,7 @@ function mergePluginActivationSections(params: { const runtimeEntries = isRecord(runtimePlugins.entries) ? runtimePlugins.entries : {}; let nextEntries: Record | undefined; for (const [pluginId, activationEntry] of Object.entries(activationEntries)) { - if (!isRecord(activationEntry) || !hasOwnValue(activationEntry, "enabled")) { + if (!isRecord(activationEntry) || !Object.hasOwn(activationEntry, "enabled")) { continue; } const runtimeEntry = runtimeEntries[pluginId]; diff --git a/src/gateway/server-methods/chat-transcript-persistence.ts b/src/gateway/server-methods/chat-transcript-persistence.ts index 0f6a3c03b8fc..b53c84f70e4d 100644 --- a/src/gateway/server-methods/chat-transcript-persistence.ts +++ b/src/gateway/server-methods/chat-transcript-persistence.ts @@ -74,10 +74,7 @@ function transcriptEventId(event: TranscriptEvent): string | undefined { } function transcriptEventMessage(event: TranscriptEvent): Record | undefined { - const message = transcriptEventRecord(event)?.message; - return message && typeof message === "object" && !Array.isArray(message) - ? (message as Record) - : undefined; + return transcriptEventRecord(transcriptEventRecord(event)?.message); } function findAssistantTranscriptMessageByIdempotencyKeyInEvents( diff --git a/src/gateway/session-observer.ts b/src/gateway/session-observer.ts index 4ae8c62d2013..b37bc9b5e6d0 100644 --- a/src/gateway/session-observer.ts +++ b/src/gateway/session-observer.ts @@ -1,10 +1,10 @@ +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import type { SessionObserverDigest } from "../../packages/gateway-protocol/src/schema/sessions.js"; import { resolveDefaultAgentId, resolveSessionAgentId } from "../agents/agent-scope.js"; import { createSessionActivityNoteState, flushSessionActivityAssistantNote, noteSessionActivityEvent, - readFiniteNumber, terminalHealthFor, } from "../agents/session-activity-notes.js"; import { resolveUtilityModelRefForAgent } from "../agents/utility-model.js"; @@ -513,7 +513,7 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve } const session = readSession(sessionKey, agentId); const startedAt = - readFiniteNumber(event.data.startedAt) ?? session?.startedAt ?? event.ts ?? now(); + asFiniteNumber(event.data.startedAt) ?? session?.startedAt ?? event.ts ?? now(); const state: SessionObserverState = { ...createSessionActivityNoteState(), sessionKey, @@ -681,7 +681,7 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve state.consecutiveFailures = 0; } state.lastActivityAt = event.ts; - const eventStartedAt = readFiniteNumber(event.data.startedAt); + const eventStartedAt = asFiniteNumber(event.data.startedAt); if (eventStartedAt !== undefined) { state.startedAt = Math.min(state.startedAt, eventStartedAt); } @@ -695,7 +695,7 @@ export function createSessionObserver(deps: SessionObserverDeps): SessionObserve preamblePublisher.clear(state); state.terminalHealth = terminalHealthFor(event); disabledRuns.delete(event.runId); - const endedAt = readFiniteNumber(event.data.endedAt) ?? now(); + const endedAt = asFiniteNumber(event.data.endedAt) ?? now(); // previousDigest is set on every ACCEPTED digest of this run; digestCount now // counts attempts (budget), so it no longer implies any digest was published. const hasRunDigest = state.previousDigest?.runId === state.runId; diff --git a/src/gateway/session-utils-core.ts b/src/gateway/session-utils-core.ts index 99c67d7d78c6..2071ca214f2e 100644 --- a/src/gateway/session-utils-core.ts +++ b/src/gateway/session-utils-core.ts @@ -1,3 +1,4 @@ +import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { countActiveDescendantRuns, @@ -12,7 +13,6 @@ import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js" import { isTerminalSessionStatus, type SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { pruneMapToMaxSize } from "../infra/map-size.js"; -import { resolveNonNegativeNumber } from "../shared/number-coercion.js"; import { truncateUtf16Safe } from "../utils.js"; import { estimateUsageCost, @@ -195,7 +195,7 @@ export function resolveEstimatedSessionCostUsd(params: { explicitCostUsd?: number; rowContext?: SessionListRowContext; }): number | undefined { - const explicitCostUsd = resolveNonNegativeNumber( + const explicitCostUsd = asNonNegativeFiniteNumber( params.explicitCostUsd ?? params.entry?.estimatedCostUsd, ); if (explicitCostUsd !== undefined) { @@ -231,7 +231,7 @@ export function resolveEstimatedSessionCostUsd(params: { }, cost, }); - return resolveNonNegativeNumber(estimated); + return asNonNegativeFiniteNumber(estimated); } const STALE_STORE_ONLY_CHILD_LINK_MS = 60 * 60 * 1_000; diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 8d3fd0d1f884..5d69e458cda2 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -1,3 +1,4 @@ +import { asNonNegativeFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { SessionCreatedActor } from "../../packages/gateway-protocol/src/index.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; @@ -29,7 +30,6 @@ import { projectPluginSessionExtensionsSync } from "../plugins/host-hook-state.j import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; import { classifySessionKind } from "../sessions/classify-session-kind.js"; import { resolveActiveSessionAgentStatus } from "../sessions/session-agent-status.js"; -import { resolveNonNegativeNumber } from "../shared/number-coercion.js"; import { projectSessionDeliveryFields } from "../utils/delivery-context.shared.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js"; import { resolveCurrentUserProfileDisplay } from "./current-user-profile-display.js"; @@ -229,7 +229,7 @@ export function buildGatewaySessionRow(params: { ); const runtimeModelPresent = Boolean(entry?.model?.trim()) || Boolean(entry?.modelProvider?.trim()); - const freshSessionTotalTokens = resolveNonNegativeNumber(resolveFreshSessionTotalTokens(entry)); + const freshSessionTotalTokens = asNonNegativeFiniteNumber(resolveFreshSessionTotalTokens(entry)); const needsTranscriptTotalTokens = freshSessionTotalTokens === undefined; const needsTranscriptContextTokens = resolvePositiveNumber(entry?.contextTokens) === undefined; const needsTranscriptEstimatedCostUsd = @@ -274,7 +274,7 @@ export function buildGatewaySessionRow(params: { : resolvedModelIdentity; const { provider: modelProvider, model } = modelIdentity; const totalTokens = - freshSessionTotalTokens ?? resolveNonNegativeNumber(transcriptUsage?.totalTokens); + freshSessionTotalTokens ?? asNonNegativeFiniteNumber(transcriptUsage?.totalTokens); const totalTokensFresh = freshSessionTotalTokens !== undefined || (typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0) @@ -326,14 +326,14 @@ export function buildGatewaySessionRow(params: { sessionKey: key, }); const estimatedCostUsd = lightweight - ? resolveNonNegativeNumber(entry?.estimatedCostUsd) + ? asNonNegativeFiniteNumber(entry?.estimatedCostUsd) : (resolveEstimatedSessionCostUsd({ cfg, provider: rowModelProvider, model: rowModel, entry, rowContext: params.rowContext, - }) ?? resolveNonNegativeNumber(transcriptUsage?.estimatedCostUsd)); + }) ?? asNonNegativeFiniteNumber(transcriptUsage?.estimatedCostUsd)); const contextTokens = lightweight ? (resolvePositiveNumber(entry?.contextTokens) ?? resolvePositiveNumber( diff --git a/src/gateway/watch-node-http.ts b/src/gateway/watch-node-http.ts index 538651de92ec..9974434dd261 100644 --- a/src/gateway/watch-node-http.ts +++ b/src/gateway/watch-node-http.ts @@ -2,7 +2,7 @@ // Apple Watch cannot use generic WebSockets on-device, so node events use bounded HTTPS polls. import { randomBytes, randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; -import { isRecord as isStringRecord } from "@openclaw/normalization-core/record-coerce"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES, @@ -1013,11 +1013,11 @@ export function createWatchNodeHttpRuntime(options: WatchNodeHttpRuntimeOptions) if (body === undefined) { return; } - if (!isStringRecord(body) || typeof body.id !== "string" || typeof body.ok !== "boolean") { + if (!isRecord(body) || typeof body.id !== "string" || typeof body.ok !== "boolean") { sendInvalidRequest(res, "invalid node invoke result"); return; } - const error = isStringRecord(body.error) + const error = isRecord(body.error) ? { ...(typeof body.error.code === "string" ? { code: body.error.code } : {}), ...(typeof body.error.message === "string" ? { message: body.error.message } : {}), diff --git a/src/infra/fixed-window-rate-limit.ts b/src/infra/fixed-window-rate-limit.ts index b42c30c22e2e..1dd96e26a037 100644 --- a/src/infra/fixed-window-rate-limit.ts +++ b/src/infra/fixed-window-rate-limit.ts @@ -4,6 +4,7 @@ * It is intentionally in-memory and process-local; callers that need distributed * limits must layer their own persistence before invoking request work. */ +import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; /** Minimal fixed-window limiter interface used by memory and request guard helpers. */ export type FixedWindowRateLimiter = { @@ -19,16 +20,6 @@ export type FixedWindowRateLimiter = { reset: () => void; }; -/** Normalizes rate-limit numeric config to a finite integer with a lower bound. */ -export function resolveFixedWindowRateLimitInteger( - value: number | undefined, - fallback: number, - params: { min: number }, -): number { - const candidate = typeof value === "number" && Number.isFinite(value) ? value : fallback; - return Math.max(params.min, Math.floor(candidate)); -} - /** Creates a fixed-window counter that reports allowance, remaining quota, and retry delay. */ export function createFixedWindowBudget(params: { /** Maximum successful consume calls allowed per window. */ @@ -38,8 +29,8 @@ export function createFixedWindowBudget(params: { /** Optional clock for tests or deterministic host runtimes. */ now?: () => number; }): FixedWindowRateLimiter { - const maxRequests = resolveFixedWindowRateLimitInteger(params.maxRequests, 1, { min: 1 }); - const windowMs = resolveFixedWindowRateLimitInteger(params.windowMs, 1, { min: 1 }); + const maxRequests = resolveIntegerOption(params.maxRequests, 1, { min: 1 }); + const windowMs = resolveIntegerOption(params.windowMs, 1, { min: 1 }); const now = params.now ?? Date.now; let count = 0; diff --git a/src/infra/outbound/reply-payload-normalize.ts b/src/infra/outbound/reply-payload-normalize.ts index b3d382884fac..318db4481605 100644 --- a/src/infra/outbound/reply-payload-normalize.ts +++ b/src/infra/outbound/reply-payload-normalize.ts @@ -1,5 +1,6 @@ // Reply-payload normalization projects loose tool/agent objects onto the // outbound-supported reply payload fields. +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { readStringValue } from "@openclaw/normalization-core/string-coerce"; import type { ReplyPayload as InternalReplyPayload } from "../../auto-reply/reply-payload.js"; import { normalizeOutboundLocation } from "../../channels/location.js"; @@ -24,10 +25,6 @@ export type OutboundReplyPayload = { videoAsNote?: boolean; }; -function readObjectValue(value: unknown): object | undefined { - return value && typeof value === "object" && !Array.isArray(value) ? value : undefined; -} - /** Extract the supported outbound reply fields from loose tool or agent payload objects. */ export function normalizeOutboundReplyPayloadCore( payload: Record, @@ -39,12 +36,12 @@ export function normalizeOutboundReplyPayloadCore( ) : undefined; const mediaUrl = readStringValue(payload.mediaUrl); - const presentation = readObjectValue( + const presentation = asOptionalRecord( payload.presentation, ) as OutboundReplyPayload["presentation"]; const presentationTextMode = payload.presentationTextMode === "fallback" ? "fallback" : undefined; - const interactive = readObjectValue(payload.interactive) as OutboundReplyPayload["interactive"]; - const channelData = readObjectValue(payload.channelData) as OutboundReplyPayload["channelData"]; + const interactive = asOptionalRecord(payload.interactive) as OutboundReplyPayload["interactive"]; + const channelData = asOptionalRecord(payload.channelData) as OutboundReplyPayload["channelData"]; const sensitiveMedia = payload.sensitiveMedia === true ? true : undefined; const replyToId = readStringValue(payload.replyToId); const location = normalizeOutboundLocation(payload.location); diff --git a/src/infra/package-json.ts b/src/infra/package-json.ts index ff5ff73b889b..6af89d0cb6fa 100644 --- a/src/infra/package-json.ts +++ b/src/infra/package-json.ts @@ -1,5 +1,6 @@ // Reads package.json metadata needed by install and update flows. import path from "node:path"; +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeNullableString as normalizeString } from "@openclaw/normalization-core/string-coerce"; import { tryReadJson } from "./json-files.js"; @@ -12,9 +13,7 @@ type PackageJson = { /** Reads package.json as a loose object, returning null for missing or invalid manifests. */ async function readPackageJson(root: string): Promise { const parsed = await tryReadJson(path.join(root, "package.json")); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as PackageJson) - : null; + return asNullableRecord(parsed) as PackageJson | null; } /** Reads and trims the package version string, returning null for blank or non-string values. */ diff --git a/src/infra/provider-usage.admin.ts b/src/infra/provider-usage.admin.ts index 23b675e7db4d..994a2435542e 100644 --- a/src/infra/provider-usage.admin.ts +++ b/src/infra/provider-usage.admin.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { readProviderJsonObjectResponse } from "../agents/provider-http-errors.js"; import type { ProviderUsageCostDaily, @@ -48,9 +49,7 @@ export function decodeProviderUsageAdminToken(prefix: string, raw: string): stri } export function asProviderUsageObject(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; + return asOptionalRecord(value); } export function parseProviderUsageNumber(value: unknown): number | undefined { diff --git a/src/infra/provider-usage.fetch.shared.ts b/src/infra/provider-usage.fetch.shared.ts index c5606dacac72..573edfc88eee 100644 --- a/src/infra/provider-usage.fetch.shared.ts +++ b/src/infra/provider-usage.fetch.shared.ts @@ -1,6 +1,6 @@ // Shared fetch and parsing helpers for provider usage endpoints. import { - asDateTimestampMs, + parseDateStringTimestampMs, resolveTimerTimeoutMs, } from "@openclaw/normalization-core/number-coercion"; import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; @@ -26,10 +26,7 @@ export { parseFiniteNumber } from "./parse-finite-number.js"; /** Parses a provider reset-time string without leaking an invalid Date timestamp. */ export function parseUsageResetAt(value: unknown): number | undefined { - if (typeof value !== "string" || !value.trim()) { - return undefined; - } - return asDateTimestampMs(Date.parse(value)); + return parseDateStringTimestampMs(value); } type BuildUsageHttpErrorSnapshotOptions = { diff --git a/src/infra/state-migrations.cron-run-logs.ts b/src/infra/state-migrations.cron-run-logs.ts index 172a3195cade..5bbb10582d1a 100644 --- a/src/infra/state-migrations.cron-run-logs.ts +++ b/src/infra/state-migrations.cron-run-logs.ts @@ -1,5 +1,6 @@ /** One-shot import of legacy cron run history into the authoritative task ledger. */ import type { DatabaseSync } from "node:sqlite"; +import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { cronRunLogEntryToTaskDetail, cronRunStatusToTaskStatus, @@ -59,17 +60,7 @@ function tableExists(db: DatabaseSync, name: string): boolean { } function parseDetail(raw: string | null): Record | undefined { - if (!raw) { - return undefined; - } - try { - const parsed: unknown = JSON.parse(raw); - return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : undefined; - } catch { - return undefined; - } + return raw ? safeParseJsonRecord(raw) : undefined; } function collectMirroredTasks(db: DatabaseSync): Map { diff --git a/src/infra/state-migrations.mcp-oauth-lock-stale.ts b/src/infra/state-migrations.mcp-oauth-lock-stale.ts index 00c56f8a56cd..c7f85f7869b8 100644 --- a/src/infra/state-migrations.mcp-oauth-lock-stale.ts +++ b/src/infra/state-migrations.mcp-oauth-lock-stale.ts @@ -1,17 +1,11 @@ +import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { getFileLockProcessStartTime, isPidDefinitelyDead } from "../shared/pid-alive.js"; import { isLockOwnerDefinitelyStale } from "./stale-lock-file.js"; const LEGACY_LOCK_STALE_MS = 60_000; function parseLockPayload(raw: string): Record | null { - try { - const parsed: unknown = JSON.parse(raw); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : null; - } catch { - return null; - } + return safeParseJsonRecord(raw) ?? null; } /** Classify only retired-runtime owners whose age and process identity are provably stale. */ diff --git a/src/llm/providers/stream-wrappers/openai.ts b/src/llm/providers/stream-wrappers/openai.ts index a5e3c23c744b..56a700d7bdcc 100644 --- a/src/llm/providers/stream-wrappers/openai.ts +++ b/src/llm/providers/stream-wrappers/openai.ts @@ -308,39 +308,8 @@ function normalizeOpenAIFastMode(value: unknown): boolean | undefined { if (typeof value === "function") { return normalizeOpenAIFastMode((value as () => unknown)()); } - if (typeof value === "boolean") { - return value; - } const fastMode = normalizeFastMode(value); - if (fastMode === "auto") { - return undefined; - } - if (typeof fastMode === "boolean") { - return fastMode; - } - const normalized = normalizeOptionalLowercaseString(value); - if (!normalized) { - return undefined; - } - if ( - normalized === "on" || - normalized === "true" || - normalized === "yes" || - normalized === "1" || - normalized === "fast" - ) { - return true; - } - if ( - normalized === "off" || - normalized === "false" || - normalized === "no" || - normalized === "0" || - normalized === "normal" - ) { - return false; - } - return undefined; + return fastMode === "auto" ? undefined : fastMode; } /** @deprecated OpenAI provider-owned stream helper; do not use from third-party plugins. */ diff --git a/src/llm/providers/stream-wrappers/proxy.ts b/src/llm/providers/stream-wrappers/proxy.ts index 82a91ea29bee..2e037f66e615 100644 --- a/src/llm/providers/stream-wrappers/proxy.ts +++ b/src/llm/providers/stream-wrappers/proxy.ts @@ -9,6 +9,7 @@ import type { StreamFn } from "../../../agents/runtime/index.js"; import type { ThinkLevel } from "../../../auto-reply/thinking.js"; import { parseStrictFiniteNumber } from "../../../infra/parse-finite-number.js"; import { normalizeOpenAICompatibleReasoningPayload } from "../../../plugin-sdk/provider-stream-shared.js"; +import { parseBooleanValue } from "../../../utils/boolean.js"; import { streamSimple } from "../../stream.js"; import { applyAnthropicEphemeralCacheControlMarkers, @@ -19,6 +20,10 @@ import { streamWithPayloadPatch } from "./stream-payload-utils.js"; const KILOCODE_FEATURE_HEADER = "X-KILOCODE-FEATURE"; const KILOCODE_FEATURE_DEFAULT = "openclaw"; const KILOCODE_FEATURE_ENV_VAR = "KILOCODE_FEATURE"; +const BOOLEAN_PARAM_PARSE_OPTIONS = { + truthy: ["1", "true", "yes", "on", "enable", "enabled"], + falsy: ["0", "false", "no", "off", "disable", "disabled"], +}; function resolveKilocodeAppHeaders(): Record { const feature = process.env[KILOCODE_FEATURE_ENV_VAR]?.trim() || KILOCODE_FEATURE_DEFAULT; @@ -40,26 +45,6 @@ function readExtraParam( return undefined; } -function resolveBooleanParam(value: unknown): boolean | undefined { - if (typeof value === "boolean") { - return value; - } - if (typeof value !== "string") { - return undefined; - } - const normalized = normalizeOptionalLowercaseString(value); - if (!normalized) { - return undefined; - } - if (["1", "true", "yes", "on", "enable", "enabled"].includes(normalized)) { - return true; - } - if (["0", "false", "no", "off", "disable", "disabled"].includes(normalized)) { - return false; - } - return undefined; -} - function resolveOpenRouterResponseCacheTtlSeconds(value: unknown): string | undefined { const parsed = typeof value === "number" @@ -95,11 +80,13 @@ function resolveOpenRouterResponseCacheHeaders( if (!shouldApplyOpenRouterResponseCacheHeaders(model)) { return undefined; } - const configuredCache = resolveBooleanParam( + const configuredCache = parseBooleanValue( readExtraParam(extraParams, ["responseCache", "response_cache"]), + BOOLEAN_PARAM_PARSE_OPTIONS, ); - const clearCache = resolveBooleanParam( + const clearCache = parseBooleanValue( readExtraParam(extraParams, ["responseCacheClear", "response_cache_clear"]), + BOOLEAN_PARAM_PARSE_OPTIONS, ); const cacheEnabled = configuredCache ?? (clearCache ? true : undefined); if (cacheEnabled === undefined) { diff --git a/src/logging/diagnostic-support-log-redaction.ts b/src/logging/diagnostic-support-log-redaction.ts index 518d475b4813..bce319d25c19 100644 --- a/src/logging/diagnostic-support-log-redaction.ts +++ b/src/logging/diagnostic-support-log-redaction.ts @@ -1,4 +1,5 @@ // Support log redaction helpers scrub sensitive fields from diagnostic log payloads. +import { safeParseJsonRecord } from "@openclaw/normalization-core"; import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { @@ -160,11 +161,7 @@ function parseJsonRecord(value: string): Record | undefined { if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { return undefined; } - try { - return asOptionalRecord(JSON.parse(trimmed)); - } catch { - return undefined; - } + return safeParseJsonRecord(trimmed); } function addLogObjectFields( diff --git a/src/logging/diagnostic-support-redaction.ts b/src/logging/diagnostic-support-redaction.ts index 8807e3c09267..8495b8e920cc 100644 --- a/src/logging/diagnostic-support-redaction.ts +++ b/src/logging/diagnostic-support-redaction.ts @@ -101,14 +101,10 @@ function createSupportRecord(): Record { return Object.create(null) as Record; } -function hasOwnRecordKey(record: Record, key: string): boolean { - return Object.hasOwn(record, key); -} - function countOwnObjectEntries(record: Record): number { let count = 0; for (const key in record) { - if (hasOwnRecordKey(record, key)) { + if (Object.hasOwn(record, key)) { count += 1; } } @@ -122,7 +118,7 @@ function limitedSupportObjectEntries(record: Record): { let count = 0; const entries: SupportObjectEntry[] = []; for (const key in record) { - if (!hasOwnRecordKey(record, key)) { + if (!Object.hasOwn(record, key)) { continue; } count += 1; diff --git a/src/plugin-sdk/error-runtime.ts b/src/plugin-sdk/error-runtime.ts index 7b137482ab1e..f5f00f933917 100644 --- a/src/plugin-sdk/error-runtime.ts +++ b/src/plugin-sdk/error-runtime.ts @@ -24,5 +24,9 @@ export { readErrorName, toErrorObject, } from "../infra/errors.js"; +export { + coerceErrorMessage, + toStringifiedError, +} from "../../packages/normalization-core/src/error-coercion.js"; export { PlatformMessageNotDispatchedError } from "../infra/outbound/deliver-types.js"; export { isApprovalNotFoundError } from "../infra/approval-errors.ts"; diff --git a/src/plugin-sdk/plugin-config-runtime.ts b/src/plugin-sdk/plugin-config-runtime.ts index 665394aa0815..36b6a2921650 100644 --- a/src/plugin-sdk/plugin-config-runtime.ts +++ b/src/plugin-sdk/plugin-config-runtime.ts @@ -1,4 +1,5 @@ // Plugin config runtime helpers load and normalize plugin-owned configuration at execution time. +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawConfig } from "../config/types.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js"; @@ -21,9 +22,7 @@ export function resolvePluginConfigObject( pluginId: string, ): Record | undefined { const pluginConfig = normalizePluginsConfig(config?.plugins).entries[pluginId]?.config; - return pluginConfig && typeof pluginConfig === "object" && !Array.isArray(pluginConfig) - ? (pluginConfig as Record) - : undefined; + return asOptionalRecord(pluginConfig); } /** Resolves live plugin config through a loader, falling back to startup config when unavailable. */ diff --git a/src/plugin-sdk/provider-catalog-live-normalize.internal.ts b/src/plugin-sdk/provider-catalog-live-normalize.internal.ts index 8d9eb2f9cae2..ac896466bfaf 100644 --- a/src/plugin-sdk/provider-catalog-live-normalize.internal.ts +++ b/src/plugin-sdk/provider-catalog-live-normalize.internal.ts @@ -1,9 +1,8 @@ +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import type { ModelDefinitionConfig, ModelProviderConfig } from "./provider-model-shared.js"; export function readLiveModelCatalogRecord(body: unknown): Record | undefined { - return body && typeof body === "object" && !Array.isArray(body) - ? (body as Record) - : undefined; + return asOptionalRecord(body); } function readLiveModelString( diff --git a/src/plugin-sdk/provider-openai-chatgpt-auth.ts b/src/plugin-sdk/provider-openai-chatgpt-auth.ts index 74d5f9780fd1..e631ee1eb31e 100644 --- a/src/plugin-sdk/provider-openai-chatgpt-auth.ts +++ b/src/plugin-sdk/provider-openai-chatgpt-auth.ts @@ -1,5 +1,7 @@ // OpenAI ChatGPT auth helpers normalize OAuth session data for provider plugins. +import { safeParseJsonRecord } from "../../packages/normalization-core/src/json-coercion.js"; import { resolveExpiresAtMsFromEpochSeconds } from "../../packages/normalization-core/src/number-coercion.js"; +import { asNonArrayRecord } from "../../packages/normalization-core/src/record-coerce.js"; import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js"; const OPENAI_CODEX_AUTH_CLAIM = "https://api.openai.com/auth"; @@ -36,19 +38,14 @@ export function decodeOpenAICodexJwtPayload(token: string): Record) - : undefined; + return safeParseJsonRecord(Buffer.from(payload, "base64url").toString("utf8")); } catch { return undefined; } } function readRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; + return asNonArrayRecord(value); } /** diff --git a/src/plugin-sdk/string-coerce-runtime.ts b/src/plugin-sdk/string-coerce-runtime.ts index d94bb4ac999c..edb779534758 100644 --- a/src/plugin-sdk/string-coerce-runtime.ts +++ b/src/plugin-sdk/string-coerce-runtime.ts @@ -13,6 +13,7 @@ export { normalizeOptionalStringifiedId, normalizeStringifiedEntries, normalizeStringifiedOptionalString, + readNonBlankString, readNonEmptyStringPreservingWhitespace, readStringValue, } from "../../packages/normalization-core/src/string-coerce.js"; @@ -35,10 +36,12 @@ export { asNullableRecord, asOptionalObjectRecord, asOptionalRecord, + filterStringRecord, isRecord, readStringField, } from "../../packages/normalization-core/src/record-coerce.js"; export { + filterStringEntries, normalizeAtHashSlug, normalizeHyphenSlug, normalizeOptionalTrimmedStringList, diff --git a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts index a2c6af84eb15..90af56a0035d 100644 --- a/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts +++ b/src/plugin-sdk/test-helpers/plugin-runtime-mock.ts @@ -1,4 +1,5 @@ // Plugin runtime mock helpers build minimal runtime doubles for plugin SDK tests. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { vi } from "vitest"; import type { InboundDebounceCreateParams } from "../../auto-reply/inbound-debounce.js"; import { normalizeInboundTextNewlines } from "../../auto-reply/reply/inbound-text.js"; @@ -56,10 +57,6 @@ type ChannelStructuredContextResolution = | { kind: "absent" } | { kind: "present"; entries: ChannelStructuredContextEntries }; -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function mergeDeep(base: T, overrides: DeepPartial): T { const result: Record = { ...(base as Record) }; for (const [key, overrideValue] of Object.entries(overrides as Record)) { @@ -67,7 +64,7 @@ function mergeDeep(base: T, overrides: DeepPartial): T { continue; } const baseValue = result[key]; - if (isObject(baseValue) && isObject(overrideValue)) { + if (isRecord(baseValue) && isRecord(overrideValue)) { result[key] = mergeDeep(baseValue, overrideValue); continue; } diff --git a/src/plugin-sdk/text-runtime.ts b/src/plugin-sdk/text-runtime.ts index 2419c07262a3..e4f4145c83d2 100644 --- a/src/plugin-sdk/text-runtime.ts +++ b/src/plugin-sdk/text-runtime.ts @@ -41,7 +41,28 @@ export { resolvePrimaryStringValue, type FastMode, } from "../../packages/normalization-core/src/string-coerce.js"; -export * from "../../packages/normalization-core/src/string-normalization.js"; +// Public compatibility: keep this legacy barrel's existing normalization surface +// without promoting new normalization-core helpers into the Plugin SDK. +export { + normalizeArrayBackedTrimmedStringList, + normalizeAtHashSlug, + normalizeCsvOrLooseStringList, + normalizeHyphenSlug, + normalizeOptionalTrimmedStringList, + normalizeSingleOrTrimmedStringList, + normalizeSortedUniqueStringEntries, + normalizeSortedUniqueTrimmedStringList, + normalizeStringEntries, + normalizeStringEntriesLower, + normalizeTrimmedStringList, + normalizeUniqueSingleOrTrimmedStringList, + normalizeUniqueStringEntries, + normalizeUniqueStringEntriesLower, + normalizeUniqueTrimmedStringList, + sortUniqueStrings, + uniqueStrings, + uniqueValues, +} from "../../packages/normalization-core/src/string-normalization.js"; export * from "../shared/string-sample.js"; export * from "../shared/text/assistant-visible-text.js"; export * from "../shared/text/auto-linked-file-ref.js"; diff --git a/src/plugin-sdk/webhook-memory-guards.ts b/src/plugin-sdk/webhook-memory-guards.ts index eef8dd1b4954..8d7bfb5c1390 100644 --- a/src/plugin-sdk/webhook-memory-guards.ts +++ b/src/plugin-sdk/webhook-memory-guards.ts @@ -1,6 +1,6 @@ // Webhook memory guards keep in-process webhook dedupe and replay state bounded. +import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; import { pruneMapToMaxSize } from "../infra/map-size.js"; -import { resolveWebhookIntegerOption } from "./webhook-numeric-options.js"; type FixedWindowState = { count: number; @@ -81,24 +81,20 @@ export function createFixedWindowRateLimiter(options: { /** Optional interval for expired-window pruning. Defaults to `windowMs`. */ pruneIntervalMs?: number; }): FixedWindowRateLimiter { - const windowMs = resolveWebhookIntegerOption( - options.windowMs, - WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, - { - min: 1, - }, - ); - const maxRequests = resolveWebhookIntegerOption( + const windowMs = resolveIntegerOption(options.windowMs, WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs, { + min: 1, + }); + const maxRequests = resolveIntegerOption( options.maxRequests, WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests, { min: 1 }, ); - const maxTrackedKeys = resolveWebhookIntegerOption( + const maxTrackedKeys = resolveIntegerOption( options.maxTrackedKeys, WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys, { min: 1 }, ); - const pruneIntervalMs = resolveWebhookIntegerOption(options.pruneIntervalMs, windowMs, { + const pruneIntervalMs = resolveIntegerOption(options.pruneIntervalMs, windowMs, { min: 1, }); const state = new Map(); @@ -159,13 +155,13 @@ export function createBoundedCounter(options: { /** Optional interval for TTL pruning. */ pruneIntervalMs?: number; }): BoundedCounter { - const maxTrackedKeys = resolveWebhookIntegerOption( + const maxTrackedKeys = resolveIntegerOption( options.maxTrackedKeys, WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys, { min: 1 }, ); - const ttlMs = resolveWebhookIntegerOption(options.ttlMs, 0, { min: 0 }); - const pruneIntervalMs = resolveWebhookIntegerOption( + const ttlMs = resolveIntegerOption(options.ttlMs, 0, { min: 0 }); + const pruneIntervalMs = resolveIntegerOption( options.pruneIntervalMs, ttlMs > 0 ? ttlMs : 60_000, { min: 1 }, @@ -228,17 +224,15 @@ export function createWebhookAnomalyTracker(options?: { /** HTTP status codes that should be counted as anomalies. */ trackedStatusCodes?: readonly number[]; }): WebhookAnomalyTracker { - const maxTrackedKeys = resolveWebhookIntegerOption( + const maxTrackedKeys = resolveIntegerOption( options?.maxTrackedKeys, WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys, { min: 1 }, ); - const ttlMs = resolveWebhookIntegerOption( - options?.ttlMs, - WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs, - { min: 0 }, - ); - const logEvery = resolveWebhookIntegerOption( + const ttlMs = resolveIntegerOption(options?.ttlMs, WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs, { + min: 0, + }); + const logEvery = resolveIntegerOption( options?.logEvery, WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery, { min: 1 }, diff --git a/src/plugin-sdk/webhook-numeric-options.ts b/src/plugin-sdk/webhook-numeric-options.ts deleted file mode 100644 index c8226d9adfa8..000000000000 --- a/src/plugin-sdk/webhook-numeric-options.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Resolves webhook numeric options to finite integers with a minimum bound. */ -export function resolveWebhookIntegerOption( - value: number | undefined, - fallback: number, - params: { min: number }, -): number { - const candidate = typeof value === "number" && Number.isFinite(value) ? value : fallback; - return Math.max(params.min, Math.floor(candidate)); -} diff --git a/src/plugin-sdk/webhook-request-guards.ts b/src/plugin-sdk/webhook-request-guards.ts index 3b8613b14648..424208175c83 100644 --- a/src/plugin-sdk/webhook-request-guards.ts +++ b/src/plugin-sdk/webhook-request-guards.ts @@ -1,5 +1,6 @@ // Webhook request guards validate incoming HTTP requests before plugin webhook dispatch. import type { IncomingMessage, ServerResponse } from "node:http"; +import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalLowercaseString } from "../../packages/normalization-core/src/string-coerce.js"; import { formatErrorMessage } from "../infra/errors.js"; import { @@ -11,7 +12,6 @@ import { import { pruneMapToMaxSize } from "../infra/map-size.js"; import { runWithGatewayIndependentRootWorkContinuation } from "../process/gateway-work-admission.js"; import type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; -import { resolveWebhookIntegerOption } from "./webhook-numeric-options.js"; export { resolveAcceptedBrowserOrigin } from "../gateway/origin-check.js"; @@ -111,12 +111,12 @@ export function createWebhookInFlightLimiter(options?: { /** Maximum number of keys retained before oldest entries are pruned. */ maxTrackedKeys?: number; }): WebhookInFlightLimiter { - const maxInFlightPerKey = resolveWebhookIntegerOption( + const maxInFlightPerKey = resolveIntegerOption( options?.maxInFlightPerKey, WEBHOOK_IN_FLIGHT_DEFAULTS.maxInFlightPerKey, { min: 1 }, ); - const maxTrackedKeys = resolveWebhookIntegerOption( + const maxTrackedKeys = resolveIntegerOption( options?.maxTrackedKeys, WEBHOOK_IN_FLIGHT_DEFAULTS.maxTrackedKeys, { min: 1 }, diff --git a/src/plugins/runtime/runtime-llm.runtime.ts b/src/plugins/runtime/runtime-llm.runtime.ts index bb31722e843a..bac381ad27e9 100644 --- a/src/plugins/runtime/runtime-llm.runtime.ts +++ b/src/plugins/runtime/runtime-llm.runtime.ts @@ -323,10 +323,6 @@ function finalizeCompletion(params: { return { ...params.result, usage }; } -function finiteOption(value: number | undefined): number | undefined { - return asFiniteNumber(value); -} - function normalizeAllowedModelRef(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) { @@ -716,8 +712,8 @@ export function createRuntimeLlm( cfg, context, options: { - maxTokens: finiteOption(params.maxTokens), - temperature: finiteOption(params.temperature), + maxTokens: asFiniteNumber(params.maxTokens), + temperature: asFiniteNumber(params.temperature), ...(params.reasoning !== undefined ? { reasoning: params.reasoning } : {}), signal: params.signal, }, diff --git a/src/plugins/session-catalog-history-import.ts b/src/plugins/session-catalog-history-import.ts index bf4e154c4471..3e2ee0fe3809 100644 --- a/src/plugins/session-catalog-history-import.ts +++ b/src/plugins/session-catalog-history-import.ts @@ -1,3 +1,4 @@ +import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; import type { SessionCatalogTranscriptItem, SessionsCatalogReadResult, @@ -15,8 +16,7 @@ function importedSessionCatalogMessage(params: { item: SessionCatalogTranscriptItem; fallbackTimestamp: number; }): AgentMessage | undefined { - const parsedTimestamp = params.item.timestamp ? Date.parse(params.item.timestamp) : Number.NaN; - const timestamp = Number.isFinite(parsedTimestamp) ? parsedTimestamp : params.fallbackTimestamp; + const timestamp = parseDateStringTimestampMs(params.item.timestamp) ?? params.fallbackTimestamp; const importedText = params.item.text?.trim(); if (!importedText && params.item.type === "reasoning") { return undefined; diff --git a/src/plugins/status-snapshot.ts b/src/plugins/status-snapshot.ts index c05a2454cec4..a30617d3b7de 100644 --- a/src/plugins/status-snapshot.ts +++ b/src/plugins/status-snapshot.ts @@ -3,6 +3,7 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js"; import { resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; import { loadPluginRegistrySnapshotWithMetadata, @@ -32,46 +33,6 @@ type PluginRegistrySnapshotReportParams = { logger?: PluginLogger; }; -type TraceDetails = Record; - -function isPluginLifecycleTraceEnabled(): boolean { - const raw = process.env.OPENCLAW_PLUGIN_LIFECYCLE_TRACE?.trim().toLowerCase(); - return raw === "1" || raw === "true" || raw === "yes"; -} - -function formatTraceValue(value: boolean | number | string): string { - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return JSON.stringify(value); -} - -function tracePluginLifecyclePhase(phase: string, fn: () => T, details?: TraceDetails): T { - if (!isPluginLifecycleTraceEnabled()) { - return fn(); - } - const start = process.hrtime.bigint(); - let status: "error" | "ok" | undefined; - try { - const result = fn(); - status = "ok"; - return result; - } catch (error) { - status = "error"; - throw error; - } finally { - const elapsedMs = Number(process.hrtime.bigint() - start) / 1_000_000; - const detailText = Object.entries(details ?? {}) - .filter((entry): entry is [string, boolean | number | string] => entry[1] !== undefined) - .map(([key, value]) => `${key}=${formatTraceValue(value)}`) - .join(" "); - const suffix = detailText ? ` ${detailText}` : ""; - console.error( - `[plugins:lifecycle] phase=${JSON.stringify(phase)} ms=${elapsedMs.toFixed(2)} status=${status ?? "error"}${suffix}`, - ); - } -} - function buildPluginRecordFromInstalledIndex( plugin: import("./installed-plugin-index.js").InstalledPluginIndexRecord, manifest?: import("./manifest-registry.js").PluginManifestRecord, diff --git a/src/shared/number-coercion.test.ts b/src/shared/number-coercion.test.ts deleted file mode 100644 index 4fcb00bf4b32..000000000000 --- a/src/shared/number-coercion.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveNonNegativeNumber } from "./number-coercion.js"; - -describe("resolveNonNegativeNumber", () => { - it.each([0, -0, 1.25, Number.MIN_VALUE, Number.MAX_VALUE])( - "preserves finite non-negative value %s", - (value) => { - expect(resolveNonNegativeNumber(value)).toBe(value); - }, - ); - - it.each([-1, -0.1, Number.NaN, Infinity, -Infinity, null, undefined])( - "rejects invalid value %s", - (value) => { - expect(resolveNonNegativeNumber(value)).toBeUndefined(); - }, - ); -}); diff --git a/src/shared/number-coercion.ts b/src/shared/number-coercion.ts index 6d0465e500f6..4c93865de6ca 100644 --- a/src/shared/number-coercion.ts +++ b/src/shared/number-coercion.ts @@ -1,6 +1,2 @@ /** Shared numeric coercion facade for legacy imports inside core. */ export * from "@openclaw/normalization-core/number-coercion"; - -export function resolveNonNegativeNumber(value: number | null | undefined): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} diff --git a/src/state/openclaw-agent-db-schema.ts b/src/state/openclaw-agent-db-schema.ts index d52e0335c461..4d6512789fdf 100644 --- a/src/state/openclaw-agent-db-schema.ts +++ b/src/state/openclaw-agent-db-schema.ts @@ -1,5 +1,7 @@ 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 { normalizeNullableString as migratedText } from "@openclaw/normalization-core/string-coerce"; import type { SessionRunStatus } from "../../packages/gateway-protocol/src/schema/sessions-row.js"; import { @@ -377,24 +379,14 @@ function parseMigratedSessionEntry(value: unknown): MigratedSessionEntry | null if (typeof value !== "string") { return null; } - try { - const parsed = JSON.parse(value) as unknown; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as MigratedSessionEntry) - : null; - } catch { - return null; - } + return safeParseJsonRecord(value) ?? null; } function migratedObjectField( entry: MigratedSessionEntry, key: string, ): MigratedSessionEntry | null { - const value = entry[key]; - return value && typeof value === "object" && !Array.isArray(value) - ? (value as MigratedSessionEntry) - : null; + return asNullableRecord(entry[key]); } function migratedNumber(value: unknown): number | null { diff --git a/src/state/openclaw-state-db-legacy-backfills.ts b/src/state/openclaw-state-db-legacy-backfills.ts index 923f1611f7d4..cad73dc6adb8 100644 --- a/src/state/openclaw-state-db-legacy-backfills.ts +++ b/src/state/openclaw-state-db-legacy-backfills.ts @@ -1,4 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; +import { safeParseJsonRecord } from "@openclaw/normalization-core"; +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"; import { buildApprovalResolutionRef } from "../infra/approval-resolution-ref.js"; @@ -373,14 +375,7 @@ export function backfillCronRunLogEntryJson(db: DatabaseSync): void { } function parseJsonRecord(value: string): Record | null { - try { - const parsed = JSON.parse(value) as unknown; - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : null; - } catch { - return null; - } + return safeParseJsonRecord(value) ?? null; } function textField(record: Record, key: string): string | null { @@ -394,10 +389,7 @@ function numberField(record: Record, key: string): number | nul } function recordField(record: Record, key: string): Record | null { - const value = record[key]; - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; + return asNullableRecord(record[key]); } function jsonField(value: unknown): string | null { diff --git a/src/system-agent/setup-inference-persist.ts b/src/system-agent/setup-inference-persist.ts index 46306c876da0..3340e3f6716e 100644 --- a/src/system-agent/setup-inference-persist.ts +++ b/src/system-agent/setup-inference-persist.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { prepareSystemAgentRunAdmission } from "../agents/admitted-run-context.js"; import { listAgentEntries } from "../agents/agent-scope.js"; import { normalizeAuthProfileCredential } from "../agents/auth-profiles/credential-normalize.js"; @@ -212,7 +213,7 @@ export async function reloadCodexRegistryAfterActivation(params: { } function isMergePatchObject(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); + return isRecord(value); } function mergePatchConflicts(base: unknown, current: unknown, patch: unknown): boolean { diff --git a/src/talk/agent-run-control-shared.ts b/src/talk/agent-run-control-shared.ts index 0fd23f06bca2..ec0458a58419 100644 --- a/src/talk/agent-run-control-shared.ts +++ b/src/talk/agent-run-control-shared.ts @@ -4,6 +4,7 @@ * This module owns the provider-facing control tool, conservative intent * classifier, and user-visible status/queue/cancel messages used by Talk. */ +import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -240,17 +241,17 @@ export function parseRealtimeVoiceAgentControlToolArgs(args: unknown): { mode: RealtimeVoiceAgentControlMode; } { const parsed = parseRealtimeVoiceAgentControlToolArgsRecord(args); - const record = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + const record = asNonArrayRecord(parsed); const text = - normalizeOptionalString((record as Record).text) ?? - normalizeOptionalString((record as Record).message) ?? - normalizeOptionalString((record as Record).request) ?? - normalizeOptionalString((record as Record).query); + normalizeOptionalString(record.text) ?? + normalizeOptionalString(record.message) ?? + normalizeOptionalString(record.request) ?? + normalizeOptionalString(record.query); if (!text) { throw new Error("text required"); } const mode = - normalizeRealtimeVoiceAgentControlMode((record as Record).mode) ?? + normalizeRealtimeVoiceAgentControlMode(record.mode) ?? resolveRealtimeVoiceAgentControlIntent({ text }).mode; return { text, mode }; } diff --git a/src/tts/tts-settings.ts b/src/tts/tts-settings.ts index 4feb3c091d37..bf266e7bb28a 100644 --- a/src/tts/tts-settings.ts +++ b/src/tts/tts-settings.ts @@ -1,6 +1,7 @@ // Lightweight TTS settings resolution shared by agent prompts, status, and speech runtime. import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; +import { asNonArrayRecord, isRecord } from "../../packages/normalization-core/src/record-coerce.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -123,15 +124,11 @@ export function resolveTtsRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig { } export function asProviderConfig(value: unknown): SpeechProviderConfig { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? withSpeakerSelectionCompat(value as SpeechProviderConfig) - : {}; + return withSpeakerSelectionCompat(asNonArrayRecord(value)); } export function asProviderConfigMap(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; + return asNonArrayRecord(value); } function normalizeProviderConfigMap( @@ -154,7 +151,7 @@ function collectTtsPersonas(raw: TtsConfig): Record const personas: Record = {}; for (const [id, value] of Object.entries(rawPersonas)) { const normalizedId = normalizeTtsPersonaId(id); - if (!normalizedId || typeof value !== "object" || value === null || Array.isArray(value)) { + if (!normalizedId || !isRecord(value)) { continue; } const persona = value as Omit; @@ -193,7 +190,7 @@ function collectDirectProviderConfigEntries(raw: TtsConfig): Record { "scripts/windows-cmd-helpers.mjs", "scripts/lib/bundled-plugin-build-entries.mjs", "scripts/lib/bundled-plugin-paths.mjs", + "scripts/lib/error-format.mts", "scripts/lib/managed-child-process.mts", "scripts/lib/npm-json-output.mts", "scripts/lib/optional-bundled-clusters.mjs", diff --git a/test/extension-import-boundaries.test.ts b/test/extension-import-boundaries.test.ts index 63bb11671bfd..fd1eef0caf3e 100644 --- a/test/extension-import-boundaries.test.ts +++ b/test/extension-import-boundaries.test.ts @@ -1,10 +1,18 @@ // Extension import boundary tests enforce extension/core import rules. -import { describe, expect, it } from "vitest"; -import { main as extensionPluginSdkMain } from "../scripts/check-extension-plugin-sdk-boundary.mts"; +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createExtensionPluginSdkBoundaryChecker, + main as extensionPluginSdkMain, +} from "../scripts/check-extension-plugin-sdk-boundary.mts"; import { main as sdkPackageMain } from "../scripts/check-sdk-package-extension-import-boundary.mts"; import { main as srcExtensionMain } from "../scripts/check-src-extension-import-boundary.mts"; import { collectModuleReferencesFromSource } from "../scripts/lib/guard-inventory-utils.mjs"; import { createCapturedIo } from "./helpers/captured-io.js"; +import { useAutoCleanupTempDirTracker } from "./helpers/temp-dir.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); type CapturedIo = ReturnType["io"]; type JsonOutputPromise = ReturnType; @@ -33,6 +41,10 @@ const boundaryInventoryCases: Array<{ name: "extension relative-outside-package boundary", output: getJsonOutput(extensionPluginSdkMain, ["--mode=relative-outside-package", "--json"]), }, + { + name: "extension normalization-core bypass boundary", + output: getJsonOutput(extensionPluginSdkMain, ["--mode=normalization-core-bypass", "--json"]), + }, ]; describe("fast module reference scanner", () => { @@ -65,6 +77,197 @@ describe("extension import boundary inventories", () => { }); }); +type BoundaryFixture = { + file?: string; + packageJson?: unknown; + source: string; +}; + +function createBoundaryFixture(fixture: BoundaryFixture) { + const repoRoot = tempDirs.make("openclaw-normalization-boundary-"); + const pluginRoot = path.join(repoRoot, "extensions", "demo"); + const relativeFile = fixture.file ?? "src/runtime.ts"; + const filePath = path.join(pluginRoot, relativeFile); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, fixture.source, "utf8"); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify(fixture.packageJson ?? { name: "@openclaw/demo" }), + "utf8", + ); + return createExtensionPluginSdkBoundaryChecker({ repoRoot }); +} + +describe("production plugin normalization ownership boundary", () => { + it.each([ + { + name: "static string import", + source: + 'import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";', + kind: "import", + specifier: "@openclaw/normalization-core/string-coerce", + resolvedPath: "packages/normalization-core/src/string-coerce.ts", + facade: "openclaw/plugin-sdk/string-coerce-runtime", + }, + { + name: "record re-export from public barrel", + file: "api.ts", + source: 'export { isRecord } from "@openclaw/normalization-core/record-coerce";', + kind: "export", + specifier: "@openclaw/normalization-core/record-coerce", + resolvedPath: "packages/normalization-core/src/record-coerce.ts", + facade: "openclaw/plugin-sdk/string-coerce-runtime", + }, + { + name: "dynamic number import", + source: 'await import("@openclaw/normalization-core/number-coercion");', + kind: "dynamic-import", + specifier: "@openclaw/normalization-core/number-coercion", + resolvedPath: "packages/normalization-core/src/number-coercion.ts", + facade: "openclaw/plugin-sdk/number-runtime", + }, + { + name: "error import", + source: 'import { toErrorObject } from "@openclaw/normalization-core/error-coercion";', + kind: "import", + specifier: "@openclaw/normalization-core/error-coercion", + resolvedPath: "packages/normalization-core/src/error-coercion.ts", + facade: "openclaw/plugin-sdk/error-runtime", + }, + { + name: "bare package import", + source: 'import { expectDefined } from "@openclaw/normalization-core";', + kind: "import", + specifier: "@openclaw/normalization-core", + resolvedPath: "packages/normalization-core/src/index.ts", + }, + { + name: "relative normalization-core escape", + source: + 'import { isRecord } from "../../../packages/normalization-core/src/record-coerce.js";', + kind: "import", + specifier: "../../../packages/normalization-core/src/record-coerce.js", + resolvedPath: "packages/normalization-core/src/record-coerce.js", + facade: "openclaw/plugin-sdk/string-coerce-runtime", + }, + { + name: "relative boolean owner escape", + source: 'import { parseBooleanValue } from "../../../src/utils/boolean.js";', + kind: "import", + specifier: "../../../src/utils/boolean.js", + resolvedPath: "src/utils/boolean.js", + facade: "openclaw/plugin-sdk/string-coerce-runtime", + }, + { + name: "relative core error owner escape", + source: 'import { formatErrorMessage } from "../../../src/infra/errors.js";', + kind: "import", + specifier: "../../../src/infra/errors.js", + resolvedPath: "src/infra/errors.js", + facade: "openclaw/plugin-sdk/error-runtime", + }, + ])( + "rejects $name with a specific public facade when known", + async ({ source, file, kind, specifier, resolvedPath, facade }) => { + const checker = createBoundaryFixture({ source, file }); + const captured = createCapturedIo(); + const exitCode = await checker.main( + ["--mode=normalization-core-bypass", "--json"], + captured.io, + ); + const entries = JSON.parse(captured.readStdout()) as Array>; + + expect(exitCode).toBe(1); + expect(captured.readStderr()).toBe(""); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + file: `extensions/demo/${file ?? "src/runtime.ts"}`, + line: 1, + kind, + specifier, + resolvedPath, + }); + expect(entries[0]?.reason).toContain(facade ?? "matching public openclaw/plugin-sdk facade"); + }, + ); + + it.each([ + { + name: "approved SDK facades", + source: [ + 'import "openclaw/plugin-sdk/string-coerce-runtime";', + 'import "openclaw/plugin-sdk/number-runtime";', + 'import "openclaw/plugin-sdk/error-runtime";', + ].join("\n"), + }, + { name: "plugin-local import", source: 'import "./local.js";' }, + { + name: "test source", + file: "src/runtime.test.ts", + source: 'import "@openclaw/normalization-core/record-coerce";', + }, + { + name: "dist generated source", + file: "dist/generated.js", + source: 'import "@openclaw/normalization-core/record-coerce";', + }, + { + name: "declared generated asset", + file: "src/generated.js", + packageJson: { + name: "@openclaw/demo", + openclaw: { + assetScripts: { build: "node build.mjs" }, + build: { staticAssets: [{ source: "src/generated.js", output: "generated.js" }] }, + }, + }, + source: 'import "@openclaw/normalization-core/record-coerce";', + }, + { + name: "comments and string literals", + source: [ + '// import "@openclaw/normalization-core/record-coerce";', + 'const text = "import(\\\"@openclaw/normalization-core/error-coercion\\\")";', + ].join("\n"), + }, + { + name: "serialized local guard", + source: [ + "function isRecord(value: unknown) {", + ' return value !== null && typeof value === "object" && !Array.isArray(value);', + "}", + "export const serialized = isRecord.toString();", + ].join("\n"), + }, + ])("allows $name", async ({ source, file, packageJson }) => { + const checker = createBoundaryFixture({ source, file, packageJson }); + const captured = createCapturedIo(); + + expect(await checker.main(["--mode=normalization-core-bypass", "--json"], captured.io)).toBe(0); + expect(captured.readStderr()).toBe(""); + expect(JSON.parse(captured.readStdout())).toEqual([]); + }); + + it("renders actionable human diagnostics and fails strict mode", async () => { + const checker = createBoundaryFixture({ + source: 'export { toErrorObject } from "@openclaw/normalization-core/error-coercion";', + file: "runtime-api.ts", + }); + const captured = createCapturedIo(); + + expect(await checker.main(["--mode=normalization-core-bypass"], captured.io)).toBe(1); + expect(captured.readStdout()).toContain( + "Rule: production bundled plugins must not import normalization-core directly", + ); + expect(captured.readStdout()).toContain("extensions/demo/runtime-api.ts"); + expect(captured.readStdout()).toContain("line 1 [export]"); + expect(captured.readStdout()).toContain("re-exports"); + expect(captured.readStdout()).toContain("@openclaw/normalization-core/error-coercion"); + expect(captured.readStdout()).toContain("openclaw/plugin-sdk/error-runtime"); + expect(captured.readStderr()).toContain("this strict mode has no baseline"); + }); +}); + async function getJsonOutput( main: (argv: string[], io: CapturedIo) => Promise, argv: string[], diff --git a/test/package-scripts.test.ts b/test/package-scripts.test.ts index 6a440359de34..6adacae91c67 100644 --- a/test/package-scripts.test.ts +++ b/test/package-scripts.test.ts @@ -152,6 +152,12 @@ describe("package scripts", () => { ); }); + it("exposes the strict production plugin normalization boundary command", () => { + expect(readPackageJson().scripts["lint:extensions:no-normalization-core-bypass"]).toBe( + "node --import tsx scripts/check-extension-plugin-sdk-boundary.mts --mode=normalization-core-bypass", + ); + }); + it("runs runtime postbuild before plugin SDK strict export checks", () => { expect(readPackageJson().scripts["build:plugin-sdk:strict-smoke"]).toBe( "node --import tsx scripts/tsdown-build.mts && node scripts/runtime-postbuild.mjs && node --import tsx scripts/run-with-env.mts OPENCLAW_PLUGIN_SDK_CANONICAL_DTS=1 -- node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/check-plugin-sdk-exports.mts", diff --git a/test/scripts/check-coercion-helper-declarations.test.ts b/test/scripts/check-coercion-helper-declarations.test.ts index 4e9f8bcb1946..ba566eedbc3e 100644 --- a/test/scripts/check-coercion-helper-declarations.test.ts +++ b/test/scripts/check-coercion-helper-declarations.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -46,6 +47,8 @@ describe("coercion helper declaration AST guard", () => { "};", "function normalizeOptionalString() {}", "const parseDateFirstTimestampMs = () => 0;", + "function safeParseJsonRecord() {}", + "function resolveIntegerOption() {}", ].join("\n"); expect(findBannedCoercionHelperDeclarations(source, "src/example.ts")).toEqual([ @@ -88,6 +91,18 @@ describe("coercion helper declaration AST guard", () => { line: 30, name: "parseDateFirstTimestampMs", }, + { + file: "src/example.ts", + kind: "function", + line: 31, + name: "safeParseJsonRecord", + }, + { + file: "src/example.ts", + kind: "function", + line: 32, + name: "resolveIntegerOption", + }, ]); }); @@ -109,50 +124,102 @@ describe("coercion helper declaration AST guard", () => { expect(findBannedCoercionHelperDeclarations(source, "src/example.ts")).toEqual([]); }); - it("checks exact counts and rejects excess, stale, or malformed carve-outs", () => { + it("allows an exact function-kind declaration and reports ordinary excess/stale counts", () => { const declarations: CoercionHelperDeclaration[] = [ { file: "src/allowed.ts", kind: "function", line: 2, name: "isRecord" }, - { file: "src/allowed.ts", kind: "variable", line: 8, name: "isRecord" }, { file: "src/new.ts", kind: "function", line: 4, name: "readString" }, ]; const carveOuts: CoercionHelperCarveOut[] = [ { file: "src/allowed.ts", name: "isRecord", + kind: "function", count: 1, reason: "Dependency-free protocol boundary.", }, { file: "src/removed.ts", name: "toError", + kind: "function", count: 1, reason: "Hostile object trap semantics.", }, - { file: "src/blank.ts", name: "asRecord", count: 0, reason: "" }, ]; expect(auditCoercionHelperDeclarations(declarations, carveOuts)).toEqual({ - excessDeclarations: [ - { file: "src/allowed.ts", kind: "variable", line: 8, name: "isRecord" }, - { file: "src/new.ts", kind: "function", line: 4, name: "readString" }, - ], - invalidCarveOuts: [ - "src/blank.ts [asRecord] must have a positive count", - "src/blank.ts [asRecord] needs a non-empty reason", - ], + excessDeclarations: [{ file: "src/new.ts", kind: "function", line: 4, name: "readString" }], + invalidCarveOuts: [], staleCarveOuts: [ { file: "src/removed.ts", name: "toError", + kind: "function", count: 1, reason: "Hostile object trap semantics.", actualCount: 0, - lines: [], }, ], }); }); + it.each(["method", "field", "property"] as const)( + "treats %s drift as both excess and stale function ownership", + (kind) => { + const declaration: CoercionHelperDeclaration = { + file: "src/owner.ts", + kind, + line: 3, + name: "isRecord", + }; + const carveOut: CoercionHelperCarveOut = { + file: "src/owner.ts", + name: "isRecord", + kind: "function", + count: 1, + reason: "Exact function owner.", + }; + + expect(auditCoercionHelperDeclarations([declaration], [carveOut])).toEqual({ + excessDeclarations: [declaration], + invalidCarveOuts: [], + staleCarveOuts: [{ ...carveOut, actualCount: 0 }], + }); + }, + ); + + it("rejects duplicate, non-banned, and malformed carve-outs", () => { + const valid: CoercionHelperCarveOut = { + file: "src/owner.ts", + name: "isRecord", + kind: "function", + count: 1, + reason: "Exact function owner.", + }; + const invalid = [ + valid, + valid, + { + ...valid, + file: "src/not-banned.ts", + name: "domainParser", + } as unknown as CoercionHelperCarveOut, + { ...valid, file: "src/blank.ts", count: 0, reason: "" }, + { + ...valid, + file: "src/kind.ts", + kind: "getter", + } as unknown as CoercionHelperCarveOut, + ]; + + expect(auditCoercionHelperDeclarations([], invalid).invalidCarveOuts).toEqual([ + "src/owner.ts [isRecord] is listed more than once", + "src/not-banned.ts [domainParser] is not a banned helper name", + "src/blank.ts [isRecord] must have a positive count", + "src/blank.ts [isRecord] needs a non-empty reason", + "src/kind.ts [isRecord] has invalid kind getter", + ]); + }); + it("excludes structural fixtures and generated sources without hiding authored fixture-named files", () => { expect(isGovernedCoercionHelperPath("src/runtime.ts")).toBe(true); expect(isGovernedCoercionHelperPath("extensions/demo/runtime.jsx")).toBe(true); @@ -178,7 +245,7 @@ describe("coercion helper declaration AST guard", () => { fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); fs.mkdirSync(path.join(repoRoot, "extensions", "demo"), { recursive: true }); fs.mkdirSync(path.join(repoRoot, "config"), { recursive: true }); - fs.writeFileSync(path.join(repoRoot, "src", "z.ts"), "function readString() {}\n"); + fs.writeFileSync(path.join(repoRoot, "src", "z.ts"), "class Owner { readString() {} }\n"); fs.writeFileSync( path.join(repoRoot, "extensions", "demo", "a.ts"), "const asRecord = () => ({});\n", @@ -191,7 +258,15 @@ describe("coercion helper declaration AST guard", () => { const stderr: string[] = []; expect( runCoercionHelperDeclarationGuard({ - carveOuts: [], + carveOuts: [ + { + file: "src/z.ts", + name: "readString", + kind: "function", + count: 1, + reason: "Exact function owner.", + }, + ], repoRoot, io: { stdout: { write: (value) => stdout.push(value) }, @@ -206,8 +281,39 @@ describe("coercion helper declaration AST guard", () => { output.indexOf("extensions/demo/a.ts:1"), ); expect(output.indexOf("extensions/demo/a.ts:1")).toBeLessThan(output.indexOf("src/z.ts:1")); + expect(output).toContain("Banned local coercion-helper declarations:"); + expect(output).toContain("readString (method declaration)"); + expect(output).toContain("Stale coercion-helper carve-outs:"); + expect(output).toContain("expected 1 function declaration(s), found 0"); expect(output).toContain("Core/package/UI/workspace-script code"); expect(output).toContain("Plugin production code"); + expect(output).toContain("number-runtime"); expect(output).toContain("Dependency-free, copied, generated, or serialized code"); }); + + it("scans only tracked files when the repository has a Git index", () => { + const repoRoot = tempDirs.make("coercion-helper-tracked-guard-"); + fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(repoRoot, "src", "tracked.ts"), "function readString() {}\n"); + fs.writeFileSync(path.join(repoRoot, "src", "untracked.ts"), "function readNumber() {}\n"); + execFileSync("git", ["init", "-q"], { cwd: repoRoot }); + execFileSync("git", ["add", "src/tracked.ts"], { cwd: repoRoot }); + const stderr: string[] = []; + + expect( + runCoercionHelperDeclarationGuard({ + carveOuts: [], + repoRoot, + io: { + stdout: { write: () => undefined }, + stderr: { write: (value) => stderr.push(value) }, + }, + }), + ).toBe(1); + + const output = stderr.join(""); + expect(output).toContain("src/tracked.ts:1 readString"); + expect(output).not.toContain("src/untracked.ts"); + expect(output).not.toContain("readNumber"); + }); }); diff --git a/test/scripts/run-additional-boundary-checks.test.ts b/test/scripts/run-additional-boundary-checks.test.ts index c422094ec5e2..edcf9410a626 100644 --- a/test/scripts/run-additional-boundary-checks.test.ts +++ b/test/scripts/run-additional-boundary-checks.test.ts @@ -254,6 +254,14 @@ describe("run-additional-boundary-checks", () => { }); }); + it("keeps the production plugin normalization boundary in CI checks", () => { + expect(BOUNDARY_CHECKS).toContainEqual({ + label: "extension-normalization-core-bypass-boundary", + command: "pnpm", + args: ["run", "lint:extensions:no-normalization-core-bypass"], + }); + }); + it("keeps native and Node state schema versions aligned in CI", () => { expect(BOUNDARY_CHECKS).toContainEqual({ label: "native-state-schema-version", diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index b7485b791154..a6549d6432b3 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -144,6 +144,29 @@ describe("runtime postbuild static assets", () => { ]); }); + it.each([ + { name: "top-level array", packageJson: [] }, + { name: "array openclaw section", packageJson: { openclaw: [] } }, + { name: "array build section", packageJson: { openclaw: { build: [] } } }, + { + name: "non-record asset entries", + packageJson: { + openclaw: { + build: { + staticAssets: [[], "asset", null, { source: 42, output: [] }], + }, + }, + }, + }, + ])("ignores malformed $name metadata", async ({ packageJson }) => { + const rootDir = createTempDir("openclaw-runtime-postbuild-malformed-"); + const packageDir = path.join(rootDir, "extensions", "demo"); + await fs.mkdir(packageDir, { recursive: true }); + await fs.writeFile(path.join(packageDir, "package.json"), JSON.stringify(packageJson), "utf8"); + + expect(discoverStaticExtensionAssets({ rootDir })).toEqual([]); + }); + it("excludes external plugin (bundledDist: false) static assets by default", async () => { const rootDir = createTempDir("openclaw-runtime-postbuild-"); const packageDir = path.join(rootDir, "extensions", "external-demo"); diff --git a/ui/src/api/gateway.node.test.ts b/ui/src/api/gateway.node.test.ts index 4bd25e56b401..64a139d0bdb2 100644 --- a/ui/src/api/gateway.node.test.ts +++ b/ui/src/api/gateway.node.test.ts @@ -6,6 +6,7 @@ import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION, } from "@openclaw/gateway-client/browser"; +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../../test/helpers/promise.js"; import { @@ -202,12 +203,7 @@ type ConnectTimingPayload = { errorCode?: string; }; -function requireRecord(value: unknown, label: string): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`expected ${label}`); - } - return value as Record; -} +const requireRecord = createRequireRecord("record", "expected-label"); function requireFirstMockArg( mock: ReturnType, diff --git a/ui/src/app/canvas-surface-lease.runtime.ts b/ui/src/app/canvas-surface-lease.runtime.ts index c030b493a9a1..a39a383dfa25 100644 --- a/ui/src/app/canvas-surface-lease.runtime.ts +++ b/ui/src/app/canvas-surface-lease.runtime.ts @@ -1,5 +1,6 @@ // Loaded after hello so capability renewal does not inflate the startup chunk. import { resolveSafeTimeoutDelayMs } from "@openclaw/gateway-client/browser"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; const RENEWAL_LEAD_MS = 15_000; const MIN_RENEWAL_DELAY_MS = 1_000; @@ -162,10 +163,10 @@ function parseCanvasSurfaceRefresh(value: unknown): CanvasSurfaceRefresh | undef return undefined; } const urls = response.pluginSurfaceUrls; - if (!urls || typeof urls !== "object" || Array.isArray(urls)) { + if (!isRecord(urls)) { return undefined; } - const canvasUrl = (urls as Record).canvas; + const canvasUrl = urls.canvas; if (typeof canvasUrl !== "string" || !canvasUrl.trim()) { return undefined; } diff --git a/ui/src/app/cloud-session-startup.runtime.ts b/ui/src/app/cloud-session-startup.runtime.ts index 0f8f8b51f9cd..e9dc63b6fdbf 100644 --- a/ui/src/app/cloud-session-startup.runtime.ts +++ b/ui/src/app/cloud-session-startup.runtime.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { GatewaySessionRow } from "../api/types.ts"; import { createGatewayConnectionLifecycle, @@ -64,10 +65,10 @@ const MIME_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i; const BASE64_CONTENT = /^[A-Za-z0-9+/]+={0,2}$/; function readDurableAttachment(value: unknown): DurableAttachment | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const mimeType = typeof record.mimeType === "string" ? record.mimeType.trim() : ""; const content = typeof record.content === "string" ? record.content : ""; if (!MIME_TYPE.test(mimeType) || !BASE64_CONTENT.test(content)) { diff --git a/ui/src/app/update-schedule-dto.ts b/ui/src/app/update-schedule-dto.ts index c45404862ee2..48b5252bb019 100644 --- a/ui/src/app/update-schedule-dto.ts +++ b/ui/src/app/update-schedule-dto.ts @@ -7,7 +7,7 @@ import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts"; export function readUpdateAvailable(hello: GatewayHelloOk | null): UpdateAvailable | null { const snapshot = hello?.snapshot; - if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + if (!isRecord(snapshot)) { return null; } const update = (snapshot as { updateAvailable?: unknown }).updateAvailable; diff --git a/ui/src/components/config-form.node.shared.ts b/ui/src/components/config-form.node.shared.ts index 29a495e5ff53..7331a944a185 100644 --- a/ui/src/components/config-form.node.shared.ts +++ b/ui/src/components/config-form.node.shared.ts @@ -1,4 +1,5 @@ // Control UI helpers shared by config form node renderers. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; import type { ConfigUiHints } from "../api/types.ts"; @@ -111,7 +112,7 @@ export function isSecretRefObject(value: unknown): value is { id: string; provider?: string; } { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return false; } const candidate = value as Record; diff --git a/ui/src/e2e/agent-config-save.e2e.test.ts b/ui/src/e2e/agent-config-save.e2e.test.ts index 6f8dc15ce79b..085e26297ef7 100644 --- a/ui/src/e2e/agent-config-save.e2e.test.ts +++ b/ui/src/e2e/agent-config-save.e2e.test.ts @@ -1,6 +1,7 @@ // Control UI E2E proves per-agent config writes use the canonical keyed shape. import { mkdir } from "node:fs/promises"; import path from "node:path"; +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { expect, it } from "vitest"; import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; @@ -14,12 +15,7 @@ const suite = createControlUiE2eSuite({ const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1"; const proofDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "agent-config-save"); -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} +const requireRecord = createRequireRecord("record", "expected-object-value"); suite.define(() => { it("submits keyed entries and surfaces Gateway validation failures", async () => { diff --git a/ui/src/e2e/agents-set-default-persistence.e2e.test.ts b/ui/src/e2e/agents-set-default-persistence.e2e.test.ts index 80c2ce48fcae..7304bd142c3f 100644 --- a/ui/src/e2e/agents-set-default-persistence.e2e.test.ts +++ b/ui/src/e2e/agents-set-default-persistence.e2e.test.ts @@ -1,4 +1,5 @@ // Control UI tests cover Agents page Set Default persistence behavior. +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { expect, it } from "vitest"; import { installMockGateway, type MockGatewayRequest } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; @@ -10,12 +11,7 @@ const suite = createControlUiE2eSuite({ `Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`, }); -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} +const requireRecord = createRequireRecord("record", "expected-object-value"); function requestParams(request: MockGatewayRequest): Record { return requireRecord(request.params); diff --git a/ui/src/e2e/approval-flow.e2e.test.ts b/ui/src/e2e/approval-flow.e2e.test.ts index 7a2aa6a39071..0a643b8df2d9 100644 --- a/ui/src/e2e/approval-flow.e2e.test.ts +++ b/ui/src/e2e/approval-flow.e2e.test.ts @@ -1,4 +1,5 @@ // Control UI E2E tests cover approval queue behavior through the Gateway WebSocket. +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import type { Page } from "playwright"; import { afterEach, expect, it } from "vitest"; import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; @@ -19,12 +20,7 @@ function approval(id: string, command: string, createdAtMs: number) { }; } -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} +const requireRecord = createRequireRecord("record", "expected-object-value"); suite.define(() => { afterEach(async () => { diff --git a/ui/src/e2e/chat-flow.test-support.ts b/ui/src/e2e/chat-flow.test-support.ts index e3f675bf03f0..77e46fc1fa2c 100644 --- a/ui/src/e2e/chat-flow.test-support.ts +++ b/ui/src/e2e/chat-flow.test-support.ts @@ -1,6 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import type { Page } from "playwright"; import { expect } from "vitest"; import { SESSION_DRAG_MIME } from "../lib/sessions/drag.ts"; @@ -51,12 +52,7 @@ export function createChatFlowE2eSuite() { }); } -export function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} +export const requireRecord = createRequireRecord("record", "expected-object-value"); export function requireString(value: unknown, label: string): string { if (typeof value !== "string" || !value.trim()) { diff --git a/ui/src/e2e/chat-large-paste-99213.e2e.test.ts b/ui/src/e2e/chat-large-paste-99213.e2e.test.ts index 36844567eecc..aa1c04e255dc 100644 --- a/ui/src/e2e/chat-large-paste-99213.e2e.test.ts +++ b/ui/src/e2e/chat-large-paste-99213.e2e.test.ts @@ -2,6 +2,7 @@ // real chat composer and verify chat.send receives it without overflowing base64 handling. import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { @@ -28,12 +29,7 @@ type RecordedPage = { rawVideoDir: string; }; -function requireRecord(value: unknown, label: string): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`Expected ${label} to be an object`); - } - return value as Record; -} +const requireRecord = createRequireRecord("record", "expected-label-object-capitalized"); function requireString(value: unknown, label: string): string { if (typeof value !== "string" || !value) { diff --git a/ui/src/e2e/control-ui-credentials.e2e.test.ts b/ui/src/e2e/control-ui-credentials.e2e.test.ts index b44bd6bdf3e2..d9f6a7e96f12 100644 --- a/ui/src/e2e/control-ui-credentials.e2e.test.ts +++ b/ui/src/e2e/control-ui-credentials.e2e.test.ts @@ -1,6 +1,7 @@ // Control UI tests cover browser credential submission and visible recovery. import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; @@ -29,12 +30,7 @@ let browser: Browser; let server: ControlUiE2eServer; const openContexts = new Set(); -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} +const requireRecord = createRequireRecord("record", "expected-object-value"); function readConnectAuth(request: MockGatewayRequest): Record | undefined { const auth = requireRecord(request.params).auth; diff --git a/ui/src/e2e/cron-filters.e2e.test.ts b/ui/src/e2e/cron-filters.e2e.test.ts index 0cc1587c0561..f0d1fdbe7776 100644 --- a/ui/src/e2e/cron-filters.e2e.test.ts +++ b/ui/src/e2e/cron-filters.e2e.test.ts @@ -1,4 +1,5 @@ // Control UI tests cover cron filters behavior. +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import type { Locator, Page } from "playwright"; import { expect, it } from "vitest"; import { @@ -53,12 +54,7 @@ function cronRunsResponse(entries: unknown[], total = entries.length) { }; } -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} +const requireRecord = createRequireRecord("record", "expected-object-value"); function requestParams(request: MockGatewayRequest): Record { return requireRecord(request.params); diff --git a/ui/src/e2e/device-token-reconnect.e2e.test.ts b/ui/src/e2e/device-token-reconnect.e2e.test.ts index cc3f99eaf429..19fdb2c402df 100644 --- a/ui/src/e2e/device-token-reconnect.e2e.test.ts +++ b/ui/src/e2e/device-token-reconnect.e2e.test.ts @@ -2,6 +2,7 @@ import { mkdir } from "node:fs/promises"; import path from "node:path"; import { gatewayCredentialScope, gatewayOriginScope } from "@openclaw/gateway-client/browser"; +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { @@ -34,12 +35,7 @@ const ROSITA_DEVICE_TOKEN = "rosita-device-token"; const WILFRED_DEVICE_TOKEN = "wilfred-device-token"; const WILFRED_ROTATED_TOKEN = "wilfred-rotated-device-token"; -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} +const requireRecord = createRequireRecord("record", "expected-object-value"); function readConnectAuth(request: { params?: unknown }): Record | undefined { const auth = requireRecord(request.params).auth; diff --git a/ui/src/e2e/session-management.test-support.ts b/ui/src/e2e/session-management.test-support.ts index 4847159350f8..64c489d3e6ab 100644 --- a/ui/src/e2e/session-management.test-support.ts +++ b/ui/src/e2e/session-management.test-support.ts @@ -1,5 +1,6 @@ import { mkdir } from "node:fs/promises"; import path from "node:path"; +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import type { Locator, Page } from "playwright"; import { expect } from "vitest"; import { @@ -97,12 +98,7 @@ export function sessionsListResponse( }; } -export function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} +export const requireRecord = createRequireRecord("record", "expected-object-value"); export async function waitForPatch( gateway: MockGatewayControls, diff --git a/ui/src/lib/board/settings.ts b/ui/src/lib/board/settings.ts index 530a63360cbe..21501e0c0f4a 100644 --- a/ui/src/lib/board/settings.ts +++ b/ui/src/lib/board/settings.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { SessionBoardFace } from "../../../../src/shared/session-types.js"; import type { BoardTab } from "./types.ts"; @@ -16,22 +17,18 @@ export type BoardSessionViews = Record; const MAX_BOARD_SESSION_VIEWS = 50; export function normalizeBoardSessionViews(value: unknown): BoardSessionViews { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return {}; } const normalized: BoardSessionViews = {}; for (const [sessionKey, rawView] of Object.entries(value)) { - if (!sessionKey.trim() || !rawView || typeof rawView !== "object" || Array.isArray(rawView)) { + if (!sessionKey.trim() || !isRecord(rawView)) { continue; } - const view = rawView as Record; + const view = rawView; const activeTabId = typeof view.activeTabId === "string" ? view.activeTabId.trim() : ""; const reopenDockByTab: Record = {}; - if ( - view.reopenDockByTab && - typeof view.reopenDockByTab === "object" && - !Array.isArray(view.reopenDockByTab) - ) { + if (isRecord(view.reopenDockByTab)) { for (const [tabId, dock] of Object.entries(view.reopenDockByTab).slice(0, 50)) { const key = tabId.trim(); if (key && (dock === "bottom" || dock === "left" || dock === "right")) { diff --git a/ui/src/lib/board/widget-bridge.ts b/ui/src/lib/board/widget-bridge.ts index 45c8c57b5263..42089c7b1db5 100644 --- a/ui/src/lib/board/widget-bridge.ts +++ b/ui/src/lib/board/widget-bridge.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { dispatchWidgetPrompt } from "../../components/mcp-app-security.ts"; type BoardWidgetBridgeRequest = { @@ -35,10 +36,10 @@ export function isBoardWidgetBridgeRequest(value: unknown): value is BoardWidget } function assertWidgetRequestRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { throw new Error("widget host request params are invalid"); } - return value as Record; + return value; } function requiredString(params: Record, key: string): string { @@ -162,10 +163,7 @@ export class BoardWidgetBridgeController { case "data.read": { const bindingId = requiredString(params, "bindingId"); const bindingParams = params.params; - if ( - bindingParams !== undefined && - (!bindingParams || typeof bindingParams !== "object" || Array.isArray(bindingParams)) - ) { + if (bindingParams !== undefined && !isRecord(bindingParams)) { throw new Error("widget data binding params are invalid"); } return await this.client.request("board.data.read", { diff --git a/ui/src/lib/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts index 01bca21f38a9..4034c2eef8b4 100644 --- a/ui/src/lib/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -99,10 +99,10 @@ function coerceCanvasPreview( ): | Extract, { type: "canvas" }>["preview"] | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const preview = value as Record; + const preview = value; if (preview.kind !== "canvas" || preview.surface === "tool_card") { return null; } @@ -110,10 +110,7 @@ function coerceCanvasPreview( if (!render) { return null; } - const mcpApp = - preview.mcpApp && typeof preview.mcpApp === "object" && !Array.isArray(preview.mcpApp) - ? (preview.mcpApp as Record) - : undefined; + const mcpApp = isRecord(preview.mcpApp) ? preview.mcpApp : undefined; return { kind: "canvas", surface: "assistant_message", @@ -241,10 +238,10 @@ function coerceAudioContentBlock( return null; } const source = item.source; - if (!source || typeof source !== "object" || Array.isArray(source)) { + if (!isRecord(source)) { return null; } - const sourceRecord = source as Record; + const sourceRecord = source; const mediaType = typeof sourceRecord.media_type === "string" && sourceRecord.media_type.trim().toLowerCase().startsWith("audio/") @@ -494,12 +491,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage { } else if (item.type === "audio") { return []; } - if ( - item.type === "attachment" && - item.attachment && - typeof item.attachment === "object" && - !Array.isArray(item.attachment) - ) { + if (item.type === "attachment" && isRecord(item.attachment)) { const attachment = item.attachment as { url?: unknown; kind?: unknown; @@ -554,12 +546,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage { }, ]; } - if ( - item.type === "canvas" && - item.preview && - typeof item.preview === "object" && - !Array.isArray(item.preview) - ) { + if (item.type === "canvas" && isRecord(item.preview)) { const preview = coerceCanvasPreview(item.preview); if (!preview) { return []; @@ -619,20 +606,14 @@ export function normalizeMessage(message: unknown): NormalizedMessage { const timestamp = typeof m.timestamp === "number" ? m.timestamp : Date.now(); const id = typeof m.id === "string" ? m.id : undefined; const rawOpenClawMeta = m["__openclaw"]; - const openClawMeta = - rawOpenClawMeta && typeof rawOpenClawMeta === "object" && !Array.isArray(rawOpenClawMeta) - ? (rawOpenClawMeta as Record) - : undefined; + const openClawMeta = isRecord(rawOpenClawMeta) ? rawOpenClawMeta : undefined; const structuredReplyToId = typeof openClawMeta?.replyToId === "string" ? openClawMeta.replyToId.trim() : ""; if (structuredReplyToId) { replyTarget = { kind: "id", id: structuredReplyToId }; } const rawReplyPreview = openClawMeta?.replyToPreview; - const replyPreviewRecord = - rawReplyPreview && typeof rawReplyPreview === "object" && !Array.isArray(rawReplyPreview) - ? (rawReplyPreview as Record) - : undefined; + const replyPreviewRecord = isRecord(rawReplyPreview) ? rawReplyPreview : undefined; const replyPreviewText = typeof replyPreviewRecord?.text === "string" ? replyPreviewRecord.text.trim() : ""; const replyPreviewSender = diff --git a/ui/src/lib/chat/outbox-store-codec.ts b/ui/src/lib/chat/outbox-store-codec.ts index 7a35abbe8087..b75a80921b71 100644 --- a/ui/src/lib/chat/outbox-store-codec.ts +++ b/ui/src/lib/chat/outbox-store-codec.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { readNonBlankString as normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeAgentId } from "../sessions/session-key.ts"; import type { ChatAttachment, ChatQueueItem } from "./chat-types.ts"; @@ -24,10 +25,10 @@ function normalizeOptionalBoolean(value: unknown): boolean | undefined { } function normalizeChatAttachment(value: unknown): ChatAttachment | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const entry = value as Record; + const entry = value; const id = normalizeOptionalString(entry.id); const mimeType = normalizeOptionalString(entry.mimeType); if (!id || !mimeType) { @@ -49,10 +50,10 @@ function normalizeChatAttachment(value: unknown): ChatAttachment | null { } export function normalizeStoredQueueItem(value: unknown): ChatQueueItem | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const entry = value as Record; + const entry = value; if (entry.skillWorkshopRevision !== undefined) { return null; } @@ -138,10 +139,10 @@ export function normalizeStoredQueueItem(value: unknown): ChatQueueItem | null { } export function normalizeStoredSession(value: unknown): StoredComposerSession | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const entry = value as Record; + const entry = value; const draft = typeof entry.draft === "string" ? entry.draft : undefined; const normalizedQueue = Array.isArray(entry.queue) ? entry.queue diff --git a/ui/src/lib/chat/tool-cards.ts b/ui/src/lib/chat/tool-cards.ts index e9220963667b..c0d36f17f002 100644 --- a/ui/src/lib/chat/tool-cards.ts +++ b/ui/src/lib/chat/tool-cards.ts @@ -1,4 +1,8 @@ -import { asNullableObjectRecord as readRecord } from "@openclaw/normalization-core/record-coerce"; +import { + asNullableObjectRecord as readRecord, + asNullableRecord, + isRecord, +} from "@openclaw/normalization-core/record-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; // Control UI chat domain owns pure tool-card extraction rules. import { @@ -22,10 +26,7 @@ function resolveTranscriptMessageId(message: Record): string | return message.messageId; } const openClawMeta = message["__openclaw"]; - const transcriptMeta = - openClawMeta && typeof openClawMeta === "object" && !Array.isArray(openClawMeta) - ? (openClawMeta as Record) - : null; + const transcriptMeta = asNullableRecord(openClawMeta); return typeof transcriptMeta?.id === "string" && transcriptMeta.id.trim() ? transcriptMeta.id : undefined; @@ -116,10 +117,10 @@ function isToolErrorOutput(outputText: string | undefined): boolean { } catch { return false; } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (!isRecord(parsed)) { return false; } - const obj = parsed as Record; + const obj = parsed; const explicitErrorFlag = readToolErrorFlag(obj); if (explicitErrorFlag !== undefined) { return explicitErrorFlag; @@ -274,10 +275,10 @@ const TOOL_ARGUMENT_PREVIEW_KEYS = [ /** First meaningful user-authored line for compact generic tool rows. */ export function resolveCollapsedToolArgumentPreview(args: unknown): string | undefined { - if (!args || typeof args !== "object" || Array.isArray(args)) { + if (!isRecord(args)) { return undefined; } - const record = args as Record; + const record = args; for (const key of TOOL_ARGUMENT_PREVIEW_KEYS) { const value = record[key]; if (typeof value !== "string") { diff --git a/ui/src/lib/config-form-utils.ts b/ui/src/lib/config-form-utils.ts index 0ff201025afb..c576026094d8 100644 --- a/ui/src/lib/config-form-utils.ts +++ b/ui/src/lib/config-form-utils.ts @@ -18,10 +18,6 @@ function keepValue(value: unknown): SanitizeResult { return { omitted: false, value }; } -function hasOwnRecordValue(record: Record | null, key: string): boolean { - return record != null && Object.hasOwn(record, key); -} - function sanitizeRedactedValue(params: { value: unknown; originalFormValue: unknown; @@ -70,7 +66,8 @@ function sanitizeRedactedValue(params: { originalFormRecord != null && Object.hasOwn(originalFormRecord, key) ? originalFormRecord[key] : undefined; - const originalRawPathExists = hasOwnRecordValue(originalRawRecord, key); + const originalRawPathExists = + originalRawRecord != null && Object.hasOwn(originalRawRecord, key); const sanitized = sanitizeRedactedValue({ value: item, originalFormValue, diff --git a/ui/src/lib/session-pull-requests.ts b/ui/src/lib/session-pull-requests.ts index 884f560844fb..30d7371cd461 100644 --- a/ui/src/lib/session-pull-requests.ts +++ b/ui/src/lib/session-pull-requests.ts @@ -1,3 +1,4 @@ +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import type { ControlUiSessionPullRequestSnapshot, ControlUiSessionPullRequestsChanged, @@ -46,9 +47,7 @@ function readChangedSessions( return null; } const sessions = (payload as { sessions?: unknown }).sessions; - return sessions && typeof sessions === "object" && !Array.isArray(sessions) - ? (sessions as ControlUiSessionPullRequestsChanged["sessions"]) - : null; + return asNullableRecord(sessions) as ControlUiSessionPullRequestsChanged["sessions"] | null; } function createStore(gateway: ApplicationGateway): SessionPullRequestSnapshotStore { diff --git a/ui/src/lib/sessions/cloud-recovery.ts b/ui/src/lib/sessions/cloud-recovery.ts index 9af3d4736da5..3c59e01aa479 100644 --- a/ui/src/lib/sessions/cloud-recovery.ts +++ b/ui/src/lib/sessions/cloud-recovery.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { hasNonEmptyString as isNonEmptyString } from "@openclaw/normalization-core/string-coerce"; import { cloudSessionRecoveryExactStorageKey, @@ -43,10 +44,10 @@ export function parseCloudSessionCreateParams( sessionKey: string, agentId: string, ): CloudSessionCreateParams | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const allowed = new Set([ "key", "agentId", @@ -74,9 +75,7 @@ export function parseCloudSessionCreateParams( function parseStoredCloudSessionRecovery(raw: string): Partial | null { try { const value: unknown = JSON.parse(raw); - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Partial) - : null; + return isRecord(value) ? (value as Partial) : null; } catch { return null; } diff --git a/ui/src/lib/workboard/change-payload.ts b/ui/src/lib/workboard/change-payload.ts index 594a9b569792..9b41d568327a 100644 --- a/ui/src/lib/workboard/change-payload.ts +++ b/ui/src/lib/workboard/change-payload.ts @@ -1,11 +1,12 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { WorkboardChange } from "@openclaw/workboard-contract"; export function normalizeWorkboardChange(payload: unknown): WorkboardChange | null { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + if (!isRecord(payload)) { return null; } - const epoch = (payload as { epoch?: unknown }).epoch; - const revision = (payload as { revision?: unknown }).revision; + const epoch = payload.epoch; + const revision = payload.revision; const keys = Object.keys(payload); return keys.length === 2 && keys.includes("epoch") && diff --git a/ui/src/lib/workboard/task-links.ts b/ui/src/lib/workboard/task-links.ts index 5d5211377eb4..540ff3a2eb82 100644 --- a/ui/src/lib/workboard/task-links.ts +++ b/ui/src/lib/workboard/task-links.ts @@ -1,6 +1,7 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts"; import type { GatewaySessionRow } from "../../api/types.ts"; +import { taskTimestampMs } from "../tasks/data.ts"; import { normalizeTaskSummary } from "../tasks/task-summary.ts"; import { isActiveWorkboardCard, @@ -40,14 +41,7 @@ export async function listWorkboardTasks( } export function taskUpdatedAtValue(task: WorkboardTaskSummary): number { - if (typeof task.updatedAt === "number") { - return task.updatedAt; - } - if (typeof task.updatedAt === "string") { - const parsed = Date.parse(task.updatedAt); - return Number.isFinite(parsed) ? parsed : 0; - } - return 0; + return taskTimestampMs(task.updatedAt); } export function taskLifecycleSourceUpdatedAt(task: WorkboardTaskSummary): number | undefined { diff --git a/ui/src/pages/chat/chat-pane-session.ts b/ui/src/pages/chat/chat-pane-session.ts index cafcb21cb8b6..2ccd03e53966 100644 --- a/ui/src/pages/chat/chat-pane-session.ts +++ b/ui/src/pages/chat/chat-pane-session.ts @@ -1,3 +1,4 @@ +import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion"; import type { SessionCatalogTranscriptItem, SessionsCatalogReadResult, @@ -283,8 +284,7 @@ export abstract class ChatPaneSession extends ChatPaneTaskSuggestions { } protected catalogItemMessage(item: SessionCatalogTranscriptItem): Record | null { - const parsedTimestamp = item.timestamp ? Date.parse(item.timestamp) : Number.NaN; - const timestamp = Number.isFinite(parsedTimestamp) ? parsedTimestamp : null; + const timestamp = parseDateStringTimestampMs(item.timestamp) ?? null; const text = item.text?.trim() ? item.text : null; if (item.type === "userMessage") { return text diff --git a/ui/src/pages/chat/chat-state-events.ts b/ui/src/pages/chat/chat-state-events.ts index ef5d2c6e407f..59d6dcb7f827 100644 --- a/ui/src/pages/chat/chat-state-events.ts +++ b/ui/src/pages/chat/chat-state-events.ts @@ -2,6 +2,11 @@ import { readSessionMessageIdentity, readSessionMessageSequence, } from "@openclaw/gateway-client/browser"; +import { + asNonArrayRecord, + asNullableRecord, + isRecord, +} from "@openclaw/normalization-core/record-coerce"; import type { SessionObserverDigest } from "../../../../packages/gateway-protocol/src/schema/sessions.js"; import type { GatewayEventFrame } from "../../api/gateway.ts"; import { fireFirstReplyConfetti } from "../../components/confetti.ts"; @@ -65,7 +70,7 @@ function applyLiveUserMessage( payload: unknown, runActive: boolean | undefined, ): void { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + if (!isRecord(payload)) { return; } const event = payload as { @@ -93,10 +98,7 @@ function applyLiveUserMessage( } const sourceRecord = sourceMessage as Record; const marker = sourceRecord["__openclaw"]; - const sourceMetadata = - marker && typeof marker === "object" && !Array.isArray(marker) - ? (marker as Record) - : {}; + const sourceMetadata = asNonArrayRecord(marker); const message = { ...sourceRecord, __openclaw: { @@ -248,10 +250,7 @@ function handleSessionsChangedEvent(state: ChatPageHost, payload: unknown) { const matchesChat = Boolean( event && globalSessionEventMatchesChat(state, event) && sessionMessageMatchesChat(state, event), ); - const source = - payload && typeof payload === "object" && !Array.isArray(payload) - ? (payload as Record) - : null; + const source = asNullableRecord(payload); const resetsSelectedSession = matchesChat && (source?.reason === "reset" || source?.phase === "reset"); if (resetsSelectedSession) { diff --git a/ui/src/pages/chat/components/chat-pull-requests.ts b/ui/src/pages/chat/components/chat-pull-requests.ts index 702190a515fe..36a20cf366c1 100644 --- a/ui/src/pages/chat/components/chat-pull-requests.ts +++ b/ui/src/pages/chat/components/chat-pull-requests.ts @@ -1,4 +1,5 @@ // Chat UI chips for pull requests detected on the session's working branch. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { html, nothing } from "lit"; import type { ControlUiSessionBranch, @@ -21,7 +22,7 @@ export function chatPullRequestId(pullRequest: ControlUiSessionPullRequest): str function readDismissedStore(storage: Storage): Record { try { const parsed: unknown = JSON.parse(storage.getItem(DISMISSED_STORAGE_KEY) ?? "{}"); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (!isRecord(parsed)) { return {}; } const store: Record = {}; diff --git a/ui/src/pages/chat/components/chat-tool-cards.ts b/ui/src/pages/chat/components/chat-tool-cards.ts index 14a09d104e4f..328367232d4b 100644 --- a/ui/src/pages/chat/components/chat-tool-cards.ts +++ b/ui/src/pages/chat/components/chat-tool-cards.ts @@ -1,4 +1,5 @@ // Control UI chat module implements tool cards behavior. +import { asNullableRecord, isRecord } from "@openclaw/normalization-core/record-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { html, nothing } from "lit"; import { icons, type IconName } from "../../../components/icons.ts"; @@ -472,10 +473,10 @@ function renderArgsKeyValueList(args: Record) { } function canRenderArgsAsKeyValue(args: unknown): args is Record { - if (!args || typeof args !== "object" || Array.isArray(args)) { + if (!isRecord(args)) { return false; } - const keys = Object.keys(args as Record); + const keys = Object.keys(args); return keys.length > 0 && keys.length <= KV_MAX_KEYS; } @@ -491,22 +492,20 @@ function extraArgsBeyondRowTarget( args: unknown, kind: ToolCallView["kind"], ): Record | null { - if (!args || typeof args !== "object" || Array.isArray(args)) { + if (!isRecord(args)) { return null; } const summarized = ROW_SUMMARIZED_ARG_KEYS[kind]; if (!summarized) { - return args as Record; + return args; } - const extras = Object.fromEntries( - Object.entries(args as Record).filter(([key]) => !summarized.has(key)), - ); + const extras = Object.fromEntries(Object.entries(args).filter(([key]) => !summarized.has(key))); return Object.keys(extras).length > 0 ? extras : null; } function resolveToolWorkspaceFilePath(card: ToolCard, view: ToolCallView): string | null { - if (card.args && typeof card.args === "object" && !Array.isArray(card.args)) { - const args = card.args as Record; + const args = asNullableRecord(card.args); + if (args) { for (const key of ["path", "file_path", "filePath", "notebook_path"]) { const value = args[key]; if (typeof value === "string" && value.trim()) { @@ -747,10 +746,7 @@ export function renderExpandedToolCardContent( // args (workdir, timeout, env…) stay visible as key-value rows so identical // commands in different contexts remain distinguishable in the audit trail. if (view.kind === "command" && view.command && !card.preview) { - const argsRecord = - card.args && typeof card.args === "object" && !Array.isArray(card.args) - ? (card.args as Record) - : null; + const argsRecord = asNullableRecord(card.args); const extraArgs = Object.fromEntries( Object.entries(argsRecord ?? {}).filter(([key]) => key !== "command"), ); diff --git a/ui/src/pages/chat/sidebar-layout-persistence.ts b/ui/src/pages/chat/sidebar-layout-persistence.ts index 39c2f4e65d3e..568bd1b2b0f0 100644 --- a/ui/src/pages/chat/sidebar-layout-persistence.ts +++ b/ui/src/pages/chat/sidebar-layout-persistence.ts @@ -1,3 +1,4 @@ +import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeSidebarLayout } from "./sidebar-layout-normalize.ts"; import type { SidebarLayout } from "./sidebar-layout.ts"; @@ -7,11 +8,10 @@ export type SidebarSessionActivePanels = Record; const MAX_SIDEBAR_SESSION_LAYOUTS = 50; export function normalizeSidebarSessionLayouts(value: unknown): SidebarSessionLayouts { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return {}; - } const layouts: SidebarSessionLayouts = {}; - for (const [sessionKey, rawLayout] of Object.entries(value).slice(-MAX_SIDEBAR_SESSION_LAYOUTS)) { + for (const [sessionKey, rawLayout] of Object.entries(asNonArrayRecord(value)).slice( + -MAX_SIDEBAR_SESSION_LAYOUTS, + )) { const key = sessionKey.trim(); if (!key) { continue; @@ -37,11 +37,10 @@ export function updateSidebarSessionLayout( } export function normalizeSidebarSessionActivePanels(value: unknown): SidebarSessionActivePanels { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return {}; - } const selections: SidebarSessionActivePanels = {}; - for (const [sessionKey, panelId] of Object.entries(value).slice(-MAX_SIDEBAR_SESSION_LAYOUTS)) { + for (const [sessionKey, panelId] of Object.entries(asNonArrayRecord(value)).slice( + -MAX_SIDEBAR_SESSION_LAYOUTS, + )) { const key = sessionKey.trim(); const id = typeof panelId === "string" ? panelId.trim() : ""; if (key && id) { diff --git a/ui/src/pages/chat/steer-lifecycle.ts b/ui/src/pages/chat/steer-lifecycle.ts index 47a2d084ae96..ba21301f8c8b 100644 --- a/ui/src/pages/chat/steer-lifecycle.ts +++ b/ui/src/pages/chat/steer-lifecycle.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord, isRecord } from "@openclaw/normalization-core/record-coerce"; import type { QueueMode } from "../../../../packages/gateway-protocol/src/schema/logs-chat.js"; import type { SessionsListResult } from "../../api/types.ts"; import { setLastActiveSessionKey } from "../../app/settings.ts"; @@ -149,20 +150,16 @@ function findQueuedSendMessageIndex( return -1; } return (Array.isArray(messages) ? messages : []).findIndex((message) => { - if (!message || typeof message !== "object" || Array.isArray(message)) { + if (!isRecord(message)) { return false; } // Render retirement requires a user-role entry: an assistant entry can // carry the same run key without proving the queued turn is visible. - const record = message as Record; + const record = message; if (userRoleOnly && record.role !== "user") { return false; } - const marker = record["__openclaw"]; - const markerIdempotencyKey = - marker && typeof marker === "object" && !Array.isArray(marker) - ? (marker as { idempotencyKey?: unknown }).idempotencyKey - : undefined; + const markerIdempotencyKey = asOptionalRecord(record["__openclaw"])?.idempotencyKey; const idempotencyKey = markerIdempotencyKey ?? record.idempotencyKey; return idempotencyKey === item.sendRunId || idempotencyKey === `${item.sendRunId}:user`; }); diff --git a/ui/src/pages/chat/terminal-message-identity.ts b/ui/src/pages/chat/terminal-message-identity.ts index 806d645ac209..837fe8450f06 100644 --- a/ui/src/pages/chat/terminal-message-identity.ts +++ b/ui/src/pages/chat/terminal-message-identity.ts @@ -1,4 +1,5 @@ import { readSessionMessageIdentity } from "@openclaw/gateway-client/browser"; +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts"; const liveTerminalRunIds = new WeakMap(); @@ -40,10 +41,7 @@ export function rememberAuthoritativeTerminal(options: { payload: unknown; runIdBeforeApply: string | null; }): void { - const payload = - options.payload && typeof options.payload === "object" && !Array.isArray(options.payload) - ? (options.payload as Record) - : null; + const payload = asNullableRecord(options.payload); const identity = readSessionMessageIdentity(payload?.message, { messageId: payload?.messageId, }); diff --git a/ui/src/pages/chat/tool-titles.ts b/ui/src/pages/chat/tool-titles.ts index 55fa8dd4e7df..2ba4bc4ccd3d 100644 --- a/ui/src/pages/chat/tool-titles.ts +++ b/ui/src/pages/chat/tool-titles.ts @@ -8,6 +8,7 @@ * labels. */ +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { resolveToolCallKind, unwrapShellWrapperCommand } from "../../lib/chat/tool-call-view.ts"; @@ -85,10 +86,7 @@ function resolveToolTitleRequest( ): { key: string; input: string } | null { const kind = resolveToolCallKind(name, args); if (kind === "command") { - const record = - args && typeof args === "object" && !Array.isArray(args) - ? (args as Record) - : null; + const record = asNullableRecord(args); const rawCommand = typeof record?.command === "string" ? record.command.trim() : ""; const command = unwrapShellWrapperCommand(rawCommand).trim(); if (command.length < MIN_COMMAND_CHARS_FOR_TITLE) { diff --git a/ui/src/pages/config/view-schema.ts b/ui/src/pages/config/view-schema.ts index fae3253ffcc8..8ffbe31abc1c 100644 --- a/ui/src/pages/config/view-schema.ts +++ b/ui/src/pages/config/view-schema.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { html, nothing } from "lit"; import { schemaType, type JsonSchema } from "../../components/config-form.shared.ts"; import { analyzeConfigSchema, type ConfigSchemaAnalysis } from "../../components/config-form.ts"; @@ -30,7 +31,7 @@ function scopeSchemaSections( } export function asConfigSchema(value: unknown): JsonSchema | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } return value as JsonSchema; diff --git a/ui/src/pages/labs/labs-registry.ts b/ui/src/pages/labs/labs-registry.ts index 11c695fb09a6..1944debce230 100644 --- a/ui/src/pages/labs/labs-registry.ts +++ b/ui/src/pages/labs/labs-registry.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { t } from "../../i18n/index.ts"; /** What a lab row writes at its gate. Most gates are booleans; some are modes. */ @@ -75,8 +76,8 @@ export const LAB_FEATURES = [ if (typeof raw === "boolean") { return raw; } - if (raw && typeof raw === "object" && !Array.isArray(raw)) { - return (raw as Record).enabled !== false; + if (isRecord(raw)) { + return raw.enabled !== false; } return true; }, @@ -115,10 +116,10 @@ export const LAB_FEATURES = [ if (typeof raw === "boolean") { return raw; } - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + if (!isRecord(raw)) { return false; } - const node = raw as Record; + const node = raw; return typeof node.enabled === "boolean" ? node.enabled : Object.keys(node).some((key) => key !== "enabled"); @@ -213,10 +214,10 @@ export const LAB_FEATURES = [ function recordAtPath(config: Record, path: readonly string[]): unknown { let current: unknown = config; for (const segment of path) { - if (!current || typeof current !== "object" || Array.isArray(current)) { + if (!isRecord(current)) { return undefined; } - current = (current as Record)[segment]; + current = current[segment]; } return current; } @@ -226,10 +227,10 @@ function defaultModelRef(config: Record): string | undefined { if (typeof model === "string") { return model; } - if (!model || typeof model !== "object" || Array.isArray(model)) { + if (!isRecord(model)) { return undefined; } - const primary = (model as Record).primary; + const primary = model.primary; return typeof primary === "string" ? primary : undefined; } @@ -258,10 +259,10 @@ function readEnabledFromParent(feature: LabFeature, parent: unknown): boolean { if (key === "enabled" && typeof parent === "boolean") { return parent; } - if (!parent || typeof parent !== "object" || Array.isArray(parent) || !key) { + if (!isRecord(parent) || !key) { return false; } - return feature.activeValues.includes((parent as Record)[key] as LabFeatureValue); + return feature.activeValues.includes(parent[key] as LabFeatureValue); } function labFeatureOverridePath( @@ -286,12 +287,7 @@ function labFeatureOverridePath( ) { return parentPath; } - if ( - parent && - typeof parent === "object" && - !Array.isArray(parent) && - Object.hasOwn(parent, key) - ) { + if (isRecord(parent) && Object.hasOwn(parent, key)) { return feature.configPath; } return null; @@ -314,13 +310,7 @@ export function resolveLabFeatureState( let defaultParent = parent; if (overridePath?.length === parentPath.length) { defaultParent = undefined; - } else if ( - overridePath && - key && - parent && - typeof parent === "object" && - !Array.isArray(parent) - ) { + } else if (overridePath && key && isRecord(parent)) { defaultParent = { ...(parent as Record) }; delete (defaultParent as Record)[key]; } diff --git a/ui/src/pages/new-session/preferences.ts b/ui/src/pages/new-session/preferences.ts index 55c48eed9e88..b7cb482423c5 100644 --- a/ui/src/pages/new-session/preferences.ts +++ b/ui/src/pages/new-session/preferences.ts @@ -1,4 +1,5 @@ import { gatewayOriginScope } from "@openclaw/gateway-client/browser"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; import { normalizeOptionalString } from "../../lib/string-coerce.ts"; import { getSafeLocalStorage } from "../../local-storage.ts"; @@ -24,10 +25,10 @@ function storageKey(gatewayUrl: string): string { } function normalizePreference(value: unknown): NewSessionPreference | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { + if (!isRecord(value)) { return null; } - const record = value as Record; + const record = value; const workspace = normalizeOptionalString(record.workspace); const folder = normalizeOptionalString(record.folder); const model = normalizeOptionalString(record.model); @@ -48,7 +49,7 @@ function normalizePreference(value: unknown): NewSessionPreference | null { function readStore(storage: Storage, gatewayUrl: string): PersistedPreferences { try { const parsed = JSON.parse(storage.getItem(storageKey(gatewayUrl)) ?? "null") as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + if (!isRecord(parsed)) { return {}; } return parsed as PersistedPreferences; diff --git a/ui/src/pages/plugins/plugins.e2e.test.ts b/ui/src/pages/plugins/plugins.e2e.test.ts index d9f9b493b557..fd9bc2df1b1a 100644 --- a/ui/src/pages/plugins/plugins.e2e.test.ts +++ b/ui/src/pages/plugins/plugins.e2e.test.ts @@ -1,6 +1,7 @@ // Control UI tests cover plugin catalog browsing and lifecycle mutations. import { mkdir, rm } from "node:fs/promises"; import path from "node:path"; +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { PluginsSearchResult } from "../../../../packages/gateway-protocol/src/schema/plugins.ts"; @@ -206,12 +207,7 @@ function readOnlyConnectResponse() { }; } -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} +const requireRecord = createRequireRecord("record", "expected-object-value"); function requestParams(request: MockGatewayRequest): Record { return requireRecord(request.params); diff --git a/ui/src/pages/workboard/workboard-status-persistence.e2e.test.ts b/ui/src/pages/workboard/workboard-status-persistence.e2e.test.ts index 62626a441f5d..69bcb4c46524 100644 --- a/ui/src/pages/workboard/workboard-status-persistence.e2e.test.ts +++ b/ui/src/pages/workboard/workboard-status-persistence.e2e.test.ts @@ -2,6 +2,7 @@ import { mkdir } from "node:fs/promises"; import path from "node:path"; // Control UI tests cover workboard status persistence behavior. import { expectDefined } from "@openclaw/normalization-core"; +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { chromium, type Browser, type Locator, type Page } from "playwright"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { WorkboardCard } from "../../lib/workboard/index.ts"; @@ -124,12 +125,7 @@ const staleReviewCard = { ], } satisfies WorkboardCard; -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} +const requireRecord = createRequireRecord("record", "expected-object-value"); function requestParams(request: MockGatewayRequest): Record { return requireRecord(request.params); diff --git a/ui/src/pages/workboard/workboard.e2e.test.ts b/ui/src/pages/workboard/workboard.e2e.test.ts index b9f3e1dc2768..240e8cf8315b 100644 --- a/ui/src/pages/workboard/workboard.e2e.test.ts +++ b/ui/src/pages/workboard/workboard.e2e.test.ts @@ -1,6 +1,7 @@ // Control UI tests cover workboard behavior. import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import type { BrowserContext, Locator, Page } from "playwright"; import { expect, it } from "vitest"; import { PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/version.js"; @@ -52,12 +53,7 @@ type ProofArtifacts = { videos: string[]; }; -function requireRecord(value: unknown): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("Expected object value"); - } - return value as Record; -} +const requireRecord = createRequireRecord("record", "expected-object-value"); function requestParams(request: MockGatewayRequest): Record { return requireRecord(request.params);