From 77d9ac30bb8d2792719b0e4e291863f1629de37f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 25 May 2026 21:20:41 +0100 Subject: [PATCH] refactor: reuse shared coercion helpers (#86419) * refactor: share talk event metric extraction * refactor: reuse shared coercion helpers * refactor: reuse shared primitive guards * refactor: reuse shared record guard * refactor: reuse shared primitive helpers * refactor: reuse shared string guards * refactor: reuse shared non-empty string guard * refactor: share plugin primitive coercion helpers * refactor: reuse plugin coercion helpers * refactor: reuse plugin coercion helpers in more plugins * refactor: reuse channel coercion helpers * refactor: reuse monitor coercion helpers * refactor: reuse provider coercion helpers * refactor: reuse core coercion helpers * refactor: reuse runtime coercion helpers * refactor: reuse helper coercion in codex paths * refactor: reuse helper coercion in runtime paths * refactor: reuse codex app-server coercion helpers * refactor: reuse codex record helpers * refactor: reuse migration and qa record helpers * refactor: reuse feishu and core helper guards * refactor: reuse browser and policy coercion helpers * refactor: reuse memory wiki record helper * refactor: share boolean coercion helpers * refactor: reuse finite number coercion * refactor: reuse trimmed string list helpers * refactor: reuse string list normalization * refactor: reuse remaining string list helpers * refactor: reuse string entry normalizer * refactor: share sorted string helpers * refactor: share string list normalization * test: preserve command registry browser imports * refactor: reuse trimmed list helpers * refactor: reuse string dedupe helpers * refactor: reuse local dedupe helpers * refactor: reuse more string dedupe helpers * refactor: reuse command string dedupe helpers * refactor: dedupe memory path lists with helper * refactor: expose string dedupe helpers to plugins * refactor: reuse core string dedupe helpers * refactor: reuse shared unique value helpers * refactor: reuse unique helpers in agent utilities * refactor: reuse unique helpers in config plumbing * refactor: reuse unique helpers in extensions * refactor: reuse unique helpers in core utilities * refactor: reuse unique helpers in qa plugins * refactor: reuse unique helpers in memory plugins * refactor: reuse unique helpers in channel plugins * refactor: reuse unique helpers in core tails * refactor: reuse unique helper in comfy workflow * refactor: reuse unique helpers in test utilities * refactor: expose unique value helper to plugins * refactor: reuse unique helpers for numeric lists * refactor: replace index dedupe filters * refactor: reuse string entry normalization * refactor: reuse string normalization in plugin helpers * refactor: reuse string normalization in extension helpers * refactor: reuse string normalization in channel parsers * refactor: reuse string normalization in memory search * refactor: reuse string normalization in provider parsers * refactor: reuse string normalization in qa helpers * refactor: reuse string normalization in infra parsers * refactor: reuse string normalization in messaging parsers * refactor: reuse string normalization in core parsers * refactor: reuse string normalization in extension parsers * refactor: reuse string normalization in remaining parsers * refactor: reuse string normalization in final parser spots * refactor: reuse string normalization in qa media helpers * refactor: reuse normalization in provider and media lists * refactor: reuse normalization for remaining set filters * refactor: reuse normalization in policy allowlists * refactor: reuse normalization in session and owner lists * refactor: centralize primitive string lists * refactor: reuse lowercase entry helpers * refactor: reuse sorted string helpers * refactor: reuse unique trimmed helpers * refactor: reuse string normalization helpers * refactor: reuse catalog string helpers * refactor: reuse remaining string helpers * refactor: simplify remaining list normalization * refactor: reuse codex auth order normalization * chore: refresh plugin sdk api baseline * fix: make shared string sorting deterministic * chore: refresh plugin sdk api baseline * fix: align host env security ordering --- AGENTS.md | 4 ++ .../HostEnvSecurityPolicy.generated.swift | 56 +++++++++---------- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- extensions/acpx/src/runtime.ts | 5 +- extensions/active-memory/index.ts | 27 ++++----- extensions/admin-http-rpc/src/handler.ts | 5 +- .../amazon-bedrock/embedding-provider.ts | 11 ++-- extensions/anthropic/cli-migration.ts | 9 ++- extensions/anthropic/config-defaults.ts | 12 ++-- extensions/anthropic/stream-wrappers.ts | 11 ++-- .../brave/src/brave-web-search-provider.ts | 5 +- extensions/brave/web-search-contract-api.ts | 5 +- extensions/browser/src/browser/chrome-mcp.ts | 6 +- .../src/browser/pw-session.page-cdp.ts | 3 +- .../src/browser/pw-tools-core.responses.ts | 5 +- .../src/browser/routes/agent.shared.ts | 5 +- .../src/browser/routes/agent.snapshot.plan.ts | 12 ++-- .../src/browser/routes/agent.storage.ts | 12 ++-- .../browser/src/browser/routes/permissions.ts | 3 +- .../src/browser/ssrf-policy-helpers.ts | 3 +- .../browser/src/node-host/invoke-browser.ts | 3 +- .../byteplus/video-generation-provider.ts | 6 +- extensions/canvas/src/config-migration.ts | 6 +- extensions/canvas/src/config.ts | 17 ++---- .../src/app-server/app-inventory-cache.ts | 5 +- .../codex/src/app-server/approval-bridge.ts | 6 +- extensions/codex/src/app-server/compact.ts | 7 +-- extensions/codex/src/app-server/config.ts | 8 +-- .../codex/src/app-server/dynamic-tools.ts | 12 ++-- .../codex/src/app-server/event-projector.ts | 15 ++--- .../src/app-server/image-payload-sanitizer.ts | 9 +-- extensions/codex/src/app-server/models.ts | 3 +- .../src/app-server/native-subagent-monitor.ts | 9 +-- .../codex/src/app-server/rate-limits.ts | 4 +- .../codex/src/app-server/run-attempt.ts | 4 +- .../codex/src/app-server/transcript-mirror.ts | 6 +- extensions/codex/src/command-account.ts | 17 +----- extensions/codex/src/command-handlers.ts | 6 +- .../codex/src/conversation-binding-data.ts | 7 +-- .../codex/src/conversation-turn-collector.ts | 7 +-- .../codex/src/conversation-turn-input.ts | 23 +++----- extensions/codex/src/migration/apply.ts | 5 +- extensions/codex/src/migration/auth.ts | 12 ++-- extensions/codex/src/migration/plan.ts | 7 +-- extensions/codex/src/node-cli-sessions.ts | 5 +- extensions/comfy/workflow-runtime.ts | 7 ++- extensions/copilot-proxy/index.ts | 8 +-- extensions/deepgram/audio.ts | 7 +-- .../realtime-transcription-provider.ts | 40 ++----------- .../deepinfra/video-generation-provider.ts | 18 +++--- extensions/discord/src/components-registry.ts | 3 +- .../discord/src/inbound-event-delivery.ts | 14 ++--- .../src/monitor/message-handler.hydration.ts | 5 +- .../discord/src/monitor/message-media.ts | 3 +- .../discord/src/monitor/provider.commands.ts | 9 +-- .../src/monitor/thread-bindings.lifecycle.ts | 3 +- extensions/discord/src/security-audit.ts | 6 +- extensions/discord/src/send.components.ts | 3 +- .../discord/src/send.emojis-stickers.ts | 7 ++- extensions/discord/src/send.shared.ts | 3 +- extensions/discord/src/voice/realtime.ts | 8 +-- extensions/discord/src/voice/tts.ts | 4 +- .../realtime-transcription-provider.ts | 22 ++------ extensions/fal/image-generation-provider.ts | 5 +- extensions/fal/video-generation-provider.ts | 5 +- extensions/feishu/src/bot.ts | 4 +- extensions/feishu/src/comment-shared.ts | 6 +- extensions/feishu/src/dedupe-key.ts | 7 +-- extensions/feishu/src/docx.ts | 9 +-- .../feishu/src/monitor.bot-menu-handler.ts | 9 +-- extensions/feishu/src/monitor.comment.ts | 5 +- .../feishu/src/monitor.message-handler.ts | 9 +-- extensions/feishu/src/outbound.ts | 42 +++++++------- extensions/feishu/src/send.ts | 5 +- extensions/feishu/src/setup-surface.ts | 9 +-- extensions/google-meet/src/config-compat.ts | 14 ++--- extensions/google-meet/src/config.ts | 16 +----- extensions/google-meet/src/meet.ts | 8 ++- extensions/google-meet/src/node-host.ts | 14 ++--- extensions/google-meet/src/runtime.ts | 4 +- extensions/google-meet/src/setup.ts | 11 +--- .../google-meet/src/transports/chrome.ts | 3 +- extensions/google/embedding-batch.ts | 9 ++- extensions/google/embedding-provider.ts | 11 ++-- .../google/image-generation-provider.ts | 5 +- extensions/google/provider-policy.ts | 5 +- extensions/google/realtime-voice-provider.ts | 14 ++--- .../src/gemini-web-search-provider.runtime.ts | 5 +- .../src/gemini-web-search-provider.shared.ts | 12 ++-- .../google/src/gemini-web-search-provider.ts | 5 +- extensions/google/transport-stream.ts | 17 ++---- extensions/google/vertex-adc.ts | 5 +- extensions/imessage/src/actions.runtime.ts | 6 +- .../src/monitor/inbound-processing.ts | 3 +- extensions/imessage/src/probe.ts | 10 ++-- extensions/irc/src/normalize.ts | 3 +- extensions/irc/src/setup-core.ts | 2 +- extensions/irc/src/setup-surface.ts | 20 ++++--- extensions/line/src/config-adapter.ts | 7 +-- extensions/line/src/quick-reply-fallback.ts | 7 +-- .../line/src/reply-payload-transform.ts | 10 ++-- extensions/lmstudio/src/models.ts | 21 +++---- extensions/lmstudio/src/setup.ts | 12 ++-- .../matrix/src/approval-handler.runtime.ts | 5 +- extensions/matrix/src/matrix/actions/polls.ts | 7 ++- .../matrix/src/matrix/monitor/auto-join.ts | 6 +- .../matrix/src/matrix/monitor/events.ts | 13 +++-- extensions/matrix/src/matrix/sdk.ts | 8 ++- .../mattermost/src/mattermost/monitor-auth.ts | 7 ++- .../src/mattermost/monitor-onchar.ts | 4 +- .../src/mattermost/monitor-resources.ts | 3 +- .../mattermost/src/mattermost/monitor.ts | 8 +-- extensions/meeting-notes/src/config.ts | 6 +- extensions/meeting-notes/src/summary.ts | 12 +--- extensions/meeting-notes/src/tool.ts | 8 ++- extensions/memory-core/src/dreaming-phases.ts | 32 ++++------- extensions/memory-core/src/dreaming.ts | 7 ++- extensions/memory-core/src/memory/hybrid.ts | 7 +-- .../memory-core/src/memory/manager-search.ts | 28 ++++------ extensions/memory-core/src/memory/manager.ts | 3 +- .../memory-core/src/memory/qmd-manager.ts | 3 +- extensions/memory-core/src/rem-evidence.ts | 3 +- .../memory-core/src/short-term-promotion.ts | 10 +++- extensions/memory-lancedb/index.ts | 11 ++-- extensions/memory-wiki/src/apply.ts | 7 +-- extensions/memory-wiki/src/chatgpt-import.ts | 3 +- extensions/memory-wiki/src/cli.ts | 16 +++--- extensions/memory-wiki/src/compile.ts | 11 ++-- extensions/memory-wiki/src/import-runs.ts | 3 +- extensions/memory-wiki/src/markdown.ts | 3 +- extensions/memory-wiki/src/query.ts | 11 ++-- extensions/migrate-claude/helpers.ts | 5 +- extensions/migrate-hermes/auth.ts | 10 +--- extensions/migrate-hermes/config.ts | 5 +- extensions/migrate-hermes/helpers.ts | 12 ++-- extensions/migrate-hermes/secrets.ts | 10 +--- .../realtime-transcription-provider.ts | 22 ++------ .../src/kimi-web-search-provider.runtime.ts | 12 ++-- extensions/msteams/src/attachments/graph.ts | 5 +- extensions/msteams/src/channel.ts | 13 ++--- extensions/msteams/src/directory-live.ts | 10 ++-- extensions/msteams/src/errors.ts | 4 +- extensions/msteams/src/outbound.ts | 18 +++--- extensions/msteams/src/polls.ts | 14 +++-- extensions/msteams/src/probe.ts | 5 +- extensions/msteams/src/sdk.ts | 8 +-- extensions/nostr/src/channel.ts | 5 +- extensions/nostr/src/setup-adapter.ts | 3 +- extensions/ollama/src/discovery-shared.ts | 5 +- extensions/ollama/src/model-id.ts | 3 +- extensions/ollama/src/stream.ts | 5 +- extensions/openai/embedding-batch.ts | 19 +++---- extensions/openai/native-web-search.ts | 5 +- extensions/openai/openai-codex-provider.ts | 3 +- extensions/openai/realtime-provider-shared.ts | 17 ++---- .../opencode/media-understanding-provider.ts | 5 +- .../openrouter/image-generation-provider.ts | 6 +- .../openrouter/music-generation-provider.ts | 6 +- .../openrouter/video-generation-provider.ts | 6 +- extensions/openrouter/video-model-catalog.ts | 11 ++-- .../perplexity-web-search-provider.runtime.ts | 6 +- .../src/perplexity-web-search-provider.ts | 5 +- extensions/phone-control/index.ts | 4 +- extensions/policy/src/doctor/register.ts | 7 +-- extensions/policy/src/policy-state.ts | 17 ++---- extensions/qa-lab/src/agentic-parity.ts | 6 +- .../qa-lab/src/bundled-plugin-staging.ts | 14 ++--- extensions/qa-lab/src/character-eval.ts | 3 +- extensions/qa-lab/src/cli.runtime.ts | 3 +- extensions/qa-lab/src/coverage-report.ts | 7 +-- extensions/qa-lab/src/docker-runtime.ts | 9 ++- extensions/qa-lab/src/gateway-child.ts | 25 ++++----- extensions/qa-lab/src/gateway-log-sentinel.ts | 13 ++--- extensions/qa-lab/src/jsonl-replay.ts | 12 ++-- .../discord/discord-live.runtime.ts | 3 +- .../slack/slack-live.runtime.ts | 3 +- .../telegram/telegram-live.runtime.ts | 7 +-- .../whatsapp/whatsapp-live.runtime.ts | 8 +-- extensions/qa-lab/src/multipass.runtime.ts | 3 +- extensions/qa-lab/src/providers/env.ts | 3 +- .../qa-lab/src/providers/image-generation.ts | 8 ++- .../src/providers/live-frontier/auth.ts | 11 ++-- .../qa-lab/src/providers/shared/mock-auth.ts | 5 +- extensions/qa-lab/src/qa-gateway-config.ts | 29 +++++----- extensions/qa-lab/src/run-config.ts | 5 +- extensions/qa-lab/src/runtime-parity.ts | 17 ++---- .../qa-lab/src/runtime-tool-metadata.ts | 17 ++---- extensions/qa-lab/src/scenario-flow-runner.ts | 5 +- extensions/qa-lab/src/scenario-packs.ts | 6 +- .../qa-lab/src/suite-runtime-agent-session.ts | 12 ++-- .../qa-lab/src/suite-runtime-gateway.ts | 5 +- extensions/qa-lab/src/suite.ts | 3 +- .../qa-lab/src/token-efficiency-report.ts | 5 +- extensions/qa-lab/src/tool-coverage-report.ts | 16 ++---- extensions/qa-matrix/src/docker-runtime.ts | 9 ++- .../contract/scenario-runtime-approval.ts | 5 +- .../contract/scenario-runtime-config.ts | 5 +- .../contract/scenario-runtime-state-files.ts | 5 +- extensions/qa-matrix/src/substrate/client.ts | 5 +- extensions/qa-matrix/src/substrate/config.ts | 7 ++- .../engine/commands/builtin/log-helpers.ts | 3 +- extensions/qqbot/src/engine/config/group.ts | 4 +- extensions/qqbot/src/engine/config/resolve.ts | 6 +- .../src/engine/gateway/interaction-handler.ts | 3 +- .../engine/gateway/stages/envelope-stage.ts | 7 ++- .../src/engine/utils/string-normalize.ts | 6 ++ .../runway/video-generation-provider.ts | 5 +- extensions/signal/src/normalize.ts | 7 ++- extensions/signal/src/shared.ts | 6 +- extensions/skill-workshop/src/config.ts | 10 +--- extensions/skill-workshop/src/reviewer.ts | 12 ++-- extensions/skill-workshop/src/tool.ts | 5 +- extensions/slack/src/errors.ts | 5 +- extensions/slack/src/interactive-replies.ts | 18 +++--- .../events/interactions.block-actions.ts | 20 ++----- .../slack/src/monitor/events/messages.ts | 14 ++--- .../message-handler/prepare-content.ts | 9 ++- .../src/monitor/message-handler/prepare.ts | 7 +-- .../slack/src/monitor/provider-support.ts | 7 +-- extensions/slack/src/monitor/slash.ts | 3 +- extensions/slack/src/scopes.ts | 9 ++- extensions/slack/src/send.ts | 5 +- extensions/slack/src/setup-core.ts | 3 +- extensions/slack/src/setup-surface.ts | 3 +- extensions/synology-chat/src/accounts.ts | 6 +- extensions/synology-chat/src/channel.ts | 8 ++- extensions/synology-chat/src/setup-surface.ts | 10 ++-- extensions/telegram/src/bot-access.ts | 4 +- .../telegram/src/bot-handlers.runtime.ts | 8 +-- extensions/telegram/src/message-cache.ts | 5 +- .../telegram/src/message-dispatch-dedupe.ts | 3 +- extensions/telegram/src/secret-contract.ts | 9 +-- extensions/telegram/src/security-audit.ts | 6 +- extensions/telegram/src/state-migrations.ts | 3 +- .../telegram/src/status-reaction-variants.ts | 8 ++- extensions/tlon/src/monitor/index.ts | 4 +- .../tlon/src/monitor/settings-helpers.ts | 12 +--- extensions/tlon/src/setup-surface.ts | 6 +- extensions/twitch/src/outbound.ts | 5 +- extensions/twitch/src/setup-surface.ts | 6 +- extensions/voice-call/src/cli.ts | 9 ++- extensions/voice-call/src/deep-merge.ts | 6 +- .../voice-call/src/realtime-agent-context.ts | 5 +- .../voice-call/src/response-generator.ts | 15 ++--- extensions/voice-call/src/webhook-security.ts | 13 +++-- extensions/voice-call/src/webhook.ts | 10 ++-- extensions/volcengine/api.ts | 3 +- extensions/voyage/embedding-batch.ts | 9 ++- .../auto-reply/monitor/inbound-dispatch.ts | 7 +-- extensions/whatsapp/src/inbound/extract.ts | 3 +- extensions/whatsapp/src/inbound/monitor.ts | 7 ++- .../whatsapp/src/inbound/send-result.ts | 7 ++- .../whatsapp/src/outbound-media-contract.ts | 3 +- .../whatsapp/src/resolve-outbound-target.ts | 5 +- extensions/whatsapp/src/security-fix.ts | 3 +- .../xai/realtime-transcription-provider.ts | 33 ++--------- extensions/xai/src/responses-tool-shared.ts | 12 ++-- extensions/xai/video-generation-provider.ts | 6 +- extensions/zalouser/src/setup-surface.ts | 10 ++-- .../src/host/backend-config.ts | 21 ++++--- .../memory-host-sdk/src/host/config-utils.ts | 16 ++++-- packages/memory-host-sdk/src/host/internal.ts | 7 +-- .../memory-host-sdk/src/host/string-utils.ts | 8 +++ packages/sdk/src/client.ts | 8 +-- .../control-plane/manager.runtime-controls.ts | 11 +--- src/acp/permission-relay.ts | 5 +- src/agents/accepted-session-spawn.ts | 11 +--- src/agents/acp-spawn-parent-stream.ts | 11 ++-- src/agents/api-key-rotation.ts | 13 +---- src/agents/auth-health.ts | 3 +- .../auth-profiles/external-cli-discovery.ts | 5 +- .../auth-profiles/legacy-oauth-sidecar.ts | 8 +-- src/agents/auth-profiles/persisted.ts | 15 ++--- src/agents/auth-profiles/profile-list.ts | 3 +- src/agents/auth-profiles/state.ts | 11 ++-- .../bash-tools.exec-approval-request.ts | 12 ++-- src/agents/bash-tools.exec-host-gateway.ts | 14 ++--- src/agents/bash-tools.exec-runtime.ts | 6 +- src/agents/bash-tools.exec.ts | 13 ++--- src/agents/bootstrap-budget.ts | 21 ++----- src/agents/channel-tools.ts | 7 +-- src/agents/cli-backends.ts | 3 +- src/agents/cli-output.ts | 6 +- .../cli-runner/bundle-mcp-adapter-shared.ts | 6 +- src/agents/cli-runner/claude-live-session.ts | 5 +- src/agents/cli-runner/prepare.ts | 3 +- src/agents/cli-runner/toml-inline.ts | 6 +- src/agents/code-mode.ts | 8 +-- src/agents/codex-native-web-search.shared.ts | 12 +--- src/agents/generated-attachments.ts | 18 ++---- src/agents/harness/lifecycle-hook-helpers.ts | 9 +-- src/agents/harness/native-hook-relay.ts | 10 ++-- src/agents/harness/runtime-plugin.ts | 26 +++------ src/agents/harness/tool-result-middleware.ts | 5 +- src/agents/inherited-tool-deny.ts | 21 +++---- src/agents/live-auth-keys.ts | 6 +- src/agents/live-cache-test-support.ts | 9 ++- src/agents/live-model-turn-probes.ts | 9 ++- src/agents/memory-search.ts | 10 ++-- src/agents/model-auth-env.ts | 9 +-- src/agents/model-auth-label.ts | 29 +++++----- src/agents/model-auth-markers.ts | 26 +++------ src/agents/model-auth.ts | 5 +- src/agents/model-catalog-scope.ts | 10 +--- src/agents/model-scan.ts | 10 ++-- .../models-config.providers.implicit.ts | 17 ++---- src/agents/openai-reasoning-effort.ts | 12 ++-- src/agents/openai-responses-payload-policy.ts | 4 +- src/agents/openai-transport-stream.ts | 8 +-- .../openclaw-tools.media-factory-plan.ts | 5 +- src/agents/openclaw-tools.registration.ts | 3 +- src/agents/pi-embedded-helpers/thinking.ts | 10 ++-- .../compaction-duplicate-user-messages.ts | 6 +- .../pi-embedded-runner/delivery-evidence.ts | 5 +- .../empty-assistant-turn.ts | 4 +- .../model-context-tokens.ts | 3 +- .../openai-stream-wrappers.ts | 5 +- .../pi-embedded-runner/replay-history.ts | 3 +- .../run/attempt-tool-construction-plan.ts | 3 +- .../run/attempt.session-lock.ts | 11 +--- .../run/attempt.tool-call-normalization.ts | 6 +- .../pi-embedded-runner/run/incomplete-turn.ts | 23 +++----- .../run/message-tool-terminal.ts | 5 +- .../run/preemptive-compaction.ts | 5 +- .../run/tool-media-payloads.ts | 10 ++-- .../pi-embedded-runner/tool-name-allowlist.ts | 3 +- .../transcript-file-state.ts | 5 +- ...pi-embedded-subscribe.handlers.messages.ts | 5 +- .../pi-embedded-subscribe.handlers.tools.ts | 18 ++---- src/agents/pi-embedded-subscribe.tools.ts | 10 +--- .../pi-hooks/compaction-safeguard-quality.ts | 3 +- src/agents/pi-tools-parameter-schema.ts | 8 +-- src/agents/pi-tools.policy.ts | 20 +++---- src/agents/plugin-text-transforms.ts | 5 +- src/agents/provider-attribution.ts | 27 +++------ src/agents/provider-http-errors.ts | 5 +- .../responses-image-payload-sanitizer.ts | 5 +- src/agents/run-timeout-attribution.ts | 4 +- src/agents/runtime-capabilities.ts | 5 +- src/agents/sandbox-tool-policy.ts | 7 ++- src/agents/sandbox/fs-paths.ts | 8 +-- src/agents/sandbox/tool-policy.ts | 7 ++- src/agents/session-transcript-repair.ts | 5 +- src/agents/skills-install-extract.ts | 6 +- src/agents/skills-install-output.ts | 7 +-- src/agents/skills-install-tar-verbose.ts | 7 +-- src/agents/skills/filter.ts | 4 +- src/agents/skills/workspace.ts | 20 +++---- src/agents/subagent-announce-delivery.ts | 9 +-- src/agents/subagent-announce-output.ts | 9 +-- src/agents/subagent-registry-lifecycle.ts | 3 +- src/agents/subagent-session-reconciliation.ts | 3 +- src/agents/subagent-spawn-thinking.ts | 11 ++-- src/agents/subagent-system-prompt.ts | 5 +- src/agents/subagent-target-policy.ts | 7 ++- src/agents/subagent-yield-output.ts | 22 +++----- src/agents/system-prompt-params.ts | 5 +- src/agents/system-prompt.ts | 17 +++--- src/agents/tool-allowlist-guard.ts | 7 ++- src/agents/tool-description-summary.ts | 11 +--- src/agents/tool-loop-detection.ts | 17 ++---- src/agents/tool-policy-audit.ts | 4 +- src/agents/tool-policy-shared.ts | 3 +- src/agents/tool-policy.ts | 9 +-- src/agents/tool-search.ts | 22 ++++---- src/agents/tools/common.ts | 6 +- src/agents/tools/gateway-tool.ts | 5 +- src/agents/tools/heartbeat-response-tool.ts | 5 +- src/agents/tools/media-tool-shared.ts | 3 +- src/agents/tools/message-tool.ts | 7 +-- src/agents/tools/session-status-tool.ts | 3 +- src/auto-reply/command-turn-context.ts | 10 +--- src/auto-reply/commands-registry.shared.ts | 5 +- src/auto-reply/heartbeat-filter.ts | 20 +++---- src/auto-reply/heartbeat-tool-response.ts | 10 +--- src/auto-reply/model.ts | 3 +- src/auto-reply/reply/commands-dock.ts | 3 +- .../reply/commands-export-session.ts | 5 +- src/auto-reply/reply/group-id.ts | 6 +- src/auto-reply/reply/history-media.ts | 3 +- src/auto-reply/reply/inbound-meta.ts | 5 +- src/auto-reply/reply/startup-context.ts | 3 +- src/auto-reply/skill-commands.ts | 7 ++- .../command-auth-registry-fixture.ts | 14 ++--- src/channels/account-snapshot-fields.ts | 14 +++-- src/channels/bundled-channel-catalog-read.ts | 11 ++-- src/channels/channel-config.ts | 16 +----- src/channels/config-presence.ts | 11 ++-- src/channels/inbound-event/media.ts | 6 +- src/channels/message-access/allowlist.ts | 5 +- src/channels/message-access/decision.ts | 3 +- src/channels/message-access/dm-allow-state.ts | 15 ++--- .../message-access/runtime-access-groups.ts | 23 ++++---- src/channels/message-access/runtime.ts | 4 +- src/channels/message-access/state.ts | 4 +- src/channels/message/receipt.ts | 5 +- src/channels/plugins/account-helpers.ts | 3 +- src/channels/plugins/catalog.ts | 25 +++++---- .../plugins/directory-config-helpers.ts | 12 +--- src/channels/plugins/helpers.ts | 6 +- .../plugins/message-action-discovery.ts | 3 +- .../plugins/outbound/presentation-limits.ts | 7 ++- src/channels/plugins/package-state-probes.ts | 14 +---- src/channels/plugins/read-only.ts | 37 ++++++------ src/channels/plugins/session-conversation.ts | 16 +----- src/channels/plugins/setup-group-access.ts | 11 +--- src/channels/plugins/setup-wizard-helpers.ts | 11 ++-- src/channels/status/read-model.ts | 11 ++-- src/chat/canvas-render.ts | 12 ++-- src/cli/channel-options.ts | 12 +--- src/cli/command-secret-targets.ts | 34 +++++------ src/cli/config-cli.ts | 16 +++--- src/cli/daemon-cli/status.gather.ts | 3 +- src/cli/devices-cli.runtime.ts | 3 +- src/cli/gateway-cli/qa-parent-watchdog.ts | 13 +++-- src/cli/nodes-media-utils.ts | 10 +--- src/cli/plugin-install-config-policy.ts | 10 +++- src/cli/plugins-authoring-command.ts | 3 +- src/cli/plugins-install-command.ts | 10 ++-- src/cli/program/register-command-groups.ts | 3 +- src/cli/update-cli/update-command.ts | 5 +- src/commands/agent-command.test-mocks.ts | 3 +- src/commands/agents.bindings.ts | 6 +- src/commands/agents.commands.bind.ts | 3 +- src/commands/agents.config.ts | 3 +- src/commands/auth-choice-options.ts | 3 +- src/commands/channels/resolve.ts | 3 +- src/commands/commitments.ts | 3 +- src/commands/docs.ts | 6 +- src/commands/doctor-auth-flat-profiles.ts | 5 +- src/commands/doctor-auth-oauth-sidecar.ts | 5 +- src/commands/doctor-command-owner.ts | 3 +- src/commands/doctor-device-pairing.ts | 28 ++-------- src/commands/doctor-memory-search.ts | 5 +- src/commands/doctor-plugin-registry.ts | 5 +- src/commands/doctor-session-snapshots.ts | 5 +- .../doctor-session-state-providers.ts | 8 +-- src/commands/doctor-state-integrity.ts | 13 +++-- .../shared/allowfrom-fallback-migration.ts | 8 ++- .../doctor/shared/allowlist-policy-repair.ts | 4 +- .../doctor/shared/codex-native-assets.ts | 10 +--- .../doctor/shared/codex-route-warnings.ts | 12 +--- .../configured-runtime-plugin-installs.ts | 13 ++--- .../shared/context-engine-host-compat.ts | 3 +- .../shared/legacy-talk-config-normalizer.ts | 5 +- .../doctor/shared/plugin-runtime-symlinks.ts | 5 +- .../shared/plugin-tool-allowlist-warnings.ts | 12 ++-- .../doctor/shared/preview-warnings.ts | 5 +- .../release-configured-plugin-installs.ts | 5 +- .../shared/stale-oauth-profile-shadows.ts | 5 +- src/commands/migrate/selection.ts | 24 +++----- src/commands/models/list.probe.ts | 3 +- src/commands/models/list.provider-catalog.ts | 3 +- src/commands/onboard-helpers.ts | 3 +- src/commands/onboarding-plugin-install.ts | 3 +- src/commands/sandbox-explain.ts | 5 +- src/commitments/extraction.ts | 8 +-- src/commitments/store.ts | 42 +++++--------- src/config/channel-capabilities.ts | 3 +- src/config/dangerous-name-matching.ts | 7 +-- src/config/defaults.ts | 5 +- src/config/doc-baseline.ts | 7 +-- src/config/model-input.ts | 5 +- src/config/plugin-install-config-migration.ts | 5 +- src/config/plugin-web-search-config.ts | 6 +- src/config/redact-snapshot.raw.ts | 3 +- src/config/redact-snapshot.ts | 5 +- src/config/sessions/store-entry-shape.ts | 5 +- src/config/sessions/store-load.ts | 7 +-- src/config/shell-env-expected-keys.ts | 13 ++--- src/config/zod-schema.agent-runtime.ts | 12 ++-- src/cron/isolated-agent/delivery-target.ts | 3 +- src/cron/normalize.ts | 5 +- src/cron/run-log.ts | 10 ++-- src/cron/schedule-identity.ts | 7 ++- src/cron/store.ts | 5 +- src/daemon/schtasks.ts | 3 +- src/daemon/service-audit.ts | 13 ++--- src/daemon/service-managed-env.ts | 3 +- src/daemon/systemd-unit.ts | 12 ++-- src/daemon/systemd.ts | 5 +- src/flows/bundled-health-checks.ts | 7 +-- src/flows/doctor-repair-flow.ts | 3 +- src/flows/model-picker.ts | 9 +-- src/gateway/chat-display-projection.ts | 20 ++----- src/gateway/cli-session-history.claude.ts | 13 ++--- src/gateway/cli-session-history.merge.ts | 7 +-- src/gateway/input-allowlist.ts | 4 +- src/gateway/mcp-http.schema.ts | 3 +- src/gateway/mcp-http.ts | 5 +- src/gateway/method-scopes.ts | 5 +- src/gateway/node-catalog.ts | 18 +----- src/gateway/node-command-policy.ts | 5 +- src/gateway/protocol/connect-error-details.ts | 9 +-- src/gateway/protocol/index.ts | 3 +- .../server-methods/agent-wait-dedupe.ts | 14 +---- src/gateway/server-methods/agent.ts | 12 ++-- src/gateway/server-methods/agents.ts | 5 +- src/gateway/server-methods/artifacts.ts | 26 +++------ src/gateway/server-methods/chat.ts | 3 +- src/gateway/server-methods/config.ts | 12 ++-- src/gateway/server-methods/environments.ts | 12 +--- src/gateway/server-methods/nodes.ts | 7 +-- .../server-methods/plugin-host-hooks.ts | 5 +- src/gateway/server-plugins.ts | 3 +- src/gateway/server-startup-log.ts | 5 +- src/gateway/server-utils.ts | 9 +-- src/gateway/server.impl.ts | 5 +- .../server/ws-connection/message-handler.ts | 3 +- src/gateway/session-transcript-files.fs.ts | 5 +- src/gateway/session-utils.ts | 3 +- src/gateway/talk-handoff.ts | 6 +- src/gateway/talk-transcription-relay.ts | 11 +--- .../bundled/compaction-notifier/handler.ts | 3 +- src/hooks/gmail.ts | 3 +- src/hooks/install.ts | 3 +- src/hooks/workspace.ts | 11 +--- src/image-generation/image-assets.ts | 5 +- src/infra/approval-native-route-notice.ts | 5 +- src/infra/bonjour-discovery.ts | 15 ++--- src/infra/clawhub.ts | 7 +-- src/infra/command-analysis/explain.ts | 9 +-- src/infra/command-analysis/risks.ts | 3 +- src/infra/control-ui-assets.ts | 6 +- src/infra/device-pairing.ts | 24 ++------ src/infra/diagnostic-flags.ts | 13 +---- src/infra/dispatch-wrapper-resolution.ts | 3 +- src/infra/event-session-routing.ts | 5 +- src/infra/exec-approval-forwarder.ts | 8 +-- src/infra/exec-approvals-effective.ts | 3 +- src/infra/exec-safe-bin-policy-profiles.ts | 3 +- src/infra/exec-safe-bin-trust.ts | 15 +++-- src/infra/gateway-process-argv.ts | 6 +- src/infra/gateway-processes.ts | 3 +- src/infra/host-env-security-policy.js | 4 +- .../host-env-security.policy-parity.test.ts | 15 +++-- .../host-env-security.reported-baseline.json | 30 +++++----- ...ost-env-security.reported-baseline.test.ts | 5 +- src/infra/host-env-security.test.ts | 2 +- src/infra/host-env-security.ts | 9 +-- src/infra/install-package-dir.ts | 5 +- src/infra/install-source-utils.ts | 6 +- src/infra/net/proxy/managed-proxy-undici.ts | 5 +- src/infra/net/ssrf.ts | 28 +++------- src/infra/net/undici-runtime.ts | 5 +- src/infra/npm-install-env.ts | 3 +- src/infra/npm-managed-root.ts | 10 +--- src/infra/outbound/outbound-policy.ts | 5 +- src/infra/outbound/outbound-session.ts | 6 +- src/infra/outbound/session-binding-service.ts | 3 +- src/infra/outbound/source-reply-mirror.ts | 9 +-- src/infra/package-dist-inventory.ts | 9 +-- src/infra/package-json.ts | 5 +- src/infra/package-update-utils.ts | 5 +- src/infra/path-env.ts | 32 ++++------- src/infra/path-prepend.ts | 33 ++++------- src/infra/provider-usage.auth.ts | 5 +- src/infra/restart-handoff.ts | 5 +- src/infra/restart-sentinel.ts | 5 +- src/infra/restart-stale-pids.ts | 7 ++- src/infra/skills-remote.ts | 3 +- src/infra/ssh-tunnel.ts | 6 +- src/infra/tailnet.ts | 3 +- src/infra/tailscale.ts | 5 +- src/infra/update-control-plane-sentinel.ts | 5 +- src/infra/update-runner.ts | 18 ++---- src/infra/voicewake-routing.ts | 14 +---- src/infra/windows-port-pids.ts | 6 +- src/interactive/payload.ts | 8 +-- src/link-understanding/format.ts | 4 +- src/logging/config.ts | 5 +- src/logging/diagnostic-support-export.ts | 20 +++---- .../diagnostic-support-log-redaction.ts | 16 ++---- src/logging/diagnostic-support-redaction.ts | 12 +--- src/media-generation/catalog.ts | 16 ++---- src/media-understanding/defaults.ts | 3 +- src/media-understanding/image.ts | 5 +- .../openai-compatible-video.ts | 7 +-- src/media-understanding/runner.entries.ts | 14 ++--- src/media-understanding/runner.ts | 42 +++++++------- src/media/image-ops.ts | 10 ++-- src/media/input-files.ts | 2 +- src/media/local-roots.ts | 3 +- src/media/web-media.ts | 17 +++--- src/model-catalog/manifest-planner.ts | 5 +- src/model-catalog/normalize.ts | 14 ++--- src/model-catalog/provider-index/normalize.ts | 15 ++--- src/music-generation/provider-assets.ts | 5 +- src/node-host/invoke.ts | 3 +- src/pairing/allow-from-store-file.ts | 13 +---- src/pairing/allow-from-store-read.ts | 7 +-- src/pairing/pairing-store.ts | 5 +- src/plugin-sdk/access-groups.ts | 3 +- src/plugin-sdk/agent-harness-task-runtime.ts | 6 +- src/plugin-sdk/allow-from.ts | 9 +-- src/plugin-sdk/approval-approvers.ts | 11 +--- src/plugin-sdk/channel-policy.ts | 5 +- src/plugin-sdk/channel-streaming.ts | 14 ++--- src/plugin-sdk/migration.ts | 5 +- src/plugin-sdk/provider-entry.ts | 9 ++- src/plugin-sdk/qa-channel-protocol.ts | 6 +- src/plugin-sdk/reply-payload.ts | 5 +- src/plugin-sdk/session-transcript-hit.ts | 5 +- src/plugin-sdk/session-visibility.ts | 3 +- src/plugin-sdk/ssrf-policy.ts | 5 +- src/plugin-sdk/string-coerce-runtime.ts | 14 +++++ .../test-helpers/public-artifacts.ts | 3 +- src/plugin-sdk/test-helpers/string-utils.ts | 4 +- src/plugin-sdk/windows-spawn.ts | 14 ++--- src/plugins/activation-planner.ts | 3 +- src/plugins/active-runtime-registry.ts | 5 +- src/plugins/bundle-manifest.ts | 16 +----- src/plugins/bundled-compat.ts | 5 +- src/plugins/bundled-dir.ts | 9 ++- src/plugins/bundled-plugin-metadata.ts | 3 +- src/plugins/bundled-sources.ts | 5 +- src/plugins/capability-provider-runtime.ts | 3 +- src/plugins/captured-registration.ts | 11 ++-- src/plugins/channel-catalog-registry.ts | 5 +- src/plugins/channel-presence-policy.ts | 22 +++----- src/plugins/cli-registry-loader.ts | 27 +++++---- src/plugins/config-contracts.ts | 15 +++-- src/plugins/config-normalization-shared.ts | 13 +++-- src/plugins/dependency-denylist.ts | 7 +-- src/plugins/doctor-contract-registry.ts | 10 +--- .../document-extractor-public-artifacts.ts | 5 +- src/plugins/document-extractors.runtime.ts | 17 +++--- src/plugins/effective-plugin-ids.ts | 7 ++- src/plugins/gateway-startup-plugin-ids.ts | 5 +- .../gateway-startup-speech-providers.ts | 5 +- src/plugins/hooks.test-helpers.ts | 3 +- src/plugins/install-security-scan.runtime.ts | 14 +---- .../installed-plugin-index-record-builder.ts | 14 +---- .../installed-plugin-index-record-reader.ts | 5 +- src/plugins/legacy-npm-declaration.ts | 5 +- src/plugins/loader-provenance.ts | 6 +- src/plugins/manifest-contract-eligibility.ts | 3 +- src/plugins/manifest-contract-runtime.ts | 7 +-- src/plugins/manifest-metadata-scan.ts | 10 +--- src/plugins/manifest-registry-installed.ts | 5 +- src/plugins/manifest-registry.ts | 13 +++-- src/plugins/manifest-tool-availability.ts | 9 +-- src/plugins/model-catalog-registration.ts | 5 +- .../official-external-plugin-catalog.ts | 13 +++-- src/plugins/package-entrypoints.ts | 3 +- src/plugins/plugin-metadata-snapshot.ts | 5 +- src/plugins/plugin-registry-contributions.ts | 15 ++--- src/plugins/plugin-scope.ts | 9 +-- src/plugins/provider-api-key-auth.ts | 4 +- src/plugins/provider-auth-choice-helpers.ts | 5 +- src/plugins/provider-auth-helpers.ts | 3 +- .../provider-contract-public-artifacts.ts | 10 +--- src/plugins/provider-discovery.runtime.ts | 7 +-- src/plugins/provider-discovery.ts | 7 +-- src/plugins/provider-install-catalog.ts | 5 +- src/plugins/provider-model-compat.ts | 8 +-- src/plugins/provider-model-helpers.ts | 3 +- src/plugins/provider-runtime.ts | 11 ++-- src/plugins/provider-self-hosted-setup.ts | 3 +- src/plugins/provider-validation.ts | 4 +- src/plugins/providers.runtime.ts | 15 +++-- src/plugins/providers.test.ts | 7 +-- src/plugins/providers.ts | 7 +-- src/plugins/registry.ts | 24 ++++---- src/plugins/roots.ts | 9 ++- src/plugins/runtime/runtime-llm.runtime.ts | 3 +- src/plugins/setup-registry.ts | 11 ++-- src/plugins/tools.ts | 10 ++-- .../web-content-extractor-public-artifacts.ts | 5 +- .../web-provider-public-artifacts.explicit.ts | 8 +-- src/plugins/web-provider-public-artifacts.ts | 3 +- src/plugins/web-search-install-catalog.ts | 14 +---- src/proxy-capture/store.sqlite.ts | 8 +-- src/routing/channel-route-targets.ts | 5 +- src/secrets/channel-env-vars.ts | 3 +- src/secrets/json-pointer.ts | 6 +- src/secrets/plan.ts | 8 +-- src/secrets/provider-env-vars.ts | 13 ++--- src/secrets/resolve.ts | 3 +- src/secrets/runtime-command-secrets.ts | 5 +- .../runtime-config-collectors-plugins.ts | 7 +-- src/secrets/runtime-fast-path.ts | 5 +- src/secrets/runtime-web-tools.ts | 3 +- src/secrets/runtime.ts | 3 +- src/secrets/storage-scan.ts | 5 +- src/security/audit-channel.ts | 3 +- src/security/audit-extra.async.ts | 23 ++++---- src/security/audit-extra.sync.ts | 3 +- src/security/audit-gateway-config.ts | 7 ++- src/security/audit.ts | 3 +- src/security/channel-metadata.ts | 3 +- src/shared/device-auth-store.ts | 5 +- src/shared/number-coercion.test.ts | 18 ++++++ src/shared/number-coercion.ts | 10 ++++ src/shared/record-coerce.ts | 2 +- src/shared/string-coerce.test.ts | 14 +++++ src/shared/string-coerce.ts | 6 ++ src/shared/string-normalization.test.ts | 53 ++++++++++++++++++ src/shared/string-normalization.ts | 40 +++++++++++++ src/shared/text/tool-call-shaped-text.ts | 21 ++----- src/talk/diagnostics.ts | 29 ++-------- src/talk/event-metrics.ts | 17 ++++++ src/talk/logging.ts | 29 ++-------- src/talk/session-log-runtime.ts | 3 +- src/talk/talk-session-controller.ts | 6 +- src/tasks/task-registry.ts | 14 ++--- .../bundled-plugin-public-surface.ts | 15 +++-- src/test-utils/openclaw-test-state.ts | 3 +- src/trajectory/cleanup.ts | 5 +- src/trajectory/export.ts | 5 +- src/tts/status-config.ts | 5 +- src/tts/tts-config.ts | 5 +- src/tui/components/searchable-select-list.ts | 3 +- src/utils/boolean.ts | 9 ++- src/utils/message-channel-normalize.ts | 3 +- src/utils/utils-misc.test.ts | 11 +++- src/video-generation/dashscope-compatible.ts | 3 +- src/video-generation/duration-support.ts | 3 +- src/web-search/runtime.ts | 15 ++--- src/wizard/setup.plugin-config.ts | 6 +- ui/src/ui/app-render.ts | 7 ++- ui/src/ui/chat/message-extract.ts | 4 +- .../slash-commands.browser-import.test.ts | 1 + ui/src/ui/control-ui-auth.ts | 23 +++----- ui/src/ui/controllers/cron.ts | 4 +- ui/src/ui/string-coerce.ts | 5 ++ ui/src/ui/views/activity.ts | 6 +- ui/src/ui/views/agents-panels-tools-skills.ts | 4 +- ui/src/ui/views/cron.ts | 3 +- ui/src/ui/views/usage-query.ts | 10 +--- 730 files changed, 2576 insertions(+), 3788 deletions(-) create mode 100644 src/shared/number-coercion.test.ts create mode 100644 src/shared/string-coerce.test.ts create mode 100644 src/talk/event-metrics.ts diff --git a/AGENTS.md b/AGENTS.md index 8fdfb729a61..b0f869a593e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,6 +154,10 @@ Skills own workflows; root owns hard policy and routing. - Inline simple one-use objects/spreads when clearer. Extract only when it removes duplication or hard logic. - Tests prove behavior/regressions, not every internal branch. - For non-trivial refactors, check `git diff --numstat` before closeout. If LOC grew, trim or explain why. +- Prefer existing narrow helpers over repeated casts/guards. Add local helpers when 2+ nearby call sites share real boundary logic. +- Prefer ctor parameter properties for injected deps/config. Do not ban them for erasable-syntax purity. +- Prefer `satisfies` for registries/config maps; derive types from schemas when a runtime schema already exists. +- Table-drive repetitive tests when it reduces code and keeps failure names clear. - Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`. - Cycles: keep `pnpm check:import-cycles` + architecture/madge green. - Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs. diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index 0c589f31988..d496531300f 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -6,7 +6,6 @@ import Foundation enum HostEnvSecurityPolicy { static let blockedInheritedKeys: Set = [ - "_JAVA_OPTIONS", "AMQP_URL", "ANSIBLE_CALLBACK_PLUGINS", "ANSIBLE_COLLECTIONS_PATH", @@ -31,12 +30,11 @@ enum HostEnvSecurityPolicy { "AZURE_CLIENT_SECRET", "BASH_ENV", "BROWSER", - "BUN_CONFIG_REGISTRY", "BUNDLE_GEMFILE", + "BUN_CONFIG_REGISTRY", "BZR_EDITOR", "BZR_PLUGIN_PATH", "BZR_SSH", - "C_INCLUDE_PATH", "CARGO_BUILD_RUSTC", "CARGO_BUILD_RUSTC_WRAPPER", "CARGO_HOME", @@ -46,8 +44,8 @@ enum HostEnvSecurityPolicy { "CGO_CFLAGS", "CGO_LDFLAGS", "CLASSPATH", - "CMAKE_C_COMPILER", "CMAKE_CXX_COMPILER", + "CMAKE_C_COMPILER", "CMAKE_TOOLCHAIN_FILE", "COMPOSER_HOME", "CONFIG_SHELL", @@ -58,6 +56,7 @@ enum HostEnvSecurityPolicy { "CPLUS_INCLUDE_PATH", "CURL_HOME", "CXX", + "C_INCLUDE_PATH", "DATABASE_URL", "DENO_DIR", "DOTNET_ADDITIONAL_DEPS", @@ -75,6 +74,8 @@ enum HostEnvSecurityPolicy { "GEM_HOME", "GEM_PATH", "GH_TOKEN", + "GITHUB_TOKEN", + "GITLAB_TOKEN", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_ASKPASS", "GIT_COMMON_DIR", @@ -95,8 +96,6 @@ enum HostEnvSecurityPolicy { "GIT_SSL_NO_VERIFY", "GIT_TEMPLATE_DIR", "GIT_WORK_TREE", - "GITHUB_TOKEN", - "GITLAB_TOKEN", "GLIBC_TUNABLES", "GOENV", "GOFLAGS", @@ -145,8 +144,8 @@ enum HostEnvSecurityPolicy { "PERL5DBCMD", "PERL5LIB", "PERL5OPT", - "PHP_INI_SCAN_DIR", "PHPRC", + "PHP_INI_SCAN_DIR", "PIP_CONFIG_FILE", "PIP_EXTRA_INDEX_URL", "PIP_FIND_LINKS", @@ -160,17 +159,17 @@ enum HostEnvSecurityPolicy { "PYTHONPATH", "PYTHONSTARTUP", "PYTHONUSERBASE", - "R_ENVIRON", - "R_ENVIRON_USER", - "R_LIBS_USER", - "R_PROFILE", - "R_PROFILE_USER", "REDIS_URL", "RUBYLIB", "RUBYOPT", "RUBYSHELL", "RUSTC_WRAPPER", "RUSTFLAGS", + "R_ENVIRON", + "R_ENVIRON_USER", + "R_LIBS_USER", + "R_PROFILE", + "R_PROFILE_USER", "SBT_OPTS", "SHELL", "SHELLOPTS", @@ -192,7 +191,8 @@ enum HostEnvSecurityPolicy { "VIRTUAL_ENV", "VISUAL", "WGETRC", - "YARN_RC_FILENAME" + "YARN_RC_FILENAME", + "_JAVA_OPTIONS" ] static let blockedInheritedPrefixes: [String] = [ @@ -202,7 +202,6 @@ enum HostEnvSecurityPolicy { ] static let blockedKeys: Set = [ - "_JAVA_OPTIONS", "ANT_OPTS", "BASH_ENV", "BROWSER", @@ -213,8 +212,8 @@ enum HostEnvSecurityPolicy { "CARGO_BUILD_RUSTC_WRAPPER", "CATALINA_OPTS", "CC", - "CMAKE_C_COMPILER", "CMAKE_CXX_COMPILER", + "CMAKE_C_COMPILER", "CMAKE_TOOLCHAIN_FILE", "CONFIG_SHELL", "CONFIG_SITE", @@ -275,14 +274,14 @@ enum HostEnvSecurityPolicy { "PYTHONBREAKPOINT", "PYTHONHOME", "PYTHONPATH", - "R_ENVIRON", - "R_ENVIRON_USER", - "R_PROFILE", - "R_PROFILE_USER", "RUBYLIB", "RUBYOPT", "RUBYSHELL", "RUSTC_WRAPPER", + "R_ENVIRON", + "R_ENVIRON_USER", + "R_PROFILE", + "R_PROFILE_USER", "SBT_OPTS", "SHELL", "SHELLOPTS", @@ -291,7 +290,8 @@ enum HostEnvSecurityPolicy { "SVN_EDITOR", "SVN_SSH", "VAGRANT_VAGRANTFILE", - "VIMINIT" + "VIMINIT", + "_JAVA_OPTIONS" ] static let blockedOverrideKeys: Set = [ @@ -321,9 +321,8 @@ enum HostEnvSecurityPolicy { "AZURE_AUTH_LOCATION", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET", - "BUN_CONFIG_REGISTRY", "BUNDLE_GEMFILE", - "C_INCLUDE_PATH", + "BUN_CONFIG_REGISTRY", "CARGO_BUILD_RUSTC_WRAPPER", "CARGO_HOME", "CFLAGS", @@ -336,6 +335,7 @@ enum HostEnvSecurityPolicy { "CPLUS_INCLUDE_PATH", "CURL_CA_BUNDLE", "CURL_HOME", + "C_INCLUDE_PATH", "DATABASE_URL", "DENO_DIR", "DOCKER_CERT_PATH", @@ -347,6 +347,8 @@ enum HostEnvSecurityPolicy { "GEM_HOME", "GEM_PATH", "GH_TOKEN", + "GITHUB_TOKEN", + "GITLAB_TOKEN", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_ASKPASS", "GIT_COMMON_DIR", @@ -362,8 +364,6 @@ enum HostEnvSecurityPolicy { "GIT_SSL_CAPATH", "GIT_SSL_NO_VERIFY", "GIT_WORK_TREE", - "GITHUB_TOKEN", - "GITLAB_TOKEN", "GOENV", "GOFLAGS", "GONOPROXY", @@ -378,8 +378,8 @@ enum HostEnvSecurityPolicy { "HGRCPATH", "HISTFILE", "HOME", - "HTTP_PROXY", "HTTPS_PROXY", + "HTTP_PROXY", "KUBECONFIG", "LDFLAGS", "LESSCLOSE", @@ -391,10 +391,10 @@ enum HostEnvSecurityPolicy { "MANPAGER", "MFLAGS", "MONGODB_URI", - "NO_PROXY", "NODE_AUTH_TOKEN", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED", + "NO_PROXY", "NPM_TOKEN", "OBJC_INCLUDE_PATH", "OPENSSL_CONF", @@ -402,8 +402,8 @@ enum HostEnvSecurityPolicy { "PAGER", "PERL5DB", "PERL5DBCMD", - "PHP_INI_SCAN_DIR", "PHPRC", + "PHP_INI_SCAN_DIR", "PIP_CONFIG_FILE", "PIP_EXTRA_INDEX_URL", "PIP_FIND_LINKS", @@ -413,11 +413,11 @@ enum HostEnvSecurityPolicy { "PROMPT_COMMAND", "PYTHONSTARTUP", "PYTHONUSERBASE", - "R_LIBS_USER", "REDIS_URL", "REQUESTS_CA_BUNDLE", "RUSTC_WRAPPER", "RUSTFLAGS", + "R_LIBS_USER", "SSH_ASKPASS", "SSH_AUTH_SOCK", "SSL_CERT_DIR", diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 56b09a99ceb..5c7d9c4e775 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -1d3e6177eeac57fc43736f7d5f76d8f825e1859ca625d268e97dc30b5567ea34 plugin-sdk-api-baseline.json -6c093ff7c10bd81ee9d2c4fc5d07b206bc3a1f5acd0bad491cfc9e0df6689f6b plugin-sdk-api-baseline.jsonl +374f1fec7d6fa8c00865dcb58b68d89ec10e85e81ef536c5746167a83d10bcc7 plugin-sdk-api-baseline.json +ffc6a2faf381d1bb118845e010b2798397c3d41fff400f52ee57b6dc197c8af3 plugin-sdk-api-baseline.jsonl diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index 03ad71de437..a99be70d35d 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -19,7 +19,9 @@ import { type AcpRuntimeTurnResult, } from "acpx/runtime"; import { redactSensitiveText } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { AcpRuntimeError, type AcpRuntime, type AcpRuntimeErrorCode } from "../runtime-api.js"; +import { splitCommandParts } from "./command-line.js"; import { createAcpxProcessLeaseId, hashAcpxProcessCommand, @@ -32,7 +34,6 @@ import { isOpenClawLeaseAwareAcpxProcessCommand, type AcpxProcessCleanupDeps, } from "./process-reaper.js"; -import { splitCommandParts } from "./command-line.js"; type AcpSessionStore = AcpRuntimeOptions["sessionStore"]; type AcpSessionRecord = Parameters[0]; @@ -189,7 +190,7 @@ function selectCurrentSessionLease(params: { sessionKeys: string[]; rootPid?: number; }): AcpxProcessLease | undefined { - const sessionKeys = new Set(params.sessionKeys.map((entry) => entry.trim()).filter(Boolean)); + const sessionKeys = new Set(normalizeStringEntries(params.sessionKeys)); const candidates = params.leases.filter((lease) => sessionKeys.has(lease.sessionKey)); if (params.rootPid) { return candidates.find((lease) => lease.rootPid === params.rootPid); diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 7e2334dbd03..197568e54f8 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -20,6 +20,12 @@ import { import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { parseAgentSessionKey, parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing"; import { isPathInside, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; +import { + asOptionalRecord as asRecord, + normalizeOptionalString, + normalizeStringEntries, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; const DEFAULT_TIMEOUT_MS = 15_000; @@ -313,11 +319,6 @@ function withToggleStoreLock(statePath: string, task: () => Promise): Prom return withLock(task); } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} type ActiveMemoryThinkingLevel = | "off" | "minimal" @@ -571,10 +572,6 @@ function resolveCanonicalSessionKeyFromSessionId(params: { } } -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function formatRuntimeToolsAllowSource(toolsAllow: readonly string[]): string { return `runtime toolsAllow: ${toolsAllow.join(", ")}`; } @@ -877,9 +874,7 @@ function normalizePluginConfig( : []; return { enabled: raw.enabled !== false, - agents: Array.isArray(raw.agents) - ? raw.agents.map((agentId) => agentId.trim()).filter(Boolean) - : [], + agents: Array.isArray(raw.agents) ? normalizeStringEntries(raw.agents) : [], model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : undefined, modelFallback: typeof raw.modelFallback === "string" && raw.modelFallback.trim() @@ -1518,11 +1513,11 @@ function buildPluginDebugLine(params: { warning && action && !cleaned ? `${warning} ${action}` : [warning, action && !cleaned ? action : ""] - .filter((value, index, values) => Boolean(value) && values.indexOf(value) === index) + .filter((value): value is string => Boolean(value)) .join(" | "); - const messages = [warningAction, cleaned] - .filter((value, index, values) => Boolean(value) && values.indexOf(value) === index) - .join(" | "); + const messages = uniqueStrings( + [warningAction, cleaned].filter((value): value is string => Boolean(value)), + ).join(" | "); const trailing = messages; if (prefix && trailing) { return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${prefix} | ${trailing}`; diff --git a/extensions/admin-http-rpc/src/handler.ts b/extensions/admin-http-rpc/src/handler.ts index 336f5a6af8b..9e45aa17db6 100644 --- a/extensions/admin-http-rpc/src/handler.ts +++ b/extensions/admin-http-rpc/src/handler.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { dispatchGatewayMethod } from "openclaw/plugin-sdk/gateway-method-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isAdminHttpRpcAllowedMethod, listAdminHttpRpcAllowedMethods } from "./methods.js"; const DEFAULT_RPC_BODY_BYTES = 1024 * 1024; @@ -38,10 +39,6 @@ type ParsedRequest = { params?: unknown; }; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function createError(code: string, message: string): RpcError { return { code, message }; } diff --git a/extensions/amazon-bedrock/embedding-provider.ts b/extensions/amazon-bedrock/embedding-provider.ts index d251d6905b5..bcb9f8cd27e 100644 --- a/extensions/amazon-bedrock/embedding-provider.ts +++ b/extensions/amazon-bedrock/embedding-provider.ts @@ -4,7 +4,10 @@ import { type MemoryEmbeddingProvider, type MemoryEmbeddingProviderCreateOptions, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord as asRecord, + normalizeLowercaseStringOrEmpty, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js"; // --------------------------------------------------------------------------- @@ -258,12 +261,6 @@ function asNumberArray(value: unknown): number[] { return value; } -function asRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function asNumberArrayBatch(value: unknown): number[][] { if (!Array.isArray(value)) { throw malformedBedrockEmbeddingResponse(); diff --git a/extensions/anthropic/cli-migration.ts b/extensions/anthropic/cli-migration.ts index a4cfdb190ca..dd38bcc9349 100644 --- a/extensions/anthropic/cli-migration.ts +++ b/extensions/anthropic/cli-migration.ts @@ -3,7 +3,10 @@ import { type OpenClawConfig, type ProviderAuthResult, } from "openclaw/plugin-sdk/provider-auth"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeLowercaseStringOrEmpty, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveClaudeCliAnthropicModelRefs } from "./claude-model-refs.js"; import { readClaudeCliCredentialsForSetup, @@ -31,10 +34,6 @@ function toAnthropicSelectedModelRef(raw: string): string | undefined { return resolved?.rewriteRef ?? resolved?.selectedRef; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function rewriteModelSelection(model: AgentDefaultsModel): { value: AgentDefaultsModel; primary?: string; diff --git a/extensions/anthropic/config-defaults.ts b/extensions/anthropic/config-defaults.ts index c62aef547a5..a235f0107ba 100644 --- a/extensions/anthropic/config-defaults.ts +++ b/extensions/anthropic/config-defaults.ts @@ -1,4 +1,8 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; +import { + isRecord, + normalizeLowercaseStringOrEmpty, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveClaudeCliAnthropicModelRefs, resolveKnownAnthropicModelRef, @@ -8,10 +12,6 @@ import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli- const ANTHROPIC_PROVIDER_API = "anthropic-messages"; const ANTHROPIC_API_KEY_DEFAULT_ALLOWLIST_REFS = ["anthropic/claude-sonnet-4-6"] as const; -function normalizeLowercaseStringOrEmpty(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : ""; -} - function normalizeProviderId(provider: string): string { const normalized = normalizeLowercaseStringOrEmpty(provider); if (normalized === "bedrock" || normalized === "aws-bedrock") { @@ -20,10 +20,6 @@ function normalizeProviderId(provider: string): string { return normalized; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function resolveAnthropicDefaultAuthMode( config: OpenClawConfig, env: NodeJS.ProcessEnv, diff --git a/extensions/anthropic/stream-wrappers.ts b/extensions/anthropic/stream-wrappers.ts index 611cb817a0b..216eecb5e92 100644 --- a/extensions/anthropic/stream-wrappers.ts +++ b/extensions/anthropic/stream-wrappers.ts @@ -13,7 +13,9 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { normalizeFastMode, normalizeLowercaseStringOrEmpty, + normalizeStringEntries, readStringValue, + uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; const log = createSubsystemLogger("anthropic-stream"); @@ -48,10 +50,7 @@ function parseHeaderList(value: unknown): string[] { if (typeof value !== "string") { return []; } - return value - .split(",") - .map((item) => item.trim()) - .filter(Boolean); + return normalizeStringEntries(value.split(",")); } function mergeAnthropicBetaHeader( @@ -63,7 +62,7 @@ function mergeAnthropicBetaHeader( (key) => normalizeLowercaseStringOrEmpty(key) === "anthropic-beta", ); const existing = existingKey ? parseHeaderList(merged[existingKey]) : []; - const values = Array.from(new Set([...existing, ...betas])); + const values = uniqueStrings([...existing, ...betas]); const key = existingKey ?? "anthropic-beta"; merged[key] = values.join(","); return merged; @@ -138,7 +137,7 @@ export function createAnthropicBetaHeadersWrapper( const piAiBetas = isOauth ? (PI_AI_OAUTH_ANTHROPIC_BETAS as readonly string[]) : (PI_AI_DEFAULT_ANTHROPIC_BETAS as readonly string[]); - const allBetas = [...new Set([...piAiBetas, ...effectiveBetas])]; + const allBetas = uniqueStrings([...piAiBetas, ...effectiveBetas]); return underlying(model, context, { ...options, headers: mergeAnthropicBetaHeader(options?.headers, allBetas), diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index 5a5ba235680..a08e774c4d8 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -5,6 +5,7 @@ import type { WebSearchProviderToolDefinition, } from "openclaw/plugin-sdk/provider-web-search"; import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-config-contract"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey"; @@ -61,10 +62,6 @@ const BraveSearchSchema = { }, } satisfies Record; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function resolveProviderWebSearchPluginConfig( config: unknown, pluginId: string, diff --git a/extensions/brave/web-search-contract-api.ts b/extensions/brave/web-search-contract-api.ts index 0bcf00daf9a..4b52b4b75de 100644 --- a/extensions/brave/web-search-contract-api.ts +++ b/extensions/brave/web-search-contract-api.ts @@ -2,10 +2,7 @@ import { createWebSearchProviderContractFields, type WebSearchProviderPlugin, } from "openclaw/plugin-sdk/provider-web-search-config-contract"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; function resolveLegacyTopLevelBraveCredential( config: unknown, diff --git a/extensions/browser/src/browser/chrome-mcp.ts b/extensions/browser/src/browser/chrome-mcp.ts index b86d898672f..4cfe0fecdbf 100644 --- a/extensions/browser/src/browser/chrome-mcp.ts +++ b/extensions/browser/src/browser/chrome-mcp.ts @@ -10,6 +10,8 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { normalizeOptionalString, readStringValue, + uniqueStrings, + uniqueValues, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { redactToolPayloadText } from "../logging/redact.js"; @@ -558,7 +560,7 @@ async function terminateChromeMcpProcessTree( const killProcess = deps?.killProcess ?? ((pid, signal) => process.kill(pid, signal)); const sleep = deps?.sleep ?? sleepTimeout; - const pids = Array.from(new Set([...descendantPids.toReversed(), rootPid])).filter( + const pids = uniqueValues([...descendantPids.toReversed(), rootPid]).filter( (pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid, ); const signaled: number[] = []; @@ -1107,7 +1109,7 @@ export async function closeChromeMcpSession(profileName: string): Promise { - const names = [...new Set([...sessions.keys()].map((key) => JSON.parse(key)[0] as string))]; + const names = uniqueStrings([...sessions.keys()].map((key) => JSON.parse(key)[0] as string)); for (const name of names) { await closeChromeMcpSession(name).catch(() => {}); } diff --git a/extensions/browser/src/browser/pw-session.page-cdp.ts b/extensions/browser/src/browser/pw-session.page-cdp.ts index 3870ea02945..8cf6b0000b1 100644 --- a/extensions/browser/src/browser/pw-session.page-cdp.ts +++ b/extensions/browser/src/browser/pw-session.page-cdp.ts @@ -1,3 +1,4 @@ +import { uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CDPSession, Page } from "playwright-core"; type PageCdpSend = (method: string, params?: Record) => Promise; @@ -72,7 +73,7 @@ export async function markBackendDomRefsOnPage(opts: { await send("DOM.enable").catch(() => {}); - const backendNodeIds = [...new Set(refs.map((entry) => Math.floor(entry.backendDOMNodeId)))]; + const backendNodeIds = uniqueValues(refs.map((entry) => Math.floor(entry.backendDOMNodeId))); const pushed = (await send("DOM.pushNodesByBackendIdsToFrontend", { backendNodeIds, }).catch(() => ({}))) as { nodeIds?: number[] }; diff --git a/extensions/browser/src/browser/pw-tools-core.responses.ts b/extensions/browser/src/browser/pw-tools-core.responses.ts index 8463c36da37..aefd971b502 100644 --- a/extensions/browser/src/browser/pw-tools-core.responses.ts +++ b/extensions/browser/src/browser/pw-tools-core.responses.ts @@ -1,11 +1,8 @@ +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { ensurePageState, getPageForTargetId } from "./pw-session.js"; import { normalizeTimeoutMs } from "./pw-tools-core.shared.js"; import { matchBrowserUrlPattern } from "./url-pattern.js"; -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" ? value.trim() || undefined : undefined; -} - export async function responseBodyViaPlaywright(opts: { cdpUrl: string; targetId?: string; diff --git a/extensions/browser/src/browser/routes/agent.shared.ts b/extensions/browser/src/browser/routes/agent.shared.ts index d95e3f13216..8a76c421f50 100644 --- a/extensions/browser/src/browser/routes/agent.shared.ts +++ b/extensions/browser/src/browser/routes/agent.shared.ts @@ -1,3 +1,4 @@ +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveBrowserNavigationProxyMode } from "../browser-proxy-mode.js"; import { toBrowserErrorResponse } from "../errors.js"; import { @@ -10,10 +11,6 @@ import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; import type { BrowserRequest, BrowserResponse } from "./types.js"; import { getProfileContext, jsonError } from "./utils.js"; -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" ? value.trim() || undefined : undefined; -} - export const SELECTOR_UNSUPPORTED_MESSAGE = [ "Error: 'selector' is not supported. Use 'ref' from snapshot instead.", "", diff --git a/extensions/browser/src/browser/routes/agent.snapshot.plan.ts b/extensions/browser/src/browser/routes/agent.snapshot.plan.ts index 5d59d250299..a255dccd7d4 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.plan.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.plan.ts @@ -1,3 +1,7 @@ +import { + normalizeOptionalString, + readStringValue, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ResolvedBrowserProfile } from "../config.js"; import { DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH, @@ -11,14 +15,6 @@ import { } from "../profile-capabilities.js"; import { toBoolean, toNumber, toStringOrEmpty } from "./utils.js"; -function readStringValue(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function normalizeOptionalString(value: unknown): string | undefined { - return readStringValue(value)?.trim() || undefined; -} - type BrowserSnapshotPlan = { format: "ai" | "aria"; mode?: "efficient"; diff --git a/extensions/browser/src/browser/routes/agent.storage.ts b/extensions/browser/src/browser/routes/agent.storage.ts index cc78dbd1f03..85caa82f2bc 100644 --- a/extensions/browser/src/browser/routes/agent.storage.ts +++ b/extensions/browser/src/browser/routes/agent.storage.ts @@ -1,3 +1,7 @@ +import { + normalizeOptionalString, + readStringValue, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { BrowserRouteContext } from "../server-context.js"; import { readBody, @@ -8,14 +12,6 @@ import { import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js"; import { asyncBrowserRoute, jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js"; -function readStringValue(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function normalizeOptionalString(value: unknown): string | undefined { - return readStringValue(value)?.trim() || undefined; -} - type StorageKind = "local" | "session"; export function parseStorageKind(raw: string): StorageKind | null { diff --git a/extensions/browser/src/browser/routes/permissions.ts b/extensions/browser/src/browser/routes/permissions.ts index e88511159e5..d7fca8a7800 100644 --- a/extensions/browser/src/browser/routes/permissions.ts +++ b/extensions/browser/src/browser/routes/permissions.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { SsrFPolicy } from "../../infra/net/ssrf.js"; import { withCdpSocket } from "../cdp.helpers.js"; import { getChromeWebSocketUrl } from "../chrome.js"; @@ -57,7 +58,7 @@ function readPermissions(raw: unknown): string[] | null { if (permissions.length !== raw.length) { return null; } - return [...new Set(permissions)]; + return uniqueStrings(permissions); } async function grantPermissions(params: { diff --git a/extensions/browser/src/browser/ssrf-policy-helpers.ts b/extensions/browser/src/browser/ssrf-policy-helpers.ts index 3b5b5980981..7255296318b 100644 --- a/extensions/browser/src/browser/ssrf-policy-helpers.ts +++ b/extensions/browser/src/browser/ssrf-policy-helpers.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; export function withAllowedHostname( @@ -6,6 +7,6 @@ export function withAllowedHostname( ): SsrFPolicy { return { ...ssrfPolicy, - allowedHostnames: Array.from(new Set([...(ssrfPolicy?.allowedHostnames ?? []), hostname])), + allowedHostnames: uniqueStrings([...(ssrfPolicy?.allowedHostnames ?? []), hostname]), }; } diff --git a/extensions/browser/src/node-host/invoke-browser.ts b/extensions/browser/src/node-host/invoke-browser.ts index 5fe8add51e5..e2a95678176 100644 --- a/extensions/browser/src/node-host/invoke-browser.ts +++ b/extensions/browser/src/node-host/invoke-browser.ts @@ -1,4 +1,5 @@ import fsPromises from "node:fs/promises"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { redactCdpUrl } from "../browser/cdp.helpers.js"; import { loadBrowserConfigForRuntimeRefresh } from "../browser/config-refresh-source.js"; import { resolveBrowserConfig } from "../browser/config.js"; @@ -40,7 +41,7 @@ const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000; const BROWSER_PROXY_STATUS_TIMEOUT_MS = 750; function normalizeProfileAllowlist(raw?: string[]): string[] { - return Array.isArray(raw) ? raw.map((entry) => entry.trim()).filter(Boolean) : []; + return Array.isArray(raw) ? normalizeStringEntries(raw) : []; } function resolveBrowserProxyConfig() { diff --git a/extensions/byteplus/video-generation-provider.ts b/extensions/byteplus/video-generation-provider.ts index 3f24cc9b58e..6c450cc89c3 100644 --- a/extensions/byteplus/video-generation-provider.ts +++ b/extensions/byteplus/video-generation-provider.ts @@ -13,7 +13,7 @@ import { waitProviderOperationPollInterval, type ProviderOperationTimeoutMs, } from "openclaw/plugin-sdk/provider-http"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { GeneratedVideoAsset, VideoGenerationProvider, @@ -43,10 +43,6 @@ type BytePlusTaskResponse = { type BytePlusTaskStatus = "running" | "failed" | "queued" | "succeeded" | "cancelled"; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - async function readBytePlusJsonResponse( response: Pick, label: string, diff --git a/extensions/canvas/src/config-migration.ts b/extensions/canvas/src/config-migration.ts index 3537c0df7ee..564fa84862f 100644 --- a/extensions/canvas/src/config-migration.ts +++ b/extensions/canvas/src/config-migration.ts @@ -1,12 +1,8 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; type MutableRecord = Record; -function readRecord(value: unknown): MutableRecord | undefined { - return isRecord(value) ? (value as MutableRecord) : undefined; -} - function mergeHostConfig(params: { legacyHost: MutableRecord; existingHost: MutableRecord | undefined; diff --git a/extensions/canvas/src/config.ts b/extensions/canvas/src/config.ts index b295cfbc0d9..f1ac5bd1e57 100644 --- a/extensions/canvas/src/config.ts +++ b/extensions/canvas/src/config.ts @@ -5,6 +5,11 @@ import { resolvePluginConfigObject, } from "openclaw/plugin-sdk/plugin-config-runtime"; import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; +import { + asBoolean as readBoolean, + isRecord, + readStringValue as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; export type CanvasHostConfig = { enabled?: boolean; @@ -22,18 +27,6 @@ type CanvasPluginConfigSchema = { uiHints: Record; }; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function readBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - function readPositiveInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } diff --git a/extensions/codex/src/app-server/app-inventory-cache.ts b/extensions/codex/src/app-server/app-inventory-cache.ts index 7b4e6117c06..60ff757de65 100644 --- a/extensions/codex/src/app-server/app-inventory-cache.ts +++ b/extensions/codex/src/app-server/app-inventory-cache.ts @@ -1,4 +1,5 @@ import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { JsonValue, v2 } from "./protocol.js"; export const CODEX_APP_INVENTORY_CACHE_TTL_MS = 60 * 60 * 1_000; @@ -260,10 +261,6 @@ function fingerprintInventoryCacheKey(key: string): string { return hash.toString(16).padStart(8, "0"); } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function redactErrorData(value: unknown, depth = 0): JsonValue | undefined { if (value === undefined) { return undefined; diff --git a/extensions/codex/src/app-server/approval-bridge.ts b/extensions/codex/src/app-server/approval-bridge.ts index 6ceda63db19..54c5044f881 100644 --- a/extensions/codex/src/app-server/approval-bridge.ts +++ b/extensions/codex/src/app-server/approval-bridge.ts @@ -9,6 +9,7 @@ import { type NativeHookRelayRegistrationHandle, runBeforeToolCallHook, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatCodexDisplayText } from "../command-formatters.js"; import { approvalRequestExplicitlyUnavailable, @@ -877,10 +878,7 @@ function summarizeNetworkPolicyAmendments(value: JsonValue | undefined): string } function readStringArray(record: JsonObject, key: string): string[] { - const value = record[key]; - return Array.isArray(value) - ? value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean) - : []; + return normalizeTrimmedStringList(record[key]); } function sanitizePermissionHostValue(value: string): string { diff --git a/extensions/codex/src/app-server/compact.ts b/extensions/codex/src/app-server/compact.ts index 29ef6a23060..6d2df335809 100644 --- a/extensions/codex/src/app-server/compact.ts +++ b/extensions/codex/src/app-server/compact.ts @@ -3,6 +3,7 @@ import { type CompactEmbeddedPiSessionParams, type EmbeddedPiCompactResult, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { defaultCodexAppServerClientFactory, type CodexAppServerClientFactory, @@ -117,12 +118,6 @@ function readAgentIdFromSessionKey(sessionKey: string | undefined): string | und return parts[1]?.trim() || undefined; } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - async function compactCodexNativeThread( params: CompactEmbeddedPiSessionParams, options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {}, diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 46fd6373508..45d7f5fd405 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -1,6 +1,7 @@ import { createHmac, randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; import { hostname as readHostName } from "node:os"; +import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime"; import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn"; import { z } from "zod"; import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js"; @@ -991,12 +992,7 @@ function normalizeHeaders(value: unknown): Record { } function normalizeStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value - .map((entry) => readNonEmptyString(entry)) - .filter((entry): entry is string => entry !== undefined); + return normalizeTrimmedStringList(value); } function readBooleanEnv(value: string | undefined): boolean | undefined { diff --git a/extensions/codex/src/app-server/dynamic-tools.ts b/extensions/codex/src/app-server/dynamic-tools.ts index dd2d7d26dbc..2f7beef33e0 100644 --- a/extensions/codex/src/app-server/dynamic-tools.ts +++ b/extensions/codex/src/app-server/dynamic-tools.ts @@ -20,6 +20,10 @@ import { wrapToolWithBeforeToolCallHook, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; +import { + asOptionalRecord as readRecord, + isRecord, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CodexDynamicToolsLoading } from "./config.js"; import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js"; import { @@ -435,14 +439,6 @@ function extractInternalSourceReplyPayload( : undefined; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function readRecord(value: unknown): Record | undefined { - return isRecord(value) ? value : undefined; -} - function readPositiveInteger(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { return undefined; diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index a24458237a9..f9c7ca16c0f 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -21,6 +21,11 @@ import { type ToolProgressDetailMode, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime"; +import { + asBoolean, + asFiniteNumber, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js"; import { readCodexNotificationThreadId, @@ -807,9 +812,7 @@ export class CodexAppServerEventProjector { } private buildToolMediaUrls(toolTelemetry: CodexAppServerToolTelemetry): string[] | undefined { - const mediaUrls = new Set( - toolTelemetry.toolMediaUrls?.map((url) => url.trim()).filter(Boolean) ?? [], - ); + const mediaUrls = new Set(normalizeStringEntries(toolTelemetry.toolMediaUrls ?? [])); if ((toolTelemetry.messagingToolSentMediaUrls?.length ?? 0) === 0) { for (const mediaUrl of this.nativeGeneratedMediaUrls) { mediaUrls.add(mediaUrl); @@ -1581,13 +1584,11 @@ function readNullableString(record: JsonObject, key: string): string | null | un } function readNumber(record: JsonObject, key: string): number | undefined { - const value = record[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(record[key]); } function readBoolean(record: JsonObject, key: string): boolean | undefined { - const value = record[key]; - return typeof value === "boolean" ? value : undefined; + return asBoolean(record[key]); } function readBooleanAlias(record: JsonObject, keys: readonly string[]): boolean | undefined { diff --git a/extensions/codex/src/app-server/image-payload-sanitizer.ts b/extensions/codex/src/app-server/image-payload-sanitizer.ts index 91e2d9c0c6a..4a88eb83993 100644 --- a/extensions/codex/src/app-server/image-payload-sanitizer.ts +++ b/extensions/codex/src/app-server/image-payload-sanitizer.ts @@ -1,3 +1,5 @@ +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; + const DATA_URL_PREFIX = "data:"; const IMAGE_OMITTED_TEXT = "omitted image payload: invalid inline image data"; const IMAGE_SIGNATURES: Array<{ @@ -105,8 +107,7 @@ function parseImageDataUrl(value: string): function metadataAllowsImageBase64(metadata: string[]): boolean { const [mimeType, ...options] = metadata; - const isImageMimeType = - mimeType !== undefined && mimeType.toLowerCase().startsWith("image/"); + const isImageMimeType = mimeType !== undefined && mimeType.toLowerCase().startsWith("image/"); return isImageMimeType && options.some((part) => part.toLowerCase() === "base64"); } @@ -137,10 +138,6 @@ export function invalidInlineImageText(label: string): string { return `[${label}] ${IMAGE_OMITTED_TEXT}`; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function sanitizeImageContentRecord( record: Record, label: string, diff --git a/extensions/codex/src/app-server/models.ts b/extensions/codex/src/app-server/models.ts index 911677d0fff..337caeb27e9 100644 --- a/extensions/codex/src/app-server/models.ts +++ b/extensions/codex/src/app-server/models.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js"; import type { CodexAppServerClient } from "./client.js"; import type { CodexAppServerStartOptions } from "./config.js"; @@ -156,7 +157,7 @@ function readReasoningEfforts(value: CodexReasoningEffortOption[]): string[] { const efforts = value .map((entry) => readNonEmptyString(entry.reasoningEffort)) .filter((entry): entry is string => entry !== undefined); - return [...new Set(efforts)]; + return uniqueStrings(efforts); } function readNonEmptyString(value: unknown): string | undefined { diff --git a/extensions/codex/src/app-server/native-subagent-monitor.ts b/extensions/codex/src/app-server/native-subagent-monitor.ts index e6c70bc8ae4..a2414285a26 100644 --- a/extensions/codex/src/app-server/native-subagent-monitor.ts +++ b/extensions/codex/src/app-server/native-subagent-monitor.ts @@ -10,6 +10,7 @@ import { type AgentHarnessTaskRuntime, type AgentHarnessTaskRecord, } from "openclaw/plugin-sdk/agent-harness-task-runtime"; +import { asFiniteNumber, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CodexAppServerClient } from "./client.js"; import { extractCodexNativeSubagentCompletions, @@ -842,11 +843,6 @@ function readString(record: JsonObject | undefined, key: string): string | undef return typeof value === "string" ? value : undefined; } -function normalizeOptionalString(value: string | undefined): string | undefined { - const normalized = value?.trim(); - return normalized || undefined; -} - function readStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -1037,8 +1033,7 @@ function readTranscriptParentThreadId(payload: JsonObject): string | undefined { } function readNumber(record: JsonObject, key: string): number | undefined { - const value = record[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(record[key]); } function secondsToMillis(value: number | undefined): number | undefined { diff --git a/extensions/codex/src/app-server/rate-limits.ts b/extensions/codex/src/app-server/rate-limits.ts index 74e42db3504..8de10c89026 100644 --- a/extensions/codex/src/app-server/rate-limits.ts +++ b/extensions/codex/src/app-server/rate-limits.ts @@ -1,3 +1,4 @@ +import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js"; const CODEX_LIMIT_ID = "codex"; @@ -603,8 +604,7 @@ function readNullableString(record: JsonObject, key: string): string | undefined } function readNumber(record: JsonObject, key: string): number | undefined { - const value = record[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(record[key]); } function normalizeText(value: string | null | undefined): string | undefined { diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 88663542207..968abe24fb6 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -55,6 +55,7 @@ import { } from "openclaw/plugin-sdk/diagnostic-runtime"; import { isToolAllowed } from "openclaw/plugin-sdk/sandbox"; import { pathExists } from "openclaw/plugin-sdk/security-runtime"; +import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js"; import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js"; import { @@ -4990,8 +4991,7 @@ function readString(record: JsonObject, key: string): string | undefined { } function readBoolean(record: JsonObject, key: string): boolean | undefined { - const value = record[key]; - return typeof value === "boolean" ? value : undefined; + return asBoolean(record[key]); } async function readMirroredSessionHistoryMessages( diff --git a/extensions/codex/src/app-server/transcript-mirror.ts b/extensions/codex/src/app-server/transcript-mirror.ts index 58bcb0b6235..b0598fc34c9 100644 --- a/extensions/codex/src/app-server/transcript-mirror.ts +++ b/extensions/codex/src/app-server/transcript-mirror.ts @@ -10,16 +10,12 @@ import { type EmbeddedRunAttemptParams, type SessionWriteLockAcquireTimeoutConfig, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; type MirroredAgentMessage = Extract; const MIRROR_IDENTITY_META_KEY = "mirrorIdentity" as const; -function normalizeOptionalString(value: string | null | undefined): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; -} - function buildSenderLabel(params: { senderId?: string; senderName?: string; diff --git a/extensions/codex/src/command-account.ts b/extensions/codex/src/command-account.ts index 4141c1e9ce0..e0ffda8a0db 100644 --- a/extensions/codex/src/command-account.ts +++ b/extensions/codex/src/command-account.ts @@ -10,6 +10,7 @@ import { type AuthProfileStore, } from "openclaw/plugin-sdk/agent-runtime"; import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; +import { normalizeUniqueStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/capabilities.js"; import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js"; import { rememberCodexRateLimits } from "./app-server/rate-limit-cache.js"; @@ -149,7 +150,7 @@ function resolveDisplayAuthOrder(params: { resolveOrder(params.store.order, OPENAI_CODEX_PROVIDER_ID) ?? resolveOrder(params.config?.auth?.order, OPENAI_CODEX_PROVIDER_ID); if (codexOrder && codexOrder.length > 0) { - return { order: dedupe(codexOrder), explicit: true }; + return { order: normalizeUniqueStringEntries(codexOrder), explicit: true }; } const order = resolveAuthProfileOrder({ cfg: params.config, @@ -573,17 +574,3 @@ function formatRelativeReset(untilMs: number, nowMs: number): string { const days = Math.ceil(durationMs / dayMs); return `in ${days} ${days === 1 ? "day" : "days"}`; } - -function dedupe(values: string[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const value of values) { - const trimmed = value.trim(); - if (!trimmed || seen.has(trimmed)) { - continue; - } - seen.add(trimmed); - result.push(trimmed); - } - return result; -} diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index c23579652a7..b79ccafc11f 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { resolveAgentDir, resolveSessionAgentIds } from "openclaw/plugin-sdk/agent-runtime"; import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/capabilities.js"; import { installCodexComputerUse, @@ -2104,8 +2105,3 @@ function normalizeComputerUseStringOverrides( } return normalized; } - -function normalizeOptionalString(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed || undefined; -} diff --git a/extensions/codex/src/conversation-binding-data.ts b/extensions/codex/src/conversation-binding-data.ts index e19638f2e54..202dc554c5e 100644 --- a/extensions/codex/src/conversation-binding-data.ts +++ b/extensions/codex/src/conversation-binding-data.ts @@ -1,5 +1,6 @@ import process from "node:process"; import type { PluginConversationBinding } from "openclaw/plugin-sdk/plugin-entry"; +import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; const BINDING_DATA_VERSION = 1; @@ -112,12 +113,6 @@ export function resolveCodexDefaultWorkspaceDir(pluginConfig: unknown): string { return configured ?? process.cwd(); } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readString(record: Record | undefined, key: string) { const value = record?.[key]; return typeof value === "string" && value.trim() ? value.trim() : undefined; diff --git a/extensions/codex/src/conversation-turn-collector.ts b/extensions/codex/src/conversation-turn-collector.ts index faf303b1b8c..c9911b6f967 100644 --- a/extensions/codex/src/conversation-turn-collector.ts +++ b/extensions/codex/src/conversation-turn-collector.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { readCodexNotificationThreadId, readCodexNotificationTurnId, @@ -173,12 +174,6 @@ function readNotificationTurnId(params: JsonObject): string | undefined { return readCodexNotificationTurnId(params); } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readString(record: Record | JsonObject | undefined, key: string) { const value = record?.[key]; return typeof value === "string" && value.trim() ? value.trim() : undefined; diff --git a/extensions/codex/src/conversation-turn-input.ts b/extensions/codex/src/conversation-turn-input.ts index 0f30bafcee1..a5622a72917 100644 --- a/extensions/codex/src/conversation-turn-input.ts +++ b/extensions/codex/src/conversation-turn-input.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { PluginHookInboundClaimEvent } from "openclaw/plugin-sdk/plugin-entry"; +import { normalizeSingleOrTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CodexUserInput } from "./app-server/protocol.js"; type InboundMedia = { @@ -28,10 +29,14 @@ function extractInboundMedia(event: PluginHookInboundClaimEvent): InboundMedia[] // OpenClaw channels expose either local staged files or remote URLs. Keep // them separate so Codex can receive the cheaper localImage input when a file // is already present, while still supporting remote-only transports. - const paths = readStringArray(metadata.mediaPaths).concat(readStringArray(metadata.mediaPath)); - const urls = readStringArray(metadata.mediaUrls).concat(readStringArray(metadata.mediaUrl)); - const mimeTypes = readStringArray(metadata.mediaTypes).concat( - readStringArray(metadata.mediaType), + const paths = normalizeSingleOrTrimmedStringList(metadata.mediaPaths).concat( + normalizeSingleOrTrimmedStringList(metadata.mediaPath), + ); + const urls = normalizeSingleOrTrimmedStringList(metadata.mediaUrls).concat( + normalizeSingleOrTrimmedStringList(metadata.mediaUrl), + ); + const mimeTypes = normalizeSingleOrTrimmedStringList(metadata.mediaTypes).concat( + normalizeSingleOrTrimmedStringList(metadata.mediaType), ); const count = Math.max(paths.length, urls.length, mimeTypes.length); const media: InboundMedia[] = []; @@ -94,13 +99,3 @@ function readLocalMediaPath(value: string | undefined): string | undefined { } return /^[a-z][a-z0-9+.-]*:/i.test(value) ? undefined : value; } - -function readStringArray(value: unknown): string[] { - if (typeof value === "string" && value.trim()) { - return [value.trim()]; - } - if (!Array.isArray(value)) { - return []; - } - return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); -} diff --git a/extensions/codex/src/migration/apply.ts b/extensions/codex/src/migration/apply.ts index a09f4e55f9d..cb50f73120b 100644 --- a/extensions/codex/src/migration/apply.ts +++ b/extensions/codex/src/migration/apply.ts @@ -20,6 +20,7 @@ import type { MigrationPlan, MigrationProviderContext, } from "openclaw/plugin-sdk/plugin-entry"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { defaultCodexAppInventoryCache } from "../app-server/app-inventory-cache.js"; import { resolveCodexAppServerAuthAccountCacheKey, @@ -176,8 +177,8 @@ export async function applyCodexMigrationPlan(params: { reportDir, }; if (items.some(isCodexPluginLoadWarningItem)) { - result.warnings = [...new Set([...(result.warnings ?? []), CODEX_PLUGIN_LOAD_WARNING])]; - result.nextSteps = [...new Set([CODEX_PLUGIN_LOAD_WARNING, ...(result.nextSteps ?? [])])]; + result.warnings = uniqueStrings([...(result.warnings ?? []), CODEX_PLUGIN_LOAD_WARNING]); + result.nextSteps = uniqueStrings([CODEX_PLUGIN_LOAD_WARNING, ...(result.nextSteps ?? [])]); } await writeMigrationReport(result, { title: "Codex Migration Report" }); return result; diff --git a/extensions/codex/src/migration/auth.ts b/extensions/codex/src/migration/auth.ts index b4c55fea9ff..6476a9adddf 100644 --- a/extensions/codex/src/migration/auth.ts +++ b/extensions/codex/src/migration/auth.ts @@ -17,6 +17,10 @@ import { type OpenClawConfig, type ProviderAuthResult, } from "openclaw/plugin-sdk/provider-auth"; +import { + isRecord, + normalizeOptionalString as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { readJsonObject } from "./helpers.js"; import type { CodexSource } from "./source.js"; import type { resolveCodexMigrationTargets } from "./targets.js"; @@ -65,14 +69,6 @@ type CodexAuthConfigApplyResult = "configured" | "conflict" | "unavailable"; class CodexAuthConfigConflict extends Error {} -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function decodeJwtPayload(token: string): Record | undefined { const payload = token.split(".")[1]; if (!payload) { diff --git a/extensions/codex/src/migration/plan.ts b/extensions/codex/src/migration/plan.ts index f3d7e27e284..aa788e4c459 100644 --- a/extensions/codex/src/migration/plan.ts +++ b/extensions/codex/src/migration/plan.ts @@ -12,6 +12,7 @@ import type { MigrationPlan, MigrationProviderContext, } from "openclaw/plugin-sdk/plugin-entry"; +import { asBoolean, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { CODEX_PLUGINS_MARKETPLACE_NAME } from "../app-server/config.js"; import { buildCodexAuthItems } from "./auth.js"; import { exists, sanitizeName } from "./helpers.js"; @@ -261,11 +262,7 @@ function readExistingAllowDestructiveActions( ...CODEX_PLUGIN_NATIVE_CONFIG_PATH, "allow_destructive_actions", ]); - return typeof value === "boolean" ? value : undefined; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); + return asBoolean(value); } export function buildCodexPluginsConfigValue( diff --git a/extensions/codex/src/node-cli-sessions.ts b/extensions/codex/src/node-cli-sessions.ts index 901e4df4e13..87bec3d30f5 100644 --- a/extensions/codex/src/node-cli-sessions.ts +++ b/extensions/codex/src/node-cli-sessions.ts @@ -8,6 +8,7 @@ import type { OpenClawPluginNodeInvokePolicy, } from "openclaw/plugin-sdk/plugin-entry"; import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { materializeWindowsSpawnProgram, @@ -705,7 +706,3 @@ function readNodeId(node: CodexCliSessionNodeInfo): string { function formatNodeLabel(node: CodexCliSessionNodeInfo): string { return [node.displayName, node.nodeId, node.remoteIp].filter(Boolean).join(" / ") || "node"; } - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} diff --git a/extensions/comfy/workflow-runtime.ts b/extensions/comfy/workflow-runtime.ts index 2b6e4ce1ca1..b08405072f6 100644 --- a/extensions/comfy/workflow-runtime.ts +++ b/extensions/comfy/workflow-runtime.ts @@ -25,9 +25,11 @@ import { type SsrFPolicy, } from "openclaw/plugin-sdk/ssrf-runtime"; import { + asBoolean, isRecord, normalizeOptionalLowercaseString, normalizeOptionalString, + uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -112,8 +114,7 @@ export function setComfyFetchGuardForTesting(impl: typeof fetchWithSsrFGuard | n } function readConfigBoolean(config: ComfyProviderConfig, key: string): boolean | undefined { - const value = config[key]; - return typeof value === "boolean" ? value : undefined; + return asBoolean(config[key]); } function readConfigInteger(config: ComfyProviderConfig, key: string): number | undefined { @@ -822,6 +823,6 @@ export async function runComfyWorkflow(params: { assets, model: providerModel, promptId, - outputNodeIds: Array.from(new Set(outputFiles.map((entry) => entry.nodeId))), + outputNodeIds: uniqueStrings(outputFiles.map((entry) => entry.nodeId)), }; } diff --git a/extensions/copilot-proxy/index.ts b/extensions/copilot-proxy/index.ts index 42f96501200..7449c49e992 100644 --- a/extensions/copilot-proxy/index.ts +++ b/extensions/copilot-proxy/index.ts @@ -1,3 +1,4 @@ +import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { definePluginEntry, type ProviderAuthContext, @@ -43,11 +44,8 @@ function validateBaseUrl(value: string): string | undefined { } function parseModelIds(input: string): string[] { - const parsed = input - .split(/[\n,]/) - .map((model) => model.trim()) - .filter(Boolean); - return Array.from(new Set(parsed)); + const parsed = normalizeStringEntries(input.split(/[\n,]/)); + return uniqueStrings(parsed); } function buildModelDefinition(modelId: string) { diff --git a/extensions/deepgram/audio.ts b/extensions/deepgram/audio.ts index bc797fb5e7f..a6ede9c2126 100644 --- a/extensions/deepgram/audio.ts +++ b/extensions/deepgram/audio.ts @@ -9,6 +9,7 @@ import { resolveProviderHttpRequestConfig, requireTranscriptionText, } from "openclaw/plugin-sdk/provider-http"; +import { asOptionalRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export const DEFAULT_DEEPGRAM_AUDIO_BASE_URL = "https://api.deepgram.com/v1"; export const DEFAULT_DEEPGRAM_AUDIO_MODEL = "nova-3"; @@ -18,12 +19,6 @@ function resolveModel(model?: string): string { return trimmed || DEFAULT_DEEPGRAM_AUDIO_MODEL; } -function asRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readDeepgramTranscript(payload: Record): string | undefined { const results = asRecord(payload.results); if (!results) { diff --git a/extensions/deepgram/realtime-transcription-provider.ts b/extensions/deepgram/realtime-transcription-provider.ts index a042be9ed7e..7c60d68d84e 100644 --- a/extensions/deepgram/realtime-transcription-provider.ts +++ b/extensions/deepgram/realtime-transcription-provider.ts @@ -6,7 +6,12 @@ import { type RealtimeTranscriptionSessionCreateRequest, } from "openclaw/plugin-sdk/realtime-transcription"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord as readRecord, + normalizeOptionalString, + parseBooleanValue as readBoolean, + parseFiniteNumber as readFiniteNumber, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_DEEPGRAM_AUDIO_BASE_URL, DEFAULT_DEEPGRAM_AUDIO_MODEL } from "./audio.js"; type DeepgramRealtimeTranscriptionEncoding = "linear16" | "mulaw" | "alaw"; @@ -55,45 +60,12 @@ const DEEPGRAM_REALTIME_MAX_RECONNECT_ATTEMPTS = 5; const DEEPGRAM_REALTIME_RECONNECT_DELAY_MS = 1000; const DEEPGRAM_REALTIME_MAX_QUEUED_BYTES = 2 * 1024 * 1024; -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readNestedDeepgramConfig(rawConfig: RealtimeTranscriptionProviderConfig) { const raw = readRecord(rawConfig); const providers = readRecord(raw?.providers); return readRecord(providers?.deepgram ?? raw?.deepgram ?? raw) ?? {}; } -function readFiniteNumber(value: unknown): number | undefined { - const next = - typeof value === "number" - ? value - : typeof value === "string" - ? Number.parseFloat(value) - : undefined; - return Number.isFinite(next) ? next : undefined; -} - -function readBoolean(value: unknown): boolean | undefined { - if (typeof value === "boolean") { - return value; - } - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim().toLowerCase(); - if (["1", "true", "yes", "on"].includes(normalized)) { - return true; - } - if (["0", "false", "no", "off"].includes(normalized)) { - return false; - } - return undefined; -} - function normalizeDeepgramEncoding( value: unknown, ): DeepgramRealtimeTranscriptionEncoding | undefined { diff --git a/extensions/deepinfra/video-generation-provider.ts b/extensions/deepinfra/video-generation-provider.ts index 6435f97a837..eb12d4562ee 100644 --- a/extensions/deepinfra/video-generation-provider.ts +++ b/extensions/deepinfra/video-generation-provider.ts @@ -7,7 +7,10 @@ import { postJsonRequest, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asFiniteNumber as coerceProviderNumber, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { GeneratedVideoAsset, VideoGenerationProvider, @@ -79,14 +82,6 @@ function parseVideoDataUrl(url: string): GeneratedVideoAsset | undefined { }; } -function coerceProviderNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function coerceProviderString(value: unknown): string | undefined { - return normalizeOptionalString(value); -} - function resolveDurationSeconds(value: number | undefined): number | undefined { if (typeof value !== "number" || !Number.isFinite(value)) { return undefined; @@ -115,11 +110,12 @@ function buildDeepInfraVideoBody( body.seed = seed; } const negativePrompt = - coerceProviderString(options.negative_prompt) ?? coerceProviderString(options.negativePrompt); + normalizeOptionalString(options.negative_prompt) ?? + normalizeOptionalString(options.negativePrompt); if (negativePrompt) { body.negative_prompt = negativePrompt; } - const style = coerceProviderString(options.style); + const style = normalizeOptionalString(options.style); if (style) { body.style = style; } diff --git a/extensions/discord/src/components-registry.ts b/extensions/discord/src/components-registry.ts index 534d3711156..d4113591195 100644 --- a/extensions/discord/src/components-registry.ts +++ b/extensions/discord/src/components-registry.ts @@ -1,4 +1,5 @@ import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js"; import { getOptionalDiscordRuntime } from "./runtime.js"; @@ -275,7 +276,7 @@ function resolveComponentConsumptionIds(entry: DiscordComponentEntry): string[] return [entry.id]; } const ids = entry.consumptionGroupEntryIds?.filter((id) => typeof id === "string" && id) ?? []; - return ids.length > 0 ? Array.from(new Set(ids)) : [entry.id]; + return ids.length > 0 ? uniqueStrings(ids) : [entry.id]; } function deleteComponentConsumptionGroup(entry: DiscordComponentEntry): void { diff --git a/extensions/discord/src/inbound-event-delivery.ts b/extensions/discord/src/inbound-event-delivery.ts index c86915de71e..90337b6c8ab 100644 --- a/extensions/discord/src/inbound-event-delivery.ts +++ b/extensions/discord/src/inbound-event-delivery.ts @@ -1,4 +1,8 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; +import { + asOptionalRecord as readRecord, + normalizeOptionalString as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; export type DiscordInboundEventDeliveryEnd = () => void; @@ -77,16 +81,6 @@ export function notifyDiscordInboundEventOutboundSuccess(params: { event.markInboundEventDelivered(); } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - export function withDiscordInboundEventDeliveryMetadata( payload: ReplyPayload, params: { diff --git a/extensions/discord/src/monitor/message-handler.hydration.ts b/extensions/discord/src/monitor/message-handler.hydration.ts index f49d6b67666..b9a05a90116 100644 --- a/extensions/discord/src/monitor/message-handler.hydration.ts +++ b/extensions/discord/src/monitor/message-handler.hydration.ts @@ -1,5 +1,6 @@ import type { APIMessage, APIUser } from "discord-api-types/v10"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { readStringValue as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getChannelMessage, Message as DiscordMessage, type Message } from "../internal/discord.js"; import { resolveDiscordMessageText, type DiscordChannelInfo } from "./message-utils.js"; @@ -93,10 +94,6 @@ function readMessageFallback(message: Message): MessageFallback { }; } -function readString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - function normalizeStringArray(value: unknown): string[] { return Array.isArray(value) ? value.flatMap((entry) => (typeof entry === "string" ? [entry] : [])) diff --git a/extensions/discord/src/monitor/message-media.ts b/extensions/discord/src/monitor/message-media.ts index 7cc47843f16..309c199c76e 100644 --- a/extensions/discord/src/monitor/message-media.ts +++ b/extensions/discord/src/monitor/message-media.ts @@ -7,6 +7,7 @@ import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, + uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { Message } from "../internal/discord.js"; import { @@ -81,7 +82,7 @@ function mergeHostnameList(...lists: Array): string[] | un if (merged.length === 0) { return undefined; } - return Array.from(new Set(merged)); + return uniqueStrings(merged); } function resolveDiscordMediaSsrFPolicy(policy?: SsrFPolicy): SsrFPolicy { diff --git a/extensions/discord/src/monitor/provider.commands.ts b/extensions/discord/src/monitor/provider.commands.ts index 15670c9d13e..923fa12105e 100644 --- a/extensions/discord/src/monitor/provider.commands.ts +++ b/extensions/discord/src/monitor/provider.commands.ts @@ -5,7 +5,10 @@ import { } from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { danger, warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeStringEntriesLower, +} from "openclaw/plugin-sdk/string-coerce-runtime"; export type GetPluginCommandSpecs = typeof import("openclaw/plugin-sdk/plugin-runtime").getPluginCommandSpecs; @@ -32,9 +35,7 @@ async function appendPluginCommandSpecs(params: { getPluginCommandSpecs?: GetPluginCommandSpecs; }): Promise { const merged = [...params.commandSpecs]; - const existingNames = new Set( - merged.map((spec) => normalizeLowercaseStringOrEmpty(spec.name)).filter(Boolean), - ); + const existingNames = new Set(normalizeStringEntriesLower(merged.map((spec) => spec.name))); const getPluginCommandSpecs = params.getPluginCommandSpecs ?? (await loadPluginRuntime()).getPluginCommandSpecs; for (const pluginCommand of getPluginCommandSpecs("discord", { config: params.cfg })) { diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.ts index b2f6dcafc8f..6bf3e90bf08 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { normalizeOptionalLowercaseString, normalizeOptionalString, + uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { parseDiscordTarget } from "../targets.js"; import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js"; @@ -349,6 +350,6 @@ export async function reconcileAcpThreadBindingsOnStartup(params: { return { checked: acpBindings.length, removed, - staleSessionKeys: [...new Set(staleSessionKeys)], + staleSessionKeys: uniqueStrings(staleSessionKeys), }; } diff --git a/extensions/discord/src/security-audit.ts b/extensions/discord/src/security-audit.ts index 8478a3e3ccc..e3803feb59b 100644 --- a/extensions/discord/src/security-audit.ts +++ b/extensions/discord/src/security-audit.ts @@ -5,15 +5,11 @@ import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, } from "openclaw/plugin-sdk/native-command-config-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ResolvedDiscordAccount } from "./accounts.js"; import type { OpenClawConfig } from "./runtime-api.js"; import { isDiscordMutableAllowEntry } from "./security-doctor.js"; -function normalizeOptionalString(value: string | null | undefined): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; -} - function addDiscordNameBasedEntries(params: { target: Set; values: unknown; diff --git a/extensions/discord/src/send.components.ts b/extensions/discord/src/send.components.ts index b32a938c660..76a55e68469 100644 --- a/extensions/discord/src/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -4,6 +4,7 @@ import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/conf import type { OutboundMediaAccess } from "openclaw/plugin-sdk/media-runtime"; import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-chunking"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveDiscordAccount } from "./accounts.js"; import { registerDiscordComponentEntries } from "./components-registry.js"; import { @@ -218,7 +219,7 @@ async function buildDiscordComponentPayload(params: { } const attachmentNames = extractComponentAttachmentNames(spec); - const uniqueAttachmentNames = [...new Set(attachmentNames)]; + const uniqueAttachmentNames = uniqueStrings(attachmentNames); if (uniqueAttachmentNames.length > 1) { throw new Error( "Discord component attachments currently support a single file. Use media-gallery for multiple files.", diff --git a/extensions/discord/src/send.emojis-stickers.ts b/extensions/discord/src/send.emojis-stickers.ts index 11cf1bdb447..ba9c34e9891 100644 --- a/extensions/discord/src/send.emojis-stickers.ts +++ b/extensions/discord/src/send.emojis-stickers.ts @@ -1,4 +1,7 @@ -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeOptionalLowercaseString, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media"; import { createGuildEmoji, createGuildSticker, listGuildEmojis } from "./internal/discord.js"; import { normalizeEmojiName, resolveDiscordRest } from "./send.shared.js"; @@ -21,7 +24,7 @@ export async function uploadEmojiDiscord(payload: DiscordEmojiUpload, opts: Disc throw new Error("Discord emoji uploads require a PNG, JPG, or GIF image"); } const image = `data:${contentType};base64,${media.buffer.toString("base64")}`; - const roleIds = (payload.roleIds ?? []).map((id) => id.trim()).filter(Boolean); + const roleIds = normalizeStringEntries(payload.roleIds ?? []); return await createGuildEmoji(rest, payload.guildId, { body: { name: normalizeEmojiName(payload.name, "Emoji name"), diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index 2530dc6a78d..637c276f16c 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -14,6 +14,7 @@ import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime" import type { ChunkMode } from "openclaw/plugin-sdk/reply-chunking"; import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload"; import type { RetryRunner } from "openclaw/plugin-sdk/retry-runtime"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { chunkDiscordTextWithMode } from "./chunk.js"; import { createDiscordClient, resolveDiscordRest, type DiscordClientOpts } from "./client.js"; @@ -80,7 +81,7 @@ function normalizeReactionEmoji(raw: string) { } function normalizeStickerIds(raw: string[]) { - const ids = raw.map((entry) => entry.trim()).filter(Boolean); + const ids = normalizeStringEntries(raw); if (ids.length === 0) { throw new Error("At least one sticker id is required"); } diff --git a/extensions/discord/src/voice/realtime.ts b/extensions/discord/src/voice/realtime.ts index afa76a4a9af..dce9f4ee612 100644 --- a/extensions/discord/src/voice/realtime.ts +++ b/extensions/discord/src/voice/realtime.ts @@ -30,6 +30,7 @@ import { } from "openclaw/plugin-sdk/realtime-voice"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asBoolean, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { maybeControlDiscordVoiceAgentRun } from "./agent-control.js"; import { convertDiscordPcm48kStereoToRealtimePcm24kMono, @@ -197,8 +198,7 @@ function readProviderConfigBoolean( config: RealtimeVoiceProviderConfig | undefined, key: string, ): boolean | undefined { - const value = config?.[key]; - return typeof value === "boolean" ? value : undefined; + return asBoolean(config?.[key]); } export function resolveDiscordVoiceMode(voice: DiscordAccountConfig["voice"]): DiscordVoiceMode { @@ -324,7 +324,7 @@ function resolveDiscordRealtimeWakeNames(params: { const configured = rawConfigured .map((name) => normalizeSupportedRealtimeVoiceActivationName(name)) .filter((name): name is string => Boolean(name)); - return sortRealtimeVoiceActivationNames(Array.from(new Set(configured))); + return sortRealtimeVoiceActivationNames(uniqueStrings(configured)); } const agent = params.cfg.agents?.list?.find((candidate) => candidate.id === params.agentId); const configuredAgentNames = [agent?.name, agent?.identity?.name] @@ -339,7 +339,7 @@ function resolveDiscordRealtimeWakeNames(params: { : [normalizeSupportedRealtimeVoiceActivationName(params.agentId), ...productWakeNames].filter( (name): name is string => Boolean(name), ); - return sortRealtimeVoiceActivationNames(Array.from(new Set(defaults))); + return sortRealtimeVoiceActivationNames(uniqueStrings(defaults)); } function matchesPendingAgentProxyQuestion(consultMessage: string, question: string): boolean { diff --git a/extensions/discord/src/voice/tts.ts b/extensions/discord/src/voice/tts.ts index 40cf87ddc16..b80350ab85b 100644 --- a/extensions/discord/src/voice/tts.ts +++ b/extensions/discord/src/voice/tts.ts @@ -7,7 +7,7 @@ import { } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig, TtsConfig } from "openclaw/plugin-sdk/config-contracts"; import { parseTtsDirectives } from "openclaw/plugin-sdk/speech"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getDiscordRuntime } from "../runtime.js"; import { sanitizeVoiceReplyTextForSpeech } from "./sanitize.js"; @@ -40,7 +40,7 @@ function mergeTtsConfig(base: TtsConfig, override?: TtsConfig): TtsConfig { const baseProviders = base.providers ?? {}; const overrideProviders = override.providers ?? {}; const mergedProviders = Object.fromEntries( - [...new Set([...Object.keys(baseProviders), ...Object.keys(overrideProviders)])].map( + uniqueStrings([...Object.keys(baseProviders), ...Object.keys(overrideProviders)]).map( (providerId) => { const baseProvider = baseProviders[providerId] ?? {}; const overrideProvider = overrideProviders[providerId] ?? {}; diff --git a/extensions/elevenlabs/realtime-transcription-provider.ts b/extensions/elevenlabs/realtime-transcription-provider.ts index 55fae953776..1af2a1f1fd5 100644 --- a/extensions/elevenlabs/realtime-transcription-provider.ts +++ b/extensions/elevenlabs/realtime-transcription-provider.ts @@ -7,7 +7,11 @@ import { type RealtimeTranscriptionWebSocketTransport, } from "openclaw/plugin-sdk/realtime-transcription"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord as readRecord, + normalizeOptionalString, + parseFiniteNumber as readFiniteNumber, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveElevenLabsApiKeyWithProfileFallback } from "./config-api.js"; import { normalizeElevenLabsBaseUrl } from "./shared.js"; @@ -57,28 +61,12 @@ const ELEVENLABS_REALTIME_MAX_RECONNECT_ATTEMPTS = 5; const ELEVENLABS_REALTIME_RECONNECT_DELAY_MS = 1000; const ELEVENLABS_REALTIME_MAX_QUEUED_BYTES = 2 * 1024 * 1024; -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readNestedElevenLabsConfig(rawConfig: RealtimeTranscriptionProviderConfig) { const raw = readRecord(rawConfig); const providers = readRecord(raw?.providers); return readRecord(providers?.elevenlabs ?? raw?.elevenlabs ?? raw) ?? {}; } -function readFiniteNumber(value: unknown): number | undefined { - const next = - typeof value === "number" - ? value - : typeof value === "string" - ? Number.parseFloat(value) - : undefined; - return Number.isFinite(next) ? next : undefined; -} - function normalizeCommitStrategy(value: unknown): "manual" | "vad" | undefined { const normalized = normalizeOptionalString(value)?.toLowerCase(); if (!normalized) { diff --git a/extensions/fal/image-generation-provider.ts b/extensions/fal/image-generation-provider.ts index 18e22f10b29..5db5b97ec20 100644 --- a/extensions/fal/image-generation-provider.ts +++ b/extensions/fal/image-generation-provider.ts @@ -21,6 +21,7 @@ import { ssrfPolicyFromDangerouslyAllowPrivateNetwork, } from "openclaw/plugin-sdk/ssrf-runtime"; import { + isRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -62,10 +63,6 @@ function matchesTrustedHostSuffix(hostname: string, trustedSuffix: string): bool return normalizedHost === normalizedSuffix || normalizedHost.endsWith(`.${normalizedSuffix}`); } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function parseFalImageGenerationResponse(payload: unknown): { images: Record[]; prompt?: string; diff --git a/extensions/fal/video-generation-provider.ts b/extensions/fal/video-generation-provider.ts index 514cf5e2978..3e5e43c9b5a 100644 --- a/extensions/fal/video-generation-provider.ts +++ b/extensions/fal/video-generation-provider.ts @@ -11,6 +11,7 @@ import { ssrfPolicyFromDangerouslyAllowPrivateNetwork, } from "openclaw/plugin-sdk/ssrf-runtime"; import { + isRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -97,10 +98,6 @@ export function setFalVideoFetchGuardForTesting(impl: typeof fetchWithSsrFGuard falFetchGuard = impl ?? fetchWithSsrFGuard; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function normalizeFalVideoUrl(value: unknown): string | undefined { const normalized = normalizeOptionalString(value); if (!normalized && value !== undefined && value !== null) { diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 1e206f03b27..8a5ff950845 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -18,7 +18,7 @@ import { warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/runtime-group-policy"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveFeishuRuntimeAccount } from "./accounts.js"; import { checkBotMentioned, @@ -595,7 +595,7 @@ export async function handleFeishuMessage(params: { const configAllowFrom = feishuCfg?.allowFrom ?? []; const rawBroadcastAgents = isGroup ? resolveBroadcastAgents(cfg, ctx.chatId) : null; const broadcastAgents = rawBroadcastAgents - ? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))] + ? uniqueStrings(rawBroadcastAgents.map((id) => normalizeAgentId(id))) : null; // Parse message create_time early so every downstream consumer (pending diff --git a/extensions/feishu/src/comment-shared.ts b/extensions/feishu/src/comment-shared.ts index 14add27b14c..31b67f3b8f3 100644 --- a/extensions/feishu/src/comment-shared.ts +++ b/extensions/feishu/src/comment-shared.ts @@ -1,6 +1,7 @@ import { isRecord as sharedIsRecord, normalizeOptionalString, + normalizeStringEntries, readStringValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { FEISHU_COMMENT_FILE_TYPES, type CommentFileType } from "./comment-target.js"; @@ -237,10 +238,7 @@ function parseCommentLinkedDocumentPath(pathname: string): { urlKind: ParsedCommentResolvedDocumentType | "wiki"; token: string; } | null { - const segments = pathname - .split("/") - .map((segment) => segment.trim()) - .filter(Boolean); + const segments = normalizeStringEntries(pathname.split("/")); const offset = segments[0]?.toLowerCase() === "space" ? 1 : 0; const kind = COMMENT_LINK_KIND_ALIASES.get(segments[offset]?.toLowerCase() ?? ""); const token = normalizeString(segments[offset + 1]); diff --git a/extensions/feishu/src/dedupe-key.ts b/extensions/feishu/src/dedupe-key.ts index 96f3e86d4bd..663fe6fedf0 100644 --- a/extensions/feishu/src/dedupe-key.ts +++ b/extensions/feishu/src/dedupe-key.ts @@ -1,15 +1,10 @@ +import { asNullableRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { FeishuMessageEvent } from "./event-types.js"; import { normalizeFeishuExternalKey } from "./external-keys.js"; import { parsePostContent } from "./post.js"; type FeishuMessageDedupeInput = Pick; -function readRecord(value: unknown): Record | null { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : null; -} - function readExternalKey(value: unknown): string | undefined { return normalizeFeishuExternalKey(typeof value === "string" ? value : ""); } diff --git a/extensions/feishu/src/docx.ts b/extensions/feishu/src/docx.ts index 1cd7c8c43d3..744ed0d2ee4 100644 --- a/extensions/feishu/src/docx.ts +++ b/extensions/feishu/src/docx.ts @@ -5,7 +5,7 @@ import { basename } from "node:path"; import type * as Lark from "@larksuiteoapi/node-sdk"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { extensionForMime } from "openclaw/plugin-sdk/media-mime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; @@ -235,7 +235,8 @@ function normalizeConvertedBlockTree( const rootIds = ( firstLevelIds && firstLevelIds.length > 0 ? firstLevelIds : inferredTopLevelIds - ).filter((id, index, arr) => typeof id === "string" && byId.has(id) && arr.indexOf(id) === index); + ).filter((id): id is string => typeof id === "string" && byId.has(id)); + const uniqueRootIds = uniqueStrings(rootIds); const orderedBlocks: FeishuDocxBlock[] = []; const visited = new Set(); @@ -255,7 +256,7 @@ function normalizeConvertedBlockTree( } }; - for (const rootId of rootIds) { + for (const rootId of uniqueRootIds) { visit(rootId); } @@ -268,7 +269,7 @@ function normalizeConvertedBlockTree( } } - return { orderedBlocks, rootIds: rootIds.filter((id): id is string => typeof id === "string") }; + return { orderedBlocks, rootIds: uniqueRootIds }; } async function insertBlocks( diff --git a/extensions/feishu/src/monitor.bot-menu-handler.ts b/extensions/feishu/src/monitor.bot-menu-handler.ts index ac45f0fc52b..69a6611bd85 100644 --- a/extensions/feishu/src/monitor.bot-menu-handler.ts +++ b/extensions/feishu/src/monitor.bot-menu-handler.ts @@ -1,3 +1,4 @@ +import { isRecord, readStringValue as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ClawdbotConfig, HistoryEntry, RuntimeEnv } from "../runtime-api.js"; import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js"; import { maybeHandleFeishuQuickActionMenu } from "./card-ux-launcher.js"; @@ -18,18 +19,10 @@ type FeishuBotMenuEvent = { }; }; -function readString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - function readStringOrNumber(value: unknown): string | number | undefined { return typeof value === "string" || typeof value === "number" ? value : undefined; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function parseFeishuBotMenuEvent(value: unknown): FeishuBotMenuEvent | null { if (!isRecord(value)) { return null; diff --git a/extensions/feishu/src/monitor.comment.ts b/extensions/feishu/src/monitor.comment.ts index f9df897689e..6b802342722 100644 --- a/extensions/feishu/src/monitor.comment.ts +++ b/extensions/feishu/src/monitor.comment.ts @@ -1,4 +1,5 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { asBoolean as readBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ClawdbotConfig } from "../runtime-api.js"; import { raceWithTimeoutAndAbort } from "./async.js"; import { createFeishuClient } from "./client.js"; @@ -163,10 +164,6 @@ type ResolvedWholeCommentTimelineEntry = { content: ParsedCommentContent; }; -function readBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - function safeJsonStringify(value: unknown): string { try { return JSON.stringify(value); diff --git a/extensions/feishu/src/monitor.message-handler.ts b/extensions/feishu/src/monitor.message-handler.ts index 350c322dd08..0342663c383 100644 --- a/extensions/feishu/src/monitor.message-handler.ts +++ b/extensions/feishu/src/monitor.message-handler.ts @@ -1,3 +1,4 @@ +import { isRecord, readStringValue as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ClawdbotConfig, HistoryEntry, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import { resolveFeishuMessageDedupeKey } from "./dedupe-key.js"; import type { FeishuMessageEvent } from "./event-types.js"; @@ -9,14 +10,6 @@ import { import { createSequentialQueue } from "./sequential-queue.js"; import type { FeishuChatType } from "./types.js"; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - type FeishuMessageReceiveHandlerContext = { cfg: ClawdbotConfig; core: PluginRuntime; diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index 93ff2c37f52..873b8b15859 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -16,7 +16,11 @@ import { sendTextMediaPayload, } from "openclaw/plugin-sdk/reply-payload"; import { statRegularFileSync } from "openclaw/plugin-sdk/security-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeLowercaseStringOrEmpty, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveFeishuAccount } from "./accounts.js"; import { createFeishuClient } from "./client.js"; import { cleanupAmbientCommentTypingReaction } from "./comment-reaction.js"; @@ -80,10 +84,6 @@ function shouldUseCard(text: string): boolean { return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text); } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function markRenderedFeishuCard(card: Record): Record { Object.defineProperty(card, RENDERED_FEISHU_CARD, { value: true, @@ -92,6 +92,21 @@ function markRenderedFeishuCard(card: Record): Record]/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + default: + return char; + } + }); +} + function resolveSafeFeishuButtonUrl(url: unknown): string | undefined { const trimmed = typeof url === "string" ? url.trim() : ""; if (!trimmed) { @@ -179,18 +194,7 @@ function sanitizeNativeFeishuCardElements(element: unknown): Record]/g, (char) => { - switch (char) { - case "&": - return "&"; - case "<": - return "<"; - case ">": - return ">"; - default: - return char; - } - }), + content: escapeFeishuCardMarkdownText(element.content), }, ]; } @@ -527,9 +531,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { }); } - const mediaUrls = resolvePayloadMediaUrls(ctx.payload) - .map((entry) => entry.trim()) - .filter(Boolean); + const mediaUrls = normalizeStringEntries(resolvePayloadMediaUrls(ctx.payload)); return attachChannelToResult( "feishu", await sendPayloadMediaSequenceAndFinalize({ diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 6143302d8b2..737a8cb7900 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -1,5 +1,6 @@ import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { + isRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -67,10 +68,6 @@ function isWithdrawnReplyError(err: unknown): boolean { return false; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - type FeishuCreateMessageClient = { im: { message: { diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 073d76564dd..3ef94ded0a0 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -13,6 +13,7 @@ import { type OpenClawConfig, type SecretInput, } from "openclaw/plugin-sdk/setup"; +import { normalizeOptionalString as normalizeString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveDefaultFeishuAccountId, resolveFeishuAccount } from "./accounts.js"; import type { AppRegistrationResult } from "./app-registration.js"; import type { FeishuConfig, FeishuDomain } from "./types.js"; @@ -27,14 +28,6 @@ const FEISHU_SETUP_FLOW_KEY = "_flow"; // Helpers // --------------------------------------------------------------------------- -function normalizeString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed || undefined; -} - function isFeishuConfigured(cfg: OpenClawConfig): boolean { const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; diff --git a/extensions/google-meet/src/config-compat.ts b/extensions/google-meet/src/config-compat.ts index d0438ecd861..44a772f94a8 100644 --- a/extensions/google-meet/src/config-compat.ts +++ b/extensions/google-meet/src/config-compat.ts @@ -1,4 +1,8 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + asNullableRecord as asRecord, + normalizeOptionalLowercaseString as normalizeProviderId, +} from "openclaw/plugin-sdk/string-coerce-runtime"; type LegacyConfigRule = { path: Array; @@ -6,16 +10,6 @@ type LegacyConfigRule = { match: (value: unknown) => boolean; }; -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -function normalizeProviderId(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined; -} - function hasOwn(record: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(record, key); } diff --git a/extensions/google-meet/src/config.ts b/extensions/google-meet/src/config.ts index 20cf3483be3..abba8da301f 100644 --- a/extensions/google-meet/src/config.ts +++ b/extensions/google-meet/src/config.ts @@ -4,8 +4,10 @@ import { type RealtimeVoiceAgentConsultToolPolicy, } from "openclaw/plugin-sdk/realtime-voice"; import { + asRecord, normalizeOptionalLowercaseString, normalizeOptionalString, + normalizeOptionalTrimmedStringList, } from "openclaw/plugin-sdk/string-coerce-runtime"; export type GoogleMeetTransport = "chrome" | "chrome-node" | "twilio"; @@ -260,12 +262,6 @@ const GOOGLE_MEET_PREVIEW_ACK_KEYS = [ "GOOGLE_MEET_PREVIEW_ACK", ] as const; -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function resolveBoolean(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } @@ -318,13 +314,7 @@ function readEnvNumber(env: NodeJS.ProcessEnv, keys: readonly string[]): number } function resolveStringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const normalized = value - .map((entry) => normalizeOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)); - return normalized.length > 0 ? normalized : undefined; + return normalizeOptionalTrimmedStringList(value); } function resolveProvidersConfig(value: unknown): Record> { diff --git a/extensions/google-meet/src/meet.ts b/extensions/google-meet/src/meet.ts index 846979c8356..bb8f4e892f6 100644 --- a/extensions/google-meet/src/meet.ts +++ b/extensions/google-meet/src/meet.ts @@ -1,4 +1,5 @@ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { exportGoogleDriveDocumentText, extractGoogleDriveDocumentId } from "./drive.js"; import { googleApiError } from "./google-api-errors.js"; @@ -797,9 +798,10 @@ function mergeAttendanceRows( grouped.set(key, { ...row, participants: [row.participant] }); continue; } - existing.participants = [ - ...new Set([...(existing.participants ?? [existing.participant]), row.participant]), - ]; + existing.participants = uniqueStrings([ + ...(existing.participants ?? [existing.participant]), + row.participant, + ]); existing.sessions.push(...row.sessions); existing.displayName ??= row.displayName; existing.user ??= row.user; diff --git a/extensions/google-meet/src/node-host.ts b/extensions/google-meet/src/node-host.ts index 2f06dfbea3c..bb023785938 100644 --- a/extensions/google-meet/src/node-host.ts +++ b/extensions/google-meet/src/node-host.ts @@ -2,6 +2,10 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { setTimeout as sleep } from "node:timers/promises"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { + asRecord, + normalizeOptionalString as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND, DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND, @@ -33,16 +37,6 @@ type NodeBridgeSession = { const sessions = new Map(); -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; diff --git a/extensions/google-meet/src/runtime.ts b/extensions/google-meet/src/runtime.ts index 7202a05fb7d..48b2c81b429 100644 --- a/extensions/google-meet/src/runtime.ts +++ b/extensions/google-meet/src/runtime.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime"; import { sleep } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { GoogleMeetConfig, GoogleMeetMode, @@ -207,7 +207,7 @@ function collectChromeAudioCommands(config: GoogleMeetConfig): string[] { config.chrome.audioOutputCommand?.[0], config.chrome.bargeInInputCommand?.[0], ]; - return [...new Set(commands.filter((value): value is string => Boolean(value?.trim())))]; + return uniqueStrings(commands.filter((value): value is string => Boolean(value?.trim()))); } async function commandExists(runtime: PluginRuntime, command: string): Promise { diff --git a/extensions/google-meet/src/setup.ts b/extensions/google-meet/src/setup.ts index 704723b3653..e792e64690b 100644 --- a/extensions/google-meet/src/setup.ts +++ b/extensions/google-meet/src/setup.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js"; type SetupCheck = { @@ -273,13 +274,3 @@ export function addGoogleMeetSetupCheck( checks, }; } - -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} diff --git a/extensions/google-meet/src/transports/chrome.ts b/extensions/google-meet/src/transports/chrome.ts index a227746e030..57c606158ed 100644 --- a/extensions/google-meet/src/transports/chrome.ts +++ b/extensions/google-meet/src/transports/chrome.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime"; import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { GoogleMeetConfig, GoogleMeetMode } from "../config.js"; import { startNodeAgentAudioBridge, @@ -313,7 +314,7 @@ function mergeBrowserNotes( } return { ...browser, - notes: [...new Set([...(browser.notes ?? []), ...notes])], + notes: uniqueStrings([...(browser.notes ?? []), ...notes]), }; } diff --git a/extensions/google/embedding-batch.ts b/extensions/google/embedding-batch.ts index c00be8f800a..d83b6b64c7c 100644 --- a/extensions/google/embedding-batch.ts +++ b/extensions/google/embedding-batch.ts @@ -9,6 +9,7 @@ import { withRemoteHttpResponse, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; import { createProviderHttpError } from "openclaw/plugin-sdk/provider-http"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { GeminiEmbeddingClient, GeminiTextEmbeddingRequest } from "./embedding-provider.js"; type EmbeddingBatchExecutionParams = { @@ -221,11 +222,9 @@ function parseGeminiBatchOutput(text: string): GeminiBatchOutputLine[] { if (!text.trim()) { return []; } - return text - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => JSON.parse(line) as GeminiBatchOutputLine); + return normalizeStringEntries(text.split("\n")).map( + (line) => JSON.parse(line) as GeminiBatchOutputLine, + ); } async function waitForGeminiBatch(params: { diff --git a/extensions/google/embedding-provider.ts b/extensions/google/embedding-provider.ts index 721a760360a..80330807a0d 100644 --- a/extensions/google/embedding-provider.ts +++ b/extensions/google/embedding-provider.ts @@ -20,7 +20,10 @@ import { readProviderJsonObjectResponse, } from "openclaw/plugin-sdk/provider-http"; import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord as asRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; export type GeminiEmbeddingClient = { baseUrl: string; @@ -91,12 +94,6 @@ type GeminiEmbeddingRequest = { }; export type GeminiTextEmbeddingRequest = GeminiEmbeddingRequest; -function asRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function malformedGeminiEmbeddingResponse(): Error { return new Error("gemini embeddings failed: malformed JSON response"); } diff --git a/extensions/google/image-generation-provider.ts b/extensions/google/image-generation-provider.ts index 4af0e088f6c..34a484cd74e 100644 --- a/extensions/google/image-generation-provider.ts +++ b/extensions/google/image-generation-provider.ts @@ -11,6 +11,7 @@ import { sanitizeConfiguredModelProviderRequest, } from "openclaw/plugin-sdk/provider-http"; import { + isRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -41,10 +42,6 @@ const GOOGLE_SUPPORTED_ASPECT_RATIOS = [ const GOOGLE_IMAGE_MALFORMED_RESPONSE = "Google image generation response malformed"; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function normalizeGoogleImageModel(model: string | undefined): string { const trimmed = model?.trim(); return normalizeGoogleModelId(trimmed || DEFAULT_GOOGLE_IMAGE_MODEL); diff --git a/extensions/google/provider-policy.ts b/extensions/google/provider-policy.ts index d9b8ff91f54..d8362403e56 100644 --- a/extensions/google/provider-policy.ts +++ b/extensions/google/provider-policy.ts @@ -3,6 +3,7 @@ import type { ProviderThinkingProfile, } from "openclaw/plugin-sdk/core"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeAntigravityModelId, normalizeGoogleModelId } from "./model-id.js"; import { isGoogleGemini3ProModel, isGoogleGemini3ThinkingLevelModel } from "./thinking-api.js"; @@ -17,10 +18,6 @@ type GoogleProviderConfigLike = GoogleApiCarrier & { export const DEFAULT_GOOGLE_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; const GOOGLE_MODEL_ID_PROVIDERS = new Set(["google", "google-gemini-cli", "google-vertex"]); -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function trimTrailingSlashes(value: string): string { return value.replace(/\/+$/, ""); } diff --git a/extensions/google/realtime-voice-provider.ts b/extensions/google/realtime-voice-provider.ts index 2c6deb1eb8c..e41a3b89043 100644 --- a/extensions/google/realtime-voice-provider.ts +++ b/extensions/google/realtime-voice-provider.ts @@ -37,7 +37,11 @@ import { resamplePcm, } from "openclaw/plugin-sdk/realtime-voice"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean, + asFiniteNumber, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { createGoogleGenAI } from "./google-genai-runtime.js"; const GOOGLE_REALTIME_DEFAULT_MODEL = "gemini-2.5-flash-native-audio-preview-12-2025"; @@ -126,14 +130,6 @@ function trimToUndefined(value: unknown): string | undefined { return normalizeOptionalString(value); } -function asFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function asBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - function asSensitivity(value: unknown): GoogleRealtimeSensitivity | undefined { const normalized = normalizeOptionalString(value)?.toLowerCase(); return normalized === "low" || normalized === "high" ? normalized : undefined; diff --git a/extensions/google/src/gemini-web-search-provider.runtime.ts b/extensions/google/src/gemini-web-search-provider.runtime.ts index 46662f39aa8..7c44e2859ab 100644 --- a/extensions/google/src/gemini-web-search-provider.runtime.ts +++ b/extensions/google/src/gemini-web-search-provider.runtime.ts @@ -23,6 +23,7 @@ import { wrapWebContent, writeCachedSearchPayload, } from "openclaw/plugin-sdk/provider-web-search"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveGeminiConfig, resolveGeminiBaseUrl, @@ -60,10 +61,6 @@ type GeminiGroundingResponse = { }; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function throwMalformedGeminiResponse(): never { throw new Error("Gemini API error: malformed JSON response"); } diff --git a/extensions/google/src/gemini-web-search-provider.shared.ts b/extensions/google/src/gemini-web-search-provider.shared.ts index 527bc2cac4c..fdbf0b49115 100644 --- a/extensions/google/src/gemini-web-search-provider.shared.ts +++ b/extensions/google/src/gemini-web-search-provider.shared.ts @@ -1,3 +1,7 @@ +import { + isRecord, + normalizeOptionalString as trimToUndefined, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeGoogleApiBaseUrl } from "../provider-policy.js"; const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash"; @@ -10,14 +14,6 @@ export type GeminiConfig = { providerBaseUrl?: unknown; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function trimToUndefined(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - export function resolveGeminiConfig(searchConfig?: Record): GeminiConfig { const gemini = searchConfig?.gemini; return isRecord(gemini) ? gemini : {}; diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index 1dfbd76adcf..5400db23687 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -6,6 +6,7 @@ import { type WebSearchProviderPlugin, type WebSearchProviderToolDefinition, } from "openclaw/plugin-sdk/provider-web-search-config-contract"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveGeminiApiKey, resolveGeminiBaseUrl, @@ -66,10 +67,6 @@ function createGeminiToolDefinition( }; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function resolveGoogleModelProviderConfig( config?: OpenClawConfig, ): Record | undefined { diff --git a/extensions/google/transport-stream.ts b/extensions/google/transport-stream.ts index e3641cb3521..abfcfb3d0b7 100644 --- a/extensions/google/transport-stream.ts +++ b/extensions/google/transport-stream.ts @@ -21,7 +21,10 @@ import { transformTransportMessages, type WritableTransportStream, } from "openclaw/plugin-sdk/provider-transport-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { parseGeminiAuth } from "./gemini-auth.js"; import { normalizeGoogleApiBaseUrl } from "./provider-policy.js"; import { @@ -140,10 +143,6 @@ type GoogleSseChunk = { let toolCallCounter = 0; const GEMINI_THOUGHT_SIGNATURE_VALIDATOR_SKIP = "skip_thought_signature_validator"; -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function requiresToolCallId(modelId: string): boolean { return modelId.startsWith("claude-") || modelId.startsWith("gpt-oss-"); } @@ -202,9 +201,7 @@ function hasGeminiThoughtSignatureTruncationFootprint(value: string): boolean { ); } -function sanitizeGeminiThoughtSignature( - thoughtSignature: string | undefined, -): string | undefined { +function sanitizeGeminiThoughtSignature(thoughtSignature: string | undefined): string | undefined { if (typeof thoughtSignature !== "string") { return undefined; } @@ -552,9 +549,7 @@ function convertGoogleMessages(model: GoogleTransportModel, context: Context) { : undefined; parts.push({ text: sanitizeTransportPayloadText(block.text), - ...(sanitizedTextSignature - ? { thoughtSignature: sanitizedTextSignature } - : {}), + ...(sanitizedTextSignature ? { thoughtSignature: sanitizedTextSignature } : {}), }); continue; } diff --git a/extensions/google/vertex-adc.ts b/extensions/google/vertex-adc.ts index 7e0742e08ca..6f870ea5833 100644 --- a/extensions/google/vertex-adc.ts +++ b/extensions/google/vertex-adc.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; type GoogleAuthorizedUserCredentials = { type: "authorized_user"; @@ -46,10 +47,6 @@ export function resetGoogleVertexAuthorizedUserTokenCacheForTest(): void { cachedGoogleVertexAdcToken = undefined; } -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - export function isGoogleVertexCredentialsMarker( apiKey: string | undefined, ): apiKey is undefined | typeof GCP_VERTEX_CREDENTIALS_MARKER { diff --git a/extensions/imessage/src/actions.runtime.ts b/extensions/imessage/src/actions.runtime.ts index 649a2016670..4caab9e0671 100644 --- a/extensions/imessage/src/actions.runtime.ts +++ b/extensions/imessage/src/actions.runtime.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { extname, join } from "node:path"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { createIMessageRpcClient } from "./client.js"; import { extractMarkdownFormatRuns } from "./markdown-format.js"; @@ -222,10 +223,7 @@ async function runIMessageCliJson( if (killEscalation) { clearTimeout(killEscalation); } - const lines = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(stdout.split(/\r?\n/)); const last = lines.at(-1); let parsed: Record | null = null; if (last) { diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index 9050b624a90..e8c6910ecc5 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -24,6 +24,7 @@ import { createChannelHistoryWindow, type HistoryEntry } from "openclaw/plugin-s import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime"; +import { uniqueStrings } 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 { resolveIMessageConversationRoute } from "../conversation-route.js"; @@ -94,7 +95,7 @@ function mergeIMessageGroupAllowFromWithLegacyChatTargets(params: { if (legacyChatTargets.length === 0) { return params.groupAllowFrom; } - return Array.from(new Set([...params.groupAllowFrom, ...legacyChatTargets])); + return uniqueStrings([...params.groupAllowFrom, ...legacyChatTargets]); } const imessageIngressIdentity = defineStableChannelIngressIdentity({ diff --git a/extensions/imessage/src/probe.ts b/extensions/imessage/src/probe.ts index c4fe1208879..8af48501e48 100644 --- a/extensions/imessage/src/probe.ts +++ b/extensions/imessage/src/probe.ts @@ -4,7 +4,10 @@ import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; 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 { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { createIMessageRpcClient } from "./client.js"; import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; import { @@ -107,10 +110,7 @@ function parseStatusPayload(stdout: string): { payload: Record | null; firstLineSnippet?: string; } { - const lines = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(stdout.split(/\r?\n/)); for (const line of lines.toReversed()) { try { const value = JSON.parse(line); diff --git a/extensions/irc/src/normalize.ts b/extensions/irc/src/normalize.ts index 8a6e67afbc5..483875e1ef2 100644 --- a/extensions/irc/src/normalize.ts +++ b/extensions/irc/src/normalize.ts @@ -1,6 +1,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, + normalizeStringEntriesLower, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { hasIrcControlChars } from "./control-chars.js"; import type { IrcInboundMessage } from "./types.js"; @@ -93,7 +94,7 @@ export function resolveIrcAllowlistMatch(params: { message: IrcInboundMessage; allowNameMatching?: boolean; }): { allowed: boolean; source?: string } { - const allowFrom = new Set(params.allowFrom.map(normalizeLowercaseStringOrEmpty).filter(Boolean)); + const allowFrom = new Set(normalizeStringEntriesLower(params.allowFrom)); if (allowFrom.has("*")) { return { allowed: true, source: "wildcard" }; } diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index 21be5c2f12d..3d17b34fddf 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -83,7 +83,7 @@ export function setIrcGroupAccess( return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy }); } const normalizedEntries = [ - ...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)), + ...new Set(entries.flatMap((entry) => normalizeGroupEntry(entry) ?? [])), ]; const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}])); return updateIrcAccountConfig(cfg, accountId, { diff --git a/extensions/irc/src/setup-surface.ts b/extensions/irc/src/setup-surface.ts index 3db151d2bb6..231722ca6ff 100644 --- a/extensions/irc/src/setup-surface.ts +++ b/extensions/irc/src/setup-surface.ts @@ -14,7 +14,9 @@ import { } from "openclaw/plugin-sdk/setup"; import { normalizeOptionalString, + normalizeStringEntries, normalizeStringifiedOptionalString, + uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js"; import { @@ -40,10 +42,7 @@ const USE_ENV_FLAG = "__ircUseEnv"; const TLS_FLAG = "__ircTls"; function parseListInput(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries(raw.split(/[\n,;]+/g)); } function normalizeGroupEntry(raw: string): string | null { @@ -74,10 +73,9 @@ const promptIrcAllowFrom = createPromptParsedAllowFromForAccount({ message: t("wizard.irc.allowFromPrompt"), placeholder: "alice, bob!ident@example.org", parseEntries: (raw) => ({ - entries: parseListInput(raw) - .map((entry) => normalizeIrcAllowEntry(entry)) - .map((entry) => entry.trim()) - .filter(Boolean), + entries: normalizeStringEntries( + parseListInput(raw).map((entry) => normalizeIrcAllowEntry(entry)), + ), }), getExistingAllowFrom: ({ cfg }) => cfg.channels?.irc?.allowFrom ?? [], applyAllowFrom: ({ cfg, allowFrom }) => setIrcAllowFrom(cfg, allowFrom), @@ -372,7 +370,11 @@ export const ircSetupWizard: ChannelSetupWizard = { setPolicy: ({ cfg, accountId, policy }) => setIrcGroupAccess(cfg as CoreConfig, accountId, policy, [], normalizeGroupEntry), resolveAllowlist: async ({ entries }) => - [...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean))] as string[], + uniqueStrings( + entries + .map((entry) => normalizeGroupEntry(entry)) + .filter((entry): entry is string => Boolean(entry)), + ), applyAllowlist: ({ cfg, accountId, resolved }) => setIrcGroupAccess( cfg as CoreConfig, diff --git a/extensions/line/src/config-adapter.ts b/extensions/line/src/config-adapter.ts index d86b96bbf13..70f32c0e013 100644 --- a/extensions/line/src/config-adapter.ts +++ b/extensions/line/src/config-adapter.ts @@ -1,4 +1,5 @@ import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { listLineAccountIds, resolveDefaultLineAccountId, @@ -21,9 +22,5 @@ export const lineConfigAdapter = createScopedChannelConfigAdapter< defaultAccountId: resolveDefaultLineAccountId, clearBaseFields: ["channelSecret", "tokenFile", "secretFile"], resolveAllowFrom: (account) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map(normalizeLineAllowFrom), + formatAllowFrom: (allowFrom) => normalizeStringEntries(allowFrom).map(normalizeLineAllowFrom), }); diff --git a/extensions/line/src/quick-reply-fallback.ts b/extensions/line/src/quick-reply-fallback.ts index c0c740aaafc..4c418ed3859 100644 --- a/extensions/line/src/quick-reply-fallback.ts +++ b/extensions/line/src/quick-reply-fallback.ts @@ -1,8 +1,7 @@ +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; + export function buildLineQuickReplyFallbackText(labels: readonly string[] | undefined): string { - const normalized = (labels ?? []) - .map((label) => label.trim()) - .filter(Boolean) - .slice(0, 13); + const normalized = normalizeStringEntries(labels ?? []).slice(0, 13); if (normalized.length === 0) { return "Choose an option."; } diff --git a/extensions/line/src/reply-payload-transform.ts b/extensions/line/src/reply-payload-transform.ts index c62c2417201..0ab0341cedb 100644 --- a/extensions/line/src/reply-payload-transform.ts +++ b/extensions/line/src/reply-payload-transform.ts @@ -1,5 +1,8 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { createAgendaCard, createAppleTvRemoteCard, @@ -49,10 +52,7 @@ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload { const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i); if (quickRepliesMatch) { - const options = quickRepliesMatch[1] - .split(",") - .map((s) => s.trim()) - .filter(Boolean); + const options = normalizeStringEntries(quickRepliesMatch[1].split(",")); if (options.length > 0) { lineData.quickReplies = [...(lineData.quickReplies || []), ...options]; } diff --git a/extensions/lmstudio/src/models.ts b/extensions/lmstudio/src/models.ts index 4d28e3c1e8e..90b7e3fe3cc 100644 --- a/extensions/lmstudio/src/models.ts +++ b/extensions/lmstudio/src/models.ts @@ -7,6 +7,7 @@ import { SELF_HOSTED_DEFAULT_COST, SELF_HOSTED_DEFAULT_MAX_TOKENS, } from "openclaw/plugin-sdk/provider-setup"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { LMSTUDIO_DEFAULT_BASE_URL, LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./defaults.js"; export type LmstudioModelWire = { @@ -76,13 +77,7 @@ function normalizeReasoningOptions(value: unknown): string[] { if (!Array.isArray(value)) { return []; } - return [ - ...new Set( - value - .map((option) => normalizeReasoningOption(option)) - .filter((option): option is string => option !== null), - ), - ]; + return uniqueStrings(value.flatMap((option) => normalizeReasoningOption(option) ?? [])); } function isLmstudioBinaryReasoningOptions(allowedOptions: readonly string[]): boolean { @@ -98,13 +93,11 @@ function resolveLmstudioTransportReasoningEfforts(allowedOptions: readonly strin ? [...LMSTUDIO_OPENAI_COMPAT_REASONING_EFFORTS] : [...LMSTUDIO_OPENAI_COMPAT_ENABLED_REASONING_EFFORTS]; } - return [ - ...new Set( - allowedOptions - .map((option) => (option === "off" ? "none" : option)) - .filter((option) => option !== "on"), - ), - ]; + return uniqueStrings( + allowedOptions + .map((option) => (option === "off" ? "none" : option)) + .filter((option) => option !== "on"), + ); } function resolveLmstudioEnabledTransportReasoningOption( diff --git a/extensions/lmstudio/src/setup.ts b/extensions/lmstudio/src/setup.ts index d64c1593675..12c6ed928d5 100644 --- a/extensions/lmstudio/src/setup.ts +++ b/extensions/lmstudio/src/setup.ts @@ -23,6 +23,7 @@ import { type ProviderRuntimeModel, } from "openclaw/plugin-sdk/provider-setup"; import { WizardCancelledError, type WizardPrompter } from "openclaw/plugin-sdk/setup"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, LMSTUDIO_DEFAULT_INFERENCE_BASE_URL, @@ -281,7 +282,7 @@ function mergeDiscoveredModels(params: { } const merged = [...explicitModels]; - const seen = new Set(explicitModels.map((model) => model.id.trim()).filter(Boolean)); + const seen = new Set(normalizeStringEntries(explicitModels.map((model) => model.id))); for (const model of discoveredModels) { const id = model.id.trim(); if (!id || seen.has(id)) { @@ -328,17 +329,16 @@ function mergeDiscoveredLmstudioAllowlistEntries(params: { }) { return withAgentModelAliases( params.existing, - params.discoveredModels - .map((model) => model.id.trim()) - .filter(Boolean) - .map((id) => `${PROVIDER_ID}/${id}`), + normalizeStringEntries(params.discoveredModels.map((model) => model.id)).map( + (id) => `${PROVIDER_ID}/${id}`, + ), ); } function selectDefaultLmstudioModelId( discoveredModels: ModelDefinitionConfig[], ): string | undefined { - const ids = discoveredModels.map((model) => model.id.trim()).filter(Boolean); + const ids = normalizeStringEntries(discoveredModels.map((model) => model.id)); if (ids.length === 0) { return undefined; } diff --git a/extensions/matrix/src/approval-handler.runtime.ts b/extensions/matrix/src/approval-handler.runtime.ts index 37036778090..f121e11cda9 100644 --- a/extensions/matrix/src/approval-handler.runtime.ts +++ b/extensions/matrix/src/approval-handler.runtime.ts @@ -20,6 +20,7 @@ import { listMessageReceiptPlatformIds, resolveMessageReceiptPrimaryId, } from "openclaw/plugin-sdk/channel-message"; +import { normalizeUniqueStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildMatrixApprovalReactionHint, listMatrixApprovalReactionBindings, @@ -155,9 +156,7 @@ function resolveHandlerContext(params: ChannelApprovalCapabilityHandlerContext): } function normalizePendingMessageIds(entry: PendingMessage): string[] { - return Array.from( - new Set(entry.platformMessageIds.map((messageId) => messageId.trim()).filter(Boolean)), - ); + return normalizeUniqueStringEntries(entry.platformMessageIds); } function normalizeReactionTargetRef(params: ReactionTargetRef): ReactionTargetRef | null { diff --git a/extensions/matrix/src/matrix/actions/polls.ts b/extensions/matrix/src/matrix/actions/polls.ts index 2106a9cb1b7..01233d434ee 100644 --- a/extensions/matrix/src/matrix/actions/polls.ts +++ b/extensions/matrix/src/matrix/actions/polls.ts @@ -1,3 +1,4 @@ +import { uniqueStrings, uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildPollResponseContent, isPollStartType, @@ -11,12 +12,12 @@ function normalizeOptionIndexes(indexes: number[]): number[] { const normalized = indexes .map((index) => Math.trunc(index)) .filter((index) => Number.isFinite(index) && index > 0); - return Array.from(new Set(normalized)); + return uniqueValues(normalized); } function normalizeOptionIds(optionIds: string[]): string[] { - return Array.from( - new Set(optionIds.map((optionId) => optionId.trim()).filter((optionId) => optionId.length > 0)), + return uniqueStrings( + optionIds.map((optionId) => optionId.trim()).filter((optionId) => optionId.length > 0), ); } diff --git a/extensions/matrix/src/matrix/monitor/auto-join.ts b/extensions/matrix/src/matrix/monitor/auto-join.ts index 6559cde8149..3cc2ff6cb75 100644 --- a/extensions/matrix/src/matrix/monitor/auto-join.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.ts @@ -1,4 +1,4 @@ -import { normalizeStringifiedOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeStringifiedEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getMatrixRuntime } from "../../runtime.js"; import type { MatrixConfig } from "../../types.js"; import type { MatrixClient } from "../sdk.js"; @@ -18,9 +18,7 @@ export function registerMatrixAutoJoin(params: { runtime.log?.(message); }; const autoJoin = accountConfig.autoJoin ?? "off"; - const rawAllowlist = (accountConfig.autoJoinAllowlist ?? []) - .map((entry) => normalizeStringifiedOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)); + const rawAllowlist = normalizeStringifiedEntries(accountConfig.autoJoinAllowlist ?? []); const autoJoinAllowlist = new Set(rawAllowlist); const allowedRoomIds = new Set(rawAllowlist.filter((entry) => entry.startsWith("!"))); const allowedAliases = rawAllowlist.filter((entry) => entry.startsWith("#")); diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index 4389583dddb..27274a673dd 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -1,4 +1,4 @@ -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { PluginRuntime, RuntimeLogger } from "../../runtime-api.js"; import type { CoreConfig } from "../../types.js"; import type { MatrixAuth } from "../client.js"; @@ -113,14 +113,15 @@ function createMatrixPostHealthySyncDecryptFailureTracker(params: { } warningEmitted = true; - const rooms = [...new Set(observations.map((entry) => entry.roomId))].slice( - 0, - MATRIX_POST_HEALTHY_SYNC_DECRYPT_FAILURE_SAMPLE_LIMIT, - ); - const senders = [...new Set(observations.map((entry) => entry.sender).filter(Boolean))].slice( + const rooms = uniqueStrings(observations.map((entry) => entry.roomId)).slice( 0, MATRIX_POST_HEALTHY_SYNC_DECRYPT_FAILURE_SAMPLE_LIMIT, ); + const senders = uniqueStrings( + observations + .map((entry) => entry.sender) + .filter((sender): sender is string => Boolean(sender)), + ).slice(0, MATRIX_POST_HEALTHY_SYNC_DECRYPT_FAILURE_SAMPLE_LIMIT); const eventIds = observations .slice(-MATRIX_POST_HEALTHY_SYNC_DECRYPT_FAILURE_SAMPLE_LIMIT) .map((entry) => entry.eventId); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 9b3d4ed222d..0bd760e7154 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -12,7 +12,11 @@ import { import { VerificationMethod } from "matrix-js-sdk/lib/types.js"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher"; -import { normalizeNullableString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeNullableString, + normalizeStringEntries, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { SsrFPolicy } from "../runtime-api.js"; import { resolveMatrixRoomKeyBackupReadinessError } from "./backup-health.js"; import { FileBackedMatrixSyncStore } from "./client/file-sync-store.js"; @@ -1840,7 +1844,7 @@ export class MatrixClient { } async deleteOwnDevices(deviceIds: string[]): Promise { - const uniqueDeviceIds = [...new Set(deviceIds.map((value) => value.trim()).filter(Boolean))]; + const uniqueDeviceIds = uniqueStrings(normalizeStringEntries(deviceIds)); const currentDeviceId = this.client.getDeviceId()?.trim() || null; const protectedDeviceIds = uniqueDeviceIds.filter((deviceId) => deviceId === currentDeviceId); if (protectedDeviceIds.length > 0) { diff --git a/extensions/mattermost/src/mattermost/monitor-auth.ts b/extensions/mattermost/src/mattermost/monitor-auth.ts index 33d32b5ff11..71d058deec6 100644 --- a/extensions/mattermost/src/mattermost/monitor-auth.ts +++ b/extensions/mattermost/src/mattermost/monitor-auth.ts @@ -6,7 +6,10 @@ import { resolveStableChannelMessageIngress, type StableChannelIngressIdentityParams, } from "openclaw/plugin-sdk/channel-ingress-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ResolvedMattermostAccount } from "./accounts.js"; import type { MattermostChannel } from "./client.js"; import type { OpenClawConfig } from "./runtime-api.js"; @@ -54,7 +57,7 @@ export function normalizeMattermostAllowList(entries: Array): s const normalized = entries .map((entry) => normalizeMattermostAllowEntry(String(entry))) .filter(Boolean); - return Array.from(new Set(normalized)); + return uniqueStrings(normalized); } export function isMattermostSenderAllowed(params: { diff --git a/extensions/mattermost/src/mattermost/monitor-onchar.ts b/extensions/mattermost/src/mattermost/monitor-onchar.ts index c23629fbee1..22e2469fa38 100644 --- a/extensions/mattermost/src/mattermost/monitor-onchar.ts +++ b/extensions/mattermost/src/mattermost/monitor-onchar.ts @@ -1,7 +1,9 @@ +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; + const DEFAULT_ONCHAR_PREFIXES = [">", "!"]; export function resolveOncharPrefixes(prefixes: string[] | undefined): string[] { - const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES; + const cleaned = prefixes ? normalizeStringEntries(prefixes) : DEFAULT_ONCHAR_PREFIXES; return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES; } diff --git a/extensions/mattermost/src/mattermost/monitor-resources.ts b/extensions/mattermost/src/mattermost/monitor-resources.ts index cdca5337731..b342d0169fd 100644 --- a/extensions/mattermost/src/mattermost/monitor-resources.ts +++ b/extensions/mattermost/src/mattermost/monitor-resources.ts @@ -1,3 +1,4 @@ +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { fetchMattermostChannel, fetchMattermostUser, @@ -52,7 +53,7 @@ export function createMattermostMonitorResources(params: { const resolveMattermostMedia = async ( fileIds?: string[] | null, ): Promise => { - const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean); + const ids = normalizeStringEntries(fileIds ?? []); if (ids.length === 0) { return []; } diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 8588f60be40..b37c689bcb3 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -19,6 +19,8 @@ import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, + normalizeTrimmedStringList, + uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getMattermostRuntime } from "../runtime.js"; import { @@ -157,9 +159,7 @@ const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000; const RECENT_MATTERMOST_MESSAGE_MAX = 2000; function normalizeInteractionSourceIps(values?: string[]): string[] { - return (values ?? []) - .map((value) => normalizeOptionalString(value)) - .filter((value): value is string => Boolean(value)); + return normalizeTrimmedStringList(values); } const recentInboundMessages = createClaimableDedupe({ @@ -188,7 +188,7 @@ function buildMattermostInboundReplayKeys(params: { accountId: string; messageIds: string[]; }): string[] { - return [...new Set(params.messageIds.map((id) => `${params.accountId}:${id.trim()}`))].filter( + return uniqueStrings(params.messageIds.map((id) => `${params.accountId}:${id.trim()}`)).filter( (key) => !key.endsWith(":"), ); } diff --git a/extensions/meeting-notes/src/config.ts b/extensions/meeting-notes/src/config.ts index 42274cdad26..0219a72d3d4 100644 --- a/extensions/meeting-notes/src/config.ts +++ b/extensions/meeting-notes/src/config.ts @@ -1,3 +1,5 @@ +import { normalizeOptionalString as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; + export type MeetingNotesAutoStartConfig = { enabled: boolean; providerId: string; @@ -15,10 +17,6 @@ export type MeetingNotesConfig = { autoStart: MeetingNotesAutoStartConfig[]; }; -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function resolveAutoStart(raw: unknown): MeetingNotesAutoStartConfig[] { if (!Array.isArray(raw)) { return []; diff --git a/extensions/meeting-notes/src/summary.ts b/extensions/meeting-notes/src/summary.ts index 30b7f31c4d2..b7bc18e4f16 100644 --- a/extensions/meeting-notes/src/summary.ts +++ b/extensions/meeting-notes/src/summary.ts @@ -2,6 +2,7 @@ import type { MeetingNotesSessionDescriptor, MeetingNotesUtterance, } from "openclaw/plugin-sdk/meeting-notes"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; export type MeetingNotesSummary = { sessionId: string; @@ -22,16 +23,9 @@ const RISK_PATTERNS = /\b(risk|blocked|blocker|concern|issue|problem|unknown|deadline|privacy|security)\b/i; function firstSentences(utterances: MeetingNotesUtterance[], limit: number): string { - const text = utterances - .map((utterance) => utterance.text.trim()) - .filter(Boolean) - .join(" "); + const text = normalizeStringEntries(utterances.map((utterance) => utterance.text)).join(" "); const sentences = text.match(/[^.!?]+[.!?]?/g) ?? []; - return sentences - .slice(0, limit) - .map((sentence) => sentence.trim()) - .filter(Boolean) - .join(" "); + return normalizeStringEntries(sentences.slice(0, limit)).join(" "); } function collectMatches(utterances: MeetingNotesUtterance[], pattern: RegExp): string[] { diff --git a/extensions/meeting-notes/src/tool.ts b/extensions/meeting-notes/src/tool.ts index 6bf8d476a18..94333c35aad 100644 --- a/extensions/meeting-notes/src/tool.ts +++ b/extensions/meeting-notes/src/tool.ts @@ -12,6 +12,7 @@ import type { OpenClawPluginService, OpenClawPluginToolContext, } from "openclaw/plugin-sdk/plugin-entry"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; import { type MeetingNotesAutoStartConfig, resolveMeetingNotesConfig } from "./config.js"; import { manualTranscriptSourceProvider } from "./manual-source.js"; @@ -363,7 +364,8 @@ async function statusMeetingNotes(api: OpenClawPluginApi) { const providers = [ manualTranscriptSourceProvider.id, ...listMeetingNotesSourceProviders(api.config).map((provider) => provider.id), - ].filter((value, index, values) => values.indexOf(value) === index); + ]; + const uniqueProviders = uniqueStrings(providers); const active = [...activeSessions.values()].map((entry) => ({ sessionId: entry.session.sessionId, providerId: entry.providerId, @@ -372,10 +374,10 @@ async function statusMeetingNotes(api: OpenClawPluginApi) { })); return toolText( [ - `Meeting notes providers: ${providers.length ? providers.join(", ") : "none"}`, + `Meeting notes providers: ${uniqueProviders.length ? uniqueProviders.join(", ") : "none"}`, `Active sessions: ${active.length}`, ].join("\n"), - { providers, active }, + { providers: uniqueProviders, active }, ); } diff --git a/extensions/memory-core/src/dreaming-phases.ts b/extensions/memory-core/src/dreaming-phases.ts index 3710925824a..d7bd7d46eae 100644 --- a/extensions/memory-core/src/dreaming-phases.ts +++ b/extensions/memory-core/src/dreaming-phases.ts @@ -19,6 +19,7 @@ import { } from "openclaw/plugin-sdk/memory-core-host-status"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { appendRegularFile, privateFileStore } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js"; import { generateAndAppendDreamNarrative, @@ -568,12 +569,9 @@ function normalizeSessionIngestionState(raw: unknown): SessionIngestionState { if (scope.trim().length === 0 || !Array.isArray(value)) { continue; } - const unique = [ + const unique = normalizeStringEntries([ ...new Set(value.filter((entry): entry is string => typeof entry === "string")), - ] - .map((entry) => entry.trim()) - .filter(Boolean) - .slice(-SESSION_INGESTION_MAX_TRACKED_MESSAGES_PER_SESSION); + ]).slice(-SESSION_INGESTION_MAX_TRACKED_MESSAGES_PER_SESSION); if (unique.length > 0) { seenMessages[scope] = unique; } @@ -704,9 +702,7 @@ function resolveSessionAgentsForWorkspace(params: { if (!match) { return []; } - return match.agentIds - .filter((agentId, index, all) => agentId.trim().length > 0 && all.indexOf(agentId) === index) - .toSorted(); + return uniqueStrings(match.agentIds.filter((agentId) => agentId.trim().length > 0)).toSorted(); } async function appendSessionCorpusLines(params: { @@ -1276,9 +1272,7 @@ export async function seedHistoricalDailyMemorySignals(params: { importedSignalCount: number; skippedPaths: string[]; }> { - const normalizedPaths = [ - ...new Set(params.filePaths.map((entry) => entry.trim()).filter(Boolean)), - ]; + const normalizedPaths = uniqueStrings(normalizeStringEntries(params.filePaths)); if (normalizedPaths.length === 0) { return { importedFileCount: 0, @@ -1395,13 +1389,7 @@ function entryAverageScore(entry: ShortTermRecallEntry): number { } function tokenizeSnippet(snippet: string): Set { - return new Set( - snippet - .toLowerCase() - .split(/[^a-z0-9]+/i) - .map((token) => token.trim()) - .filter(Boolean), - ); + return new Set(normalizeStringEntries(snippet.toLowerCase().split(/[^a-z0-9]+/i))); } function jaccardSimilarity(left: string, right: string): number { @@ -1434,11 +1422,11 @@ function dedupeEntries(entries: ShortTermRecallEntry[], threshold: number): Shor } duplicate.totalScore = Math.max(duplicate.totalScore, entry.totalScore); duplicate.maxScore = Math.max(duplicate.maxScore, entry.maxScore); - duplicate.queryHashes = [...new Set([...duplicate.queryHashes, ...entry.queryHashes])]; + duplicate.queryHashes = uniqueStrings([...duplicate.queryHashes, ...entry.queryHashes]); duplicate.recallDays = [ ...new Set([...duplicate.recallDays, ...entry.recallDays]), ].toSorted(); - duplicate.conceptTags = [...new Set([...duplicate.conceptTags, ...entry.conceptTags])]; + duplicate.conceptTags = uniqueStrings([...duplicate.conceptTags, ...entry.conceptTags]); duplicate.lastRecalledAt = Date.parse(entry.lastRecalledAt) > Date.parse(duplicate.lastRecalledAt) ? entry.lastRecalledAt @@ -1578,7 +1566,7 @@ export function previewRemDreaming(params: { confidence: entry.confidence, evidence: entry.evidence, })); - const candidateKeys = [...new Set(candidateSelections.map((entry) => entry.key))]; + const candidateKeys = uniqueStrings(candidateSelections.map((entry) => entry.key)); const bodyLines = [ "### Reflections", ...reflections, @@ -1669,7 +1657,7 @@ async function runLightDreaming(params: { } // Generate dream diary narrative from the staged entries. if (params.subagent && capped.length > 0) { - const themes = [...new Set(capped.flatMap((e) => e.conceptTags).filter(Boolean))]; + const themes = uniqueStrings(capped.flatMap((e) => e.conceptTags).filter(Boolean)); const data: NarrativePhaseData = { phase: "light", snippets: capped.map((e) => e.snippet).filter(Boolean), diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index a6b8d618986..0149a2c9c67 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -20,7 +20,10 @@ import { resolveMemoryDreamingWorkspaces, } from "openclaw/plugin-sdk/memory-core-host-status"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { peekSystemEventEntries } from "openclaw/plugin-sdk/system-event-runtime"; import { writeDeepDreamingReport } from "./dreaming-markdown.js"; import { @@ -371,7 +374,7 @@ function resolveDreamingTriggerSessionKeys(sessionKey?: string): string[] { } } - return Array.from(new Set(keys)); + return uniqueStrings(keys); } function hasPendingManagedDreamingCronEvent(sessionKey?: string): boolean { diff --git a/extensions/memory-core/src/memory/hybrid.ts b/extensions/memory-core/src/memory/hybrid.ts index 536ea737687..b21d24132a1 100644 --- a/extensions/memory-core/src/memory/hybrid.ts +++ b/extensions/memory-core/src/memory/hybrid.ts @@ -1,3 +1,4 @@ +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { applyMMRToHybridResults, type MMRConfig, DEFAULT_MMR_CONFIG } from "./mmr.js"; import { applyTemporalDecayToHybridResults, @@ -28,11 +29,7 @@ type HybridKeywordResult = { }; export function buildFtsQuery(raw: string): string | null { - const tokens = - raw - .match(/[\p{L}\p{N}_]+/gu) - ?.map((t) => t.trim()) - .filter(Boolean) ?? []; + const tokens = normalizeStringEntries(raw.match(/[\p{L}\p{N}_]+/gu) ?? []); if (tokens.length === 0) { return null; } diff --git a/extensions/memory-core/src/memory/manager-search.ts b/extensions/memory-core/src/memory/manager-search.ts index 46ddc12726e..21fa99b36f5 100644 --- a/extensions/memory-core/src/memory/manager-search.ts +++ b/extensions/memory-core/src/memory/manager-search.ts @@ -4,6 +4,11 @@ import { cosineSimilarity, parseEmbedding, } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +import { + normalizeStringEntries, + normalizeStringEntriesLower, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; const vectorToBlob = (embedding: number[]): Buffer => Buffer.from(new Float32Array(embedding).buffer); @@ -36,12 +41,7 @@ type SearchRowResult = { }; function normalizeSearchTokens(raw: string): string[] { - return ( - raw - .match(FTS_QUERY_TOKEN_RE) - ?.map((token) => token.trim().toLowerCase()) - .filter(Boolean) ?? [] - ); + return normalizeStringEntriesLower(raw.match(FTS_QUERY_TOKEN_RE) ?? []); } function scoreFallbackKeywordResult(params: { @@ -50,7 +50,7 @@ function scoreFallbackKeywordResult(params: { text: string; ftsScore: number; }): number { - const queryTokens = [...new Set(normalizeSearchTokens(params.query))]; + const queryTokens = uniqueStrings(normalizeSearchTokens(params.query)); if (queryTokens.length === 0) { return params.ftsScore; } @@ -105,11 +105,7 @@ function planKeywordSearch(params: { }; } - const tokens = - params.query - .match(FTS_QUERY_TOKEN_RE) - ?.map((token) => token.trim()) - .filter(Boolean) ?? []; + const tokens = normalizeStringEntries(params.query.match(FTS_QUERY_TOKEN_RE) ?? []); if (tokens.length === 0) { return { matchQuery: null, substringTerms: [] }; } @@ -375,12 +371,8 @@ export async function searchKeyword(params: { // Log the root cause, then fall back to per-token LIKE-based substring // search so results are still returned instead of being silently dropped. console.warn(`memory search: FTS5 MATCH failed, falling back to LIKE: ${String(matchErr)}`); - const queryTokens = - params.query - .match(FTS_QUERY_TOKEN_RE) - ?.map((t) => t.trim()) - .filter(Boolean) ?? []; - const allTerms = [...new Set([...queryTokens, ...plan.substringTerms])]; + const queryTokens = normalizeStringEntries(params.query.match(FTS_QUERY_TOKEN_RE) ?? []); + const allTerms = uniqueStrings([...queryTokens, ...plan.substringTerms]); const fallbackLikeClause = allTerms.map(() => " AND text LIKE ? ESCAPE '\\'").join(""); const fallbackLikeParams = allTerms.map((term) => `%${escapeLikePattern(term)}%`); rows = params.db diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index 1e39d4e7b47..05e4fd23a1d 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -20,6 +20,7 @@ import { type MemorySource, type MemorySyncProgressUpdate, } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +import { uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createEmbeddingProvider, type EmbeddingProvider, @@ -426,7 +427,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem const maxResults = opts?.maxResults ?? this.settings.query.maxResults; const searchSources = opts?.sources && opts.sources.length > 0 - ? [...new Set(opts.sources)].filter((s) => this.sources.has(s)) + ? uniqueValues(opts.sources).filter((s) => this.sources.has(s)) : undefined; if ( opts?.sources && diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 81ee38dba34..c836752b3a4 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -52,6 +52,7 @@ import { import { localeLowercasePreservingWhitespace, normalizeLowercaseStringOrEmpty, + uniqueValues, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { asRecord } from "../dreaming-shared.js"; import { resolveQmdCollectionPatternFlags, type QmdCollectionPatternFlag } from "./qmd-compat.js"; @@ -1120,7 +1121,7 @@ export class QmdMemoryManager implements MemorySearchManager { this.qmd.limits.maxResults, opts?.maxResults ?? this.qmd.limits.maxResults, ); - const requestedSources = opts?.sources?.length ? [...new Set(opts.sources)] : undefined; + const requestedSources = opts?.sources?.length ? uniqueValues(opts.sources) : undefined; const collectionNames = this.listManagedCollectionNames(requestedSources); const limit = resultLimit; if (collectionNames.length === 0) { diff --git a/extensions/memory-core/src/rem-evidence.ts b/extensions/memory-core/src/rem-evidence.ts index 13112d4a240..b39de11d40c 100644 --- a/extensions/memory-core/src/rem-evidence.ts +++ b/extensions/memory-core/src/rem-evidence.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; const REM_BLOCKED_SECTION_RE = /\b(morning reminders|tasks? for today|to-?do|pickups?|action items?|next steps?|open questions?|stats|setup tasks?|priority contacts|visitors?|top priority candidates|timeline coverage|action items for morning review|test .* skill|heartbeat checks?|date semantics guardrail|still broken|last message (?:&|and) status|plugin \/ service warning|email triage cron)\b/i; @@ -638,7 +639,7 @@ function atomizeClaimText(text: string): string[] { .flatMap((part) => splitSubjectLeadClaim(part)) .map((part) => normalizeWhitespace(part)) .filter(Boolean); - return Array.from(new Set(atomic)).slice(0, 3); + return uniqueStrings(atomic).slice(0, 3); } function classifyCandidateLeanFromText(text: string, title: string): GroundedRemCandidate["lean"] { diff --git a/extensions/memory-core/src/short-term-promotion.ts b/extensions/memory-core/src/short-term-promotion.ts index 7b302372be6..367fd0e30b9 100644 --- a/extensions/memory-core/src/short-term-promotion.ts +++ b/extensions/memory-core/src/short-term-promotion.ts @@ -5,7 +5,11 @@ import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-ru import { formatMemoryDreamingDay } from "openclaw/plugin-sdk/memory-core-host-status"; import { appendMemoryHostEvent } from "openclaw/plugin-sdk/memory-host-events"; import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeStringEntries, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { deriveConceptTags, MAX_CONCEPT_TAGS, @@ -1184,7 +1188,7 @@ export async function recordDreamingPhaseSignals(params: { if (!workspaceDir) { return; } - const keys = [...new Set(params.keys.map((key) => key.trim()).filter(Boolean))]; + const keys = uniqueStrings(normalizeStringEntries(params.keys)); if (keys.length === 0) { return; } @@ -1237,7 +1241,7 @@ export async function recordRemConsideredPhaseSignals(params: { if (!workspaceDir) { return; } - const keys = [...new Set(params.keys.map((key) => key.trim()).filter(Boolean))]; + const keys = uniqueStrings(normalizeStringEntries(params.keys)); if (keys.length === 0) { return; } diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index 9ca02043cb1..1c404f26b0f 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -13,7 +13,10 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { MemoryEmbeddingProvider } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord as asRecord, + normalizeLowercaseStringOrEmpty, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { Type } from "typebox"; import { definePluginEntry, type OpenClawPluginApi } from "./api.js"; @@ -91,12 +94,6 @@ function loadMemoryHostCoreModule(): Promise< return memoryHostCoreModulePromise; } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function extractUserTextContent(message: unknown): string[] { const msgObj = asRecord(message); if (!msgObj || msgObj.role !== "user") { diff --git a/extensions/memory-wiki/src/apply.ts b/extensions/memory-wiki/src/apply.ts index c47a82ff75f..3e822067c8d 100644 --- a/extensions/memory-wiki/src/apply.ts +++ b/extensions/memory-wiki/src/apply.ts @@ -4,6 +4,7 @@ import { withTrailingNewline, } from "openclaw/plugin-sdk/memory-host-markdown"; import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { compileMemoryWikiVault, type CompileMemoryWikiResult } from "./compile.js"; import type { ResolvedMemoryWikiConfig } from "./config.js"; import { @@ -115,11 +116,7 @@ function normalizeUniqueStrings(values: string[] | undefined): string[] | undefi if (!values) { return undefined; } - const normalized = values - .map((value) => value.trim()) - .filter(Boolean) - .filter((value, index, all) => all.indexOf(value) === index); - return normalized; + return uniqueStrings(normalizeStringEntries(values)); } function ensureHumanNotesBlock(body: string): string { diff --git a/extensions/memory-wiki/src/chatgpt-import.ts b/extensions/memory-wiki/src/chatgpt-import.ts index 339ac219972..ffdd048d76c 100644 --- a/extensions/memory-wiki/src/chatgpt-import.ts +++ b/extensions/memory-wiki/src/chatgpt-import.ts @@ -6,6 +6,7 @@ import { replaceManagedMarkdownBlock, withTrailingNewline, } from "openclaw/plugin-sdk/memory-host-markdown"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { compileMemoryWikiVault } from "./compile.js"; import type { ResolvedMemoryWikiConfig } from "./config.js"; import { appendMemoryWikiLog } from "./log.js"; @@ -293,7 +294,7 @@ function inferRisk(title: string, sampleText: string): ChatGptRiskAssessment { (rule) => rule.label, ); if (reasons.length > 0) { - return { level: "high", reasons: [...new Set(reasons)] }; + return { level: "high", reasons: uniqueStrings(reasons) }; } if (/\b(career|job|salary|interview|offer|resume|cover letter)\b/i.test(blob)) { return { level: "medium", reasons: ["work_career"] }; diff --git a/extensions/memory-wiki/src/cli.ts b/extensions/memory-wiki/src/cli.ts index 2c53668f7be..765e2ad6bca 100644 --- a/extensions/memory-wiki/src/cli.ts +++ b/extensions/memory-wiki/src/cli.ts @@ -1,6 +1,11 @@ import fs from "node:fs/promises"; import type { Command } from "commander"; import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime"; +import { + isRecord, + normalizeStringEntries, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawConfig } from "../api.js"; import { applyMemoryWikiMutation } from "./apply.js"; import { @@ -196,10 +201,6 @@ function shouldRouteBridgeRuntimeThroughGateway(config: ResolvedMemoryWikiConfig ); } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function isBoundedGatewayString( value: unknown, maxChars = GATEWAY_RESPONSE_MAX_STRING_CHARS, @@ -336,11 +337,8 @@ function normalizeCliStringList(values?: string[]): string[] | undefined { if (!values) { return undefined; } - const normalized = values - .map((value) => value.trim()) - .filter(Boolean) - .filter((value, index, all) => all.indexOf(value) === index); - return normalized.length > 0 ? normalized : undefined; + const uniqueValues = uniqueStrings(normalizeStringEntries(values)); + return uniqueValues.length > 0 ? uniqueValues : undefined; } function collectCliValues(value: string, acc: string[] = []) { diff --git a/extensions/memory-wiki/src/compile.ts b/extensions/memory-wiki/src/compile.ts index 1b9124ef9fa..d2edfa598dd 100644 --- a/extensions/memory-wiki/src/compile.ts +++ b/extensions/memory-wiki/src/compile.ts @@ -5,7 +5,10 @@ import { withTrailingNewline, } from "openclaw/plugin-sdk/memory-host-markdown"; import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { assessClaimFreshness, assessPageFreshness, @@ -1128,14 +1131,14 @@ function buildAgentDigestContradictionClusters( label: cluster.label, kind: "page-note" as const, entryCount: cluster.entries.length, - paths: [...new Set(cluster.entries.map((entry) => entry.pagePath))].toSorted(), + paths: uniqueStrings(cluster.entries.map((entry) => entry.pagePath)).toSorted(), })); const claimClusters = buildClaimContradictionClusters({ pages }).map((cluster) => ({ key: cluster.key, label: cluster.label, kind: "claim-id" as const, entryCount: cluster.entries.length, - paths: [...new Set(cluster.entries.map((entry) => entry.pagePath))].toSorted(), + paths: uniqueStrings(cluster.entries.map((entry) => entry.pagePath)).toSorted(), })); return [...pageClusters, ...claimClusters].toSorted((left, right) => left.label.localeCompare(right.label), @@ -1229,7 +1232,7 @@ function buildClaimsDigestLines(params: { pages: WikiPageSummary[] }): string[] status: normalizeClaimStatus(claim.status), confidence: claim.confidence, sourceIds: page.sourceIds, - evidenceKinds: [...new Set(claim.evidence.flatMap((entry) => entry.kind ?? []))], + evidenceKinds: uniqueStrings(claim.evidence.flatMap((entry) => entry.kind ?? [])), privacyTiers: [ ...new Set( [ diff --git a/extensions/memory-wiki/src/import-runs.ts b/extensions/memory-wiki/src/import-runs.ts index 4ec0fc784c1..af8672e6ea0 100644 --- a/extensions/memory-wiki/src/import-runs.ts +++ b/extensions/memory-wiki/src/import-runs.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ResolvedMemoryWikiConfig } from "./config.js"; type MemoryWikiImportRunSummary = { @@ -62,7 +63,7 @@ function normalizeImportRunSummary(raw: unknown): MemoryWikiImportRunSummary | n .map((entry) => (typeof entry?.path === "string" ? entry.path.trim() : "")) .filter((entry): entry is string => entry.length > 0) : []; - const pagePaths = [...new Set([...createdPaths, ...updatedPaths])]; + const pagePaths = uniqueStrings([...createdPaths, ...updatedPaths]); const conversationCount = typeof record.conversationCount === "number" && Number.isFinite(record.conversationCount) ? Math.max(0, Math.floor(record.conversationCount)) diff --git a/extensions/memory-wiki/src/markdown.ts b/extensions/memory-wiki/src/markdown.ts index b8f37d05498..5de3dc716d4 100644 --- a/extensions/memory-wiki/src/markdown.ts +++ b/extensions/memory-wiki/src/markdown.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import path from "node:path"; import { + asFiniteNumber, normalizeLowercaseStringOrEmpty, normalizeOptionalString, normalizeSingleOrTrimmedStringList, @@ -276,7 +277,7 @@ export function normalizeWikiClaims(value: unknown): WikiClaim[] { } function normalizeOptionalNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } function normalizeWikiPersonCard(value: unknown): WikiPersonCard | undefined { diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts index 6ba8156a618..a94769d77c5 100644 --- a/extensions/memory-wiki/src/query.ts +++ b/extensions/memory-wiki/src/query.ts @@ -13,7 +13,10 @@ import { createSessionVisibilityGuard, resolveEffectiveSessionToolsVisibility, } from "openclaw/plugin-sdk/session-visibility"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawConfig } from "../api.js"; import { assessClaimFreshness, isClaimContestedStatus } from "./claim-health.js"; import type { ResolvedMemoryWikiConfig, WikiSearchBackend, WikiSearchCorpus } from "./config.js"; @@ -959,7 +962,7 @@ function normalizeLookupKey(value: string): string { function buildLookupCandidates(lookup: string): string[] { const normalized = normalizeLookupKey(lookup); const withExtension = normalized.endsWith(".md") ? normalized : `${normalized}.md`; - return [...new Set([normalized, withExtension])]; + return uniqueStrings([normalized, withExtension]); } function shouldEnforceSessionVisibility(params: { @@ -1165,8 +1168,8 @@ function buildClaimResultMetadata(claim: WikiClaim | undefined): Partial evidence.kind ?? []))], - evidenceSourceIds: [...new Set(claim.evidence.flatMap((evidence) => evidence.sourceId ?? []))], + evidenceKinds: uniqueStrings(claim.evidence.flatMap((evidence) => evidence.kind ?? [])), + evidenceSourceIds: uniqueStrings(claim.evidence.flatMap((evidence) => evidence.sourceId ?? [])), }; } diff --git a/extensions/migrate-claude/helpers.ts b/extensions/migrate-claude/helpers.ts index 71007e94f4d..f236e0ec17b 100644 --- a/extensions/migrate-claude/helpers.ts +++ b/extensions/migrate-claude/helpers.ts @@ -7,6 +7,7 @@ import { } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; import { appendRegularFile, pathExists } from "openclaw/plugin-sdk/security-runtime"; +import { isRecord as sharedIsRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export function resolveHomePath(input: string): string { const trimmed = input.trim(); @@ -62,9 +63,7 @@ export async function readJsonObject( } } -export function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} +export const isRecord = sharedIsRecord; export function childRecord( root: Record | undefined, diff --git a/extensions/migrate-hermes/auth.ts b/extensions/migrate-hermes/auth.ts index c7eb917ad90..5a8738b8161 100644 --- a/extensions/migrate-hermes/auth.ts +++ b/extensions/migrate-hermes/auth.ts @@ -21,7 +21,7 @@ import { hasCurrentAuthProfileConfigConflict, type HermesAuthProfileConfig, } from "./auth-config.js"; -import { readText } from "./helpers.js"; +import { isRecord, readString, readText } from "./helpers.js"; import { HERMES_REASON_AUTH_PROFILE_EXISTS, HERMES_REASON_AUTH_PROFILE_WRITE_FAILED, @@ -67,14 +67,6 @@ type CodexIdentity = { profileName?: string; }; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readTimestamp(value: unknown): number | undefined { if (typeof value !== "string" || !value.trim()) { return undefined; diff --git a/extensions/migrate-hermes/config.ts b/extensions/migrate-hermes/config.ts index fe09a5eb2bb..5ffee5ba876 100644 --- a/extensions/migrate-hermes/config.ts +++ b/extensions/migrate-hermes/config.ts @@ -6,6 +6,7 @@ import { hasMigrationConfigPatchConflict, } from "openclaw/plugin-sdk/migration"; import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { childRecord, isRecord, readString, readStringArray } from "./helpers.js"; type HermesProviderConfig = { @@ -81,7 +82,7 @@ function collectHermesProviders( ...Object.keys(childRecord(raw, "models")), readString(raw.model), ].filter((value): value is string => Boolean(value)); - collected.push({ id, baseUrl, apiKeyEnv, models: [...new Set(models)] }); + collected.push({ id, baseUrl, apiKeyEnv, models: uniqueStrings(models) }); } const customProviders = config.custom_providers; @@ -101,7 +102,7 @@ function collectHermesProviders( ...Object.keys(childRecord(raw, "models")), readString(raw.model), ].filter((value): value is string => Boolean(value)); - collected.push({ id, baseUrl, apiKeyEnv, models: [...new Set(models)] }); + collected.push({ id, baseUrl, apiKeyEnv, models: uniqueStrings(models) }); } } diff --git a/extensions/migrate-hermes/helpers.ts b/extensions/migrate-hermes/helpers.ts index 92d3522dd26..ac47be53c66 100644 --- a/extensions/migrate-hermes/helpers.ts +++ b/extensions/migrate-hermes/helpers.ts @@ -7,6 +7,10 @@ import { } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; import { appendRegularFile, pathExists } from "openclaw/plugin-sdk/security-runtime"; +import { + isRecord as sharedIsRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { parse as parseYaml } from "yaml"; const HOME_SHORTHAND_RE = /^~(?=$|[\\/])/u; @@ -77,9 +81,7 @@ export function parseHermesConfig(content: string | undefined): Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} +export const isRecord = sharedIsRecord; export function childRecord( root: Record | undefined, @@ -89,9 +91,7 @@ export function childRecord( return isRecord(value) ? value : {}; } -export function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} +export const readString = normalizeOptionalString; export function readStringArray(value: unknown): string[] { if (!Array.isArray(value)) { diff --git a/extensions/migrate-hermes/secrets.ts b/extensions/migrate-hermes/secrets.ts index 6e781483ee5..152f0cc526c 100644 --- a/extensions/migrate-hermes/secrets.ts +++ b/extensions/migrate-hermes/secrets.ts @@ -7,7 +7,7 @@ import { hasCurrentAuthProfileConfigConflict, type HermesAuthProfileConfig, } from "./auth-config.js"; -import { parseEnv, readText } from "./helpers.js"; +import { isRecord, parseEnv, readString, readText } from "./helpers.js"; import { createHermesSecretItem, HERMES_REASON_AUTH_PROFILE_EXISTS, @@ -142,14 +142,6 @@ function secretAuthProfileConfig(details: { }; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function secretMode(mapping: SecretMapping): SecretCredentialMode { return mapping.mode ?? "api_key"; } diff --git a/extensions/mistral/realtime-transcription-provider.ts b/extensions/mistral/realtime-transcription-provider.ts index 0cc9660f51a..101067503d4 100644 --- a/extensions/mistral/realtime-transcription-provider.ts +++ b/extensions/mistral/realtime-transcription-provider.ts @@ -7,7 +7,11 @@ import { type RealtimeTranscriptionWebSocketTransport, } from "openclaw/plugin-sdk/realtime-transcription"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord as readRecord, + normalizeOptionalString, + parseFiniteNumber as readFiniteNumber, +} from "openclaw/plugin-sdk/string-coerce-runtime"; type MistralRealtimeTranscriptionEncoding = | "pcm_s16le" @@ -55,28 +59,12 @@ const MISTRAL_REALTIME_MAX_RECONNECT_ATTEMPTS = 5; const MISTRAL_REALTIME_RECONNECT_DELAY_MS = 1000; const MISTRAL_REALTIME_MAX_QUEUED_BYTES = 2 * 1024 * 1024; -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readNestedMistralConfig(rawConfig: RealtimeTranscriptionProviderConfig) { const raw = readRecord(rawConfig); const providers = readRecord(raw?.providers); return readRecord(providers?.mistral ?? raw?.mistral ?? raw) ?? {}; } -function readFiniteNumber(value: unknown): number | undefined { - const next = - typeof value === "number" - ? value - : typeof value === "string" - ? Number.parseFloat(value) - : undefined; - return Number.isFinite(next) ? next : undefined; -} - function normalizeMistralEncoding( value: unknown, ): MistralRealtimeTranscriptionEncoding | undefined { diff --git a/extensions/moonshot/src/kimi-web-search-provider.runtime.ts b/extensions/moonshot/src/kimi-web-search-provider.runtime.ts index 0cdac1dcf42..bf77715311b 100644 --- a/extensions/moonshot/src/kimi-web-search-provider.runtime.ts +++ b/extensions/moonshot/src/kimi-web-search-provider.runtime.ts @@ -24,7 +24,11 @@ import { wrapWebContent, writeCachedSearchPayload, } from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeOptionalString, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { isNativeMoonshotBaseUrl, MOONSHOT_BASE_URL, @@ -84,10 +88,6 @@ type KimiSearchResult = { grounded: boolean; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function throwMalformedKimiResponse(): never { throw new Error("Kimi API error: malformed JSON response"); } @@ -186,7 +186,7 @@ function extractKimiCitations(data: KimiSearchResponse): string[] { } } - return [...new Set(citations)]; + return uniqueStrings(citations); } function hasKimiSearchResults(data: KimiSearchResponse): boolean { diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts index 6a51fcdfd34..f369d963831 100644 --- a/extensions/msteams/src/attachments/graph.ts +++ b/extensions/msteams/src/attachments/graph.ts @@ -3,6 +3,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, + uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getMSTeamsRuntime } from "../runtime.js"; import { ensureUserAgentHeader } from "../user-agent.js"; @@ -102,7 +103,7 @@ export function buildMSTeamsGraphMessageUrls(params: { `${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`, ); } - return Array.from(new Set(urls)); + return uniqueStrings(urls); } const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]); @@ -116,7 +117,7 @@ export function buildMSTeamsGraphMessageUrls(params: { (candidate) => `${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`, ); - return Array.from(new Set(urls)); + return uniqueStrings(urls); } async function fetchGraphCollection(params: { diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index c12178a99ef..11e4409d02d 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -21,7 +21,10 @@ import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-ru import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/outbound-runtime"; import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeOptionalString, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; import type { ChannelMessageActionName, @@ -1092,12 +1095,8 @@ export const msteamsPlugin: ChannelPlugin role.trim()).filter(Boolean) - : []; - const scopes = Array.isArray(graph.scopes) - ? graph.scopes.map((scope) => scope.trim()).filter(Boolean) - : []; + const roles = Array.isArray(graph.roles) ? normalizeStringEntries(graph.roles) : []; + const scopes = Array.isArray(graph.scopes) ? normalizeStringEntries(graph.scopes) : []; const formatPermission = (permission: string) => { const hint = TEAMS_GRAPH_PERMISSION_HINTS[permission]; return hint ? `${permission} (${hint})` : permission; diff --git a/extensions/msteams/src/directory-live.ts b/extensions/msteams/src/directory-live.ts index 0147ee74fe1..26c0589ff46 100644 --- a/extensions/msteams/src/directory-live.ts +++ b/extensions/msteams/src/directory-live.ts @@ -1,4 +1,7 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ChannelDirectoryEntry } from "../runtime-api.js"; import { searchGraphUsers } from "./graph-users.js"; import { @@ -53,10 +56,7 @@ export async function listMSTeamsDirectoryGroupsLive(params: { const token = await resolveGraphToken(params.cfg); const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; const [teamQuery, channelQuery] = rawQuery.includes("/") - ? rawQuery - .split("/", 2) - .map((part) => part.trim()) - .filter(Boolean) + ? normalizeStringEntries(rawQuery.split("/", 2)) : [rawQuery, null]; const teams = await listTeamsByName(token, teamQuery); diff --git a/extensions/msteams/src/errors.ts b/extensions/msteams/src/errors.ts index ebb1a22342e..bb145941176 100644 --- a/extensions/msteams/src/errors.ts +++ b/extensions/msteams/src/errors.ts @@ -1,6 +1,4 @@ -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export function formatUnknownError(err: unknown): string { if (err instanceof Error) { diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index cb52646a22a..d1ec9a08335 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -8,7 +8,11 @@ import { resolveTextChunksWithFallback, sendPayloadMediaSequence, } from "openclaw/plugin-sdk/reply-payload"; -import { chunkTextForOutbound, type ChannelOutboundAdapter } from "../runtime-api.js"; +import { + chunkTextForOutbound, + normalizeStringEntries, + type ChannelOutboundAdapter, +} from "../runtime-api.js"; import { createMSTeamsPollStoreFs } from "./polls.js"; import { buildMSTeamsPresentationCard, MSTEAMS_PRESENTATION_CAPABILITIES } from "./presentation.js"; import { sendAdaptiveCardMSTeams, sendMessageMSTeams, sendPollMSTeams } from "./send.js"; @@ -80,12 +84,12 @@ export const msteamsOutbound: ChannelOutboundAdapter = { }); return attachChannelToResult("msteams", result); } - const mediaUrls = resolvePayloadMediaUrls({ - ...payload, - mediaUrl: payload.mediaUrl ?? mediaUrl, - }) - .map((url) => url.trim()) - .filter(Boolean); + const mediaUrls = normalizeStringEntries( + resolvePayloadMediaUrls({ + ...payload, + mediaUrl: payload.mediaUrl ?? mediaUrl, + }), + ); if (mediaUrls.length > 0) { type SendFn = ( to: string, diff --git a/extensions/msteams/src/polls.ts b/extensions/msteams/src/polls.ts index 1faa601a6ae..8e593f9aa64 100644 --- a/extensions/msteams/src/polls.ts +++ b/extensions/msteams/src/polls.ts @@ -1,5 +1,10 @@ import crypto from "node:crypto"; -import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeOptionalString, + normalizeStringEntries, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveMSTeamsStorePath } from "./storage.js"; import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js"; @@ -68,10 +73,7 @@ function extractSelections(value: unknown): string[] { return []; } if (normalized.includes(",")) { - return normalized - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries(normalized.split(",")); } return [normalized]; } @@ -256,7 +258,7 @@ export function normalizeMSTeamsPollSelections(poll: MSTeamsPoll, selections: st .filter((value) => value >= 0 && value < poll.options.length) .map((value) => String(value)); const limited = maxSelections > 1 ? mapped.slice(0, maxSelections) : mapped.slice(0, 1); - return Array.from(new Set(limited)); + return uniqueStrings(limited); } export function createMSTeamsPollStoreFs(params?: MSTeamsPollStoreFsOptions): MSTeamsPollStore { diff --git a/extensions/msteams/src/probe.ts b/extensions/msteams/src/probe.ts index ab95e755ace..b0389f24fbd 100644 --- a/extensions/msteams/src/probe.ts +++ b/extensions/msteams/src/probe.ts @@ -53,10 +53,7 @@ function readScopes(value: unknown): string[] | undefined { if (typeof value !== "string") { return undefined; } - const out = value - .split(/\s+/) - .map((entry) => entry.trim()) - .filter(Boolean); + const out = normalizeStringEntries(value.split(/\s+/)); return out.length > 0 ? out : undefined; } diff --git a/extensions/msteams/src/sdk.ts b/extensions/msteams/src/sdk.ts index 6eff44c39c8..359cb399724 100644 --- a/extensions/msteams/src/sdk.ts +++ b/extensions/msteams/src/sdk.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; // but tsgo cannot resolve the chain. Use the dist subpath directly (type-only import). import type { IHttpServerAdapter } from "@microsoft/teams.apps/dist/http/index.js"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatUnknownError } from "./errors.js"; import type { MSTeamsAdapter } from "./messenger.js"; import type { MSTeamsCredentials, MSTeamsFederatedCredentials } from "./token.js"; @@ -683,10 +684,9 @@ function getAudienceClaims(payload: unknown): string[] { return trimmed ? [trimmed] : []; } if (Array.isArray(audience)) { - return audience - .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean); + return normalizeStringEntries( + audience.filter((value): value is string => typeof value === "string"), + ); } return []; } diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index f0465413174..a5125a047e5 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -10,6 +10,7 @@ import { buildTrafficStatusSummary, } from "openclaw/plugin-sdk/extension-shared"; import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildChannelConfigSchema, collectStatusIssuesFromLastError, @@ -70,9 +71,7 @@ const nostrConfigAdapter = createTopLevelChannelConfigAdapter account.config.allowFrom, formatAllowFrom: (allowFrom) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) + normalizeStringEntries(allowFrom) .map((entry) => { if (entry === "*") { return "*"; diff --git a/extensions/nostr/src/setup-adapter.ts b/extensions/nostr/src/setup-adapter.ts index 4913a21bcb7..25cc8cdced1 100644 --- a/extensions/nostr/src/setup-adapter.ts +++ b/extensions/nostr/src/setup-adapter.ts @@ -2,6 +2,7 @@ import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { patchTopLevelChannelConfigSection, splitSetupEntries } from "openclaw/plugin-sdk/setup"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; const channel = "nostr" as const; @@ -25,7 +26,7 @@ export function parseRelayUrls(raw: string): { relays: string[]; error?: string } relays.push(entry); } - return { relays: [...new Set(relays)] }; + return { relays: uniqueStrings(relays) }; } export function createNostrSetupAdapter(params: { diff --git a/extensions/ollama/src/discovery-shared.ts b/extensions/ollama/src/discovery-shared.ts index 957782cbc87..7d8b653924e 100644 --- a/extensions/ollama/src/discovery-shared.ts +++ b/extensions/ollama/src/discovery-shared.ts @@ -1,5 +1,6 @@ import { getCachedLiveCatalogValue } from "openclaw/plugin-sdk/provider-catalog-shared"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; import { readProviderBaseUrl } from "./provider-base-url.js"; import { resolveOllamaApiBase } from "./provider-models.js"; @@ -29,10 +30,6 @@ type OllamaDiscoveryContext = { }; }; -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readStringValue(value: unknown): string | undefined { if (typeof value === "string") { return normalizeOptionalString(value); diff --git a/extensions/ollama/src/model-id.ts b/extensions/ollama/src/model-id.ts index 8ee14a22e69..3cc1578fbda 100644 --- a/extensions/ollama/src/model-id.ts +++ b/extensions/ollama/src/model-id.ts @@ -1,4 +1,5 @@ import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; const OLLAMA_PROVIDER_ID = "ollama"; @@ -6,7 +7,7 @@ function uniqueModelPrefixCandidates(providerId?: string): string[] { const candidates = [providerId, normalizeProviderId(providerId ?? ""), OLLAMA_PROVIDER_ID] .map((candidate) => candidate?.trim()) .filter((candidate): candidate is string => Boolean(candidate)); - return [...new Set(candidates)]; + return uniqueStrings(candidates); } export function normalizeOllamaWireModelId(modelId: string, providerId?: string): string { diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index cbbc4898b40..ca03e9bf207 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -29,6 +29,7 @@ import { import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { + isRecord, normalizeLowercaseStringOrEmpty, readStringValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -705,10 +706,6 @@ function normalizeOllamaCompatMessageToolArgs(payloadRecord: Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function inferOllamaSchemaType(schema: Record): string | undefined { if (schema.properties && isRecord(schema.properties)) { return "object"; diff --git a/extensions/openai/embedding-batch.ts b/extensions/openai/embedding-batch.ts index ab119aaa0bd..ab86d9161c7 100644 --- a/extensions/openai/embedding-batch.ts +++ b/extensions/openai/embedding-batch.ts @@ -17,6 +17,7 @@ import { uploadBatchJsonlFile, withRemoteHttpResponse, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenAiEmbeddingClient } from "./embedding-provider.js"; type EmbeddingBatchExecutionParams = { @@ -126,17 +127,13 @@ export function parseOpenAiBatchOutput(text: string): OpenAiBatchOutputLine[] { if (!text.trim()) { return []; } - return text - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line) as OpenAiBatchOutputLine; - } catch { - throw new Error("OpenAI embedding batch output contained malformed JSONL"); - } - }); + return normalizeStringEntries(text.split("\n")).map((line) => { + try { + return JSON.parse(line) as OpenAiBatchOutputLine; + } catch { + throw new Error("OpenAI embedding batch output contained malformed JSONL"); + } + }); } async function readOpenAiBatchError(params: { diff --git a/extensions/openai/native-web-search.ts b/extensions/openai/native-web-search.ts index 7257b62f125..ab6955175b7 100644 --- a/extensions/openai/native-web-search.ts +++ b/extensions/openai/native-web-search.ts @@ -3,6 +3,7 @@ import { streamSimple } from "@earendil-works/pi-ai"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream-shared"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isOpenAIApiBaseUrl } from "./base-url.js"; const OPENAI_WEB_SEARCH_TOOL = { type: "web_search" } as const; @@ -12,10 +13,6 @@ type OpenAINativeWebSearchPatchResult = | "native_tool_already_present" | "injected"; -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function isOpenAINativeWebSearchEligibleModel(model: { api?: unknown; provider?: unknown; diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index f13384c2e2d..5889e05068e 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -23,6 +23,7 @@ import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage"; import { normalizeLowercaseStringOrEmpty, readStringValue, + uniqueValues, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { OPENAI_CODEX_DEVICE_PAIRING_HINT, @@ -330,7 +331,7 @@ function withDefaultCodexContextMetadata(params: { : params.contextTokens; const input = params.model.input?.includes("image") ? params.model.input - : ([...new Set([...(params.model.input ?? ["text"]), "image"])] as ("text" | "image")[]); + : uniqueValues<"text" | "image">([...(params.model.input ?? ["text"]), "image"]); return { ...params.model, input, diff --git a/extensions/openai/realtime-provider-shared.ts b/extensions/openai/realtime-provider-shared.ts index ff146e1b200..00e8508fbff 100644 --- a/extensions/openai/realtime-provider-shared.ts +++ b/extensions/openai/realtime-provider-shared.ts @@ -4,19 +4,14 @@ import { } from "openclaw/plugin-sdk/provider-http"; import { captureWsEvent } from "openclaw/plugin-sdk/proxy-capture"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asFiniteNumber, + asOptionalRecord as asObjectRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; export const trimToUndefined = normalizeOptionalString; - -export function asFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -export function asObjectRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} +export { asFiniteNumber, asObjectRecord }; export function readRealtimeErrorDetail(error: unknown): string { if (typeof error === "string" && error) { diff --git a/extensions/opencode/media-understanding-provider.ts b/extensions/opencode/media-understanding-provider.ts index 83c50a9f0de..fa29a2ac94f 100644 --- a/extensions/opencode/media-understanding-provider.ts +++ b/extensions/opencode/media-understanding-provider.ts @@ -4,10 +4,7 @@ import { describeImagesWithModelPayloadTransform, type MediaUnderstandingProvider, } from "openclaw/plugin-sdk/media-understanding"; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export function stripOpencodeDisabledResponsesReasoningPayload(payload: unknown): void { if (!isRecord(payload)) { diff --git a/extensions/openrouter/image-generation-provider.ts b/extensions/openrouter/image-generation-provider.ts index 522128e7f82..42c5a487033 100644 --- a/extensions/openrouter/image-generation-provider.ts +++ b/extensions/openrouter/image-generation-provider.ts @@ -15,7 +15,7 @@ import { postJsonRequest, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { OPENROUTER_BASE_URL } from "./provider-catalog.js"; const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview"; @@ -40,10 +40,6 @@ const SUPPORTED_ASPECT_RATIOS = [ ] as const; const OPENROUTER_IMAGE_MALFORMED_RESPONSE = "OpenRouter image generation response malformed"; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function throwMalformedOpenRouterImageResponse(message: string | undefined): never | undefined { if (message) { throw new Error(message); diff --git a/extensions/openrouter/music-generation-provider.ts b/extensions/openrouter/music-generation-provider.ts index 4d326286475..0a9308c7af0 100644 --- a/extensions/openrouter/music-generation-provider.ts +++ b/extensions/openrouter/music-generation-provider.ts @@ -10,7 +10,7 @@ import { postJsonRequest, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { OPENROUTER_BASE_URL } from "./provider-catalog.js"; const DEFAULT_OPENROUTER_MUSIC_MODEL = "google/lyria-3-pro-preview"; @@ -31,10 +31,6 @@ type OpenRouterStreamDeadline = { timeoutMs: number; }; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function resolveOpenRouterMusicModel(model: string | undefined): string { return normalizeOptionalString(model) ?? DEFAULT_OPENROUTER_MUSIC_MODEL; } diff --git a/extensions/openrouter/video-generation-provider.ts b/extensions/openrouter/video-generation-provider.ts index a6348256947..08b57f93cc8 100644 --- a/extensions/openrouter/video-generation-provider.ts +++ b/extensions/openrouter/video-generation-provider.ts @@ -10,7 +10,7 @@ import { sanitizeConfiguredModelProviderRequest, waitProviderOperationPollInterval, } from "openclaw/plugin-sdk/provider-http"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { GeneratedVideoAsset, VideoGenerationProvider, @@ -62,10 +62,6 @@ type OpenRouterFrameImagePart = OpenRouterImagePart & { frame_type: "first_frame" | "last_frame"; }; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - async function readOpenRouterVideoJson(response: Response): Promise> { let payload: unknown; try { diff --git a/extensions/openrouter/video-model-catalog.ts b/extensions/openrouter/video-model-catalog.ts index 4e6bb0c6d6f..f5974e4d6fc 100644 --- a/extensions/openrouter/video-model-catalog.ts +++ b/extensions/openrouter/video-model-catalog.ts @@ -8,7 +8,10 @@ import { assertOkOrThrowHttpError, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeOptionalString, + normalizeTrimmedStringList, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { VideoGenerationModelCapabilitiesContext, VideoGenerationProviderCapabilities, @@ -49,11 +52,7 @@ export type OpenRouterVideoModelCatalogCapabilities = VideoGenerationProviderCap }; function normalizeStringArray(value: unknown): string[] { - return Array.isArray(value) - ? value - .map((entry) => normalizeOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)) - : []; + return normalizeTrimmedStringList(value); } function normalizeNumberArray(value: unknown): number[] { diff --git a/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts index 7006dd229ba..63dfc043ef0 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts @@ -22,7 +22,7 @@ import { wrapWebContent, writeCachedSearchPayload, } from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_PERPLEXITY_BASE_URL, inferPerplexityBaseUrlFromApiKey, @@ -174,7 +174,7 @@ function extractPerplexityCitations(data: PerplexitySearchResponse): string[] { Boolean(normalizeOptionalString(url)), ); if (topLevel.length > 0) { - return [...new Set(topLevel)]; + return uniqueStrings(topLevel); } const citations: string[] = []; for (const choice of data.choices ?? []) { @@ -194,7 +194,7 @@ function extractPerplexityCitations(data: PerplexitySearchResponse): string[] { } } } - return [...new Set(citations)]; + return uniqueStrings(citations); } async function runPerplexitySearchApi(params: { diff --git a/extensions/perplexity/src/perplexity-web-search-provider.ts b/extensions/perplexity/src/perplexity-web-search-provider.ts index 4dbce0c3ef6..424aac51a3c 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.ts @@ -4,6 +4,7 @@ import { type WebSearchProviderPlugin, type WebSearchProviderToolDefinition, } from "openclaw/plugin-sdk/provider-web-search-config-contract"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createPerplexityWebSearchProviderBase, resolvePerplexityWebSearchRuntimeMetadata, @@ -18,10 +19,6 @@ function loadPerplexityWebSearchRuntime(): Promise { return perplexityWebSearchRuntimePromise; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function createPerplexityParameters(transport?: string): Record { const properties: Record = { query: { type: "string", description: "Search query string." }, diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts index 5b1a528bb14..f6446f2ac54 100644 --- a/extensions/phone-control/index.ts +++ b/extensions/phone-control/index.ts @@ -5,6 +5,8 @@ import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, + normalizeStringEntries, + sortUniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { definePluginEntry, @@ -44,7 +46,7 @@ const GROUP_COMMANDS: Record, string[]> = { }; function uniqSorted(values: string[]): string[] { - return [...new Set(values.map((v) => v.trim()).filter(Boolean))].toSorted(); + return sortUniqueStrings(normalizeStringEntries(values)); } function resolveCommandsForGroup(group: ArmGroup): string[] { diff --git a/extensions/policy/src/doctor/register.ts b/extensions/policy/src/doctor/register.ts index 33ff4d5a076..eae710fc8b7 100644 --- a/extensions/policy/src/doctor/register.ts +++ b/extensions/policy/src/doctor/register.ts @@ -8,6 +8,7 @@ import { } from "openclaw/plugin-sdk/health"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; +import { isRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { collectPolicyEvidence, createPolicyAttestation, @@ -2831,7 +2832,7 @@ function toolRequiredDenyFindings( if (required.length === 0) { return []; } - const requiredTools = [...new Set(required.flatMap(expandPolicyToolRequirement))]; + const requiredTools = uniqueStrings(required.flatMap(expandPolicyToolRequirement)); const findings: HealthFinding[] = []; for (const entry of toolPostureEntries(evidence, "deny").filter(evidenceFilter)) { for (const tool of requiredTools) { @@ -3924,7 +3925,3 @@ function policyDisplayName(ctx: HealthCheckContext): string { const configured = policyPathSetting(ctx); return isAbsolute(configured) ? basename(configured) : configured; } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/extensions/policy/src/policy-state.ts b/extensions/policy/src/policy-state.ts index 724b386423a..a8e927f69c1 100644 --- a/extensions/policy/src/policy-state.ts +++ b/extensions/policy/src/policy-state.ts @@ -1,6 +1,11 @@ import { createHash } from "node:crypto"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input"; +import { + asBoolean as readBoolean, + isRecord, + normalizeOptionalString as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { POLICY_TOOL_GROUPS } from "./tool-policy-conformance.js"; export type PolicyAttestation = { @@ -1070,10 +1075,6 @@ function pushToolAlsoAllowPostureList( const AGENT_WORKSPACE_POLICY_TOOLS = ["exec", "process", "write", "edit", "apply_patch"] as const; -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined; -} - function readStringArray(value: unknown): readonly string[] { if (!Array.isArray(value)) { return []; @@ -1096,10 +1097,6 @@ function readStringOrNumberArray(value: unknown): readonly string[] { return entries; } -function readBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - function normalizePolicyToolName(value: string): string { const normalized = value.trim().toLowerCase(); if (normalized === "bash") { @@ -1701,7 +1698,3 @@ function stableJson(value: unknown): string { } return JSON.stringify(value); } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/extensions/qa-lab/src/agentic-parity.ts b/extensions/qa-lab/src/agentic-parity.ts index 920a34de78f..7d5c467cef3 100644 --- a/extensions/qa-lab/src/agentic-parity.ts +++ b/extensions/qa-lab/src/agentic-parity.ts @@ -1,3 +1,5 @@ +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; + const QA_AGENTIC_PARITY_PACK = "agentic"; const QA_AGENTIC_PARITY_SCENARIOS = [ @@ -76,7 +78,7 @@ export function resolveQaParityPackScenarioIds(params: { scenarioIds?: string[]; }): string[] { const normalizedPack = params.parityPack?.trim().toLowerCase(); - const explicitScenarioIds = [...new Set(params.scenarioIds ?? [])]; + const explicitScenarioIds = uniqueStrings(params.scenarioIds ?? []); if (!normalizedPack) { return explicitScenarioIds; } @@ -86,5 +88,5 @@ export function resolveQaParityPackScenarioIds(params: { ); } - return [...new Set([...explicitScenarioIds, ...QA_AGENTIC_PARITY_SCENARIO_IDS])]; + return uniqueStrings([...explicitScenarioIds, ...QA_AGENTIC_PARITY_SCENARIO_IDS]); } diff --git a/extensions/qa-lab/src/bundled-plugin-staging.ts b/extensions/qa-lab/src/bundled-plugin-staging.ts index a605a2771ef..5ec3d8c041d 100644 --- a/extensions/qa-lab/src/bundled-plugin-staging.ts +++ b/extensions/qa-lab/src/bundled-plugin-staging.ts @@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; const QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS = Object.freeze([ "image-generation-core", @@ -77,9 +78,7 @@ export function resolveQaBundledPluginSourceDir(params: { repoRoot: string; plug ]; const existingCandidates = candidates.filter((candidate) => existsSync(candidate)); const manifestCandidates = findQaBundledPluginDirsByManifestId(params); - const allCandidates = [...existingCandidates, ...manifestCandidates].filter( - (candidate, index, all) => all.indexOf(candidate) === index, - ); + const allCandidates = uniqueStrings([...existingCandidates, ...manifestCandidates]); if (allCandidates.length === 0) { return null; } @@ -93,11 +92,12 @@ export function resolveQaBundledPluginSourceDir(params: { repoRoot: string; plug } function resolveQaBundledPluginScanRoots(repoRoot: string) { - return [ + const candidates = [ path.join(repoRoot, "dist", "extensions"), path.join(repoRoot, "dist-runtime", "extensions"), path.join(repoRoot, "extensions"), - ].filter((candidate, index, all) => existsSync(candidate) && all.indexOf(candidate) === index); + ]; + return uniqueStrings(candidates.filter((candidate) => existsSync(candidate))); } function readQaBundledManifestId(manifestPath: string): string | null { @@ -136,9 +136,7 @@ export async function resolveQaOwnerPluginIdsForProviderIds(params: { providerIds: readonly string[]; providerConfigs?: Record; }) { - const providerIds = [ - ...new Set(params.providerIds.map((providerId) => providerId.trim())), - ].filter((providerId) => providerId.length > 0); + const providerIds = uniqueStrings(normalizeStringEntries(params.providerIds)); if (providerIds.length === 0) { return []; } diff --git a/extensions/qa-lab/src/character-eval.ts b/extensions/qa-lab/src/character-eval.ts index e45ae0d27f4..ce23a5de92d 100644 --- a/extensions/qa-lab/src/character-eval.ts +++ b/extensions/qa-lab/src/character-eval.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { runQaManualLane } from "./manual-lane.runtime.js"; import { isQaFastModeModelRef, type QaProviderMode } from "./model-selection.js"; import { @@ -125,7 +126,7 @@ export type QaCharacterEvalParams = { }; function normalizeModelRefs(models: readonly string[]) { - return [...new Set(models.map((model) => model.trim()).filter((model) => model.length > 0))]; + return uniqueStrings(normalizeStringEntries(models)); } function resolveCandidateThinkingDefault(params: { diff --git a/extensions/qa-lab/src/cli.runtime.ts b/extensions/qa-lab/src/cli.runtime.ts index f35f8c0692c..b1ed377da6a 100644 --- a/extensions/qa-lab/src/cli.runtime.ts +++ b/extensions/qa-lab/src/cli.runtime.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildQaAgenticParityComparison, buildQaRuntimeParityReport, @@ -221,7 +222,7 @@ function resolveQaRuntimeParityTierScenarioIds(params: { `--runtime-parity-tier matched no scenarios for ${params.runtimeParityTiers.join(", ")}.`, ); } - return [...new Set([...params.scenarioIds, ...matchingScenarioIds])]; + return uniqueStrings([...params.scenarioIds, ...matchingScenarioIds]); } async function readQaFailedScenarioCountFromSummary(summaryPath: string) { diff --git a/extensions/qa-lab/src/coverage-report.ts b/extensions/qa-lab/src/coverage-report.ts index a72f6f6f73a..254f795eefd 100644 --- a/extensions/qa-lab/src/coverage-report.ts +++ b/extensions/qa-lab/src/coverage-report.ts @@ -1,3 +1,4 @@ +import { normalizeStringEntriesLower } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildLiveTransportCoverageLaneSummaries, type LiveTransportCoverageLaneSummary, @@ -85,11 +86,7 @@ function normalizeSearchText(value: string) { } function tokenizeScenarioSearchQuery(query: string) { - return query - .toLowerCase() - .split(/\s+/u) - .map((token) => token.trim()) - .filter(Boolean); + return normalizeStringEntriesLower(query.split(/\s+/u)); } function scenarioSearchText(scenario: QaSeedScenarioWithSource) { diff --git a/extensions/qa-lab/src/docker-runtime.ts b/extensions/qa-lab/src/docker-runtime.ts index a5f9a937f6b..6628be18c83 100644 --- a/extensions/qa-lab/src/docker-runtime.ts +++ b/extensions/qa-lab/src/docker-runtime.ts @@ -1,6 +1,7 @@ import { execFile } from "node:child_process"; import { createServer } from "node:net"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; export type RunCommand = ( command: string, @@ -189,11 +190,9 @@ function parseDockerComposePsRows(stdout: string) { } return [parsed]; } catch { - return trimmed - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => JSON.parse(line) as { Health?: string; State?: string }); + return normalizeStringEntries(trimmed.split("\n")).map( + (line) => JSON.parse(line) as { Health?: string; State?: string }, + ); } } diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index f8b51275832..1d9e32c6e8d 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -10,6 +10,11 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + isRecord, + normalizeStringEntries, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { createQaBundledPluginsDir, @@ -385,10 +390,6 @@ async function stopQaGatewayChildProcessTree( await waitForQaGatewayChildExit(child, opts?.forceTimeoutMs ?? 2_000); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function isQaModelProviderConfig(value: unknown): value is ModelProviderConfig { return isRecord(value) && typeof value.baseUrl === "string" && Array.isArray(value.models); } @@ -448,9 +449,7 @@ async function readQaLiveProviderConfigOverrides(params: { providerIds: readonly string[]; env?: NodeJS.ProcessEnv; }) { - const providerIds = [ - ...new Set(params.providerIds.map((providerId) => providerId.trim())), - ].filter((providerId) => providerId.length > 0); + const providerIds = uniqueStrings(normalizeStringEntries(params.providerIds)); if (providerIds.length === 0) { return {}; } @@ -694,14 +693,10 @@ export async function startQaGatewayChild(params: { wsUrl = `ws://127.0.0.1:${gatewayPort}`; cfg = await buildStagedGatewayConfig(gatewayPort); if (!env) { - const allowedPluginIds = [...(cfg.plugins?.allow ?? []), "openai"].filter( - (pluginId, index, array): pluginId is string => { - return ( - typeof pluginId === "string" && - pluginId.length > 0 && - array.indexOf(pluginId) === index - ); - }, + const allowedPluginIds = uniqueStrings( + [...(cfg.plugins?.allow ?? []), "openai"].filter( + (pluginId): pluginId is string => typeof pluginId === "string" && pluginId.length > 0, + ), ); const stagedPluginRuntime = gatewayCommand?.usePackagedPlugins ? { bundledPluginsDir: undefined, runtimeHostVersion: undefined } diff --git a/extensions/qa-lab/src/gateway-log-sentinel.ts b/extensions/qa-lab/src/gateway-log-sentinel.ts index 848ddb2a1a3..ba5e2cd0ed6 100644 --- a/extensions/qa-lab/src/gateway-log-sentinel.ts +++ b/extensions/qa-lab/src/gateway-log-sentinel.ts @@ -1,3 +1,8 @@ +import { + isRecord, + normalizeOptionalString as readNonEmptyString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; + export type GatewayLogSentinelKind = | "plugin-hook-failure" | "plugin-contract-error" @@ -138,14 +143,6 @@ function lineNumberForOffset(logs: string, offset: number) { return logs.slice(0, offset).split(/\r?\n/u).length; } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function extractMessageText(message: Record) { const rawContent = message.content; if (typeof rawContent === "string") { diff --git a/extensions/qa-lab/src/jsonl-replay.ts b/extensions/qa-lab/src/jsonl-replay.ts index bd54bb2bd29..f11dbcd74dd 100644 --- a/extensions/qa-lab/src/jsonl-replay.ts +++ b/extensions/qa-lab/src/jsonl-replay.ts @@ -1,5 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { + isRecord, + normalizeOptionalString as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { runRuntimeParityScenario, type RuntimeId, @@ -50,14 +54,6 @@ export type JsonlReplayMarkdownReport = { transcripts: JsonlReplayResult["transcripts"]; }; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function readReplayMessage(record: Record): Record | undefined { if (isRecord(record.message)) { return record.message; diff --git a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts index 8930774c49c..ad41bcffd85 100644 --- a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts @@ -11,6 +11,7 @@ import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { chromium } from "playwright-core"; import { z } from "zod"; import { startQaGatewayChild } from "../../gateway-child.js"; @@ -461,7 +462,7 @@ function buildDiscordQaConfig( }; } = {}, ): OpenClawConfig { - const pluginAllow = [...new Set([...(baseCfg.plugins?.allow ?? []), "discord"])]; + const pluginAllow = uniqueStrings([...(baseCfg.plugins?.allow ?? []), "discord"]); const pluginEntries = { ...baseCfg.plugins?.entries, discord: { enabled: true }, diff --git a/extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts b/extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts index a8c95f2f64d..1fed9ddb4b3 100644 --- a/extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts @@ -5,6 +5,7 @@ import { createSlackWebClient, createSlackWriteClient } from "@openclaw/slack/ap import type { WebClient } from "@slack/web-api"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { z } from "zod"; import { startQaGatewayChild } from "../../gateway-child.js"; import { DEFAULT_QA_LIVE_PROVIDER_MODE } from "../../providers/index.js"; @@ -586,7 +587,7 @@ function buildSlackQaConfig( sutBotToken: string; }, ): OpenClawConfig { - const pluginAllow = [...new Set([...(baseCfg.plugins?.allow ?? []), "slack"])]; + const pluginAllow = uniqueStrings([...(baseCfg.plugins?.allow ?? []), "slack"]); const approvalOverrides = params.overrides?.approvals; const approvalForwardingConfig = approvalOverrides?.exec || approvalOverrides?.plugin diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts index e07f86db181..f221e162b05 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts @@ -6,6 +6,7 @@ import { promisify } from "node:util"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { isRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { z } from "zod"; import { startQaGatewayChild } from "../../gateway-child.js"; @@ -530,10 +531,6 @@ function isTruthyOptIn(value: string | undefined) { return normalized === "1" || normalized === "true" || normalized === "yes"; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function readConfigRecord(root: Record, key: string): Record { const value = root[key]; if (!isRecord(value)) { @@ -708,7 +705,7 @@ function buildTelegramQaConfig( sutAccountId: string; }, ): OpenClawConfig { - const pluginAllow = [...new Set([...(baseCfg.plugins?.allow ?? []), "telegram"])]; + const pluginAllow = uniqueStrings([...(baseCfg.plugins?.allow ?? []), "telegram"]); const pluginEntries = { ...baseCfg.plugins?.entries, telegram: { enabled: true }, diff --git a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts index 367d16d6bf8..f8088dd9812 100644 --- a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts @@ -7,6 +7,7 @@ import { startWhatsAppQaDriverSession } from "@openclaw/whatsapp/api.js"; import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { z } from "zod"; import { startQaGatewayChild } from "../../gateway-child.js"; @@ -322,7 +323,7 @@ function buildWhatsAppQaConfig( sutAccountId: string; }, ): OpenClawConfig { - const pluginAllow = [...new Set([...(baseCfg.plugins?.allow ?? []), "whatsapp"])]; + const pluginAllow = uniqueStrings([...(baseCfg.plugins?.allow ?? []), "whatsapp"]); return { ...baseCfg, plugins: { @@ -470,10 +471,7 @@ async function listTarEntries(archivePath: string): Promise { const { stdout } = await execFileAsync("tar", ["-tzf", archivePath], { maxBuffer: 1024 * 1024, }); - return stdout - .split("\n") - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries(stdout.split("\n")); } function assertSafeArchiveEntries(entries: string[]) { diff --git a/extensions/qa-lab/src/multipass.runtime.ts b/extensions/qa-lab/src/multipass.runtime.ts index 74b1d6619c5..f87d343e620 100644 --- a/extensions/qa-lab/src/multipass.runtime.ts +++ b/extensions/qa-lab/src/multipass.runtime.ts @@ -5,6 +5,7 @@ import { access, mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { sleep } from "openclaw/plugin-sdk/runtime-env"; import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import type { QaProviderMode } from "./model-selection.js"; import { resolveQaForwardedLiveEnv, resolveQaLiveProviderConfigPath } from "./providers/env.js"; @@ -249,7 +250,7 @@ export function createQaMultipassPlan(params: { disk?: string; }) { const outputDir = params.outputDir ?? createQaMultipassOutputDir(params.repoRoot); - const scenarioIds = [...new Set(params.scenarioIds ?? [])]; + const scenarioIds = uniqueStrings(params.scenarioIds ?? []); const transportId = params.transportId?.trim() || "qa-channel"; const providerMode = params.providerMode ?? DEFAULT_QA_LIVE_PROVIDER_MODE; const provider = getQaProvider(providerMode); diff --git a/extensions/qa-lab/src/providers/env.ts b/extensions/qa-lab/src/providers/env.ts index b5497d4b856..ae6af468a1d 100644 --- a/extensions/qa-lab/src/providers/env.ts +++ b/extensions/qa-lab/src/providers/env.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { QaProviderMode } from "./index.js"; import { getQaProvider } from "./index.js"; @@ -124,7 +125,7 @@ function parsePreservedCliEnv(baseEnv: NodeJS.ProcessEnv) { } function renderPreservedCliEnv(values: string[]) { - return JSON.stringify([...new Set(values)]); + return JSON.stringify(uniqueStrings(values)); } export function normalizeQaProviderModeEnv(env: NodeJS.ProcessEnv, providerMode?: QaProviderMode) { diff --git a/extensions/qa-lab/src/providers/image-generation.ts b/extensions/qa-lab/src/providers/image-generation.ts index 65d00b9d6f6..7258da6d987 100644 --- a/extensions/qa-lab/src/providers/image-generation.ts +++ b/extensions/qa-lab/src/providers/image-generation.ts @@ -1,3 +1,7 @@ +import { + normalizeTrimmedStringList, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { QA_BASE_RUNTIME_PLUGIN_IDS } from "../qa-gateway-config.js"; import type { QaProviderMode } from "./index.js"; import { getQaProvider } from "./index.js"; @@ -15,9 +19,7 @@ function splitModelProviderId(modelRef: string) { } function uniqueNonEmpty(values: readonly (string | null | undefined)[]) { - return [ - ...new Set(values.map((value) => value?.trim()).filter((value): value is string => !!value)), - ]; + return uniqueStrings(normalizeTrimmedStringList(values)); } export function buildQaImageGenerationConfigPatch(input: QaImageGenerationPatchInput) { diff --git a/extensions/qa-lab/src/providers/live-frontier/auth.ts b/extensions/qa-lab/src/providers/live-frontier/auth.ts index 7faf2aa5ef3..95bc8fc7fdc 100644 --- a/extensions/qa-lab/src/providers/live-frontier/auth.ts +++ b/extensions/qa-lab/src/providers/live-frontier/auth.ts @@ -9,6 +9,7 @@ import { resolveEnvApiKey, validateAnthropicSetupToken, } from "openclaw/plugin-sdk/provider-auth"; +import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveQaAgentAuthDir, writeQaAuthProfiles } from "../shared/auth-store.js"; export const QA_LIVE_ANTHROPIC_SETUP_TOKEN_ENV = "OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN"; @@ -35,9 +36,7 @@ function buildQaLiveApiKeyProfileId(provider: string): string { } function normalizeQaLiveProviderIds(providerIds: readonly string[]) { - return [...new Set(providerIds.map((providerId) => providerId.trim()))] - .filter((providerId) => providerId.length > 0) - .toSorted(); + return uniqueStrings(normalizeStringEntries(providerIds)).toSorted(); } function isQaLiveOfficialOpenAiBaseUrl(baseUrl: unknown): boolean { @@ -231,9 +230,7 @@ export async function stageQaLiveApiKeyProfiles(params: { agentIds?: readonly string[]; }): Promise { const env = params.env ?? process.env; - const providerIds = [...new Set(params.providerIds.map((providerId) => providerId.trim()))] - .filter((providerId) => providerId.length > 0) - .toSorted(); + const providerIds = uniqueStrings(normalizeStringEntries(params.providerIds)).toSorted(); const profiles: Record< string, { @@ -267,7 +264,7 @@ export async function stageQaLiveApiKeyProfiles(params: { if (Object.keys(profiles).length === 0) { return next; } - const agentIds = [...new Set(params.agentIds ?? QA_LIVE_API_KEY_AGENT_IDS)]; + const agentIds = uniqueStrings(params.agentIds ?? QA_LIVE_API_KEY_AGENT_IDS); await Promise.all( agentIds.map((agentId) => writeQaAuthProfiles({ diff --git a/extensions/qa-lab/src/providers/shared/mock-auth.ts b/extensions/qa-lab/src/providers/shared/mock-auth.ts index e373fdc740b..380acf3ec2c 100644 --- a/extensions/qa-lab/src/providers/shared/mock-auth.ts +++ b/extensions/qa-lab/src/providers/shared/mock-auth.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { applyAuthProfileConfig } from "openclaw/plugin-sdk/provider-auth-api-key"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveQaAgentAuthDir, writeQaAuthProfiles } from "./auth-store.js"; /** Providers the mock harness stages placeholder credentials for by default. */ @@ -37,8 +38,8 @@ export async function stageQaMockAuthProfiles(params: { agentIds?: readonly string[]; providers?: readonly string[]; }): Promise { - const agentIds = [...new Set(params.agentIds ?? QA_MOCK_AUTH_AGENT_IDS)]; - const providers = [...new Set(params.providers ?? QA_MOCK_AUTH_PROVIDERS)]; + const agentIds = uniqueStrings(params.agentIds ?? QA_MOCK_AUTH_AGENT_IDS); + const providers = uniqueStrings(params.providers ?? QA_MOCK_AUTH_PROVIDERS); let next = params.cfg; for (const agentId of agentIds) { await writeQaAuthProfiles({ diff --git a/extensions/qa-lab/src/qa-gateway-config.ts b/extensions/qa-lab/src/qa-gateway-config.ts index 26897bb52e6..b6eaaa9359b 100644 --- a/extensions/qa-lab/src/qa-gateway-config.ts +++ b/extensions/qa-lab/src/qa-gateway-config.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { defaultQaModelForMode, normalizeQaProviderMode, @@ -26,7 +27,7 @@ export function mergeQaControlUiAllowedOrigins(extraOrigins?: string[]) { const normalizedExtra = (extraOrigins ?? []) .map((origin) => origin.trim()) .filter((origin) => origin.length > 0); - return [...new Set([...DEFAULT_QA_CONTROL_UI_ALLOWED_ORIGINS, ...normalizedExtra])]; + return uniqueStrings([...DEFAULT_QA_CONTROL_UI_ALLOWED_ORIGINS, ...normalizedExtra]); } function normalizeQaGatewayModelRef(input: string | undefined, fallback: string) { @@ -85,21 +86,17 @@ export function buildQaGatewayConfig(params: { ] : []; const selectedPluginIds = provider.usesModelProviderPlugins - ? [ - ...new Set( - (params.enabledPluginIds?.length ?? 0) > 0 - ? params.enabledPluginIds - : selectedProviderIds, - ), - ] - : [ - ...new Set( - (params.enabledPluginIds ?? []) - .map((pluginId) => pluginId.trim()) - .filter((pluginId) => pluginId.length > 0), - ), - ]; - const transportPluginIds = [...new Set(params.transportPluginIds ?? [])] + ? uniqueStrings( + (params.enabledPluginIds?.length ?? 0) > 0 + ? (params.enabledPluginIds ?? []) + : selectedProviderIds, + ) + : uniqueStrings( + (params.enabledPluginIds ?? []) + .map((pluginId) => pluginId.trim()) + .filter((pluginId) => pluginId.length > 0), + ); + const transportPluginIds = uniqueStrings(params.transportPluginIds ?? []) .map((pluginId) => pluginId.trim()) .filter((pluginId) => pluginId.length > 0); const pluginEntries = Object.fromEntries( diff --git a/extensions/qa-lab/src/run-config.ts b/extensions/qa-lab/src/run-config.ts index 92db7fe07ea..3ad381e8c07 100644 --- a/extensions/qa-lab/src/run-config.ts +++ b/extensions/qa-lab/src/run-config.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { defaultQaModelForMode as defaultStaticQaModelForMode } from "./model-selection.js"; import { defaultQaRuntimeModelForMode } from "./model-selection.runtime.js"; import { @@ -85,9 +86,7 @@ function normalizeScenarioIds(input: unknown, scenarios: QaSeedScenario[]) { .map((value) => (typeof value === "string" ? value.trim() : "")) .filter((value) => value.length > 0) : []; - const selectedIds = requestedIds.filter((id, index) => { - return availableIds.has(id) && requestedIds.indexOf(id) === index; - }); + const selectedIds = uniqueStrings(requestedIds.filter((id) => availableIds.has(id))); return selectedIds.length > 0 ? selectedIds : scenarios.map((scenario) => scenario.id); } diff --git a/extensions/qa-lab/src/runtime-parity.ts b/extensions/qa-lab/src/runtime-parity.ts index e1ff8464cc0..3a3699ab647 100644 --- a/extensions/qa-lab/src/runtime-parity.ts +++ b/extensions/qa-lab/src/runtime-parity.ts @@ -2,6 +2,11 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + asFiniteNumber as readFiniteNumber, + isRecord as isMessageRecord, + normalizeOptionalString as readNonEmptyString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { scanDirectReplyTranscriptSentinels, scanGatewayLogSentinels, @@ -133,14 +138,6 @@ function normalizeTextForParity(text: string) { return text.replace(/\s+/gu, " ").trim(); } -function readFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function sha256(value: string) { return createHash("sha256").update(value).digest("hex"); } @@ -164,10 +161,6 @@ function stableHash(value: unknown) { return sha256(JSON.stringify(normalizeForStableHash(value)) ?? "null"); } -function isMessageRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function readUsageTotals(raw: unknown): RuntimeParityUsage { const usage = isMessageRecord(raw) ? raw : {}; const inputTokens = diff --git a/extensions/qa-lab/src/runtime-tool-metadata.ts b/extensions/qa-lab/src/runtime-tool-metadata.ts index acf9a346469..306d1ba94f2 100644 --- a/extensions/qa-lab/src/runtime-tool-metadata.ts +++ b/extensions/qa-lab/src/runtime-tool-metadata.ts @@ -1,3 +1,8 @@ +import { + asBoolean as readBoolean, + isRecord, + normalizeOptionalString as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { QaRuntimeParityTier, QaSeedScenarioWithSource } from "./scenario-catalog.js"; export type QaRuntimeToolBucket = @@ -70,18 +75,6 @@ const DEFAULT_CAPABILITY_LAYER_BY_BUCKET: Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - -function readBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - function isQaRuntimeToolBucket(value: string): value is QaRuntimeToolBucket { return QA_RUNTIME_TOOL_BUCKETS.includes(value as QaRuntimeToolBucket); } diff --git a/extensions/qa-lab/src/scenario-flow-runner.ts b/extensions/qa-lab/src/scenario-flow-runner.ts index ac40ac40468..a5a5024ee3f 100644 --- a/extensions/qa-lab/src/scenario-flow-runner.ts +++ b/extensions/qa-lab/src/scenario-flow-runner.ts @@ -1,3 +1,4 @@ +import { isRecord as isPlainObject } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { QaTransportState } from "./qa-transport.js"; import type { QaScenarioFlow, QaSeedScenarioWithSource } from "./scenario-catalog.js"; @@ -36,10 +37,6 @@ const qaFlowImportLoaders: Record = { "./codex-plugin.fixture.js": () => import("./codex-plugin.fixture.js"), }; -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function formatFlowDetails(details: unknown) { if (details === undefined) { return undefined; diff --git a/extensions/qa-lab/src/scenario-packs.ts b/extensions/qa-lab/src/scenario-packs.ts index fee4ffbfab3..7195e60003b 100644 --- a/extensions/qa-lab/src/scenario-packs.ts +++ b/extensions/qa-lab/src/scenario-packs.ts @@ -1,3 +1,5 @@ +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; + export type QaScenarioPackDefinition = { id: string; title: string; @@ -45,7 +47,7 @@ export function resolveQaScenarioPackScenarioIds(params: { scenarioIds?: string[]; }): string[] { const normalizedPack = params.pack?.trim().toLowerCase(); - const explicitScenarioIds = [...new Set(params.scenarioIds ?? [])]; + const explicitScenarioIds = uniqueStrings(params.scenarioIds ?? []); if (!normalizedPack) { return explicitScenarioIds; } @@ -55,5 +57,5 @@ export function resolveQaScenarioPackScenarioIds(params: { `--pack must be one of ${QA_SCENARIO_PACKS.map((candidate) => candidate.id).join(", ")}, got "${params.pack}"`, ); } - return [...new Set([...explicitScenarioIds, ...pack.scenarioIds])]; + return uniqueStrings([...explicitScenarioIds, ...pack.scenarioIds]); } diff --git a/extensions/qa-lab/src/suite-runtime-agent-session.ts b/extensions/qa-lab/src/suite-runtime-agent-session.ts index 4e98c329b9b..1c599f548b2 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-session.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-session.ts @@ -2,6 +2,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { + isRecord, + normalizeOptionalString as readNonEmptyString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { scanDirectReplyTranscriptSentinels } from "./gateway-log-sentinel.js"; import { liveTurnTimeoutMs } from "./suite-runtime-agent-common.js"; import type { @@ -31,14 +35,6 @@ function isSessionStoreLockTimeout(error: unknown) { ); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function extractSessionTranscriptText(message: Record) { const rawContent = message.content; if (typeof rawContent === "string") { diff --git a/extensions/qa-lab/src/suite-runtime-gateway.ts b/extensions/qa-lab/src/suite-runtime-gateway.ts index a3ffdf1bc59..86d3031b9e8 100644 --- a/extensions/qa-lab/src/suite-runtime-gateway.ts +++ b/extensions/qa-lab/src/suite-runtime-gateway.ts @@ -1,6 +1,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { isRecord as isPlainObject } from "openclaw/plugin-sdk/string-coerce-runtime"; import { liveTurnTimeoutMs } from "./suite-runtime-agent-common.js"; import type { QaConfigSnapshot, QaSuiteRuntimeEnv } from "./suite-runtime-types.js"; @@ -136,10 +137,6 @@ function getGatewayRetryAfterMs(error: unknown) { return null; } -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isObjectWithStringId(value: unknown): value is { id: string } & Record { return isPlainObject(value) && typeof value.id === "string"; } diff --git a/extensions/qa-lab/src/suite.ts b/extensions/qa-lab/src/suite.ts index 1cdc400b4e2..296184dd003 100644 --- a/extensions/qa-lab/src/suite.ts +++ b/extensions/qa-lab/src/suite.ts @@ -5,6 +5,7 @@ import { disposeRegisteredAgentHarnesses } from "openclaw/plugin-sdk/agent-harne import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { startQaGatewayChild, type QaCliBackendAuthMode } from "./gateway-child.js"; import type { QaLabLatestReport, @@ -1024,7 +1025,7 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise pluginId.trim()).filter(Boolean), + ...normalizeStringEntries(params?.enabledPluginIds ?? []), ...(params?.forcedRuntime && params.forcedRuntime !== "pi" ? [params.forcedRuntime] : []), ]), ]; diff --git a/extensions/qa-lab/src/token-efficiency-report.ts b/extensions/qa-lab/src/token-efficiency-report.ts index def4ab853aa..c4060df8492 100644 --- a/extensions/qa-lab/src/token-efficiency-report.ts +++ b/extensions/qa-lab/src/token-efficiency-report.ts @@ -1,3 +1,4 @@ +import { sortUniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { RuntimeId, RuntimeParityCell, RuntimeParityResult } from "./runtime-parity.js"; export type TokenEfficiencyRuntimeUsage = { @@ -113,9 +114,7 @@ function runtimeUsage(cell: RuntimeParityCell): TokenEfficiencyRuntimeUsage { } function toolNamesForCells(pi: RuntimeParityCell, codex: RuntimeParityCell): string[] { - return [...new Set([...pi.toolCalls, ...codex.toolCalls].map((call) => call.tool))].toSorted( - (left, right) => left.localeCompare(right), - ); + return sortUniqueStrings([...pi.toolCalls, ...codex.toolCalls].map((call) => call.tool)); } function buildRow(params: { diff --git a/extensions/qa-lab/src/tool-coverage-report.ts b/extensions/qa-lab/src/tool-coverage-report.ts index 71699f999c6..86f722ec5e9 100644 --- a/extensions/qa-lab/src/tool-coverage-report.ts +++ b/extensions/qa-lab/src/tool-coverage-report.ts @@ -1,3 +1,7 @@ +import { + isRecord, + normalizeOptionalString as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { RuntimeId, RuntimeParityCell, @@ -77,14 +81,6 @@ type ToolFixtureGroup = { const PASSING_DRIFTS: ReadonlySet = new Set(["none", "text-only"]); -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function normalizeRuntimePair( pair: [RuntimeId, RuntimeId] | null | undefined, ): [RuntimeId, RuntimeId] { @@ -209,9 +205,7 @@ function buildRow(params: { .find((entry) => entry.required); const fallbackMetadata = readScenarioRuntimeToolCoverageMetadata(params.group.scenarios[0]); const rowMetadata = metadata ?? fallbackMetadata; - const runtimeToolName = params.group.scenarios - .map(readScenarioRuntimeToolName) - .find(Boolean); + const runtimeToolName = params.group.scenarios.map(readScenarioRuntimeToolName).find(Boolean); return { tool: params.group.tool, ...(runtimeToolName ? { runtimeToolName } : {}), diff --git a/extensions/qa-matrix/src/docker-runtime.ts b/extensions/qa-matrix/src/docker-runtime.ts index f434a5b8839..b63c5e562f0 100644 --- a/extensions/qa-matrix/src/docker-runtime.ts +++ b/extensions/qa-matrix/src/docker-runtime.ts @@ -1,6 +1,7 @@ import { createServer } from "node:net"; import { runExec } from "openclaw/plugin-sdk/process-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; const DEFAULT_DOCKER_COMMAND_TIMEOUT_MS = 120_000; @@ -186,11 +187,9 @@ function parseDockerComposePsRows(stdout: string) { } return [parsed]; } catch { - return trimmed - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => JSON.parse(line) as { Health?: string; State?: string }); + return normalizeStringEntries(trimmed.split("\n")).map( + (line) => JSON.parse(line) as { Health?: string; State?: string }, + ); } } diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-approval.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-approval.ts index 35bffbd9ff9..6556754256a 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-approval.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-approval.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { setTimeout as sleep } from "node:timers/promises"; +import { normalizeUniqueStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { MatrixQaObservedEvent } from "../../substrate/events.js"; import { MATRIX_QA_DRIVER_DM_ROOM_KEY, resolveMatrixQaScenarioRoomId } from "./scenario-catalog.js"; import { @@ -177,9 +178,7 @@ async function waitForObservedApprovalEvent(params: { timeoutMs: number; }) { const client = createMatrixQaDriverScenarioClient(params.context); - const roomIds = Array.from( - new Set(params.roomIds.map((roomId) => roomId.trim()).filter(Boolean)), - ); + const roomIds = normalizeUniqueStringEntries(params.roomIds); const primaryRoomId = roomIds[0]; if (!primaryRoomId) { throw new Error("Matrix approval wait requires at least one candidate room"); diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-config.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-config.ts index 660e888f515..257702d879b 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-config.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-config.ts @@ -1,9 +1,8 @@ import { readFile } from "node:fs/promises"; import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; +import { isRecord as isMatrixQaPlainRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -export function isMatrixQaPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} +export { isMatrixQaPlainRecord }; function requireMatrixQaGatewayConfigObject(config: unknown): Record { if (!isMatrixQaPlainRecord(config)) { diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-state-files.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-state-files.ts index 848e5bec00f..8cf38ec3178 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-state-files.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-state-files.ts @@ -1,16 +1,13 @@ import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { MatrixQaScenarioContext } from "./scenario-runtime-shared.js"; const MATRIX_SYNC_STORE_FILENAME = "bot-storage.json"; const MATRIX_INBOUND_DEDUPE_FILENAME = "inbound-dedupe.json"; const MATRIX_STATE_POLL_INTERVAL_MS = 100; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - async function readJsonFile(pathname: string): Promise { return JSON.parse(await fs.readFile(pathname, "utf8")) as unknown; } diff --git a/extensions/qa-matrix/src/substrate/client.ts b/extensions/qa-matrix/src/substrate/client.ts index ecbbd6b5667..9b6b37f18c7 100644 --- a/extensions/qa-matrix/src/substrate/client.ts +++ b/extensions/qa-matrix/src/substrate/client.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { setTimeout as sleep } from "node:timers/promises"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { uniqueStrings, uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { MatrixQaObservedEvent } from "./events.js"; import { requestMatrixJson, type MatrixQaFetchLike } from "./request.js"; import { @@ -203,7 +204,7 @@ export function buildMatrixQaMessageContent(params: { threadRootEventId?: string; }): MatrixQaSendMessageContent { const body = params.body; - const uniqueMentionUserIds = [...new Set(params.mentionUserIds?.filter(Boolean) ?? [])]; + const uniqueMentionUserIds = uniqueStrings(params.mentionUserIds?.filter(Boolean) ?? []); const formattedParts: string[] = []; let cursor = 0; let usedFormattedMention = false; @@ -764,7 +765,7 @@ function resolveTopologyMemberAccounts( accounts: Record, memberRoles: MatrixQaParticipantRole[], ) { - const uniqueRoles = [...new Set(memberRoles)]; + const uniqueRoles = uniqueValues(memberRoles); if (uniqueRoles.length === 0) { throw new Error("Matrix QA room provisioning requires at least one member"); } diff --git a/extensions/qa-matrix/src/substrate/config.ts b/extensions/qa-matrix/src/substrate/config.ts index b61f8e084ca..a085ac20334 100644 --- a/extensions/qa-matrix/src/substrate/config.ts +++ b/extensions/qa-matrix/src/substrate/config.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { MatrixQaProvisionedTopology } from "./topology.js"; type MatrixQaReplyToMode = "off" | "first" | "all" | "batched"; @@ -166,7 +167,7 @@ type MatrixQaAccountExecApprovalsConfig = { }; function normalizeMatrixQaAllowlist(entries?: string[]) { - return [...new Set((entries ?? []).map((entry) => entry.trim()).filter(Boolean))]; + return uniqueStrings(normalizeStringEntries(entries ?? [])); } function resolveMatrixQaGroupSnapshots(params: { @@ -230,7 +231,7 @@ function resolveMatrixQaDmAllowFrom(params: { const dmParticipantUserIds = params.topology.rooms .filter((room) => room.kind === "dm") .flatMap((room) => room.memberUserIds.filter((userId) => userId !== params.sutUserId)); - const dmAllowFrom = [...new Set(dmParticipantUserIds)]; + const dmAllowFrom = uniqueStrings(dmParticipantUserIds); return dmAllowFrom.length > 0 ? dmAllowFrom : [params.driverUserId]; } @@ -570,7 +571,7 @@ export function buildMatrixQaConfig( topology: MatrixQaProvisionedTopology; }, ): OpenClawConfig { - const pluginAllow = [...new Set([...(baseCfg.plugins?.allow ?? []), "matrix"])]; + const pluginAllow = uniqueStrings([...(baseCfg.plugins?.allow ?? []), "matrix"]); const snapshot = buildMatrixQaConfigSnapshot({ driverUserId: params.driverUserId, observerUserId: params.observerUserId, diff --git a/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts b/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts index 3e8f21a0746..7681c1a76f4 100644 --- a/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts +++ b/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { loadJsonFile } from "openclaw/plugin-sdk/json-store"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getHomeDir, getQQBotDataDir, isWindows } from "../../utils/platform.js"; import type { SlashCommandResult } from "../slash-commands.js"; @@ -329,7 +330,7 @@ export function buildBotLogsResult(): SlashCommandResult { ); const fileCount = recentFiles.length; - const topSources = Array.from(new Set(recentFiles.map((item) => item.sourceDir))).slice(0, 3); + const topSources = uniqueStrings(recentFiles.map((item) => item.sourceDir)).slice(0, 3); let summaryText = `共 ${fileCount} 个日志文件,包含 ${totalIncluded} 行内容`; if (truncatedCount > 0) { summaryText += `(其中 ${truncatedCount} 个文件已截断为最后 ${MAX_LINES_PER_FILE} 行,总计原始 ${totalOriginal} 行)`; diff --git a/extensions/qqbot/src/engine/config/group.ts b/extensions/qqbot/src/engine/config/group.ts index 3328501ba91..c40630a3b94 100644 --- a/extensions/qqbot/src/engine/config/group.ts +++ b/extensions/qqbot/src/engine/config/group.ts @@ -1,3 +1,4 @@ +import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; import { asOptionalObjectRecord as asRecord } from "../utils/string-normalize.js"; import { resolveAccountBase } from "./resolve.js"; @@ -45,8 +46,7 @@ function readGroupsMap( } function readBoolean(obj: Record, key: string): boolean | undefined { - const v = obj[key]; - return typeof v === "boolean" ? v : undefined; + return asBoolean(obj[key]); } function readString(obj: Record, key: string): string | undefined { diff --git a/extensions/qqbot/src/engine/config/resolve.ts b/extensions/qqbot/src/engine/config/resolve.ts index d061cdeb633..433d6cb411f 100644 --- a/extensions/qqbot/src/engine/config/resolve.ts +++ b/extensions/qqbot/src/engine/config/resolve.ts @@ -12,7 +12,7 @@ import { getPlatformAdapter } from "../adapter/index.js"; import { asOptionalObjectRecord as asRecord, normalizeOptionalLowercaseString, - normalizeStringifiedOptionalString, + normalizeStringifiedEntries, readStringField as readString, } from "../utils/string-normalize.js"; @@ -275,9 +275,7 @@ export function describeAccount(account: AccountSnapshot | undefined) { /** Normalize allowFrom entries into uppercase strings without the qqbot: prefix. */ export function formatAllowFrom(allowFrom: Array | undefined | null): string[] { - return (allowFrom ?? []) - .map((entry) => normalizeStringifiedOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)) + return normalizeStringifiedEntries(allowFrom ?? []) .map((entry) => entry.replace(/^qqbot:/i, "")) .map((entry) => entry.toUpperCase()); } diff --git a/extensions/qqbot/src/engine/gateway/interaction-handler.ts b/extensions/qqbot/src/engine/gateway/interaction-handler.ts index 6cffcf0f62e..0cd3255e0ef 100644 --- a/extensions/qqbot/src/engine/gateway/interaction-handler.ts +++ b/extensions/qqbot/src/engine/gateway/interaction-handler.ts @@ -12,6 +12,7 @@ */ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { authorizeQQBotApprovalAction } from "../../exec-approvals.js"; import { resolveQQBotEffectivePolicies } from "../access/resolve-policy.js"; import { getPlatformAdapter } from "../adapter/index.js"; @@ -319,7 +320,7 @@ function resolveApprovalActorSenderIds(event: InteractionEvent): string[] { const normalized = typeof value === "string" ? value.trim() : ""; return normalized ? [normalized] : []; }); - return Array.from(new Set(ids)); + return uniqueStrings(ids); } function resolveApprovalKind(approvalId: string): "exec" | "plugin" { diff --git a/extensions/qqbot/src/engine/gateway/stages/envelope-stage.ts b/extensions/qqbot/src/engine/gateway/stages/envelope-stage.ts index 501208683bb..5ae2a6e6d94 100644 --- a/extensions/qqbot/src/engine/gateway/stages/envelope-stage.ts +++ b/extensions/qqbot/src/engine/gateway/stages/envelope-stage.ts @@ -7,6 +7,7 @@ * dispatcher needs. No decisions / gating. */ +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ProcessedAttachments } from "../inbound-attachments.js"; import type { InboundGroupInfo, InboundPipelineDeps, ReplyToInfo } from "../inbound-context.js"; import type { QueuedMessage } from "../message-queue.js"; @@ -125,8 +126,8 @@ export function classifyMedia(processed: ProcessedAttachments): MediaClassificat } } - const uniqueVoicePaths = [...new Set(processed.voiceAttachmentPaths)]; - const uniqueVoiceUrls = [...new Set(processed.voiceAttachmentUrls)]; + const uniqueVoicePaths = uniqueStrings(processed.voiceAttachmentPaths); + const uniqueVoiceUrls = uniqueStrings(processed.voiceAttachmentUrls); const voiceMediaTypes = [...uniqueVoicePaths, ...uniqueVoiceUrls].map(() => "audio/wav"); return { @@ -136,7 +137,7 @@ export function classifyMedia(processed: ProcessedAttachments): MediaClassificat remoteMediaTypes, uniqueVoicePaths, uniqueVoiceUrls, - uniqueVoiceAsrReferTexts: [...new Set(processed.voiceAsrReferTexts)].filter(Boolean), + uniqueVoiceAsrReferTexts: uniqueStrings(processed.voiceAsrReferTexts).filter(Boolean), voiceMediaTypes, hasAsrReferFallback: processed.voiceTranscriptSources.includes("asr"), voiceTranscriptSources: processed.voiceTranscriptSources, diff --git a/extensions/qqbot/src/engine/utils/string-normalize.ts b/extensions/qqbot/src/engine/utils/string-normalize.ts index e8c2cd4d6bc..d774b55d7ae 100644 --- a/extensions/qqbot/src/engine/utils/string-normalize.ts +++ b/extensions/qqbot/src/engine/utils/string-normalize.ts @@ -39,6 +39,12 @@ export function normalizeStringifiedOptionalString(value: unknown): string | und return undefined; } +export function normalizeStringifiedEntries(values?: ReadonlyArray): string[] { + return (values ?? []) + .map((entry) => normalizeStringifiedOptionalString(entry)) + .filter((entry): entry is string => Boolean(entry)); +} + /** Return the trimmed lowercase string or `undefined`. */ export function normalizeOptionalLowercaseString(value: unknown): string | undefined { return normalizeOptionalString(value)?.toLowerCase(); diff --git a/extensions/runway/video-generation-provider.ts b/extensions/runway/video-generation-provider.ts index caa0d90a840..f611341eb50 100644 --- a/extensions/runway/video-generation-provider.ts +++ b/extensions/runway/video-generation-provider.ts @@ -14,6 +14,7 @@ import { type ProviderOperationTimeoutMs, } from "openclaw/plugin-sdk/provider-http"; import { + isRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -61,10 +62,6 @@ const VIDEO_MODELS = new Set(["gen4_aleph"]); const RUNWAY_TEXT_ASPECT_RATIOS = ["16:9", "9:16"] as const; const RUNWAY_EDIT_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "3:4", "4:3", "21:9"] as const; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - async function readRunwayJsonResponse( response: Pick, label: string, diff --git a/extensions/signal/src/normalize.ts b/extensions/signal/src/normalize.ts index 945d2768ff2..aa893bdd6f3 100644 --- a/extensions/signal/src/normalize.ts +++ b/extensions/signal/src/normalize.ts @@ -1,4 +1,7 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; export function normalizeSignalMessagingTarget(raw: string): string | undefined { const trimmed = raw.trim(); @@ -36,7 +39,7 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{ const UUID_COMPACT_PATTERN = /^[0-9a-f]{32}$/i; export function looksLikeSignalTargetId(raw: string, normalized?: string): boolean { - const candidates = [raw, normalized ?? ""].map((value) => value.trim()).filter(Boolean); + const candidates = normalizeStringEntries([raw, normalized ?? ""]); for (const candidate of candidates) { if (/^(signal:)?(group:|username:|u:)/i.test(candidate)) { diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index 6eae7ae23cd..2b2c17821c1 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -6,7 +6,7 @@ import { import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy"; import { createChannelPluginBase, getChatChannelMeta } from "openclaw/plugin-sdk/core"; import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; -import { normalizeStringifiedOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeStringifiedEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime"; import { listSignalAccountIds, @@ -35,9 +35,7 @@ export const signalConfigAdapter = createScopedChannelConfigAdapter account.config.allowFrom, formatAllowFrom: (allowFrom) => - allowFrom - .map((entry) => normalizeStringifiedOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)) + normalizeStringifiedEntries(allowFrom) .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) .filter(Boolean), resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, diff --git a/extensions/skill-workshop/src/config.ts b/extensions/skill-workshop/src/config.ts index e7db0285a37..f80bc95edc4 100644 --- a/extensions/skill-workshop/src/config.ts +++ b/extensions/skill-workshop/src/config.ts @@ -1,3 +1,5 @@ +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; + export type SkillWorkshopConfig = { enabled: boolean; autoCapture: boolean; @@ -10,12 +12,6 @@ export type SkillWorkshopConfig = { maxSkillBytes: number; }; -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function readBoolean(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } @@ -27,7 +23,7 @@ function readInteger(value: unknown, fallback: number, min: number, max: number) } export function resolveConfig(raw: unknown): SkillWorkshopConfig { - const cfg = asRecord(raw); + const cfg = asNullableRecord(raw) ?? {}; const approvalPolicy = cfg.approvalPolicy === "auto" ? "auto" : "pending"; const reviewMode = cfg.reviewMode === "off" || diff --git a/extensions/skill-workshop/src/reviewer.ts b/extensions/skill-workshop/src/reviewer.ts index ee065084224..73d5e697c56 100644 --- a/extensions/skill-workshop/src/reviewer.ts +++ b/extensions/skill-workshop/src/reviewer.ts @@ -5,6 +5,10 @@ import { resolveAgentEffectiveModelPrimary, resolveDefaultModelForAgent, } from "openclaw/plugin-sdk/agent-runtime"; +import { + isRecord, + normalizeOptionalString as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawPluginApi } from "../api.js"; import type { SkillWorkshopConfig } from "./config.js"; import { normalizeSkillName } from "./skills.js"; @@ -54,14 +58,6 @@ function resolveReviewerFallbackModel(params: { api: OpenClawPluginApi; agentId: }; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function parseReviewerJson(raw: string): ReviewerJson | undefined { const trimmed = raw.trim(); if (!trimmed) { diff --git a/extensions/skill-workshop/src/tool.ts b/extensions/skill-workshop/src/tool.ts index 1fc75724cf1..c3e370eee63 100644 --- a/extensions/skill-workshop/src/tool.ts +++ b/extensions/skill-workshop/src/tool.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { normalizeOptionalString as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; import { jsonResult, type OpenClawPluginApi } from "../api.js"; import type { SkillWorkshopConfig } from "./config.js"; @@ -22,10 +23,6 @@ type ToolParams = { apply?: boolean; }; -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function buildProposal(params: { workspaceDir: string; raw: ToolParams; diff --git a/extensions/slack/src/errors.ts b/extensions/slack/src/errors.ts index 70fecb6cc5d..d7aa4e82fa7 100644 --- a/extensions/slack/src/errors.ts +++ b/extensions/slack/src/errors.ts @@ -1,11 +1,8 @@ import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; const NO_ERROR_DETAIL = "no error detail"; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function redact(value: string): string { return redactSensitiveText(value); } diff --git a/extensions/slack/src/interactive-replies.ts b/extensions/slack/src/interactive-replies.ts index 864697d974f..bf992e8bad0 100644 --- a/extensions/slack/src/interactive-replies.ts +++ b/extensions/slack/src/interactive-replies.ts @@ -1,6 +1,10 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeStringEntries, + normalizeStringEntriesLower, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js"; const SLACK_BUTTON_MAX_ITEMS = 5; @@ -98,10 +102,7 @@ function buildButtonsBlock( function buildSelectBlock( raw: string, ): NonNullable["blocks"][number] | null { - const parts = raw - .split("|") - .map((entry) => entry.trim()) - .filter(Boolean); + const parts = normalizeStringEntries(raw.split("|")); if (parts.length === 0) { return null; } @@ -127,17 +128,14 @@ function hasSlackBlocks(payload: ReplyPayload): boolean { } function parseSimpleSlackOptions(raw: string): SlackChoice[] | null { - const entries = raw - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean); + const entries = normalizeStringEntries(raw.split(",")); if (entries.length < 2 || entries.length > SLACK_AUTO_SELECT_MAX_ITEMS) { return null; } if (!entries.every((entry) => SLACK_SIMPLE_OPTION_RE.test(entry))) { return null; } - const deduped = new Set(entries.map((entry) => normalizeLowercaseStringOrEmpty(entry))); + const deduped = new Set(normalizeStringEntriesLower(entries)); if (deduped.size !== entries.length) { return null; } diff --git a/extensions/slack/src/monitor/events/interactions.block-actions.ts b/extensions/slack/src/monitor/events/interactions.block-actions.ts index db1cbc56dd5..ff429a31e0c 100644 --- a/extensions/slack/src/monitor/events/interactions.block-actions.ts +++ b/extensions/slack/src/monitor/events/interactions.block-actions.ts @@ -4,7 +4,10 @@ import { resolveApprovalOverGateway } from "openclaw/plugin-sdk/approval-gateway import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime"; import { resolveCommandAuthorization } from "openclaw/plugin-sdk/command-auth-native"; import { requestHeartbeat } from "openclaw/plugin-sdk/heartbeat-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeOptionalString, + normalizeUniqueTrimmedStringList, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; import { isSlackApprovalAuthorizedSender } from "../../approval-auth.js"; import { isSlackExecApprovalAuthorizedSender } from "../../exec-approvals.js"; @@ -136,20 +139,7 @@ function readOptionLabels(options: unknown): string[] | undefined { } function uniqueNonEmptyStrings(values: string[]): string[] { - const unique: string[] = []; - const seen = new Set(); - for (const entry of values) { - if (typeof entry !== "string") { - continue; - } - const trimmed = entry.trim(); - if (!trimmed || seen.has(trimmed)) { - continue; - } - seen.add(trimmed); - unique.push(trimmed); - } - return unique; + return normalizeUniqueTrimmedStringList(values); } function collectRichTextFragments(value: unknown, out: string[]): void { diff --git a/extensions/slack/src/monitor/events/messages.ts b/extensions/slack/src/monitor/events/messages.ts index 497b7016bf4..27559610dae 100644 --- a/extensions/slack/src/monitor/events/messages.ts +++ b/extensions/slack/src/monitor/events/messages.ts @@ -1,6 +1,10 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { + asOptionalRecord as asRecord, + normalizeOptionalString as asString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime"; import type { SlackAppMentionEvent, SlackMessageEvent } from "../../types.js"; import { normalizeSlackChannelType } from "../channel-type.js"; @@ -23,16 +27,6 @@ type SlackAssistantMessageRecord = { blocks?: unknown; }; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function isSlackUserId(value: string): boolean { return /^[UW][A-Z0-9]+$/.test(value); } diff --git a/extensions/slack/src/monitor/message-handler/prepare-content.ts b/extensions/slack/src/monitor/message-handler/prepare-content.ts index 62a26c087eb..0e5c0545691 100644 --- a/extensions/slack/src/monitor/message-handler/prepare-content.ts +++ b/extensions/slack/src/monitor/message-handler/prepare-content.ts @@ -1,7 +1,10 @@ import type { WebClient as SlackWebClient } from "@slack/web-api"; import { runTasksWithConcurrency } from "openclaw/plugin-sdk/concurrency-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeOptionalString, + readStringValue as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatSlackFileReference } from "../../file-reference.js"; import type { SlackFile, SlackMessageEvent } from "../../types.js"; import { MAX_SLACK_MEDIA_FILES, type SlackMediaResult } from "../media-types.js"; @@ -88,10 +91,6 @@ function renderSlackUserMentions( }); } -function readString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - function readTextObject(value: unknown): string | undefined { if (!value || typeof value !== "object") { return undefined; diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index 226a39140d5..015e99719a9 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -29,6 +29,7 @@ import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; import { + asOptionalRecord as asRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -83,12 +84,6 @@ const SLACK_HISTORY_MEDIA_MAX_BYTES = 10 * 1024 * 1024; const SLACK_HISTORY_MEDIA_IDLE_TIMEOUT_MS = 1_000; const SLACK_HISTORY_MEDIA_TOTAL_TIMEOUT_MS = 3_000; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function recordString( record: Record | undefined, key: string, diff --git a/extensions/slack/src/monitor/provider-support.ts b/extensions/slack/src/monitor/provider-support.ts index df491461b97..06aafb8da33 100644 --- a/extensions/slack/src/monitor/provider-support.ts +++ b/extensions/slack/src/monitor/provider-support.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { SlackChannelResolution } from "../resolve-channels.js"; import type { SlackUserResolution } from "../resolve-users.js"; import { formatUnknownError, waitForSlackSocketDisconnect } from "./reconnect-policy.js"; @@ -265,12 +266,6 @@ export function createSlackSocketModeLogger( }; } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - export function shouldSkipOpenClawSlackSelfEvent(args: SlackSelfFilterArgs): boolean { const botId = args.context?.botId; const botUserId = args.context?.botUserId; diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index a9ba678c2ee..a7adf1ea26e 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -22,6 +22,7 @@ import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session- import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, + normalizeStringEntriesLower, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { chunkItems } from "openclaw/plugin-sdk/text-chunking"; import type { ResolvedSlackAccount } from "../accounts.js"; @@ -796,7 +797,7 @@ export async function registerSlackMonitorSlashCommands(params: { provider: "slack", }); const existingNativeNames = new Set( - nativeCommands.map((c) => normalizeLowercaseStringOrEmpty(c.name)).filter(Boolean), + normalizeStringEntriesLower(nativeCommands.map((command) => command.name)), ); const { listProviderPluginCommandSpecs } = await loadSlackPluginCommandsRuntime(); for (const pluginCommand of listProviderPluginCommandSpecs("slack")) { diff --git a/extensions/slack/src/scopes.ts b/extensions/slack/src/scopes.ts index 621c210a8e4..cf82b0e5c40 100644 --- a/extensions/slack/src/scopes.ts +++ b/extensions/slack/src/scopes.ts @@ -1,5 +1,10 @@ import type { WebClient } from "@slack/web-api"; -import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeStringEntries, + normalizeOptionalString, + sortUniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { createSlackWebClient } from "./client.js"; import { formatSlackError } from "./errors.js"; @@ -49,7 +54,7 @@ function collectScopes(value: unknown, into: string[]) { } function normalizeScopes(scopes: string[]) { - return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean))).toSorted(); + return sortUniqueStrings(normalizeStringEntries(scopes)); } function extractScopes(payload: unknown): string[] { diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index 24c021f5eab..31b31374dd8 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -22,6 +22,7 @@ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, + normalizeOptionalString as normalizeSlackApiString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; @@ -174,10 +175,6 @@ function buildSlackPostMessagePayload(params: { }; } -function normalizeSlackApiString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function normalizeSlackScopeList(value: unknown): string[] { if (!Array.isArray(value)) { return []; diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 55d367ba6ff..f40c38a072e 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -20,6 +20,7 @@ import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, + uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { inspectSlackAccount } from "./account-inspect.js"; import { resolveSlackAccount } from "./accounts.js"; @@ -63,7 +64,7 @@ function setSlackInteractiveReplies( const capabilities = resolveSlackAccount({ cfg, accountId }).config.capabilities; const nextCapabilities = Array.isArray(capabilities) ? interactiveReplies - ? [...new Set([...capabilities, "interactiveReplies"])] + ? uniqueStrings([...capabilities, "interactiveReplies"]) : capabilities.filter( (entry) => normalizeLowercaseStringOrEmpty(entry) !== "interactivereplies", ) diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index e334ab30382..f7c9653fefe 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -14,6 +14,7 @@ import type { ChannelSetupWizardAllowFromEntry, } from "openclaw/plugin-sdk/setup-runtime"; import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveDefaultSlackAccountId, resolveSlackAccount, @@ -137,7 +138,7 @@ async function resolveSlackGroupAllowlist(params: { .filter((entry) => entry.resolved && entry.id) .map((entry) => entry.id as string); const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); - keys = [...resolvedKeys, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + keys = [...resolvedKeys, ...normalizeStringEntries(unresolved)]; await noteChannelLookupSummary({ prompter: params.prompter, label: t("wizard.slack.channelsLabel"), diff --git a/extensions/synology-chat/src/accounts.ts b/extensions/synology-chat/src/accounts.ts index 859dd2e592c..6e26000293b 100644 --- a/extensions/synology-chat/src/accounts.ts +++ b/extensions/synology-chat/src/accounts.ts @@ -10,6 +10,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; import { resolveDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { SynologyChatChannelConfig, ResolvedSynologyChatAccount, @@ -61,10 +62,7 @@ function parseAllowedUserIds(raw: string | string[] | undefined): string[] { if (Array.isArray(raw)) { return raw.filter(Boolean); } - return raw - .split(",") - .map((s) => s.trim()) - .filter(Boolean); + return normalizeStringEntries(raw.split(",")); } function parseRateLimitPerMinute(raw: string | undefined): number { diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index e689b0c40fa..5d70fdeb3f9 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -25,7 +25,10 @@ import { projectAccountWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeStringEntriesLower, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { synologyChatApprovalAuth } from "./approval-auth.js"; import { sendMessage, sendFileUrl } from "./client.js"; @@ -94,8 +97,7 @@ const synologyChatConfigAdapter = createHybridChannelConfigAdapter account.allowedUserIds, - formatAllowFrom: (allowFrom) => - allowFrom.map((entry) => normalizeLowercaseStringOrEmpty(String(entry))).filter(Boolean), + formatAllowFrom: (allowFrom) => normalizeStringEntriesLower(allowFrom), }); const collectSynologyChatSecurityWarnings = diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts index 907127fd9fd..ffcac001306 100644 --- a/extensions/synology-chat/src/setup-surface.ts +++ b/extensions/synology-chat/src/setup-surface.ts @@ -12,7 +12,10 @@ import { type ChannelSetupWizard, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeOptionalString, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { listAccountIds, resolveAccount } from "./accounts.js"; import type { SynologyChatAccountRaw, SynologyChatChannelConfig } from "./types.js"; @@ -147,10 +150,7 @@ function resolveExistingAllowedUserIds(cfg: OpenClawConfig, accountId: string): if (Array.isArray(raw)) { return raw.map(normalizeSynologyAllowedUserId).filter(Boolean); } - return normalizeSynologyAllowedUserId(raw) - .split(",") - .map((value) => value.trim()) - .filter(Boolean); + return normalizeStringEntries(normalizeSynologyAllowedUserId(raw).split(",")); } export const synologyChatSetupAdapter: ChannelSetupAdapter = { diff --git a/extensions/telegram/src/bot-access.ts b/extensions/telegram/src/bot-access.ts index 50ebb0d96e2..642fe50836e 100644 --- a/extensions/telegram/src/bot-access.ts +++ b/extensions/telegram/src/bot-access.ts @@ -9,7 +9,7 @@ import type { TelegramGroupConfig, } from "openclaw/plugin-sdk/config-contracts"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; export type NormalizedAllowFrom = { entries: string[]; @@ -52,7 +52,7 @@ export const normalizeAllowFrom = (list?: Array): NormalizedAll .map((value) => value.replace(/^(telegram|tg):/i, "")); const invalidEntries = normalized.filter((value) => !/^\d+$/.test(value)); if (invalidEntries.length > 0) { - warnInvalidAllowFromEntries([...new Set(invalidEntries)]); + warnInvalidAllowFromEntries(uniqueStrings(invalidEntries)); } const ids = normalized.filter((value) => /^\d+$/.test(value)); return { diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 5a9597dd0f4..c38ad770b2e 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -39,6 +39,7 @@ import { resolveSessionStoreEntry, updateSessionStore, } from "openclaw/plugin-sdk/session-store-runtime"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js"; import { resolveTelegramAccount, resolveTelegramMediaRuntimeOptions } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; @@ -278,12 +279,7 @@ export const registerTelegramHandlers = ({ return latest; }; const mergeDispatchDedupeKeys = (...groups: Array) => [ - ...new Set( - groups - .flatMap((group) => group ?? []) - .map((key) => key.trim()) - .filter(Boolean), - ), + ...new Set(normalizeStringEntries(groups.flatMap((group) => group ?? []))), ]; const releaseDispatchDedupeKeys = (keys: readonly string[], error?: unknown) => { releaseTelegramMessageDispatchReplay({ diff --git a/extensions/telegram/src/message-cache.ts b/extensions/telegram/src/message-cache.ts index 50a72a8cbe3..c76216635b6 100644 --- a/extensions/telegram/src/message-cache.ts +++ b/extensions/telegram/src/message-cache.ts @@ -5,6 +5,7 @@ import { formatLocationText } from "openclaw/plugin-sdk/channel-inbound"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { appendRegularFileSync, replaceFileAtomicSync } from "openclaw/plugin-sdk/security-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveTelegramPrimaryMedia } from "./bot/body-helpers.js"; import { buildSenderName, @@ -230,10 +231,6 @@ function normalizeMessageNodes( return observations; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isString(value: unknown): value is string { return typeof value === "string" && value.length > 0; } diff --git a/extensions/telegram/src/message-dispatch-dedupe.ts b/extensions/telegram/src/message-dispatch-dedupe.ts index a1c690ea89a..c7c22fef5dd 100644 --- a/extensions/telegram/src/message-dispatch-dedupe.ts +++ b/extensions/telegram/src/message-dispatch-dedupe.ts @@ -1,6 +1,7 @@ import path from "node:path"; import type { Message } from "grammy/types"; import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; +import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; const TELEGRAM_MESSAGE_DISPATCH_TTL_MS = 7 * 24 * 60 * 60 * 1000; const TELEGRAM_MESSAGE_DISPATCH_MEMORY_MAX = 5000; @@ -81,7 +82,7 @@ export async function claimTelegramMessageDispatchReplay(params: { } function normalizeReplayKeys(keys?: readonly string[]): string[] { - return [...new Set((keys ?? []).map((key) => key.trim()).filter(Boolean))]; + return uniqueStrings(normalizeStringEntries(keys ?? [])); } export async function commitTelegramMessageDispatchReplay(params: { diff --git a/extensions/telegram/src/secret-contract.ts b/extensions/telegram/src/secret-contract.ts index 9589be00b06..14466203ddc 100644 --- a/extensions/telegram/src/secret-contract.ts +++ b/extensions/telegram/src/secret-contract.ts @@ -6,14 +6,7 @@ import { type ResolverContext, type SecretDefaults, } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; - -function normalizeOptionalString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; export const secretTargetRegistryEntries: import("openclaw/plugin-sdk/channel-secret-basic-runtime").SecretTargetRegistryEntry[] = [ diff --git a/extensions/telegram/src/security-audit.ts b/extensions/telegram/src/security-audit.ts index 174e82f9757..9c480ce9b92 100644 --- a/extensions/telegram/src/security-audit.ts +++ b/extensions/telegram/src/security-audit.ts @@ -1,14 +1,10 @@ import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; import { resolveNativeSkillsEnabled } from "openclaw/plugin-sdk/native-command-config-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawConfig } from "../runtime-api.js"; import type { ResolvedTelegramAccount } from "./accounts.js"; import { isNumericTelegramSenderUserId, normalizeTelegramAllowFromEntry } from "./allow-from.js"; -function normalizeOptionalString(value: string | null | undefined): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; -} - function collectInvalidTelegramAllowFromEntries(params: { entries: unknown; target: Set }) { if (!Array.isArray(params.entries)) { return; diff --git a/extensions/telegram/src/state-migrations.ts b/extensions/telegram/src/state-migrations.ts index aaf38f4b780..adfda5c7e1d 100644 --- a/extensions/telegram/src/state-migrations.ts +++ b/extensions/telegram/src/state-migrations.ts @@ -4,6 +4,7 @@ import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { statRegularFileSync } from "openclaw/plugin-sdk/security-runtime"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { listTelegramAccountIds, resolveDefaultTelegramAccountId } from "./account-selection.js"; import { listTelegramLegacyBotInfoCacheEntries, @@ -56,7 +57,7 @@ function detectTelegramMessageCacheLegacyStateMigration(params: { const legacyStorePath = resolveLegacySessionStorePath(params); const legacyPersistedPath = resolveTelegramMessageCachePath(legacyStorePath); const scopeKey = resolveTelegramMessageCachePersistentScopeKey(runtimePersistedPath); - const sourcePaths = Array.from(new Set([runtimePersistedPath, legacyPersistedPath])); + const sourcePaths = uniqueStrings([runtimePersistedPath, legacyPersistedPath]); return sourcePaths.flatMap((persistedPath) => { if (!fileExists(persistedPath)) { return []; diff --git a/extensions/telegram/src/status-reaction-variants.ts b/extensions/telegram/src/status-reaction-variants.ts index c0bf6f1d7a7..d44f6d59c00 100644 --- a/extensions/telegram/src/status-reaction-variants.ts +++ b/extensions/telegram/src/status-reaction-variants.ts @@ -1,6 +1,10 @@ import type { ReactionTypeEmoji } from "grammy/types"; import { DEFAULT_EMOJIS, type StatusReactionEmojis } from "openclaw/plugin-sdk/channel-feedback"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeOptionalString, + normalizeStringEntries, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { TelegramChatDetails, TelegramGetChat } from "./bot/types.js"; type StatusReactionEmojiKey = keyof Required; @@ -121,7 +125,7 @@ const STATUS_REACTION_EMOJI_KEYS: StatusReactionEmojiKey[] = [ ]; function toUniqueNonEmpty(values: string[]): string[] { - return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); + return uniqueStrings(normalizeStringEntries(values)); } export function resolveTelegramStatusReactionEmojis(params: { diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index af343461d3a..f7074bdb585 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -1,5 +1,6 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawConfig } from "../../runtime-api.js"; import { createLoggerBackedRuntime } from "../../runtime-api.js"; import { getTlonRuntime } from "../runtime.js"; @@ -53,8 +54,7 @@ type MonitorTlonOpts = { }; function readNumber(record: Record | null, key: string): number | undefined { - const value = record?.[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(record?.[key]); } export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { diff --git a/extensions/tlon/src/monitor/settings-helpers.ts b/extensions/tlon/src/monitor/settings-helpers.ts index 520d509766b..d20ee09bf2e 100644 --- a/extensions/tlon/src/monitor/settings-helpers.ts +++ b/extensions/tlon/src/monitor/settings-helpers.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { PendingApproval, TlonSettingsStore } from "../settings.js"; import { normalizeShip } from "../targets.js"; import type { TlonResolvedAccount } from "../types.js"; @@ -145,14 +146,5 @@ export function applyTlonSettingsOverrides(params: { } export function mergeUniqueStrings(base: string[], next?: string[]): string[] { - if (!next?.length) { - return [...base]; - } - const merged = [...base]; - for (const value of next) { - if (!merged.includes(value)) { - merged.push(value); - } - } - return merged; + return uniqueStrings([...base, ...(next ?? [])]); } diff --git a/extensions/tlon/src/setup-surface.ts b/extensions/tlon/src/setup-surface.ts index 8a17e391982..7120c6771f8 100644 --- a/extensions/tlon/src/setup-surface.ts +++ b/extensions/tlon/src/setup-surface.ts @@ -1,4 +1,5 @@ import { createSetupTranslator } from "openclaw/plugin-sdk/setup-runtime"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { applyTlonSetupConfig, createTlonSetupWizardBase, @@ -12,10 +13,7 @@ import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.j const t = createSetupTranslator(); function parseList(value: string): string[] { - return value - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries(value.split(/[\n,;]+/g)); } export const tlonSetupWizard = createTlonSetupWizardBase({ diff --git a/extensions/twitch/src/outbound.ts b/extensions/twitch/src/outbound.ts index 479f72e86a3..5b439ed3c41 100644 --- a/extensions/twitch/src/outbound.ts +++ b/extensions/twitch/src/outbound.ts @@ -11,6 +11,7 @@ import { type ChannelMessageSendResult, type MessageReceiptPartKind, } from "openclaw/plugin-sdk/channel-message"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveTwitchAccountContext } from "./config.js"; import { sendMessageTwitchInternal } from "./send.js"; import type { @@ -56,9 +57,7 @@ export const twitchOutbound: ChannelOutboundAdapter = { */ resolveTarget: ({ to, allowFrom, mode }) => { const trimmed = to?.trim() ?? ""; - const allowListRaw = (allowFrom ?? []) - .map((entry: unknown) => String(entry).trim()) - .filter(Boolean); + const allowListRaw = normalizeStringEntries(allowFrom ?? []); const hasWildcard = allowListRaw.includes("*"); const allowList = allowListRaw .filter((entry: string) => entry !== "*") diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index def018fa44b..edf2de646df 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -14,6 +14,7 @@ import { normalizeAccountId, createSetupTranslator, } from "openclaw/plugin-sdk/setup"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_ACCOUNT_ID, getAccountConfig, @@ -349,10 +350,7 @@ const twitchDmPolicy: ChannelSetupDmPolicy = { initialValue: existingAllowFrom[0] || undefined, }); - const allowFrom = (entry ?? "") - .split(/[\n,;]+/g) - .map((s) => s.trim()) - .filter(Boolean); + const allowFrom = normalizeStringEntries((entry ?? "").split(/[\n,;]+/g)); return setTwitchAccount( cfg, diff --git a/extensions/voice-call/src/cli.ts b/extensions/voice-call/src/cli.ts index 9d88741b051..c7d649c0b56 100644 --- a/extensions/voice-call/src/cli.ts +++ b/extensions/voice-call/src/cli.ts @@ -5,7 +5,10 @@ import { format } from "node:util"; import type { Command } from "commander"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime"; -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeOptionalLowercaseString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { sleep } from "../api.js"; import { validateProviderConfig, type VoiceCallConfig } from "./config.js"; import type { VoiceCallRuntime } from "./runtime.js"; @@ -85,10 +88,6 @@ function parseVoiceCallIntOption( return parsed; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function isGatewayUnavailableForLocalFallback(err: unknown): boolean { const message = formatErrorMessage(err); return ( diff --git a/extensions/voice-call/src/deep-merge.ts b/extensions/voice-call/src/deep-merge.ts index b889ec14e1a..b79ade926ab 100644 --- a/extensions/voice-call/src/deep-merge.ts +++ b/extensions/voice-call/src/deep-merge.ts @@ -1,3 +1,5 @@ +import { isRecord as isPlainObject } from "openclaw/plugin-sdk/string-coerce-runtime"; + const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]); export function deepMergeDefined(base: unknown, override: unknown): unknown { @@ -17,7 +19,3 @@ export function deepMergeDefined(base: unknown, override: unknown): unknown { return result; } - -function isPlainObject(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} diff --git a/extensions/voice-call/src/realtime-agent-context.ts b/extensions/voice-call/src/realtime-agent-context.ts index 2760d9e61a2..344beac58a3 100644 --- a/extensions/voice-call/src/realtime-agent-context.ts +++ b/extensions/voice-call/src/realtime-agent-context.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { buildRealtimeVoiceAgentConsultPolicyInstructions } from "openclaw/plugin-sdk/realtime-voice"; import { root } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeOptionalString as normalizeString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { VoiceCallConfig } from "./config.js"; import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; @@ -17,10 +18,6 @@ type VoiceIdentityLike = { vibe?: unknown; }; -function normalizeString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readAgentEntries(cfg: CoreConfig): AgentEntryLike[] { const agents = (cfg as { agents?: { list?: unknown } }).agents; return Array.isArray(agents?.list) diff --git a/extensions/voice-call/src/response-generator.ts b/extensions/voice-call/src/response-generator.ts index e32fd216891..70ce4488342 100644 --- a/extensions/voice-call/src/response-generator.ts +++ b/extensions/voice-call/src/response-generator.ts @@ -5,7 +5,11 @@ import crypto from "node:crypto"; import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeLowercaseStringOrEmpty, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveVoiceCallSessionKey, type VoiceCallConfig } from "./config.js"; import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; import { resolveVoiceResponseModel } from "./response-model.js"; @@ -40,10 +44,6 @@ type VoiceResponsePayload = { isReasoning?: boolean; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function readExplicitToolsAllow(value: unknown): string[] | undefined { if (!isRecord(value)) { return undefined; @@ -159,10 +159,7 @@ function sanitizePlainSpokenText(text: string): string | null { return null; } - const paragraphs = withoutCodeFences - .split(/\n\s*\n+/) - .map((paragraph) => paragraph.trim()) - .filter(Boolean); + const paragraphs = normalizeStringEntries(withoutCodeFences.split(/\n\s*\n+/)); while (paragraphs.length > 1 && isLikelyMetaReasoningParagraph(paragraphs[0])) { paragraphs.shift(); diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index cb5f78067e0..1c6b0e70935 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -2,7 +2,10 @@ import crypto from "node:crypto"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime"; import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { getHeader } from "./http-headers.js"; import type { WebhookContext } from "./types.js"; @@ -829,11 +832,9 @@ function validatePlivoV3Signature(params: { const expected = normalizeSignatureBase64(digest); // Header can contain multiple signatures separated by commas. - const provided = params.signatureHeader - .split(",") - .map((s) => s.trim()) - .filter(Boolean) - .map((s) => normalizeSignatureBase64(s)); + const provided = normalizeStringEntries(params.signatureHeader.split(",")).map((s) => + normalizeSignatureBase64(s), + ); for (const sig of provided) { if (timingSafeEqualString(expected, sig)) { diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 3c129e465e9..6495fde40fc 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -3,7 +3,10 @@ import { URL } from "node:url"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime"; import type { TalkEvent } from "openclaw/plugin-sdk/realtime-voice"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeOptionalString, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { createWebhookInFlightLimiter, WEBHOOK_BODY_READ_DEFAULTS, @@ -131,10 +134,7 @@ function resolveForwardedClientIp( ); const forwardedFor = getHeader(request.headers, "x-forwarded-for"); if (forwardedFor) { - const forwardedIps = forwardedFor - .split(",") - .map((part) => part.trim()) - .filter(Boolean); + const forwardedIps = normalizeStringEntries(forwardedFor.split(",")); if (forwardedIps.length > 0) { if (normalizedTrustedProxyIps.size === 0) { return forwardedIps[0]; diff --git a/extensions/volcengine/api.ts b/extensions/volcengine/api.ts index 40ec8a743a5..674c4f18abf 100644 --- a/extensions/volcengine/api.ts +++ b/extensions/volcengine/api.ts @@ -1,4 +1,5 @@ import type { ModelCompatConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; export const VOLCENGINE_UNSUPPORTED_TOOL_SCHEMA_KEYWORDS = [ "minLength", @@ -10,7 +11,7 @@ export const VOLCENGINE_UNSUPPORTED_TOOL_SCHEMA_KEYWORDS = [ ] as const; function mergeUnsupportedToolSchemaKeywords(existing: readonly string[] | undefined): string[] { - return Array.from(new Set([...(existing ?? []), ...VOLCENGINE_UNSUPPORTED_TOOL_SCHEMA_KEYWORDS])); + return uniqueStrings([...(existing ?? []), ...VOLCENGINE_UNSUPPORTED_TOOL_SCHEMA_KEYWORDS]); } export function resolveVolcengineToolSchemaCompatPatch( diff --git a/extensions/voyage/embedding-batch.ts b/extensions/voyage/embedding-batch.ts index 4a90723c19b..fbe97e04cc8 100644 --- a/extensions/voyage/embedding-batch.ts +++ b/extensions/voyage/embedding-batch.ts @@ -20,6 +20,7 @@ import { uploadBatchJsonlFile, withRemoteHttpResponse, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { VoyageEmbeddingClient } from "./embedding-provider.js"; /** @@ -151,11 +152,9 @@ async function readVoyageBatchError(params: { if (!text.trim()) { return undefined; } - const lines = text - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => JSON.parse(line) as VoyageBatchOutputLine); + const lines = normalizeStringEntries(text.split("\n")).map( + (line) => JSON.parse(line) as VoyageBatchOutputLine, + ); return extractBatchErrorMessage(lines); }, }), diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index ae8af884004..528c31dc0be 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -7,6 +7,7 @@ import { deliverInboundReplyWithMessageSendContext } from "openclaw/plugin-sdk/c import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/inbound-reply-dispatch"; 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 { type DeliverableWhatsAppOutboundPayload, normalizeWhatsAppOutboundPayload, @@ -175,12 +176,10 @@ function resolveWhatsAppDeliverablePayload( function getWhatsAppPayloadMediaUrls(payload: ReplyPayload): Set { return new Set( - [ + normalizeStringEntries([ ...(Array.isArray(payload.mediaUrls) ? payload.mediaUrls : []), ...(typeof payload.mediaUrl === "string" ? [payload.mediaUrl] : []), - ] - .map((url) => url.trim()) - .filter(Boolean), + ]), ); } diff --git a/extensions/whatsapp/src/inbound/extract.ts b/extensions/whatsapp/src/inbound/extract.ts index 7542bf0e34e..3075086c50c 100644 --- a/extensions/whatsapp/src/inbound/extract.ts +++ b/extensions/whatsapp/src/inbound/extract.ts @@ -2,6 +2,7 @@ import type { proto } from "baileys"; import { extractMessageContent, getContentType, normalizeMessageContent } from "baileys"; import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveComparableIdentity, type WhatsAppReplyContext } from "../identity.js"; import { jidToE164 } from "../text-runtime.js"; import { parseVcard } from "../vcard.js"; @@ -226,7 +227,7 @@ export function extractMentionedJids(rawMessage: proto.IMessage | undefined): st if (flattened.length === 0) { return undefined; } - return Array.from(new Set(flattened)); + return uniqueStrings(flattened); } export function extractText(rawMessage: proto.IMessage | undefined): string | undefined { diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index 2cb5e054352..ec68e49ca6b 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -12,6 +12,7 @@ import { createInboundDebouncer } from "openclaw/plugin-sdk/channel-inbound-debo import { getChildLogger } from "openclaw/plugin-sdk/logging-core"; import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { maybeResolveWhatsAppApprovalReaction } from "../approval-reactions.js"; import { readWebSelfIdentityForDecision, WhatsAppAuthUnstableError } from "../auth-store.js"; import { getPrimaryIdentityId, resolveComparableIdentity } from "../identity.js"; @@ -266,9 +267,9 @@ export async function attachWebInboxToSocket( entries: QueuedInboundMessage[], error?: unknown, ): Promise => { - const dedupeKeys = [ - ...new Set(entries.map((entry) => entry.dedupeKey).filter(isNonEmptyString)), - ]; + const dedupeKeys = uniqueStrings( + entries.map((entry) => entry.dedupeKey).filter(isNonEmptyString), + ); const durableEntries = entries.filter( (entry): entry is QueuedInboundMessage & { durableId: string } => isNonEmptyString(entry.durableId), diff --git a/extensions/whatsapp/src/inbound/send-result.ts b/extensions/whatsapp/src/inbound/send-result.ts index a0f4f929802..256f22f5363 100644 --- a/extensions/whatsapp/src/inbound/send-result.ts +++ b/extensions/whatsapp/src/inbound/send-result.ts @@ -6,6 +6,7 @@ import { type MessageReceiptPartKind, type MessageReceiptSourceResult, } from "openclaw/plugin-sdk/channel-message"; +import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; export type WhatsAppSendKind = "media" | "poll" | "reaction" | "text"; @@ -85,7 +86,7 @@ export function combineWhatsAppSendResults( kind: WhatsAppSendKind, results: readonly WhatsAppSendResult[], ): WhatsAppSendResult { - const messageIds = [...new Set(results.flatMap(listWhatsAppSendResultMessageIds))]; + const messageIds = uniqueStrings(results.flatMap(listWhatsAppSendResultMessageIds)); const keys = results.flatMap((result) => result.keys); return { kind, @@ -101,9 +102,9 @@ export function listWhatsAppSendResultMessageIds(result: WhatsAppSendResult): st if (receiptIds.length > 0) { return receiptIds; } - const keyIds = result.keys.map((key) => key.id.trim()).filter(Boolean); + const keyIds = normalizeStringEntries(result.keys.map((key) => key.id)); if (keyIds.length > 0) { - return [...new Set(keyIds)]; + return uniqueStrings(keyIds); } return []; } diff --git a/extensions/whatsapp/src/outbound-media-contract.ts b/extensions/whatsapp/src/outbound-media-contract.ts index 0c019d2279e..8c73d84ccfb 100644 --- a/extensions/whatsapp/src/outbound-media-contract.ts +++ b/extensions/whatsapp/src/outbound-media-contract.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime"; import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path"; import { resolveWhatsAppDocumentFileName } from "./document-filename.js"; import { formatError } from "./session-errors.js"; @@ -90,7 +91,7 @@ export function resolveWhatsAppOutboundMediaUrls( const orderedMediaUrls = [primaryMediaUrl, ...mediaUrls].filter((entry): entry is string => Boolean(entry), ); - return Array.from(new Set(orderedMediaUrls)); + return uniqueStrings(orderedMediaUrls); } // Keep new WhatsApp outbound-media behavior in this helper so payload, gateway, and auto-reply paths stay aligned. diff --git a/extensions/whatsapp/src/resolve-outbound-target.ts b/extensions/whatsapp/src/resolve-outbound-target.ts index 8a994629fa7..ce5bbf4149b 100644 --- a/extensions/whatsapp/src/resolve-outbound-target.ts +++ b/extensions/whatsapp/src/resolve-outbound-target.ts @@ -1,4 +1,5 @@ import { missingTargetError } from "openclaw/plugin-sdk/channel-feedback"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isWhatsAppGroupJid, isWhatsAppNewsletterJid, @@ -37,9 +38,7 @@ export function resolveWhatsAppOutboundTarget(params: { return { ok: true, to: normalizedTo }; } - const allowListRaw = (params.allowFrom ?? []) - .map((entry) => String(entry).trim()) - .filter(Boolean); + const allowListRaw = normalizeStringEntries(params.allowFrom ?? []); const hasWildcard = allowListRaw.includes("*"); const allowList = allowListRaw .filter((entry) => entry !== "*") diff --git a/extensions/whatsapp/src/security-fix.ts b/extensions/whatsapp/src/security-fix.ts index bbffd126159..7a9b587acbd 100644 --- a/extensions/whatsapp/src/security-fix.ts +++ b/extensions/whatsapp/src/security-fix.ts @@ -2,6 +2,7 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract"; import { readChannelAllowFromStore } from "openclaw/plugin-sdk/channel-pairing"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { normalizeUniqueStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; function applyGroupAllowFromFromStore(params: { cfg: OpenClawConfig; @@ -56,7 +57,7 @@ export async function applyWhatsAppSecurityConfigFixes(params: { params.env, DEFAULT_ACCOUNT_ID, ).catch(() => []); - const normalized = Array.from(new Set(fromStore.map((entry) => entry.trim()))).filter(Boolean); + const normalized = normalizeUniqueStringEntries(fromStore); if (normalized.length === 0) { return { config: params.cfg, changes: [] }; } diff --git a/extensions/xai/realtime-transcription-provider.ts b/extensions/xai/realtime-transcription-provider.ts index f7b76ad497b..a7ff39bffb2 100644 --- a/extensions/xai/realtime-transcription-provider.ts +++ b/extensions/xai/realtime-transcription-provider.ts @@ -12,7 +12,11 @@ import { type RealtimeTranscriptionWebSocketTransport, } from "openclaw/plugin-sdk/realtime-transcription"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeOptionalString, + parseBooleanValue as readBoolean, + parseFiniteNumber as readFiniteNumber, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { XAI_BASE_URL } from "./model-definitions.js"; import { xaiUserAgentHeaderFor } from "./src/xai-user-agent.js"; @@ -69,33 +73,6 @@ function readNestedXaiConfig(rawConfig: RealtimeTranscriptionProviderConfig) { return readRecord(providers?.xai ?? raw?.xai ?? raw) ?? {}; } -function readFiniteNumber(value: unknown): number | undefined { - const next = - typeof value === "number" - ? value - : typeof value === "string" - ? Number.parseFloat(value) - : undefined; - return Number.isFinite(next) ? next : undefined; -} - -function readBoolean(value: unknown): boolean | undefined { - if (typeof value === "boolean") { - return value; - } - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim().toLowerCase(); - if (["1", "true", "yes", "on"].includes(normalized)) { - return true; - } - if (["0", "false", "no", "off"].includes(normalized)) { - return false; - } - return undefined; -} - function normalizeEncoding(value: unknown): XaiRealtimeTranscriptionEncoding | undefined { const normalized = normalizeOptionalString(value)?.toLowerCase(); if (!normalized) { diff --git a/extensions/xai/src/responses-tool-shared.ts b/extensions/xai/src/responses-tool-shared.ts index 5bd5b60ad1a..431708e9d23 100644 --- a/extensions/xai/src/responses-tool-shared.ts +++ b/extensions/xai/src/responses-tool-shared.ts @@ -1,3 +1,7 @@ +import { + normalizeOptionalString as trimString, + uniqueStrings, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { XaiWebSearchResponse } from "./web-search-response.types.js"; function isRecord(value: unknown): value is Record { @@ -21,10 +25,6 @@ function extractUrlCitations(annotations: unknown): string[] { const XAI_RESPONSES_BASE_URL = "https://api.x.ai/v1"; export const XAI_RESPONSES_ENDPOINT = `${XAI_RESPONSES_BASE_URL}/responses`; -function trimString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - export function resolveXaiResponsesEndpoint(baseUrl?: unknown): string { return `${(trimString(baseUrl) ?? XAI_RESPONSES_BASE_URL).replace(/\/+$/, "")}/responses`; } @@ -59,14 +59,14 @@ export function extractXaiWebSearchContent(data: XaiWebSearchResponse): { } if (block.type === "output_text" && typeof block.text === "string" && block.text) { const urls = extractUrlCitations(block.annotations); - return { text: block.text, annotationCitations: [...new Set(urls)] }; + return { text: block.text, annotationCitations: uniqueStrings(urls) }; } } } if (output.type === "output_text" && typeof output.text === "string" && output.text) { const urls = extractUrlCitations(output.annotations); - return { text: output.text, annotationCitations: [...new Set(urls)] }; + return { text: output.text, annotationCitations: uniqueStrings(urls) }; } } diff --git a/extensions/xai/video-generation-provider.ts b/extensions/xai/video-generation-provider.ts index ccdae8c21ec..800f3c0b91f 100644 --- a/extensions/xai/video-generation-provider.ts +++ b/extensions/xai/video-generation-provider.ts @@ -13,7 +13,7 @@ import { waitProviderOperationPollInterval, type ProviderOperationTimeoutMs, } from "openclaw/plugin-sdk/provider-http"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { GeneratedVideoAsset, VideoGenerationProvider, @@ -63,10 +63,6 @@ type VideoGenerationSourceInput = { role?: string; }; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - async function readXaiVideoJson(response: Response): Promise> { let payload: unknown; try { diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index ef802bb10ce..4f781e3a1f4 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -13,6 +13,7 @@ import { type DmPolicy, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; +import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { checkZcaAuthenticated, listZalouserAccountIds, @@ -38,10 +39,7 @@ const ZALOUSER_ALLOWLIST_TITLE = t("wizard.zalouser.allowlistTitle"); const ZALOUSER_GROUPS_TITLE = t("wizard.zalouser.groupsTitle"); function parseZalouserEntries(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries(raw.split(/[\n,;]+/g)); } function setZalouserAccountScopedConfig( @@ -450,7 +448,7 @@ export const zalouserSetupWizard: ChannelSetupWizard = { .filter((entry) => entry.resolved && entry.id) .map((entry) => entry.id as string); const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); - const keys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + const keys = [...resolvedIds, ...normalizeStringEntries(unresolved)]; const resolution = formatResolvedUnresolvedNote({ resolved: resolvedIds, unresolved, @@ -464,7 +462,7 @@ export const zalouserSetupWizard: ChannelSetupWizard = { t("wizard.zalouser.groupLookupFailed", { error: String(err) }), ZALOUSER_GROUPS_TITLE, ); - return entries.map((entry) => entry.trim()).filter(Boolean); + return normalizeStringEntries(entries); } }, applyAllowlist: ({ cfg, accountId, resolved }) => diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index ba387270b28..ac396da43f2 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -18,7 +18,11 @@ import { splitShellArgs, } from "./config-utils.js"; import { isPathInside } from "./fs-utils.js"; -import { normalizeLowercaseStringOrEmpty } from "./string-utils.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeStringEntries, + uniqueStrings, +} from "./string-utils.js"; function escapeQmdExactFilePattern(fileName: string): string { return fileName.replace(/[\\*?[\]{}()!+@]/g, "\\$&"); @@ -396,14 +400,13 @@ export function resolveMemoryBackendConfig(params: { const agentEntry = params.cfg.agents?.list?.find( (entry) => normalizeAgentId(entry?.id) === normalizedAgentId, ); - const mergedExtraPaths = [ - ...(params.cfg.agents?.defaults?.memorySearch?.extraPaths ?? []), - ...(agentEntry?.memorySearch?.extraPaths ?? []), - ] - .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean); - const dedupedExtraPaths = Array.from(new Set(mergedExtraPaths)); + const mergedExtraPaths = normalizeStringEntries( + [ + ...(params.cfg.agents?.defaults?.memorySearch?.extraPaths ?? []), + ...(agentEntry?.memorySearch?.extraPaths ?? []), + ].filter((value): value is string => typeof value === "string"), + ); + const dedupedExtraPaths = uniqueStrings(mergedExtraPaths); const searchExtraPaths = dedupedExtraPaths.map( (pathValue): { path: string; pattern?: string; name?: string } => ({ path: pathValue }), ); diff --git a/packages/memory-host-sdk/src/host/config-utils.ts b/packages/memory-host-sdk/src/host/config-utils.ts index bac4d740491..6b8cb8410b5 100644 --- a/packages/memory-host-sdk/src/host/config-utils.ts +++ b/packages/memory-host-sdk/src/host/config-utils.ts @@ -1,7 +1,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-utils.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, + normalizeStringEntries, + uniqueStrings, +} from "./string-utils.js"; export { splitShellArgs } from "./openclaw-runtime-io.js"; export type ChatType = "direct" | "group" | "channel"; @@ -314,12 +319,13 @@ export function resolveMemorySearchConfig( if (!enabled) { return null; } - const rawPaths = [...(defaults?.extraPaths ?? []), ...(overrides?.extraPaths ?? [])] - .map((value) => value.trim()) - .filter(Boolean); + const rawPaths = normalizeStringEntries([ + ...(defaults?.extraPaths ?? []), + ...(overrides?.extraPaths ?? []), + ]); return { enabled, - extraPaths: Array.from(new Set(rawPaths)), + extraPaths: uniqueStrings(rawPaths), }; } diff --git a/packages/memory-host-sdk/src/host/internal.ts b/packages/memory-host-sdk/src/host/internal.ts index 82d39ec9156..430e7886f11 100644 --- a/packages/memory-host-sdk/src/host/internal.ts +++ b/packages/memory-host-sdk/src/host/internal.ts @@ -29,6 +29,7 @@ import { resolveCanonicalRootMemoryFile, shouldSkipRootMemoryAuxiliaryPath, } from "./openclaw-runtime-memory.js"; +import { normalizeStringEntries, uniqueStrings } from "./string-utils.js"; export { hashText } from "./hash.js"; import { hashText } from "./hash.js"; @@ -89,14 +90,12 @@ export function normalizeExtraMemoryPaths(workspaceDir: string, extraPaths?: str if (!extraPaths?.length) { return []; } - const resolved = extraPaths - .map((value) => value.trim()) - .filter(Boolean) + const resolved = normalizeStringEntries(extraPaths) .map((value) => expandHomePath(value)) .map((value) => path.isAbsolute(value) ? path.resolve(value) : path.resolve(workspaceDir, value), ); - return Array.from(new Set(resolved)); + return uniqueStrings(resolved); } export function isMemoryPath(relPath: string): boolean { diff --git a/packages/memory-host-sdk/src/host/string-utils.ts b/packages/memory-host-sdk/src/host/string-utils.ts index 108f3eab3b1..117ad87b6e2 100644 --- a/packages/memory-host-sdk/src/host/string-utils.ts +++ b/packages/memory-host-sdk/src/host/string-utils.ts @@ -17,3 +17,11 @@ export function normalizeOptionalLowercaseString(value: unknown): string | undef export function normalizeLowercaseStringOrEmpty(value: unknown): string { return normalizeOptionalLowercaseString(value) ?? ""; } + +export function normalizeStringEntries(values: ReadonlyArray): string[] { + return values.map((value) => normalizeOptionalString(String(value)) ?? "").filter(Boolean); +} + +export function uniqueStrings(values: Iterable): string[] { + return [...new Set(values)]; +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 151fceba5f4..212c07b9fc4 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -569,7 +569,7 @@ export class Run { }, { timeoutMs: null }, ); - const record = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + const record = asRecord(raw); const status = runStatusFromWaitPayload(raw); const error = readOptionalString(record.error) ? { message: readOptionalString(record.error) ?? "run failed" } @@ -605,7 +605,7 @@ export class Session { const params: SessionSendParams = typeof input === "string" ? { key: this.key, message: input } : { ...input, key: this.key }; const raw = await this.client.request("sessions.send", params, { expectFinal: true }); - const record = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + const record = asRecord(raw); const runId = readOptionalString(record.runId); if (!runId) { throw new Error("sessions.send did not return a runId"); @@ -662,7 +662,7 @@ export class SessionsNamespace { async create(params: SessionCreateParams = {}): Promise { const raw = await this.client.request("sessions.create", params); - const record = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + const record = asRecord(raw); const key = readOptionalString(record.key) ?? readOptionalString(record.sessionKey) ?? params.key; if (!key) { @@ -693,7 +693,7 @@ export class RunsNamespace { expectFinal: false, timeoutMs: params.timeoutMs, }); - const record = typeof raw === "object" && raw !== null ? (raw as Record) : {}; + const record = asRecord(raw); const runId = readOptionalString(record.runId); if (!runId) { throw new Error("agent did not return a runId"); diff --git a/src/acp/control-plane/manager.runtime-controls.ts b/src/acp/control-plane/manager.runtime-controls.ts index 212f103a6ad..ff3faf791e1 100644 --- a/src/acp/control-plane/manager.runtime-controls.ts +++ b/src/acp/control-plane/manager.runtime-controls.ts @@ -1,3 +1,4 @@ +import { asNullableRecord } from "../../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { AcpRuntimeError, withAcpRuntimeErrorBoundary } from "../runtime/errors.js"; import type { @@ -18,12 +19,6 @@ import { const OPTIONAL_TIMEOUT_CONFIG_KEYS = new Set(["timeout", "timeout_seconds"]); -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function extractConfigOptionKeys(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -33,14 +28,14 @@ function extractConfigOptionKeys(value: unknown): string[] { if (typeof entry === "string") { return normalizeText(entry); } - const record = asRecord(entry); + const record = asNullableRecord(entry); return normalizeText(record?.id ?? record?.key); }) .filter(Boolean) as string[]; } function extractRuntimeStatusConfigOptionKeys(status: AcpRuntimeStatus | undefined): string[] { - const details = asRecord(status?.details); + const details = asNullableRecord(status?.details); return [ ...extractConfigOptionKeys(details?.configOptions), ...extractConfigOptionKeys(details?.config_options), diff --git a/src/acp/permission-relay.ts b/src/acp/permission-relay.ts index bf0873f4ab8..2d54b5d2627 100644 --- a/src/acp/permission-relay.ts +++ b/src/acp/permission-relay.ts @@ -3,6 +3,7 @@ import type { RequestPermissionRequest, RequestPermissionResponse, } from "@agentclientprotocol/sdk"; +import { normalizeOptionalString as readNonEmptyString } from "../shared/string-coerce.js"; export type GatewayExecApprovalDecision = "allow-once" | "allow-always" | "deny"; @@ -23,10 +24,6 @@ export type GatewayExecApprovalDetails = { const FALLBACK_EXEC_APPROVAL_DECISIONS = ["allow-once", "deny"] as const; -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function normalizeGatewayExecApprovalDecision( value: unknown, ): GatewayExecApprovalDecision | undefined { diff --git a/src/agents/accepted-session-spawn.ts b/src/agents/accepted-session-spawn.ts index 9dc64d49f4e..22d1565db7e 100644 --- a/src/agents/accepted-session-spawn.ts +++ b/src/agents/accepted-session-spawn.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord } from "../shared/record-coerce.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; export type AcceptedSessionSpawn = { @@ -5,14 +6,8 @@ export type AcceptedSessionSpawn = { childSessionKey: string; }; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - export function normalizeAcceptedSessionSpawnResult(result: unknown): AcceptedSessionSpawn | null { - const details = asRecord(asRecord(result)?.details); + const details = asOptionalRecord(asOptionalRecord(result)?.details); if (!details || details.status !== "accepted") { return null; } @@ -26,7 +21,7 @@ export function normalizeAcceptedSessionSpawnResult(result: unknown): AcceptedSe export function hasAcceptedSessionSpawn(acceptedSessionSpawns?: readonly unknown[]): boolean { return (acceptedSessionSpawns ?? []).some((spawn) => { - const record = asRecord(spawn); + const record = asOptionalRecord(spawn); if (!record) { return false; } diff --git a/src/agents/acp-spawn-parent-stream.ts b/src/agents/acp-spawn-parent-stream.ts index 1503bc7e7e1..b7cb5823568 100644 --- a/src/agents/acp-spawn-parent-stream.ts +++ b/src/agents/acp-spawn-parent-stream.ts @@ -12,6 +12,7 @@ import { requestHeartbeat } from "../infra/heartbeat-wake.js"; import { appendRegularFile } from "../infra/regular-file.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { normalizeAssistantPhase } from "../shared/chat-message-content.js"; +import { asFiniteNumber } from "../shared/number-coercion.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { recordTaskRunProgressByRunId } from "../tasks/detached-task-runtime.js"; import type { DeliveryContext } from "../utils/delivery-context.types.js"; @@ -37,10 +38,6 @@ function truncate(value: string, maxChars: number): string { return `${value.slice(0, maxChars - 1)}…`; } -function toFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -441,7 +438,7 @@ export function startAcpSpawnParentStreamRelay(params: { const phase = normalizeOptionalString(data?.phase); logEvent("acp", { phase: phase ?? "unknown", data: event.data }); if (phase === "prompt_submitted") { - const at = toFiniteNumber(data?.at) ?? Date.now(); + const at = asFiniteNumber(data?.at) ?? Date.now(); promptSubmittedAt ??= at; proxyEnvKeysAtPrompt = normalizeStringArray(data?.proxyEnvKeys); lastProgressAt = Date.now(); @@ -465,10 +462,10 @@ export function startAcpSpawnParentStreamRelay(params: { logEvent("lifecycle", { phase: phase ?? "unknown", data: event.data }); if (phase === "end") { flushPending(); - const startedAt = toFiniteNumber( + const startedAt = asFiniteNumber( (event.data as { startedAt?: unknown } | undefined)?.startedAt, ); - const endedAt = toFiniteNumber((event.data as { endedAt?: unknown } | undefined)?.endedAt); + const endedAt = asFiniteNumber((event.data as { endedAt?: unknown } | undefined)?.endedAt); const durationMs = startedAt != null && endedAt != null && endedAt >= startedAt ? endedAt - startedAt diff --git a/src/agents/api-key-rotation.ts b/src/agents/api-key-rotation.ts index 6a2feecd306..ef7ef74a9a9 100644 --- a/src/agents/api-key-rotation.ts +++ b/src/agents/api-key-rotation.ts @@ -7,6 +7,7 @@ import { shouldRetrySameKeyProviderOperation, type TransientProviderRetryConfig, } from "../provider-runtime/operation-retry.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { collectProviderApiKeys, isApiKeyRateLimitError } from "./live-auth-keys.js"; type ApiKeyRetryParams = { @@ -25,17 +26,7 @@ type ExecuteWithApiKeyRotationOptions = { }; function dedupeApiKeys(raw: string[]): string[] { - const seen = new Set(); - const keys: string[] = []; - for (const value of raw) { - const apiKey = value.trim(); - if (!apiKey || seen.has(apiKey)) { - continue; - } - seen.add(apiKey); - keys.push(apiKey); - } - return keys; + return normalizeUniqueStringEntries(raw); } export function collectProviderApiKeysForExecution(params: { diff --git a/src/agents/auth-health.ts b/src/agents/auth-health.ts index 23776786016..84e90d0af9d 100644 --- a/src/agents/auth-health.ts +++ b/src/agents/auth-health.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { DEFAULT_OAUTH_REFRESH_MARGIN_MS, type AuthCredentialReasonCode, @@ -210,7 +211,7 @@ export function buildAuthHealthSummary(params: { const now = Date.now(); const warnAfterMs = params.warnAfterMs ?? DEFAULT_OAUTH_WARN_MS; const providerFilter = params.providers - ? new Set(params.providers.map((p) => normalizeProviderId(p)).filter(Boolean)) + ? new Set(normalizeUniqueStringEntries(params.providers.map((p) => normalizeProviderId(p)))) : null; const profiles = Object.entries(params.store.profiles) diff --git a/src/agents/auth-profiles/external-cli-discovery.ts b/src/agents/auth-profiles/external-cli-discovery.ts index d8c0a48ec90..5f990d9eb4a 100644 --- a/src/agents/auth-profiles/external-cli-discovery.ts +++ b/src/agents/auth-profiles/external-cli-discovery.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeTrimmedStringList } from "../../shared/string-normalization.js"; import { resolveExternalCliAuthScopeFromConfig, type ExternalCliAuthScope, @@ -43,9 +44,7 @@ type ProviderSetDiscoveryParams = { }; function normalizeStringList(values: Iterable): string[] { - return [...values] - .map((value) => value?.trim()) - .filter((value): value is string => Boolean(value)); + return normalizeTrimmedStringList([...values]); } export function externalCliDiscoveryNone(params?: { diff --git a/src/agents/auth-profiles/legacy-oauth-sidecar.ts b/src/agents/auth-profiles/legacy-oauth-sidecar.ts index 16faa9f8e74..80d22cfeaff 100644 --- a/src/agents/auth-profiles/legacy-oauth-sidecar.ts +++ b/src/agents/auth-profiles/legacy-oauth-sidecar.ts @@ -5,6 +5,8 @@ import os from "node:os"; import path from "node:path"; import { resolveOAuthDir, resolveStateDir } from "../../config/paths.js"; import { loadJsonFile } from "../../infra/json-file.js"; +import { isRecord } from "../../shared/record-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { log } from "./constants.js"; const LEGACY_OAUTH_REF_SOURCE = "openclaw-credentials"; @@ -36,10 +38,6 @@ type LegacyOAuthEncryptedPayload = { ciphertext: string; }; -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } @@ -164,7 +162,7 @@ function isPathInsideOrEqual(parentDir: string, candidatePath: string): boolean } function uniquePaths(paths: Array): string[] { - return Array.from(new Set(paths.filter((entry): entry is string => Boolean(entry)))); + return uniqueStrings(paths.filter((entry): entry is string => Boolean(entry))); } function resolveLegacyOAuthSecretKeyFileCandidates(env: NodeJS.ProcessEnv): string[] { diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index 51537374934..c2f633a8f8b 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -2,6 +2,9 @@ import { createHash } from "node:crypto"; import { resolveOAuthPath } from "../../config/paths.js"; import { coerceSecretRef } from "../../config/types.secrets.js"; import { loadJsonFile } from "../../infra/json-file.js"; +import { isRecord } from "../../shared/record-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; +import { asBoolean } from "../../utils/boolean.js"; import { normalizeProviderId } from "../provider-id.js"; import { AUTH_STORE_VERSION, log } from "./constants.js"; import { @@ -47,10 +50,6 @@ const LEGACY_OAUTH_REF_PROVIDER = "openai-codex"; const runtimeLegacyOAuthSidecarCredentials = new WeakSet(); const runtimeLegacyOAuthSidecarMaterialFingerprints = new Map(); -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function normalizeOptionalCredentialString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -82,10 +81,6 @@ function buildLegacyOAuthSecretMaterialFingerprint( .digest("hex"); } -function normalizeOptionalCredentialBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - function normalizeExpiryField(value: unknown): number | undefined { if (value === undefined) { return undefined; @@ -126,7 +121,7 @@ function normalizeCommonCredentialFields(entry: Record): Record const normalized: Record = { provider: typeof entry.provider === "string" ? normalizeProviderId(entry.provider) : "", }; - const copyToAgents = normalizeOptionalCredentialBoolean(entry.copyToAgents); + const copyToAgents = asBoolean(entry.copyToAgents); if (copyToAgents !== undefined) { normalized.copyToAgents = copyToAgents; } @@ -416,7 +411,7 @@ function mergeRecord( } function dedupeMergedProfileOrder(profileIds: string[]): string[] { - return Array.from(new Set(profileIds)); + return uniqueStrings(profileIds); } function hasComparableOAuthIdentityConflict( diff --git a/src/agents/auth-profiles/profile-list.ts b/src/agents/auth-profiles/profile-list.ts index 3b1ce9aacf6..c91519db01b 100644 --- a/src/agents/auth-profiles/profile-list.ts +++ b/src/agents/auth-profiles/profile-list.ts @@ -1,8 +1,9 @@ +import { uniqueStrings } from "../../shared/string-normalization.js"; import { resolveProviderIdForAuth } from "../provider-auth-aliases.js"; import type { AuthProfileStore } from "./types.js"; export function dedupeProfileIds(profileIds: string[]): string[] { - return [...new Set(profileIds)]; + return uniqueStrings(profileIds); } export function listProfilesForProvider(store: AuthProfileStore, provider: string): string[] { diff --git a/src/agents/auth-profiles/state.ts b/src/agents/auth-profiles/state.ts index 3ea6125ac97..76650ae5418 100644 --- a/src/agents/auth-profiles/state.ts +++ b/src/agents/auth-profiles/state.ts @@ -1,6 +1,9 @@ import fs from "node:fs"; import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; +import { asFiniteNumber } from "../../shared/number-coercion.js"; +import { isRecord } from "../../shared/record-coerce.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { normalizeTrimmedStringList } from "../../shared/string-normalization.js"; import { normalizeProviderId } from "../provider-id.js"; import { AUTH_STORE_VERSION } from "./constants.js"; import { resolveAuthStatePath } from "./paths.js"; @@ -31,12 +34,8 @@ const AUTH_FAILURE_REASONS = new Set([ const AUTH_BLOCKED_REASONS = new Set(["subscription_limit"]); const AUTH_BLOCKED_SOURCES = new Set(["codex_rate_limits", "wham"]); -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function normalizeFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } function normalizeEnumValue(value: unknown, allowed: Set): T | undefined { @@ -76,7 +75,7 @@ function normalizeAuthProfileOrder(raw: unknown): AuthProfileState["order"] { if (!providerKey) { return acc; } - const list = value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean); + const list = normalizeTrimmedStringList(value); if (list.length > 0) { acc[providerKey] = list; } diff --git a/src/agents/bash-tools.exec-approval-request.ts b/src/agents/bash-tools.exec-approval-request.ts index eece585f914..e25d1b6df2a 100644 --- a/src/agents/bash-tools.exec-approval-request.ts +++ b/src/agents/bash-tools.exec-approval-request.ts @@ -10,7 +10,11 @@ import { POSIX_SHELL_WRAPPERS, resolveShellWrapperTransportArgv, } from "../infra/shell-wrapper-resolution.js"; -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { asFiniteNumber } from "../shared/number-coercion.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString as parseString, +} from "../shared/string-coerce.js"; import { DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS, DEFAULT_APPROVAL_TIMEOUT_MS, @@ -99,12 +103,8 @@ function parseDecision(value: unknown): ParsedDecision { return { present: true, value: typeof decision === "string" ? decision : null }; } -function parseString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function parseExpiresAtMs(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } export type ExecApprovalRegistration = { diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index 0c902f4abda..194af03a589 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -16,6 +16,8 @@ import { requiresExecApproval, } from "../infra/exec-approvals.js"; import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js"; +import { isRecord } from "../shared/record-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../utils/message-channel.js"; import { markBackgrounded, tail } from "./bash-process-registry.js"; import { @@ -213,10 +215,6 @@ function formatOutcomeExitLabel(outcome: { exitCode: number | null; timedOut: bo return outcome.timedOut ? "timeout" : `code ${outcome.exitCode ?? "?"}`; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function formatBytes(value: unknown): string | null { if (typeof value !== "number" || !Number.isFinite(value)) { return null; @@ -761,10 +759,10 @@ export async function processGatewayAllowlist( trigger: params.trigger, outcome, }); - const approvalFollowupText = [params.approvalFollowupText, dynamicFollowupText] - .map((text) => text?.trim()) - .filter(Boolean) - .join("\n\n"); + const approvalFollowupText = normalizeStringEntries([ + params.approvalFollowupText ?? "", + dynamicFollowupText ?? "", + ]).join("\n\n"); const summary = buildGatewayExecApprovalFollowupSummary({ approvalId, sessionId: run.session.id, diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 849267f2838..2aa01fd2a81 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -18,6 +18,7 @@ import { isDangerousHostInheritedEnvVarName } from "../infra/host-env-security.j import { findPathKey, mergePathPrepend, removePathPrepend } from "../infra/path-prepend.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { isSubagentSessionKey } from "../sessions/session-key-utils.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { ProcessSession } from "./bash-process-registry.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js"; @@ -306,10 +307,7 @@ export function applyShellPath(env: Record, shellPath?: string | if (!shellPath) { return; } - const entries = shellPath - .split(path.delimiter) - .map((part) => part.trim()) - .filter(Boolean); + const entries = normalizeStringEntries(shellPath.split(path.delimiter)); if (entries.length === 0) { return; } diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 08828f68903..829290b574b 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -26,6 +26,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import { splitShellArgs } from "../utils/shell-argv.js"; import { markBackgrounded } from "./bash-process-registry.js"; @@ -1190,14 +1191,10 @@ function rejectUnsafeControlShellCommand(command: string): void { const analysis = analyzeShellCommand({ command: rawCommand }); const candidates = analysis.ok ? analysis.segments.flatMap((segment) => buildCommandPayloadCandidates(segment.argv)) - : rawCommand - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .flatMap((line) => { - const argv = splitShellArgs(line); - return argv ? buildCommandPayloadCandidates(argv) : [line]; - }); + : normalizeStringEntries(rawCommand.split(/\r?\n/)).flatMap((line) => { + const argv = splitShellArgs(line); + return argv ? buildCommandPayloadCandidates(argv) : [line]; + }); for (const candidate of candidates) { if (parseExecApprovalShellCommand(candidate)) { throw new Error( diff --git a/src/agents/bootstrap-budget.ts b/src/agents/bootstrap-budget.ts index 85c43173301..39c29adce1b 100644 --- a/src/agents/bootstrap-budget.ts +++ b/src/agents/bootstrap-budget.ts @@ -1,5 +1,9 @@ import path from "node:path"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { + normalizeStringEntries, + normalizeUniqueStringEntries, +} from "../shared/string-normalization.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; @@ -73,20 +77,7 @@ function isAgentsBootstrapName(name: string | undefined): boolean { } function normalizeSeenSignatures(signatures?: string[]): string[] { - if (!Array.isArray(signatures) || signatures.length === 0) { - return []; - } - const seen = new Set(); - const result: string[] = []; - for (const signature of signatures) { - const value = normalizeOptionalString(signature) ?? ""; - if (!value || seen.has(value)) { - continue; - } - seen.add(value); - result.push(value); - } - return result; + return normalizeUniqueStringEntries(signatures); } function appendSeenSignature(signatures: string[], signature: string): string[] { @@ -345,7 +336,7 @@ export function appendBootstrapPromptWarning( preserveExactPrompt?: string; }, ): string { - const normalizedLines = (warningLines ?? []).map((line) => line.trim()).filter(Boolean); + const normalizedLines = normalizeStringEntries(warningLines); if (normalizedLines.length === 0) { return prompt; } diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index b7ceb378699..3f2b5bbe006 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -16,6 +16,7 @@ import type { } from "../channels/plugins/types.public.js"; import { normalizeAnyChannelId } from "../channels/registry.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; type ChannelAgentToolMeta = { channelId: string; @@ -128,9 +129,7 @@ export function resolveChannelMessageToolHints(params: { return []; } const cfg = params.cfg ?? ({} as OpenClawConfig); - return (resolve({ cfg, accountId: params.accountId }) ?? []) - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries(resolve({ cfg, accountId: params.accountId })); } export function resolveChannelPromptCapabilities(params: { @@ -154,7 +153,7 @@ export function resolveChannelPromptCapabilities(params: { } function normalizePromptCapabilities(capabilities?: readonly string[] | null): string[] { - return (capabilities ?? []).map((entry) => entry.trim()).filter(Boolean); + return normalizeStringEntries(capabilities ?? []); } export function resolveChannelReactionGuidance(params: { diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index 4ac61ec25fa..5e2882c3633 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -13,6 +13,7 @@ import type { PluginTextTransforms, } from "../plugins/types.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { normalizeProviderId } from "./model-selection.js"; import { mergePluginTextTransforms } from "./plugin-text-transforms.js"; @@ -159,7 +160,7 @@ function mergeBackendConfig(base: CliBackendConfig, override?: CliBackendConfig) args: override.args ?? base.args, env: { ...base.env, ...override.env }, modelAliases: { ...base.modelAliases, ...override.modelAliases }, - clearEnv: Array.from(new Set([...(base.clearEnv ?? []), ...(override.clearEnv ?? [])])), + clearEnv: uniqueStrings([...(base.clearEnv ?? []), ...(override.clearEnv ?? [])]), sessionIdFields: override.sessionIdFields ?? base.sessionIdFields, sessionArgs: override.sessionArgs ?? base.sessionArgs, resumeArgs: override.resumeArgs ?? base.resumeArgs, diff --git a/src/agents/cli-output.ts b/src/agents/cli-output.ts index 2d5be32016b..07101af5b00 100644 --- a/src/agents/cli-output.ts +++ b/src/agents/cli-output.ts @@ -1,6 +1,7 @@ import type { CliBackendConfig } from "../config/types.js"; import { extractBalancedJsonFragments } from "../shared/balanced-json.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { isRecord } from "../utils.js"; type CliUsage = { @@ -510,10 +511,7 @@ export function parseCliJsonl( backend: CliBackendConfig, providerId: string, ): CliOutput | null { - const lines = raw - .split(/\r?\n/g) - .map((line) => line.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(raw.split(/\r?\n/g)); if (lines.length === 0) { return null; } diff --git a/src/agents/cli-runner/bundle-mcp-adapter-shared.ts b/src/agents/cli-runner/bundle-mcp-adapter-shared.ts index 3ff7b53171b..dfb923f3ccb 100644 --- a/src/agents/cli-runner/bundle-mcp-adapter-shared.ts +++ b/src/agents/cli-runner/bundle-mcp-adapter-shared.ts @@ -1,8 +1,6 @@ import type { BundleMcpServerConfig } from "../../plugins/bundle-mcp.js"; - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +import { isRecord } from "../../shared/record-coerce.js"; +export { isRecord } from "../../shared/record-coerce.js"; function normalizeStringArray(value: unknown): string[] | undefined { return Array.isArray(value) && value.every((entry) => typeof entry === "string") diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index c1145c178c5..c952d4332ac 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import type { ReplyBackendHandle } from "../../auto-reply/reply/reply-run-registry.js"; import type { CliBackendConfig } from "../../config/types.js"; +import { isRecord } from "../../shared/record-coerce.js"; import { loadExecApprovals, maxAsk, @@ -468,10 +469,6 @@ function parseSessionId(parsed: Record): string | undefined { return sessionId || undefined; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function extractClaudeEffectivePermissionMode(argv: readonly string[]): string | undefined { // Scan from the end so the last-set --permission-mode wins, matching how // CLI arg precedence works in practice and how diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index a94a836d317..3b28434c293 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -20,6 +20,7 @@ import type { import { buildAgentHookContextChannelFields } from "../../plugins/hook-agent-context.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js"; import { externalCliDiscoveryForProviderAuth } from "../auth-profiles/external-cli-discovery.js"; import { loadAuthProfileStoreForRuntime } from "../auth-profiles/store.js"; @@ -284,7 +285,7 @@ export async function prepareCliRunContext( backend: { ...preparedBackend.backend, ...(preparedBackendClearEnv.length > 0 - ? { clearEnv: Array.from(new Set(preparedBackendClearEnv)) } + ? { clearEnv: uniqueStrings(preparedBackendClearEnv) } : {}), }, ...(preparedBackendEnv ? { env: preparedBackendEnv } : {}), diff --git a/src/agents/cli-runner/toml-inline.ts b/src/agents/cli-runner/toml-inline.ts index d1212c448f2..ac7d5fec234 100644 --- a/src/agents/cli-runner/toml-inline.ts +++ b/src/agents/cli-runner/toml-inline.ts @@ -1,3 +1,5 @@ +import { isRecord } from "../../shared/record-coerce.js"; + function escapeTomlString(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); } @@ -6,10 +8,6 @@ function formatTomlKey(key: string): string { return /^[A-Za-z0-9_-]+$/.test(key) ? key : `"${escapeTomlString(key)}"`; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - export function serializeTomlInlineValue(value: unknown): string { if (typeof value === "string") { return `"${escapeTomlString(value)}"`; diff --git a/src/agents/code-mode.ts b/src/agents/code-mode.ts index ffbbc78d4c0..5a912465577 100644 --- a/src/agents/code-mode.ts +++ b/src/agents/code-mode.ts @@ -6,6 +6,8 @@ import type { AgentToolUpdateCallback } from "@earendil-works/pi-agent-core"; import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isRecord } from "../shared/record-coerce.js"; +import { uniqueValues } from "../shared/string-normalization.js"; import { resolveAgentConfig } from "./agent-scope-config.js"; import { CODE_MODE_EXEC_TOOL_NAME, @@ -126,10 +128,6 @@ const activeRuns = new Map(); const resumingRunIds = new Set(); let typescriptRuntimePromise: Promise | null = null; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function normalizeCodeModeRawConfig(value: unknown): Record | undefined { const codeMode = value; if (codeMode === true) { @@ -170,7 +168,7 @@ function readLanguages(value: unknown): CodeModeLanguage[] { const languages = value.filter( (entry): entry is CodeModeLanguage => entry === "javascript" || entry === "typescript", ); - return languages.length > 0 ? [...new Set(languages)] : ["javascript", "typescript"]; + return languages.length > 0 ? uniqueValues(languages) : ["javascript", "typescript"]; } export function resolveCodeModeConfig(config?: OpenClawConfig, agentId?: string): CodeModeConfig { diff --git a/src/agents/codex-native-web-search.shared.ts b/src/agents/codex-native-web-search.shared.ts index c65edf86c62..77a4ce3f8ea 100644 --- a/src/agents/codex-native-web-search.shared.ts +++ b/src/agents/codex-native-web-search.shared.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeUniqueTrimmedStringList } from "../shared/string-normalization.js"; import { isRecord } from "../utils.js"; export type CodexNativeSearchMode = "cached" | "live"; @@ -20,16 +21,7 @@ export type ResolvedCodexNativeWebSearchConfig = { }; function normalizeAllowedDomains(value: unknown): string[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const deduped = [ - ...new Set( - value - .map((entry) => (typeof entry === "string" ? entry.trim() : null)) - .filter((entry): entry is string => Boolean(entry)), - ), - ]; + const deduped = normalizeUniqueTrimmedStringList(value); return deduped.length > 0 ? deduped : undefined; } diff --git a/src/agents/generated-attachments.ts b/src/agents/generated-attachments.ts index 01fef25efec..467af70d3f6 100644 --- a/src/agents/generated-attachments.ts +++ b/src/agents/generated-attachments.ts @@ -1,5 +1,6 @@ import { basenameFromAnyPath } from "../media/file-name.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; export type AgentGeneratedAttachment = { type?: "image" | "audio" | "video" | "file"; @@ -22,20 +23,9 @@ export function generatedAttachmentReference( export function mediaUrlsFromGeneratedAttachments( attachments: readonly AgentGeneratedAttachment[] | undefined, ): string[] { - if (!attachments?.length) { - return []; - } - const urls: string[] = []; - const seen = new Set(); - for (const attachment of attachments) { - const url = generatedAttachmentReference(attachment); - if (!url || seen.has(url)) { - continue; - } - seen.add(url); - urls.push(url); - } - return urls; + return uniqueStrings( + attachments?.flatMap((attachment) => generatedAttachmentReference(attachment) ?? []) ?? [], + ); } export function nameFromGeneratedAttachment( diff --git a/src/agents/harness/lifecycle-hook-helpers.ts b/src/agents/harness/lifecycle-hook-helpers.ts index d971ea13c59..a2b2547aefb 100644 --- a/src/agents/harness/lifecycle-hook-helpers.ts +++ b/src/agents/harness/lifecycle-hook-helpers.ts @@ -10,6 +10,7 @@ import type { } from "../../plugins/hook-types.js"; import type { VoidHookRunOptions } from "../../plugins/hooks.js"; import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; +import { normalizeOptionalString as normalizeTrimmedString } from "../../shared/string-coerce.js"; import { buildAgentHookContext, type AgentHarnessHookContext } from "./hook-context.js"; const log = createSubsystemLogger("agents/harness"); @@ -227,11 +228,3 @@ function isBeforeAgentFinalizeRetry( ): value is NonNullable { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } - -function normalizeTrimmedString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} diff --git a/src/agents/harness/native-hook-relay.ts b/src/agents/harness/native-hook-relay.ts index 1f749d38bd1..1f3a77c8c18 100644 --- a/src/agents/harness/native-hook-relay.ts +++ b/src/agents/harness/native-hook-relay.ts @@ -15,6 +15,8 @@ import { privateFileStoreSync } from "../../infra/private-file-store.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { hasGlobalHooks } from "../../plugins/hook-runner-global.js"; import { PluginApprovalResolutions } from "../../plugins/types.js"; +import { uniqueValues } from "../../shared/string-normalization.js"; +import { asBoolean } from "../../utils/boolean.js"; import { runBeforeToolCallHook } from "../pi-tools.before-tool-call.js"; import { stableStringify } from "../stable-stringify.js"; import { normalizeToolName } from "../tool-policy.js"; @@ -1408,7 +1410,7 @@ function normalizeCodexHookMetadata(rawPayload: JsonValue): NativeHookRelayInvoc if (permissionMode) { metadata.permissionMode = permissionMode; } - const stopHookActive = readOptionalBoolean(payload.stop_hook_active); + const stopHookActive = asBoolean(payload.stop_hook_active); if (stopHookActive !== undefined) { metadata.stopHookActive = stopHookActive; } @@ -1684,7 +1686,7 @@ function normalizeAllowedEvents( if (!events?.length) { return NATIVE_HOOK_RELAY_EVENTS; } - return [...new Set(events)]; + return uniqueValues(events); } function normalizePositiveInteger(value: number | undefined, fallback: number): number { @@ -1737,10 +1739,6 @@ function readOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function readOptionalBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - function isJsonValue(value: unknown): value is JsonValue { const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; let nodes = 0; diff --git a/src/agents/harness/runtime-plugin.ts b/src/agents/harness/runtime-plugin.ts index 61cba143a56..36b5286707e 100644 --- a/src/agents/harness/runtime-plugin.ts +++ b/src/agents/harness/runtime-plugin.ts @@ -5,22 +5,9 @@ import { resolveBundledProviderCompatPluginIds, resolveOwningPluginIdsForProvider, } from "../../plugins/providers.js"; +import { normalizeUniqueStringEntries } from "../../shared/string-normalization.js"; import { resolveAgentHarnessPolicy } from "./policy.js"; -function dedupePluginIds(values: readonly string[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const value of values) { - const pluginId = value.trim(); - if (!pluginId || seen.has(pluginId)) { - continue; - } - seen.add(pluginId); - result.push(pluginId); - } - return result; -} - function restrictiveAllowlistOmitsPlugin(config: OpenClawConfig | undefined, pluginId: string) { if (config?.plugins?.bundledDiscovery === "compat") { return false; @@ -37,7 +24,7 @@ function resolveCodexHarnessPluginIds(params: { if (restrictiveAllowlistOmitsPlugin(params.config, "codex")) { return ["codex"]; } - const providerOwnerPluginIds = dedupePluginIds( + const providerOwnerPluginIds = normalizeUniqueStringEntries( resolveOwningPluginIdsForProvider({ provider: params.provider, config: params.config, @@ -47,7 +34,7 @@ function resolveCodexHarnessPluginIds(params: { if (providerOwnerPluginIds.length === 0) { return ["codex"]; } - const safeProviderOwnerPluginIds = dedupePluginIds([ + const safeProviderOwnerPluginIds = normalizeUniqueStringEntries([ ...resolveBundledProviderCompatPluginIds({ config: params.config, workspaceDir: params.workspaceDir, @@ -59,7 +46,7 @@ function resolveCodexHarnessPluginIds(params: { workspaceDir: params.workspaceDir, }), ]); - return dedupePluginIds([ + return normalizeUniqueStringEntries([ "codex", ...providerOwnerPluginIds.filter( (pluginId) => pluginId !== "codex" && safeProviderOwnerPluginIds.includes(pluginId), @@ -78,7 +65,10 @@ function withRuntimePluginIdsAllowed(params: { if (restrictiveAllowlistOmitsPlugin(params.config, params.requiredPluginId)) { return params.config; } - const allow = dedupePluginIds([...(params.config?.plugins?.allow ?? []), ...params.pluginIds]); + const allow = normalizeUniqueStringEntries([ + ...(params.config?.plugins?.allow ?? []), + ...params.pluginIds, + ]); return { ...params.config, plugins: { diff --git a/src/agents/harness/tool-result-middleware.ts b/src/agents/harness/tool-result-middleware.ts index d464dced603..113c044e7d3 100644 --- a/src/agents/harness/tool-result-middleware.ts +++ b/src/agents/harness/tool-result-middleware.ts @@ -6,6 +6,7 @@ import type { OpenClawAgentToolResult, } from "../../plugins/agent-tool-result-middleware-types.js"; import { createLazyPromiseLoader } from "../../shared/lazy-promise.js"; +import { isRecord } from "../../shared/record-coerce.js"; import { truncateUtf16Safe } from "../../utils.js"; const log = createSubsystemLogger("agents/harness"); @@ -21,10 +22,6 @@ const NESTED_TOOL_RESULT_BLOCK_TYPES = new Set(["toolresult", "tool_result"]); type MiddlewareContentBlock = OpenClawAgentToolResult["content"][number]; type MiddlewareContentCoerceState = { depth: number; seen: Set }; -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - function isValidMiddlewareContentBlock(value: unknown): boolean { if (!isRecord(value) || typeof value.type !== "string") { return false; diff --git a/src/agents/inherited-tool-deny.ts b/src/agents/inherited-tool-deny.ts index 249c2c9be90..8ecffe23328 100644 --- a/src/agents/inherited-tool-deny.ts +++ b/src/agents/inherited-tool-deny.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "../shared/string-normalization.js"; import { isToolAllowedByPolicyName } from "./tool-policy-match.js"; import { normalizeToolName } from "./tool-policy-shared.js"; @@ -31,20 +32,12 @@ export function normalizeInheritedToolDenylist(value: unknown): string[] { if (!Array.isArray(value)) { return []; } - const seen = new Set(); - const result: string[] = []; - for (const entry of value) { - if (typeof entry !== "string") { - continue; - } - const normalized = normalizeToolName(entry); - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - result.push(normalized); - } - return result; + return uniqueStrings( + value.flatMap((entry) => { + const normalized = typeof entry === "string" ? normalizeToolName(entry) : ""; + return normalized ? [normalized] : []; + }), + ); } export function inheritedToolDenyPatch(value: unknown): { inheritedToolDeny?: string[] } { diff --git a/src/agents/live-auth-keys.ts b/src/agents/live-auth-keys.ts index 795a60b9663..a1f0dea35f9 100644 --- a/src/agents/live-auth-keys.ts +++ b/src/agents/live-auth-keys.ts @@ -3,6 +3,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { normalizeProviderId } from "./model-selection.js"; const KEY_SPLIT_RE = /[\s,;]+/g; @@ -57,10 +58,7 @@ function parseKeyList(raw?: string | null): string[] { if (!raw) { return []; } - return raw - .split(KEY_SPLIT_RE) - .map((value) => value.trim()) - .filter(Boolean); + return normalizeStringEntries(raw.split(KEY_SPLIT_RE)); } function collectEnvPrefixedKeys(prefix: string, env: NodeJS.ProcessEnv): string[] { diff --git a/src/agents/live-cache-test-support.ts b/src/agents/live-cache-test-support.ts index 7f0c5bcec92..5d0a30c5f26 100644 --- a/src/agents/live-cache-test-support.ts +++ b/src/agents/live-cache-test-support.ts @@ -7,6 +7,7 @@ import { } from "@earendil-works/pi-ai"; import { getRuntimeConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { resolveDefaultAgentDir } from "./agent-scope.js"; import { collectProviderApiKeys } from "./live-auth-keys.js"; import { isLiveTestEnabled } from "./live-test-helpers.js"; @@ -128,11 +129,9 @@ export function buildStableCachePrefix(tag: string, sections = 160): string { } export function extractAssistantText(message: AssistantMessage): string { - return message.content - .filter((block) => block.type === "text") - .map((block) => block.text.trim()) - .filter(Boolean) - .join(" "); + return normalizeStringEntries( + message.content.filter((block) => block.type === "text").map((block) => block.text), + ).join(" "); } export function buildAssistantHistoryTurn( diff --git a/src/agents/live-model-turn-probes.ts b/src/agents/live-model-turn-probes.ts index 03e3caff110..2ae4458ebec 100644 --- a/src/agents/live-model-turn-probes.ts +++ b/src/agents/live-model-turn-probes.ts @@ -1,4 +1,5 @@ import type { Api, AssistantMessage, Context, Model } from "@earendil-works/pi-ai"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; export const LIVE_MODEL_FILE_PROBE_TOKEN = "opal"; @@ -60,11 +61,9 @@ export function isLiveModelProbeEnabled( } export function extractAssistantText(message: Pick): string { - return message.content - .filter((block) => block.type === "text") - .map((block) => block.text.trim()) - .filter(Boolean) - .join(" "); + return normalizeStringEntries( + message.content.filter((block) => block.type === "text").map((block) => block.text), + ).join(" "); } export function modelSupportsImageInput(model: Pick, "input">): boolean { diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index 42adc2ace68..b3fdb79bfa6 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -9,6 +9,7 @@ import { type MemoryMultimodalSettings, } from "../memory-host-sdk/multimodal.js"; import { getMemoryEmbeddingProvider } from "../plugins/memory-embedding-providers.js"; +import { normalizeStringEntries, uniqueStrings } from "../shared/string-normalization.js"; import { clampInt, clampNumber, resolveUserPath } from "../utils.js"; import { resolveAgentConfig } from "./agent-scope.js"; import { findNormalizedProviderValue, normalizeProviderId } from "./provider-id.js"; @@ -239,10 +240,11 @@ function mergeConfig( contextSize: overrides?.local?.contextSize ?? defaults?.local?.contextSize, }; const sources = normalizeSources(overrides?.sources ?? defaults?.sources, sessionMemory); - const rawPaths = [...(defaults?.extraPaths ?? []), ...(overrides?.extraPaths ?? [])] - .map((value) => value.trim()) - .filter(Boolean); - const extraPaths = Array.from(new Set(rawPaths)); + const rawPaths = normalizeStringEntries([ + ...(defaults?.extraPaths ?? []), + ...(overrides?.extraPaths ?? []), + ]); + const extraPaths = uniqueStrings(rawPaths); const multimodal = normalizeMemoryMultimodalSettings({ enabled: overrides?.multimodal?.enabled ?? defaults?.multimodal?.enabled, modalities: overrides?.multimodal?.modalities ?? defaults?.multimodal?.modalities, diff --git a/src/agents/model-auth-env.ts b/src/agents/model-auth-env.ts index 2c637156de4..05e833d2c2b 100644 --- a/src/agents/model-auth-env.ts +++ b/src/agents/model-auth-env.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getShellEnvAppliedKeys } from "../infra/shell-env.js"; import { resolvePluginSetupProvider } from "../plugins/setup-registry.js"; import type { ProviderAuthEvidence } from "../secrets/provider-env-vars.js"; +import { normalizeOptionalString as normalizeOptionalPathInput } from "../shared/string-coerce.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { resolveProviderEnvApiKeyCandidates, @@ -40,14 +41,6 @@ function expandAuthEvidencePath(rawPath: string, env: NodeJS.ProcessEnv): string return trimmed.replaceAll("${HOME}", homeDir).replaceAll("${APPDATA}", appDataDir ?? ""); } -function normalizeOptionalPathInput(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function hasRequiredAuthEvidenceEnv( evidence: ProviderAuthEvidence, env: NodeJS.ProcessEnv, diff --git a/src/agents/model-auth-label.ts b/src/agents/model-auth-label.ts index 287872685c2..d82c1ce47d6 100644 --- a/src/agents/model-auth-label.ts +++ b/src/agents/model-auth-label.ts @@ -1,5 +1,6 @@ import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { externalCliDiscoveryForProviderAuth, ensureAuthProfileStore, @@ -41,23 +42,19 @@ export function resolveModelAuthLabel(params: { }), }); const profileOverride = params.sessionEntry?.authProfileOverride?.trim(); - const acceptedProviderKeys = [ - ...new Set( - [...(params.acceptedProviderIds ?? []).map(normalizeProviderId), providerKey].filter(Boolean), + const acceptedProviderKeys = uniqueStrings( + [...(params.acceptedProviderIds ?? []).map(normalizeProviderId), providerKey].filter(Boolean), + ); + const order = uniqueStrings( + acceptedProviderKeys.flatMap((acceptedProvider) => + resolveAuthProfileOrder({ + cfg: params.cfg, + store, + provider: acceptedProvider, + preferredProfile: profileOverride, + }), ), - ]; - const order = [ - ...new Set( - acceptedProviderKeys.flatMap((acceptedProvider) => - resolveAuthProfileOrder({ - cfg: params.cfg, - store, - provider: acceptedProvider, - preferredProfile: profileOverride, - }), - ), - ), - ]; + ); const candidates = [profileOverride, ...order].filter(Boolean) as string[]; for (const profileId of candidates) { diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts index c592caad065..9884b83d99d 100644 --- a/src/agents/model-auth-markers.ts +++ b/src/agents/model-auth-markers.ts @@ -1,5 +1,6 @@ import type { SecretRefSource } from "../config/types.secrets.js"; import { listOpenClawPluginManifestMetadata } from "../plugins/manifest-metadata-scan.js"; +import { normalizeTrimmedStringList, uniqueStrings } from "../shared/string-normalization.js"; import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js"; /** @deprecated MiniMax provider-owned marker; do not use from third-party plugins. */ @@ -37,13 +38,6 @@ const LEGACY_ENV_API_KEY_MARKERS = [ "MINIMAX_CODE_PLAN_KEY", ]; -function normalizeStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); -} - function listKnownEnvApiKeyMarkers(): Set { knownEnvApiKeyMarkersCache ??= new Set([ ...listKnownProviderEnvApiKeyNames(), @@ -54,16 +48,14 @@ function listKnownEnvApiKeyMarkers(): Set { } export function listKnownNonSecretApiKeyMarkers(): string[] { - knownNonSecretApiKeyMarkersCache ??= [ - ...new Set([ - ...CORE_NON_SECRET_API_KEY_MARKERS, - ...listOpenClawPluginManifestMetadata().flatMap((plugin) => - plugin.origin === "bundled" - ? normalizeStringList(plugin.manifest.nonSecretAuthMarkers) - : [], - ), - ]), - ]; + knownNonSecretApiKeyMarkersCache ??= uniqueStrings([ + ...CORE_NON_SECRET_API_KEY_MARKERS, + ...listOpenClawPluginManifestMetadata().flatMap((plugin) => + plugin.origin === "bundled" + ? normalizeTrimmedStringList(plugin.manifest.nonSecretAuthMarkers) + : [], + ), + ]); return [...knownNonSecretApiKeyMarkersCache]; } diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index f7a0d6d7a4c..616cb7e2da1 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -19,6 +19,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { resolveDefaultAgentDir } from "./agent-scope-config.js"; import { @@ -399,7 +400,7 @@ function listProviderSyntheticAuthRefs(params: { if (providerConfig?.api) { refs.push(providerConfig.api); } - return [...new Set(refs.map((ref) => normalizeProviderId(ref)).filter(Boolean))]; + return normalizeUniqueStringEntries(refs.map((ref) => normalizeProviderId(ref))); } function shouldResolvePluginSyntheticAuth(params: { @@ -413,7 +414,7 @@ function shouldResolvePluginSyntheticAuth(params: { return true; } const eligibleRefs = new Set( - syntheticAuthProviderRefs.map((ref) => normalizeProviderId(ref)).filter(Boolean), + normalizeUniqueStringEntries(syntheticAuthProviderRefs.map((ref) => normalizeProviderId(ref))), ); if (eligibleRefs.size === 0) { return false; diff --git a/src/agents/model-catalog-scope.ts b/src/agents/model-catalog-scope.ts index e1c57f9e552..fdef170f45d 100644 --- a/src/agents/model-catalog-scope.ts +++ b/src/agents/model-catalog-scope.ts @@ -1,15 +1,9 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeUniqueSingleOrTrimmedStringList } from "../shared/string-normalization.js"; import { findNormalizedProviderValue, normalizeProviderId } from "./provider-id.js"; function dedupeCatalogScopeRefs(values: Array): string[] { - const refs = new Set(); - for (const value of values) { - const trimmed = value?.trim(); - if (trimmed) { - refs.add(trimmed); - } - } - return [...refs]; + return normalizeUniqueSingleOrTrimmedStringList(values); } function providerFromModelRef(value: string | undefined): string | undefined { diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index e57f8665e06..1930cd78dee 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -14,6 +14,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { normalizeStringEntries, uniqueStrings } from "../shared/string-normalization.js"; import { normalizeProviderId } from "./provider-id.js"; const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; @@ -221,10 +222,9 @@ async function fetchOpenRouterModels( : null; const supportedParameters = Array.isArray(obj.supported_parameters) - ? obj.supported_parameters - .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean) + ? normalizeStringEntries( + obj.supported_parameters.filter((value) => typeof value === "string"), + ) : []; const supportedParametersCount = supportedParameters.length; @@ -343,7 +343,7 @@ function ensureImageInput(model: OpenAIModel): OpenAIModel { } return { ...model, - input: Array.from(new Set([...(model.input ?? []), "image"])), + input: uniqueStrings([...(model.input ?? []), "image"]) as OpenAIModel["input"], }; } diff --git a/src/agents/models-config.providers.implicit.ts b/src/agents/models-config.providers.implicit.ts index 7b42089fcfb..4041e85f534 100644 --- a/src/agents/models-config.providers.implicit.ts +++ b/src/agents/models-config.providers.implicit.ts @@ -10,6 +10,7 @@ import { runProviderStaticCatalog, } from "../plugins/provider-discovery.js"; import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js"; +import { normalizeStringEntries, uniqueStrings } from "../shared/string-normalization.js"; import { ensureAuthProfileStore } from "./auth-profiles/store.js"; import { isNonSecretApiKeyMarker, @@ -84,15 +85,12 @@ function resolveProviderDiscoveryFilter(params: { const { config, workspaceDir, env } = params; const testRaw = env.OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS?.trim(); if (testRaw) { - const ids = testRaw - .split(",") - .map((value) => value.trim()) - .filter(Boolean); - return ids.length > 0 ? [...new Set(ids)] : undefined; + const ids = normalizeStringEntries(testRaw.split(",")); + return ids.length > 0 ? uniqueStrings(ids) : undefined; } const scopedProviderIds = params.providerIds - ?.map((value) => value.trim()) - .filter((value) => value.length > 0); + ? normalizeStringEntries([...params.providerIds]) + : undefined; if (scopedProviderIds) { return resolveProviderPluginScopeFromProviderIds({ providerIds: scopedProviderIds, @@ -114,10 +112,7 @@ function resolveProviderDiscoveryFilter(params: { if (rawValues.length === 0) { return undefined; } - const ids = rawValues - .flatMap((value) => value.split(",")) - .map((value) => value.trim()) - .filter(Boolean); + const ids = normalizeStringEntries(rawValues.flatMap((value) => value.split(","))); if (ids.length === 0) { return undefined; } diff --git a/src/agents/openai-reasoning-effort.ts b/src/agents/openai-reasoning-effort.ts index 10d6734e271..662225bdd48 100644 --- a/src/agents/openai-reasoning-effort.ts +++ b/src/agents/openai-reasoning-effort.ts @@ -1,4 +1,5 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { normalizeStringEntries, uniqueStrings } from "../shared/string-normalization.js"; export type OpenAIReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; @@ -46,14 +47,9 @@ function readCompatReasoningEfforts(compat: unknown): OpenAIApiReasoningEffort[] if (!Array.isArray(raw)) { return undefined; } - const supported = [ - ...new Set( - raw - .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean), - ), - ]; + const supported = uniqueStrings( + normalizeStringEntries(raw.filter((value) => typeof value === "string")), + ); return supported.length > 0 ? supported : undefined; } diff --git a/src/agents/openai-responses-payload-policy.ts b/src/agents/openai-responses-payload-policy.ts index d9cc7c70545..187ae5b7bf8 100644 --- a/src/agents/openai-responses-payload-policy.ts +++ b/src/agents/openai-responses-payload-policy.ts @@ -1,4 +1,5 @@ import { readStringValue } from "../shared/string-coerce.js"; +import { asBoolean } from "../utils/boolean.js"; import { supportsOpenAIReasoningEffort } from "./openai-reasoning-effort.js"; type OpenAIResponsesPayloadModel = { @@ -212,8 +213,7 @@ function readCompatPayloadBoolean( if (!compat || typeof compat !== "object") { return undefined; } - const value = (compat as Record)[key]; - return typeof value === "boolean" ? value : undefined; + return asBoolean((compat as Record)[key]); } function resolveOpenAIResponsesPayloadCapabilities( diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index e4334dd34e8..f14bab4e80f 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -29,6 +29,8 @@ import { redactSensitiveText } from "../logging/redact.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; import { resolveProviderTransportTurnStateWithPlugin } from "../plugins/provider-runtime.js"; +import { isRecord } from "../shared/record-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { CHARS_PER_TOKEN_ESTIMATE, estimateStringChars } from "../utils/cjk-chars.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js"; import { createDeepSeekTextFilter } from "./deepseek-text-filter.js"; @@ -1860,10 +1862,6 @@ function resolveOpenAIReasoningEffort( ) as OpenAIApiReasoningEffort; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function hasResponsesWebSearchTool(tools: unknown): boolean { if (!Array.isArray(tools)) { return false; @@ -3400,7 +3398,7 @@ function getReasoningContentReplayModelIdCandidates(modelId: unknown): string[] if (colonParts.length > 1) { candidates.push(colonParts[0] ?? "", colonParts[colonParts.length - 1] ?? ""); } - return [...new Set(candidates.filter(Boolean))]; + return uniqueStrings(candidates.filter(Boolean)); } function shouldPreserveReasoningContentReplay( diff --git a/src/agents/openclaw-tools.media-factory-plan.ts b/src/agents/openclaw-tools.media-factory-plan.ts index 384cd9307cf..9690fe9e02f 100644 --- a/src/agents/openclaw-tools.media-factory-plan.ts +++ b/src/agents/openclaw-tools.media-factory-plan.ts @@ -5,6 +5,7 @@ import { import type { AgentModelConfig } from "../config/types.agents-shared.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { listProfilesForProvider } from "./auth-profiles/profile-list.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; import { isToolAllowedByPolicyName } from "./tool-policy-match.js"; @@ -80,7 +81,7 @@ export function mergeFactoryPolicyList( ...lists: Array ): string[] | undefined { const merged = lists.flatMap((list) => (Array.isArray(list) ? list : [])); - return merged.length > 0 ? Array.from(new Set(merged)) : undefined; + return merged.length > 0 ? uniqueStrings(merged) : undefined; } function mergeBuiltInFactoryAllowlist(...lists: Array): string[] | undefined { @@ -95,7 +96,7 @@ function mergeBuiltInFactoryAllowlist(...lists: Array): st const withoutDefaultPluginMarker = allowlist.filter( (entry) => typeof entry !== "string" || entry.trim() !== DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, ); - return Array.from(new Set(["*", ...withoutDefaultPluginMarker])); + return uniqueStrings(["*", ...withoutDefaultPluginMarker]); } export function resolveImageToolFactoryAvailable(params: { diff --git a/src/agents/openclaw-tools.registration.ts b/src/agents/openclaw-tools.registration.ts index f907d738d73..35c421c23db 100644 --- a/src/agents/openclaw-tools.registration.ts +++ b/src/agents/openclaw-tools.registration.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { isStrictAgenticExecutionContractActive } from "./execution-contract.js"; import { isToolAllowedByPolicyName } from "./tool-policy-match.js"; import type { AnyAgentTool } from "./tools/common.js"; @@ -31,7 +32,7 @@ export function isUpdatePlanToolEnabledForOpenClawTools(params: { function mergeOpenClawToolPolicyList(...lists: Array): string[] | undefined { const merged = lists.flatMap((list) => (Array.isArray(list) ? list : [])); - return merged.length > 0 ? Array.from(new Set(merged)) : undefined; + return merged.length > 0 ? uniqueStrings(merged) : undefined; } function isToolExplicitlyAllowedByOpenClawToolPolicy(params: { diff --git a/src/agents/pi-embedded-helpers/thinking.ts b/src/agents/pi-embedded-helpers/thinking.ts index 3f4f37b0d05..367e1f038cd 100644 --- a/src/agents/pi-embedded-helpers/thinking.ts +++ b/src/agents/pi-embedded-helpers/thinking.ts @@ -1,4 +1,5 @@ import { normalizeThinkLevel, type ThinkLevel } from "../../auto-reply/thinking.js"; +import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { isReasoningConstraintErrorMessage } from "./errors.js"; function extractSupportedValues(raw: string): string[] { @@ -12,12 +13,11 @@ function extractSupportedValues(raw: string): string[] { entry[1]?.trim(), ); if (quoted.length > 0) { - return quoted.filter((entry): entry is string => Boolean(entry)); + return normalizeStringEntries(quoted.filter((entry): entry is string => Boolean(entry))); } - return fragment - .split(/,|\band\b/gi) - .map((entry) => entry.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, "").trim()) - .filter(Boolean); + return normalizeStringEntries( + fragment.split(/,|\band\b/gi).map((entry) => entry.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, "")), + ); } export function pickFallbackThinkingLevel(params: { diff --git a/src/agents/pi-embedded-runner/compaction-duplicate-user-messages.ts b/src/agents/pi-embedded-runner/compaction-duplicate-user-messages.ts index 9b978859a5a..da5fdf9a42e 100644 --- a/src/agents/pi-embedded-runner/compaction-duplicate-user-messages.ts +++ b/src/agents/pi-embedded-runner/compaction-duplicate-user-messages.ts @@ -1,3 +1,5 @@ +import { isRecord } from "../../shared/record-coerce.js"; + const DEFAULT_DUPLICATE_USER_MESSAGE_WINDOW_MS = 60_000; const MIN_DUPLICATE_USER_MESSAGE_CHARS = 24; @@ -17,10 +19,6 @@ type DuplicateUserMessageOptions = { windowMs?: number; }; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function normalizeUserMessageContent(content: unknown): string | undefined { if (typeof content === "string") { return content.replace(/\s+/g, " ").trim(); diff --git a/src/agents/pi-embedded-runner/delivery-evidence.ts b/src/agents/pi-embedded-runner/delivery-evidence.ts index 77c6077dd7c..f6d2d5e6744 100644 --- a/src/agents/pi-embedded-runner/delivery-evidence.ts +++ b/src/agents/pi-embedded-runner/delivery-evidence.ts @@ -1,3 +1,4 @@ +import { normalizeStringEntries, uniqueStrings } from "../../shared/string-normalization.js"; import { hasAcceptedSessionSpawn } from "../accepted-session-spawn.js"; type AgentPayloadLike = { @@ -107,9 +108,7 @@ export function hasDeliveredExpectedMedia( result: AgentDeliveryEvidence, expectedMediaUrls: readonly string[], ): boolean { - const expected = Array.from( - new Set(expectedMediaUrls.map((url) => url.trim()).filter((url) => url.length > 0)), - ); + const expected = uniqueStrings(normalizeStringEntries(expectedMediaUrls)); if (expected.length === 0) { return true; } diff --git a/src/agents/pi-embedded-runner/empty-assistant-turn.ts b/src/agents/pi-embedded-runner/empty-assistant-turn.ts index d33b6b433ef..2a77a962a59 100644 --- a/src/agents/pi-embedded-runner/empty-assistant-turn.ts +++ b/src/agents/pi-embedded-runner/empty-assistant-turn.ts @@ -1,3 +1,5 @@ +import { asFiniteNumber } from "../../shared/number-coercion.js"; + type EmptyAssistantTurnLike = { content?: unknown; stopReason?: unknown; @@ -17,7 +19,7 @@ type UsageFieldMap = { // Upstream badlogic/pi-mono should normalize Anthropic zero-token empty `stop` // turns before OpenClaw sees them. Downstream: openclaw/openclaw#71880. function readFiniteTokenCount(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } function isZero(value: number | undefined): value is 0 { diff --git a/src/agents/pi-embedded-runner/model-context-tokens.ts b/src/agents/pi-embedded-runner/model-context-tokens.ts index 1f81ff34d32..31d2e601564 100644 --- a/src/agents/pi-embedded-runner/model-context-tokens.ts +++ b/src/agents/pi-embedded-runner/model-context-tokens.ts @@ -1,4 +1,5 @@ import type { Api, Model } from "@earendil-works/pi-ai"; +import { asFiniteNumber } from "../../shared/number-coercion.js"; type PiModelWithOptionalContextTokens = Model & { contextTokens?: number; @@ -6,5 +7,5 @@ type PiModelWithOptionalContextTokens = Model & { export function readPiModelContextTokens(model: Model | null | undefined): number | undefined { const value = (model as PiModelWithOptionalContextTokens | null | undefined)?.contextTokens; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index e5ba53a2b12..51a3ee6abce 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -3,6 +3,7 @@ import type { SimpleStreamOptions } from "@earendil-works/pi-ai"; import { streamSimple } from "@earendil-works/pi-ai"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isRecord } from "../../shared/record-coerce.js"; import { normalizeOptionalLowercaseString, readStringValue } from "../../shared/string-coerce.js"; import { patchCodexNativeWebSearchPayload, @@ -210,10 +211,6 @@ function shouldStripOpenAICompletionMessageKeys(model: { return model.api === "openai-completions" && compat?.strictMessageKeys === true; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function hasResponsesWebSearchTool(tools: unknown): boolean { if (!Array.isArray(tools)) { return false; diff --git a/src/agents/pi-embedded-runner/replay-history.ts b/src/agents/pi-embedded-runner/replay-history.ts index 53aa90dede0..65ade368671 100644 --- a/src/agents/pi-embedded-runner/replay-history.ts +++ b/src/agents/pi-embedded-runner/replay-history.ts @@ -17,6 +17,7 @@ import { hasInterSessionUserProvenance, normalizeInputProvenance, } from "../../sessions/input-provenance.js"; +import { asFiniteNumber } from "../../shared/number-coercion.js"; import { resolveImageSanitizationLimits } from "../image-sanitization.js"; import { downgradeOpenAIFunctionCallReasoningPairs, @@ -536,7 +537,7 @@ function normalizeAssistantUsageCost(usage: unknown): AssistantUsageSnapshot["co } function toFiniteCostNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } function ensureAssistantUsageSnapshots(messages: AgentMessage[]): AgentMessage[] { diff --git a/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.ts b/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.ts index 51e10a3738d..d9d6827879a 100644 --- a/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.ts +++ b/src/agents/pi-embedded-runner/run/attempt-tool-construction-plan.ts @@ -5,6 +5,7 @@ import { buildPluginToolGroups, expandPolicyWithPluginGroups, expandToolGroups, + normalizeToolList, normalizeToolName, } from "../../tool-policy.js"; @@ -140,7 +141,7 @@ function resolveCodingToolConstructionPlanForAllowlist( return cloneCodingToolConstructionPlan(ALL_CODING_TOOL_CONSTRUCTION_PLAN); } const expanded = expandToolGroups(toolsAllow); - const normalized = expanded.map((entry) => normalizeToolName(entry)).filter(Boolean); + const normalized = normalizeToolList(expanded); const includeBaseCodingTools = normalized.some((name) => BASE_CODING_TOOL_FACTORY_NAMES.has(name), ); diff --git a/src/agents/pi-embedded-runner/run/attempt.session-lock.ts b/src/agents/pi-embedded-runner/run/attempt.session-lock.ts index d945a562a68..d521c58359b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.session-lock.ts +++ b/src/agents/pi-embedded-runner/run/attempt.session-lock.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import { withOwnedSessionTranscriptWrites } from "../../../config/sessions/transcript-write-context.js"; +import { normalizeStringEntries } from "../../../shared/string-normalization.js"; import { isSessionWriteLockTimeoutError } from "../../session-write-lock-error.js"; import type { acquireSessionWriteLock } from "../../session-write-lock.js"; @@ -169,10 +170,7 @@ function sameSessionFileIdentity( } function splitSessionFileLines(text: string): string[] { - return text - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); + return normalizeStringEntries(text.split(/\r?\n/)); } function isJsonRecord(value: unknown): value is Record { @@ -336,10 +334,7 @@ async function sessionFenceAdvanceIsBenign(params: { if (!text?.endsWith("\n")) { return false; } - const lines = text - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(text.split("\n")); return lines.length > 0 && lines.every(isTranscriptOnlyOpenClawAssistantLine); } diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts b/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts index ef9432b02cc..089a8d502f7 100644 --- a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts +++ b/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts @@ -2,6 +2,7 @@ import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; import { streamSimple } from "@earendil-works/pi-ai"; import { visitObjectContentBlocks } from "../../../shared/message-content-blocks.js"; import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; +import { normalizeStringEntries } from "../../../shared/string-normalization.js"; import { validateAnthropicTurns, validateGeminiTurns } from "../../pi-embedded-helpers.js"; import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js"; import { @@ -88,10 +89,7 @@ function buildStructuredToolNameCandidates(rawName: string): string[] { addCandidate(normalizedDelimiter); addCandidate(normalizeToolName(normalizedDelimiter)); - const segments = normalizedDelimiter - .split(".") - .map((segment) => segment.trim()) - .filter(Boolean); + const segments = normalizeStringEntries(normalizedDelimiter.split(".")); if (segments.length > 1) { for (let index = 1; index < segments.length; index += 1) { const suffix = segments.slice(index).join("."); diff --git a/src/agents/pi-embedded-runner/run/incomplete-turn.ts b/src/agents/pi-embedded-runner/run/incomplete-turn.ts index 3b1fb336c4d..5ca10a6b570 100644 --- a/src/agents/pi-embedded-runner/run/incomplete-turn.ts +++ b/src/agents/pi-embedded-runner/run/incomplete-turn.ts @@ -6,6 +6,7 @@ import { } from "../../../auto-reply/tokens.js"; import type { EmbeddedPiExecutionContract } from "../../../config/types.agent-defaults.js"; import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; +import { normalizeStringEntries } from "../../../shared/string-normalization.js"; import { hasAcceptedSessionSpawn } from "../../accepted-session-spawn.js"; import { collectTextContentBlocks } from "../../content-blocks.js"; import { @@ -730,28 +731,18 @@ export function resolveAckExecutionFastPathInstruction(params: { } function extractPlanningOnlySteps(text: string): string[] { - const lines = text - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const bulletLines = lines - .map((line) => line.replace(/^[-*•]\s+|^\d+[.)]\s+/u, "").trim()) - .filter(Boolean); + const lines = normalizeStringEntries(text.split(/\r?\n/)); + const bulletLines = normalizeStringEntries( + lines.map((line) => line.replace(/^[-*•]\s+|^\d+[.)]\s+/u, "")), + ); if (bulletLines.length >= 2) { return bulletLines.slice(0, 4); } - return text - .split(/(?<=[.!?])\s+/u) - .map((step) => step.trim()) - .filter(Boolean) - .slice(0, 4); + return normalizeStringEntries(text.split(/(?<=[.!?])\s+/u)).slice(0, 4); } function hasStructuredPlanningOnlyFormat(text: string): boolean { - const lines = text - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(text.split(/\r?\n/)); if (lines.length === 0) { return false; } diff --git a/src/agents/pi-embedded-runner/run/message-tool-terminal.ts b/src/agents/pi-embedded-runner/run/message-tool-terminal.ts index 56eb26ac3ff..99faaa66ddb 100644 --- a/src/agents/pi-embedded-runner/run/message-tool-terminal.ts +++ b/src/agents/pi-embedded-runner/run/message-tool-terminal.ts @@ -4,6 +4,7 @@ import type { Agent, } from "@earendil-works/pi-agent-core"; import type { SourceReplyDeliveryMode } from "../../../auto-reply/get-reply-options.types.js"; +import { hasNonEmptyString as hasStringValue } from "../../../shared/string-coerce.js"; import { isMessageToolSendActionName } from "../../pi-embedded-messaging.js"; import { isToolResultError } from "../../pi-embedded-subscribe.tools.js"; import { normalizeToolName } from "../../tool-policy.js"; @@ -31,10 +32,6 @@ function argsRecordForToolCall(context: AfterToolCallContext): Record 0; -} - function hasExplicitMessageRoute(args: Record): boolean { if (EXPLICIT_MESSAGE_ROUTE_KEYS.some((key) => hasStringValue(args[key]))) { return true; diff --git a/src/agents/pi-embedded-runner/run/preemptive-compaction.ts b/src/agents/pi-embedded-runner/run/preemptive-compaction.ts index e8dcfe22777..53fa4616f4d 100644 --- a/src/agents/pi-embedded-runner/run/preemptive-compaction.ts +++ b/src/agents/pi-embedded-runner/run/preemptive-compaction.ts @@ -1,5 +1,6 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { SessionContextBudgetStatus } from "../../../config/sessions.js"; +import { isRecord } from "../../../shared/record-coerce.js"; import { estimateStringChars } from "../../../utils/cjk-chars.js"; import { SAFETY_MARGIN } from "../../compaction.js"; import { @@ -75,10 +76,6 @@ function estimateIdentifierTokenPressure( return estimateJsonPayloadTokenPressure(value, charsPerToken); } -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function estimateContentBlockTokenPressure( block: unknown, charsPerToken = ESTIMATED_CHARS_PER_TOKEN, diff --git a/src/agents/pi-embedded-runner/run/tool-media-payloads.ts b/src/agents/pi-embedded-runner/run/tool-media-payloads.ts index 486d858c8c3..86e23aa824f 100644 --- a/src/agents/pi-embedded-runner/run/tool-media-payloads.ts +++ b/src/agents/pi-embedded-runner/run/tool-media-payloads.ts @@ -3,6 +3,10 @@ import { copyReplyPayloadMetadata, getReplyPayloadMetadata, } from "../../../auto-reply/reply-payload.js"; +import { + normalizeUniqueStringEntries, + uniqueStrings, +} from "../../../shared/string-normalization.js"; import type { EmbeddedPiRunResult } from "../types.js"; type EmbeddedRunPayload = NonNullable[number]; @@ -14,9 +18,7 @@ export function mergeAttemptToolMediaPayloads(params: { toolTrustedLocalMedia?: boolean; sourceReplyDeliveryMode?: SourceReplyDeliveryMode; }): EmbeddedRunPayload[] | undefined { - const mediaUrls = Array.from( - new Set(params.toolMediaUrls?.map((url) => url.trim()).filter(Boolean) ?? []), - ); + const mediaUrls = normalizeUniqueStringEntries(params.toolMediaUrls); if (mediaUrls.length === 0 && !params.toolAudioAsVoice && !params.toolTrustedLocalMedia) { return params.payloads; } @@ -31,7 +33,7 @@ export function mergeAttemptToolMediaPayloads(params: { ) { return payloads; } - const mergedMediaUrls = Array.from(new Set([...(payload.mediaUrls ?? []), ...mediaUrls])); + const mergedMediaUrls = uniqueStrings([...(payload.mediaUrls ?? []), ...mediaUrls]); payloads[payloadIndex] = copyReplyPayloadMetadata(payload, { ...payload, mediaUrls: mergedMediaUrls.length ? mergedMediaUrls : undefined, diff --git a/src/agents/pi-embedded-runner/tool-name-allowlist.ts b/src/agents/pi-embedded-runner/tool-name-allowlist.ts index da8b28f7a26..4fa60e2ac30 100644 --- a/src/agents/pi-embedded-runner/tool-name-allowlist.ts +++ b/src/agents/pi-embedded-runner/tool-name-allowlist.ts @@ -1,4 +1,5 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { sortUniqueStrings } from "../../shared/string-normalization.js"; import type { ClientToolDefinition } from "./run/params.js"; /** @@ -57,5 +58,5 @@ export function collectCoreBuiltinToolNames( } export function toSessionToolAllowlist(allowedToolNames: Iterable): string[] { - return [...new Set(allowedToolNames)].toSorted((a, b) => a.localeCompare(b)); + return sortUniqueStrings(allowedToolNames); } diff --git a/src/agents/pi-embedded-runner/transcript-file-state.ts b/src/agents/pi-embedded-runner/transcript-file-state.ts index b78835f3630..f57369a7b68 100644 --- a/src/agents/pi-embedded-runner/transcript-file-state.ts +++ b/src/agents/pi-embedded-runner/transcript-file-state.ts @@ -13,6 +13,7 @@ import { } from "@earendil-works/pi-coding-agent"; import { appendRegularFile } from "../../infra/fs-safe.js"; import { privateFileStore } from "../../infra/private-file-store.js"; +import { isRecord } from "../../shared/record-coerce.js"; type BranchSummaryEntry = Extract; type CompactionEntry = Extract; @@ -47,10 +48,6 @@ const repairableToolCallContentTypes = new Set([ const invalidJsonlSlotType = "__openclaw_invalid_jsonl_slot"; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function isString(value: unknown): value is string { return typeof value === "string" && value.trim() !== ""; } diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 5f1122d1081..e48483ef30a 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -16,6 +16,7 @@ import { type AssistantPhase, } from "../shared/chat-message-content.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { isMessagingToolDuplicateNormalized, normalizeTextForComparison, @@ -254,7 +255,7 @@ export function readPendingToolMediaReply( } return { mediaUrls: state.pendingToolMediaUrls.length - ? Array.from(new Set(state.pendingToolMediaUrls)) + ? uniqueStrings(state.pendingToolMediaUrls) : undefined, audioAsVoice: state.pendingToolAudioAsVoice || undefined, trustedLocalMedia: state.pendingToolTrustedLocalMedia || undefined, @@ -288,7 +289,7 @@ function mergeReplyDirectiveResults( if (!second) { return first; } - const mediaUrls = Array.from(new Set([...(first.mediaUrls ?? []), ...(second.mediaUrls ?? [])])); + const mediaUrls = uniqueStrings([...(first.mediaUrls ?? []), ...(second.mediaUrls ?? [])]); return { text: `${first.text ?? ""}${second.text ?? ""}`, mediaUrls: mediaUrls.length ? mediaUrls : undefined, diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 11c8a9a118c..10f9e8052ca 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -20,6 +20,10 @@ import type { ExecApprovalDecision } from "../infra/exec-approvals.js"; import { normalizeInteractiveReply, normalizeMessagePresentation } from "../interactive/payload.js"; import type { PluginHookAfterToolCallEvent } from "../plugins/types.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; +import { + asOptionalObjectRecord, + asOptionalRecord as readRecordField, +} from "../shared/record-coerce.js"; import { normalizeOptionalLowercaseString, readStringValue } from "../shared/string-coerce.js"; import { truncateUtf16Safe } from "../utils.js"; import { normalizeAcceptedSessionSpawnResult } from "./accepted-session-spawn.js"; @@ -193,13 +197,7 @@ function emitTrackedItemEvent(ctx: ToolHandlerContext, itemData: AgentItemEventD } function readToolResultDetailsRecord(result: unknown): Record | undefined { - if (!result || typeof result !== "object") { - return undefined; - } - const details = (result as { details?: unknown }).details; - return details && typeof details === "object" && !Array.isArray(details) - ? (details as Record) - : undefined; + return readRecordField(asOptionalObjectRecord(result)?.details); } function isAsyncStartedToolResult(result: unknown): boolean { @@ -432,12 +430,6 @@ function collectMessagingMediaUrlsFromToolResult(result: unknown): string[] { return urls; } -function readRecordField(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readStringField(record: Record, key: string): string | undefined { const value = record[key]; return typeof value === "string" && value.trim() ? value : undefined; diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index 45e3b3fd52a..f1b51d806a8 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -2,11 +2,13 @@ import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index. import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js"; import { redactSensitiveFieldValue, redactToolPayloadText } from "../logging/redact.js"; import { splitMediaFromOutput } from "../media/parse.js"; +import { asOptionalRecord as readRecord } from "../shared/record-coerce.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, readStringValue, } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { truncateUtf16Safe } from "../utils.js"; import { collectTextContentBlocks } from "./content-blocks.js"; import { isMessageToolSendActionName } from "./pi-embedded-messaging.js"; @@ -143,12 +145,6 @@ function extractDirectErrorCodeField(value: unknown): string | undefined { ); } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - export function buildToolLifecycleErrorResult(error: unknown): { details: Record; } { @@ -476,7 +472,7 @@ function collectStructuredMediaUrls(media: Record): string[] { pushAttachment(attachment); } } - return Array.from(new Set(urls)); + return uniqueStrings(urls); } function isNonOutboundToolResultMedia(media: Record): boolean { diff --git a/src/agents/pi-hooks/compaction-safeguard-quality.ts b/src/agents/pi-hooks/compaction-safeguard-quality.ts index 6c6ab429214..820b0c59ca4 100644 --- a/src/agents/pi-hooks/compaction-safeguard-quality.ts +++ b/src/agents/pi-hooks/compaction-safeguard-quality.ts @@ -1,5 +1,6 @@ import { extractKeywords, isQueryStopWordToken } from "../../memory-host-sdk/query.js"; import { localeLowercasePreservingWhitespace } from "../../shared/string-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import type { CompactionSummarizationInstructions } from "../compaction.js"; import { wrapUntrustedPromptDataBlock } from "../sanitize-for-prompt.js"; @@ -186,7 +187,7 @@ function hasAskOverlap(summary: string, latestAsk: string | null): boolean { if (!latestAsk) { return true; } - const askTokens = Array.from(new Set(tokenizeAskOverlapText(latestAsk))).slice( + const askTokens = uniqueStrings(tokenizeAskOverlapText(latestAsk)).slice( 0, MAX_ASK_OVERLAP_TOKENS, ); diff --git a/src/agents/pi-tools-parameter-schema.ts b/src/agents/pi-tools-parameter-schema.ts index ff1e69ff2be..24a6c829545 100644 --- a/src/agents/pi-tools-parameter-schema.ts +++ b/src/agents/pi-tools-parameter-schema.ts @@ -5,7 +5,9 @@ import { resolveUnsupportedToolSchemaKeywords, shouldOmitEmptyArrayItems, } from "../plugins/provider-model-compat.js"; +import { isRecord as isSchemaRecord } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { uniqueValues } from "../shared/string-normalization.js"; import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; export type ToolParameterSchemaOptions = { @@ -51,7 +53,7 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown { const existingEnum = extractEnumValues(existing); const incomingEnum = extractEnumValues(incoming); if (existingEnum || incomingEnum) { - const values = Array.from(new Set([...(existingEnum ?? []), ...(incomingEnum ?? [])])); + const values = uniqueValues([...(existingEnum ?? []), ...(incomingEnum ?? [])]); const merged: Record = {}; for (const source of [existing, incoming]) { if (!source || typeof source !== "object") { @@ -78,10 +80,6 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown { type FlattenableVariantKey = "anyOf" | "oneOf"; type TopLevelConditionalKey = FlattenableVariantKey | "allOf"; -function isSchemaRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function setOwnSchemaProperty(target: Record, key: string, value: unknown): void { Object.defineProperty(target, key, { value, diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 4d991ef4ac8..3ac4ab9f84e 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -14,6 +14,10 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; +import { + normalizeUniqueSingleOrTrimmedStringList, + uniqueStrings, +} from "../shared/string-normalization.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; @@ -90,7 +94,7 @@ function mergeConfiguredSubagentAllow( allow: string[] | undefined, alsoAllow: string[] | undefined, ): string[] | undefined { - return allow && alsoAllow ? Array.from(new Set([...allow, ...alsoAllow])) : allow; + return allow && alsoAllow ? uniqueStrings([...allow, ...alsoAllow]) : allow; } export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number): SandboxToolPolicy { @@ -228,17 +232,7 @@ function buildProviderToolPolicyLookup( } function collectUniqueStrings(values: Array): string[] { - const seen = new Set(); - const resolved: string[] = []; - for (const value of values) { - const trimmed = value?.trim(); - if (!trimmed || seen.has(trimmed)) { - continue; - } - seen.add(trimmed); - resolved.push(trimmed); - } - return resolved; + return normalizeUniqueSingleOrTrimmedStringList(values); } function buildScopedGroupIdCandidates(groupId?: string | null): string[] { @@ -509,7 +503,7 @@ export function resolveEffectiveToolPolicy(params: { } const profileAlsoAllow = explicitProfileAlsoAllow - ? Array.from(new Set(explicitProfileAlsoAllow)) + ? uniqueStrings(explicitProfileAlsoAllow) : undefined; return { agentId, diff --git a/src/agents/plugin-text-transforms.ts b/src/agents/plugin-text-transforms.ts index 8f62046cfc3..abcb52b268c 100644 --- a/src/agents/plugin-text-transforms.ts +++ b/src/agents/plugin-text-transforms.ts @@ -1,6 +1,7 @@ import type { StreamFn } from "@earendil-works/pi-agent-core"; import { streamSimple, type AssistantMessageEvent } from "@earendil-works/pi-ai"; import type { PluginTextReplacement, PluginTextTransforms } from "../plugins/cli-backend.types.js"; +import { isRecord } from "../shared/record-coerce.js"; import { createStreamIteratorWrapper } from "./stream-iterator-wrapper.js"; export function mergePluginTextTransforms( @@ -31,10 +32,6 @@ export function applyPluginTextReplacements( return next; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function transformContentText(content: unknown, replacements?: PluginTextReplacement[]): unknown { if (typeof content === "string") { return applyPluginTextReplacements(content, replacements); diff --git a/src/agents/provider-attribution.ts b/src/agents/provider-attribution.ts index 553dba81095..79fc0d7b9b8 100644 --- a/src/agents/provider-attribution.ts +++ b/src/agents/provider-attribution.ts @@ -1,9 +1,12 @@ import { listOpenClawPluginManifestMetadata } from "../plugins/manifest-metadata-scan.js"; +import { isRecord } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; +import { asBoolean } from "../utils/boolean.js"; import type { RuntimeVersionEnv } from "../version.js"; import { resolveRuntimeServiceVersion } from "../version.js"; import { normalizeProviderId } from "./provider-id.js"; @@ -119,8 +122,7 @@ function readCompatBoolean( if (!compat || typeof compat !== "object") { return undefined; } - const value = (compat as Record)[key]; - return typeof value === "boolean" ? value : undefined; + return asBoolean((compat as Record)[key]); } const OPENCLAW_ATTRIBUTION_PRODUCT = "OpenClaw"; @@ -231,19 +233,6 @@ function isManifestProviderEndpointClass(value: string): value is ProviderEndpoi return MANIFEST_PROVIDER_ENDPOINT_CLASSES.has(value as ProviderEndpointClass); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function normalizeStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value - .map((entry) => normalizeOptionalString(entry)) - .filter((entry): entry is string => entry !== undefined); -} - function readManifestProviderEndpoints( manifest: Record, ): ManifestProviderEndpointCacheEntry[] { @@ -261,9 +250,11 @@ function readManifestProviderEndpoints( } entries.push({ endpointClass: endpointClassRaw, - hosts: normalizeStringList(rawEndpoint.hosts).map((host) => host.toLowerCase()), - hostSuffixes: normalizeStringList(rawEndpoint.hostSuffixes).map((host) => host.toLowerCase()), - normalizedBaseUrls: normalizeStringList(rawEndpoint.baseUrls) + hosts: normalizeTrimmedStringList(rawEndpoint.hosts).map((host) => host.toLowerCase()), + hostSuffixes: normalizeTrimmedStringList(rawEndpoint.hostSuffixes).map((host) => + host.toLowerCase(), + ), + normalizedBaseUrls: normalizeTrimmedStringList(rawEndpoint.baseUrls) .map((baseUrl) => normalizeComparableBaseUrl(baseUrl)) .filter((baseUrl): baseUrl is string => baseUrl !== undefined), ...(normalizeOptionalString(rawEndpoint.googleVertexRegion) diff --git a/src/agents/provider-http-errors.ts b/src/agents/provider-http-errors.ts index a5f4849f823..819cdb20475 100644 --- a/src/agents/provider-http-errors.ts +++ b/src/agents/provider-http-errors.ts @@ -1,14 +1,11 @@ export { asFiniteNumber } from "../shared/number-coercion.js"; import { redactSensitiveText } from "../logging/redact.js"; import { normalizeOptionalString as trimToUndefined } from "../shared/string-coerce.js"; +export { asBoolean } from "../utils/boolean.js"; export { normalizeOptionalString as trimToUndefined } from "../shared/string-coerce.js"; const ERROR_BODY_METADATA_LIMIT = 500; -export function asBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - export function asObject(value: unknown): Record | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) diff --git a/src/agents/responses-image-payload-sanitizer.ts b/src/agents/responses-image-payload-sanitizer.ts index e1e64d62e19..27f5978a4c1 100644 --- a/src/agents/responses-image-payload-sanitizer.ts +++ b/src/agents/responses-image-payload-sanitizer.ts @@ -1,14 +1,11 @@ import { canonicalizeBase64 } from "../media/base64.js"; +import { isRecord } from "../shared/record-coerce.js"; const DATA_URL_PREFIX = "data:"; const IMAGE_OMITTED_TEXT = "omitted image payload: invalid inline image data"; type JsonRecord = Record; -function isRecord(value: unknown): value is JsonRecord { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function startsWithDataUrl(value: string): boolean { return value.slice(0, DATA_URL_PREFIX.length).toLowerCase() === DATA_URL_PREFIX; } diff --git a/src/agents/run-timeout-attribution.ts b/src/agents/run-timeout-attribution.ts index 5f6ab350344..568e187c127 100644 --- a/src/agents/run-timeout-attribution.ts +++ b/src/agents/run-timeout-attribution.ts @@ -20,6 +20,4 @@ export function normalizeAgentRunTimeoutPhase(value: unknown): AgentRunTimeoutPh : undefined; } -export function normalizeProviderStarted(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} +export { asBoolean as normalizeProviderStarted } from "../utils/boolean.js"; diff --git a/src/agents/runtime-capabilities.ts b/src/agents/runtime-capabilities.ts index fd58b282ce0..dfdab840b8c 100644 --- a/src/agents/runtime-capabilities.ts +++ b/src/agents/runtime-capabilities.ts @@ -5,6 +5,7 @@ import { import { resolveChannelCapabilities } from "../config/channel-capabilities.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { normalizeStringEntriesLower } from "../shared/string-normalization.js"; import { resolveChannelPromptCapabilities } from "./channel-tools.js"; const THREAD_BOUND_SUBAGENT_SPAWN_CAPABILITY = "threadbound-subagent-spawn"; @@ -15,9 +16,7 @@ function mergeRuntimeCapabilities( additions: readonly string[] = [], ): string[] | undefined { const merged = [...(base ?? [])]; - const seen = new Set( - merged.map((capability) => normalizeOptionalLowercaseString(capability)).filter(Boolean), - ); + const seen = new Set(normalizeStringEntriesLower(merged)); for (const capability of additions) { const normalizedCapability = normalizeOptionalLowercaseString(capability); diff --git a/src/agents/sandbox-tool-policy.ts b/src/agents/sandbox-tool-policy.ts index 7a610d622e7..80509014230 100644 --- a/src/agents/sandbox-tool-policy.ts +++ b/src/agents/sandbox-tool-policy.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "../shared/string-normalization.js"; import type { SandboxToolPolicy } from "./sandbox/types.js"; export const IMPLICIT_ALLOW_ALL_FROM_ALSO_ALLOW = Symbol.for( @@ -15,12 +16,12 @@ function unionAllow(base?: string[], extra?: string[]): string[] | undefined { return base; } if (!Array.isArray(base)) { - return Array.from(new Set(["*", ...extra])); + return uniqueStrings(["*", ...extra]); } if (base.length === 0) { - return Array.from(new Set(["*", ...extra])); + return uniqueStrings(["*", ...extra]); } - return Array.from(new Set([...base, ...extra])); + return uniqueStrings([...base, ...extra]); } function hasExplicitAllowAll(list?: string[]): boolean { diff --git a/src/agents/sandbox/fs-paths.ts b/src/agents/sandbox/fs-paths.ts index a678b2cc243..eeeeb11f4a5 100644 --- a/src/agents/sandbox/fs-paths.ts +++ b/src/agents/sandbox/fs-paths.ts @@ -2,6 +2,7 @@ import os from "node:os"; import path from "node:path"; import { isPathInside } from "../../infra/path-guards.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; +import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { resolveSandboxInputPath, resolveSandboxPath } from "../sandbox-paths.js"; import type { SandboxFsBridgeContext } from "./backend-handle.types.js"; import { splitSandboxBindSpec } from "./bind-spec.js"; @@ -51,12 +52,7 @@ export function parseSandboxBindMount(spec: string): ParsedBindMount | null { return null; } const optionsToken = normalizeOptionalLowercaseString(parsed.options) ?? ""; - const optionParts = optionsToken - ? optionsToken - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean) - : []; + const optionParts = optionsToken ? normalizeStringEntries(optionsToken.split(",")) : []; const writable = !optionParts.includes("ro"); return { hostRoot: path.resolve(hostToken), diff --git a/src/agents/sandbox/tool-policy.ts b/src/agents/sandbox/tool-policy.ts index 784c7239f13..8ff9edd27b1 100644 --- a/src/agents/sandbox/tool-policy.ts +++ b/src/agents/sandbox/tool-policy.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { resolveAgentConfig } from "../agent-scope.js"; import { compileGlobPatterns, matchesAnyGlobPattern } from "../glob-pattern.js"; import { expandToolGroups, normalizeToolName } from "../tool-policy.js"; @@ -101,10 +102,10 @@ function mergeAllowlist(base: string[] | undefined, extra: string[] | undefined) if (!Array.isArray(extra) || extra.length === 0) { return [...base]; } - return Array.from(new Set([...base, ...extra])); + return uniqueStrings([...base, ...extra]); } if (Array.isArray(extra) && extra.length > 0) { - return Array.from(new Set([...DEFAULT_TOOL_ALLOW, ...extra])); + return uniqueStrings([...DEFAULT_TOOL_ALLOW, ...extra]); } return [...DEFAULT_TOOL_ALLOW]; } @@ -133,7 +134,7 @@ function resolveExplicitSandboxReAllowPatterns(params: { allow?: string[]; alsoAllow?: string[]; }): string[] { - return Array.from(new Set([...(params.allow ?? []), ...(params.alsoAllow ?? [])])); + return uniqueStrings([...(params.allow ?? []), ...(params.alsoAllow ?? [])]); } function filterDefaultDenyForExplicitAllows(params: { diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index 84f7b0cfad3..52da8bbf34d 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -1,5 +1,6 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { + hasNonEmptyString as hasNonEmptyStringField, normalizeLowercaseStringOrEmpty, normalizeOptionalString, readStringValue, @@ -58,10 +59,6 @@ function hasToolCallInput(block: RawToolCallBlock): boolean { return hasInput || hasArguments; } -function hasNonEmptyStringField(value: unknown): boolean { - return typeof value === "string" && value.trim().length > 0; -} - function hasToolCallId(block: RawToolCallBlock): boolean { return ( hasNonEmptyStringField(block.id) || diff --git a/src/agents/skills-install-extract.ts b/src/agents/skills-install-extract.ts index 8270eb28c36..c61a377ba03 100644 --- a/src/agents/skills-install-extract.ts +++ b/src/agents/skills-install-extract.ts @@ -9,6 +9,7 @@ import { } from "../infra/archive.js"; import { formatErrorMessage } from "../infra/errors.js"; import { runCommandWithTimeout } from "../process/exec.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { parseTarVerboseMetadata } from "./skills-install-tar-verbose.js"; import { hasBinary } from "./skills.js"; @@ -65,10 +66,7 @@ async function readTarPreflight(params: { if (listResult.code !== 0) { return commandFailureResult(listResult, "tar list failed"); } - const entries = listResult.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); + const entries = normalizeStringEntries(listResult.stdout.split("\n")); const verboseResult = await runCommandWithTimeout(["tar", "tvf", params.archivePath], { timeoutMs: params.timeoutMs, diff --git a/src/agents/skills-install-output.ts b/src/agents/skills-install-output.ts index 25362acc2bc..dff5f196a1e 100644 --- a/src/agents/skills-install-output.ts +++ b/src/agents/skills-install-output.ts @@ -1,3 +1,5 @@ +import { normalizeStringEntries } from "../shared/string-normalization.js"; + type InstallCommandResult = { code: number | null; stdout: string; @@ -9,10 +11,7 @@ function summarizeInstallOutput(text: string): string | undefined { if (!raw) { return undefined; } - const lines = raw - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(raw.split("\n")); if (lines.length === 0) { return undefined; } diff --git a/src/agents/skills-install-tar-verbose.ts b/src/agents/skills-install-tar-verbose.ts index fb1ce93b12d..c5b621e87b3 100644 --- a/src/agents/skills-install-tar-verbose.ts +++ b/src/agents/skills-install-tar-verbose.ts @@ -1,3 +1,5 @@ +import { normalizeStringEntries } from "../shared/string-normalization.js"; + const TAR_VERBOSE_MONTHS = new Set([ "Jan", "Feb", @@ -63,10 +65,7 @@ function parseTarVerboseSize(line: string): number { } export function parseTarVerboseMetadata(stdout: string): Array<{ type: string; size: number }> { - const lines = stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(stdout.split("\n")); return lines.map((line) => { const typeChar = line[0] ?? ""; if (!typeChar) { diff --git a/src/agents/skills/filter.ts b/src/agents/skills/filter.ts index 27496737bb8..af912934a56 100644 --- a/src/agents/skills/filter.ts +++ b/src/agents/skills/filter.ts @@ -1,4 +1,4 @@ -import { normalizeStringEntries } from "../../shared/string-normalization.js"; +import { normalizeStringEntries, sortUniqueStrings } from "../../shared/string-normalization.js"; export function normalizeSkillFilter(skillFilter?: ReadonlyArray): string[] | undefined { if (skillFilter === undefined) { @@ -14,7 +14,7 @@ export function normalizeSkillFilterForComparison( if (normalized === undefined) { return undefined; } - return Array.from(new Set(normalized)).toSorted(); + return sortUniqueStrings(normalized); } export function matchesSkillFilter( diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index 8aaf19a8bce..0c186b992a2 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -7,6 +7,7 @@ import { resolveOsHomeDir } from "../../infra/home-dir.js"; import { isPathInside } from "../../infra/path-guards.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { normalizeTrimmedStringList, uniqueStrings } from "../../shared/string-normalization.js"; import { CONFIG_DIR, resolveHomeDir, resolveUserPath } from "../../utils.js"; import { resolveSandboxPath } from "../sandbox-paths.js"; import { @@ -61,9 +62,7 @@ function resolveCompactHomePrefixes(): string[] { const realHomes = resolvedHomes .map((home) => tryRealpath(home)) .filter((home): home is string => !!home); - return [...resolvedHomes, ...realHomes] - .filter((home, index, all) => all.indexOf(home) === index) - .sort((a, b) => b.length - a.length); + return uniqueStrings([...resolvedHomes, ...realHomes]).sort((a, b) => b.length - a.length); } function compactSkillPaths(skills: Skill[]): Skill[] { @@ -496,20 +495,19 @@ function resolveSkillFilePath(params: { } function resolvePluginSkillRootRealPaths(pluginSkillDirs: readonly string[]): string[] { - return pluginSkillDirs - .map((dir) => tryRealpath(dir)) - .filter((dir): dir is string => Boolean(dir)) - .filter((dir, index, all) => all.indexOf(dir) === index); + return uniqueStrings( + pluginSkillDirs.map((dir) => tryRealpath(dir)).filter((dir): dir is string => Boolean(dir)), + ); } function resolveAllowedSymlinkTargetRealPaths(config?: OpenClawConfig): string[] { const rawTargets = config?.skills?.load?.allowSymlinkTargets ?? []; - return rawTargets + const targetPaths = rawTargets .map((dir) => normalizeOptionalString(dir) ?? "") .filter(Boolean) .map((dir) => tryRealpath(resolveUserPath(dir))) - .filter((dir): dir is string => Boolean(dir)) - .filter((dir, index, all) => all.indexOf(dir) === index); + .filter((dir): dir is string => Boolean(dir)); + return uniqueStrings(targetPaths); } function loadGeneratedPluginSkillRecords(params: { @@ -846,7 +844,7 @@ function loadSkillEntries( const bundledSkillsDir = opts?.bundledSkillsDir ?? resolveBundledSkillsDir(); const pluginSkillsDir = opts?.pluginSkillsDir ?? path.join(CONFIG_DIR, "plugin-skills"); const extraDirsRaw = opts?.config?.skills?.load?.extraDirs ?? []; - const extraDirs = extraDirsRaw.map((d) => normalizeOptionalString(d) ?? "").filter(Boolean); + const extraDirs = normalizeTrimmedStringList(extraDirsRaw); const pluginSkillDirs = resolvePluginSkillDirs({ workspaceDir, config: opts?.config, diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 6dd7446fe36..a229660a01a 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -13,6 +13,7 @@ import { import { isCronRunSessionKey, isCronSessionKey } from "../sessions/session-key-utils.js"; import { isNonTerminalAgentRunStatus } from "../shared/agent-run-status.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { normalizeStringEntries, uniqueStrings } from "../shared/string-normalization.js"; import { mergeDeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js"; import { INTERNAL_MESSAGE_CHANNEL, @@ -593,9 +594,7 @@ function hasGatewayAgentMessagingToolDeliveredExpectedMedia( response: unknown, expectedMediaUrls: readonly string[], ): boolean { - const expected = Array.from( - new Set(expectedMediaUrls.map((url) => url.trim()).filter((url) => url.length > 0)), - ); + const expected = uniqueStrings(normalizeStringEntries(expectedMediaUrls)); if (expected.length === 0) { return true; } @@ -714,9 +713,7 @@ function resolveGeneratedMediaDirectFallbackUrls(params: { expectedMediaUrls: readonly string[]; announceResponse?: unknown; }): string[] { - const expected = Array.from( - new Set(params.expectedMediaUrls.map((url) => url.trim()).filter((url) => url.length > 0)), - ); + const expected = uniqueStrings(normalizeStringEntries(params.expectedMediaUrls)); const result = getGatewayAgentResult(params.announceResponse); if (!result) { return expected; diff --git a/src/agents/subagent-announce-output.ts b/src/agents/subagent-announce-output.ts index 9854474e20e..eeac7d72c1f 100644 --- a/src/agents/subagent-announce-output.ts +++ b/src/agents/subagent-announce-output.ts @@ -1,5 +1,6 @@ import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { formatBlockedLivenessError, isBlockedLivenessState } from "../shared/agent-liveness.js"; +import { asFiniteNumber } from "../shared/number-coercion.js"; import { isAbortedAgentStopReason } from "./run-termination.js"; import { wrapPromptDataBlock } from "./sanitize-for-prompt.js"; import { @@ -69,10 +70,6 @@ export type SubagentRunOutcome = { elapsedMs?: number; }; -function readFiniteNumber(value: number | undefined): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - export function withSubagentOutcomeTiming( outcome: SubagentRunOutcome, timing: { @@ -80,8 +77,8 @@ export function withSubagentOutcomeTiming( endedAt?: number; }, ): SubagentRunOutcome { - const startedAt = readFiniteNumber(timing.startedAt) ?? readFiniteNumber(outcome.startedAt); - const endedAt = readFiniteNumber(timing.endedAt) ?? readFiniteNumber(outcome.endedAt); + const startedAt = asFiniteNumber(timing.startedAt) ?? asFiniteNumber(outcome.startedAt); + const endedAt = asFiniteNumber(timing.endedAt) ?? asFiniteNumber(outcome.endedAt); const nextTiming: Pick = {}; if (typeof startedAt === "number") { nextTiming.startedAt = startedAt; diff --git a/src/agents/subagent-registry-lifecycle.ts b/src/agents/subagent-registry-lifecycle.ts index 98ea3be6d41..718711196e9 100644 --- a/src/agents/subagent-registry-lifecycle.ts +++ b/src/agents/subagent-registry-lifecycle.ts @@ -6,6 +6,7 @@ import { defaultRuntime } from "../runtime.js"; import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js"; import { extractTextFromChatContent } from "../shared/chat-content.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { completeTaskRunByRunId, failTaskRunByRunId, @@ -162,7 +163,7 @@ export function createSubagentRegistryLifecycleController(params: { .map((value) => value?.trim()) .filter((value): value is string => Boolean(value)); return errors.length > 0 - ? [...new Set(errors)].join("; ") + ? uniqueStrings(errors).join("; ") : `delivery path ${delivery.path} did not complete`; }; diff --git a/src/agents/subagent-session-reconciliation.ts b/src/agents/subagent-session-reconciliation.ts index c3182efba89..c7b68739fdc 100644 --- a/src/agents/subagent-session-reconciliation.ts +++ b/src/agents/subagent-session-reconciliation.ts @@ -6,6 +6,7 @@ import { type SessionEntry, } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { asFiniteNumber } from "../shared/number-coercion.js"; import type { SubagentRunOutcome } from "./subagent-announce-output.js"; import { SUBAGENT_ENDED_REASON_COMPLETE, @@ -23,7 +24,7 @@ export type SubagentSessionCompletion = { }; function finiteTimestamp(value: number | undefined): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } function terminalSessionTimestamp(sessionEntry: SessionEntry | undefined): number | undefined { diff --git a/src/agents/subagent-spawn-thinking.ts b/src/agents/subagent-spawn-thinking.ts index 73b36f4a80f..8e665ae5e89 100644 --- a/src/agents/subagent-spawn-thinking.ts +++ b/src/agents/subagent-spawn-thinking.ts @@ -1,9 +1,6 @@ import { normalizeThinkLevel } from "../auto-reply/thinking.shared.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; - -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} +import { asOptionalObjectRecord } from "../shared/record-coerce.js"; function readString(value: Record, key: string): string | undefined { const raw = value[key]; @@ -15,8 +12,10 @@ export function resolveSubagentThinkingOverride(params: { targetAgentConfig?: unknown; thinkingOverrideRaw?: string; }) { - const targetSubagents = asRecord(asRecord(params.targetAgentConfig)?.subagents); - const defaultSubagents = asRecord(params.cfg.agents?.defaults?.subagents); + const targetSubagents = asOptionalObjectRecord( + asOptionalObjectRecord(params.targetAgentConfig)?.subagents, + ); + const defaultSubagents = asOptionalObjectRecord(params.cfg.agents?.defaults?.subagents); const resolvedThinkingDefaultRaw = readString(targetSubagents ?? {}, "thinking") ?? readString(defaultSubagents ?? {}, "thinking"); diff --git a/src/agents/subagent-system-prompt.ts b/src/agents/subagent-system-prompt.ts index 812d3dfdc8e..904cdcd4c08 100644 --- a/src/agents/subagent-system-prompt.ts +++ b/src/agents/subagent-system-prompt.ts @@ -1,4 +1,5 @@ import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import type { DeliveryContext } from "../utils/delivery-context.types.js"; export function buildSubagentSystemPrompt(params: { @@ -24,8 +25,8 @@ export function buildSubagentSystemPrompt(params: { ? params.maxSpawnDepth : DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH; const acpEnabled = params.acpEnabled === true; - const nativeCommandGuidanceLines = Array.from( - new Set((params.nativeCommandGuidanceLines ?? []).map((line) => line.trim()).filter(Boolean)), + const nativeCommandGuidanceLines = normalizeUniqueStringEntries( + params.nativeCommandGuidanceLines, ); const canSpawn = childDepth < maxSpawnDepth; const parentLabel = childDepth >= 2 ? "parent orchestrator" : "main agent"; diff --git a/src/agents/subagent-target-policy.ts b/src/agents/subagent-target-policy.ts index 08b5fe7f29a..ef4f5fb2c81 100644 --- a/src/agents/subagent-target-policy.ts +++ b/src/agents/subagent-target-policy.ts @@ -1,4 +1,5 @@ import { normalizeAgentId } from "../routing/session-key.js"; +import { normalizeUniqueStringEntries, sortUniqueStrings } from "../shared/string-normalization.js"; type SubagentTargetPolicyResult = { ok: true } | { ok: false; allowedText: string; error: string }; @@ -22,14 +23,14 @@ function normalizeAllowAgents(allowAgents: readonly string[] | undefined): { return { configured: true, allowAny: allowAgents.some((value) => value.trim() === "*"), - allowedIds: Array.from(new Set(allowedIds)).toSorted((a, b) => a.localeCompare(b)), + allowedIds: sortUniqueStrings(allowedIds), }; } function normalizeConfiguredAgentIds( configuredAgentIds: readonly string[] | undefined, ): Set { - return new Set((configuredAgentIds ?? []).map((id) => normalizeAgentId(id)).filter(Boolean)); + return new Set(normalizeUniqueStringEntries((configuredAgentIds ?? []).map(normalizeAgentId))); } function filterConfiguredAllowedIds(params: { @@ -60,7 +61,7 @@ export function resolveSubagentAllowedTargetIds(params: { } return { allowAny: true, - allowedIds: Array.from(new Set(configuredIds)).toSorted((a, b) => a.localeCompare(b)), + allowedIds: sortUniqueStrings(configuredIds), }; } return { diff --git a/src/agents/subagent-yield-output.ts b/src/agents/subagent-yield-output.ts index a85533bfc3c..7078e151a15 100644 --- a/src/agents/subagent-yield-output.ts +++ b/src/agents/subagent-yield-output.ts @@ -1,11 +1,7 @@ -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} +import { asOptionalRecord } from "../shared/record-coerce.js"; function readToolName(value: unknown): string | undefined { - const record = asRecord(value); + const record = asOptionalRecord(value); if (!record) { return undefined; } @@ -19,7 +15,7 @@ function readToolName(value: unknown): string | undefined { } function isToolCallBlock(value: unknown): boolean { - const record = asRecord(value); + const record = asOptionalRecord(value); if (!record) { return false; } @@ -33,7 +29,7 @@ function isToolCallBlock(value: unknown): boolean { } export function assistantCallsSessionsYield(message: unknown): boolean { - const record = asRecord(message); + const record = asOptionalRecord(message); if (!record || record.role !== "assistant" || !Array.isArray(record.content)) { return false; } @@ -48,14 +44,14 @@ function parseJsonObject(text: string): Record | undefined { return undefined; } try { - return asRecord(JSON.parse(trimmed)); + return asOptionalRecord(JSON.parse(trimmed)); } catch { return undefined; } } function readStructuredToolPayload(content: unknown): Record | undefined { - const record = asRecord(content); + const record = asOptionalRecord(content); if (record) { return record; } @@ -66,7 +62,7 @@ function readStructuredToolPayload(content: unknown): Record | return undefined; } for (const block of content) { - const blockRecord = asRecord(block); + const blockRecord = asOptionalRecord(block); if (!blockRecord) { continue; } @@ -86,7 +82,7 @@ export function isSessionsYieldToolResult( message: unknown, previousAssistantCalledYield: boolean, ): boolean { - const record = asRecord(message); + const record = asOptionalRecord(message); if (!record || (record.role !== "toolResult" && record.role !== "tool")) { return false; } @@ -97,7 +93,7 @@ export function isSessionsYieldToolResult( if (!previousAssistantCalledYield) { return false; } - const details = asRecord(record.details); + const details = asOptionalRecord(record.details); if (details?.status === "yielded") { return true; } diff --git a/src/agents/system-prompt-params.ts b/src/agents/system-prompt-params.ts index 336128120a2..392c98643d6 100644 --- a/src/agents/system-prompt-params.ts +++ b/src/agents/system-prompt-params.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { findGitRoot } from "../infra/git-root.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { ActiveProcessSessionReference } from "./bash-process-references.js"; import { formatUserTime, @@ -78,9 +79,7 @@ function resolveRepoRoot(params: { // ignore invalid config path } } - const candidates = [params.workspaceDir, params.cwd] - .map((value) => value?.trim()) - .filter(Boolean) as string[]; + const candidates = normalizeStringEntries([params.workspaceDir ?? "", params.cwd ?? ""]); const seen = new Set(); for (const candidate of candidates) { const resolved = path.resolve(candidate); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 54886cfc9f2..dab75fb3928 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -14,6 +14,11 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; +import { + normalizeStringEntries, + normalizeStringEntriesLower, + normalizeUniqueStringEntries, +} from "../shared/string-normalization.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js"; import type { ActiveProcessSessionReference } from "./bash-process-references.js"; import type { BootstrapMode } from "./bootstrap-mode.js"; @@ -384,7 +389,7 @@ function buildOwnerIdentityLine( ownerDisplay: OwnerIdDisplay, ownerDisplaySecret?: string, ) { - const normalized = ownerNumbers.map((value) => value.trim()).filter(Boolean); + const normalized = normalizeStringEntries(ownerNumbers); if (normalized.length === 0) { return undefined; } @@ -822,8 +827,8 @@ export function buildAgentSystemPrompt(params: { const availableTools = new Set(normalizedTools); const hasSessionsSpawn = availableTools.has("sessions_spawn"); const acpHarnessSpawnAllowed = hasSessionsSpawn && acpSpawnRuntimeEnabled; - const nativeCommandGuidanceLines = Array.from( - new Set((params.nativeCommandGuidanceLines ?? []).map((line) => line.trim()).filter(Boolean)), + const nativeCommandGuidanceLines = normalizeUniqueStringEntries( + params.nativeCommandGuidanceLines, ); const externalToolSummaries = new Map(); for (const [key, value] of Object.entries(params.toolSummaries ?? {})) { @@ -894,9 +899,7 @@ export function buildAgentSystemPrompt(params: { const modelIdentityLine = buildModelIdentityPromptLine(runtimeInfo?.model); const runtimeChannel = normalizeOptionalLowercaseString(runtimeInfo?.channel); const runtimeCapabilities = runtimeInfo?.capabilities ?? []; - const runtimeCapabilitiesLower = new Set( - runtimeCapabilities.map((cap) => normalizeLowercaseStringOrEmpty(cap)).filter(Boolean), - ); + const runtimeCapabilitiesLower = new Set(normalizeStringEntriesLower(runtimeCapabilities)); const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons"); const threadBoundAcpSpawnEnabled = runtimeCapabilitiesLower.has("threadbound-acp-spawn"); const promptMode = params.promptMode ?? "full"; @@ -955,7 +958,7 @@ export function buildAgentSystemPrompt(params: { isMinimal, readToolName, }); - const workspaceNotes = (params.workspaceNotes ?? []).map((note) => note.trim()).filter(Boolean); + const workspaceNotes = normalizeStringEntries(params.workspaceNotes); // For "none" mode, return just the basic identity line if (promptMode === "none") { diff --git a/src/agents/tool-allowlist-guard.ts b/src/agents/tool-allowlist-guard.ts index 1239dcd3875..5919a74a6b5 100644 --- a/src/agents/tool-allowlist-guard.ts +++ b/src/agents/tool-allowlist-guard.ts @@ -1,4 +1,5 @@ -import { normalizeToolName } from "./tool-policy.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; +import { normalizeToolList, normalizeToolName } from "./tool-policy.js"; type ExplicitToolAllowlistSource = { label: string; @@ -10,7 +11,7 @@ export function collectExplicitToolAllowlistSources( sources: Array<{ label: string; allow?: string[]; enforceWhenToolsDisabled?: boolean }>, ): ExplicitToolAllowlistSource[] { return sources.flatMap((source) => { - const entries = (source.allow ?? []).map((entry) => entry.trim()).filter(Boolean); + const entries = normalizeStringEntries(source.allow); if (entries.length === 0) { return []; } @@ -34,7 +35,7 @@ export function buildEmptyExplicitToolAllowlistError(params: { params.disableTools === true ? params.sources.filter((source) => source.enforceWhenToolsDisabled === true) : params.sources; - const callableToolNames = params.callableToolNames.map(normalizeToolName).filter(Boolean); + const callableToolNames = normalizeToolList(params.callableToolNames); if (sources.length === 0 || callableToolNames.length > 0) { return null; } diff --git a/src/agents/tool-description-summary.ts b/src/agents/tool-description-summary.ts index 5f828743660..42c313d6077 100644 --- a/src/agents/tool-description-summary.ts +++ b/src/agents/tool-description-summary.ts @@ -1,4 +1,5 @@ import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; function normalizeSummaryWhitespace(value: string): string { return value.replace(/\s+/g, " ").trim(); @@ -53,15 +54,9 @@ export function summarizeToolDescriptionText(params: { return "Tool"; } - const paragraphs = raw - .split(/\n\s*\n/g) - .map((part) => part.trim()) - .filter(Boolean); + const paragraphs = normalizeStringEntries(raw.split(/\n\s*\n/g)); for (const paragraph of paragraphs) { - const lines = paragraph - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(paragraph.split("\n")); if (lines.length === 0) { continue; } diff --git a/src/agents/tool-loop-detection.ts b/src/agents/tool-loop-detection.ts index 6d846122b59..33659b7e880 100644 --- a/src/agents/tool-loop-detection.ts +++ b/src/agents/tool-loop-detection.ts @@ -2,6 +2,10 @@ import { createHash } from "node:crypto"; import type { ToolLoopDetectionConfig } from "../config/types.tools.js"; import type { SessionState, ToolCallRecord } from "../logging/diagnostic-session-state.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + normalizeNullableString as nonEmptyStringField, + normalizeOptionalString as normalizeRunId, +} from "../shared/string-coerce.js"; import { isPlainObject } from "../utils.js"; import { stableStringify } from "./stable-stringify.js"; @@ -63,11 +67,6 @@ type ToolLoopDetectionScope = { runId?: string; }; -function normalizeRunId(runId?: string): string | undefined { - const trimmed = runId?.trim(); - return trimmed ? trimmed : undefined; -} - function selectHistoryForScope( history: readonly ToolCallRecord[], scope?: ToolLoopDetectionScope, @@ -192,14 +191,6 @@ function stringField(value: unknown): string | null { return typeof value === "string" ? value : null; } -function nonEmptyStringField(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed ? trimmed : null; -} - function hashExecToolOutcome(details: Record, text: string): string | undefined { const status = stringField(details.status); if (!status) { diff --git a/src/agents/tool-policy-audit.ts b/src/agents/tool-policy-audit.ts index 4714793a106..eda41796419 100644 --- a/src/agents/tool-policy-audit.ts +++ b/src/agents/tool-policy-audit.ts @@ -1,7 +1,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import type { SandboxConfig } from "./sandbox/types.js"; import { isToolAllowedByPolicyName } from "./tool-policy-match.js"; -import { normalizeToolName, type ToolPolicyLike } from "./tool-policy.js"; +import { normalizeToolList, normalizeToolName, type ToolPolicyLike } from "./tool-policy.js"; const MAX_AUDIT_TOOL_NAMES = 50; const MAX_AUDIT_FIELD_LENGTH = 160; @@ -25,7 +25,7 @@ function toolPolicyRuleKind(policy: ToolPolicyLike): ToolPolicyRuleKind { } function normalizedToolNames(tools: readonly { name: string }[]): string[] { - return tools.map((tool) => normalizeToolName(tool.name)).filter((name) => name.length > 0); + return normalizeToolList(tools.map((tool) => tool.name)); } function removedToolNamesByRule(params: { diff --git a/src/agents/tool-policy-shared.ts b/src/agents/tool-policy-shared.ts index cb811a32c3b..35c67c16863 100644 --- a/src/agents/tool-policy-shared.ts +++ b/src/agents/tool-policy-shared.ts @@ -1,4 +1,5 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { CORE_TOOL_GROUPS, resolveCoreToolProfilePolicy, @@ -40,7 +41,7 @@ export function expandToolGroups(list?: string[]) { } expanded.push(value); } - return Array.from(new Set(expanded)); + return uniqueStrings(expanded); } export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined { diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index bd37eab53fc..258cdfd1eaa 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -1,4 +1,5 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { IMPLICIT_ALLOW_ALL_FROM_ALSO_ALLOW } from "./sandbox-tool-policy.js"; import { expandToolGroups, normalizeToolList, normalizeToolName } from "./tool-policy-shared.js"; export { @@ -81,7 +82,7 @@ export function collectExplicitAllowlist(policies: Array): string[] { @@ -152,7 +153,7 @@ export function expandPluginGroups( } expanded.push(normalized); } - return Array.from(new Set(expanded)); + return uniqueStrings(expanded); } export function expandPolicyWithPluginGroups( @@ -203,7 +204,7 @@ export function analyzeAllowlistByToolType( const pluginOnlyAllowlist = hasOnlyPluginEntries; return { policy, - unknownAllowlist: Array.from(new Set(unknownAllowlist)), + unknownAllowlist: uniqueStrings(unknownAllowlist), pluginOnlyAllowlist, }; } @@ -215,5 +216,5 @@ export function mergeAlsoAllowPolicy( if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) { return policy; } - return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) }; + return { ...policy, allow: uniqueStrings([...policy.allow, ...alsoAllow]) }; } diff --git a/src/agents/tool-search.ts b/src/agents/tool-search.ts index 78f098853bd..1551c1b1060 100644 --- a/src/agents/tool-search.ts +++ b/src/agents/tool-search.ts @@ -9,6 +9,12 @@ import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getPluginToolMeta } from "../plugins/tools.js"; +import { isRecord } from "../shared/record-coerce.js"; +import { + normalizeStringEntries, + uniqueStrings, + uniqueValues, +} from "../shared/string-normalization.js"; import { isToolWrappedWithBeforeToolCallHook, type HookContext, @@ -370,10 +376,6 @@ const sessionCatalogs = globalToolSearchState[SESSION_CATALOGS_KEY] ?? (globalToolSearchState[SESSION_CATALOGS_KEY] = new Map()); -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function readToolSearchConfig(config?: OpenClawConfig): Record { const tools = isRecord(config?.tools) ? config.tools : undefined; const toolSearch = tools?.toolSearch; @@ -450,7 +452,7 @@ function sessionCatalogKeys(input: { if (input.agentId?.trim()) { keys.push(`agent:${input.agentId.trim()}`); } - return [...new Set(keys)]; + return uniqueStrings(keys); } function sessionCatalogKey(input: { @@ -802,7 +804,7 @@ export function registerToolSearchCatalog(params: { byId.set(entry.name, entry); } const next = { - entries: [...new Set(byId.values())].toSorted((a, b) => a.id.localeCompare(b.id)), + entries: uniqueValues(byId.values()).toSorted((a, b) => a.id.localeCompare(b.id)), searchCount: prior?.searchCount ?? 0, describeCount: prior?.describeCount ?? 0, callCount: prior?.callCount ?? 0, @@ -850,7 +852,7 @@ function resolveCatalog(ctx: ToolSearchToolContext): ToolSearchCatalogSession { if (ctx.runId?.trim()) { throw new ToolInputError("Tool Search catalog is unavailable for this run."); } - const uniqueCatalogs = [...new Set(sessionCatalogs.values())]; + const uniqueCatalogs = uniqueValues(sessionCatalogs.values()); if (uniqueCatalogs.length === 1) { const catalog = uniqueCatalogs[0]; if (catalog) { @@ -879,11 +881,7 @@ function describeEntry(entry: ToolSearchCatalogEntry) { } function tokenize(input: string): string[] { - return input - .toLowerCase() - .split(/[^a-z0-9_./:-]+/u) - .map((part) => part.trim()) - .filter(Boolean); + return normalizeStringEntries(input.toLowerCase().split(/[^a-z0-9_./:-]+/u)); } function scoreEntry(entry: ToolSearchCatalogEntry, terms: string[]): number { diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index 00ad285c401..75d20cba514 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -7,6 +7,7 @@ import type { TSchema } from "typebox"; import { readLocalFileSafely } from "../../infra/fs-safe.js"; import { detectMime } from "../../media/mime.js"; import { readSnakeCaseParamRaw } from "../../param-key.js"; +import { normalizeStringEntries } from "../../shared/string-normalization.js"; import type { ImageSanitizationLimits } from "../image-sanitization.js"; import { sanitizeToolResultImages } from "../tool-images.js"; @@ -202,10 +203,7 @@ export function readStringArrayParam( const { required = false, label = key } = options; const raw = readParamRaw(params, key); if (Array.isArray(raw)) { - const values = raw - .filter((entry) => typeof entry === "string") - .map((entry) => entry.trim()) - .filter(Boolean); + const values = normalizeStringEntries(raw.filter((entry) => typeof entry === "string")); if (values.length === 0) { if (required) { throw new ToolInputError(`${label} required`); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index c162d313930..b4cafbd3e90 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -15,6 +15,7 @@ import { import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { collectEnabledInsecureOrDangerousFlags } from "../../security/dangerous-config-flags.js"; +import { isRecord as isPlainObject } from "../../shared/record-coerce.js"; import { normalizeOptionalString, readStringValue } from "../../shared/string-coerce.js"; import { stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; @@ -124,10 +125,6 @@ function parseGatewayConfigMutationRaw( return parsedRes.parsed; } -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function normalizeGatewayConfigPath(path: string): string { return path.startsWith("tools.bash.") ? path.replace(/^tools\.bash\./, "tools.exec.") : path; } diff --git a/src/agents/tools/heartbeat-response-tool.ts b/src/agents/tools/heartbeat-response-tool.ts index 01afbe373c2..577b8a9b017 100644 --- a/src/agents/tools/heartbeat-response-tool.ts +++ b/src/agents/tools/heartbeat-response-tool.ts @@ -6,6 +6,7 @@ import { normalizeHeartbeatToolResponse, } from "../../auto-reply/heartbeat-tool-response.js"; import { readSnakeCaseParamRaw } from "../../param-key.js"; +import { isRecord } from "../../shared/record-coerce.js"; import { optionalStringEnum, stringEnum } from "../schema/string-enum.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, ToolInputError } from "./common.js"; @@ -23,10 +24,6 @@ const HeartbeatResponseToolSchema = Type.Object( { additionalProperties: false }, ); -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - function readRequiredBoolean(params: Record, key: string): boolean { const raw = readSnakeCaseParamRaw(params, key); if (typeof raw !== "boolean") { diff --git a/src/agents/tools/media-tool-shared.ts b/src/agents/tools/media-tool-shared.ts index 1d94cbc3bf3..6eb448a1a71 100644 --- a/src/agents/tools/media-tool-shared.ts +++ b/src/agents/tools/media-tool-shared.ts @@ -10,6 +10,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; import { normalizeModelRef } from "../model-selection.js"; import { normalizeProviderId } from "../provider-id.js"; @@ -549,7 +550,7 @@ export function resolveMediaToolLocalRoots( return workspaceDir ? [workspaceDir] : []; } const roots = getDefaultLocalRoots(); - return workspaceDir ? Array.from(new Set([...roots, workspaceDir])) : [...roots]; + return workspaceDir ? uniqueStrings([...roots, workspaceDir]) : [...roots]; } export function resolvePromptAndModelOverride( diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 1e1686780bb..3e109c05bee 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -28,6 +28,7 @@ import { parseThreadSessionSuffix, } from "../../routing/session-key.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { sortUniqueStrings, uniqueValues } from "../../shared/string-normalization.js"; import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { resolveSessionAgentId } from "../agent-scope.js"; @@ -717,7 +718,7 @@ function resolveMessageToolActionSchemaActions(params: MessageToolDiscoveryParam function listAllMessageToolActions(params: MessageToolDiscoveryParams): ChannelMessageActionName[] { const pluginActions = listAllChannelSupportedActions(buildMessageActionDiscoveryInput(params)); - return Array.from(new Set(["send", "broadcast", ...pluginActions])); + return uniqueValues(["send", "broadcast", ...pluginActions]); } function resolveIncludeCapability( @@ -803,9 +804,7 @@ function buildMessageToolDescription(options?: { if (messageToolDiscoveryParams) { const actions = resolveMessageToolActionSchemaActions(messageToolDiscoveryParams); if (actions.length > 0) { - const sortedActions = Array.from(new Set(actions)).toSorted() as Array< - ChannelMessageActionName | "send" - >; + const sortedActions = sortUniqueStrings(actions) as Array; return appendMessageToolReadHint( appendMessageToolVisibleReplyHint( `${baseDescription} Supports actions: ${sortedActions.join(", ")}.`, diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 6c4895c7f8a..60fa11bc001 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -24,6 +24,7 @@ import { } from "../../routing/session-key.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import type { BuildStatusTextParams } from "../../status/status-text.types.js"; import { buildTaskStatusSnapshotForRelatedSessionKeyForOwner } from "../../tasks/task-owner-access.js"; import { formatTaskStatusDetail, formatTaskStatusTitle } from "../../tasks/task-status.js"; @@ -182,7 +183,7 @@ function listImplicitDefaultDirectFallbackKeys(params: { }), params.mainKey, ]; - return [...new Set(candidates)]; + return uniqueStrings(candidates); } type ActiveStatusModelIdentity = { provider?: string; model: string }; diff --git a/src/auto-reply/command-turn-context.ts b/src/auto-reply/command-turn-context.ts index 55c02fe95ec..87aa8d7f4a2 100644 --- a/src/auto-reply/command-turn-context.ts +++ b/src/auto-reply/command-turn-context.ts @@ -1,3 +1,5 @@ +import { normalizeOptionalString } from "../shared/string-coerce.js"; + export type CommandTurnKind = "native" | "text-slash" | "normal"; export type CommandTurnSource = "native" | "text" | "message"; @@ -39,14 +41,6 @@ export type CommandTurnContextInput = { Body?: unknown; }; -function normalizeOptionalString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - function resolveCommandBody(input: CommandTurnContextInput): string | undefined { return ( normalizeOptionalString(input.CommandBody) ?? diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index a164f11796c..a1517bcccae 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -1,4 +1,5 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { COMMAND_ARG_FORMATTERS } from "./commands-args.js"; import type { ChatCommandDefinition, @@ -51,7 +52,9 @@ export function defineChatCommand(command: DefineChatCommandInput): ChatCommandD return { key: command.key, nativeName: command.nativeName, - nativeAliases: command.nativeAliases?.map((alias) => alias.trim()).filter(Boolean), + nativeAliases: command.nativeAliases + ? normalizeStringEntries(command.nativeAliases) + : undefined, description: command.description, acceptsArgs, args: command.args, diff --git a/src/auto-reply/heartbeat-filter.ts b/src/auto-reply/heartbeat-filter.ts index 2d2207e4041..3971fd06b0d 100644 --- a/src/auto-reply/heartbeat-filter.ts +++ b/src/auto-reply/heartbeat-filter.ts @@ -1,3 +1,6 @@ +import { isRecord } from "../shared/record-coerce.js"; +import { normalizeOptionalString as readString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { HEARTBEAT_RESPONSE_TOOL_NAME } from "./heartbeat-tool-response.js"; import { HEARTBEAT_RESPONSE_TOOL_PROMPT, @@ -27,18 +30,11 @@ const MESSAGE_TOOL_DELIVERY_PREFIX = "Delivery: to send a message, use the `mess type HeartbeatTranscriptMessage = { role: string; content?: unknown }; -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readNestedString(record: Record, key: string): string | undefined { const value = record[key]; - if (typeof value === "string" && value.trim()) { - return value.trim(); + const direct = readString(value); + if (direct) { + return direct; } if (!isRecord(value)) { return undefined; @@ -79,7 +75,7 @@ function collectToolCallIds(block: Record): string[] { readString(block.toolUseId), readString(block.id), ].filter((id): id is string => Boolean(id)); - return [...new Set(ids)]; + return uniqueStrings(ids); } function readNestedToolCallArguments(record: Record): unknown { @@ -239,7 +235,7 @@ function collectSuccessfulToolResultCallIds(message: { ids.push(...collectToolCallIds(block)); } } - return [...new Set(ids)]; + return uniqueStrings(ids); } function isRealNonHeartbeatUserMessage( diff --git a/src/auto-reply/heartbeat-tool-response.ts b/src/auto-reply/heartbeat-tool-response.ts index b6daad997f1..d95f2f2a353 100644 --- a/src/auto-reply/heartbeat-tool-response.ts +++ b/src/auto-reply/heartbeat-tool-response.ts @@ -1,3 +1,5 @@ +import { isRecord } from "../shared/record-coerce.js"; +import { normalizeOptionalString as readString } from "../shared/string-coerce.js"; import type { ReplyPayload } from "./reply-payload.js"; import { HEARTBEAT_TOKEN } from "./tokens.js"; @@ -29,14 +31,6 @@ export type HeartbeatToolResponse = { const OUTCOMES = new Set(HEARTBEAT_TOOL_OUTCOMES); const PRIORITIES = new Set(HEARTBEAT_TOOL_PRIORITIES); -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readStringAlias(record: Record, ...keys: string[]) { for (const key of keys) { const value = readString(record[key]); diff --git a/src/auto-reply/model.ts b/src/auto-reply/model.ts index 9f2043ed11e..41d27c584a5 100644 --- a/src/auto-reply/model.ts +++ b/src/auto-reply/model.ts @@ -1,4 +1,5 @@ import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { escapeRegExp } from "../utils.js"; export function extractModelDirective( @@ -19,7 +20,7 @@ export function extractModelDirective( /(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?(?:\s+(?:--runtime|runtime=|harness=)\s*([A-Za-z0-9_.:-]+))?/i, ); - const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean); + const aliases = normalizeStringEntries(options?.aliases); const aliasMatch = modelMatch || aliases.length === 0 ? null diff --git a/src/auto-reply/reply/commands-dock.ts b/src/auto-reply/reply/commands-dock.ts index 1d50d03d7dc..382e277f2e9 100644 --- a/src/auto-reply/reply/commands-dock.ts +++ b/src/auto-reply/reply/commands-dock.ts @@ -3,6 +3,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { normalizeTrimmedStringList } from "../../shared/string-normalization.js"; import { resolveTextCommand } from "../commands-registry.js"; import { resolveCommandSurfaceChannel } from "./channel-context.js"; import { persistSessionEntry } from "./commands-session-store.js"; @@ -91,7 +92,7 @@ function resolveLinkedDockTarget(params: { if (!Array.isArray(ids)) { continue; } - const normalizedIds = ids.map((id) => normalizeLowercaseStringOrEmpty(id)).filter(Boolean); + const normalizedIds = normalizeTrimmedStringList(ids).map((id) => id.toLowerCase()); if (!normalizedIds.some((id) => params.sourceCandidates.has(id))) { continue; } diff --git a/src/auto-reply/reply/commands-export-session.ts b/src/auto-reply/reply/commands-export-session.ts index 96f1b6e04a2..aa2478b8184 100644 --- a/src/auto-reply/reply/commands-export-session.ts +++ b/src/auto-reply/reply/commands-export-session.ts @@ -8,6 +8,7 @@ import { type SessionHeader, } from "@earendil-works/pi-coding-agent"; import { pathExists } from "../../infra/fs-safe.js"; +import { isRecord } from "../../shared/record-coerce.js"; import type { ReplyPayload } from "../types.js"; import { isReplyPayload, @@ -156,10 +157,6 @@ async function writeNewDefaultExportFile(filePath: string, html: string): Promis throw new Error(`Could not find an unused export filename near ${filePath}`); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function isSessionFileEntry(value: unknown): value is PiSessionFileEntry { if (!isRecord(value) || typeof value.type !== "string") { return false; diff --git a/src/auto-reply/reply/group-id.ts b/src/auto-reply/reply/group-id.ts index f8f9a1e92b6..605be7a52f6 100644 --- a/src/auto-reply/reply/group-id.ts +++ b/src/auto-reply/reply/group-id.ts @@ -10,6 +10,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { extractSimpleExplicitGroupId } from "./group-id-simple.js"; function extractInferredGroupTargetId(params: { @@ -18,9 +19,8 @@ function extractInferredGroupTargetId(params: { messaging?: ChannelMessagingAdapter; }): string | undefined { const normalized = params.messaging?.normalizeTarget?.(params.raw); - const candidates = [normalized, params.raw].filter( - (candidate, index, values): candidate is string => - Boolean(candidate) && values.indexOf(candidate) === index, + const candidates = uniqueStrings( + [normalized, params.raw].filter((candidate): candidate is string => Boolean(candidate)), ); for (const candidate of candidates) { const chatType = params.messaging?.inferTargetChatType?.({ to: candidate }); diff --git a/src/auto-reply/reply/history-media.ts b/src/auto-reply/reply/history-media.ts index 5c0516000bb..a595f2c1f35 100644 --- a/src/auto-reply/reply/history-media.ts +++ b/src/auto-reply/reply/history-media.ts @@ -1,4 +1,5 @@ import { mimeTypeFromFilePath } from "../../media/mime.js"; +import { asFiniteNumber } from "../../shared/number-coercion.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import type { MsgContext } from "../templating.js"; import type { HistoryEntry, HistoryMediaEntry } from "./history.types.js"; @@ -41,7 +42,7 @@ function isHistoryImageMedia(media: HistoryMediaEntry): boolean { } function resolveTimestamp(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } function resolveHistoryEntries(ctx: MsgContext): HistoryEntry[] { diff --git a/src/auto-reply/reply/inbound-meta.ts b/src/auto-reply/reply/inbound-meta.ts index 5956a02d85d..a9729e21aab 100644 --- a/src/auto-reply/reply/inbound-meta.ts +++ b/src/auto-reply/reply/inbound-meta.ts @@ -3,6 +3,7 @@ import { getLoadedChannelPluginById } from "../../channels/plugins/registry-load import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import { normalizeAnyChannelId } from "../../channels/registry.js"; import { resolveSenderLabel } from "../../channels/sender-label.js"; +import { isRecord } from "../../shared/record-coerce.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { truncateUtf16Safe } from "../../utils.js"; import type { EnvelopeFormatOptions } from "../envelope.js"; @@ -76,10 +77,6 @@ function sanitizeUntrustedJsonValue(value: unknown): unknown { ); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function truncateUntrustedTranscriptField(value: string): string { if (value.length <= MAX_UNTRUSTED_TRANSCRIPT_FIELD_CHARS) { return value; diff --git a/src/auto-reply/reply/startup-context.ts b/src/auto-reply/reply/startup-context.ts index 004cb1db633..21e774f50d5 100644 --- a/src/auto-reply/reply/startup-context.ts +++ b/src/auto-reply/reply/startup-context.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { resolveUserTimezone } from "../../agents/date-time.js"; import type { OpenClawConfig } from "../../config/config.js"; import { openRootFile } from "../../infra/boundary-file-read.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; const STARTUP_MEMORY_FILE_MAX_BYTES = 16_384; const STARTUP_MEMORY_FILE_MAX_CHARS = 1_200; @@ -226,7 +227,7 @@ async function listStartupMemoryPathsByDate(params: { stamps: string[]; }): Promise> { const memoryDir = path.join(params.workspaceDir, "memory"); - const uniqueStamps = Array.from(new Set(params.stamps)); + const uniqueStamps = uniqueStrings(params.stamps); const fallback = new Map(uniqueStamps.map((stamp) => [stamp, [`${stamp}.md`]])); const stampSet = new Set(uniqueStamps); diff --git a/src/auto-reply/skill-commands.ts b/src/auto-reply/skill-commands.ts index c7a629f3a75..51d1ebf0b5d 100644 --- a/src/auto-reply/skill-commands.ts +++ b/src/auto-reply/skill-commands.ts @@ -13,6 +13,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { listReservedChatSlashCommandNames } from "./skill-commands-base.js"; export { listReservedChatSlashCommandNames, @@ -69,12 +70,12 @@ export function listSkillCommandsForAgents(params: { } // An empty allowlist contributes no skills but does not widen the merge to unrestricted. if (existing.length === 0) { - return Array.from(new Set(incoming)); + return uniqueStrings(incoming); } if (incoming.length === 0) { - return Array.from(new Set(existing)); + return uniqueStrings(existing); } - return Array.from(new Set([...existing, ...incoming])); + return uniqueStrings([...existing, ...incoming]); }; const agentIds = params.agentIds ?? listAgentIds(params.cfg); diff --git a/src/auto-reply/test-helpers/command-auth-registry-fixture.ts b/src/auto-reply/test-helpers/command-auth-registry-fixture.ts index 31f0bf7ae8b..f5845ea93c0 100644 --- a/src/auto-reply/test-helpers/command-auth-registry-fixture.ts +++ b/src/auto-reply/test-helpers/command-auth-registry-fixture.ts @@ -1,24 +1,18 @@ import { afterEach, beforeEach } from "vitest"; import { normalizeE164 } from "../../plugin-sdk/account-resolution.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { - lowercasePreservingWhitespace, - normalizeOptionalString, -} from "../../shared/string-coerce.js"; +import { lowercasePreservingWhitespace } from "../../shared/string-coerce.js"; +import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; function formatDiscordAllowFromEntries(allowFrom: Array): string[] { - return allowFrom - .map((entry) => normalizeOptionalString(String(entry)) ?? "") - .filter(Boolean) + return normalizeStringEntries(allowFrom) .map((entry) => entry.replace(/^(discord|user|pk):/i, "").replace(/^<@!?(\d+)>$/, "$1")) .map((entry) => lowercasePreservingWhitespace(entry)); } function normalizePhoneAllowFromEntries(allowFrom: Array): string[] { - return allowFrom - .map((entry) => normalizeOptionalString(String(entry)) ?? "") - .filter((entry): entry is string => Boolean(entry)) + return normalizeStringEntries(allowFrom) .map((entry) => { if (entry === "*") { return entry; diff --git a/src/channels/account-snapshot-fields.ts b/src/channels/account-snapshot-fields.ts index 98e0ec402c6..ca342c7ffb4 100644 --- a/src/channels/account-snapshot-fields.ts +++ b/src/channels/account-snapshot-fields.ts @@ -1,6 +1,9 @@ import { stripUrlUserInfo } from "../shared/net/url-userinfo.js"; +import { asFiniteNumber } from "../shared/number-coercion.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { isRecord } from "../utils.js"; +import { asBoolean } from "../utils/boolean.js"; import type { ChannelAccountSnapshot } from "./plugins/types.core.js"; // Read-only status commands project a safe subset of account fields into snapshots @@ -18,12 +21,12 @@ const CREDENTIAL_STATUS_KEYS = [ type CredentialStatusKey = (typeof CREDENTIAL_STATUS_KEYS)[number]; function readBoolean(record: Record, key: string): boolean | undefined { - return typeof record[key] === "boolean" ? record[key] : undefined; + return asBoolean(record[key]); } function readNumber(record: Record, key: string): number | undefined { const value = record[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } function readNullableNumber( @@ -41,10 +44,9 @@ function readStringArray(record: Record, key: string): string[] if (!Array.isArray(value)) { return undefined; } - const normalized = value - .map((entry) => (typeof entry === "string" || typeof entry === "number" ? String(entry) : "")) - .map((entry) => entry.trim()) - .filter(Boolean); + const normalized = normalizeStringEntries( + value.map((entry) => (typeof entry === "string" || typeof entry === "number" ? entry : "")), + ); return normalized.length > 0 ? normalized : undefined; } diff --git a/src/channels/bundled-channel-catalog-read.ts b/src/channels/bundled-channel-catalog-read.ts index 8dc311512d5..a8292b6a604 100644 --- a/src/channels/bundled-channel-catalog-read.ts +++ b/src/channels/bundled-channel-catalog-read.ts @@ -5,6 +5,7 @@ import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js"; import type { PluginPackageChannel } from "../plugins/manifest.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; type ChannelCatalogEntryLike = { openclaw?: { @@ -24,10 +25,12 @@ const officialCatalogFileCache = new Map(); function listPackageRoots(): string[] { - return [ - resolveOpenClawPackageRootSync({ cwd: process.cwd() }), - resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url }), - ].filter((entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index); + return uniqueStrings( + [ + resolveOpenClawPackageRootSync({ cwd: process.cwd() }), + resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url }), + ].filter((entry): entry is string => Boolean(entry)), + ); } function readBundledExtensionCatalogEntriesSync(): ChannelCatalogEntryLike[] { diff --git a/src/channels/channel-config.ts b/src/channels/channel-config.ts index fd74617c012..fbca00dcf55 100644 --- a/src/channels/channel-config.ts +++ b/src/channels/channel-config.ts @@ -1,4 +1,5 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { normalizeUniqueSingleOrTrimmedStringList } from "../shared/string-normalization.js"; export type ChannelMatchSource = "direct" | "parent" | "wildcard"; @@ -41,20 +42,7 @@ export function normalizeChannelSlug(value: string): string { } export function buildChannelKeyCandidates(...keys: Array): string[] { - const seen = new Set(); - const candidates: string[] = []; - for (const key of keys) { - if (typeof key !== "string") { - continue; - } - const trimmed = key.trim(); - if (!trimmed || seen.has(trimmed)) { - continue; - } - seen.add(trimmed); - candidates.push(trimmed); - } - return candidates; + return normalizeUniqueSingleOrTrimmedStringList(keys); } export function resolveChannelEntryMatch(params: { diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts index 0afe26687de..99df31ba432 100644 --- a/src/channels/config-presence.ts +++ b/src/channels/config-presence.ts @@ -9,6 +9,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { hasNonEmptyString } from "../infra/outbound/channel-target.js"; import type { PluginDiscoveryResult } from "../plugins/discovery.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { isRecord } from "../utils.js"; import { listBundledChannelIds } from "./plugins/bundled-ids.js"; @@ -106,13 +107,11 @@ export function listPotentialConfiguredChannelIds( env: NodeJS.ProcessEnv = process.env, options: ChannelPresenceOptions = {}, ): string[] { - return [ - ...new Set( - listPotentialConfiguredChannelPresenceSignals(cfg, env, options).map( - (signal) => signal.channelId, - ), + return uniqueStrings( + listPotentialConfiguredChannelPresenceSignals(cfg, env, options).map( + (signal) => signal.channelId, ), - ]; + ); } export function listPotentialConfiguredChannelPresenceSignals( diff --git a/src/channels/inbound-event/media.ts b/src/channels/inbound-event/media.ts index bdfef7d76be..f0da19e75be 100644 --- a/src/channels/inbound-event/media.ts +++ b/src/channels/inbound-event/media.ts @@ -1,4 +1,5 @@ import type { HistoryMediaEntry } from "../../auto-reply/reply/history.types.js"; +import { normalizeOptionalString as normalizeString } from "../../shared/string-coerce.js"; import type { InboundMediaFacts } from "../turn/types.js"; export type ChannelInboundMediaInput = { @@ -27,11 +28,6 @@ function alignedStrings(values: Array): string[] | undefined return values.map((value) => value ?? ""); } -function normalizeString(value: string | null | undefined): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; -} - function normalizeKind(value: InboundMediaFacts["kind"] | null | undefined) { return value ?? undefined; } diff --git a/src/channels/message-access/allowlist.ts b/src/channels/message-access/allowlist.ts index c2bca2ae665..587848f5cd1 100644 --- a/src/channels/message-access/allowlist.ts +++ b/src/channels/message-access/allowlist.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "../../shared/string-normalization.js"; import type { ChannelIngressPolicyInput, ChannelIngressState, @@ -37,10 +38,6 @@ export function redactedAllowlistDiagnostics( }; } -function uniqueStrings(values: readonly string[]): string[] { - return Array.from(new Set(values)); -} - function mergeResolvedAllowlists( allowlists: readonly ResolvedIngressAllowlist[], ): ResolvedIngressAllowlist { diff --git a/src/channels/message-access/decision.ts b/src/channels/message-access/decision.ts index 599309e3023..9f642e680d7 100644 --- a/src/channels/message-access/decision.ts +++ b/src/channels/message-access/decision.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "../../shared/string-normalization.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../command-gating.js"; import { resolveInboundMentionDecision } from "../mention-gating.js"; import { applyMutableIdentifierPolicy, redactedAllowlistDiagnostics } from "./allowlist.js"; @@ -114,7 +115,7 @@ function mergeCommandMatch( owner: RedactedIngressMatch, group: RedactedIngressMatch, ): RedactedIngressMatch { - const matchedEntryIds = Array.from(new Set([...owner.matchedEntryIds, ...group.matchedEntryIds])); + const matchedEntryIds = uniqueStrings([...owner.matchedEntryIds, ...group.matchedEntryIds]); return { matched: owner.matched || group.matched || matchedEntryIds.length > 0, matchedEntryIds, diff --git a/src/channels/message-access/dm-allow-state.ts b/src/channels/message-access/dm-allow-state.ts index 42cfb627058..40bcba333a4 100644 --- a/src/channels/message-access/dm-allow-state.ts +++ b/src/channels/message-access/dm-allow-state.ts @@ -26,15 +26,12 @@ export async function resolveDmAllowAuditState(params: { readStore: params.readStore, }); const normalizeEntry = params.normalizeEntry ?? ((value: string) => value); - const normalizedCfg = configAllowFrom - .filter((value) => value !== "*") - .map((value) => normalizeEntry(value)) - .map((value) => value.trim()) - .filter(Boolean); - const normalizedStore = storeAllowFrom - .map((value) => normalizeEntry(value)) - .map((value) => value.trim()) - .filter(Boolean); + const normalizedCfg = normalizeStringEntries( + configAllowFrom.filter((value) => value !== "*").map((value) => normalizeEntry(value)), + ); + const normalizedStore = normalizeStringEntries( + storeAllowFrom.map((value) => normalizeEntry(value)), + ); const allowCount = new Set([...normalizedCfg, ...normalizedStore]).size; return { configAllowFrom, diff --git a/src/channels/message-access/runtime-access-groups.ts b/src/channels/message-access/runtime-access-groups.ts index 3ecd6bb72df..f6e5b26f5fd 100644 --- a/src/channels/message-access/runtime-access-groups.ts +++ b/src/channels/message-access/runtime-access-groups.ts @@ -1,26 +1,20 @@ -import { normalizeStringEntries } from "../../shared/string-normalization.js"; +import { normalizeStringEntries, uniqueStrings } from "../../shared/string-normalization.js"; import { parseAccessGroupAllowFromEntry } from "../allow-from.js"; import type { ChannelIngressAdapter, ResolveChannelMessageIngressParams } from "./runtime-types.js"; import type { AccessGroupMembershipFact, ChannelIngressChannelId } from "./types.js"; -function uniqueValues(values: readonly T[]): T[] { - return Array.from(new Set(values)); -} - function accessGroupNames(entries: readonly (string | number)[]): string[] { - return Array.from( - new Set( - entries - .map((entry) => parseAccessGroupAllowFromEntry(String(entry))) - .filter((entry): entry is string => entry != null), - ), + return uniqueStrings( + entries + .map((entry) => parseAccessGroupAllowFromEntry(String(entry))) + .filter((entry): entry is string => entry != null), ); } export function allReferencedAccessGroupNames( entries: Array, ): string[] { - return Array.from(new Set(entries.flatMap((entryGroup) => accessGroupNames(entryGroup)))); + return uniqueStrings(entries.flatMap((entryGroup) => accessGroupNames(entryGroup))); } export async function normalizeEffectiveEntries(params: { @@ -42,7 +36,10 @@ export async function normalizeEffectiveEntries(params: { context: params.context, accountId: params.accountId, }); - return uniqueValues([...accessGroupEntries, ...normalized.matchable.map((entry) => entry.value)]); + return uniqueStrings([ + ...accessGroupEntries, + ...normalized.matchable.map((entry) => entry.value), + ]); } export async function resolveRuntimeAccessGroupMembershipFacts(params: { diff --git a/src/channels/message-access/runtime.ts b/src/channels/message-access/runtime.ts index 598aaedb9a2..be629d8b125 100644 --- a/src/channels/message-access/runtime.ts +++ b/src/channels/message-access/runtime.ts @@ -1,6 +1,6 @@ import { readChannelAllowFromStore } from "../../pairing/pairing-store.js"; import type { PairingChannel } from "../../pairing/pairing-store.types.js"; -import { normalizeStringEntries } from "../../shared/string-normalization.js"; +import { normalizeStringEntries, uniqueStrings } from "../../shared/string-normalization.js"; import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../allow-from.js"; import { decideChannelIngress } from "./decision.js"; import { @@ -591,7 +591,7 @@ function appendAccessGroupMatchedEntry(params: { matchedEntry: string | null; }): string[] { return params.matchedEntry && params.allowlist.accessGroups.matched.length > 0 - ? Array.from(new Set([...params.entries, params.matchedEntry])) + ? uniqueStrings([...params.entries, params.matchedEntry]) : params.entries; } diff --git a/src/channels/message-access/state.ts b/src/channels/message-access/state.ts index 104771b669b..bd4e0fb0a0b 100644 --- a/src/channels/message-access/state.ts +++ b/src/channels/message-access/state.ts @@ -1,4 +1,4 @@ -import { normalizeStringEntries } from "../../shared/string-normalization.js"; +import { normalizeStringEntries, uniqueStrings } from "../../shared/string-normalization.js"; import { parseAccessGroupAllowFromEntry } from "../allow-from.js"; import type { AccessGroupMembershipFact, @@ -22,7 +22,7 @@ function emptyMatch(): RedactedIngressMatch { } function mergeMatches(matches: readonly RedactedIngressMatch[]): RedactedIngressMatch { - const matchedEntryIds = Array.from(new Set(matches.flatMap((match) => match.matchedEntryIds))); + const matchedEntryIds = uniqueStrings(matches.flatMap((match) => match.matchedEntryIds)); return { matched: matches.some((match) => match.matched) || matchedEntryIds.length > 0, matchedEntryIds, diff --git a/src/channels/message/receipt.ts b/src/channels/message/receipt.ts index c76d2b82016..d5dab3a83d2 100644 --- a/src/channels/message/receipt.ts +++ b/src/channels/message/receipt.ts @@ -1,3 +1,4 @@ +import { normalizeUniqueStringEntries } from "../../shared/string-normalization.js"; import type { MessageReceipt, MessageReceiptPartKind, @@ -108,9 +109,7 @@ export function createMessageReceiptFromOutboundResults(params: { } export function listMessageReceiptPlatformIds(receipt: MessageReceipt): string[] { - return Array.from( - new Set(receipt.platformMessageIds.map((messageId) => messageId.trim()).filter(Boolean)), - ); + return normalizeUniqueStringEntries(receipt.platformMessageIds); } export function resolveMessageReceiptPrimaryId(receipt: MessageReceipt): string | undefined { diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index c8a621773a3..7d649662ab7 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -9,6 +9,7 @@ import { normalizeOptionalAccountId, } from "../../routing/session-key.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { normalizeUniqueStringEntries } from "../../shared/string-normalization.js"; import type { ChannelAccountSnapshot } from "./types.core.js"; export function createAccountListHelpers( @@ -70,7 +71,7 @@ export function createAccountListHelpers( if (!normalizeConfiguredAccountId) { return ids; } - return [...new Set(ids.map((id) => normalizeConfiguredAccountId(id)).filter(Boolean))]; + return normalizeUniqueStringEntries(ids.map((id) => normalizeConfiguredAccountId(id))); } function listAccountIds(cfg: OpenClawConfig): string[] { diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 4d7d0027fc9..49790ca9711 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -15,6 +15,7 @@ import type { PluginPackageChannel, PluginPackageInstall } from "../../plugins/m import { listOfficialExternalChannelCatalogEntries } from "../../plugins/official-external-plugin-catalog.js"; import type { PluginOrigin } from "../../plugins/plugin-origin.types.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { normalizeStringEntries, uniqueStrings } from "../../shared/string-normalization.js"; import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js"; import { buildManifestChannelMeta } from "./channel-meta.js"; import type { ChannelMeta } from "./types.public.js"; @@ -100,11 +101,9 @@ function splitEnvPaths(value: string): string[] { if (!trimmed) { return []; } - return trimmed - .split(/[;,]/g) - .flatMap((chunk) => chunk.split(path.delimiter)) - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries( + trimmed.split(/[;,]/g).flatMap((chunk) => chunk.split(path.delimiter)), + ); } function resolveDefaultCatalogPaths(env: NodeJS.ProcessEnv): string[] { @@ -118,7 +117,7 @@ function resolveDefaultCatalogPaths(env: NodeJS.ProcessEnv): string[] { function resolveExternalCatalogPaths(options: CatalogOptions): string[] { if (options.catalogPaths && options.catalogPaths.length > 0) { - return options.catalogPaths.map((entry) => entry.trim()).filter(Boolean); + return normalizeStringEntries(options.catalogPaths); } const env = options.env ?? process.env; for (const key of ENV_CATALOG_PATHS) { @@ -189,13 +188,15 @@ function loadOfficialCatalogEntriesFromPaths(paths: Iterable): ExternalC function resolveOfficialCatalogPaths(options: CatalogOptions): string[] { if (options.officialCatalogPaths && options.officialCatalogPaths.length > 0) { - return options.officialCatalogPaths.map((entry) => entry.trim()).filter(Boolean); + return normalizeStringEntries(options.officialCatalogPaths); } - const packageRoots = [ - resolveOpenClawPackageRootSync({ cwd: process.cwd() }), - resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url }), - ].filter((entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index); + const packageRoots = uniqueStrings( + [ + resolveOpenClawPackageRootSync({ cwd: process.cwd() }), + resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url }), + ].filter((entry): entry is string => Boolean(entry)), + ); const candidates = packageRoots.map((packageRoot) => path.join(packageRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH), @@ -207,7 +208,7 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] { candidates.push(path.join(execDir, "channel-catalog.json")); } - return candidates.filter((entry, index, all) => entry && all.indexOf(entry) === index); + return uniqueStrings(candidates); } function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] { diff --git a/src/channels/plugins/directory-config-helpers.ts b/src/channels/plugins/directory-config-helpers.ts index 537f5be3f57..c4726d0cbe5 100644 --- a/src/channels/plugins/directory-config-helpers.ts +++ b/src/channels/plugins/directory-config-helpers.ts @@ -3,6 +3,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import type { DirectoryConfigParams } from "./directory-types.js"; import type { ChannelDirectoryEntry } from "./types.public.js"; @@ -80,16 +81,7 @@ function collectDirectoryIdsFromMapKeys(params: { } function dedupeDirectoryIds(ids: string[]): string[] { - const deduped: string[] = []; - const seen = new Set(); - for (const id of ids) { - if (seen.has(id)) { - continue; - } - seen.add(id); - deduped.push(id); - } - return deduped; + return uniqueStrings(ids); } export function collectNormalizedDirectoryIds(params: { diff --git a/src/channels/plugins/helpers.ts b/src/channels/plugins/helpers.ts index 6442df802f5..820420ee307 100644 --- a/src/channels/plugins/helpers.ts +++ b/src/channels/plugins/helpers.ts @@ -1,6 +1,7 @@ import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; +import { normalizeStringEntries } from "../../shared/string-normalization.js"; import type { ChannelSecurityDmPolicy } from "./types.core.js"; import type { ChannelPlugin } from "./types.plugin.js"; @@ -24,10 +25,7 @@ export function parseOptionalDelimitedEntries(value?: string): string[] | undefi if (!value?.trim()) { return undefined; } - const parsed = value - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); + const parsed = normalizeStringEntries(value.split(/[\n,;]+/g)); return parsed.length > 0 ? parsed : undefined; } diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts index 341136317ca..e111d91eb53 100644 --- a/src/channels/plugins/message-action-discovery.ts +++ b/src/channels/plugins/message-action-discovery.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { defaultRuntime } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { normalizeAnyChannelId } from "../registry.js"; import { getChannelPlugin, getLoadedChannelPlugin, listChannelPlugins } from "./index.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; @@ -382,7 +383,7 @@ export function resolveChannelMessageToolMediaSourceParamKeys( action: params.action, includeSchema: false, }); - return Array.from(new Set(described.mediaSourceParams)); + return uniqueStrings(described.mediaSourceParams); } export function channelSupportsMessageCapability( diff --git a/src/channels/plugins/outbound/presentation-limits.ts b/src/channels/plugins/outbound/presentation-limits.ts index fab73c1b572..7edbced269b 100644 --- a/src/channels/plugins/outbound/presentation-limits.ts +++ b/src/channels/plugins/outbound/presentation-limits.ts @@ -4,6 +4,7 @@ import type { MessagePresentationButton, MessagePresentationOption, } from "../../../interactive/payload.js"; +import { normalizeStringEntries } from "../../../shared/string-normalization.js"; import type { ChannelPresentationCapabilities } from "../outbound.types.js"; type ActionLimits = NonNullable["actions"]>; @@ -81,9 +82,9 @@ function fallbackListBlock(params: { labels: readonly string[]; maxLabelLength?: number; }): MessagePresentationBlock | undefined { - const labels = params.labels - .map((label) => truncateText(label, params.maxLabelLength).trim()) - .filter(Boolean); + const labels = normalizeStringEntries( + params.labels.map((label) => truncateText(label, params.maxLabelLength)), + ); return labels.length > 0 ? { type: params.blockType, diff --git a/src/channels/plugins/package-state-probes.ts b/src/channels/plugins/package-state-probes.ts index abb276276a7..9591f88d053 100644 --- a/src/channels/plugins/package-state-probes.ts +++ b/src/channels/plugins/package-state-probes.ts @@ -14,6 +14,7 @@ import { type PluginModuleLoaderCache, } from "../../plugins/plugin-module-loader-cache.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { normalizeTrimmedStringList } from "../../shared/string-normalization.js"; import { loadChannelPluginModule, resolveExistingPluginModulePath } from "./module-loader.js"; type ChannelPackageStateChecker = (params: { @@ -62,15 +63,6 @@ function loadChannelPackageStateModule(params: { modulePath: string; rootDir: st } } -function normalizeStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value - .map((entry) => normalizeOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)); -} - function hasNonEmptyEnvValue(env: NodeJS.ProcessEnv | undefined, key: string): boolean { return typeof env?.[key] === "string" && env[key].trim().length > 0; } @@ -160,8 +152,8 @@ function resolveChannelPackageStateMetadata( const specifier = normalizeOptionalString(metadata.specifier) ?? ""; const exportName = normalizeOptionalString(metadata.exportName) ?? ""; const envMetadata = "env" in metadata ? metadata.env : undefined; - const allOf = normalizeStringList(envMetadata?.allOf); - const anyOf = normalizeStringList(envMetadata?.anyOf); + const allOf = normalizeTrimmedStringList(envMetadata?.allOf); + const anyOf = normalizeTrimmedStringList(envMetadata?.anyOf); const env = allOf.length > 0 || anyOf.length > 0 ? { allOf, anyOf } : undefined; if ((!specifier || !exportName) && !env) { return null; diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index d277c90715f..0fa4be3abba 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -22,6 +22,7 @@ import { type PluginModuleLoaderCache, } from "../../plugins/plugin-module-loader-cache.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import { sortUniqueStrings, uniqueStrings } from "../../shared/string-normalization.js"; import { sanitizeForLog } from "../../terminal/ansi.js"; import { getBundledChannelSetupPlugin } from "./bundled.js"; import { @@ -247,14 +248,12 @@ function listManifestChannelAccountIds(cfg: OpenClawConfig, channelId: string): const channelConfig = getChannelConfigRecord(cfg, channelId); const accounts = channelConfig.accounts; if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) { - return [ - ...new Set( - Object.keys(accounts) - .filter((accountId) => !isBlockedObjectKey(accountId)) - .map((accountId) => normalizeAccountId(accountId)) - .filter((accountId) => !isBlockedObjectKey(accountId)), - ), - ].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings( + Object.keys(accounts) + .filter((accountId) => !isBlockedObjectKey(accountId)) + .map((accountId) => normalizeAccountId(accountId)) + .filter((accountId) => !isBlockedObjectKey(accountId)), + ); } return hasExplicitChannelConfig({ config: cfg, channelId }) ? [DEFAULT_ACCOUNT_ID] : []; } @@ -767,18 +766,16 @@ export function resolveReadOnlyChannelPluginsForConfig( }).plugins; const bundledManifestRecords = listBundledChannelManifestRecords(manifestRecords); const externalManifestRecords = listExternalChannelManifestRecords(manifestRecords); - const configuredChannelIds = [ - ...new Set( - listConfiguredChannelIdsForReadOnlyScope({ - config: cfg, - activationSourceConfig: options.activationSourceConfig ?? cfg, - workspaceDir, - env, - includePersistedAuthState: options.includePersistedAuthState, - manifestRecords, - }), - ), - ].filter(isSafeManifestChannelId); + const configuredChannelIds = uniqueStrings( + listConfiguredChannelIdsForReadOnlyScope({ + config: cfg, + activationSourceConfig: options.activationSourceConfig ?? cfg, + workspaceDir, + env, + includePersistedAuthState: options.includePersistedAuthState, + manifestRecords, + }), + ).filter(isSafeManifestChannelId); const byId = new Map(); const loadFailures: ReadOnlyChannelPluginLoadFailure[] = []; diff --git a/src/channels/plugins/session-conversation.ts b/src/channels/plugins/session-conversation.ts index f7a37e075d6..62ecae8f6b4 100644 --- a/src/channels/plugins/session-conversation.ts +++ b/src/channels/plugins/session-conversation.ts @@ -10,6 +10,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { normalizeUniqueSingleOrTrimmedStringList } from "../../shared/string-normalization.js"; import { normalizeChannelId as normalizeChatChannelId } from "../registry.js"; import { getLoadedChannelPlugin, normalizeChannelId as normalizeAnyChannelId } from "./registry.js"; @@ -77,20 +78,7 @@ function getMessagingAdapter(channel: string) { } function dedupeConversationIds(values: Array): string[] { - const seen = new Set(); - const resolved: string[] = []; - for (const value of values) { - if (typeof value !== "string") { - continue; - } - const trimmed = value.trim(); - if (!trimmed || seen.has(trimmed)) { - continue; - } - seen.add(trimmed); - resolved.push(trimmed); - } - return resolved; + return normalizeUniqueSingleOrTrimmedStringList(values); } function buildGenericConversationResolution(rawId: string): ResolvedSessionConversation | null { diff --git a/src/channels/plugins/setup-group-access.ts b/src/channels/plugins/setup-group-access.ts index 321d464574e..08ddc4f69f3 100644 --- a/src/channels/plugins/setup-group-access.ts +++ b/src/channels/plugins/setup-group-access.ts @@ -1,19 +1,14 @@ +import { normalizeStringEntries } from "../../shared/string-normalization.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; export type ChannelAccessPolicy = "allowlist" | "open" | "disabled"; export function parseAllowlistEntries(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries(raw.split(/[\n,;]+/g)); } export function formatAllowlistEntries(entries: string[]): string { - return entries - .map((entry) => entry.trim()) - .filter(Boolean) - .join(", "); + return normalizeStringEntries(entries).join(", "); } export async function promptChannelAccessPolicy(params: { diff --git a/src/channels/plugins/setup-wizard-helpers.ts b/src/channels/plugins/setup-wizard-helpers.ts index c318eaa13e1..6db4e474307 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -4,7 +4,7 @@ import type { SecretInput } from "../../config/types.secrets.js"; import { resolveSecretInputModeForEnvSelection } from "../../plugins/provider-auth-mode.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; -import { normalizeStringEntries } from "../../shared/string-normalization.js"; +import { normalizeStringEntries, uniqueStrings } from "../../shared/string-normalization.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import { resolveChannelDmAllowFrom, resolveChannelDmPolicy } from "./dm-access.js"; import { @@ -75,14 +75,11 @@ export function mergeAllowFromEntries( additions: Array, ): string[] { const merged = normalizeStringEntries([...(current ?? []), ...additions]); - return [...new Set(merged)]; + return uniqueStrings(merged); } export function splitSetupEntries(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries(raw.split(/[\n,;]+/g)); } type ParsedSetupEntry = { value: string } | { error: string }; @@ -155,7 +152,7 @@ export function normalizeAllowFromEntries( return normalizeOptionalString(normalizeEntry(entry)) ?? ""; }) .filter(Boolean); - return [...new Set(normalized)]; + return uniqueStrings(normalized); } export function createStandardChannelSetupStatus(params: { diff --git a/src/channels/status/read-model.ts b/src/channels/status/read-model.ts index b822307a7b4..8a26f757288 100644 --- a/src/channels/status/read-model.ts +++ b/src/channels/status/read-model.ts @@ -1,6 +1,7 @@ import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { asRecord } from "../../shared/record-coerce.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { hasConfiguredUnavailableCredentialStatus } from "../account-snapshot-fields.js"; import type { ChannelAccountSnapshot } from "../plugins/types.public.js"; @@ -111,12 +112,10 @@ export async function resolveChannelAccountStatusRows(params: { source: "gateway" | "config"; }> > { - const mergedAccountIds = [ - ...new Set([ - ...params.localAccountIds, - ...params.runtimeAccounts.map((account) => account.accountId), - ]), - ]; + const mergedAccountIds = uniqueStrings([ + ...params.localAccountIds, + ...params.runtimeAccounts.map((account) => account.accountId), + ]); const rows: Array<{ accountId: string; snapshot: ChannelAccountSnapshot; diff --git a/src/chat/canvas-render.ts b/src/chat/canvas-render.ts index 4beee918434..1f01b82979f 100644 --- a/src/chat/canvas-render.ts +++ b/src/chat/canvas-render.ts @@ -1,4 +1,6 @@ import { parseFenceSpans } from "../markdown/fences.js"; +import { asFiniteNumber } from "../shared/number-coercion.js"; +import { asOptionalRecord } from "../shared/record-coerce.js"; type CanvasSurface = "assistant_message"; @@ -20,9 +22,7 @@ function tryParseJsonRecord(value: string | undefined): Record } try { const parsed = JSON.parse(value); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Record) - : undefined; + return asOptionalRecord(parsed); } catch { return undefined; } @@ -41,7 +41,7 @@ function getRecordNumberField( key: string, ): number | undefined { const value = record?.[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } function getNestedRecord( @@ -49,9 +49,7 @@ function getNestedRecord( key: string, ): Record | undefined { const value = record?.[key]; - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; + return asOptionalRecord(value); } function normalizeSurface(value: string | undefined): CanvasSurface | undefined { diff --git a/src/cli/channel-options.ts b/src/cli/channel-options.ts index 0591b5dcace..49778ebb9e0 100644 --- a/src/cli/channel-options.ts +++ b/src/cli/channel-options.ts @@ -1,16 +1,8 @@ +import { uniqueStrings } from "../shared/string-normalization.js"; import { readCliStartupMetadata } from "./startup-metadata.js"; function dedupe(values: string[]): string[] { - const seen = new Set(); - const resolved: string[] = []; - for (const value of values) { - if (!value || seen.has(value)) { - continue; - } - seen.add(value); - resolved.push(value); - } - return resolved; + return uniqueStrings(values.filter(Boolean)); } let precomputedChannelOptions: string[] | null | undefined; diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index 96e6dd6ff74..4e52b8d4750 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -14,7 +14,9 @@ import { discoverConfigSecretTargetsByIds, listSecretTargetRegistryEntries, } from "../secrets/target-registry.js"; +import { isRecord } from "../shared/record-coerce.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; const STATIC_QR_REMOTE_TARGET_IDS = ["gateway.remote.token", "gateway.remote.password"] as const; const STATIC_MODEL_TARGET_IDS = [ @@ -134,26 +136,22 @@ function isPluginWebFetchCredentialTargetId(id: string): boolean { } function getCapabilityWebSearchTargetIds(): string[] { - cachedCapabilityWebSearchTargetIds ??= [ - ...new Set([ - ...STATIC_CAPABILITY_WEB_SEARCH_TARGET_IDS, - ...listSecretTargetRegistryEntries() - .map((entry) => entry.id) - .filter(isPluginWebSearchCredentialTargetId), - ]), - ].toSorted(); + cachedCapabilityWebSearchTargetIds ??= sortUniqueStrings([ + ...STATIC_CAPABILITY_WEB_SEARCH_TARGET_IDS, + ...listSecretTargetRegistryEntries() + .map((entry) => entry.id) + .filter(isPluginWebSearchCredentialTargetId), + ]); return cachedCapabilityWebSearchTargetIds; } function getCapabilityWebFetchTargetIds(): string[] { - cachedCapabilityWebFetchTargetIds ??= [ - ...new Set([ - ...STATIC_CAPABILITY_WEB_FETCH_TARGET_IDS, - ...listSecretTargetRegistryEntries() - .map((entry) => entry.id) - .filter(isPluginWebFetchCredentialTargetId), - ]), - ].toSorted(); + cachedCapabilityWebFetchTargetIds ??= sortUniqueStrings([ + ...STATIC_CAPABILITY_WEB_FETCH_TARGET_IDS, + ...listSecretTargetRegistryEntries() + .map((entry) => entry.id) + .filter(isPluginWebFetchCredentialTargetId), + ]); return cachedCapabilityWebFetchTargetIds; } @@ -178,10 +176,6 @@ function resolveSearchConfig(config: OpenClawConfig): Record | : undefined; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function pathPatternMatchesConcretePath(pathPattern: string, path: string): boolean { const pathSegments = path.split("."); const patternSegments = pathPattern.split("."); diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 1575d75828a..7d34d10a7f2 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -43,7 +43,9 @@ import { discoverConfigSecretTargets, resolveConfigSecretTargetByPath, } from "../secrets/target-registry.js"; +import { isRecord as isPlainRecord } from "../shared/record-coerce.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeStringEntries, uniqueValues } from "../shared/string-normalization.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; @@ -382,7 +384,7 @@ function parsePath(raw: string): PathSegment[] { if (current) { parts.push(current); } - return parts.map((part) => part.trim()).filter(Boolean); + return normalizeStringEntries(parts); } function parseValue(raw: string, opts: ConfigSetParseOpts): unknown { @@ -406,10 +408,6 @@ function hasOwnPathKey(value: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function formatDoctorHint(message: string): string { return `Run \`${formatCliCommand("openclaw doctor --fix")}\` ${message}`; } @@ -1057,7 +1055,7 @@ function buildProviderFromBuilder(opts: ConfigSetOptions): SecretProviderConfig let provider: SecretProviderConfig; if (source === "env") { - const allowlist = (opts.providerAllowlist ?? []).map((entry) => entry.trim()).filter(Boolean); + const allowlist = normalizeStringEntries(opts.providerAllowlist); for (const envName of allowlist) { if (!isValidEnvSecretRefId(envName)) { throw new Error( @@ -1104,10 +1102,10 @@ function buildProviderFromBuilder(opts: ConfigSetOptions): SecretProviderConfig ...(opts.providerJsonOnly ? { jsonOnly: true } : {}), ...(providerEnv ? { env: providerEnv } : {}), ...(opts.providerPassEnv && opts.providerPassEnv.length > 0 - ? { passEnv: opts.providerPassEnv.map((entry) => entry.trim()).filter(Boolean) } + ? { passEnv: normalizeStringEntries(opts.providerPassEnv) } : {}), ...(opts.providerTrustedDir && opts.providerTrustedDir.length > 0 - ? { trustedDirs: opts.providerTrustedDir.map((entry) => entry.trim()).filter(Boolean) } + ? { trustedDirs: normalizeStringEntries(opts.providerTrustedDir) } : {}), ...(opts.providerAllowInsecurePath ? { allowInsecurePath: true } : {}), ...(opts.providerAllowSymlinkCommand ? { allowSymlinkCommand: true } : {}), @@ -1978,7 +1976,7 @@ async function runConfigOperations(params: { ok: dedupedErrors.length === 0, operations: operations.length, configPath: shortenHomePath(snapshot.path), - inputModes: [...new Set(operations.map((operation) => operation.inputMode))], + inputModes: uniqueValues(operations.map((operation) => operation.inputMode)), checks: { schema: requiresFullSchemaValidation || policyIssueLines.length > 0, resolvability: hasJsonMode || hasBuilderMode || hasUnsetMode, diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 50fc084ca9e..75844942b33 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -38,6 +38,7 @@ import { } from "../../infra/restart-handoff.js"; import { resolveConfiguredLogFilePath } from "../../logging/log-file-path.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { VERSION } from "../../version.js"; import { normalizeListenerAddress, parsePortFromArgs, pickProbeHostForBind } from "./shared.js"; import type { GatewayRpcOpts } from "./types.js"; @@ -258,7 +259,7 @@ function appendProbeNote( if (values.length === 0) { return undefined; } - return [...new Set(values)].join(" "); + return uniqueStrings(values).join(" "); } export type DaemonStatus = { cli?: CliStatusSummary; diff --git a/src/cli/devices-cli.runtime.ts b/src/cli/devices-cli.runtime.ts index 09fb6b69a43..81a77b1e6a8 100644 --- a/src/cli/devices-cli.runtime.ts +++ b/src/cli/devices-cli.runtime.ts @@ -30,6 +30,7 @@ import { normalizeOptionalString, normalizeStringifiedOptionalString, } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; @@ -403,7 +404,7 @@ function resolveOriginalReplacementScopes( ): string[] { const requestedScopes = normalizeDeviceAuthScopes(original.scopes); const inferredOperatorScopes = resolvePendingOperatorApprovalScopes(original, paired); - return [...new Set([...requestedScopes, ...inferredOperatorScopes])]; + return uniqueStrings([...requestedScopes, ...inferredOperatorScopes]); } function replacementScopesCoverOriginal( diff --git a/src/cli/gateway-cli/qa-parent-watchdog.ts b/src/cli/gateway-cli/qa-parent-watchdog.ts index 63b444a8ec3..dd62e339694 100644 --- a/src/cli/gateway-cli/qa-parent-watchdog.ts +++ b/src/cli/gateway-cli/qa-parent-watchdog.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; export const QA_PARENT_PID_ENV = "OPENCLAW_QA_PARENT_PID"; export const QA_TEMP_ROOT_ENV = "OPENCLAW_QA_TEMP_ROOT"; @@ -59,12 +60,12 @@ function resolveQaCleanupRoot(rawValue: string | undefined): string | null { } function resolveQaCleanupRoots(env: NodeJS.ProcessEnv): string[] { - return [ - resolveQaCleanupRoot(env[QA_TEMP_ROOT_ENV]), - resolveQaCleanupRoot(env[QA_STAGED_RUNTIME_ROOT_ENV]), - ].filter((target, index, array): target is string => { - return target !== null && array.indexOf(target) === index; - }); + return uniqueStrings( + [ + resolveQaCleanupRoot(env[QA_TEMP_ROOT_ENV]), + resolveQaCleanupRoot(env[QA_STAGED_RUNTIME_ROOT_ENV]), + ].filter((target): target is string => target !== null), + ); } function pathContains(root: string, candidate: string): boolean { diff --git a/src/cli/nodes-media-utils.ts b/src/cli/nodes-media-utils.ts index 5b28c724bfa..82526f86e0d 100644 --- a/src/cli/nodes-media-utils.ts +++ b/src/cli/nodes-media-utils.ts @@ -1,19 +1,13 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +export { asFiniteNumber as asNumber } from "../shared/number-coercion.js"; import { readStringValue } from "../shared/string-coerce.js"; export { asRecord } from "../shared/record-coerce.js"; +export { asBoolean } from "../utils/boolean.js"; export const asString = readStringValue; -export function asNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -export function asBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - export function resolveTempPathParts(opts: { ext: string; tmpDir?: string; id?: string }): { ext: string; tmpDir: string; diff --git a/src/cli/plugin-install-config-policy.ts b/src/cli/plugin-install-config-policy.ts index bbdbc7cd000..3b44c7edaec 100644 --- a/src/cli/plugin-install-config-policy.ts +++ b/src/cli/plugin-install-config-policy.ts @@ -9,6 +9,7 @@ import { resolveOfficialExternalPluginId, resolveOfficialExternalPluginInstall, } from "../plugins/official-external-plugin-catalog.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { resolveUserPath } from "../utils.js"; import { parseNpmPrefixSpec, resolveFileNpmSpecToLocalPath } from "./plugins-command-helpers.js"; @@ -110,9 +111,12 @@ function resolveOfficialExternalInstallRecoveryMetadata( const rawNpmPrefixSpec = parseNpmPrefixSpec(request.rawSpec); const normalizedNpmPrefixSpec = parseNpmPrefixSpec(request.normalizedSpec); const values = new Set( - [request.rawSpec, request.normalizedSpec, rawNpmPrefixSpec ?? "", normalizedNpmPrefixSpec ?? ""] - .map((value) => value.trim()) - .filter(Boolean), + normalizeStringEntries([ + request.rawSpec, + request.normalizedSpec, + rawNpmPrefixSpec ?? "", + normalizedNpmPrefixSpec ?? "", + ]), ); if (values.size === 0) { return {}; diff --git a/src/cli/plugins-authoring-command.ts b/src/cli/plugins-authoring-command.ts index 7bc408e8015..b641a2a0fe0 100644 --- a/src/cli/plugins-authoring-command.ts +++ b/src/cli/plugins-authoring-command.ts @@ -14,6 +14,7 @@ import { import { buildPluginLoaderAliasMap } from "../plugins/sdk-alias.js"; import { defaultRuntime } from "../runtime.js"; import { toSafeImportPath } from "../shared/import-specifier.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { isRecord } from "../utils.js"; type JsonObject = Record; @@ -197,7 +198,7 @@ export function buildToolPluginPackageManifest(params: { const existingExtensions = Array.isArray(openclaw.extensions) ? openclaw.extensions.filter((entry): entry is string => typeof entry === "string") : []; - const extensions = [...new Set([...existingExtensions, params.entry])]; + const extensions = uniqueStrings([...existingExtensions, params.entry]); return { ...params.packageManifest, openclaw: { diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 439000e39c7..890f51c08ee 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -31,6 +31,8 @@ import { import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { isRecord } from "../shared/record-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; @@ -93,10 +95,6 @@ function findTrustedCatalogPackageInstall(packageName: string): }; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function isEmptyRecord(value: Record): boolean { return Object.keys(value).length === 0; } @@ -204,7 +202,7 @@ async function tryInstallHookPackFromLocalPath(params: { } const existing = params.snapshot.config.hooks?.internal?.load?.extraDirs ?? []; - const merged = Array.from(new Set([...existing, params.resolvedPath])); + const merged = uniqueStrings([...existing, params.resolvedPath]); await persistHookPackInstall({ snapshot: { config: { @@ -687,7 +685,7 @@ export async function runPluginInstallCommand(params: { if (fs.existsSync(resolved)) { if (opts.link) { const existing = cfg.plugins?.load?.paths ?? []; - const merged = Array.from(new Set([...existing, resolved])); + const merged = uniqueStrings([...existing, resolved]); const probe = await installPluginFromPath({ ...safetyOverrides, mode: installMode, diff --git a/src/cli/program/register-command-groups.ts b/src/cli/program/register-command-groups.ts index f8dcb89a0d0..6443fee0937 100644 --- a/src/cli/program/register-command-groups.ts +++ b/src/cli/program/register-command-groups.ts @@ -1,4 +1,5 @@ import type { Command } from "commander"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { removeCommandByName } from "./command-tree.js"; import { registerLazyCommand } from "./register-lazy-command.js"; @@ -60,7 +61,7 @@ export function registerLazyCommandGroup( name: placeholder.name, description: placeholder.description, options: placeholder.options, - removeNames: [...new Set(getCommandGroupNames(entry))], + removeNames: uniqueStrings(getCommandGroupNames(entry)), register: async () => { await entry.register(program); }, diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 4b7e952382c..f78f977c3db 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -96,6 +96,7 @@ import { } from "../../plugins/update.js"; import { runCommandWithTimeout } from "../../process/exec.js"; import { defaultRuntime } from "../../runtime.js"; +import { isRecord } from "../../shared/record-coerce.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { stylePromptMessage } from "../../terminal/prompt-style.js"; import { theme } from "../../terminal/theme.js"; @@ -235,10 +236,6 @@ function isTrackedPackageInstallRecord(record: PluginInstallRecord): boolean { ); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function normalizePluginInstallRecordMap(value: unknown): Record { if (!isRecord(value)) { return {}; diff --git a/src/commands/agent-command.test-mocks.ts b/src/commands/agent-command.test-mocks.ts index 7c3e265f8f7..a5cf06d7f09 100644 --- a/src/commands/agent-command.test-mocks.ts +++ b/src/commands/agent-command.test-mocks.ts @@ -1,4 +1,5 @@ import { vi } from "vitest"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; vi.mock("../logging/subsystem.js", () => { const createMockLogger = () => ({ @@ -255,7 +256,7 @@ vi.mock("../agents/skills/refresh-state.js", () => ({ vi.mock("../agents/skills/filter.js", () => ({ normalizeSkillFilter: vi.fn((skillFilter?: ReadonlyArray) => - skillFilter?.map((entry) => String(entry).trim()).filter(Boolean), + skillFilter ? normalizeStringEntries(skillFilter) : undefined, ), normalizeSkillFilterForComparison: vi.fn((skillFilter?: ReadonlyArray) => skillFilter diff --git a/src/commands/agents.bindings.ts b/src/commands/agents.bindings.ts index 7eb1f4ea7f2..ab02ed9e240 100644 --- a/src/commands/agents.bindings.ts +++ b/src/commands/agents.bindings.ts @@ -10,7 +10,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { listManifestChannelContributionIds } from "../plugins/manifest-contribution-ids.js"; import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { normalizeStringEntries } from "../shared/string-normalization.js"; +import { normalizeSortedUniqueStringEntries } from "../shared/string-normalization.js"; import type { ChannelChoice } from "./onboard-types.js"; export { describeBinding } from "./agents.binding-format.js"; @@ -22,9 +22,7 @@ function bindingMatchKey(match: AgentRouteBinding["match"]) { } function bindingMatchIdentityKey(match: AgentRouteBinding["match"]) { - const roles = Array.isArray(match.roles) - ? Array.from(new Set(normalizeStringEntries(match.roles).toSorted())) - : []; + const roles = Array.isArray(match.roles) ? normalizeSortedUniqueStringEntries(match.roles) : []; return JSON.stringify([ match.channel, match.peer?.kind ?? "", diff --git a/src/commands/agents.commands.bind.ts b/src/commands/agents.commands.bind.ts index b73ab64d9de..fe63e58909c 100644 --- a/src/commands/agents.commands.bind.ts +++ b/src/commands/agents.commands.bind.ts @@ -8,6 +8,7 @@ import { normalizeAgentId } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { describeBinding } from "./agents.binding-format.js"; import { requireValidConfig, requireValidConfigFileSnapshot } from "./agents.command-shared.js"; @@ -115,7 +116,7 @@ async function resolveParsedBindingsOrExit(params: { bindings: AgentRouteBinding[]; errors: string[]; } | null> { - const specs = (params.bindValues ?? []).map((value) => value.trim()).filter(Boolean); + const specs = normalizeStringEntries(params.bindValues); if (specs.length === 0) { params.runtime.error(params.emptyMessage); params.runtime.exit(1); diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts index 3f968f28347..f5f96c5e814 100644 --- a/src/commands/agents.config.ts +++ b/src/commands/agents.config.ts @@ -11,6 +11,7 @@ import type { IdentityConfig } from "../config/types.base.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeOptionalString, resolvePrimaryStringValue } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; export type AgentSummary = { id: string; @@ -70,7 +71,7 @@ export function buildAgentSummaries(cfg: OpenClawConfig): AgentSummary[] { bindingCounts.set(agentId, (bindingCounts.get(agentId) ?? 0) + 1); } - const ordered = orderedIds.filter((id, index) => orderedIds.indexOf(id) === index); + const ordered = uniqueStrings(orderedIds); return ordered.map((id) => { const workspace = resolveAgentWorkspaceDir(cfg, id); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index a6dac84db95..470fe565918 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -1,6 +1,7 @@ import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveProviderSetupFlowContributions } from "../flows/provider-flow.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { CORE_AUTH_CHOICE_OPTIONS, type AuthChoiceGroup, @@ -88,7 +89,7 @@ export function formatAuthChoiceChoicesForCli(params?: { }).map((contribution) => contribution.option.value), ]; - return [...new Set(values)].join("|"); + return uniqueStrings(values).join("|"); } export function buildAuthChoiceOptions(params: { diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index 69cf1fe5b3b..b20c432035d 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -21,6 +21,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../../shared/string-coerce.js"; +import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js"; export type ChannelsResolveOptions = { @@ -130,7 +131,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti runtime, autoEnable: true, }); - const entries = (opts.entries ?? []).map((entry) => entry.trim()).filter(Boolean); + const entries = normalizeStringEntries(opts.entries); if (entries.length === 0) { throw new Error( `At least one entry is required. Example: ${formatCliCommand("openclaw channels resolve --channel discord ")}.`, diff --git a/src/commands/commitments.ts b/src/commands/commitments.ts index d5586a937c4..2d72784190e 100644 --- a/src/commands/commitments.ts +++ b/src/commands/commitments.ts @@ -9,6 +9,7 @@ import { getRuntimeConfig } from "../config/config.js"; import { info } from "../globals.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { sanitizeTerminalText } from "../terminal/safe-text.js"; import { isRich, theme } from "../terminal/theme.js"; @@ -137,7 +138,7 @@ export async function commitmentsDismissCommand( opts: { ids: string[]; json?: boolean }, runtime: RuntimeEnv, ): Promise { - const ids = opts.ids.map((id) => id.trim()).filter(Boolean); + const ids = normalizeStringEntries(opts.ids); if (ids.length === 0) { runtime.error( `At least one commitment id is required. Run ${formatCliCommand("openclaw commitments list")} to choose one.`, diff --git a/src/commands/docs.ts b/src/commands/docs.ts index 640f57949b8..9e1a7a7446a 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -2,6 +2,7 @@ import { hasBinary } from "../agents/skills.js"; import { formatCliCommand } from "../cli/command-format.js"; import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { formatDocsLink } from "../terminal/links.js"; import { isRich, theme } from "../terminal/theme.js"; @@ -88,10 +89,7 @@ function firstParagraph(text: string): string { function parseSearchOutput(raw: string): DocResult[] { const normalized = raw.replace(/\r/g, ""); - const blocks = normalized - .split(/\n(?=Title: )/g) - .map((chunk) => chunk.trim()) - .filter(Boolean); + const blocks = normalizeStringEntries(normalized.split(/\n(?=Title: )/g)); const results: DocResult[] = []; for (const block of blocks) { diff --git a/src/commands/doctor-auth-flat-profiles.ts b/src/commands/doctor-auth-flat-profiles.ts index 2812ecd3534..9e955e7a881 100644 --- a/src/commands/doctor-auth-flat-profiles.ts +++ b/src/commands/doctor-auth-flat-profiles.ts @@ -13,6 +13,7 @@ import { resolveStateDir } from "../config/paths.js"; import type { AuthProfileConfig } from "../config/types.auth.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadJsonFile } from "../infra/json-file.js"; +import { isRecord } from "../shared/record-coerce.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; @@ -50,10 +51,6 @@ export type LegacyFlatAuthProfileRepairResult = { const UNSAFE_LEGACY_AUTH_PROFILE_KEYS = new Set(["__proto__", "constructor", "prototype"]); -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } diff --git a/src/commands/doctor-auth-oauth-sidecar.ts b/src/commands/doctor-auth-oauth-sidecar.ts index 35ce2cec07d..7a9f5855ed6 100644 --- a/src/commands/doctor-auth-oauth-sidecar.ts +++ b/src/commands/doctor-auth-oauth-sidecar.ts @@ -17,6 +17,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; +import { isRecord } from "../shared/record-coerce.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; @@ -49,10 +50,6 @@ export type LegacyOAuthSidecarRepairResult = { warnings: string[]; }; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } diff --git a/src/commands/doctor-command-owner.ts b/src/commands/doctor-command-owner.ts index cfae9771f41..35759a355e1 100644 --- a/src/commands/doctor-command-owner.ts +++ b/src/commands/doctor-command-owner.ts @@ -2,6 +2,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PairingChannel } from "../pairing/pairing-store.types.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { note } from "../terminal/note.js"; function resolveConfiguredCommandOwners(cfg: OpenClawConfig): string[] { @@ -9,7 +10,7 @@ function resolveConfiguredCommandOwners(cfg: OpenClawConfig): string[] { if (!Array.isArray(owners)) { return []; } - return owners.map((entry) => normalizeOptionalString(String(entry ?? "")) ?? "").filter(Boolean); + return normalizeStringEntries(owners.map((entry) => String(entry ?? ""))); } export function hasConfiguredCommandOwners(cfg: OpenClawConfig): boolean { diff --git a/src/commands/doctor-device-pairing.ts b/src/commands/doctor-device-pairing.ts index ea636a9bac9..54ce2d99384 100644 --- a/src/commands/doctor-device-pairing.ts +++ b/src/commands/doctor-device-pairing.ts @@ -15,6 +15,7 @@ import { JsonFileReadError, tryReadJsonSync } from "../infra/json-files.js"; import type { DeviceAuthStore } from "../shared/device-auth.js"; import { normalizeDeviceAuthScopes } from "../shared/device-auth.js"; import { roleScopesAllow } from "../shared/operator-scope-compat.js"; +import { normalizeUniqueSingleOrTrimmedStringList } from "../shared/string-normalization.js"; import { note } from "../terminal/note.js"; import { sanitizeTerminalText } from "../terminal/safe-text.js"; @@ -103,29 +104,6 @@ function isDeviceAuthStoreTokenEntry(value: unknown): value is DeviceAuthStore[" ); } -function uniqueStrings(...items: Array): string[] { - const values = new Set(); - for (const item of items) { - if (!item) { - continue; - } - if (Array.isArray(item)) { - for (const value of item) { - const trimmed = value.trim(); - if (trimmed) { - values.add(trimmed); - } - } - continue; - } - const trimmed = item.trim(); - if (trimmed) { - values.add(trimmed); - } - } - return [...values]; -} - function normalizeGatewayPairedDevice(device: GatewayListedPairedDevice): DoctorPairedDevice { return { ...device, @@ -273,7 +251,9 @@ function resolvePendingPairingIssue( removeCommand: formatCliArgs(["openclaw", "devices", "remove", pending.deviceId]), }; } - const requestedRoles = uniqueStrings(pending.roles, pending.role); + const requestedRoles = normalizeUniqueSingleOrTrimmedStringList( + [pending.roles, pending.role].flat(), + ); const approvedRoles = listApprovedPairedDeviceRoles(paired); if (requestedRoles.some((role) => !approvedRoles.includes(role))) { return { diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 003bb8a1673..a76c537c99d 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -36,6 +36,7 @@ import { import { defaultSlotIdForKey } from "../plugins/slots.js"; import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { note } from "../terminal/note.js"; import { resolveUserPath } from "../utils.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; @@ -625,7 +626,9 @@ function resolvePrimaryMemoryProviderEnvVar(provider: string): string { } function formatMemoryProviderEnvVarList(providers: Array<{ envVars: string[] }>): string { - return [...new Set(providers.flatMap((provider) => provider.envVars).filter(Boolean))].join(", "); + return uniqueStrings(providers.flatMap((provider) => provider.envVars).filter(Boolean)).join( + ", ", + ); } function buildGatewayProbeWarning( diff --git a/src/commands/doctor-plugin-registry.ts b/src/commands/doctor-plugin-registry.ts index 9b5085e52c7..bb2504b7d0c 100644 --- a/src/commands/doctor-plugin-registry.ts +++ b/src/commands/doctor-plugin-registry.ts @@ -20,6 +20,7 @@ import { listStaleLocalBundledPluginInstallRecords, type StaleLocalBundledPluginInstallRecord, } from "../plugins/stale-local-bundled-plugin-install-records.js"; +import { isRecord } from "../shared/record-coerce.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; @@ -49,10 +50,6 @@ type PluginRegistryDoctorNoteLogger = { warn: (message: string) => void; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function readJsonObject(filePath: string): Record | null { const parsed = tryReadJsonSync(filePath); return isRecord(parsed) ? parsed : null; diff --git a/src/commands/doctor-session-snapshots.ts b/src/commands/doctor-session-snapshots.ts index 81dca7efde2..bef3efab7fd 100644 --- a/src/commands/doctor-session-snapshots.ts +++ b/src/commands/doctor-session-snapshots.ts @@ -6,6 +6,7 @@ import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions/targe import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { expandHomePrefix } from "../infra/home-dir.js"; +import { isRecord } from "../shared/record-coerce.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; @@ -50,10 +51,6 @@ function extractSkillLocations(prompt: unknown): string[] { return locations; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function collectResolvedSkillPaths(value: unknown): string[] { if (!Array.isArray(value)) { return []; diff --git a/src/commands/doctor-session-state-providers.ts b/src/commands/doctor-session-state-providers.ts index 4cc33ca4e03..38f1f7f9eb7 100644 --- a/src/commands/doctor-session-state-providers.ts +++ b/src/commands/doctor-session-state-providers.ts @@ -16,6 +16,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { listPluginDoctorSessionRouteStateOwners } from "../plugins/doctor-contract-registry.js"; import type { DoctorSessionRouteStateOwner } from "../plugins/doctor-session-route-state-owner-types.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; +import { normalizeOptionalString as normalizeString } from "../shared/string-coerce.js"; +import { normalizeStringEntriesLower } from "../shared/string-normalization.js"; import { note } from "../terminal/note.js"; type DoctorPrompterLike = { @@ -31,16 +33,12 @@ function countLabel(count: number, singular: string, plural = `${singular}s`): s return `${count} ${count === 1 ? singular : plural}`; } -function normalizeString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function normalizeIdSet(values: readonly string[] | undefined): Set { return new Set((values ?? []).map((value) => normalizeProviderId(value))); } function normalizePrefixList(values: readonly string[] | undefined): string[] { - return (values ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean); + return normalizeStringEntriesLower(values); } function ownsPrefixedValue(prefixes: readonly string[], value: unknown): boolean { diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 6949ef266fc..5439bc65084 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -31,6 +31,7 @@ import { normalizeAgentId } from "../routing/session-key.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; import { asNullableObjectRecord } from "../shared/record-coerce.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; import { repairHeartbeatPoisonedMainSession } from "./doctor-heartbeat-main-session-repair.js"; @@ -929,12 +930,12 @@ export async function noteStateIntegrity( } } - const wedgedReasons = wedgedSubagentSessions - .map(([, entry]) => formatSubagentRecoveryWedgedReason(entry)) - .filter((reason, index, all) => all.indexOf(reason) === index) - .slice(0, 2); - if (wedgedReasons.length > 0) { - warnings.push(wedgedReasons.map((reason) => ` Reason: ${reason}`).join("\n")); + const wedgedReasons = wedgedSubagentSessions.map(([, entry]) => + formatSubagentRecoveryWedgedReason(entry), + ); + const visibleWedgedReasons = uniqueStrings(wedgedReasons).slice(0, 2); + if (visibleWedgedReasons.length > 0) { + warnings.push(visibleWedgedReasons.map((reason) => ` Reason: ${reason}`).join("\n")); } } diff --git a/src/commands/doctor/shared/allowfrom-fallback-migration.ts b/src/commands/doctor/shared/allowfrom-fallback-migration.ts index c8c3e071bfd..11aaf2d61a5 100644 --- a/src/commands/doctor/shared/allowfrom-fallback-migration.ts +++ b/src/commands/doctor/shared/allowfrom-fallback-migration.ts @@ -2,7 +2,7 @@ import { resolveChannelDmAllowFrom } from "../../../channels/plugins/dm-access.j import { normalizeAnyChannelId } from "../../../channels/registry.js"; import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "../../../config/bundled-channel-config-metadata.generated.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { normalizeStringEntries } from "../../../shared/string-normalization.js"; +import { normalizeUniqueStringEntries } from "../../../shared/string-normalization.js"; import { getDoctorChannelCapabilities } from "../channel-capabilities.js"; import { asObjectRecord } from "./object.js"; @@ -23,7 +23,7 @@ function isDisabled(record: ChannelRecord): boolean { } function normalizeAllowFrom(raw: unknown): string[] { - return Array.from(new Set(normalizeStringEntries(Array.isArray(raw) ? raw : []))); + return normalizeUniqueStringEntries(Array.isArray(raw) ? raw : []); } function readGroupAllowFrom(record: ChannelRecord): string[] { @@ -53,7 +53,9 @@ function readOwnDmAllowFrom(params: { channelName: string; account: ChannelRecor ); } -function findGeneratedChannelConfigSchema(channelName: string): Record | undefined { +function findGeneratedChannelConfigSchema( + channelName: string, +): Record | undefined { const normalizedChannelId = normalizeAnyChannelId(channelName); return GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.find( (entry) => entry.channelId === channelName || entry.channelId === normalizedChannelId, diff --git a/src/commands/doctor/shared/allowlist-policy-repair.ts b/src/commands/doctor/shared/allowlist-policy-repair.ts index 2de0c81f9c9..df0863679b5 100644 --- a/src/commands/doctor/shared/allowlist-policy-repair.ts +++ b/src/commands/doctor/shared/allowlist-policy-repair.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js"; import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js"; -import { normalizeStringEntries } from "../../../shared/string-normalization.js"; +import { normalizeUniqueStringEntries } from "../../../shared/string-normalization.js"; import { resolveAllowFromMode, type AllowFromMode } from "./allow-from-mode.js"; import { hasAllowFromEntries } from "./allowlist.js"; import { asObjectRecord } from "./object.js"; @@ -74,7 +74,7 @@ export async function maybeRepairAllowlistPolicyAllowFrom(cfg: OpenClawConfig): process.env, normalizedAccountId, ).catch(() => []); - const recovered = Array.from(new Set(normalizeStringEntries(fromStore))); + const recovered = normalizeUniqueStringEntries(fromStore); if (recovered.length === 0) { return; } diff --git a/src/commands/doctor/shared/codex-native-assets.ts b/src/commands/doctor/shared/codex-native-assets.ts index 18eeaa06dc4..d98712ad7c8 100644 --- a/src/commands/doctor/shared/codex-native-assets.ts +++ b/src/commands/doctor/shared/codex-native-assets.ts @@ -4,6 +4,8 @@ import os from "node:os"; import path from "node:path"; import { collectConfiguredAgentHarnessRuntimes } from "../../../agents/harness-runtimes.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { isRecord as hasRecord } from "../../../shared/record-coerce.js"; +import { normalizeOptionalLowercaseString as normalizeString } from "../../../shared/string-coerce.js"; export type CodexNativeAssetHit = { kind: "skill" | "plugin" | "config" | "hooks"; @@ -13,14 +15,6 @@ export type CodexNativeAssetHit = { const MAX_SCAN_DEPTH = 6; const MAX_DISCOVERED_DIRS = 2000; -function hasRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function normalizeString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined; -} - function resolveUserHome(env: NodeJS.ProcessEnv): string { return env.HOME?.trim() || os.homedir(); } diff --git a/src/commands/doctor/shared/codex-route-warnings.ts b/src/commands/doctor/shared/codex-route-warnings.ts index bce1a26ce00..c469336405b 100644 --- a/src/commands/doctor/shared/codex-route-warnings.ts +++ b/src/commands/doctor/shared/codex-route-warnings.ts @@ -15,6 +15,8 @@ import type { AgentRuntimePolicyConfig } from "../../../config/types.agents-shar import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { detectWindowsSpawnCommandInlineArgs } from "../../../plugin-sdk/windows-spawn.js"; import { normalizeAgentId } from "../../../routing/session-key.js"; +import { asOptionalRecord as asMutableRecord } from "../../../shared/record-coerce.js"; +import { normalizeOptionalLowercaseString as normalizeString } from "../../../shared/string-coerce.js"; type CodexRouteHit = { path: string; @@ -65,21 +67,11 @@ type CodexSessionRouteRepairSummary = { changes: string[]; }; -function normalizeString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined; -} - function normalizeRuntimeString(value: unknown): string | undefined { const normalized = normalizeString(value); return normalized ? normalizeEmbeddedAgentRuntime(normalized) : undefined; } -function asMutableRecord(value: unknown): MutableRecord | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as MutableRecord) - : undefined; -} - function asAgentRuntimePolicyConfig(value: unknown): AgentRuntimePolicyConfig | undefined { const record = asMutableRecord(value); return record ? { id: typeof record.id === "string" ? record.id : undefined } : undefined; diff --git a/src/commands/doctor/shared/configured-runtime-plugin-installs.ts b/src/commands/doctor/shared/configured-runtime-plugin-installs.ts index e17f4c3636c..b3a03626d43 100644 --- a/src/commands/doctor/shared/configured-runtime-plugin-installs.ts +++ b/src/commands/doctor/shared/configured-runtime-plugin-installs.ts @@ -4,6 +4,7 @@ import { } from "../../../agents/harness-runtimes.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { PluginPackageInstall } from "../../../plugins/manifest.js"; +import { asOptionalRecord } from "../../../shared/record-coerce.js"; export type ConfiguredRuntimePluginInstallCandidate = { pluginId: string; @@ -39,17 +40,13 @@ export function resolveConfiguredRuntimePluginInstallCandidate( ); } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function acpxRuntimeIsConfigured(cfg: OpenClawConfig): boolean { - const acp = asRecord(cfg.acp); + const acp = asOptionalRecord(cfg.acp); const backend = typeof acp?.backend === "string" ? acp.backend.trim().toLowerCase() : ""; return ( - (backend === "acpx" || acp?.enabled === true || asRecord(acp?.dispatch)?.enabled === true) && + (backend === "acpx" || + acp?.enabled === true || + asOptionalRecord(acp?.dispatch)?.enabled === true) && (!backend || backend === "acpx") ); } diff --git a/src/commands/doctor/shared/context-engine-host-compat.ts b/src/commands/doctor/shared/context-engine-host-compat.ts index ce8d557d278..a086c1f5510 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.ts @@ -18,6 +18,7 @@ import { getContextEngineFactory, resolveContextEngine } from "../../../context- import type { ContextEngineInfo } from "../../../context-engine/types.js"; import { ensurePluginRegistryLoaded } from "../../../plugins/runtime/runtime-registry-loader.js"; import { defaultSlotIdForKey } from "../../../plugins/slots.js"; +import { uniqueStrings } from "../../../shared/string-normalization.js"; import { isRecord, resolveUserPath } from "../../../utils.js"; export type HostCandidate = { @@ -326,7 +327,7 @@ function collectHostCompatibilityIssues(params: { } function formatPaths(paths: string[]): string { - const unique = [...new Set(paths)]; + const unique = uniqueStrings(paths); if (unique.length <= 2) { return unique.join(", "); } diff --git a/src/commands/doctor/shared/legacy-talk-config-normalizer.ts b/src/commands/doctor/shared/legacy-talk-config-normalizer.ts index a91fdc44ca3..02d4fd4b81b 100644 --- a/src/commands/doctor/shared/legacy-talk-config-normalizer.ts +++ b/src/commands/doctor/shared/legacy-talk-config-normalizer.ts @@ -1,6 +1,7 @@ import { isDeepStrictEqual } from "node:util"; import { normalizeTalkSection } from "../../../config/talk.js"; import type { OpenClawConfig } from "../../../config/types.js"; +import { isRecord } from "../../../shared/record-coerce.js"; function buildLegacyTalkProviderCompat( talk: Record, @@ -39,10 +40,6 @@ function buildLegacyRealtimeTalkCompat( return normalizeTalkSection({ realtime: compat } as OpenClawConfig["talk"])?.realtime; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - export function normalizeLegacyTalkConfig(cfg: OpenClawConfig, changes: string[]): OpenClawConfig { const rawTalk = cfg.talk; if (!isRecord(rawTalk)) { diff --git a/src/commands/doctor/shared/plugin-runtime-symlinks.ts b/src/commands/doctor/shared/plugin-runtime-symlinks.ts index 505e283aa94..f9dae19b0ea 100644 --- a/src/commands/doctor/shared/plugin-runtime-symlinks.ts +++ b/src/commands/doctor/shared/plugin-runtime-symlinks.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { sortUniqueStrings } from "../../../shared/string-normalization.js"; import { note } from "../../../terminal/note.js"; import { shortenHomePath } from "../../../utils.js"; @@ -142,9 +143,7 @@ export async function removeStalePluginRuntimeSymlinks( } function uniqueResolvedRoots(values: readonly string[]): string[] { - return [...new Set(values.map((value) => path.resolve(value)))].toSorted((left, right) => - left.localeCompare(right), - ); + return sortUniqueStrings(values.map((value) => path.resolve(value))); } function isPathInsideRoot(candidate: string, root: string): boolean { diff --git a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts index 872532dc687..1dafaeb48ea 100644 --- a/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts +++ b/src/commands/doctor/shared/plugin-tool-allowlist-warnings.ts @@ -14,7 +14,9 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { normalizePluginId } from "../../../plugins/config-state.js"; import { loadManifestMetadataSnapshot } from "../../../plugins/manifest-contract-eligibility.js"; import type { PluginManifestRegistry } from "../../../plugins/manifest-registry.js"; +import { isRecord as hasRecord } from "../../../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; +import { sortUniqueStrings, uniqueStrings } from "../../../shared/string-normalization.js"; type ToolAllowlistSource = { label: string; @@ -42,10 +44,6 @@ type ToolPolicyConfig = { byProvider?: unknown; }; -function hasRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function normalizePluginIdMaybe(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? normalizePluginId(value) : undefined; } @@ -99,7 +97,7 @@ function collectToolAllowlistSources(cfg: OpenClawConfig): ToolAllowlistSource[] } function collectSortedSourceLabels(labels: Iterable): string[] { - return [...new Set(labels)].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings(labels); } function formatSortedSourceLabels(sorted: readonly string[]): string { @@ -301,7 +299,9 @@ function buildEffectiveSandboxToolPolicy(params: { Boolean(label), ); const labels = allowLabels.length > 0 ? allowLabels : ["tools.sandbox.tools.alsoAllow (unset)"]; - const dedupeLabels = Array.from(new Set([...labels, deny.label].filter(Boolean))); + const dedupeLabels = uniqueStrings( + [...labels, deny.label].filter((label): label is string => Boolean(label)), + ); return { labels, diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 2bcc84d0a3d..345af16de6d 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -10,6 +10,7 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { AgentToolsConfig, ToolsConfig } from "../../../config/types.tools.js"; import { collectChannelRouteTargets } from "../../../routing/channel-route-targets.js"; import { createLazyImportLoader } from "../../../shared/lazy-promise.js"; +import { isRecord as hasRecord } from "../../../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; type ChannelDoctorModule = typeof import("./channel-doctor.js"); @@ -22,10 +23,6 @@ function loadChannelDoctorModule(): Promise { return channelDoctorModuleLoader.load(); } -function hasRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function listAgentRecords(cfg: OpenClawConfig): Record[] { return Array.isArray(cfg.agents?.list) ? cfg.agents.list.filter(hasRecord) : []; } diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index 0be9b473677..3f000426148 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -9,6 +9,7 @@ import { compareOpenClawVersions } from "../../../config/version.js"; import { getOfficialExternalPluginCatalogEntry } from "../../../plugins/official-external-plugin-catalog.js"; import { resolveProviderInstallCatalogEntries } from "../../../plugins/provider-install-catalog.js"; import { resolveWebSearchInstallCatalogEntry } from "../../../plugins/web-search-install-catalog.js"; +import { normalizeNullableString as normalizeId } from "../../../shared/string-coerce.js"; import { VERSION } from "../../../version.js"; import { repairMissingPluginInstallsForIds } from "./missing-configured-plugin-install.js"; import { asObjectRecord } from "./object.js"; @@ -26,10 +27,6 @@ type ReleaseConfiguredPluginIds = { channelIds: string[]; }; -function normalizeId(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function isPluginsGloballyDisabled(cfg: OpenClawConfig): boolean { return cfg.plugins?.enabled === false; } diff --git a/src/commands/doctor/shared/stale-oauth-profile-shadows.ts b/src/commands/doctor/shared/stale-oauth-profile-shadows.ts index d2474781c7a..176cc4bd679 100644 --- a/src/commands/doctor/shared/stale-oauth-profile-shadows.ts +++ b/src/commands/doctor/shared/stale-oauth-profile-shadows.ts @@ -18,6 +18,7 @@ import type { AuthProfileStore, OAuthCredential } from "../../../agents/auth-pro import { resolveStateDir } from "../../../config/paths.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { withFileLock } from "../../../infra/file-lock.js"; +import { isRecord } from "../../../shared/record-coerce.js"; import { shortenHomePath } from "../../../utils.js"; type StaleOAuthProfileShadow = { @@ -29,10 +30,6 @@ type StaleOAuthProfileShadow = { const LEGACY_OAUTH_REF_SOURCE = "openclaw-credentials"; const LEGACY_OAUTH_REF_PROVIDER = "openai-codex"; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function isLegacyOAuthRef(value: unknown): boolean { return ( isRecord(value) && diff --git a/src/commands/migrate/selection.ts b/src/commands/migrate/selection.ts index 08517e1fb43..cc23a1eb217 100644 --- a/src/commands/migrate/selection.ts +++ b/src/commands/migrate/selection.ts @@ -1,6 +1,9 @@ import path from "node:path"; import { markMigrationItemSkipped, summarizeMigrationItems } from "../../plugin-sdk/migration.js"; import type { MigrationItem, MigrationPlan } from "../../plugins/types.js"; +import { isRecord } from "../../shared/record-coerce.js"; +import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { MIGRATION_CONFLICT_REASON_PHRASES } from "./output.js"; export const MIGRATION_SKILL_NOT_SELECTED_REASON = "not selected for migration"; @@ -21,32 +24,23 @@ function normalizeSelectionRef(value: string): string { } function readMigrationSkillName(item: MigrationItem): string | undefined { - const value = item.details?.skillName; - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; + return normalizeOptionalString(item.details?.skillName); } function readMigrationSkillSourceLabel(item: MigrationItem): string | undefined { - const value = item.details?.sourceLabel; - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; + return normalizeOptionalString(item.details?.sourceLabel); } function readMigrationPluginName(item: MigrationItem): string | undefined { - const value = item.details?.pluginName; - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; + return normalizeOptionalString(item.details?.pluginName); } function readMigrationPluginConfigKey(item: MigrationItem): string | undefined { - const value = item.details?.configKey; - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; + return normalizeOptionalString(item.details?.configKey); } function readMigrationPluginMarketplaceName(item: MigrationItem): string | undefined { - const value = item.details?.marketplaceName; - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return normalizeOptionalString(item.details?.marketplaceName); } function migrationSkillRefs(item: MigrationItem): string[] { @@ -499,7 +493,7 @@ export function reconcileInteractiveMigrationEnterValues( if (opts.preserveDeselectedActivatedValue && !selectedValues.includes(activatedValue)) { return selectedSelectableValues; } - return Array.from(new Set([...selectedSelectableValues, activatedValue])); + return uniqueStrings([...selectedSelectableValues, activatedValue]); } return [...selectedValues]; } diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index f7dc913b1f2..43f507645f5 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -32,6 +32,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { coerceSecretRef, normalizeSecretInputString } from "../../config/types.secrets.js"; import { type SecretRefResolveCache, resolveSecretRefString } from "../../secrets/resolve.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; +import { normalizeUniqueStringEntries } from "../../shared/string-normalization.js"; import { redactSecrets } from "../status-all/format.js"; import { DEFAULT_PROVIDER, formatMs } from "./shared.js"; @@ -303,7 +304,7 @@ export async function buildProbeTargets(params: { }); const providerFilter = options.provider?.trim(); const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null; - const profileFilter = new Set((options.profileIds ?? []).map((id) => id.trim()).filter(Boolean)); + const profileFilter = new Set(normalizeUniqueStringEntries(options.profileIds)); const refResolveCache: SecretRefResolveCache = {}; const catalog = await loadModelCatalog({ config: cfg }); const candidates = buildCandidateMap(modelCandidates); diff --git a/src/commands/models/list.provider-catalog.ts b/src/commands/models/list.provider-catalog.ts index 9c811d575cc..5e2e6db6378 100644 --- a/src/commands/models/list.provider-catalog.ts +++ b/src/commands/models/list.provider-catalog.ts @@ -28,6 +28,7 @@ import { resolveOwningPluginIdsForProvider, } from "../../plugins/providers.js"; import type { ProviderPlugin } from "../../plugins/types.js"; +import { sortUniqueStrings } from "../../shared/string-normalization.js"; const DISCOVERY_ORDERS = ["simple", "profile", "paired", "late"] as const; const SELF_HOSTED_DISCOVERY_PROVIDER_IDS = new Set(["lmstudio", "ollama", "sglang", "vllm"]); @@ -92,7 +93,7 @@ function resolveInstalledIndexPluginIdsForProviderFilter(params: { ...collectMatchingContributionOwners(index, "cliBackends", params.providerFilter, params.cfg), ]; if (pluginIds.length > 0) { - return [...new Set(pluginIds)].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings(pluginIds); } const disabledPluginIds = [ ...collectMatchingContributionOwners(index, "providers", params.providerFilter, params.cfg, { diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 5e857e5c12d..ac1f979deb5 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -20,6 +20,7 @@ import { detectBinary } from "../infra/detect-binary.js"; import { movePathToTrash } from "../infra/fs-safe.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { visibleWidth } from "../terminal/ansi.js"; import { decorativeEmoji, supportsDecorativeEmoji } from "../terminal/decorative-emoji.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; @@ -284,7 +285,7 @@ async function resolveMoveToTrashAllowedRoots(targetPath: string): Promise) => - (values ?? []).map((v) => normalizeStringifiedOptionalString(v) ?? "").filter(Boolean); + const allowTokens = (values?: Array) => normalizeStringifiedEntries(values); const globalAllowTokens = allowTokens(globalAllow); const agentAllowTokens = allowTokens(agentAllow); diff --git a/src/commitments/extraction.ts b/src/commitments/extraction.ts index 7a751baceb8..5af9c2aa405 100644 --- a/src/commitments/extraction.ts +++ b/src/commitments/extraction.ts @@ -1,6 +1,8 @@ import { resolveAgentConfig } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveHeartbeatIntervalMs } from "../infra/heartbeat-summary.js"; +import { asFiniteNumber } from "../shared/number-coercion.js"; +import { normalizeOptionalString as asString } from "../shared/string-coerce.js"; import { isRecord } from "../utils.js"; import { resolveCommitmentsConfig } from "./config.js"; import { listPendingCommitmentsForScope, upsertInferredCommitments } from "./store.js"; @@ -22,12 +24,8 @@ const KIND_VALUES = new Set([ const SENSITIVITY_VALUES = new Set(["routine", "personal", "care"]); const SOURCE_VALUES = new Set(["inferred_user_context", "agent_promise"]); -function asString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function asNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } function parseCandidate(raw: unknown): CommitmentCandidate | undefined { diff --git a/src/commitments/store.ts b/src/commitments/store.ts index c836a704977..d4efa5dcbfa 100644 --- a/src/commitments/store.ts +++ b/src/commitments/store.ts @@ -4,6 +4,8 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { expandHomePrefix } from "../infra/home-dir.js"; import { privateFileStore } from "../infra/private-file-store.js"; +import { isRecord } from "../shared/record-coerce.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import { DEFAULT_COMMITMENT_EXPIRE_AFTER_HOURS, DEFAULT_COMMITMENT_MAX_PER_HEARTBEAT, @@ -55,22 +57,6 @@ function emptyStore(): CommitmentStoreFile { return { version: STORE_VERSION, commitments: [] }; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeRequiredString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - -function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" ? value.trim() || undefined : undefined; -} - function normalizeNonNegativeNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; } @@ -88,24 +74,24 @@ function coerceCommitment(raw: unknown): CommitmentRecord | undefined { return undefined; } - const id = normalizeRequiredString(raw.id); - const agentId = normalizeRequiredString(raw.agentId); - const sessionKey = normalizeRequiredString(raw.sessionKey); - const channel = normalizeRequiredString(raw.channel); - const reason = normalizeRequiredString(raw.reason); - const suggestedText = normalizeRequiredString(raw.suggestedText); - const dedupeKey = normalizeRequiredString(raw.dedupeKey); - const kind = normalizeRequiredString(raw.kind); - const sensitivity = normalizeRequiredString(raw.sensitivity); - const source = normalizeRequiredString(raw.source); - const status = normalizeRequiredString(raw.status); + const id = normalizeOptionalString(raw.id); + const agentId = normalizeOptionalString(raw.agentId); + const sessionKey = normalizeOptionalString(raw.sessionKey); + const channel = normalizeOptionalString(raw.channel); + const reason = normalizeOptionalString(raw.reason); + const suggestedText = normalizeOptionalString(raw.suggestedText); + const dedupeKey = normalizeOptionalString(raw.dedupeKey); + const kind = normalizeOptionalString(raw.kind); + const sensitivity = normalizeOptionalString(raw.sensitivity); + const source = normalizeOptionalString(raw.source); + const status = normalizeOptionalString(raw.status); const confidence = normalizeNonNegativeNumber(raw.confidence); const createdAtMs = normalizeNonNegativeNumber(raw.createdAtMs); const updatedAtMs = normalizeNonNegativeNumber(raw.updatedAtMs); const attempts = normalizeNonNegativeInteger(raw.attempts); const earliestMs = normalizeNonNegativeNumber(dueWindow.earliestMs); const latestMs = normalizeNonNegativeNumber(dueWindow.latestMs); - const timezone = normalizeRequiredString(dueWindow.timezone); + const timezone = normalizeOptionalString(dueWindow.timezone); const accountId = normalizeOptionalString(raw.accountId); const to = normalizeOptionalString(raw.to); const threadId = normalizeOptionalString(raw.threadId); diff --git a/src/config/channel-capabilities.ts b/src/config/channel-capabilities.ts index 16938cc548e..0815d6fc4a2 100644 --- a/src/config/channel-capabilities.ts +++ b/src/config/channel-capabilities.ts @@ -1,6 +1,7 @@ import { normalizeAnyChannelId } from "../channels/registry.js"; import { resolveAccountEntry } from "../routing/account-lookup.js"; import { normalizeAccountId } from "../routing/session-key.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { OpenClawConfig } from "./config.js"; import type { SlackCapabilitiesConfig } from "./types.slack.js"; import type { TelegramCapabilitiesConfig } from "./types.telegram.js"; @@ -16,7 +17,7 @@ function normalizeCapabilities(capabilities: CapabilitiesConfig | undefined): st if (!isStringArray(capabilities)) { return undefined; } - const normalized = capabilities.map((entry) => entry.trim()).filter(Boolean); + const normalized = normalizeStringEntries(capabilities); return normalized.length > 0 ? normalized : undefined; } diff --git a/src/config/dangerous-name-matching.ts b/src/config/dangerous-name-matching.ts index 330259fd1f6..86a4b81c116 100644 --- a/src/config/dangerous-name-matching.ts +++ b/src/config/dangerous-name-matching.ts @@ -1,3 +1,4 @@ +import { asBoolean } from "../utils/boolean.js"; import type { OpenClawConfig } from "./config.js"; type DangerousNameMatchingConfig = { @@ -23,10 +24,6 @@ function asObjectRecord(value: unknown): Record | null { return value as Record; } -function asOptionalBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - export function isDangerousNameMatchingEnabled( config: DangerousNameMatchingConfig | null | undefined, ): boolean { @@ -80,7 +77,7 @@ export function collectProviderDangerousNameMatchingScopes( } const accountPrefix = `${providerPrefix}.accounts.${key}`; - const accountDangerousNameMatching = asOptionalBoolean(account.dangerouslyAllowNameMatching); + const accountDangerousNameMatching = asBoolean(account.dangerouslyAllowNameMatching); scopes.push({ prefix: accountPrefix, diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 0242a4fb403..56ec254857c 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -2,6 +2,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js"; import { normalizeProviderId } from "../agents/provider-id.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { isRecord } from "../shared/record-coerce.js"; import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_ARCHIVE_AFTER_MINUTES, @@ -60,10 +61,6 @@ const MISTRAL_SAFE_MAX_TOKENS_BY_MODEL = { type ModelDefinitionLike = Partial & Pick; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function isPositiveNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; } diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts index e5f42b6c64f..dfc4d7de19e 100644 --- a/src/config/doc-baseline.ts +++ b/src/config/doc-baseline.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { replaceFileAtomicSync } from "../infra/replace-file.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import type { ConfigSchemaResponse } from "./schema.js"; import { schemaHasChildren } from "./schema.shared.js"; @@ -266,7 +267,7 @@ function normalizeTypeValue(value: string | string[] | undefined): string | stri return undefined; } if (Array.isArray(value)) { - const normalized = [...new Set(value)].toSorted((left, right) => left.localeCompare(right)); + const normalized = sortUniqueStrings(value); return normalized.length === 1 ? normalized[0] : normalized; } return value; @@ -335,9 +336,7 @@ function mergeConfigDocBaselineEntry( defaultValue, deprecated: current.deprecated || next.deprecated, sensitive: current.sensitive || next.sensitive, - tags: [...new Set([...current.tags, ...next.tags])].toSorted((left, right) => - left.localeCompare(right), - ), + tags: sortUniqueStrings([...current.tags, ...next.tags]), label, help, hasChildren: current.hasChildren || next.hasChildren, diff --git a/src/config/model-input.ts b/src/config/model-input.ts index dbe78871a2d..771b6229690 100644 --- a/src/config/model-input.ts +++ b/src/config/model-input.ts @@ -1,5 +1,6 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import { normalizeGooglePreviewModelId } from "../plugin-sdk/provider-model-id-normalize.js"; +import { isRecord as isPlainRecord } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -12,10 +13,6 @@ type AgentModelListLike = { fallbacks?: string[]; }; -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function modelKeyForConfig(provider: string, model: string): string { const providerId = provider.trim(); const modelId = model.trim(); diff --git a/src/config/plugin-install-config-migration.ts b/src/config/plugin-install-config-migration.ts index e1650799a0e..562a730c780 100644 --- a/src/config/plugin-install-config-migration.ts +++ b/src/config/plugin-install-config-migration.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { isRecord } from "../shared/record-coerce.js"; import type { PluginInstallRecord } from "./types.plugins.js"; import { PluginInstallRecordShape } from "./zod-schema.installs.js"; @@ -7,10 +8,6 @@ const PluginInstallRecordsSchema = z.record( z.object(PluginInstallRecordShape).passthrough(), ); -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function pruneEmptyPluginsObject(plugins: Record): unknown { const { installs: _installs, ...rest } = plugins; return Object.keys(rest).length === 0 ? undefined : rest; diff --git a/src/config/plugin-web-search-config.ts b/src/config/plugin-web-search-config.ts index 85cf76211d5..9793616df08 100644 --- a/src/config/plugin-web-search-config.ts +++ b/src/config/plugin-web-search-config.ts @@ -1,3 +1,5 @@ +import { isRecord } from "../shared/record-coerce.js"; + type PluginWebSearchConfigCarrier = { plugins?: { entries?: Record< @@ -9,10 +11,6 @@ type PluginWebSearchConfigCarrier = { }; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - export function resolvePluginWebSearchConfig( config: PluginWebSearchConfigCarrier | undefined, pluginId: string, diff --git a/src/config/redact-snapshot.raw.ts b/src/config/redact-snapshot.raw.ts index f403261a111..7b10d249cc2 100644 --- a/src/config/redact-snapshot.raw.ts +++ b/src/config/redact-snapshot.raw.ts @@ -1,5 +1,6 @@ import { isDeepStrictEqual } from "node:util"; import JSON5 from "json5"; +import { uniqueStrings } from "../shared/string-normalization.js"; export function replaceSensitiveValuesInRaw(params: { raw: string; @@ -8,7 +9,7 @@ export function replaceSensitiveValuesInRaw(params: { }): string { // Empty string is not a valid replacement token here: replaceAll("", x) // matches every character boundary and corrupts the whole raw snapshot. - const values = [...new Set(params.sensitiveValues)] + const values = uniqueStrings(params.sensitiveValues) .filter((value) => value !== "") .toSorted((a, b) => b.length - a.length); let result = params.raw; diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index e0619f9d5c8..b461daed764 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -5,6 +5,7 @@ import { isSensitiveUrlConfigPath, redactSensitiveUrlLikeString, } from "../shared/net/redact-sensitive-url.js"; +import { isRecord as isObjectRecord } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { replaceSensitiveValuesInRaw, @@ -44,10 +45,6 @@ function hasSensitiveUrlHintPath(hints: ConfigUiHints | undefined, paths: string return paths.some((path) => hasSensitiveUrlHintTag(hints[path])); } -function isObjectRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function collectSensitiveStrings(value: unknown, values: string[]): void { if (typeof value === "string") { if (!isEnvVarPlaceholder(value)) { diff --git a/src/config/sessions/store-entry-shape.ts b/src/config/sessions/store-entry-shape.ts index d207c49e113..987bbc77462 100644 --- a/src/config/sessions/store-entry-shape.ts +++ b/src/config/sessions/store-entry-shape.ts @@ -1,10 +1,7 @@ +import { isRecord } from "../../shared/record-coerce.js"; import { validateSessionId } from "./paths.js"; import type { SessionEntry } from "./types.js"; -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function isSafeSessionId(value: unknown): value is string { if (typeof value !== "string") { return false; diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index 6cb71413ac7..4dfc3ee0cc3 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -3,6 +3,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import { isPluginJsonValue, type PluginJsonValue } from "../../plugins/host-hook-json.js"; import { normalizeSessionEntrySlotKey } from "../../plugins/session-entry-slot-keys.js"; +import { isRecord } from "../../shared/record-coerce.js"; import { normalizeDeliveryChannelRoute, normalizeDeliveryContext, @@ -46,11 +47,7 @@ export type LoadSessionStoreOptions = { const log = createSubsystemLogger("sessions/store"); function isSessionStoreRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); + return isRecord(value); } function normalizeOptionalFiniteNumber(value: unknown): number | undefined { diff --git a/src/config/shell-env-expected-keys.ts b/src/config/shell-env-expected-keys.ts index a50b8173844..2bc4620c0b1 100644 --- a/src/config/shell-env-expected-keys.ts +++ b/src/config/shell-env-expected-keys.ts @@ -1,14 +1,13 @@ import { listKnownChannelEnvVarNames } from "../secrets/channel-env-vars.js"; import { listKnownProviderAuthEnvVarNames } from "../secrets/provider-env-vars.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; const CORE_SHELL_ENV_EXPECTED_KEYS = ["OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD"]; export function resolveShellEnvExpectedKeys(env: NodeJS.ProcessEnv): string[] { - return [ - ...new Set([ - ...listKnownProviderAuthEnvVarNames({ env }), - ...listKnownChannelEnvVarNames({ env }), - ...CORE_SHELL_ENV_EXPECTED_KEYS, - ]), - ]; + return uniqueStrings([ + ...listKnownProviderAuthEnvVarNames({ env }), + ...listKnownChannelEnvVarNames({ env }), + ...CORE_SHELL_ENV_EXPECTED_KEYS, + ]); } diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 92ee0ba0205..5bef3b9535c 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -3,10 +3,12 @@ import { splitSandboxBindSpec } from "../agents/sandbox/bind-spec.js"; import { isSandboxHostPathAbsolute } from "../agents/sandbox/host-paths.js"; import { getBlockedNetworkModeReason } from "../agents/sandbox/network-mode.js"; import { parseDurationMs } from "../cli/parse-duration.js"; +import { isRecord as isPlainRecord } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; import { AgentModelSchema, AgentToolModelSchema } from "./zod-schema.agent-model.js"; import { @@ -330,9 +332,9 @@ const TrimmedOptionalConfigStringSchema = z const CodexAllowedDomainsSchema = z .array(z.string()) .transform((values) => { - const deduped = [ - ...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)), - ]; + const deduped = uniqueStrings( + values.map((value) => value.trim()).filter((value) => value.length > 0), + ); return deduped.length > 0 ? deduped : undefined; }) .optional(); @@ -365,10 +367,6 @@ const LEGACY_WEB_SEARCH_PROVIDER_CONFIG_KEYS = new Set([ "tavily", ]); -function isPlainRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - const BLOCKED_WEB_SEARCH_KEYS_ISSUE_FIELD = "__openclawBlockedWebSearchKeys"; const ToolsWebSearchSchema = z diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index be4fb27b79f..ac5f17c6096 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -15,6 +15,7 @@ import type { OutboundChannel } from "../../infra/outbound/targets.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { normalizeOptionalThreadValue } from "../../shared/string-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { resolveCronStoredDeliveryContext } from "../delivery-context.js"; import { resolveCronAgentSessionKey } from "./session-key.js"; @@ -265,7 +266,7 @@ export async function resolveDeliveryTarget( const configuredAllowFrom = configuredAllowFromRaw ? mapAllowFromEntries(configuredAllowFromRaw) : []; - const allowFromOverride = [...new Set(configuredAllowFrom)]; + const allowFromOverride = uniqueStrings(configuredAllowFrom); effectiveAllowFrom = allowFromOverride; if (toCandidate && allowFromOverride.length > 0) { diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index 3a204f3912e..2b7d124ce5c 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -4,6 +4,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; import { isRecord } from "../utils.js"; import { TimeoutSecondsFieldSchema, @@ -53,9 +54,7 @@ function normalizeTrimmedStringArray( options?: { allowNull?: boolean }, ): string[] | null | undefined { if (Array.isArray(value)) { - const normalized = value - .map((entry) => normalizeOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)); + const normalized = normalizeTrimmedStringList(value); if (normalized.length === 0 && value.length > 0) { return undefined; } diff --git a/src/cron/run-log.ts b/src/cron/run-log.ts index ebce6b09491..b9f98737ddc 100644 --- a/src/cron/run-log.ts +++ b/src/cron/run-log.ts @@ -10,6 +10,7 @@ import { normalizeOptionalString, normalizeStringifiedOptionalString, } from "../shared/string-coerce.js"; +import { normalizeStringEntries, uniqueValues } from "../shared/string-normalization.js"; import { normalizeCronRunDiagnostics } from "./run-diagnostics.js"; import type { CronDeliveryStatus, @@ -146,10 +147,7 @@ async function pruneIfNeeded(filePath: string, opts: { maxBytes: number; keepLin } const raw = await fs.readFile(filePath, "utf-8").catch(() => ""); - const lines = raw - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(raw.split("\n")); const kept = lines.slice(Math.max(0, lines.length - opts.keepLines)); await privateFileStore(path.dirname(filePath)).writeText( path.basename(filePath), @@ -241,7 +239,7 @@ function normalizeRunStatuses(opts?: { status === "ok" || status === "error" || status === "skipped", ); if (filtered.length > 0) { - return Array.from(new Set(filtered)); + return uniqueValues(filtered); } } const status = normalizeRunStatusFilter(opts?.status); @@ -264,7 +262,7 @@ function normalizeDeliveryStatuses(opts?: { status === "not-requested", ); if (filtered.length > 0) { - return Array.from(new Set(filtered)); + return uniqueValues(filtered); } } if ( diff --git a/src/cron/schedule-identity.ts b/src/cron/schedule-identity.ts index d7dc2254348..bff1f3387d6 100644 --- a/src/cron/schedule-identity.ts +++ b/src/cron/schedule-identity.ts @@ -1,13 +1,14 @@ +import { asFiniteNumber } from "../shared/number-coercion.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { CronJob } from "./types.js"; function readString(record: Record, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; + return normalizeOptionalString(record[key]); } function readNumber(record: Record, key: string): number | undefined { const value = record[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } function schedulePayloadFromRecord( diff --git a/src/cron/store.ts b/src/cron/store.ts index 463ddb60c64..1019e4d86e7 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { expandHomePrefix } from "../infra/home-dir.js"; import { replaceFileAtomic } from "../infra/replace-file.js"; +import { isRecord } from "../shared/record-coerce.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { resolveConfigDir } from "../utils.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; @@ -61,10 +62,6 @@ type CronStateFile = { jobs: Record; }; -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function normalizeCronStoreFile(parsed: unknown): CronStoreFile { const rawJobs = Array.isArray(parsed) ? parsed diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 1805af6be48..8bd13c3623e 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -7,6 +7,7 @@ import { inspectPortUsage } from "../infra/ports.js"; import { getWindowsInstallRoots } from "../infra/windows-install-roots.js"; import { killProcessTree } from "../process/kill-tree.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { sleep } from "../utils.js"; import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js"; import { assertNoCmdLineBreak, parseCmdSetAssignment, renderCmdSetAssignment } from "./cmd-set.js"; @@ -97,7 +98,7 @@ function resolveStartupEntryPath(env: GatewayServiceEnv, extension?: "cmd" | "vb function resolveStartupEntryPaths(env: GatewayServiceEnv): string[] { const primaryPath = resolveStartupEntryPath(env); const legacyCmdPath = resolveStartupEntryPath(env, "cmd"); - return Array.from(new Set([primaryPath, legacyCmdPath])); + return uniqueStrings([primaryPath, legacyCmdPath]); } // `/TR` is parsed by schtasks itself, while the generated `gateway.cmd` line is parsed by cmd.exe. diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index 31745eb9f90..53d15d8fd70 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { normalizeEnvVarKey } from "../infra/host-env-security.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeStringEntries, sortUniqueStrings } from "../shared/string-normalization.js"; import { resolveLaunchAgentPlistPath } from "./launchd.js"; import { isBunRuntime, isNodeRuntime } from "./runtime-binary.js"; import { @@ -350,7 +351,7 @@ function collectInlineProxyEnvKeys(command: GatewayServiceCommand): string[] { } inlineKeys.push(normalized); } - return [...new Set(inlineKeys)].toSorted(); + return sortUniqueStrings(inlineKeys); } function auditProxyServiceEnvironment( @@ -424,19 +425,13 @@ function auditGatewayServicePath( } const expected = expectedServicePath?.trim() - ? expectedServicePath - .split(getPathModule(platform).delimiter) - .map((entry) => entry.trim()) - .filter(Boolean) + ? normalizeStringEntries(expectedServicePath.split(getPathModule(platform).delimiter)) : getMinimalServicePathPartsFromEnv({ platform, env, includeMissingUserBinDefaults: false, }); - const parts = servicePath - .split(getPathModule(platform).delimiter) - .map((entry) => entry.trim()) - .filter(Boolean); + const parts = normalizeStringEntries(servicePath.split(getPathModule(platform).delimiter)); const normalizedParts = new Set(parts.map((entry) => normalizeServicePathEntry(entry, platform))); const normalizedExpected = new Set( expected.map((entry) => normalizeServicePathEntry(entry, platform)), diff --git a/src/daemon/service-managed-env.ts b/src/daemon/service-managed-env.ts index 5048b7233cb..8d572a3fd15 100644 --- a/src/daemon/service-managed-env.ts +++ b/src/daemon/service-managed-env.ts @@ -1,4 +1,5 @@ import { normalizeEnvVarKey } from "../infra/host-env-security.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import type { GatewayServiceEnvironmentValueSource } from "./service-types.js"; const MANAGED_SERVICE_ENV_KEYS_VAR = "OPENCLAW_SERVICE_MANAGED_ENV_KEYS"; @@ -155,5 +156,5 @@ export function collectInlineManagedServiceEnvKeys( } inlineKeys.push(normalized); } - return [...new Set(inlineKeys)].toSorted(); + return sortUniqueStrings(inlineKeys); } diff --git a/src/daemon/systemd-unit.ts b/src/daemon/systemd-unit.ts index d2a2049fd4a..51d9f150572 100644 --- a/src/daemon/systemd-unit.ts +++ b/src/daemon/systemd-unit.ts @@ -1,3 +1,4 @@ +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { splitArgsPreservingQuotes } from "./arg-split.js"; import type { GatewayServiceRenderArgs } from "./service-types.js"; @@ -39,13 +40,10 @@ function renderEnvironmentFileLines(environmentFiles: string[] | undefined): str if (!environmentFiles) { return []; } - return environmentFiles - .map((entry) => entry.trim()) - .filter(Boolean) - .map((entry) => { - assertNoSystemdLineBreaks(entry, "Systemd EnvironmentFile values"); - return `EnvironmentFile=-${systemdEscapeArg(entry)}`; - }); + return normalizeStringEntries(environmentFiles).map((entry) => { + assertNoSystemdLineBreaks(entry, "Systemd EnvironmentFile values"); + return `EnvironmentFile=-${systemdEscapeArg(entry)}`; + }); } export function buildSystemdUnit({ diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index cfc440ab6aa..5a4ebfd48cb 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -8,6 +8,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import { normalizeEnvVarKey } from "../infra/host-env-security.js"; import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { splitArgsPreservingQuotes } from "./arg-split.js"; import { LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, @@ -307,9 +308,7 @@ function expandSystemdSpecifier(input: string, env: GatewayServiceEnv): string { } function parseEnvironmentFileSpecs(raw: string): string[] { - return splitArgsPreservingQuotes(raw, { escapeMode: "backslash" }) - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries(splitArgsPreservingQuotes(raw, { escapeMode: "backslash" })); } function parseEnvironmentFileLine(rawLine: string): { key: string; value: string } | null { diff --git a/src/flows/bundled-health-checks.ts b/src/flows/bundled-health-checks.ts index 9b0a088a890..c5adb4ee011 100644 --- a/src/flows/bundled-health-checks.ts +++ b/src/flows/bundled-health-checks.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { passesManifestOwnerBasePolicy } from "../plugins/manifest-owner-policy.js"; import { loadBundledPluginPublicArtifactModuleSync } from "../plugins/public-surface-loader.js"; +import { asOptionalObjectRecord as readRecord } from "../shared/record-coerce.js"; import { registerHealthCheck } from "./health-check-registry.js"; type BundledHealthApi = { @@ -20,7 +21,7 @@ export function registerBundledHealthChecks(params: { cfg: OpenClawConfig; cwd?: function shouldRegisterPolicyHealth(params: { cfg: OpenClawConfig; cwd?: string }): boolean { const entry = params.cfg.plugins?.entries?.policy; - const config = isRecord(entry?.config) ? entry.config : {}; + const config = readRecord(entry?.config) ?? {}; if (entry === undefined || entry.enabled === false || config.enabled === false) { return false; } @@ -34,7 +35,3 @@ function shouldRegisterPolicyHealth(params: { cfg: OpenClawConfig; cwd?: string } return entry.enabled === true || config.enabled === true; } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} diff --git a/src/flows/doctor-repair-flow.ts b/src/flows/doctor-repair-flow.ts index acbd71bde4e..e48cee60af9 100644 --- a/src/flows/doctor-repair-flow.ts +++ b/src/flows/doctor-repair-flow.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { scrubDoctorErrorMessage } from "./doctor-error-message.js"; import { normalizeHealthCheck } from "./health-check-adapter.js"; import { listHealthChecks } from "./health-check-registry.js"; @@ -289,5 +290,5 @@ function createValidationScope(findings: readonly HealthFinding[]) { } function uniqueDefined(values: readonly (string | undefined)[]): readonly string[] { - return [...new Set(values.filter((value): value is string => value !== undefined))]; + return uniqueStrings(values.filter((value): value is string => value !== undefined)); } diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index a2a2dd9354a..4ad967c754e 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -32,6 +32,7 @@ import type { ProviderPlugin } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; @@ -390,9 +391,7 @@ async function promptManualModel(params: { function buildModelProviderFilterOptions( models: Array<{ provider: string }>, ): Array<{ value: string; label: string; hint: string }> { - const providerIds = Array.from(new Set(models.map((entry) => entry.provider))).toSorted((a, b) => - a.localeCompare(b), - ); + const providerIds = sortUniqueStrings(models.map((entry) => entry.provider)); return providerIds.map((provider) => { const count = models.filter((entry) => entry.provider === provider).length; return { @@ -418,9 +417,7 @@ async function maybeFilterModelsByProvider(params: { env?: NodeJS.ProcessEnv; }): Promise { let next = params.models.filter((entry) => isModelPickerVisibleProvider(entry.provider)); - const providerIds = Array.from(new Set(next.map((entry) => entry.provider))).toSorted((a, b) => - a.localeCompare(b), - ); + const providerIds = sortUniqueStrings(next.map((entry) => entry.provider)); const hasPreferredProvider = !!params.preferredProvider; const shouldPromptProvider = !hasPreferredProvider && providerIds.length > 1 && next.length > PROVIDER_FILTER_THRESHOLD; diff --git a/src/gateway/chat-display-projection.ts b/src/gateway/chat-display-projection.ts index e2d97160bf8..f50dbbab61e 100644 --- a/src/gateway/chat-display-projection.ts +++ b/src/gateway/chat-display-projection.ts @@ -10,6 +10,9 @@ import { parseAssistantTextSignature, resolveAssistantMessagePhase, } from "../shared/chat-message-content.js"; +import { asFiniteNumber } from "../shared/number-coercion.js"; +import { asOptionalRecord as readRecord } from "../shared/record-coerce.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { stripEnvelopeFromMessages } from "./chat-sanitize.js"; import { isSuppressedControlReplyText } from "./control-reply-text.js"; @@ -215,7 +218,7 @@ function projectAssistantTextFromMixedToolContent( } function toFiniteNumber(x: unknown): number | undefined { - return typeof x === "number" && Number.isFinite(x) ? x : undefined; + return asFiniteNumber(x); } function sanitizeCost(raw: unknown): { total?: number } | undefined { @@ -446,26 +449,11 @@ function hasAssistantMixedToolVisibleText(message: unknown): boolean { return hasToolHistoryBlock && hasText; } -function normalizeOptionalString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - function normalizeToolHistoryType(value: unknown): string | undefined { const normalized = normalizeOptionalString(value)?.toLowerCase(); return normalized ? normalized.replace(/_/g, "") : undefined; } -function readRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - return value as Record; -} - function parseJsonRecord(value: string): Record | undefined { try { return readRecord(JSON.parse(value)); diff --git a/src/gateway/cli-session-history.claude.ts b/src/gateway/cli-session-history.claude.ts index 71da6b977b9..d97b5d45843 100644 --- a/src/gateway/cli-session-history.claude.ts +++ b/src/gateway/cli-session-history.claude.ts @@ -8,6 +8,7 @@ import { type ToolContentBlock, } from "../chat/tool-content.js"; import type { SessionEntry } from "../config/sessions.js"; +import { asFiniteNumber } from "../shared/number-coercion.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { attachOpenClawTranscriptMeta } from "./session-utils.fs.js"; @@ -63,10 +64,6 @@ export function resolveClaudeCliBindingSessionId( return legacyClaudeSessionId || undefined; } -function resolveFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function resolveTimestampMs(value: unknown): number | undefined { if (typeof value !== "string") { return undefined; @@ -79,10 +76,10 @@ function resolveClaudeCliUsage(raw: ClaudeCliUsage) { if (!raw || typeof raw !== "object") { return undefined; } - const input = resolveFiniteNumber(raw.input_tokens); - const output = resolveFiniteNumber(raw.output_tokens); - const cacheRead = resolveFiniteNumber(raw.cache_read_input_tokens); - const cacheWrite = resolveFiniteNumber(raw.cache_creation_input_tokens); + const input = asFiniteNumber(raw.input_tokens); + const output = asFiniteNumber(raw.output_tokens); + const cacheRead = asFiniteNumber(raw.cache_read_input_tokens); + const cacheWrite = asFiniteNumber(raw.cache_creation_input_tokens); if ( input === undefined && output === undefined && diff --git a/src/gateway/cli-session-history.merge.ts b/src/gateway/cli-session-history.merge.ts index a900b9decb9..a9d88aca522 100644 --- a/src/gateway/cli-session-history.merge.ts +++ b/src/gateway/cli-session-history.merge.ts @@ -1,4 +1,5 @@ import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; +import { asFiniteNumber } from "../shared/number-coercion.js"; import { normalizeOptionalString, readStringValue } from "../shared/string-coerce.js"; const DEDUPE_TIMESTAMP_WINDOW_MS = 5 * 60 * 1000; @@ -39,15 +40,11 @@ function extractComparableText(message: unknown): string | undefined { return normalized || undefined; } -function resolveFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function resolveComparableTimestamp(message: unknown): number | undefined { if (!message || typeof message !== "object") { return undefined; } - return resolveFiniteNumber((message as { timestamp?: unknown }).timestamp); + return asFiniteNumber((message as { timestamp?: unknown }).timestamp); } function resolveComparableRole(message: unknown): string | undefined { diff --git a/src/gateway/input-allowlist.ts b/src/gateway/input-allowlist.ts index 61ad9d06cc4..11e0d9633d7 100644 --- a/src/gateway/input-allowlist.ts +++ b/src/gateway/input-allowlist.ts @@ -1,3 +1,5 @@ +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; + /** * Normalize optional gateway URL-input hostname allowlists. * @@ -11,6 +13,6 @@ export function normalizeInputHostnameAllowlist( if (!values || values.length === 0) { return undefined; } - const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0); + const normalized = normalizeTrimmedStringList(values); return normalized.length > 0 ? normalized : undefined; } diff --git a/src/gateway/mcp-http.schema.ts b/src/gateway/mcp-http.schema.ts index 9942e9bfda4..4063d26fdbb 100644 --- a/src/gateway/mcp-http.schema.ts +++ b/src/gateway/mcp-http.schema.ts @@ -1,4 +1,5 @@ import { logWarn } from "../logger.js"; +import { uniqueValues } from "../shared/string-normalization.js"; import { resolveGatewayScopedTools } from "./tool-resolution.js"; export type McpLoopbackTool = ReturnType["tools"][number]; @@ -29,7 +30,7 @@ function flattenUnionSchema(raw: Record): Record): console.error(`[mcp-loopback] ${step} ${JSON.stringify(details)}`); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function createRequestAbortSignal(req: IncomingMessage, res: ServerResponse) { const controller = new AbortController(); const abort = () => { diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index f618294cb52..9e851dad1ef 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -1,5 +1,6 @@ import { getPluginRegistryState } from "../plugins/runtime-state.js"; import { resolveReservedGatewayMethodScope } from "../shared/gateway-method-policy.js"; +import { normalizeOptionalString as normalizeSessionActionParam } from "../shared/string-coerce.js"; import { isCoreGatewayMethodClassified, isCoreNodeGatewayMethod, @@ -80,10 +81,6 @@ export function resolveRequiredOperatorScopeForMethod(method: string): OperatorS return resolveScopedMethod(method); } -function normalizeSessionActionParam(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function resolveSessionActionRegisteredScopes(params: unknown): OperatorScope[] | undefined { if (!params || typeof params !== "object" || Array.isArray(params)) { return undefined; diff --git a/src/gateway/node-catalog.ts b/src/gateway/node-catalog.ts index 442d7562a05..004f22493fe 100644 --- a/src/gateway/node-catalog.ts +++ b/src/gateway/node-catalog.ts @@ -2,6 +2,7 @@ import { hasEffectivePairedDeviceRole, type PairedDevice } from "../infra/device import type { NodePairingPairedNode } from "../infra/node-pairing.js"; import type { NodeListNode } from "../shared/node-list-types.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { normalizeSortedUniqueTrimmedStringList } from "../shared/string-normalization.js"; import type { NodeSession } from "./node-registry.js"; type KnownNodeDevicePairingSource = { @@ -48,22 +49,7 @@ type KnownNodeCatalog = { }; function uniqueSortedStrings(...items: Array): string[] { - const values = new Set(); - for (const item of items) { - if (!Array.isArray(item)) { - continue; - } - for (const value of item) { - if (typeof value !== "string") { - continue; - } - const trimmed = value.trim(); - if (trimmed) { - values.add(trimmed); - } - } - } - return [...values].toSorted((left, right) => left.localeCompare(right)); + return normalizeSortedUniqueTrimmedStringList(items.flatMap((item) => item ?? [])); } function buildDevicePairingSource(entry: PairedDevice): KnownNodeDevicePairingSource { diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index b43f537b63a..8dadef262a4 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -6,6 +6,7 @@ import { } from "../infra/node-commands.js"; import { getActiveRuntimePluginRegistry } from "../plugins/active-runtime-registry.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { normalizeDeviceMetadataForPolicy } from "./device-metadata-normalization.js"; import type { NodeSession } from "./node-registry.js"; @@ -225,7 +226,7 @@ export function listDangerousPluginNodeCommands(): string[] { .filter((entry) => entry.policy.dangerous === true) .flatMap((entry) => entry.policy.commands), ]; - return [...new Set(commands.map((command) => command.trim()).filter(Boolean))]; + return normalizeUniqueStringEntries(commands); } function listDefaultPluginNodeCommands(platformId: PlatformId): string[] { @@ -240,7 +241,7 @@ function listDefaultPluginNodeCommands(platformId: PlatformId): string[] { const defaults = entry.policy.defaultPlatforms ?? []; return defaults.includes(platformId) ? entry.policy.commands : []; }); - return [...new Set(commands.map((command) => command.trim()).filter(Boolean))]; + return normalizeUniqueStringEntries(commands); } export function isForegroundRestrictedPluginNodeCommand(command: string): boolean { diff --git a/src/gateway/protocol/connect-error-details.ts b/src/gateway/protocol/connect-error-details.ts index 8b7d54517ce..f6ceb238400 100644 --- a/src/gateway/protocol/connect-error-details.ts +++ b/src/gateway/protocol/connect-error-details.ts @@ -1,4 +1,5 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { normalizeArrayBackedTrimmedStringList } from "../../shared/string-normalization.js"; export const ConnectErrorDetailCodes = { AUTH_REQUIRED: "AUTH_REQUIRED", @@ -236,13 +237,7 @@ export function normalizePairingConnectRequestId(value: unknown): string | undef } function normalizeStringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const normalized = value - .map((item) => normalizeOptionalString(item)) - .filter((item): item is string => Boolean(item)); - return normalized.length > 0 ? normalized : []; + return normalizeArrayBackedTrimmedStringList(value); } function createPairingConnectErrorDetails(params: { diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 98fe8eccfc4..d9f358f1028 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -1,4 +1,5 @@ import AjvPkg, { type AnySchema, type ErrorObject, type ValidateFunction } from "ajv"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import type { SessionsPatchResult } from "../session-utils.types.js"; import { type AgentEvent, @@ -856,7 +857,7 @@ export function formatValidationErrors(errors: ErrorObject[] | null | undefined) } // De-dupe while preserving order. - const unique = Array.from(new Set(parts.filter((part) => part.trim()))); + const unique = uniqueStrings(parts.filter((part) => part.trim())); if (!unique.length) { const fallback = getAjv().errorsText(errors, { separator: "; " }); return fallback || "unknown validation error"; diff --git a/src/gateway/server-methods/agent-wait-dedupe.ts b/src/gateway/server-methods/agent-wait-dedupe.ts index de5884f0528..e6c5c9b7dc5 100644 --- a/src/gateway/server-methods/agent-wait-dedupe.ts +++ b/src/gateway/server-methods/agent-wait-dedupe.ts @@ -6,6 +6,8 @@ import { } from "../../agents/run-timeout-attribution.js"; import { normalizeBlockedLivenessWaitStatus } from "../../shared/agent-liveness.js"; import { isNonTerminalAgentRunStatus } from "../../shared/agent-run-status.js"; +import { asFiniteNumber } from "../../shared/number-coercion.js"; +import { asOptionalRecord } from "../../shared/record-coerce.js"; import { setSafeTimeout } from "../../utils/timer-delay.js"; import type { DedupeEntry } from "../server-shared.js"; @@ -33,16 +35,6 @@ function parseRunIdFromDedupeKey(key: string): string | null { return null; } -function asFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function asString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } @@ -109,7 +101,7 @@ function readTerminalSnapshotFromDedupeEntry(entry: DedupeEntry): AgentWaitTermi const startedAt = asFiniteNumber(payload?.startedAt); const endedAt = asFiniteNumber(payload?.endedAt) ?? entry.ts; - const resultMeta = asRecord(asRecord(payload?.result)?.meta); + const resultMeta = asOptionalRecord(asOptionalRecord(payload?.result)?.meta); const stopReason = asString(payload?.stopReason) ?? asString(resultMeta?.stopReason); const livenessState = asString(payload?.livenessState) ?? asString(resultMeta?.livenessState); const yielded = payload?.yielded === true || resultMeta?.yielded === true; diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index e2f61d7e43e..1ee0bae9158 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -90,6 +90,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { normalizeStringEntries, uniqueStrings } from "../../shared/string-normalization.js"; import { createRunningTaskRun, finalizeTaskRunByRunId } from "../../tasks/detached-task-runtime.js"; import type { TaskStatus } from "../../tasks/task-registry.types.js"; import { @@ -486,7 +487,7 @@ function resolveAgentDedupeKeys(params: { if (approvalId) { keys.push(`agent:exec-approval-followup:${approvalId}`); } - return [...new Set(keys)]; + return uniqueStrings(keys); } function readGatewayDedupeEntry(params: { @@ -1113,10 +1114,11 @@ export const agentHandlers: GatewayRequestHandlers = { // channel hints so subagent spawns from those parent runs are not rejected. const isKnownGatewayChannel = (value: string): boolean => isGatewayMessageChannel(value) || isInternalNonDeliveryChannel(value); - const channelHints = [request.channel, request.replyChannel] - .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) - .filter(Boolean); + const channelHints = normalizeStringEntries( + [request.channel, request.replyChannel].filter( + (value): value is string => typeof value === "string", + ), + ); for (const rawChannel of channelHints) { const normalized = normalizeMessageChannel(rawChannel); if (normalized && normalized !== "last" && !isKnownGatewayChannel(normalized)) { diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 4d4415c1380..6404e3eb71d 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -30,6 +30,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { root, FsSafeError, type ReadResult } from "../../infra/fs-safe.js"; import { movePathToTrash } from "../../plugin-sdk/browser-maintenance.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; +import { normalizeOptionalString as resolveOptionalStringParam } from "../../shared/string-coerce.js"; import { resolveUserPath } from "../../utils.js"; import { ErrorCodes, @@ -265,10 +266,6 @@ function sanitizeIdentityLine(value: string): string { return value.replace(/\s+/g, " ").trim(); } -function resolveOptionalStringParam(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function respondInvalidMethodParams( respond: RespondFn, method: string, diff --git a/src/gateway/server-methods/artifacts.ts b/src/gateway/server-methods/artifacts.ts index e43f2a282bd..3b44016487d 100644 --- a/src/gateway/server-methods/artifacts.ts +++ b/src/gateway/server-methods/artifacts.ts @@ -7,6 +7,8 @@ import { resolveAgentIdFromSessionKey, toAgentStoreSessionKey, } from "../../routing/session-key.js"; +import { asOptionalRecord } from "../../shared/record-coerce.js"; +import { normalizeOptionalString as asNonEmptyString } from "../../shared/string-coerce.js"; import { getTaskSessionLookupByIdForStatus } from "../../tasks/task-status-access.js"; import { ErrorCodes, @@ -55,16 +57,6 @@ function artifactError(type: string, message: string, details?: Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function asNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function resolveRequesterSessionAgentId( sessionKey: string | undefined, cfg?: OpenClawConfig, @@ -164,7 +156,7 @@ function mediaUrlValue(value: unknown): string | undefined { if (typeof value === "string") { return asNonEmptyString(value); } - const record = asRecord(value); + const record = asOptionalRecord(value); return asNonEmptyString(record?.url); } @@ -201,18 +193,18 @@ function artifactId(parts: { } function resolveMessageSeq(message: Record, fallback: number): number { - const meta = asRecord(message["__openclaw"]); + const meta = asOptionalRecord(message["__openclaw"]); const seq = meta?.seq; return typeof seq === "number" && Number.isInteger(seq) && seq > 0 ? seq : fallback; } function resolveMessageRunId(message: Record): string | undefined { - const meta = asRecord(message["__openclaw"]); + const meta = asOptionalRecord(message["__openclaw"]); return asNonEmptyString(meta?.runId) ?? asNonEmptyString(message.runId); } function resolveMessageTaskId(message: Record): string | undefined { - const meta = asRecord(message["__openclaw"]); + const meta = asOptionalRecord(message["__openclaw"]); return ( asNonEmptyString(meta?.messageTaskId) ?? asNonEmptyString(meta?.taskId) ?? @@ -233,7 +225,7 @@ function resolveBlockDownload(block: Record): { const url = asNonEmptyString(block.url) ?? asNonEmptyString(block.openUrl); const imageUrl = mediaUrlValue(block.image_url); const audioUrl = asNonEmptyString(block.audio_url); - const source = asRecord(block.source); + const source = asOptionalRecord(block.source); const sourceData = asNonEmptyString(source?.data); const sourceUrl = asNonEmptyString(source?.url); const dataUrl = [url, sourceUrl, imageUrl, audioUrl, data, content, sourceData].find( @@ -308,7 +300,7 @@ function collectArtifactsFromMessage(params: { runId?: string; taskId?: string; }): void { - const msg = asRecord(params.message); + const msg = asOptionalRecord(params.message); if (!msg) { return; } @@ -323,7 +315,7 @@ function collectArtifactsFromMessage(params: { } const content = Array.isArray(msg.content) ? msg.content : []; for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) { - const block = asRecord(content[contentIndex]); + const block = asOptionalRecord(content[contentIndex]); if (!block || !isArtifactBlock(block)) { continue; } diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index e6a203e1027..18caa6d3b2a 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -53,6 +53,7 @@ import { normalizeInputProvenance, type InputProvenance } from "../../sessions/i import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js"; import { stripInlineDirectiveTagsForDisplay, @@ -1848,7 +1849,7 @@ function resolvePreRegisteredAgentDedupeKeys( keys.push(normalized); } } - return [...new Set(keys)]; + return uniqueStrings(keys); } function writePreRegisteredAgentAbort(params: { diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 41e59b2e987..bb6d8f4c7f8 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -25,6 +25,8 @@ import { prepareSecretsRuntimeSnapshot, type PreparedSecretsRuntimeSnapshot, } from "../../secrets/runtime.js"; +import { isRecord } from "../../shared/record-coerce.js"; +import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { diffConfigPaths } from "../config-diff.js"; import { resolveConfigReloadMetadata } from "../config-reload-plan.js"; import { @@ -192,10 +194,6 @@ function formatConfigOpenError(error: unknown): string { return String(error); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function hasOwnRecordValue(value: unknown, key: string): boolean { return isRecord(value) && Object.prototype.hasOwnProperty.call(value, key); } @@ -320,9 +318,9 @@ function parseValidateConfigFromRawOrRespond( function summarizeConfigValidationIssues(issues: ReadonlyArray): string { const trimmed = issues.slice(0, MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE); - const lines = formatConfigIssueLines(trimmed, "", { normalizeRoot: true }) - .map((line) => line.trim()) - .filter(Boolean); + const lines = normalizeStringEntries( + formatConfigIssueLines(trimmed, "", { normalizeRoot: true }), + ); if (lines.length === 0) { return "invalid config"; } diff --git a/src/gateway/server-methods/environments.ts b/src/gateway/server-methods/environments.ts index 2f0f11616af..cc7f9771115 100644 --- a/src/gateway/server-methods/environments.ts +++ b/src/gateway/server-methods/environments.ts @@ -1,6 +1,7 @@ import { listDevicePairing } from "../../infra/device-pairing.js"; import { listNodePairing } from "../../infra/node-pairing.js"; import type { NodeListNode } from "../../shared/node-list-types.js"; +import { normalizeSortedUniqueTrimmedStringList } from "../../shared/string-normalization.js"; import { createKnownNodeCatalog, listKnownNodes } from "../node-catalog.js"; import { type EnvironmentSummary, @@ -21,16 +22,7 @@ const GATEWAY_ENVIRONMENT: EnvironmentSummary = { }; function uniqueSortedStrings(...items: Array): string[] { - const values = new Set(); - for (const item of items) { - for (const value of item ?? []) { - const trimmed = value.trim(); - if (trimmed) { - values.add(trimmed); - } - } - } - return [...values].toSorted((left, right) => left.localeCompare(right)); + return normalizeSortedUniqueTrimmedStringList(items.flatMap((item) => item ?? [])); } function summarizeNodeEnvironment(node: NodeListNode): EnvironmentSummary { diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index eda46a48512..4bc7686dc0c 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -30,6 +30,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { normalizeUniqueTrimmedStringList } from "../../shared/string-normalization.js"; import { createKnownNodeCatalog, getKnownNode, listKnownNodes } from "../node-catalog.js"; import { isForegroundRestrictedPluginNodeCommand, @@ -1030,11 +1031,7 @@ export const nodeHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required")); return; } - const ackIds = Array.from( - new Set( - (params.ids ?? []).map((value) => normalizeOptionalString(value) ?? "").filter(Boolean), - ), - ); + const ackIds = normalizeUniqueTrimmedStringList(params.ids); const remaining = ackPendingNodeActions(trimmedNodeId, ackIds); respond( true, diff --git a/src/gateway/server-methods/plugin-host-hooks.ts b/src/gateway/server-methods/plugin-host-hooks.ts index 091e90e4c40..38e8d4deb0e 100644 --- a/src/gateway/server-methods/plugin-host-hooks.ts +++ b/src/gateway/server-methods/plugin-host-hooks.ts @@ -7,6 +7,7 @@ import { type JsonSchemaValidationError, type JsonSchemaValue, } from "../../plugins/schema-validator.js"; +import { isRecord } from "../../shared/record-coerce.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../operator-scopes.js"; import { @@ -21,10 +22,6 @@ import type { GatewayRequestHandlers } from "./types.js"; const log = createSubsystemLogger("gateway/plugin-host-hooks"); -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function formatSessionActionPayloadSchemaErrors(errors: JsonSchemaValidationError[]): string { return errors.map((error) => error.text).join("; "); } diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 25360d46650..1efc9b6d1f0 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -15,6 +15,7 @@ import { createPluginRuntimeLoaderLogger } from "../plugins/runtime/load-context import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { PluginLogger } from "../plugins/types.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js"; import { ADMIN_SCOPE, APPROVALS_SCOPE, WRITE_SCOPE } from "./method-scopes.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./protocol/client-info.js"; @@ -729,7 +730,7 @@ export function loadGatewayPlugins(params: { const loadMs = performance.now() - beforeLoad; const loaderStatsAfter = getPluginModuleLoaderStats(); const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers); - const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods])); + const gatewayMethods = uniqueStrings([...params.baseMethods, ...pluginMethods]); params.startupTrace?.detail("plugins.gateway-load", [ ["autoEnableMs", autoEnableMs], ["resolvedConfigMs", resolvedConfigMs], diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index e8990575ba5..f3dac7ff05f 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -12,6 +12,7 @@ import { import type { OpenClawConfig } from "../config/types.openclaw.js"; import { getResolvedLoggerSettings } from "../logging.js"; import { collectEnabledInsecureOrDangerousFlags } from "../security/dangerous-config-flags.js"; +import { normalizeSortedUniqueStringEntries } from "../shared/string-normalization.js"; type StartupThinkLevel = | "off" @@ -151,9 +152,7 @@ function formatReadyDetails( loadedPluginIds: readonly string[], startupDurationLabel: string | null, ) { - const pluginIds = [...new Set(loadedPluginIds.map((id) => id.trim()).filter(Boolean))].toSorted( - (a, b) => a.localeCompare(b), - ); + const pluginIds = normalizeSortedUniqueStringEntries(loadedPluginIds); const pluginSummary = pluginIds.length === 0 ? "0 plugins" diff --git a/src/gateway/server-utils.ts b/src/gateway/server-utils.ts index 697eb1a5ea4..13b780a3dcb 100644 --- a/src/gateway/server-utils.ts +++ b/src/gateway/server-utils.ts @@ -1,13 +1,10 @@ import { defaultVoiceWakeTriggers } from "../infra/voicewake.js"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; export function normalizeVoiceWakeTriggers(input: unknown): string[] { - const raw = Array.isArray(input) ? input : []; - const cleaned = raw - .map((v) => normalizeOptionalString(v)) - .filter((v): v is string => v !== undefined) + const cleaned = normalizeTrimmedStringList(input) .slice(0, 32) - .map((v) => v.slice(0, 64)); + .map((value) => value.slice(0, 64)); return cleaned.length > 0 ? cleaned : defaultVoiceWakeTriggers(); } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 2111e3effbb..273be5b6d16 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -56,6 +56,7 @@ import { clearSecretsRuntimeSnapshot, getActiveSecretsRuntimeSnapshot, } from "../secrets/runtime-state.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js"; import { resolveGatewayAuth } from "./auth.js"; import { ADMIN_SCOPE } from "./method-scopes.js"; @@ -730,7 +731,7 @@ export async function startGatewayServer( return methods; }; const listActiveGatewayMethods = (nextBaseGatewayMethods: string[]) => - Array.from(new Set([...nextBaseGatewayMethods, ...listStartupChannelGatewayMethods()])); + uniqueStrings([...nextBaseGatewayMethods, ...listStartupChannelGatewayMethods()]); const runtimeConfig = await startupTrace.measure("runtime.config", async () => { const { resolveGatewayRuntimeConfig } = await import("./server-runtime-config.js"); return resolveGatewayRuntimeConfig({ @@ -1197,7 +1198,7 @@ export async function startGatewayServer( const listAttachedGatewayMethods = () => { const methods = attachedGatewayMethodRegistry.listAdvertisedMethods(); methods.push(...listStartupChannelGatewayMethods()); - return Array.from(new Set(methods)); + return uniqueStrings(methods); }; runtimeState.gatewayMethods.splice( 0, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 6262028112d..d3272eb9fb9 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -50,6 +50,7 @@ import { type DeviceBootstrapProfile, } from "../../../shared/device-bootstrap-profile.js"; import { roleScopesAllow } from "../../../shared/operator-scope-compat.js"; +import { uniqueStrings } from "../../../shared/string-normalization.js"; import { isBrowserOperatorUiClient, isGatewayCliClient, @@ -1112,7 +1113,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar // setup-code profile; admin/pairing scopes still require an explicit // owner flow. const bootstrapPairingRoles = allowSilentBootstrapPairing - ? Array.from(new Set([role, ...boundBootstrapProfile.roles])) + ? uniqueStrings([role, ...boundBootstrapProfile.roles]) : undefined; const bootstrapPairingScopes = allowSilentBootstrapPairing && bootstrapPairingRoles diff --git a/src/gateway/session-transcript-files.fs.ts b/src/gateway/session-transcript-files.fs.ts index e3cc714b26d..24e47ab3de7 100644 --- a/src/gateway/session-transcript-files.fs.ts +++ b/src/gateway/session-transcript-files.fs.ts @@ -13,6 +13,7 @@ import { } from "../config/sessions/paths.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; type ArchiveFileReason = SessionArchiveReason; export type ArchivedSessionTranscript = { @@ -121,7 +122,7 @@ export function resolveSessionTranscriptCandidates( const legacyDir = path.join(home, ".openclaw", "sessions"); pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir)); - return Array.from(new Set(candidates)); + return uniqueStrings(candidates); } export function archiveFileOnDisk(filePath: string, reason: ArchiveFileReason): string { @@ -257,7 +258,7 @@ export async function cleanupArchivedSessionTranscripts(opts: { } const now = opts.nowMs ?? Date.now(); const reason: ArchiveFileReason = opts.reason ?? "deleted"; - const directories = Array.from(new Set(opts.directories.map((dir) => path.resolve(dir)))); + const directories = uniqueStrings(opts.directories.map((dir) => path.resolve(dir))); let removed = 0; let scanned = 0; diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index c320eaf117d..9806f8d6e64 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -78,6 +78,7 @@ import { normalizeOptionalString, normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { normalizeSessionDeliveryFields } from "../utils/delivery-context.shared.js"; import type { ModelCostConfig } from "../utils/usage-format.js"; import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js"; @@ -654,7 +655,7 @@ function mergeChildSessionKeys( if (!storeChildSessions?.length) { return runtimeChildSessions; } - return Array.from(new Set([...runtimeChildSessions, ...storeChildSessions])); + return uniqueStrings([...runtimeChildSessions, ...storeChildSessions]); } function resolveChildSessionKeys( diff --git a/src/gateway/talk-handoff.ts b/src/gateway/talk-handoff.ts index 5356d40c249..0f520fe7efe 100644 --- a/src/gateway/talk-handoff.ts +++ b/src/gateway/talk-handoff.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import { recordTalkObservabilityEvent } from "../talk/observability.js"; import { createTalkSessionController, @@ -386,8 +387,3 @@ function joinTalkHandoffRoom(record: TalkHandoffRecord, clientId: string | undef ); return events; } - -function normalizeOptionalString(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} diff --git a/src/gateway/talk-transcription-relay.ts b/src/gateway/talk-transcription-relay.ts index c6b5b47f1f6..ee3879a9ccd 100644 --- a/src/gateway/talk-transcription-relay.ts +++ b/src/gateway/talk-transcription-relay.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { RealtimeTranscriptionProviderPlugin } from "../plugins/types.js"; import type { RealtimeTranscriptionProviderConfig } from "../realtime-transcription/provider-types.js"; +import { parseFiniteNumber as readFiniteNumber } from "../shared/number-coercion.js"; import { recordTalkObservabilityEvent } from "../talk/observability.js"; import { type TalkEvent, @@ -104,16 +105,6 @@ function normalizeRelayInputEncoding( return undefined; } -function readFiniteNumber(value: unknown): number | undefined { - const next = - typeof value === "number" - ? value - : typeof value === "string" - ? Number.parseFloat(value) - : undefined; - return Number.isFinite(next) ? next : undefined; -} - function inferSampleRateFromAudioFormat(value: unknown): number | undefined { if (typeof value !== "string") { return undefined; diff --git a/src/hooks/bundled/compaction-notifier/handler.ts b/src/hooks/bundled/compaction-notifier/handler.ts index cbe1d4baad9..4726e3348a7 100644 --- a/src/hooks/bundled/compaction-notifier/handler.ts +++ b/src/hooks/bundled/compaction-notifier/handler.ts @@ -1,8 +1,9 @@ +import { asFiniteNumber } from "../../../shared/number-coercion.js"; import type { HookHandler } from "../../hooks.js"; function readOptionalNumber(context: Record, key: string): number | undefined { const value = context[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } const handler: HookHandler = async (event) => { diff --git a/src/hooks/gmail.ts b/src/hooks/gmail.ts index 03c70ab9d6d..ce86762ed16 100644 --- a/src/hooks/gmail.ts +++ b/src/hooks/gmail.ts @@ -9,6 +9,7 @@ import { import { resolveExecutable } from "../infra/executable-path.js"; import { getWindowsInstallRoots } from "../infra/windows-install-roots.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; export const DEFAULT_GMAIL_LABEL = "INBOX"; export const DEFAULT_GMAIL_TOPIC = "gog-gmail-watch"; @@ -70,7 +71,7 @@ export function generateHookToken(bytes = 24): string { } export function mergeHookPresets(existing: string[] | undefined, preset: string): string[] { - const next = new Set((existing ?? []).map((item) => item.trim()).filter(Boolean)); + const next = new Set(normalizeUniqueStringEntries(existing)); next.add(preset); return Array.from(next); } diff --git a/src/hooks/install.ts b/src/hooks/install.ts index 5db98ff968d..9d7fe5cadf9 100644 --- a/src/hooks/install.ts +++ b/src/hooks/install.ts @@ -4,6 +4,7 @@ import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { resolveSafeInstallDir, unscopedPackageName } from "../infra/install-safe-path.js"; import { type NpmIntegrityDrift, type NpmSpecResolution } from "../infra/install-source-utils.js"; import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { parseFrontmatter } from "./frontmatter.js"; @@ -106,7 +107,7 @@ async function ensureOpenClawHooks(manifest: HookPackageManifest) { if (!Array.isArray(hooks)) { throw new Error("package.json missing openclaw.hooks"); } - const list = hooks.map((e) => (typeof e === "string" ? e.trim() : "")).filter(Boolean); + const list = normalizeTrimmedStringList(hooks); if (list.length === 0) { throw new Error("package.json openclaw.hooks is empty"); } diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index d0c506e45cd..ebb0c32acdc 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openRootFileSync } from "../infra/boundary-file-read.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { isPathInsideWithRealpath } from "../security/scan-paths.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { resolveBundledHooksDir } from "./bundled-dir.js"; import { @@ -44,11 +45,7 @@ function readHookPackageManifest(dir: string): HookPackageManifest | null { } function resolvePackageHooks(manifest: HookPackageManifest): string[] { - const raw = manifest[MANIFEST_KEY]?.hooks; - if (!Array.isArray(raw)) { - return []; - } - return raw.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); + return normalizeTrimmedStringList(manifest[MANIFEST_KEY]?.hooks); } function resolveContainedDir(baseDir: string, targetDir: string): string | null { @@ -235,9 +232,7 @@ function discoverWorkspaceHookEntries( const workspaceHooksDir = path.join(workspaceDir, "hooks"); const bundledHooksDir = opts?.bundledHooksDir ?? resolveBundledHooksDir(); const extraDirsRaw = opts?.config?.hooks?.internal?.load?.extraDirs ?? []; - const extraDirs = extraDirsRaw - .map((d) => (typeof d === "string" ? d.trim() : "")) - .filter(Boolean); + const extraDirs = normalizeTrimmedStringList(extraDirsRaw); const pluginHookDirs = resolvePluginHookDirs({ workspaceDir, config: opts?.config, diff --git a/src/image-generation/image-assets.ts b/src/image-generation/image-assets.ts index 4b735c39caa..100cb238a39 100644 --- a/src/image-generation/image-assets.ts +++ b/src/image-generation/image-assets.ts @@ -1,4 +1,5 @@ import { canonicalizeBase64 } from "../media/base64.js"; +import { isRecord } from "../shared/record-coerce.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -23,10 +24,6 @@ export type OpenAiCompatibleImageResponsePayload = { data?: unknown; }; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function throwMalformedImageResponse(message: string | undefined): never | undefined { if (message) { throw new Error(message); diff --git a/src/infra/approval-native-route-notice.ts b/src/infra/approval-native-route-notice.ts index ec26e563c4b..09a0bd5f110 100644 --- a/src/infra/approval-native-route-notice.ts +++ b/src/infra/approval-native-route-notice.ts @@ -1,4 +1,5 @@ import { formatHumanList } from "../shared/human-list.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import type { ChannelApprovalNativePlannedTarget } from "./approval-native-delivery.js"; export function describeApprovalDeliveryDestination(params: { @@ -14,14 +15,14 @@ export function describeApprovalDeliveryDestination(params: { export function resolveApprovalRoutedElsewhereNoticeText( destinations: readonly string[], ): string | null { - const uniqueDestinations = Array.from(new Set(destinations.map((value) => value.trim()))).filter( + const uniqueDestinations = sortUniqueStrings(destinations.map((value) => value.trim())).filter( Boolean, ); if (uniqueDestinations.length === 0) { return null; } return `Approval required. I sent the approval request to ${formatHumanList( - uniqueDestinations.toSorted((a, b) => a.localeCompare(b)), + uniqueDestinations, )}, not this chat.`; } diff --git a/src/infra/bonjour-discovery.ts b/src/infra/bonjour-discovery.ts index b97a9b53a22..b7b32d88c49 100644 --- a/src/infra/bonjour-discovery.ts +++ b/src/infra/bonjour-discovery.ts @@ -1,5 +1,6 @@ import { runCommandWithTimeout } from "../process/exec.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { normalizeStringEntries, uniqueStrings } from "../shared/string-normalization.js"; import { isTailnetIPv4 } from "./tailnet.js"; import { resolveWideAreaDiscoveryDomain } from "./widearea-dns.js"; @@ -110,10 +111,7 @@ function decodeDnsSdEscapes(value: string): string { } function parseDigShortLines(stdout: string): string[] { - return stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); + return normalizeStringEntries(stdout.split("\n")); } function parseDigTxt(stdout: string): string[] { @@ -191,7 +189,7 @@ function parseTailscaleStatusIPv4s(stdout: string): string[] { } } - return [...new Set(out)]; + return uniqueStrings(out); } function parseIntOrNull(value: string | undefined): number | undefined { @@ -586,10 +584,9 @@ export async function discoverGatewayBeacons( const wideAreaDomain = resolveWideAreaDiscoveryDomain({ configDomain: opts.wideAreaDomain }); const domainsRaw = Array.isArray(opts.domains) ? opts.domains : []; const defaultDomains = ["local.", ...(wideAreaDomain ? [wideAreaDomain] : [])]; - const domains = (domainsRaw.length > 0 ? domainsRaw : defaultDomains) - .map((d) => d.trim()) - .filter(Boolean) - .map((d) => (d.endsWith(".") ? d : `${d}.`)); + const domains = normalizeStringEntries(domainsRaw.length > 0 ? domainsRaw : defaultDomains).map( + (d) => (d.endsWith(".") ? d : `${d}.`), + ); try { if (platform === "darwin") { diff --git a/src/infra/clawhub.ts b/src/infra/clawhub.ts index 627bb624697..d21ce331d13 100644 --- a/src/infra/clawhub.ts +++ b/src/infra/clawhub.ts @@ -7,6 +7,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { isAtLeast, parseSemver } from "./runtime-guard.js"; import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js"; import { createTempDownloadTarget } from "./temp-download.js"; @@ -532,11 +533,7 @@ function satisfiesComparator(version: string, token: string): boolean { } function satisfiesSemverRange(version: string, range: string): boolean { - const tokens = range - .trim() - .split(/\s+/) - .map((token) => token.trim()) - .filter(Boolean); + const tokens = normalizeStringEntries(range.trim().split(/\s+/)); if (tokens.length === 0) { return false; } diff --git a/src/infra/command-analysis/explain.ts b/src/infra/command-analysis/explain.ts index ef0da9c82bf..cdb70100165 100644 --- a/src/infra/command-analysis/explain.ts +++ b/src/infra/command-analysis/explain.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "../../shared/string-normalization.js"; import type { CommandExplanation, CommandRisk } from "../command-explainer/types.js"; import type { ExecCommandSegment } from "../exec-approvals-analysis.js"; import { analyzeCommandForPolicy } from "./policy.js"; @@ -32,7 +33,7 @@ function riskLabel(risk: CommandRisk): string { export function summarizeCommandExplanation( explanation: CommandExplanation, ): CommandExplanationSummary { - const riskKinds = [...new Set(explanation.risks.map((risk) => risk.kind))]; + const riskKinds = uniqueStrings(explanation.risks.map((risk) => risk.kind)); const warningLines = explanation.risks.map((risk) => { const label = riskLabel(risk); return label === risk.kind ? `Contains ${risk.kind}` : `Contains ${risk.kind}: ${label}`; @@ -41,14 +42,10 @@ export function summarizeCommandExplanation( commandCount: explanation.topLevelCommands.length, nestedCommandCount: explanation.nestedCommands.length, riskKinds, - warningLines: [...new Set(warningLines)], + warningLines: uniqueStrings(warningLines), }; } -function uniqueStrings(values: string[]): string[] { - return [...new Set(values)]; -} - export function summarizeCommandSegmentsForDisplay( segments: readonly ExecCommandSegment[], ): CommandExplanationSummary { diff --git a/src/infra/command-analysis/risks.ts b/src/infra/command-analysis/risks.ts index 7924c8c7107..751a85d701e 100644 --- a/src/infra/command-analysis/risks.ts +++ b/src/infra/command-analysis/risks.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "../../shared/string-normalization.js"; import { splitShellArgs } from "../../utils/shell-argv.js"; import { COMMAND_CARRIER_EXECUTABLES, @@ -76,7 +77,7 @@ function stripLeadingEnvAssignments(argv: string[]): string[] { } function uniqueCommandPayloadCandidates(candidates: string[]): string[] { - return [...new Set(candidates.filter((candidate) => candidate.trim().length > 0))]; + return uniqueStrings(candidates.filter((candidate) => candidate.trim().length > 0)); } type ShellPositionalCarrierPlan = { kind: "all" } | { kind: "indexes"; indexes: number[] }; diff --git a/src/infra/control-ui-assets.ts b/src/infra/control-ui-assets.ts index 7e96b687769..01ebe81a903 100644 --- a/src/infra/control-ui-assets.ts +++ b/src/infra/control-ui-assets.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import * as controlUiFsRuntime from "./control-ui-assets.fs.runtime.js"; import { resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } from "./openclaw-root.js"; @@ -275,10 +276,7 @@ export type EnsureControlUiAssetsResult = { }; function summarizeCommandOutput(text: string): string | undefined { - const lines = text - .split(/\r?\n/g) - .map((l) => l.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(text.split(/\r?\n/g)); if (!lines.length) { return undefined; } diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 579269cfb90..7d7c6487a17 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -10,6 +10,7 @@ import { resolveScopeOutsideRequestedRoles, roleScopesAllow, } from "../shared/operator-scope-compat.js"; +import { normalizeUniqueSingleOrTrimmedStringList } from "../shared/string-normalization.js"; import { revokeDeviceBootstrapTokensForDevice } from "./device-bootstrap.js"; import { createAsyncLock, @@ -437,24 +438,11 @@ function resolveRoleScopedDeviceTokenScopes(role: string, scopes: string[] | und } function preserveRoleScopedApprovalScopes(role: string, scopes: string[] | undefined): string[] { - if (!Array.isArray(scopes)) { - return []; - } - const out = new Set(); - for (const scope of scopes) { - const trimmed = scope.trim(); - if (!trimmed) { - continue; - } - const belongsToRole = - role === OPERATOR_ROLE - ? trimmed.startsWith(OPERATOR_SCOPE_PREFIX) - : !trimmed.startsWith(OPERATOR_SCOPE_PREFIX); - if (belongsToRole) { - out.add(trimmed); - } - } - return [...out]; + return normalizeUniqueSingleOrTrimmedStringList(scopes).filter((scope) => + role === OPERATOR_ROLE + ? scope.startsWith(OPERATOR_SCOPE_PREFIX) + : !scope.startsWith(OPERATOR_SCOPE_PREFIX), + ); } function resolveApprovedTokenScopes(params: { diff --git a/src/infra/diagnostic-flags.ts b/src/infra/diagnostic-flags.ts index 7e6dfda211e..716232b8700 100644 --- a/src/infra/diagnostic-flags.ts +++ b/src/infra/diagnostic-flags.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { normalizeUniqueStringEntriesLower } from "../shared/string-normalization.js"; const DIAGNOSTICS_ENV = "OPENCLAW_DIAGNOSTICS"; @@ -33,17 +34,7 @@ function parseEnvFlags(raw?: string): ParsedEnvFlags { } function uniqueFlags(flags: string[]): string[] { - const seen = new Set(); - const out: string[] = []; - for (const flag of flags) { - const normalized = normalizeLowercaseStringOrEmpty(flag); - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - out.push(normalized); - } - return out; + return normalizeUniqueStringEntriesLower(flags); } export function resolveDiagnosticFlags( diff --git a/src/infra/dispatch-wrapper-resolution.ts b/src/infra/dispatch-wrapper-resolution.ts index 5e7835af979..5ba37f75ae6 100644 --- a/src/infra/dispatch-wrapper-resolution.ts +++ b/src/infra/dispatch-wrapper-resolution.ts @@ -1,4 +1,5 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { envInvocationUsesModifiers, parseEnvInvocationPrelude, @@ -132,7 +133,7 @@ export function extractEnvAssignmentKeysFromDispatchWrappers( } current = unwrap.argv; } - return Array.from(new Set(assignmentKeys)).toSorted((a, b) => a.localeCompare(b)); + return sortUniqueStrings(assignmentKeys); } function unwrapDashOptionInvocation( diff --git a/src/infra/event-session-routing.ts b/src/infra/event-session-routing.ts index f8a2c94bb14..4c6b4172c25 100644 --- a/src/infra/event-session-routing.ts +++ b/src/infra/event-session-routing.ts @@ -10,6 +10,7 @@ import { import { resolveEventSessionKey, scopedHeartbeatWakeOptions } from "../routing/session-key.js"; import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js"; import { deriveSessionChatTypeFromKey } from "../sessions/session-chat-type-shared.js"; +import { isRecord } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; type UnknownRecord = Record; @@ -31,10 +32,6 @@ type DirectSessionTarget = { peerId: string; }; -function isRecord(value: unknown): value is UnknownRecord { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function readAllowFrom(value: unknown): Array | undefined { if (!isRecord(value)) { return undefined; diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index e1726b8848b..d5358ad6637 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -18,6 +18,7 @@ import { } from "../plugin-sdk/approval-renderers.js"; import { channelRouteDedupeKey } from "../plugin-sdk/channel-route.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, @@ -239,10 +240,9 @@ export function buildExecApprovalRequestMessage(request: ExecApprovalRequest, no if (warningText) { lines.push("", warningText); } - const analysisWarningLines = request.request.commandAnalysis?.warningLines - .map((line) => sanitizeExecApprovalWarningText(line).trim()) - .filter(Boolean) - .slice(0, 5); + const analysisWarningLines = normalizeStringEntries( + request.request.commandAnalysis?.warningLines.map(sanitizeExecApprovalWarningText), + ).slice(0, 5); if (analysisWarningLines && analysisWarningLines.length > 0) { lines.push("", "Command analysis:"); for (const line of analysisWarningLines) { diff --git a/src/infra/exec-approvals-effective.ts b/src/infra/exec-approvals-effective.ts index c5e082b2b2a..33af2690ee2 100644 --- a/src/infra/exec-approvals-effective.ts +++ b/src/infra/exec-approvals-effective.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { DEFAULT_EXEC_APPROVAL_ASK_FALLBACK, resolveExecApprovalAllowedDecisions, @@ -171,7 +172,7 @@ export function collectExecPolicyScopeSnapshots(params: { const approvalAgentIds = Object.keys(params.approvals.agents ?? {}).filter( (agentId) => agentId !== "*" && agentId !== "default" && agentId !== DEFAULT_AGENT_ID, ); - const agentIds = Array.from(new Set([...configAgentIds, ...approvalAgentIds])).toSorted(); + const agentIds = sortUniqueStrings([...configAgentIds, ...approvalAgentIds]); for (const agentId of agentIds) { const agentConfig = params.cfg.agents?.list?.find((agent) => agent.id === agentId); snapshots.push( diff --git a/src/infra/exec-safe-bin-policy-profiles.ts b/src/infra/exec-safe-bin-policy-profiles.ts index 283bb220620..a678b7a8d95 100644 --- a/src/infra/exec-safe-bin-policy-profiles.ts +++ b/src/infra/exec-safe-bin-policy-profiles.ts @@ -1,4 +1,5 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; export type SafeBinProfile = { minPositional?: number; @@ -300,7 +301,7 @@ function resolveSafeBinDeniedFlags( ): Record { const out: Record = {}; for (const [name, fixture] of Object.entries(fixtures)) { - const denied = Array.from(new Set(fixture.deniedFlags ?? [])).toSorted(); + const denied = sortUniqueStrings(fixture.deniedFlags ?? []); if (denied.length > 0) { out[name] = denied; } diff --git a/src/infra/exec-safe-bin-trust.ts b/src/infra/exec-safe-bin-trust.ts index 56403d68211..70c34fef1c3 100644 --- a/src/infra/exec-safe-bin-trust.ts +++ b/src/infra/exec-safe-bin-trust.ts @@ -1,5 +1,10 @@ import fs from "node:fs"; import path from "node:path"; +import { + normalizeSortedUniqueStringEntries, + sortUniqueStrings, + uniqueStrings, +} from "../shared/string-normalization.js"; // Keep defaults to OS-managed immutable bins only. // User/package-manager bins must be opted in via tools.exec.safeBinTrustedDirs. @@ -84,14 +89,14 @@ export function normalizeTrustedSafeBinDirs(entries?: readonly string[] | null): return []; } const normalized = entries.map((entry) => entry.trim()).filter((entry) => entry.length > 0); - return Array.from(new Set(normalized)); + return uniqueStrings(normalized); } function resolveTrustedSafeBinDirs(entries: readonly string[], forComparison = true): string[] { const resolved = entries .map((entry) => normalizeTrustedDir(entry, forComparison)) .filter((entry): entry is string => Boolean(entry)); - return Array.from(new Set(resolved)).toSorted(); + return sortUniqueStrings(resolved); } function hasPathSelector(value: string): boolean { @@ -146,7 +151,7 @@ function resolveTrustedSafeBinTargetDirs( } } } - return Array.from(new Set(dirs)).toSorted(); + return sortUniqueStrings(dirs); } function buildTrustedSafeBinCacheKey( @@ -155,9 +160,7 @@ function buildTrustedSafeBinCacheKey( targetDirs: readonly string[], ): string { const dirsKey = resolveTrustedSafeBinDirs(normalizeTrustedSafeBinDirs(entries)).join("\u0001"); - const binsKey = Array.from(new Set(safeBins.map((entry) => entry.trim()).filter(Boolean))) - .toSorted() - .join("\u0001"); + const binsKey = normalizeSortedUniqueStringEntries(safeBins).join("\u0001"); const targetDirsKey = targetDirs.join("\u0001"); return `${dirsKey}\u0002${binsKey}\u0002${targetDirsKey}`; } diff --git a/src/infra/gateway-process-argv.ts b/src/infra/gateway-process-argv.ts index 028e798940d..ae091c81c3f 100644 --- a/src/infra/gateway-process-argv.ts +++ b/src/infra/gateway-process-argv.ts @@ -1,14 +1,12 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; function normalizeProcArg(arg: string): string { return normalizeLowercaseStringOrEmpty(arg.replaceAll("\\", "/")); } export function parseProcCmdline(raw: string): string[] { - return raw - .split("\0") - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries(raw.split("\0")); } /** diff --git a/src/infra/gateway-processes.ts b/src/infra/gateway-processes.ts index 97682fc73fb..7b2d47e4184 100644 --- a/src/infra/gateway-processes.ts +++ b/src/infra/gateway-processes.ts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; import fsSync from "node:fs"; +import { uniqueValues } from "../shared/string-normalization.js"; import { isGatewayArgv, parseProcCmdline } from "./gateway-process-argv.js"; import { findGatewayPidsOnPortSync as findUnixGatewayPidsOnPortSync } from "./restart-stale-pids.js"; import { @@ -46,7 +47,7 @@ export function findVerifiedGatewayListenerPidsOnPortSync(port: number): number[ ? readWindowsListeningPidsOnPortSync(port) : findUnixGatewayPidsOnPortSync(port); - return Array.from(new Set(rawPids)) + return uniqueValues(rawPids) .filter((pid): pid is number => Number.isFinite(pid) && pid > 0 && pid !== process.pid) .filter((pid) => { const args = readGatewayProcessArgsSync(pid); diff --git a/src/infra/host-env-security-policy.js b/src/infra/host-env-security-policy.js index 0e455048843..e4930a65a87 100644 --- a/src/infra/host-env-security-policy.js +++ b/src/infra/host-env-security-policy.js @@ -2,8 +2,8 @@ import HOST_ENV_SECURITY_POLICY_JSON from "./host-env-security-policy.json" with function sortUniqueUppercase(values) { return Object.freeze( - Array.from(new Set(values.map((value) => value.toUpperCase()))).toSorted((a, b) => - a.localeCompare(b), + Array.from(new Set(values.map((value) => value.toUpperCase()))).toSorted((left, right) => + left < right ? -1 : left > right ? 1 : 0, ), ); } diff --git a/src/infra/host-env-security.policy-parity.test.ts b/src/infra/host-env-security.policy-parity.test.ts index d3610e2981e..1e47b374de4 100644 --- a/src/infra/host-env-security.policy-parity.test.ts +++ b/src/infra/host-env-security.policy-parity.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { loadHostEnvSecurityPolicy } from "./host-env-security-policy.js"; function parseSwiftStringArray(source: string, marker: string): string[] { @@ -13,10 +14,6 @@ function parseSwiftStringArray(source: string, marker: string): string[] { return Array.from(match[1].matchAll(/"([^"]+)"/g), (m) => m[1]); } -function sortUnique(values: string[]): string[] { - return Array.from(new Set(values)).toSorted((a, b) => a.localeCompare(b)); -} - describe("host env security policy parity", () => { it("keeps generated macOS host env policy in sync with shared JSON policy", () => { const repoRoot = process.cwd(); @@ -95,10 +92,12 @@ describe("host env security policy parity", () => { ), ); - expect(policy.blockedKeys).toEqual(sortUnique([...policy.blockedEverywhereKeys])); - expect(policy.blockedOverrideKeys).toEqual(sortUnique([...policy.blockedOverrideOnlyKeys])); + expect(policy.blockedKeys).toEqual(sortUniqueStrings([...policy.blockedEverywhereKeys])); + expect(policy.blockedOverrideKeys).toEqual( + sortUniqueStrings([...policy.blockedOverrideOnlyKeys]), + ); expect(policy.blockedInheritedKeys).toEqual( - sortUnique([ + sortUniqueStrings([ ...policy.blockedEverywhereKeys, ...policy.blockedOverrideOnlyKeys.filter( (value) => !allowedInheritedOverrideOnlyKeys.has(value.toUpperCase()), @@ -106,7 +105,7 @@ describe("host env security policy parity", () => { ]), ); expect(policy.blockedInheritedPrefixes).toEqual( - sortUnique(rawPolicy.blockedInheritedPrefixes ?? rawPolicy.blockedPrefixes ?? []), + sortUniqueStrings(rawPolicy.blockedInheritedPrefixes ?? rawPolicy.blockedPrefixes ?? []), ); }); }); diff --git a/src/infra/host-env-security.reported-baseline.json b/src/infra/host-env-security.reported-baseline.json index 790bb181b71..bf5728eb609 100644 --- a/src/infra/host-env-security.reported-baseline.json +++ b/src/infra/host-env-security.reported-baseline.json @@ -2,7 +2,6 @@ "source": "OpenClaw host env dangerous-variable baseline (reported GHSA class)", "generatedAt": "2026-04-10", "reportedDangerousEverywhereKeys": [ - "_JAVA_OPTIONS", "ANT_OPTS", "BASH_ENV", "BROWSER", @@ -13,8 +12,8 @@ "CARGO_BUILD_RUSTC_WRAPPER", "CATALINA_OPTS", "CC", - "CMAKE_C_COMPILER", "CMAKE_CXX_COMPILER", + "CMAKE_C_COMPILER", "CMAKE_TOOLCHAIN_FILE", "CONFIG_SHELL", "CONFIG_SITE", @@ -75,14 +74,14 @@ "PYTHONBREAKPOINT", "PYTHONHOME", "PYTHONPATH", - "R_ENVIRON", - "R_ENVIRON_USER", - "R_PROFILE", - "R_PROFILE_USER", "RUBYLIB", "RUBYOPT", "RUBYSHELL", "RUSTC_WRAPPER", + "R_ENVIRON", + "R_ENVIRON_USER", + "R_PROFILE", + "R_PROFILE_USER", "SBT_OPTS", "SHELL", "SHELLOPTS", @@ -91,7 +90,8 @@ "SVN_EDITOR", "SVN_SSH", "VAGRANT_VAGRANTFILE", - "VIMINIT" + "VIMINIT", + "_JAVA_OPTIONS" ], "reportedDangerousOverrideOnlyKeys": [ "ALL_PROXY", @@ -120,9 +120,8 @@ "AZURE_AUTH_LOCATION", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET", - "BUN_CONFIG_REGISTRY", "BUNDLE_GEMFILE", - "C_INCLUDE_PATH", + "BUN_CONFIG_REGISTRY", "CARGO_BUILD_RUSTC_WRAPPER", "CARGO_HOME", "CFLAGS", @@ -135,6 +134,7 @@ "CPLUS_INCLUDE_PATH", "CURL_CA_BUNDLE", "CURL_HOME", + "C_INCLUDE_PATH", "DATABASE_URL", "DENO_DIR", "DOCKER_CERT_PATH", @@ -146,6 +146,8 @@ "GEM_HOME", "GEM_PATH", "GH_TOKEN", + "GITHUB_TOKEN", + "GITLAB_TOKEN", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_ASKPASS", "GIT_COMMON_DIR", @@ -161,8 +163,6 @@ "GIT_SSL_CAPATH", "GIT_SSL_NO_VERIFY", "GIT_WORK_TREE", - "GITHUB_TOKEN", - "GITLAB_TOKEN", "GOENV", "GOFLAGS", "GONOPROXY", @@ -177,8 +177,8 @@ "HGRCPATH", "HISTFILE", "HOME", - "HTTP_PROXY", "HTTPS_PROXY", + "HTTP_PROXY", "KUBECONFIG", "LDFLAGS", "LESSCLOSE", @@ -190,10 +190,10 @@ "MANPAGER", "MFLAGS", "MONGODB_URI", - "NO_PROXY", "NODE_AUTH_TOKEN", "NODE_EXTRA_CA_CERTS", "NODE_TLS_REJECT_UNAUTHORIZED", + "NO_PROXY", "NPM_TOKEN", "OBJC_INCLUDE_PATH", "OPENSSL_CONF", @@ -201,8 +201,8 @@ "PAGER", "PERL5DB", "PERL5DBCMD", - "PHP_INI_SCAN_DIR", "PHPRC", + "PHP_INI_SCAN_DIR", "PIP_CONFIG_FILE", "PIP_EXTRA_INDEX_URL", "PIP_FIND_LINKS", @@ -212,11 +212,11 @@ "PROMPT_COMMAND", "PYTHONSTARTUP", "PYTHONUSERBASE", - "R_LIBS_USER", "REDIS_URL", "REQUESTS_CA_BUNDLE", "RUSTC_WRAPPER", "RUSTFLAGS", + "R_LIBS_USER", "SSH_ASKPASS", "SSH_AUTH_SOCK", "SSL_CERT_DIR", diff --git a/src/infra/host-env-security.reported-baseline.test.ts b/src/infra/host-env-security.reported-baseline.test.ts index c01a5e33ce3..d4d14a4b605 100644 --- a/src/infra/host-env-security.reported-baseline.test.ts +++ b/src/infra/host-env-security.reported-baseline.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { isDangerousHostEnvOverrideVarName, isDangerousHostEnvVarName, @@ -79,9 +80,7 @@ function readBaselineAndPolicy(): { } function sortUniqueUpper(values: string[]): string[] { - return Array.from(new Set(values.map((value) => value.toUpperCase()))).toSorted((a, b) => - a.localeCompare(b), - ); + return sortUniqueStrings(values.map((value) => value.toUpperCase())); } describe("host env reported baseline coverage", () => { diff --git a/src/infra/host-env-security.test.ts b/src/infra/host-env-security.test.ts index 2ebbba1e9b7..6cd920b2cb0 100644 --- a/src/infra/host-env-security.test.ts +++ b/src/infra/host-env-security.test.ts @@ -1002,7 +1002,6 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => { "AWS_SHARED_CREDENTIALS_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AZURE_AUTH_LOCATION", - "C_INCLUDE_PATH", "CARGO_BUILD_RUSTC_WRAPPER", "CARGO_HOME", "CARGO_REGISTRIES_CRATES_IO_INDEX", @@ -1012,6 +1011,7 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => { "CPLUS_INCLUDE_PATH", "CURL_CA_BUNDLE", "CXX", + "C_INCLUDE_PATH", "DOCKER_CERT_PATH", "DOCKER_CONTEXT", "DOCKER_HOST", diff --git a/src/infra/host-env-security.ts b/src/infra/host-env-security.ts index 2cc65310449..ba1cf34adc9 100644 --- a/src/infra/host-env-security.ts +++ b/src/infra/host-env-security.ts @@ -1,3 +1,4 @@ +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { HOST_ENV_SECURITY_POLICY } from "./host-env-security-policy.js"; import { markOpenClawExecEnv } from "./openclaw-exec-env.js"; @@ -146,10 +147,6 @@ function listNormalizedEnvEntries( return entries; } -function sortUnique(values: Iterable): string[] { - return Array.from(new Set(values)).toSorted((a, b) => a.localeCompare(b)); -} - function sanitizeHostEnvOverridesWithDiagnostics(params?: { overrides?: Record | null; blockPathOverrides?: boolean; @@ -198,8 +195,8 @@ function sanitizeHostEnvOverridesWithDiagnostics(params?: { return { acceptedOverrides, - rejectedOverrideBlockedKeys: sortUnique(rejectedBlocked), - rejectedOverrideInvalidKeys: sortUnique(rejectedInvalid), + rejectedOverrideBlockedKeys: sortUniqueStrings(rejectedBlocked), + rejectedOverrideInvalidKeys: sortUniqueStrings(rejectedInvalid), }; } diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index d6031dd1ab2..64aaccac235 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; +import { isRecord as isObjectRecord } from "../shared/record-coerce.js"; import { pathExists } from "./fs-safe.js"; import { assertCanonicalPathWithinBase } from "./install-safe-path.js"; import { tryReadJson, writeJson } from "./json-files.js"; @@ -24,10 +25,6 @@ type HiddenProjectConfigFile = { hiddenPath: string; } | null; -function isObjectRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - async function sanitizeManifestForNpmInstall(targetDir: string): Promise { const manifestPath = path.join(targetDir, "package.json"); const parsed = await tryReadJson(manifestPath); diff --git a/src/infra/install-source-utils.ts b/src/infra/install-source-utils.ts index fdfffc618bd..9b1f44589d0 100644 --- a/src/infra/install-source-utils.ts +++ b/src/infra/install-source-utils.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { resolveUserPath } from "../utils.js"; import { resolveArchiveKind } from "./archive.js"; import { pathExists } from "./fs-safe.js"; @@ -233,10 +234,7 @@ function parseNpmPackJsonOutput( } function parsePackedArchiveFromStdout(stdout: string): string | undefined { - const lines = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(stdout.split(/\r?\n/)); for (let index = lines.length - 1; index >= 0; index -= 1) { const line = lines[index]; diff --git a/src/infra/net/proxy/managed-proxy-undici.ts b/src/infra/net/proxy/managed-proxy-undici.ts index 9328315681b..ce5060075b6 100644 --- a/src/infra/net/proxy/managed-proxy-undici.ts +++ b/src/infra/net/proxy/managed-proxy-undici.ts @@ -1,4 +1,5 @@ import type { EnvHttpProxyAgent } from "undici"; +import { isRecord as isProxyTlsRecord } from "../../../shared/record-coerce.js"; import { resolveEnvHttpProxyAgentOptions, resolveEnvHttpProxyUrl } from "../proxy-env.js"; import { getActiveManagedProxyTlsOptions, getActiveManagedProxyUrl } from "./active-proxy-state.js"; import { @@ -9,10 +10,6 @@ import { export type ManagedEnvHttpProxyAgentOptions = ConstructorParameters[0]; -function isProxyTlsRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function readProxyTlsRecord(options: object | undefined): Record | undefined { if (!options || !("proxyTls" in options)) { return undefined; diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index a6c66ed04e0..0cd8082df01 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -15,6 +15,7 @@ import { parseCanonicalIpAddress, parseLooseIpAddress, } from "../../shared/net/ip.js"; +import { normalizeUniqueStringEntries } from "../../shared/string-normalization.js"; import { normalizeHostname } from "./hostname.js"; import { createHttp1Agent, @@ -62,12 +63,11 @@ export type SsrFPolicy = { }; function normalizeSsrFPolicyHostnames(values?: string[]): string[] { - if (!values || values.length === 0) { - return []; - } - return Array.from( - new Set(values.map((value) => normalizeHostname(value)).filter(Boolean)), - ).toSorted(); + return normalizePolicyHostnames(values).toSorted(); +} + +function normalizePolicyHostnames(values?: string[]): string[] { + return normalizeUniqueStringEntries(values?.map((value) => normalizeHostname(value))); } function normalizeSsrFPolicyForComparison(policy?: SsrFPolicy) { @@ -211,23 +211,11 @@ const BLOCKED_HOSTNAMES = new Set([ ]); function normalizeHostnameSet(values?: string[]): Set { - if (!values || values.length === 0) { - return new Set(); - } - return new Set(values.map((value) => normalizeHostname(value)).filter(Boolean)); + return new Set(normalizePolicyHostnames(values)); } export function normalizeHostnameAllowlist(values?: string[]): string[] { - if (!values || values.length === 0) { - return []; - } - return Array.from( - new Set( - values - .map((value) => normalizeHostname(value)) - .filter((value) => value !== "*" && value !== "*." && value.length > 0), - ), - ); + return normalizePolicyHostnames(values).filter((value) => value !== "*" && value !== "*."); } export function isPrivateNetworkAllowedByPolicy(policy?: SsrFPolicy): boolean { diff --git a/src/infra/net/undici-runtime.ts b/src/infra/net/undici-runtime.ts index 47f20d66c62..ba49cb854a3 100644 --- a/src/infra/net/undici-runtime.ts +++ b/src/infra/net/undici-runtime.ts @@ -1,5 +1,6 @@ import { createRequire } from "node:module"; import net from "node:net"; +import { isRecord as isObjectRecord } from "../../shared/record-coerce.js"; import { addActiveManagedProxyTlsOptions } from "./proxy/managed-proxy-undici.js"; import { resolveUndiciAutoSelectFamilyConnectOptions } from "./undici-family-policy.js"; @@ -34,10 +35,6 @@ const HTTP1_ONLY_DISPATCHER_OPTIONS = Object.freeze({ allowH2: false as const, }); -function isObjectRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function applyMissingConnectOptions( connect: Record, defaults: Record, diff --git a/src/infra/npm-install-env.ts b/src/infra/npm-install-env.ts index 012229e4133..8d4a1aee87a 100644 --- a/src/infra/npm-install-env.ts +++ b/src/infra/npm-install-env.ts @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; +import { uniqueStrings } from "../shared/string-normalization.js"; export type NpmProjectInstallEnvOptions = { cacheDir?: string; @@ -138,7 +139,7 @@ function resolveNpmConfigFiles( resolveScopedGlobalNpmrc(scope), readNpmGlobalConfigPath(env, scope), ]; - return [...new Set(files.filter((file): file is string => Boolean(file)))]; + return uniqueStrings(files.filter((file): file is string => Boolean(file))); } function hasNpmrcConfigKey(filePath: string, key: string): boolean { diff --git a/src/infra/npm-managed-root.ts b/src/infra/npm-managed-root.ts index 47ead616448..466abe45c34 100644 --- a/src/infra/npm-managed-root.ts +++ b/src/infra/npm-managed-root.ts @@ -3,6 +3,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; +import { isRecord } from "../shared/record-coerce.js"; +import { normalizeOptionalString as readOptionalString } from "../shared/string-coerce.js"; import type { NpmSpecResolution } from "./install-source-utils.js"; import { readJson, readJsonIfExists, writeJson } from "./json-files.js"; import type { ParsedRegistryNpmSpec } from "./npm-registry-spec.js"; @@ -55,14 +57,6 @@ type ManagedNpmRootRunCommand = typeof runCommandWithTimeout; type ManagedNpmRootOpenClawHostState = "none" | "managed-active-host" | "linked-active-host"; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readDependencyRecord(value: unknown): Record { if (!isRecord(value)) { return {}; diff --git a/src/infra/outbound/outbound-policy.ts b/src/infra/outbound/outbound-policy.ts index 0d48277869e..81a2b80b04e 100644 --- a/src/infra/outbound/outbound-policy.ts +++ b/src/infra/outbound/outbound-policy.ts @@ -7,6 +7,7 @@ import type { import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { MessageToolsConfig } from "../../config/types.tools.js"; import type { MessagePresentation } from "../../interactive/payload.js"; +import { normalizeUniqueStringEntries } from "../../shared/string-normalization.js"; import { normalizeTargetForProvider } from "./target-normalization.js"; import { formatTargetDisplay, lookupDirectoryDisplay } from "./target-resolver.js"; @@ -152,8 +153,8 @@ export function resolveAllowedMessageActions(params: { if (!allow) { return undefined; } - const normalized = allow.map((entry) => entry.trim()).filter(Boolean); - return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined; + const normalized = normalizeUniqueStringEntries(allow); + return normalized.length > 0 ? normalized : undefined; } export function enforceMessageActionAllowlist(params: { diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index c38a1defa5a..fb73f729f8d 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -10,6 +10,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { RoutePeer } from "../../routing/resolve-route.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { buildOutboundBaseSessionKey } from "./base-session-key.js"; import type { ResolvedMessagingTarget } from "./target-resolver.js"; @@ -127,10 +128,7 @@ function inferPeerKind(params: { } const plugin = resolveOutboundChannelPlugin(params.channel); const strippedTarget = stripProviderPrefix(params.target, params.channel).trim(); - const targets = [params.target, strippedTarget].filter( - (target, index, values): target is string => - Boolean(target) && values.indexOf(target) === index, - ); + const targets = uniqueStrings([params.target, strippedTarget].filter(Boolean)); return ( inferPeerKindFromPlugin({ plugin, targets }) ?? inferPeerKindFromLegacyParser({ plugin, targets }) ?? diff --git a/src/infra/outbound/session-binding-service.ts b/src/infra/outbound/session-binding-service.ts index 574f9eb71b1..97e654c9f51 100644 --- a/src/infra/outbound/session-binding-service.ts +++ b/src/infra/outbound/session-binding-service.ts @@ -1,4 +1,5 @@ import { resolveGlobalMap } from "../../shared/global-singleton.js"; +import { uniqueValues } from "../../shared/string-normalization.js"; import { testing as genericCurrentConversationBindingTesting, bindGenericCurrentConversation, @@ -97,7 +98,7 @@ function resolveAdapterPlacements(adapter: SessionBindingAdapter): SessionBindin Boolean(value), ); if (placements && placements.length > 0) { - return [...new Set(placements)]; + return uniqueValues(placements); } return ["current", "child"]; } diff --git a/src/infra/outbound/source-reply-mirror.ts b/src/infra/outbound/source-reply-mirror.ts index 966029714b9..c6eeaf265fa 100644 --- a/src/infra/outbound/source-reply-mirror.ts +++ b/src/infra/outbound/source-reply-mirror.ts @@ -10,6 +10,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { normalizeOptionalTrimmedStringList } from "../../shared/string-normalization.js"; import { createOutboundPayloadPlan, projectOutboundPayloadPlanForMirror } from "./payloads.js"; type SourceReplyTranscriptMirrorParams = { @@ -29,13 +30,7 @@ type MirrorableSourceReplyTranscriptParams = SourceReplyTranscriptMirrorParams & }; function readStringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const normalized = value - .map((entry) => normalizeOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)); - return normalized.length ? normalized : undefined; + return normalizeOptionalTrimmedStringList(value); } function readFirstString( diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index 63ce53e423c..bdedd454812 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { isLocalBuildMetadataDistPath } from "../../scripts/lib/local-build-metadata-paths.mjs"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { readJsonIfExists, writeJson } from "./json-files.js"; export { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-metadata-paths.mjs"; @@ -310,9 +311,7 @@ export async function assertNoLegacyPluginDependencyStagingDebris( export async function writePackageDistInventory(packageRoot: string): Promise { await assertNoLegacyPluginDependencyStagingDebris(packageRoot); - const inventory = [...new Set(await collectPackageDistInventory(packageRoot))].toSorted( - (left, right) => left.localeCompare(right), - ); + const inventory = sortUniqueStrings(await collectPackageDistInventory(packageRoot)); const inventoryPath = path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH); await writeJson(inventoryPath, inventory, { trailingNewline: true }); return inventory; @@ -327,9 +326,7 @@ async function readPackageDistInventoryOptional(packageRoot: string): Promise typeof entry !== "string")) { throw new Error(`Invalid package dist inventory at ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`); } - return [...new Set(parsed.map(normalizeRelativePath))].toSorted((left, right) => - left.localeCompare(right), - ); + return sortUniqueStrings(parsed.map(normalizeRelativePath)); } export async function readPackageDistInventoryIfPresent( diff --git a/src/infra/package-json.ts b/src/infra/package-json.ts index b6529912e3e..6aa5cdf2cf2 100644 --- a/src/infra/package-json.ts +++ b/src/infra/package-json.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { normalizeNullableString as normalizeString } from "../shared/string-coerce.js"; import { tryReadJson } from "./json-files.js"; type PackageJson = { @@ -7,10 +8,6 @@ type PackageJson = { version?: unknown; }; -function normalizeString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - export async function readPackageJson(root: string): Promise { const parsed = await tryReadJson(path.join(root, "package.json")); return parsed && typeof parsed === "object" && !Array.isArray(parsed) diff --git a/src/infra/package-update-utils.ts b/src/infra/package-update-utils.ts index 47d21e8c23f..97457974c42 100644 --- a/src/infra/package-update-utils.ts +++ b/src/infra/package-update-utils.ts @@ -1,6 +1,7 @@ import fsSync from "node:fs"; import path from "node:path"; import { readRootJsonObjectSync } from "@openclaw/fs-safe/json"; +import { isRecord } from "../shared/record-coerce.js"; export function expectedIntegrityForUpdate( spec: string | undefined, @@ -24,10 +25,6 @@ export function expectedIntegrityForUpdate( return integrity; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function readInstalledPackageManifest(dir: string): Record | undefined { const result = readRootJsonObjectSync({ rootDir: dir, diff --git a/src/infra/path-env.ts b/src/infra/path-env.ts index f009ce809ce..71eb57ca10a 100644 --- a/src/infra/path-env.ts +++ b/src/infra/path-env.ts @@ -1,6 +1,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { + normalizeStringEntries, + normalizeUniqueStringEntries, +} from "../shared/string-normalization.js"; import { resolveBrewPathDirs } from "./brew.js"; import { isTruthyEnvValue } from "./env.js"; @@ -31,12 +35,7 @@ function isDirectory(dirPath: string): boolean { } function splitPathParts(pathEnv: string): Set { - return new Set( - pathEnv - .split(path.delimiter) - .map((part) => part.trim()) - .filter(Boolean), - ); + return new Set(normalizeStringEntries(pathEnv.split(path.delimiter))); } function isKnownPathDir(existingPathParts: ReadonlySet, dirPath: string): boolean { @@ -62,22 +61,11 @@ function resolvePathBootstrapBrewDirs(params: { } function mergePath(params: { existing: string; prepend?: string[]; append?: string[] }): string { - const partsExisting = params.existing - .split(path.delimiter) - .map((part) => part.trim()) - .filter(Boolean); - const partsPrepend = (params.prepend ?? []).map((part) => part.trim()).filter(Boolean); - const partsAppend = (params.append ?? []).map((part) => part.trim()).filter(Boolean); - - const seen = new Set(); - const merged: string[] = []; - for (const part of [...partsPrepend, ...partsExisting, ...partsAppend]) { - if (!seen.has(part)) { - seen.add(part); - merged.push(part); - } - } - return merged.join(path.delimiter); + return normalizeUniqueStringEntries([ + ...(params.prepend ?? []), + ...params.existing.split(path.delimiter), + ...(params.append ?? []), + ]).join(path.delimiter); } function candidateBinDirs( diff --git a/src/infra/path-prepend.ts b/src/infra/path-prepend.ts index c3181848c44..e06bf345165 100644 --- a/src/infra/path-prepend.ts +++ b/src/infra/path-prepend.ts @@ -1,4 +1,8 @@ import path from "node:path"; +import { + normalizeStringEntries, + normalizeUniqueStringEntries, +} from "../shared/string-normalization.js"; /** * Find the actual key used for PATH in the env object. @@ -41,20 +45,9 @@ export function mergePathPrepend(existing: string | undefined, prepend: string[] if (prepend.length === 0) { return existing; } - const partsExisting = (existing ?? "") - .split(path.delimiter) - .map((part) => part.trim()) - .filter(Boolean); - const merged: string[] = []; - const seen = new Set(); - for (const part of [...prepend, ...partsExisting]) { - if (seen.has(part)) { - continue; - } - seen.add(part); - merged.push(part); - } - return merged.join(path.delimiter); + return normalizeUniqueStringEntries([...prepend, ...(existing ?? "").split(path.delimiter)]).join( + path.delimiter, + ); } export function removePathPrepend( @@ -65,15 +58,11 @@ export function removePathPrepend( return existing; } - const prependEntries = new Set( - prepend.map((part) => part.trim()).filter(Boolean), - ); + const prependEntries = new Set(normalizeStringEntries(prepend)); - const remaining: string[] = (existing ?? "") - .split(path.delimiter) - .map((part) => part.trim()) - .filter(Boolean) - .filter((part) => !prependEntries.has(part)); + const remaining = normalizeStringEntries((existing ?? "").split(path.delimiter)).filter( + (part) => !prependEntries.has(part), + ); return remaining.join(path.delimiter); } diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 16e5b65563e..2e99afb3d86 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -21,6 +21,7 @@ import { import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { resolveProviderUsageAuthWithPlugin } from "../plugins/provider-runtime.js"; import { resolveProviderAuthEnvVarCandidates } from "../secrets/provider-env-vars.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { resolveLegacyPiAgentAccessToken } from "./provider-usage.shared.js"; import type { UsageProviderId } from "./provider-usage.types.js"; @@ -109,7 +110,9 @@ function resolveProviderApiKeyFromConfigAndStore(params: { } const normalizedProviderIds = new Set( - params.providerIds.map((providerId) => normalizeProviderId(providerId)).filter(Boolean), + normalizeUniqueStringEntries( + params.providerIds.map((providerId) => normalizeProviderId(providerId)), + ), ); const cred = [...normalizedProviderIds] .flatMap((providerId) => diff --git a/src/infra/restart-handoff.ts b/src/infra/restart-handoff.ts index 005c7b72137..99b90c078f2 100644 --- a/src/infra/restart-handoff.ts +++ b/src/infra/restart-handoff.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { isRecord } from "../shared/record-coerce.js"; export const GATEWAY_SUPERVISOR_RESTART_HANDOFF_FILENAME = "gateway-supervisor-restart-handoff.json"; @@ -207,10 +208,6 @@ function isSupervisorMode(value: unknown): value is GatewayRestartHandoffSupervi return value === "launchd" || value === "systemd" || value === "schtasks" || value === "external"; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function parseGatewayRestartHandoff(raw: string): GatewayRestartHandoff | null { let parsed: unknown; try { diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index 8efb69d888b..5fce4030beb 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveStateDir } from "../config/paths.js"; +import { isRecord as isPlainRecord } from "../shared/record-coerce.js"; import { resolveRuntimeServiceVersion } from "../version.js"; import { writeJson } from "./json-files.js"; @@ -89,10 +90,6 @@ export async function writeRestartSentinel( return filePath; } -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function cloneRestartSentinelPayload(payload: RestartSentinelPayload): RestartSentinelPayload { return JSON.parse(JSON.stringify(payload)) as RestartSentinelPayload; } diff --git a/src/infra/restart-stale-pids.ts b/src/infra/restart-stale-pids.ts index 252a6d863cf..b061b29914c 100644 --- a/src/infra/restart-stale-pids.ts +++ b/src/infra/restart-stale-pids.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { resolveGatewayPort } from "../config/paths.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { uniqueValues } from "../shared/string-normalization.js"; import { isGatewayArgv, parseProcCmdline } from "./gateway-process-argv.js"; import { resolveLsofCommandSync } from "./ports-lsof.js"; import { getWindowsInstallRoots } from "./windows-install-roots.js"; @@ -274,7 +275,7 @@ function parsePidsFromLsofOutput(stdout: string, spawnTimeoutMs: number): number pids.push(entry.pid); } } - return [...new Set(pids)]; + return uniqueValues(pids); } /** @@ -285,7 +286,7 @@ function parsePidsFromLsofOutput(stdout: string, spawnTimeoutMs: number): number */ function filterVerifiedWindowsGatewayPids(rawPids: number[]): number[] { const excluded = getSelfAndAncestorPidsSync(); - return Array.from(new Set(rawPids)) + return uniqueValues(rawPids) .filter((pid) => Number.isFinite(pid) && pid > 0 && !excluded.has(pid)) .filter((pid) => { const args = readWindowsProcessArgsSync(pid); @@ -299,7 +300,7 @@ function filterVerifiedWindowsGatewayPidsResult( ): WindowsListeningPidsResult { const excluded = getSelfAndAncestorPidsSync(); const verified: number[] = []; - for (const pid of Array.from(new Set(rawPids))) { + for (const pid of uniqueValues(rawPids)) { if (!Number.isFinite(pid) || pid <= 0 || excluded.has(pid)) { continue; } diff --git a/src/infra/skills-remote.ts b/src/infra/skills-remote.ts index 129da82c9a7..066159b974e 100644 --- a/src/infra/skills-remote.ts +++ b/src/infra/skills-remote.ts @@ -9,6 +9,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { listNodePairing, updatePairedNodeMetadata } from "./node-pairing.js"; type RemoteNodeRecord = { @@ -276,7 +277,7 @@ function parseBinProbePayload(payloadJSON: string | null | undefined, payload?: ? (JSON.parse(payloadJSON) as { stdout?: unknown; bins?: unknown }) : (payload as { stdout?: unknown; bins?: unknown }); if (Array.isArray(parsed.bins)) { - return parsed.bins.map((bin) => normalizeOptionalString(String(bin)) ?? "").filter(Boolean); + return normalizeStringEntries(parsed.bins); } if (parsed.bins && typeof parsed.bins === "object") { return Object.entries(parsed.bins) diff --git a/src/infra/ssh-tunnel.ts b/src/infra/ssh-tunnel.ts index c6da4a8cde3..28930bc4e32 100644 --- a/src/infra/ssh-tunnel.ts +++ b/src/infra/ssh-tunnel.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import net from "node:net"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { formatErrorMessage, isErrno } from "./errors.js"; import { ensurePortAvailable } from "./ports.js"; @@ -157,10 +158,7 @@ export async function startSshPortForward(opts: { }); child.stderr?.setEncoding("utf8"); child.stderr?.on("data", (chunk) => { - const lines = String(chunk) - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(String(chunk).split("\n")); stderr.push(...lines); }); diff --git a/src/infra/tailnet.ts b/src/infra/tailnet.ts index 4f9d27a2d9c..d7a13c60ab1 100644 --- a/src/infra/tailnet.ts +++ b/src/infra/tailnet.ts @@ -1,4 +1,5 @@ import { isIpInCidr } from "../shared/net/ip.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { listExternalInterfaceAddresses, readNetworkInterfaces } from "./network-interfaces.js"; type TailnetAddresses = { @@ -34,7 +35,7 @@ export function listTailnetAddresses(): TailnetAddresses { } } - return { ipv4: [...new Set(ipv4)], ipv6: [...new Set(ipv6)] }; + return { ipv4: uniqueStrings(ipv4), ipv6: uniqueStrings(ipv6) }; } export function pickPrimaryTailnetIPv4(): string | undefined { diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 60c894c1dd6..9741de3b354 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -4,6 +4,7 @@ import { promptYesNo } from "../cli/prompt.js"; import { danger, info, logVerbose, shouldLogVerbose, warn } from "../globals.js"; import { runExec } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { asNullableObjectRecord as readRecord } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -517,10 +518,6 @@ export async function disableTailscaleFunnel(exec: typeof runExec = runExec) { }); } -function readRecord(value: unknown): Record | null { - return value && typeof value === "object" ? (value as Record) : null; -} - function parseWhoisIdentity(payload: Record): TailscaleWhoisIdentity | null { const userProfile = readRecord(payload.UserProfile) ?? readRecord(payload.userProfile) ?? readRecord(payload.User); diff --git a/src/infra/update-control-plane-sentinel.ts b/src/infra/update-control-plane-sentinel.ts index 6cde2ebec63..be74a798b81 100644 --- a/src/infra/update-control-plane-sentinel.ts +++ b/src/infra/update-control-plane-sentinel.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { isRecord } from "../shared/record-coerce.js"; import { markUpdateRestartSentinelFailure, writeRestartSentinel, @@ -51,10 +52,6 @@ export function isPendingControlPlaneUpdateRestartSentinel( ); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function normalizeText(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 56db25dabbe..c41bb101c0f 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { resolveGatewayInstallEntrypoint } from "../daemon/gateway-entrypoint.js"; import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js"; +import { normalizeStringEntries, uniqueStrings } from "../shared/string-normalization.js"; import { resolveControlUiDistIndexHealth, resolveControlUiDistIndexPathForRoot, @@ -235,7 +236,7 @@ function buildStartDirs(opts: UpdateRunnerOptions): string[] { if (proc) { dirs.push(proc); } - return Array.from(new Set(dirs)); + return uniqueStrings(dirs); } function resolvePreflightTempRootPrefix() { @@ -302,10 +303,7 @@ async function listGitTags( if (!res || res.code !== 0) { return []; } - return res.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); + return normalizeStringEntries(res.stdout.split("\n")); } async function resolveChannelTag( @@ -917,10 +915,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< step("git remote", ["git", "-C", gitRoot, "remote"], gitRoot), ); steps.push(remoteListStep); - const remotes = (remoteListStep.stdoutTail ?? "") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); + const remotes = normalizeStringEntries((remoteListStep.stdoutTail ?? "").split("\n")); let fetchedTag = false; for (const remote of remotes) { const targetTagFetchStep = await runStep( @@ -1037,10 +1032,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< }; } - candidates = (revListStep.stdoutTail ?? "") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); + candidates = normalizeStringEntries((revListStep.stdoutTail ?? "").split("\n")); if (candidates.length === 0) { return { status: "error", diff --git a/src/infra/voicewake-routing.ts b/src/infra/voicewake-routing.ts index ed68a50e38a..6af738db8b7 100644 --- a/src/infra/voicewake-routing.ts +++ b/src/infra/voicewake-routing.ts @@ -5,6 +5,8 @@ import { isValidAgentId, normalizeAgentId, } from "../routing/session-key.js"; +import { isRecord as isPlainObject } from "../shared/record-coerce.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import { createAsyncLock, tryReadJson, writeJson } from "./json-files.js"; type VoiceWakeRouteTarget = @@ -48,14 +50,6 @@ export function normalizeVoiceWakeTriggerWord(value: string): string { .join(" "); } -function normalizeOptionalString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function normalizeRouteTarget(value: unknown): VoiceWakeRouteTarget | null { if (!value || typeof value !== "object") { return null; @@ -104,10 +98,6 @@ function isCanonicalAgentSessionKey(value: string): boolean { return !trimmed.split(":").some((part) => part.length === 0); } -function isPlainObject(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function validateRouteTargetInput( value: unknown, label: string, diff --git a/src/infra/windows-port-pids.ts b/src/infra/windows-port-pids.ts index 15d9a8e68a1..bca9c15f1d7 100644 --- a/src/infra/windows-port-pids.ts +++ b/src/infra/windows-port-pids.ts @@ -1,6 +1,7 @@ import { spawnSync } from "node:child_process"; import { parseCmdScriptCommandLine } from "../daemon/cmd-argv.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; const DEFAULT_TIMEOUT_MS = 5_000; @@ -91,10 +92,7 @@ export function readWindowsListeningPidsResultSync( // --------------------------------------------------------------------------- function extractWindowsCommandLine(raw: string): string | null { - const lines = raw - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(raw.split(/\r?\n/)); for (const line of lines) { if (!normalizeLowercaseStringOrEmpty(line).startsWith("commandline=")) { continue; diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts index c83cc00b45f..42b947d3908 100644 --- a/src/interactive/payload.ts +++ b/src/interactive/payload.ts @@ -1,3 +1,4 @@ +import { asOptionalRecord as toRecord } from "../shared/record-coerce.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -175,13 +176,6 @@ function normalizePresentationTone(value: unknown): MessagePresentationTone | un : undefined; } -function toRecord(raw: unknown): Record | undefined { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return undefined; - } - return raw as Record; -} - function normalizeButton(raw: unknown): InteractiveReplyButton | undefined { const record = toRecord(raw); if (!record) { diff --git a/src/link-understanding/format.ts b/src/link-understanding/format.ts index a81a86bf3f9..2fb1545ed04 100644 --- a/src/link-understanding/format.ts +++ b/src/link-understanding/format.ts @@ -1,5 +1,7 @@ +import { normalizeStringEntries } from "../shared/string-normalization.js"; + export function formatLinkUnderstandingBody(params: { body?: string; outputs: string[] }): string { - const outputs = params.outputs.map((output) => output.trim()).filter(Boolean); + const outputs = normalizeStringEntries(params.outputs); if (outputs.length === 0) { return params.body ?? ""; } diff --git a/src/logging/config.ts b/src/logging/config.ts index 987bca7a437..f0aa94310b2 100644 --- a/src/logging/config.ts +++ b/src/logging/config.ts @@ -3,6 +3,7 @@ import JSON5 from "json5"; import { getCommandPathWithRootOptions } from "../cli/argv.js"; import { resolveConfigPath } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isRecord as isObjectRecord } from "../shared/record-coerce.js"; type LoggingConfig = OpenClawConfig["logging"]; @@ -18,10 +19,6 @@ export function shouldSkipMutatingLoggingConfigRead(argv: string[] = process.arg return primary === "config" && (secondary === "schema" || secondary === "validate"); } -function isObjectRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - export function readLoggingConfig(): LoggingConfig | undefined { if (shouldSkipMutatingLoggingConfigRead()) { return undefined; diff --git a/src/logging/diagnostic-support-export.ts b/src/logging/diagnostic-support-export.ts index 6aa3e317062..bad474257dd 100644 --- a/src/logging/diagnostic-support-export.ts +++ b/src/logging/diagnostic-support-export.ts @@ -5,6 +5,7 @@ import { parseConfigJson5 } from "../config/io.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; import { resolveHomeRelativePath } from "../infra/home-dir.js"; +import { asOptionalRecord } from "../shared/record-coerce.js"; import { VERSION } from "../version.js"; import { readDiagnosticStabilityBundleFileSync, @@ -165,13 +166,6 @@ function normalizePositiveInteger(value: unknown, fallback: number): number { return Math.floor(parsed); } -function asRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - return value as Record; -} - function safeScalar(value: unknown): unknown { if (typeof value === "boolean") { return value; @@ -187,15 +181,15 @@ function safeScalar(value: unknown): unknown { } function sortedObjectKeys(value: unknown): string[] { - return Object.keys(asRecord(value) ?? {}).toSorted((a, b) => a.localeCompare(b)); + return Object.keys(asOptionalRecord(value) ?? {}).toSorted((a, b) => a.localeCompare(b)); } function sanitizeConfigShape(parsed: unknown, configPath: string, stat: fs.Stats): ConfigShape { - const root = asRecord(parsed) ?? {}; - const gateway = asRecord(root.gateway); - const auth = asRecord(gateway?.auth); - const channels = asRecord(root.channels); - const plugins = asRecord(root.plugins); + const root = asOptionalRecord(parsed) ?? {}; + const gateway = asOptionalRecord(root.gateway); + const auth = asOptionalRecord(gateway?.auth); + const channels = asOptionalRecord(root.channels); + const plugins = asOptionalRecord(root.plugins); const agents = Array.isArray(root.agents) ? root.agents : undefined; const shape: ConfigShape = { diff --git a/src/logging/diagnostic-support-log-redaction.ts b/src/logging/diagnostic-support-log-redaction.ts index 5eadd1df1f2..241ec6eef2e 100644 --- a/src/logging/diagnostic-support-log-redaction.ts +++ b/src/logging/diagnostic-support-log-redaction.ts @@ -1,4 +1,5 @@ import { isBlockedObjectKey } from "../infra/prototype-keys.js"; +import { asOptionalRecord } from "../shared/record-coerce.js"; import { redactSupportString, type SupportRedactionContext, @@ -25,13 +26,6 @@ function byteLength(content: string): number { return Buffer.byteLength(content, "utf8"); } -function asRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - return value as Record; -} - function createLogRecord(): Record { return Object.create(null) as Record; } @@ -50,7 +44,7 @@ export function sanitizeSupportLogRecord( }; } - const source = asRecord(parsed); + const source = asOptionalRecord(parsed); if (!source) { return { omitted: "non-object", @@ -89,7 +83,7 @@ function addLogTapeMetaFields( source: Record, redaction: SupportRedactionContext, ): void { - const meta = asRecord(source[LOGTAPE_META_FIELD]); + const meta = asOptionalRecord(source[LOGTAPE_META_FIELD]); if (!meta) { return; } @@ -121,7 +115,7 @@ function addLogTapeArgFields( .toSorted(([left], [right]) => Number(left) - Number(right)); for (const [, value] of args) { - const record = typeof value === "string" ? parseJsonRecord(value) : asRecord(value); + const record = typeof value === "string" ? parseJsonRecord(value) : asOptionalRecord(value); if (record) { addLogObjectFields(sanitized, record, redaction); continue; @@ -163,7 +157,7 @@ function parseJsonRecord(value: string): Record | undefined { return undefined; } try { - return asRecord(JSON.parse(trimmed)); + return asOptionalRecord(JSON.parse(trimmed)); } catch { return undefined; } diff --git a/src/logging/diagnostic-support-redaction.ts b/src/logging/diagnostic-support-redaction.ts index 7f91f75c961..272411332cc 100644 --- a/src/logging/diagnostic-support-redaction.ts +++ b/src/logging/diagnostic-support-redaction.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { isSecretRefShape } from "../config/redact-snapshot.secret-ref.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { isSensitiveUrlQueryParamName } from "../shared/net/redact-sensitive-url.js"; +import { asOptionalRecord } from "../shared/record-coerce.js"; import { redactSensitiveText } from "./redact.js"; const SECRET_SUPPORT_FIELD_RE = @@ -60,13 +61,6 @@ type LimitedSupportArray = { items: unknown[]; }; -function asRecord(value: unknown): Record | undefined { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - return value as Record; -} - function isPrivateSupportField(key: string): boolean { return ( SECRET_SUPPORT_FIELD_RE.test(key) || @@ -398,7 +392,7 @@ export function sanitizeSupportSnapshotValue( count, ); } - const record = asRecord(value); + const record = asOptionalRecord(value); if (!record) { return ""; } @@ -447,7 +441,7 @@ export function sanitizeSupportConfigValue( count, ); } - const record = asRecord(value); + const record = asOptionalRecord(value); if (!record) { return ""; } diff --git a/src/media-generation/catalog.ts b/src/media-generation/catalog.ts index f3253e7fef9..c4b30904b02 100644 --- a/src/media-generation/catalog.ts +++ b/src/media-generation/catalog.ts @@ -3,6 +3,7 @@ import type { UnifiedModelCatalogKind, UnifiedModelCatalogSource, } from "../model-catalog/types.js"; +import { normalizeUniqueSingleOrTrimmedStringList } from "../shared/string-normalization.js"; export type MediaGenerationCatalogKind = Exclude; @@ -27,17 +28,10 @@ export type MediaGenerationCatalogProvider = { }; function uniqueModels(provider: { defaultModel?: string; models?: readonly string[] }): string[] { - const seen = new Set(); - const models: string[] = []; - for (const candidate of [provider.defaultModel, ...(provider.models ?? [])]) { - const model = candidate?.trim(); - if (!model || seen.has(model)) { - continue; - } - seen.add(model); - models.push(model); - } - return models; + return normalizeUniqueSingleOrTrimmedStringList([ + provider.defaultModel, + ...(provider.models ?? []), + ]); } export function synthesizeMediaGenerationCatalogEntries(params: { diff --git a/src/media-understanding/defaults.ts b/src/media-understanding/defaults.ts index fb20e9b224b..0123c2cc547 100644 --- a/src/media-understanding/defaults.ts +++ b/src/media-understanding/defaults.ts @@ -1,6 +1,7 @@ import { resolveRuntimeConfigCacheKey } from "../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../config/types.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { buildMediaUnderstandingManifestMetadataRegistry } from "./manifest-metadata.js"; import { normalizeMediaExecutionProviderId, @@ -132,7 +133,7 @@ function insertConfiguredImageProviders(params: { for (const providerId of params.configured.filter((id) => !isExecutionAliasProvider(id))) { merged.push(providerId); } - return [...new Set(merged)]; + return uniqueStrings(merged); } export function resolveDefaultMediaModel(params: { diff --git a/src/media-understanding/image.ts b/src/media-understanding/image.ts index 4481bbec9de..6dfbcfcb721 100644 --- a/src/media-understanding/image.ts +++ b/src/media-understanding/image.ts @@ -27,6 +27,7 @@ import { COPILOT_INTEGRATION_ID, resolveCopilotApiToken, } from "../plugin-sdk/provider-auth.js"; +import { isRecord } from "../shared/record-coerce.js"; import { normalizeMediaProviderId } from "./provider-id.js"; import type { ImageDescriptionRequest, @@ -46,10 +47,6 @@ function resolveImageToolMaxTokens(modelMaxTokens: number | undefined, requested return Math.min(requestedMaxTokens, modelMaxTokens); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function isNativeResponsesReasoningPayload(model: Model): boolean { if ( model.api !== "openai-responses" && diff --git a/src/media-understanding/openai-compatible-video.ts b/src/media-understanding/openai-compatible-video.ts index 7f13f0dce31..56ec887c7de 100644 --- a/src/media-understanding/openai-compatible-video.ts +++ b/src/media-understanding/openai-compatible-video.ts @@ -1,4 +1,5 @@ import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; export type OpenAiCompatibleVideoPayload = { choices?: Array<{ @@ -28,11 +29,7 @@ export function coerceOpenAiCompatibleVideoText( return message.content.trim(); } if (Array.isArray(message.content)) { - const text = message.content - .map((part) => (typeof part.text === "string" ? part.text.trim() : "")) - .filter(Boolean) - .join("\n") - .trim(); + const text = normalizeTrimmedStringList(message.content.map((part) => part.text)).join("\n"); if (text) { return text; } diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 8557ee5c347..8bd05af7327 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -23,7 +23,11 @@ import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { runFfmpeg } from "../media/media-services.js"; import { runExec } from "../process/exec.js"; import { providerOperationRetryConfig } from "../provider-runtime/operation-retry.js"; -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeNullableString, +} from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { MediaAttachmentCache } from "./attachments.js"; import { CLI_OUTPUT_MAX_BUFFER, @@ -65,8 +69,7 @@ function resolveLiteralProviderApiKey(params: { cfg: OpenClawConfig; providerId: string; }): string | null { - const value = params.cfg.models?.providers?.[params.providerId]?.apiKey; - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + return normalizeNullableString(params.cfg.models?.providers?.[params.providerId]?.apiKey); } function sanitizeProviderHeaders( @@ -128,10 +131,7 @@ function extractSherpaOnnxText(raw: string): { matched: boolean; text: string } return direct; } - const lines = raw - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); + const lines = normalizeStringEntries(raw.split("\n")); for (let i = lines.length - 1; i >= 0; i -= 1) { const parsed = tryParse(lines[i] ?? ""); if (parsed.matched) { diff --git a/src/media-understanding/runner.ts b/src/media-understanding/runner.ts index 2fd7f3eec90..0a53a693a8a 100644 --- a/src/media-understanding/runner.ts +++ b/src/media-understanding/runner.ts @@ -27,8 +27,12 @@ import { resolveChannelInboundAttachmentRoots } from "../media/channel-inbound-r import { mergeInboundPathRoots } from "../media/inbound-path-policy.js"; import { getDefaultMediaLocalRoots } from "../media/local-roots.js"; import { runExec } from "../process/exec.js"; -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeNullableString, + normalizeOptionalString, +} from "../shared/string-coerce.js"; +import { normalizeStringEntries, uniqueStrings } from "../shared/string-normalization.js"; import type { ActiveMediaModel } from "./active-model.types.js"; import { MediaAttachmentCache, selectAttachments } from "./attachments.js"; import { isMediaUnderstandingSkipError } from "./errors.js"; @@ -80,8 +84,9 @@ function resolveLiteralProviderApiKey( cfg: OpenClawConfig | undefined, providerId: string, ): string | null { - const value = findNormalizedProviderValue(cfg?.models?.providers, providerId)?.apiKey; - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + return normalizeNullableString( + findNormalizedProviderValue(cfg?.models?.providers, providerId)?.apiKey, + ); } async function hasProviderAuthAvailable(params: { @@ -106,16 +111,14 @@ function resolveConfiguredKeyProviderOrder(params: { }): string[] { const configuredProviders = Object.keys(params.cfg.models?.providers ?? {}) .map((providerId) => normalizeMediaExecutionProviderId(providerId)) - .filter(Boolean) - .filter((providerId, index, values) => values.indexOf(providerId) === index) - .filter((providerId) => - providerSupportsCapability( - params.providerRegistry.get(normalizeMediaProviderId(providerId)), - params.capability, - ), - ); - - return [...new Set([...configuredProviders, ...params.fallbackProviders])]; + .filter(Boolean); + const supportedProviders = uniqueStrings(configuredProviders).filter((providerId) => + providerSupportsCapability( + params.providerRegistry.get(normalizeMediaProviderId(providerId)), + params.capability, + ), + ); + return uniqueStrings([...supportedProviders, ...params.fallbackProviders]); } function resolveConfiguredImageModelId(params: { @@ -343,13 +346,10 @@ function candidateBinaryNames(name: string): string[] { if (ext) { return [name]; } - const pathext = (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM") - .split(";") - .map((item) => item.trim()) - .filter(Boolean) - .map((item) => (item.startsWith(".") ? item : `.${item}`)); - const unique = Array.from(new Set(pathext)); - return [name, ...unique.map((item) => `${name}${item}`)]; + const pathext = normalizeStringEntries( + (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";"), + ).map((item) => (item.startsWith(".") ? item : `.${item}`)); + return [name, ...uniqueStrings(pathext).map((item) => `${name}${item}`)]; } async function isExecutable(filePath: string): Promise { diff --git a/src/media/image-ops.ts b/src/media/image-ops.ts index 3e0175babd3..472e9205ff0 100644 --- a/src/media/image-ops.ts +++ b/src/media/image-ops.ts @@ -3,6 +3,7 @@ import { resolveSystemBin } from "../infra/resolve-system-bin.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { runExec } from "../process/exec.js"; import { createLazyPromiseLoader } from "../shared/lazy-promise.js"; +import { uniqueValues } from "../shared/string-normalization.js"; export type ImageMetadata = { width: number; @@ -103,10 +104,11 @@ export function isImageProcessorUnavailableError(err: unknown): boolean { } export function buildImageResizeSideGrid(maxSide: number, sideStart: number): number[] { - return [sideStart, 1800, 1600, 1400, 1200, 1000, 800] - .map((value) => Math.min(maxSide, value)) - .filter((value, idx, arr) => value > 0 && arr.indexOf(value) === idx) - .toSorted((a, b) => b - a); + return uniqueValues( + [sideStart, 1800, 1600, 1400, 1200, 1000, 800] + .map((value) => Math.min(maxSide, value)) + .filter((value) => value > 0), + ).toSorted((a, b) => b - a); } function getImageBackendPreference(): ImageBackendPreference { diff --git a/src/media/input-files.ts b/src/media/input-files.ts index 91d326204c1..3ec16992a41 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -154,7 +154,7 @@ export function parseContentType(value: string | undefined): { export function normalizeMimeList(values: string[] | undefined, fallback: string[]): Set { const input = values && values.length > 0 ? values : fallback; - return new Set(input.map((value) => normalizeMimeType(value)).filter(Boolean) as string[]); + return new Set(input.flatMap((value) => normalizeMimeType(value) ?? [])); } export function resolveInputFileLimits(config?: InputFileLimitsConfig): InputFileLimits { diff --git a/src/media/local-roots.ts b/src/media/local-roots.ts index a91052a4315..859f6ed0441 100644 --- a/src/media/local-roots.ts +++ b/src/media/local-roots.ts @@ -9,6 +9,7 @@ import type { OpenClawConfig } from "../config/types.js"; import { safeFileURLToPath } from "../infra/local-file-access.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { isPassThroughRemoteMediaSource } from "./media-source-url.js"; @@ -96,7 +97,7 @@ export function appendLocalMediaParentRoots( roots: readonly string[], mediaSources?: readonly string[], ): string[] { - const appended = Array.from(new Set(roots.map((root) => path.resolve(root)))); + const appended = uniqueStrings(roots.map((root) => path.resolve(root))); for (const source of mediaSources ?? []) { const localPath = resolveLocalMediaPath(source); if (!localPath) { diff --git a/src/media/web-media.ts b/src/media/web-media.ts index 712ebc2783b..7c91f5dd63a 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -5,6 +5,7 @@ import { FsSafeError, readLocalFileSafely } from "../infra/fs-safe.js"; import { assertNoWindowsNetworkPath, safeFileURLToPath } from "../infra/local-file-access.js"; import type { PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; +import { uniqueValues } from "../shared/string-normalization.js"; import { resolveUserPath } from "../utils.js"; import { maxBytesForKind, type MediaKind } from "./constants.js"; import { readRemoteMediaBuffer } from "./fetch.js"; @@ -500,21 +501,21 @@ export function effectiveImageBytesCap( function buildDescendingLadder(maxSide: number, values: readonly number[]): number[] { const normalizedMax = Math.max(1, Math.floor(maxSide)); - const ladder = [normalizedMax, ...values, ...LOW_IMAGE_SIDE_FALLBACKS] - .map((value) => Math.min(normalizedMax, value)) - .filter((value, idx, arr) => value > 0 && arr.indexOf(value) === idx) - .toSorted((a, b) => b - a); + const ladder = uniqueValues( + [normalizedMax, ...values, ...LOW_IMAGE_SIDE_FALLBACKS] + .map((value) => Math.min(normalizedMax, value)) + .filter((value) => value > 0), + ).toSorted((a, b) => b - a); if (ladder.length > 1 || normalizedMax <= 1) { return ladder; } - return [ + const fallbackLadder = [ normalizedMax, Math.floor(normalizedMax * 0.75), Math.floor(normalizedMax * 0.5), Math.floor(normalizedMax * 0.25), - ] - .filter((value, idx, arr) => value > 0 && arr.indexOf(value) === idx) - .toSorted((a, b) => b - a); + ]; + return uniqueValues(fallbackLadder.filter((value) => value > 0)).toSorted((a, b) => b - a); } export function resolveImageCompressionGrid(policy?: ImageCompressionPolicy): { diff --git a/src/model-catalog/manifest-planner.ts b/src/model-catalog/manifest-planner.ts index 16426f99daf..c3653b7f627 100644 --- a/src/model-catalog/manifest-planner.ts +++ b/src/model-catalog/manifest-planner.ts @@ -1,4 +1,5 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { normalizeModelCatalogProviderRows } from "./normalize.js"; import { buildModelCatalogMergeKey, normalizeModelCatalogProviderId } from "./refs.js"; import type { @@ -159,7 +160,9 @@ function planManifestModelCatalogPluginEntries(params: { } function buildOwnedProviderSet(plugin: ManifestModelCatalogPlugin): ReadonlySet { - return new Set((plugin.providers ?? []).map(normalizeModelCatalogProviderId).filter(Boolean)); + return new Set( + normalizeUniqueStringEntries((plugin.providers ?? []).map(normalizeModelCatalogProviderId)), + ); } function buildModelCatalogProviderAliasTargets( diff --git a/src/model-catalog/normalize.ts b/src/model-catalog/normalize.ts index 587635aa6ce..b0e150fd431 100644 --- a/src/model-catalog/normalize.ts +++ b/src/model-catalog/normalize.ts @@ -8,7 +8,10 @@ import { } from "../config/types.models.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; +import { + normalizeOptionalTrimmedStringList, + normalizeTrimmedStringList, +} from "../shared/string-normalization.js"; import { isRecord } from "../utils.js"; import { buildModelCatalogMergeKey, @@ -467,11 +470,6 @@ export function normalizeModelCatalog( return Object.keys(catalog).length > 0 ? catalog : undefined; } -function normalizeStringList(value: unknown): string[] | undefined { - const normalized = normalizeTrimmedStringList(value); - return normalized.length > 0 ? normalized : undefined; -} - export function normalizeModelCatalogProviderRows(params: { provider: string; providerCatalog: ModelCatalogProvider; @@ -502,8 +500,8 @@ export function normalizeModelCatalogProviderRows(params: { const mediaInput = normalizeModelCatalogMediaInput(model.mediaInput); const statusReason = normalizeOptionalString(model.statusReason) ?? ""; const replacedBy = normalizeOptionalString(model.replacedBy) ?? ""; - const replaces = normalizeStringList(model.replaces); - const tags = normalizeStringList(model.tags); + const replaces = normalizeOptionalTrimmedStringList(model.replaces); + const tags = normalizeOptionalTrimmedStringList(model.tags); rows.push({ provider, id, diff --git a/src/model-catalog/provider-index/normalize.ts b/src/model-catalog/provider-index/normalize.ts index f73a73cf3df..76ff020ac5d 100644 --- a/src/model-catalog/provider-index/normalize.ts +++ b/src/model-catalog/provider-index/normalize.ts @@ -1,8 +1,9 @@ import { parseClawHubPluginSpec } from "../../infra/clawhub-spec.js"; import { parseRegistryNpmSpec } from "../../infra/npm-registry-spec.js"; import { isBlockedObjectKey } from "../../infra/prototype-keys.js"; +import { asFiniteNumber } from "../../shared/number-coercion.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; -import { normalizeTrimmedStringList } from "../../shared/string-normalization.js"; +import { normalizeUniqueTrimmedStringList } from "../../shared/string-normalization.js"; import { isRecord } from "../../utils.js"; import { normalizeModelCatalog } from "../normalize.js"; import { normalizeModelCatalogProviderId } from "../refs.js"; @@ -70,7 +71,7 @@ function normalizePlugin(value: unknown): OpenClawProviderIndexPlugin | undefine } function normalizeCategories(value: unknown): readonly string[] { - return [...new Set(normalizeTrimmedStringList(value))]; + return normalizeUniqueTrimmedStringList(value); } function normalizePreviewCatalog(params: { @@ -94,11 +95,11 @@ function normalizePreviewCatalog(params: { function normalizeOnboardingScopes( value: unknown, ): OpenClawProviderIndexProviderAuthChoice["onboardingScopes"] | undefined { - const scopes = normalizeTrimmedStringList(value).filter( + const scopes = normalizeUniqueTrimmedStringList(value).filter( (scope): scope is "text-inference" | "image-generation" | "music-generation" => scope === "text-inference" || scope === "image-generation" || scope === "music-generation", ); - return scopes.length > 0 ? [...new Set(scopes)] : undefined; + return scopes.length > 0 ? scopes : undefined; } function normalizeAssistantVisibility( @@ -107,10 +108,6 @@ function normalizeAssistantVisibility( return value === "visible" || value === "manual-only" ? value : undefined; } -function normalizeFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function normalizeAuthChoice(params: { providerId: string; providerName: string; @@ -133,7 +130,7 @@ function normalizeAuthChoice(params: { const cliFlag = normalizeOptionalString(params.value.cliFlag); const cliOption = normalizeOptionalString(params.value.cliOption); const cliDescription = normalizeOptionalString(params.value.cliDescription); - const assistantPriority = normalizeFiniteNumber(params.value.assistantPriority); + const assistantPriority = asFiniteNumber(params.value.assistantPriority); const assistantVisibility = normalizeAssistantVisibility(params.value.assistantVisibility); const onboardingScopes = normalizeOnboardingScopes(params.value.onboardingScopes); return { diff --git a/src/music-generation/provider-assets.ts b/src/music-generation/provider-assets.ts index cdebe073d29..a648a07d4e7 100644 --- a/src/music-generation/provider-assets.ts +++ b/src/music-generation/provider-assets.ts @@ -1,5 +1,6 @@ import { fetchProviderDownloadResponse } from "../media-understanding/shared.js"; import { extensionForMime } from "../media/mime.js"; +import { isRecord } from "../shared/record-coerce.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { GeneratedMusicAsset } from "./types.js"; @@ -9,10 +10,6 @@ export type GeneratedMusicFileCandidate = { fileName?: string; }; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function normalizeSpecificAudioMimeType(value: unknown): string | undefined { const mimeType = normalizeOptionalString(value)?.split(";")[0]?.trim().toLowerCase(); if (!mimeType || mimeType === "application/octet-stream" || mimeType === "binary/octet-stream") { diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index be7a134294a..9a0b43ee2d6 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -24,6 +24,7 @@ import { resolveWindowsConsoleEncoding, } from "../infra/windows-encoding.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { buildSystemRunApprovalPlan, handleSystemRunInvoke } from "./invoke-system-run.js"; import type { ExecEventPayload, @@ -257,7 +258,7 @@ function resolveExecutable(bin: string, env?: Record) { } async function handleSystemWhich(params: SystemWhichParams, env?: Record) { - const bins = params.bins.map((bin) => bin.trim()).filter(Boolean); + const bins = normalizeStringEntries(params.bins); const found: Record = {}; for (const bin of bins) { const path = resolveExecutable(bin, env); diff --git a/src/pairing/allow-from-store-file.ts b/src/pairing/allow-from-store-file.ts index 9695a287a1b..4d0f63b9e7a 100644 --- a/src/pairing/allow-from-store-file.ts +++ b/src/pairing/allow-from-store-file.ts @@ -8,6 +8,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import type { PairingChannel } from "./pairing-store.types.js"; export type AllowFromStore = { @@ -111,17 +112,7 @@ export function resolveAllowFromFilePath( } export function dedupePreserveOrder(entries: string[]): string[] { - const seen = new Set(); - const out: string[] = []; - for (const entry of entries) { - const normalized = normalizeOptionalString(entry) ?? ""; - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - out.push(normalized); - } - return out; + return normalizeUniqueStringEntries(entries); } export function shouldIncludeLegacyAllowFromEntries(normalizedAccountId: string): boolean { diff --git a/src/pairing/allow-from-store-read.ts b/src/pairing/allow-from-store-read.ts index 3eb0ccd581c..b646097ae9b 100644 --- a/src/pairing/allow-from-store-read.ts +++ b/src/pairing/allow-from-store-read.ts @@ -1,4 +1,4 @@ -import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeUniqueTrimmedStringList } from "../shared/string-normalization.js"; import { clearAllowFromFileReadCacheForNamespace, dedupePreserveOrder, @@ -13,10 +13,7 @@ import type { PairingChannel } from "./pairing-store.types.js"; const ALLOW_FROM_STORE_READ_CACHE_NAMESPACE = "allow-from-store-read"; function normalizeRawAllowFromList(store: AllowFromStore): string[] { - const list = Array.isArray(store.allowFrom) ? store.allowFrom : []; - return dedupePreserveOrder( - list.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean), - ); + return normalizeUniqueTrimmedStringList(store.allowFrom); } function readAllowFromEntriesForPathSyncWithExists(filePath: string): { diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index af75447f859..b437513c732 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -6,6 +6,7 @@ import type { ChannelPairingAdapter } from "../channels/plugins/pairing.types.js import { withFileLock as withPathLock } from "../infra/file-lock.js"; import { readJsonFileWithFallback, writeJsonFileAtomically } from "../plugin-sdk/json-store.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { isRecord } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty, normalizeNullableString, @@ -58,10 +59,6 @@ type PairingStore = { requests: PairingRequest[]; }; -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function resolvePairingPath(channel: PairingChannel, env: NodeJS.ProcessEnv = process.env): string { return path.join(resolvePairingCredentialsDir(env), `${safeChannelKey(channel)}-pairing.json`); } diff --git a/src/plugin-sdk/access-groups.ts b/src/plugin-sdk/access-groups.ts index 5bd05911d25..e4b1b647446 100644 --- a/src/plugin-sdk/access-groups.ts +++ b/src/plugin-sdk/access-groups.ts @@ -5,6 +5,7 @@ import { import type { ChannelId } from "../channels/plugins/types.public.js"; import type { AccessGroupConfig } from "../config/types.access-groups.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; export { ACCESS_GROUP_ALLOW_FROM_PREFIX, parseAccessGroupAllowFromEntry }; @@ -177,5 +178,5 @@ export async function expandAllowFromWithAccessGroups(params: { return allowFrom; } const senderEntry = params.senderAllowEntry ?? params.senderId; - return Array.from(new Set([...allowFrom, senderEntry])); + return uniqueStrings([...allowFrom, senderEntry]); } diff --git a/src/plugin-sdk/agent-harness-task-runtime.ts b/src/plugin-sdk/agent-harness-task-runtime.ts index 1563460b44b..84bd15ee425 100644 --- a/src/plugin-sdk/agent-harness-task-runtime.ts +++ b/src/plugin-sdk/agent-harness-task-runtime.ts @@ -14,6 +14,7 @@ import { resolveSubagentCompletionOrigin, } from "../agents/subagent-announce-delivery.js"; import { resolveAnnounceOrigin } from "../agents/subagent-announce-origin.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import { assertAgentHarnessTaskRuntimeScope, type AgentHarnessTaskRuntimeScope, @@ -249,11 +250,6 @@ export function isDurableAgentHarnessCompletionDelivery( ); } -function normalizeOptionalString(value: string | undefined): string | undefined { - const normalized = value?.trim(); - return normalized || undefined; -} - function assertScopedRunId(runId: string, runIdPrefix: string | undefined): void { const normalized = runId.trim(); if (!normalized) { diff --git a/src/plugin-sdk/allow-from.ts b/src/plugin-sdk/allow-from.ts index 31a4c2ea38e..fcf0d48843e 100644 --- a/src/plugin-sdk/allow-from.ts +++ b/src/plugin-sdk/allow-from.ts @@ -1,5 +1,6 @@ import { isAllowedParsedChatSender as isAllowedParsedChatSenderShared } from "../channels/plugins/chat-target-prefixes.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; export type { AllowlistMatch, @@ -35,9 +36,7 @@ export function formatAllowFromLowercase(params: { allowFrom: Array; stripPrefixRe?: RegExp; }): string[] { - return params.allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) + return normalizeStringEntries(params.allowFrom) .map((entry) => (params.stripPrefixRe ? entry.replace(params.stripPrefixRe, "") : entry)) .map((entry) => normalizeOptionalLowercaseString(entry)) .filter((entry): entry is string => Boolean(entry)); @@ -48,9 +47,7 @@ export function formatNormalizedAllowFromEntries(params: { allowFrom: Array; normalizeEntry: (entry: string) => string | undefined | null; }): string[] { - return params.allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) + return normalizeStringEntries(params.allowFrom) .map((entry) => params.normalizeEntry(entry)) .filter((entry): entry is string => Boolean(entry)); } diff --git a/src/plugin-sdk/approval-approvers.ts b/src/plugin-sdk/approval-approvers.ts index 6169d3bf0bc..b7581daa73a 100644 --- a/src/plugin-sdk/approval-approvers.ts +++ b/src/plugin-sdk/approval-approvers.ts @@ -1,14 +1,9 @@ +import { uniqueStrings } from "../shared/string-normalization.js"; + type ApproverInput = string | number; function dedupeDefined(values: Array): string[] { - const resolved = new Set(); - for (const value of values) { - if (!value) { - continue; - } - resolved.add(value); - } - return [...resolved]; + return uniqueStrings(values.filter((value): value is string => Boolean(value))); } export function resolveApprovalApprovers(params: { diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index 230cd6ccb0c..5f122fc08e2 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -3,6 +3,7 @@ import type { ChannelSecurityAdapter } from "../channels/plugins/types.adapters. import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; import type { GroupPolicy } from "../config/types.base.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeStringEntries, uniqueStrings } from "../shared/string-normalization.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { createScopedDmSecurityResolver } from "./channel-config-helpers.js"; /** Shared policy warnings and DM/group policy helpers for channel plugins. */ @@ -62,7 +63,7 @@ export function normalizeAllowFromList(list: Array | undefined if (!Array.isArray(list)) { return []; } - return list.map((value) => String(value).trim()).filter(Boolean); + return normalizeStringEntries(list); } export function coerceNativeSetting(value: unknown): boolean | "auto" | undefined { @@ -95,7 +96,7 @@ function collectMutableAllowlistWarningLines( .map((hit) => `- ${sanitizeForLog(hit.path)}: ${sanitizeForLog(hit.entry)}`); const remaining = hits.length > 8 ? `- +${hits.length - 8} more mutable allowlist entries.` : null; - const flagPaths = Array.from(new Set(hits.map((hit) => hit.dangerousFlagPath))); + const flagPaths = uniqueStrings(hits.map((hit) => hit.dangerousFlagPath)); const flagHint = flagPaths.length === 1 ? sanitizeForLog(flagPaths[0] ?? "") diff --git a/src/plugin-sdk/channel-streaming.ts b/src/plugin-sdk/channel-streaming.ts index d4cccaba629..41d1e3781e3 100644 --- a/src/plugin-sdk/channel-streaming.ts +++ b/src/plugin-sdk/channel-streaming.ts @@ -10,6 +10,8 @@ import type { TextChunkMode, } from "../config/types.base.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; +import { asBoolean } from "../utils/boolean.js"; export type { ChannelDeliveryStreamingConfig, @@ -44,10 +46,6 @@ function asTextChunkMode(value: unknown): TextChunkMode | undefined { return value === "length" || value === "newline" ? value : undefined; } -function asBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - function asStringNumberArray(value: unknown): Array | undefined { return Array.isArray(value) && value.every((entry) => typeof entry === "string" || typeof entry === "number") @@ -723,13 +721,11 @@ export function resolveChannelProgressDraftConfig( } function normalizeProgressLabels(labels: unknown): string[] { - if (!Array.isArray(labels)) { + const normalized = normalizeTrimmedStringList(labels); + if (normalized.length === 0) { return [...DEFAULT_PROGRESS_DRAFT_LABELS]; } - const normalized = labels - .map((entry) => (typeof entry === "string" ? entry.trim() : "")) - .filter((entry) => entry.length > 0); - return normalized.length > 0 ? normalized : [...DEFAULT_PROGRESS_DRAFT_LABELS]; + return normalized; } function hashProgressSeed(seed: string): number { diff --git a/src/plugin-sdk/migration.ts b/src/plugin-sdk/migration.ts index 1ce1785dd06..0f711aba97d 100644 --- a/src/plugin-sdk/migration.ts +++ b/src/plugin-sdk/migration.ts @@ -8,6 +8,7 @@ import type { MigrationProviderPlugin, MigrationSummary, } from "../plugins/types.js"; +import { isRecord } from "../shared/record-coerce.js"; export type { MigrationDetection, @@ -92,10 +93,6 @@ function isSecretKey(key: string): boolean { return SECRET_KEY_MARKERS.some((marker) => normalized.includes(marker)); } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - export type MigrationConfigPatchDetails = { path: string[]; value: unknown; diff --git a/src/plugin-sdk/provider-entry.ts b/src/plugin-sdk/provider-entry.ts index c5c50858138..3fe4e84aa93 100644 --- a/src/plugin-sdk/provider-entry.ts +++ b/src/plugin-sdk/provider-entry.ts @@ -9,6 +9,7 @@ import type { UnifiedModelCatalogProviderContext, ProviderPluginWizardSetup, } from "../plugins/types.js"; +import { normalizeStringEntries, uniqueStrings } from "../shared/string-normalization.js"; import { definePluginEntry } from "./plugin-entry.js"; import type { OpenClawPluginApi, @@ -101,13 +102,11 @@ function resolveEnvVars(params: { envVars?: string[]; auth?: SingleProviderPluginApiKeyAuthOptions[]; }): string[] | undefined { - const combined = [ + const combined = normalizeStringEntries([ ...(params.envVars ?? []), ...(params.auth ?? []).map((entry) => entry.envVar).filter(Boolean), - ] - .map((value) => value.trim()) - .filter(Boolean); - return combined.length > 0 ? [...new Set(combined)] : undefined; + ]); + return combined.length > 0 ? uniqueStrings(combined) : undefined; } function projectProviderCatalogResultToUnifiedTextRows(params: { diff --git a/src/plugin-sdk/qa-channel-protocol.ts b/src/plugin-sdk/qa-channel-protocol.ts index e3405914097..fee2fff5aee 100644 --- a/src/plugin-sdk/qa-channel-protocol.ts +++ b/src/plugin-sdk/qa-channel-protocol.ts @@ -1,3 +1,5 @@ +import { isRecord } from "../shared/record-coerce.js"; + export type QaBusConversationKind = "direct" | "channel" | "group"; export type QaBusConversation = { @@ -171,10 +173,6 @@ const QA_BUS_TOOL_CALL_REDACTED = "[redacted]"; const QA_BUS_TOOL_CALL_SENSITIVE_KEY_RE = /authorization|cookie|credential|password|secret|token|api[-_]?key|access[-_]?key|private[-_]?key/iu; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function sanitizeQaBusToolCallValue(value: unknown, depth: number, key?: string): unknown { if (key && QA_BUS_TOOL_CALL_SENSITIVE_KEY_RE.test(key)) { return QA_BUS_TOOL_CALL_REDACTED; diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index 20bbddbf02d..6b6afd8c7c4 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -3,6 +3,7 @@ import type { ChannelOutboundAdapter } from "../channels/plugins/outbound.types. import { createReplyToFanout } from "../infra/outbound/reply-policy.js"; import { hasReplyPayloadContent } from "../interactive/payload.js"; import { normalizeLowercaseStringOrEmpty, readStringValue } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media-payload.js"; export { buildMediaPayload } from "../channels/plugins/media-payload.js"; @@ -181,9 +182,7 @@ export function resolveSendableOutboundReplyParts( ): SendableOutboundReplyParts { const text = options?.text ?? payload.text ?? ""; const trimmedText = text.trim(); - const mediaUrls = resolveOutboundMediaUrls(payload) - .map((entry) => entry.trim()) - .filter(Boolean); + const mediaUrls = normalizeStringEntries(resolveOutboundMediaUrls(payload)); const mediaCount = mediaUrls.length; const hasText = Boolean(trimmedText); const hasMedia = mediaCount > 0; diff --git a/src/plugin-sdk/session-transcript-hit.ts b/src/plugin-sdk/session-transcript-hit.ts index 45e6165ce26..e2e7a74c42e 100644 --- a/src/plugin-sdk/session-transcript-hit.ts +++ b/src/plugin-sdk/session-transcript-hit.ts @@ -3,6 +3,7 @@ import { parseUsageCountedSessionIdFromFileName } from "../config/sessions/artif import type { SessionEntry } from "../config/sessions/types.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; export { loadCombinedSessionStoreForGateway } from "../config/sessions/combined-store-gateway.js"; @@ -135,7 +136,7 @@ export function resolveTranscriptStemToSessionKeys(params: { matches.push(sessionKey); } } - const deduped = [...new Set(matches)]; + const deduped = uniqueStrings(matches); if (deduped.length > 0) { return deduped; } @@ -157,7 +158,7 @@ export function resolveTranscriptStemToSessionKeys(params: { } } } - const normalizedDeduped = [...new Set(matches)]; + const normalizedDeduped = uniqueStrings(matches); if (normalizedDeduped.length > 0) { return normalizedDeduped.length === 1 ? normalizedDeduped : []; } diff --git a/src/plugin-sdk/session-visibility.ts b/src/plugin-sdk/session-visibility.ts index 4dae08a54f3..516b4b46e61 100644 --- a/src/plugin-sdk/session-visibility.ts +++ b/src/plugin-sdk/session-visibility.ts @@ -5,6 +5,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; type GatewayCaller = typeof defaultCallGateway; @@ -58,7 +59,7 @@ export async function listSpawnedSessionKeys(params: { }, }); const sessions = Array.isArray(list?.sessions) ? list.sessions : []; - const keys = sessions.map((entry) => normalizeOptionalString(entry?.key) ?? "").filter(Boolean); + const keys = normalizeTrimmedStringList(sessions.map((entry) => entry?.key)); return new Set(keys); } catch { return new Set(); diff --git a/src/plugin-sdk/ssrf-policy.ts b/src/plugin-sdk/ssrf-policy.ts index bd40e082085..babfd6db314 100644 --- a/src/plugin-sdk/ssrf-policy.ts +++ b/src/plugin-sdk/ssrf-policy.ts @@ -8,6 +8,7 @@ import { } from "../infra/net/ssrf.js"; import { asNullableRecord } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import type { ChannelDoctorConfigMutation, ChannelDoctorLegacyConfigRule, @@ -285,11 +286,11 @@ export function normalizeHostnameSuffixAllowlist( if (!source || source.length === 0) { return []; } - const normalized = source.map(normalizeHostnameSuffix).filter(Boolean); + const normalized = normalizeUniqueStringEntries(source.map(normalizeHostnameSuffix)); if (normalized.includes("*")) { return ["*"]; } - return Array.from(new Set(normalized)); + return normalized; } /** Check whether a URL is HTTPS and its hostname matches the normalized suffix allowlist. */ diff --git a/src/plugin-sdk/string-coerce-runtime.ts b/src/plugin-sdk/string-coerce-runtime.ts index f6902c5d3bc..8aae2968b30 100644 --- a/src/plugin-sdk/string-coerce-runtime.ts +++ b/src/plugin-sdk/string-coerce-runtime.ts @@ -10,9 +10,16 @@ export { normalizeOptionalLowercaseString, normalizeOptionalString, normalizeOptionalStringifiedId, + normalizeStringifiedEntries, normalizeStringifiedOptionalString, readStringValue, } from "../shared/string-coerce.js"; +export { + asFiniteNumber, + asPositiveSafeInteger, + parseFiniteNumber, +} from "../shared/number-coercion.js"; +export { asBoolean, parseBooleanValue } from "../utils/boolean.js"; export { asRecord, asNullableRecord, @@ -24,8 +31,15 @@ export { normalizeAtHashSlug, normalizeHyphenSlug, normalizeOptionalTrimmedStringList, + normalizeSortedUniqueTrimmedStringList, normalizeSingleOrTrimmedStringList, normalizeStringEntries, normalizeStringEntriesLower, + normalizeUniqueStringEntries, + normalizeUniqueTrimmedStringList, + normalizeTrimmedStringList, + sortUniqueStrings, + uniqueStrings, + uniqueValues, } from "../shared/string-normalization.js"; export { summarizeStringEntries } from "../shared/string-sample.js"; diff --git a/src/plugin-sdk/test-helpers/public-artifacts.ts b/src/plugin-sdk/test-helpers/public-artifacts.ts index 29179260ae1..5fe5708b0b7 100644 --- a/src/plugin-sdk/test-helpers/public-artifacts.ts +++ b/src/plugin-sdk/test-helpers/public-artifacts.ts @@ -2,6 +2,7 @@ import { assertUniqueValues, BUNDLED_RUNTIME_SIDECAR_PATHS, } from "../../plugins/runtime-sidecar-paths.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; export function getPublicArtifactBasename(relativePath: string): string { return relativePath.split("/").at(-1) ?? relativePath; @@ -30,7 +31,7 @@ const EXTRA_GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES = assertUniqueValues( ); export const BUNDLED_RUNTIME_SIDECAR_BASENAMES = assertUniqueValues( - [...new Set(BUNDLED_RUNTIME_SIDECAR_PATHS.map(getPublicArtifactBasename))], + uniqueStrings(BUNDLED_RUNTIME_SIDECAR_PATHS.map(getPublicArtifactBasename)), "bundled runtime sidecar basename", ); diff --git a/src/plugin-sdk/test-helpers/string-utils.ts b/src/plugin-sdk/test-helpers/string-utils.ts index dc06b627c0d..1afa1537d9b 100644 --- a/src/plugin-sdk/test-helpers/string-utils.ts +++ b/src/plugin-sdk/test-helpers/string-utils.ts @@ -1,3 +1,5 @@ +import { sortUniqueStrings } from "../../shared/string-normalization.js"; + export function uniqueSortedStrings(values: readonly string[]) { - return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings(values); } diff --git a/src/plugin-sdk/windows-spawn.ts b/src/plugin-sdk/windows-spawn.ts index 2f9c57b9e69..49408d13b27 100644 --- a/src/plugin-sdk/windows-spawn.ts +++ b/src/plugin-sdk/windows-spawn.ts @@ -4,6 +4,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; export type WindowsSpawnResolution = | "direct" @@ -128,10 +129,7 @@ export function resolveWindowsExecutablePath(command: string, env: NodeJS.Proces } const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? ""; - const pathEntries = pathValue - .split(";") - .map((entry) => entry.trim()) - .filter(Boolean); + const pathEntries = normalizeStringEntries(pathValue.split(";")); const hasExtension = path.extname(command).length > 0; const pathExtRaw = env.PATHEXT ?? @@ -141,11 +139,9 @@ export function resolveWindowsExecutablePath(command: string, env: NodeJS.Proces ".EXE;.CMD;.BAT;.COM"; const pathExt = hasExtension ? [""] - : pathExtRaw - .split(";") - .map((ext) => ext.trim()) - .filter(Boolean) - .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`)); + : normalizeStringEntries(pathExtRaw.split(";")).map((ext) => + ext.startsWith(".") ? ext : `.${ext}`, + ); for (const dir of pathEntries) { for (const ext of pathExt) { diff --git a/src/plugins/activation-planner.ts b/src/plugins/activation-planner.ts index 3e79e23df74..d48a70071c7 100644 --- a/src/plugins/activation-planner.ts +++ b/src/plugins/activation-planner.ts @@ -1,6 +1,7 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { normalizePluginsConfig } from "./config-state.js"; import { passesManifestOwnerBasePolicy } from "./manifest-owner-policy.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; @@ -108,7 +109,7 @@ export function resolveManifestActivationPlan( return { trigger: params.trigger, - pluginIds: [...new Set(entries.map((entry) => entry.pluginId))], + pluginIds: uniqueStrings(entries.map((entry) => entry.pluginId)), entries, diagnostics: registry.diagnostics, }; diff --git a/src/plugins/active-runtime-registry.ts b/src/plugins/active-runtime-registry.ts index e3698fcb14e..1c09ae05cd6 100644 --- a/src/plugins/active-runtime-registry.ts +++ b/src/plugins/active-runtime-registry.ts @@ -1,3 +1,4 @@ +import { normalizeSortedUniqueStringEntries } from "../shared/string-normalization.js"; import { resolveCompatibleRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js"; import type { PluginRegistry } from "./registry-types.js"; import { @@ -17,9 +18,7 @@ function normalizeRequiredPluginIds(ids?: readonly string[]): string[] | undefin if (ids === undefined) { return undefined; } - return [...new Set(ids.map((id) => id.trim()).filter(Boolean))].toSorted((left, right) => - left.localeCompare(right), - ); + return normalizeSortedUniqueStringEntries(ids); } export function registryContainsRuntimePluginIds( diff --git a/src/plugins/bundle-manifest.ts b/src/plugins/bundle-manifest.ts index eec3cf300b7..8b3c2c741a9 100644 --- a/src/plugins/bundle-manifest.ts +++ b/src/plugins/bundle-manifest.ts @@ -7,6 +7,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { normalizeUniqueSingleOrTrimmedStringList } from "../shared/string-normalization.js"; import { isRecord } from "../utils.js"; import type { PluginBundleFormat } from "./manifest-types.js"; import type { PluginManifestActivation } from "./manifest.js"; @@ -42,21 +43,8 @@ type BundleManifestFileLoadResult = | { ok: true; raw: Record; manifestPath: string } | { ok: false; error: string; manifestPath: string }; -function normalizePathList(value: unknown): string[] { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed ? [trimmed] : []; - } - if (!Array.isArray(value)) { - return []; - } - return value - .map((entry) => normalizeOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)); -} - export function normalizeBundlePathList(value: unknown): string[] { - return Array.from(new Set(normalizePathList(value))); + return normalizeUniqueSingleOrTrimmedStringList(value); } export function mergeBundlePathLists(...groups: string[][]): string[] { diff --git a/src/plugins/bundled-compat.ts b/src/plugins/bundled-compat.ts index 67f739e2768..fb9bd51c596 100644 --- a/src/plugins/bundled-compat.ts +++ b/src/plugins/bundled-compat.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginEntryConfig } from "../config/types.plugins.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { hasExplicitPluginConfig } from "./config-policy.js"; import { normalizePluginId } from "./config-state.js"; @@ -15,7 +16,7 @@ export function withBundledPluginAllowlistCompat(params: { return params.config; } - const allowSet = new Set(allow.map((entry) => entry.trim()).filter(Boolean)); + const allowSet = new Set(normalizeUniqueStringEntries(allow)); let changed = false; for (const pluginId of params.pluginIds) { if (!allowSet.has(pluginId)) { @@ -47,7 +48,7 @@ export function withBundledPluginEnablementCompat(params: { const allow = params.config?.plugins?.allow; const allowSet = !useCompatDiscovery && Array.isArray(allow) && allow.length > 0 - ? new Set(allow.map((pluginId) => normalizePluginId(pluginId)).filter(Boolean)) + ? new Set(normalizeUniqueStringEntries(allow.map((pluginId) => normalizePluginId(pluginId)))) : undefined; let hasEligiblePlugin = false; let changed = false; diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index c6b901eea0d..4d3af3f43ea 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { isPathInside } from "../infra/path-guards.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { resolveUserPath } from "../utils.js"; const DISABLED_BUNDLED_PLUGINS_DIR = path.join(os.tmpdir(), "openclaw-empty-bundled-plugins"); @@ -96,9 +97,7 @@ function trustedBundledPluginRootsForPackageRoot(packageRoot: string): string[] function resolvePackageRootsForBundledPlugins(): string[] { const argvRoot = resolveOpenClawPackageRootSync({ argv1: process.argv[1] }); const moduleRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url }); - return [argvRoot, moduleRoot].filter( - (entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index, - ); + return uniqueStrings([argvRoot, moduleRoot].filter((entry): entry is string => Boolean(entry))); } export function resolveSourceCheckoutDependencyDiagnostic( @@ -250,8 +249,8 @@ function resolveBundledPluginsDirUncached(env: NodeJS.ProcessEnv): string | unde ); const safeArgvRoot = rejectedOverrideUsesArgvRoot ? null : argvRoot; const moduleRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url }); - const packageRoots = [safeArgvRoot, moduleRoot].filter( - (entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index, + const packageRoots = uniqueStrings( + [safeArgvRoot, moduleRoot].filter((entry): entry is string => Boolean(entry)), ); for (const packageRoot of packageRoots) { const bundledDir = resolveBundledDirFromPackageRoot(packageRoot); diff --git a/src/plugins/bundled-plugin-metadata.ts b/src/plugins/bundled-plugin-metadata.ts index 99944277058..20b610ebf13 100644 --- a/src/plugins/bundled-plugin-metadata.ts +++ b/src/plugins/bundled-plugin-metadata.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { tryReadJsonSync } from "../infra/json-files.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { collectBundledChannelConfigs } from "./bundled-channel-config-metadata.js"; import { collectBundledPluginPublicSurfaceArtifacts, @@ -234,7 +235,7 @@ function listBundledPluginEntryBaseDirs(params: { path.resolve(params.rootDir, "dist-runtime", "extensions", params.pluginDirName ?? ""), path.resolve(params.rootDir, "extensions", params.pluginDirName ?? ""), ]; - return baseDirs.filter((entry, index, all) => all.indexOf(entry) === index); + return uniqueStrings(baseDirs); } export function resolveBundledPluginGeneratedPath( diff --git a/src/plugins/bundled-sources.ts b/src/plugins/bundled-sources.ts index b18a89d04de..69d31d56e63 100644 --- a/src/plugins/bundled-sources.ts +++ b/src/plugins/bundled-sources.ts @@ -1,3 +1,4 @@ +import { isRecord } from "../shared/record-coerce.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; import { loadPluginManifest } from "./manifest.js"; @@ -83,10 +84,6 @@ export function resolveBundledPluginSources(params: { return bundled; } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function pluginConfigSchemaHasRequiredFields(schema: unknown): boolean { if (!isRecord(schema)) { return false; diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index 64233b8910d..d1b7ce88fa6 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js"; import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js"; import { @@ -95,7 +96,7 @@ function shouldSkipCapabilityResolution(params: { } function uniqueSorted(values: Iterable): string[] { - return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings(values); } export function loadCapabilityManifestSnapshot(params: { diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index e41f40da49f..403900ee38a 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { AgentToolResultMiddleware, AgentToolResultMiddlewareOptions, @@ -174,9 +175,7 @@ export function createCapturedPluginRegistration(params?: { resolvePath: (input) => input, handlers: { registerCli(registrar, opts) { - const parentPath = (opts?.parentPath ?? []) - .map((segment) => segment.trim()) - .filter(Boolean); + const parentPath = normalizeStringEntries(opts?.parentPath ?? []); const descriptors = (opts?.descriptors ?? []) .map((descriptor) => ({ name: descriptor.name.trim(), @@ -184,12 +183,10 @@ export function createCapturedPluginRegistration(params?: { hasSubcommands: descriptor.hasSubcommands, })) .filter((descriptor) => descriptor.name && descriptor.description); - const commands = [ + const commands = normalizeStringEntries([ ...(opts?.commands ?? []), ...descriptors.map((descriptor) => descriptor.name), - ] - .map((command) => command.trim()) - .filter(Boolean); + ]); if (commands.length === 0) { return; } diff --git a/src/plugins/channel-catalog-registry.ts b/src/plugins/channel-catalog-registry.ts index 9ad969d0abc..845101c9b7c 100644 --- a/src/plugins/channel-catalog-registry.ts +++ b/src/plugins/channel-catalog-registry.ts @@ -1,4 +1,5 @@ import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { normalizeOptionalString as resolveOptionalString } from "../shared/string-coerce.js"; import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import type { PluginPackageChannel, PluginPackageInstall } from "./manifest.js"; @@ -65,10 +66,6 @@ export function listChannelCatalogEntries( }); } -function resolveOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function resolveChannelCatalogPluginId( candidate: PluginDiscoveryResult["candidates"][number], ): string | undefined { diff --git a/src/plugins/channel-presence-policy.ts b/src/plugins/channel-presence-policy.ts index a2668f319c7..4ec0c223282 100644 --- a/src/plugins/channel-presence-policy.ts +++ b/src/plugins/channel-presence-policy.ts @@ -8,6 +8,7 @@ import { import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isSafeChannelEnvVarTriggerName } from "../secrets/channel-env-var-names.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { resolveManifestActivationPluginIds } from "./activation-planner.js"; import { createPluginActivationSource, @@ -51,18 +52,13 @@ export type ConfiguredChannelPresencePolicyEntry = { blockedReasons: ConfiguredChannelBlockedReason[]; }; -function dedupeSortedPluginIds(values: Iterable): string[] { - return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); -} - function normalizeChannelIds(channelIds: Iterable): string[] { - return Array.from( - new Set( - [...channelIds] - .map((channelId) => normalizeOptionalLowercaseString(channelId)) - .filter((channelId): channelId is string => Boolean(channelId)), - ), - ).toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings( + [...channelIds].flatMap((channelId) => { + const normalized = normalizeOptionalLowercaseString(channelId); + return normalized ? [normalized] : []; + }), + ); } function hasNonEmptyEnvValue(env: NodeJS.ProcessEnv, key: string): boolean { @@ -403,7 +399,7 @@ export function resolveConfiguredChannelPresencePolicy(params: { left.localeCompare(right), ), effective: effectivePluginIds.length > 0, - pluginIds: dedupeSortedPluginIds(effectivePluginIds), + pluginIds: sortUniqueStrings(effectivePluginIds), blockedReasons, }); } @@ -464,7 +460,7 @@ function resolveScopedChannelOwnerPluginIds(params: { }); const trustConfig = params.activationSourceConfig ?? params.config; const normalizedConfig = normalizePluginsConfig(trustConfig.plugins); - const candidateIds = dedupeSortedPluginIds( + const candidateIds = sortUniqueStrings( channelIds.flatMap((channelId) => { return resolveManifestActivationPluginIds({ trigger: { diff --git a/src/plugins/cli-registry-loader.ts b/src/plugins/cli-registry-loader.ts index 18d54020ca2..6ab2f6fdcea 100644 --- a/src/plugins/cli-registry-loader.ts +++ b/src/plugins/cli-registry-loader.ts @@ -1,6 +1,7 @@ import { collectUniqueCommandDescriptors } from "../cli/program/command-descriptor-utils.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { resolveManifestActivationPluginIds } from "./activation-planner.js"; import { createPluginCliGatewayNodesRuntime } from "./cli-gateway-nodes-runtime.js"; import type { PluginLoadOptions } from "./loader.js"; @@ -91,20 +92,18 @@ function listPluginCliRootOwnerIds(registry: PluginRegistry, primaryCommand: str if (!normalizedPrimary) { return []; } - return [ - ...new Set( - registry.cliRegistrars - .filter((entry) => { - const parentPath = entry.parentPath ?? []; - const roots = - parentPath.length > 0 - ? [parentPath[0]] - : [...entry.commands, ...entry.descriptors.map((descriptor) => descriptor.name)]; - return roots.includes(normalizedPrimary); - }) - .map((entry) => entry.pluginId), - ), - ]; + return uniqueStrings( + registry.cliRegistrars + .filter((entry) => { + const parentPath = entry.parentPath ?? []; + const roots = + parentPath.length > 0 + ? [parentPath[0]] + : [...entry.commands, ...entry.descriptors.map((descriptor) => descriptor.name)]; + return roots.includes(normalizedPrimary); + }) + .map((entry) => entry.pluginId), + ); } async function resolvePrimaryCommandPluginIds( diff --git a/src/plugins/config-contracts.ts b/src/plugins/config-contracts.ts index 2131f12e492..1b6e8f736fc 100644 --- a/src/plugins/config-contracts.ts +++ b/src/plugins/config-contracts.ts @@ -1,4 +1,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + normalizeSortedUniqueStringEntries, + normalizeStringEntries, +} from "../shared/string-normalization.js"; import { isRecord } from "../utils.js"; import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; @@ -22,10 +26,7 @@ type TraversalState = { }; function normalizePathPattern(pathPattern: string): string[] { - return pathPattern - .split(".") - .map((segment) => segment.trim()) - .filter(Boolean); + return normalizeStringEntries(pathPattern.split(".")); } function appendPathSegment(path: string, segment: string): string { @@ -117,14 +118,12 @@ export function resolvePluginConfigContractsById(params: { discovery?: PluginDiscoveryResult; }): ReadonlyMap { const matches = new Map(); - const pluginIds = [ - ...new Set(params.pluginIds.map((pluginId) => pluginId.trim()).filter(Boolean)), - ]; + const pluginIds = normalizeSortedUniqueStringEntries(params.pluginIds); if (pluginIds.length === 0) { return matches; } const fallbackBundledPluginIds = new Set( - (params.fallbackBundledPluginIds ?? []).map((pluginId) => pluginId.trim()).filter(Boolean), + normalizeSortedUniqueStringEntries(params.fallbackBundledPluginIds), ); const bundledContractFallbacks = new Map(); const findBundledConfigContracts = ( diff --git a/src/plugins/config-normalization-shared.ts b/src/plugins/config-normalization-shared.ts index f630c0cb722..756c6b559e4 100644 --- a/src/plugins/config-normalization-shared.ts +++ b/src/plugins/config-normalization-shared.ts @@ -4,6 +4,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { normalizeArrayBackedTrimmedStringList } from "../shared/string-normalization.js"; import { defaultSlotIdForKey } from "./slots.js"; export type NormalizedPluginsConfig = { @@ -149,9 +150,9 @@ function normalizePluginEntries( (subagentRaw as { allowedModels?: unknown }).allowedModels, ), allowedModels: Array.isArray((subagentRaw as { allowedModels?: unknown }).allowedModels) - ? ((subagentRaw as { allowedModels?: unknown }).allowedModels as unknown[]) - .map((model) => normalizeOptionalString(model)) - .filter((model): model is string => Boolean(model)) + ? normalizeArrayBackedTrimmedStringList( + (subagentRaw as { allowedModels?: unknown }).allowedModels, + ) : undefined, } : undefined; @@ -179,9 +180,9 @@ function normalizePluginEntries( (llmRaw as { allowedModels?: unknown }).allowedModels, ), allowedModels: Array.isArray((llmRaw as { allowedModels?: unknown }).allowedModels) - ? ((llmRaw as { allowedModels?: unknown }).allowedModels as unknown[]) - .map((model) => normalizeOptionalString(model)) - .filter((model): model is string => Boolean(model)) + ? normalizeArrayBackedTrimmedStringList( + (llmRaw as { allowedModels?: unknown }).allowedModels, + ) : undefined, allowAgentIdOverride: (llmRaw as { allowAgentIdOverride?: unknown }) .allowAgentIdOverride, diff --git a/src/plugins/dependency-denylist.ts b/src/plugins/dependency-denylist.ts index fac178df5b5..84975e0718d 100644 --- a/src/plugins/dependency-denylist.ts +++ b/src/plugins/dependency-denylist.ts @@ -1,3 +1,5 @@ +import { normalizeStringEntries } from "../shared/string-normalization.js"; + const BLOCKED_INSTALL_DEPENDENCY_PACKAGE_NAMES = ["plain-crypto-js"] as const; export const blockedInstallDependencyPackageNames = [ @@ -58,10 +60,7 @@ function isBlockedInstallDependencyPackagePathName(packageName: string): boolean } function normalizePathSegments(relativePath: string): string[] { - return relativePath - .split(/[\\/]+/) - .map((segment) => segment.trim()) - .filter(Boolean); + return normalizeStringEntries(relativePath.split(/[\\/]+/)); } function parseBlockedNodeModulesPackageId( diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index c4126ae83d3..5bf474e12e8 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import type { LegacyConfigRule } from "../config/legacy.shared.js"; import type { OpenClawConfig } from "../config/types.js"; import { asNullableRecord } from "../shared/record-coerce.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; import type { DoctorSessionRouteStateOwner } from "./doctor-session-route-state-owner-types.js"; import type { PluginManifestRegistry } from "./manifest-registry.js"; import { @@ -94,15 +95,6 @@ function coerceNormalizeCompatibilityConfig( return typeof value === "function" ? (value as PluginDoctorCompatibilityNormalizer) : undefined; } -function normalizeTrimmedStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value - .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) - .map((entry) => entry.trim()); -} - function isDoctorSessionRouteStateOwner(value: unknown): value is DoctorSessionRouteStateOwner { if (!value || typeof value !== "object") { return false; diff --git a/src/plugins/document-extractor-public-artifacts.ts b/src/plugins/document-extractor-public-artifacts.ts index e451f0f8070..d6d13f97d96 100644 --- a/src/plugins/document-extractor-public-artifacts.ts +++ b/src/plugins/document-extractor-public-artifacts.ts @@ -1,3 +1,4 @@ +import { isRecord } from "../shared/record-coerce.js"; import type { DocumentExtractorPlugin, PluginDocumentExtractorEntry, @@ -9,10 +10,6 @@ const DOCUMENT_EXTRACTOR_ARTIFACT_CANDIDATES = [ "document-extractor-api.js", ] as const; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isDocumentExtractorPlugin(value: unknown): value is DocumentExtractorPlugin { return ( isRecord(value) && diff --git a/src/plugins/document-extractors.runtime.ts b/src/plugins/document-extractors.runtime.ts index ee6ed99d06b..741ce74265b 100644 --- a/src/plugins/document-extractors.runtime.ts +++ b/src/plugins/document-extractors.runtime.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeStringEntries, sortUniqueStrings } from "../shared/string-normalization.js"; import { resolveEnabledBundledManifestContractPlugins } from "./bundled-manifest-contract-plugins.js"; import { loadBundledDocumentExtractorEntriesFromDir } from "./document-extractor-public-artifacts.js"; import type { PluginDocumentExtractorEntry } from "./document-extractor-types.js"; @@ -27,16 +28,12 @@ function resolveExplicitAllowedDocumentExtractorPluginIds(params: { params.onlyPluginIds && params.onlyPluginIds.length > 0 ? new Set(params.onlyPluginIds) : null; const deniedPluginIds = new Set(params.config?.plugins?.deny ?? []); const entries = params.config?.plugins?.entries ?? {}; - return [ - ...new Set( - allow - .map((pluginId) => pluginId.trim()) - .filter(Boolean) - .filter((pluginId) => !onlyPluginIdSet || onlyPluginIdSet.has(pluginId)) - .filter((pluginId) => !deniedPluginIds.has(pluginId)) - .filter((pluginId) => entries[pluginId]?.enabled !== false), - ), - ].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings( + normalizeStringEntries(allow) + .filter((pluginId) => !onlyPluginIdSet || onlyPluginIdSet.has(pluginId)) + .filter((pluginId) => !deniedPluginIds.has(pluginId)) + .filter((pluginId) => entries[pluginId]?.enabled !== false), + ); } export function resolvePluginDocumentExtractors(params?: { diff --git a/src/plugins/effective-plugin-ids.ts b/src/plugins/effective-plugin-ids.ts index d40b9117bde..9fa4a00fed9 100644 --- a/src/plugins/effective-plugin-ids.ts +++ b/src/plugins/effective-plugin-ids.ts @@ -5,6 +5,7 @@ import { import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { listExplicitConfiguredChannelIdsForConfig, loadGatewayStartupPluginPlan, @@ -92,7 +93,7 @@ function collectBundledChannelOwnerPluginIds(params: { } } } - return [...pluginIds].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings(pluginIds); } function collectExplicitEffectivePluginIds(config: OpenClawConfig): string[] { @@ -118,7 +119,7 @@ function collectExplicitEffectivePluginIds(config: OpenClawConfig): string[] { ids.delete(pluginId); } } - return [...ids].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings(ids); } function collectSelectedContextEnginePluginIds(config: OpenClawConfig): string[] { @@ -184,5 +185,5 @@ export function resolveEffectivePluginIds(params: { }).pluginIds) { ids.add(pluginId); } - return [...ids].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings(ids); } diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index 58863db3786..997a74888c0 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -10,6 +10,7 @@ import { resolveMemoryDreamingPluginConfig, resolveMemoryDreamingPluginId, } from "../memory-host-sdk/dreaming.js"; +import { isRecord } from "../shared/record-coerce.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { hasExplicitChannelConfig } from "./channel-presence-policy.js"; import { collectPluginConfigContractMatches } from "./config-contracts.js"; @@ -45,10 +46,6 @@ type GenerationProviderContractKey = | "musicGenerationProviders"; type ConfiguredGenerationProviderIds = Record>; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function isConfigActivationValueEnabled(value: unknown): boolean { if (value === false) { return false; diff --git a/src/plugins/gateway-startup-speech-providers.ts b/src/plugins/gateway-startup-speech-providers.ts index b7f0bc60cd6..f4bb9a7b4b6 100644 --- a/src/plugins/gateway-startup-speech-providers.ts +++ b/src/plugins/gateway-startup-speech-providers.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isRecord } from "../shared/record-coerce.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { resolveEffectiveTtsConfig } from "../tts/tts-config.js"; @@ -17,10 +18,6 @@ const TTS_PROVIDER_CONFIG_RESERVED_KEYS = new Set([ "timeoutMs", ]); -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function isConfigActivationValueEnabled(value: unknown): boolean { if (value === false) { return false; diff --git a/src/plugins/hooks.test-helpers.ts b/src/plugins/hooks.test-helpers.ts index 5eb4a0e8788..0fb85acbec7 100644 --- a/src/plugins/hooks.test-helpers.ts +++ b/src/plugins/hooks.test-helpers.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "../shared/string-normalization.js"; import { createHookRunner } from "./hooks.js"; import type { PluginRegistry } from "./registry.js"; import { createPluginRecord } from "./status.test-helpers.js"; @@ -12,7 +13,7 @@ export function createMockPluginRegistry( ): PluginRegistry { const pluginIds = hooks.length > 0 - ? [...new Set(hooks.map((hook) => hook.pluginId ?? "test-plugin"))] + ? uniqueStrings(hooks.map((hook) => hook.pluginId ?? "test-plugin")) : ["test-plugin"]; return { plugins: pluginIds.map((pluginId) => diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index e39808d6782..d0a9c6b53ff 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -5,6 +5,7 @@ import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import { scanDirectoryWithSummary } from "../security/skill-scanner.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeTrimmedStringList, uniqueStrings } from "../shared/string-normalization.js"; import { findBlockedPackageDirectoryInPath, findBlockedPackageFileAliasInPath, @@ -823,22 +824,13 @@ async function scanDirectoryTarget(params: { } } -function readStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value - .map((entry) => normalizeOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)); -} - function collectPackageExecutableScanEntries(params: { extensions: string[]; packageMetadata?: PackageExecutableScanMetadata; }): string[] { const entries: string[] = []; const metadata = params.packageMetadata; - const runtimeExtensions = readStringList(metadata?.runtimeExtensions); + const runtimeExtensions = normalizeTrimmedStringList(metadata?.runtimeExtensions); for (const [index, extensionEntry] of params.extensions.entries()) { entries.push(extensionEntry); const runtimeEntry = runtimeExtensions[index]; @@ -859,7 +851,7 @@ function collectPackageExecutableScanEntries(params: { } else if (setupEntry) { entries.push(...listBuiltRuntimeEntryCandidates(setupEntry)); } - return [...new Set(entries)]; + return uniqueStrings(entries); } async function resolveRuntimeGraphFileCandidate(filePath: string): Promise { diff --git a/src/plugins/installed-plugin-index-record-builder.ts b/src/plugins/installed-plugin-index-record-builder.ts index 251fbdc890a..14bb53ce728 100644 --- a/src/plugins/installed-plugin-index-record-builder.ts +++ b/src/plugins/installed-plugin-index-record-builder.ts @@ -1,5 +1,6 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/types.js"; +import { normalizeSortedUniqueStringEntries } from "../shared/string-normalization.js"; import type { PluginCompatCode } from "./compat/registry.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; @@ -20,22 +21,13 @@ import type { PluginPackageChannel } from "./manifest.js"; import { isPathInside, safeRealpathSync } from "./path-safety.js"; import { hasKind } from "./slots.js"; -function sortUnique(values: readonly string[] | undefined): readonly string[] { - if (!values || values.length === 0) { - return []; - } - return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).toSorted( - (left, right) => left.localeCompare(right), - ); -} - function buildStartupInfo(record: PluginManifestRecord): InstalledPluginStartupInfo { return { sidecar: record.activation?.onStartup === true, memory: hasKind(record.kind, "memory"), deferConfiguredChannelFullLoadUntilAfterListen: record.startupDeferConfiguredChannelFullLoadUntilAfterListen === true, - agentHarnesses: sortUnique([ + agentHarnesses: normalizeSortedUniqueStringEntries([ ...(record.activation?.onAgentHarnesses ?? []), ...(record.cliBackends ?? []), ]), @@ -73,7 +65,7 @@ export function collectPluginManifestCompatCodes( if (record.activation?.onCapabilities?.length) { codes.push("activation-capability-hint"); } - return sortUnique(codes) as readonly PluginCompatCode[]; + return normalizeSortedUniqueStringEntries(codes) as readonly PluginCompatCode[]; } function resolvePackageJsonPath( diff --git a/src/plugins/installed-plugin-index-record-reader.ts b/src/plugins/installed-plugin-index-record-reader.ts index ba2a0c2f76e..ea55ab350f1 100644 --- a/src/plugins/installed-plugin-index-record-reader.ts +++ b/src/plugins/installed-plugin-index-record-reader.ts @@ -2,16 +2,13 @@ import fs from "node:fs"; import path from "node:path"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { tryReadJson, tryReadJsonSync } from "../infra/json-files.js"; +import { isRecord } from "../shared/record-coerce.js"; import { resolveDefaultPluginNpmDir, validatePluginId } from "./install-paths.js"; import { resolveInstalledPluginIndexStorePath, type InstalledPluginIndexStoreOptions, } from "./installed-plugin-index-store-path.js"; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function cloneInstallRecords( records: Record | undefined, ): Record { diff --git a/src/plugins/legacy-npm-declaration.ts b/src/plugins/legacy-npm-declaration.ts index 1a7d3f2bda0..0937d199b72 100644 --- a/src/plugins/legacy-npm-declaration.ts +++ b/src/plugins/legacy-npm-declaration.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { tryReadJsonSync } from "../infra/json-files.js"; import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; +import { isRecord } from "../shared/record-coerce.js"; import { validatePluginId } from "./install-paths.js"; export const LEGACY_NPM_DECLARATION_FILE = "openclaw.extension.json"; @@ -11,10 +12,6 @@ export type LegacyNpmPluginDeclaration = { source: string; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - export function readLegacyNpmPluginDeclaration( pluginDir: string, ): LegacyNpmPluginDeclaration | null { diff --git a/src/plugins/loader-provenance.ts b/src/plugins/loader-provenance.ts index 569ada46e88..02065d37e3f 100644 --- a/src/plugins/loader-provenance.ts +++ b/src/plugins/loader-provenance.ts @@ -1,5 +1,5 @@ import type { PluginInstallRecord } from "../config/types.plugins.js"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; import { resolveUserPath } from "../utils.js"; import type { PluginCandidate } from "./discovery.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-records.js"; @@ -82,9 +82,7 @@ export function buildProvenanceIndex(params: { trackedWithoutPaths: false, matcher: createPathMatcher(), }; - const trackedPaths = [install.installPath, install.sourcePath] - .map((entry) => normalizeOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)); + const trackedPaths = normalizeTrimmedStringList([install.installPath, install.sourcePath]); if (trackedPaths.length === 0) { rule.trackedWithoutPaths = true; } else { diff --git a/src/plugins/manifest-contract-eligibility.ts b/src/plugins/manifest-contract-eligibility.ts index fbff47a77a4..e130b0ea063 100644 --- a/src/plugins/manifest-contract-eligibility.ts +++ b/src/plugins/manifest-contract-eligibility.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { isInstalledPluginEnabled } from "./installed-plugin-index.js"; import type { PluginManifestContractListKey, PluginManifestRecord } from "./manifest-registry.js"; import { resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; @@ -63,7 +64,7 @@ export function listAvailableManifestContractValues(params: { values.add(value); } } - return [...values].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings(values); } export function loadManifestContractSnapshot(params: { diff --git a/src/plugins/manifest-contract-runtime.ts b/src/plugins/manifest-contract-runtime.ts index c7216266967..997bb82b263 100644 --- a/src/plugins/manifest-contract-runtime.ts +++ b/src/plugins/manifest-contract-runtime.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { hasManifestContractValue, listAvailableManifestContractPlugins, @@ -42,9 +43,7 @@ export function resolveManifestContractRuntimePluginResolution(params: { config: params.cfg, }).map((plugin) => plugin.id); return { - pluginIds: [...new Set(pluginIds)].toSorted((left, right) => left.localeCompare(right)), - bundledCompatPluginIds: [...new Set(bundledCompatPluginIds)].toSorted((left, right) => - left.localeCompare(right), - ), + pluginIds: sortUniqueStrings(pluginIds), + bundledCompatPluginIds: sortUniqueStrings(bundledCompatPluginIds), }; } diff --git a/src/plugins/manifest-metadata-scan.ts b/src/plugins/manifest-metadata-scan.ts index 68f04049a02..575d3fb24bc 100644 --- a/src/plugins/manifest-metadata-scan.ts +++ b/src/plugins/manifest-metadata-scan.ts @@ -2,6 +2,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { isRecord } from "../shared/record-coerce.js"; +import { normalizeOptionalString as normalizeTrimmedString } from "../shared/string-coerce.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; type PluginManifestMetadataRecord = { @@ -26,14 +28,6 @@ let manifestMetadataCache: } | undefined; -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function normalizeTrimmedString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function resolveUserPath(value: string, env: NodeJS.ProcessEnv): string { if (value === "~" || value.startsWith("~/")) { const home = env.OPENCLAW_HOME ?? env.HOME ?? env.USERPROFILE ?? os.homedir(); diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index 3a7c86467ca..317e82401d9 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { tryReadJsonSync } from "../infra/json-files.js"; +import { isRecord } from "../shared/record-coerce.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { normalizeOptionalTrimmedStringList } from "../shared/string-normalization.js"; import type { PluginCandidate } from "./discovery.js"; @@ -131,10 +132,6 @@ function resolveFallbackPluginSource(record: InstalledPluginIndexRecord): string return path.join(rootDir, DEFAULT_PLUGIN_ENTRY_CANDIDATES[0]); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function normalizePackageChannelCommands( commands: unknown, ): PluginPackageChannel["commands"] | undefined { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 457f3463b62..2b60dd13180 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -4,7 +4,10 @@ import type { OpenClawConfig } from "../config/types.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { normalizeOptionalTrimmedStringList } from "../shared/string-normalization.js"; +import { + normalizeOptionalTrimmedStringList, + uniqueStrings, +} from "../shared/string-normalization.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { resolveUserPath } from "../utils.js"; import { resolveCompatibilityHostVersion } from "../version.js"; @@ -355,9 +358,11 @@ function mergeContractLists( left: readonly string[] | undefined, right: readonly string[] | undefined, ): string[] | undefined { - const merged = [...(left ?? []), ...(right ?? [])] - .map((value) => value.trim()) - .filter((value, index, all) => value.length > 0 && all.indexOf(value) === index); + const merged = uniqueStrings( + [...(left ?? []), ...(right ?? [])] + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ); return merged.length > 0 ? merged : undefined; } diff --git a/src/plugins/manifest-tool-availability.ts b/src/plugins/manifest-tool-availability.ts index d48bac3cf63..b288bacf1c0 100644 --- a/src/plugins/manifest-tool-availability.ts +++ b/src/plugins/manifest-tool-availability.ts @@ -1,6 +1,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { coerceSecretRef, type SecretRef } from "../config/types.secrets.js"; import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; +import { isRecord } from "../shared/record-coerce.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import type { PluginManifestCapabilityProviderAuthSignal, @@ -11,10 +13,6 @@ type ToolMetadata = NonNullable[string]; export type ManifestConfigAvailabilitySignal = PluginManifestCapabilityProviderConfigSignal; export type ManifestAuthAvailabilitySignal = PluginManifestCapabilityProviderAuthSignal; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function readPath(root: unknown, path: string | undefined): unknown { if (!path?.trim()) { return root; @@ -34,8 +32,7 @@ function readPath(root: unknown, path: string | undefined): unknown { } function readStringAtPath(root: unknown, path: string): string | undefined { - const value = readPath(root, path); - return typeof value === "string" && value.trim() ? value.trim() : undefined; + return normalizeOptionalString(readPath(root, path)); } function readEffectiveConfig(params: { diff --git a/src/plugins/model-catalog-registration.ts b/src/plugins/model-catalog-registration.ts index a3492f983ef..18b486e566f 100644 --- a/src/plugins/model-catalog-registration.ts +++ b/src/plugins/model-catalog-registration.ts @@ -5,6 +5,7 @@ import { } from "../media-generation/catalog.js"; import type { UnifiedModelCatalogEntry } from "../model-catalog/types.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { uniqueValues } from "../shared/string-normalization.js"; import type { PluginDiagnostic } from "./manifest-types.js"; import type { PluginRecord, PluginRegistry } from "./registry-types.js"; import type { @@ -80,7 +81,7 @@ export function createModelCatalogRegistrationHandlers(params: { }); return; } - const normalizedKinds = [...new Set(provider.kinds)]; + const normalizedKinds = uniqueValues(provider.kinds); const samePluginOverlapping = params.registry.modelCatalogProviders.find( (entry) => entry.provider.provider === providerId && @@ -92,7 +93,7 @@ export function createModelCatalogRegistrationHandlers(params: { ...samePluginOverlapping.provider, ...provider, provider: providerId, - kinds: [...new Set([...samePluginOverlapping.provider.kinds, ...normalizedKinds])], + kinds: uniqueValues([...samePluginOverlapping.provider.kinds, ...normalizedKinds]), staticCatalog: provider.staticCatalog ?? samePluginOverlapping.provider.staticCatalog, liveCatalog: provider.liveCatalog ?? samePluginOverlapping.provider.liveCatalog, }; diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts index 8d43060d4e8..0b71795318f 100644 --- a/src/plugins/official-external-plugin-catalog.ts +++ b/src/plugins/official-external-plugin-catalog.ts @@ -3,6 +3,7 @@ import officialExternalPluginCatalog from "../../scripts/lib/official-external-p import officialExternalProviderCatalog from "../../scripts/lib/official-external-provider-catalog.json" with { type: "json" }; import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { isRecord } from "../utils.js"; import type { PluginManifestChannelConfig, @@ -122,11 +123,13 @@ function resolveOfficialExternalPluginLookupIds( entry: OfficialExternalPluginCatalogEntry, ): string[] { const manifest = getOfficialExternalPluginCatalogManifest(entry); - return [ - normalizeOptionalString(manifest?.plugin?.id), - normalizeOptionalString(manifest?.channel?.id), - normalizeOptionalString(manifest?.providers?.[0]?.id), - ].filter((value, index, all): value is string => Boolean(value) && all.indexOf(value) === index); + return uniqueStrings( + [ + normalizeOptionalString(manifest?.plugin?.id), + normalizeOptionalString(manifest?.channel?.id), + normalizeOptionalString(manifest?.providers?.[0]?.id), + ].filter((value): value is string => Boolean(value)), + ); } export function resolveOfficialExternalPluginLabel( diff --git a/src/plugins/package-entrypoints.ts b/src/plugins/package-entrypoints.ts index ccfe55ebdba..5b0f5e175cc 100644 --- a/src/plugins/package-entrypoints.ts +++ b/src/plugins/package-entrypoints.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { uniqueStrings } from "../shared/string-normalization.js"; export function isTypeScriptPackageEntry(entryPath: string): boolean { return [".ts", ".mts", ".cts"].includes(path.extname(entryPath).toLowerCase()); @@ -23,5 +24,5 @@ export function listBuiltRuntimeEntryCandidates(entryPath: string): string[] { ...withJavaScriptExtensions(distWithoutExtension), ...withJavaScriptExtensions(withoutExtension), ]; - return [...new Set(candidates)].filter((candidate) => candidate !== normalized); + return uniqueStrings(candidates).filter((candidate) => candidate !== normalized); } diff --git a/src/plugins/plugin-metadata-snapshot.ts b/src/plugins/plugin-metadata-snapshot.ts index 193f8843b6b..e8de6d529f2 100644 --- a/src/plugins/plugin-metadata-snapshot.ts +++ b/src/plugins/plugin-metadata-snapshot.ts @@ -6,6 +6,7 @@ import { getActiveDiagnosticsTimelineSpan, measureDiagnosticsTimelineSpanSync, } from "../infra/diagnostics-timeline.js"; +import { isRecord } from "../shared/record-coerce.js"; import { resolveUserPath } from "../utils.js"; import { resolveCompatibilityHostVersion } from "../version.js"; import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; @@ -91,10 +92,6 @@ function fileFingerprint(filePath: string): unknown { } } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function readJsonObject(filePath: string): Record | undefined { try { const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); diff --git a/src/plugins/plugin-registry-contributions.ts b/src/plugins/plugin-registry-contributions.ts index 9c5ddae5859..910455992d0 100644 --- a/src/plugins/plugin-registry-contributions.ts +++ b/src/plugins/plugin-registry-contributions.ts @@ -1,5 +1,6 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeSortedUniqueStringEntries } from "../shared/string-normalization.js"; import { normalizePluginsConfigWithResolver, type NormalizedPluginsConfig, @@ -103,12 +104,6 @@ function normalizeContributionId(value: string): string { return value.trim(); } -function sortUnique(values: Iterable): string[] { - return [...new Set([...values].map((value) => value.trim()).filter(Boolean))].toSorted( - (left, right) => left.localeCompare(right), - ); -} - function collectObjectKeys(value: Record | undefined): readonly string[] { return value ? Object.keys(value) : []; } @@ -263,7 +258,9 @@ function filterContributionOwnerIds(params: { config: params.config, }), ); - return sortUnique(params.owners.filter((owner) => enabledPluginIds.has(owner))); + return normalizeSortedUniqueStringEntries( + params.owners.filter((owner) => enabledPluginIds.has(owner)), + ); } function canReuseCurrentManifestRegistry(params: LoadPluginRegistryManifestParams): boolean { @@ -353,7 +350,7 @@ export function listPluginContributionIds( ): readonly string[] { const index = params.lookUpTable?.index ?? loadPluginRegistrySnapshot(params); const plugins = listContributionManifestPlugins({ ...params, index }); - return sortUnique( + return normalizeSortedUniqueStringEntries( plugins.flatMap((plugin) => listManifestContributionIds(plugin, params.contribution)), ); } @@ -380,7 +377,7 @@ export function resolvePluginContributionOwners( ? (contributionId: string) => contributionId === params.matches : params.matches; const plugins = listContributionManifestPlugins({ ...params, index }); - return sortUnique( + return normalizeSortedUniqueStringEntries( plugins.flatMap((plugin) => listManifestContributionIds(plugin, params.contribution).some(matcher) ? [plugin.id] : [], ), diff --git a/src/plugins/plugin-scope.ts b/src/plugins/plugin-scope.ts index 3053ebc1dfd..e31bcdcf682 100644 --- a/src/plugins/plugin-scope.ts +++ b/src/plugins/plugin-scope.ts @@ -1,3 +1,5 @@ +import { normalizeStringEntries } from "../shared/string-normalization.js"; + export type PluginIdScope = readonly string[] | undefined; export function normalizePluginIdScope(ids?: readonly unknown[]): string[] | undefined { @@ -5,12 +7,7 @@ export function normalizePluginIdScope(ids?: readonly unknown[]): string[] | und return undefined; } return Array.from( - new Set( - ids - .filter((id): id is string => typeof id === "string") - .map((id) => id.trim()) - .filter(Boolean), - ), + new Set(normalizeStringEntries(ids.filter((id): id is string => typeof id === "string"))), ).toSorted(); } diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts index a8a7f70f6a9..b4619d86b5b 100644 --- a/src/plugins/provider-api-key-auth.ts +++ b/src/plugins/provider-api-key-auth.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { SecretInput } from "../config/types.secrets.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { normalizeStringEntries } from "../shared/string-normalization.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import type { ProviderAuthMethod, @@ -52,7 +52,7 @@ function resolveProfileIds(params: { profileId?: string; profileIds?: string[]; }) { - const explicit = Array.from(new Set(normalizeStringEntries(params.profileIds ?? []))); + const explicit = normalizeUniqueStringEntries(params.profileIds ?? []); if (explicit.length > 0) { return explicit; } diff --git a/src/plugins/provider-auth-choice-helpers.ts b/src/plugins/provider-auth-choice-helpers.ts index 790b9022df6..26a5827f193 100644 --- a/src/plugins/provider-auth-choice-helpers.ts +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -8,6 +8,7 @@ import { normalizeProviderConfigForConfigDefaults } from "../config/provider-pol import type { AgentModelConfig } from "../config/types.agents-shared.js"; import type { ModelProviderConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isRecord as isPlainRecord } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -50,10 +51,6 @@ export function pickAuthMethod( ); } -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - // Guard config patches against prototype-pollution payloads if a patch ever // arrives from a JSON-parsed source that preserves these keys. const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]); diff --git a/src/plugins/provider-auth-helpers.ts b/src/plugins/provider-auth-helpers.ts index f03b29900bb..7a3b27a57dd 100644 --- a/src/plugins/provider-auth-helpers.ts +++ b/src/plugins/provider-auth-helpers.ts @@ -14,6 +14,7 @@ import { type SecretRef, } from "../config/types.secrets.js"; import { getProviderEnvVars } from "../secrets/provider-env-vars.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import type { SecretInputMode } from "./provider-auth-types.js"; @@ -179,7 +180,7 @@ export function applyAuthProfileConfig( ); const existingProviderOrder = matchingProviderOrderEntries.length > 0 - ? [...new Set(matchingProviderOrderEntries.flatMap(([, order]) => order))] + ? uniqueStrings(matchingProviderOrderEntries.flatMap(([, order]) => order)) : undefined; const preferProfileFirst = params.preferProfileFirst ?? true; const reorderedProviderOrder = diff --git a/src/plugins/provider-contract-public-artifacts.ts b/src/plugins/provider-contract-public-artifacts.ts index bcd9cf1dcbe..e38d8f4d950 100644 --- a/src/plugins/provider-contract-public-artifacts.ts +++ b/src/plugins/provider-contract-public-artifacts.ts @@ -1,3 +1,5 @@ +import { isRecord } from "../shared/record-coerce.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { loadBundledPluginPublicArtifactModuleSync } from "./public-surface-loader.js"; import type { ProviderPlugin } from "./types.js"; @@ -6,10 +8,6 @@ type ProviderContractEntry = { provider: ProviderPlugin; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isProviderPlugin(value: unknown): value is ProviderPlugin { return ( isRecord(value) && @@ -64,9 +62,7 @@ export function resolveBundledExplicitProviderContractsFromPublicArtifacts(param onlyPluginIds: readonly string[]; }): ProviderContractEntry[] | null { const providers: ProviderContractEntry[] = []; - for (const pluginId of [...new Set(params.onlyPluginIds)].toSorted((left, right) => - left.localeCompare(right), - )) { + for (const pluginId of sortUniqueStrings(params.onlyPluginIds)) { const mod = tryLoadProviderContractApi(pluginId); if (!mod) { return null; diff --git a/src/plugins/provider-discovery.runtime.ts b/src/plugins/provider-discovery.runtime.ts index a6ea0e4b535..310c931fe2b 100644 --- a/src/plugins/provider-discovery.runtime.ts +++ b/src/plugins/provider-discovery.runtime.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { loadManifestMetadataSnapshot } from "./manifest-contract-eligibility.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import type { PluginMetadataRegistryView } from "./plugin-metadata-snapshot.types.js"; @@ -66,10 +67,6 @@ function hasProviderAuthEnvCredential( }); } -function dedupeSorted(values: Iterable): string[] { - return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); -} - function resolveProviderDiscoveryEntryPlugins(params: { config?: OpenClawConfig; workspaceDir?: string; @@ -137,7 +134,7 @@ function resolveSelectiveFullPluginIds(params: { .filter((plugin) => !params.entryResult.entryPluginIds.has(plugin.id)) .filter((plugin) => hasProviderAuthEnvCredential(plugin, params.env)) .map((plugin) => plugin.id); - return dedupeSorted([...staticOnlyEntryPluginIds, ...missingEntryCredentialPluginIds]); + return sortUniqueStrings([...staticOnlyEntryPluginIds, ...missingEntryCredentialPluginIds]); } export function resolvePluginDiscoveryProvidersRuntime(params: { diff --git a/src/plugins/provider-discovery.ts b/src/plugins/provider-discovery.ts index 6e30ac74ab8..1b57f9a109a 100644 --- a/src/plugins/provider-discovery.ts +++ b/src/plugins/provider-discovery.ts @@ -2,6 +2,7 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import type { ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { listManifestProviderContributionIds } from "./manifest-contribution-ids.js"; import type { PluginMetadataRegistryView } from "./plugin-metadata-snapshot.types.js"; import { type LoadPluginRegistryParams, type PluginRegistrySnapshot } from "./plugin-registry.js"; @@ -49,10 +50,6 @@ export type ResolveInstalledPluginProviderContributionIdsParams = LoadPluginRegi includeDisabled?: boolean; }; -function sortedValues(values: Iterable): string[] { - return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); -} - export function resolveInstalledPluginProviderContributionIds( params: ResolveInstalledPluginProviderContributionIdsParams = {}, ): string[] { @@ -60,7 +57,7 @@ export function resolveInstalledPluginProviderContributionIds( params.candidates && params.preferPersisted === undefined ? { ...params, preferPersisted: false } : params; - return sortedValues( + return sortUniqueStrings( listManifestProviderContributionIds({ ...registryParams, index: params.index, diff --git a/src/plugins/provider-install-catalog.ts b/src/plugins/provider-install-catalog.ts index 55db4937b09..1e3b5d968f1 100644 --- a/src/plugins/provider-install-catalog.ts +++ b/src/plugins/provider-install-catalog.ts @@ -2,6 +2,7 @@ import { loadOpenClawProviderIndex, type OpenClawProviderIndexProvider, } from "../model-catalog/index.js"; +import { isRecord } from "../shared/record-coerce.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { @@ -62,10 +63,6 @@ function normalizeDefaultChoice(value: unknown): PluginPackageInstall["defaultCh return value === "clawhub" || value === "npm" || value === "local" ? value : undefined; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function resolveInstallInfoFromInstallRecord( record: InstalledPluginInstallRecordInfo | undefined, ): PluginPackageInstall | null { diff --git a/src/plugins/provider-model-compat.ts b/src/plugins/provider-model-compat.ts index 11af0f56ed9..9f8dc2ccd3e 100644 --- a/src/plugins/provider-model-compat.ts +++ b/src/plugins/provider-model-compat.ts @@ -1,6 +1,7 @@ import type { Api, Model } from "@earendil-works/pi-ai"; import { detectOpenAICompletionsCompat } from "../agents/openai-completions-compat.js"; import type { ModelCompatConfig } from "../config/types.models.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; export function extractModelCompat( modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined, @@ -58,10 +59,9 @@ export function resolveUnsupportedToolSchemaKeywords( ): ReadonlySet { const keywords = extractModelCompat(modelOrCompat)?.unsupportedToolSchemaKeywords ?? []; return new Set( - keywords - .filter((keyword): keyword is string => typeof keyword === "string") - .map((keyword) => keyword.trim()) - .filter(Boolean), + normalizeStringEntries( + keywords.filter((keyword): keyword is string => typeof keyword === "string"), + ), ); } diff --git a/src/plugins/provider-model-helpers.ts b/src/plugins/provider-model-helpers.ts index fe88e4373c7..b142778d073 100644 --- a/src/plugins/provider-model-helpers.ts +++ b/src/plugins/provider-model-helpers.ts @@ -1,4 +1,5 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { normalizeModelCompat } from "./provider-model-compat.js"; import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js"; import type { ProviderResolveDynamicModelContext } from "./types.js"; @@ -19,7 +20,7 @@ export function cloneFirstTemplateModel(params: { patch?: Partial; }): ProviderRuntimeModel | undefined { const trimmedModelId = params.modelId.trim(); - for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) { + for (const templateId of uniqueStrings(params.templateIds).filter(Boolean)) { const template = params.ctx.modelRegistry.find( params.providerId, templateId, diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index edb4baa85fe..28a574a80d0 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -10,6 +10,7 @@ import type { ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { sortUniqueStrings, uniqueStrings } from "../shared/string-normalization.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normalization.js"; import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; @@ -111,7 +112,7 @@ function resolveProviderHookRefs( if (apiRef && normalizeProviderId(apiRef) !== normalizeProviderId(provider)) { refs.push(apiRef); } - return [...new Set(refs)]; + return uniqueStrings(refs); } function matchesAnyProviderPluginRef(provider: ProviderPlugin, providerRefs: readonly string[]) { @@ -242,7 +243,9 @@ function mergeProviderSystemPromptContributions( } function mergeUniquePromptSections(...sections: Array): string | undefined { - const uniqueSections = [...new Set(sections.filter((section) => section?.trim()))]; + const uniqueSections = uniqueStrings( + sections.filter((section): section is string => Boolean(section?.trim())), + ); return uniqueSections.length > 0 ? uniqueSections.join("\n\n") : undefined; } @@ -955,9 +958,7 @@ export function resolveExternalAuthProfilesWithPlugins(params: { declaredPluginIds, manifestRegistry, }); - const pluginIds = [...new Set([...externalAuthPluginIds, ...fallbackPluginIds])].toSorted( - (left, right) => left.localeCompare(right), - ); + const pluginIds = sortUniqueStrings([...externalAuthPluginIds, ...fallbackPluginIds]); if (pluginIds.length === 0) { return []; } diff --git a/src/plugins/provider-self-hosted-setup.ts b/src/plugins/provider-self-hosted-setup.ts index 0a1c68c18e0..55ce289c8a2 100644 --- a/src/plugins/provider-self-hosted-setup.ts +++ b/src/plugins/provider-self-hosted-setup.ts @@ -16,6 +16,7 @@ import { normalizeOptionalString, normalizeStringifiedOptionalString, } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { applyAuthProfileConfig } from "./provider-auth-helpers.js"; @@ -181,7 +182,7 @@ export async function discoverOpenAICompatibleLocalModels(params: { }); const runtimeContextTokensByModelId = new Map(); if (params.contextWindow === undefined) { - const uniqueModelIds = [...new Set(discoveredModels.map((model) => model.id))]; + const uniqueModelIds = uniqueStrings(discoveredModels.map((model) => model.id)); const runtimeContextTokenResults = await Promise.all( uniqueModelIds.map( async (modelId) => diff --git a/src/plugins/provider-validation.ts b/src/plugins/provider-validation.ts index 33f7cc402b1..6e5f839a0fe 100644 --- a/src/plugins/provider-validation.ts +++ b/src/plugins/provider-validation.ts @@ -1,5 +1,5 @@ import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; +import { normalizeUniqueTrimmedStringList } from "../shared/string-normalization.js"; import type { PluginDiagnostic } from "./manifest-types.js"; import type { ProviderAuthMethod, ProviderPlugin } from "./types.js"; import { pushPluginValidationDiagnostic } from "./validation-diagnostics.js"; @@ -11,7 +11,7 @@ type ProviderWizardModelPicker = NonNullable; function normalizeTextList(values: string[] | undefined): string[] | undefined { - const normalized = Array.from(new Set(normalizeTrimmedStringList(values))); + const normalized = normalizeUniqueTrimmedStringList(values); return normalized.length > 0 ? normalized : undefined; } diff --git a/src/plugins/providers.runtime.ts b/src/plugins/providers.runtime.ts index 54aa63989f1..5984bd03f81 100644 --- a/src/plugins/providers.runtime.ts +++ b/src/plugins/providers.runtime.ts @@ -1,3 +1,4 @@ +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { withActivatedPluginIds } from "./activation-context.js"; import { resolveBundledPluginCompatibleActivationInputs } from "./activation-context.js"; import { resolveManifestActivationPluginIds } from "./activation-planner.js"; @@ -31,7 +32,7 @@ import { import type { ProviderPlugin } from "./types.js"; function dedupeSortedPluginIds(values: Iterable): string[] { - return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings(values); } function resolveExplicitProviderOwnerPluginIds( @@ -151,13 +152,11 @@ function resolvePluginProviderLoadBase( params.modelRefs?.length || providerOwnedPluginIds.length > 0 || modelOwnedPluginIds.length > 0 - ? [ - ...new Set([ - ...(params.onlyPluginIds ?? []), - ...providerOwnedPluginIds, - ...modelOwnedPluginIds, - ]), - ].toSorted((left, right) => left.localeCompare(right)) + ? dedupeSortedPluginIds([ + ...(params.onlyPluginIds ?? []), + ...providerOwnedPluginIds, + ...modelOwnedPluginIds, + ]) : undefined; const explicitOwnerPluginIds = dedupeSortedPluginIds([ ...providerOwnedPluginIds, diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 005c63d7dc9..2a4c877fd0e 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -1,6 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginAutoEnableResult } from "../config/plugin-auto-enable.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import type { OpenClawPackageManifest } from "./manifest.js"; import type { PluginRegistrySnapshot } from "./plugin-registry.js"; @@ -182,10 +183,6 @@ function normalizeProviderForFixture(value: string): string { return value.trim().toLowerCase(); } -function sortUniqueFixtureValues(values: Iterable): string[] { - return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); -} - function listManifestContributionIdsForFixture( plugin: PluginManifestRecord, contribution: string, @@ -208,7 +205,7 @@ function resolvePluginContributionOwnersFixture(params: { typeof params.matches === "string" ? (contributionId: string) => contributionId === params.matches : params.matches; - return sortUniqueFixtureValues( + return sortUniqueStrings( loadPluginManifestRegistryMock().plugins.flatMap((plugin) => listManifestContributionIdsForFixture(plugin, params.contribution).some(matcher) ? [plugin.id] diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index b9871624d9d..457b9a99c87 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -1,6 +1,7 @@ import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { normalizeProviderId } from "../agents/provider-id.js"; import { compileSafeRegex } from "../security/safe-regex.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { withBundledPluginVitestCompat } from "./bundled-compat.js"; import { resolveEffectivePluginActivationState } from "./config-state.js"; import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js"; @@ -473,7 +474,7 @@ function resolveModelSupportMatchKind( } function dedupeSortedPluginIds(values: Iterable): string[] { - return [...new Set(values)].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings(values); } function resolvePreferredManifestPluginIds( @@ -662,8 +663,6 @@ export function resolveCatalogHookProviderPluginIds(params: { ...params, manifestRegistry, }).filter((pluginId) => runtimeAugmentPluginIds.has(pluginId)); - return [...new Set([...enabledProviderPluginIds, ...bundledCompatPluginIds])].toSorted( - (left, right) => left.localeCompare(right), - ); + return dedupeSortedPluginIds([...enabledProviderPluginIds, ...bundledCompatPluginIds]); } export { testing as __testing }; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index dfe779ddd1a..4bbc617358f 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -37,6 +37,11 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { uniqueValues } from "../shared/string-normalization.js"; +import { + normalizeStringEntries, + normalizeUniqueStringEntries, +} from "../shared/string-normalization.js"; import { getDetachedTaskLifecycleRuntimeRegistration, registerDetachedTaskLifecycleRuntime, @@ -537,7 +542,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { (entry) => entry.pluginId === record.id && entry.rawHandler === handler, ); if (existing) { - existing.runtimes = [...new Set([...existing.runtimes, ...runtimes])]; + existing.runtimes = uniqueValues([...existing.runtimes, ...runtimes]); return; } const safeHandler: AgentToolResultMiddleware = async (event, ctx) => { @@ -625,8 +630,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { config: OpenClawPluginApi["config"], pluginConfig: unknown, ) => { - const eventList = Array.isArray(events) ? events : [events]; - const normalizedEvents = eventList.map((event) => event.trim()).filter(Boolean); + const normalizedEvents = normalizeStringEntries(Array.isArray(events) ? events : [events]); const entry = opts?.entry ?? null; const hookName = requireRegistrationValue( entry?.hook.name ?? opts?.name?.trim(), @@ -1485,12 +1489,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { ]); const registerReload = (record: PluginRecord, registration: OpenClawPluginReloadRegistration) => { - const normalize = (values?: string[]) => - (values ?? []).map((value) => value.trim()).filter(Boolean); const normalized: OpenClawPluginReloadRegistration = { - restartPrefixes: normalize(registration.restartPrefixes), - hotPrefixes: normalize(registration.hotPrefixes), - noopPrefixes: normalize(registration.noopPrefixes), + restartPrefixes: normalizeStringEntries(registration.restartPrefixes), + hotPrefixes: normalizeStringEntries(registration.hotPrefixes), + noopPrefixes: normalizeStringEntries(registration.noopPrefixes), }; if ( (normalized.restartPrefixes?.length ?? 0) === 0 && @@ -1567,9 +1569,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { policy: OpenClawPluginNodeInvokePolicy, pluginConfig?: Record, ) => { - const commands = Array.isArray(policy.commands) - ? policy.commands.map((command) => command.trim()).filter(Boolean) - : []; + const commands = normalizeUniqueStringEntries( + Array.isArray(policy.commands) ? policy.commands : [], + ); if (commands.length === 0) { pushDiagnostic({ level: "error", diff --git a/src/plugins/roots.ts b/src/plugins/roots.ts index 4ec51d9051b..f532689a1e7 100644 --- a/src/plugins/roots.ts +++ b/src/plugins/roots.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; @@ -37,10 +38,8 @@ export function resolvePluginCacheInputs(params: { env, }); // Preserve caller order because load-path precedence follows input order. - const loadPaths = (params.loadPaths ?? []) - .filter((entry): entry is string => typeof entry === "string") - .map((entry) => entry.trim()) - .filter(Boolean) - .map((entry) => resolveUserPath(entry, env)); + const loadPaths = normalizeStringEntries( + (params.loadPaths ?? []).filter((entry): entry is string => typeof entry === "string"), + ).map((entry) => resolveUserPath(entry, env)); return { roots, loadPaths }; } diff --git a/src/plugins/runtime/runtime-llm.runtime.ts b/src/plugins/runtime/runtime-llm.runtime.ts index 7c42fdcd1e9..5b38befbb34 100644 --- a/src/plugins/runtime/runtime-llm.runtime.ts +++ b/src/plugins/runtime/runtime-llm.runtime.ts @@ -6,6 +6,7 @@ import { normalizeUsage } from "../../agents/usage.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getChildLogger } from "../../logging.js"; import { normalizeAgentId } from "../../routing/session-key.js"; +import { asFiniteNumber } from "../../shared/number-coercion.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js"; import { normalizePluginsConfig } from "../config-state.js"; @@ -215,7 +216,7 @@ function buildUsage(params: { } function finiteOption(value: number | undefined): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(value); } function normalizeAllowedModelRef(raw: string): string | null { diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index 44963eaefbf..94a4067729f 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -3,6 +3,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + normalizeStringEntries, + normalizeUniqueStringEntries, +} from "../shared/string-normalization.js"; import { buildPluginApi } from "./api-builder.js"; import { collectPluginConfigContractMatches } from "./config-contracts.js"; import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; @@ -158,10 +162,7 @@ function collectConfiguredPluginEntryIds(config: OpenClawConfig): string[] { if (!entries || typeof entries !== "object") { return []; } - return Object.keys(entries) - .map((pluginId) => pluginId.trim()) - .filter(Boolean) - .toSorted(); + return normalizeStringEntries(Object.keys(entries)).toSorted(); } function resolveRelevantSetupMigrationPluginIds(params: { @@ -421,7 +422,7 @@ export function resolvePluginSetupRegistry(params?: { }): PluginSetupRegistry { const env = params?.env ?? process.env; const scopedPluginIds = params?.pluginIds - ? new Set(params.pluginIds.map((pluginId) => pluginId.trim()).filter(Boolean)) + ? new Set(normalizeUniqueStringEntries(params.pluginIds)) : null; if (scopedPluginIds && scopedPluginIds.size === 0) { const empty = { diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index 76b6237f4bb..5142716f0c9 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -2,6 +2,8 @@ import { compileGlobPatterns, matchesAnyGlobPattern } from "../agents/glob-patte import { DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, normalizeToolName } from "../agents/tool-policy.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { isRecord } from "../shared/record-coerce.js"; +import { normalizeUniqueStringEntries, uniqueStrings } from "../shared/string-normalization.js"; import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js"; import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; @@ -85,7 +87,7 @@ export function buildPluginToolMetadataKey(pluginId: string, toolName: string): } function normalizeAllowlist(list?: string[]) { - return new Set((list ?? []).map(normalizeToolName).filter(Boolean)); + return new Set(normalizeUniqueStringEntries((list ?? []).map(normalizeToolName))); } function normalizeDenylist(list?: string[]) { @@ -193,10 +195,6 @@ function isOptionalToolEntryPotentiallyAllowed(params: { return params.names.some((name) => params.allowlist.has(normalizeToolName(name))); } -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function readPluginToolName(tool: unknown): string { if (!isRecord(tool)) { return ""; @@ -384,7 +382,7 @@ function listManifestToolNamesForAllowlist(params: { const defaultToolNames = params.toolNames.filter( (name) => !isManifestToolOptional(params.plugin, name), ); - return [...new Set([...defaultToolNames, ...matchedToolNames])]; + return uniqueStrings([...defaultToolNames, ...matchedToolNames]); } function listManifestToolNamesForAvailability(params: { diff --git a/src/plugins/web-content-extractor-public-artifacts.ts b/src/plugins/web-content-extractor-public-artifacts.ts index ce7a9268520..a66e9285475 100644 --- a/src/plugins/web-content-extractor-public-artifacts.ts +++ b/src/plugins/web-content-extractor-public-artifacts.ts @@ -1,3 +1,4 @@ +import { isRecord } from "../shared/record-coerce.js"; import { loadBundledPluginPublicArtifactModuleSync } from "./public-surface-loader.js"; import type { PluginWebContentExtractorEntry, @@ -9,10 +10,6 @@ const WEB_CONTENT_EXTRACTOR_ARTIFACT_CANDIDATES = [ "web-content-extractor-api.js", ] as const; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isWebContentExtractorPlugin(value: unknown): value is WebContentExtractorPlugin { return ( isRecord(value) && diff --git a/src/plugins/web-provider-public-artifacts.explicit.ts b/src/plugins/web-provider-public-artifacts.explicit.ts index ebf37f2e95d..bd7b7691f21 100644 --- a/src/plugins/web-provider-public-artifacts.explicit.ts +++ b/src/plugins/web-provider-public-artifacts.explicit.ts @@ -1,3 +1,5 @@ +import { isRecord } from "../shared/record-coerce.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { loadBundledPluginPublicArtifactModuleSync, resolveBundledPluginPublicArtifactPath, @@ -21,10 +23,6 @@ const WEB_FETCH_ARTIFACT_CANDIDATES = [ "web-fetch.js", ] as const; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((entry) => typeof entry === "string"); } @@ -104,7 +102,7 @@ function tryLoadBundledPublicArtifactModule(params: { } function normalizeExplicitBundledPluginIds(pluginIds: readonly string[]): string[] { - return [...new Set(pluginIds)].toSorted((left, right) => left.localeCompare(right)); + return sortUniqueStrings(pluginIds); } function loadBundledProviderEntriesFromDir(params: { diff --git a/src/plugins/web-provider-public-artifacts.ts b/src/plugins/web-provider-public-artifacts.ts index b528eb40919..8e1687f79a9 100644 --- a/src/plugins/web-provider-public-artifacts.ts +++ b/src/plugins/web-provider-public-artifacts.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { normalizePluginId } from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; import { loadManifestMetadataSnapshot } from "./manifest-contract-eligibility.js"; @@ -40,7 +41,7 @@ function filterAllowlistedBundledPluginIds( return [...pluginIds]; } const allowedPluginIds = new Set( - allow.map((pluginId) => normalizePluginId(pluginId)).filter(Boolean), + normalizeUniqueStringEntries(allow.map((pluginId) => normalizePluginId(pluginId))), ); return pluginIds.filter((pluginId) => allowedPluginIds.has(pluginId)); } diff --git a/src/plugins/web-search-install-catalog.ts b/src/plugins/web-search-install-catalog.ts index 726d1395cd0..606aa9f7067 100644 --- a/src/plugins/web-search-install-catalog.ts +++ b/src/plugins/web-search-install-catalog.ts @@ -1,4 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeOptionalString as normalizeString } from "../shared/string-coerce.js"; +import { normalizeTrimmedStringList } from "../shared/string-normalization.js"; import { isRecord } from "../utils.js"; import { enablePluginInConfig } from "./enable.js"; import type { PluginPackageInstall } from "./manifest.js"; @@ -19,16 +21,6 @@ export type WebSearchInstallCatalogEntry = { trustedSourceLinkedOfficialInstall?: boolean; }; -function normalizeString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function normalizeStringList(value: unknown): string[] { - return Array.isArray(value) - ? value.map(normalizeString).filter((entry): entry is string => Boolean(entry)) - : []; -} - function normalizeOnboardingScopes( value: OfficialExternalWebSearchProvider["onboardingScopes"], ): readonly "text-inference"[] | undefined { @@ -83,7 +75,7 @@ function buildProviderEntry(params: { const credentialPath = normalizeString(params.provider.credentialPath) ?? `plugins.entries.${params.pluginId}.config.webSearch.apiKey`; - const envVars = normalizeStringList(params.provider.envVars); + const envVars = normalizeTrimmedStringList(params.provider.envVars); const placeholder = normalizeString(params.provider.placeholder); const signupUrl = normalizeString(params.provider.signupUrl); if (!providerId || !label || !hint || envVars.length === 0 || !placeholder || !signupUrl) { diff --git a/src/proxy-capture/store.sqlite.ts b/src/proxy-capture/store.sqlite.ts index 666ebeea6ff..8c155b725cd 100644 --- a/src/proxy-capture/store.sqlite.ts +++ b/src/proxy-capture/store.sqlite.ts @@ -3,6 +3,8 @@ import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { requireNodeSqlite } from "../infra/node-sqlite.js"; import { configureSqliteWalMaintenance, type SqliteWalMaintenance } from "../infra/sqlite-wal.js"; +import { normalizeNullableString as normalizeObservedValue } from "../shared/string-coerce.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { readCaptureBlobText, writeCaptureBlob } from "./blob-store.js"; import type { CaptureBlobRecord, @@ -87,10 +89,6 @@ function parseMetaJson(metaJson: unknown): Record | null { } } -function normalizeObservedValue(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; -} - function sortObservedCounts(counts: Map): CaptureObservedDimension[] { return [...counts.entries()] .map(([value, count]) => ({ value, count })) @@ -390,7 +388,7 @@ export class DebugProxyCaptureStore { } deleteSessions(sessionIds: string[]): { sessions: number; events: number; blobs: number } { - const uniqueSessionIds = [...new Set(sessionIds.map((id) => id.trim()).filter(Boolean))]; + const uniqueSessionIds = normalizeUniqueStringEntries(sessionIds); if (uniqueSessionIds.length === 0) { return { sessions: 0, events: 0, blobs: 0 }; } diff --git a/src/routing/channel-route-targets.ts b/src/routing/channel-route-targets.ts index e221bd89280..63a4c20ce40 100644 --- a/src/routing/channel-route-targets.ts +++ b/src/routing/channel-route-targets.ts @@ -1,6 +1,7 @@ import { normalizeChatChannelId } from "../channels/ids.js"; import { listRouteBindings } from "../config/bindings.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isRecord as hasRecord } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { resolveAgentRoute } from "./resolve-route.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, normalizeAgentId } from "./session-key.js"; @@ -12,10 +13,6 @@ export type ChannelRouteTarget = { const CHANNELS_CONFIG_META_KEYS = new Set(["defaults", "modelByChannel"]); -function hasRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - function normalizeConfiguredChannelKey(raw?: string | null): string { return normalizeChatChannelId(raw) ?? normalizeLowercaseStringOrEmpty(raw); } diff --git a/src/secrets/channel-env-vars.ts b/src/secrets/channel-env-vars.ts index 0ca3f696e54..0fb23271173 100644 --- a/src/secrets/channel-env-vars.ts +++ b/src/secrets/channel-env-vars.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; export { isSafeChannelEnvVarTriggerName } from "./channel-env-var-names.js"; type ChannelEnvVarLookupParams = { @@ -58,5 +59,5 @@ export function getChannelEnvVars(channelId: string, params?: ChannelEnvVarLooku } export function listKnownChannelEnvVarNames(params?: ChannelEnvVarLookupParams): string[] { - return [...new Set(Object.values(resolveChannelEnvVars(params)).flatMap((keys) => keys))]; + return uniqueStrings(Object.values(resolveChannelEnvVars(params)).flatMap((keys) => keys)); } diff --git a/src/secrets/json-pointer.ts b/src/secrets/json-pointer.ts index bb088d71bf8..7b1ddb73f6e 100644 --- a/src/secrets/json-pointer.ts +++ b/src/secrets/json-pointer.ts @@ -1,3 +1,5 @@ +import { isRecord as isJsonObject } from "../shared/record-coerce.js"; + function failOrUndefined(params: { onMissing: "throw" | "undefined"; message: string }): undefined { if (params.onMissing === "throw") { throw new Error(params.message); @@ -5,10 +7,6 @@ function failOrUndefined(params: { onMissing: "throw" | "undefined"; message: st return undefined; } -function isJsonObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function decodeJsonPointerToken(token: string): string { return token.replace(/~1/g, "/").replace(/~0/g, "~"); } diff --git a/src/secrets/plan.ts b/src/secrets/plan.ts index 6a4125f3707..5708480d51b 100644 --- a/src/secrets/plan.ts +++ b/src/secrets/plan.ts @@ -1,5 +1,7 @@ import type { SecretProviderConfig, SecretRef } from "../config/types.secrets.js"; import { SecretProviderSchema } from "../config/zod-schema.core.js"; +import { isRecord as isObjectRecord } from "../shared/record-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { isValidExecSecretRefId, isValidSecretProviderAlias } from "./ref-contract.js"; import { parseDotPath, toDotPath } from "./shared.js"; import { resolvePlanTargetAgainstRegistry, type ResolvedPlanTarget } from "./target-registry.js"; @@ -56,10 +58,6 @@ export type SecretsApplyPlan = { const FORBIDDEN_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]); -function isObjectRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function isSecretProviderConfigShape(value: unknown): value is SecretProviderConfig { return SecretProviderSchema.safeParse(value).success; } @@ -86,7 +84,7 @@ export function resolveValidatedPlanTarget(candidate: { } const segments = Array.isArray(candidate.pathSegments) && candidate.pathSegments.length > 0 - ? candidate.pathSegments.map((segment) => segment.trim()).filter(Boolean) + ? normalizeStringEntries(candidate.pathSegments) : parseDotPath(path); if (segments.length === 0 || hasForbiddenPathSegment(segments) || path !== toDotPath(segments)) { return null; diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index 8d657140873..52ca1ec40f3 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -13,6 +13,7 @@ import { type PluginMetadataSnapshot, } from "../plugins/plugin-metadata-snapshot.js"; import { hasKind } from "../plugins/slots.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; const CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], @@ -333,16 +334,14 @@ export function getProviderEnvVars( // OPENCLAW_API_KEY authenticates the local OpenClaw bridge itself and must // remain available to child bridge/runtime processes. export function listKnownProviderAuthEnvVarNames(params?: ProviderEnvVarLookupParams): string[] { - return [ - ...new Set([ - ...Object.values(resolveProviderAuthEnvVarCandidates(params)).flatMap((keys) => keys), - ...Object.values(resolveProviderEnvVars(params)).flatMap((keys) => keys), - ]), - ]; + return uniqueStrings([ + ...Object.values(resolveProviderAuthEnvVarCandidates(params)).flatMap((keys) => keys), + ...Object.values(resolveProviderEnvVars(params)).flatMap((keys) => keys), + ]); } export function listKnownSecretEnvVarNames(params?: ProviderEnvVarLookupParams): string[] { - return [...new Set(Object.values(resolveProviderEnvVars(params)).flatMap((keys) => keys))]; + return uniqueStrings(Object.values(resolveProviderEnvVars(params)).flatMap((keys) => keys)); } export function omitEnvKeysCaseInsensitive( diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index f97fe9f6ff0..4f530eb1c1f 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -13,6 +13,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import { FsSafeError, readSecureFile } from "../infra/fs-safe.js"; import { inspectPathPermissions, safeStat } from "../security/audit-fs.js"; import { isPathInside } from "../security/scan-paths.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { resolveUserPath } from "../utils.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { readJsonPointer } from "./json-pointer.js"; @@ -636,7 +637,7 @@ async function resolveExecRefs(params: { env: NodeJS.ProcessEnv; limits: ResolutionLimits; }): Promise { - const ids = [...new Set(params.refs.map((ref) => ref.id))]; + const ids = uniqueStrings(params.refs.map((ref) => ref.id)); if (ids.length > params.limits.maxRefsPerProvider) { throw providerResolutionError({ source: "exec", diff --git a/src/secrets/runtime-command-secrets.ts b/src/secrets/runtime-command-secrets.ts index 222b17a9a4a..678e73669dd 100644 --- a/src/secrets/runtime-command-secrets.ts +++ b/src/secrets/runtime-command-secrets.ts @@ -3,6 +3,7 @@ import { resolveSecretInputRef } from "../config/types.secrets.js"; import { resolveManifestContractOwnerPluginId } from "../plugins/plugin-registry.js"; import { resolveBundledExplicitWebSearchProvidersFromPublicArtifacts } from "../plugins/web-provider-public-artifacts.explicit.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { analyzeCommandSecretAssignmentsFromSnapshot, collectCommandSecretAssignmentsFromSnapshot, @@ -516,7 +517,7 @@ async function resolveCommandSecretsFromSnapshot(params: { ) .map((entry) => entry.path); if (impliedInactivePaths.length > 0) { - inactiveRefPaths = [...new Set([...inactiveRefPaths, ...impliedInactivePaths])]; + inactiveRefPaths = uniqueStrings([...inactiveRefPaths, ...impliedInactivePaths]); analyzed = analyzeCommandSecretAssignmentsFromSnapshot({ sourceConfig, resolvedConfig, @@ -530,7 +531,7 @@ async function resolveCommandSecretsFromSnapshot(params: { .filter((entry) => params.optionalActivePaths?.has(entry.path)) .map((entry) => entry.path); if (optionalActiveUnresolvedPaths.length > 0) { - inactiveRefPaths = [...new Set([...inactiveRefPaths, ...optionalActiveUnresolvedPaths])]; + inactiveRefPaths = uniqueStrings([...inactiveRefPaths, ...optionalActiveUnresolvedPaths]); analyzed = analyzeCommandSecretAssignmentsFromSnapshot({ sourceConfig, resolvedConfig, diff --git a/src/secrets/runtime-config-collectors-plugins.ts b/src/secrets/runtime-config-collectors-plugins.ts index 2da2d46870d..87822380894 100644 --- a/src/secrets/runtime-config-collectors-plugins.ts +++ b/src/secrets/runtime-config-collectors-plugins.ts @@ -6,6 +6,7 @@ import { } from "../plugins/config-contracts.js"; import { normalizePluginsConfig, resolveEnableState } from "../plugins/config-state.js"; import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { collectSecretInputAssignment, type ResolverContext, @@ -161,11 +162,7 @@ function createPluginConfigAssignmentApply( relativePath: string, ): (value: unknown) => void { return (value) => { - const segments = relativePath - .replace(/\[(\d+)\]/g, ".$1") - .split(".") - .map((segment) => segment.trim()) - .filter(Boolean); + const segments = normalizeStringEntries(relativePath.replace(/\[(\d+)\]/g, ".$1").split(".")); if (segments.length === 0) { return; } diff --git a/src/secrets/runtime-fast-path.ts b/src/secrets/runtime-fast-path.ts index bb57b8e1363..f1a37efd0a0 100644 --- a/src/secrets/runtime-fast-path.ts +++ b/src/secrets/runtime-fast-path.ts @@ -15,6 +15,7 @@ import { resolveOAuthPath } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { coerceSecretRef } from "../config/types.secrets.js"; import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { resolveUserPath } from "../utils.js"; import type { PreparedSecretsRuntimeSnapshot, @@ -71,7 +72,7 @@ export function resolveRefreshAgentDirs( if (!context.explicitAgentDirs || context.explicitAgentDirs.length === 0) { return configDerived; } - return [...new Set([...context.explicitAgentDirs, ...configDerived])]; + return uniqueStrings([...context.explicitAgentDirs, ...configDerived]); } function resolveCandidateAgentDirs(params: { @@ -80,7 +81,7 @@ function resolveCandidateAgentDirs(params: { agentDirs?: string[]; }): string[] { return params.agentDirs?.length - ? [...new Set(params.agentDirs.map((entry) => resolveUserPath(entry, params.env)))] + ? uniqueStrings(params.agentDirs.map((entry) => resolveUserPath(entry, params.env))) : collectCandidateAgentDirs(params.config, params.env); } diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 05966baa8c7..ace4c687fbd 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -15,6 +15,7 @@ import { import { sortWebSearchProvidersForAutoDetect } from "../plugins/web-search-providers.shared.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { sortUniqueStrings } from "../shared/string-normalization.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { secretRefKey } from "./ref-contract.js"; import { resolveSecretRefValues } from "./resolve.js"; @@ -380,7 +381,7 @@ async function resolveBundledWebSearchProviders(params: { params.configuredBundledPluginId !== undefined ? [params.configuredBundledPluginId] : params.onlyPluginIds && params.onlyPluginIds.length > 0 - ? [...new Set(params.onlyPluginIds)].toSorted((left, right) => left.localeCompare(right)) + ? sortUniqueStrings(params.onlyPluginIds) : undefined; if (onlyPluginIds && onlyPluginIds.length > 0) { const bundled = resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ onlyPluginIds }); diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index 7a18f7a3ef2..ef8590654f4 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -8,6 +8,7 @@ import { import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { resolveUserPath } from "../utils.js"; import { canUseSecretsRuntimeFastPath, @@ -104,7 +105,7 @@ export async function prepareSecretsRuntimeSnapshot(params: { let authStores: Array<{ agentDir: string; store: AuthProfileStore }> = []; const fastPathLoadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreWithoutExternalProfiles; const candidateDirs = params.agentDirs?.length - ? [...new Set(params.agentDirs.map((entry) => resolveUserPath(entry, runtimeEnv)))] + ? uniqueStrings(params.agentDirs.map((entry) => resolveUserPath(entry, runtimeEnv))) : collectCandidateAgentDirs(resolvedConfig, runtimeEnv); if (includeAuthStoreRefs) { for (const agentDir of candidateDirs) { diff --git a/src/secrets/storage-scan.ts b/src/secrets/storage-scan.ts index 38e6624dac8..6d1d46bab9d 100644 --- a/src/secrets/storage-scan.ts +++ b/src/secrets/storage-scan.ts @@ -3,14 +3,11 @@ import path from "node:path"; import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { isRecord as isJsonObject } from "../shared/record-coerce.js"; import { resolveUserPath } from "../utils.js"; import { listAuthProfileStorePaths as listAuthProfileStorePathsFromAuthStorePaths } from "./auth-store-paths.js"; import { parseEnvValue } from "./shared.js"; -function isJsonObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - export function parseEnvAssignmentValue(raw: string): string { return parseEnvValue(raw); } diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index 9c66a6f1a29..812498af1bc 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -11,6 +11,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.types.js"; function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity { @@ -269,7 +270,7 @@ export async function collectChannelSecurityFindings(params: { cfg: sourceConfig, accountIds, }); - const orderedAccountIds = Array.from(new Set([defaultAccountId, ...accountIds])); + const orderedAccountIds = uniqueStrings([defaultAccountId, ...accountIds]); for (const accountId of orderedAccountIds) { const hasExplicitAccountPath = hasExplicitProviderAccountConfig( diff --git a/src/security/audit-extra.async.ts b/src/security/audit-extra.async.ts index f522be4baf2..3fde222ea71 100644 --- a/src/security/audit-extra.async.ts +++ b/src/security/audit-extra.async.ts @@ -15,6 +15,11 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../shared/string-coerce.js"; +import { + normalizeStringEntries, + normalizeTrimmedStringList, + uniqueStrings, +} from "../shared/string-normalization.js"; import { shouldIgnoreInstalledPluginDirName } from "./installed-plugin-dirs.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "./scan-paths.js"; import type { SkillScanFinding } from "./skill-scanner.js"; @@ -189,7 +194,7 @@ async function readPluginManifestExtensions(pluginPath: string): Promise normalizeOptionalString(entry) ?? "").filter(Boolean); + return normalizeTrimmedStringList(extensions); } function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string): string { @@ -231,7 +236,7 @@ function buildCodeSafetySummaryCacheKey(params: { dirPath: string; includeFiles?: string[]; }): string { - const includeFiles = (params.includeFiles ?? []).map((entry) => entry.trim()).filter(Boolean); + const includeFiles = normalizeStringEntries(params.includeFiles); const includeKey = includeFiles.length > 0 ? includeFiles.toSorted().join("\u0000") : ""; return `${params.dirPath}\u0000${includeKey}`; } @@ -337,11 +342,7 @@ async function listSandboxBrowserContainers(params: { if (result.code !== 0) { return null; } - return result.stdout - .toString("utf8") - .split(/\r?\n/) - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries(result.stdout.toString("utf8").split(/\r?\n/)); } catch (err) { if (isDockerProbeTimeoutError(err)) { params.onTimeout?.(); @@ -424,11 +425,7 @@ async function readSandboxBrowserPortMappings(params: { if (result.code !== 0) { return null; } - return result.stdout - .toString("utf8") - .split(/\r?\n/) - .map((entry) => entry.trim()) - .filter(Boolean); + return normalizeStringEntries(result.stdout.toString("utf8").split(/\r?\n/)); } catch (err) { if (isDockerProbeTimeoutError(err)) { params.onTimeout?.(); @@ -706,7 +703,7 @@ export async function collectStateDeepFilesystemFindings(params: { : []; const { resolveDefaultAgentId } = await loadAgentScopeModule(); const defaultAgentId = resolveDefaultAgentId(params.cfg); - const ids = Array.from(new Set([defaultAgentId, ...agentIds])).map((id) => normalizeAgentId(id)); + const ids = uniqueStrings([defaultAgentId, ...agentIds]).map((id) => normalizeAgentId(id)); for (const agentId of ids) { const agentDir = path.join(params.stateDir, "agents", agentId, "agent"); diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index 46dd90227c5..6ef894d93a9 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -21,6 +21,7 @@ import { normalizeOptionalString, normalizeStringifiedOptionalString, } from "../shared/string-coerce.js"; +import { normalizeUniqueStringEntries } from "../shared/string-normalization.js"; import { collectAuditModelRefs } from "./audit-model-refs.js"; import { pickSandboxToolPolicy } from "./audit-tool-policy.js"; @@ -993,7 +994,7 @@ export function collectNodeDangerousAllowCommandFindings( return findings; } - const allow = new Set(allowRaw.map(normalizeNodeCommand).filter(Boolean)); + const allow = new Set(normalizeUniqueStringEntries(allowRaw.map(normalizeNodeCommand))); if (allow.size === 0) { return findings; } diff --git a/src/security/audit-gateway-config.ts b/src/security/audit-gateway-config.ts index df5f67174ea..ad92894cccc 100644 --- a/src/security/audit-gateway-config.ts +++ b/src/security/audit-gateway-config.ts @@ -8,6 +8,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { SecurityAuditFinding } from "./audit.types.js"; import { collectCoreInsecureOrDangerousFlags } from "./core-dangerous-config-flags.js"; import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.js"; @@ -40,9 +41,9 @@ export function collectGatewayConfigFindings( env, }); const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false; - const controlUiAllowedOrigins = (cfg.gateway?.controlUi?.allowedOrigins ?? []) - .map((value) => value.trim()) - .filter(Boolean); + const controlUiAllowedOrigins = normalizeStringEntries( + cfg.gateway?.controlUi?.allowedOrigins ?? [], + ); const dangerouslyAllowHostHeaderOriginFallback = cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true; const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies) diff --git a/src/security/audit.ts b/src/security/audit.ts index b514b8e3249..3a6735c3b07 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -26,6 +26,7 @@ import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import { asNullableRecord } from "../shared/record-coerce.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { collectDeepCodeSafetyFindings } from "./audit-deep-code-safety.js"; import { collectDeepProbeFindings } from "./audit-deep-probe-findings.js"; import { @@ -283,7 +284,7 @@ function normalizeAllowFromList(list: Array | undefined | null) if (!Array.isArray(list)) { return []; } - return list.map((v) => String(v).trim()).filter(Boolean); + return normalizeStringEntries(list); } export async function collectFilesystemFindings(params: { diff --git a/src/security/channel-metadata.ts b/src/security/channel-metadata.ts index 83372eff709..90ec962b713 100644 --- a/src/security/channel-metadata.ts +++ b/src/security/channel-metadata.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "../shared/string-normalization.js"; import { wrapExternalContent } from "./external-content.js"; const DEFAULT_MAX_CHARS = 800; @@ -28,7 +29,7 @@ export function buildUntrustedChannelMetadata(params: { .map((entry) => (typeof entry === "string" ? normalizeEntry(entry) : "")) .filter((entry) => Boolean(entry)) .map((entry) => truncateText(entry, DEFAULT_MAX_ENTRY_CHARS)); - const deduped = cleaned.filter((entry, index, list) => list.indexOf(entry) === index); + const deduped = uniqueStrings(cleaned); if (deduped.length === 0) { return undefined; } diff --git a/src/shared/device-auth-store.ts b/src/shared/device-auth-store.ts index 0eaf5e3dfbd..257c82b13e0 100644 --- a/src/shared/device-auth-store.ts +++ b/src/shared/device-auth-store.ts @@ -4,6 +4,7 @@ import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes, } from "./device-auth.js"; +import { isRecord } from "./record-coerce.js"; export type { DeviceAuthEntry, DeviceAuthStore } from "./device-auth.js"; export type DeviceAuthStoreAdapter = { @@ -11,10 +12,6 @@ export type DeviceAuthStoreAdapter = { writeStore: (store: DeviceAuthStore) => void; }; -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function coerceDeviceAuthEntry(role: string, value: unknown): DeviceAuthEntry | null { if (!isRecord(value) || typeof value.token !== "string") { return null; diff --git a/src/shared/number-coercion.test.ts b/src/shared/number-coercion.test.ts new file mode 100644 index 00000000000..b7fc58510c6 --- /dev/null +++ b/src/shared/number-coercion.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "vitest"; +import { asFiniteNumber, parseFiniteNumber } from "./number-coercion.js"; + +describe("number-coercion", () => { + test("asFiniteNumber accepts only finite numbers", () => { + expect(asFiniteNumber(4)).toBe(4); + expect(asFiniteNumber("4")).toBeUndefined(); + expect(asFiniteNumber(Number.NaN)).toBeUndefined(); + expect(asFiniteNumber(Number.POSITIVE_INFINITY)).toBeUndefined(); + }); + + test("parseFiniteNumber accepts finite numbers and numeric strings", () => { + expect(parseFiniteNumber(4)).toBe(4); + expect(parseFiniteNumber("4.5ms")).toBe(4.5); + expect(parseFiniteNumber("")).toBeUndefined(); + expect(parseFiniteNumber("nope")).toBeUndefined(); + }); +}); diff --git a/src/shared/number-coercion.ts b/src/shared/number-coercion.ts index 623aba1a519..ddf99bbf948 100644 --- a/src/shared/number-coercion.ts +++ b/src/shared/number-coercion.ts @@ -2,6 +2,16 @@ export function asFiniteNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +export function parseFiniteNumber(value: unknown): number | undefined { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseFloat(value) + : undefined; + return Number.isFinite(parsed) ? parsed : undefined; +} + export function asPositiveSafeInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; } diff --git a/src/shared/record-coerce.ts b/src/shared/record-coerce.ts index 10409602b1a..65b24290462 100644 --- a/src/shared/record-coerce.ts +++ b/src/shared/record-coerce.ts @@ -1,5 +1,5 @@ // Keep this local so browser bundles do not pull in src/utils.ts and its Node-only side effects. -function isRecord(value: unknown): value is Record { +export function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/src/shared/string-coerce.test.ts b/src/shared/string-coerce.test.ts new file mode 100644 index 00000000000..b78e02deb46 --- /dev/null +++ b/src/shared/string-coerce.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { normalizeStringifiedEntries } from "./string-coerce.js"; + +describe("shared/string-coerce", () => { + it("normalizes primitive stringified entries", () => { + expect(normalizeStringifiedEntries([" a ", 42, true, 0n, "", " ", null, {}])).toEqual([ + "a", + "42", + "true", + "0", + ]); + expect(normalizeStringifiedEntries(undefined)).toEqual([]); + }); +}); diff --git a/src/shared/string-coerce.ts b/src/shared/string-coerce.ts index 4c10d181691..cef13c22ec1 100644 --- a/src/shared/string-coerce.ts +++ b/src/shared/string-coerce.ts @@ -24,6 +24,12 @@ export function normalizeStringifiedOptionalString(value: unknown): string | und return undefined; } +export function normalizeStringifiedEntries(values?: ReadonlyArray): string[] { + return (values ?? []) + .map((entry) => normalizeStringifiedOptionalString(entry)) + .filter((entry): entry is string => Boolean(entry)); +} + export function normalizeOptionalLowercaseString(value: unknown): string | undefined { return normalizeOptionalString(value)?.toLowerCase(); } diff --git a/src/shared/string-normalization.test.ts b/src/shared/string-normalization.test.ts index d7ce98f8932..da5ae18df40 100644 --- a/src/shared/string-normalization.test.ts +++ b/src/shared/string-normalization.test.ts @@ -2,8 +2,16 @@ import { describe, expect, it } from "vitest"; import { normalizeAtHashSlug, normalizeHyphenSlug, + normalizeSortedUniqueStringEntries, + normalizeSortedUniqueTrimmedStringList, normalizeStringEntries, normalizeStringEntriesLower, + normalizeUniqueSingleOrTrimmedStringList, + normalizeUniqueStringEntries, + normalizeUniqueStringEntriesLower, + normalizeUniqueTrimmedStringList, + sortUniqueStrings, + uniqueStrings, } from "./string-normalization.js"; describe("shared/string-normalization", () => { @@ -21,6 +29,51 @@ describe("shared/string-normalization", () => { expect(normalizeStringEntriesLower([" A ", "MiXeD", 7])).toEqual(["a", "mixed", "7"]); }); + it("sorts unique string values", () => { + expect(sortUniqueStrings(["b", "a", "b"])).toEqual(["a", "b"]); + }); + + it("deduplicates string values while preserving first-seen order", () => { + expect(uniqueStrings(["b", "a", "b", "c", "a"])).toEqual(["b", "a", "c"]); + }); + + it("normalizes unique string entries", () => { + expect(normalizeUniqueStringEntries([" b ", "a", "b", "", 4, "a"])).toEqual(["b", "a", "4"]); + }); + + it("normalizes unique lowercase string entries", () => { + expect(normalizeUniqueStringEntriesLower([" A ", "a", "MiXeD", "", 7])).toEqual([ + "a", + "mixed", + "7", + ]); + }); + + it("normalizes sorted unique string entries", () => { + expect(normalizeSortedUniqueStringEntries([" b ", "a", "b", "", 4])).toEqual(["4", "a", "b"]); + }); + + it("normalizes unique trimmed string lists", () => { + expect(normalizeUniqueTrimmedStringList([" b ", "a", "b", "", "a"])).toEqual(["b", "a"]); + expect(normalizeUniqueTrimmedStringList("b")).toEqual([]); + }); + + it("normalizes sorted unique trimmed string lists", () => { + expect(normalizeSortedUniqueTrimmedStringList([" b ", "a", "b", "", "a"])).toEqual(["a", "b"]); + expect(normalizeSortedUniqueTrimmedStringList(["z", 1, " a "] as unknown[])).toEqual([ + "a", + "z", + ]); + }); + + it("normalizes unique single-or-list string values", () => { + expect(normalizeUniqueSingleOrTrimmedStringList([" b ", "a", "b", "", "a"])).toEqual([ + "b", + "a", + ]); + expect(normalizeUniqueSingleOrTrimmedStringList(" b ")).toEqual(["b"]); + }); + it("normalizes slug-like labels while preserving supported symbols", () => { expect(normalizeHyphenSlug(" Team Room ")).toBe("team-room"); expect(normalizeHyphenSlug(" #My_Channel + Alerts ")).toBe("#my_channel-+-alerts"); diff --git a/src/shared/string-normalization.ts b/src/shared/string-normalization.ts index ea09ebdf0a5..c45b485f198 100644 --- a/src/shared/string-normalization.ts +++ b/src/shared/string-normalization.ts @@ -8,6 +8,34 @@ export function normalizeStringEntriesLower(list?: ReadonlyArray) { return normalizeStringEntries(list).map((entry) => normalizeOptionalLowercaseString(entry) ?? ""); } +export function uniqueValues(values: Iterable): T[] { + return [...new Set(values)]; +} + +export function uniqueStrings(values: Iterable): string[] { + return uniqueValues(values); +} + +export function sortUniqueStrings(values: Iterable): string[] { + return uniqueStrings(values).toSorted((left, right) => + left < right ? -1 : left > right ? 1 : 0, + ); +} + +export function normalizeUniqueStringEntries(values?: Iterable): string[] { + return uniqueStrings(normalizeStringEntries(values ? [...values] : undefined)); +} + +export function normalizeUniqueStringEntriesLower(values?: Iterable): string[] { + return uniqueStrings( + normalizeStringEntriesLower(values ? [...values] : undefined).filter(Boolean), + ); +} + +export function normalizeSortedUniqueStringEntries(values?: Iterable): string[] { + return sortUniqueStrings(normalizeUniqueStringEntries(values)); +} + export function normalizeTrimmedStringList(value: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -18,6 +46,14 @@ export function normalizeTrimmedStringList(value: unknown): string[] { }); } +export function normalizeUniqueTrimmedStringList(value: unknown): string[] { + return uniqueStrings(normalizeTrimmedStringList(value)); +} + +export function normalizeSortedUniqueTrimmedStringList(value: unknown): string[] { + return sortUniqueStrings(normalizeTrimmedStringList(value)); +} + export function normalizeOptionalTrimmedStringList(value: unknown): string[] | undefined { const normalized = normalizeTrimmedStringList(value); return normalized.length > 0 ? normalized : undefined; @@ -38,6 +74,10 @@ export function normalizeSingleOrTrimmedStringList(value: unknown): string[] { return normalized ? [normalized] : []; } +export function normalizeUniqueSingleOrTrimmedStringList(value: unknown): string[] { + return uniqueStrings(normalizeSingleOrTrimmedStringList(value)); +} + export function normalizeCsvOrLooseStringList(value: unknown): string[] { if (Array.isArray(value)) { return normalizeStringEntries(value); diff --git a/src/shared/text/tool-call-shaped-text.ts b/src/shared/text/tool-call-shaped-text.ts index 2700eab6e4f..22870b3b3e1 100644 --- a/src/shared/text/tool-call-shaped-text.ts +++ b/src/shared/text/tool-call-shaped-text.ts @@ -1,3 +1,6 @@ +import { asOptionalRecord } from "../record-coerce.js"; +import { normalizeOptionalString as readTrimmedString } from "../string-coerce.js"; + export type ToolCallShapedTextDetection = { kind: "json_tool_call" | "xml_tool_call" | "bracketed_tool_call" | "react_action"; toolName?: string; @@ -9,20 +12,6 @@ const MAX_SCAN_CHARS = 20_000; const MAX_JSON_CANDIDATES = 20; const MAX_JSON_CANDIDATE_CHARS = 8_000; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readTrimmedString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function readToolName(record: Record): string | undefined { return ( readTrimmedString(record.name) ?? @@ -47,7 +36,7 @@ function classifyJsonValue(value: unknown): ToolCallShapedTextDetection | null { return null; } - const record = asRecord(value); + const record = asOptionalRecord(value); if (!record) { return null; } @@ -63,7 +52,7 @@ function classifyJsonValue(value: unknown): ToolCallShapedTextDetection | null { return { kind: "json_tool_call" }; } - const functionRecord = asRecord(record.function); + const functionRecord = asOptionalRecord(record.function); if (functionRecord) { const toolName = readToolName(functionRecord); if (toolName && hasToolArgs(functionRecord)) { diff --git a/src/talk/diagnostics.ts b/src/talk/diagnostics.ts index 17a004c5ba4..731dc75562e 100644 --- a/src/talk/diagnostics.ts +++ b/src/talk/diagnostics.ts @@ -2,12 +2,13 @@ import { emitTrustedDiagnosticEvent, type DiagnosticEventInput, } from "../infra/diagnostic-events.js"; +import { firstFiniteTalkEventNumber, talkEventPayloadRecord } from "./event-metrics.js"; import type { TalkEvent } from "./talk-events.js"; type TalkDiagnosticEventInput = Extract; export function createTalkDiagnosticEvent(event: TalkEvent): TalkDiagnosticEventInput { - const payload = asRecord(event.payload); + const payload = talkEventPayloadRecord(event.payload); return { type: "talk.event", sessionId: event.sessionId, @@ -19,33 +20,11 @@ export function createTalkDiagnosticEvent(event: TalkEvent): TalkDiagnosticEvent brain: event.brain, provider: event.provider, final: event.final, - durationMs: firstFiniteNumber(payload, ["durationMs", "latencyMs", "elapsedMs"]), - byteLength: firstFiniteNumber(payload, ["byteLength", "audioBytes"]), + durationMs: firstFiniteTalkEventNumber(payload, ["durationMs", "latencyMs", "elapsedMs"]), + byteLength: firstFiniteTalkEventNumber(payload, ["byteLength", "audioBytes"]), }; } export function recordTalkDiagnosticEvent(event: TalkEvent): void { emitTrustedDiagnosticEvent(createTalkDiagnosticEvent(event)); } - -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function firstFiniteNumber( - record: Record | undefined, - keys: readonly string[], -): number | undefined { - if (!record) { - return undefined; - } - for (const key of keys) { - const value = record[key]; - if (typeof value === "number" && Number.isFinite(value) && value >= 0) { - return value; - } - } - return undefined; -} diff --git a/src/talk/event-metrics.ts b/src/talk/event-metrics.ts new file mode 100644 index 00000000000..0d280d0074b --- /dev/null +++ b/src/talk/event-metrics.ts @@ -0,0 +1,17 @@ +export { asOptionalRecord as talkEventPayloadRecord } from "../shared/record-coerce.js"; + +export function firstFiniteTalkEventNumber( + record: Record | undefined, + keys: readonly string[], +): number | undefined { + if (!record) { + return undefined; + } + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return value; + } + } + return undefined; +} diff --git a/src/talk/logging.ts b/src/talk/logging.ts index 5de39a6026d..cc7b2bfca56 100644 --- a/src/talk/logging.ts +++ b/src/talk/logging.ts @@ -1,4 +1,5 @@ import { getChildLogger } from "../logging/logger.js"; +import { firstFiniteTalkEventNumber, talkEventPayloadRecord } from "./event-metrics.js"; import type { TalkEvent, TalkEventType } from "./talk-events.js"; type TalkLogLevel = "info" | "warn"; @@ -24,7 +25,7 @@ export function createTalkLogRecord(event: TalkEvent): TalkLogRecord | undefined return undefined; } - const payload = asRecord(event.payload); + const payload = talkEventPayloadRecord(event.payload); const attributes: Record = { sessionId: event.sessionId, talkEventType: event.type, @@ -40,11 +41,11 @@ export function createTalkLogRecord(event: TalkEvent): TalkLogRecord | undefined attributes.talkFinal = event.final; } - const durationMs = firstFiniteNumber(payload, ["durationMs", "latencyMs", "elapsedMs"]); + const durationMs = firstFiniteTalkEventNumber(payload, ["durationMs", "latencyMs", "elapsedMs"]); if (durationMs !== undefined) { attributes.talkDurationMs = durationMs; } - const byteLength = firstFiniteNumber(payload, ["byteLength", "audioBytes"]); + const byteLength = firstFiniteTalkEventNumber(payload, ["byteLength", "audioBytes"]); if (byteLength !== undefined) { attributes.talkByteLength = byteLength; } @@ -73,25 +74,3 @@ export function recordTalkLogEvent(event: TalkEvent): void { // logging must never block the realtime Talk path } } - -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function firstFiniteNumber( - record: Record | undefined, - keys: readonly string[], -): number | undefined { - if (!record) { - return undefined; - } - for (const key of keys) { - const value = record[key]; - if (typeof value === "number" && Number.isFinite(value) && value >= 0) { - return value; - } - } - return undefined; -} diff --git a/src/talk/session-log-runtime.ts b/src/talk/session-log-runtime.ts index 361eea3929f..ceb30fbcfea 100644 --- a/src/talk/session-log-runtime.ts +++ b/src/talk/session-log-runtime.ts @@ -1,3 +1,4 @@ +import { uniqueStrings } from "../shared/string-normalization.js"; import type { RealtimeVoiceBridgeEvent, RealtimeVoiceRole } from "./provider-types.js"; export type RealtimeVoiceTranscriptEntry = { @@ -92,7 +93,7 @@ function hasMeaningfulEchoOverlap(userTokens: string[], assistantTokens: string[ if (userTokens.length < 4 || assistantTokens.length < 4) { return false; } - const uniqueUserTokens = [...new Set(userTokens)]; + const uniqueUserTokens = uniqueStrings(userTokens); if (uniqueUserTokens.length < 4) { return false; } diff --git a/src/talk/talk-session-controller.ts b/src/talk/talk-session-controller.ts index bcddad03eef..8c5f9cd1835 100644 --- a/src/talk/talk-session-controller.ts +++ b/src/talk/talk-session-controller.ts @@ -1,3 +1,4 @@ +import { normalizeOptionalString } from "../shared/string-coerce.js"; import { createTalkEventSequencer, type TalkBrain, @@ -210,8 +211,3 @@ export function normalizeTalkTransport(value: string | undefined): string | unde } export type { TalkBrain, TalkEvent, TalkEventContext, TalkEventInput, TalkMode, TalkTransport }; - -function normalizeOptionalString(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} diff --git a/src/tasks/task-registry.ts b/src/tasks/task-registry.ts index 6967f5b53d0..86bb69466dc 100644 --- a/src/tasks/task-registry.ts +++ b/src/tasks/task-registry.ts @@ -9,6 +9,7 @@ import { enqueueSystemEvent } from "../infra/system-events.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.shared.js"; import { isDeliverableMessageChannel } from "../utils/message-channel.js"; import { isChildlessCodexNativeSubagentTask } from "./codex-native-subagent-task.js"; @@ -521,14 +522,11 @@ function deleteIndexedKey(index: Map>, key: string, taskId: } function getTaskRelatedSessionIndexKeys(task: Pick) { - return [ - ...new Set( - [ - normalizeOptionalString(task.ownerKey), - normalizeOptionalString(task.childSessionKey), - ].filter(Boolean) as string[], - ), - ]; + return uniqueStrings( + [normalizeOptionalString(task.ownerKey), normalizeOptionalString(task.childSessionKey)].filter( + Boolean, + ) as string[], + ); } function addOwnerKeyIndex(taskId: string, task: Pick) { diff --git a/src/test-utils/bundled-plugin-public-surface.ts b/src/test-utils/bundled-plugin-public-surface.ts index 4f3bcac23db..b226404f7ee 100644 --- a/src/test-utils/bundled-plugin-public-surface.ts +++ b/src/test-utils/bundled-plugin-public-surface.ts @@ -16,6 +16,7 @@ import { } from "../plugins/plugin-module-loader-cache.js"; import { normalizeBundledPluginArtifactSubpath } from "../plugins/public-surface-runtime.js"; import { resolveLoaderPackageRoot } from "../plugins/sdk-alias.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; const OPENCLAW_PACKAGE_ROOT = resolveLoaderPackageRoot({ @@ -46,14 +47,13 @@ function findBundledPluginMetadataFast( if (!isSafeBundledPluginDirName(pluginId)) { return undefined; } - const roots = [ + const rawRoots = [ resolveBundledPluginsDir(), path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions"), path.resolve(OPENCLAW_PACKAGE_ROOT, "dist-runtime", "extensions"), path.resolve(OPENCLAW_PACKAGE_ROOT, "dist", "extensions"), - ].filter( - (entry, index, values): entry is string => Boolean(entry) && values.indexOf(entry) === index, - ); + ].filter((entry): entry is string => Boolean(entry)); + const roots = uniqueStrings(rawRoots); for (const root of roots) { const pluginDir = path.join(root, pluginId); @@ -84,14 +84,13 @@ function readPackageName(packageDir: string): string | undefined { } function resolveWorkspacePackageDir(packageName: string): string { - const roots = [ + const rawRoots = [ resolveBundledPluginsDir(), path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions"), path.resolve(OPENCLAW_PACKAGE_ROOT, "dist-runtime", "extensions"), path.resolve(OPENCLAW_PACKAGE_ROOT, "dist", "extensions"), - ].filter( - (entry, index, values): entry is string => Boolean(entry) && values.indexOf(entry) === index, - ); + ].filter((entry): entry is string => Boolean(entry)); + const roots = uniqueStrings(rawRoots); for (const root of roots) { let entries: string[]; diff --git a/src/test-utils/openclaw-test-state.ts b/src/test-utils/openclaw-test-state.ts index 68c33497c1c..7950c5e8bf9 100644 --- a/src/test-utils/openclaw-test-state.ts +++ b/src/test-utils/openclaw-test-state.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { captureEnv } from "./env.js"; import { cleanupSessionStateForTest } from "./session-state-cleanup.js"; @@ -275,7 +276,7 @@ export async function createOpenClawTestState( extraEnv: options.env ?? {}, }); const env = createSpawnEnv(envVars); - const snapshot = captureEnv([...new Set([...ENV_KEYS, ...Object.keys(envVars)])]); + const snapshot = captureEnv(uniqueStrings([...ENV_KEYS, ...Object.keys(envVars)])); let envApplied = false; let cleaned = false; const agentDir = (agentId = "main") => path.join(paths.stateDir, "agents", agentId, "agent"); diff --git a/src/trajectory/cleanup.ts b/src/trajectory/cleanup.ts index 0be83151021..9ac15d0c9de 100644 --- a/src/trajectory/cleanup.ts +++ b/src/trajectory/cleanup.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { resolveSessionFilePath } from "../config/sessions/paths.js"; import { isPathInside } from "../infra/path-guards.js"; +import { isRecord } from "../shared/record-coerce.js"; import { resolveTrajectoryFilePath, resolveTrajectoryPointerFilePath, @@ -17,10 +18,6 @@ type TrajectoryPointer = { runtimeFile: string; }; -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - function canonicalizePathForComparison(filePath: string): string { const resolved = path.resolve(filePath); try { diff --git a/src/trajectory/export.ts b/src/trajectory/export.ts index a821170c677..53ad0988532 100644 --- a/src/trajectory/export.ts +++ b/src/trajectory/export.ts @@ -17,6 +17,7 @@ import { redactSupportString, type SupportRedactionContext, } from "../logging/diagnostic-support-redaction.js"; +import { isRecord } from "../shared/record-coerce.js"; import { safeJsonStringify } from "../utils/safe-json.js"; import { TRAJECTORY_RUNTIME_FILE_MAX_BYTES, @@ -66,10 +67,6 @@ function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function isSessionFileEntry(value: unknown): value is FileEntry { if (!isRecord(value) || typeof value.type !== "string") { return false; diff --git a/src/tts/status-config.ts b/src/tts/status-config.ts index 14141b2fd6c..5b7e102e69f 100644 --- a/src/tts/status-config.ts +++ b/src/tts/status-config.ts @@ -2,6 +2,7 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/types.js"; import type { TtsAutoMode, TtsConfig, TtsProvider } from "../config/types.tts.js"; import { tryReadJsonSync } from "../infra/json-files.js"; +import { isRecord as isObjectRecord } from "../shared/record-coerce.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -101,10 +102,6 @@ function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefin return undefined; } -function isObjectRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function normalizeStatusDetail( value: unknown, maxLength = MAX_STATUS_DETAIL_LENGTH, diff --git a/src/tts/tts-config.ts b/src/tts/tts-config.ts index 3a6d8a0dbc9..8c1e2ea87b4 100644 --- a/src/tts/tts-config.ts +++ b/src/tts/tts-config.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/types.js"; import type { TtsAutoMode, TtsConfig, TtsMode } from "../config/types.tts.js"; import { normalizeAccountId, normalizeAgentId } from "../routing/session-key.js"; +import { isRecord as isPlainObject } from "../shared/record-coerce.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -19,10 +20,6 @@ export type TtsConfigResolutionContext = { accountId?: string; }; -function isPlainObject(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - function deepMergeDefined(base: unknown, override: unknown): unknown { if (!isPlainObject(base) || !isPlainObject(override)) { return override === undefined ? base : override; diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index 0c2ecb61466..84aebac1e5e 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -8,6 +8,7 @@ import { truncateToWidth, } from "@earendil-works/pi-tui"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; +import { uniqueStrings } from "../../shared/string-normalization.js"; import { stripAnsi, visibleWidth } from "../../terminal/ansi.js"; import { findWordBoundaryIndex, fuzzyFilterLower } from "./fuzzy-filter.js"; @@ -179,7 +180,7 @@ export class SearchableSelectList implements Component { return text; } - const uniqueTokens = Array.from(new Set(tokens)).toSorted((a, b) => b.length - a.length); + const uniqueTokens = uniqueStrings(tokens).toSorted((a, b) => b.length - a.length); let parts = this.splitAnsiParts(text); for (const token of uniqueTokens) { const regex = this.getCachedRegex(token); diff --git a/src/utils/boolean.ts b/src/utils/boolean.ts index bd420230c71..0578dc048bc 100644 --- a/src/utils/boolean.ts +++ b/src/utils/boolean.ts @@ -10,12 +10,17 @@ const DEFAULT_FALSY = ["false", "0", "no", "off"] as const; const DEFAULT_TRUTHY_SET = new Set(DEFAULT_TRUTHY); const DEFAULT_FALSY_SET = new Set(DEFAULT_FALSY); +export function asBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + export function parseBooleanValue( value: unknown, options: BooleanParseOptions = {}, ): boolean | undefined { - if (typeof value === "boolean") { - return value; + const booleanValue = asBoolean(value); + if (booleanValue !== undefined) { + return booleanValue; } if (typeof value !== "string") { return undefined; diff --git a/src/utils/message-channel-normalize.ts b/src/utils/message-channel-normalize.ts index 6dcd844a4c7..2f3c253b9d3 100644 --- a/src/utils/message-channel-normalize.ts +++ b/src/utils/message-channel-normalize.ts @@ -1,5 +1,6 @@ import { CHANNEL_IDS } from "../channels/ids.js"; import { listRegisteredChannelPluginIds } from "../channels/registry.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { INTERNAL_MESSAGE_CHANNEL, type InternalMessageChannel, @@ -21,7 +22,7 @@ const listPluginChannelIds = (): string[] => { }; export const listDeliverableMessageChannels = (): ChannelId[] => - Array.from(new Set([...CHANNEL_IDS, ...listPluginChannelIds()])); + uniqueStrings([...CHANNEL_IDS, ...listPluginChannelIds()]) as ChannelId[]; const listGatewayMessageChannels = (): GatewayMessageChannel[] => [ ...listDeliverableMessageChannels(), diff --git a/src/utils/utils-misc.test.ts b/src/utils/utils-misc.test.ts index 841d80c770c..8df314e2b27 100644 --- a/src/utils/utils-misc.test.ts +++ b/src/utils/utils-misc.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from "vitest"; -import { parseBooleanValue } from "./boolean.js"; +import { asBoolean, parseBooleanValue } from "./boolean.js"; import { splitShellArgs } from "./shell-argv.js"; +describe("asBoolean", () => { + it("accepts booleans only", () => { + expect(asBoolean(true)).toBe(true); + expect(asBoolean(false)).toBe(false); + expect(asBoolean("true")).toBeUndefined(); + expect(asBoolean(1)).toBeUndefined(); + }); +}); + describe("parseBooleanValue", () => { it("handles boolean inputs", () => { expect(parseBooleanValue(true)).toBe(true); diff --git a/src/video-generation/dashscope-compatible.ts b/src/video-generation/dashscope-compatible.ts index 29ddf2ac746..fe49ad8c1db 100644 --- a/src/video-generation/dashscope-compatible.ts +++ b/src/video-generation/dashscope-compatible.ts @@ -10,6 +10,7 @@ import { type ProviderOperationTimeoutMs, } from "openclaw/plugin-sdk/provider-http"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import type { GeneratedVideoAsset, VideoGenerationProviderCapabilities, @@ -159,7 +160,7 @@ export function extractDashscopeVideoUrls(payload: DashscopeVideoGenerationRespo ...(payload.output?.results?.map((entry) => entry.video_url).filter(Boolean) ?? []), payload.output?.video_url, ].filter((value): value is string => typeof value === "string" && value.trim().length > 0); - return [...new Set(urls)]; + return uniqueStrings(urls); } export async function pollDashscopeVideoTaskUntilComplete(params: { diff --git a/src/video-generation/duration-support.ts b/src/video-generation/duration-support.ts index 6bdae9774ae..40b50452ee9 100644 --- a/src/video-generation/duration-support.ts +++ b/src/video-generation/duration-support.ts @@ -1,3 +1,4 @@ +import { uniqueValues } from "../shared/string-normalization.js"; import { resolveVideoGenerationModeCapabilities } from "./capabilities.js"; import type { VideoGenerationProvider } from "./types.js"; @@ -7,7 +8,7 @@ function normalizeSupportedDurationValues( if (!Array.isArray(values) || values.length === 0) { return undefined; } - const normalized = [...new Set(values)] + const normalized = uniqueValues(values) .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.round(value)) .filter((value) => value > 0) diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index a7c9be7ba5c..cf99b15f0fa 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -23,6 +23,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; +import { uniqueStrings } from "../shared/string-normalization.js"; import { hasWebProviderEntryCredential, providerRequiresCredential, @@ -364,13 +365,13 @@ function resolveWebSearchCandidates( return []; } - const preferredIds = [ - options?.providerId, - runtimeWebSearch?.selectedProvider, - runtimeWebSearch?.providerConfigured, - resolveWebSearchProviderId({ config, agentDir: options?.agentDir, search, providers }), - ].filter( - (value, index, array): value is string => Boolean(value) && array.indexOf(value) === index, + const preferredIds = uniqueStrings( + [ + options?.providerId, + runtimeWebSearch?.selectedProvider, + runtimeWebSearch?.providerConfigured, + resolveWebSearchProviderId({ config, agentDir: options?.agentDir, search, providers }), + ].filter((value): value is string => Boolean(value)), ); const explicitProviderId = options?.providerId?.trim(); diff --git a/src/wizard/setup.plugin-config.ts b/src/wizard/setup.plugin-config.ts index c52d0ea76bf..71149615b6a 100644 --- a/src/wizard/setup.plugin-config.ts +++ b/src/wizard/setup.plugin-config.ts @@ -3,6 +3,7 @@ import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import type { PluginConfigUiHint } from "../plugins/types.js"; import { getPath, setPathCreateStrict } from "../secrets/path-utils.js"; import type { JsonSchemaObject } from "../shared/json-schema.types.js"; +import { normalizeStringEntries } from "../shared/string-normalization.js"; import { t } from "./i18n/index.js"; import type { WizardPrompter } from "./prompts.js"; @@ -251,10 +252,7 @@ async function promptPluginFields(params: { const trimmed = input.trim(); if (trimmed !== currentStr) { if (trimmed) { - const values = trimmed - .split(",") - .map((v) => v.trim()) - .filter(Boolean); + const values = normalizeStringEntries(trimmed.split(",")); setPathCreateStrict(updatedConfig, pathSegments, values); } else { setPathCreateStrict(updatedConfig, pathSegments, undefined); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 69c906b8ed3..8a0b3dee0d1 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -145,8 +145,8 @@ import { type Tab, } from "./navigation.ts"; import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts"; -import "./components/dashboard-header.ts"; import { isCronSessionKey, resolveSessionDisplayName } from "./session-display.ts"; +import "./components/dashboard-header.ts"; import { buildAgentMainSessionKey, isSubagentSessionKey, @@ -154,6 +154,7 @@ import { resolveAgentIdFromSessionKey, } from "./session-key.ts"; import { loadLocalAssistantIdentity } from "./storage.ts"; +import { normalizeStringEntries } from "./string-coerce.ts"; import { normalizeOptionalString } from "./string-coerce.ts"; import type { GatewaySessionRow } from "./types.ts"; import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts"; @@ -2497,7 +2498,7 @@ export function renderApp(state: AppViewState) { state.agentSkillsReport?.skills?.map((skill) => skill.name).filter(Boolean) ?? []; const existing = Array.isArray(entry?.skills) - ? entry.skills.map((name) => String(name).trim()).filter(Boolean) + ? normalizeStringEntries(entry.skills) : undefined; const base = existing ?? allSkills; const next = new Set(base); @@ -2546,7 +2547,7 @@ export function renderApp(state: AppViewState) { void refreshVisibleToolsEffectiveForCurrentSession(state); }, onModelFallbacksChange: (agentId, fallbacks) => { - const normalized = fallbacks.map((name) => name.trim()).filter(Boolean); + const normalized = normalizeStringEntries(fallbacks); const currentConfig = getCurrentConfigValue(); const resolvedConfig = resolveAgentConfig(currentConfig, agentId); const effectivePrimary = diff --git a/ui/src/ui/chat/message-extract.ts b/ui/src/ui/chat/message-extract.ts index a233a0d7c6e..bbb88cd7087 100644 --- a/ui/src/ui/chat/message-extract.ts +++ b/ui/src/ui/chat/message-extract.ts @@ -2,7 +2,7 @@ import { stripInternalRuntimeContext } from "../../../../src/agents/internal-run import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js"; import { stripEnvelope } from "../../../../src/shared/chat-envelope.js"; import { extractAssistantVisibleText as extractSharedAssistantVisibleText } from "../../../../src/shared/chat-message-content.js"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; +import { normalizeLowercaseStringOrEmpty, normalizeStringEntries } from "../string-coerce.ts"; import { stripThinkingTags } from "../strip-thinking-tags.ts"; const textCache = new WeakMap(); @@ -70,7 +70,7 @@ export function extractThinking(message: unknown): string | null { const matches = [ ...rawText.matchAll(/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi), ]; - const extracted = matches.map((m) => (m[1] ?? "").trim()).filter(Boolean); + const extracted = normalizeStringEntries(matches.map((m) => m[1] ?? "")); return extracted.length > 0 ? extracted.join("\n") : null; } diff --git a/ui/src/ui/chat/slash-commands.browser-import.test.ts b/ui/src/ui/chat/slash-commands.browser-import.test.ts index d53c3ac6ea2..8da1b47a13d 100644 --- a/ui/src/ui/chat/slash-commands.browser-import.test.ts +++ b/ui/src/ui/chat/slash-commands.browser-import.test.ts @@ -64,6 +64,7 @@ describe("slash command browser import", () => { ]); expect(importLines(sharedRegistry)).toEqual([ 'import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";', + 'import { normalizeStringEntries } from "../shared/string-normalization.js";', 'import { COMMAND_ARG_FORMATTERS } from "./commands-args.js";', "import type {", 'import { BASE_THINKING_LEVELS, type ThinkLevel } from "./thinking.shared.js";', diff --git a/ui/src/ui/control-ui-auth.ts b/ui/src/ui/control-ui-auth.ts index cb6d356627c..dc95bfe2936 100644 --- a/ui/src/ui/control-ui-auth.ts +++ b/ui/src/ui/control-ui-auth.ts @@ -1,4 +1,4 @@ -import { normalizeOptionalString } from "./string-coerce.ts"; +import { normalizeOptionalString, uniqueStrings } from "./string-coerce.ts"; type ControlUiAuthSource = { hello?: { auth?: { deviceToken?: string | null } | null } | null; @@ -39,18 +39,11 @@ export function resolveControlUiAuthHeader(source: ControlUiAuthSource): string // when the first returns 401 — for example, recovering from a stale // `settings.token` when the live session is authenticated via `password`. export function resolveControlUiAuthCandidates(source: ControlUiAuthSource): string[] { - const seen = new Set(); - const out: string[] = []; - for (const raw of [ - normalizeOptionalString(source.hello?.auth?.deviceToken), - normalizeOptionalString(source.settings?.token), - normalizeOptionalString(source.password), - ]) { - const sanitized = sanitizeHeaderToken(raw ?? null); - if (sanitized && !seen.has(sanitized)) { - seen.add(sanitized); - out.push(sanitized); - } - } - return out; + return uniqueStrings( + [ + normalizeOptionalString(source.hello?.auth?.deviceToken), + normalizeOptionalString(source.settings?.token), + normalizeOptionalString(source.password), + ].flatMap((raw) => sanitizeHeaderToken(raw ?? null) ?? []), + ); } diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 7f8aa77beca..acf37ce467b 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -3,7 +3,7 @@ import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; import { getCronJobPayload, hasCronJobPayload } from "../cron-payload.ts"; import { toNumber } from "../format.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; +import { normalizeLowercaseStringOrEmpty, sortUniqueStrings } from "../string-coerce.ts"; import type { CronJob, CronDeliveryStatus, @@ -221,7 +221,7 @@ export async function loadCronModelSuggestions(state: CronModelSuggestionsState) return typeof id === "string" ? id.trim() : ""; }) .filter(Boolean); - state.cronModelSuggestions = Array.from(new Set(ids)).toSorted((a, b) => a.localeCompare(b)); + state.cronModelSuggestions = sortUniqueStrings(ids); } catch { state.cronModelSuggestions = []; } diff --git a/ui/src/ui/string-coerce.ts b/ui/src/ui/string-coerce.ts index 38be33e69c0..44370438ad3 100644 --- a/ui/src/ui/string-coerce.ts +++ b/ui/src/ui/string-coerce.ts @@ -4,3 +4,8 @@ export { normalizeOptionalString, normalizeStringifiedOptionalString, } from "../../../src/shared/string-coerce.js"; +export { + normalizeStringEntries, + sortUniqueStrings, + uniqueStrings, +} from "../../../src/shared/string-normalization.js"; diff --git a/ui/src/ui/views/activity.ts b/ui/src/ui/views/activity.ts index c9d611d0143..65be2b4c69b 100644 --- a/ui/src/ui/views/activity.ts +++ b/ui/src/ui/views/activity.ts @@ -2,7 +2,7 @@ import { html, nothing } from "lit"; import { t } from "../../i18n/index.ts"; import type { ActivityEntry, ActivityStatus } from "../activity-model.ts"; import { icons } from "../icons.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; +import { normalizeLowercaseStringOrEmpty, sortUniqueStrings } from "../string-coerce.ts"; const STATUS_ORDER: ActivityStatus[] = ["running", "done", "error"]; @@ -96,9 +96,7 @@ function matchesEntry(entry: ActivityEntry, needle: string): boolean { } function resolveToolNames(entries: ActivityEntry[]): string[] { - return Array.from(new Set(entries.map((entry) => entry.toolName))).toSorted((a, b) => - a.localeCompare(b), - ); + return sortUniqueStrings(entries.map((entry) => entry.toolName)); } function filterEntries(props: ActivityProps): ActivityEntry[] { diff --git a/ui/src/ui/views/agents-panels-tools-skills.ts b/ui/src/ui/views/agents-panels-tools-skills.ts index f6404bdb7ae..45b139c9b45 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.ts @@ -1,7 +1,7 @@ import { html, nothing } from "lit"; import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js"; import { t } from "../../i18n/index.ts"; -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; +import { normalizeLowercaseStringOrEmpty, normalizeStringEntries } from "../string-coerce.ts"; import type { SkillStatusEntry, SkillStatusReport, @@ -684,7 +684,7 @@ export function renderAgentSkills(params: { const editable = Boolean(params.configForm) && !params.configLoading && !params.configSaving; const config = resolveAgentConfig(params.configForm, params.agentId); const allowlist = Array.isArray(config.entry?.skills) ? config.entry?.skills : undefined; - const allowSet = new Set((allowlist ?? []).map((name) => name.trim()).filter(Boolean)); + const allowSet = new Set(normalizeStringEntries(allowlist ?? [])); const usingAllowlist = allowlist !== undefined; const reportReady = Boolean(params.report && params.activeAgentId === params.agentId); const rawSkills = reportReady ? (params.report?.skills ?? []) : []; diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index 8a29384b939..b7c7cea8d5d 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -13,6 +13,7 @@ import { formatRelativeTimestamp, formatMs } from "../format.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts"; import { pathForTab } from "../navigation.ts"; import { formatCronSchedule, formatNextRun } from "../presenter.ts"; +import { normalizeStringEntries, uniqueStrings } from "../string-coerce.ts"; import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types.ts"; import type { CronDeliveryStatus, @@ -212,7 +213,7 @@ function renderRunFilterDropdown(params: { } function renderSuggestionList(id: string, options: string[]) { - const clean = Array.from(new Set(options.map((option) => option.trim()).filter(Boolean))); + const clean = uniqueStrings(normalizeStringEntries(options)); if (clean.length === 0) { return nothing; } diff --git a/ui/src/ui/views/usage-query.ts b/ui/src/ui/views/usage-query.ts index d9696ac2c8c..1a3e9f97642 100644 --- a/ui/src/ui/views/usage-query.ts +++ b/ui/src/ui/views/usage-query.ts @@ -1,4 +1,4 @@ -import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts"; +import { normalizeLowercaseStringOrEmpty, uniqueStrings } from "../string-coerce.ts"; import { extractQueryTerms } from "../usage-helpers.ts"; import type { CostDailyEntry, UsageAggregates, UsageSessionEntry } from "./usageTypes.ts"; @@ -143,13 +143,7 @@ const buildQuerySuggestions = ( const value = normalizeLowercaseStringOrEmpty(rawValue); const unique = (items: Array): string[] => { - const set = new Set(); - for (const item of items) { - if (item) { - set.add(item); - } - } - return Array.from(set); + return uniqueStrings(items.filter((item): item is string => Boolean(item))); }; const agents = unique(sessions.map((s) => s.agentId)).slice(0, 6);