From fa03d9b9134340aa4c7147e136c40df7d90740c4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 00:02:18 -0700 Subject: [PATCH] refactor: consolidate coercion helpers (#121366) * refactor: consolidate coercion helpers * fix: remove duplicate coercion imports * fix: preserve serialized coercion guard * chore: ratchet coercion helper carve-outs * fix(test): keep gauntlet subprocess startup lean * fix: preserve imported session timestamp semantics * fix: preserve catalog timestamp string semantics * chore: align plugin SDK surface ratchet * fix: preserve trajectory and SDK string contracts * fix(test): preserve QA record assertion semantics * fix: complete standalone record guard rename * refactor(cron): use canonical string coercion * fix(acpx): preserve Pi timestamp parsing * test(channels): adapt custody test harnesses * test(telegram): classify media harness as test support * test(acpx): split timestamp contract coverage * test(channels): support generated custody contracts * chore: ban the full coercion helper name set Extends the declaration guard to all eleven consolidated helper names and renames the cron schedule-identity readNumber wrapper to readScheduleInteger so the banned generic name cannot regrow. * fix(scripts): repair release-validation guard drift and lint cause Restores the renamed isJsonRecord guard in assertTrustedWorkflowHarness after main added isRecord call sites in parallel, and attaches the caught YAML error as the thrown error cause (preserve-caught-error was red on main). * fix: preserve Claude timestamp string semantics * fix: preserve persisted timestamp string semantics * fix: preserve date-first timestamp contracts * fix(openai): harden delegation failure formatting * chore: close coercion helper guard gaps * test(openai): model non-error delegation rejection * chore: refresh plugin SDK API contract * fix(tasks): use canonical string field reader * fix(ai): use canonical provider error field coercion * fix(browser): migrate native bootstrap coercion * docs(plugin-sdk): clarify text record export compatibility * fix(gateway): normalize approval execution identity * test(outbound): isolate message action poll harness --- .github/workflows/ci.yml | 8 + docs/.generated/plugin-sdk-api-baseline.jsonl | 135 +++--- extensions/acpx/src/pi-session-catalog.ts | 15 +- extensions/acpx/src/pi-session-paths.ts | 4 +- extensions/acpx/src/pi-session-store.ts | 24 +- .../acpx/src/pi-session-timestamp.test.ts | 61 +++ extensions/acpx/src/pi-session-timestamp.ts | 10 + .../src/pi-session-upstream-activity.test.ts | 60 +++ .../acpx/src/pi-session-upstream-activity.ts | 16 +- extensions/active-memory/config.ts | 4 +- extensions/active-memory/prompt.ts | 6 +- extensions/active-memory/session-policy.ts | 8 +- extensions/active-memory/transcript-result.ts | 8 +- extensions/active-memory/transcript-watch.ts | 14 +- extensions/active-memory/transcript.ts | 19 +- .../amazon-bedrock/embedding-provider.ts | 8 +- .../anthropic/session-catalog-history.test.ts | 15 +- extensions/anthropic/session-catalog.test.ts | 28 ++ extensions/anthropic/session-catalog.ts | 84 ++-- extensions/azure-speech/speech-provider.ts | 12 +- extensions/azure-speech/tts.ts | 7 +- extensions/beam/src/types.ts | 15 +- extensions/bonjour/src/advertiser.test.ts | 9 +- extensions/browser/setup-api.ts | 6 +- .../browser/src/browser-proxy-upload.ts | 8 +- .../browser/src/browser-tool-session-tabs.ts | 15 +- .../browser/src/browser/chrome-mcp-result.ts | 10 +- .../src/browser/chrome-mcp.snapshot.ts | 24 +- .../browser/src/browser/chrome.graphics.ts | 40 +- .../src/browser/chrome.profile-decoration.ts | 11 +- .../src/browser/extension-native-host.ts | 7 +- .../src/browser/extension-native-protocol.ts | 8 +- extensions/browser/src/doctor-browser.ts | 24 +- extensions/browser/src/record-shared.ts | 26 -- extensions/browser/src/security-audit.ts | 6 +- extensions/clawrouter/provider-catalog.ts | 82 ++-- extensions/codex/doctor-contract-api.ts | 41 +- .../codex/src/app-server/approval-bridge.ts | 10 +- .../src/app-server/attempt-notifications.ts | 6 +- .../codex/src/app-server/bounded-turn.ts | 6 +- .../src/app-server/effective-mcp-catalog.ts | 23 +- .../src/app-server/elicitation-bridge.ts | 76 ++-- .../app-server/event-projector-assistant.ts | 3 +- .../src/app-server/event-projector-events.ts | 13 +- .../src/app-server/event-projector-media.ts | 3 +- .../event-projector-native-tool-lifecycle.ts | 3 +- .../app-server/event-projector-reasoning.ts | 2 +- .../app-server/event-projector-tool-output.ts | 2 +- .../event-projector-tool-progress.ts | 2 +- .../src/app-server/event-projector-usage.ts | 11 +- .../src/app-server/event-projector-values.ts | 23 +- .../codex/src/app-server/event-projector.ts | 2 +- .../codex/src/app-server/managed-binary.ts | 12 +- .../codex/src/app-server/native-mcp-app.ts | 36 +- .../src/app-server/native-subagent-monitor.ts | 11 +- .../native-subagent-notification.ts | 6 +- .../app-server/native-subagent-task-mirror.ts | 6 +- .../app-server/notification-correlation.ts | 15 +- .../codex/src/app-server/rate-limits.ts | 76 ++-- .../codex/src/app-server/run-attempt-state.ts | 69 ++- .../run-attempt.context-engine.test.ts | 9 +- .../codex/src/app-server/session-binding.ts | 15 +- .../codex/src/app-server/side-question.ts | 6 +- .../codex/src/app-server/usage-limit-error.ts | 13 +- .../codex/src/app-server/user-input-bridge.ts | 6 +- extensions/codex/src/command-account.ts | 16 +- extensions/codex/src/command-diagnostics.ts | 4 +- extensions/codex/src/command-formatters.ts | 43 +- .../codex/src/command-handler-actions.ts | 11 +- .../codex/src/conversation-binding-data.ts | 20 +- .../codex/src/conversation-turn-collector.ts | 23 +- extensions/codex/src/native-thread-tool.ts | 19 +- .../codex/src/session-catalog-parsing.ts | 10 +- extensions/codex/src/session-catalog.ts | 6 +- extensions/codex/src/supervision-tools.ts | 12 +- extensions/copilot/src/attempt-cleanup.ts | 4 +- extensions/copilot/src/attempt-config.ts | 60 +-- extensions/copilot/src/attempt-execution.ts | 40 +- extensions/copilot/src/attempt-prepare.ts | 12 +- .../copilot/src/attempt-transcript-journal.ts | 13 +- extensions/copilot/src/attempt.ts | 12 +- extensions/copilot/src/auth-bridge.ts | 29 +- .../copilot/src/event-bridge-transcript.ts | 4 +- extensions/copilot/src/event-bridge.ts | 12 +- extensions/copilot/src/provider-bridge.ts | 23 +- extensions/copilot/src/runtime.ts | 12 +- extensions/copilot/src/tool-bridge.ts | 16 +- extensions/deepgram/audio.ts | 8 +- extensions/deepinfra/speech-provider.ts | 4 +- extensions/diffs/src/tool.test.ts | 6 +- extensions/diffs/src/viewer-payload.ts | 12 +- extensions/discord/src/components.parse.ts | 87 ++-- .../discord/src/inbound-event-delivery.ts | 18 +- .../message-handler.process.test-harness.ts | 8 +- .../model-picker-preference-primitives.ts | 2 +- .../model-picker-preferences-migrations.ts | 4 +- .../src/monitor/model-picker-preferences.ts | 6 +- .../native-command.plugin-dispatch.test.ts | 10 +- .../thread-bindings.session-adapter.ts | 13 +- extensions/discord/src/status-issues.ts | 12 +- extensions/elevenlabs/speech-provider.ts | 16 +- extensions/feishu/src/accounts.ts | 2 +- extensions/feishu/src/card-interaction.ts | 2 +- extensions/feishu/src/card-test-helpers.ts | 17 +- extensions/feishu/src/channel.ts | 2 +- extensions/feishu/src/client.test.ts | 6 +- extensions/feishu/src/comment-shared.ts | 12 +- extensions/feishu/src/drive.ts | 9 +- extensions/feishu/src/feishu-ingress.ts | 22 +- extensions/feishu/src/monitor.account.ts | 2 +- extensions/feishu/src/monitor.comment.ts | 10 +- extensions/feishu/src/post.ts | 6 +- .../feishu/src/reply-dispatcher.test.ts | 5 +- extensions/feishu/src/reply-dispatcher.ts | 8 +- .../src/shared/node-invoke-policy.ts | 9 +- extensions/file-transfer/src/shared/params.ts | 12 - .../file-transfer/src/tools/dir-fetch-tool.ts | 5 +- .../src/tools/file-write-tool.ts | 7 +- .../fish-audio-speech/speech-provider.ts | 11 +- extensions/fish-audio-speech/tts.ts | 5 +- extensions/google-meet/index.test.ts | 5 +- extensions/google-meet/src/config-compat.ts | 20 +- extensions/google/cli-backend-auth.runtime.ts | 35 +- .../cli-backend-isolated-auth.runtime.ts | 41 +- extensions/google/embedding-provider.ts | 6 +- extensions/google/provider-catalog.ts | 22 +- extensions/google/speech-provider.ts | 11 +- extensions/gradium/speech-provider.ts | 7 +- extensions/inworld/speech-provider.ts | 7 +- extensions/irc/src/client.ts | 4 +- extensions/logbook/src/config.ts | 13 +- extensions/matrix/src/outbound.ts | 15 +- .../memory-core/src/cli-runtime-common.ts | 6 +- .../memory-core/src/cli-status.runtime.ts | 6 +- .../memory-core/src/dreaming-command.test.ts | 14 +- .../memory-core/src/dreaming-command.ts | 16 +- .../src/dreaming-ingestion-state.ts | 20 +- extensions/memory-core/src/dreaming-shared.ts | 1 - extensions/memory-core/src/dreaming.ts | 49 +- .../src/migration/doctor-memory-sidecar.ts | 31 +- .../migration/dreaming-state-comparison.ts | 9 +- .../src/session-backfill-gateway.ts | 6 +- .../src/short-term-promotion-store.ts | 8 +- extensions/memory-core/src/test-helpers.ts | 11 +- extensions/memory-core/src/tools.ts | 4 +- .../memory-lancedb/doctor-contract-api.ts | 13 +- extensions/memory-lancedb/embeddings.ts | 8 +- extensions/memory-lancedb/index.ts | 6 +- extensions/memory-lancedb/memory-policy.ts | 8 +- .../src/agent-vault-isolation.test.ts | 20 +- extensions/memory-wiki/src/config-compat.ts | 25 +- .../src/corpus-supplement.visibility.test.ts | 16 +- extensions/microsoft/speech-provider.ts | 15 +- extensions/migrate-claude/config.ts | 3 +- extensions/migrate-claude/helpers.ts | 4 +- extensions/migrate-hermes/auth-source.ts | 17 +- extensions/migrate-hermes/auth.ts | 11 +- extensions/migrate-hermes/config-env.ts | 2 +- extensions/migrate-hermes/config-mcp.ts | 50 ++- .../config-provider-contract.ts | 39 +- extensions/migrate-hermes/config-providers.ts | 27 +- extensions/migrate-hermes/config.ts | 5 +- extensions/migrate-hermes/helpers.ts | 9 +- extensions/migrate-hermes/items.ts | 18 +- extensions/migrate-hermes/model.ts | 61 +-- extensions/migrate-hermes/secrets.ts | 23 +- extensions/minimax/speech-provider.ts | 11 +- extensions/nextcloud-talk/src/setup-core.ts | 18 +- extensions/ollama/src/node-inference.ts | 19 +- extensions/onepassword/src/config.ts | 23 +- extensions/openai/realtime-provider-shared.ts | 26 +- ...ltime-quicksilver-delegation-controller.ts | 11 +- .../realtime-quicksilver-delegation.test.ts | 23 + .../realtime-quicksilver-gateway-bridge.ts | 9 +- .../realtime-quicksilver-peer.runtime.ts | 13 +- .../openai/realtime-transcription-provider.ts | 9 +- .../openai/realtime-voice-provider.test.ts | 5 +- extensions/openai/realtime-voice-provider.ts | 24 +- extensions/openai/speech-provider.ts | 68 +-- extensions/openrouter/oauth.ts | 36 +- extensions/openrouter/speech-provider.ts | 4 +- extensions/openrouter/stream.ts | 17 +- .../policy/src/doctor/ingress-findings.ts | 4 +- extensions/policy/src/doctor/utils.ts | 2 +- extensions/qa-lab/src/confidence-report.ts | 19 +- extensions/qa-lab/src/evidence-gallery.ts | 19 +- extensions/qa-lab/src/jsonl-replay.ts | 14 +- .../matrix/substrate/config.test.ts | 30 +- extensions/qa-lab/src/runtime-tool-fixture.ts | 50 +-- .../qa-lab/web/src/capture-saved-view.ts | 6 +- extensions/qqbot/src/bridge/config-shared.ts | 2 +- extensions/qqbot/src/bridge/config.ts | 2 +- extensions/qqbot/src/channel.ts | 2 +- .../qqbot/src/engine/config/credentials.ts | 6 +- extensions/qqbot/src/engine/config/group.ts | 43 +- extensions/qqbot/src/engine/config/resolve.ts | 28 +- .../src/engine/gateway/inbound-attachments.ts | 2 +- .../src/engine/messaging/outbound-deliver.ts | 8 +- .../engine/messaging/outbound-media-send.ts | 3 +- .../qqbot/src/engine/messaging/outbound.ts | 8 +- .../src/engine/messaging/reply-dispatcher.ts | 2 +- extensions/qqbot/src/engine/utils/audio.ts | 16 +- .../qqbot/src/engine/utils/file-utils.ts | 2 +- .../src/engine/utils/string-normalize.ts | 75 +--- extensions/qqbot/src/engine/utils/stt.ts | 54 +-- extensions/reef/src/flow.ts | 15 +- extensions/signal/src/config-compat.ts | 52 ++- extensions/slack/src/agent-context.ts | 32 +- extensions/slack/src/blocks-fallback.ts | 56 +-- extensions/slack/src/channel.test.ts | 5 +- extensions/slack/src/data-table.ts | 25 +- extensions/slack/src/data-visualization.ts | 23 +- extensions/slack/src/monitor/relay-source.ts | 24 +- extensions/slack/src/native-data-blocks.ts | 27 +- .../src/bot-message-dispatch.test-harness.ts | 8 +- .../bot-native-commands.session-meta.test.ts | 6 +- .../src/bot-native-commands.test-helpers.ts | 15 +- ...dia-file-path-no-file-download.e2e.test.ts | 2 +- ...rness.ts => bot.media.e2e.test-harness.ts} | 0 ...t.media.stickers-and-fragments.e2e.test.ts | 2 +- .../telegram/src/bot.media.test-utils.ts | 2 +- .../src/bot.media.warning-topics.e2e.test.ts | 2 +- extensions/telegram/src/status-issues.ts | 20 +- extensions/tlon/src/monitor/cites.ts | 3 +- extensions/tlon/src/monitor/discovery.ts | 3 +- extensions/tlon/src/monitor/history.ts | 3 +- extensions/tlon/src/monitor/index.ts | 20 +- extensions/tlon/src/monitor/utils.ts | 69 ++- extensions/tts-local-cli/speech-provider.ts | 17 +- extensions/vault/vault-secret-ref-resolver.js | 4 +- extensions/voice-call/doctor-contract-api.ts | 13 +- extensions/voice-call/src/config-migration.ts | 36 +- .../voice-call/src/realtime-agent-context.ts | 22 +- extensions/volcengine/speech-provider.ts | 7 +- extensions/vydra/shared.ts | 47 +- extensions/vydra/speech-provider.ts | 33 +- extensions/whatsapp/src/agent-tools-login.ts | 11 +- ...o-reply.connection-and-logging.e2e.test.ts | 17 +- extensions/whatsapp/src/status-issues.ts | 12 +- .../workboard/src/store-card-helpers.ts | 2 +- extensions/workboard/src/store-core.ts | 2 +- extensions/workboard/src/store-normalizers.ts | 5 +- extensions/workboard/src/store-workflow.ts | 2 +- extensions/workboard/src/workspace-access.ts | 21 +- .../xai/capability-provider-metadata.ts | 10 +- extensions/xai/realtime-voice-config.ts | 19 +- extensions/xai/setup-api.ts | 2 +- extensions/xai/src/tool-config-shared.ts | 2 - extensions/xai/src/web-search-shared.ts | 2 +- extensions/xai/src/x-search-config.ts | 2 +- extensions/xai/src/xai-user-agent.ts | 12 +- extensions/xai/tts.ts | 7 +- extensions/xiaomi/speech-provider.ts | 23 +- package.json | 1 + packages/acp-core/src/meta.test.ts | 15 +- packages/acp-core/src/meta.ts | 4 +- .../agent-core/src/harness/messages.test.ts | 16 + .../providers/google-shared.convert.test.ts | 42 +- .../providers/google-shared.test-helpers.ts | 6 +- packages/ai/src/utils/provider-error.test.ts | 17 + packages/ai/src/utils/provider-error.ts | 29 +- .../src/number-coercion.test.ts | 31 ++ .../normalization-core/src/number-coercion.ts | 12 + packages/sdk/src/client.ts | 20 +- packages/sdk/src/normalize.ts | 26 +- qa/convex-credential-broker/convex/http.ts | 20 +- .../convex/payload_validation.ts | 6 +- scripts/bench-agent-concurrency-worker.ts | 9 +- scripts/bench-task-registry-sqlite-worker.ts | 7 +- scripts/check-changed.mts | 1 + .../check-coercion-helper-declarations.mts | 417 ++++++++++++++++++ scripts/check-control-ui-performance.mts | 4 +- scripts/check-file-utils.ts | 2 + scripts/check-gateway-cpu-scenarios.mts | 5 +- scripts/check-memory-fd-repro.mts | 11 +- scripts/check-plugin-gateway-gauntlet.mts | 7 +- scripts/dependency-changes-report.mts | 10 +- scripts/docker-e2e.mts | 16 +- .../lib/npm-telegram-live/prepare-package.mts | 8 +- scripts/e2e/parallels/npm-update-smoke.ts | 11 +- scripts/e2e/telegram-bot-api.ts | 9 +- scripts/e2e/telegram-user-credential.ts | 4 +- scripts/full-release-validation-at-sha.mts | 22 +- .../lib/export-name-collision-baseline.json | 16 - scripts/lib/plugin-gateway-gauntlet.mts | 20 +- scripts/lib/release-beta-verifier.ts | 117 ++--- scripts/lib/windows-cmd-helpers-runtime.mts | 4 +- scripts/plugin-sdk-surface-report.mts | 8 +- .../resolve-openclaw-package-candidate.mts | 30 +- scripts/verify-pr-hosted-gates.mts | 63 ++- src/acp/session-mapper.ts | 6 +- src/acp/translator.prompt-stream.ts | 4 +- src/acp/translator.session-list.ts | 4 +- .../attempt.model-diagnostic-events.test.ts | 7 +- .../embedded-agent-runner/thinking.test.ts | 21 + src/agents/failover-error.ts | 10 +- src/agents/harness/native-hook-relay-codec.ts | 22 +- .../harness/native-hook-relay-permissions.ts | 6 +- src/agents/harness/native-hook-relay-utils.ts | 2 +- src/agents/harness/native-hook-relay.test.ts | 5 +- src/agents/openai-strict-tool-setting.ts | 10 +- src/agents/prepared-model-runtime.owner.ts | 10 +- src/agents/prepared-model-runtime.ts | 8 +- src/agents/session-activity-notes.ts | 22 +- .../spawn/subagent-spawn-thinking.ts | 12 +- .../live-cache-regression-runner.ts | 10 +- src/agents/tool-search-config.ts | 4 +- src/agents/tools/sessions-tool.ts | 10 +- src/agents/tools/terminal-tool.ts | 6 +- .../reply/reply-media-paths.test.ts | 6 +- src/channels/plugins/status-issues/shared.ts | 13 +- src/cli/nodes-camera.ts | 14 +- src/cli/nodes-media-utils.test.ts | 6 +- src/cli/nodes-media-utils.ts | 8 +- src/cli/nodes-screen.ts | 12 +- src/cli/program/register.setup.ts | 24 +- .../channel-setup/plugin-install.test.ts | 5 +- src/commands/doctor-config-flow.test.ts | 185 ++++---- .../doctor/cron/legacy-store-migration.ts | 22 +- src/commands/export-trajectory.ts | 12 +- src/commands/models/auth.test.ts | 3 +- src/config/sessions/lifecycle.test.ts | 4 +- src/config/sessions/lifecycle.ts | 2 +- src/cron/schedule-identity.ts | 28 +- src/fleet/containers.runtime.ts | 10 +- src/gateway/agent-turn/agent-job.ts | 8 +- .../android-node.capabilities.live.test.ts | 37 +- ...id-node.capabilities.policy-config.test.ts | 5 +- src/gateway/cli-session-history.claude.ts | 6 +- src/gateway/cli-session-history.test.ts | 28 ++ src/gateway/control-ui-github-api.ts | 5 +- src/gateway/control-ui-github-preview.ts | 12 +- src/gateway/control-ui-session-prs.ts | 24 +- .../gateway-models.profiles.live.test.ts | 5 +- src/gateway/mcp-app-reconstruction.ts | 66 +-- src/gateway/mcp-app-standalone.ts | 16 +- src/gateway/node-normalize.ts | 4 - src/gateway/node-plugin-tool-snapshot.ts | 29 +- src/gateway/node-registry.ts | 10 +- src/gateway/operator-approval-store.ts | 54 ++- src/gateway/server-methods/attach.ts | 21 +- src/gateway/server-plugins.test.ts | 5 +- .../server.sessions.list-changed.test.ts | 10 +- ...rver.shared-token-session-rotation.test.ts | 13 +- .../session-transcript-readers.test.ts | 27 ++ src/logging/diagnostic-stability-bundle.ts | 97 ++-- src/meeting-bot/node-host.ts | 36 +- src/meeting-bot/node-invoke-policy.ts | 30 +- .../realtime-node-audio-transport.ts | 4 +- src/node-host/plugin-node-host.ts | 13 +- src/plugin-sdk/number-runtime.ts | 1 + src/plugin-sdk/provider-stream-shared.ts | 11 +- src/plugin-sdk/status-helpers.test.ts | 7 + src/plugin-sdk/status-helpers.ts | 3 +- src/plugin-sdk/string-coerce-runtime.ts | 5 +- src/plugin-sdk/text-runtime.ts | 2 +- .../config-footprint-guardrails.test.ts | 18 +- src/plugins/installed-plugin-index.test.ts | 5 +- src/plugins/provider-runtime.test.ts | 5 +- src/plugins/session-catalog.test.ts | 13 +- src/skills/lifecycle/upload-store.ts | 4 +- src/skills/workshop/config.ts | 4 +- src/skills/workshop/policy.ts | 13 +- src/state/openclaw-database-verify.impl.ts | 8 +- src/tasks/task-registry-activity.ts | 18 +- src/trajectory/export.test.ts | 37 ++ src/trajectory/export.ts | 2 +- src/trajectory/runtime-store.sqlite.test.ts | 20 + src/tui/tui-event-handlers.ts | 10 +- src/tui/tui-formatters.ts | 2 +- src/tui/tui-run-lifecycle.ts | 4 +- src/tui/tui-session-actions.ts | 10 +- src/worker/embedded-agent.runtime.ts | 12 +- src/worker/worker-connection-admission.ts | 4 +- src/worker/worker-connection-contract.ts | 2 +- src/worker/worker-connection-frames.ts | 11 +- src/worker/worker-connection.test.ts | 14 +- src/worker/worker-connection.ts | 8 +- src/worker/worker.runtime.test.ts | 5 +- src/worker/worker.runtime.ts | 8 +- .../cloud-worker-disappearance-proof.ts | 27 +- .../runtime/gateway-rpc-account-health.ts | 32 +- ...android-node-capabilities-policy-config.ts | 6 +- test/helpers/openclaw-test-instance.ts | 5 +- test/scripts/changed-lanes.test.ts | 4 + ...check-coercion-helper-declarations.test.ts | 131 ++++++ test/scripts/check-file-utils.test.ts | 1 + test/scripts/check-memory-fd-repro.test.ts | 18 +- test/scripts/ci-workflow-guards.test.ts | 8 + test/scripts/k8s-manifests.test.ts | 42 +- ui/src/components/browser/browser-client.ts | 49 +- ui/src/components/github-link-hovercard.ts | 13 +- .../control-ui-auth-transports.e2e.test.ts | 25 +- ui/src/i18n/test/translate.test.ts | 16 +- ui/src/lib/board/widget-bridge.ts | 4 +- ui/src/lib/chat/tool-call-patch.ts | 11 +- ui/src/lib/chat/tool-call-view.ts | 36 +- ui/src/test-helpers/control-ui-e2e.ts | 2 + 398 files changed, 3861 insertions(+), 3285 deletions(-) create mode 100644 extensions/acpx/src/pi-session-timestamp.test.ts create mode 100644 extensions/acpx/src/pi-session-timestamp.ts delete mode 100644 extensions/browser/src/record-shared.ts rename extensions/telegram/src/{bot.media.e2e-harness.ts => bot.media.e2e.test-harness.ts} (100%) create mode 100644 scripts/check-coercion-helper-declarations.mts delete mode 100644 src/gateway/node-normalize.ts create mode 100644 test/scripts/check-coercion-helper-declarations.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 414b694c1542..7b90f916dc22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2348,6 +2348,14 @@ jobs: pnpm tool-display:check pnpm check:host-env-policy:swift pnpm dup:check:coverage + if has_package_script "check:coercion-helpers"; then + pnpm check:coercion-helpers + elif [[ "$HISTORICAL_TARGET" == "true" ]]; then + echo "[skip] historical target predates the coercion-helper declaration guard" + else + echo "Current CI targets must provide the check:coercion-helpers package script." >&2 + exit 1 + fi if [ -n "$PR_BASE_SHA" ]; then timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/ci-base" node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/ci-base --head HEAD --no-merge-base diff --git a/docs/.generated/plugin-sdk-api-baseline.jsonl b/docs/.generated/plugin-sdk-api-baseline.jsonl index 5e1e2c724a48..b8467cc986a0 100644 --- a/docs/.generated/plugin-sdk-api-baseline.jsonl +++ b/docs/.generated/plugin-sdk-api-baseline.jsonl @@ -53,17 +53,17 @@ {"closureHash":"ff3c4616cd6212a6d698831a8b287ad87e3968c8663f4090d095bb30ec40fc1c","declaration":"export function abortAndDrainAgentHarnessRun(params: { sessionId: string; sessionKey?: string; settleMs?: number; forceClear?: boolean; reason?: string; }): Promise;","entrypoint":"agent-harness","exportName":"abortAndDrainAgentHarnessRun","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"58989e3fa91eecf2e05202e36548e6e64a3b9219e83154bd99866c851604e34e","declaration":"export function createAgentToolResultMiddlewareRunner(ctx: AgentToolResultMiddlewareContext, handlers?: AgentToolResultMiddleware[]): { applyToolResultMiddleware(event: AgentToolResultMiddlewareEvent): Promise; };","entrypoint":"agent-harness","exportName":"createAgentToolResultMiddlewareRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"b8f6355d7ad1700aceecddaa6e9ccf43091a792d5b481a1ee3f55294a881a67d","declaration":"export function createCodexAppServerToolResultExtensionRunner(ctx: CodexAppServerExtensionContext, factories?: CodexAppServerExtensionFactory[]): { applyToolResultExtensions(event: CodexAppServerToolResultEvent): Promise>; };","entrypoint":"agent-harness","exportName":"createCodexAppServerToolResultExtensionRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} -{"closureHash":"1115e6754f66da077a1d2f349cf2bfd26382b0978b4efcf131d8d5e8a219e485","declaration":"export function createOpenClawCodingTools(options?: OpenClawCodingToolsOptions): AnyAgentTool[];","entrypoint":"agent-harness","exportName":"createOpenClawCodingTools","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} +{"closureHash":"2624f0c11a7852cedded8f42322cb6e6a6c719d6be2ec4571e8d1fb252c6eaaf","declaration":"export function createOpenClawCodingTools(options?: OpenClawCodingToolsOptions): AnyAgentTool[];","entrypoint":"agent-harness","exportName":"createOpenClawCodingTools","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"13649ee853485319e7449fda25c106b3f3f53d3090e53cdbb28e220a8529eb80","declaration":"export function disposeRegisteredAgentHarnesses(): Promise;","entrypoint":"agent-harness","exportName":"disposeRegisteredAgentHarnesses","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"f255a91162ece4c239bb8fa745efa14a1054bc024a291e9954f75f239b2b87c3","declaration":"export function resolveActiveEmbeddedRunSessionId(sessionKey: string): string | undefined;","entrypoint":"agent-harness","exportName":"resolveActiveEmbeddedRunSessionId","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"27d9f61df9ccf68615cb2002a6da891e36ab0c07623bc37c4d07aa980b446881","declaration":"export function resolveWebSearchToolPolicy(params: WebSearchToolPolicyParams): WebSearchToolPolicyResolution;","entrypoint":"agent-harness","exportName":"resolveWebSearchToolPolicy","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} -{"closureHash":"7dcb6a9a37ace260567e3b46fc8d051aa124df2edfb093dfb56f082da56eaafb","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"agent-harness","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} -{"closureHash":"65b516857f90d0029866f6b3179cac436dd6d5c85bd83d440fbcdbe297b70bf6","declaration":"export type AgentHarnessV2 = AgentHarnessV2;","entrypoint":"agent-harness","exportName":"AgentHarnessV2","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} +{"closureHash":"beb6715ad81487caa2cc2ed8444d6ac2dcf83c9e71fc42d5db9214f2eb12b946","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"agent-harness","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} +{"closureHash":"ca20bb488d357785086f04e71b0fbb300a27325217bdbfdcaa1e422eca2fe255","declaration":"export type AgentHarnessV2 = AgentHarnessV2;","entrypoint":"agent-harness","exportName":"AgentHarnessV2","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"closureHash":"4ad0f830bf11a2db48faefb1439b7fbb08d9f22d22083f5e301327e1696f2310","declaration":"export type AgentToolResultMiddleware = AgentToolResultMiddleware;","entrypoint":"agent-harness","exportName":"AgentToolResultMiddleware","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"closureHash":"008b6c78d5e44a9d1df1d7e32e6015382ee9ba1cb9b10bd4c2b467619d1c89d4","declaration":"export type AgentToolResultMiddlewareEvent = AgentToolResultMiddlewareEvent;","entrypoint":"agent-harness","exportName":"AgentToolResultMiddlewareEvent","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"closureHash":"7414a2a79c2d78c1d52538250c827305bba1a2bb25010448837c8524a9f95b3c","declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"agent-harness","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} -{"closureHash":"f6d52c26f4fa2377f384935aa8e0c990e8634bc56ddc35dc00f4e00d5dfd16c4","declaration":"export type EmbeddedRunAttemptParams = EmbeddedRunAttemptParams;","entrypoint":"agent-harness","exportName":"EmbeddedRunAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} -{"closureHash":"b96b39b906aac64a2ce1037ad8f6f811f832b5189f53c0c966cba9dedca50615","declaration":"export type EmbeddedRunAttemptParamsV2 = EmbeddedRunAttemptParamsV2;","entrypoint":"agent-harness","exportName":"EmbeddedRunAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} +{"closureHash":"b0d89fe509b6812c24b0a46cbc7d7ce20af0009a415c4e50e20c41af02975b11","declaration":"export type EmbeddedRunAttemptParams = EmbeddedRunAttemptParams;","entrypoint":"agent-harness","exportName":"EmbeddedRunAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} +{"closureHash":"67dd9a76536fb9f41ae19a380dd71bf10682ff80c63f363b5f8511948dcd1aab","declaration":"export type EmbeddedRunAttemptParamsV2 = EmbeddedRunAttemptParamsV2;","entrypoint":"agent-harness","exportName":"EmbeddedRunAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"closureHash":"25c6985c3bef2c6efc6a96e70af6d66fe073be0b0d311681cfc0dfc186528d9a","declaration":"export type OpenClawAgentToolResult = OpenClawAgentToolResult;","entrypoint":"agent-harness","exportName":"OpenClawAgentToolResult","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"category":"runtime","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","recordType":"module"} {"closureHash":"fab99dfcb01d475a1bfdbe7a8fac9be10a93dac5f12ac78c2dea673c8ab86abf","declaration":"export function abortAgentHarnessRun(sessionId: string): boolean;\nexport function abortAgentHarnessRun(sessionId: undefined, opts: { mode: \"all\" | \"compacting\"; reason?: \"restart\"; }): boolean;","entrypoint":"agent-harness-runtime","exportName":"abortAgentHarnessRun","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -82,8 +82,8 @@ {"closureHash":"f8ad3d762c9753fdc36b2b9af0980167fdc54090c7bcca2f3dbe7c8951e86821","declaration":"export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): AgentRuntimePlan;","entrypoint":"agent-harness-runtime","exportName":"buildAgentRuntimePlan","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"528d11abf061b0b303722737de5a6406e7e55bffa38c45144c77d896ae1b3b0a","declaration":"export function buildBootstrapContextForFiles(bootstrapFiles: WorkspaceBootstrapFile[], params: { config?: OpenClawConfig; agentId?: string | null; warn?: (message: string) => void; }): EmbeddedContextFile[];","entrypoint":"agent-harness-runtime","exportName":"buildBootstrapContextForFiles","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"44d5a4667d2f3dc3a28e5c9d7b46765b1fb3c62820288e40251f5174911c8a6a","declaration":"export function buildEmbeddedAttemptToolRunContext(params: { trigger?: EmbeddedRunTrigger; jobId?: string; memoryFlushWritePath?: string; toolsAllow?: string[]; conversationToolPolicy?: GroupToolPolicyConfig; trace?: DiagnosticTraceContext; }): { trigger?: EmbeddedRunTrigger; jobId?: string; memoryFlushWritePath?: string; runtimeToolAllowlist?: string[]; conversationToolPolicy?: GroupToolPolicyConfig; trace?: DiagnosticTraceContext; };","entrypoint":"agent-harness-runtime","exportName":"buildEmbeddedAttemptToolRunContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"faa83a109c85aaabd294ba35f76000b22901b5d6cc33a57f9cfbb7e3792c6506","declaration":"export function buildHarnessContextEngineRuntimeContext(params: Parameters[0]): ContextEngineRuntimeContext;","entrypoint":"agent-harness-runtime","exportName":"buildHarnessContextEngineRuntimeContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"e10f6ebee88eb98a88ea66a8d1dfff03f7ce1b82c50dc3b5d130652b450e9ccc","declaration":"export function buildHarnessContextEngineRuntimeContextFromUsage(params: Parameters[0]): ContextEngineRuntimeContext;","entrypoint":"agent-harness-runtime","exportName":"buildHarnessContextEngineRuntimeContextFromUsage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"e194f5cdcc6a602b877027a0c105c9e850c02f81439eb655d3e9d56febc28a22","declaration":"export function buildHarnessContextEngineRuntimeContext(params: Parameters[0]): ContextEngineRuntimeContext;","entrypoint":"agent-harness-runtime","exportName":"buildHarnessContextEngineRuntimeContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"74b53fe40a520f5dffa1593ed0bfea15ac8b08cc74428d03375e47e9bed0adc1","declaration":"export function buildHarnessContextEngineRuntimeContextFromUsage(params: Parameters[0]): ContextEngineRuntimeContext;","entrypoint":"agent-harness-runtime","exportName":"buildHarnessContextEngineRuntimeContextFromUsage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"33a7cd8d63d4ef045ee3a42f806026a8b621e7dd32d8d6fbf4e7b44452d5e0ce","declaration":"export function buildNativeHookRelayCommand(params: { provider: NativeHookRelayProvider; relayId: string; generation?: string; event: NativeHookRelayEvent; preToolUseUnavailable?: \"noop\"; timeoutMs?: number; executable?: string; nice?: number | false; nodeExecutable?: string; }): string;","entrypoint":"agent-harness-runtime","exportName":"buildNativeHookRelayCommand","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"3846647c3c2fb0242f70591cf95c2e02f2a5a3c44447efb4cf7a31b2dc2122fd","declaration":"export function buildSkillWorkshopPromptSection(): string[];","entrypoint":"agent-harness-runtime","exportName":"buildSkillWorkshopPromptSection","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"32e977ece65144a83ae1b0a3c8d1b9b00a2a2506ad9ef1db3b2b9770ce762dbe","declaration":"export function buildWatchedSessionsHarnessContext(params: { config?: OpenClawConfig; sessionKey?: string; sandboxed?: boolean; toolNames: Iterable; capabilityToolNames?: Iterable; }): string | undefined;","entrypoint":"agent-harness-runtime","exportName":"buildWatchedSessionsHarnessContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -100,7 +100,7 @@ {"closureHash":"51f76e9e37b65b358f0313e10152d950b003bc3e50f4bb26fe29a988237772e2","declaration":"export function consumePreExecutionBlockedToolCall(toolCallId: string, runId?: string): boolean;","entrypoint":"agent-harness-runtime","exportName":"consumePreExecutionBlockedToolCall","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"58989e3fa91eecf2e05202e36548e6e64a3b9219e83154bd99866c851604e34e","declaration":"export function createAgentToolResultMiddlewareRunner(ctx: AgentToolResultMiddlewareContext, handlers?: AgentToolResultMiddleware[]): { applyToolResultMiddleware(event: AgentToolResultMiddlewareEvent): Promise; };","entrypoint":"agent-harness-runtime","exportName":"createAgentToolResultMiddlewareRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"b8f6355d7ad1700aceecddaa6e9ccf43091a792d5b481a1ee3f55294a881a67d","declaration":"export function createCodexAppServerToolResultExtensionRunner(ctx: CodexAppServerExtensionContext, factories?: CodexAppServerExtensionFactory[]): { applyToolResultExtensions(event: CodexAppServerToolResultEvent): Promise>; };","entrypoint":"agent-harness-runtime","exportName":"createCodexAppServerToolResultExtensionRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"b7399f160a14a25556f800de6cede4d57349dd45aa90f59a8fae5aaf19c01913","declaration":"export function deliverAgentHarnessUserInputPrompt(params: PromptDeliveryParams, questions: readonly AgentHarnessUserInputQuestion[], options?: AgentHarnessUserInputPromptOptions): Promise;","entrypoint":"agent-harness-runtime","exportName":"deliverAgentHarnessUserInputPrompt","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"b63d7e807de37989954018686a2c3ddaa30f4bb71d610221b178495f978ef9bb","declaration":"export function deliverAgentHarnessUserInputPrompt(params: PromptDeliveryParams, questions: readonly AgentHarnessUserInputQuestion[], options?: AgentHarnessUserInputPromptOptions): Promise;","entrypoint":"agent-harness-runtime","exportName":"deliverAgentHarnessUserInputPrompt","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"6f436485257f69fa07a18203b91d4341690522f12aff4f25805c7a1e21326ee3","declaration":"export function detectAndLoadAgentHarnessPromptImages(params: { prompt: string; workspaceDir: string; model: { input?: string[]; }; existingImages?: ImageContent[]; imageOrder?: PromptImageOrderEntry[]; media?: MediaFact[]; config?: OpenClawConfig; workspaceOnly?: boolean; localRoots?: readonly string[]; sandbox?: { root: string; bridge: SandboxFsBridge; }; }): Promise<{ images: ImageContent[]; detectedRefs: Array<{ raw: string; resolved: string; type: \"path\" | \"media-uri\"; }>; loadedCount: number; skippedCount: number; }>;","entrypoint":"agent-harness-runtime","exportName":"detectAndLoadAgentHarnessPromptImages","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"13649ee853485319e7449fda25c106b3f3f53d3090e53cdbb28e220a8529eb80","declaration":"export function disposeRegisteredAgentHarnesses(): Promise;","entrypoint":"agent-harness-runtime","exportName":"disposeRegisteredAgentHarnesses","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"c38cd390190bb07ddcd247420d6e0b489a0229bd402ddca0440417217a57251a","declaration":"export function emitAgentEvent(event: Omit): void;","entrypoint":"agent-harness-runtime","exportName":"emitAgentEvent","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -162,7 +162,7 @@ {"closureHash":"40154c3b7b2a7179df33f4a3fbef5e56078be2759d5aab29f39eb0f3c92fc631","declaration":"export function projectRuntimeToolInputSchema(schema: unknown, path?: string): RuntimeToolInputSchemaProjection;","entrypoint":"agent-harness-runtime","exportName":"projectRuntimeToolInputSchema","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"e42e3dc63e8545bb9a6056336fbd6c1dc8f356dce4f0f1c6d9765254c974390e","declaration":"export function projectSettledTurnFinalizationAttemptResult(result: AgentHarnessAttemptResult): AgentHarnessSettledTurnFinalizationResult;","entrypoint":"agent-harness-runtime","exportName":"projectSettledTurnFinalizationAttemptResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"3fbe470f065d2c2f16de538d8347934ec2daee2081d8903db1777b0afd2a8043","declaration":"export function queueAgentHarnessMessage(sessionId: string, text: string, options?: EmbeddedAgentQueueMessageOptions): boolean;","entrypoint":"agent-harness-runtime","exportName":"queueAgentHarnessMessage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"acb8a5ff66840716e1996e6e1ffa633baa50cf4fd79a1eb203f45c09f62ee2ac","declaration":"export function registerNativeHookRelay(params: RegisterNativeHookRelayParams): ActiveNativeHookRelayRegistrationHandle;","entrypoint":"agent-harness-runtime","exportName":"registerNativeHookRelay","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"ed44ea59be68bee94ef22b61cd88171c361275dcc72e786f13155e997c803b28","declaration":"export function registerNativeHookRelay(params: RegisterNativeHookRelayParams): ActiveNativeHookRelayRegistrationHandle;","entrypoint":"agent-harness-runtime","exportName":"registerNativeHookRelay","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"b7cc6c35506dc6955e0359aa5c53c15a4d0db23639da8c1717f8fefda953c119","declaration":"export function requestDeferredPluginToolApproval(params: { deferredApproval: DeferredPluginToolApproval; signal?: AbortSignal; }): Promise;","entrypoint":"agent-harness-runtime","exportName":"requestDeferredPluginToolApproval","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"a2306cba08cb05ff8430c8362c0cc3c821706da5a4e65a06849ad0d0fb007ae3","declaration":"export function resetAgentEventsForTest(options?: { preserveListeners?: boolean; }): void;","entrypoint":"agent-harness-runtime","exportName":"resetAgentEventsForTest","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"f255a91162ece4c239bb8fa745efa14a1054bc024a291e9954f75f239b2b87c3","declaration":"export function resolveActiveEmbeddedRunSessionId(sessionKey: string): string | undefined;","entrypoint":"agent-harness-runtime","exportName":"resolveActiveEmbeddedRunSessionId","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -197,7 +197,7 @@ {"closureHash":"e4784aeda4c24b60cd49b7f5983e70a380a4759d5176c74d30f2900952ba848f","declaration":"export function runAgentHarnessBeforeAgentFinalizeHook(params: { event: PluginHookBeforeAgentFinalizeEvent; ctx: AgentHarnessHookContext; hookRunner?: AgentHarnessHookRunner; }): Promise;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessBeforeAgentFinalizeHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"8fec73ee8c7c9be3335bd6dc1bac9278f0be5beef22a5975eb06e56f504f4bd6","declaration":"export function runAgentHarnessBeforeCompactionHook(params: { sessionFile: string; messages?: AgentMessage[]; ctx: AgentHarnessHookContext; }): Promise;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessBeforeCompactionHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"021b81820bef2e8d54c50a5fc159d6665b23b50a1f5c40dae76a4833839d89c9","declaration":"export function runAgentHarnessBeforeMessageWriteHook(params: { message: AgentMessage; agentId?: string; sessionKey?: string; }): AgentMessage | null;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessBeforeMessageWriteHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"e0214147021b247e720c4a4ae1fc8597c082c2c4286ac131b6f981f0895b8b0f","declaration":"export function runAgentHarnessGatewayQuestion(params: RunAgentHarnessGatewayQuestionParams): Promise;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessGatewayQuestion","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"d40db54ef0fcc481063d718dea2ed1bdbe64b75aee82cd0efcf02ba86039765d","declaration":"export function runAgentHarnessGatewayQuestion(params: RunAgentHarnessGatewayQuestionParams): Promise;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessGatewayQuestion","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"50a4d7cb20dd8f884abbcb62d77b5b2df183fbfcd058cc7aa76edacd1f7b90d8","declaration":"export function runAgentHarnessLlmInputHook(params: { event: PluginHookLlmInputEvent; ctx: AgentHarnessHookContext; hookRunner?: AgentHarnessHookRunner; }): void;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessLlmInputHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"1bd55c141c249b1c04470cafe0cd4d41dce334ea2883ce83235af56d24a15cca","declaration":"export function runAgentHarnessLlmOutputHook(params: { event: PluginHookLlmOutputEvent; ctx: AgentHarnessHookContext; hookRunner?: AgentHarnessHookRunner; }): void;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessLlmOutputHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"c4c5131ab7065decd8d8e935ac3779e3baa2c52fbbbd95a6ccb624377db09ca1","declaration":"export function runBeforeToolCallHook(args: { toolName: string; params: unknown; toolKind?: PluginHookToolKind; toolInputKind?: PluginHookToolInputKind; toolCallId?: string; ctx?: HookContext; signal?: AbortSignal; approvalMode?: \"request\" | \"report\" | \"deny\" | \"defer\"; }): Promise;","entrypoint":"agent-harness-runtime","exportName":"runBeforeToolCallHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -219,13 +219,13 @@ {"closureHash":"0701a22057edc3b733da7e05948db94aa98b337b224a5718efdd85212a8602bf","declaration":"export const TRANSCRIPT_CREDENTIAL_SAFETY_PROMPT: string;","entrypoint":"agent-harness-runtime","exportName":"TRANSCRIPT_CREDENTIAL_SAFETY_PROMPT","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} {"closureHash":"b5845c99df6c2a834a16678c320e0111bad96a3a7ac245d379aa5e6d2555d29b","declaration":"export const agentHarnessAttemptTerminal: { merge: (current: AgentRunAttemptTerminal, incoming: AgentRunAttemptTerminal) => AgentRunAttemptTerminal; normalize: (input: { aborted?: boolean; externalAbort?: boolean; idleTimedOut?: boolean; promptError?: unknown; promptErrorSource?: AgentRunAttemptFailureSource | null; timedOut?: boolean; timedOutByRunBudget?: boolean; timedOutDuringCompaction?: boolean; timedOutDuringToolExecution?: boolean; }) => AgentRunAttemptTerminal; project: (terminal: AgentRunAttemptTerminal) => { aborted: boolean; cleanupYieldAborted: boolean; externalAbort: boolean; failed: boolean; idleTimedOut: boolean; interrupted: boolean; promptError: unknown; promptErrorSource: AgentRunAttemptFailureSource | null; timedOut: boolean; timedOutByRunBudget: boolean; timedOutDuringCompaction: boolean; timedOutDuringToolExecution: boolean; }; setFailure: (terminal: AgentRunAttemptTerminal, failure: { source: AgentRunAttemptFailureSource; error: unknown; } | null) => AgentRunAttemptTerminal;};","entrypoint":"agent-harness-runtime","exportName":"agentHarnessAttemptTerminal","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} {"closureHash":"22bcf962aa76c76074c4ed4ad7d6558edbc3588e239263e09af74c1000bbe012","declaration":"export const embeddedAgentLog: SubsystemLogger;","entrypoint":"agent-harness-runtime","exportName":"embeddedAgentLog","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} -{"closureHash":"6e65a3ec38a9eef02bee0ae22b71a7008ffe3253d15f7cf9f5c0a9d2bf1729e0","declaration":"export const nativeHookRelayTesting: { readonly clearNativeHookRelaysForTests: () => void; readonly getNativeHookRelayInvocationsForTests: () => NativeHookRelayInvocation[]; readonly getNativeHookRelayRegistrationForTests: (relayId: string) => NativeHookRelayRegistration | undefined; readonly getNativeHookRelayBridgeDirForTests: () => string; readonly getNativeHookRelayBridgeRegistryPathForTests: (relayId: string) => string; readonly getNativeHookRelayBridgeRecordForTests: (relayId: string) => Record | undefined; readonly isNativeHookRelayBridgeLookupRetryableForTests: (error: unknown, elapsedMs?: number) => boolean; readonly formatPermissionApprovalDescriptionForTests: (request: NativeHookRelayPermissionApprovalRequest) => string; readonly permissionRequestContentFingerprintForTests: (request: NativeHookRelayPermissionApprovalRequest) => string; readonly permissionRequestToolInputKeyFingerprintForTests: (toolInput: Record) => string; readonly setNativeHookRelayPermissionApprovalRequesterForTests: (requester: NativeHookRelayPermissionApprovalRequester) => void; readonly setNativeHookRelayDeferredToolApprovalRequesterForTests: (requester: NativeHookRelayDeferredToolApprovalRequester) => void;};","entrypoint":"agent-harness-runtime","exportName":"nativeHookRelayTesting","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} +{"closureHash":"3bf4f5b98fbf675ac0208efd9de33e62abb30a5a8063e99a661f097d0dee7b84","declaration":"export const nativeHookRelayTesting: { readonly clearNativeHookRelaysForTests: () => void; readonly getNativeHookRelayInvocationsForTests: () => NativeHookRelayInvocation[]; readonly getNativeHookRelayRegistrationForTests: (relayId: string) => NativeHookRelayRegistration | undefined; readonly getNativeHookRelayBridgeDirForTests: () => string; readonly getNativeHookRelayBridgeRegistryPathForTests: (relayId: string) => string; readonly getNativeHookRelayBridgeRecordForTests: (relayId: string) => Record | undefined; readonly isNativeHookRelayBridgeLookupRetryableForTests: (error: unknown, elapsedMs?: number) => boolean; readonly formatPermissionApprovalDescriptionForTests: (request: NativeHookRelayPermissionApprovalRequest) => string; readonly permissionRequestContentFingerprintForTests: (request: NativeHookRelayPermissionApprovalRequest) => string; readonly permissionRequestToolInputKeyFingerprintForTests: (toolInput: Record) => string; readonly setNativeHookRelayPermissionApprovalRequesterForTests: (requester: NativeHookRelayPermissionApprovalRequester) => void; readonly setNativeHookRelayDeferredToolApprovalRequesterForTests: (requester: NativeHookRelayDeferredToolApprovalRequester) => void;};","entrypoint":"agent-harness-runtime","exportName":"nativeHookRelayTesting","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} {"closureHash":"f237bbc11e1d389679cd4e2ad7aa9055951b3029348e3374b81f769786a51d55","declaration":"export type AbortAndDrainAgentHarnessRunResult = AbortAndDrainEmbeddedAgentRunResult;","entrypoint":"agent-harness-runtime","exportName":"AbortAndDrainAgentHarnessRunResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"6f802f46717d28d260bbcb2cf4790dfed4e9ab28c4fe822d259228189cca4665","declaration":"export type AgentApprovalEventData = AgentApprovalEventData;","entrypoint":"agent-harness-runtime","exportName":"AgentApprovalEventData","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"12aac3a57ce200b0f082002be237625729f84ed7c337f1bac90bcd0688a53319","declaration":"export type AgentEventPayload = AgentEventPayload;","entrypoint":"agent-harness-runtime","exportName":"AgentEventPayload","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"7dcb6a9a37ace260567e3b46fc8d051aa124df2edfb093dfb56f082da56eaafb","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"agent-harness-runtime","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"7dcb6a9a37ace260567e3b46fc8d051aa124df2edfb093dfb56f082da56eaafb","declaration":"export type AgentHarnessAttemptParams = AgentHarnessAttemptParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"4759104bcc383686c88c8abe83e69cf8ea009becfc6321e2924f447eab73ea04","declaration":"export type AgentHarnessAttemptParamsV2 = AgentHarnessAttemptParamsV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"beb6715ad81487caa2cc2ed8444d6ac2dcf83c9e71fc42d5db9214f2eb12b946","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"agent-harness-runtime","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"beb6715ad81487caa2cc2ed8444d6ac2dcf83c9e71fc42d5db9214f2eb12b946","declaration":"export type AgentHarnessAttemptParams = AgentHarnessAttemptParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"f6c32237f90d7fe6a024b516a1dd2e6e395c42c66db291649dab441fc1a84ebc","declaration":"export type AgentHarnessAttemptParamsV2 = AgentHarnessAttemptParamsV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"2dc2a62deea63b316142cc28d40afb88fb0d1e5ce3e048f141420726cda38248","declaration":"export type AgentHarnessAttemptResult = AgentHarnessAttemptResult;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"c7e642234081160830fd9e18a301900f8ee1ea8c88382e5a286f0102de65b714","declaration":"export type AgentHarnessAuthBindingFingerprintParams = AgentHarnessAuthBindingFingerprintParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAuthBindingFingerprintParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"8f496ce75f294f2aa440a49c3c5b065ddff3a4714df775116af5e53f7fd14fe5","declaration":"export type AgentHarnessCompactParams = CompactEmbeddedAgentSessionParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessCompactParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -239,8 +239,8 @@ {"closureHash":"95741b872a194687696eba4a96d6e887ec6e81922695fa0282cb2f0a46ff9f97","declaration":"export type AgentHarnessSessionForkParams = AgentHarnessSessionForkParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSessionForkParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"f3c46fef2a6c3393b0b6ad09915abcd6f57bb58ddddde1d60ebd113c7a1c5fa6","declaration":"export type AgentHarnessSessionForkResult = AgentHarnessSessionForkResult;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSessionForkResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"4b5a59730309e446f59a8ec1c554c3193aca1959916594cc657347098bd67a35","declaration":"export type AgentHarnessSettledTurnFinalizationResult = AgentHarnessSettledTurnFinalizationResult;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSettledTurnFinalizationResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"2b1360e9069d9dd3d4f620b5dd962312128d71e0ce2c91d28b521dc97a8f5785","declaration":"export type AgentHarnessSideQuestionParams = AgentHarnessSideQuestionParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"468d17121be5df45a4f9af2a72586e03deccdee7c56c43577bbd209541498ab7","declaration":"export type AgentHarnessSideQuestionParamsV2 = AgentHarnessSideQuestionParamsV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"cf635da6cc7587a76acf23e9b488e32f10eac93aa77df664c79f8e9d9e7117be","declaration":"export type AgentHarnessSideQuestionParams = AgentHarnessSideQuestionParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"9cccf03a0d94f0917acf7759c8438a447288490f2fa616890e342faa501f84ac","declaration":"export type AgentHarnessSideQuestionParamsV2 = AgentHarnessSideQuestionParamsV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"e84173055503c40dd0ef1cb67c6d333e9257fcf995252b27c1d58f5c904e8de9","declaration":"export type AgentHarnessSideQuestionResult = AgentHarnessSideQuestionResult;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"50214d08dac98e3c1031ac7bc733b85cb2247d2eef3b6b2045758ae403af7647","declaration":"export type AgentHarnessSupport = AgentHarnessSupport;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSupport","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"50ce26ea22bc225e6064d2e5f37356b6f4ce4a57f8b9fa42baa4c421da48aad5","declaration":"export type AgentHarnessSupportContext = AgentHarnessSupportContext;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSupportContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -250,7 +250,7 @@ {"closureHash":"20a8a7c9d5ebf425c30d04eea3856c6c889a210f1fa53c99299df24882afbf92","declaration":"export type AgentHarnessUserInputOption = AgentHarnessUserInputOption;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessUserInputOption","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"6224f51b4189f10a642ad7a8f50a902ec9c6a6a4f8dac0c0de5f7bc0271a27e5","declaration":"export type AgentHarnessUserInputPromptOptions = AgentHarnessUserInputPromptOptions;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessUserInputPromptOptions","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"d8f1d0d118e1536d1f442d11aeae5334a1ed7949a6c61824f547e12931278e1d","declaration":"export type AgentHarnessUserInputQuestion = AgentHarnessUserInputQuestion;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessUserInputQuestion","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"65b516857f90d0029866f6b3179cac436dd6d5c85bd83d440fbcdbe297b70bf6","declaration":"export type AgentHarnessV2 = AgentHarnessV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"ca20bb488d357785086f04e71b0fbb300a27325217bdbfdcaa1e422eca2fe255","declaration":"export type AgentHarnessV2 = AgentHarnessV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"6f2af683e6a31cde5a473c77e4f03b8f0ec0351a43ea6c0af979d5b394a33f95","declaration":"export type AgentMessage = AgentMessage;","entrypoint":"agent-harness-runtime","exportName":"AgentMessage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"4ad0f830bf11a2db48faefb1439b7fbb08d9f22d22083f5e301327e1696f2310","declaration":"export type AgentToolResultMiddleware = AgentToolResultMiddleware;","entrypoint":"agent-harness-runtime","exportName":"AgentToolResultMiddleware","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"185abd2870d4df65e9f94623061a30167d17ef1279a395201fe429398bf23500","declaration":"export type AgentToolResultMiddlewareContext = AgentToolResultMiddlewareContext;","entrypoint":"agent-harness-runtime","exportName":"AgentToolResultMiddlewareContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -276,8 +276,8 @@ {"closureHash":"f30b66fd82f4cd61c60880c42a3b8f152ca64a2216036c99ab4129648ed329e7","declaration":"export type EmbeddedAgentCompactResult = EmbeddedAgentCompactResult;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedAgentCompactResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"1bbefad0193436f7340f9d0dec50be5d482973325c3c65170b1053dd05566f0a","declaration":"export type EmbeddedContextFile = EmbeddedContextFile;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedContextFile","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"f30b66fd82f4cd61c60880c42a3b8f152ca64a2216036c99ab4129648ed329e7","declaration":"export type EmbeddedPiCompactResult = EmbeddedAgentCompactResult;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedPiCompactResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"f6d52c26f4fa2377f384935aa8e0c990e8634bc56ddc35dc00f4e00d5dfd16c4","declaration":"export type EmbeddedRunAttemptParams = EmbeddedRunAttemptParams;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"b96b39b906aac64a2ce1037ad8f6f811f832b5189f53c0c966cba9dedca50615","declaration":"export type EmbeddedRunAttemptParamsV2 = EmbeddedRunAttemptParamsV2;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"b0d89fe509b6812c24b0a46cbc7d7ce20af0009a415c4e50e20c41af02975b11","declaration":"export type EmbeddedRunAttemptParams = EmbeddedRunAttemptParams;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"67dd9a76536fb9f41ae19a380dd71bf10682ff80c63f363b5f8511948dcd1aab","declaration":"export type EmbeddedRunAttemptParamsV2 = EmbeddedRunAttemptParamsV2;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"a6cd2eebe0d06e6578ec8438a207c5acda10ddf01041102d84e4c576a0b6a42f","declaration":"export type EmbeddedRunAttemptResult = EmbeddedRunAttemptResult;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"f6cb2d6411db22cb04220777720d97bcca977351de1cb344ce2ff6ab126a3a08","declaration":"export type ExecApprovalDecision = ExecApprovalDecision;","entrypoint":"agent-harness-runtime","exportName":"ExecApprovalDecision","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"7acb0dffc7bcda6c727e4afc49c955fa8e14f4f8b7b45d197ed794dfa0d73b8e","declaration":"export type ExecAutoReviewDecision = ExecAutoReviewDecision;","entrypoint":"agent-harness-runtime","exportName":"ExecAutoReviewDecision","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -293,7 +293,7 @@ {"closureHash":"d8a720bf250da8daf4dc6a52087aa8728ec33b650b3ad3906fcb40e184de66a0","declaration":"export type NativeHookRelayEvent = \"pre_tool_use\" | \"post_tool_use\" | \"permission_request\" | \"before_agent_finalize\";","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayEvent","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"cb72ca18e056e52e779d0bf5b67606e1dc160f225225b55937304541105027f4","declaration":"export type NativeHookRelayProcessResponse = NativeHookRelayProcessResponse;","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayProcessResponse","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"6ee390ae4784b987e22255c16884fb4171033bbfaefaef655e3dd2a2513814a5","declaration":"export type NativeHookRelayProvider = \"codex\";","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayProvider","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"41d1cdaabc26115ac40b9eed172da5f18884e89ac8e198e1b63f48a685f66d62","declaration":"export type NativeHookRelayRegistrationHandle = NativeHookRelayRegistrationHandle;","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayRegistrationHandle","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"b8476f913ffe1828ec5a260a90635d3766d93e772f6a34f804e45ba287c863c5","declaration":"export type NativeHookRelayRegistrationHandle = NativeHookRelayRegistrationHandle;","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayRegistrationHandle","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"90fea3155b5d32c22da90460fdaea94e4fd6895d1ca18613d5036ba9835153f9","declaration":"export type NodeListNode = NodeListNode;","entrypoint":"agent-harness-runtime","exportName":"NodeListNode","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"cefe1325c866f0cf7edbec5dcca2fe420237eedc0da172e54094e365894d205d","declaration":"export type NormalizedUsage = NormalizedUsage;","entrypoint":"agent-harness-runtime","exportName":"NormalizedUsage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"25c6985c3bef2c6efc6a96e70af6d66fe073be0b0d311681cfc0dfc186528d9a","declaration":"export type OpenClawAgentToolResult = OpenClawAgentToolResult;","entrypoint":"agent-harness-runtime","exportName":"OpenClawAgentToolResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -762,7 +762,7 @@ {"closureHash":"9fc7e45343d7bfbc6ed56d05c9f824e2f9cf6dafc3ca4b25437d1f65c2200727","declaration":"export function clearAccountEntryFields(params: { accounts?: Record; accountId: string; fields: string[]; isValueSet?: (value: unknown) => boolean; markClearedOnFieldPresence?: boolean; }): { nextAccounts?: Record; changed: boolean; cleared: boolean; };","entrypoint":"channel-core","exportName":"clearAccountEntryFields","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"f4e084b64b5376268885a73fe507aa5d72bee17a88ec2d7ae1d19b36ea5f1ccb","declaration":"export function createChannelConfigUiHints(params: { channelLabel: string; dmPolicy?: { channelKey: string; includeLegacyNestedPolicy?: boolean; legacyNestedPolicyOrder?: \"before\" | \"after\"; }; configWrites?: boolean; mentionPatterns?: { targetDescription: string; policyTargetDescription?: string; policyNote?: string; denyNote?: string; }; nativeCommands?: boolean; implicitMentions?: boolean; progress?: { includeCommentary?: boolean; commentaryOrder?: \"before-command\" | \"after-command\"; labels?: \"openclaw\"; titleWording?: boolean; }; streaming?: Partial>; retry?: boolean; }): HintMap;","entrypoint":"channel-core","exportName":"createChannelConfigUiHints","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"ed8c322fac76968f7f059b65eaa881098ed918b22705b27101166b052213a9d9","declaration":"export function createChatChannelPlugin(params: { base: ChatChannelPluginBase; security?: ChannelSecurityAdapter | ChatChannelSecurityOptions; pairing?: ChannelPairingAdapter | ChatChannelPairingOptions; threading?: ChannelThreadingAdapter | ChatChannelThreadingOptions; outbound?: ChannelOutboundAdapter | ChatChannelAttachedOutboundOptions; }): ChannelPlugin;","entrypoint":"channel-core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} -{"closureHash":"20eebe7796477e813273c70382bb74de3f830573f2a84500e969305ad6f7d251","declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"channel-core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} +{"closureHash":"19ee364314ffd766b8f15835014206753bc1f96af4d7084b97a0a1cc2774d4df","declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"channel-core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"0e0a9f6b22c433bdb1165e7707b1f9a1ddac1a544a8add00b503048cf157921f","declaration":"export function defineSetupPluginEntry(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"channel-core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"8895f7af11b994dcd383ebc45d8cfb3c3b1e6d88a64a001b04f604a83f77e517","declaration":"export function parseOptionalDelimitedEntries(value?: string): string[] | undefined;","entrypoint":"channel-core","exportName":"parseOptionalDelimitedEntries","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"3f97534b10d08eda6a556044eaba623b3744b200a2033877e48d2f2ca924d298","declaration":"export function recoverCurrentThreadSessionId(params: { route: ChannelOutboundSessionRoute; currentSessionKey?: string | null; canRecover?: (context: ThreadAwareOutboundSessionRouteRecoveryContext) => boolean; }): string | undefined;","entrypoint":"channel-core","exportName":"recoverCurrentThreadSessionId","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} @@ -774,24 +774,24 @@ {"closureHash":"8126b651a679d5435eaa5d346916792c3600e5f0f75d8492b077dd361854f7dd","declaration":"export type ChannelOutboundSessionRouteParams = { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; currentSessionKey?: string; resolvedTarget?: { to: string; kind: ChannelDirectoryEntryKind | \"channel\"; display?: string; source: \"normalized\" | \"directory\"; }; replyToId?: string | null; threadId?: string | number | null;};","entrypoint":"channel-core","exportName":"ChannelOutboundSessionRouteParams","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"closureHash":"37b373e93fe7f86fdbce119947c9e49b17e71ce50fe47e118c4d078c5cea20ec","declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"channel-core","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"closureHash":"8054ffbe71b91afced900ef8a06f5e368ac10c4f99ffc1c6f538a802856f61c6","declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"channel-core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} -{"closureHash":"6545dfa8438683bf5ab321b0fe5cca9e47fc91686fe2166f8abc9084ce170aac","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} +{"closureHash":"ff621688b7b58ebfa7157b153d83921a47158e43ac312ff61df85d437216ae10","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"closureHash":"86553d1dbbbe0b04d6a233ab33dee847c2cf5f9809032d41c5bff6bec06b78da","declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"channel-core","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"closureHash":"4180509522e17f8afdf5014bc51f5601952dfa403503e66f8f53e92b0c6ddd45","declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"channel-core","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"category":"channel","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy","recordType":"module"} {"closureHash":"821fe6ccd0d9323322bf70c4d1ca5697071a0e32032f44c1e14157162c2c729d","declaration":"export function createChannelDmPolicy(params: CreateChannelDmPolicyParams): ChannelSetupDmPolicy & { promptAllowFrom: NonNullable; };","entrypoint":"channel-dm-policy","exportName":"createChannelDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy","kind":"function","recordType":"export"} {"category":null,"entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","recordType":"module"} -{"closureHash":"69a48a0339b8298cbf357f4b03fd5d0bdd64dd0900b1c9aa0eec9ea1d967f9ed","declaration":"export function defineBundledChannelEntry({ id, name, description, importMetaUrl, plugin, outbound, secrets, configSchema, runtime, accountInspect, features, registerCliMetadata, registerFull, registerCapabilities, }: DefineBundledChannelEntryOptions): BundledChannelEntryContract;","entrypoint":"channel-entry-contract","exportName":"defineBundledChannelEntry","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} -{"closureHash":"a9d4fe591235caf5ebf66ed031d29a7b23bfe4b83fe3506ee97f61f02ce31165","declaration":"export function defineBundledChannelSetupEntry({ importMetaUrl, plugin, secrets, runtime, legacyStateMigrations, legacySessionSurface, registerSetupRuntime, features, }: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract;","entrypoint":"channel-entry-contract","exportName":"defineBundledChannelSetupEntry","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} +{"closureHash":"449cfd12281e321f062ab0decf424e0dcdc9c4542aaed374c1d513c2e520a0fb","declaration":"export function defineBundledChannelEntry({ id, name, description, importMetaUrl, plugin, outbound, secrets, configSchema, runtime, accountInspect, features, registerCliMetadata, registerFull, registerCapabilities, }: DefineBundledChannelEntryOptions): BundledChannelEntryContract;","entrypoint":"channel-entry-contract","exportName":"defineBundledChannelEntry","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} +{"closureHash":"73376c2d9f2518d075102db21266e4ed390cff19ee17167d8d6dcaa90391aca4","declaration":"export function defineBundledChannelSetupEntry({ importMetaUrl, plugin, secrets, runtime, legacyStateMigrations, legacySessionSurface, registerSetupRuntime, features, }: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract;","entrypoint":"channel-entry-contract","exportName":"defineBundledChannelSetupEntry","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} {"closureHash":"cd150287d7e286e7a0d7d6caeb05d0a1efa63b5f351f75f69ba932510a108afd","declaration":"export function loadBundledEntryExportSync(importMetaUrl: string, reference: BundledEntryModuleRef, options?: BundledEntryModuleLoadOptions): T;","entrypoint":"channel-entry-contract","exportName":"loadBundledEntryExportSync","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} {"closureHash":"7781a3d570d08929af5b3b6cffbb9ef4b9c8e8f1bed55234e0015a681868c812","declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"channel-entry-contract","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} -{"closureHash":"fa5a588c0ce180e7a20042afea7ea0159187359a8642759d7009f3debdca5ae0","declaration":"export type BundledChannelEntryContract = BundledChannelEntryContract;","entrypoint":"channel-entry-contract","exportName":"BundledChannelEntryContract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} +{"closureHash":"685f95aaa394a91f73185ac30346f2046707aa09c77e4cfc27a100bc3ec23f15","declaration":"export type BundledChannelEntryContract = BundledChannelEntryContract;","entrypoint":"channel-entry-contract","exportName":"BundledChannelEntryContract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"f4b82dfd995bd6f08b232984743389d1c854866cb845a8dbb3605acb28818688","declaration":"export type BundledChannelEntryFeatures = BundledChannelEntryFeatures;","entrypoint":"channel-entry-contract","exportName":"BundledChannelEntryFeatures","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"960693591b9bdc974bf3f0097076d90490078db3dceb35349a4f851736cde4fc","declaration":"export type BundledChannelLegacySessionSurface = BundledChannelLegacySessionSurface;","entrypoint":"channel-entry-contract","exportName":"BundledChannelLegacySessionSurface","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"edf79e6a736e5c4977e3b86ef21e7fdf0c0b2bc7deacd8edbee2ca76cb8cef43","declaration":"export type BundledChannelLegacyStateMigrationDetector = BundledChannelLegacyStateMigrationDetector;","entrypoint":"channel-entry-contract","exportName":"BundledChannelLegacyStateMigrationDetector","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} -{"closureHash":"8bf37d6eeb66e312f279aa9230c6f35058758d2748e0e336bc43265d961cfce2","declaration":"export type BundledChannelSetupEntryContract = BundledChannelSetupEntryContract;","entrypoint":"channel-entry-contract","exportName":"BundledChannelSetupEntryContract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} +{"closureHash":"f95a404850bcbf1c6ec8227f92ecdf94e65f96de06f63f2f993a5d838b831f62","declaration":"export type BundledChannelSetupEntryContract = BundledChannelSetupEntryContract;","entrypoint":"channel-entry-contract","exportName":"BundledChannelSetupEntryContract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"24679ecdf2ff34157b6345a37b58ec1363ec8cb1969b522599d0d92bb7a113b7","declaration":"export type BundledChannelSetupEntryFeatures = BundledChannelSetupEntryFeatures;","entrypoint":"channel-entry-contract","exportName":"BundledChannelSetupEntryFeatures","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"367219c821915e5be4d1ba3a86754475a5f8a4194512f0401de3be11cc649c60","declaration":"export type BundledEntryModuleLoadOptions = BundledEntryModuleLoadOptions;","entrypoint":"channel-entry-contract","exportName":"BundledEntryModuleLoadOptions","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} -{"closureHash":"83a2ebce6f822a66006deff2ec1d23f7fde3e1da68e23d5c3d57d152f857726a","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-entry-contract","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} +{"closureHash":"fcee79acac847664aaaa8af8392ae3797cd55ac1e38dfbb622fafa5b7d3cb0bf","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-entry-contract","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"e54f9b270df967e0f857de804dbe989f2c55b637c449c9948c4baae8eee18c7b","declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"channel-entry-contract","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"568b5ab033b4970cedd3916d948d31bcdd3c19ea8928c7cd7dc7abf7623b8af8","declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"channel-entry-contract","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"category":null,"entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback","recordType":"module"} @@ -1103,7 +1103,7 @@ {"closureHash":"9555a73ba2717bba0bf94888017be4cecef2c51cc3887ed34f8d8ef3c79d6abf","declaration":"export const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS: number;","entrypoint":"channel-message","exportName":"DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"3a2df3ab656149630c004d2d9f71b8ceda99aa81eb6b72b0cc7b3982ed0ce044","declaration":"export const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS: 8;","entrypoint":"channel-message","exportName":"DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"38c735a14c65067aa7825390d1c5c8876c651d0603e84980dfaf947f5235747a","declaration":"export const INGRESS_CLAIM_PROCESS_ID: string;","entrypoint":"channel-message","exportName":"INGRESS_CLAIM_PROCESS_ID","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} -{"closureHash":"50a03b18eb2b53c0e4f3aea18130b93e600214baf98686ce749addf6f8004a66","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-message","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} +{"closureHash":"49e98f675c3f385cf2acea1d1b7480f1a510449c1a04bf3d65475b9a7492d2dd","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-message","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"2c52eff200a5b9bd2d93ee72209c2626bbe165f59b538591db661b94773023b0","declaration":"export type AgentPlanStep = AgentPlanStep;","entrypoint":"channel-message","exportName":"AgentPlanStep","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} {"closureHash":"1e8bcb2c8d8f5327f5b81b8c217f722f090dccba21ba3943324010f4f80a5a50","declaration":"export type AgentPlanStepStatus = AgentPlanStepStatus;","entrypoint":"channel-message","exportName":"AgentPlanStepStatus","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} {"closureHash":"b036fc26e5330dfd7da1af8839bb3c23e85283b9e16e252a659dd4bb0c35cd5e","declaration":"export type ChannelDeliveryStreamingConfig = ChannelDeliveryStreamingConfig;","entrypoint":"channel-message","exportName":"ChannelDeliveryStreamingConfig","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} @@ -1232,7 +1232,7 @@ {"closureHash":"9555a73ba2717bba0bf94888017be4cecef2c51cc3887ed34f8d8ef3c79d6abf","declaration":"export const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS: number;","entrypoint":"channel-outbound","exportName":"DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"3a2df3ab656149630c004d2d9f71b8ceda99aa81eb6b72b0cc7b3982ed0ce044","declaration":"export const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS: 8;","entrypoint":"channel-outbound","exportName":"DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"38c735a14c65067aa7825390d1c5c8876c651d0603e84980dfaf947f5235747a","declaration":"export const INGRESS_CLAIM_PROCESS_ID: string;","entrypoint":"channel-outbound","exportName":"INGRESS_CLAIM_PROCESS_ID","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} -{"closureHash":"50a03b18eb2b53c0e4f3aea18130b93e600214baf98686ce749addf6f8004a66","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-outbound","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} +{"closureHash":"49e98f675c3f385cf2acea1d1b7480f1a510449c1a04bf3d65475b9a7492d2dd","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-outbound","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"2c52eff200a5b9bd2d93ee72209c2626bbe165f59b538591db661b94773023b0","declaration":"export type AgentPlanStep = AgentPlanStep;","entrypoint":"channel-outbound","exportName":"AgentPlanStep","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} {"closureHash":"1e8bcb2c8d8f5327f5b81b8c217f722f090dccba21ba3943324010f4f80a5a50","declaration":"export type AgentPlanStepStatus = AgentPlanStepStatus;","entrypoint":"channel-outbound","exportName":"AgentPlanStepStatus","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} {"closureHash":"b036fc26e5330dfd7da1af8839bb3c23e85283b9e16e252a659dd4bb0c35cd5e","declaration":"export type ChannelDeliveryStreamingConfig = ChannelDeliveryStreamingConfig;","entrypoint":"channel-outbound","exportName":"ChannelDeliveryStreamingConfig","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} @@ -1298,7 +1298,7 @@ {"closureHash":"cd6b0d3f962c8cb40d405e92bb06bb8e9285b386200897f550d21036022262c1","declaration":"export const PAIRING_APPROVED_MESSAGE: \"✅ OpenClaw access approved. Send a message to start chatting.\";","entrypoint":"channel-plugin-common","exportName":"PAIRING_APPROVED_MESSAGE","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"const","recordType":"export"} {"closureHash":"712435252d2fe5135542ca6b5a4375a6edea24e83829338011dc6ae7e9802fbb","declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"channel-plugin-common","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} {"closureHash":"37b373e93fe7f86fdbce119947c9e49b17e71ce50fe47e118c4d078c5cea20ec","declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"channel-plugin-common","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} -{"closureHash":"6545dfa8438683bf5ab321b0fe5cca9e47fc91686fe2166f8abc9084ce170aac","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-plugin-common","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} +{"closureHash":"ff621688b7b58ebfa7157b153d83921a47158e43ac312ff61df85d437216ae10","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-plugin-common","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} {"closureHash":"4180509522e17f8afdf5014bc51f5601952dfa403503e66f8f53e92b0c6ddd45","declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"channel-plugin-common","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} {"category":null,"entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy","recordType":"module"} {"closureHash":"017ab29d16d0f3d02d60f3513e65a373868d26496bc93d020d8fc8c17fd875f4","declaration":"export function buildAccountScopedDmSecurityPolicy(params: { cfg: OpenClawConfig; channelKey: string; accountId?: string | null; fallbackAccountId?: string | null; policy?: string | null; allowFrom?: Array | null; defaultPolicy?: string; allowFromPathSuffix?: string; policyPathSuffix?: string; approveChannelId?: string; approveHint?: string; normalizeEntry?: (raw: string) => string; inheritSharedDefaultsFromDefaultAccount?: boolean; }): ChannelSecurityDmPolicy;","entrypoint":"channel-policy","exportName":"buildAccountScopedDmSecurityPolicy","importSpecifier":"openclaw/plugin-sdk/channel-policy","kind":"function","recordType":"export"} @@ -1897,8 +1897,8 @@ {"closureHash":"ed8c322fac76968f7f059b65eaa881098ed918b22705b27101166b052213a9d9","declaration":"export function createChatChannelPlugin(params: { base: ChatChannelPluginBase; security?: ChannelSecurityAdapter | ChatChannelSecurityOptions; pairing?: ChannelPairingAdapter | ChatChannelPairingOptions; threading?: ChannelThreadingAdapter | ChatChannelThreadingOptions; outbound?: ChannelOutboundAdapter | ChatChannelAttachedOutboundOptions; }): ChannelPlugin;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"785b0f750c5bd20f9f3ca081886a5af79ee0e4d2c38f78f769bf994b234f5076","declaration":"export function createDedupeCache(options: DedupeCacheOptions): DedupeCache;","entrypoint":"core","exportName":"createDedupeCache","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"e2df8d235401aebf58eb71652fbeaedb42ca00aaa5042a76909ff2c48ba6fd54","declaration":"export function createSubsystemLogger(subsystem: string): SubsystemLogger;","entrypoint":"core","exportName":"createSubsystemLogger","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} -{"closureHash":"20eebe7796477e813273c70382bb74de3f830573f2a84500e969305ad6f7d251","declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} -{"closureHash":"60eaea4ee788df80eb81ba87d49e396cb9ffb40eb7d6bfd74fbcd817a34f3dcc","declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, reload, nodeHostCommands, securityAuditCollectors, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"core","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} +{"closureHash":"19ee364314ffd766b8f15835014206753bc1f96af4d7084b97a0a1cc2774d4df","declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} +{"closureHash":"ea866f93256b2e5f8da5bad7c96511f00f9b42fa85f6689cc70ab26bde2dd0cb","declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, reload, nodeHostCommands, securityAuditCollectors, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"core","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"0e0a9f6b22c433bdb1165e7707b1f9a1ddac1a544a8add00b503048cf157921f","declaration":"export function defineSetupPluginEntry(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"3c5ce0c3733cbb038aeb914af1df5d701f362f0521921b8abd0c9214db9cc4b8","declaration":"export function delegateCompactionToRuntime(params: Parameters[0]): Promise;","entrypoint":"core","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"2c282e0e308f9442bca7cdeb8e89a097531b1d54a692c9d86e5f39a8db1c2537","declaration":"export function deleteAccountFromConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; accountId: string; clearBaseFields?: string[]; }): OpenClawConfig;","entrypoint":"core","exportName":"deleteAccountFromConfigSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} @@ -1945,7 +1945,7 @@ {"closureHash":"2e6ce1f4c1692270a00dc0cf08e2752a26739c3143f8091cb270063c6034605d","declaration":"export function tryReadSecretFileSync(filePath: string | undefined, label: string, options: CredentialFileReadOptions): string | undefined;\nexport function tryReadSecretFileSync(filePath: string | undefined, label: string, options?: FsSafeSecretFileReadOptions): string | undefined;\nexport function tryReadSecretFileSync(filePath: string, label: string, options: FsSafeSecretFileReadOptions | undefined, diagnostic: { configPath: string; }): ConfiguredCredentialResult;\nexport function tryReadSecretFileSync(filePath: string | undefined, label: string, options: FsSafeSecretFileReadOptions | undefined, diagnostic: { configPath: string; }): CredentialResult;","entrypoint":"core","exportName":"tryReadSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"b40933a11984425d14a7a479bda0d2cd14cbfa9371164774f39bebbf12861df7","declaration":"export const DEFAULT_ACCOUNT_ID: \"default\";","entrypoint":"core","exportName":"DEFAULT_ACCOUNT_ID","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export"} {"closureHash":null,"declaration":"export const DEFAULT_SECRET_FILE_MAX_BYTES: number;","entrypoint":"core","exportName":"DEFAULT_SECRET_FILE_MAX_BYTES","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export"} -{"closureHash":"7dcb6a9a37ace260567e3b46fc8d051aa124df2edfb093dfb56f082da56eaafb","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"core","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} +{"closureHash":"beb6715ad81487caa2cc2ed8444d6ac2dcf83c9e71fc42d5db9214f2eb12b946","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"core","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"d2ff4de9ee3fd111c2cc64a3ee7e557d9f161af1b6b437ee7886bcb16e426317","declaration":"export type AgentPromptGuidance = AgentPromptGuidance;","entrypoint":"core","exportName":"AgentPromptGuidance","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"2841127a90e04467adfea8126cbf03655f44ddb089ad9e0d48833c2beea39cbd","declaration":"export type AgentPromptGuidanceEntry = AgentPromptGuidanceEntry;","entrypoint":"core","exportName":"AgentPromptGuidanceEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"aa0412acefe62caff12e5271ead0545504874e824380dc21cfc0566388a05f33","declaration":"export type AgentPromptSurfaceKind = \"openclaw_main\" | \"pi_main\" | \"codex_app_server\" | \"cli_backend\" | \"acp_backend\" | \"subagent\";","entrypoint":"core","exportName":"AgentPromptSurfaceKind","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} @@ -1977,10 +1977,10 @@ {"closureHash":"54ea426dde51859b572b4c1079d2b70b412096f73da2b8c265f2749d9f01cbb5","declaration":"export type NormalizedLocation = NormalizedLocation;","entrypoint":"core","exportName":"NormalizedLocation","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"8054ffbe71b91afced900ef8a06f5e368ac10c4f99ffc1c6f538a802856f61c6","declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"5189698d102b8b064eefe1442b008312973c3994fd6b203d2205725d7ee9d118","declaration":"export type OpenClawPluginActiveModelContext = OpenClawPluginActiveModelContext;","entrypoint":"core","exportName":"OpenClawPluginActiveModelContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} -{"closureHash":"6545dfa8438683bf5ab321b0fe5cca9e47fc91686fe2166f8abc9084ce170aac","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} +{"closureHash":"ff621688b7b58ebfa7157b153d83921a47158e43ac312ff61df85d437216ae10","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"c968e96cdb5c97e92d26b7b15fb5255531b45a12e7c5c0a2e7e456d31cc6e358","declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"365beb5cee974639c2f35fc7a39088721ca8e9c618442830298b98033e9c768c","declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} -{"closureHash":"0fd3922f47a01a793d0b383cf6744734aae5c4fc2990344c2ed7b0969ae1d309","declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} +{"closureHash":"05cefddc1e544e0efd37301f07a5adae39f6cc580239b314c1959b8887a23ce9","declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"a7ed83f0924f818b23cecb5a1f3591ff28b9499ccac9667a2b8505c2bc608ea9","declaration":"export type OpenClawPluginGatewayEventScope = OpenClawPluginGatewayEventScope;","entrypoint":"core","exportName":"OpenClawPluginGatewayEventScope","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"f81f224d9b837e6353b7a546c5a76b124bcf568fd1f1fd0c6881fda30c53120a","declaration":"export type OpenClawPluginGatewayEvents = OpenClawPluginGatewayEvents;","entrypoint":"core","exportName":"OpenClawPluginGatewayEvents","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"ddc2738dd0ece94ddde9a7c655f92c15ec6416645ecf932d4aa404c7b7761159","declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} @@ -2215,7 +2215,7 @@ {"closureHash":"0db968dad99d84ecef5202167daadd092e3b4f456270283d3fd61585ac2bbd5a","declaration":"export type DiscordComponentSendResult = DiscordComponentSendResult;","entrypoint":"discord","exportName":"DiscordComponentSendResult","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"084b46d7dd510eb182a4705559251e824220b57dbfa6dc54392507b72ba1815e","declaration":"export type DiscordOutboundTargetResolution = DiscordOutboundTargetResolution;","entrypoint":"discord","exportName":"DiscordOutboundTargetResolution","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"8054ffbe71b91afced900ef8a06f5e368ac10c4f99ffc1c6f538a802856f61c6","declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"discord","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} -{"closureHash":"6545dfa8438683bf5ab321b0fe5cca9e47fc91686fe2166f8abc9084ce170aac","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"discord","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} +{"closureHash":"ff621688b7b58ebfa7157b153d83921a47158e43ac312ff61df85d437216ae10","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"discord","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"4180509522e17f8afdf5014bc51f5601952dfa403503e66f8f53e92b0c6ddd45","declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"discord","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"6b9967e919c1cd420c8356b4f9d40dbc81932b4b15a733eecdcfb2ad84f8e5d8","declaration":"export type ResolvedDiscordAccount = ResolvedDiscordAccount;","entrypoint":"discord","exportName":"ResolvedDiscordAccount","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"6bed5ca896b7034117e4757e2c6b4111dd5293dbad9416e3828dd6afa4e858ce","declaration":"export type ThreadBindingRecord = ThreadBindingRecord;","entrypoint":"discord","exportName":"ThreadBindingRecord","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} @@ -2360,7 +2360,7 @@ {"closureHash":"98e0cc16352b7dfcceb4957f8cd2acfe3c2b3acb223c42a5cfb885e088fcb04c","declaration":"export function resolveInboundReplyDispatchCounts(result: ChannelTurnDispatchResultLike): Record;","entrypoint":"inbound-reply-dispatch","exportName":"resolveInboundReplyDispatchCounts","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} {"closureHash":"4cd63266d379acc2cdf9aba02f50b8ceb55500cf820787c370fc98d173fb1950","declaration":"export function runChannelInboundEvent(params: RunChannelTurnParams): Promise>;\nexport function runChannelInboundEvent(params: ChannelInboundEventRunnerParams): Promise>;","entrypoint":"inbound-reply-dispatch","exportName":"runChannelInboundEvent","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} {"closureHash":"2c68496a174872b31aaf1ac8fad2789deb79d490c5ffbcb157325f1c95d1b51f","declaration":"export function runPreparedInboundReply(params: PreparedChannelTurn): Promise>;","entrypoint":"inbound-reply-dispatch","exportName":"runPreparedInboundReply","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} -{"closureHash":"50a03b18eb2b53c0e4f3aea18130b93e600214baf98686ce749addf6f8004a66","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"inbound-reply-dispatch","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"const","recordType":"export"} +{"closureHash":"49e98f675c3f385cf2acea1d1b7480f1a510449c1a04bf3d65475b9a7492d2dd","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"inbound-reply-dispatch","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"const","recordType":"export"} {"closureHash":"ab30d0470348e3af7c18a273afc0d169d91f204fba557c75c4694e8a0ea6c936","declaration":"export type AssembledInboundReply = AssembledChannelTurn;","entrypoint":"inbound-reply-dispatch","exportName":"AssembledInboundReply","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} {"closureHash":"3de0d6d1bb8f9a83bd1c6e57aa8618e3fea71054236cd530c555e348bd0b7702","declaration":"export type ChannelBotLoopProtectionFacts = ChannelBotLoopProtectionFacts;","entrypoint":"inbound-reply-dispatch","exportName":"ChannelBotLoopProtectionFacts","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} {"closureHash":"f0da845bbdf49b30f6718a4b7ff012a0536deb98f65418583555dc90825797ca","declaration":"export type ChannelInboundDroppedHistoryOptions = ChannelTurnDroppedHistoryOptions;","entrypoint":"inbound-reply-dispatch","exportName":"ChannelInboundDroppedHistoryOptions","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} @@ -3195,19 +3195,19 @@ {"closureHash":"3362ea0b650933db6db467ef8ae1c82e682e946761647c8dfae401eec1237bd6","declaration":"export function callMeetingBrowserProxyOnNode(params: { runtime: PluginRuntime; adapter: NodeAdapter; nodeId: string; } & MeetingBrowserRequestParams): Promise;","entrypoint":"meeting-runtime","exportName":"callMeetingBrowserProxyOnNode","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c5fcd1d157edcb4773235adfaf8051e3ec7ca428cb7916b6d55f2511affab56c","declaration":"export function convertMeetingBridgeAudioForStt(audio: Buffer, audioFormat: MeetingRealtimeAudioFormat): Buffer;","entrypoint":"meeting-runtime","exportName":"convertMeetingBridgeAudioForStt","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"aa9063c27c5ff714d3653571c4a2a71e9d2eea5609b161f03e4738e15411358b","declaration":"export function convertMeetingTtsAudioForBridge(audio: Buffer, sampleRate: number, audioFormat: MeetingRealtimeAudioFormat, outputFormat?: string, platformName?: string): Buffer;","entrypoint":"meeting-runtime","exportName":"convertMeetingTtsAudioForBridge","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"100fe96a062f631b3d357570ee03626683a69465aeb2afee265a31e7153a31d9","declaration":"export function createLocalMeetingRealtimeAudioTransport(params: { inputCommand: string[]; outputCommand: string[]; bargeInInputCommand?: string[]; bargeInRmsThreshold: number; bargeInPeakThreshold: number; bargeInCooldownMs: number; logger: RuntimeLogger; logScope: string; audioFormat?: MeetingRealtimeAudioFormat; spawn?: MeetingRealtimeAudioSpawn; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createLocalMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"41a29ffc111d6672c56e82aa27884261d3d23aded7b02186e1acd9817f450fa1","declaration":"export function createLocalMeetingRealtimeAudioTransport(params: { inputCommand: string[]; outputCommand: string[]; bargeInInputCommand?: string[]; bargeInRmsThreshold: number; bargeInPeakThreshold: number; bargeInCooldownMs: number; logger: RuntimeLogger; logScope: string; audioFormat?: MeetingRealtimeAudioFormat; spawn?: MeetingRealtimeAudioSpawn; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createLocalMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"0f81bc377d2158b9c8adb6878e454f34c2e57d53bdef9aa34d567d279800c240","declaration":"export function createMeetingBrowserNodeCaller(params: { runtime: PluginRuntime; adapter: NodeAdapter; nodeId: string; }): MeetingBrowserRequestCaller;","entrypoint":"meeting-runtime","exportName":"createMeetingBrowserNodeCaller","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"f244ca8c47f556c9f388953cf9a7581c7d4d413af93e0dfa1462779e6a687a7c","declaration":"export function createMeetingBrowserNodeInvokePolicy(options: MeetingBrowserNodePolicyOptions): OpenClawPluginNodeInvokePolicy;","entrypoint":"meeting-runtime","exportName":"createMeetingBrowserNodeInvokePolicy","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"1aa4e3ff39a2cbe162421c62b1fa4a362973381d4d9ff15c671249b1ebd566bc","declaration":"export function createMeetingNodeHost(options: MeetingNodeHostOptions): { handleCommand(paramsJSON?: string | null): Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingNodeHost","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"909a4ea24585b49a91095ada5ab95e0aa7d741a4554f757ddab8ef45385af1ee","declaration":"export function createMeetingRealtimeEngineBindings(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; fullConfig: OpenClawConfig; runtime: PluginRuntime; logger: RuntimeLogger; }): { platform: MeetingRuntimePlatform; consultAgent: (consult: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (call: MeetingRealtimeToolCallParams) => Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingRealtimeEngineBindings","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"7bbd4d7df1e47521e1c2a00bb1f228a7aebc93ae0a9b5fa5ed66c9c24c8a4166","declaration":"export function createMeetingRealtimeEngineBindings(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; fullConfig: OpenClawConfig; runtime: PluginRuntime; logger: RuntimeLogger; }): { platform: MeetingRuntimePlatform; consultAgent: (consult: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (call: MeetingRealtimeToolCallParams) => Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingRealtimeEngineBindings","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"9124db05923060c467edb149bd8abdc8be1d00d00f867cf84600972923a58489","declaration":"export function createMeetingSession(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { provider?: string; voiceProvider?: string; transcriptionProvider?: string; model?: string; toolPolicy: TToolPolicy; }; }; resolved: MeetingResolvedJoin; createdAt: string; }): MeetingSessionRecord;","entrypoint":"meeting-runtime","exportName":"createMeetingSession","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"fb276e07e395ab19177b3d7967fd0a8fcda79130883f15092a4a306b826481b9","declaration":"export function createMeetingSetupStatus(checks: MeetingSetupCheck[]): MeetingSetupStatus;","entrypoint":"meeting-runtime","exportName":"createMeetingSetupStatus","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"0457ac9dc4b0b2d11cdbe2070d75ae2e5c2fe5cee69017e92f849849cd06b0f3","declaration":"export function createMeetingVoiceCallGateway(params: { config: MeetingVoiceCallConfig; runtime: PluginRuntime; surface: MeetingVoiceCallSurface; connectClient: (params: { config: MeetingVoiceCallConfig; surface: MeetingVoiceCallSurface; }) => Promise; }): MeetingVoiceCallGateway;","entrypoint":"meeting-runtime","exportName":"createMeetingVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"34fdb56d1bcb289d35cdde3b9818a22020cec7a9de7de6e4a2ce028769650e1a","declaration":"export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; bridgeId: string; logger: RuntimeLogger; commandName: string; logScope: string; logPrefix: string; audioFormat?: MeetingRealtimeAudioFormat; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createNodeMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"153b475879020471ce39741d83aacf36f31aed63a58897eae9f8ae902a67814c","declaration":"export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; bridgeId: string; logger: RuntimeLogger; commandName: string; logScope: string; logPrefix: string; audioFormat?: MeetingRealtimeAudioFormat; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createNodeMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c0987b15a391a92d53d3e4e12b61eb122f9547fcf4f45478cdd3c5a8973a3e52","declaration":"export function endMeetingVoiceCallGatewayCall(params: { gateway: MeetingVoiceCallGateway; callId: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"endMeetingVoiceCallGatewayCall","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"b3ebc10fe7ab372e1317eaa8f78621cf277bba1e33fda57acf2254f6759d1e9d","declaration":"export function getMeetingVoiceCallGatewayCall(params: { gateway: MeetingVoiceCallGateway; callId: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"getMeetingVoiceCallGatewayCall","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"aac088df8e80d9ce89c45feccdb04ace3ae4417b3fcdd0f058410ddf25c96b46","declaration":"export function isMeetingVoiceCallMissingError(error: unknown): boolean;","entrypoint":"meeting-runtime","exportName":"isMeetingVoiceCallMissingError","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"d24f2c4fbb2bf5bf5d36afb40e95ec5930ac1f1c71ccd6458193ddd11c1466bd","declaration":"export function joinMeetingViaVoiceCallGateway(params: { config: MeetingVoiceCallConfig; gateway: MeetingVoiceCallGateway; surface: MeetingVoiceCallSurface; dialInNumber: string; dtmfSequence?: string; logger?: RuntimeLogger; message?: string; requesterSessionKey?: string; agentId?: string; sessionKey?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"joinMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"2ddcdaee317e00718d3061c832e4fb4a6d1a43b43e926c6136ecd553d2989167","declaration":"export function joinMeetingViaVoiceCallGateway(params: { config: MeetingVoiceCallConfig; gateway: MeetingVoiceCallGateway; surface: MeetingVoiceCallSurface; dialInNumber: string; dtmfSequence?: string; logger?: RuntimeLogger; message?: string; requesterSessionKey?: string; agentId?: string; sessionKey?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"joinMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"bac9621cd025754fbcd4c21409b06eb0503405db81f35cac993aaf0b881ab3e1","declaration":"export function leaveMeetingWithBrowser(params: { adapter: BrowserAdapter; callBrowser: MeetingBrowserRequestCaller; launch: boolean; meetingSessionId?: string; meetingUrl: string; tab: MeetingBrowserTab; timeoutMs: number; }): Promise<{ left: boolean; note: string; }>;","entrypoint":"meeting-runtime","exportName":"leaveMeetingWithBrowser","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c7b1aeac134b2070d053adf33026d876998b852ccd65cd2c9667119b1a1abee2","declaration":"export function openMeetingWithBrowser, Mode extends string, Health extends MeetingBrowserHealth & { browserTitle?: string; browserUrl?: string; notes?: string[]; }, Transcript extends MeetingTranscriptSnapshot>(params: { adapter: BrowserAdapter; callBrowser: MeetingBrowserRequestCaller; config: MeetingBrowserControllerConfig; session: Session; }): Promise<{ launched: boolean; browser?: Health; tab?: MeetingBrowserTab; }>;","entrypoint":"meeting-runtime","exportName":"openMeetingWithBrowser","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"34858979bd248799c2215d9c7bdd5d4a6b27660c9173bfca2e50dd4373ac27db","declaration":"export function readMeetingBrowserTab(result: unknown): MeetingBrowserCandidateTab | undefined;","entrypoint":"meeting-runtime","exportName":"readMeetingBrowserTab","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} @@ -3218,8 +3218,8 @@ {"closureHash":"a1c367ec2dd927543d490a1393128d475e10c7bd131ffd789c80d88a2d2d0cb2","declaration":"export function resolveMeetingBrowserNodeInfo(params: { runtime: PluginRuntime; adapter: NodeAdapter; requestedNode?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"resolveMeetingBrowserNodeInfo","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"f21196c9ea8ab90248ffdcdef3e942f1247ee2b9064ddb9757fffe352362c779","declaration":"export function resolveMeetingRealtimeAudioFormat(audioFormat: MeetingRealtimeAudioFormat): RealtimeVoiceAudioFormat;","entrypoint":"meeting-runtime","exportName":"resolveMeetingRealtimeAudioFormat","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"521dc5c262524a709cdd27cdc88e95004073da8223537bcbc73d5d423172dc2b","declaration":"export function speakMeetingViaVoiceCallGateway(params: { gateway: MeetingVoiceCallGateway; callId: string; message: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"speakMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"afc66af1fb900349a8f9d8bad16b33fdd83ee3fa460a966af8eb028f43148620","declaration":"export function startMeetingAgentRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeTranscriptionProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingAgentRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"07c791b011bbbe133c0180c2c2c9975369282e0897f295be5a04beb30c2d9c16","declaration":"export function startMeetingRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; talkSessionId?: string; talkContext?: { nodeId: string; bridgeId: string; }; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeVoiceProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (params: MeetingRealtimeToolCallParams) => Promise; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"d30a62bb6292da6e0da6cbd96032084122428ec76c16fb0b711813a1cd7d386e","declaration":"export function startMeetingAgentRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeTranscriptionProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingAgentRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"0e6b815a67f214774c65fff9589717c3f0a3e6327a1c0ecce7c405a9bb627b64","declaration":"export function startMeetingRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; talkSessionId?: string; talkContext?: { nodeId: string; bridgeId: string; }; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeVoiceProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (params: MeetingRealtimeToolCallParams) => Promise; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"663b9c77231b21af697efd5042e22d1c33e9ee972adeb312ad86d81a2b2e36f7","declaration":"export type MeetingAgentConsultParams = MeetingAgentConsultParams;","entrypoint":"meeting-runtime","exportName":"MeetingAgentConsultParams","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"adf0745c611bf737fc28978d766fc2a25b7104e71b6b2fed4e9b95e519c7b115","declaration":"export type MeetingBrowserCandidateTab = MeetingBrowserCandidateTab;","entrypoint":"meeting-runtime","exportName":"MeetingBrowserCandidateTab","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"59b1526172b7491b85f14f13c1ee26d8d6726f33093770748580c767b1689a17","declaration":"export type MeetingBrowserControllerConfig = MeetingBrowserControllerConfig;","entrypoint":"meeting-runtime","exportName":"MeetingBrowserControllerConfig","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} @@ -3251,7 +3251,7 @@ {"closureHash":"8244b408803f57cf2004157e96f2ce11bb036cdf864123b15ba2fac7f03b5d43","declaration":"export type MeetingSessionRuntimeHandles = MeetingSessionRuntimeHandles;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeHandles","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"aa8054fc2c442891197e89bdad61e79999ea15d813c4b127fb6150607b9e67e7","declaration":"export type MeetingSessionRuntimeJoinContext, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab> = MeetingSessionRuntimeJoinContext;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeJoinContext","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"325241724edd38da439c5df03d225e2a7cb8e56d817e98ed7c37850656d3744b","declaration":"export type MeetingSessionRuntimeMessages = MeetingSessionRuntimeMessages;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeMessages","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} -{"closureHash":"b0575ac4d37523c2a2a427931c64c2f8b89fcd2a779eedad66f43efe77b6b1ac","declaration":"export type MeetingSessionRuntimeOptions, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> = MeetingSessionRuntimeOptions;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeOptions","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} +{"closureHash":"9140b7d74ad1d6db84343684461ee4328a4f977b1e071843a5fcc6fe4a0593c7","declaration":"export type MeetingSessionRuntimeOptions, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> = MeetingSessionRuntimeOptions;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeOptions","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"d3c730cba02d30512c4cdae6e2367c662982e2c25671833ecd8701283924c642","declaration":"export type MeetingSessionState = MeetingSessionState;","entrypoint":"meeting-runtime","exportName":"MeetingSessionState","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"2496931157bf64fdb16013172ab9c023e60c5f85b98f46a3ee9532ce58d636d6","declaration":"export type MeetingSetupCheck = MeetingSetupCheck;","entrypoint":"meeting-runtime","exportName":"MeetingSetupCheck","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"f0aed6f7955a43fc387d8bbc072cb6276b0f2d18e7f829441a2ad45974bb3831","declaration":"export type MeetingSetupStatus = MeetingSetupStatus;","entrypoint":"meeting-runtime","exportName":"MeetingSetupStatus","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} @@ -3265,9 +3265,9 @@ {"closureHash":"20c70a94483aeddda4d1500cfcc5e71fab844b474ba4061c55d6c8693d40821b","declaration":"export type MeetingVoiceCallJoinResult = MeetingVoiceCallJoinResult;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallJoinResult","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"4fb2c5928aa6f809dd4c604d71c5c99cdfe3e98b6e9c1662601c9b4ad9738162","declaration":"export type MeetingVoiceCallStatusResult = MeetingVoiceCallStatusResult;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallStatusResult","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"5ef812397b6409a4ca228943dd1080661365959477b42e705a9aaa219757d14f","declaration":"export type MeetingVoiceCallSurface = MeetingVoiceCallSurface;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallSurface","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} -{"closureHash":"dd91b64c44f2f66a2b3a36610e50ba1b10638db372bfbd17eb30be636f704da6","declaration":"export interface MeetingPlatformAdapter extends MeetingPlatformAdapterContract {\n}","entrypoint":"meeting-runtime","exportName":"MeetingPlatformAdapter","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} +{"closureHash":"0f7612ffa6432932130e8e6f83d267c98466eb46a91d43fc8c037c2303e63e05","declaration":"export interface MeetingPlatformAdapter extends MeetingPlatformAdapterContract {\n}","entrypoint":"meeting-runtime","exportName":"MeetingPlatformAdapter","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} {"closureHash":"79884bd1df1ea0d07d5ceb4c62d93c1ee744b75d31c94c988845869c0f3cbd2d","declaration":"export interface MeetingRealtimeAudioTransport {\n onFatal(handler: () => void): void;\n startInput(onAudio: (audio: Buffer) => void): void;\n beginOutput?(): void;\n stop(): Promise;\n writeOutput(audio: Buffer): Promise;\n clearOutput(): Promise;\n dispose(): Promise;\n getHealth?(): MeetingRealtimeAudioTransportHealth;\n startBargeInMonitor?(onBargeIn: (audio: Buffer) => boolean): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} -{"closureHash":"bcc4e7d34932202ad3805c54ac67cb466924d2c0374a33e6a2a02ffa518ce3ca","declaration":"export class MeetingSessionRuntime, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> {\n readonly #sessions: Map;\n readonly #sessionLeaves: Map>>;\n readonly #sessionCleanup: MeetingSessionCleanupTracker;\n readonly #meetingLock: MeetingSessionJoinLock;\n readonly #sessionStops: Map Promise>;\n readonly #sessionSpeakers: Map void>;\n readonly #sessionHealth: Map Partial>;\n readonly #durableTranscripts: MeetingSessionDurableTranscripts;\n readonly #transcriptStore: MeetingSessionTranscriptStore;\n constructor(private readonly options: MeetingSessionRuntimeOptions);\n list(): TSession[];\n getSession(sessionId: string): TSession | undefined;\n async status(sessionId?: string): Promise<{\n found: boolean;\n session?: TSession;\n sessions?: TSession[];\n }>;\n async transcript(sessionId: string, options: {\n sinceIndex?: number;\n }): Promise<{ found: boolean; sessionId?: string; startIndex?: number; nextIndex?: number; droppedLines?: number; evicted?: boolean; lines?: MeetingTranscriptLine[]; }>;\n async startTranscriptSource(request: TranscriptStartRequest): Promise;\n async stopTranscriptSource(request: TranscriptStopRequest): Promise;\n isReusableSession(session: TSession, resolved: MeetingResolvedJoin): boolean;\n async join(request: TRequest): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async leave(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async speak(sessionId: string, instructions?: string): Promise<{\n found: boolean;\n spoken: boolean;\n session?: TSession;\n }>;\n async speakWhenReady(session: TSession, instructions: string): Promise;\n hasHealthHandle(sessionId: string): boolean;\n refreshHealth(sessionId?: string): void;\n async refreshBrowserHealth(session: TSession, options: {\n force?: boolean;\n readOnly?: boolean;\n }): Promise;\n async refreshCaptionHealth(session: TSession): Promise;\n refreshSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n markSessionEnded(session: TSession, reason: string): void;\n async #joinUnlocked(request: TRequest, resolved: MeetingResolvedJoin): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async #leaveUnlocked(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async #leaveSession(session: TSession, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n #meetingKey(transport: TTransport, url: string): string;\n #inheritBrowserTabOwnership(params: {\n session: TSession;\n transport: TTransport;\n nodeId?: string;\n meetingUrl: string;\n tab?: TTab;\n }): TTab | undefined;\n async #settleRetainedBrowserTabs(retained: Array<{\n session: TSession;\n tab: TTab;\n }>, adopted?: {\n transport: TTransport;\n nodeId?: string;\n tab: TTab;\n }): Promise;\n async #rollbackFailedJoinSession(session: TSession): Promise;\n async #settleRetainedBrowserTabsAfterFailure(retained: Array<{\n session: TSession;\n tab: TTab;\n }>): Promise;\n #attachRuntimeHandles(session: TSession, handles: MeetingSessionRuntimeHandles): void;\n #dropRuntimeHandles(sessionId: string): void;\n #isManagedBrowserSession(session: TSession): boolean;\n #evaluateSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n #noteSession(session: TSession, note: string): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"class","recordType":"export"} +{"closureHash":"9e91eff317b13d93107e856c077bd51db614f2aa65d3c7d4c60e86ae09f489b6","declaration":"export class MeetingSessionRuntime, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> {\n readonly #sessions: Map;\n readonly #sessionLeaves: Map>>;\n readonly #sessionCleanup: MeetingSessionCleanupTracker;\n readonly #meetingLock: MeetingSessionJoinLock;\n readonly #sessionStops: Map Promise>;\n readonly #sessionSpeakers: Map void>;\n readonly #sessionHealth: Map Partial>;\n readonly #durableTranscripts: MeetingSessionDurableTranscripts;\n readonly #transcriptStore: MeetingSessionTranscriptStore;\n constructor(private readonly options: MeetingSessionRuntimeOptions);\n list(): TSession[];\n getSession(sessionId: string): TSession | undefined;\n async status(sessionId?: string): Promise<{\n found: boolean;\n session?: TSession;\n sessions?: TSession[];\n }>;\n async transcript(sessionId: string, options: {\n sinceIndex?: number;\n }): Promise<{ found: boolean; sessionId?: string; startIndex?: number; nextIndex?: number; droppedLines?: number; evicted?: boolean; lines?: MeetingTranscriptLine[]; }>;\n async startTranscriptSource(request: TranscriptStartRequest): Promise;\n async stopTranscriptSource(request: TranscriptStopRequest): Promise;\n isReusableSession(session: TSession, resolved: MeetingResolvedJoin): boolean;\n async join(request: TRequest): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async leave(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async speak(sessionId: string, instructions?: string): Promise<{\n found: boolean;\n spoken: boolean;\n session?: TSession;\n }>;\n async speakWhenReady(session: TSession, instructions: string): Promise;\n hasHealthHandle(sessionId: string): boolean;\n refreshHealth(sessionId?: string): void;\n async refreshBrowserHealth(session: TSession, options: {\n force?: boolean;\n readOnly?: boolean;\n }): Promise;\n async refreshCaptionHealth(session: TSession): Promise;\n refreshSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n markSessionEnded(session: TSession, reason: string): void;\n async #joinUnlocked(request: TRequest, resolved: MeetingResolvedJoin): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async #leaveUnlocked(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async #leaveSession(session: TSession, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n #meetingKey(transport: TTransport, url: string): string;\n #inheritBrowserTabOwnership(params: {\n session: TSession;\n transport: TTransport;\n nodeId?: string;\n meetingUrl: string;\n tab?: TTab;\n }): TTab | undefined;\n async #settleRetainedBrowserTabs(retained: Array<{\n session: TSession;\n tab: TTab;\n }>, adopted?: {\n transport: TTransport;\n nodeId?: string;\n tab: TTab;\n }): Promise;\n async #rollbackFailedJoinSession(session: TSession): Promise;\n async #settleRetainedBrowserTabsAfterFailure(retained: Array<{\n session: TSession;\n tab: TTab;\n }>): Promise;\n #attachRuntimeHandles(session: TSession, handles: MeetingSessionRuntimeHandles): void;\n #dropRuntimeHandles(sessionId: string): void;\n #isManagedBrowserSession(session: TSession): boolean;\n #evaluateSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n #noteSession(session: TSession, note: string): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"class","recordType":"export"} {"category":null,"entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","recordType":"module"} {"closureHash":"e2df8d235401aebf58eb71652fbeaedb42ca00aaa5042a76909ff2c48ba6fd54","declaration":"export function createSubsystemLogger(subsystem: string): SubsystemLogger;","entrypoint":"memory-core-host-engine-foundation","exportName":"createSubsystemLogger","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","kind":"function","recordType":"export"} {"closureHash":null,"declaration":"export function isPathInside(root: string, target: string): boolean;","entrypoint":"memory-core-host-engine-foundation","exportName":"isPathInside","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","kind":"function","recordType":"export"} @@ -3403,9 +3403,9 @@ {"category":"core","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","recordType":"module"} {"closureHash":"3d27406644141af2733cfca6e8c1240fc62327910109f585ce9073cafd7fd89c","declaration":"export function buildJsonPluginConfigSchema(schema: JsonSchemaObject, options?: BuildJsonPluginConfigSchemaOptions): OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"buildJsonPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} {"closureHash":"aff66c792d532642afb9c6e5a03c8d220f219250c3a8b49733f32058f22f98ca","declaration":"export function buildPluginConfigSchema(schema: ZodTypeAny, options?: BuildPluginConfigSchemaOptions): OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"buildPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} -{"closureHash":"60eaea4ee788df80eb81ba87d49e396cb9ffb40eb7d6bfd74fbcd817a34f3dcc","declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, reload, nodeHostCommands, securityAuditCollectors, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"plugin-entry","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} +{"closureHash":"ea866f93256b2e5f8da5bad7c96511f00f9b42fa85f6689cc70ab26bde2dd0cb","declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, reload, nodeHostCommands, securityAuditCollectors, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"plugin-entry","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} {"closureHash":"0ef1dfa3b5deeb456480e292c0e835c3cd6d13e7bb7059fc735546507da51594","declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} -{"closureHash":"7dcb6a9a37ace260567e3b46fc8d051aa124df2edfb093dfb56f082da56eaafb","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"plugin-entry","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} +{"closureHash":"beb6715ad81487caa2cc2ed8444d6ac2dcf83c9e71fc42d5db9214f2eb12b946","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"plugin-entry","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"d2ff4de9ee3fd111c2cc64a3ee7e557d9f161af1b6b437ee7886bcb16e426317","declaration":"export type AgentPromptGuidance = AgentPromptGuidance;","entrypoint":"plugin-entry","exportName":"AgentPromptGuidance","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"2841127a90e04467adfea8126cbf03655f44ddb089ad9e0d48833c2beea39cbd","declaration":"export type AgentPromptGuidanceEntry = AgentPromptGuidanceEntry;","entrypoint":"plugin-entry","exportName":"AgentPromptGuidanceEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"aa0412acefe62caff12e5271ead0545504874e824380dc21cfc0566388a05f33","declaration":"export type AgentPromptSurfaceKind = \"openclaw_main\" | \"pi_main\" | \"codex_app_server\" | \"cli_backend\" | \"acp_backend\" | \"subagent\";","entrypoint":"plugin-entry","exportName":"AgentPromptSurfaceKind","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} @@ -3421,10 +3421,10 @@ {"closureHash":"8054ffbe71b91afced900ef8a06f5e368ac10c4f99ffc1c6f538a802856f61c6","declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"plugin-entry","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"2f6e368fff73e942044cdf95c7c751b46c741e22ac0d2d4d97f45a3158f758ae","declaration":"export type OpenClawGatewayDiscoveryAdvertiseContext = OpenClawGatewayDiscoveryAdvertiseContext;","entrypoint":"plugin-entry","exportName":"OpenClawGatewayDiscoveryAdvertiseContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"b198058e0065e5c60b729a5bdf568f7e2436a006d8c4a7c63de764c7283f96ba","declaration":"export type OpenClawGatewayDiscoveryService = OpenClawGatewayDiscoveryService;","entrypoint":"plugin-entry","exportName":"OpenClawGatewayDiscoveryService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} -{"closureHash":"6545dfa8438683bf5ab321b0fe5cca9e47fc91686fe2166f8abc9084ce170aac","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} +{"closureHash":"ff621688b7b58ebfa7157b153d83921a47158e43ac312ff61df85d437216ae10","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"c968e96cdb5c97e92d26b7b15fb5255531b45a12e7c5c0a2e7e456d31cc6e358","declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"365beb5cee974639c2f35fc7a39088721ca8e9c618442830298b98033e9c768c","declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} -{"closureHash":"0fd3922f47a01a793d0b383cf6744734aae5c4fc2990344c2ed7b0969ae1d309","declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} +{"closureHash":"05cefddc1e544e0efd37301f07a5adae39f6cc580239b314c1959b8887a23ce9","declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"a7ed83f0924f818b23cecb5a1f3591ff28b9499ccac9667a2b8505c2bc608ea9","declaration":"export type OpenClawPluginGatewayEventScope = OpenClawPluginGatewayEventScope;","entrypoint":"plugin-entry","exportName":"OpenClawPluginGatewayEventScope","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"f81f224d9b837e6353b7a546c5a76b124bcf568fd1f1fd0c6881fda30c53120a","declaration":"export type OpenClawPluginGatewayEvents = OpenClawPluginGatewayEvents;","entrypoint":"plugin-entry","exportName":"OpenClawPluginGatewayEvents","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"7470b211ac4d3449cd0fc353ee2eb48e9fbc2dbc9259d21c680d0203577aa94f","declaration":"export type OpenClawPluginHttpRouteHandler = OpenClawPluginHttpRouteHandler;","entrypoint":"plugin-entry","exportName":"OpenClawPluginHttpRouteHandler","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} @@ -3574,14 +3574,14 @@ {"closureHash":"ad45bb1eb13e99e3217eb7e7e2d29fa456bc1930059dd5e94d7500e05e54c6bc","declaration":"export function executePluginCommand(params: { command: RegisteredPluginCommand; args?: string; senderId?: string; channel: string; channelId?: PluginCommandContext[\"channelId\"]; isAuthorizedSender: boolean; senderIsOwner?: boolean; gatewayClientScopes?: PluginCommandContext[\"gatewayClientScopes\"]; agentId?: string; sessionKey?: PluginCommandContext[\"sessionKey\"]; sessionId?: PluginCommandContext[\"sessionId\"]; sessionTarget?: PluginCommandContext[\"sessionTarget\"]; sessionFile?: PluginCommandContext[\"sessionFile\"]; authProfileId?: string; commandBody: string; config: OpenClawConfig; from?: PluginCommandContext[\"from\"]; to?: PluginCommandContext[\"to\"]; originatingTo?: string; accountId?: PluginCommandContext[\"accountId\"]; messageThreadId?: PluginCommandContext[\"messageThreadId\"]; threadParentId?: PluginCommandContext[\"threadParentId\"]; diagnosticsSessions?: PluginCommandContext[\"diagnosticsSessions\"]; diagnosticsUploadApproved?: PluginCommandContext[\"diagnosticsUploadApproved\"]; diagnosticsPreviewOnly?: PluginCommandContext[\"diagnosticsPreviewOnly\"]; diagnosticsPrivateRouted?: PluginCommandContext[\"diagnosticsPrivateRouted\"]; }): Promise;","entrypoint":"plugin-runtime","exportName":"executePluginCommand","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"7e75fc04d621dc8d12f459d5b5b0c039a39b77c30c2b51af481e123c352f57b8","declaration":"export function getGlobalHookRunner(): HookRunner | null;","entrypoint":"plugin-runtime","exportName":"getGlobalHookRunner","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"db6583607237e4081814e2c5d4f8fb6f216ad3bbfe623bf16fc1a9c09c15f64b","declaration":"export function getPluginCommandSpecs(provider?: string, options?: PluginCommandSpecOptions): Array<{ name: string; description: string; descriptionLocalizations?: Record; acceptsArgs: boolean; }>;","entrypoint":"plugin-runtime","exportName":"getPluginCommandSpecs","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} -{"closureHash":"501447aa669a7dd46a644791d7374819a7e1d5b3cfffac3251a8d5a3efb9d1b5","declaration":"export function getPluginRuntimeGatewayRequestScope(): PluginRuntimeGatewayRequestScope | undefined;","entrypoint":"plugin-runtime","exportName":"getPluginRuntimeGatewayRequestScope","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} +{"closureHash":"dbfeaea0c67177141cc6e662d6df7d97cac656a9079377c514bffb89073f3c8e","declaration":"export function getPluginRuntimeGatewayRequestScope(): PluginRuntimeGatewayRequestScope | undefined;","entrypoint":"plugin-runtime","exportName":"getPluginRuntimeGatewayRequestScope","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"cfe277ce6de0c6ed50f1f54b66c889f6c95e91b058e8bcdc27aa37317a93a447","declaration":"export function listRegisteredPluginAgentPromptGuidance(params?: { surface?: AgentPromptSurfaceKind; includeLegacyGlobalGuidance?: boolean; }): string[];","entrypoint":"plugin-runtime","exportName":"listRegisteredPluginAgentPromptGuidance","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"693462c2a5c07646277169ede37b71ed12dc8d56c648b7fed73712900be34dcf","declaration":"export function matchPluginCommand(commandBody: string, options?: { channel?: string; }): { command: RegisteredPluginCommand; args?: string; } | null;","entrypoint":"plugin-runtime","exportName":"matchPluginCommand","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"8d575a525d165780642e70ff1385311c3422429c2e26c479a85baae491d62efe","declaration":"export function registerPluginCommand(pluginId: string, command: OpenClawPluginCommandDefinition, opts?: { pluginName?: string; pluginRoot?: string; allowReservedCommandNames?: boolean; allowOwnerStatusExposure?: boolean; }): CommandRegistrationResult;","entrypoint":"plugin-runtime","exportName":"registerPluginCommand","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"16986e5d431f584c2d5053289eca3d2225bbe47a92cdfbbc2af91b264a165846","declaration":"export function registerPluginInteractiveHandler(pluginId: string, registration: PluginInteractiveHandlerRegistration, opts?: { pluginName?: string; pluginRoot?: string; }): InteractiveRegistrationResult;","entrypoint":"plugin-runtime","exportName":"registerPluginInteractiveHandler","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"f20c07a48e2c949fbc889e7a32760f4e52339d9f6d3eab49e00ff249266e2396","declaration":"export function startLazyPluginServiceModule(params: { skipEnvVar?: string; overrideEnvVar?: string; validateOverrideSpecifier?: (specifier: string) => string; loadDefaultModule: () => Promise; loadOverrideModule?: (specifier: string) => Promise; startExportNames: string[]; stopExportNames?: string[]; }): Promise;","entrypoint":"plugin-runtime","exportName":"startLazyPluginServiceModule","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"bad43ffe69d1d9d6c92c79df4c388b3dda685013a92392daef456f1622ade659","declaration":"export type LazyPluginServiceHandle = LazyPluginServiceHandle;","entrypoint":"plugin-runtime","exportName":"LazyPluginServiceHandle","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} -{"closureHash":"6545dfa8438683bf5ab321b0fe5cca9e47fc91686fe2166f8abc9084ce170aac","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-runtime","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} +{"closureHash":"ff621688b7b58ebfa7157b153d83921a47158e43ac312ff61df85d437216ae10","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-runtime","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} {"closureHash":"365beb5cee974639c2f35fc7a39088721ca8e9c618442830298b98033e9c768c","declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"plugin-runtime","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} {"closureHash":"f06379ada10ce0eca4e66bdf978db2c0035e054a135dc044c6d831ac7797c08f","declaration":"export type PluginConversationBinding = PluginConversationBinding;","entrypoint":"plugin-runtime","exportName":"PluginConversationBinding","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} {"closureHash":"a1223d674b2b66918a576ae05b04318d7b8cbe9b1d3bc6dd7a08d1a4fa79b291","declaration":"export type PluginConversationBindingRequestParams = PluginConversationBindingRequestParams;","entrypoint":"plugin-runtime","exportName":"PluginConversationBindingRequestParams","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} @@ -3671,10 +3671,10 @@ {"closureHash":"e66adcb55dec79be73a52bc76594b7e9210fd93c69a85b56098fbb58cecbb354","declaration":"export type WriteOAuthCredentialsOptions = WriteOAuthCredentialsOptions;","entrypoint":"provider-auth","exportName":"WriteOAuthCredentialsOptions","importSpecifier":"openclaw/plugin-sdk/provider-auth","kind":"type","recordType":"export"} {"category":null,"entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","recordType":"module"} {"closureHash":"b5665596a3f011454a5614681513516acd50b6c749536c43842d811980cf7ad3","declaration":"export function augmentModelCatalogWithProviderPlugins(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; metadataSnapshot?: PluginMetadataSnapshot; context: ProviderAugmentModelCatalogContext; }): Promise;","entrypoint":"provider-catalog-runtime","exportName":"augmentModelCatalogWithProviderPlugins","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"d3d9275ea2612b0f8837fe12d4b14cd3e3d06e3aed4c3417754a3b1c95e39e25","declaration":"export function isPluginProvidersLoadInFlight(params: Parameters[0]): boolean;","entrypoint":"provider-catalog-runtime","exportName":"isPluginProvidersLoadInFlight","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"5d83dd4ba5e9f0b8c1d9ffaf583f5312c88e21d5cb90637ac0f260133265f738","declaration":"export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; metadataSnapshot?: ProviderManifestLoadParams[\"metadataSnapshot\"]; }): string[];","entrypoint":"provider-catalog-runtime","exportName":"resolveCatalogHookProviderPluginIds","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"4c281c99ff58abf4ab164909c5528ec9c7d33b10fd660bf340b028111b616c6f","declaration":"export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; manifestRegistry?: PluginManifestRegistry; metadataSnapshot?: Pick; }): string[] | undefined;","entrypoint":"provider-catalog-runtime","exportName":"resolveOwningPluginIdsForProvider","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"048abfae415819c157f5a6f7fbdab5f0a17f48863f973a7ac361c7a645e6ec5b","declaration":"export function resolvePluginProviders(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; pluginSdkResolution?: PluginLoadOptions[\"pluginSdkResolution\"]; mode?: \"runtime\" | \"setup\"; includeUntrustedWorkspacePlugins?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; skipIfLoadInFlight?: boolean; }): ProviderPlugin[];","entrypoint":"provider-catalog-runtime","exportName":"resolvePluginProviders","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"a360cb1f899330116af2fd6f2370cc53c0ff5083bd27a4d5e5977c05fa4627a6","declaration":"export function isPluginProvidersLoadInFlight(params: Parameters[0]): boolean;","entrypoint":"provider-catalog-runtime","exportName":"isPluginProvidersLoadInFlight","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"a8a3abd31253da5e27e43db29968d818f98a55a6f08e0be1e7e94265c634f7fa","declaration":"export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; metadataSnapshot?: ProviderManifestLoadParams[\"metadataSnapshot\"]; }): string[];","entrypoint":"provider-catalog-runtime","exportName":"resolveCatalogHookProviderPluginIds","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"0d463b51098a4f8bb1ca72db875efc938f64978bbf498632bdc7fb8b61648f4a","declaration":"export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; manifestRegistry?: PluginManifestRegistry; metadataSnapshot?: Pick; }): string[] | undefined;","entrypoint":"provider-catalog-runtime","exportName":"resolveOwningPluginIdsForProvider","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"4e83218d414f08a78be577d566d883f6147e50776a128e6c3e9c49057ab93cdc","declaration":"export function resolvePluginProviders(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; pluginSdkResolution?: PluginLoadOptions[\"pluginSdkResolution\"]; mode?: \"runtime\" | \"setup\"; includeUntrustedWorkspacePlugins?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; skipIfLoadInFlight?: boolean; }): ProviderPlugin[];","entrypoint":"provider-catalog-runtime","exportName":"resolvePluginProviders","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} {"category":null,"entrypoint":"proxy-capture","importSpecifier":"openclaw/plugin-sdk/proxy-capture","recordType":"module"} {"closureHash":"d0e4e026d736bc00b1758d1089c44c3c5f3a09c7254291584de8cb297365804c","declaration":"export function acquireDebugProxyCaptureStore(dbPath: string, blobDir: string): { store: LegacyDebugProxyCaptureStore; release: () => void; };\nexport function acquireDebugProxyCaptureStore(options?: DebugProxyCaptureStoreOptions): { store: SharedDebugProxyCaptureStore; release: () => void; };","entrypoint":"proxy-capture","exportName":"acquireDebugProxyCaptureStore","importSpecifier":"openclaw/plugin-sdk/proxy-capture","kind":"function","recordType":"export"} {"closureHash":"d41cbb9de51b7aee1c6cf8c713b0bc44f27ac926fbb6422c05726f196d11964e","declaration":"export function captureHttpExchange(params: { url: string; method: string; requestHeaders?: Headers | Record | undefined; requestBody?: BodyInit | Buffer | string | null; response: Response; transport?: \"http\" | \"sse\"; flowId?: string; meta?: Record; }, resolved?: DebugProxySettings, deps?: DebugProxyCaptureRuntimeDeps): void;","entrypoint":"proxy-capture","exportName":"captureHttpExchange","importSpecifier":"openclaw/plugin-sdk/proxy-capture","kind":"function","recordType":"export"} @@ -4303,7 +4303,7 @@ {"closureHash":"eb612aa7bb0e0ac71925a219bb93f04867caec9eafa8c46e118433b7fc7651e6","declaration":"export let STATE_DIR: string;","entrypoint":"state-paths","exportName":"STATE_DIR","importSpecifier":"openclaw/plugin-sdk/state-paths","kind":"variable","recordType":"export"} {"category":null,"entrypoint":"status-helpers","importSpecifier":"openclaw/plugin-sdk/status-helpers","recordType":"module"} {"closureHash":"da271ab51c875f3b557969c3fed4b62459b26cf511fff81be0f37be71a7dc1db","declaration":"export function appendMatchMetadata(message: string, params: { matchKey?: unknown; matchSource?: unknown; }): string;","entrypoint":"status-helpers","exportName":"appendMatchMetadata","importSpecifier":"openclaw/plugin-sdk/status-helpers","kind":"function","recordType":"export"} -{"closureHash":"feb6b6cc6719326e5f09c3ed6c38083e178f7ca9fb5dae74e90ab0f50811bcf6","declaration":"export function asString(value: unknown): string | undefined;","entrypoint":"status-helpers","exportName":"asString","importSpecifier":"openclaw/plugin-sdk/status-helpers","kind":"function","recordType":"export"} +{"closureHash":"c0c92d709ca55c2e9c2d579eed61ce2203c9046bcbf6699a0d0bb3a72287d477","declaration":"export function asString(value: unknown): string | undefined;","entrypoint":"status-helpers","exportName":"asString","importSpecifier":"openclaw/plugin-sdk/status-helpers","kind":"function","recordType":"export"} {"closureHash":"36929d73a3e6a600c84a9f80a68403194e0669e4ffa146306f9b9627253c2e47","declaration":"export function buildBaseAccountStatusSnapshot(params: { account: { accountId: string; name?: string; enabled?: boolean; configured?: boolean; }; runtime?: RuntimeLifecycleSnapshot | null; probe?: unknown; }, extra?: TExtra): { lastInboundAt: number | null; lastOutboundAt: number | null; activeRunStartedAt?: number | undefined; lastRunActivityAt?: number | undefined; activeRuns?: number | undefined; busy?: boolean | undefined; terminalDisconnect?: true | undefined; ingressUnavailable?: true | undefined; lifecycle?: \"blocked\" | \"starting\" | \"ready\" | \"recovering\" | \"stopped\" | undefined; healthState?: string | undefined; lastTransportActivityAt?: number | undefined; lastEventAt?: number | undefined; lastDisconnect?: string | { at: number; status?: number; error?: string; loggedOut?: boolean; } | undefined; lastConnectedAt?: number | undefined; reconnectAttempts?: number | undefined; restartPending?: boolean | undefined; connected?: boolean | undefined; linked?: boolean | undefined; running: boolean; lastStartAt: number | null; lastStopAt: number | null; lastError: string | null; probe: unknown; accountId: string; name: string | undefined; enabled: boolean | undefined; configured: boolean | undefined; } & TExtra;","entrypoint":"status-helpers","exportName":"buildBaseAccountStatusSnapshot","importSpecifier":"openclaw/plugin-sdk/status-helpers","kind":"function","recordType":"export"} {"closureHash":"0bf8ae29b2d9d8623e8a34f219f4e078c8f6e1c01cfd9fae07176500563e803b","declaration":"export function buildBaseChannelStatusSummary(snapshot: { configured?: boolean | null; running?: boolean | null; lastStartAt?: number | null; lastStopAt?: number | null; lastError?: string | null; }, extra?: TExtra): { configured: boolean; } & TExtra & { running: boolean; lastStartAt: number | null; lastStopAt: number | null; lastError: string | null; };","entrypoint":"status-helpers","exportName":"buildBaseChannelStatusSummary","importSpecifier":"openclaw/plugin-sdk/status-helpers","kind":"function","recordType":"export"} {"closureHash":"091884288f381f9d44c4f98f991990eedf0ca4b62af623b97beb0c17fe9ec6a6","declaration":"export function buildComputedAccountStatusSnapshot(params: { accountId: string; name?: string; enabled?: boolean; configured?: boolean; runtime?: RuntimeLifecycleSnapshot | null; probe?: unknown; }, extra?: TExtra): { lastInboundAt: number | null; lastOutboundAt: number | null; activeRunStartedAt?: number | undefined; lastRunActivityAt?: number | undefined; activeRuns?: number | undefined; busy?: boolean | undefined; terminalDisconnect?: true | undefined; ingressUnavailable?: true | undefined; lifecycle?: \"blocked\" | \"starting\" | \"ready\" | \"recovering\" | \"stopped\" | undefined; healthState?: string | undefined; lastTransportActivityAt?: number | undefined; lastEventAt?: number | undefined; lastDisconnect?: string | { at: number; status?: number; error?: string; loggedOut?: boolean; } | undefined; lastConnectedAt?: number | undefined; reconnectAttempts?: number | undefined; restartPending?: boolean | undefined; connected?: boolean | undefined; linked?: boolean | undefined; running: boolean; lastStartAt: number | null; lastStopAt: number | null; lastError: string | null; probe: unknown; accountId: string; name: string | undefined; enabled: boolean | undefined; configured: boolean | undefined; } & TExtra;","entrypoint":"status-helpers","exportName":"buildComputedAccountStatusSnapshot","importSpecifier":"openclaw/plugin-sdk/status-helpers","kind":"function","recordType":"export"} @@ -4333,7 +4333,9 @@ {"closureHash":"c4a67c4e399ef77063c033fa6408ab52201677b3e91d18cbf72c2b0ef3b04868","declaration":"export function asBoolean(value: unknown): boolean | undefined;","entrypoint":"string-coerce-runtime","exportName":"asBoolean","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"8cc60d63f99d6ed490bb90c379e40689b7a7360b5731c47622dbca57646867c7","declaration":"export function asFiniteNumber(value: unknown): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"asFiniteNumber","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"91afebd87438e96391af3fe271f3c0297be88678420987a648e0ab2073fec8cb","declaration":"export function asFiniteNumberInRange(value: unknown, range: { min?: number; max?: number; minExclusive?: boolean; maxExclusive?: boolean; }): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"asFiniteNumberInRange","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} +{"closureHash":"8f5f89cd550e17fde19f6229edd7502cd473384edc289f448032a066f27d8139","declaration":"export function asNullableObjectRecord(value: unknown): Record | null;","entrypoint":"string-coerce-runtime","exportName":"asNullableObjectRecord","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"4c772989882503a1861c55c9ebac600ceaf218d44147d87da0628eba845bff15","declaration":"export function asNullableRecord(value: unknown): Record | null;","entrypoint":"string-coerce-runtime","exportName":"asNullableRecord","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} +{"closureHash":"3ce3856918e2e7e45071d174e5948657ba2cf18bfedc6fe1ac9d7d859012f6b2","declaration":"export function asOptionalObjectRecord(value: unknown): Record | undefined;","entrypoint":"string-coerce-runtime","exportName":"asOptionalObjectRecord","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"1627961066a987bec6f21c1cebccf61ded3c26ae834f5819e1909632a5586834","declaration":"export function asOptionalRecord(value: unknown): Record | undefined;","entrypoint":"string-coerce-runtime","exportName":"asOptionalRecord","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"41c29f2f42fa1a43f9f4a56dbe9ccd3c87232ffc4b7046bb15848f0150e5a1a4","declaration":"export function asPositiveSafeInteger(value: unknown): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"asPositiveSafeInteger","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"28f70710cc0d9006bc47149d51fc65a6c990f1c2f7c9a16f96d94fa593041bcb","declaration":"export function asRecord(value: unknown): Record;","entrypoint":"string-coerce-runtime","exportName":"asRecord","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} @@ -4361,6 +4363,7 @@ {"closureHash":"37287f8f868dd5f82766f20dd47e2c83aea94022461d2b27ad1fbe97a5c7cca8","declaration":"export function normalizeUniqueStringEntries(values?: Iterable): string[];","entrypoint":"string-coerce-runtime","exportName":"normalizeUniqueStringEntries","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"70c7cea3e68443c494677f7012c4aae2d3be3a4b586d777e299273230fa3a460","declaration":"export function normalizeUniqueTrimmedStringList(value: unknown): string[];","entrypoint":"string-coerce-runtime","exportName":"normalizeUniqueTrimmedStringList","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"b4468874f0640fe53b525195faa47195b7946cb28e2edbee566dc00d53d81ed5","declaration":"export function parseBooleanValue(value: unknown, options?: BooleanParseOptions): boolean | undefined;","entrypoint":"string-coerce-runtime","exportName":"parseBooleanValue","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} +{"closureHash":"8e6357d6b3d286570af672d24cc93cbe84875a4c49c4142b6ba283687015799a","declaration":"export function parseDateTimestampMs(value: unknown): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"parseDateTimestampMs","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"1b4c281d27d98dd358c598879e048447d3be1855a4e7f27dc769e20cdef38a10","declaration":"export function parseFiniteNumber(value: unknown): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"parseFiniteNumber","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"35e6fd636ae00132a4b61439bd6932cd2f2f7b3d3870f239f7d3935babcce365","declaration":"export function parseStrictFiniteNumber(value: unknown): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"parseStrictFiniteNumber","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"0eecec70484482da2e1775cd09bd90c1a1b7a5541b6fe77ea28ce355c475becd","declaration":"export function parseStrictInteger(value: unknown): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"parseStrictInteger","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} @@ -4636,16 +4639,16 @@ {"closureHash":"e365d7ad911453b5139ba700931b061e560cff80b50d4cbe5e9615b5d7e13a52","declaration":"export type ScopedExpiringIdCache = ScopedExpiringIdCache;","entrypoint":"text-runtime","exportName":"ScopedExpiringIdCache","importSpecifier":"openclaw/plugin-sdk/text-runtime","kind":"type","recordType":"export"} {"closureHash":"af5502c92594c2e9049a4b4994e17c00d3adee3f6d1e68edd818a539c74c8787","declaration":"export interface CodeRegion {\n start: number;\n end: number;\n}","entrypoint":"text-runtime","exportName":"CodeRegion","importSpecifier":"openclaw/plugin-sdk/text-runtime","kind":"interface","recordType":"export"} {"category":null,"entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin","recordType":"module"} -{"closureHash":"1b4fb3ffd40b901d362f1e8e4824a82cf8526ef00ef846974f13254e1c0dffa5","declaration":"export function defineToolPlugin(definition: DefineToolPluginOptions): DefinedToolPluginEntry;","entrypoint":"tool-plugin","exportName":"defineToolPlugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"function","recordType":"export"} +{"closureHash":"0a3affca237e0370b796f418bfbe87212c7f1dc7d8066586a113c8d3f1c9c183","declaration":"export function defineToolPlugin(definition: DefineToolPluginOptions): DefinedToolPluginEntry;","entrypoint":"tool-plugin","exportName":"defineToolPlugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"function","recordType":"export"} {"closureHash":"36ef907ce20f885b5f3068cbf0475dc442a68da9652579bb5bf8fb83ad34c38b","declaration":"export function getToolPluginMetadata(entry: unknown): ToolPluginMetadata | undefined;","entrypoint":"tool-plugin","exportName":"getToolPluginMetadata","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"function","recordType":"export"} {"closureHash":"bdc58154e36b2625fb3daccca3eab248d981077d04d747f0ad507cb2ff565198","declaration":"export const toolPluginMetadataSymbol: typeof toolPluginMetadataSymbol;","entrypoint":"tool-plugin","exportName":"toolPluginMetadataSymbol","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"const","recordType":"export"} -{"closureHash":"bb1c06399667b2257908a8f507e882da468aa6c3ff6fd6b038e8a4338b594441","declaration":"export type DefineToolPluginOptions = DefineToolPluginOptions;","entrypoint":"tool-plugin","exportName":"DefineToolPluginOptions","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} -{"closureHash":"c4eec6214d34f00b92ad6e3c995f2bafe2ecc652ee616b98ba38fb870f9d81ea","declaration":"export type DefinedToolPluginEntry = DefinedToolPluginEntry;","entrypoint":"tool-plugin","exportName":"DefinedToolPluginEntry","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} -{"closureHash":"870ea60b2645fb6af508f6a2a588fd6e53836bc187b3aa6cb519bb1553d20e82","declaration":"export type ToolPluginExecutionContext = ToolPluginExecutionContext;","entrypoint":"tool-plugin","exportName":"ToolPluginExecutionContext","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} -{"closureHash":"dff06f936a371772c6fe219d33fd974f8bb778b5e96f9d92de14dc2f57886e94","declaration":"export type ToolPluginFactoryContext = ToolPluginFactoryContext;","entrypoint":"tool-plugin","exportName":"ToolPluginFactoryContext","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"11aa5fbcec024a768b7af0c54f8fc899c202ff892edf481ba1476ff895345abb","declaration":"export type DefineToolPluginOptions = DefineToolPluginOptions;","entrypoint":"tool-plugin","exportName":"DefineToolPluginOptions","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"7daed12952abd818d864a6e96c60c90bb79ddb73a94f50f3e21c676dbdb3dbd7","declaration":"export type DefinedToolPluginEntry = DefinedToolPluginEntry;","entrypoint":"tool-plugin","exportName":"DefinedToolPluginEntry","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"15bac4724ce2f1cfb2aebdc5f66cd10de56c84f916bf65169ce1bfed39866e2d","declaration":"export type ToolPluginExecutionContext = ToolPluginExecutionContext;","entrypoint":"tool-plugin","exportName":"ToolPluginExecutionContext","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"84872c7903f1cb60ab330047241d2bca21e43ee84f2e1073fd78cf3aa6b56dcb","declaration":"export type ToolPluginFactoryContext = ToolPluginFactoryContext;","entrypoint":"tool-plugin","exportName":"ToolPluginFactoryContext","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} {"closureHash":"f774bd092f51a31fbb34847213f9583234f585f66918d0af55e81bbbae40463b","declaration":"export type ToolPluginMetadata = ToolPluginMetadata;","entrypoint":"tool-plugin","exportName":"ToolPluginMetadata","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} {"closureHash":"414a89d0c80eaaf682dc25d306e7223c981c6fd25366adb973a106f4268a6a54","declaration":"export type ToolPluginStaticToolMetadata = ToolPluginStaticToolMetadata;","entrypoint":"tool-plugin","exportName":"ToolPluginStaticToolMetadata","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} -{"closureHash":"372dc6fb47a6060961e287821d9e87b59359ace16d0b9fb38347205a9f9ab5d6","declaration":"export type ToolPluginToolDefinition = ToolPluginToolDefinition;","entrypoint":"tool-plugin","exportName":"ToolPluginToolDefinition","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"11e44768d91eaaa804d40fd52e29e5a93d24698515dcd50bb22f48c542b92f9a","declaration":"export type ToolPluginToolDefinition = ToolPluginToolDefinition;","entrypoint":"tool-plugin","exportName":"ToolPluginToolDefinition","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} {"category":null,"entrypoint":"tool-results","importSpecifier":"openclaw/plugin-sdk/tool-results","recordType":"module"} {"closureHash":"c5b0951c3629834dff78f92317cf1421a6bbc0faf9307be1cabb915055665280","declaration":"export function jsonResult(payload: TDetails): AgentToolResult;","entrypoint":"tool-results","exportName":"jsonResult","importSpecifier":"openclaw/plugin-sdk/tool-results","kind":"function","recordType":"export"} {"closureHash":"b9aefc637a694c74e028e92006004bef18185ed569af48ac43d36b646b0af2f3","declaration":"export function textResult(text: string, details: TDetails): AgentToolResult;","entrypoint":"tool-results","exportName":"textResult","importSpecifier":"openclaw/plugin-sdk/tool-results","kind":"function","recordType":"export"} @@ -4679,9 +4682,9 @@ {"closureHash":"9f9174f1023a8a451232f5d97d1e922b7faa88b76b58cffbfbe246a1488e0ddd","declaration":"export function readJsonWebhookBodyOrReject(params: { req: IncomingMessage; res: ServerResponse; maxBytes?: number; timeoutMs?: number; profile?: WebhookBodyReadProfile; emptyObjectOnEmpty?: boolean; invalidJsonMessage?: string; }): Promise<{ ok: true; value: unknown; } | { ok: false; }>;","entrypoint":"webhook-ingress","exportName":"readJsonWebhookBodyOrReject","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"1bbddd5d1c8297e315275db4d5e1c8748ff09c0be7c37b0cf456db4fc69f8909","declaration":"export function readRequestBodyWithLimit(req: IncomingMessage, options: ReadRequestBodyOptions): Promise;","entrypoint":"webhook-ingress","exportName":"readRequestBodyWithLimit","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"e8a1174cac83ce346a231fd25e82bb5f7618ffb57cdd9af6eaa208411f70646d","declaration":"export function readWebhookBodyOrReject(params: { req: IncomingMessage; res: ServerResponse; maxBytes?: number; timeoutMs?: number; profile?: WebhookBodyReadProfile; invalidBodyMessage?: string; }): Promise<{ ok: true; value: string; } | { ok: false; }>;","entrypoint":"webhook-ingress","exportName":"readWebhookBodyOrReject","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} -{"closureHash":"d958ba4df86fc65fd5fdbabd463e4b6ab5a572f28c350dffa1f987f64c8280bd","declaration":"export function registerPluginHttpRoute(params: { path?: string | null; fallbackPath?: string | null; handler: PluginHttpRouteHandler; auth: PluginHttpRouteRegistration[\"auth\"]; match?: PluginHttpRouteRegistration[\"match\"]; gatewayRuntimeScopeSurface?: PluginHttpRouteRegistration[\"gatewayRuntimeScopeSurface\"]; replaceExisting?: boolean; reuseExistingSameOwner?: boolean; throwOnFailure?: boolean; pluginId?: string; source?: string; accountId?: string; log?: (message: string) => void; registry?: PluginRegistry; }): () => void;","entrypoint":"webhook-ingress","exportName":"registerPluginHttpRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} +{"closureHash":"16a1cd83bd12e1f265a4b2e2fabe6c4d108e9570ee88c7daaf020608a5007f25","declaration":"export function registerPluginHttpRoute(params: { path?: string | null; fallbackPath?: string | null; handler: PluginHttpRouteHandler; auth: PluginHttpRouteRegistration[\"auth\"]; match?: PluginHttpRouteRegistration[\"match\"]; gatewayRuntimeScopeSurface?: PluginHttpRouteRegistration[\"gatewayRuntimeScopeSurface\"]; replaceExisting?: boolean; reuseExistingSameOwner?: boolean; throwOnFailure?: boolean; pluginId?: string; source?: string; accountId?: string; log?: (message: string) => void; registry?: PluginRegistry; }): () => void;","entrypoint":"webhook-ingress","exportName":"registerPluginHttpRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"05835f2f66f906f736b82b332a84a9714bc3a6b211890a62d8fc4dbf9258a7d4","declaration":"export function registerWebhookTarget(targetsByPath: Map, target: T, opts?: RegisterWebhookTargetOptions): RegisteredWebhookTarget;","entrypoint":"webhook-ingress","exportName":"registerWebhookTarget","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} -{"closureHash":"2dd6ae1adcd4f011a2969c4a990622dc764078e775304eac40c9991864091a86","declaration":"export function registerWebhookTargetWithPluginRoute(params: { targetsByPath: Map; target: T; route: RegisterWebhookPluginRouteOptions; onLastPathTargetRemoved?: RegisterWebhookTargetOptions[\"onLastPathTargetRemoved\"]; }): RegisteredWebhookTarget;","entrypoint":"webhook-ingress","exportName":"registerWebhookTargetWithPluginRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} +{"closureHash":"3a78257a9745ef83d912a6d4659ff16a67d2eab326098f6084119ef510f915a7","declaration":"export function registerWebhookTargetWithPluginRoute(params: { targetsByPath: Map; target: T; route: RegisterWebhookPluginRouteOptions; onLastPathTargetRemoved?: RegisterWebhookTargetOptions[\"onLastPathTargetRemoved\"]; }): RegisteredWebhookTarget;","entrypoint":"webhook-ingress","exportName":"registerWebhookTargetWithPluginRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"ec2f47e9bd7a075a3dd4302ac9e4c0bfe20630445db6735cf803f2d354275841","declaration":"export function requestBodyErrorToText(code: RequestBodyLimitErrorCode): string;","entrypoint":"webhook-ingress","exportName":"requestBodyErrorToText","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"5e1f3b468d56eb0f82104184077fdf4b6bd0c1444aa10b71a65fcaa2ec0298a2","declaration":"export function resolveRequestClientIp(req?: IncomingMessage, trustedProxies?: string[], allowRealIpFallback?: boolean): string | undefined;","entrypoint":"webhook-ingress","exportName":"resolveRequestClientIp","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"56c3e9868dfa6ee2a7a128eb02039a16b52696759adadbd3fe0a3bd7a6a07eeb","declaration":"export function resolveSingleWebhookTarget(targets: readonly T[], isMatch: (target: T) => boolean): WebhookTargetMatchResult;","entrypoint":"webhook-ingress","exportName":"resolveSingleWebhookTarget","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} @@ -4699,7 +4702,7 @@ {"closureHash":"efb74e84339c15d6f607b38d1d477b0504d47d04ae78e77083a1893c756a0b54","declaration":"export const WEBHOOK_RATE_LIMIT_DEFAULTS: Readonly<{ windowMs: 60000; maxRequests: 120; maxTrackedKeys: 4096;}>;","entrypoint":"webhook-ingress","exportName":"WEBHOOK_RATE_LIMIT_DEFAULTS","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"const","recordType":"export"} {"closureHash":"13b28ec9acae43c3bdcd683b9831154f88118dee8142e42b7eebfd59f08c602c","declaration":"export type BoundedCounter = BoundedCounter;","entrypoint":"webhook-ingress","exportName":"BoundedCounter","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} {"closureHash":"9e737ae8c17038ef7becbd2bf1b756cfeaed571e2ded368c0fe3a446ebd1f666","declaration":"export type FixedWindowRateLimiter = FixedWindowRateLimiter;","entrypoint":"webhook-ingress","exportName":"FixedWindowRateLimiter","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} -{"closureHash":"c78ccdc3cb8e45f5da08133b6cb7beef96a37484c85f2eb9d018b5e78aa6d1d7","declaration":"export type RegisterWebhookPluginRouteOptions = RegisterWebhookPluginRouteOptions;","entrypoint":"webhook-ingress","exportName":"RegisterWebhookPluginRouteOptions","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} +{"closureHash":"75b1673b974b3866ffe5ad188f96f05a191310fcd95ff97efbcac5829e9eafdc","declaration":"export type RegisterWebhookPluginRouteOptions = RegisterWebhookPluginRouteOptions;","entrypoint":"webhook-ingress","exportName":"RegisterWebhookPluginRouteOptions","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} {"closureHash":"b3e98619854462acfac6538ac9a2d0cefb88704a0eac712563f0996b07364152","declaration":"export type RegisterWebhookTargetOptions = RegisterWebhookTargetOptions;","entrypoint":"webhook-ingress","exportName":"RegisterWebhookTargetOptions","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} {"closureHash":"964c2bfd8f268c411ee7b982c9de02c207f4bec402dd62c9f998a59752bb9d1f","declaration":"export type RegisteredWebhookTarget = RegisteredWebhookTarget;","entrypoint":"webhook-ingress","exportName":"RegisteredWebhookTarget","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} {"closureHash":"aa552e5bdaefc9eb94d3b5a5cdf8c413fad555d9f452c6a2a6af5a3ce695c9d1","declaration":"export type WebhookAnomalyTracker = WebhookAnomalyTracker;","entrypoint":"webhook-ingress","exportName":"WebhookAnomalyTracker","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} diff --git a/extensions/acpx/src/pi-session-catalog.ts b/extensions/acpx/src/pi-session-catalog.ts index 22e0427c76d1..f306a3bcd5a8 100644 --- a/extensions/acpx/src/pi-session-catalog.ts +++ b/extensions/acpx/src/pi-session-catalog.ts @@ -7,6 +7,7 @@ import type { import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { listPiSummaryPage, readPiSessionById } from "./pi-session-store.js"; +import { parsePiSessionTimestampMs } from "./pi-session-timestamp.js"; const LOCAL_HOST_ID = "gateway"; const DEFAULT_PAGE_LIMIT = 20; @@ -161,17 +162,6 @@ function textFromContent(content: unknown): string { .join("\n"); } -function timestampMs(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? undefined : parsed; - } - return undefined; -} - function parseListParams(value: unknown): { searchTerm?: string; limit: number; cursor?: string } { if (value === undefined || value === null) { return { limit: DEFAULT_PAGE_LIMIT }; @@ -236,7 +226,8 @@ function isoTimestamp( message: Record, entry: Record, ): string | undefined { - const value = timestampMs(message.timestamp) ?? timestampMs(entry.timestamp); + const value = + parsePiSessionTimestampMs(message.timestamp) ?? parsePiSessionTimestampMs(entry.timestamp); if (value === undefined) { return undefined; } diff --git a/extensions/acpx/src/pi-session-paths.ts b/extensions/acpx/src/pi-session-paths.ts index 18532f404781..1ef8e2357129 100644 --- a/extensions/acpx/src/pi-session-paths.ts +++ b/extensions/acpx/src/pi-session-paths.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -function optionalString(value: unknown, maxLength: number): string | undefined { +function readBoundedString(value: unknown, maxLength: number): string | undefined { if (typeof value !== "string") { return undefined; } @@ -52,7 +52,7 @@ function resolveConfiguredPath( function settingsSessionDir(file: string): string | undefined { try { const value = JSON.parse(readFileSync(file, "utf8")) as unknown; - return isRecord(value) ? optionalString(value.sessionDir, 4_096) : undefined; + return isRecord(value) ? readBoundedString(value.sessionDir, 4_096) : undefined; } catch { return undefined; } diff --git a/extensions/acpx/src/pi-session-store.ts b/extensions/acpx/src/pi-session-store.ts index 4f27016d523a..7bad3921f03d 100644 --- a/extensions/acpx/src/pi-session-store.ts +++ b/extensions/acpx/src/pi-session-store.ts @@ -4,6 +4,7 @@ import path from "node:path"; import type { SessionCatalogSession } from "openclaw/plugin-sdk/session-catalog"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { piAcpSessionStoreRoot, piSessionStore } from "./pi-session-paths.js"; +import { parsePiSessionTimestampMs } from "./pi-session-timestamp.js"; const MAX_DISCOVERY_FILES = 10_000; const SUMMARY_SCAN_BATCH_SIZE = 100; @@ -84,7 +85,7 @@ function cacheSummary(file: string, value: CachedSummary): void { } } -function optionalString(value: unknown, maxLength: number): string | undefined { +function readBoundedString(value: unknown, maxLength: number): string | undefined { if (typeof value !== "string") { return undefined; } @@ -258,17 +259,6 @@ function textFromContent(content: unknown): string { .join("\n"); } -function timestampMs(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? undefined : parsed; - } - return undefined; -} - function processSummaryLine(state: PiSummaryScanState, line: Buffer): void { const content = line.at(-1) === 0x0d ? line.subarray(0, -1) : line; const entry = parsePiJsonLines(content.toString("utf8"))[0]; @@ -285,14 +275,14 @@ function processSummaryLine(state: PiSummaryScanState, line: Buffer): void { } if (entry.type === "session_info") { // Latest metadata wins, including an explicit empty-name clear. - state.name = optionalString(entry.name, 1_000); + state.name = readBoundedString(entry.name, 1_000); } else if ( !state.firstMessage && entry.type === "message" && isRecord(entry.message) && entry.message.role === "user" ) { - state.firstMessage = optionalString(textFromContent(entry.message.content), 1_000); + state.firstMessage = readBoundedString(textFromContent(entry.message.content), 1_000); } } @@ -419,10 +409,10 @@ async function readPiSessionSummary( const { header, name, firstMessage } = projectedState; const version = header?.type === "session" && typeof header.version === "number" ? header.version : 1; - const threadId = header?.type === "session" ? optionalString(header.id, 256) : undefined; + const threadId = header?.type === "session" ? readBoundedString(header.id, 256) : undefined; if (header && threadId && SESSION_ID_PATTERN.test(threadId)) { - const cwd = optionalString(header.cwd, 4_096); - const createdAt = timestampMs(header.timestamp); + const cwd = readBoundedString(header.cwd, 4_096); + const createdAt = parsePiSessionTimestampMs(header.timestamp); summary = { file: candidate.file, version, diff --git a/extensions/acpx/src/pi-session-timestamp.test.ts b/extensions/acpx/src/pi-session-timestamp.test.ts new file mode 100644 index 000000000000..b6a77b594c0b --- /dev/null +++ b/extensions/acpx/src/pi-session-timestamp.test.ts @@ -0,0 +1,61 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { listLocalPiSessionPage, readLocalPiTranscriptPage } from "./pi-session-catalog.js"; +import { createPiStoreFixture } from "./pi-session-catalog.test-support.js"; + +const temporaryDirectories: string[] = []; +const originalSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR; +const originalAgentDir = process.env.PI_CODING_AGENT_DIR; + +afterEach(async () => { + if (originalSessionDir === undefined) { + delete process.env.PI_CODING_AGENT_SESSION_DIR; + } else { + process.env.PI_CODING_AGENT_SESSION_DIR = originalSessionDir; + } + if (originalAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = originalAgentDir; + } + await Promise.all( + temporaryDirectories.splice(0).map(async (directory) => { + await fs.rm(directory, { recursive: true, force: true }); + }), + ); +}); + +describe("Pi session timestamp contract", () => { + it("preserves date parsing for numeric-looking timestamp strings", async () => { + const directory = await createPiStoreFixture(temporaryDirectories); + const entries = [ + { + type: "session", + version: 3, + id: "pi-session", + timestamp: "2026", + cwd: "/workspace", + }, + { + type: "message", + id: "user-1", + parentId: null, + timestamp: "2026", + message: { role: "user", content: "hello", timestamp: "0" }, + }, + ]; + await fs.writeFile( + path.join(directory, "session.jsonl"), + `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, + ); + + const listed = await listLocalPiSessionPage({ limit: 20 }); + expect(listed.sessions[0]?.createdAt).toBe(Date.parse("2026")); + expect(listed.sessions[0]?.createdAt).not.toBe(2_026); + + const transcript = await readLocalPiTranscriptPage({ threadId: "pi-session", limit: 20 }); + expect(transcript.items[0]?.timestamp).toBe(new Date(Date.parse("0")).toISOString()); + expect(transcript.items[0]?.timestamp).not.toBe(new Date(0).toISOString()); + }); +}); diff --git a/extensions/acpx/src/pi-session-timestamp.ts b/extensions/acpx/src/pi-session-timestamp.ts new file mode 100644 index 000000000000..b25b98ec5cdb --- /dev/null +++ b/extensions/acpx/src/pi-session-timestamp.ts @@ -0,0 +1,10 @@ +import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; + +/** Preserve Pi JSONL's date-first string contract while accepting numeric millisecond values. */ +export function parsePiSessionTimestampMs(value: unknown): number | undefined { + if (typeof value === "string") { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return asFiniteNumber(value); +} diff --git a/extensions/acpx/src/pi-session-upstream-activity.test.ts b/extensions/acpx/src/pi-session-upstream-activity.test.ts index 137db28b4d20..3bc8a52becc0 100644 --- a/extensions/acpx/src/pi-session-upstream-activity.test.ts +++ b/extensions/acpx/src/pi-session-upstream-activity.test.ts @@ -192,6 +192,66 @@ describe("Pi session upstream activity", () => { ).resolves.toEqual([]); }); + it("preserves Pi date parsing for numeric-looking activity timestamps", async () => { + const sessionDirectory = await createPiStoreFixture( + temporaryDirectories, + "hi", + "Pi catalog session", + { command: "pwd" }, + true, + ); + const continued = await linkContinuedPiSession("agent:main:pi", "pi-session"); + const file = path.join(sessionDirectory, "session.jsonl"); + const probe = { + sessionKey: continued.sessionKey, + agentId: "main", + threadId: "pi-session", + hostId: "gateway", + upstreamKind: continued.upstream!.kind, + upstreamRef: continued.upstream!.ref, + marker: continued.upstream!.marker, + ownRecentUserTexts: [], + }; + + await fs.appendFile( + file, + `${JSON.stringify({ + type: "message", + timestamp: "0", + message: { role: "user", content: "first external Pi turn" }, + })}\n`, + ); + const first = await checkPiUpstreamActivity([probe]); + expect(first).toEqual([ + expect.objectContaining({ + kind: "activity", + humanTurns: 1, + occurredAt: Date.parse("0"), + }), + ]); + expect(first[0]?.kind === "activity" ? first[0].occurredAt : undefined).not.toBe(0); + + await fs.appendFile( + file, + `${JSON.stringify({ + type: "message", + timestamp: "2026", + message: { role: "user", content: "second external Pi turn" }, + })}\n`, + ); + await expect( + checkPiUpstreamActivity([ + { ...probe, marker: first[0]!.kind === "activity" ? first[0]!.nextMarker : null }, + ]), + ).resolves.toEqual([ + expect.objectContaining({ + kind: "activity", + humanTurns: 1, + occurredAt: Date.parse("2026"), + }), + ]); + }); + it("stops the cursor before the first unclassifiable complete row", async () => { const sessionDirectory = await createPiStoreFixture( temporaryDirectories, diff --git a/extensions/acpx/src/pi-session-upstream-activity.ts b/extensions/acpx/src/pi-session-upstream-activity.ts index 623f58d4ff1d..94fbd99b246a 100644 --- a/extensions/acpx/src/pi-session-upstream-activity.ts +++ b/extensions/acpx/src/pi-session-upstream-activity.ts @@ -8,6 +8,7 @@ import { } from "openclaw/plugin-sdk/session-catalog"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { readPiSessionFileBaseline } from "./pi-session-store.js"; +import { parsePiSessionTimestampMs } from "./pi-session-timestamp.js"; const MAX_PI_UPSTREAM_SCAN_BYTES = 1024 * 1024; @@ -72,17 +73,6 @@ function textFromContent(content: unknown): string | undefined { return text || undefined; } -function timestampMs(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? undefined : parsed; - } - return undefined; -} - function readFilePath(probe: SessionUpstreamProbe): string | undefined { return isRecord(probe.upstreamRef) && typeof probe.upstreamRef.filePath === "string" ? probe.upstreamRef.filePath @@ -168,7 +158,9 @@ async function checkPiSessionUpstreamActivity( humanTurns += 1; occurredAt = Math.max( occurredAt ?? 0, - timestampMs(entry.message.timestamp) ?? timestampMs(entry.timestamp) ?? stat.mtimeMs, + parsePiSessionTimestampMs(entry.message.timestamp) ?? + parsePiSessionTimestampMs(entry.timestamp) ?? + stat.mtimeMs, ); } const nextOffset = markerOffset + classifiedBytes; diff --git a/extensions/active-memory/config.ts b/extensions/active-memory/config.ts index da6c3aa1ced6..1d59ea87eb20 100644 --- a/extensions/active-memory/config.ts +++ b/extensions/active-memory/config.ts @@ -4,7 +4,7 @@ import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { isPathInside } from "openclaw/plugin-sdk/security-runtime"; import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeLowercaseStringOrEmpty, normalizeStringEntries, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -138,7 +138,7 @@ function normalizePromptConfigText(value: unknown): string | undefined { } function hasDeprecatedModelFallbackPolicy(pluginConfig: unknown): boolean { - const raw = asRecord(pluginConfig); + const raw = asOptionalRecord(pluginConfig); return raw ? Object.hasOwn(raw, "modelFallbackPolicy") : false; } diff --git a/extensions/active-memory/prompt.ts b/extensions/active-memory/prompt.ts index 29451f9f7290..58e807650a5f 100644 --- a/extensions/active-memory/prompt.ts +++ b/extensions/active-memory/prompt.ts @@ -1,5 +1,5 @@ import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -179,7 +179,7 @@ function readExplicitMemoryEvidence(source: Record): boolean | } function readStructuredMemoryFailure(source: unknown): boolean | undefined { - const record = asRecord(source); + const record = asOptionalRecord(source); if (!record) { return undefined; } @@ -206,7 +206,7 @@ function readStructuredMemoryEvidence(source: unknown): boolean | undefined { if (Array.isArray(source)) { return source.length > 0; } - const record = asRecord(source); + const record = asOptionalRecord(source); return record ? readExplicitMemoryEvidence(record) : undefined; } diff --git a/extensions/active-memory/session-policy.ts b/extensions/active-memory/session-policy.ts index 02cd296eb096..a4436dd3456e 100644 --- a/extensions/active-memory/session-policy.ts +++ b/extensions/active-memory/session-policy.ts @@ -7,7 +7,7 @@ import { } from "openclaw/plugin-sdk/plugin-config-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { parseAgentSessionKey, parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing"; -import { asOptionalRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCanonicalSessionKeyFromSessionId } from "./session.js"; import { DEFAULT_AGENT_ID, @@ -108,7 +108,7 @@ function formatActiveMemoryCommandHelp(): string { } function isActiveMemoryGloballyEnabled(cfg: OpenClawConfig): boolean { - const entry = asRecord(cfg.plugins?.entries?.["active-memory"]); + const entry = asOptionalRecord(cfg.plugins?.entries?.["active-memory"]); if (entry?.enabled === false) { return false; } @@ -142,8 +142,8 @@ function updateActiveMemoryGlobalEnabledInConfig( enabled: boolean, ): OpenClawConfig { const entries = { ...cfg.plugins?.entries }; - const existingEntry = asRecord(entries["active-memory"]) ?? {}; - const existingConfig = asRecord(existingEntry.config) ?? {}; + const existingEntry = asOptionalRecord(entries["active-memory"]) ?? {}; + const existingConfig = asOptionalRecord(existingEntry.config) ?? {}; entries["active-memory"] = { ...existingEntry, enabled: true, diff --git a/extensions/active-memory/transcript-result.ts b/extensions/active-memory/transcript-result.ts index 649437f0dc79..10dc76ff2671 100644 --- a/extensions/active-memory/transcript-result.ts +++ b/extensions/active-memory/transcript-result.ts @@ -1,5 +1,5 @@ import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -35,7 +35,7 @@ function readMemoryToolResultEvidence(params: { hasUsableMemoryResult: boolean; hasUnavailableMemorySearchResult: boolean; } { - const result = asRecord(params.result); + const result = asOptionalRecord(params.result); const rawContent = result?.content; const textContent = normalizeOptionalString(result?.detailedContent) ?? @@ -63,11 +63,11 @@ function readMemoryToolResultEvidence(params: { } function extractAssistantTextFromSessionRecord(value: unknown): string { - const record = asRecord(value); + const record = asOptionalRecord(value); if (!record) { return ""; } - const nestedMessage = asRecord(record.message); + const nestedMessage = asOptionalRecord(record.message); const topLevelMessage = normalizeOptionalString(record.role) === "assistant" ? record : undefined; const message = nestedMessage ?? topLevelMessage; if (!message || normalizeOptionalString(message.role) !== "assistant") { diff --git a/extensions/active-memory/transcript-watch.ts b/extensions/active-memory/transcript-watch.ts index 460ef71d9b18..a7e2395a6c18 100644 --- a/extensions/active-memory/transcript-watch.ts +++ b/extensions/active-memory/transcript-watch.ts @@ -1,5 +1,5 @@ import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -221,7 +221,7 @@ function watchTerminalMemorySearchResult(params: { } function normalizeSearchDebug(value: unknown): ActiveMemorySearchDebug | undefined { - const debug = asRecord(value); + const debug = asOptionalRecord(value); if (!debug) { return undefined; } @@ -255,8 +255,8 @@ function normalizeSearchDebug(value: unknown): ActiveMemorySearchDebug | undefin function readActiveMemorySearchDebugFromRunResult( result: unknown, ): ActiveMemorySearchDebug | undefined { - const record = asRecord(result); - const meta = asRecord(record?.meta); + const record = asOptionalRecord(result); + const meta = asOptionalRecord(record?.meta); return ( normalizeSearchDebug(meta?.activeMemorySearchDebug) ?? normalizeSearchDebug(meta?.memorySearchDebug) ?? @@ -266,9 +266,9 @@ function readActiveMemorySearchDebugFromRunResult( } function readActiveMemorySessionFileFromRunResult(result: unknown): string | undefined { - const record = asRecord(result); - const meta = asRecord(record?.meta); - const agentMeta = asRecord(meta?.agentMeta); + const record = asOptionalRecord(result); + const meta = asOptionalRecord(record?.meta); + const agentMeta = asOptionalRecord(meta?.agentMeta); return ( normalizeOptionalString(agentMeta?.sessionFile) ?? normalizeOptionalString(meta?.sessionFile) ); diff --git a/extensions/active-memory/transcript.ts b/extensions/active-memory/transcript.ts index 4b12d7cec528..fe1120ea7010 100644 --- a/extensions/active-memory/transcript.ts +++ b/extensions/active-memory/transcript.ts @@ -7,7 +7,7 @@ import { type SessionTranscriptTargetParams, } from "openclaw/plugin-sdk/session-transcript-runtime"; import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -176,8 +176,9 @@ async function streamActiveMemoryTranscriptRecords(params: { } function resolveToolResultMessage(value: unknown): Record | undefined { - const record = asRecord(value); - const message = asRecord(record?.message) ?? (record?.role === "toolResult" ? record : undefined); + const record = asOptionalRecord(value); + const message = + asOptionalRecord(record?.message) ?? (record?.role === "toolResult" ? record : undefined); return message && normalizeOptionalString(message.role) === "toolResult" ? message : undefined; } @@ -192,8 +193,8 @@ function extractActiveMemorySearchDebugFromSessionRecord( if (toolName !== "memory_search" && toolName !== "memory_recall") { return undefined; } - const details = asRecord(message.details); - const debug = asRecord(details?.debug); + const details = asOptionalRecord(message.details); + const debug = asOptionalRecord(details?.debug); const warning = normalizeOptionalString(details?.warning); const action = normalizeOptionalString(details?.action); const error = normalizeOptionalString(details?.error); @@ -240,7 +241,7 @@ function hasUnavailableMemoryResultInSessionRecord( if (!toolName || !toolsAllow.includes(toolName)) { return false; } - const details = asRecord(message.details); + const details = asOptionalRecord(message.details); const unavailable = message.isError === true || readStructuredMemoryFailure(details) === true; if (unavailable) { return true; @@ -260,7 +261,7 @@ function hasTerminalUnavailableMemoryResultInSessionRecord( if (!toolName || !toolsAllow.includes(toolName)) { return false; } - const details = asRecord(message.details); + const details = asOptionalRecord(message.details); if (details?.disabled === true || details?.unavailable === true) { return true; } @@ -325,7 +326,7 @@ function hasUsableMemoryResultInSessionRecord( if (hasUnavailableMemoryResultInSessionRecord(value, toolsAllow)) { return false; } - const details = asRecord(message.details); + const details = asOptionalRecord(message.details); const content = extractTextContent(message.content); if (toolName === "memory_search") { if (Array.isArray(details?.results)) { @@ -372,7 +373,7 @@ function hasUsableMemoryResultInSessionRecord( return true; } try { - const parsed = asRecord(JSON.parse(content)); + const parsed = asOptionalRecord(JSON.parse(content)); return ( typeof parsed?.expandedSummaryCount === "number" && Number.isFinite(parsed.expandedSummaryCount) && diff --git a/extensions/amazon-bedrock/embedding-provider.ts b/extensions/amazon-bedrock/embedding-provider.ts index 8d0336985b05..6b710ed0f2a4 100644 --- a/extensions/amazon-bedrock/embedding-provider.ts +++ b/extensions/amazon-bedrock/embedding-provider.ts @@ -9,7 +9,7 @@ import { type MemoryEmbeddingProviderCreateOptions, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -263,9 +263,9 @@ function parseSingle(family: Family, raw: string): number[] { return asNumberArray(Array.isArray(data.embeddings) ? data.embeddings[0]?.embedding : null); case "twelvelabs": { if (Array.isArray(data.data)) { - return asNumberArray(asRecord(data.data[0])?.embedding); + return asNumberArray(asOptionalRecord(data.data[0])?.embedding); } - const dataRecord = asRecord(data.data); + const dataRecord = asOptionalRecord(data.data); if (dataRecord) { return asNumberArray(dataRecord.embedding); } @@ -283,7 +283,7 @@ function parseCohereBatch(family: Family, raw: string): number[][] { throw malformedBedrockEmbeddingResponse(); } if (family === "cohere-v4" && !Array.isArray(embeddings)) { - const embeddingRecord = asRecord(embeddings); + const embeddingRecord = asOptionalRecord(embeddings); if (!embeddingRecord) { throw malformedBedrockEmbeddingResponse(); } diff --git a/extensions/anthropic/session-catalog-history.test.ts b/extensions/anthropic/session-catalog-history.test.ts index 8b0ce386bc78..2ecfea87d054 100644 --- a/extensions/anthropic/session-catalog-history.test.ts +++ b/extensions/anthropic/session-catalog-history.test.ts @@ -20,7 +20,7 @@ vi.mock("openclaw/plugin-sdk/session-transcript-runtime", () => ({ })); describe("importClaudeHistory", () => { - it("falls back for invalid timestamps while preserving valid pre-epoch dates", async () => { + it("preserves Date.parse semantics for valid strings and falls back for invalid values", async () => { appended.length = 0; const fallbackTimestamp = new Date("2026-07-18T12:00:00.000Z").getTime(); vi.useFakeTimers(); @@ -29,11 +29,13 @@ describe("importClaudeHistory", () => { await importClaudeHistory({ items: [ { type: "userMessage", text: "invalid", timestamp: "not-a-date", uuid: "u-1" }, + { type: "userMessage", text: "numeric year", timestamp: "2026", uuid: "u-2" }, + { type: "userMessage", text: "numeric zero", timestamp: "0", uuid: "u-3" }, { type: "userMessage", text: "pre-epoch", timestamp: "1969-12-31T23:59:59.000Z", - uuid: "u-2", + uuid: "u-4", }, ], threadId: "thread-1", @@ -47,8 +49,13 @@ describe("importClaudeHistory", () => { vi.useRealTimers(); } - expect(appended).toHaveLength(2); - expect(appended.map((message) => message.timestamp)).toEqual([-1_000, fallbackTimestamp + 1]); + expect(appended).toHaveLength(4); + expect(appended.map((message) => message.timestamp)).toEqual([ + -1_000, + Date.parse("0"), + Date.parse("2026"), + fallbackTimestamp + 3, + ]); expect(JSON.stringify(appended)).not.toContain('"timestamp":null'); }); diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index 278d05062e0b..786f4fbe6b4b 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -544,6 +544,34 @@ describe("Claude session catalog", () => { ); }); + it("preserves date-first parsing for numeric-looking index timestamps", async () => { + const home = await createHome(); + const sessionId = "numeric-looking-timestamps"; + await writeProject({ + home, + entries: [ + { + sessionId, + created: "0", + modified: "2026", + isSidechain: false, + }, + ], + transcripts: { [sessionId]: [message(sessionId, "user", "timestamp contract", 1)] }, + }); + + await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({ + sessions: [ + { + threadId: sessionId, + createdAt: Date.parse("0"), + updatedAt: Date.parse("2026"), + recencyAt: Date.parse("2026"), + }, + ], + }); + }); + it("adopts a local CLI row with a locked one-shot fork binding", async () => { const home = await createHome(); process.env.HOME = home; diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index e172d21f1e07..b577840cb325 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -12,7 +12,7 @@ import type { SessionCatalogPullRequestSummary, SessionCatalogTranscriptItem, } from "openclaw/plugin-sdk/session-catalog"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumber, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { readClaudeDesktopCustomGroups } from "./claude-desktop-groups.js"; import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_MODEL_REF } from "./cli-constants.js"; import { @@ -222,7 +222,7 @@ function cacheCatalogDiscovery(filePath: string, entry: CatalogDiscoveryCacheEnt setBoundedCache(catalogDiscoveryCache, filePath, entry, MAX_CATALOG_DISCOVERY_CACHE_ENTRIES); } -function optionalString(value: unknown, maxLength = MAX_STRING_LENGTH): string | undefined { +function readBoundedString(value: unknown, maxLength = MAX_STRING_LENGTH): string | undefined { if (typeof value !== "string") { return undefined; } @@ -323,15 +323,10 @@ function isCliEntrypoint(value: unknown): value is string { return typeof value === "string" && CLI_ENTRYPOINTS.has(value); } -function timestampMs(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value !== "string") { - return undefined; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; +// Claude's persisted string timestamps are date expressions, including numeric-looking years. +// Numeric fields are already millisecond values, so preserve that distinct mixed-input contract. +function parseClaudeCatalogTimestampMs(value: unknown): number | undefined { + return typeof value === "string" ? asFiniteNumber(Date.parse(value)) : asFiniteNumber(value); } function isWithin(root: string, candidate: string): boolean { @@ -550,7 +545,7 @@ async function readDesktopMetadata(homeDir: string): Promise<{ continue; } const metadata = raw as DesktopSessionMetadata; - const cliSessionId = optionalString(metadata.cliSessionId, 256); + const cliSessionId = readBoundedString(metadata.cliSessionId, 256); if (!cliSessionId) { continue; } @@ -560,7 +555,7 @@ async function readDesktopMetadata(homeDir: string): Promise<{ continue; } if (!archived.has(cliSessionId)) { - const localSessionId = optionalString(metadata.sessionId, 256); + const localSessionId = readBoundedString(metadata.sessionId, 256); const customGroup = localSessionId ? customGroups.get(localSessionId) : undefined; active.set(cliSessionId, customGroup ? { ...metadata, customGroup } : metadata); } @@ -607,7 +602,7 @@ async function readIndexRecords(context: ClaudeSessionScanContext): Promise<{ continue; } const entry = candidate as SessionIndexEntry; - const sessionId = optionalString(entry.sessionId, 256); + const sessionId = readBoundedString(entry.sessionId, 256); if (!sessionId) { continue; } @@ -621,7 +616,7 @@ async function readIndexRecords(context: ClaudeSessionScanContext): Promise<{ if (entry.isSidechain === true) { return undefined; } - const indexedPath = optionalString(entry.fullPath, MAX_STRING_LENGTH); + const indexedPath = readBoundedString(entry.fullPath, MAX_STRING_LENGTH); return await safeSessionFileForScan( context, indexedPath ?? path.join(directory, `${sessionId}.jsonl`), @@ -640,21 +635,23 @@ async function readIndexRecords(context: ClaudeSessionScanContext): Promise<{ if (!safeFile) { continue; } - const createdAt = timestampMs(entry.created); - const updatedAt = timestampMs(entry.modified) ?? timestampMs(entry.fileMtime); - const summary = optionalString(entry.summary, 500); - const firstPrompt = optionalString(entry.firstPrompt, 500); + const createdAt = parseClaudeCatalogTimestampMs(entry.created); + const updatedAt = + parseClaudeCatalogTimestampMs(entry.modified) ?? + parseClaudeCatalogTimestampMs(entry.fileMtime); + const summary = readBoundedString(entry.summary, 500); + const firstPrompt = readBoundedString(entry.firstPrompt, 500); records.set(sessionId, { threadId: sessionId, name: summary ?? firstPrompt ?? null, - cwd: optionalString(entry.projectPath), + cwd: readBoundedString(entry.projectPath), status: "stored", ...(createdAt !== undefined ? { createdAt } : {}), ...(updatedAt !== undefined ? { updatedAt, recencyAt: updatedAt } : {}), source: "claude-cli", modelProvider: "anthropic", - ...(optionalString(entry.gitBranch, 500) - ? { gitBranch: optionalString(entry.gitBranch, 500) } + ...(readBoundedString(entry.gitBranch, 500) + ? { gitBranch: readBoundedString(entry.gitBranch, 500) } : {}), archived: false, filePath: safeFile.filePath, @@ -800,7 +797,7 @@ async function discoverCliRecords( return false; } if (raw.type === "ai-title") { - aiTitle = optionalString(raw.aiTitle, 500) ?? aiTitle; + aiTitle = readBoundedString(raw.aiTitle, 500) ?? aiTitle; return false; } if (typeof raw.entrypoint === "string" && !isCliEntrypoint(raw.entrypoint)) { @@ -820,23 +817,23 @@ async function discoverCliRecords( } const fragments: string[] = []; collectTranscriptText(raw.message.content, fragments); - const firstPrompt = optionalString(fragments[0], 500); - const createdAt = timestampMs(raw.timestamp); + const firstPrompt = readBoundedString(fragments[0], 500); + const createdAt = parseClaudeCatalogTimestampMs(raw.timestamp); records.set(sessionId, { threadId: sessionId, name: aiTitle ?? firstPrompt ?? null, - cwd: optionalString(raw.cwd), + cwd: readBoundedString(raw.cwd), status: "stored", ...(createdAt !== undefined ? { createdAt } : {}), updatedAt: stat.mtimeMs, recencyAt: stat.mtimeMs, source: "claude-cli", modelProvider: "anthropic", - ...(optionalString(raw.version, 256) - ? { cliVersion: optionalString(raw.version, 256) } + ...(readBoundedString(raw.version, 256) + ? { cliVersion: readBoundedString(raw.version, 256) } : {}), - ...(optionalString(raw.gitBranch, 500) - ? { gitBranch: optionalString(raw.gitBranch, 500) } + ...(readBoundedString(raw.gitBranch, 500) + ? { gitBranch: readBoundedString(raw.gitBranch, 500) } : {}), archived: false, filePath, @@ -937,9 +934,9 @@ async function scanClaudeSessions( if (!filePath) { continue; } - const createdAt = timestampMs(metadata.createdAt) ?? existing?.createdAt; - const updatedAt = timestampMs(metadata.lastActivityAt) ?? existing?.updatedAt; - const customGroup = optionalString(metadata.customGroup, 500); + const createdAt = parseClaudeCatalogTimestampMs(metadata.createdAt) ?? existing?.createdAt; + const updatedAt = parseClaudeCatalogTimestampMs(metadata.lastActivityAt) ?? existing?.updatedAt; + const customGroup = readBoundedString(metadata.customGroup, 500); const pullRequest = desktopPullRequestSummary(metadata); records.set(sessionId, { ...(existing ?? { @@ -948,8 +945,9 @@ async function scanClaudeSessions( modelProvider: "anthropic" as const, archived: false as const, }), - name: optionalString(metadata.title, 500) ?? existing?.name ?? null, - cwd: optionalString(metadata.cwd) ?? optionalString(metadata.originCwd) ?? existing?.cwd, + name: readBoundedString(metadata.title, 500) ?? existing?.name ?? null, + cwd: + readBoundedString(metadata.cwd) ?? readBoundedString(metadata.originCwd) ?? existing?.cwd, ...(createdAt !== undefined ? { createdAt } : {}), ...(updatedAt !== undefined ? { updatedAt, recencyAt: updatedAt } : {}), ...(customGroup ? { customGroup } : {}), @@ -1090,7 +1088,7 @@ function readListParams(value: unknown): { throw new ClaudeCatalogParamsError(`unknown Claude session catalog parameter: ${unknown}`); } const cursor = readOptionalCursor(value.cursor, "catalog"); - const searchTerm = optionalString(value.searchTerm, MAX_SEARCH_LENGTH); + const searchTerm = readBoundedString(value.searchTerm, MAX_SEARCH_LENGTH); return { limit: readLimit(value.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT), ...(cursor ? { cursor } : {}), @@ -1140,7 +1138,7 @@ function readTranscriptParams( if (unknown) { throw new ClaudeCatalogParamsError(`unknown Claude session read parameter: ${unknown}`); } - const threadId = optionalString(value.threadId, 256); + const threadId = readBoundedString(value.threadId, 256); if (!threadId || !/^[A-Za-z0-9._:-]+$/.test(threadId)) { throw new ClaudeCatalogParamsError("threadId is invalid"); } @@ -1208,7 +1206,7 @@ export async function readLocalClaudeTranscriptPage( const segment = chunk.subarray(index + 1, right); if (segment.length > 0 || fragments.length > 0) { const line = Buffer.concat([segment, ...fragments.toReversed()]); - const item = parseTranscriptLine(line, optionalString); + const item = parseTranscriptLine(line, readBoundedString); fragments = []; if (item) { found.push({ item, start: position + index + 1 }); @@ -1226,7 +1224,7 @@ export async function readLocalClaudeTranscriptPage( if (position === 0) { if (prefix.length > 0 || fragments.length > 0) { const line = Buffer.concat([prefix, ...fragments.toReversed()]); - const item = parseTranscriptLine(line, optionalString); + const item = parseTranscriptLine(line, readBoundedString); if (item) { found.push({ item, start: 0 }); } @@ -1294,7 +1292,7 @@ function parseCatalogPage(value: unknown): ClaudeSessionCatalogPage { if (!isRecord(candidate)) { throw new Error("Claude node returned an invalid session"); } - const threadId = optionalString(candidate.threadId, 256); + const threadId = readBoundedString(candidate.threadId, 256); const source = candidate.source; if ( !threadId || @@ -1309,7 +1307,7 @@ function parseCatalogPage(value: unknown): ClaudeSessionCatalogPage { if (!(key in candidate)) { return undefined; } - const parsed = optionalString(candidate[key], maxLength); + const parsed = readBoundedString(candidate[key], maxLength); if (!parsed) { throw new Error("Claude node returned an invalid session"); } @@ -1385,7 +1383,7 @@ function parseGatewayQuery(value: unknown): { if (unknown) { throw new ClaudeCatalogParamsError(`unknown Claude session catalog parameter: ${unknown}`); } - const search = optionalString(value.search, MAX_SEARCH_LENGTH); + const search = readBoundedString(value.search, MAX_SEARCH_LENGTH); let hostIds: string[] | undefined; if (value.hostIds !== undefined) { if (!Array.isArray(value.hostIds) || value.hostIds.length > MAX_HOSTS) { @@ -1394,7 +1392,7 @@ function parseGatewayQuery(value: unknown): { hostIds = [ ...new Set( value.hostIds.map((hostId) => { - const normalized = optionalString(hostId, 256); + const normalized = readBoundedString(hostId, 256); if ( !normalized || (normalized !== CLAUDE_LOCAL_SESSION_HOST_ID && !normalized.startsWith("node:")) diff --git a/extensions/azure-speech/speech-provider.ts b/extensions/azure-speech/speech-provider.ts index 793247564874..045a9f9f1345 100644 --- a/extensions/azure-speech/speech-provider.ts +++ b/extensions/azure-speech/speech-provider.ts @@ -12,10 +12,10 @@ import type { } from "openclaw/plugin-sdk/speech-core"; import { asFiniteNumber, - asObject, resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { azureSpeechTTS, DEFAULT_AZURE_SPEECH_AUDIO_FORMAT, @@ -68,12 +68,12 @@ function readAzureSpeechEnvEndpoint(): string | undefined { function resolveAzureSpeechConfigRecord( rawConfig: Record, ): Record | undefined { - const providers = asObject(rawConfig.providers); + const providers = asOptionalRecord(rawConfig.providers); return ( - asObject(providers?.["azure-speech"]) ?? - asObject(providers?.azure) ?? - asObject(rawConfig["azure-speech"]) ?? - asObject(rawConfig.azure) + asOptionalRecord(providers?.["azure-speech"]) ?? + asOptionalRecord(providers?.azure) ?? + asOptionalRecord(rawConfig["azure-speech"]) ?? + asOptionalRecord(rawConfig.azure) ); } diff --git a/extensions/azure-speech/tts.ts b/extensions/azure-speech/tts.ts index 91da9e046176..b04bdfb03a31 100644 --- a/extensions/azure-speech/tts.ts +++ b/extensions/azure-speech/tts.ts @@ -8,11 +8,12 @@ import { readProviderJsonResponse, } from "openclaw/plugin-sdk/provider-http"; import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech-core"; -import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech-core"; +import { trimToUndefined } from "openclaw/plugin-sdk/speech-core"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; /** Default Azure Speech neural voice. */ export const DEFAULT_AZURE_SPEECH_VOICE = "en-US-JennyNeural"; @@ -157,12 +158,12 @@ export async function listAzureSpeechVoices(params: { const voices = await readProviderJsonResponse(response, "azure-speech.voices"); return Array.isArray(voices) ? voices.flatMap((value) => { - const voice = asObject(value); + const voice = asOptionalRecord(value); const id = trimToUndefined(voice?.ShortName); if (!voice || !id || isDeprecatedVoice(voice)) { return []; } - const voiceTag = asObject(voice.VoiceTag); + const voiceTag = asOptionalRecord(voice.VoiceTag); const tailoredScenarios = readAzureVoiceTagStrings(voiceTag?.TailoredScenarios); const personalities = readAzureVoiceTagStrings(voiceTag?.VoicePersonalities); return [ diff --git a/extensions/beam/src/types.ts b/extensions/beam/src/types.ts index 99bb7d5e8130..71196f3da5d1 100644 --- a/extensions/beam/src/types.ts +++ b/extensions/beam/src/types.ts @@ -47,7 +47,7 @@ function hasOnlyKeys(value: Record, allowed: Set): bool return Object.keys(value).every((key) => allowed.has(key)); } -function optionalString(value: unknown, maxLength: number): string | undefined { +function readBoundedString(value: unknown, maxLength: number): string | undefined { if (typeof value !== "string") { return undefined; } @@ -96,19 +96,19 @@ export function parseBeamUpload( if (value.version !== 1) { return { ok: false, error: "version must be 1" }; } - const beamId = optionalString(value.beamId, 64); + const beamId = readBoundedString(value.beamId, 64); if (!beamId || !/^[a-f0-9]{32}$/i.test(beamId)) { return { ok: false, error: "beamId must be a 32-character hex id" }; } - const source = optionalString(value.source, 32); + const source = readBoundedString(value.source, 32); if (!source || !/^[a-z0-9._-]+$/i.test(source)) { return { ok: false, error: "source must be a short identifier" }; } - const title = optionalString(value.title, 160); + const title = readBoundedString(value.title, 160); if (!title) { return { ok: false, error: "title must be a non-empty string" }; } - const updatedAt = optionalString(value.updatedAt, 64); + const updatedAt = readBoundedString(value.updatedAt, 64); if (!updatedAt || !isIsoTimestamp(updatedAt)) { return { ok: false, error: "updatedAt must be an ISO timestamp" }; } @@ -118,7 +118,8 @@ export function parseBeamUpload( if (value.truncated !== undefined && typeof value.truncated !== "boolean") { return { ok: false, error: "truncated must be a boolean" }; } - const hookEvent = value.hookEvent === undefined ? undefined : optionalString(value.hookEvent, 64); + const hookEvent = + value.hookEvent === undefined ? undefined : readBoundedString(value.hookEvent, 64); if (value.hookEvent !== undefined && !hookEvent) { return { ok: false, error: "hookEvent must be a short string" }; } @@ -140,7 +141,7 @@ export function parseBeamUpload( ) { return { ok: false, error: "transcript item type is invalid" }; } - const text = optionalString(rawItem.text, BEAM_MAX_ITEM_CHARS); + const text = readBoundedString(rawItem.text, BEAM_MAX_ITEM_CHARS); if (!text) { return { ok: false, diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index 621f9ca73cdb..2a447735d987 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -25,7 +25,7 @@ const { } = mocks; const dnsLabelEncoder = new TextEncoder(); -const asString = (value: unknown, fallback: string) => +const stringOrFallback = (value: unknown, fallback: string) => typeof value === "string" && value.trim() ? value : fallback; function expectDnsLabelByteLength(value: string, expected: number) { @@ -81,8 +81,9 @@ function mockCiaoService(params?: { advertise, destroy, on, - getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, - getHostname: () => asString(options.hostname, "unknown"), + getFQDN: () => + `${stringOrFallback(options.type, "service")}.${stringOrFallback(options.domain, "local")}.`, + getHostname: () => stringOrFallback(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; Object.defineProperty(service, "serviceState", { @@ -173,7 +174,7 @@ describe("gateway bonjour advertiser", () => { expect(createService).toHaveBeenCalledTimes(1); const [gatewayCall] = createService.mock.calls as Array<[Record]>; expect(gatewayCall?.[0]?.type).toBe("openclaw-gw"); - const gatewayType = asString(gatewayCall?.[0]?.type, ""); + const gatewayType = stringOrFallback(gatewayCall?.[0]?.type, ""); expect(gatewayType.length).toBeLessThanOrEqual(15); expect(gatewayCall?.[0]?.port).toBe(18789); expect(gatewayCall?.[0]?.domain).toBe("local"); diff --git a/extensions/browser/setup-api.ts b/extensions/browser/setup-api.ts index eebd5686c059..a5393f0d93fc 100644 --- a/extensions/browser/setup-api.ts +++ b/extensions/browser/setup-api.ts @@ -4,8 +4,10 @@ */ import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { isRecord } from "./src/record-shared.js"; +import { + isRecord, + normalizeOptionalLowercaseString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; function listContainsBrowser(value: unknown): boolean { return ( diff --git a/extensions/browser/src/browser-proxy-upload.ts b/extensions/browser/src/browser-proxy-upload.ts index 7fa7c1221c4d..34ef1324369a 100644 --- a/extensions/browser/src/browser-proxy-upload.ts +++ b/extensions/browser/src/browser-proxy-upload.ts @@ -7,6 +7,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { assertBrowserProxyFileBytesWithinLimits, assertBrowserProxyFileCountWithinLimit, @@ -16,7 +17,6 @@ import { type BrowserProxyUploadV1, } from "./browser-proxy-envelope.js"; import { DEFAULT_UPLOAD_DIR, resolveExistingUploadPaths } from "./browser/paths.js"; -import { asRecord } from "./record-shared.js"; const logger = createSubsystemLogger("browser"); const BROWSER_PROXY_UPLOAD_ROOT_NAME = ".proxy-uploads"; @@ -73,7 +73,7 @@ export function isBrowserProxyUploadRequest(params: { if (!isFileChooserRequest(params.method, params.path)) { return false; } - const body = asRecord(params.body); + const body = asNullableRecord(params.body); return Boolean(body && Array.isArray(body.paths) && body.paths.length > 0); } @@ -125,7 +125,7 @@ export async function prepareBrowserProxyUploadRequest(params: { if (!isFileChooserRequest(params.method, params.path)) { return { body: params.body }; } - const body = asRecord(params.body); + const body = asNullableRecord(params.body); if (!body) { return { body: params.body }; } @@ -497,7 +497,7 @@ export async function stageBrowserProxyUploadRequest(params: { if (!isFileChooserRequest(params.method, params.path)) { throw new Error("INVALID_REQUEST: browser proxy upload requires the file chooser route"); } - const body = asRecord(params.body); + const body = asNullableRecord(params.body); if (!body || Object.hasOwn(body, "paths")) { throw new Error("INVALID_REQUEST: browser proxy upload body must omit paths"); } diff --git a/extensions/browser/src/browser-tool-session-tabs.ts b/extensions/browser/src/browser-tool-session-tabs.ts index 677402cd395c..1a9567e3347d 100644 --- a/extensions/browser/src/browser-tool-session-tabs.ts +++ b/extensions/browser/src/browser-tool-session-tabs.ts @@ -1,6 +1,7 @@ /** * Session tracking for tabs created through the browser tool. */ +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { BrowserTabOwnership } from "./browser/client.types.js"; type SessionTabParams = { @@ -19,10 +20,6 @@ type SessionTabRegistry = { untrackSessionBrowserTab: (params: SessionTabParams) => void; }; -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readOpenedTab(result: unknown): { targetId?: string; aliases: string[]; @@ -33,14 +30,14 @@ function readOpenedTab(result: unknown): { return { aliases: [] }; } const opened = result as Record; - const targetId = readString(opened.targetId); + const targetId = normalizeOptionalString(opened.targetId); const aliases = [ targetId, - readString(opened.tabId), - readString(opened.label), - readString(opened.suggestedTargetId), + normalizeOptionalString(opened.tabId), + normalizeOptionalString(opened.label), + normalizeOptionalString(opened.suggestedTargetId), ].filter((alias): alias is string => Boolean(alias)); - const profile = readString(opened.resolvedProfile); + const profile = normalizeOptionalString(opened.resolvedProfile); const rawOwnership = opened.ownership && typeof opened.ownership === "object" ? (opened.ownership as BrowserTabOwnership) diff --git a/extensions/browser/src/browser/chrome-mcp-result.ts b/extensions/browser/src/browser/chrome-mcp-result.ts index 2d18cf3dec0e..5f89a50590cd 100644 --- a/extensions/browser/src/browser/chrome-mcp-result.ts +++ b/extensions/browser/src/browser/chrome-mcp-result.ts @@ -1,12 +1,12 @@ // Parses Chrome MCP tool results and formats redacted tool failures. import path from "node:path"; import { + asNullableRecord, normalizeOptionalString, readStringValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { toErrorObject } from "../infra/errors.js"; import { redactToolPayloadText } from "../logging/redact.js"; -import { asRecord } from "../record-shared.js"; import { redactCdpUrl } from "./cdp.helpers.js"; import { CHROME_CONNECTION_TOOL_ERROR_RE, @@ -28,7 +28,7 @@ function asPages(value: unknown): ChromeMcpStructuredPage[] { } const out: ChromeMcpStructuredPage[] = []; for (const entry of value) { - const record = asRecord(entry); + const record = asNullableRecord(entry); if (!record || typeof record.id !== "number") { continue; } @@ -42,14 +42,14 @@ function asPages(value: unknown): ChromeMcpStructuredPage[] { } function extractStructuredContent(result: ChromeMcpToolResult): Record { - return asRecord(result.structuredContent) ?? {}; + return asNullableRecord(result.structuredContent) ?? {}; } function extractTextContent(result: ChromeMcpToolResult): string[] { const content = Array.isArray(result.content) ? result.content : []; return content .map((entry) => { - const record = asRecord(entry); + const record = asNullableRecord(entry); return record && typeof record.text === "string" ? record.text : ""; }) .filter(Boolean); @@ -80,7 +80,7 @@ export function extractStructuredPages(result: ChromeMcpToolResult): ChromeMcpSt export function extractSnapshot(result: ChromeMcpToolResult): ChromeMcpSnapshotNode { const structured = extractStructuredContent(result); - const snapshot = asRecord(structured.snapshot); + const snapshot = asNullableRecord(structured.snapshot); if (!snapshot) { throw new Error("Chrome MCP snapshot response was missing structured snapshot data."); } diff --git a/extensions/browser/src/browser/chrome-mcp.snapshot.ts b/extensions/browser/src/browser/chrome-mcp.snapshot.ts index 0ae8cc084d6d..72e79b9e1bf3 100644 --- a/extensions/browser/src/browser/chrome-mcp.snapshot.ts +++ b/extensions/browser/src/browser/chrome-mcp.snapshot.ts @@ -5,7 +5,6 @@ * and compact AI snapshots with stable refs and duplicate tracking. */ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { normalizeString } from "../record-shared.js"; import type { SnapshotAriaNode } from "./client.types.js"; import type { RoleRefMap, RoleSnapshotOptions } from "./pw-role-snapshot.js"; import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js"; @@ -20,6 +19,13 @@ export type ChromeMcpSnapshotNode = { children?: ChromeMcpSnapshotNode[]; }; +function normalizeSnapshotString(value: unknown): string | undefined { + if (typeof value === "string") { + return value.trim() || undefined; + } + return typeof value === "number" || typeof value === "boolean" ? String(value) : undefined; +} + function normalizeRole(node: ChromeMcpSnapshotNode): string { const role = normalizeLowercaseStringOrEmpty(node.role); return role || "generic"; @@ -94,14 +100,14 @@ export function flattenChromeMcpSnapshotToAriaNodes( if (out.length >= boundedLimit) { return; } - const ref = normalizeString(node.id); + const ref = normalizeSnapshotString(node.id); if (ref) { out.push({ ref, role: normalizeRole(node), - name: normalizeString(node.name) ?? "", - value: normalizeString(node.value), - description: normalizeString(node.description), + name: normalizeSnapshotString(node.name) ?? "", + value: normalizeSnapshotString(node.value), + description: normalizeSnapshotString(node.description), depth, }); } @@ -131,9 +137,9 @@ export function buildAiSnapshotFromChromeMcpSnapshot(params: { const visit = (node: ChromeMcpSnapshotNode, depth: number) => { const role = normalizeRole(node); - const name = normalizeString(node.name); - const value = normalizeString(node.value); - const description = normalizeString(node.description); + const name = normalizeSnapshotString(node.name); + const value = normalizeSnapshotString(node.value); + const description = normalizeSnapshotString(node.description); const maxDepth = params.options?.maxDepth; if (maxDepth !== undefined && depth > maxDepth) { return; @@ -145,7 +151,7 @@ export function buildAiSnapshotFromChromeMcpSnapshot(params: { if (name) { line += ` "${escapeQuoted(name)}"`; } - const ref = normalizeString(node.id); + const ref = normalizeSnapshotString(node.id); if (ref && shouldCreateRef(role, name)) { const nth = registerRef(tracker, ref, role, name); refs[ref] = nth === undefined ? { role, name } : { role, name, nth }; diff --git a/extensions/browser/src/browser/chrome.graphics.ts b/extensions/browser/src/browser/chrome.graphics.ts index 5f24e38c8753..33ba5fe98df9 100644 --- a/extensions/browser/src/browser/chrome.graphics.ts +++ b/extensions/browser/src/browser/chrome.graphics.ts @@ -1,3 +1,4 @@ +import { asNullableRecord, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; /** * Managed Chrome graphics diagnostics. * @@ -5,7 +6,6 @@ * exact RunningChrome instance that owns the process. */ import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { asRecord, isRecord } from "../record-shared.js"; import { redactCdpErrorText, withCdpSocket } from "./cdp.helpers.js"; import { getChromeWebSocketUrl, type RunningChrome } from "./chrome.js"; import type { @@ -23,16 +23,16 @@ type ChromeGraphicsProbeOptions = { ssrfPolicy?: SsrFPolicy; }; -function readString(value: unknown): string { +function readChromeString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } -function readNumber(value: unknown): number { +function readChromeNumber(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } function readStringRecord(value: unknown): Record { - const record = asRecord(value); + const record = asNullableRecord(value); if (!record) { return {}; } @@ -49,10 +49,10 @@ function readStringArray(value: unknown): string[] { } function readSize(value: unknown): { width: number; height: number } { - const size = asRecord(value); + const size = asNullableRecord(value); return { - width: readNumber(size?.width), - height: readNumber(size?.height), + width: readChromeNumber(size?.width), + height: readChromeNumber(size?.height), }; } @@ -62,18 +62,18 @@ function readRecordArray(value: unknown): Record[] { function readDevices(value: unknown): BrowserGraphicsDevice[] { return readRecordArray(value).map((device) => ({ - vendorId: readNumber(device.vendorId), - deviceId: readNumber(device.deviceId), - vendor: readString(device.vendorString), - device: readString(device.deviceString), - driverVendor: readString(device.driverVendor), - driverVersion: readString(device.driverVersion), + vendorId: readChromeNumber(device.vendorId), + deviceId: readChromeNumber(device.deviceId), + vendor: readChromeString(device.vendorString), + device: readChromeString(device.deviceString), + driverVendor: readChromeString(device.driverVendor), + driverVersion: readChromeString(device.driverVersion), })); } function readVideoDecoding(value: unknown): BrowserVideoDecodeCapability[] { return readRecordArray(value).map((capability) => ({ - profile: readString(capability.profile), + profile: readChromeString(capability.profile), minResolution: readSize(capability.minResolution), maxResolution: readSize(capability.maxResolution), })); @@ -81,10 +81,10 @@ function readVideoDecoding(value: unknown): BrowserVideoDecodeCapability[] { function readVideoEncoding(value: unknown): BrowserVideoEncodeCapability[] { return readRecordArray(value).map((capability) => ({ - profile: readString(capability.profile), + profile: readChromeString(capability.profile), maxResolution: readSize(capability.maxResolution), - maxFramerateNumerator: readNumber(capability.maxFramerateNumerator), - maxFramerateDenominator: readNumber(capability.maxFramerateDenominator), + maxFramerateNumerator: readChromeNumber(capability.maxFramerateNumerator), + maxFramerateDenominator: readChromeNumber(capability.maxFramerateDenominator), })); } @@ -93,7 +93,7 @@ function firstAttribute( names: readonly string[], ): string | null { for (const name of names) { - const value = readString(attributes[name]); + const value = readChromeString(attributes[name]); if (value) { return value; } @@ -133,8 +133,8 @@ function normalizeChromeGraphicsInfo( value: unknown, observedAt = Date.now(), ): BrowserGraphicsDiagnostics { - const result = asRecord(value); - const gpu = asRecord(result?.gpu); + const result = asNullableRecord(value); + const gpu = asNullableRecord(result?.gpu); if (!gpu) { return { status: "unavailable", diff --git a/extensions/browser/src/browser/chrome.profile-decoration.ts b/extensions/browser/src/browser/chrome.profile-decoration.ts index 47a78f7583f1..ba78c38212f9 100644 --- a/extensions/browser/src/browser/chrome.profile-decoration.ts +++ b/extensions/browser/src/browser/chrome.profile-decoration.ts @@ -7,7 +7,7 @@ import fs from "node:fs"; import path from "node:path"; import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store"; -import { asRecord } from "../record-shared.js"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, @@ -20,7 +20,7 @@ function decoratedMarkerPath(userDataDir: string) { } function safeReadJson(filePath: string): Record | null { - return asRecord(loadJsonFile(filePath)); + return asNullableRecord(loadJsonFile(filePath)); } function safeWriteJson(filePath: string, data: Record) { @@ -28,11 +28,14 @@ function safeWriteJson(filePath: string, data: Record) { } function readNestedRecord(root: unknown, key: string): Record | null { - return asRecord(asRecord(root)?.[key]); + return asNullableRecord(asNullableRecord(root)?.[key]); } function readDefaultProfileInfo(localState: unknown): Record | null { - return readNestedRecord(readNestedRecord(asRecord(localState)?.profile, "info_cache"), "Default"); + return readNestedRecord( + readNestedRecord(asNullableRecord(localState)?.profile, "info_cache"), + "Default", + ); } function setDeep(obj: Record, keys: string[], value: unknown) { diff --git a/extensions/browser/src/browser/extension-native-host.ts b/extensions/browser/src/browser/extension-native-host.ts index 4bea06f7134e..f5ec05911c49 100644 --- a/extensions/browser/src/browser/extension-native-host.ts +++ b/extensions/browser/src/browser/extension-native-host.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; -import { asRecord } from "../record-shared.js"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { type BrowserNativeBootstrapResponse, decodeBrowserNativeFrame, @@ -103,10 +103,11 @@ async function validateNativeManifest(params: { throw new Error("launcher is outside the managed root"); } const parsed: unknown = JSON.parse(await fs.readFile(manifestPath, "utf8")); - if (!asRecord(parsed)) { + const manifestRecord = asNullableRecord(parsed); + if (!manifestRecord) { throw new Error("invalid manifest"); } - const manifest = parsed as NativeHostManifest; + const manifest = manifestRecord as NativeHostManifest; const expectedOrigins = validateExpectedOrigins(params.expectedOrigins); const keys = ["name", "description", "path", "type", "allowed_origins"]; if ( diff --git a/extensions/browser/src/browser/extension-native-protocol.ts b/extensions/browser/src/browser/extension-native-protocol.ts index c350f5e597c6..f5dc5e258ed6 100644 --- a/extensions/browser/src/browser/extension-native-protocol.ts +++ b/extensions/browser/src/browser/extension-native-protocol.ts @@ -1,5 +1,5 @@ import os from "node:os"; -import { asRecord } from "../record-shared.js"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; const BROWSER_NATIVE_REQUEST_MAX_BYTES = 4 * 1024; const BROWSER_NATIVE_RESPONSE_MAX_BYTES = 1024 * 1024; @@ -38,7 +38,7 @@ function rootJsonKeys(raw: string): string[] | null { index += 1; } }; - const readString = (): string | null => { + const readJsonString = (): string | null => { if (raw[index] !== '"') { return null; } @@ -103,7 +103,7 @@ function rootJsonKeys(raw: string): string[] | null { if (raw[index] === "}") { return keys; } - const key = readString(); + const key = readJsonString(); if (key === null) { return null; } @@ -149,7 +149,7 @@ function parseBrowserNativeRequest(raw: string): BrowserNativeBootstrapRequest | } catch { return null; } - const record = asRecord(parsed); + const record = asNullableRecord(parsed); return record?.v === 1 && record.op === "bootstrap" && isCanonicalNonce(record.nonce) ? { v: 1, op: "bootstrap", nonce: record.nonce } : null; diff --git a/extensions/browser/src/doctor-browser.ts b/extensions/browser/src/doctor-browser.ts index 423fea348336..5d562bc96f7d 100644 --- a/extensions/browser/src/doctor-browser.ts +++ b/extensions/browser/src/doctor-browser.ts @@ -5,7 +5,10 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asNullableRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { parseBrowserMajorVersion, readBrowserVersion, @@ -20,7 +23,6 @@ import { import { listSystemProfiles } from "./browser/system-profiles.js"; import { movePathToTrash } from "./browser/trash.js"; import type { OpenClawConfig } from "./config/config.js"; -import { asRecord } from "./record-shared.js"; import { formatCliCommand, note } from "./sdk-setup-tools.js"; import { CONFIG_DIR, resolveUserPath } from "./utils.js"; @@ -57,7 +59,7 @@ type BrowserDoctorFilesystemDeps = { }; function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] { - const browser = asRecord(cfg.browser); + const browser = asNullableRecord(cfg.browser); if (!browser) { return []; } @@ -68,13 +70,13 @@ function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] profiles.set("user", { name: "user" }); } - const configuredProfiles = asRecord(browser.profiles); + const configuredProfiles = asNullableRecord(browser.profiles); if (!configuredProfiles) { return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name)); } for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) { - const profile = asRecord(rawProfile); + const profile = asNullableRecord(rawProfile); const driver = normalizeOptionalString(profile?.driver) ?? ""; if (driver === "existing-session") { profiles.set(profileName, { @@ -88,7 +90,7 @@ function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] } function collectManagedProfiles(cfg: OpenClawConfig): ManagedProfile[] { - const browser = asRecord(cfg.browser); + const browser = asNullableRecord(cfg.browser); if (!browser) { return []; } @@ -99,13 +101,13 @@ function collectManagedProfiles(cfg: OpenClawConfig): ManagedProfile[] { profiles.set(defaultProfile, { name: defaultProfile }); } - const configuredProfiles = asRecord(browser.profiles); + const configuredProfiles = asNullableRecord(browser.profiles); if (!configuredProfiles) { return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name)); } for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) { - const profile = asRecord(rawProfile); + const profile = asNullableRecord(rawProfile); const driver = normalizeOptionalString(profile?.driver) ?? "openclaw"; if (driver !== "existing-session") { profiles.set(profileName, { name: profileName }); @@ -134,7 +136,7 @@ function isSameOrChildPath(candidatePath: string, parentPath: string): boolean { } function isLegacyClawdProfileConfigured(cfg: OpenClawConfig, legacyProfileDir: string): boolean { - const browser = asRecord(cfg.browser); + const browser = asNullableRecord(cfg.browser); if (!browser) { return false; } @@ -142,7 +144,7 @@ function isLegacyClawdProfileConfigured(cfg: OpenClawConfig, legacyProfileDir: s return true; } - const configuredProfiles = asRecord(browser.profiles); + const configuredProfiles = asNullableRecord(browser.profiles); if (!configuredProfiles) { return false; } @@ -151,7 +153,7 @@ function isLegacyClawdProfileConfigured(cfg: OpenClawConfig, legacyProfileDir: s } for (const rawProfile of Object.values(configuredProfiles)) { - const profile = asRecord(rawProfile); + const profile = asNullableRecord(rawProfile); const userDataDir = normalizeOptionalString(profile?.userDataDir); if (userDataDir && isSameOrChildPath(resolveUserPath(userDataDir), legacyProfileDir)) { return true; diff --git a/extensions/browser/src/record-shared.ts b/extensions/browser/src/record-shared.ts deleted file mode 100644 index ab28c0f3f015..000000000000 --- a/extensions/browser/src/record-shared.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Small record/string coercion helpers shared by Browser setup and audits. - */ -import { - asNullableRecord, - hasNonEmptyString as sharedHasNonEmptyString, - isRecord, -} from "openclaw/plugin-sdk/string-coerce-runtime"; - -/** Re-export record guards under Browser-local names. */ -export { asNullableRecord as asRecord, isRecord }; - -/** Re-export shared non-empty string predicate. */ -export const hasNonEmptyString = sharedHasNonEmptyString; - -/** Normalizes primitive string/number/boolean values to non-empty strings. */ -export function normalizeString(value: unknown): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed || undefined; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return undefined; -} diff --git a/extensions/browser/src/security-audit.ts b/extensions/browser/src/security-audit.ts index 0b940814f165..27b518f51dac 100644 --- a/extensions/browser/src/security-audit.ts +++ b/extensions/browser/src/security-audit.ts @@ -5,10 +5,12 @@ import type { OpenClawPluginSecurityAuditContext } from "openclaw/plugin-sdk/plu import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input"; import { formatCliCommand } from "openclaw/plugin-sdk/setup-tools"; import { isPrivateNetworkOptInEnabled, isPrivateIpAddress } from "openclaw/plugin-sdk/ssrf-policy"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + hasNonEmptyString, + normalizeLowercaseStringOrEmpty, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { redactCdpUrl, resolveBrowserConfig, resolveProfile } from "./browser/config.js"; import { resolveBrowserControlAuth } from "./browser/control-auth.js"; -import { hasNonEmptyString } from "./record-shared.js"; const BLOCKED_HOSTNAMES = new Set([ "localhost", diff --git a/extensions/clawrouter/provider-catalog.ts b/extensions/clawrouter/provider-catalog.ts index 22a3643388fe..e3fc82e7fa09 100644 --- a/extensions/clawrouter/provider-catalog.ts +++ b/extensions/clawrouter/provider-catalog.ts @@ -8,6 +8,12 @@ import type { ModelDefinitionConfig, ModelProviderConfig, } from "openclaw/plugin-sdk/provider-model-shared"; +import { + asFiniteNumberInRange, + asOptionalRecord, + asPositiveSafeInteger, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; const CLAWROUTER_DEFAULT_BASE_URL = "https://clawrouter.openclaw.ai"; @@ -73,19 +79,9 @@ type RouteMetadata = { upstreamModel?: string; }; -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; -} - function readStringArray(value: unknown): string[] { return Array.isArray(value) - ? value.map(readString).filter((entry): entry is string => Boolean(entry)) + ? value.map(normalizeOptionalString).filter((entry): entry is string => Boolean(entry)) : []; } @@ -102,16 +98,8 @@ export function normalizeClawRouterReasoningEfforts( return efforts.length > 0 ? efforts : undefined; } -function readNonNegativeNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} - -function readPositiveSafeInteger(value: unknown): number | undefined { - return Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : undefined; -} - function readCatalogRows(body: unknown): readonly unknown[] { - const providers = readRecord(body)?.providers; + const providers = asOptionalRecord(body)?.providers; if (!Array.isArray(providers)) { throw new Error("ClawRouter catalog response must contain providers[]"); } @@ -119,9 +107,9 @@ function readCatalogRows(body: unknown): readonly unknown[] { } function parseCatalogRoute(value: unknown): CatalogRoute | undefined { - const row = readRecord(value); - const path = readString(row?.path); - const requestFormat = readString(row?.requestFormat); + const row = asOptionalRecord(value); + const path = normalizeOptionalString(row?.path); + const requestFormat = normalizeOptionalString(row?.requestFormat); if (!path || !requestFormat) { return undefined; } @@ -133,25 +121,35 @@ function parseCatalogRoute(value: unknown): CatalogRoute | undefined { } function parseCatalogPricing(value: unknown): CatalogPricing | undefined { - const row = readRecord(value); + const row = asOptionalRecord(value); if (!row) { return undefined; } return { - inputMicrosPerMillion: readNonNegativeNumber(row.inputMicrosPerMillion), - outputMicrosPerMillion: readNonNegativeNumber(row.outputMicrosPerMillion), - cachedInputMicrosPerMillion: readNonNegativeNumber(row.cachedInputMicrosPerMillion), - cacheWrite5mInputMicrosPerMillion: readNonNegativeNumber(row.cacheWrite5mInputMicrosPerMillion), - cacheWrite1hInputMicrosPerMillion: readNonNegativeNumber(row.cacheWrite1hInputMicrosPerMillion), - maxInputTokens: readPositiveSafeInteger(row.maxInputTokens), - defaultMaxOutputTokens: readPositiveSafeInteger(row.defaultMaxOutputTokens), + inputMicrosPerMillion: asFiniteNumberInRange(row.inputMicrosPerMillion, { min: 0 }), + outputMicrosPerMillion: asFiniteNumberInRange(row.outputMicrosPerMillion, { min: 0 }), + cachedInputMicrosPerMillion: asFiniteNumberInRange(row.cachedInputMicrosPerMillion, { min: 0 }), + cacheWrite5mInputMicrosPerMillion: asFiniteNumberInRange( + row.cacheWrite5mInputMicrosPerMillion, + { + min: 0, + }, + ), + cacheWrite1hInputMicrosPerMillion: asFiniteNumberInRange( + row.cacheWrite1hInputMicrosPerMillion, + { + min: 0, + }, + ), + maxInputTokens: asPositiveSafeInteger(row.maxInputTokens), + defaultMaxOutputTokens: asPositiveSafeInteger(row.defaultMaxOutputTokens), }; } function parseCatalogModel(value: unknown): CatalogModel | undefined { - const row = readRecord(value); - const id = readString(row?.id); - const upstream = readString(row?.upstream); + const row = asOptionalRecord(value); + const id = normalizeOptionalString(row?.id); + const upstream = normalizeOptionalString(row?.upstream); if (!id || !upstream) { return undefined; } @@ -165,15 +163,15 @@ function parseCatalogModel(value: unknown): CatalogModel | undefined { } function parseCatalogProvider(value: unknown): CatalogProvider | undefined { - const row = readRecord(value); - const id = readString(row?.id); - const nativeBaseUrl = readString(row?.nativeBaseUrl); + const row = asOptionalRecord(value); + const id = normalizeOptionalString(row?.id); + const nativeBaseUrl = normalizeOptionalString(row?.nativeBaseUrl); if (!id || !nativeBaseUrl || !nativeBaseUrl.startsWith("/v1/native/")) { return undefined; } return { id, - displayName: readString(row?.displayName) ?? id, + displayName: normalizeOptionalString(row?.displayName) ?? id, openaiCompatible: row?.openaiCompatible === true, nativeBaseUrl, routes: Array.isArray(row?.routes) @@ -377,9 +375,9 @@ export async function buildClawRouterProviderConfig(params: { } function readRouteMetadata(params: ProviderRuntimeModel["params"]): RouteMetadata | undefined { - const row = readRecord(params?.[ROUTE_METADATA_KEY]); - const baseUrl = readString(row?.baseUrl); - const api = readString(row?.api); + const row = asOptionalRecord(params?.[ROUTE_METADATA_KEY]); + const baseUrl = normalizeOptionalString(row?.baseUrl); + const api = normalizeOptionalString(row?.api); if ( !baseUrl || (api !== "openai-responses" && @@ -389,7 +387,7 @@ function readRouteMetadata(params: ProviderRuntimeModel["params"]): RouteMetadat ) { return undefined; } - const upstreamModel = readString(row?.upstreamModel); + const upstreamModel = normalizeOptionalString(row?.upstreamModel); return { api, baseUrl, diff --git a/extensions/codex/doctor-contract-api.ts b/extensions/codex/doctor-contract-api.ts index 9792d7bd3cbc..a917737cbfeb 100644 --- a/extensions/codex/doctor-contract-api.ts +++ b/extensions/codex/doctor-contract-api.ts @@ -2,6 +2,7 @@ * Doctor contract hooks for Codex plugin config and state migrations. */ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; type LegacyConfigRule = { path: string[]; @@ -9,32 +10,26 @@ 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 hasRetiredDynamicToolsProfile(value: unknown): boolean { - return Object.hasOwn(asRecord(value) ?? {}, "codexDynamicToolsProfile"); + return Object.hasOwn(asNullableRecord(value) ?? {}, "codexDynamicToolsProfile"); } function hasLegacyPluginDestructivePolicy(value: unknown): boolean { - const codexPlugins = asRecord(value); + const codexPlugins = asNullableRecord(value); if (!codexPlugins) { return false; } if (codexPlugins.allow_destructive_actions === "on-request") { return true; } - const plugins = asRecord(codexPlugins.plugins); + const plugins = asNullableRecord(codexPlugins.plugins); return Object.values(plugins ?? {}).some( - (plugin) => asRecord(plugin)?.allow_destructive_actions === "on-request", + (plugin) => asNullableRecord(plugin)?.allow_destructive_actions === "on-request", ); } function hasRetiredOnFailureApprovalPolicy(value: unknown): boolean { - return asRecord(value)?.approvalPolicy === "on-failure"; + return asNullableRecord(value)?.approvalPolicy === "on-failure"; } /** Legacy Codex config keys that doctor should report or repair. */ @@ -66,10 +61,10 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): config: OpenClawConfig; changes: string[]; } { - const rawEntry = asRecord(cfg.plugins?.entries?.codex); - const rawPluginConfig = asRecord(rawEntry?.config); - const rawCodexPlugins = asRecord(rawPluginConfig?.codexPlugins); - const rawAppServer = asRecord(rawPluginConfig?.appServer); + const rawEntry = asNullableRecord(cfg.plugins?.entries?.codex); + const rawPluginConfig = asNullableRecord(rawEntry?.config); + const rawCodexPlugins = asNullableRecord(rawPluginConfig?.codexPlugins); + const rawAppServer = asNullableRecord(rawPluginConfig?.appServer); const shouldRemoveDynamicToolsProfile = rawPluginConfig !== null && hasRetiredDynamicToolsProfile(rawPluginConfig); const shouldRewriteDestructivePolicy = hasLegacyPluginDestructivePolicy(rawCodexPlugins); @@ -86,10 +81,10 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): const nextConfig = structuredClone(cfg) as OpenClawConfig & { plugins?: Record; }; - const nextPlugins = asRecord(nextConfig.plugins); - const nextEntries = asRecord(nextPlugins?.entries); - const nextEntry = asRecord(nextEntries?.codex); - const nextPluginConfig = asRecord(nextEntry?.config); + const nextPlugins = asNullableRecord(nextConfig.plugins); + const nextEntries = asNullableRecord(nextPlugins?.entries); + const nextEntry = asNullableRecord(nextEntries?.codex); + const nextPluginConfig = asNullableRecord(nextEntry?.config); if (!nextPluginConfig) { return { config: cfg, changes: [] }; } @@ -103,13 +98,13 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): } if (shouldRewriteDestructivePolicy) { - const nextCodexPlugins = asRecord(nextPluginConfig.codexPlugins); + const nextCodexPlugins = asNullableRecord(nextPluginConfig.codexPlugins); if (nextCodexPlugins?.allow_destructive_actions === "on-request") { nextCodexPlugins.allow_destructive_actions = "auto"; } - const nextPluginPolicies = asRecord(nextCodexPlugins?.plugins); + const nextPluginPolicies = asNullableRecord(nextCodexPlugins?.plugins); for (const plugin of Object.values(nextPluginPolicies ?? {})) { - const nextPlugin = asRecord(plugin); + const nextPlugin = asNullableRecord(plugin); if (nextPlugin?.allow_destructive_actions === "on-request") { nextPlugin.allow_destructive_actions = "auto"; } @@ -120,7 +115,7 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): } if (shouldRewriteApprovalPolicy) { - const nextAppServer = asRecord(nextPluginConfig.appServer); + const nextAppServer = asNullableRecord(nextPluginConfig.appServer); if (nextAppServer?.approvalPolicy === "on-failure") { nextAppServer.approvalPolicy = "on-request"; } diff --git a/extensions/codex/src/app-server/approval-bridge.ts b/extensions/codex/src/app-server/approval-bridge.ts index b653193e680d..b3cdaa38947a 100644 --- a/extensions/codex/src/app-server/approval-bridge.ts +++ b/extensions/codex/src/app-server/approval-bridge.ts @@ -13,7 +13,10 @@ import { type NativeHookRelayProcessResponse, type NativeHookRelayRegistrationHandle, } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeTrimmedStringList, + readStringField as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { formatCodexDisplayText } from "../command-formatters.js"; import { resolveCodexToolAbortTerminalReason } from "./dynamic-tool-execution.js"; @@ -1271,11 +1274,6 @@ function readStringPreview( return value === undefined ? undefined : previewSource(value); } -function readString(record: JsonObject | undefined, key: string): string | undefined { - const value = record?.[key]; - return typeof value === "string" ? value : undefined; -} - function previewSource(value: string): ApprovalPreviewSource { return { value: sliceUtf16Safe(value, 0, APPROVAL_PREVIEW_SCAN_MAX_LENGTH), diff --git a/extensions/codex/src/app-server/attempt-notifications.ts b/extensions/codex/src/app-server/attempt-notifications.ts index 0da610fa689e..3d4a3b0798ef 100644 --- a/extensions/codex/src/app-server/attempt-notifications.ts +++ b/extensions/codex/src/app-server/attempt-notifications.ts @@ -1,3 +1,4 @@ +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; /** * Predicates and readers for Codex app-server notification envelopes. */ @@ -390,11 +391,6 @@ function extractRawResponseItemText(item: JsonObject): string { .join(""); } -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - /** Reads a typed Codex item from notification params when id/type are present. */ export function readCodexNotificationItem( params: JsonValue | undefined, diff --git a/extensions/codex/src/app-server/bounded-turn.ts b/extensions/codex/src/app-server/bounded-turn.ts index 027729d358ac..069bc7084c25 100644 --- a/extensions/codex/src/app-server/bounded-turn.ts +++ b/extensions/codex/src/app-server/bounded-turn.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { AuthProfileStore } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path"; import { CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS, @@ -671,8 +672,3 @@ function collectAssistantTextFromItems(items: CodexThreadItem[] | undefined): st .join("\n\n") .trim(); } - -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} diff --git a/extensions/codex/src/app-server/effective-mcp-catalog.ts b/extensions/codex/src/app-server/effective-mcp-catalog.ts index 690035aa7f50..d2991d05af7b 100644 --- a/extensions/codex/src/app-server/effective-mcp-catalog.ts +++ b/extensions/codex/src/app-server/effective-mcp-catalog.ts @@ -3,6 +3,10 @@ import { assignMcpCatalogSafeServerNames, type McpToolCatalog, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CodexAppServerClient } from "./client.js"; import type { CodexMcpServerStatus } from "./protocol.js"; import { sessionBindingIdentity, type CodexAppServerBindingStore } from "./session-binding.js"; @@ -12,16 +16,6 @@ const MCP_STATUS_PAGE_SIZE = 100; const MCP_STATUS_MAX_PAGES = 100; type AgentHarnessMcpCatalogParams = Parameters>[0]; -function asRecord(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; -} - function catalogTool(params: { serverName: string; safeServerName: string; @@ -29,15 +23,16 @@ function catalogTool(params: { raw?: unknown; deniedBySession?: true; }): McpToolCatalog["tools"][number] { - const raw = asRecord(params.raw); - const description = readString(raw?.description); + const raw = asOptionalRecord(params.raw); + const description = normalizeOptionalString(raw?.description); + const title = normalizeOptionalString(raw?.title); return { serverName: params.serverName, safeServerName: params.safeServerName, toolName: params.toolName, - ...(readString(raw?.title) ? { title: readString(raw?.title) } : {}), + ...(title ? { title } : {}), ...(description ? { description } : {}), - inputSchema: (asRecord(raw?.inputSchema) ?? { type: "object" }) as never, + inputSchema: (asOptionalRecord(raw?.inputSchema) ?? { type: "object" }) as never, fallbackDescription: description ?? params.toolName, ...(params.deniedBySession ? { deniedBySession: true } : {}), }; diff --git a/extensions/codex/src/app-server/elicitation-bridge.ts b/extensions/codex/src/app-server/elicitation-bridge.ts index c8dc1531a30b..b90d7e22c684 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.ts @@ -80,7 +80,7 @@ export async function handleCodexAppServerElicitationRequest(params: { signal?: AbortSignal; }): Promise { const requestParams = isJsonObject(params.requestParams) ? params.requestParams : undefined; - if (!requestParams || readString(requestParams, "threadId") !== params.threadId) { + if (!requestParams || readNonBlankStringField(requestParams, "threadId") !== params.threadId) { return undefined; } const requestTurnId = requestParams.turnId; @@ -161,7 +161,7 @@ function resolvePluginElicitation(params: { return uniquePluginMatch(entry ? [entry] : [], "connector_id"); } - const serverName = readString(requestParams, "serverName"); + const serverName = readNonBlankStringField(requestParams, "serverName"); if (serverName && context) { const matches = entries.filter((entry) => entry.mcpServerNames.includes(serverName)); if (matches.length > 0) { @@ -188,9 +188,10 @@ function resolvePluginElicitation(params: { function isCodexConnectorApprovalElicitation(requestParams: JsonObject, meta: JsonObject): boolean { return ( - readString(requestParams, "serverName") === CODEX_APPS_SERVER_NAME && - readString(meta, MCP_TOOL_APPROVAL_KIND_KEY) === MCP_TOOL_APPROVAL_KIND && - readString(meta, MCP_TOOL_APPROVAL_SOURCE_KEY) === MCP_TOOL_APPROVAL_CONNECTOR_SOURCE + readNonBlankStringField(requestParams, "serverName") === CODEX_APPS_SERVER_NAME && + readNonBlankStringField(meta, MCP_TOOL_APPROVAL_KIND_KEY) === MCP_TOOL_APPROVAL_KIND && + readNonBlankStringField(meta, MCP_TOOL_APPROVAL_SOURCE_KEY) === + MCP_TOOL_APPROVAL_CONNECTOR_SOURCE ); } @@ -247,7 +248,7 @@ function hasDisplayNameOnlyPluginMatch( meta: JsonObject, entries: CodexAppPolicyContextEntry[], ): boolean { - const connectorName = readString(meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY); + const connectorName = readNonBlankStringField(meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY); if (!connectorName) { return false; } @@ -340,14 +341,14 @@ function readPluginApprovalElicitation( requestParams: JsonObject, ): BridgeableApprovalElicitation | undefined { if ( - readString(requestParams, "mode") !== "form" || + readNonBlankStringField(requestParams, "mode") !== "form" || !isJsonObject(requestParams.requestedSchema) ) { return undefined; } const requestedSchema = requestParams.requestedSchema; if ( - readString(requestedSchema, "type") !== "object" || + readNonBlankStringField(requestedSchema, "type") !== "object" || !isJsonObject(requestedSchema.properties) ) { return undefined; @@ -355,9 +356,10 @@ function readPluginApprovalElicitation( const meta = isJsonObject(requestParams["_meta"]) ? requestParams["_meta"] : {}; const title = - sanitizeDisplayText(readString(requestParams, "message") ?? "") || "Codex plugin approval"; + sanitizeDisplayText(readNonBlankStringField(requestParams, "message") ?? "") || + "Codex plugin approval"; const descriptionMeta: JsonObject = { ...meta }; - if (!readString(descriptionMeta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY)) { + if (!readNonBlankStringField(descriptionMeta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY)) { descriptionMeta[MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY] = appPolicyDisplayName(entry); } return { @@ -366,7 +368,7 @@ function readPluginApprovalElicitation( title, meta: descriptionMeta, requestedSchema, - serverName: sanitizeOptionalDisplayText(readString(requestParams, "serverName")), + serverName: sanitizeOptionalDisplayText(readNonBlankStringField(requestParams, "serverName")), }), requestedSchema, meta, @@ -409,8 +411,8 @@ function declineElicitationResponse(): JsonValue { function logPluginElicitationDecline(reason: string, requestParams: JsonObject | undefined): void { embeddedAgentLog.debug("codex plugin elicitation declined", { reason, - serverName: readString(requestParams, "serverName"), - mode: readString(requestParams, "mode"), + serverName: readNonBlankStringField(requestParams, "serverName"), + mode: readNonBlankStringField(requestParams, "mode"), }); } @@ -419,7 +421,7 @@ function readBridgeableApprovalElicitation( ): BridgeableApprovalElicitation | undefined { if ( !requestParams || - readString(requestParams, "mode") !== "form" || + readNonBlankStringField(requestParams, "mode") !== "form" || !isJsonObject(requestParams["_meta"]) || requestParams["_meta"][MCP_TOOL_APPROVAL_KIND_KEY] !== MCP_TOOL_APPROVAL_KIND || !isJsonObject(requestParams.requestedSchema) @@ -429,21 +431,22 @@ function readBridgeableApprovalElicitation( const requestedSchema = requestParams.requestedSchema; if ( - readString(requestedSchema, "type") !== "object" || + readNonBlankStringField(requestedSchema, "type") !== "object" || !isJsonObject(requestedSchema.properties) ) { return undefined; } const title = - sanitizeDisplayText(readString(requestParams, "message") ?? "") || "Codex MCP tool approval"; + sanitizeDisplayText(readNonBlankStringField(requestParams, "message") ?? "") || + "Codex MCP tool approval"; return { title, description: buildApprovalDescription({ title, meta: requestParams["_meta"], requestedSchema, - serverName: sanitizeOptionalDisplayText(readString(requestParams, "serverName")), + serverName: sanitizeOptionalDisplayText(readNonBlankStringField(requestParams, "serverName")), }), requestedSchema, meta: requestParams["_meta"], @@ -454,12 +457,12 @@ function readComputerUseApprovalElicitation( requestParams: JsonObject | undefined, expectedServerName: string | undefined, ): BridgeableApprovalElicitation | undefined { - const serverName = readString(requestParams, "serverName"); + const serverName = readNonBlankStringField(requestParams, "serverName"); if ( !serverName || !expectedServerName || serverName !== expectedServerName || - readString(requestParams, "mode") !== "form" + readNonBlankStringField(requestParams, "mode") !== "form" ) { return undefined; } @@ -468,7 +471,7 @@ function readComputerUseApprovalElicitation( ? requestParams.requestedSchema : EMPTY_OBJECT_SCHEMA; if ( - readString(requestedSchema, "type") !== "object" || + readNonBlankStringField(requestedSchema, "type") !== "object" || !isJsonObject(requestedSchema.properties) ) { return undefined; @@ -476,7 +479,8 @@ function readComputerUseApprovalElicitation( const meta = isJsonObject(requestParams?.["_meta"]) ? requestParams["_meta"] : {}; const title = - sanitizeDisplayText(readString(requestParams, "message") ?? "") || COMPUTER_USE_APPROVAL_TITLE; + sanitizeDisplayText(readNonBlankStringField(requestParams, "message") ?? "") || + COMPUTER_USE_APPROVAL_TITLE; return { title, description: buildApprovalDescription({ @@ -497,13 +501,13 @@ function buildApprovalDescription(params: { serverName: string | undefined; }): string { const connectorName = sanitizeOptionalDisplayText( - readString(params.meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY), + readNonBlankStringField(params.meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY), ); const toolTitle = sanitizeOptionalDisplayText( - readString(params.meta, MCP_TOOL_APPROVAL_TOOL_TITLE_KEY), + readNonBlankStringField(params.meta, MCP_TOOL_APPROVAL_TOOL_TITLE_KEY), ); const toolDescription = sanitizeOptionalDisplayText( - readString(params.meta, MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY), + readNonBlankStringField(params.meta, MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY), ); const summaryLines = [ connectorName && `App: ${connectorName}`, @@ -532,10 +536,12 @@ function readPropertyDescriptionLines(requestedSchema: JsonObject): string[] { return undefined; } const propTitle = - sanitizeDisplayText(readString(schema, "title") ?? "") || + sanitizeDisplayText(readNonBlankStringField(schema, "title") ?? "") || sanitizeDisplayText(name) || "field"; - const description = sanitizeOptionalDisplayText(readString(schema, "description")); + const description = sanitizeOptionalDisplayText( + readNonBlankStringField(schema, "description"), + ); return description ? `- ${propTitle}: ${description}` : `- ${propTitle}`; }) .filter((line): line is string => Boolean(line)); @@ -554,8 +560,8 @@ function readDisplayParamLines(meta: JsonObject): string[] { return undefined; } const name = - sanitizeOptionalDisplayText(readString(param, "display_name")) ?? - sanitizeOptionalDisplayText(readString(param, "name")); + sanitizeOptionalDisplayText(readNonBlankStringField(param, "display_name")) ?? + sanitizeOptionalDisplayText(readNonBlankStringField(param, "name")); if (!name) { return undefined; } @@ -764,7 +770,7 @@ function readApprovalFieldValue( if (!isApprovalField(property)) { return undefined; } - const type = readString(property.schema, "type"); + const type = readNonBlankStringField(property.schema, "type"); if (type === "boolean") { return true; } @@ -831,8 +837,8 @@ function isPersistField(property: ApprovalPropertyContext): boolean { function propertyText(property: ApprovalPropertyContext): string { return [ property.name, - readString(property.schema, "title"), - readString(property.schema, "description"), + readNonBlankStringField(property.schema, "title"), + readNonBlankStringField(property.schema, "description"), ] .filter(Boolean) .join(" "); @@ -902,11 +908,11 @@ function readEnumOptions(schema: JsonObject): Array<{ value: string; label: stri return schema.oneOf .map((entry) => { const option = isJsonObject(entry) ? entry : undefined; - const value = readString(option, "const"); + const value = readNonBlankStringField(option, "const"); if (!value) { return undefined; } - return { value, label: readString(option, "title") ?? value }; + return { value, label: readNonBlankStringField(option, "title") ?? value }; }) .filter((entry): entry is { value: string; label: string } => Boolean(entry)); } @@ -925,14 +931,14 @@ function isSessionApprovalOption(option: { value: string; label: string }): bool ); } -function readString(record: JsonObject | undefined, key: string): string | undefined { +function readNonBlankStringField(record: JsonObject | undefined, key: string): string | undefined { const value = record?.[key]; return typeof value === "string" && value.trim() ? value : undefined; } function readFirstString(record: JsonObject | undefined, keys: string[]): string | undefined { for (const key of keys) { - const value = readString(record, key); + const value = readNonBlankStringField(record, key); if (value) { return value; } diff --git a/extensions/codex/src/app-server/event-projector-assistant.ts b/extensions/codex/src/app-server/event-projector-assistant.ts index 354a08ae00e7..031f451c7ccd 100644 --- a/extensions/codex/src/app-server/event-projector-assistant.ts +++ b/extensions/codex/src/app-server/event-projector-assistant.ts @@ -1,5 +1,6 @@ import type { EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createAssistantCommentaryMessage as buildAssistantCommentaryMessage, createAssistantMessage as buildAssistantMessage, @@ -7,7 +8,7 @@ import { type AssistantMessageOptions, } from "./event-projector-assistant-message.js"; import { shouldClearTerminalPresentationForNativeItem } from "./event-projector-items.js"; -import { extractRawAssistantText, readItemString, readString } from "./event-projector-values.js"; +import { extractRawAssistantText, readItemString } from "./event-projector-values.js"; import type { CodexThreadItem, JsonObject } from "./protocol.js"; type AgentEvent = Parameters>[0]; diff --git a/extensions/codex/src/app-server/event-projector-events.ts b/extensions/codex/src/app-server/event-projector-events.ts index 13d03be0b50d..72bd1b87ab98 100644 --- a/extensions/codex/src/app-server/event-projector-events.ts +++ b/extensions/codex/src/app-server/event-projector-events.ts @@ -1,4 +1,8 @@ import type { EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + asFiniteNumber, + readStringField as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { isNonSuccessItemStatus, itemKind, @@ -19,12 +23,7 @@ import { shouldEmitTranscriptToolProgress, } from "./event-projector-tool-progress.js"; import { CodexToolTranscriptProjection } from "./event-projector-tool-transcript.js"; -import { - readHookOutputEntries, - readNullableString, - readNumber, - readString, -} from "./event-projector-values.js"; +import { readHookOutputEntries, readNullableString } from "./event-projector-values.js"; import { isJsonObject, type CodexThreadItem, type JsonObject } from "./protocol.js"; type AgentEvent = Parameters>[0]; @@ -78,7 +77,7 @@ export class CodexEventProjection { if (!run) { return; } - const durationMs = readNumber(run, "durationMs"); + const durationMs = asFiniteNumber(run.durationMs); const entries = readHookOutputEntries(run.entries); const hookTurnId = readNullableString(params, "turnId"); this.emitAgentEvent({ diff --git a/extensions/codex/src/app-server/event-projector-media.ts b/extensions/codex/src/app-server/event-projector-media.ts index ae9b77c8a508..e6d1b428507d 100644 --- a/extensions/codex/src/app-server/event-projector-media.ts +++ b/extensions/codex/src/app-server/event-projector-media.ts @@ -5,7 +5,8 @@ import { import { generatedImageAssetFromBase64 } from "openclaw/plugin-sdk/image-generation"; import { resolveGeneratedMediaMaxBytes } from "openclaw/plugin-sdk/media-generation-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store"; -import { readItemString, readString } from "./event-projector-values.js"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { readItemString } from "./event-projector-values.js"; import type { CodexThreadItem, JsonObject } from "./protocol.js"; import type { CodexRemoteWorkspaceFileReader } from "./remote-workspace-media.js"; diff --git a/extensions/codex/src/app-server/event-projector-native-tool-lifecycle.ts b/extensions/codex/src/app-server/event-projector-native-tool-lifecycle.ts index fcfe152715f6..ba86225b6b21 100644 --- a/extensions/codex/src/app-server/event-projector-native-tool-lifecycle.ts +++ b/extensions/codex/src/app-server/event-projector-native-tool-lifecycle.ts @@ -4,6 +4,7 @@ import type { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime"; import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCodexToolAbortTerminalReason } from "./dynamic-tool-execution.js"; import { auditNativeToolName, @@ -13,7 +14,7 @@ import { type CodexNativeToolAuditStatus, type CodexNativeToolUnfinishedStatus, } from "./event-projector-items.js"; -import { readItem, readString } from "./event-projector-values.js"; +import { readItem } from "./event-projector-values.js"; import { emitCodexNativePreToolUseFailureDiagnostic, type CodexNativePreToolUseFailure, diff --git a/extensions/codex/src/app-server/event-projector-reasoning.ts b/extensions/codex/src/app-server/event-projector-reasoning.ts index 0d79cb1963d0..75cf1e0402af 100644 --- a/extensions/codex/src/app-server/event-projector-reasoning.ts +++ b/extensions/codex/src/app-server/event-projector-reasoning.ts @@ -1,9 +1,9 @@ import type { EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { AgentPlanStep, AgentPlanStepStatus } from "openclaw/plugin-sdk/channel-outbound"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { readNonNegativeInteger, readNullableString, - readString, splitPlanText, } from "./event-projector-values.js"; import type { CodexThreadItem, JsonObject } from "./protocol.js"; diff --git a/extensions/codex/src/app-server/event-projector-tool-output.ts b/extensions/codex/src/app-server/event-projector-tool-output.ts index f1c265ce0e70..9f9117224b82 100644 --- a/extensions/codex/src/app-server/event-projector-tool-output.ts +++ b/extensions/codex/src/app-server/event-projector-tool-output.ts @@ -2,8 +2,8 @@ import { formatToolAggregate, formatToolProgressOutput, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { readString } from "./event-projector-values.js"; import { isJsonObject, type CodexThreadItem } from "./protocol.js"; export const MAX_TOOL_OUTPUT_DELTA_MESSAGES_PER_ITEM = 20; diff --git a/extensions/codex/src/app-server/event-projector-tool-progress.ts b/extensions/codex/src/app-server/event-projector-tool-progress.ts index 6db4b62e5bdd..a7584c74347a 100644 --- a/extensions/codex/src/app-server/event-projector-tool-progress.ts +++ b/extensions/codex/src/app-server/event-projector-tool-progress.ts @@ -4,6 +4,7 @@ import { type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, type ToolProgressDetailMode, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js"; import { @@ -35,7 +36,6 @@ import { toolOutputRawEchoSignature, truncateToolTranscriptText, } from "./event-projector-tool-output.js"; -import { readString } from "./event-projector-values.js"; import type { CodexDynamicToolCallOutputContentItem, CodexThreadItem, diff --git a/extensions/codex/src/app-server/event-projector-usage.ts b/extensions/codex/src/app-server/event-projector-usage.ts index d3b67a878db9..5caa688b0bb3 100644 --- a/extensions/codex/src/app-server/event-projector-usage.ts +++ b/extensions/codex/src/app-server/event-projector-usage.ts @@ -1,5 +1,9 @@ import { normalizeUsage } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { readNonNegativeInteger, readNumber, readString } from "./event-projector-values.js"; +import { + asFiniteNumber, + readStringField as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { readNonNegativeInteger } from "./event-projector-values.js"; import { isJsonObject, type JsonObject } from "./protocol.js"; function readTokenCount(record: JsonObject, key: string): number | undefined { @@ -80,7 +84,10 @@ function normalizeCodexTokenUsageBreakdown( ): ReturnType { // v2 TokenUsageBreakdown. inputTokens includes cached input; OpenClaw usage // tracks uncached input, cache reads, and cache writes separately. - const readCount = source === "response" ? readTokenCount : readNumber; + const readCount = + source === "response" + ? readTokenCount + : (value: JsonObject, key: string) => asFiniteNumber(value[key]); const totalTokens = readCount(record, "totalTokens"); const inputTokens = readCount(record, "inputTokens"); const cacheRead = readCount(record, "cachedInputTokens"); diff --git a/extensions/codex/src/app-server/event-projector-values.ts b/extensions/codex/src/app-server/event-projector-values.ts index 1e620831f15e..9c0bbe04c11a 100644 --- a/extensions/codex/src/app-server/event-projector-values.ts +++ b/extensions/codex/src/app-server/event-projector-values.ts @@ -1,10 +1,6 @@ +import { asFiniteNumber, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isJsonObject, type CodexThreadItem, type JsonObject, type JsonValue } from "./protocol.js"; -export function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - export function normalizeNonEmptyString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -39,19 +35,14 @@ export function readNullableString(record: JsonObject, key: string): string | nu return typeof value === "string" ? value : undefined; } -export function readNumber(record: JsonObject, key: string): number | undefined { - const value = record[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - export function readNonNegativeInteger(record: JsonObject, key: string): number | undefined { - const value = readNumber(record, key); + const value = asFiniteNumber(record[key]); return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined; } export function readCodexErrorNotificationMessage(record: JsonObject): string | undefined { const error = record.error; - return isJsonObject(error) ? readString(error, "message") : undefined; + return isJsonObject(error) ? readStringField(error, "message") : undefined; } export function readHookOutputEntries( @@ -64,11 +55,11 @@ export function readHookOutputEntries( if (!isJsonObject(entry)) { return []; } - const text = readString(entry, "text"); + const text = readStringField(entry, "text"); if (!text) { return []; } - const kind = readString(entry, "kind"); + const kind = readStringField(entry, "kind"); return [{ ...(kind ? { kind } : {}), text }]; }); } @@ -86,11 +77,11 @@ export function extractRawAssistantText(item: JsonObject): string | undefined { if (!isJsonObject(entry)) { return []; } - const type = readString(entry, "type"); + const type = readStringField(entry, "type"); if (type !== "output_text" && type !== "text") { return []; } - const value = readString(entry, "text"); + const value = readStringField(entry, "text"); return value === undefined ? [] : [value]; }); return parts.length > 0 ? parts.join("").trim() : undefined; diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 9c34a7bc617b..0306f9fddb8b 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -7,6 +7,7 @@ import { type BeforeToolCallFailureDisposition, type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { AttemptFailureSource } from "./attempt-terminal.js"; import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js"; import { CodexAssistantProjection } from "./event-projector-assistant.js"; @@ -37,7 +38,6 @@ import { readCodexErrorNotificationMessage, readItem, readItemString, - readString, } from "./event-projector-values.js"; import type { CodexNativePreToolUseFailure } from "./native-hook-relay.js"; import { diff --git a/extensions/codex/src/app-server/managed-binary.ts b/extensions/codex/src/app-server/managed-binary.ts index 68567890f81a..cfbe8e685f01 100644 --- a/extensions/codex/src/app-server/managed-binary.ts +++ b/extensions/codex/src/app-server/managed-binary.ts @@ -299,11 +299,15 @@ function resolveManagedCodexPackageBinCandidate(root: string): string | null { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { bin?: unknown; }; + const packageBin = + packageJson.bin && typeof packageJson.bin === "object" + ? (packageJson.bin as Record) + : undefined; const binPath = typeof packageJson.bin === "string" ? packageJson.bin - : isRecord(packageJson.bin) && typeof packageJson.bin.codex === "string" - ? packageJson.bin.codex + : typeof packageBin?.codex === "string" + ? packageBin.codex : null; return binPath ? path.resolve(packageRoot, binPath) : null; } catch { @@ -311,10 +315,6 @@ function resolveManagedCodexPackageBinCandidate(root: string): string | null { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - function isDistExtensionRoot(pluginRoot: string, platform: NodeJS.Platform): boolean { const pathApi = pathForPlatform(platform); const extensionsDir = pathApi.dirname(pluginRoot); diff --git a/extensions/codex/src/app-server/native-mcp-app.ts b/extensions/codex/src/app-server/native-mcp-app.ts index ca248025cb18..74b43ec3ed73 100644 --- a/extensions/codex/src/app-server/native-mcp-app.ts +++ b/extensions/codex/src/app-server/native-mcp-app.ts @@ -4,6 +4,10 @@ import { type McpToolCatalog, type SessionMcpRuntime, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { getCodexAppServerClientInstanceId, type CodexAppServerClient } from "./client.js"; import type { CodexMcpServerStatus, CodexThreadItem, JsonObject, JsonValue } from "./protocol.js"; import { retainSharedCodexAppServerClientIfCurrent } from "./shared-client.js"; @@ -15,28 +19,20 @@ type NativeMcpCallToolResult = { _meta?: JsonValue; }; -function asRecord(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; -} - function readMcpAppResourceUri(item: CodexThreadItem): string | undefined { - const appContext = asRecord(item.appContext); - const uri = readString(appContext?.resourceUri) ?? readString(item.mcpAppResourceUri); + const appContext = asOptionalRecord(item.appContext); + const uri = + normalizeOptionalString(appContext?.resourceUri) ?? + normalizeOptionalString(item.mcpAppResourceUri); return uri?.startsWith("ui://") ? uri : undefined; } function readMcpToolResult(item: CodexThreadItem): NativeMcpCallToolResult | undefined { - const result = asRecord(item.result); + const result = asOptionalRecord(item.result); if (!result || !Array.isArray(result.content)) { return undefined; } - const resultMeta = asRecord(result["_meta"]); + const resultMeta = asOptionalRecord(result["_meta"]); return { content: result.content as JsonValue[], ...(result.structuredContent !== undefined @@ -52,7 +48,7 @@ function readMcpToolResult(item: CodexThreadItem): NativeMcpCallToolResult | und function statusTools(status: CodexMcpServerStatus): Array> { return Object.entries(status.tools).map(([name, value]) => - Object.assign({}, asRecord(value), { name }), + Object.assign({}, asOptionalRecord(value) ?? {}, { name }), ); } @@ -100,8 +96,8 @@ function createNativeMcpRuntime(params: { serverName: status.name, safeServerName: status.name, toolName: String(tool.name), - inputSchema: (asRecord(tool.inputSchema) ?? { type: "object" }) as never, - fallbackDescription: readString(tool.description) ?? String(tool.name), + inputSchema: (asOptionalRecord(tool.inputSchema) ?? { type: "object" }) as never, + fallbackDescription: normalizeOptionalString(tool.description) ?? String(tool.name), })), ), }; @@ -128,7 +124,7 @@ function createNativeMcpRuntime(params: { threadId: params.threadId, server: serverName, tool: toolName, - arguments: (asRecord(input) ?? {}) as JsonObject, + arguments: (asOptionalRecord(input) ?? {}) as JsonObject, })) as never, listTools: async (serverName) => { const status = (await loadStatuses()).find((entry) => entry.name === serverName); @@ -163,8 +159,8 @@ export function createCodexNativeMcpAppResultDetailsPreparer(params: { } const runtime = createNativeMcpRuntime(params); return async (item) => { - const serverName = readString(item.server); - const toolName = readString(item.tool); + const serverName = normalizeOptionalString(item.server); + const toolName = normalizeOptionalString(item.tool); const uiResourceUri = readMcpAppResourceUri(item); const toolResult = readMcpToolResult(item); if (!serverName || !toolName || !uiResourceUri || !toolResult) { diff --git a/extensions/codex/src/app-server/native-subagent-monitor.ts b/extensions/codex/src/app-server/native-subagent-monitor.ts index 3cc5037051d1..fee7dde601af 100644 --- a/extensions/codex/src/app-server/native-subagent-monitor.ts +++ b/extensions/codex/src/app-server/native-subagent-monitor.ts @@ -12,7 +12,11 @@ import { type AgentHarnessTaskRuntimeScope, } from "openclaw/plugin-sdk/agent-harness-task-runtime"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; -import { asFiniteNumber, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asFiniteNumber, + normalizeOptionalString, + readStringField as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { claimCodexAppServerLiveThread, releaseCodexAppServerLiveThread, @@ -1835,11 +1839,6 @@ function readThreadSpawnSource(thread: JsonObject | undefined): JsonObject | und return isJsonObject(subAgent?.thread_spawn) ? subAgent.thread_spawn : undefined; } -function readString(record: JsonObject | undefined, key: string): string | undefined { - const value = record?.[key]; - return typeof value === "string" ? value : undefined; -} - function readStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; diff --git a/extensions/codex/src/app-server/native-subagent-notification.ts b/extensions/codex/src/app-server/native-subagent-notification.ts index 6bb731f0a64a..ef63ea332302 100644 --- a/extensions/codex/src/app-server/native-subagent-notification.ts +++ b/extensions/codex/src/app-server/native-subagent-notification.ts @@ -2,6 +2,7 @@ * Extracts native Codex subagent completion notifications from trusted * inter-agent commentary messages emitted by the app-server. */ +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CodexServerNotification, JsonObject, JsonValue } from "./protocol.js"; import { isJsonObject } from "./protocol.js"; @@ -253,11 +254,6 @@ function extractSingleTextPart(item: JsonObject): string | undefined { return readString(entry, "text")?.trim(); } -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - function normalizeStatusKey(value: string): string { return value.replace(/[^a-z0-9]/giu, "").toLowerCase(); } diff --git a/extensions/codex/src/app-server/native-subagent-task-mirror.ts b/extensions/codex/src/app-server/native-subagent-task-mirror.ts index 497f231a37ac..c99ff754fb8a 100644 --- a/extensions/codex/src/app-server/native-subagent-task-mirror.ts +++ b/extensions/codex/src/app-server/native-subagent-task-mirror.ts @@ -3,6 +3,7 @@ * runtime rows so parent sessions can observe child progress. */ import type { AgentHarnessTaskRuntime } from "openclaw/plugin-sdk/agent-harness-task-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { CODEX_NATIVE_SUBAGENT_RUN_ID_PREFIX } from "./native-subagent-task-ids.js"; import type { CodexServerNotification, @@ -502,11 +503,6 @@ function readStringArray(value: JsonValue | undefined): string[] { return value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== ""); } -function readString(value: JsonObject, key: string): string | undefined { - const entry = value[key]; - return typeof entry === "string" ? entry : undefined; -} - function readNullableString(value: JsonObject, key: string): string | null | undefined { const entry = value[key]; return typeof entry === "string" || entry === null ? entry : undefined; diff --git a/extensions/codex/src/app-server/notification-correlation.ts b/extensions/codex/src/app-server/notification-correlation.ts index 4a04fe5b7591..b6f09a4535d5 100644 --- a/extensions/codex/src/app-server/notification-correlation.ts +++ b/extensions/codex/src/app-server/notification-correlation.ts @@ -2,6 +2,7 @@ * Correlates Codex app-server notifications with the active thread/turn so * projectors can ignore global or stale events without losing diagnostics. */ +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js"; /** Returns true when a notification payload belongs to the exact active thread and turn. */ @@ -27,20 +28,18 @@ export function isCodexNotificationForTurn( */ export function readCodexNotificationThreadId(record: JsonObject): string | undefined { const thread = isJsonObject(record.thread) ? record.thread : undefined; - return readString(record, "threadId") ?? (thread ? readString(thread, "id") : undefined); + return ( + normalizeOptionalString(record.threadId) ?? + (thread ? normalizeOptionalString(thread.id) : undefined) + ); } /** Reads a turn id from either top-level notification params or nested turn payloads. */ export function readCodexNotificationTurnId(record: JsonObject): string | undefined { - return readNestedTurnId(record) ?? readString(record, "turnId"); + return readNestedTurnId(record) ?? normalizeOptionalString(record.turnId); } function readNestedTurnId(record: JsonObject): string | undefined { const turn = record.turn; - return isJsonObject(turn) ? readString(turn, "id") : undefined; -} - -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; + return isJsonObject(turn) ? normalizeOptionalString(turn.id) : undefined; } diff --git a/extensions/codex/src/app-server/rate-limits.ts b/extensions/codex/src/app-server/rate-limits.ts index d37c0e5cded0..c2a6482f0705 100644 --- a/extensions/codex/src/app-server/rate-limits.ts +++ b/extensions/codex/src/app-server/rate-limits.ts @@ -13,7 +13,12 @@ import { type ProviderUsageSnapshot, type UsageWindow, } from "openclaw/plugin-sdk/provider-usage"; -import { asFiniteNumber, parseStrictFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean, + asFiniteNumber, + normalizeOptionalString, + parseStrictFiniteNumber, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js"; const CODEX_LIMIT_ID = "codex"; @@ -61,7 +66,7 @@ export function formatCodexUsageLimitErrorMessage(params: { rateLimitsAuthoritative?: boolean; nowMs?: number; }): string | undefined { - const message = normalizeText(params.message); + const message = normalizeOptionalString(params.message); if (!isCodexUsageLimitError(params.codexErrorInfo)) { return undefined; } @@ -108,7 +113,7 @@ export function formatCodexUsageLimitErrorMessage(params: { export function shouldRefreshCodexRateLimitsForUsageLimitMessage( message: string | null | undefined, ): boolean { - const text = normalizeText(message); + const text = normalizeOptionalString(message); // Only our formatted prefix is a refresh contract. Provider prose alone is // not structural evidence of a Codex usage-limit failure. return Boolean( @@ -274,7 +279,8 @@ function summarizeRateLimitSnapshot(snapshot: JsonObject, nowMs: number): string return window ? [formatRateLimitWindow(key, window, nowMs)] : []; }); const reachedType = - readString(snapshot, "rateLimitReachedType") ?? readString(snapshot, "rate_limit_reached_type"); + normalizeOptionalString(snapshot.rateLimitReachedType) ?? + normalizeOptionalString(snapshot.rate_limit_reached_type); const suffix = reachedType ? ` (${formatReachedType(reachedType)})` : ""; if (windows.length > 0) { return `${label}: ${windows.join(" · ")}${suffix}`; @@ -346,8 +352,10 @@ function addRateLimitSnapshot( seen: Set, ): void { const signature = [ - readNullableString(snapshot, "limitId") ?? readNullableString(snapshot, "limit_id") ?? "", - readNullableString(snapshot, "limitName") ?? readNullableString(snapshot, "limit_name") ?? "", + normalizeOptionalString(snapshot.limitId) ?? normalizeOptionalString(snapshot.limit_id) ?? "", + normalizeOptionalString(snapshot.limitName) ?? + normalizeOptionalString(snapshot.limit_name) ?? + "", formatWindowSignature(snapshot.primary), formatWindowSignature(snapshot.secondary), ].join("|"); @@ -379,7 +387,7 @@ function readRateLimitWindow( if (!isJsonObject(window)) { return undefined; } - const resetsAt = readNumber(window, "resetsAt") ?? readNumber(window, "resets_at"); + const resetsAt = asFiniteNumber(window.resetsAt) ?? asFiniteNumber(window.resets_at); const resetsAtMs = resolveExpiresAtMsFromEpochSeconds(resetsAt, { maxMs: MAX_DATE_TIMESTAMP_MS }) ?? 0; return { @@ -397,8 +405,8 @@ function readRateLimitWindow( function snapshotHasDisplayableData(snapshot: JsonObject): boolean { if ( - readString(snapshot, "rateLimitReachedType") ?? - readString(snapshot, "rate_limit_reached_type") + normalizeOptionalString(snapshot.rateLimitReachedType) ?? + normalizeOptionalString(snapshot.rate_limit_reached_type) ) { return true; } @@ -411,7 +419,7 @@ function readOptionalNumberField( record: JsonObject, ...keys: string[] ): { usedPercent?: number; windowDurationMins?: number } { - const value = keys.map((key) => readNumber(record, key)).find((entry) => entry !== undefined); + const value = keys.map((key) => asFiniteNumber(record[key])).find((entry) => entry !== undefined); if (value === undefined) { return {}; } @@ -436,10 +444,10 @@ function formatRateLimitWindowDetails(window: RateLimitReset, nowMs: number): st function formatLimitLabel(snapshot: JsonObject): string { const label = - readNullableString(snapshot, "limitName") ?? - readNullableString(snapshot, "limit_name") ?? - readNullableString(snapshot, "limitId") ?? - readNullableString(snapshot, "limit_id"); + normalizeOptionalString(snapshot.limitName) ?? + normalizeOptionalString(snapshot.limit_name) ?? + normalizeOptionalString(snapshot.limitId) ?? + normalizeOptionalString(snapshot.limit_id); if (!label || label === CODEX_LIMIT_ID) { return "Codex"; } @@ -465,8 +473,8 @@ function formatAccountResetTime(resetsAtMs: number, nowMs: number): string { function snapshotHasLimitBlock(snapshot: JsonObject): boolean { return Boolean( - readString(snapshot, "rateLimitReachedType") ?? - readString(snapshot, "rate_limit_reached_type") ?? + normalizeOptionalString(snapshot.rateLimitReachedType) ?? + normalizeOptionalString(snapshot.rate_limit_reached_type) ?? readWindowEntries(snapshot).some( (entry) => entry.window.usedPercent !== undefined && entry.window.usedPercent >= 100, ), @@ -474,7 +482,8 @@ function snapshotHasLimitBlock(snapshot: JsonObject): boolean { } function isCodexLimitSnapshot(snapshot: JsonObject): boolean { - const id = readNullableString(snapshot, "limitId") ?? readNullableString(snapshot, "limit_id"); + const id = + normalizeOptionalString(snapshot.limitId) ?? normalizeOptionalString(snapshot.limit_id); return !id || id === CODEX_LIMIT_ID; } @@ -522,7 +531,8 @@ function formatProviderUsageWindowLabel( } function resolveCodexProviderUsagePlan(snapshot: JsonObject): string | undefined { - const plan = readString(snapshot, "planType") ?? readString(snapshot, "plan_type"); + const plan = + normalizeOptionalString(snapshot.planType) ?? normalizeOptionalString(snapshot.plan_type); const credits = isJsonObject(snapshot.credits) ? snapshot.credits : undefined; const creditSummary = formatCodexCreditSummary(credits); if (!creditSummary) { @@ -535,11 +545,11 @@ function formatCodexCreditSummary(credits: JsonObject | undefined): string | und if (!credits) { return undefined; } - const hasCredits = readBoolean(credits, "hasCredits") ?? readBoolean(credits, "has_credits"); + const hasCredits = asBoolean(credits.hasCredits) ?? asBoolean(credits.has_credits); if (hasCredits === false) { return undefined; } - if (readBoolean(credits, "unlimited")) { + if (asBoolean(credits.unlimited)) { return "Unlimited credits"; } const balance = @@ -745,8 +755,8 @@ function formatWindowSignature(value: JsonValue | undefined): string { if (!isJsonObject(value)) { return ""; } - return `${readNumber(value, "usedPercent") ?? readNumber(value, "used_percent") ?? ""}:${ - readNumber(value, "resetsAt") ?? readNumber(value, "resets_at") ?? "" + return `${asFiniteNumber(value.usedPercent) ?? asFiniteNumber(value.used_percent) ?? ""}:${ + asFiniteNumber(value.resetsAt) ?? asFiniteNumber(value.resets_at) ?? "" }`; } @@ -764,26 +774,4 @@ function extractCodexRetryHint(message: string | undefined): string | undefined return tryAgainRelative?.[1]?.trim(); } -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function readNullableString(record: JsonObject, key: string): string | undefined { - return readString(record, key) ?? undefined; -} - -function readNumber(record: JsonObject, key: string): number | undefined { - return asFiniteNumber(record[key]); -} - -function readBoolean(record: JsonObject, key: string): boolean | undefined { - const value = record[key]; - return typeof value === "boolean" ? value : undefined; -} - -function normalizeText(value: string | null | undefined): string | undefined { - const text = value?.trim(); - return text ? text : undefined; -} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/codex/src/app-server/run-attempt-state.ts b/extensions/codex/src/app-server/run-attempt-state.ts index 074136da0864..7c5ac24cfad6 100644 --- a/extensions/codex/src/app-server/run-attempt-state.ts +++ b/extensions/codex/src/app-server/run-attempt-state.ts @@ -3,6 +3,11 @@ import { formatErrorMessage, type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + asBoolean, + asFiniteNumber, + hasNonEmptyString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js"; import { CodexAppServerRpcError } from "./client.js"; import { isJsonObject, type CodexServerNotification } from "./protocol.js"; @@ -147,54 +152,44 @@ export function buildCodexAppServerTimeoutDiagnostics(params: { lastActivityReason?: string; details?: Record; }): NonNullable["diagnostics"] { - const readString = (key: string) => { + const readNonBlankDetailString = (key: string) => { const value = params.details?.[key]; - return typeof value === "string" && value.trim() ? value : undefined; - }; - const readNumber = (key: string) => { - const value = params.details?.[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; - }; - const readBoolean = (key: string) => { - const value = params.details?.[key]; - return typeof value === "boolean" ? value : undefined; + return hasNonEmptyString(value) ? value : undefined; }; + const activeAppServerTurnRequests = asFiniteNumber(params.details?.activeAppServerTurnRequests); + const activeTurnItemCount = asFiniteNumber(params.details?.activeTurnItemCount); + const terminalTurnNotificationQueued = asBoolean(params.details?.terminalTurnNotificationQueued); + const completionIdleWatchArmed = asBoolean(params.details?.completionIdleWatchArmed); + const assistantCompletionIdleWatchArmed = asBoolean( + params.details?.assistantCompletionIdleWatchArmed, + ); + const terminalIdleWatchArmed = asBoolean(params.details?.terminalIdleWatchArmed); return { ...(params.idleMs !== undefined ? { idleMs: params.idleMs } : {}), ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}), ...(params.lastActivityReason ? { lastActivityReason: params.lastActivityReason } : {}), - ...(readString("lastNotificationMethod") - ? { lastNotificationMethod: readString("lastNotificationMethod") } + ...(readNonBlankDetailString("lastNotificationMethod") + ? { lastNotificationMethod: readNonBlankDetailString("lastNotificationMethod") } : {}), - ...(readString("lastNotificationItemId") - ? { lastNotificationItemId: readString("lastNotificationItemId") } + ...(readNonBlankDetailString("lastNotificationItemId") + ? { lastNotificationItemId: readNonBlankDetailString("lastNotificationItemId") } : {}), - ...(readString("lastNotificationItemType") - ? { lastNotificationItemType: readString("lastNotificationItemType") } + ...(readNonBlankDetailString("lastNotificationItemType") + ? { lastNotificationItemType: readNonBlankDetailString("lastNotificationItemType") } : {}), - ...(readString("lastNotificationItemRole") - ? { lastNotificationItemRole: readString("lastNotificationItemRole") } + ...(readNonBlankDetailString("lastNotificationItemRole") + ? { lastNotificationItemRole: readNonBlankDetailString("lastNotificationItemRole") } : {}), - ...(readString("lastAssistantTextPreview") - ? { lastAssistantTextPreview: readString("lastAssistantTextPreview") } + ...(readNonBlankDetailString("lastAssistantTextPreview") + ? { lastAssistantTextPreview: readNonBlankDetailString("lastAssistantTextPreview") } : {}), - ...(readNumber("activeAppServerTurnRequests") !== undefined - ? { activeAppServerTurnRequests: readNumber("activeAppServerTurnRequests") } - : {}), - ...(readNumber("activeTurnItemCount") !== undefined - ? { activeTurnItemCount: readNumber("activeTurnItemCount") } - : {}), - ...(readBoolean("terminalTurnNotificationQueued") !== undefined - ? { terminalTurnNotificationQueued: readBoolean("terminalTurnNotificationQueued") } - : {}), - ...(readBoolean("completionIdleWatchArmed") !== undefined - ? { completionIdleWatchArmed: readBoolean("completionIdleWatchArmed") } - : {}), - ...(readBoolean("assistantCompletionIdleWatchArmed") !== undefined - ? { assistantCompletionIdleWatchArmed: readBoolean("assistantCompletionIdleWatchArmed") } - : {}), - ...(readBoolean("terminalIdleWatchArmed") !== undefined - ? { terminalIdleWatchArmed: readBoolean("terminalIdleWatchArmed") } + ...(activeAppServerTurnRequests !== undefined ? { activeAppServerTurnRequests } : {}), + ...(activeTurnItemCount !== undefined ? { activeTurnItemCount } : {}), + ...(terminalTurnNotificationQueued !== undefined ? { terminalTurnNotificationQueued } : {}), + ...(completionIdleWatchArmed !== undefined ? { completionIdleWatchArmed } : {}), + ...(assistantCompletionIdleWatchArmed !== undefined + ? { assistantCompletionIdleWatchArmed } : {}), + ...(terminalIdleWatchArmed !== undefined ? { terminalIdleWatchArmed } : {}), }; } diff --git a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts index d46ffb62015a..9c4138904636 100644 --- a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts +++ b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts @@ -15,6 +15,7 @@ import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtim import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox"; import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { formatSqliteSessionFileMarker } from "openclaw/plugin-sdk/sqlite-runtime-testing"; +import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; // Codex tests cover run attempt.context engine plugin behavior. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { describe, expect, it, vi } from "vitest"; @@ -208,10 +209,6 @@ type MockCallReader = { mock: { calls: unknown[][] } }; const requireRecord = createRequireRecord("record", "expected-label-object"); -function optionalString(value: unknown): string { - return typeof value === "string" ? value : ""; -} - function requireFirstCallArg(mock: unknown, label: string): unknown { const call = (mock as MockCallReader).mock.calls[0]; if (!call) { @@ -256,7 +253,7 @@ function getRequestInputTextAt( return input .map((entry) => { const item = requireRecord(entry, "turn/start input entry"); - return item.type === "text" ? optionalString(item.text) : ""; + return item.type === "text" ? (readStringValue(item.text) ?? "") : ""; }) .join("\n"); } @@ -343,7 +340,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { expect(assembleParams.availableTools).toEqual(new Set()); const threadStartParams = requireRequestParams(harness, "thread/start"); - expect(optionalString(threadStartParams.developerInstructions)).toContain( + expect(readStringValue(threadStartParams.developerInstructions) ?? "").toContain( "context-engine system", ); expectRequestInputTextContains(harness, "OpenClaw assembled context for this turn:"); diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index d5e62fd6b2ca..96ae0f183ecb 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -15,6 +15,7 @@ import { import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { z } from "zod"; import { CODEX_PLUGINS_MARKETPLACE_NAME, @@ -493,7 +494,7 @@ export function createStoredCodexAppServerBinding( lookup?: Omit; } = {}, ): Extract | undefined { - const rawRecord = asRecord(value); + const rawRecord = asOptionalRecord(value); if (!rawRecord) { return undefined; } @@ -1392,12 +1393,6 @@ function stripUndefinedValue(value: unknown): unknown { ); } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readTimestamp(value: unknown): string | undefined { return optionalTimestampSchema.parse(value); } @@ -1406,17 +1401,17 @@ function readPluginAppPolicyContext( value: unknown, bindingSchemaVersion: 1 | 2, ): PluginAppPolicyContext | undefined { - const record = asRecord(value); + const record = asOptionalRecord(value); if (!record || typeof record.fingerprint !== "string") { return undefined; } - const apps = asRecord(record.apps); + const apps = asOptionalRecord(record.apps); if (!apps) { return undefined; } const parsedApps: PluginAppPolicyContext["apps"] = {}; for (const [appId, rawEntry] of Object.entries(apps)) { - const entry = asRecord(rawEntry); + const entry = asOptionalRecord(rawEntry); if (!entry) { return undefined; } diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index 961fc9bba407..eb31408af573 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -18,6 +18,7 @@ import { type NativeHookRelayRegistrationHandle, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js"; import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js"; import { @@ -1356,11 +1357,6 @@ function readNotificationTurnId(record: JsonObject): string | undefined { return readCodexNotificationTurnId(record); } -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - function formatCodexErrorMessage(params: JsonObject, rateLimits: JsonValue | undefined): Error { const error = isJsonObject(params.error) ? params.error : undefined; const message = diff --git a/extensions/codex/src/app-server/usage-limit-error.ts b/extensions/codex/src/app-server/usage-limit-error.ts index 38873bb4a32e..9a6799d92cba 100644 --- a/extensions/codex/src/app-server/usage-limit-error.ts +++ b/extensions/codex/src/app-server/usage-limit-error.ts @@ -8,14 +8,10 @@ import { type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { markAuthProfileBlockedUntil } from "openclaw/plugin-sdk/agent-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { CODEX_CONTROL_METHODS } from "./capabilities.js"; import type { CodexAppServerClient } from "./client.js"; -import { - isJsonObject, - type CodexServerNotification, - type JsonObject, - type JsonValue, -} from "./protocol.js"; +import { isJsonObject, type CodexServerNotification, type JsonValue } from "./protocol.js"; import { readCodexRateLimitsRevision, readRecentCodexRateLimits, @@ -257,8 +253,3 @@ function readCodexErrorPayload(error: unknown): { rateLimits, }; } - -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} diff --git a/extensions/codex/src/app-server/user-input-bridge.ts b/extensions/codex/src/app-server/user-input-bridge.ts index e6192231a543..519b95ec24d3 100644 --- a/extensions/codex/src/app-server/user-input-bridge.ts +++ b/extensions/codex/src/app-server/user-input-bridge.ts @@ -11,6 +11,7 @@ import { type AgentHarnessUserInputQuestion, type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatCodexDisplayText } from "../command-formatters.js"; import { isJsonObject, @@ -314,11 +315,6 @@ function emptyUserInputResponse(): JsonObject { return emptyAgentHarnessUserInputAnswers() as unknown as JsonObject; } -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - function readRequestId(record: JsonObject): string | number | undefined { const value = record.requestId; return typeof value === "string" || typeof value === "number" ? value : undefined; diff --git a/extensions/codex/src/command-account.ts b/extensions/codex/src/command-account.ts index b036d8beb34b..ef634cefaec9 100644 --- a/extensions/codex/src/command-account.ts +++ b/extensions/codex/src/command-account.ts @@ -10,9 +10,12 @@ 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 { + normalizeOptionalString, + 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 { isJsonObject, type JsonValue } from "./app-server/protocol.js"; import { summarizeCodexAccountUsage, type CodexAccountUsageSummary, @@ -278,9 +281,9 @@ function resolveLiveAccountProfileId(params: { const account = isJsonObject(params.account.value.account) ? params.account.value.account : params.account.value; - const type = readString(account, "type")?.toLowerCase(); + const type = normalizeOptionalString(account.type)?.toLowerCase(); if (type === "chatgpt") { - const email = readString(account, "email")?.toLowerCase(); + const email = normalizeOptionalString(account.email)?.toLowerCase(); const firstSubscription = params.order.find((profileId) => isChatGptSubscriptionProfile(params.store.profiles[profileId]), ); @@ -437,11 +440,6 @@ function formatUsageLineForDisplay(value: string): string { return value.replace(/^weekly\b/u, "Weekly").replace(/\bshort-term\b/u, "Short-term"); } -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function isChatGptSubscriptionProfile(credential: AuthProfileCredential | undefined): boolean { return credential?.type === "oauth" || credential?.type === "token"; } diff --git a/extensions/codex/src/command-diagnostics.ts b/extensions/codex/src/command-diagnostics.ts index 8f1ddb103511..d011e4b2db98 100644 --- a/extensions/codex/src/command-diagnostics.ts +++ b/extensions/codex/src/command-diagnostics.ts @@ -1,6 +1,7 @@ import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; import { parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCodexAppServerAuthProfileIdForAgent } from "./app-server/auth-bridge.js"; import { resolveCodexBindingAppServerConnection } from "./app-server/binding-connection.js"; import { isJsonObject } from "./app-server/protocol.js"; @@ -29,7 +30,6 @@ import { readPendingCodexDiagnosticsConfirmation, recordCodexDiagnosticsUpload, } from "./command-diagnostics-support.js"; -import { readString } from "./command-formatters.js"; import { CODEX_CONTROL_METHODS, type CodexCommandDeps } from "./command-handler-deps.js"; import { resolveControlTarget } from "./command-handler-scope.js"; @@ -345,7 +345,7 @@ async function sendCodexDiagnosticsFeedbackForTargets( continue; } const responseThreadId = isJsonObject(response.value) - ? readString(response.value, "threadId") + ? normalizeOptionalString(response.value.threadId) : undefined; sent.push({ ...target, threadId: responseThreadId ?? target.threadId }); recordCodexDiagnosticsUpload(target.threadId, ctx, now, options.cooldownScope); diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index 4cd3882c4a71..1c80f42ad9a9 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -2,9 +2,10 @@ * Formats Codex command responses for safe chat display, including status, * lists, account summaries, and user-facing help text. */ +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CodexComputerUseStatus } from "./app-server/computer-use.js"; import type { CodexAppServerModelListResult } from "./app-server/models.js"; -import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js"; +import { isJsonObject, type JsonValue } from "./app-server/protocol.js"; import { hasCodexRateLimitSnapshots, summarizeCodexAccountRateLimits, @@ -96,13 +97,18 @@ export function formatThreads(response: JsonValue | undefined): string { "Codex threads:", ...threads.slice(0, 10).map((thread) => { const record = isJsonObject(thread) ? thread : {}; - const id = readString(record, "threadId") ?? readString(record, "id") ?? ""; + const id = + normalizeOptionalString(record.threadId) ?? + normalizeOptionalString(record.id) ?? + ""; const title = - readString(record, "title") ?? readString(record, "name") ?? readString(record, "summary"); + normalizeOptionalString(record.title) ?? + normalizeOptionalString(record.name) ?? + normalizeOptionalString(record.summary); const details = [ - readString(record, "model"), - readString(record, "cwd"), - readString(record, "updatedAt") ?? readString(record, "lastUpdatedAt"), + normalizeOptionalString(record.model), + normalizeOptionalString(record.cwd), + normalizeOptionalString(record.updatedAt) ?? normalizeOptionalString(record.lastUpdatedAt), ].filter((value): value is string => Boolean(value)); return `- ${formatCodexDisplayText(id)}${title ? ` - ${formatCodexDisplayText(title)}` : ""}${ details.length > 0 ? ` (${details.map(formatCodexDisplayText).join(", ")})` : "" @@ -222,7 +228,9 @@ export function formatList(response: JsonValue | undefined, label: string): stri ...entries.slice(0, 25).map((entry) => { const record = isJsonObject(entry) ? entry : {}; return `- ${formatCodexDisplayText( - readString(record, "name") ?? readString(record, "id") ?? JSON.stringify(entry), + normalizeOptionalString(record.name) ?? + normalizeOptionalString(record.id) ?? + JSON.stringify(entry), )}`; }), ].join("\n"); @@ -267,7 +275,7 @@ export function formatSkills(response: JsonValue | undefined): string { function formatCodexSkillEntry(entry: JsonValue): string { const record = isJsonObject(entry) ? entry : {}; - const name = readString(record, "name") ?? ""; + const name = normalizeOptionalString(record.name) ?? ""; return `\`${formatCodexDisplayText(name)}\``; } @@ -406,15 +414,15 @@ function summarizeAccount(value: JsonValue | undefined): string { return "unavailable"; } const account = isJsonObject(value.account) ? value.account : value; - const accountType = readString(account, "type"); + const accountType = normalizeOptionalString(account.type); if (accountType === "amazonBedrock") { return "Amazon Bedrock"; } return ( - readString(account, "email") ?? - readString(account, "accountEmail") ?? - readString(account, "planType") ?? - readString(account, "id") ?? + normalizeOptionalString(account.email) ?? + normalizeOptionalString(account.accountEmail) ?? + normalizeOptionalString(account.planType) ?? + normalizeOptionalString(account.id) ?? "available" ); } @@ -501,7 +509,8 @@ function isMeaningfulRateLimitSnapshot(value: JsonValue | undefined): boolean { return false; } const reachedType = - readString(value, "rateLimitReachedType") ?? readString(value, "rate_limit_reached_type"); + normalizeOptionalString(value.rateLimitReachedType) ?? + normalizeOptionalString(value.rate_limit_reached_type); if (reachedType) { return true; } @@ -526,9 +535,3 @@ function extractArray(value: JsonValue | undefined): JsonValue[] { } return []; } - -/** Reads and trims a non-empty string field from a JSON object. */ -export function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} diff --git a/extensions/codex/src/command-handler-actions.ts b/extensions/codex/src/command-handler-actions.ts index 354f1145bee6..70f029542392 100644 --- a/extensions/codex/src/command-handler-actions.ts +++ b/extensions/codex/src/command-handler-actions.ts @@ -1,6 +1,7 @@ import { MODEL_SELECTION_LOCKED_MESSAGE } from "openclaw/plugin-sdk/model-session-runtime"; import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCodexBindingAppServerConnection } from "./app-server/binding-connection.js"; import type { CodexComputerUseSetupParams } from "./app-server/computer-use.js"; import { isJsonObject, type JsonValue } from "./app-server/protocol.js"; @@ -9,11 +10,7 @@ import { resolveCodexNativeSandboxBlock, } from "./app-server/sandbox-guard.js"; import { canMutateCodexHost } from "./command-authorization.js"; -import { - formatCodexDisplayText, - formatComputerUseStatus, - readString, -} from "./command-formatters.js"; +import { formatCodexDisplayText, formatComputerUseStatus } from "./command-formatters.js"; import { formatComputerUsePersistentIdentityMigration, parseBindArgs, @@ -260,8 +257,8 @@ function formatNativeGoal(response: JsonValue | undefined): string { if (!goal) { return "No Codex goal is active."; } - const objective = readString(goal, "objective") ?? "unknown"; - const status = readString(goal, "status") ?? "unknown"; + const objective = normalizeOptionalString(goal.objective) ?? "unknown"; + const status = normalizeOptionalString(goal.status) ?? "unknown"; const tokensUsed = typeof goal.tokensUsed === "number" ? goal.tokensUsed : 0; const tokenBudget = typeof goal.tokenBudget === "number" ? goal.tokenBudget : undefined; return [ diff --git a/extensions/codex/src/conversation-binding-data.ts b/extensions/codex/src/conversation-binding-data.ts index e8e0f352fb9f..ae07b54eb895 100644 --- a/extensions/codex/src/conversation-binding-data.ts +++ b/extensions/codex/src/conversation-binding-data.ts @@ -2,7 +2,10 @@ import { createHash, randomUUID } from "node:crypto"; 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"; +import { + asOptionalRecord as readRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; const APP_SERVER_BINDING_DATA_VERSION = 2; const CLI_BINDING_DATA_VERSION = 1; @@ -160,10 +163,10 @@ export function readCodexConversationBindingDataRecord( function readConversationSource( value: CodexAppServerConversationSource | Record | undefined, ): CodexAppServerConversationSource | undefined { - const agentId = readString(value, "agentId"); - const sessionId = readString(value, "sessionId"); - const threadId = readString(value, "threadId"); - const sessionKey = readString(value, "sessionKey"); + const agentId = normalizeOptionalString(value?.agentId); + const sessionId = normalizeOptionalString(value?.sessionId); + const threadId = normalizeOptionalString(value?.threadId); + const sessionKey = normalizeOptionalString(value?.sessionKey); if (!agentId || !sessionId || !threadId) { return undefined; } @@ -182,15 +185,10 @@ export function legacyCodexConversationBindingId(sessionFile: string): string { export function resolveCodexDefaultWorkspaceDir(pluginConfig: unknown): string { const appServer = readRecord(readRecord(pluginConfig)?.appServer); - const configured = readString(appServer, "defaultWorkspaceDir"); + const configured = normalizeOptionalString(appServer?.defaultWorkspaceDir); return configured ?? process.cwd(); } -function readString(record: Record | undefined, key: string) { - const value = record?.[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readConversationStart( value: CodexAppServerConversationStart | Record | undefined, ): CodexAppServerConversationStart | undefined { diff --git a/extensions/codex/src/conversation-turn-collector.ts b/extensions/codex/src/conversation-turn-collector.ts index 4420b36971bd..1d507ec0c670 100644 --- a/extensions/codex/src/conversation-turn-collector.ts +++ b/extensions/codex/src/conversation-turn-collector.ts @@ -1,6 +1,9 @@ // Codex plugin module implements conversation turn collector behavior. import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; -import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord as readRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { isAssistantCommentaryCompletionNotification } from "./app-server/attempt-notifications.js"; import { isCodexNotificationForTurn } from "./app-server/notification-correlation.js"; import { @@ -57,7 +60,7 @@ export function createCodexConversationTurnCollector(threadId: string) { return; } if (notification.method === "item/agentMessage/delta") { - const itemId = readString(params, "itemId") ?? "assistant"; + const itemId = normalizeOptionalString(params.itemId) ?? "assistant"; const delta = readTextString(params, "delta"); if (!delta) { return; @@ -68,7 +71,8 @@ export function createCodexConversationTurnCollector(threadId: string) { if (notification.method === "item/completed") { const item = isJsonObject(params.item) ? params.item : undefined; if (item?.type === "agentMessage") { - const itemId = readString(item, "id") ?? readString(params, "itemId") ?? "assistant"; + const itemId = + normalizeOptionalString(item.id) ?? normalizeOptionalString(params.itemId) ?? "assistant"; assistantTextByItem.delete(itemId); if (isAssistantCommentaryCompletionNotification(notification)) { return; @@ -82,10 +86,11 @@ export function createCodexConversationTurnCollector(threadId: string) { } if (notification.method === "turn/completed") { const turn = isJsonObject(params.turn) ? params.turn : undefined; - const status = readString(turn, "status"); + const status = normalizeOptionalString(turn?.status); if (status === "failed") { failedError = - readString(readRecord(turn?.error), "message") ?? "codex app-server turn failed"; + normalizeOptionalString(readRecord(turn?.error)?.message) ?? + "codex app-server turn failed"; } else if (status === "interrupted") { // Codex reports an interrupted turn as a terminal completion without a // final answer; streamed partial text must not become a successful reply. @@ -99,7 +104,8 @@ export function createCodexConversationTurnCollector(threadId: string) { if (!isJsonObject(item) || item.type !== "agentMessage") { continue; } - const itemId = readString(item, "id") ?? `assistant-${assistantTextByItem.size + 1}`; + const itemId = + normalizeOptionalString(item.id) ?? `assistant-${assistantTextByItem.size + 1}`; assistantTextByItem.delete(itemId); const text = item.phase === "commentary" ? undefined : readTextString(item, "text"); if (text?.trim()) { @@ -139,11 +145,6 @@ export function createCodexConversationTurnCollector(threadId: string) { }; } -function readString(record: Record | JsonObject | undefined, key: string) { - const value = record?.[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readTextString(record: Record | JsonObject | undefined, key: string) { const value = record?.[key]; return typeof value === "string" && value.length > 0 ? value : undefined; diff --git a/extensions/codex/src/native-thread-tool.ts b/extensions/codex/src/native-thread-tool.ts index ea05ceb7c2d3..fda1c0fa7f85 100644 --- a/extensions/codex/src/native-thread-tool.ts +++ b/extensions/codex/src/native-thread-tool.ts @@ -12,6 +12,7 @@ import { ModelSelectionLockedError, } from "openclaw/plugin-sdk/model-session-runtime"; import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; +import { asBoolean, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; import { resolveCodexBindingAppServerConnection } from "./app-server/binding-connection.js"; import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js"; @@ -111,16 +112,6 @@ type CodexThreadsToolOptions = { request?: typeof codexControlRequest; }; -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function readBoolean(value: unknown, fallback = false): boolean { - return typeof value === "boolean" ? value : fallback; -} - function readLimit(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100 ? value @@ -279,7 +270,7 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge "Manage native Codex threads: list, read, fork, rename, archive (confirm:true), unarchive. When supervision is enabled, raw transcript reads and every mutation require their matching supervision policy option.", parameters: CodexThreadsParamsSchema, async execute(_toolCallId, rawParams) { - const params = asRecord(rawParams); + const params = asOptionalRecord(rawParams) ?? {}; const action = readStringParam(params, "action", { required: true, label: "action" }); const pluginConfig = options.getPluginConfig(); const plugin = readCodexPluginConfig(pluginConfig); @@ -299,7 +290,7 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge pluginConfig, CODEX_CONTROL_METHODS.listThreads, { - archived: readBoolean(params.archived), + archived: asBoolean(params.archived) ?? false, limit: readLimit(params.limit) ?? 20, modelProviders: [], sortKey: "recency_at", @@ -315,7 +306,7 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge const threadId = readThreadId(params); if (action === "read") { - const includeTurns = readBoolean(params.include_turns); + const includeTurns = asBoolean(params.include_turns) ?? false; if (includeTurns && !mayReadRawTranscripts) { throw new Error( "Codex raw transcript reads are disabled for this codex plugin supervision config.", @@ -429,7 +420,7 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge throw new Error(`unsupported codex_threads action: ${action}`); } - const attach = readBoolean(params.attach, true); + const attach = asBoolean(params.attach) ?? true; if (attach && !session) { throw new Error("cannot attach a Codex fork without an active OpenClaw session"); } diff --git a/extensions/codex/src/session-catalog-parsing.ts b/extensions/codex/src/session-catalog-parsing.ts index c77654198f67..7420c4482156 100644 --- a/extensions/codex/src/session-catalog-parsing.ts +++ b/extensions/codex/src/session-catalog-parsing.ts @@ -179,7 +179,7 @@ export function normalizeLimit(value: unknown, key: string): number { return value as number; } -export function readOptionalString( +export function readBoundedOptionalString( params: Record, key: string, maxLength: number, @@ -217,9 +217,9 @@ export function readPageParams(value: unknown): CodexSessionCatalogPageParams { } const params = value; requireOnlyKeys(params, new Set(["cursor", "limit", "searchTerm", "cwd"])); - const cursor = readOptionalString(params, "cursor", MAX_CURSOR_LENGTH); - const searchTerm = readOptionalString(params, "searchTerm", MAX_SEARCH_LENGTH); - const cwd = readOptionalString(params, "cwd", MAX_CWD_LENGTH); + const cursor = readBoundedOptionalString(params, "cursor", MAX_CURSOR_LENGTH); + const searchTerm = readBoundedOptionalString(params, "searchTerm", MAX_SEARCH_LENGTH); + const cwd = readBoundedOptionalString(params, "cwd", MAX_CWD_LENGTH); return { limit: normalizeLimit(params.limit, "limit"), ...(cursor ? { cursor } : {}), @@ -234,7 +234,7 @@ export function readGatewayParams(value: unknown): CodexSessionCatalogParams { } const params = isRecord(value) ? value : {}; requireOnlyKeys(params, new Set(["search", "limitPerHost", "hostIds", "cursors"])); - const search = readOptionalString(params, "search", MAX_SEARCH_LENGTH); + const search = readBoundedOptionalString(params, "search", MAX_SEARCH_LENGTH); let hostIds: string[] | undefined; if (params.hostIds !== undefined) { if (!Array.isArray(params.hostIds) || params.hostIds.length > MAX_HOST_COUNT) { diff --git a/extensions/codex/src/session-catalog.ts b/extensions/codex/src/session-catalog.ts index d7b0a1ff27ea..628bd64675d5 100644 --- a/extensions/codex/src/session-catalog.ts +++ b/extensions/codex/src/session-catalog.ts @@ -87,7 +87,7 @@ import { parseTranscriptPage, readControlCursor, readGatewayParams, - readOptionalString, + readBoundedOptionalString, readPageParams, requireBoundThread, requireOnlyKeys, @@ -731,11 +731,11 @@ function readNodeTranscriptParams(value: unknown): CodexNodeSessionTranscriptPar throw new CatalogParamsError("Codex session read parameters must be an object"); } requireOnlyKeys(value, new Set(["threadId", "cursor", "limit"])); - const threadId = readOptionalString(value, "threadId", MAX_SESSION_ID_LENGTH); + const threadId = readBoundedOptionalString(value, "threadId", MAX_SESSION_ID_LENGTH); if (!threadId) { throw new CatalogParamsError("threadId is required"); } - const cursor = readOptionalString(value, "cursor", MAX_CURSOR_LENGTH); + const cursor = readBoundedOptionalString(value, "cursor", MAX_CURSOR_LENGTH); const limit = readBoundedLimit( value.limit, "limit", diff --git a/extensions/codex/src/supervision-tools.ts b/extensions/codex/src/supervision-tools.ts index af47084fdecc..ca60c5f40ed7 100644 --- a/extensions/codex/src/supervision-tools.ts +++ b/extensions/codex/src/supervision-tools.ts @@ -157,10 +157,6 @@ type CodexSupervisionToolsOptions = { useLegacyMcpPolicyEnv?: boolean; }; -function asRecord(value: unknown): Record { - return isRecord(value) ? value : {}; -} - function asRecordArray(value: unknown): Record[] { return Array.isArray(value) ? value.filter(isRecord) : []; } @@ -1081,7 +1077,7 @@ export function createCodexSupervisionTools(options: CodexSupervisionToolsOption description: "List Codex sessions visible to the OpenClaw supervisor.", parameters: SessionsListParamsSchema, execute: async (_toolCallId, rawParams) => { - const params = asRecord(rawParams); + const params = isRecord(rawParams) ? rawParams : {}; const { endpoints } = current(); const result = await listSessionSnapshot({ endpoints, @@ -1107,7 +1103,7 @@ export function createCodexSupervisionTools(options: CodexSupervisionToolsOption execute: async (_toolCallId, rawParams) => { const { endpoints, pluginConfig } = current(); requireRawTranscriptAccess(options, pluginConfig); - const params = asRecord(rawParams); + const params = isRecord(rawParams) ? rawParams : {}; const threadId = readStringParam(params, "thread_id", { required: true }); const endpoint = await resolveEndpointForThread({ endpoints, @@ -1137,7 +1133,7 @@ export function createCodexSupervisionTools(options: CodexSupervisionToolsOption execute: async (_toolCallId, rawParams) => { const { endpoints, pluginConfig } = current(); requireWriteAccess(options, pluginConfig); - const params = asRecord(rawParams); + const params = isRecord(rawParams) ? rawParams : {}; const threadId = readStringParam(params, "thread_id", { required: true }); const text = readStringParam(params, "text", { required: true, allowEmpty: false }); const mode = readModeParam(params) ?? "auto"; @@ -1186,7 +1182,7 @@ export function createCodexSupervisionTools(options: CodexSupervisionToolsOption execute: async (_toolCallId, rawParams) => { const { endpoints, pluginConfig } = current(); requireWriteAccess(options, pluginConfig); - const params = asRecord(rawParams); + const params = isRecord(rawParams) ? rawParams : {}; const threadId = readStringParam(params, "thread_id", { required: true }); const endpoint = await resolveEndpointForThread({ endpoints, diff --git a/extensions/copilot/src/attempt-cleanup.ts b/extensions/copilot/src/attempt-cleanup.ts index 52aaf1fecd5d..d60ba06c4731 100644 --- a/extensions/copilot/src/attempt-cleanup.ts +++ b/extensions/copilot/src/attempt-cleanup.ts @@ -2,7 +2,7 @@ import { awaitAgentEndSideEffects, runAgentEndSideEffects, } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { toError } from "./attempt-config.js"; +import { toCopilotError } from "./attempt-config.js"; import { BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS, type AgentHarnessAttemptResult, @@ -31,7 +31,7 @@ export async function finalizeCopilotAttempt( messages: result.messagesSnapshot, success: !aborted && !failure && !timedOut, ...(failure - ? { error: toError(failure.error).message } + ? { error: toCopilotError(failure.error).message } : timedOut ? { error: "Copilot SDK turn timed out." } : {}), diff --git a/extensions/copilot/src/attempt-config.ts b/extensions/copilot/src/attempt-config.ts index a2921bfab7c2..2592271a2f87 100644 --- a/extensions/copilot/src/attempt-config.ts +++ b/extensions/copilot/src/attempt-config.ts @@ -129,8 +129,8 @@ export function createResult( messagingToolSentTargets: [], messagingToolSentTexts: [], replayMetadata, - sessionFileUsed: readString(params.sessionFile), - sessionIdUsed: state.sessionIdUsed ?? readString(params.sessionId) ?? "copilot-session", + sessionFileUsed: readNonEmptyString(params.sessionFile), + sessionIdUsed: state.sessionIdUsed ?? readNonEmptyString(params.sessionId) ?? "copilot-session", toolMetas, yieldDetected: state.yieldDetected === true, }; @@ -323,7 +323,7 @@ export function createSystemMessageContent( if (bootstrap) { sections.push(bootstrap); } - const extraSystemPrompt = readString(params.extraSystemPrompt)?.trim(); + const extraSystemPrompt = readNonEmptyString(params.extraSystemPrompt)?.trim(); if (extraSystemPrompt) { const contextHeader = params.promptMode === "minimal" ? "## Subagent Context" : "## Conversation Context"; @@ -334,11 +334,11 @@ export function createSystemMessageContent( export function isRawCopilotModelRun(params: AttemptParamsLike): boolean { return params.modelRun === true || params.promptMode === "none"; } -export function readString(value: unknown): string | undefined { +export function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } export function readResolvedAttemptPath(value: unknown): string | undefined { - const raw = readString(value)?.trim(); + const raw = readNonEmptyString(value)?.trim(); if (!raw) { return undefined; } @@ -354,20 +354,20 @@ export function resolveModelRef(params: AttemptParamsLike): ModelRef { const requestTransport = getModelProviderRequestTransport(rawModel); const rawRequest = model.request; return { - api: readString(model.api), + api: readNonEmptyString(model.api), id: - readString(model.id) ?? - readString((params as { modelId?: unknown }).modelId) ?? + readNonEmptyString(model.id) ?? + readNonEmptyString((params as { modelId?: unknown }).modelId) ?? "unknown-model", provider: - readString(model.provider) ?? - readString((params as { provider?: unknown }).provider) ?? + readNonEmptyString(model.provider) ?? + readNonEmptyString((params as { provider?: unknown }).provider) ?? "unknown-provider", - baseUrl: readString(model.baseUrl), - azureApiVersion: readString(model.azureApiVersion ?? model.params?.azureApiVersion), + baseUrl: readNonEmptyString(model.baseUrl), + azureApiVersion: readNonEmptyString(model.azureApiVersion ?? model.params?.azureApiVersion), headers: model.headers, authHeader: model.authHeader, - requestAuthMode: readString(requestTransport?.auth?.mode ?? rawRequest?.auth?.mode), + requestAuthMode: readNonEmptyString(requestTransport?.auth?.mode ?? rawRequest?.auth?.mode), requestProxy: requestTransport?.proxy ?? rawRequest?.proxy, requestTls: requestTransport?.tls ?? rawRequest?.tls, requestAllowPrivateNetwork: @@ -379,10 +379,10 @@ export function resolveModelRef(params: AttemptParamsLike): ModelRef { } return { id: - readString(typeof rawModel === "string" ? rawModel : undefined) ?? - readString((params as { modelId?: unknown }).modelId) ?? + readNonEmptyString(typeof rawModel === "string" ? rawModel : undefined) ?? + readNonEmptyString((params as { modelId?: unknown }).modelId) ?? "unknown-model", - provider: readString((params as { provider?: unknown }).provider) ?? "unknown-provider", + provider: readNonEmptyString((params as { provider?: unknown }).provider) ?? "unknown-provider", }; } export function resolvePoolAcquire(params: AttemptParamsLike): { @@ -394,28 +394,28 @@ export function resolvePoolAcquire(params: AttemptParamsLike): { const model = resolveModelRef(params); const provider = resolveCopilotProvider({ model, - resolvedApiKey: readString(params.resolvedApiKey), - authProfileId: readString(params.authProfileId), + resolvedApiKey: readNonEmptyString(params.resolvedApiKey), + authProfileId: readNonEmptyString(params.authProfileId), }); const auth = provider.mode === "byok" ? createCopilotByokAuth({ - agentId: readString(params.agentId), - agentDir: readString(params.agentDir), - workspaceDir: readString(params.workspaceDir), - copilotHome: readString(params.copilotHome), + agentId: readNonEmptyString(params.agentId), + agentDir: readNonEmptyString(params.agentDir), + workspaceDir: readNonEmptyString(params.workspaceDir), + copilotHome: readNonEmptyString(params.copilotHome), authProfileId: provider.authProfileId, authProfileVersion: provider.authProfileVersion, }) : resolveCopilotAuth({ - agentId: readString(params.agentId), - agentDir: readString(params.agentDir), - workspaceDir: readString(params.workspaceDir), - copilotHome: readString(params.copilotHome), + agentId: readNonEmptyString(params.agentId), + agentDir: readNonEmptyString(params.agentDir), + workspaceDir: readNonEmptyString(params.workspaceDir), + copilotHome: readNonEmptyString(params.copilotHome), auth: params.auth, - resolvedApiKey: readString(params.resolvedApiKey), - authProfileId: readString(params.authProfileId), - profileVersion: readString(params.profileVersion), + resolvedApiKey: readNonEmptyString(params.resolvedApiKey), + authProfileId: readNonEmptyString(params.authProfileId), + profileVersion: readNonEmptyString(params.profileVersion), }); return { key: { @@ -440,7 +440,7 @@ export function resolvePoolAcquire(params: AttemptParamsLike): { provider, }; } -export function toError(error: unknown): Error { +export function toCopilotError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } export function isSdkSendAndWaitTimeoutError(error: unknown): boolean { diff --git a/extensions/copilot/src/attempt-execution.ts b/extensions/copilot/src/attempt-execution.ts index 69692b2c2758..1ba21239b0b8 100644 --- a/extensions/copilot/src/attempt-execution.ts +++ b/extensions/copilot/src/attempt-execution.ts @@ -20,10 +20,10 @@ import { createPromptError, createResult, isSdkSendAndWaitTimeoutError, + readNonEmptyString, readResolvedAttemptPath, - readString, resolvePoolAcquire, - toError, + toCopilotError, } from "./attempt-config.js"; import { completeCopilotAttempt } from "./attempt-finalize.js"; import { resolveCopilotAttemptSandbox } from "./attempt-prepare.js"; @@ -179,7 +179,7 @@ export async function runCopilotExecution(context: { now, promptError: createPromptError( "sandbox_resolution_failure", - `[copilot-attempt] sandbox resolution failed: ${toError(error).message}`, + `[copilot-attempt] sandbox resolution failed: ${toCopilotError(error).message}`, error, ), sdkSessionId: undefined, @@ -237,7 +237,7 @@ export async function runCopilotExecution(context: { createResult(input, { messagesSnapshot: messages, now, - promptError: createPromptError("model_not_supported", toError(error).message, error), + promptError: createPromptError("model_not_supported", toCopilotError(error).message, error), sdkSessionId: undefined, sessionIdUsed: input.sessionId, }), @@ -265,10 +265,10 @@ export async function runCopilotExecution(context: { allowModelTools: poolAcquire.provider.mode === "byok", modelProvider: modelRef.provider, modelId: modelRef.id, - agentId: readString(params.agentId) ?? "copilot", - sessionId: readString(input.sessionId) ?? "copilot-session", - sessionKey: readString((input as { sessionKey?: unknown }).sessionKey), - agentDir: readString(input.agentDir), + agentId: readNonEmptyString(params.agentId) ?? "copilot", + sessionId: readNonEmptyString(input.sessionId) ?? "copilot-session", + sessionKey: readNonEmptyString((input as { sessionKey?: unknown }).sessionKey), + agentDir: readNonEmptyString(input.agentDir), workspaceDir: effectiveWorkspaceDir, cwd: effectiveCwd, sandbox, @@ -309,7 +309,7 @@ export async function runCopilotExecution(context: { now, promptError: createPromptError( "tool_bridge_failure", - `[copilot-attempt] tool-bridge construction failed: ${toError(error).message}`, + `[copilot-attempt] tool-bridge construction failed: ${toCopilotError(error).message}`, error, ), sdkSessionId: undefined, @@ -366,7 +366,7 @@ export async function runCopilotExecution(context: { if (settledToolFinalization) { throw createPromptError( "settled_finalization_resume_failed", - `[copilot-attempt] settled tool finalization could not resume the existing Copilot SDK session: ${toError(error).message}`, + `[copilot-attempt] settled tool finalization could not resume the existing Copilot SDK session: ${toCopilotError(error).message}`, error, ); } @@ -386,8 +386,8 @@ export async function runCopilotExecution(context: { } sessionRef.current = session; sdkSessionId = - readString(session.sessionId) ?? - readString(session.id) ?? + readNonEmptyString(session.sessionId) ?? + readNonEmptyString(session.id) ?? (resumeFailureRecovered ? undefined : resumeSessionId); if (!sdkSessionId) { throw createPromptError( @@ -428,7 +428,7 @@ export async function runCopilotExecution(context: { if (settledToolFinalization) { return; } - const sessionFile = readString(input.sessionFile); + const sessionFile = readNonEmptyString(input.sessionFile); if (!sessionFile) { return; } @@ -441,7 +441,7 @@ export async function runCopilotExecution(context: { if (settledToolFinalization) { return; } - const sessionFile = readString(input.sessionFile); + const sessionFile = readNonEmptyString(input.sessionFile); if (!success || !sessionFile) { return; } @@ -525,15 +525,15 @@ export async function runCopilotExecution(context: { try { await transcriptJournal?.barrier("timeout"); } catch (transcriptError) { - promptError = toError(transcriptError); + promptError = toCopilotError(transcriptError); } } else { try { bridge?.flushTranscriptProjection(); await transcriptJournal?.barrier("attempt error"); - promptError = toError(error); + promptError = toCopilotError(error); } catch (transcriptError) { - promptError = toError(transcriptError); + promptError = toCopilotError(transcriptError); } } } @@ -543,7 +543,7 @@ export async function runCopilotExecution(context: { bridge?.flushTranscriptProjection(); await transcriptJournal?.barrier("bridge detach"); } catch (transcriptError) { - promptError = toError(transcriptError); + promptError = toCopilotError(transcriptError); } userInputBridgeRef?.cancelPending(); if (activeRunHandleRef) { @@ -612,7 +612,7 @@ export async function runCopilotExecution(context: { try { await session.disconnect(); } catch (error: unknown) { - disconnectError = toError(error); + disconnectError = toCopilotError(error); if (!promptError && !timedOut) { promptError = disconnectError; } @@ -622,7 +622,7 @@ export async function runCopilotExecution(context: { try { await deps.pool.release(handle); } catch (error: unknown) { - const releaseFailure = toError(error); + const releaseFailure = toCopilotError(error); if (promptError) { console.warn( "[copilot-attempt] pool.release failed after primary error", diff --git a/extensions/copilot/src/attempt-prepare.ts b/extensions/copilot/src/attempt-prepare.ts index c54c517c6c16..36a33ae3846b 100644 --- a/extensions/copilot/src/attempt-prepare.ts +++ b/extensions/copilot/src/attempt-prepare.ts @@ -6,7 +6,7 @@ import { resolveSandboxContext as defaultResolveSandboxContext, resolveSessionAgentIds, } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { readResolvedAttemptPath, readString, resolveModelRef } from "./attempt-config.js"; +import { readNonEmptyString, readResolvedAttemptPath, resolveModelRef } from "./attempt-config.js"; import type { AttemptParamsLike, CopilotAttemptDeps, @@ -58,13 +58,13 @@ export function prepareCopilotAttemptContext( const resolvedWorkspaceForSandbox = readResolvedAttemptPath(input.workspaceDir) ?? readResolvedAttemptPath(input.cwd); const sandboxSessionKey = - readString((input as { sandboxSessionKey?: unknown }).sandboxSessionKey) ?? - readString((input as { sessionKey?: unknown }).sessionKey) ?? - readString(input.sessionId); + readNonEmptyString((input as { sandboxSessionKey?: unknown }).sandboxSessionKey) ?? + readNonEmptyString((input as { sessionKey?: unknown }).sessionKey) ?? + readNonEmptyString(input.sessionId); const { sessionAgentId } = resolveSessionAgentIds({ - sessionKey: readString((input as { sessionKey?: unknown }).sessionKey), + sessionKey: readNonEmptyString((input as { sessionKey?: unknown }).sessionKey), config: input.config, - agentId: readString(params.agentId), + agentId: readNonEmptyString(params.agentId), }); const hookContextWindowFields = { ...(input.contextWindowInfo?.tokens diff --git a/extensions/copilot/src/attempt-transcript-journal.ts b/extensions/copilot/src/attempt-transcript-journal.ts index bcdaa4785f07..2ed7134a523e 100644 --- a/extensions/copilot/src/attempt-transcript-journal.ts +++ b/extensions/copilot/src/attempt-transcript-journal.ts @@ -12,6 +12,7 @@ import { type SessionTranscriptTargetParams, type TranscriptEntryAnchor, } from "openclaw/plugin-sdk/session-transcript-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { AttemptParamsLike } from "./attempt-types.js"; type TranscriptMessage = Extract; @@ -606,9 +607,9 @@ function createPersistenceReceipt(): PersistenceReceipt { } function resolveTranscriptTarget(attempt: AttemptParamsLike): SessionTranscriptTargetParams { - const sessionId = readString(attempt.sessionTarget?.sessionId); - const sessionKey = readString(attempt.sessionTarget?.sessionKey); - const storePath = readString(attempt.sessionTarget?.storePath); + const sessionId = normalizeOptionalString(attempt.sessionTarget?.sessionId); + const sessionKey = normalizeOptionalString(attempt.sessionTarget?.sessionKey); + const storePath = normalizeOptionalString(attempt.sessionTarget?.storePath); if (!sessionId || !sessionKey || !storePath) { const error = new Error( "[copilot-attempt] canonical transcript persistence requires an exact runtime session target", @@ -616,7 +617,7 @@ function resolveTranscriptTarget(attempt: AttemptParamsLike): SessionTranscriptT error.code = "transcript_persistence_failed"; throw error; } - const agentId = readString(attempt.sessionTarget?.agentId ?? attempt.agentId); + const agentId = normalizeOptionalString(attempt.sessionTarget?.agentId ?? attempt.agentId); return { sessionId, sessionKey, storePath, ...(agentId ? { agentId } : {}) }; } @@ -738,7 +739,3 @@ function userText(content: unknown): string { } return JSON.stringify(content) ?? ""; } - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} diff --git a/extensions/copilot/src/attempt.ts b/extensions/copilot/src/attempt.ts index f4bdff32b53b..f3c151efd503 100644 --- a/extensions/copilot/src/attempt.ts +++ b/extensions/copilot/src/attempt.ts @@ -2,9 +2,9 @@ import { finalizeCopilotAttempt } from "./attempt-cleanup.js"; import { createPromptError, createResult, - readString, + readNonEmptyString, resolvePoolAcquire, - toError, + toCopilotError, } from "./attempt-config.js"; import { runCopilotExecution } from "./attempt-execution.js"; import { prepareCopilotAttemptContext } from "./attempt-prepare.js"; @@ -55,22 +55,22 @@ export async function runCopilotAttempt( try { resolveCopilotProvider({ model: modelRef, - resolvedApiKey: readString(params.resolvedApiKey), - authProfileId: readString(params.authProfileId), + resolvedApiKey: readNonEmptyString(params.resolvedApiKey), + authProfileId: readNonEmptyString(params.authProfileId), }); } catch (error) { return finishAttempt( createResult(input, { messagesSnapshot: messages, now, - promptError: createPromptError("model_not_supported", toError(error).message, error), + promptError: createPromptError("model_not_supported", toCopilotError(error).message, error), sdkSessionId: undefined, sessionIdUsed: input.sessionId, }), ); } const settledFinalizationSessionId = settledToolFinalization - ? readString(input.initialReplayState?.sdkSessionId) + ? readNonEmptyString(input.initialReplayState?.sdkSessionId) : undefined; if (settledToolFinalization && !settledFinalizationSessionId) { return finishAttempt( diff --git a/extensions/copilot/src/auth-bridge.ts b/extensions/copilot/src/auth-bridge.ts index 21e4c079c08f..b111e126de9b 100755 --- a/extensions/copilot/src/auth-bridge.ts +++ b/extensions/copilot/src/auth-bridge.ts @@ -149,18 +149,19 @@ export function resolveCopilotAuth(input: ResolveCopilotAuthInput): ResolvedCopi const agentId = sanitizeAgentId(input.agentId); const copilotHome = resolveCopilotHome({ - explicit: readString(input.copilotHome), - agentDir: readString(input.agentDir), - workspaceDir: readString(input.workspaceDir), + explicit: readNonEmptyString(input.copilotHome), + agentDir: readNonEmptyString(input.agentDir), + workspaceDir: readNonEmptyString(input.workspaceDir), agentId, env, homeDir, }); - const explicitToken = readString(input.auth?.gitHubToken); - const explicitProfileId = readString(input.auth?.profileId) ?? readString(input.authProfileId); + const explicitToken = readNonEmptyString(input.auth?.gitHubToken); + const explicitProfileId = + readNonEmptyString(input.auth?.profileId) ?? readNonEmptyString(input.authProfileId); const explicitProfileVersion = - readString(input.auth?.profileVersion) ?? readString(input.profileVersion); + readNonEmptyString(input.auth?.profileVersion) ?? readNonEmptyString(input.profileVersion); if (input.auth?.useLoggedInUser === true) { return { @@ -192,9 +193,9 @@ export function resolveCopilotAuth(input: ResolveCopilotAuthInput): ResolvedCopi // We synthesise the pool-key version from the token fingerprint so // rotation busts the cache cleanly (matching the env-fallback // strategy). The contract does not carry a separate `profileVersion`. - const contractToken = readString(input.resolvedApiKey); + const contractToken = readNonEmptyString(input.resolvedApiKey); if (contractToken) { - const contractProfileId = readString(input.authProfileId); + const contractProfileId = readNonEmptyString(input.authProfileId); return { authMode: "gitHubToken", gitHubToken: contractToken, @@ -268,7 +269,7 @@ function resolveCopilotHome(args: { return resolve(join(args.agentDir, "copilot")); } - const openClawHome = readString(args.env.OPENCLAW_HOME); + const openClawHome = readNonEmptyString(args.env.OPENCLAW_HOME); const rootHome = openClawHome ? resolve(openClawHome) : safeHomeDir(args.homeDir); // Per-agent isolation per proposal section 3.6: // /.openclaw/agents//copilot @@ -301,10 +302,10 @@ function readEnvTokenFallback( // agentRuntime.id: "copilot" gets the token they configured rather // than silently falling through to the logged-in CLI user. const candidates: Array<{ name: string; value: string | undefined }> = [ - { name: "OPENCLAW_GITHUB_TOKEN", value: readString(env.OPENCLAW_GITHUB_TOKEN) }, - { name: "COPILOT_GITHUB_TOKEN", value: readString(env.COPILOT_GITHUB_TOKEN) }, - { name: "GH_TOKEN", value: readString(env.GH_TOKEN) }, - { name: "GITHUB_TOKEN", value: readString(env.GITHUB_TOKEN) }, + { name: "OPENCLAW_GITHUB_TOKEN", value: readNonEmptyString(env.OPENCLAW_GITHUB_TOKEN) }, + { name: "COPILOT_GITHUB_TOKEN", value: readNonEmptyString(env.COPILOT_GITHUB_TOKEN) }, + { name: "GH_TOKEN", value: readNonEmptyString(env.GH_TOKEN) }, + { name: "GITHUB_TOKEN", value: readNonEmptyString(env.GITHUB_TOKEN) }, ]; for (const { name, value } of candidates) { if (value) { @@ -331,6 +332,6 @@ export function tokenFingerprint(token: string): string { return `sha256:${hex}`; } -function readString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/extensions/copilot/src/event-bridge-transcript.ts b/extensions/copilot/src/event-bridge-transcript.ts index e363a78462b5..ffd0cc5d2158 100644 --- a/extensions/copilot/src/event-bridge-transcript.ts +++ b/extensions/copilot/src/event-bridge-transcript.ts @@ -78,7 +78,7 @@ export function resolveAssistantUsage( latest: AssistantUsageSnapshot | undefined, byApiCallId: Map, ): AssistantUsageSnapshot | undefined { - const apiCallId = readString(event?.data.apiCallId); + const apiCallId = readNonEmptyString(event?.data.apiCallId); return apiCallId ? (byApiCallId.get(apiCallId) ?? latest) : latest; } @@ -162,6 +162,6 @@ export function sanitizeToolDetailText(text: string): string { return typeof value === "string" ? value : ""; } -function readString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/extensions/copilot/src/event-bridge.ts b/extensions/copilot/src/event-bridge.ts index 12fd007daf88..732973eea72b 100644 --- a/extensions/copilot/src/event-bridge.ts +++ b/extensions/copilot/src/event-bridge.ts @@ -177,7 +177,7 @@ export function attachEventBridge( if (!projection) { return; } - const source = readString(event.data.source); + const source = readNonEmptyString(event.data.source); const transformedContent = typeof event.data.transformedContent === "string" ? event.data.transformedContent : undefined; const openClawMeta = projectSdkUserMetadata(event.data.attachments, source); @@ -229,7 +229,7 @@ export function attachEventBridge( if (!isRootSessionEvent(event)) { return; } - const messageId = readString(event.data.messageId) ?? "assistant-message"; + const messageId = readNonEmptyString(event.data.messageId) ?? "assistant-message"; const delta = event.data.deltaContent; if (!delta) { return; @@ -266,7 +266,7 @@ export function attachEventBridge( if (!isRootSessionEvent(event)) { return; } - const reasoningId = readString(event.data.reasoningId) ?? "assistant-reasoning"; + const reasoningId = readNonEmptyString(event.data.reasoningId) ?? "assistant-reasoning"; const delta = event.data.deltaContent; if (!delta) { return; @@ -311,7 +311,7 @@ export function attachEventBridge( return; } usage = normalizeCopilotUsage(event.data); - const apiCallId = readString(event.data.apiCallId); + const apiCallId = readNonEmptyString(event.data.apiCallId); if (apiCallId && usage) { usageByApiCallId.set(apiCallId, usage); } @@ -664,7 +664,7 @@ export function attachEventBridge( transcriptAssistantTexts: [event.data.content ?? ""], ...(transcriptReasoningText ? { transcriptReasoningText } : {}), }; - const apiCallId = readString(event.data.apiCallId); + const apiCallId = readNonEmptyString(event.data.apiCallId); if (!apiCallId) { if (projectedAssistantMessageIdsWithoutApiCall.has(event.data.messageId)) { options.transcriptProjection?.journal.markReplayIncomplete(); @@ -969,7 +969,7 @@ function splitPlanText(text: string | undefined): string[] { .filter((line) => line.length > 0); } -function readString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/extensions/copilot/src/provider-bridge.ts b/extensions/copilot/src/provider-bridge.ts index 4197d052213e..6236d8500f03 100644 --- a/extensions/copilot/src/provider-bridge.ts +++ b/extensions/copilot/src/provider-bridge.ts @@ -2,6 +2,7 @@ import type { ProviderConfig } from "@github/copilot-sdk"; import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth"; import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { tokenFingerprint } from "./auth-bridge.js"; const COPILOT_BYOK_PROVIDER_ERROR = @@ -83,7 +84,7 @@ export function resolveCopilotProvider(params: { return { mode: "github-copilot" }; } - const baseUrl = readString(params.model.baseUrl); + const baseUrl = normalizeOptionalString(params.model.baseUrl); if (!baseUrl) { throw new Error(COPILOT_BYOK_PROVIDER_ERROR); } @@ -92,11 +93,11 @@ export function resolveCopilotProvider(params: { throw new Error(COPILOT_BYOK_TRANSPORT_POLICY_ERROR); } - const api = readString(params.model.api)?.toLowerCase() ?? "openai-responses"; + const api = normalizeOptionalString(params.model.api)?.toLowerCase() ?? "openai-responses"; const provider = resolveProviderType(api, baseUrl, params.model.azureApiVersion); const resolvedApiKey = resolveProviderCredential(params.resolvedApiKey); const headers = resolveProviderHeaders(params.model.headers); - const requestAuthMode = readString(params.model.requestAuthMode)?.toLowerCase(); + const requestAuthMode = normalizeOptionalString(params.model.requestAuthMode)?.toLowerCase(); const usePreparedRequestAuth = requestAuthMode !== undefined && requestAuthMode !== "provider-default"; const providerConfig: ProviderConfig = { @@ -156,16 +157,16 @@ export function supportsCopilotByokProviderShape( "api" | "baseUrl" | "requestProxy" | "requestTls" | "requestAllowPrivateNetwork" >, ): boolean { - if (!readString(model.baseUrl) || hasUnsupportedTransportPolicy(model)) { + if (!normalizeOptionalString(model.baseUrl) || hasUnsupportedTransportPolicy(model)) { return false; } try { resolveProviderType( - readString(model.api)?.toLowerCase() ?? "openai-responses", - readString(model.baseUrl)!, + normalizeOptionalString(model.api)?.toLowerCase() ?? "openai-responses", + normalizeOptionalString(model.baseUrl)!, undefined, ); - assertByokEndpointHostAllowed(readString(model.baseUrl)!); + assertByokEndpointHostAllowed(normalizeOptionalString(model.baseUrl)!); return true; } catch { return false; @@ -274,7 +275,7 @@ function resolveAzureProviderType( url.pathname = ""; url.search = ""; url.hash = ""; - const resolvedApiVersion = readString(apiVersion); + const resolvedApiVersion = normalizeOptionalString(apiVersion); return { type: "azure", wireApi: "responses", @@ -317,12 +318,8 @@ function stableSerialize(value: unknown): string { return JSON.stringify(value) ?? "null"; } -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function resolveProviderCredential(value: string | undefined): string | undefined { - const credential = readString(value); + const credential = normalizeOptionalString(value); return credential && !isNonSecretApiKeyMarker(credential) ? credential : undefined; } diff --git a/extensions/copilot/src/runtime.ts b/extensions/copilot/src/runtime.ts index de5415cfc4c7..150f8dc2cb8a 100644 --- a/extensions/copilot/src/runtime.ts +++ b/extensions/copilot/src/runtime.ts @@ -118,7 +118,7 @@ export function createCopilotClientPool(options: CopilotClientPoolOptions = {}): try { return await client.stop(); } catch (error: unknown) { - return [toError(error)]; + return [toCopilotRuntimeError(error)]; } finally { entry.state = { kind: "stopped" }; maybeDeleteEntry(entry); @@ -136,7 +136,7 @@ export function createCopilotClientPool(options: CopilotClientPoolOptions = {}): await entry.state.promise; } catch (error: unknown) { maybeDeleteEntry(entry); - return [toError(error)]; + return [toCopilotRuntimeError(error)]; } return stopEntry(entry); } @@ -187,7 +187,7 @@ export function createCopilotClientPool(options: CopilotClientPoolOptions = {}): } catch (error: unknown) { entry.state = { kind: "stopped" }; maybeDeleteEntry(entry); - throw toError(error); + throw toCopilotRuntimeError(error); } })(); @@ -220,7 +220,7 @@ export function createCopilotClientPool(options: CopilotClientPoolOptions = {}): } return { key: created.entry.key, client }; } catch (error: unknown) { - throw toError(error); + throw toCopilotRuntimeError(error); } } @@ -235,7 +235,7 @@ export function createCopilotClientPool(options: CopilotClientPoolOptions = {}): } return { key: existing.key, client }; } catch (error: unknown) { - throw toError(error); + throw toCopilotRuntimeError(error); } } case "ready": @@ -390,7 +390,7 @@ function normalizeCopilotHome(copilotHome: string): string { return normalizedHome; } -function toError(error: unknown): Error { +function toCopilotRuntimeError(error: unknown): Error { if (error instanceof Error) { return error; } diff --git a/extensions/copilot/src/tool-bridge.ts b/extensions/copilot/src/tool-bridge.ts index fe93566a6679..d2a55d5b2bc9 100644 --- a/extensions/copilot/src/tool-bridge.ts +++ b/extensions/copilot/src/tool-bridge.ts @@ -254,7 +254,7 @@ export async function createCopilotToolBridge( } } catch (error: unknown) { throw createError( - `[copilot-tool-bridge] createOpenClawCodingTools failed: ${toError(error).message}`, + `[copilot-tool-bridge] createOpenClawCodingTools failed: ${toCopilotToolError(error).message}`, error, ); } @@ -547,7 +547,7 @@ function convertOpenClawToolToSdkTool( error: unknown, executionStarted: boolean, ): ToolResultObject => { - const errorMessage = toError(error).message; + const errorMessage = toCopilotToolError(error).message; ctx.observeToolTerminal?.({ toolCallId: invocation.toolCallId, toolName: sourceTool.name, @@ -595,7 +595,7 @@ function convertOpenClawToolToSdkTool( args, invocation, startedAt, - `[copilot-tool-bridge] beforeExecute failed for tool '${sourceTool.name}': ${toError(error).message}`, + `[copilot-tool-bridge] beforeExecute failed for tool '${sourceTool.name}': ${toCopilotToolError(error).message}`, error, false, ); @@ -609,7 +609,7 @@ function convertOpenClawToolToSdkTool( args, invocation, startedAt, - `[copilot-tool-bridge] prepareArguments failed for tool '${sourceTool.name}': ${toError(error).message}`, + `[copilot-tool-bridge] prepareArguments failed for tool '${sourceTool.name}': ${toCopilotToolError(error).message}`, error, false, ); @@ -628,7 +628,7 @@ function convertOpenClawToolToSdkTool( preparedArgs, invocation, startedAt, - `[copilot-tool-bridge] tool '${sourceTool.name}' failed: ${toError(error).message}`, + `[copilot-tool-bridge] tool '${sourceTool.name}' failed: ${toCopilotToolError(error).message}`, error, true, ); @@ -756,7 +756,7 @@ async function executeCatalogTool( }); return result; } catch (error: unknown) { - const message = toError(error).message; + const message = toCopilotToolError(error).message; // Completion hooks can throw after the tool terminal outcome. Do not // rewrite that recorded outcome as a second, contradictory tool failure. if (!terminalObserved) { @@ -801,7 +801,7 @@ function createFailureResult(message: string, error: unknown): ToolResultObject // Error object would produce a non-serializable JSON-RPC payload, so we // surface the message string instead. return { - error: toError(error).message, + error: toCopilotToolError(error).message, resultType: "failure", textResultForLlm: message, }; @@ -912,6 +912,6 @@ function findDuplicateToolNames(sourceTools: AnyAgentTool[]): string[] { .toSorted(); } -function toError(error: unknown): Error { +function toCopilotToolError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } diff --git a/extensions/deepgram/audio.ts b/extensions/deepgram/audio.ts index 4994f1dfa093..660a58eac0b3 100644 --- a/extensions/deepgram/audio.ts +++ b/extensions/deepgram/audio.ts @@ -10,7 +10,7 @@ import { resolveProviderHttpRequestConfig, requireTranscriptionText, } from "openclaw/plugin-sdk/provider-http"; -import { asOptionalRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asOptionalRecord } 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"; @@ -21,21 +21,21 @@ function resolveModel(model?: string): string { } function readDeepgramTranscript(payload: Record): string | undefined { - const results = asRecord(payload.results); + const results = asOptionalRecord(payload.results); if (!results) { return undefined; } if (!Array.isArray(results.channels)) { throw new Error("Audio transcription failed: malformed JSON response"); } - const channel = asRecord(results.channels[0]); + const channel = asOptionalRecord(results.channels[0]); if (!channel) { return undefined; } if (!Array.isArray(channel.alternatives)) { throw new Error("Audio transcription failed: malformed JSON response"); } - const alternative = asRecord(channel.alternatives[0]); + const alternative = asOptionalRecord(channel.alternatives[0]); if (!alternative) { return undefined; } diff --git a/extensions/deepinfra/speech-provider.ts b/extensions/deepinfra/speech-provider.ts index 22d0522dd002..d1982e3e72a3 100644 --- a/extensions/deepinfra/speech-provider.ts +++ b/extensions/deepinfra/speech-provider.ts @@ -1,9 +1,9 @@ // Deepinfra provider module implements model/runtime integration. import { - asObject, createOpenAiCompatibleSpeechProvider, type SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEEPINFRA_BASE_URL, DEEPINFRA_TTS_FALLBACK_MODELS, @@ -44,7 +44,7 @@ export function buildDeepInfraSpeechProvider(options?: { normalizeModel: normalizeDeepInfraModelRef, apiErrorLabel: "DeepInfra TTS API error", missingApiKeyError: "DeepInfra API key missing", - readExtraConfig: (raw) => ({ extraBody: asObject(raw?.extraBody) }), + readExtraConfig: (raw) => ({ extraBody: asOptionalRecord(raw?.extraBody) }), extraJsonBodyFields: [{ configKey: "extraBody", requestKey: "extra_body" }], }); } diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 74eb00171a49..f7cd9c8a2bd5 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -651,13 +651,13 @@ function createPdfScreenshotter( return { screenshotHtml }; } -function isRecord(value: unknown): value is Record { +function isObjectValue(value: unknown): value is Record { return typeof value === "object" && value !== null; } function readDetails(result: unknown): Record { const details = (result as { details?: unknown } | null | undefined)?.details; - if (!isRecord(details)) { + if (!isObjectValue(details)) { throw new Error("expected diffs tool result details"); } return details; @@ -688,7 +688,7 @@ function extractViewerArtifactToken(viewerPath: string): string { } function readParametersProperties(parameters: unknown): Record { - if (isRecord(parameters) && isRecord(parameters.properties)) { + if (isObjectValue(parameters) && isObjectValue(parameters.properties)) { return parameters.properties; } throw new Error("expected diffs tool parameter properties"); diff --git a/extensions/diffs/src/viewer-payload.ts b/extensions/diffs/src/viewer-payload.ts index 3cebf4c684c3..3b2dee7d3f32 100644 --- a/extensions/diffs/src/viewer-payload.ts +++ b/extensions/diffs/src/viewer-payload.ts @@ -4,7 +4,7 @@ import type { DiffViewerPayload } from "./types.js"; const OVERFLOW_VALUES = ["scroll", "wrap"] as const; -function isRecord(value: unknown): value is Record { +function isViewerRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -24,7 +24,7 @@ export function parseViewerPayloadJson(raw: string): DiffViewerPayload { } function isDiffViewerPayload(value: unknown): value is DiffViewerPayload { - if (!isRecord(value)) { + if (!isViewerRecord(value)) { return false; } @@ -40,8 +40,8 @@ function isDiffViewerPayload(value: unknown): value is DiffViewerPayload { return false; } - const hasFileDiff = isRecord(value.fileDiff); - const hasBeforeAfterFiles = isRecord(value.oldFile) && isRecord(value.newFile); + const hasFileDiff = isViewerRecord(value.fileDiff); + const hasBeforeAfterFiles = isViewerRecord(value.oldFile) && isViewerRecord(value.newFile); if (!hasFileDiff && !hasBeforeAfterFiles) { return false; } @@ -50,11 +50,11 @@ function isDiffViewerPayload(value: unknown): value is DiffViewerPayload { } function isViewerOptions(value: unknown): boolean { - if (!isRecord(value)) { + if (!isViewerRecord(value)) { return false; } - if (!isRecord(value.theme)) { + if (!isViewerRecord(value.theme)) { return false; } if (value.theme.light !== "pierre-light" || value.theme.dark !== "pierre-dark") { diff --git a/extensions/discord/src/components.parse.ts b/extensions/discord/src/components.parse.ts index 44b94ab46fdc..2e4f03199c21 100644 --- a/extensions/discord/src/components.parse.ts +++ b/extensions/discord/src/components.parse.ts @@ -1,6 +1,9 @@ // Discord plugin module implements components.parse behavior. import { ButtonStyle, TextInputStyle } from "discord-api-types/v10"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { DiscordComponentBlock, DiscordComponentButtonSpec, @@ -32,7 +35,11 @@ function requireObject(value: unknown, label: string): Record { return value as Record; } -function readString(value: unknown, label: string, opts?: { allowEmpty?: boolean }): string { +function readRequiredString( + value: unknown, + label: string, + opts?: { allowEmpty?: boolean }, +): string { if (typeof value !== "string") { throw new Error(`${label} must be a string`); } @@ -43,19 +50,11 @@ function readString(value: unknown, label: string, opts?: { allowEmpty?: boolean return opts?.allowEmpty ? value : trimmed; } -function readOptionalString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function readOptionalCallbackDataKind( value: unknown, label: string, ): DiscordComponentCallbackDataKind | undefined { - const kind = readOptionalString(value); + const kind = normalizeOptionalString(value); if (kind === undefined) { return undefined; } @@ -75,7 +74,7 @@ function readOptionalStringArray(value: unknown, label: string): string[] | unde if (value.length === 0) { return undefined; } - return value.map((entry, index) => readString(entry, `${label}[${index}]`)); + return value.map((entry, index) => readRequiredString(entry, `${label}[${index}]`)); } function readOptionalInteger( @@ -104,8 +103,8 @@ function readOptionalEmoji(value: unknown, label: string) { } const obj = value as { name?: unknown; id?: unknown; animated?: unknown }; return { - name: readString(obj.name, `${label}.name`), - id: readOptionalString(obj.id), + name: readRequiredString(obj.name, `${label}.name`), + id: normalizeOptionalString(obj.id), animated: typeof obj.animated === "boolean" ? obj.animated : undefined, }; } @@ -181,9 +180,9 @@ function parseSelectOptions( return raw.map((entry, index) => { const obj = requireObject(entry, `${label}[${index}]`); return { - label: readString(obj.label, `${label}[${index}].label`), - value: readString(obj.value, `${label}[${index}].value`), - description: readOptionalString(obj.description), + label: readRequiredString(obj.label, `${label}[${index}].label`), + value: readRequiredString(obj.value, `${label}[${index}].value`), + description: normalizeOptionalString(obj.description), emoji: readOptionalEmoji(obj.emoji, `${label}[${index}].emoji`), default: typeof obj.default === "boolean" ? obj.default : undefined, }; @@ -192,16 +191,16 @@ function parseSelectOptions( function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpec { const obj = requireObject(raw, label); - const style = readOptionalString(obj.style) as DiscordComponentButtonStyle | undefined; - const url = readOptionalString(obj.url); + const style = normalizeOptionalString(obj.style) as DiscordComponentButtonStyle | undefined; + const url = normalizeOptionalString(obj.url); if ((style === "link" || url) && !url) { throw new Error(`${label}.url is required for link buttons`); } return { - label: readString(obj.label, `${label}.label`), + label: readRequiredString(obj.label, `${label}.label`), style, url, - callbackData: readOptionalString(obj.callbackData), + callbackData: normalizeOptionalString(obj.callbackData), callbackDataKind: readOptionalCallbackDataKind( obj.callbackDataKind, `${label}.callbackDataKind`, @@ -214,7 +213,7 @@ function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpe function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpec { const obj = requireObject(raw, label); - const type = readOptionalString(obj.type) as DiscordComponentSelectType | undefined; + const type = normalizeOptionalString(obj.type) as DiscordComponentSelectType | undefined; const allowedTypes: DiscordComponentSelectType[] = [ "string", "user", @@ -227,12 +226,12 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe } return { type, - callbackData: readOptionalString(obj.callbackData), + callbackData: normalizeOptionalString(obj.callbackData), callbackDataKind: readOptionalCallbackDataKind( obj.callbackDataKind, `${label}.callbackDataKind`, ), - placeholder: readOptionalString(obj.placeholder), + placeholder: normalizeOptionalString(obj.placeholder), minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, { min: 0, max: 25 }), maxValues: readOptionalInteger(obj.maxValues, `${label}.maxValues`, { min: 1, max: 25 }), options: parseSelectOptions(obj.options, `${label}.options`), @@ -243,7 +242,7 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe function parseModalField(raw: unknown, label: string, index: number): DiscordModalFieldSpec { const obj = requireObject(raw, label); const type = normalizeLowercaseStringOrEmpty( - readString(obj.type, `${label}.type`), + readRequiredString(obj.type, `${label}.type`), ) as DiscordComponentModalFieldType; const supported: DiscordComponentModalFieldType[] = [ "text", @@ -267,10 +266,10 @@ function parseModalField(raw: unknown, label: string, index: number): DiscordMod const maxValues = type === "checkbox" ? 10 : 25; return { type, - name: normalizeModalFieldName(readOptionalString(obj.name), index), - label: readString(obj.label, `${label}.label`), - description: readOptionalString(obj.description), - placeholder: readOptionalString(obj.placeholder), + name: normalizeModalFieldName(normalizeOptionalString(obj.name), index), + label: readRequiredString(obj.label, `${label}.label`), + description: normalizeOptionalString(obj.description), + placeholder: normalizeOptionalString(obj.placeholder), required, options, minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, { @@ -283,25 +282,25 @@ function parseModalField(raw: unknown, label: string, index: number): DiscordMod }), minLength: readOptionalInteger(obj.minLength, `${label}.minLength`, { min: 0, max: 4000 }), maxLength: readOptionalInteger(obj.maxLength, `${label}.maxLength`, { min: 1, max: 4000 }), - style: readOptionalString(obj.style) as DiscordModalFieldSpec["style"], + style: normalizeOptionalString(obj.style) as DiscordModalFieldSpec["style"], }; } function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock { const obj = requireObject(raw, label); - const typeRaw = normalizeLowercaseStringOrEmpty(readString(obj.type, `${label}.type`)); + const typeRaw = normalizeLowercaseStringOrEmpty(readRequiredString(obj.type, `${label}.type`)); const type = normalizeBlockType(typeRaw); switch (type) { case "text": return { type: "text", - text: readString(obj.text, `${label}.text`), + text: readRequiredString(obj.text, `${label}.text`), }; case "section": { - const text = readOptionalString(obj.text); + const text = normalizeOptionalString(obj.text); const textsRaw = obj.texts; const texts = Array.isArray(textsRaw) - ? textsRaw.map((entry, idx) => readString(entry, `${label}.texts[${idx}]`)) + ? textsRaw.map((entry, idx) => readRequiredString(entry, `${label}.texts[${idx}]`)) : undefined; if (!text && (!texts || texts.length === 0)) { throw new Error(`${label}.text or ${label}.texts is required for section blocks`); @@ -310,12 +309,12 @@ function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock if (obj.accessory !== undefined) { const accessoryObj = requireObject(obj.accessory, `${label}.accessory`); const accessoryType = normalizeLowercaseStringOrEmpty( - readString(accessoryObj.type, `${label}.accessory.type`), + readRequiredString(accessoryObj.type, `${label}.accessory.type`), ); if (accessoryType === "thumbnail") { accessory = { type: "thumbnail", - url: readString(accessoryObj.url, `${label}.accessory.url`), + url: readRequiredString(accessoryObj.url, `${label}.accessory.url`), }; } else if (accessoryType === "button") { accessory = { @@ -376,8 +375,8 @@ function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock const items = itemsRaw.map((entry, idx) => { const itemObj = requireObject(entry, `${label}.items[${idx}]`); return { - url: readString(itemObj.url, `${label}.items[${idx}].url`), - description: readOptionalString(itemObj.description), + url: readRequiredString(itemObj.url, `${label}.items[${idx}].url`), + description: normalizeOptionalString(itemObj.description), spoiler: typeof itemObj.spoiler === "boolean" ? itemObj.spoiler : undefined, }; }); @@ -387,7 +386,7 @@ function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock }; } case "file": { - const file = readString(obj.file, `${label}.file`); + const file = readRequiredString(obj.file, `${label}.file`); return { type: "file", file: normalizeAttachmentRef(file, `${label}.file`), @@ -424,16 +423,16 @@ export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageS parseModalField(entry, `components.modal.fields[${idx}]`, idx), ); modal = { - title: readString(modalObj.title, "components.modal.title"), - callbackData: readOptionalString(modalObj.callbackData), - triggerLabel: readOptionalString(modalObj.triggerLabel), - triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle, + title: readRequiredString(modalObj.title, "components.modal.title"), + callbackData: normalizeOptionalString(modalObj.callbackData), + triggerLabel: normalizeOptionalString(modalObj.triggerLabel), + triggerStyle: normalizeOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle, allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"), fields, }; } return { - text: readOptionalString(obj.text), + text: normalizeOptionalString(obj.text), reusable, container: typeof obj.container === "object" && obj.container && !Array.isArray(obj.container) diff --git a/extensions/discord/src/inbound-event-delivery.ts b/extensions/discord/src/inbound-event-delivery.ts index 718d7ba40ab3..8dd809b31c44 100644 --- a/extensions/discord/src/inbound-event-delivery.ts +++ b/extensions/discord/src/inbound-event-delivery.ts @@ -2,8 +2,8 @@ import { createInboundEventDeliveryCorrelation } from "openclaw/plugin-sdk/inbound-event-delivery"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; import { - asOptionalRecord as readRecord, - normalizeOptionalString as readString, + asOptionalRecord, + normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; const DISCORD_INBOUND_EVENT_DELIVERY_KEY = "__openclawInboundEventDelivery"; @@ -32,8 +32,8 @@ export function withDiscordInboundEventDeliveryMetadata( if (!sessionKey || params.inboundEventKind !== "room_event") { return payload; } - const channelData = readRecord(payload.channelData) ?? {}; - const discordData = readRecord(channelData.discord) ?? {}; + const channelData = asOptionalRecord(payload.channelData) ?? {}; + const discordData = asOptionalRecord(channelData.discord) ?? {}; return { ...payload, channelData: { @@ -54,15 +54,15 @@ export function notifyDiscordInboundEventOutboundPayloadSuccess(params: { to: string; accountId?: string | null; }): void { - const channelData = readRecord(params.payload.channelData); - const discordData = readRecord(channelData?.discord); - const metadata = readRecord(discordData?.[DISCORD_INBOUND_EVENT_DELIVERY_KEY]); + const channelData = asOptionalRecord(params.payload.channelData); + const discordData = asOptionalRecord(channelData?.discord); + const metadata = asOptionalRecord(discordData?.[DISCORD_INBOUND_EVENT_DELIVERY_KEY]); if (!metadata) { return; } discordInboundEventDelivery.notify({ - sessionKey: readString(metadata.sessionKey), - inboundEventKind: readString(metadata.inboundEventKind), + sessionKey: normalizeOptionalString(metadata.sessionKey), + inboundEventKind: normalizeOptionalString(metadata.inboundEventKind), to: params.to, accountId: params.accountId, }); diff --git a/extensions/discord/src/monitor/message-handler.process.test-harness.ts b/extensions/discord/src/monitor/message-handler.process.test-harness.ts index a9470ee8c68d..351655d6520c 100644 --- a/extensions/discord/src/monitor/message-handler.process.test-harness.ts +++ b/extensions/discord/src/monitor/message-handler.process.test-harness.ts @@ -415,7 +415,13 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { cfg, dispatcherOptions: { ...plan.dispatcherOptions, - deliver: (delivery.deliverWithProviderMessageSending ?? delivery.deliver)!, + deliver: (payload, info) => { + const providerInfo = { + ...info, + onPlatformSendDispatch: async () => undefined, + }; + return delivery.deliverWithProviderMessageSending(payload, providerInfo); + }, onError: delivery.onError, }, toolsAllow: plan.toolsAllow, diff --git a/extensions/discord/src/monitor/model-picker-preference-primitives.ts b/extensions/discord/src/monitor/model-picker-preference-primitives.ts index aaa4e8ed7ac8..70493d722dbf 100644 --- a/extensions/discord/src/monitor/model-picker-preference-primitives.ts +++ b/extensions/discord/src/monitor/model-picker-preference-primitives.ts @@ -43,7 +43,7 @@ export function buildPreferenceModelKey(scopeKey: string, modelRef: string): str return `v1:${hashSegment(scopeKey, 32)}:${hashSegment(modelRef, 24)}`; } -export function timestampMs(value: unknown): number { +export function preferenceTimestampMs(value: unknown): number { const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN; return Number.isFinite(parsed) ? parsed : 0; } diff --git a/extensions/discord/src/monitor/model-picker-preferences-migrations.ts b/extensions/discord/src/monitor/model-picker-preferences-migrations.ts index 24f6b37ddc96..f67a27abbd0d 100644 --- a/extensions/discord/src/monitor/model-picker-preferences-migrations.ts +++ b/extensions/discord/src/monitor/model-picker-preferences-migrations.ts @@ -10,8 +10,8 @@ import { } from "../command-deploy-store.js"; import { buildPreferenceModelKey, + preferenceTimestampMs, sanitizeRecentModels, - timestampMs, } from "./model-picker-preference-primitives.js"; import { normalizePersistedBinding, @@ -74,7 +74,7 @@ function normalizeLegacyPreferenceKey(key: string): string | undefined { } function legacyUpdatedAtForIndex(updatedAt: unknown, index: number, total: number): string { - const baseMs = timestampMs(updatedAt); + const baseMs = preferenceTimestampMs(updatedAt); const anchorMs = Math.min(baseMs + Math.max(0, total), MAX_DATE_TIMESTAMP_MS); const shiftedMs = anchorMs - Math.max(0, index); return ( diff --git a/extensions/discord/src/monitor/model-picker-preferences.ts b/extensions/discord/src/monitor/model-picker-preferences.ts index fde339bfac85..9fe9d4aeb424 100644 --- a/extensions/discord/src/monitor/model-picker-preferences.ts +++ b/extensions/discord/src/monitor/model-picker-preferences.ts @@ -10,8 +10,8 @@ import { getDiscordRuntime } from "../runtime.js"; import { buildPreferenceModelKey, normalizeModelRef, + preferenceTimestampMs, sanitizeRecentModels, - timestampMs, } from "./model-picker-preference-primitives.js"; const DEFAULT_RECENT_LIMIT = 5; @@ -96,7 +96,7 @@ function comparePreferenceEntries( right: { key: string; value: ModelPickerPreferencesEntry }, ): number { return ( - timestampMs(right.value.updatedAt) - timestampMs(left.value.updatedAt) || + preferenceTimestampMs(right.value.updatedAt) - preferenceTimestampMs(left.value.updatedAt) || timestampOrder(right.value.updatedOrder) - timestampOrder(left.value.updatedOrder) || left.key.localeCompare(right.key) ); @@ -107,7 +107,7 @@ function nextPreferenceTimestamp(existingEntries: ModelPickerPreferencesEntry[]) updatedOrder: number; } { const existingMaxTimestampMs = existingEntries.reduce( - (max, entry) => Math.max(max, timestampMs(entry.updatedAt)), + (max, entry) => Math.max(max, preferenceTimestampMs(entry.updatedAt)), 0, ); lastPreferenceTimestampMs = Math.min( diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 42cf73e89b7c..4502f8bcfb85 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -228,13 +228,13 @@ type MockCalls = { mock: { calls: unknown[][] }; }; -function isRecord(value: unknown): value is Record { +function isObjectValue(value: unknown): value is Record { return typeof value === "object" && value !== null; } function requireRecord(value: unknown, label: string): Record { - expect(isRecord(value), `${label} should be an object`).toBe(true); - if (!isRecord(value)) { + expect(isObjectValue(value), `${label} should be an object`).toBe(true); + if (!isObjectValue(value)) { throw new Error(`${label} should be an object`); } return value; @@ -292,7 +292,7 @@ function expectFollowUpFields( function expectNoFollowUpContent(interaction: MockCommandInteraction, content: string) { const calls = (interaction.followUp as unknown as MockCalls).mock.calls; - const matched = calls.some(([payload]) => isRecord(payload) && payload.content === content); + const matched = calls.some(([payload]) => isObjectValue(payload) && payload.content === content); expect(matched).toBe(false); } @@ -1896,7 +1896,7 @@ describe("Discord native plugin command dispatch", () => { const replyCalls = (interaction.reply as unknown as MockCalls).mock.calls; const blockedReply = replyCalls.some( ([payload]) => - isRecord(payload) && + isObjectValue(payload) && payload.content === "Configured ACP binding is unavailable right now. Please try again.", ); expect(blockedReply).toBe(false); diff --git a/extensions/discord/src/monitor/thread-bindings.session-adapter.ts b/extensions/discord/src/monitor/thread-bindings.session-adapter.ts index 5514884214f2..31da7ab14b11 100644 --- a/extensions/discord/src/monitor/thread-bindings.session-adapter.ts +++ b/extensions/discord/src/monitor/thread-bindings.session-adapter.ts @@ -115,7 +115,8 @@ export function createThreadBindingSessionAdapter(params: { resolveCurrentCfg: () => OpenClawConfig; resolveCurrentToken: () => string | undefined; }): SessionBindingAdapter { - const toRecord = (entry: ThreadBindingRecord) => toSessionBindingRecord(entry, params.defaults); + const serializeBinding = (entry: ThreadBindingRecord) => + toSessionBindingRecord(entry, params.defaults); return { channel: "discord", @@ -184,16 +185,16 @@ export function createThreadBindingSessionAdapter(params: { introText, metadata, }); - return bound ? toRecord(bound) : null; + return bound ? serializeBinding(bound) : null; }, listBySession: (targetSessionKey) => - params.manager.listBySessionKey(targetSessionKey).map(toRecord), + params.manager.listBySessionKey(targetSessionKey).map(serializeBinding), resolveByConversation: (ref) => { if (ref.channel !== "discord") { return null; } const binding = params.manager.getByThreadId(ref.conversationId); - return binding ? toRecord(binding) : null; + return binding ? serializeBinding(binding) : null; }, touch: (bindingId, at) => { const threadId = resolveThreadBindingConversationIdFromBindingId({ @@ -211,7 +212,7 @@ export function createThreadBindingSessionAdapter(params: { targetSessionKey: input.targetSessionKey, reason: input.reason, }); - return removed.map(toRecord); + return removed.map(serializeBinding); } const threadId = resolveThreadBindingConversationIdFromBindingId({ accountId: params.accountId, @@ -224,7 +225,7 @@ export function createThreadBindingSessionAdapter(params: { threadId, reason: input.reason, }); - return removed ? [toRecord(removed)] : []; + return removed ? [serializeBinding(removed)] : []; }, }; } diff --git a/extensions/discord/src/status-issues.ts b/extensions/discord/src/status-issues.ts index f12f93f08a79..7d3342305680 100644 --- a/extensions/discord/src/status-issues.ts +++ b/extensions/discord/src/status-issues.ts @@ -5,11 +5,11 @@ import type { } from "openclaw/plugin-sdk/channel-contract"; import { appendMatchMetadata, - asString, isRecord, readAccountStatusSnapshot, resolveEnabledConfiguredAccountId, } from "openclaw/plugin-sdk/status-helpers"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; type DiscordIntentSummary = { messageContent?: "enabled" | "limited" | "disabled"; @@ -66,17 +66,17 @@ function readDiscordPermissionsAuditSummary(value: unknown): DiscordPermissionsA if (!isRecord(entry)) { return null; } - const channelId = asString(entry.channelId); + const channelId = normalizeOptionalString(entry.channelId); if (!channelId) { return null; } const ok = typeof entry.ok === "boolean" ? entry.ok : undefined; const missing = Array.isArray(entry.missing) - ? entry.missing.map((v) => asString(v)).filter(Boolean) + ? entry.missing.map((v) => normalizeOptionalString(v)).filter(Boolean) : undefined; - const error = asString(entry.error) ?? null; - const matchKey = asString(entry.matchKey) ?? undefined; - const matchSource = asString(entry.matchSource) ?? undefined; + const error = normalizeOptionalString(entry.error) ?? null; + const matchKey = normalizeOptionalString(entry.matchKey); + const matchSource = normalizeOptionalString(entry.matchSource); return { channelId, ok, diff --git a/extensions/elevenlabs/speech-provider.ts b/extensions/elevenlabs/speech-provider.ts index a048b9da7e71..7509dc6e43d8 100644 --- a/extensions/elevenlabs/speech-provider.ts +++ b/extensions/elevenlabs/speech-provider.ts @@ -17,7 +17,6 @@ import type { import { asBoolean, asFiniteNumber, - asObject, normalizeApplyTextNormalization, normalizeLanguageCode, normalizeSeed, @@ -30,6 +29,7 @@ import { ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; import { + asOptionalRecord, normalizeLowercaseStringOrEmpty, parseBooleanValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -134,9 +134,9 @@ function normalizeVoiceSettings( function normalizeElevenLabsProviderConfig( rawConfig: Record, ): ElevenLabsProviderConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.elevenlabs) ?? asObject(rawConfig.elevenlabs); - const rawVoiceSettings = asObject(raw?.voiceSettings); + const providers = asOptionalRecord(rawConfig.providers); + const raw = asOptionalRecord(providers?.elevenlabs) ?? asOptionalRecord(rawConfig.elevenlabs); + const rawVoiceSettings = asOptionalRecord(raw?.voiceSettings); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, @@ -162,7 +162,7 @@ function normalizeElevenLabsProviderConfig( function readElevenLabsProviderConfig(config: SpeechProviderConfig): ElevenLabsProviderConfig { const defaults = normalizeElevenLabsProviderConfig({}); - const voiceSettings = asObject(config.voiceSettings); + const voiceSettings = asOptionalRecord(config.voiceSettings); return { apiKey: trimToUndefined(config.apiKey) ?? defaults.apiKey, baseUrl: normalizeElevenLabsBaseUrl(trimToUndefined(config.baseUrl) ?? defaults.baseUrl), @@ -205,7 +205,7 @@ function mergeVoiceSettingsOverride( return { ...ctx.currentOverrides, voiceSettings: { - ...asObject(ctx.currentOverrides?.voiceSettings), + ...asOptionalRecord(ctx.currentOverrides?.voiceSettings), ...next, }, }; @@ -215,7 +215,7 @@ function resolveVoiceSettingsOverride( base: ElevenLabsProviderConfig["voiceSettings"], overrides: unknown, ): ElevenLabsProviderConfig["voiceSettings"] { - const voiceSettings = asObject(overrides); + const voiceSettings = asOptionalRecord(overrides); return { ...base, ...normalizeVoiceSettings(voiceSettings), @@ -450,7 +450,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin { parseDirectiveToken, resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => { const base = normalizeElevenLabsProviderConfig(baseTtsConfig); - const talkVoiceSettings = asObject(talkProviderConfig.voiceSettings); + const talkVoiceSettings = asOptionalRecord(talkProviderConfig.voiceSettings); const resolvedTalkApiKey = resolveElevenLabsTalkApiKey(talkProviderConfig); return { ...base, diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts index bf3fa4ade4ed..13b7e79e0b22 100644 --- a/extensions/feishu/src/accounts.ts +++ b/extensions/feishu/src/accounts.ts @@ -8,7 +8,7 @@ import { normalizeOptionalAccountId, } from "openclaw/plugin-sdk/account-resolution"; import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth"; -import { normalizeString } from "./comment-shared.js"; +import { normalizeOptionalString as normalizeString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { FeishuConfig, FeishuAccountConfig, diff --git a/extensions/feishu/src/card-interaction.ts b/extensions/feishu/src/card-interaction.ts index 67d9726b7542..ae12050f7df5 100644 --- a/extensions/feishu/src/card-interaction.ts +++ b/extensions/feishu/src/card-interaction.ts @@ -1,5 +1,5 @@ // Feishu plugin module implements card interaction behavior. -import { isRecord } from "./comment-shared.js"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export const FEISHU_CARD_INTERACTION_VERSION = "ocf1"; diff --git a/extensions/feishu/src/card-test-helpers.ts b/extensions/feishu/src/card-test-helpers.ts index 2b10cf2768f5..665d347b1243 100644 --- a/extensions/feishu/src/card-test-helpers.ts +++ b/extensions/feishu/src/card-test-helpers.ts @@ -1,3 +1,4 @@ +import { asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; // Feishu helper module supports card test helpers behavior. import { expect } from "vitest"; @@ -5,10 +6,6 @@ type MockCalls = { mock: { calls: unknown[][] }; }; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } @@ -36,17 +33,17 @@ export function expectFirstSentCardUsesFillWidthOnly(sendCardMock: { export function expectSentCardHasP2pAction(sendCardMock: MockCalls) { const hasP2pAction = sendCardMock.mock.calls.some(([arg]) => { - const card = asRecord(asRecord(arg)?.card); - const body = asRecord(card?.body); + const card = asOptionalObjectRecord(asOptionalObjectRecord(arg)?.card); + const body = asOptionalObjectRecord(card?.body); return asArray(body?.elements).some((element) => { - const elementRecord = asRecord(element); + const elementRecord = asOptionalObjectRecord(element); if (elementRecord?.tag !== "action") { return false; } return asArray(elementRecord.actions).some((action) => { - const actionRecord = asRecord(action); - const value = asRecord(actionRecord?.value); - const command = asRecord(value?.c); + const actionRecord = asOptionalObjectRecord(action); + const value = asOptionalObjectRecord(actionRecord?.value); + const command = asOptionalObjectRecord(value?.c); return command?.t === "p2p"; }); }); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 9b174b762f56..a88fc4874fbf 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -40,6 +40,7 @@ import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers"; import { + isRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -69,7 +70,6 @@ import { PAIRING_APPROVED_MESSAGE, } from "./channel-runtime-api.js"; import { normalizeFeishuChatType, resolveFeishuChatType } from "./chat-type.js"; -import { isRecord } from "./comment-shared.js"; import { FeishuChannelConfigSchema } from "./config-schema.js"; import { buildFeishuConversationId, diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index 9be99fc6aff8..00f41732a10a 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -141,7 +141,7 @@ const baseAccount: ResolvedFeishuAccount = { config: FeishuConfigSchema.parse({}), }; -function isRecord(value: unknown): value is Record { +function isObjectRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -158,7 +158,7 @@ type HttpInstanceLike = { function requireHttpInstance(value: unknown): HttpInstanceLike { if ( - isRecord(value) && + isObjectRecord(value) && typeof value.request === "function" && typeof value.get === "function" && typeof value.post === "function" @@ -173,7 +173,7 @@ function readCallOptions( index = -1, ): Record { const call = index < 0 ? mock.mock.calls.at(index)?.[0] : mock.mock.calls[index]?.[0]; - return isRecord(call) ? call : {}; + return isObjectRecord(call) ? call : {}; } function firstWsClientOptions(): { diff --git a/extensions/feishu/src/comment-shared.ts b/extensions/feishu/src/comment-shared.ts index a535836e34c8..ca5de3eaae5d 100644 --- a/extensions/feishu/src/comment-shared.ts +++ b/extensions/feishu/src/comment-shared.ts @@ -1,10 +1,10 @@ // Feishu plugin module implements comment shared behavior. import { retryAsync } from "openclaw/plugin-sdk/retry-runtime"; import { - isRecord as sharedIsRecord, - normalizeOptionalString, + isRecord, + normalizeOptionalString as normalizeString, normalizeStringEntries, - readStringValue, + readStringValue as readString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { FEISHU_COMMENT_FILE_TYPES, type CommentFileType } from "./comment-target.js"; import { @@ -24,12 +24,6 @@ export function encodeQuery(params: Record): string return queryString ? `?${queryString}` : ""; } -export const readString = readStringValue; - -export const normalizeString = normalizeOptionalString; - -export const isRecord = sharedIsRecord; - export function formatFeishuApiError( error: unknown, options: { diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index a6047fc38e59..6cab3cf67fc0 100644 --- a/extensions/feishu/src/drive.ts +++ b/extensions/feishu/src/drive.ts @@ -2,16 +2,11 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers"; +import { isRecord, readStringValue as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { cleanupAmbientCommentTypingReaction } from "./comment-reaction.js"; -import { - encodeQuery, - extractReplyText, - formatFeishuApiError, - isRecord, - readString, -} from "./comment-shared.js"; +import { encodeQuery, extractReplyText, formatFeishuApiError } from "./comment-shared.js"; import { parseFeishuCommentTarget, type CommentFileType } from "./comment-target.js"; import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js"; import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; diff --git a/extensions/feishu/src/feishu-ingress.ts b/extensions/feishu/src/feishu-ingress.ts index f3754f1c5797..7bb49c3bbb13 100644 --- a/extensions/feishu/src/feishu-ingress.ts +++ b/extensions/feishu/src/feishu-ingress.ts @@ -12,6 +12,7 @@ import { import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { ChannelReplayClaimHandle } from "openclaw/plugin-sdk/persistent-dedupe"; +import { normalizeNullableString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getFeishuRuntime } from "./runtime.js"; const FEISHU_INGRESS_PAYLOAD_VERSION = 1; @@ -75,10 +76,6 @@ export const FeishuIngressPermanentError = createChannelIngressError< >("FeishuIngressPermanentError", { withReason: true }); export type FeishuIngressPermanentError = InstanceType; -function readString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function parseRawEnvelope(rawEnvelope: string): Record { let parsed: unknown; try { @@ -103,7 +100,7 @@ function decryptEnvelope( envelope: Record, encryptKey: string | undefined, ): Record { - const encrypted = readString(envelope.encrypt); + const encrypted = normalizeNullableString(envelope.encrypt); if (!encrypted) { return envelope; } @@ -136,13 +133,16 @@ function inspectFeishuIngressEnvelope( const envelope = decryptEnvelope(parseRawEnvelope(rawEnvelope), encryptKey); const nestedHeader = isRecord(envelope.header) ? envelope.header : null; const nestedEvent = isRecord(envelope.event) ? envelope.event : null; - const eventType = readString(nestedHeader?.event_type) ?? readString(envelope.event_type); + const eventType = + normalizeNullableString(nestedHeader?.event_type) ?? + normalizeNullableString(envelope.event_type); if (!eventType || !FEISHU_DURABLE_EVENT_TYPES.has(eventType)) { return null; } // Lark v2 carries delivery identity in header.event_id. The flattened shape // is also accepted because EventDispatcher hands that exact shape to handlers. - const eventId = readString(nestedHeader?.event_id) ?? readString(envelope.event_id); + const eventId = + normalizeNullableString(nestedHeader?.event_id) ?? normalizeNullableString(envelope.event_id); if (!eventId) { throw new FeishuIngressPermanentError( "invalid-event", @@ -152,7 +152,7 @@ function inspectFeishuIngressEnvelope( const event = nestedEvent ?? envelope; if (eventType === "im.message.receive_v1") { const message = isRecord(event.message) ? event.message : null; - const chatId = readString(message?.chat_id); + const chatId = normalizeNullableString(message?.chat_id); if (!chatId) { if (allowInvalidLane) { return { eventId, eventType, laneKey: `invalid:${eventType}:${eventId}` }; @@ -165,8 +165,8 @@ function inspectFeishuIngressEnvelope( return { eventId, eventType, laneKey: `chat:${chatId}` }; } const noticeMeta = isRecord(event.notice_meta) ? event.notice_meta : null; - const fileType = readString(noticeMeta?.file_type); - const documentId = readString(noticeMeta?.file_token); + const fileType = normalizeNullableString(noticeMeta?.file_type); + const documentId = normalizeNullableString(noticeMeta?.file_token); if (!fileType || !documentId) { if (allowInvalidLane) { return { eventId, eventType, laneKey: `invalid:${eventType}:${eventId}` }; @@ -483,7 +483,7 @@ export function createFeishuDurableIngress(options: FeishuIngressOptions): Feish invoke, invokeWebhook, resolveLifecycle: (data) => { - const eventId = isRecord(data) ? readString(data.event_id) : null; + const eventId = isRecord(data) ? normalizeNullableString(data.event_id) : null; return eventId ? activeLifecycles.get(eventId) : undefined; }, setSocketTerminator: (terminate) => { diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index d1ce35994bd6..62cca798d3ef 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -1,6 +1,7 @@ // Feishu plugin module implements monitor.account behavior. import * as crypto from "node:crypto"; import type * as Lark from "@larksuiteoapi/node-sdk"; +import { isRecord, readStringValue as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ClawdbotConfig, PluginRuntime, RuntimeEnv, HistoryEntry } from "../runtime-api.js"; import { raceWithTimeoutAndAbort } from "./async.js"; import { @@ -11,7 +12,6 @@ import { } from "./bot.js"; import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; import { createEventDispatcher } from "./client.js"; -import { isRecord, readString } from "./comment-shared.js"; import { hasProcessedFeishuMessage, warmupDedupFromPluginState } from "./dedup.js"; import { createFeishuDurableIngress, type FeishuIngressLifecycle } from "./feishu-ingress.js"; import { applyBotIdentityState, startBotIdentityRecovery } from "./monitor.bot-identity.js"; diff --git a/extensions/feishu/src/monitor.comment.ts b/extensions/feishu/src/monitor.comment.ts index a020dec20cdf..f0b33dc6af7a 100644 --- a/extensions/feishu/src/monitor.comment.ts +++ b/extensions/feishu/src/monitor.comment.ts @@ -1,6 +1,11 @@ // Feishu plugin module implements monitor.comment behavior. import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { asBoolean as readBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean as readBoolean, + isRecord, + normalizeOptionalString as normalizeString, + readStringValue as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { ClawdbotConfig } from "../runtime-api.js"; import { raceWithTimeoutAndAbort, waitForAbortableDelay } from "./async.js"; @@ -8,12 +13,9 @@ import { createFeishuClient } from "./client.js"; import { encodeQuery, extractReplyText, - isRecord, - normalizeString, parseCommentContentElements, type ParsedCommentContent, type ParsedCommentLinkedDocument, - readString, } from "./comment-shared.js"; import { normalizeCommentFileType, type CommentFileType } from "./comment-target.js"; import type { ResolvedFeishuAccount } from "./types.js"; diff --git a/extensions/feishu/src/post.ts b/extensions/feishu/src/post.ts index 0d879e65788a..7ddbfb713d86 100644 --- a/extensions/feishu/src/post.ts +++ b/extensions/feishu/src/post.ts @@ -1,6 +1,8 @@ // Feishu plugin module implements post behavior. -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { isRecord } from "./comment-shared.js"; +import { + isRecord, + normalizeLowercaseStringOrEmpty, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeFeishuExternalKey } from "./external-keys.js"; const FALLBACK_POST_TEXT = "[Rich text message]"; diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index b1a7a2b4d1aa..ce5577402379 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -5,6 +5,7 @@ import { createChannelPartialDeliveryError, isChannelPartialDeliveryError, } from "openclaw/plugin-sdk/channel-inbound"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; type StreamingSessionStub = { @@ -322,10 +323,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { return { ...result.dispatcherOptions, ...result.delivery }; } - function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); - } - function requireRecord(value: unknown, label: string): Record { expect(isRecord(value), `${label} must be an object`).toBe(true); return value as Record; diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 16aa660b026a..9ef31a9c11cc 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -77,7 +77,7 @@ function mergeStreamingFinalText( return `${previousText}\n\n${nextText}`; } -function toError(error: unknown): Error { +function toFeishuError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } @@ -851,7 +851,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (isChannelPartialDeliveryError(error)) { // The attachment is already visible; text recovery would duplicate delivery. markVisibleReplySent(); - throw toError(error); + throw toFeishuError(error); } const fallbackText = await buildFeishuMediaFallbackText({ text: sentFallbackText ? undefined : options.fallbackText, @@ -1069,7 +1069,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } } if (closeOutcome.error !== undefined) { - throw toError(closeOutcome.error); + throw toFeishuError(closeOutcome.error); } } while (pendingStreamingDeliveries.length > 0); } finally { @@ -1313,7 +1313,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP : undefined; if (!shouldDeliverText && !hasMedia) { if (priorClosedStreamingSettlement?.error !== undefined) { - throw toError(priorClosedStreamingSettlement.error); + throw toFeishuError(priorClosedStreamingSettlement.error); } return priorClosedStreamingSettlement?.result ?? noVisibleFeishuReplyDelivery; } diff --git a/extensions/file-transfer/src/shared/node-invoke-policy.ts b/extensions/file-transfer/src/shared/node-invoke-policy.ts index e032bfe6247b..11bb4f7b726d 100644 --- a/extensions/file-transfer/src/shared/node-invoke-policy.ts +++ b/extensions/file-transfer/src/shared/node-invoke-policy.ts @@ -9,6 +9,7 @@ import type { OpenClawPluginNodeInvokePolicyResult, } from "openclaw/plugin-sdk/plugin-entry"; import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { projectBoundedTextTail } from "./append-bounded-text-tail.js"; import { appendFileTransferAudit, type FileTransferAuditOp } from "./audit.js"; import { @@ -29,12 +30,6 @@ const DIR_FETCH_ARCHIVE_LIST_ERROR_STDERR_CHARS = 200; type FileTransferCommand = FileTransferNodeInvokeCommand; -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function readPath(params: Record): string { return typeof params.path === "string" ? params.path.trim() : ""; } @@ -750,7 +745,7 @@ async function handleFileTransferInvoke( } const command = ctx.command as FileTransferCommand; const op: FileTransferAuditOp = command; - const params = asRecord(ctx.params); + const params = asOptionalRecord(ctx.params) ?? {}; const requestedPath = readPath(params); const nodeDisplayName = ctx.node?.displayName; const startedAt = Date.now(); diff --git a/extensions/file-transfer/src/shared/params.ts b/extensions/file-transfer/src/shared/params.ts index db47f4f8a5c7..5251eaf7fc4b 100644 --- a/extensions/file-transfer/src/shared/params.ts +++ b/extensions/file-transfer/src/shared/params.ts @@ -27,18 +27,6 @@ export function readTrimmedString(params: Record, key: string): return typeof value === "string" ? value.trim() : ""; } -export function readBoolean( - params: Record, - key: string, - defaultValue = false, -): boolean { - const value = params[key]; - if (typeof value === "boolean") { - return value; - } - return defaultValue; -} - export function readClampedInt(params: { input: Record; key: string; diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.ts index 9c196a41d0d5..f515a9655ae8 100644 --- a/extensions/file-transfer/src/tools/dir-fetch-tool.ts +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.ts @@ -10,9 +10,10 @@ import { type ArchiveEntryKind, } from "openclaw/plugin-sdk/archive"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store"; +import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; import { appendFileTransferAudit } from "../shared/audit.js"; import { IMAGE_MIME_INLINE_SET, mimeFromExtension } from "../shared/mime.js"; -import { humanSize, readBoolean, readClampedInt } from "../shared/params.js"; +import { humanSize, readClampedInt } from "../shared/params.js"; import { DIR_FETCH_DEFAULT_MAX_BYTES, DIR_FETCH_HARD_MAX_BYTES, @@ -134,7 +135,7 @@ export function createDirFetchTool(): AnyAgentTool { hardMin: 1, hardMax: DIR_FETCH_HARD_MAX_BYTES, }); - const includeDotfiles = readBoolean(params, "includeDotfiles", false); + const includeDotfiles = asBoolean(params.includeDotfiles) ?? false; const { nodeId, nodeDisplayName, payload, startedAt } = await invokeNodeToolPayload({ node, diff --git a/extensions/file-transfer/src/tools/file-write-tool.ts b/extensions/file-transfer/src/tools/file-write-tool.ts index 7e7333459071..fda64062e848 100644 --- a/extensions/file-transfer/src/tools/file-write-tool.ts +++ b/extensions/file-transfer/src/tools/file-write-tool.ts @@ -2,9 +2,10 @@ import crypto from "node:crypto"; import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness-runtime"; import { readMediaBuffer } from "openclaw/plugin-sdk/media-store"; +import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; import { appendFileTransferAudit } from "../shared/audit.js"; import { inspectStrictBase64 } from "../shared/base64.js"; -import { humanSize, readBoolean } from "../shared/params.js"; +import { humanSize } from "../shared/params.js"; import { FILE_TRANSFER_SUBDIR, FILE_WRITE_HARD_MAX_BYTES, @@ -73,8 +74,8 @@ export function createFileWriteTool(): AnyAgentTool { const { node: nodeQuery, requestedPath: filePath } = readRequiredNodePath(raw); const contentBase64 = typeof raw.contentBase64 === "string" ? raw.contentBase64 : undefined; const sourceMediaId = typeof raw.sourceMediaId === "string" ? raw.sourceMediaId : undefined; - const overwrite = readBoolean(raw, "overwrite", false); - const createParents = readBoolean(raw, "createParents", false); + const overwrite = asBoolean(raw.overwrite) ?? false; + const createParents = asBoolean(raw.createParents) ?? false; // Compute the sha256 of the bytes we're sending so the node can do // an end-to-end integrity check after writing. This is always diff --git a/extensions/fish-audio-speech/speech-provider.ts b/extensions/fish-audio-speech/speech-provider.ts index 59d6d36b8e33..901382e0066e 100644 --- a/extensions/fish-audio-speech/speech-provider.ts +++ b/extensions/fish-audio-speech/speech-provider.ts @@ -12,11 +12,11 @@ import type { import { asBoolean, asFiniteNumber, - asObject, parseSpeechDirectiveNumberOverride, resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { FISH_AUDIO_STREAM_MAX_BYTES, type FishAudioFormat, @@ -80,8 +80,9 @@ function resolveReferenceId(raw: Record | undefined): string | } function normalizeProviderConfig(rawConfig: Record): FishAudioProviderConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.["fish-audio"]) ?? asObject(rawConfig["fish-audio"]); + const providers = asOptionalRecord(rawConfig.providers); + const raw = + asOptionalRecord(providers?.["fish-audio"]) ?? asOptionalRecord(rawConfig["fish-audio"]); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, @@ -100,7 +101,7 @@ function normalizeProviderConfig(rawConfig: Record): FishAudioP function readProviderConfig(config: SpeechProviderConfig): FishAudioProviderConfig { const defaults = normalizeProviderConfig({}); - const raw = asObject(config) ?? {}; + const raw = asOptionalRecord(config) ?? {}; return { apiKey: trimToUndefined(raw.apiKey) ?? defaults.apiKey, baseUrl: normalizeFishAudioBaseUrl(trimToUndefined(raw.baseUrl) ?? defaults.baseUrl), @@ -115,7 +116,7 @@ function readProviderConfig(config: SpeechProviderConfig): FishAudioProviderConf } function readOverrides(overrides: SpeechProviderOverrides | undefined): FishAudioOverrides { - const raw = asObject(overrides) ?? {}; + const raw = asOptionalRecord(overrides) ?? {}; return { model: trimToUndefined(raw.model ?? raw.modelId) ? normalizeModel(raw.model ?? raw.modelId) diff --git a/extensions/fish-audio-speech/tts.ts b/extensions/fish-audio-speech/tts.ts index f1b6d6892458..f705fb66116c 100644 --- a/extensions/fish-audio-speech/tts.ts +++ b/extensions/fish-audio-speech/tts.ts @@ -6,11 +6,12 @@ import { readProviderBinaryResponse, readProviderJsonResponse, } from "openclaw/plugin-sdk/provider-http"; -import { asObject, trimToUndefined, type SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; +import { trimToUndefined, type SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; const FISH_AUDIO_BASE_URL = "https://api.fish.audio"; const FISH_AUDIO_VOICES_MAX_BYTES = 2 * 1024 * 1024; @@ -205,7 +206,7 @@ type FishAudioVoicePayload = { }; function parseVoiceItem(value: unknown): SpeechVoiceOption | undefined { - const item = asObject(value); + const item = asOptionalRecord(value); const id = trimToUndefined(item?.["_id"]); if (!id) { return undefined; diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index e4682eb45857..f94da72d5181 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -21,6 +21,7 @@ import type { RealtimeVoiceBridge, RealtimeVoiceProviderPlugin, } from "openclaw/plugin-sdk/realtime-voice"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; // Google Meet tests cover index plugin behavior. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -573,10 +574,6 @@ function requireConfigProperty( return value as Record; } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - type MockSessionEntry = { sessionId?: string; updatedAt?: number; diff --git a/extensions/google-meet/src/config-compat.ts b/extensions/google-meet/src/config-compat.ts index f970ef3b4ae5..6c5fbb0c4240 100644 --- a/extensions/google-meet/src/config-compat.ts +++ b/extensions/google-meet/src/config-compat.ts @@ -1,7 +1,7 @@ // Google Meet helper module supports config compat behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { - asNullableRecord as asRecord, + asNullableRecord, normalizeOptionalLowercaseString as normalizeProviderId, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -16,7 +16,7 @@ function hasOwn(record: Record, key: string): boolean { } function hasLegacyGoogleRealtimeProvider(value: unknown): boolean { - const realtime = asRecord(value); + const realtime = asNullableRecord(value); if (!realtime || normalizeProviderId(realtime.provider) !== "google") { return false; } @@ -36,23 +36,23 @@ function migrateGoogleMeetLegacyRealtimeProvider(config: OpenClawConfig): { config: OpenClawConfig; changes: string[]; } | null { - const rawEntry = asRecord(config.plugins?.entries?.["google-meet"]); - const rawPluginConfig = asRecord(rawEntry?.config); - const rawRealtime = asRecord(rawPluginConfig?.realtime); + const rawEntry = asNullableRecord(config.plugins?.entries?.["google-meet"]); + const rawPluginConfig = asNullableRecord(rawEntry?.config); + const rawRealtime = asNullableRecord(rawPluginConfig?.realtime); if (!rawRealtime || !hasLegacyGoogleRealtimeProvider(rawRealtime)) { return null; } const nextConfig = structuredClone(config); - const nextPlugins = asRecord(nextConfig.plugins) ?? {}; + const nextPlugins = asNullableRecord(nextConfig.plugins) ?? {}; nextConfig.plugins = nextPlugins; - const nextEntries = asRecord(nextPlugins.entries) ?? {}; + const nextEntries = asNullableRecord(nextPlugins.entries) ?? {}; nextPlugins.entries = nextEntries; - const nextEntry = asRecord(nextEntries["google-meet"]) ?? {}; + const nextEntry = asNullableRecord(nextEntries["google-meet"]) ?? {}; nextEntries["google-meet"] = nextEntry; - const nextPluginConfig = asRecord(nextEntry.config) ?? {}; + const nextPluginConfig = asNullableRecord(nextEntry.config) ?? {}; nextEntry.config = nextPluginConfig; - const nextRealtime = asRecord(nextPluginConfig.realtime) ?? {}; + const nextRealtime = asNullableRecord(nextPluginConfig.realtime) ?? {}; nextPluginConfig.realtime = nextRealtime; nextRealtime.provider = "openai"; diff --git a/extensions/google/cli-backend-auth.runtime.ts b/extensions/google/cli-backend-auth.runtime.ts index 625cb4bef3b8..922b1cdd0787 100644 --- a/extensions/google/cli-backend-auth.runtime.ts +++ b/extensions/google/cli-backend-auth.runtime.ts @@ -6,7 +6,7 @@ import { type CliBackendPreparedExecution, type CliBackendToolAvailability, } from "openclaw/plugin-sdk/cli-backend"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { assertGeminiCliLiteralIsolatedPrompt, @@ -95,11 +95,6 @@ type GeminiCliPreparedExecution = CliBackendPreparedExecution & { isolatedCompletionEnforced?: true; }; -function normalizeString(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - function throwUnsupportedGeminiCredential(credential: GeminiAuthProfileCredential): never { // Route compatibility is not credential-health evidence. Keep this local so // a profile that is valid for its owner is not quarantined across providers. @@ -115,7 +110,7 @@ function throwUnstageableSelectedGeminiProfile( ctx: GeminiCliAuthHomeContext, credential: GeminiAuthProfileCredential | undefined, ): never { - const authProfileId = normalizeString(ctx.authProfileId); + const authProfileId = normalizeOptionalString(ctx.authProfileId); if (!authProfileId) { throw new Error("Gemini CLI execution requires a selected auth profile."); } @@ -147,8 +142,8 @@ function requireGeminiOAuthCredential( throwUnsupportedGeminiCredential(credential); } - const access = normalizeString(credential.access); - const refresh = normalizeString(credential.refresh); + const access = normalizeOptionalString(credential.access); + const refresh = normalizeOptionalString(credential.refresh); if ( !access || !refresh || @@ -167,8 +162,8 @@ function requireGeminiOAuthCredential( access, refresh, expires: credential.expires, - idToken: normalizeString(credential.idToken), - projectId: normalizeString(credential.projectId), + idToken: normalizeOptionalString(credential.idToken), + projectId: normalizeOptionalString(credential.projectId), }; } @@ -188,7 +183,7 @@ function requireGeminiApiKeyCredential( throwUnsupportedGeminiCredential(credential); } - const key = normalizeString(credential.key); + const key = normalizeOptionalString(credential.key); if (!key) { throw new CliBackendAuthProfilePreparationError( "Gemini CLI API-key profile is missing usable key material.", @@ -207,11 +202,11 @@ function resolveGeminiCliProfileHome(ctx: GeminiCliAuthHomeContext): { home: string; geminiDir: string; } { - const agentDir = normalizeString(ctx.agentDir); + const agentDir = normalizeOptionalString(ctx.agentDir); if (!agentDir) { throw new Error("Gemini CLI auth profile execution requires an agent directory."); } - const authProfileId = normalizeString(ctx.authProfileId); + const authProfileId = normalizeOptionalString(ctx.authProfileId); if (!authProfileId) { throw new Error("Gemini CLI auth profile execution requires a selected auth profile."); } @@ -260,7 +255,7 @@ async function buildGeminiCliSystemSettings( if (selectedType) { const security = isRecord(settings.security) ? { ...settings.security } : {}; const auth = isRecord(security.auth) ? { ...security.auth } : {}; - const enforcedType = normalizeString( + const enforcedType = normalizeOptionalString( typeof auth.enforcedType === "string" ? auth.enforcedType : undefined, ); if (enforcedType && enforcedType !== selectedType) { @@ -284,7 +279,7 @@ function applyGeminiCliIsolatedCompletionSettings( if (ctx.isolatedCompletionSystemPrompt === undefined) { return base; } - const modelId = normalizeString(ctx.isolatedCompletionModelId); + const modelId = normalizeOptionalString(ctx.isolatedCompletionModelId); if (!modelId || modelId === "auto" || modelId.startsWith("auto-")) { throw isolatedCompletionInputError( "Gemini isolated completion requires one concrete model id.", @@ -463,7 +458,7 @@ async function writeGeminiCliPrivateFile(filePath: string, value: string): Promi } async function stageGeminiCliIsolatedCwd(ctx: GeminiCliAuthHomeContext): Promise { - const cwd = normalizeString(ctx.isolatedCompletionCwd); + const cwd = normalizeOptionalString(ctx.isolatedCompletionCwd); if (!cwd) { return; } @@ -531,7 +526,7 @@ async function clearGeminiCliCachedCredentials(geminiDir: string): Promise } function buildGeminiCliProjectEnv(projectId: string | undefined): Record { - const normalized = normalizeString(projectId); + const normalized = normalizeOptionalString(projectId); if (!normalized) { return {}; } @@ -559,7 +554,7 @@ async function prepareGeminiCliOAuthHome( } const profileHome = await prepareGeminiCliProfileHome(ctx, "oauth-personal"); - const idToken = normalizeString(oauth.idToken); + const idToken = normalizeOptionalString(oauth.idToken); const oauthCreds: Record = { access_token: oauth.access, refresh_token: oauth.refresh, @@ -710,7 +705,7 @@ export async function prepareGeminiCliExecution( if (prepared) { return ctx.toolAvailability ? { ...prepared, toolAvailabilityEnforced: true } : prepared; } - if (normalizeString(ctx.authProfileId)) { + if (normalizeOptionalString(ctx.authProfileId)) { throwUnstageableSelectedGeminiProfile(ctx, authCredential); } return ctx.toolAvailability ? await prepareGeminiCliRestrictedSystemSettings(ctx) : null; diff --git a/extensions/google/cli-backend-isolated-auth.runtime.ts b/extensions/google/cli-backend-isolated-auth.runtime.ts index fda4bd21a620..d0082318370d 100644 --- a/extensions/google/cli-backend-isolated-auth.runtime.ts +++ b/extensions/google/cli-backend-isolated-auth.runtime.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { parse as parseDotEnv } from "dotenv"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; const GEMINI_CLI_AMBIENT_AUTH_ENV = new Set([ "GEMINI_API_KEY", @@ -65,11 +65,6 @@ type GeminiCliAmbientEnv = { telemetryEnabled?: boolean; }; -function normalizeString(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - // Gemini CLI 0.39.1 runs this commandUtils grammar before pasted-text unescape; // any immediate backslash suppresses inclusion. Parity logic would reject // prompts the pinned CLI keeps literal. @@ -127,7 +122,7 @@ export function assertGeminiCliLiteralIsolatedPrompt(ctx: GeminiCliRestrictedAut export async function readGeminiCliJsonObject( filePath: string | undefined, ): Promise> { - const normalized = normalizeString(filePath); + const normalized = normalizeOptionalString(filePath); if (!normalized) { return {}; } @@ -172,8 +167,8 @@ function projectGeminiCliSafeSettings(settings: Record): Record function resolveGeminiCliAmbientHome(ctx: GeminiCliRestrictedAuthContext): string { return ( - normalizeString(ctx.baseEnv?.GEMINI_CLI_HOME) ?? - normalizeString(process.env.GEMINI_CLI_HOME) ?? + normalizeOptionalString(ctx.baseEnv?.GEMINI_CLI_HOME) ?? + normalizeOptionalString(process.env.GEMINI_CLI_HOME) ?? os.homedir() ); } @@ -185,9 +180,9 @@ function projectGeminiCliTrustedTransportEnv( return Object.fromEntries( [...GEMINI_CLI_TRUSTED_TRANSPORT_ENV].map((name) => [ name, - normalizeString(ctx.baseEnv?.[name]) ?? - normalizeString(process.env[name]) ?? - normalizeString(ambientEnv.transport[name]) ?? + normalizeOptionalString(ctx.baseEnv?.[name]) ?? + normalizeOptionalString(process.env[name]) ?? + normalizeOptionalString(ambientEnv.transport[name]) ?? "", ]), ); @@ -260,20 +255,20 @@ export async function resolveGeminiCliAmbientAuth( systemSecurity && isRecord(systemSecurity.auth) ? systemSecurity.auth : undefined; const ambientEnv = await loadGeminiCliAmbientEnv(ctx); const preparedSelectorOwnsAuth = [...GEMINI_CLI_AUTH_SELECTOR_ENV].some((name) => { - const value = normalizeString(ctx.baseEnv?.[name]); + const value = normalizeOptionalString(ctx.baseEnv?.[name]); return value !== undefined && value !== "false" && value !== "0"; }); - const systemSelectedType = normalizeString( + const systemSelectedType = normalizeOptionalString( typeof systemAuth?.selectedType === "string" ? systemAuth.selectedType : undefined, ); - const userSelectedType = normalizeString( + const userSelectedType = normalizeOptionalString( typeof userAuth?.selectedType === "string" ? userAuth.selectedType : undefined, ); // A request-prepared selector is the credential owner for this turn. It may // override ambient user preference, but never system-enforced selection. const selectedType = systemSelectedType ?? (preparedSelectorOwnsAuth ? undefined : userSelectedType); - const enforcedType = normalizeString( + const enforcedType = normalizeOptionalString( typeof systemAuth?.enforcedType === "string" ? systemAuth.enforcedType : undefined, ); if (enforcedType && enforcedType !== "gemini-api-key" && enforcedType !== "vertex-ai") { @@ -283,7 +278,7 @@ export async function resolveGeminiCliAmbientAuth( ); } const envValue = (name: string): string | undefined => { - const prepared = normalizeString(ctx.baseEnv?.[name]); + const prepared = normalizeOptionalString(ctx.baseEnv?.[name]); if (prepared !== undefined) { return prepared; } @@ -291,9 +286,9 @@ export async function resolveGeminiCliAmbientAuth( return undefined; } return ( - normalizeString(process.env[name]) ?? - normalizeString(ambientEnv.auth[name]) ?? - normalizeString(ambientEnv.unsafeAuth[name]) + normalizeOptionalString(process.env[name]) ?? + normalizeOptionalString(ambientEnv.auth[name]) ?? + normalizeOptionalString(ambientEnv.unsafeAuth[name]) ); }; // Gemini CLI selects auth from selectedType or its auth env, then compares @@ -333,9 +328,11 @@ export async function resolveGeminiCliAmbientAuth( envOverrides[name] = value; } } - const applicationCredentials = normalizeString(envOverrides.GOOGLE_APPLICATION_CREDENTIALS); + const applicationCredentials = normalizeOptionalString( + envOverrides.GOOGLE_APPLICATION_CREDENTIALS, + ); if (applicationCredentials && !path.isAbsolute(applicationCredentials)) { - const workspaceDir = normalizeString(ctx.workspaceDir); + const workspaceDir = normalizeOptionalString(ctx.workspaceDir); if (!workspaceDir) { throw new Error( "Gemini exact tool availability cannot resolve relative GOOGLE_APPLICATION_CREDENTIALS without a workspace.", diff --git a/extensions/google/embedding-provider.ts b/extensions/google/embedding-provider.ts index f8d54dd2fc02..115323fcb791 100644 --- a/extensions/google/embedding-provider.ts +++ b/extensions/google/embedding-provider.ts @@ -22,7 +22,7 @@ import { } from "openclaw/plugin-sdk/provider-http"; import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { parseGeminiAuth } from "./gemini-auth.js"; @@ -88,7 +88,7 @@ function readGeminiEmbeddingValues(value: unknown): number[] { } function readGeminiSingleEmbedding(payload: Record): number[] { - const embedding = asRecord(payload.embedding); + const embedding = asOptionalRecord(payload.embedding); if (!embedding) { throw malformedGeminiEmbeddingResponse(); } @@ -103,7 +103,7 @@ function readGeminiBatchEmbeddings( throw malformedGeminiEmbeddingResponse(); } return payload.embeddings.map((entry) => { - const embedding = asRecord(entry); + const embedding = asOptionalRecord(entry); if (!embedding) { throw malformedGeminiEmbeddingResponse(); } diff --git a/extensions/google/provider-catalog.ts b/extensions/google/provider-catalog.ts index 90b41fcdb3d7..6ae43afeec40 100644 --- a/extensions/google/provider-catalog.ts +++ b/extensions/google/provider-catalog.ts @@ -7,6 +7,10 @@ import type { ModelDefinitionConfig, ModelProviderConfig, } from "openclaw/plugin-sdk/provider-model-shared"; +import { + asPositiveSafeInteger, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { isGoogleTextGenerationModelId, resolveGoogleStaticModelId } from "./provider-models.js"; const GOOGLE_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; @@ -67,16 +71,6 @@ function readGoogleLiveModels(body: unknown): readonly unknown[] { return Array.isArray(models) ? models : []; } -function readString(row: Record, key: string): string | undefined { - const value = row[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function readPositiveInteger(row: Record, key: string): number | undefined { - const value = row[key]; - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; -} - function googleLiveModelInput(id: string): ModelDefinitionConfig["input"] { if (!id.startsWith("gemma-")) { return ["text", "image"]; @@ -93,11 +87,11 @@ function buildGoogleLiveModel(row: unknown): ModelDefinitionConfig | undefined { return undefined; } const record = row as Record; - const resourceName = readString(record, "name"); + const resourceName = normalizeOptionalString(record.name); const id = resourceName?.startsWith("models/") ? resourceName.slice("models/".length) : undefined; const methods = record.supportedGenerationMethods; - const contextWindow = readPositiveInteger(record, "inputTokenLimit"); - const maxTokens = readPositiveInteger(record, "outputTokenLimit"); + const contextWindow = asPositiveSafeInteger(record.inputTokenLimit); + const maxTokens = asPositiveSafeInteger(record.outputTokenLimit); if ( !id || !isGoogleTextGenerationModelId(id) || @@ -115,7 +109,7 @@ function buildGoogleLiveModel(row: unknown): ModelDefinitionConfig | undefined { const staticModel = staticId ? GOOGLE_GEMINI_TEXT_MODEL_BY_ID.get(staticId) : undefined; return { id, - name: readString(record, "displayName") ?? id, + name: normalizeOptionalString(record.displayName) ?? id, reasoning: record.thinking === true, // models.list omits modalities. Gemma has both text-only small variants and // multimodal families, so keep this capability distinction explicit. diff --git a/extensions/google/speech-provider.ts b/extensions/google/speech-provider.ts index d53e04d50e7c..482444945fb6 100644 --- a/extensions/google/speech-provider.ts +++ b/extensions/google/speech-provider.ts @@ -15,8 +15,11 @@ import type { SpeechProviderPlugin, SpeechSynthesisRequest, } from "openclaw/plugin-sdk/speech-core"; -import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech-core"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { trimToUndefined } from "openclaw/plugin-sdk/speech-core"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveGoogleGenerativeAiHttpRequestConfig } from "./api.js"; import { canonicalizeGoogleProviderBase64 } from "./base64.js"; @@ -193,8 +196,8 @@ function resolveGoogleTtsBaseUrl(params: { function resolveGoogleTtsConfigRecord( rawConfig: Record, ): Record | undefined { - const providers = asObject(rawConfig.providers); - return asObject(providers?.google) ?? asObject(rawConfig.google); + const providers = asOptionalRecord(rawConfig.providers); + return asOptionalRecord(providers?.google) ?? asOptionalRecord(rawConfig.google); } function normalizeGoogleTtsProviderConfig( diff --git a/extensions/gradium/speech-provider.ts b/extensions/gradium/speech-provider.ts index 381edc65bbac..76aa3af52709 100644 --- a/extensions/gradium/speech-provider.ts +++ b/extensions/gradium/speech-provider.ts @@ -6,8 +6,9 @@ import type { SpeechProviderConfig, SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech"; -import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech"; +import { trimToUndefined } from "openclaw/plugin-sdk/speech"; import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_GRADIUM_VOICE_ID, GRADIUM_VOICES, normalizeGradiumBaseUrl } from "./shared.js"; import { gradiumTTS } from "./tts.js"; @@ -18,8 +19,8 @@ type GradiumProviderConfig = { }; function normalizeGradiumProviderConfig(rawConfig: Record): GradiumProviderConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.gradium) ?? asObject(rawConfig.gradium); + const providers = asOptionalRecord(rawConfig.providers); + const raw = asOptionalRecord(providers?.gradium) ?? asOptionalRecord(rawConfig.gradium); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, diff --git a/extensions/inworld/speech-provider.ts b/extensions/inworld/speech-provider.ts index 0ed8a756a653..9886aa962d04 100644 --- a/extensions/inworld/speech-provider.ts +++ b/extensions/inworld/speech-provider.ts @@ -7,12 +7,11 @@ import type { SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; import { - asObject, parseSpeechDirectiveNumberOverride, resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; -import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumberInRange, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_INWORLD_MODEL_ID, DEFAULT_INWORLD_VOICE_ID, @@ -42,8 +41,8 @@ function normalizeInworldTemperature(value: unknown): number | undefined { } function normalizeInworldProviderConfig(rawConfig: Record): InworldProviderConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.inworld) ?? asObject(rawConfig.inworld); + const providers = asOptionalRecord(rawConfig.providers); + const raw = asOptionalRecord(providers?.inworld) ?? asOptionalRecord(rawConfig.inworld); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, diff --git a/extensions/irc/src/client.ts b/extensions/irc/src/client.ts index 871ca03000fa..7104d502ac22 100644 --- a/extensions/irc/src/client.ts +++ b/extensions/irc/src/client.ts @@ -92,7 +92,7 @@ export type IrcClient = { close: () => void; }; -function toError(err: unknown): Error { +function toIrcError(err: unknown): Error { if (err instanceof Error) { return err; } @@ -175,7 +175,7 @@ export async function connectIrcClient(options: IrcClientOptions): Promise { - const error = toError(err); + const error = toIrcError(err); if (options.onError) { options.onError(error); } diff --git a/extensions/logbook/src/config.ts b/extensions/logbook/src/config.ts index a98a28aefac1..886b798dcd4f 100644 --- a/extensions/logbook/src/config.ts +++ b/extensions/logbook/src/config.ts @@ -1,4 +1,5 @@ // Logbook plugin config resolution: clamps operator input into safe runtime bounds. +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; export type LogbookConfig = { captureEnabled: boolean; @@ -25,14 +26,6 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number) return Math.min(max, Math.max(min, Math.round(num))); } -function optionalString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - export function resolveLogbookConfig(raw: unknown): LogbookConfig { const value = (raw && typeof raw === "object" ? raw : {}) as Record; return { @@ -49,10 +42,10 @@ export function resolveLogbookConfig(raw: unknown): LogbookConfig { 3, 120, ), - nodeId: optionalString(value.nodeId), + nodeId: normalizeOptionalString(value.nodeId), screenIndex: clampNumber(value.screenIndex, DEFAULTS.screenIndex, 0, 16), maxWidth: clampNumber(value.maxWidth, DEFAULTS.maxWidth, 480, 3840), - visionModel: optionalString(value.visionModel), + visionModel: normalizeOptionalString(value.visionModel), retentionDays: clampNumber(value.retentionDays, DEFAULTS.retentionDays, 1, 365), }; } diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index 101615c4aa86..7724b3cc3f36 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -13,6 +13,7 @@ import { sendPayloadMediaSequence, } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; import type { MatrixExtraContentFields } from "./matrix/send/types.js"; import { @@ -29,15 +30,9 @@ type MatrixChannelData = { extraContent?: MatrixExtraContentFields; }; -function toRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function resolveMatrixChannelData(payload: ReplyPayload): MatrixChannelData { - const raw = toRecord(payload.channelData)?.matrix; - return (toRecord(raw) as MatrixChannelData | undefined) ?? {}; + const raw = asOptionalRecord(payload.channelData)?.matrix; + return (asOptionalRecord(raw) as MatrixChannelData | undefined) ?? {}; } function buildMatrixPresentationContent(presentation: MessagePresentation) { @@ -51,8 +46,8 @@ function buildMatrixPresentationContent(presentation: MessagePresentation) { function resolveMatrixPresentationContent( payload: ReplyPayload, ): Record | undefined { - const extraContent = toRecord(resolveMatrixChannelData(payload).extraContent); - const presentation = toRecord(extraContent?.[MATRIX_OPENCLAW_PRESENTATION_KEY]); + const extraContent = asOptionalRecord(resolveMatrixChannelData(payload).extraContent); + const presentation = asOptionalRecord(extraContent?.[MATRIX_OPENCLAW_PRESENTATION_KEY]); if ( !presentation || presentation.version !== 1 || diff --git a/extensions/memory-core/src/cli-runtime-common.ts b/extensions/memory-core/src/cli-runtime-common.ts index 2b3b46ac7b57..f6448475bfef 100644 --- a/extensions/memory-core/src/cli-runtime-common.ts +++ b/extensions/memory-core/src/cli-runtime-common.ts @@ -8,6 +8,7 @@ import { type MemoryExtraPath, } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { defaultRuntime, formatErrorMessage, @@ -23,7 +24,6 @@ import { type OpenClawConfig, withManager, } from "./cli.host.runtime.js"; -import { asRecord } from "./dreaming-shared.js"; import type { MemoryCoreAcquireLocalService } from "./memory/embedding-local-service.js"; import type { ShortTermAuditSummary } from "./short-term-promotion.js"; const { warn } = theme; @@ -107,8 +107,8 @@ function emitMemorySecretResolveDiagnostics( } } export function resolveMemoryPluginConfig(cfg: OpenClawConfig): Record { - const entry = asRecord(cfg.plugins?.entries?.["memory-core"]); - return asRecord(entry?.config) ?? {}; + const entry = asNullableRecord(cfg.plugins?.entries?.["memory-core"]); + return asNullableRecord(entry?.config) ?? {}; } export function formatAuditCounts(audit: ShortTermAuditSummary): string { const scriptCoverage = audit.conceptTagScripts diff --git a/extensions/memory-core/src/cli-status.runtime.ts b/extensions/memory-core/src/cli-status.runtime.ts index 6cbf101f6b58..1e8dcb70991d 100644 --- a/extensions/memory-core/src/cli-status.runtime.ts +++ b/extensions/memory-core/src/cli-status.runtime.ts @@ -3,6 +3,7 @@ import { resolveMemoryLightDreamingConfig, resolveMemoryRemDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatAuditCounts, formatExtraPaths, @@ -30,7 +31,6 @@ import { type DreamingArtifactsAuditSummary, type RepairDreamingArtifactsResult, } from "./dreaming-repair.js"; -import { asRecord } from "./dreaming-shared.js"; import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js"; import type { MemoryCoreRuntimeHost } from "./memory/runtime-host.js"; import { @@ -65,7 +65,7 @@ type LlamaCppRuntimeStatus = { function readLlamaCppRuntimeStatus( status: ReturnType, ): LlamaCppRuntimeStatus | null { - const runtime = asRecord(asRecord(status.custom)?.llamaCppRuntime); + const runtime = asNullableRecord(asNullableRecord(status.custom)?.llamaCppRuntime); return runtime?.engine === "llama.cpp" ? (runtime as LlamaCppRuntimeStatus) : null; } function formatMemoryIndexIdentityWarning( @@ -75,7 +75,7 @@ function formatMemoryIndexIdentityWarning( reason: string; fix: string; } | null { - const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity); + const indexIdentity = asNullableRecord(asNullableRecord(status.custom)?.indexIdentity); const reason = (indexIdentity?.status === "mismatched" || indexIdentity?.status === "missing") && typeof indexIdentity.reason === "string" diff --git a/extensions/memory-core/src/dreaming-command.test.ts b/extensions/memory-core/src/dreaming-command.test.ts index ac3f0cc6c864..8102e8a356bd 100644 --- a/extensions/memory-core/src/dreaming-command.test.ts +++ b/extensions/memory-core/src/dreaming-command.test.ts @@ -2,20 +2,14 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { PluginCommandContext } from "openclaw/plugin-sdk/core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { describe, expect, it, vi } from "vitest"; import { handleDreamingCommand } from "./dreaming-command.js"; -function asRecord(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - return value as Record; -} - function resolveStoredDreaming(config: OpenClawConfig): Record { - const entry = asRecord(config.plugins?.entries?.["memory-core"]); - const pluginConfig = asRecord(entry?.config); - return asRecord(pluginConfig?.dreaming) ?? {}; + const entry = asNullableRecord(config.plugins?.entries?.["memory-core"]); + const pluginConfig = asNullableRecord(entry?.config); + return asNullableRecord(pluginConfig?.dreaming) ?? {}; } function createHarness(initialConfig: OpenClawConfig = {}) { diff --git a/extensions/memory-core/src/dreaming-command.ts b/extensions/memory-core/src/dreaming-command.ts index 83c885d7c6af..041b33191d43 100644 --- a/extensions/memory-core/src/dreaming-command.ts +++ b/extensions/memory-core/src/dreaming-command.ts @@ -2,20 +2,22 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveMemoryDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status"; import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { asRecord } from "./dreaming-shared.js"; +import { + asNullableRecord, + normalizeLowercaseStringOrEmpty, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js"; function resolveDreamingPluginConfig(cfg: OpenClawConfig): Record { - const entry = asRecord(cfg.plugins?.entries?.["memory-core"]); - return asRecord(entry?.config) ?? {}; + const entry = asNullableRecord(cfg.plugins?.entries?.["memory-core"]); + return asNullableRecord(entry?.config) ?? {}; } function updateDreamingEnabledInConfig(cfg: OpenClawConfig, enabled: boolean): OpenClawConfig { const entries = { ...cfg.plugins?.entries }; - const existingEntry = asRecord(entries["memory-core"]) ?? {}; - const existingConfig = asRecord(existingEntry.config) ?? {}; - const existingSleep = asRecord(existingConfig.dreaming) ?? {}; + const existingEntry = asNullableRecord(entries["memory-core"]) ?? {}; + const existingConfig = asNullableRecord(existingEntry.config) ?? {}; + const existingSleep = asNullableRecord(existingConfig.dreaming) ?? {}; entries["memory-core"] = { ...existingEntry, config: { diff --git a/extensions/memory-core/src/dreaming-ingestion-state.ts b/extensions/memory-core/src/dreaming-ingestion-state.ts index 9d188c9f7e76..0d2ad9dcce12 100644 --- a/extensions/memory-core/src/dreaming-ingestion-state.ts +++ b/extensions/memory-core/src/dreaming-ingestion-state.ts @@ -1,6 +1,8 @@ // Memory Core codecs normalize canonical and legacy dreaming ingestion state. -import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { asRecord } from "./dreaming-shared.js"; +import { + asNullableRecord, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; const MEMORY_DAY_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -40,14 +42,14 @@ export function normalizeMemoryDay(value: unknown): string | undefined { } export function normalizeDailyIngestionState(raw: unknown): DailyIngestionState { - const record = asRecord(raw); - const filesRaw = asRecord(record?.files); + const record = asNullableRecord(raw); + const filesRaw = asNullableRecord(record?.files); if (!filesRaw) { return { version: 1, files: {} }; } const files: Record = {}; for (const [key, value] of Object.entries(filesRaw)) { - const file = asRecord(value); + const file = asNullableRecord(value); if (!file || typeof key !== "string" || key.trim().length === 0) { continue; } @@ -67,12 +69,12 @@ export function normalizeDailyIngestionState(raw: unknown): DailyIngestionState } export function normalizeSessionIngestionState(raw: unknown): SessionIngestionState { - const record = asRecord(raw); - const filesRaw = asRecord(record?.files); + const record = asNullableRecord(raw); + const filesRaw = asNullableRecord(record?.files); const files: Record = {}; if (filesRaw) { for (const [key, value] of Object.entries(filesRaw)) { - const file = asRecord(value); + const file = asNullableRecord(value); if (!file || key.trim().length === 0) { continue; } @@ -98,7 +100,7 @@ export function normalizeSessionIngestionState(raw: unknown): SessionIngestionSt }; } } - const seenMessagesRaw = asRecord(record?.seenMessages); + const seenMessagesRaw = asNullableRecord(record?.seenMessages); const seenMessages: Record = {}; if (seenMessagesRaw) { for (const [scope, value] of Object.entries(seenMessagesRaw)) { diff --git a/extensions/memory-core/src/dreaming-shared.ts b/extensions/memory-core/src/dreaming-shared.ts index 0d9af9a90a2c..6aec427dd918 100644 --- a/extensions/memory-core/src/dreaming-shared.ts +++ b/extensions/memory-core/src/dreaming-shared.ts @@ -4,7 +4,6 @@ import { normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; -export { asNullableRecord as asRecord, normalizeOptionalString as normalizeTrimmedString }; export { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; export function extractAssistantText(messages: unknown[]): string | null { diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index 798cbdbe8666..d24826780347 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -20,16 +20,13 @@ import { import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeLowercaseStringOrEmpty, + normalizeOptionalString, uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { peekSystemEventEntries } from "openclaw/plugin-sdk/system-event-runtime"; import { appendFailedDreamingEvent } from "./dreaming-events.js"; import type { NarrativePhaseData } from "./dreaming-narrative.js"; -import { - formatErrorMessage, - includesSystemEventToken, - normalizeTrimmedString, -} from "./dreaming-shared.js"; +import { formatErrorMessage, includesSystemEventToken } from "./dreaming-shared.js"; const RUNTIME_CRON_RECONCILE_INTERVAL_MS = 60_000; const STARTUP_CRON_RETRY_DELAY_MS = 5_000; @@ -201,25 +198,25 @@ function buildManagedDreamingCronJob( function resolveManagedDreamingPayloadToken( payload: ManagedCronJobLike["payload"], ): string | undefined { - const payloadKind = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(payload?.kind)); + const payloadKind = normalizeLowercaseStringOrEmpty(normalizeOptionalString(payload?.kind)); if (payloadKind === "systemevent") { - return normalizeTrimmedString(payload?.text); + return normalizeOptionalString(payload?.text); } if (payloadKind === "agentturn") { - return normalizeTrimmedString(payload?.message); + return normalizeOptionalString(payload?.message); } return undefined; } function isManagedDreamingJob(job: ManagedCronJobLike): boolean { - if (normalizeTrimmedString(job.declarationKey) === MANAGED_DREAMING_DECLARATION_KEY) { + if (normalizeOptionalString(job.declarationKey) === MANAGED_DREAMING_DECLARATION_KEY) { return true; } - const name = normalizeTrimmedString(job.name); + const name = normalizeOptionalString(job.name); if (name !== MANAGED_DREAMING_CRON_NAME) { return false; } - const description = normalizeTrimmedString(job.description); + const description = normalizeOptionalString(job.description); if (description?.includes(MANAGED_DREAMING_CRON_TAG)) { return true; } @@ -227,15 +224,15 @@ function isManagedDreamingJob(job: ManagedCronJobLike): boolean { } function isLegacyPhaseDreamingJob(job: ManagedCronJobLike): boolean { - const description = normalizeTrimmedString(job.description); + const description = normalizeOptionalString(job.description); if ( description?.includes(LEGACY_LIGHT_SLEEP_CRON_TAG) || description?.includes(LEGACY_REM_SLEEP_CRON_TAG) ) { return true; } - const name = normalizeTrimmedString(job.name); - const payloadText = normalizeTrimmedString(job.payload?.text); + const name = normalizeOptionalString(job.name); + const payloadText = normalizeOptionalString(job.payload?.text); if (name === LEGACY_LIGHT_SLEEP_CRON_NAME && payloadText === LEGACY_LIGHT_SLEEP_EVENT_TEXT) { return true; } @@ -297,19 +294,19 @@ function buildManagedDreamingPatch( ): ManagedCronJobPatch | null { const patch: ManagedCronJobPatch = {}; - if (!compareOptionalStrings(normalizeTrimmedString(job.name), desired.name)) { + if (!compareOptionalStrings(normalizeOptionalString(job.name), desired.name)) { patch.name = desired.name; } - if (!compareOptionalStrings(normalizeTrimmedString(job.description), desired.description)) { + if (!compareOptionalStrings(normalizeOptionalString(job.description), desired.description)) { patch.description = desired.description; } if (job.enabled !== true) { patch.enabled = true; } - const scheduleKind = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.schedule?.kind)); - const scheduleExpr = normalizeTrimmedString(job.schedule?.expr); - const scheduleTz = normalizeTrimmedString(job.schedule?.tz); + const scheduleKind = normalizeLowercaseStringOrEmpty(normalizeOptionalString(job.schedule?.kind)); + const scheduleExpr = normalizeOptionalString(job.schedule?.expr); + const scheduleTz = normalizeOptionalString(job.schedule?.tz); if ( scheduleKind !== "cron" || !compareOptionalStrings(scheduleExpr, desired.schedule.expr) || @@ -318,16 +315,16 @@ function buildManagedDreamingPatch( patch.schedule = desired.schedule; } - const sessionTarget = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.sessionTarget)); + const sessionTarget = normalizeLowercaseStringOrEmpty(normalizeOptionalString(job.sessionTarget)); if (sessionTarget !== desired.sessionTarget) { patch.sessionTarget = desired.sessionTarget; } - const wakeMode = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.wakeMode)); + const wakeMode = normalizeLowercaseStringOrEmpty(normalizeOptionalString(job.wakeMode)); if (wakeMode !== "now") { patch.wakeMode = "now"; } - const payloadKind = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.payload?.kind)); + const payloadKind = normalizeLowercaseStringOrEmpty(normalizeOptionalString(job.payload?.kind)); const payloadToken = resolveManagedDreamingPayloadToken(job.payload); const desiredPayloadToken = desired.payload.kind === "systemEvent" ? desired.payload.text : desired.payload.message; @@ -339,7 +336,7 @@ function buildManagedDreamingPatch( if (payloadNeedsUpdate) { patch.payload = desired.payload; } - const deliveryMode = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.delivery?.mode)); + const deliveryMode = normalizeLowercaseStringOrEmpty(normalizeOptionalString(job.delivery?.mode)); if (deliveryMode !== "none") { patch.delivery = desired.delivery; } @@ -386,7 +383,7 @@ function resolveCronServiceFromGatewayContext(context: { getCron?: () => unknown } function resolveDreamingTriggerSessionKeys(sessionKey?: string): string[] { - const normalized = normalizeTrimmedString(sessionKey); + const normalized = normalizeOptionalString(sessionKey); if (!normalized) { return []; } @@ -409,7 +406,7 @@ function hasPendingManagedDreamingCronEvent(sessionKey?: string): boolean { peekSystemEventEntries(candidateSessionKey).some( (event) => event.contextKey?.startsWith("cron:") === true && - normalizeTrimmedString(event.text) === DREAMING_SYSTEM_EVENT_TEXT, + normalizeOptionalString(event.text) === DREAMING_SYSTEM_EVENT_TEXT, ), ); } @@ -573,7 +570,7 @@ async function runShortTermDreamingPromotionIfTriggered(params: { const recencyHalfLifeDays = params.config.recencyHalfLifeDays ?? DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS; - const fallbackWorkspaceDir = normalizeTrimmedString(params.workspaceDir); + const fallbackWorkspaceDir = normalizeOptionalString(params.workspaceDir); // Narrative subagent sessions live in per-agent SQLite stores, so every swept workspace // carries its owning agent. The triggering agent owns whatever the roster cannot attribute. const triggerAgentId = normalizeLowercaseStringOrEmpty(params.agentId); diff --git a/extensions/memory-core/src/migration/doctor-memory-sidecar.ts b/extensions/memory-core/src/migration/doctor-memory-sidecar.ts index 1059c18d6953..e50a2f5fc91d 100644 --- a/extensions/memory-core/src/migration/doctor-memory-sidecar.ts +++ b/extensions/memory-core/src/migration/doctor-memory-sidecar.ts @@ -11,6 +11,7 @@ import { legacyStateFileExists, type PluginDoctorStateMigration, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; +import { asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; // sqlite-runtime re-exports the agent-db/kysely graph; keep it lazy so doctor // enumeration does not cold-load it with this closure. import { @@ -28,10 +29,10 @@ type MemoryFtsTokenizer = "unicode61" | "trigram"; function resolveConfiguredAgentIds(config: unknown): string[] { const cfg = config as { agents?: { entries?: unknown; list?: unknown } }; - const entries = asRecord(cfg.agents?.entries); + const entries = asOptionalObjectRecord(cfg.agents?.entries); const listedIds = Array.isArray(cfg.agents?.list) ? cfg.agents.list.flatMap((entry) => { - const id = asRecord(entry)?.id; + const id = asOptionalObjectRecord(entry)?.id; return typeof id === "string" ? [id] : []; }) : []; @@ -39,46 +40,44 @@ function resolveConfiguredAgentIds(config: unknown): string[] { return ids.size > 0 ? [...ids] : [normalizeAgentId(undefined)]; } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - function readAgentMemorySearch( config: unknown, agentId: string, ): Record | undefined { - const agents = asRecord(asRecord(config)?.agents); - const keyedEntries = asRecord(agents?.entries); + const agents = asOptionalObjectRecord(asOptionalObjectRecord(config)?.agents); + const keyedEntries = asOptionalObjectRecord(agents?.entries); const keyedEntry = keyedEntries ? Object.entries(keyedEntries).find(([id]) => normalizeAgentId(id) === agentId)?.[1] : undefined; - const keyedSearch = asRecord(asRecord(asRecord(keyedEntry)?.memory)?.search); + const keyedSearch = asOptionalObjectRecord( + asOptionalObjectRecord(asOptionalObjectRecord(keyedEntry)?.memory)?.search, + ); if (keyedSearch) { return keyedSearch; } const entries = Array.isArray(agents?.list) ? agents.list : []; const entry = entries - .map(asRecord) + .map(asOptionalObjectRecord) .find( (candidate) => normalizeAgentId(typeof candidate?.id === "string" ? candidate.id : undefined) === agentId, ); - return asRecord(asRecord(entry?.memory)?.search); + return asOptionalObjectRecord(asOptionalObjectRecord(entry?.memory)?.search); } function readMemorySearchLayers(config: unknown, agentId: string): Record[] { - const cfg = asRecord(config); + const cfg = asOptionalObjectRecord(config); return [ readAgentMemorySearch(config, agentId), - asRecord(asRecord(cfg?.memory)?.search), + asOptionalObjectRecord(asOptionalObjectRecord(cfg?.memory)?.search), // Doctor still inspects the retired root shape to migrate its persisted sidecar path. - asRecord(cfg?.memorySearch), + asOptionalObjectRecord(cfg?.memorySearch), ].filter((value): value is Record => value !== undefined); } function readStoreLayers(config: unknown, agentId: string): Record[] { return readMemorySearchLayers(config, agentId).flatMap((search) => { - const store = asRecord(search.store); + const store = asOptionalObjectRecord(search.store); return store ? [store] : []; }); } @@ -93,7 +92,7 @@ function readNestedStoreLayers( key: string, ): Record[] { return readStoreLayers(config, agentId).flatMap((store) => { - const nested = asRecord(store[key]); + const nested = asOptionalObjectRecord(store[key]); return nested ? [nested] : []; }); } diff --git a/extensions/memory-core/src/migration/dreaming-state-comparison.ts b/extensions/memory-core/src/migration/dreaming-state-comparison.ts index 6af64346c981..126190175edf 100644 --- a/extensions/memory-core/src/migration/dreaming-state-comparison.ts +++ b/extensions/memory-core/src/migration/dreaming-state-comparison.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeDailyIngestionState, normalizeSessionIngestionState, @@ -41,12 +42,6 @@ function targetNamespacesForSource(label: string): string[] { ]; } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - async function memoryCoreLegacyTargetHasRows(source: LegacyDreamingSource): Promise { const counts = await Promise.all( targetNamespacesForSource(source.label).map( @@ -137,7 +132,7 @@ async function memoryCoreLegacySourceMatchesCanonical( const canonical = normalizeShortTermRecallStore(canonicalRaw, updatedAt); const fallbackCandidates = new Set([updatedAt]); for (const row of entryRows) { - const value = asRecord(row.value); + const value = asOptionalRecord(row.value); for (const key of ["firstRecalledAt", "lastRecalledAt"] as const) { if (typeof value?.[key] === "string") { fallbackCandidates.add(value[key]); diff --git a/extensions/memory-core/src/session-backfill-gateway.ts b/extensions/memory-core/src/session-backfill-gateway.ts index ab3c645cff9b..abeeee4db906 100644 --- a/extensions/memory-core/src/session-backfill-gateway.ts +++ b/extensions/memory-core/src/session-backfill-gateway.ts @@ -60,7 +60,7 @@ function assertOnlyKeys(params: Record, allowed: ReadonlySet, key: "from" | "to") { +function readOptionalSessionBoundary(params: Record, key: "from" | "to") { const raw = params[key]; if (raw !== undefined && typeof raw !== "string") { throw new Error(`${key} must be a string.`); @@ -74,8 +74,8 @@ function readGatewayParams(value: unknown): SessionBackfillGatewayParams { const agentId = normalizeAgentId(readStringParam(params, "agentId", { required: true })); const selection = normalizeSessionBackfillSelection( { - from: readOptionalString(params, "from"), - to: readOptionalString(params, "to"), + from: readOptionalSessionBoundary(params, "from"), + to: readOptionalSessionBoundary(params, "to"), limitDays: readPositiveIntegerParam(params, "limitDays"), }, { from: "from", to: "to", limitDays: "limitDays" }, diff --git a/extensions/memory-core/src/short-term-promotion-store.ts b/extensions/memory-core/src/short-term-promotion-store.ts index fb65484fac73..7876161e0f1b 100644 --- a/extensions/memory-core/src/short-term-promotion-store.ts +++ b/extensions/memory-core/src/short-term-promotion-store.ts @@ -1,6 +1,6 @@ import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import { sleep } from "openclaw/plugin-sdk/runtime-env"; -import { asRecord } from "./dreaming-shared.js"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { SHORT_TERM_LOCK_MAX_ENTRIES, SHORT_TERM_LOCK_NAMESPACE, @@ -163,17 +163,17 @@ export function normalizeShortTermPhaseSignalStore( raw: unknown, nowIso: string, ): ShortTermPhaseSignalStore { - const record = asRecord(raw); + const record = asNullableRecord(raw); if (!record) { return emptyPhaseSignalStore(nowIso); } - const entriesRaw = asRecord(record?.entries); + const entriesRaw = asNullableRecord(record?.entries); if (!entriesRaw) { return emptyPhaseSignalStore(nowIso); } const entries: Record = {}; for (const [mapKey, value] of Object.entries(entriesRaw)) { - const entry = asRecord(value); + const entry = asNullableRecord(value); if (!entry) { continue; } diff --git a/extensions/memory-core/src/test-helpers.ts b/extensions/memory-core/src/test-helpers.ts index 4d2aef718ac5..27ba97ac8b93 100644 --- a/extensions/memory-core/src/test-helpers.ts +++ b/extensions/memory-core/src/test-helpers.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; import { createPluginStateKeyedStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { afterAll, beforeAll } from "vitest"; import { @@ -53,12 +54,6 @@ type ShortTermLockEntry = { acquiredAt: number; }; -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - async function readShortTermStoreEntries(params: { namespace: string; workspaceDir: string; @@ -88,8 +83,8 @@ async function writeRawShortTermStore(params: { namespace: string; metaKey: "recall" | "phase"; }): Promise { - const record = asRecord(params.raw); - const entries = asRecord(record?.entries); + const record = asOptionalRecord(params.raw); + const entries = asOptionalRecord(record?.entries); await Promise.all([ writeMemoryCoreWorkspaceEntries({ namespace: params.namespace, diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index 61090013474a..8adef63a76fe 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -26,7 +26,7 @@ import { resolveMemoryDeepDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; -import { asRecord } from "./dreaming-shared.js"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { MemoryCoreAcquireLocalService } from "./memory/embedding-local-service.js"; import { DEFAULT_MEMORY_SEARCH_TIMEOUT_MS, @@ -161,7 +161,7 @@ const PAUSED_MEMORY_INDEX_ACTION = "Tell the user to run: openclaw memory status --index or openclaw memory index --force."; function resolvePausedMemoryIndexIdentityReason(status: { custom?: unknown }): string | undefined { - const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity); + const indexIdentity = asNullableRecord(asNullableRecord(status.custom)?.indexIdentity); if (indexIdentity?.status !== "mismatched" && indexIdentity?.status !== "missing") { return undefined; } diff --git a/extensions/memory-lancedb/doctor-contract-api.ts b/extensions/memory-lancedb/doctor-contract-api.ts index fcb28e324bcd..d488f28e9bad 100644 --- a/extensions/memory-lancedb/doctor-contract-api.ts +++ b/extensions/memory-lancedb/doctor-contract-api.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor-migrations"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { hasAgentScopeColumn, memoryAgentPredicate, @@ -82,12 +83,6 @@ export function resolveMemoryLanceDbPluginRoot(moduleUrl: string): string { const DEFAULT_PLUGIN_ROOT = resolveMemoryLanceDbPluginRoot(import.meta.url); -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function resolveHome(env: NodeJS.ProcessEnv): string { return env.HOME?.trim() || os.homedir(); } @@ -97,7 +92,7 @@ function resolveConfiguredDbPath( env: NodeJS.ProcessEnv, pluginRoot: string, ): string { - const pluginConfig = asRecord(config.plugins?.entries?.["memory-lancedb"]?.config); + const pluginConfig = asOptionalRecord(config.plugins?.entries?.["memory-lancedb"]?.config); const configured = typeof pluginConfig?.dbPath === "string" ? pluginConfig.dbPath.trim() : ""; if (!configured) { return path.join(resolveHome(env), ".openclaw", "memory", "lancedb"); @@ -116,8 +111,8 @@ function resolveStorageOptions( config: OpenClawConfig, env: NodeJS.ProcessEnv, ): Record | undefined { - const pluginConfig = asRecord(config.plugins?.entries?.["memory-lancedb"]?.config); - const rawOptions = asRecord(pluginConfig?.storageOptions); + const pluginConfig = asOptionalRecord(config.plugins?.entries?.["memory-lancedb"]?.config); + const rawOptions = asOptionalRecord(pluginConfig?.storageOptions); if (!rawOptions) { return undefined; } diff --git a/extensions/memory-lancedb/embeddings.ts b/extensions/memory-lancedb/embeddings.ts index 94f681ad22d6..79b969b6453e 100644 --- a/extensions/memory-lancedb/embeddings.ts +++ b/extensions/memory-lancedb/embeddings.ts @@ -10,7 +10,7 @@ import type { MemoryEmbeddingProvider } from "openclaw/plugin-sdk/memory-core-ho import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env"; -import { asOptionalRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawPluginApi } from "./api.js"; import type { MemoryConfig } from "./config.js"; @@ -147,7 +147,7 @@ class OpenAiCompatibleEmbeddings implements Embeddings { } function isEmbeddingDimensionsRejectedError(error: unknown): boolean { - const record = asRecord(error); + const record = asOptionalRecord(error); if (record?.status !== 400 && record?.status !== 422) { return false; } @@ -170,7 +170,7 @@ function isUnsupportedEmbeddingFieldError(details: string): boolean { } function stringifyEmbeddingApiError(error: unknown): string { - const record = asRecord(error); + const record = asOptionalRecord(error); const parts = error instanceof Error ? [error.message] : []; for (const value of [record?.code, record?.type, record?.param, record?.error]) { if (typeof value === "string" || typeof value === "number") { @@ -475,7 +475,7 @@ export async function runWithTimeout(params: { export function isMemoryRecallTimeoutError(error: unknown): boolean { let current: unknown = error; for (let depth = 0; depth < 3 && current !== undefined; depth += 1) { - const record = asRecord(current); + const record = asOptionalRecord(current); const name = current instanceof Error ? current.name : typeof record?.name === "string" ? record.name : ""; const message = diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index aa7a8a36c1cc..c57e2d09a0a9 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -12,7 +12,7 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { readFiniteNumberParam, readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers"; import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; import { isIncognitoSessionKey, normalizeAgentId } from "openclaw/plugin-sdk/routing"; -import { asOptionalRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asOptionalRecord } 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"; @@ -154,7 +154,7 @@ export default definePluginEntry({ ...(typeof cfg.embedding.dimensions === "number" ? { dimensions: cfg.embedding.dimensions } : {}), - ...asRecord(runtimePluginConfig.embedding), + ...asOptionalRecord(runtimePluginConfig.embedding), }, ...(cfg.dreaming ? { dreaming: cfg.dreaming } : {}), dbPath: cfg.dbPath, @@ -163,7 +163,7 @@ export default definePluginEntry({ captureMaxChars: cfg.captureMaxChars, recallMaxChars: cfg.recallMaxChars, ...(cfg.storageOptions ? { storageOptions: cfg.storageOptions } : {}), - ...asRecord(runtimePluginConfig), + ...asOptionalRecord(runtimePluginConfig), }); }; const readMemoryRecallCooldown = (agentId: string): { error: string } | undefined => { diff --git a/extensions/memory-lancedb/memory-policy.ts b/extensions/memory-lancedb/memory-policy.ts index 5e300a6fb0db..799fadd4097b 100644 --- a/extensions/memory-lancedb/memory-policy.ts +++ b/extensions/memory-lancedb/memory-policy.ts @@ -1,5 +1,5 @@ import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeLowercaseStringOrEmpty, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -17,7 +17,7 @@ export type AutoCaptureCursor = { }; export function extractUserTextContent(message: unknown): string[] { - const msgObj = asRecord(message); + const msgObj = asOptionalRecord(message); if (!msgObj || msgObj.role !== "user") { return []; } @@ -33,7 +33,7 @@ export function extractUserTextContent(message: unknown): string[] { const texts: string[] = []; for (const block of content) { - const blockObj = asRecord(block); + const blockObj = asOptionalRecord(block); if (blockObj?.type === "text" && typeof blockObj.text === "string") { texts.push(blockObj.text); } @@ -67,7 +67,7 @@ function normalizeMaxChars(value: number | undefined, fallback: number): number } export function messageFingerprint(message: unknown): string { - const msgObj = asRecord(message); + const msgObj = asOptionalRecord(message); if (!msgObj) { return `${typeof message}:${String(message)}`; } diff --git a/extensions/memory-wiki/src/agent-vault-isolation.test.ts b/extensions/memory-wiki/src/agent-vault-isolation.test.ts index 70b325b1e705..19560b658294 100644 --- a/extensions/memory-wiki/src/agent-vault-isolation.test.ts +++ b/extensions/memory-wiki/src/agent-vault-isolation.test.ts @@ -22,7 +22,7 @@ import { createWikiApplyTool, createWikiGetTool, createWikiSearchTool } from "./ const { createTempDir } = createMemoryWikiTestHarness(); -function asRecord(value: unknown): Record { +function assertToolDetailsRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Expected tool details object"); } @@ -132,7 +132,7 @@ describe("agent-scoped memory-wiki tools", () => { sourceIds: [`source.${agent.id}`], }, ); - const pagePath = asRecord(result.details).pagePath; + const pagePath = assertToolDetailsRecord(result.details).pagePath; if (typeof pagePath !== "string") { throw new Error("Expected wiki_apply to return pagePath"); } @@ -164,7 +164,7 @@ describe("agent-scoped memory-wiki tools", () => { query: agent.sentinel, }); expect(textContent(ownSearch)).toContain(agent.sentinel); - expect(asRecord(ownSearch.details).results).toEqual([ + expect(assertToolDetailsRecord(ownSearch.details).results).toEqual([ expect.objectContaining({ path: agent.pagePath }), ]); @@ -172,18 +172,18 @@ describe("agent-scoped memory-wiki tools", () => { query: foreignAgent.sentinel, }); expect(textContent(foreignSearch)).toBe("No wiki or memory results."); - expect(asRecord(foreignSearch.details).results).toEqual([]); + expect(assertToolDetailsRecord(foreignSearch.details).results).toEqual([]); const getTool = createWikiGetTool(agent.config, appConfig, { agentId: agent.id }); const ownGet = await getTool.execute(`get-own-${agent.id}`, { lookup: agent.pagePath }); expect(textContent(ownGet)).toContain(agent.sentinel); - expect(asRecord(ownGet.details).found).toBe(true); + expect(assertToolDetailsRecord(ownGet.details).found).toBe(true); const foreignGet = await getTool.execute(`get-foreign-${agent.id}`, { lookup: foreignAgent.pagePath, }); expect(textContent(foreignGet)).toBe(`Wiki page not found: ${foreignAgent.pagePath}`); - expect(asRecord(foreignGet.details).found).toBe(false); + expect(assertToolDetailsRecord(foreignGet.details).found).toBe(false); } clearMemoryPluginState(); @@ -218,7 +218,7 @@ describe("agent-scoped memory-wiki tools", () => { query: agent.sentinel, corpus: "wiki", }); - expect(asRecord(ownMemorySearch.details).results).toEqual([ + expect(assertToolDetailsRecord(ownMemorySearch.details).results).toEqual([ expect.objectContaining({ corpus: "wiki", path: agent.pagePath, @@ -233,7 +233,7 @@ describe("agent-scoped memory-wiki tools", () => { corpus: "wiki", }, ); - expect(asRecord(foreignMemorySearch.details).results).toEqual([]); + expect(assertToolDetailsRecord(foreignMemorySearch.details).results).toEqual([]); const memoryGet = createMemoryCoreTool({ factories: memoryCoreFactories, @@ -245,7 +245,7 @@ describe("agent-scoped memory-wiki tools", () => { path: agent.pagePath, corpus: "wiki", }); - expect(asRecord(ownMemoryGet.details)).toMatchObject({ + expect(assertToolDetailsRecord(ownMemoryGet.details)).toMatchObject({ corpus: "wiki", path: agent.pagePath, text: expect.stringContaining(agent.sentinel), @@ -255,7 +255,7 @@ describe("agent-scoped memory-wiki tools", () => { path: foreignAgent.pagePath, corpus: "wiki", }); - expect(asRecord(foreignMemoryGet.details)).toMatchObject({ + expect(assertToolDetailsRecord(foreignMemoryGet.details)).toMatchObject({ path: foreignAgent.pagePath, text: "", disabled: true, diff --git a/extensions/memory-wiki/src/config-compat.ts b/extensions/memory-wiki/src/config-compat.ts index 590c5e3d52ef..ae2517a7894c 100644 --- a/extensions/memory-wiki/src/config-compat.ts +++ b/extensions/memory-wiki/src/config-compat.ts @@ -1,4 +1,5 @@ // Memory Wiki helper module supports config compat behavior. +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawConfig } from "../api.js"; type LegacyConfigRule = { @@ -7,14 +8,8 @@ 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 hasLegacyBridgeArtifactToggle(value: unknown): boolean { - return Object.hasOwn(asRecord(value) ?? {}, "readMemoryCore"); + return Object.hasOwn(asNullableRecord(value) ?? {}, "readMemoryCore"); } export const legacyConfigRules: LegacyConfigRule[] = [ @@ -30,23 +25,23 @@ export function migrateMemoryWikiLegacyConfig(config: OpenClawConfig): { config: OpenClawConfig; changes: string[]; } | null { - const rawEntry = asRecord(config.plugins?.entries?.["memory-wiki"]); - const rawPluginConfig = asRecord(rawEntry?.config); - const rawBridge = asRecord(rawPluginConfig?.bridge); + const rawEntry = asNullableRecord(config.plugins?.entries?.["memory-wiki"]); + const rawPluginConfig = asNullableRecord(rawEntry?.config); + const rawBridge = asNullableRecord(rawPluginConfig?.bridge); if (!rawBridge || !hasLegacyBridgeArtifactToggle(rawBridge)) { return null; } const nextConfig = structuredClone(config); - const nextPlugins = asRecord(nextConfig.plugins) ?? {}; + const nextPlugins = asNullableRecord(nextConfig.plugins) ?? {}; nextConfig.plugins = nextPlugins; - const nextEntries = asRecord(nextPlugins.entries) ?? {}; + const nextEntries = asNullableRecord(nextPlugins.entries) ?? {}; nextPlugins.entries = nextEntries; - const nextEntry = asRecord(nextEntries["memory-wiki"]) ?? {}; + const nextEntry = asNullableRecord(nextEntries["memory-wiki"]) ?? {}; nextEntries["memory-wiki"] = nextEntry; - const nextPluginConfig = asRecord(nextEntry.config) ?? {}; + const nextPluginConfig = asNullableRecord(nextEntry.config) ?? {}; nextEntry.config = nextPluginConfig; - const nextBridge = asRecord(nextPluginConfig.bridge) ?? {}; + const nextBridge = asNullableRecord(nextPluginConfig.bridge) ?? {}; nextPluginConfig.bridge = nextBridge; const legacyValue = nextBridge.readMemoryCore; diff --git a/extensions/memory-wiki/src/corpus-supplement.visibility.test.ts b/extensions/memory-wiki/src/corpus-supplement.visibility.test.ts index 0a6903789b97..c8211f3fc2b6 100644 --- a/extensions/memory-wiki/src/corpus-supplement.visibility.test.ts +++ b/extensions/memory-wiki/src/corpus-supplement.visibility.test.ts @@ -49,7 +49,7 @@ async function writeBridgePage(params: { ); } -function asRecord(value: unknown): Record { +function assertToolDetailsRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Expected tool details object"); } @@ -217,29 +217,29 @@ describe("memory-wiki corpus supplement visibility", () => { corpus: "wiki", }); - expect(asRecord(foreignGet.details)).toMatchObject({ + expect(assertToolDetailsRecord(foreignGet.details)).toMatchObject({ text: "", disabled: true, error: "wiki corpus result not found", }); - expect(asRecord(unownedGet.details)).toMatchObject({ + expect(assertToolDetailsRecord(unownedGet.details)).toMatchObject({ text: "", disabled: true, error: "wiki corpus result not found", }); - expect(asRecord(ownGet.details)).toMatchObject({ + expect(assertToolDetailsRecord(ownGet.details)).toMatchObject({ path: "sources/main-private.md", text: expect.stringContaining("REDACTED-OWN-MARKER"), }); - expect(asRecord(foreignSearch.details).results).toEqual([]); - expect(asRecord(ownSearch.details).results).toEqual([ + expect(assertToolDetailsRecord(foreignSearch.details).results).toEqual([]); + expect(assertToolDetailsRecord(ownSearch.details).results).toEqual([ expect.objectContaining({ path: "sources/main-private.md" }), ]); - expect(asRecord(openForeignGet.details)).toMatchObject({ + expect(assertToolDetailsRecord(openForeignGet.details)).toMatchObject({ path: "sources/secondary-private.md", text: expect.stringContaining("REDACTED-FOREIGN-MARKER"), }); - expect(asRecord(openForeignSearch.details).results).toEqual([ + expect(assertToolDetailsRecord(openForeignSearch.details).results).toEqual([ expect.objectContaining({ path: "sources/secondary-private.md" }), ]); } finally { diff --git a/extensions/microsoft/speech-provider.ts b/extensions/microsoft/speech-provider.ts index 508b5d9bbe07..2742f8607a8a 100644 --- a/extensions/microsoft/speech-provider.ts +++ b/extensions/microsoft/speech-provider.ts @@ -20,11 +20,12 @@ import type { SpeechProviderPlugin, SpeechVoiceOption, } from "openclaw/plugin-sdk/speech"; -import { asBoolean, asFiniteNumber, asObject, trimToUndefined } from "openclaw/plugin-sdk/speech"; +import { asBoolean, asFiniteNumber, trimToUndefined } from "openclaw/plugin-sdk/speech"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { edgeTTS, inferEdgeExtension } from "./tts.js"; @@ -50,10 +51,10 @@ type MicrosoftProviderConfig = { function normalizeMicrosoftProviderConfig( rawConfig: Record, ): MicrosoftProviderConfig { - const providers = asObject(rawConfig.providers); - const rawEdge = asObject(rawConfig.edge); - const rawMicrosoft = asObject(rawConfig.microsoft); - const rawProviderMicrosoft = asObject(providers?.microsoft); + const providers = asOptionalRecord(rawConfig.providers); + const rawEdge = asOptionalRecord(rawConfig.edge); + const rawMicrosoft = asOptionalRecord(rawConfig.microsoft); + const rawProviderMicrosoft = asOptionalRecord(providers?.microsoft); const raw = { ...rawEdge, ...rawMicrosoft, ...rawProviderMicrosoft }; const outputFormat = trimToUndefined(raw.outputFormat); return { @@ -166,12 +167,12 @@ async function listMicrosoftVoices( const voices = await readProviderJsonResponse(response, "microsoft.speech-voices"); return Array.isArray(voices) ? voices.flatMap((value) => { - const voice = asObject(value); + const voice = asOptionalRecord(value); const id = trimToUndefined(voice?.ShortName); if (!voice || !id) { return []; } - const voiceTag = asObject(voice.VoiceTag); + const voiceTag = asOptionalRecord(voice.VoiceTag); const categories = readMicrosoftVoiceTagStrings(voiceTag?.ContentCategories); const personalities = readMicrosoftVoiceTagStrings(voiceTag?.VoicePersonalities); return [ diff --git a/extensions/migrate-claude/config.ts b/extensions/migrate-claude/config.ts index 78fbfbb3068e..37b10fd9e677 100644 --- a/extensions/migrate-claude/config.ts +++ b/extensions/migrate-claude/config.ts @@ -8,7 +8,8 @@ import { MIGRATION_REASON_TARGET_EXISTS, } from "openclaw/plugin-sdk/migration"; import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; -import { childRecord, isRecord, readJsonObject, sanitizeName } from "./helpers.js"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { childRecord, readJsonObject, sanitizeName } from "./helpers.js"; import type { ClaudeSource } from "./source.js"; type MappedMcpSource = { diff --git a/extensions/migrate-claude/helpers.ts b/extensions/migrate-claude/helpers.ts index 16c9bb6c0e00..f3cef8c82163 100644 --- a/extensions/migrate-claude/helpers.ts +++ b/extensions/migrate-claude/helpers.ts @@ -8,7 +8,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"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export function resolveHomePath(input: string): string { const trimmed = input.trim(); @@ -64,8 +64,6 @@ export async function readJsonObject( } } -export const isRecord = sharedIsRecord; - export function childRecord( root: Record | undefined, key: string, diff --git a/extensions/migrate-hermes/auth-source.ts b/extensions/migrate-hermes/auth-source.ts index ea9c408becd8..216a74141cca 100644 --- a/extensions/migrate-hermes/auth-source.ts +++ b/extensions/migrate-hermes/auth-source.ts @@ -1,7 +1,8 @@ // Hermes-native auth discovery and reauthentication planning. import { createMigrationManualItem } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; -import { isRecord, readString, readText } from "./helpers.js"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { readText } from "./helpers.js"; import type { HermesSource } from "./source.js"; const HERMES_OPENAI_CODEX_SOURCE_PROVIDER_ID = "openai-codex"; @@ -48,8 +49,8 @@ function readHermesProviderCandidate( ? providers[HERMES_OPENAI_CODEX_SOURCE_PROVIDER_ID] : undefined; const tokens = isRecord(provider?.tokens) ? provider.tokens : undefined; - const access = readString(tokens?.access_token); - const refresh = readString(tokens?.refresh_token); + const access = normalizeOptionalString(tokens?.access_token); + const refresh = normalizeOptionalString(tokens?.refresh_token); if (!access || !refresh) { return undefined; } @@ -76,8 +77,8 @@ function readHermesPoolCandidates( if (!isRecord(entry)) { return []; } - const access = readString(entry.access_token); - const refresh = readString(entry.refresh_token); + const access = normalizeOptionalString(entry.access_token); + const refresh = normalizeOptionalString(entry.refresh_token); if (!access || !refresh) { return []; } @@ -87,7 +88,7 @@ function readHermesPoolCandidates( refresh, sourceKind: "hermes-auth-json" as const, sourceSlot: "pool" as const, - sourceLabel: readString(entry.label) ?? "Hermes OpenAI Codex credential pool", + sourceLabel: normalizeOptionalString(entry.label) ?? "Hermes OpenAI Codex credential pool", sourcePath, updatedAt: readTimestamp(entry.last_refresh) ?? readTimestamp(entry.last_status_at), }, @@ -142,7 +143,9 @@ async function readHermesOAuthProviderIds(authPath: string | undefined): Promise ? Object.entries(parsed.credential_pool).flatMap(([provider, entries]) => Array.isArray(entries) && entries.some( - (entry) => isRecord(entry) && readString(entry.auth_type)?.toLowerCase() === "oauth", + (entry) => + isRecord(entry) && + normalizeOptionalString(entry.auth_type)?.toLowerCase() === "oauth", ) ? [provider] : [], diff --git a/extensions/migrate-hermes/auth.ts b/extensions/migrate-hermes/auth.ts index 39a490c27c42..a57bfca77e1e 100644 --- a/extensions/migrate-hermes/auth.ts +++ b/extensions/migrate-hermes/auth.ts @@ -27,6 +27,7 @@ import { applyAgentDefaultModelPrimary, resolveAgentModelPrimaryValue, } from "openclaw/plugin-sdk/provider-onboard"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { applyAuthProfileConfigWithConflictCheck, hasAuthProfileConfigConflict, @@ -38,7 +39,7 @@ import { readHermesCodexAuthCandidates, type HermesCodexAuthCandidate, } from "./auth-source.js"; -import { isRecord, readString, readText } from "./helpers.js"; +import { readText } from "./helpers.js"; import { HERMES_REASON_AUTH_PROFILE_EXISTS, HERMES_REASON_AUTH_PROFILE_WRITE_FAILED, @@ -101,9 +102,9 @@ async function readOpenCodeOpenAICandidates( return []; } const openai = isRecord(parsed.openai) ? parsed.openai : undefined; - const access = readString(openai?.access); - const accountId = readString(openai?.accountId); - const refresh = readString(openai?.refresh); + const access = normalizeOptionalString(openai?.access); + const accountId = normalizeOptionalString(openai?.accountId); + const refresh = normalizeOptionalString(openai?.refresh); if (!access || !refresh) { return []; } @@ -147,7 +148,7 @@ function readProviderAuthModelConfigs(result: ProviderAuthResult): AgentDefaultM if (isRecord(models)) { return { ...models }; } - const defaultModel = readString(result.defaultModel) ?? OPENAI_CODEX_DEFAULT_MODEL; + const defaultModel = normalizeOptionalString(result.defaultModel) ?? OPENAI_CODEX_DEFAULT_MODEL; return { [defaultModel]: {} }; } diff --git a/extensions/migrate-hermes/config-env.ts b/extensions/migrate-hermes/config-env.ts index 777d6783b496..e422ce4c2cd6 100644 --- a/extensions/migrate-hermes/config-env.ts +++ b/extensions/migrate-hermes/config-env.ts @@ -1,5 +1,5 @@ // Hermes environment interpolation shared by provider and MCP config. -import { isRecord } from "./helpers.js"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export const MCP_ENV_REFERENCE_RE = /\$\{([^}]+)\}/gu; diff --git a/extensions/migrate-hermes/config-mcp.ts b/extensions/migrate-hermes/config-mcp.ts index 4804d2b9c036..7450a73e3ab2 100644 --- a/extensions/migrate-hermes/config-mcp.ts +++ b/extensions/migrate-hermes/config-mcp.ts @@ -1,18 +1,19 @@ // Hermes MCP config mapping and manual follow-up planning. import { createMigrationManualItem } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; -import { parseBooleanValue } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean, + isRecord, + normalizeOptionalString, + parseBooleanValue, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { mcpValueHasEnvReferences, resolveMcpEnvReferences } from "./config-env.js"; import { readPositiveNumber } from "./config-provider-contract.js"; -import { isRecord, readString, sanitizeName } from "./helpers.js"; +import { sanitizeName } from "./helpers.js"; const MCP_RESOURCE_UTILITY_TOOLS = ["resources_list", "resources_read"] as const; const MCP_PROMPT_UTILITY_TOOLS = ["prompts_list", "prompts_get"] as const; -function readBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - function readPositiveNumeric(value: unknown): number | undefined { if (typeof value === "number") { return readPositiveNumber(value); @@ -81,13 +82,13 @@ function mapHermesClientCertificate(value: Record): { clientKey?: string; } { const cert = value.clientCert ?? value.client_cert; - const key = readString(value.clientKey) ?? readString(value.client_key); + const key = normalizeOptionalString(value.clientKey) ?? normalizeOptionalString(value.client_key); if (Array.isArray(cert) && cert.length === 2) { - const certPath = readString(cert[0]); - const keyPath = readString(cert[1]); + const certPath = normalizeOptionalString(cert[0]); + const keyPath = normalizeOptionalString(cert[1]); return certPath && keyPath ? { clientCert: certPath, clientKey: keyPath } : {}; } - const certPath = readString(cert); + const certPath = normalizeOptionalString(cert); return certPath && key ? { clientCert: certPath, clientKey: key } : {}; } @@ -121,7 +122,7 @@ function mapHermesMcpOauth(value: Record): Record = {}; for (const key of ["authProfileId", "scope", "redirectUrl", "clientMetadataUrl"]) { - const fieldValue = readString(oauth[key]); + const fieldValue = normalizeOptionalString(oauth[key]); if (fieldValue) { mapped[key] = fieldValue; } @@ -151,12 +152,12 @@ export function mapMcpServer( } } } - const transport = readString(value.transport) ?? readString(value.type); + const transport = normalizeOptionalString(value.transport) ?? normalizeOptionalString(value.type); if (transport === "http" || transport === "streamable-http") { next.transport = "streamable-http"; } else if (transport === "sse" || transport === "stdio") { next.transport = transport; - } else if (!transport && readString(next.url)) { + } else if (!transport && normalizeOptionalString(next.url)) { next.transport = "streamable-http"; } // Canonical timeout fields are finite().positive(); drop non-positive or @@ -179,11 +180,11 @@ export function mapMcpServer( ) { next.requestTimeoutMs = requestTimeoutSeconds * 1_000; } - next.supportsParallelToolCalls = readBoolean( + next.supportsParallelToolCalls = asBoolean( value.supportsParallelToolCalls ?? value.supports_parallel_tool_calls, ); - next.sslVerify = readBoolean(value.sslVerify ?? value.ssl_verify); - next.auth = readString(value.auth) === "oauth" ? "oauth" : undefined; + next.sslVerify = asBoolean(value.sslVerify ?? value.ssl_verify); + next.auth = normalizeOptionalString(value.auth) === "oauth" ? "oauth" : undefined; next.oauth = mapHermesMcpOauth(value); Object.assign(next, mapHermesClientCertificate(value)); const toolFilter = mapHermesToolFilter(value); @@ -201,7 +202,9 @@ export function mapMcpServer( const mapped = Object.fromEntries( Object.entries(next).filter(([, entry]) => entry !== undefined), ); - return readString(mapped.command) || readString(mapped.url) ? mapped : {}; + return normalizeOptionalString(mapped.command) || normalizeOptionalString(mapped.url) + ? mapped + : {}; } export function mcpManualItems(params: { @@ -256,7 +259,7 @@ export function mcpManualItems(params: { } const cert = raw.clientCert ?? raw.client_cert; - const key = readString(raw.clientKey) ?? readString(raw.client_key); + const key = normalizeOptionalString(raw.clientKey) ?? normalizeOptionalString(raw.client_key); if (Array.isArray(cert) && cert.length === 3) { add( "client-cert-password", @@ -266,8 +269,11 @@ export function mcpManualItems(params: { } else if ( (cert !== undefined || key !== undefined) && !( - (Array.isArray(cert) && cert.length === 2 && readString(cert[0]) && readString(cert[1])) || - (readString(cert) && key) + (Array.isArray(cert) && + cert.length === 2 && + normalizeOptionalString(cert[0]) && + normalizeOptionalString(cert[1])) || + (normalizeOptionalString(cert) && key) ) ) { add( @@ -284,7 +290,7 @@ export function mcpManualItems(params: { ); } - const transport = readString(raw.transport) ?? readString(raw.type); + const transport = normalizeOptionalString(raw.transport) ?? normalizeOptionalString(raw.type); if (transport && !["http", "streamable-http", "sse", "stdio"].includes(transport)) { add( "transport", @@ -293,7 +299,7 @@ export function mcpManualItems(params: { ); } - const auth = readString(raw.auth); + const auth = normalizeOptionalString(raw.auth); if (auth && auth !== "oauth") { add( "auth", diff --git a/extensions/migrate-hermes/config-provider-contract.ts b/extensions/migrate-hermes/config-provider-contract.ts index 6d1c1417c6d2..783599c40553 100644 --- a/extensions/migrate-hermes/config-provider-contract.ts +++ b/extensions/migrate-hermes/config-provider-contract.ts @@ -1,10 +1,11 @@ +// Hermes provider config contract parsing and normalization. +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { MCP_ENV_REFERENCE_RE, mcpValueHasEnvReferences, resolveMcpEnvReferences, } from "./config-env.js"; -// Hermes provider config contract parsing and normalization. -import { childRecord, isRecord, readString, readStringArray } from "./helpers.js"; +import { childRecord, readStringArray } from "./helpers.js"; import { normalizeHermesCustomProviderId, normalizeHermesProviderId } from "./model.js"; type OpenClawModelApi = @@ -170,7 +171,7 @@ export function resolveProviderApi( raw: Record, providerId?: string, ): OpenClawModelApi | undefined { - const transport = readString(raw.transport) ?? readString(raw.api_mode); + const transport = normalizeOptionalString(raw.transport) ?? normalizeOptionalString(raw.api_mode); const sourceProvider = providerId?.trim().toLowerCase() ?? ""; if (sourceProvider === "openai-codex") { return "openai-chatgpt-responses"; @@ -180,10 +181,10 @@ export function resolveProviderApi( } const provider = sourceProvider ? normalizeHermesProviderId(sourceProvider) : ""; const baseUrl = - readString(raw.base_url) ?? - readString(raw.baseUrl) ?? - readString(raw.url) ?? - readString(raw.api); + normalizeOptionalString(raw.base_url) ?? + normalizeOptionalString(raw.baseUrl) ?? + normalizeOptionalString(raw.url) ?? + normalizeOptionalString(raw.api); let hostname = ""; let pathname = ""; try { @@ -238,7 +239,7 @@ function normalizeProviderBaseUrl(baseUrl: string, api: OpenClawModelApi): strin } export function readEnvReference(value: unknown): string | undefined { - const raw = readString(value); + const raw = normalizeOptionalString(value); const match = raw?.match(/^\$\{([^}]+)\}$/u); return match ? normalizeHermesEnvReferenceName(match[1] ?? "") : undefined; } @@ -251,10 +252,10 @@ function normalizeHermesEnvReferenceName(value: string): string | undefined { export function readProviderApiKeyEnv(raw: Record): string | undefined { return ( - readString(raw.key_env) ?? - readString(raw.api_key_env) ?? - readString(raw.apiKeyEnv) ?? - readString(raw.env) ?? + normalizeOptionalString(raw.key_env) ?? + normalizeOptionalString(raw.api_key_env) ?? + normalizeOptionalString(raw.apiKeyEnv) ?? + normalizeOptionalString(raw.env) ?? readEnvReference(raw.api_key) ); } @@ -302,9 +303,9 @@ export function collectProviderModels(raw: Record): HermesModel }); } for (const modelId of [ - readString(raw.default_model), - readString(raw.default), - readString(raw.model), + normalizeOptionalString(raw.default_model), + normalizeOptionalString(raw.default), + normalizeOptionalString(raw.model), ]) { if (modelId && !models.has(modelId)) { models.set(modelId, { id: modelId, ...rootMetadata }); @@ -347,10 +348,10 @@ export function readProviderBaseUrl( env: Record, ): { baseUrl?: string; sensitive: boolean; unresolved: boolean } { const value = - readString(raw.base_url) ?? - readString(raw.baseUrl) ?? - readString(raw.url) ?? - readString(raw.api); + normalizeOptionalString(raw.base_url) ?? + normalizeOptionalString(raw.baseUrl) ?? + normalizeOptionalString(raw.url) ?? + normalizeOptionalString(raw.api); if (!value) { return { sensitive: false, unresolved: false }; } diff --git a/extensions/migrate-hermes/config-providers.ts b/extensions/migrate-hermes/config-providers.ts index 8743e58e2cc9..319150e867b0 100644 --- a/extensions/migrate-hermes/config-providers.ts +++ b/extensions/migrate-hermes/config-providers.ts @@ -1,6 +1,7 @@ // Hermes provider config collection and migration planning. import { createMigrationManualItem } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { HERMES_TRANSPORTS, collectProviderModels, @@ -15,7 +16,7 @@ import { resolveProviderApi, type HermesProviderConfig, } from "./config-provider-contract.js"; -import { childRecord, isRecord, readString, sanitizeName } from "./helpers.js"; +import { childRecord, sanitizeName } from "./helpers.js"; import { normalizeHermesCustomProviderId, resolveHermesConfiguredProviderId } from "./model.js"; type HermesProviderSecretBinding = { @@ -81,7 +82,7 @@ export function collectHermesProviders( if (!isRecord(raw)) { continue; } - const id = readString(raw.name) ?? readString(raw.id); + const id = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id); if (!id) { continue; } @@ -109,7 +110,7 @@ export function collectHermesProviders( const model = config.model; if (isRecord(model)) { - const rawProvider = readString(model.provider); + const rawProvider = normalizeOptionalString(model.provider); const resolvedBaseUrl = readProviderBaseUrl(model, env); const envBaseUrl = resolveHermesProviderBaseUrlEnv(rawProvider, env); const baseUrl = @@ -131,7 +132,7 @@ export function collectHermesProviders( }); } } else { - const rawProvider = readString(config.provider); + const rawProvider = normalizeOptionalString(config.provider); const baseUrl = resolveHermesProviderBaseUrlEnv(rawProvider, env) ?? resolveHermesImplicitBaseUrl(rawProvider); @@ -175,7 +176,7 @@ export function collectHermesProviderSecretBindings( if (!isRecord(raw)) { continue; } - const sourceProvider = readString(raw.name) ?? readString(raw.id); + const sourceProvider = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id); const envVar = readProviderApiKeyEnv(raw); if (sourceProvider && envVar) { bindings.push({ @@ -186,7 +187,8 @@ export function collectHermesProviderSecretBindings( } } const model = isRecord(config.model) ? config.model : undefined; - const selectedProvider = readString(model?.provider) ?? readString(config.provider); + const selectedProvider = + normalizeOptionalString(model?.provider) ?? normalizeOptionalString(config.provider); const selectedEnv = (model ? readProviderApiKeyEnv(model) : undefined) ?? resolveHermesProviderApiKeyEnv(selectedProvider); @@ -240,15 +242,17 @@ export function providerManualItems( if (!isRecord(raw)) { continue; } - const id = readString(raw.name) ?? readString(raw.id); + const id = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id); if (id && !currentProviderIds.has(normalizeHermesCustomProviderId(id))) { entries.push({ id, raw, source: `config.yaml:custom_providers.${id}` }); } } } if (isRecord(config.model)) { - const provider = readString(config.model.provider); - const baseUrl = readString(config.model.base_url) ?? readString(config.model.baseUrl); + const provider = normalizeOptionalString(config.model.provider); + const baseUrl = + normalizeOptionalString(config.model.base_url) ?? + normalizeOptionalString(config.model.baseUrl); if (baseUrl) { entries.push({ id: provider @@ -261,7 +265,8 @@ export function providerManualItems( } const items: MigrationItem[] = []; for (const { id, raw, source } of entries) { - const transport = readString(raw.transport) ?? readString(raw.api_mode); + const transport = + normalizeOptionalString(raw.transport) ?? normalizeOptionalString(raw.api_mode); const baseUrlConfig = readProviderBaseUrl(raw, env); const baseUrl = baseUrlConfig.baseUrl ?? resolveHermesImplicitBaseUrl(id); const headerConfig = readProviderHeaders(raw, env, includeSecrets); @@ -294,7 +299,7 @@ export function providerManualItems( }), ); } - if (readString(raw.api_key) && !readEnvReference(raw.api_key)) { + if (normalizeOptionalString(raw.api_key) && !readEnvReference(raw.api_key)) { items.push( createMigrationManualItem({ id: `manual:model-provider-inline-key:${sanitizeName(id)}`, diff --git a/extensions/migrate-hermes/config.ts b/extensions/migrate-hermes/config.ts index a00400f34d22..91b3d9eba795 100644 --- a/extensions/migrate-hermes/config.ts +++ b/extensions/migrate-hermes/config.ts @@ -7,6 +7,7 @@ import { hasMigrationConfigPatchConflict, } from "openclaw/plugin-sdk/migration"; import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { importsMcpSensitiveValues, mapMcpServer, mcpManualItems } from "./config-mcp.js"; import { providerConfig } from "./config-provider-contract.js"; import { @@ -14,7 +15,7 @@ import { collectHermesProviders, providerManualItems, } from "./config-providers.js"; -import { childRecord, isRecord, readString, sanitizeName } from "./helpers.js"; +import { childRecord, sanitizeName } from "./helpers.js"; function mapSkillEntries(config: Record): Record | undefined { const entries: Record = {}; @@ -38,7 +39,7 @@ export function buildConfigItems(params: { }): MigrationItem[] { const items: MigrationItem[] = []; const memory = childRecord(params.config, "memory"); - const memoryProvider = readString(memory.provider); + const memoryProvider = normalizeOptionalString(memory.provider); if (params.hasMemoryFiles || memoryProvider) { items.push( diff --git a/extensions/migrate-hermes/helpers.ts b/extensions/migrate-hermes/helpers.ts index 8bf1468c8969..6a251a332a36 100644 --- a/extensions/migrate-hermes/helpers.ts +++ b/extensions/migrate-hermes/helpers.ts @@ -10,10 +10,7 @@ import { } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; import { appendRegularFile, pathExists } from "openclaw/plugin-sdk/security-runtime"; -import { - isRecord as sharedIsRecord, - normalizeOptionalString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { parse as parseYaml } from "yaml"; const HOME_SHORTHAND_RE = /^~(?=$|[\\/])/u; @@ -57,8 +54,6 @@ export function parseHermesConfig(content: string | undefined): Record | undefined, key: string, @@ -67,8 +62,6 @@ export function childRecord( return isRecord(value) ? value : {}; } -export const readString = normalizeOptionalString; - export function readStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; diff --git a/extensions/migrate-hermes/items.ts b/extensions/migrate-hermes/items.ts index 5ff7f1e15188..a4b98a0ac302 100644 --- a/extensions/migrate-hermes/items.ts +++ b/extensions/migrate-hermes/items.ts @@ -6,7 +6,7 @@ import { markMigrationItemError, markMigrationItemSkipped, } from "openclaw/plugin-sdk/migration"; -import { isRecord, readString } from "./helpers.js"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; export const HERMES_REASON_ALREADY_CONFIGURED = "already configured"; export const HERMES_REASON_DEFAULT_MODEL_CONFIGURED = "default model already configured"; @@ -41,7 +41,7 @@ export function createHermesModelItem(params: { } export function readHermesModelDetails(item: MigrationItem): { model: string } | undefined { - const model = readString(item.details?.model); + const model = normalizeOptionalString(item.details?.model); return model ? { model } : undefined; } @@ -112,17 +112,17 @@ export function readHermesSecretDetails(item: MigrationItem): secretField?: string; } | undefined { - const envVar = readString(item.details?.envVar); - const provider = readString(item.details?.provider); - const profileId = readString(item.details?.profileId); + const envVar = normalizeOptionalString(item.details?.envVar); + const provider = normalizeOptionalString(item.details?.provider); + const profileId = normalizeOptionalString(item.details?.profileId); if (!provider || !profileId) { return undefined; } const mode = item.details?.mode === "token" ? "token" : undefined; - const sourceKind = readString(item.details?.sourceKind); - const sourceProvider = readString(item.details?.sourceProvider); - const sourceCredentialId = readString(item.details?.sourceCredentialId); - const secretField = readString(item.details?.secretField); + const sourceKind = normalizeOptionalString(item.details?.sourceKind); + const sourceProvider = normalizeOptionalString(item.details?.sourceProvider); + const sourceCredentialId = normalizeOptionalString(item.details?.sourceCredentialId); + const secretField = normalizeOptionalString(item.details?.secretField); return { ...(envVar ? { envVar } : {}), provider, diff --git a/extensions/migrate-hermes/model.ts b/extensions/migrate-hermes/model.ts index 2fc92a60a052..7b327536c00f 100644 --- a/extensions/migrate-hermes/model.ts +++ b/extensions/migrate-hermes/model.ts @@ -6,7 +6,10 @@ import { } from "openclaw/plugin-sdk/agent-runtime"; import { resolveMigrationConfigRuntime } from "openclaw/plugin-sdk/migration"; import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; -import { readString } from "./helpers.js"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { HERMES_REASON_ALREADY_CONFIGURED, HERMES_REASON_CONFIG_RUNTIME_UNAVAILABLE, @@ -147,30 +150,24 @@ function isRetiredHermesQwenProviderValue(value: string): boolean { } export function usesRetiredHermesQwenProvider(config: Record): boolean { - const model = asRecord(config.model); + const model = asOptionalRecord(config.model); return [ - readString(config.provider), + normalizeOptionalString(config.provider), typeof config.model === "string" ? config.model : undefined, - readString(model?.provider), - readString(model?.default), - readString(model?.model), - readString(config.default_model), - readString(config.model_name), + normalizeOptionalString(model?.provider), + normalizeOptionalString(model?.default), + normalizeOptionalString(model?.model), + normalizeOptionalString(config.default_model), + normalizeOptionalString(config.model_name), ].some((value) => value !== undefined && isRetiredHermesQwenProviderValue(value)); } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readBaseUrl(value: Record | undefined): string | undefined { return value - ? (readString(value.base_url) ?? - readString(value.baseUrl) ?? - readString(value.url) ?? - readString(value.api)) + ? (normalizeOptionalString(value.base_url) ?? + normalizeOptionalString(value.baseUrl) ?? + normalizeOptionalString(value.url) ?? + normalizeOptionalString(value.api)) : undefined; } @@ -179,8 +176,9 @@ function readKimiBaseUrl( provider: string, env: Record, ): string | undefined { - const model = asRecord(config.model); - const selectedProvider = readString(model?.provider) ?? readString(config.provider); + const model = asOptionalRecord(config.model); + const selectedProvider = + normalizeOptionalString(model?.provider) ?? normalizeOptionalString(config.provider); if ( selectedProvider && HERMES_DYNAMIC_KIMI_PROVIDER_IDS.has(normalizeHermesCustomProviderId(selectedProvider)) @@ -190,13 +188,13 @@ function readKimiBaseUrl( return modelBaseUrl; } } - const providers = asRecord(config.providers); + const providers = asOptionalRecord(config.providers); for (const [id, value] of Object.entries(providers ?? {})) { if ( normalizeHermesCustomProviderId(id) === normalizeHermesCustomProviderId(provider) && - asRecord(value) + asOptionalRecord(value) ) { - const providerBaseUrl = readBaseUrl(asRecord(value)); + const providerBaseUrl = readBaseUrl(asOptionalRecord(value)); if (providerBaseUrl) { return providerBaseUrl; } @@ -260,7 +258,7 @@ function hasExplicitHermesProvider(config: Record, provider: st return false; } const record = entry as Record; - const id = readString(record.name) ?? readString(record.id); + const id = normalizeOptionalString(record.name) ?? normalizeOptionalString(record.id); return id ? normalizeHermesCustomProviderId(id) === normalized : false; }) ); @@ -317,20 +315,23 @@ export function resolveHermesModelRef( const model = config.model; if (typeof model === "string" && model.trim()) { const rawModel = model.trim(); - const provider = readString(config.provider); + const provider = normalizeOptionalString(config.provider); return joinHermesProviderModel(config, provider, rawModel, env); } if (model && typeof model === "object" && !Array.isArray(model)) { const modelRecord = model as Record; - const rawModel = readString(modelRecord.default) ?? readString(modelRecord.model); + const rawModel = + normalizeOptionalString(modelRecord.default) ?? normalizeOptionalString(modelRecord.model); const hasCustomEndpoint = Boolean( - readString(modelRecord.base_url) ?? readString(modelRecord.baseUrl), + normalizeOptionalString(modelRecord.base_url) ?? normalizeOptionalString(modelRecord.baseUrl), ); - const provider = readString(modelRecord.provider) ?? (hasCustomEndpoint ? "custom" : undefined); + const provider = + normalizeOptionalString(modelRecord.provider) ?? (hasCustomEndpoint ? "custom" : undefined); return rawModel ? joinHermesProviderModel(config, provider, rawModel, env) : undefined; } - const rootModel = readString(config.default_model) ?? readString(config.model_name); - const rootProvider = readString(config.provider); + const rootModel = + normalizeOptionalString(config.default_model) ?? normalizeOptionalString(config.model_name); + const rootProvider = normalizeOptionalString(config.provider); return rootModel ? joinHermesProviderModel(config, rootProvider, rootModel, env) : undefined; } diff --git a/extensions/migrate-hermes/secrets.ts b/extensions/migrate-hermes/secrets.ts index 3c1a68b4f758..189f7515b8bb 100644 --- a/extensions/migrate-hermes/secrets.ts +++ b/extensions/migrate-hermes/secrets.ts @@ -5,6 +5,7 @@ import { } from "openclaw/plugin-sdk/agent-runtime"; import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; import { updateAuthProfileStoreWithLock } from "openclaw/plugin-sdk/provider-auth"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { applyAuthProfileConfigWithConflictCheck, hasAuthProfileConfigConflict, @@ -12,7 +13,7 @@ import { type HermesAuthProfileConfig, } from "./auth-config.js"; import { collectHermesProviderSecretBindings } from "./config-providers.js"; -import { isRecord, parseEnv, readString, readText, sanitizeName } from "./helpers.js"; +import { parseEnv, readText, sanitizeName } from "./helpers.js"; import { createHermesSecretItem, HERMES_REASON_AUTH_PROFILE_EXISTS, @@ -141,9 +142,9 @@ async function buildOpenCodeSecretCandidates( const opencode = isRecord(auth.opencode) ? auth.opencode : {}; const opencodeGo = isRecord(auth["opencode-go"]) ? auth["opencode-go"] : {}; const githubCopilot = isRecord(auth["github-copilot"]) ? auth["github-copilot"] : {}; - const githubCopilotEnterpriseUrl = readString(githubCopilot.enterpriseUrl); + const githubCopilotEnterpriseUrl = normalizeOptionalString(githubCopilot.enterpriseUrl); const candidates: SecretCandidate[] = []; - if (readString(opencode.key)) { + if (normalizeOptionalString(opencode.key)) { candidates.push({ id: "secret:opencode:opencode-auth-json", source: authPath, @@ -155,7 +156,7 @@ async function buildOpenCodeSecretCandidates( secretField: "key", }); } - if (readString(opencodeGo.key)) { + if (normalizeOptionalString(opencodeGo.key)) { candidates.push({ id: "secret:opencode-go:opencode-auth-json", source: authPath, @@ -168,7 +169,7 @@ async function buildOpenCodeSecretCandidates( }); } // OpenClaw's Copilot token profile cannot preserve OpenCode enterprise routing yet. - if (readString(githubCopilot.refresh) && !githubCopilotEnterpriseUrl) { + if (normalizeOptionalString(githubCopilot.refresh) && !githubCopilotEnterpriseUrl) { candidates.push({ id: "secret:github-copilot:opencode-auth-json", source: authPath, @@ -214,14 +215,14 @@ async function buildHermesPoolSecretCandidates( if (!isRecord(rawEntry)) { continue; } - const sourceCredentialId = readString(rawEntry.id); - const authType = readString(rawEntry.auth_type); - const source = readString(rawEntry.source); + const sourceCredentialId = normalizeOptionalString(rawEntry.id); + const authType = normalizeOptionalString(rawEntry.auth_type); + const source = normalizeOptionalString(rawEntry.source); if ( !sourceCredentialId || authType !== "api_key" || source !== "manual" || - !readString(rawEntry.access_token) + !normalizeOptionalString(rawEntry.access_token) ) { continue; } @@ -264,7 +265,7 @@ async function readSecretCandidateValue( return undefined; } const provider = isRecord(auth[sourceProvider]) ? auth[sourceProvider] : {}; - return readString(provider[secretField]); + return normalizeOptionalString(provider[secretField]); } if (details.sourceKind === "hermes-auth-json") { const auth = await readAuthJson(source); @@ -276,7 +277,7 @@ async function readSecretCandidateValue( const entry = entries.find( (candidate) => isRecord(candidate) && candidate.id === details.sourceCredentialId, ); - return isRecord(entry) ? readString(entry.access_token) : undefined; + return isRecord(entry) ? normalizeOptionalString(entry.access_token) : undefined; } if (!details.envVar) { return undefined; diff --git a/extensions/minimax/speech-provider.ts b/extensions/minimax/speech-provider.ts index 91b1d2d59d19..fc9ad1d7e905 100644 --- a/extensions/minimax/speech-provider.ts +++ b/extensions/minimax/speech-provider.ts @@ -13,12 +13,11 @@ import type { SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; import { - asObject, parseSpeechDirectiveNumberOverride, resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; -import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumberInRange, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_MINIMAX_TTS_BASE_URL, MINIMAX_TTS_MODELS, @@ -53,8 +52,8 @@ type MinimaxTtsProviderOverrides = { }; function resolveConfiguredPortalTtsBaseUrl(cfg: OpenClawConfig | undefined): string | undefined { - const providers = asObject(asObject(cfg?.models)?.providers); - const portalProvider = asObject(providers?.[MINIMAX_PORTAL_PROVIDER_ID]); + const providers = asOptionalRecord(asOptionalRecord(cfg?.models)?.providers); + const portalProvider = asOptionalRecord(providers?.[MINIMAX_PORTAL_PROVIDER_ID]); const portalBaseUrl = trimToUndefined(portalProvider?.baseUrl); return portalBaseUrl ? normalizeMinimaxTtsBaseUrl(portalBaseUrl) : undefined; } @@ -97,8 +96,8 @@ function normalizeMinimaxProviderConfig( rawConfig: Record, cfg?: OpenClawConfig, ): MinimaxTtsProviderConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.minimax) ?? asObject(rawConfig.minimax); + const providers = asOptionalRecord(rawConfig.providers); + const raw = asOptionalRecord(providers?.minimax) ?? asOptionalRecord(rawConfig.minimax); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 731b0e13c4dd..a026efa87813 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -20,7 +20,10 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup-runtime"; import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + readStringValue, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount } from "./accounts.js"; import type { CoreConfig } from "./types.js"; @@ -36,8 +39,9 @@ type NextcloudSetupInput = ChannelSetupInput & { password?: string; }; -function readOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; +function readNonEmptyUntrimmedString(value: unknown): string | undefined { + const text = readStringValue(value); + return text ? text : undefined; } export function normalizeNextcloudTalkBaseUrl(value: string | undefined): string { @@ -147,12 +151,12 @@ const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { const setupInput = input as NextcloudSetupInput; return { ...setupInput, - baseUrl: setupInput.baseUrl ?? readOptionalString(setupInput.url), + baseUrl: setupInput.baseUrl ?? readNonEmptyUntrimmedString(setupInput.url), secret: setupInput.secret ?? - readOptionalString(setupInput.token) ?? - readOptionalString(setupInput.password), - secretFile: setupInput.secretFile ?? readOptionalString(setupInput.tokenFile), + readNonEmptyUntrimmedString(setupInput.token) ?? + readNonEmptyUntrimmedString(setupInput.password), + secretFile: setupInput.secretFile ?? readNonEmptyUntrimmedString(setupInput.tokenFile), }; }, applyAccountName: ({ cfg, accountId, name }) => diff --git a/extensions/ollama/src/node-inference.ts b/extensions/ollama/src/node-inference.ts index 1f6cc9a41cec..4c0e96b97ce9 100644 --- a/extensions/ollama/src/node-inference.ts +++ b/extensions/ollama/src/node-inference.ts @@ -18,6 +18,7 @@ import { readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; import { DEFAULT_INFERENCE_TIMEOUT_MS, @@ -82,17 +83,11 @@ type NodeSummary = Awaited< ReturnType >["nodes"][number]; -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function readNodeCommandParams(paramsJSON?: string | null): Record { if (!paramsJSON) { return {}; } - const parsed = asRecord(JSON.parse(paramsJSON)); + const parsed = asNullableRecord(JSON.parse(paramsJSON)); if (!parsed) { throw new Error("node inference params must be a JSON object"); } @@ -144,7 +139,7 @@ async function requestOllamaJson(params: { const body = (await readResponseTextLimited(response, MAX_ERROR_BODY_BYTES)).trim(); let detail = body; try { - const parsed = asRecord(JSON.parse(body)); + const parsed = asNullableRecord(JSON.parse(body)); detail = typeof parsed?.error === "string" ? parsed.error : body; } catch { // Keep the bounded response text when Ollama returns a non-JSON error. @@ -401,10 +396,10 @@ function findNode(nodes: NodeSummary[], query: string): NodeSummary { } function parseInvokePayload(raw: unknown): Record { - const result = asRecord(raw); - let payload = asRecord(result?.payload); + const result = asNullableRecord(raw); + let payload = asNullableRecord(result?.payload); if (!payload && typeof result?.payloadJSON === "string") { - payload = asRecord(JSON.parse(result.payloadJSON)); + payload = asNullableRecord(JSON.parse(result.payloadJSON)); } if (!payload) { throw new Error("node returned an invalid Ollama inference payload"); @@ -437,7 +432,7 @@ export function createOllamaNodeInferenceTool(api: OpenClawPluginApi): AnyAgentT ...ollamaNodeInferenceToolDefinition, execute: async (_toolCallId, args, signal) => { throwIfOllamaRequestAborted(signal); - const params = asRecord(args) ?? {}; + const params = asNullableRecord(args) ?? {}; const action = readStringParam(params, "action", { required: true }); const nodeQuery = readStringParam(params, "node"); const listed = await api.runtime.nodes.list({ connected: true }); diff --git a/extensions/onepassword/src/config.ts b/extensions/onepassword/src/config.ts index 1f1faf496fed..f0bbfbff6240 100644 --- a/extensions/onepassword/src/config.ts +++ b/extensions/onepassword/src/config.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; export const MAX_REGISTERED_ITEMS = 32; @@ -33,7 +33,10 @@ function requiredString(record: Record, key: string): string { return value.trim(); } -function optionalString(record: Record, key: string): string | undefined { +function readOptionalOnePasswordString( + record: Record, + key: string, +): string | undefined { const value = record[key]; if (value === undefined) { return undefined; @@ -54,7 +57,7 @@ function readPolicy(value: unknown, label: string, fallback: OnePasswordPolicy): throw new Error(`1Password config ${label} must be auto, approve, or deny`); } -function readNumber( +function readOnePasswordNumber( record: Record, key: string, fallback: number, @@ -108,17 +111,17 @@ export function parseOnePasswordConfig(value: unknown): OnePasswordConfig | unde if (item.startsWith("-")) { throw new Error(`1Password config item ${slug} item must not start with a hyphen`); } - const itemVault = optionalString(rawItem, "vault") ?? vault; + const itemVault = readOptionalOnePasswordString(rawItem, "vault") ?? vault; if (itemVault.startsWith("-")) { throw new Error(`1Password config item ${slug} vault must not start with a hyphen`); } - const description = optionalString(rawItem, "description"); + const description = readOptionalOnePasswordString(rawItem, "description"); if (description && description.length > MAX_DESCRIPTION_LENGTH) { throw new Error( `1Password config item ${slug} description must be at most ${MAX_DESCRIPTION_LENGTH} characters`, ); } - const field = optionalString(rawItem, "field") ?? "credential"; + const field = readOptionalOnePasswordString(rawItem, "field") ?? "credential"; // op treats commas in --fields as multiple selectors. Reject them so one // registry entry can never load more than its single configured field. if (field.includes(",")) { @@ -133,7 +136,7 @@ export function parseOnePasswordConfig(value: unknown): OnePasswordConfig | unde }; } - const opBin = optionalString(value, "opBin"); + const opBin = readOptionalOnePasswordString(value, "opBin"); if (opBin && !path.isAbsolute(opBin)) { throw new Error("1Password config opBin must be an absolute path"); } @@ -142,15 +145,15 @@ export function parseOnePasswordConfig(value: unknown): OnePasswordConfig | unde vault, ...(opBin ? { opBin } : {}), defaultPolicy, - cacheTtlSeconds: readNumber(value, "cacheTtlSeconds", 300, { + cacheTtlSeconds: readOnePasswordNumber(value, "cacheTtlSeconds", 300, { integer: true, allowZero: true, }), - grantTtlHours: readNumber(value, "grantTtlHours", 720, { + grantTtlHours: readOnePasswordNumber(value, "grantTtlHours", 720, { integer: false, allowZero: false, }), - opTimeoutMs: readNumber(value, "opTimeoutMs", 15_000, { + opTimeoutMs: readOnePasswordNumber(value, "opTimeoutMs", 15_000, { integer: true, allowZero: false, }), diff --git a/extensions/openai/realtime-provider-shared.ts b/extensions/openai/realtime-provider-shared.ts index d464bf102ba8..75751cc1e7cd 100644 --- a/extensions/openai/realtime-provider-shared.ts +++ b/extensions/openai/realtime-provider-shared.ts @@ -8,8 +8,7 @@ import { import { captureWsEvent } from "openclaw/plugin-sdk/proxy-capture"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { - asFiniteNumber, - asOptionalRecord as asObjectRecord, + asOptionalRecord, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -23,14 +22,11 @@ const OPENAI_REALTIME_SSRF_POLICY = { // with the maintained realtime Talk live smoke. const OPENAI_REALTIME_CLIENT_SECRET_REQUEST_TIMEOUT_MS = 30_000; -export const trimToUndefined = normalizeOptionalString; -export { asFiniteNumber, asObjectRecord }; - export function readRealtimeErrorDetail(error: unknown): string { if (typeof error === "string" && error) { return error; } - const message = asObjectRecord(error)?.message; + const message = asOptionalRecord(error)?.message; if (typeof message === "string" && message) { return message; } @@ -40,9 +36,11 @@ export function readRealtimeErrorDetail(error: unknown): string { export function resolveOpenAIProviderConfigRecord( config: Record, ): Record | undefined { - const providers = asObjectRecord(config.providers); + const providers = asOptionalRecord(config.providers); return ( - asObjectRecord(providers?.openai) ?? asObjectRecord(config.openai) ?? asObjectRecord(config) + asOptionalRecord(providers?.openai) ?? + asOptionalRecord(config.openai) ?? + asOptionalRecord(config) ); } @@ -85,14 +83,6 @@ type OpenAIRealtimeSecretRequest = { missingValueMessage: string; }; -function readStringField(value: unknown, key: string): string | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - const raw = (value as Record)[key]; - return typeof raw === "string" && raw.trim() ? raw.trim() : undefined; -} - async function createOpenAIRealtimeSecret( params: OpenAIRealtimeSecretRequest, ): Promise { @@ -140,7 +130,9 @@ async function createOpenAIRealtimeSecret( payload && typeof payload === "object" ? (payload as Record).client_secret : undefined; - const clientSecret = readStringField(payload, "value") ?? readStringField(nestedSecret, "value"); + const clientSecret = + normalizeOptionalString(asOptionalRecord(payload)?.value) ?? + normalizeOptionalString(asOptionalRecord(nestedSecret)?.value); if (!clientSecret) { throw new Error(params.missingValueMessage); } diff --git a/extensions/openai/realtime-quicksilver-delegation-controller.ts b/extensions/openai/realtime-quicksilver-delegation-controller.ts index 26e9d0d07895..51138b913cc9 100644 --- a/extensions/openai/realtime-quicksilver-delegation-controller.ts +++ b/extensions/openai/realtime-quicksilver-delegation-controller.ts @@ -1,3 +1,4 @@ +import { formatErrorMessage, toErrorObject } from "openclaw/plugin-sdk/error-runtime"; import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; import type { RealtimeVoiceAgentConsultRunner } from "openclaw/plugin-sdk/realtime-voice"; import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; @@ -36,12 +37,8 @@ type OpenAIQuicksilverDelegationControllerOptions = { signal: AbortSignal; }; -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - function shortFailureReason(error: unknown): string { - return toError(error).message.replaceAll(/\s+/g, " ").trim().slice(0, 180) || "unknown error"; + return formatErrorMessage(error).replaceAll(/\s+/g, " ").trim().slice(0, 180) || "unknown error"; } function readWireEventType(payload: string): string | undefined { @@ -185,7 +182,9 @@ export class OpenAIQuicksilverDelegationController { this.activeDelegationId = delegation.id; const signal = AbortSignal.any([this.options.signal, controller.signal]); void this.runDelegation(delegation, signal) - .catch((error: unknown) => this.fail(toError(error))) + .catch((error: unknown) => + this.fail(toErrorObject(error, "OpenAI GPT-Live delegation failed")), + ) .finally(() => { if (this.consultController !== controller) { return; diff --git a/extensions/openai/realtime-quicksilver-delegation.test.ts b/extensions/openai/realtime-quicksilver-delegation.test.ts index e1590672b2f8..cdac73d975e6 100644 --- a/extensions/openai/realtime-quicksilver-delegation.test.ts +++ b/extensions/openai/realtime-quicksilver-delegation.test.ts @@ -308,6 +308,29 @@ describe("GPT-Live sideband protocol", () => { expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("workspace unavailable")); }); + it("handles structured delegated failures with a non-string message", async () => { + const structuredFailure = { code: "UNAVAILABLE", message: 503 } as unknown as Error; + const runAgentConsult = vi.fn(() => Promise.reject(structuredFailure)); + const { controller, logger, socket } = createDelegationHarness({ runAgentConsult }); + + delegate(controller, "delegation-structured-failure", "do work"); + + await vi.waitFor(() => expect(socket.sent.length).toBeGreaterThan(0)); + expect(parseSent(socket)).toContainEqual( + expect.objectContaining({ + delegation_item_id: "delegation-structured-failure", + channel: "speakable", + content: [ + { + type: "input_text", + text: "The agent task failed. Tell the user it did not complete and offer to try again.", + }, + ], + }), + ); + expect(logger.warn).toHaveBeenCalled(); + }); + it("surfaces fatal sideband errors to the lifecycle owner", () => { const { controller, logger, onFatalError } = createDelegationHarness(); controller.handleEvent({ kind: "error", message: "token expired", fatalAuth: true }); diff --git a/extensions/openai/realtime-quicksilver-gateway-bridge.ts b/extensions/openai/realtime-quicksilver-gateway-bridge.ts index 9fd41004fdd4..e6740e0acfbc 100644 --- a/extensions/openai/realtime-quicksilver-gateway-bridge.ts +++ b/extensions/openai/realtime-quicksilver-gateway-bridge.ts @@ -1,5 +1,6 @@ // Gateway-owned GPT-Live WebRTC bridge: werift media peer plus OpenAI sideband control. import { randomUUID } from "node:crypto"; +import { toErrorObject } from "openclaw/plugin-sdk/error-runtime"; import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; import type { RealtimeVoiceBridge, @@ -33,10 +34,6 @@ const QUICKSILVER_SESSION_TTL_MS = 30 * 60_000; const QUICKSILVER_CONNECT_TIMEOUT_MS = 30_000; const WEBSOCKET_OPEN = 1; -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - function isAbortLikeError(error: unknown): boolean { if (!error || typeof error !== "object") { return false; @@ -100,7 +97,7 @@ function waitForConnectStep(promise: Promise, signal: AbortSignal): Promis }, (error: unknown) => { signal.removeEventListener("abort", onAbort); - reject(toError(error)); + reject(toErrorObject(error, "OpenAI GPT-Live gateway relay failed")); }, ); }); @@ -302,7 +299,7 @@ export class OpenAIQuicksilverGatewayBridge implements RealtimeVoiceBridge { } } catch (error) { this.releaseResources(); - throw toError(error); + throw toErrorObject(error, "OpenAI GPT-Live gateway relay failed"); } } diff --git a/extensions/openai/realtime-quicksilver-peer.runtime.ts b/extensions/openai/realtime-quicksilver-peer.runtime.ts index 5ae8d6119d24..d9900e883206 100644 --- a/extensions/openai/realtime-quicksilver-peer.runtime.ts +++ b/extensions/openai/realtime-quicksilver-peer.runtime.ts @@ -1,5 +1,6 @@ // Lazy GPT-Live media runtime: werift peer plus WASM Opus framing and PCM conversion. import { randomInt } from "node:crypto"; +import { toErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { resamplePcm } from "openclaw/plugin-sdk/realtime-voice"; import { OpenAIQuicksilverPendingAudio, @@ -49,10 +50,6 @@ export type OpenAIQuicksilverAudioPeerContract = { close(): void; }; -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - function pcmBufferToInt16(pcm: Buffer): Int16Array { const samples = new Int16Array(Math.floor(pcm.length / 2)); for (let index = 0; index < samples.length; index += 1) { @@ -305,7 +302,7 @@ export class OpenAIQuicksilverAudioPeer implements OpenAIQuicksilverAudioPeerCon this.flushInboundReorderWindow(state); this.scheduleInboundFlush(state); } catch (error) { - this.state.callbacks.onError(toError(error)); + this.state.callbacks.onError(toErrorObject(error, "OpenAI GPT-Live WebRTC media failed")); } } @@ -376,7 +373,7 @@ export class OpenAIQuicksilverAudioPeer implements OpenAIQuicksilverAudioPeerCon try { this.flushInboundReorderWindow(state, true); } catch (error) { - this.state.callbacks.onError(toError(error)); + this.state.callbacks.onError(toErrorObject(error, "OpenAI GPT-Live WebRTC media failed")); } }, INBOUND_REORDER_DEPTH * OPUS_FRAME_DURATION_MS); state.flushTimer.unref?.(); @@ -438,10 +435,10 @@ export class OpenAIQuicksilverAudioPeer implements OpenAIQuicksilverAudioPeerCon // werift queues encrypted UDP synchronously before sendRtp yields // (rtpSender.js:538; transport/dtls.js:455), preserving per-tick order. void this.state.transceiver.sender.sendRtp(rtp).catch((error: unknown) => { - this.state.callbacks.onError(toError(error)); + this.state.callbacks.onError(toErrorObject(error, "OpenAI GPT-Live WebRTC media failed")); }); } catch (error) { - this.state.callbacks.onError(toError(error)); + this.state.callbacks.onError(toErrorObject(error, "OpenAI GPT-Live WebRTC media failed")); } } diff --git a/extensions/openai/realtime-transcription-provider.ts b/extensions/openai/realtime-transcription-provider.ts index d84ffd3a17cd..6599515c3009 100644 --- a/extensions/openai/realtime-transcription-provider.ts +++ b/extensions/openai/realtime-transcription-provider.ts @@ -14,12 +14,11 @@ import { type RealtimeTranscriptionWebSocketTransport, } from "openclaw/plugin-sdk/realtime-transcription"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; +import { asFiniteNumber, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { - asFiniteNumber, createOpenAIRealtimeTranscriptionClientSecret, readRealtimeErrorDetail, resolveOpenAIProviderConfigRecord, - trimToUndefined, } from "./realtime-provider-shared.js"; type OpenAIRealtimeTranscriptionProviderConfig = { @@ -125,9 +124,9 @@ function normalizeProviderConfig( value: raw?.openaiApiKey, path: "plugins.entries.voice-call.config.streaming.openaiApiKey", }), - language: trimToUndefined(raw?.language), - model: trimToUndefined(raw?.model) ?? trimToUndefined(raw?.sttModel), - prompt: trimToUndefined(raw?.prompt), + language: normalizeOptionalString(raw?.language), + model: normalizeOptionalString(raw?.model) ?? normalizeOptionalString(raw?.sttModel), + prompt: normalizeOptionalString(raw?.prompt), silenceDurationMs: normalizeNonNegativeInteger(raw?.silenceDurationMs), vadThreshold: normalizeVadThreshold(raw?.vadThreshold), }; diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index 7cf99c47b010..7439acbf17e7 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -6,6 +6,7 @@ import type { RealtimeVoiceBridgeEvent, RealtimeVoiceTool, } from "openclaw/plugin-sdk/realtime-voice"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; @@ -311,10 +312,6 @@ function createJsonResponse(body: unknown, init?: { status?: number }): Response }); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function requireRecord(value: unknown, label: string): Record { expect(isRecord(value), `${label} must be an object`).toBe(true); return value as Record; diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index d3bb3fc78946..80e958ec4270 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -38,15 +38,17 @@ import { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/secret-input"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import WebSocket from "ws"; import { asFiniteNumber, + isRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; +import WebSocket from "ws"; +import { captureOpenAIRealtimeWsClose, createOpenAIRealtimeClientSecret, readRealtimeErrorDetail, resolveOpenAIProviderConfigRecord, - trimToUndefined, } from "./realtime-provider-shared.js"; import { OpenAIQuicksilverVoiceBridge } from "./realtime-quicksilver-bridge.js"; import { OpenAIQuicksilverGatewayBridge } from "./realtime-quicksilver-gateway-bridge.js"; @@ -272,7 +274,7 @@ function normalizeProviderConfig( value: raw?.apiKey, path: "plugins.entries.voice-call.config.realtime.providers.openai.apiKey", }), - model: trimToUndefined(raw?.model), + model: normalizeOptionalString(raw?.model), voice: normalizeOpenAIRealtimeVoice(raw?.speakerVoice ?? raw?.voice), temperature: asFiniteNumber(raw?.temperature), vadThreshold: asUnitInterval(raw?.vadThreshold), @@ -283,10 +285,10 @@ function normalizeProviderConfig( ? raw.interruptResponseOnInputAudio : undefined, minBargeInAudioEndMs: asNonNegativeInteger(raw?.minBargeInAudioEndMs), - reasoningEffort: trimToUndefined(raw?.reasoningEffort), - azureEndpoint: trimToUndefined(raw?.azureEndpoint), - azureDeployment: trimToUndefined(raw?.azureDeployment), - azureApiVersion: trimToUndefined(raw?.azureApiVersion), + reasoningEffort: normalizeOptionalString(raw?.reasoningEffort), + azureEndpoint: normalizeOptionalString(raw?.azureEndpoint), + azureDeployment: normalizeOptionalString(raw?.azureDeployment), + azureApiVersion: normalizeOptionalString(raw?.azureApiVersion), }; } @@ -2113,7 +2115,7 @@ function buildOpenAIRealtimeBrowserSessionConfig( session.tools = tools; session.tool_choice = "auto"; } - const reasoningEffort = trimToUndefined(req.reasoningEffort) ?? config.reasoningEffort; + const reasoningEffort = normalizeOptionalString(req.reasoningEffort) ?? config.reasoningEffort; if (reasoningEffort) { session.reasoning = { effort: reasoningEffort }; } @@ -2150,7 +2152,7 @@ async function createOpenAIRealtimeBrowserSession( model, noiseReduction: { type: "near_field" }, prefixPaddingMs: req.prefixPaddingMs ?? config.prefixPaddingMs, - reasoningEffort: trimToUndefined(req.reasoningEffort) ?? config.reasoningEffort, + reasoningEffort: normalizeOptionalString(req.reasoningEffort) ?? config.reasoningEffort, silenceDurationMs: req.silenceDurationMs ?? config.silenceDurationMs, tools: normalizeOpenAIRealtimeTools(req.tools), vadThreshold: req.vadThreshold ?? config.vadThreshold, @@ -2204,7 +2206,7 @@ async function createOpenAIRealtimeBrowserSession( if (!quicksilverBroker) { throw new Error("OpenAI GPT-Live browser session broker is unavailable"); } - const configuredVoice = trimToUndefined(rawConfig?.speakerVoice ?? rawConfig?.voice); + const configuredVoice = normalizeOptionalString(rawConfig?.speakerVoice ?? rawConfig?.voice); const quicksilverRequest = { ...req, model, diff --git a/extensions/openai/speech-provider.ts b/extensions/openai/speech-provider.ts index 58a24498b8a1..c2b041bc28fd 100644 --- a/extensions/openai/speech-provider.ts +++ b/extensions/openai/speech-provider.ts @@ -10,15 +10,13 @@ import type { } from "openclaw/plugin-sdk/speech-core"; import { parseSpeechDirectiveNumberOverride } from "openclaw/plugin-sdk/speech-core"; import { + asFiniteNumber, + asOptionalRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, + normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { - asFiniteNumber, - asObjectRecord, - resolveOpenAIProviderConfigRecord, - trimToUndefined, -} from "./realtime-provider-shared.js"; +import { resolveOpenAIProviderConfigRecord } from "./realtime-provider-shared.js"; import { DEFAULT_OPENAI_BASE_URL, isValidOpenAIModel, @@ -51,7 +49,9 @@ type OpenAITtsProviderOverrides = { }; function resolveOpenAISpeechApiKey(config: OpenAITtsProviderConfig): string | undefined { - return trimToUndefined(config.apiKey) ?? trimToUndefined(process.env.OPENAI_API_KEY); + return ( + normalizeOptionalString(config.apiKey) ?? normalizeOptionalString(process.env.OPENAI_API_KEY) + ); } function normalizeOpenAISpeechResponseFormat( @@ -106,7 +106,7 @@ function responseFormatToFileExtension( } function readExtraBody(value: unknown): Record | undefined { - const body = asObjectRecord(value); + const body = asOptionalRecord(value); if (!body || Object.keys(body).length === 0) { return undefined; } @@ -130,8 +130,8 @@ function normalizeOpenAIProviderConfig( const raw = resolveOpenAIProviderConfigRecord(rawConfig); const extraBody = readExtraBody(raw?.extraBody) ?? readExtraBody(raw?.extra_body); const baseUrl = normalizeOpenAITtsBaseUrl( - trimToUndefined(raw?.baseUrl) ?? - trimToUndefined(process.env.OPENAI_TTS_BASE_URL) ?? + normalizeOptionalString(raw?.baseUrl) ?? + normalizeOptionalString(process.env.OPENAI_TTS_BASE_URL) ?? DEFAULT_OPENAI_BASE_URL, ); return { @@ -140,10 +140,10 @@ function normalizeOpenAIProviderConfig( path: "tts.providers.openai.apiKey", }), baseUrl, - model: trimToUndefined(raw?.model) ?? "gpt-4o-mini-tts", - voice: trimToUndefined(raw?.voice) ?? "coral", + model: normalizeOptionalString(raw?.model) ?? "gpt-4o-mini-tts", + voice: normalizeOptionalString(raw?.voice) ?? "coral", speed: normalizeOpenAISpeechSpeed(raw?.speed, baseUrl), - instructions: trimToUndefined(raw?.instructions), + instructions: normalizeOptionalString(raw?.instructions), responseFormat: normalizeOpenAISpeechResponseFormat(raw?.responseFormat), extraBody, }; @@ -152,16 +152,16 @@ function normalizeOpenAIProviderConfig( function readOpenAIProviderConfig(config: SpeechProviderConfig): OpenAITtsProviderConfig { const normalized = normalizeOpenAIProviderConfig({}); return { - apiKey: trimToUndefined(config.apiKey) ?? normalized.apiKey, - baseUrl: trimToUndefined(config.baseUrl) ?? normalized.baseUrl, - model: trimToUndefined(config.model) ?? normalized.model, - voice: trimToUndefined(config.voice) ?? normalized.voice, + apiKey: normalizeOptionalString(config.apiKey) ?? normalized.apiKey, + baseUrl: normalizeOptionalString(config.baseUrl) ?? normalized.baseUrl, + model: normalizeOptionalString(config.model) ?? normalized.model, + voice: normalizeOptionalString(config.voice) ?? normalized.voice, speed: normalizeOpenAISpeechSpeed( config.speed, - trimToUndefined(config.baseUrl) ?? normalized.baseUrl, + normalizeOptionalString(config.baseUrl) ?? normalized.baseUrl, ) ?? normalized.speed, - instructions: trimToUndefined(config.instructions) ?? normalized.instructions, + instructions: normalizeOptionalString(config.instructions) ?? normalized.instructions, responseFormat: normalizeOpenAISpeechResponseFormat(config.responseFormat) ?? normalized.responseFormat, extraBody: readExtraBody(config.extraBody) ?? readExtraBody(config.extra_body), @@ -176,8 +176,8 @@ function readOpenAIOverrides( return {}; } return { - model: trimToUndefined(overrides.model), - voice: trimToUndefined(overrides.voice), + model: normalizeOptionalString(overrides.model), + voice: normalizeOptionalString(overrides.voice), speed: normalizeOpenAISpeechSpeed(overrides.speed, baseUrl), }; } @@ -194,7 +194,7 @@ function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): { overrides?: SpeechProviderOverrides; warnings?: string[]; } { - const baseUrl = trimToUndefined(asObjectRecord(ctx.providerConfig)?.baseUrl); + const baseUrl = normalizeOptionalString(asOptionalRecord(ctx.providerConfig)?.baseUrl); switch (ctx.key) { case "voice": case "openai_voice": @@ -248,7 +248,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin { resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => { const base = normalizeOpenAIProviderConfig(baseTtsConfig); const responseFormat = normalizeOpenAISpeechResponseFormat(talkProviderConfig.responseFormat); - const baseUrl = trimToUndefined(talkProviderConfig.baseUrl) ?? base.baseUrl; + const baseUrl = normalizeOptionalString(talkProviderConfig.baseUrl) ?? base.baseUrl; const speed = normalizeOpenAISpeechSpeed(talkProviderConfig.speed, baseUrl); return { ...base, @@ -260,27 +260,27 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin { path: "talk.providers.openai.apiKey", }), }), - ...(trimToUndefined(talkProviderConfig.baseUrl) == null ? {} : { baseUrl }), - ...(trimToUndefined(talkProviderConfig.modelId) == null + ...(normalizeOptionalString(talkProviderConfig.baseUrl) == null ? {} : { baseUrl }), + ...(normalizeOptionalString(talkProviderConfig.modelId) == null ? {} - : { model: trimToUndefined(talkProviderConfig.modelId) }), - ...(trimToUndefined(talkProviderConfig.voiceId) == null + : { model: normalizeOptionalString(talkProviderConfig.modelId) }), + ...(normalizeOptionalString(talkProviderConfig.voiceId) == null ? {} - : { voice: trimToUndefined(talkProviderConfig.voiceId) }), + : { voice: normalizeOptionalString(talkProviderConfig.voiceId) }), ...(speed == null ? {} : { speed }), - ...(trimToUndefined(talkProviderConfig.instructions) == null + ...(normalizeOptionalString(talkProviderConfig.instructions) == null ? {} - : { instructions: trimToUndefined(talkProviderConfig.instructions) }), + : { instructions: normalizeOptionalString(talkProviderConfig.instructions) }), ...(responseFormat == null ? {} : { responseFormat }), }; }, resolveTalkOverrides: ({ params }) => ({ - ...(trimToUndefined(params.voiceId) == null + ...(normalizeOptionalString(params.voiceId) == null ? {} - : { voice: trimToUndefined(params.voiceId) }), - ...(trimToUndefined(params.modelId) == null + : { voice: normalizeOptionalString(params.voiceId) }), + ...(normalizeOptionalString(params.modelId) == null ? {} - : { model: trimToUndefined(params.modelId) }), + : { model: normalizeOptionalString(params.modelId) }), ...(asFiniteNumber(params.speed) == null ? {} : { speed: asFiniteNumber(params.speed) }), }), listVoices: async () => OPENAI_TTS_VOICES.map((voice) => ({ id: voice, name: voice })), diff --git a/extensions/openrouter/oauth.ts b/extensions/openrouter/oauth.ts index efa3b379d947..206435d7f2c0 100644 --- a/extensions/openrouter/oauth.ts +++ b/extensions/openrouter/oauth.ts @@ -12,7 +12,7 @@ import { readProviderJsonResponse, readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "openrouter"; @@ -48,10 +48,6 @@ type OpenRouterOAuthLoginOptions = { waitForCallback?: typeof waitForOpenRouterOAuthCallback; }; -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function extractOpenRouterError(value: unknown): string | undefined { if (typeof value === "string") { return value.trim() || undefined; @@ -59,7 +55,8 @@ function extractOpenRouterError(value: unknown): string | undefined { if (!isRecord(value)) { return undefined; } - const direct = readString(value.message) ?? readString(value.error_description); + const direct = + normalizeOptionalString(value.message) ?? normalizeOptionalString(value.error_description); if (direct) { return direct; } @@ -68,7 +65,7 @@ function extractOpenRouterError(value: unknown): string | undefined { return error.trim() || undefined; } if (isRecord(error)) { - return readString(error.message) ?? readString(error.code); + return normalizeOptionalString(error.message) ?? normalizeOptionalString(error.code); } return undefined; } @@ -95,11 +92,11 @@ function parseOpenRouterKeyResponse(value: unknown): OpenRouterOAuthKeyResult { if (!isRecord(value)) { throw new Error("OpenRouter OAuth key exchange returned an unexpected response."); } - const key = readString(value.key); + const key = normalizeOptionalString(value.key); if (!key) { throw new Error("OpenRouter OAuth key exchange returned no API key."); } - const userId = readString(value.user_id) ?? readString(value.userId); + const userId = normalizeOptionalString(value.user_id) ?? normalizeOptionalString(value.userId); return { key, ...(userId ? { userId } : {}), @@ -143,15 +140,18 @@ function parseOpenRouterOAuthCallbackInput( } const parseParams = (params: URLSearchParams): OpenRouterOAuthCallbackResult => { - const state = requireOpenRouterOAuthState(readString(params.get("state")), expectedState); - const error = readString(params.get("error")); + const state = requireOpenRouterOAuthState( + normalizeOptionalString(params.get("state")), + expectedState, + ); + const error = normalizeOptionalString(params.get("error")); if (error) { - const description = readString(params.get("error_description")); + const description = normalizeOptionalString(params.get("error_description")); throw new Error( `OpenRouter OAuth error: ${description ? `${error}: ${description}` : error}`, ); } - const code = readString(params.get("code")); + const code = normalizeOptionalString(params.get("code")); if (!code) { throw new Error("Missing 'code' parameter in redirect URL."); } @@ -238,7 +238,7 @@ async function waitForOpenRouterOAuthCallback(params: { return; } - const state = readString(requestUrl.searchParams.get("state")); + const state = normalizeOptionalString(requestUrl.searchParams.get("state")); try { requireOpenRouterOAuthState(state, params.expectedState); } catch (err) { @@ -249,9 +249,11 @@ async function waitForOpenRouterOAuthCallback(params: { return; } - const error = readString(requestUrl.searchParams.get("error")); + const error = normalizeOptionalString(requestUrl.searchParams.get("error")); if (error) { - const description = readString(requestUrl.searchParams.get("error_description")); + const description = normalizeOptionalString( + requestUrl.searchParams.get("error_description"), + ); const detail = description ? `${error}: ${description}` : error; res.statusCode = 400; res.setHeader("Content-Type", "text/plain"); @@ -260,7 +262,7 @@ async function waitForOpenRouterOAuthCallback(params: { return; } - const code = readString(requestUrl.searchParams.get("code")); + const code = normalizeOptionalString(requestUrl.searchParams.get("code")); if (!code) { res.statusCode = 400; res.setHeader("Content-Type", "text/plain"); diff --git a/extensions/openrouter/speech-provider.ts b/extensions/openrouter/speech-provider.ts index 4faa24d4e546..16df4db02436 100644 --- a/extensions/openrouter/speech-provider.ts +++ b/extensions/openrouter/speech-provider.ts @@ -1,9 +1,9 @@ // Openrouter provider module implements model/runtime integration. import { - asObject, createOpenAiCompatibleSpeechProvider, type SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { OPENROUTER_BASE_URL } from "./provider-catalog.js"; const DEFAULT_OPENROUTER_TTS_MODEL = "hexgrad/kokoro-82m"; @@ -41,7 +41,7 @@ export function buildOpenRouterSpeechProvider(): SpeechProviderPlugin { }, apiErrorLabel: "OpenRouter TTS API error", missingApiKeyError: "OpenRouter API key missing", - readExtraConfig: (raw) => ({ provider: asObject(raw?.provider) }), + readExtraConfig: (raw) => ({ provider: asOptionalRecord(raw?.provider) }), extraJsonBodyFields: [{ configKey: "provider" }], }); } diff --git a/extensions/openrouter/stream.ts b/extensions/openrouter/stream.ts index 900b2bbe192a..dae7d19407ff 100644 --- a/extensions/openrouter/stream.ts +++ b/extensions/openrouter/stream.ts @@ -8,6 +8,7 @@ import { normalizeOpenAICompatibleReasoningReplay, } from "openclaw/plugin-sdk/provider-stream-shared"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isOpenRouterDeepSeekV4ModelId, normalizeOpenRouterModelFamilyId } from "./models.js"; import { isOpenRouterProxyReasoningUnsupportedModel, @@ -18,13 +19,13 @@ import { const log = createSubsystemLogger("openrouter-stream"); const openRouterThinkingStreamHooks = buildProviderStreamFamilyHooks("openrouter-thinking"); -function readString(value: unknown): string | undefined { - return typeof value === "string" ? value.trim() : undefined; +function normalizeOpenRouterStringPreservingEmpty(value: unknown): string | undefined { + return readStringValue(value)?.trim(); } function isVerifiedOpenRouterRoute(model: Parameters[0]): boolean { - const provider = readString(model.provider)?.toLowerCase(); - const baseUrl = readString(model.baseUrl); + const provider = normalizeOpenRouterStringPreservingEmpty(model.provider)?.toLowerCase(); + const baseUrl = normalizeOpenRouterStringPreservingEmpty(model.baseUrl); if (baseUrl) { return normalizeOpenRouterBaseUrl(baseUrl) === OPENROUTER_BASE_URL; } @@ -32,7 +33,7 @@ function isVerifiedOpenRouterRoute(model: Parameters[0]): boolean { } function shouldPatchAnthropicOpenRouterPayload(model: Parameters[0]): boolean { - const api = readString(model.api); + const api = normalizeOpenRouterStringPreservingEmpty(model.api); return ( (api === undefined || api === "openai-completions") && normalizeOpenRouterModelFamilyId(model.id)?.startsWith("anthropic/") === true && @@ -41,7 +42,7 @@ function shouldPatchAnthropicOpenRouterPayload(model: Parameters[0]): } function shouldPatchDeepSeekV4OpenRouterPayload(model: Parameters[0]): boolean { - const api = readString(model.api); + const api = normalizeOpenRouterStringPreservingEmpty(model.api); return ( (api === undefined || api === "openai-completions") && isOpenRouterDeepSeekV4ModelId(model.id) && @@ -50,12 +51,12 @@ function shouldPatchDeepSeekV4OpenRouterPayload(model: Parameters[0]): } function shouldPatchOpenRouterRoutingPayload(model: Parameters[0]): boolean { - const api = readString(model.api); + const api = normalizeOpenRouterStringPreservingEmpty(model.api); return (api === undefined || api === "openai-completions") && isVerifiedOpenRouterRoute(model); } function mergeOpenRouterAuthHeaders(options: Parameters[2]): Parameters[2] { - const apiKey = readString(options?.apiKey); + const apiKey = normalizeOpenRouterStringPreservingEmpty(options?.apiKey); if (!apiKey) { return options; } diff --git a/extensions/policy/src/doctor/ingress-findings.ts b/extensions/policy/src/doctor/ingress-findings.ts index 6ae1a4d6d246..cf2835244bb4 100644 --- a/extensions/policy/src/doctor/ingress-findings.ts +++ b/extensions/policy/src/doctor/ingress-findings.ts @@ -6,7 +6,7 @@ import { CHECK_IDS, POLICY_CHECK_IDS } from "./check-ids.js"; import { normalizePolicyChannelId } from "./policy-runtime.js"; import { channelScopedPolicyTargets } from "./policy-scope.js"; import { hasValidScopedPolicy } from "./scoped-policy-shape.js"; -import { ocPathSegment, readPolicyBoolean, readString, readStringList } from "./utils.js"; +import { ocPathSegment, readPolicyBoolean, readPolicyPathString, readStringList } from "./utils.js"; export function ingressFindings( policy: unknown, @@ -104,7 +104,7 @@ function ingressDmScopeFindings( evidence: PolicyEvidence, evidenceFilter: (entry: PolicyIngressEvidence) => boolean, ): readonly HealthFinding[] { - const required = readString(ingressPolicy, ["session", "requireDmScope"]); + const required = readPolicyPathString(ingressPolicy, ["session", "requireDmScope"]); if (required === undefined) { return []; } diff --git a/extensions/policy/src/doctor/utils.ts b/extensions/policy/src/doctor/utils.ts index 6cff34bba5a8..11b644a7743d 100644 --- a/extensions/policy/src/doctor/utils.ts +++ b/extensions/policy/src/doctor/utils.ts @@ -33,7 +33,7 @@ export function readStringList( return readPolicyStringArray(policy, path, options) ?? []; } -export function readString(policy: unknown, path: readonly string[]): string | undefined { +export function readPolicyPathString(policy: unknown, path: readonly string[]): string | undefined { let current: unknown = policy; for (const part of path) { if (!isRecord(current)) { diff --git a/extensions/qa-lab/src/confidence-report.ts b/extensions/qa-lab/src/confidence-report.ts index 5fc580e76755..712011953c99 100644 --- a/extensions/qa-lab/src/confidence-report.ts +++ b/extensions/qa-lab/src/confidence-report.ts @@ -2,7 +2,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean as readBoolean, + asFiniteNumber as readNumber, + isRecord, + normalizeOptionalString as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatGatewayLogSentinelSummary, type GatewayLogSentinelFinding, @@ -141,22 +146,10 @@ const QA_CONFIDENCE_SELF_TEST_CANARY_IDS = [ "jsonl-replay-ordering-drift", ] as const; -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - -function readNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function readCount(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined; } -function readBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - function readStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; diff --git a/extensions/qa-lab/src/evidence-gallery.ts b/extensions/qa-lab/src/evidence-gallery.ts index 633d9a93fa4e..21ff145e6d0f 100644 --- a/extensions/qa-lab/src/evidence-gallery.ts +++ b/extensions/qa-lab/src/evidence-gallery.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { StringDecoder } from "node:string_decoder"; import { pathToFileURL } from "node:url"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; import pLimit from "p-limit"; import type { QaEvidenceArtifactView, @@ -564,10 +565,6 @@ async function buildArtifactView(params: { }; } -function readString(value: unknown): string | null { - return typeof value === "string" ? value : null; -} - function readRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -610,7 +607,7 @@ function readMatrixDimensionIds(params: { if (typeof entry === "string") { return entry; } - return readString(readRecord(entry)?.id); + return readStringValue(readRecord(entry)?.id) ?? null; }), params, ); @@ -667,9 +664,9 @@ function readMatrixCells(params: { : []; const entriesByCell = buildUxMatrixEvidenceEntryIndex(params.summaryEntries); return rawCells.flatMap((cell): QaEvidenceMatrixCellView[] => { - const rawSurface = readString(cell.surface); - const rawStage = readString(cell.stage); - const rawStatus = readString(cell.status) ?? "proof-gap"; + const rawSurface = readStringValue(cell.surface) ?? null; + const rawStage = readStringValue(cell.stage) ?? null; + const rawStatus = readStringValue(cell.status) ?? "proof-gap"; if (!rawSurface || !rawStage) { return []; } @@ -683,7 +680,7 @@ function readMatrixCells(params: { repoRoot: params.repoRoot, }); const readRunnerString = (value: unknown) => { - const text = readString(value); + const text = readStringValue(value); return text ? sanitizeCellString(text) : null; }; return [ @@ -795,8 +792,8 @@ async function buildProducerContext(params: { const matrix = await readJsonIfExists(matrixPath, allowedRoots); const releaseLedger = await readJsonIfExists(releaseLedgerPath, allowedRoots); const run = readRecord(manifest?.run); - const runId = readString(run?.runId); - const runStatus = readString(run?.status); + const runId = readStringValue(run?.runId) ?? null; + const runStatus = readStringValue(run?.status) ?? null; const producerFiles = Object.fromEntries( await Promise.all( UX_MATRIX_PRODUCER_FILES.map(async (file) => [ diff --git a/extensions/qa-lab/src/jsonl-replay.ts b/extensions/qa-lab/src/jsonl-replay.ts index a0c9daa47bd4..5de5092c4d53 100644 --- a/extensions/qa-lab/src/jsonl-replay.ts +++ b/extensions/qa-lab/src/jsonl-replay.ts @@ -1,7 +1,7 @@ // Qa Lab plugin module implements jsonl replay behavior. import fs from "node:fs/promises"; import path from "node:path"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { runRuntimeParityScenario, type RuntimeId, @@ -52,23 +52,19 @@ type JsonlReplayMarkdownReport = { transcripts: JsonlReplayResult["transcripts"]; }; -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; } - return readString(record.role) ? record : undefined; + return normalizeOptionalString(record.role) ? record : undefined; } function readRole(message: Record) { - return readString(message.role)?.toLowerCase(); + return normalizeOptionalString(message.role)?.toLowerCase(); } function isTextLikeContentBlock(block: Record) { - const type = readString(block.type)?.toLowerCase(); + const type = normalizeOptionalString(block.type)?.toLowerCase(); return ( !type || type === "text" || @@ -97,7 +93,7 @@ function extractTextContent(content: unknown): string { if (!isRecord(block) || !isTextLikeContentBlock(block)) { continue; } - const text = readString(block.text) ?? readString(block.content); + const text = normalizeOptionalString(block.text) ?? normalizeOptionalString(block.content); if (text) { parts.push(text); } diff --git a/extensions/qa-lab/src/live-transports/matrix/substrate/config.test.ts b/extensions/qa-lab/src/live-transports/matrix/substrate/config.test.ts index 767a66234200..9399ee2c2869 100644 --- a/extensions/qa-lab/src/live-transports/matrix/substrate/config.test.ts +++ b/extensions/qa-lab/src/live-transports/matrix/substrate/config.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { buildMatrixQaConfig } from "./config.js"; import type { MatrixQaProvisionedTopology } from "./topology.js"; -function asRecord(value: unknown): Record { +function castRecord(value: unknown): Record { return value as Record; } @@ -385,19 +385,19 @@ describe("matrix qa config", () => { }, } as unknown as OpenClawConfig; const current = structuredClone(baseline) as OpenClawConfig & Record; - const currentRoot = asRecord(current); + const currentRoot = castRecord(current); currentRoot.unrelated = { currentOnly: true }; - asRecord(currentRoot.approvals).exec = { + castRecord(currentRoot.approvals).exec = { enabled: true, mode: "session", reviewer: "current", }; - asRecord(currentRoot.agents).defaults = { + castRecord(currentRoot.agents).defaults = { blockStreamingChunk: { maxChars: 8 }, adjacentDefault: "current", }; - const currentTools = asRecord(currentRoot.tools); - const currentMedia = asRecord(currentTools.media); + const currentTools = castRecord(currentRoot.tools); + const currentMedia = castRecord(currentTools.media); currentTools.profile = "coding"; currentMedia.models = [{ provider: "openai", model: "previous" }]; currentMedia.audio = { @@ -409,16 +409,16 @@ describe("matrix qa config", () => { rules: [{ action: "deny", match: { chatType: "group" } }], }, }; - asRecord(currentRoot.messages).groupChat = { + castRecord(currentRoot.messages).groupChat = { mentionPatterns: ["previous"], adjacentMessage: "current", }; - const currentMatrix = asRecord(asRecord(currentRoot.channels).matrix); + const currentMatrix = castRecord(castRecord(currentRoot.channels).matrix); currentMatrix.unknownRoot = "current"; - const currentAccounts = asRecord(currentMatrix.accounts); + const currentAccounts = castRecord(currentMatrix.accounts); currentAccounts.sibling = { enabled: false, homeserver: "https://sibling.invalid" }; currentAccounts["qa-driver-bot-source"] = { enabled: false, userId: "@stale:test" }; - const currentSut = asRecord(currentAccounts.sut); + const currentSut = castRecord(currentAccounts.sut); currentAccounts.sut = { ...currentSut, allowBots: "mentions", @@ -426,7 +426,7 @@ describe("matrix qa config", () => { deviceId: "CURRENT-DEVICE", lifecycleState: "current", dm: { - ...asRecord(currentSut.dm), + ...castRecord(currentSut.dm), allowFrom: ["@previous:test"], adjacentDm: "current", sessionScope: "per-user", @@ -440,7 +440,7 @@ describe("matrix qa config", () => { target: "both", }, groups: { - ...asRecord(currentSut.groups), + ...castRecord(currentSut.groups), "!main:matrix-qa.test": { allowBots: "mentions", adjacentGroup: "current", @@ -482,7 +482,7 @@ describe("matrix qa config", () => { topology, }); - expect(asRecord(next).unrelated).toEqual({ currentOnly: true }); + expect(castRecord(next).unrelated).toEqual({ currentOnly: true }); expect(next.approvals?.exec).toEqual({ enabled: false, mode: "session", @@ -513,7 +513,7 @@ describe("matrix qa config", () => { expect(next.channels?.matrix).toMatchObject({ unknownRoot: "current" }); expect(next.channels?.matrix?.accounts?.sibling).toEqual(currentAccounts.sibling); expect(next.channels?.matrix?.accounts?.["qa-driver-bot-source"]).toBeUndefined(); - const sut = asRecord(next.channels?.matrix?.accounts?.sut); + const sut = castRecord(next.channels?.matrix?.accounts?.sut); expect(sut).toMatchObject({ allowBots: false, autoJoin: "allowlist", @@ -548,7 +548,7 @@ describe("matrix qa config", () => { idleHours: 12, }, }); - const sutGroups = asRecord(sut.groups); + const sutGroups = castRecord(sut.groups); expect(sutGroups["!main:matrix-qa.test"]).toEqual({ adjacentGroup: "current", allowBots: false, diff --git a/extensions/qa-lab/src/runtime-tool-fixture.ts b/extensions/qa-lab/src/runtime-tool-fixture.ts index 0d0ea20478ba..b64cc7a9557a 100644 --- a/extensions/qa-lab/src/runtime-tool-fixture.ts +++ b/extensions/qa-lab/src/runtime-tool-fixture.ts @@ -4,7 +4,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { loadTranscriptEventsSync } from "openclaw/plugin-sdk/session-store-runtime"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean, + isRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { QaSuiteInfraError, QaSuiteScenarioSkipError } from "./errors.js"; import { qaMockRequestCursorUrl, @@ -111,14 +115,6 @@ type QaRuntimeToolFixtureDeps = { ensureImageGenerationConfigured: (env: QaSuiteRuntimeEnv) => Promise; }; -function readString(raw: unknown, fallback = "") { - return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : fallback; -} - -function readBoolean(raw: unknown, fallback: boolean) { - return typeof raw === "boolean" ? raw : fallback; -} - function isKnownBroken(raw: unknown): raw is Record { return isRecord(raw); } @@ -813,8 +809,8 @@ function formatKnownBrokenDetails( config: QaRuntimeToolFixtureConfig, ) { const knownBroken = isKnownBroken(config.knownBroken) ? config.knownBroken : {}; - const issue = readString(knownBroken.issue); - const reason = readString(knownBroken.reason, "known broken runtime tool fixture"); + const issue = normalizeOptionalString(knownBroken.issue) ?? ""; + const reason = normalizeOptionalString(knownBroken.reason) ?? "known broken runtime tool fixture"; return [ `known-broken ${toolName}: ${reason}`, issue ? `tracking: ${issue}` : undefined, @@ -886,8 +882,8 @@ function plannedRequestHasPrompt(request: QaRuntimeToolFixtureRequest) { function formatKnownHarnessGapDetails(toolName: string, config: QaRuntimeToolFixtureConfig) { const knownHarnessGap = isKnownHarnessGap(config.knownHarnessGap) ? config.knownHarnessGap : {}; - const issue = readString(knownHarnessGap.issue); - const reason = readString(knownHarnessGap.reason, "known QA harness gap"); + const issue = normalizeOptionalString(knownHarnessGap.issue) ?? ""; + const reason = normalizeOptionalString(knownHarnessGap.reason) ?? "known QA harness gap"; return [`known-harness-gap ${toolName}: ${reason}`, issue ? `tracking: ${issue}` : undefined] .filter(Boolean) .join("\n"); @@ -898,7 +894,7 @@ export async function runRuntimeToolFixture( config: QaRuntimeToolFixtureConfig, deps: QaRuntimeToolFixtureDeps, ) { - const toolName = readString(config.toolName); + const toolName = normalizeOptionalString(config.toolName) ?? ""; if (!toolName) { throw new Error("runtime tool fixture missing execution.config.toolName"); } @@ -947,7 +943,7 @@ export async function runRuntimeToolFixture( const dynamicExposureIntentionallyExcluded = forcedCodexNativeWorkspace && !tools.has(toolName); const requireCodexNativePatchCoverage = forcedCodexNativeWorkspace && metadata.required && toolName === "apply_patch"; - const expectedAvailable = readBoolean(config.expectedAvailable, true); + const expectedAvailable = asBoolean(config.expectedAvailable) ?? true; if (!tools.has(toolName) && !dynamicExposureIntentionallyExcluded) { if (!expectedAvailable) { skipFixture(formatExpectedUnavailableDetails(toolName, tools)); @@ -965,20 +961,16 @@ export async function runRuntimeToolFixture( ); } - const happyPrompt = readString( - config.happyPrompt, - `tool search qa check target=${toolName}. Call exactly that tool once and then summarize.`, - ); - const failurePrompt = readString( - config.failurePrompt, - `tool search qa failure target=${toolName}. Exercise the denied-input path once and then summarize.`, - ); - const promptSnippet = readString(config.promptSnippet, `target=${toolName}`); - const failurePromptSnippet = readString( - config.failurePromptSnippet, - `failure target=${toolName}`, - ); - const happyPathOutputRequired = readBoolean(config.happyPathOutputRequired, true); + const happyPrompt = + normalizeOptionalString(config.happyPrompt) ?? + `tool search qa check target=${toolName}. Call exactly that tool once and then summarize.`; + const failurePrompt = + normalizeOptionalString(config.failurePrompt) ?? + `tool search qa failure target=${toolName}. Exercise the denied-input path once and then summarize.`; + const promptSnippet = normalizeOptionalString(config.promptSnippet) ?? `target=${toolName}`; + const failurePromptSnippet = + normalizeOptionalString(config.failurePromptSnippet) ?? `failure target=${toolName}`; + const happyPathOutputRequired = asBoolean(config.happyPathOutputRequired) ?? true; // Private QA can expose an actual dynamic patch tool. Real Codex instead // mirrors native file changes into linked apply_patch transcript evidence. const requireNativePatchTranscriptEvidence = !env.mock && requireCodexNativePatchCoverage; diff --git a/extensions/qa-lab/web/src/capture-saved-view.ts b/extensions/qa-lab/web/src/capture-saved-view.ts index 787a67ebc4ed..313afd8c0a5b 100644 --- a/extensions/qa-lab/web/src/capture-saved-view.ts +++ b/extensions/qa-lab/web/src/capture-saved-view.ts @@ -34,7 +34,7 @@ const payloadLayouts = new Set>([ ]); const payloadExtents = new Set(["preview", "full"]); -function readString(value: unknown, maxLength: number): string | null { +function readBoundedString(value: unknown, maxLength: number): string | null { if (typeof value !== "string") { return null; } @@ -73,8 +73,8 @@ export function normalizeCaptureSavedView(value: unknown): CaptureSavedView | nu if (!record) { return null; } - const id = readString(record.id, MAX_FILTER_VALUE_LENGTH); - const name = readString(record.name, MAX_NAME_LENGTH); + const id = readBoundedString(record.id, MAX_FILTER_VALUE_LENGTH); + const name = readBoundedString(record.name, MAX_NAME_LENGTH); if (!id || !name) { return null; } diff --git a/extensions/qqbot/src/bridge/config-shared.ts b/extensions/qqbot/src/bridge/config-shared.ts index 777340ca816a..a5fd7e0d1810 100644 --- a/extensions/qqbot/src/bridge/config-shared.ts +++ b/extensions/qqbot/src/bridge/config-shared.ts @@ -4,6 +4,7 @@ import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { applyAccountNameToChannelSection } from "openclaw/plugin-sdk/core"; import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { describeAccount as engineDescribeAccount, formatAllowFrom as engineFormatAllowFrom, @@ -13,7 +14,6 @@ import { applySetupAccountConfig as engineApplySetupAccountConfig, validateSetupInput as engineValidateSetupInput, } from "../engine/config/setup-logic.js"; -import { normalizeLowercaseStringOrEmpty } from "../engine/utils/string-normalize.js"; import type { ResolvedQQBotAccount } from "../types.js"; import { listQQBotAccountIds, diff --git a/extensions/qqbot/src/bridge/config.ts b/extensions/qqbot/src/bridge/config.ts index 95ec9906cff3..f2bb97f3c2c5 100644 --- a/extensions/qqbot/src/bridge/config.ts +++ b/extensions/qqbot/src/bridge/config.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; import { coerceSecretRef, normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getPlatformAdapter } from "../engine/adapter/index.js"; import { DEFAULT_ACCOUNT_ID as ENGINE_DEFAULT_ACCOUNT_ID, @@ -11,7 +12,6 @@ import { resolveAccountBase, resolveDefaultAccountId, } from "../engine/config/resolve.js"; -import { normalizeOptionalString } from "../engine/utils/string-normalize.js"; import type { ResolvedQQBotAccount, QQBotAccountConfig } from "../types.js"; export const DEFAULT_ACCOUNT_ID = ENGINE_DEFAULT_ACCOUNT_ID; diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts index 4941fcfe74c1..20cc68bd3bc6 100644 --- a/extensions/qqbot/src/channel.ts +++ b/extensions/qqbot/src/channel.ts @@ -11,6 +11,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { channelReadyPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; // Register the PlatformAdapter before any core/ module is used. import "./bridge/bootstrap.js"; import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking"; @@ -36,7 +37,6 @@ import { looksLikeQQBotTarget, parseTarget, } from "./engine/messaging/target-parser.js"; -import { normalizeOptionalString } from "./engine/utils/string-normalize.js"; import { resolveQQBotGroupToolPolicy } from "./group-policy.js"; import type { ResolvedQQBotAccount } from "./types.js"; diff --git a/extensions/qqbot/src/engine/config/credentials.ts b/extensions/qqbot/src/engine/config/credentials.ts index d869ff1ec542..4e0a62a8098c 100644 --- a/extensions/qqbot/src/engine/config/credentials.ts +++ b/extensions/qqbot/src/engine/config/credentials.ts @@ -7,7 +7,7 @@ * and stay framework-agnostic. */ -import { asOptionalObjectRecord as asRecord } from "../utils/string-normalize.js"; +import { asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_ACCOUNT_ID } from "./resolve.js"; // ---- Logout: clear all credential fields for an account ---- @@ -29,8 +29,8 @@ export function clearAccountCredentials( accountId: string, ): ClearCredentialsResult { const nextCfg = { ...cfg }; - const channels = asRecord(cfg.channels); - const nextQQBot = channels?.qqbot ? { ...asRecord(channels.qqbot) } : undefined; + const channels = asOptionalObjectRecord(cfg.channels); + const nextQQBot = channels?.qqbot ? { ...asOptionalObjectRecord(channels.qqbot) } : undefined; let cleared = false; let changed = false; diff --git a/extensions/qqbot/src/engine/config/group.ts b/extensions/qqbot/src/engine/config/group.ts index 690f53b96d1f..1684c781b041 100644 --- a/extensions/qqbot/src/engine/config/group.ts +++ b/extensions/qqbot/src/engine/config/group.ts @@ -1,7 +1,6 @@ // Qqbot plugin module implements group behavior. import { resolveScopeRequireMention, type ScopeTree } from "openclaw/plugin-sdk/channel-policy"; -import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { asOptionalObjectRecord as asRecord } from "../utils/string-normalize.js"; +import { asBoolean, asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveAccountBase } from "./resolve.js"; interface GroupConfig { @@ -36,13 +35,13 @@ function readGroupsMap( accountId?: string | null, ): Record> { const account = resolveAccountBase(cfg, accountId); - const groups = asRecord(account.config.groups); + const groups = asOptionalObjectRecord(account.config.groups); if (!groups) { return {}; } const normalized: Record> = {}; for (const [key, value] of Object.entries(groups)) { - const sub = asRecord(value); + const sub = asOptionalObjectRecord(value); if (sub) { normalized[key] = sub; } @@ -50,11 +49,7 @@ function readGroupsMap( return normalized; } -function readBoolean(obj: Record, key: string): boolean | undefined { - return asBoolean(obj[key]); -} - -function readString(obj: Record, key: string): string | undefined { +function readNonEmptyGroupString(obj: Record, key: string): string | undefined { const v = obj[key]; return typeof v === "string" && v.length > 0 ? v : undefined; } @@ -63,7 +58,7 @@ function readCommandLevel( obj: Record, key: string, ): QQBotGroupCommandLevel | undefined { - const v = readString(obj, key); + const v = readNonEmptyGroupString(obj, key); return v === "all" || v === "safety" || v === "strict" ? v : undefined; } @@ -88,11 +83,11 @@ export function resolveGroupConfig( // 账户级默认值:defaultRequireMention 配置 > 默认 true const accountDefaultRequireMention = asBoolean(account.config.defaultRequireMention); const mentionTree: ScopeTree = { - defaults: { requireMention: readBoolean(wildcard, "requireMention") }, + defaults: { requireMention: asBoolean(wildcard.requireMention) }, scopes: Object.fromEntries( Object.entries(scopes).map(([key, entry]) => [ key, - { requireMention: readBoolean(entry, "requireMention") }, + { requireMention: asBoolean(entry.requireMention) }, ]), ), }; @@ -109,15 +104,19 @@ export function resolveGroupConfig( overrideOrder: "after-config", }), ignoreOtherMentions: - readBoolean(specific, "ignoreOtherMentions") ?? - readBoolean(wildcard, "ignoreOtherMentions") ?? + asBoolean(specific.ignoreOtherMentions) ?? + asBoolean(wildcard.ignoreOtherMentions) ?? DEFAULT_GROUP_CONFIG.ignoreOtherMentions, commandLevel: readCommandLevel(specific, "commandLevel") ?? readCommandLevel(wildcard, "commandLevel") ?? DEFAULT_GROUP_CONFIG.commandLevel, - name: readString(specific, "name") ?? readString(wildcard, "name") ?? DEFAULT_GROUP_CONFIG.name, - prompt: readString(specific, "prompt") ?? readString(wildcard, "prompt"), + name: + readNonEmptyGroupString(specific, "name") ?? + readNonEmptyGroupString(wildcard, "name") ?? + DEFAULT_GROUP_CONFIG.name, + prompt: + readNonEmptyGroupString(specific, "prompt") ?? readNonEmptyGroupString(wildcard, "prompt"), historyLimit: readHistoryLimit(specific, "historyLimit") ?? readHistoryLimit(wildcard, "historyLimit") ?? @@ -129,9 +128,9 @@ export function resolveGroupCommandLevelFromAccountConfig( accountConfig: Record | undefined, groupOpenid?: string | null, ): QQBotGroupCommandLevel { - const groups = asRecord(accountConfig?.groups); - const wildcard = asRecord(groups?.["*"]) ?? {}; - const specific = groupOpenid ? (asRecord(groups?.[groupOpenid]) ?? {}) : {}; + const groups = asOptionalObjectRecord(accountConfig?.groups); + const wildcard = asOptionalObjectRecord(groups?.["*"]) ?? {}; + const specific = groupOpenid ? (asOptionalObjectRecord(groups?.[groupOpenid]) ?? {}) : {}; return ( readCommandLevel(specific, "commandLevel") ?? readCommandLevel(wildcard, "commandLevel") ?? @@ -181,7 +180,7 @@ export function resolveMentionPatterns( agentId?: string | null, ): string[] { if (agentId) { - const agents = asRecord(cfg.agents); + const agents = asOptionalObjectRecord(cfg.agents); const list = Array.isArray(agents?.list) ? (agents?.list as AgentEntry[]) : []; const entry = list.find( (a) => typeof a.id === "string" && a.id.trim().toLowerCase() === agentId.trim().toLowerCase(), @@ -195,8 +194,8 @@ export function resolveMentionPatterns( } } - const messages = asRecord(cfg.messages); - const globalGroupChat = asRecord(messages?.groupChat); + const messages = asOptionalObjectRecord(cfg.messages); + const globalGroupChat = asOptionalObjectRecord(messages?.groupChat); if (globalGroupChat && Object.hasOwn(globalGroupChat, "mentionPatterns")) { const patterns = globalGroupChat.mentionPatterns; return Array.isArray(patterns) diff --git a/extensions/qqbot/src/engine/config/resolve.ts b/extensions/qqbot/src/engine/config/resolve.ts index 987f7da2bb62..7a1210c99afb 100644 --- a/extensions/qqbot/src/engine/config/resolve.ts +++ b/extensions/qqbot/src/engine/config/resolve.ts @@ -8,14 +8,14 @@ * this module stays framework-agnostic and self-contained. */ -import { getPlatformAdapter } from "../adapter/index.js"; import { - asOptionalObjectRecord as asRecord, + asOptionalObjectRecord, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringifiedEntries, - readStringField as readString, -} from "../utils/string-normalize.js"; + readStringField, +} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { getPlatformAdapter } from "../adapter/index.js"; /** * Default account ID, used for the unnamed top-level account. @@ -73,7 +73,7 @@ function normalizeAccountConfig( if (!account) { return {}; } - const audioPolicy = asRecord(account.audioFormatPolicy); + const audioPolicy = asOptionalObjectRecord(account.audioFormatPolicy); return { ...account, ...(audioPolicy ? { audioFormatPolicy: { ...audioPolicy } } : {}), @@ -81,8 +81,8 @@ function normalizeAccountConfig( } function readQQBotSection(cfg: Record): QQBotChannelConfig | undefined { - const channels = asRecord(cfg.channels); - return asRecord(channels?.qqbot) as QQBotChannelConfig | undefined; + const channels = asOptionalObjectRecord(cfg.channels); + return asOptionalObjectRecord(channels?.qqbot) as QQBotChannelConfig | undefined; } function readOwnAccounts( @@ -91,7 +91,7 @@ function readOwnAccounts( if (!qqbot || !Object.hasOwn(qqbot, "accounts")) { return undefined; } - return asRecord(qqbot.accounts) as QQBotChannelConfig["accounts"] | undefined; + return asOptionalObjectRecord(qqbot.accounts) as QQBotChannelConfig["accounts"] | undefined; } function readOwnAccountConfig( @@ -102,7 +102,7 @@ function readOwnAccountConfig( if (!accounts || !Object.hasOwn(accounts, accountId)) { return undefined; } - const account = asRecord(accounts[accountId]); + const account = asOptionalObjectRecord(accounts[accountId]); return account ? { ...account } : undefined; } @@ -177,7 +177,7 @@ export function resolveAccountBase( if (resolvedAccountId === DEFAULT_ACCOUNT_ID) { accountConfig = normalizeAccountConfig({ - ...asRecord(qqbot), + ...asOptionalObjectRecord(qqbot), ...readOwnAccountConfig(qqbot, DEFAULT_ACCOUNT_ID), }); appId = normalizeAppId(accountConfig.appId); @@ -193,10 +193,10 @@ export function resolveAccountBase( return { accountId: resolvedAccountId, - name: readString(accountConfig, "name"), + name: readStringField(accountConfig, "name"), enabled: accountConfig.enabled !== false, appId, - systemPrompt: readString(accountConfig, "systemPrompt"), + systemPrompt: readStringField(accountConfig, "systemPrompt"), markdownSupport: accountConfig.markdownSupport !== false, config: accountConfig, }; @@ -218,8 +218,8 @@ export function applyAccountConfig( input: ApplyAccountInput, ): Record { const next = { ...cfg }; - const channels = asRecord(cfg.channels) ?? {}; - const existingQQBot = asRecord(channels.qqbot) ?? {}; + const channels = asOptionalObjectRecord(cfg.channels) ?? {}; + const existingQQBot = asOptionalObjectRecord(channels.qqbot) ?? {}; if (accountId === DEFAULT_ACCOUNT_ID) { const allowFrom = (existingQQBot.allowFrom as unknown[]) ?? ["*"]; diff --git a/extensions/qqbot/src/engine/gateway/inbound-attachments.ts b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts index b1a14146c31c..1fb589e7a290 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-attachments.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts @@ -1,11 +1,11 @@ // Qqbot plugin module implements inbound attachments behavior. import { normalizeMimeType } from "openclaw/plugin-sdk/media-mime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { AudioConvertPort } from "../adapter/audio.port.js"; import { downloadFile } from "../utils/file-utils.js"; import { getQQBotMediaDir } from "../utils/platform.js"; -import { normalizeOptionalString } from "../utils/string-normalize.js"; import { transcribeAudio, resolveSTTConfig } from "../utils/stt.js"; interface RawAttachment { diff --git a/extensions/qqbot/src/engine/messaging/outbound-deliver.ts b/extensions/qqbot/src/engine/messaging/outbound-deliver.ts index 2f861932495d..1bc4f4f88828 100644 --- a/extensions/qqbot/src/engine/messaging/outbound-deliver.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-deliver.ts @@ -11,15 +11,15 @@ import { sendPayloadMediaSequence, sendPayloadTextChunkSequence, } from "openclaw/plugin-sdk/reply-payload"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { GatewayAccount } from "../types.js"; import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "../utils/image-size.js"; import { normalizeMediaTags } from "../utils/media-tags.js"; import { isLocalPath as isLocalFilePath } from "../utils/platform.js"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "../utils/string-normalize.js"; import { filterInternalMarkers } from "../utils/text-parsing.js"; import { decodeMediaPath } from "./decode-media-path.js"; import { DEFAULT_MEDIA_SEND_ERROR, type OutboundMediaAccessContext } from "./outbound-types.js"; diff --git a/extensions/qqbot/src/engine/messaging/outbound-media-send.ts b/extensions/qqbot/src/engine/messaging/outbound-media-send.ts index 237407aff1d1..1383c483e7e4 100644 --- a/extensions/qqbot/src/engine/messaging/outbound-media-send.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-media-send.ts @@ -14,6 +14,7 @@ import { sanitizeUntrustedFileName, writeExternalFileWithinRoot, } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { GatewayAccount } from "../types.js"; import { MediaFileType } from "../types.js"; @@ -33,7 +34,7 @@ import { isLocalPath as isLocalFilePath, normalizePath, } from "../utils/platform.js"; -import { normalizeLowercaseStringOrEmpty, sanitizeFileName } from "../utils/string-normalize.js"; +import { sanitizeFileName } from "../utils/string-normalize.js"; import { audioFileToSilkBase64, shouldTranscodeVoice, waitForFile } from "./outbound-audio-port.js"; import { isPathWithinRoot, diff --git a/extensions/qqbot/src/engine/messaging/outbound.ts b/extensions/qqbot/src/engine/messaging/outbound.ts index b5ed81b2a9cb..5d635405ecae 100644 --- a/extensions/qqbot/src/engine/messaging/outbound.ts +++ b/extensions/qqbot/src/engine/messaging/outbound.ts @@ -34,6 +34,10 @@ export { } from "./outbound-media-send.js"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { qqbotNotConfiguredMessage } from "../config/setup-guidance.js"; import type { GatewayAccount } from "../types.js"; @@ -41,10 +45,6 @@ import type { EngineLogger } from "../types.js"; import { debugError, debugLog, debugWarn } from "../utils/log.js"; import { normalizeMediaTags } from "../utils/media-tags.js"; import { decodeCronPayload } from "../utils/payload.js"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "../utils/string-normalize.js"; import { decodeMediaPath } from "./decode-media-path.js"; import { isImageFile as coreIsImageFile, diff --git a/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts index fe6bd54953d9..cd9b461d87fa 100644 --- a/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts +++ b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts @@ -9,6 +9,7 @@ import crypto from "node:crypto"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { MediaFileType, type GatewayAccount } from "../types.js"; import { formatFileSize, getImageMimeType, getMaxUploadSize } from "../utils/file-utils.js"; @@ -20,7 +21,6 @@ import { type MediaPayload, } from "../utils/payload.js"; import { normalizePath } from "../utils/platform.js"; -import { normalizeLowercaseStringOrEmpty } from "../utils/string-normalize.js"; import { sanitizeFileName } from "../utils/string-normalize.js"; import { openLocalFile } from "./media-source.js"; import { diff --git a/extensions/qqbot/src/engine/utils/audio.ts b/extensions/qqbot/src/engine/utils/audio.ts index d627cfbfb6e1..6f9ca9ca6a7d 100644 --- a/extensions/qqbot/src/engine/utils/audio.ts +++ b/extensions/qqbot/src/engine/utils/audio.ts @@ -14,8 +14,8 @@ import * as path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; import { readRegularFileSync } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { debugLog, debugError, debugWarn } from "./log.js"; -import { normalizeLowercaseStringOrEmpty as normalizeLowercase } from "./string-normalize.js"; type SilkWasm = typeof import("silk-wasm"); let silkWasmPromise: Promise | null = null; @@ -116,11 +116,11 @@ export function isVoiceAttachment(att: { content_type?: string; filename?: strin // MIME types are case-insensitive (RFC 2045) and relays may emit mixed-case // values; the bare "voice" platform sentinel gets the same treatment. // Compare lowercased like the extension check below. - const contentType = normalizeLowercase(att.content_type); + const contentType = normalizeLowercaseStringOrEmpty(att.content_type); if (contentType === "voice" || contentType.startsWith("audio/")) { return true; } - const ext = att.filename ? normalizeLowercase(path.extname(att.filename)) : ""; + const ext = att.filename ? normalizeLowercaseStringOrEmpty(path.extname(att.filename)) : ""; return [".amr", ".silk", ".slk", ".slac"].includes(ext); } @@ -131,7 +131,7 @@ export function isAudioFile(filePath: string, mimeType?: string): boolean { return true; } } - const ext = normalizeLowercase(path.extname(filePath)); + const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); return [ ".silk", ".slk", @@ -162,10 +162,10 @@ const QQ_NATIVE_VOICE_EXTS = new Set([".silk", ".slk", ".amr", ".wav", ".mp3"]); /** Check whether a voice file needs transcoding for upload (QQ-native formats skip it). */ export function shouldTranscodeVoice(filePath: string, mimeType?: string): boolean { - if (mimeType && QQ_NATIVE_VOICE_MIMES.has(normalizeLowercase(mimeType))) { + if (mimeType && QQ_NATIVE_VOICE_MIMES.has(normalizeLowercaseStringOrEmpty(mimeType))) { return false; } - const ext = normalizeLowercase(path.extname(filePath)); + const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); if (QQ_NATIVE_VOICE_EXTS.has(ext)) { return false; } @@ -176,7 +176,7 @@ const QQ_NATIVE_UPLOAD_FORMATS = [".wav", ".mp3", ".silk"]; function normalizeFormats(formats: string[]): string[] { return formats.map((f) => { - const lower = normalizeLowercase(f); + const lower = normalizeLowercaseStringOrEmpty(f); return lower.startsWith(".") ? lower : `.${lower}`; }); } @@ -202,7 +202,7 @@ export async function audioFileToSilkBase64( return null; } - const ext = normalizeLowercase(path.extname(filePath)); + const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); const uploadFormats = directUploadFormats ? normalizeFormats(directUploadFormats) : QQ_NATIVE_UPLOAD_FORMATS; diff --git a/extensions/qqbot/src/engine/utils/file-utils.ts b/extensions/qqbot/src/engine/utils/file-utils.ts index 911ccb61264b..77a95ff1e6f7 100644 --- a/extensions/qqbot/src/engine/utils/file-utils.ts +++ b/extensions/qqbot/src/engine/utils/file-utils.ts @@ -10,10 +10,10 @@ import { readRegularFile, statRegularFileSync, } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getPlatformAdapter } from "../adapter/index.js"; import type { SsrfPolicyConfig } from "../adapter/types.js"; import { MediaFileType } from "../types.js"; -import { normalizeOptionalString } from "./string-normalize.js"; /** Maximum file size accepted by the QQ Bot one-shot upload API (base64 direct). */ export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; diff --git a/extensions/qqbot/src/engine/utils/string-normalize.ts b/extensions/qqbot/src/engine/utils/string-normalize.ts index 4ab024d4d2b8..c8bd84ee5a36 100644 --- a/extensions/qqbot/src/engine/utils/string-normalize.ts +++ b/extensions/qqbot/src/engine/utils/string-normalize.ts @@ -1,77 +1,4 @@ -/** - * String normalization and record-coercion helpers. - * - * These are self-contained re-implementations of the functions that - * the plugin previously imported from broad SDK text barrels - * and shared record/string coercion helpers. - * - * core/ modules use these instead of importing plugin-sdk, keeping the - * shared layer portable between the built-in and standalone versions. - */ - -// ---- String coercion ---- - -/** Return the trimmed string or `null` when the value is not a non-empty string. */ -function normalizeNullableString(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed ? trimmed : null; -} - -/** Return the trimmed string or `undefined` when the value is not a non-empty string. */ -export function normalizeOptionalString(value: unknown): string | undefined { - return normalizeNullableString(value) ?? undefined; -} - -/** - * Stringify then normalize. Accepts `string | number | boolean | bigint`. - * Returns `undefined` for objects, arrays, null, and undefined. - */ -function normalizeStringifiedOptionalString(value: unknown): string | undefined { - if (typeof value === "string") { - return normalizeOptionalString(value); - } - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { - return normalizeOptionalString(String(value)); - } - 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(); -} - -/** Return the trimmed lowercase string or `""`. */ -export function normalizeLowercaseStringOrEmpty(value: unknown): string { - return normalizeOptionalLowercaseString(value) ?? ""; -} - -// ---- Record coercion ---- - -/** Coerce a value into a `Record` or `undefined`. */ -export function asOptionalObjectRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - -/** Read a string field from a record. */ -export function readStringField( - record: Record | null | undefined, - key: string, -): string | undefined { - const v = record?.[key]; - return typeof v === "string" ? v : undefined; -} - -// ---- Filename normalization ---- +// Filename normalization specific to QQ Bot's upload API. /** * Normalize filenames into a UTF-8 form that the QQ Bot API accepts reliably. diff --git a/extensions/qqbot/src/engine/utils/stt.ts b/extensions/qqbot/src/engine/utils/stt.ts index 3a438f99e16a..247c666bf241 100644 --- a/extensions/qqbot/src/engine/utils/stt.ts +++ b/extensions/qqbot/src/engine/utils/stt.ts @@ -1,8 +1,7 @@ /** * OpenAI-compatible STT (Speech-to-Text) configuration and transcription. * - * Migrated from `src/stt.ts` — uses core/utils/string-normalize instead - * of broad SDK text barrels. + * Uses canonical Plugin SDK coercion helpers plus QQ-specific filename sanitization. */ import * as fs from "node:fs"; @@ -14,13 +13,13 @@ import { readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { + asOptionalObjectRecord, normalizeOptionalString, - asOptionalObjectRecord as asRecord, - readStringField as readString, - sanitizeFileName, -} from "./string-normalize.js"; + readStringField, +} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { sanitizeFileName } from "./string-normalize.js"; const STT_ERROR_BODY_LIMIT_BYTES = 8 * 1024; const DEFAULT_STT_TIMEOUT_MS = 60_000; @@ -44,19 +43,20 @@ function resolveSTTTimeoutMs(...timeoutSeconds: unknown[]): number { /** Resolve the STT configuration from the nested config object. */ export function resolveSTTConfig(cfg: Record): STTConfig | null { - const channels = asRecord(cfg.channels); - const qqbot = asRecord(channels?.qqbot); - const channelStt = asRecord(qqbot?.stt); - const models = asRecord(cfg.models); - const providers = asRecord(models?.providers); + const channels = asOptionalObjectRecord(cfg.channels); + const qqbot = asOptionalObjectRecord(channels?.qqbot); + const channelStt = asOptionalObjectRecord(qqbot?.stt); + const models = asOptionalObjectRecord(cfg.models); + const providers = asOptionalObjectRecord(models?.providers); // Prefer plugin-specific STT config. if (channelStt && channelStt.enabled !== false) { - const providerId = readString(channelStt, "provider") ?? "openai"; - const providerCfg = asRecord(providers?.[providerId]); - const baseUrl = readString(channelStt, "baseUrl") ?? readString(providerCfg, "baseUrl"); - const apiKey = readString(channelStt, "apiKey") ?? readString(providerCfg, "apiKey"); - const model = readString(channelStt, "model") ?? "whisper-1"; + const providerId = readStringField(channelStt, "provider") ?? "openai"; + const providerCfg = asOptionalObjectRecord(providers?.[providerId]); + const baseUrl = + readStringField(channelStt, "baseUrl") ?? readStringField(providerCfg, "baseUrl"); + const apiKey = readStringField(channelStt, "apiKey") ?? readStringField(providerCfg, "apiKey"); + const model = readStringField(channelStt, "model") ?? "whisper-1"; if (baseUrl && apiKey) { return { baseUrl: baseUrl.replace(/\/+$/, ""), @@ -68,19 +68,21 @@ export function resolveSTTConfig(cfg: Record): STTConfig | null } // Fall back to framework-level audio model config. - const tools = asRecord(cfg.tools); - const media = asRecord(tools?.media); - const audio = asRecord(media?.audio); + const tools = asOptionalObjectRecord(cfg.tools); + const media = asOptionalObjectRecord(tools?.media); + const audio = asOptionalObjectRecord(media?.audio); const mediaModels = Array.isArray(media?.models) ? media.models : []; const audioModelEntry = mediaModels - .map((entry) => asRecord(entry)) + .map((entry) => asOptionalObjectRecord(entry)) .find((entry) => !Array.isArray(entry?.capabilities) || entry.capabilities.includes("audio")); if (audioModelEntry) { - const providerId = readString(audioModelEntry, "provider") ?? "openai"; - const providerCfg = asRecord(providers?.[providerId]); - const baseUrl = readString(audioModelEntry, "baseUrl") ?? readString(providerCfg, "baseUrl"); - const apiKey = readString(audioModelEntry, "apiKey") ?? readString(providerCfg, "apiKey"); - const model = readString(audioModelEntry, "model") ?? "whisper-1"; + const providerId = readStringField(audioModelEntry, "provider") ?? "openai"; + const providerCfg = asOptionalObjectRecord(providers?.[providerId]); + const baseUrl = + readStringField(audioModelEntry, "baseUrl") ?? readStringField(providerCfg, "baseUrl"); + const apiKey = + readStringField(audioModelEntry, "apiKey") ?? readStringField(providerCfg, "apiKey"); + const model = readStringField(audioModelEntry, "model") ?? "whisper-1"; if (baseUrl && apiKey) { return { baseUrl: baseUrl.replace(/\/+$/, ""), diff --git a/extensions/reef/src/flow.ts b/extensions/reef/src/flow.ts index 879eb1872e51..96b6a05d1fa7 100644 --- a/extensions/reef/src/flow.ts +++ b/extensions/reef/src/flow.ts @@ -1,4 +1,7 @@ -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { appendAudit, appendInboxRead, @@ -42,12 +45,6 @@ interface LegacyDeliveryCandidate { expiresAt: number; } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function buildLegacyDeliveryIndex( entries: readonly AuditEntry[], ): Map { @@ -57,12 +54,12 @@ function buildLegacyDeliveryIndex( const candidates = new Map(); for (let index = entries.length - 1; index >= 0; index -= 1) { const entry = entries[index]!; - const payload = asRecord(entry.event.payload); + const payload = asOptionalRecord(entry.event.payload); if (entry.event.type === "confirm_delivery") { if (entry.event.ts < oldest) { continue; } - const receipt = asRecord(payload?.receipt); + const receipt = asOptionalRecord(payload?.receipt); if (typeof receipt?.id === "string") { confirmed.add(receipt.id); sealed.delete(receipt.id); diff --git a/extensions/signal/src/config-compat.ts b/extensions/signal/src/config-compat.ts index a7a1800150b9..d17ea92dec9d 100644 --- a/extensions/signal/src/config-compat.ts +++ b/extensions/signal/src/config-compat.ts @@ -2,7 +2,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco // Signal compatibility migration moves shipped flat transport config into account ownership. import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { SignalTransportConfig } from "./account-types.js"; import { allocateSignalManagedNativePort, @@ -77,20 +77,16 @@ function isSignalTransportConfig(value: unknown): value is SignalTransportConfig } } -function optionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function inherited(entry: Record, parent: Record, key: string) { return Object.hasOwn(entry, key) ? entry[key] : parent[key]; } function legacyBaseUrl(entry: Record, parent: Record): string { - const url = optionalString(inherited(entry, parent, "httpUrl")); + const url = normalizeOptionalString(inherited(entry, parent, "httpUrl")); if (url) { return normalizeSignalTransportUrl(url); } - const host = optionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1"; + const host = normalizeOptionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1"; const rawPort = inherited(entry, parent, "httpPort"); const port = typeof rawPort === "number" ? rawPort : 8080; return buildSignalTransportHttpUrl(host, port); @@ -105,11 +101,11 @@ function wasLegacySignalAccountConfigured( parent: Record, ): boolean { return Boolean( - optionalString(inherited(entry, parent, "account")) || - optionalString(inherited(entry, parent, "configPath")) || - optionalString(inherited(entry, parent, "httpUrl")) || - optionalString(inherited(entry, parent, "httpHost")) || - optionalString(inherited(entry, parent, "cliPath")) || + normalizeOptionalString(inherited(entry, parent, "account")) || + normalizeOptionalString(inherited(entry, parent, "configPath")) || + normalizeOptionalString(inherited(entry, parent, "httpUrl")) || + normalizeOptionalString(inherited(entry, parent, "httpHost")) || + normalizeOptionalString(inherited(entry, parent, "cliPath")) || typeof inherited(entry, parent, "httpPort") === "number" || typeof inherited(entry, parent, "autoStart") === "boolean", ); @@ -120,7 +116,7 @@ function hasInvalidLegacyHttpUrl( parent: Record, ): boolean { return entries.some((entry) => { - const httpUrl = optionalString(inherited(entry, parent, "httpUrl")); + const httpUrl = normalizeOptionalString(inherited(entry, parent, "httpUrl")); if (!httpUrl) { return false; } @@ -138,14 +134,14 @@ function findInvalidLegacyDerivedEndpoint( parent: Record, ): "host" | "port" | undefined { for (const entry of entries) { - if (optionalString(inherited(entry, parent, "httpUrl"))) { + if (normalizeOptionalString(inherited(entry, parent, "httpUrl"))) { continue; } const rawPort = inherited(entry, parent, "httpPort"); if (rawPort !== undefined && !isValidSignalManagedNativePort(rawPort)) { return "port"; } - const host = optionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1"; + const host = normalizeOptionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1"; try { buildSignalTransportHttpUrl(host, typeof rawPort === "number" ? rawPort : 8080); } catch { @@ -175,7 +171,7 @@ function requiresDetection( return false; } return ( - Boolean(optionalString(inherited(entry, parent, "httpUrl"))) || + Boolean(normalizeOptionalString(inherited(entry, parent, "httpUrl"))) || !resolveLegacyAutoStart(entry, parent) ); } @@ -188,20 +184,20 @@ function resolveLegacyAutoStart( if (typeof autoStart === "boolean") { return autoStart; } - return !optionalString(inherited(entry, parent, "httpUrl")); + return !normalizeOptionalString(inherited(entry, parent, "httpUrl")); } function resolveManagedConnectionUrl( entry: Record, parent: Record, ): string | undefined { - const httpUrl = optionalString(inherited(entry, parent, "httpUrl")); + const httpUrl = normalizeOptionalString(inherited(entry, parent, "httpUrl")); if (!httpUrl) { return undefined; } const normalizedUrl = normalizeSignalTransportUrl(httpUrl); const endpoint = new URL(normalizedUrl); - const bindHost = (optionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1") + const bindHost = (normalizeOptionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1") .replace(/^\[|\]$/g, "") .toLowerCase(); const rawBindPort = inherited(entry, parent, "httpPort"); @@ -222,10 +218,10 @@ function buildManagedNativeTransport( parent: Record, ): SignalTransportConfig { const value = (key: string) => inherited(entry, parent, key); - const configPath = optionalString(value("configPath")); - const cliPath = optionalString(value("cliPath")); + const configPath = normalizeOptionalString(value("configPath")); + const cliPath = normalizeOptionalString(value("cliPath")); const url = resolveManagedConnectionUrl(entry, parent); - const httpHost = optionalString(value("httpHost")); + const httpHost = normalizeOptionalString(value("httpHost")); const httpPort = value("httpPort"); const startupTimeoutMs = value("startupTimeoutMs"); const receiveMode = value("receiveMode"); @@ -280,7 +276,7 @@ async function resolveLegacyTransport(params: { if (resolved) { return resolved; } - const account = optionalString(inherited(params.entry, params.parent, "account")); + const account = normalizeOptionalString(inherited(params.entry, params.parent, "account")); try { const detected = await params.detect?.({ url: legacyBaseUrl(params.entry, params.parent), @@ -315,7 +311,7 @@ function hasRootSignalAccount(entries: Record[]): boolean { const root = entries[0]; return ( entries.length === 1 || - Boolean(optionalString(root?.account)) || + Boolean(normalizeOptionalString(root?.account)) || isSignalTransportConfig(root?.transport) ); } @@ -525,8 +521,8 @@ function hasContainerTransportWithoutEffectiveAccount(cfg: OpenClawConfig): bool const defaultEntry = defaultKey ? accounts[defaultKey] : undefined; const defaultAccount = isRecord(defaultEntry) && defaultEntry.account !== undefined - ? optionalString(defaultEntry.account) - : optionalString(signal.account); + ? normalizeOptionalString(defaultEntry.account) + : normalizeOptionalString(signal.account); const channelEnabled = signal.enabled !== false; const defaultEnabled = !isRecord(defaultEntry) || defaultEntry.enabled !== false; if (rootTransport?.kind === "container" && channelEnabled && defaultEnabled && !defaultAccount) { @@ -550,7 +546,9 @@ function hasContainerTransportWithoutEffectiveAccount(cfg: OpenClawConfig): bool continue; } const account = - entry.account === undefined ? optionalString(signal.account) : optionalString(entry.account); + entry.account === undefined + ? normalizeOptionalString(signal.account) + : normalizeOptionalString(entry.account); if (!account) { return true; } diff --git a/extensions/slack/src/agent-context.ts b/extensions/slack/src/agent-context.ts index 6e3ebc1dfaeb..863465652bce 100644 --- a/extensions/slack/src/agent-context.ts +++ b/extensions/slack/src/agent-context.ts @@ -1,4 +1,8 @@ // Slack plugin module normalizes Agent View active-context entities. +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; export type SlackAppContext = { entities?: unknown; @@ -19,29 +23,19 @@ type SlackAppContextEntity = team_id?: string; }; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function trimmedString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function normalizeEntity(value: unknown): SlackAppContextEntity | undefined { - const entity = asRecord(value); - const type = trimmedString(entity?.type); + const entity = asOptionalRecord(value); + const type = normalizeOptionalString(entity?.type); if (!entity || !type) { return undefined; } - const teamId = trimmedString(entity.team_id); + const teamId = normalizeOptionalString(entity.team_id); if ( type === "slack#/types/channel_id" || type === "slack#/types/canvas_id" || type === "slack#/types/list_id" ) { - const entityValue = trimmedString(entity.value); + const entityValue = normalizeOptionalString(entity.value); return entityValue ? { type, value: entityValue, ...(teamId ? { team_id: teamId } : {}) } : undefined; @@ -49,9 +43,9 @@ function normalizeEntity(value: unknown): SlackAppContextEntity | undefined { if (type !== "slack#/types/message_context") { return undefined; } - const message = asRecord(entity.value); - const channelId = trimmedString(message?.channel_id); - const messageTs = trimmedString(message?.message_ts); + const message = asOptionalRecord(entity.value); + const channelId = normalizeOptionalString(message?.channel_id); + const messageTs = normalizeOptionalString(message?.message_ts); return channelId && messageTs ? { type, @@ -62,11 +56,11 @@ function normalizeEntity(value: unknown): SlackAppContextEntity | undefined { } export function isSlackAppContext(value: unknown): value is SlackAppContext { - return Boolean(asRecord(value)); + return Boolean(asOptionalRecord(value)); } export function normalizeSlackAppContextEntities(value: unknown): SlackAppContextEntity[] { - const context = asRecord(value); + const context = asOptionalRecord(value); if (!Array.isArray(context?.entities)) { return []; } diff --git a/extensions/slack/src/blocks-fallback.ts b/extensions/slack/src/blocks-fallback.ts index de879946f8f1..590481149105 100644 --- a/extensions/slack/src/blocks-fallback.ts +++ b/extensions/slack/src/blocks-fallback.ts @@ -1,4 +1,8 @@ // Slack plugin module implements blocks fallback behavior. +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { renderSlackDataTableFallbackText, renderSlackDataTableMrkdwnFallbackText, @@ -53,29 +57,15 @@ const SLACK_SELECT_ELEMENT_TYPES = new Set([ "multi_channels_select", ]); -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readNonEmptyString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - function readTextObject( value: unknown, options: RenderSlackBlockFallbackOptions = {}, ): string | undefined { - const record = asRecord(value); + const record = asOptionalRecord(value); if (!record) { return undefined; } - const text = readNonEmptyString(record?.text); + const text = normalizeOptionalString(record?.text); if (!text) { return undefined; } @@ -88,7 +78,7 @@ function readTextValue( value: unknown, options: RenderSlackBlockFallbackOptions = {}, ): string | undefined { - return readNonEmptyString(value) ?? readTextObject(value, options); + return normalizeOptionalString(value) ?? readTextObject(value, options); } function renderSlackRichTextLeaf(element: SlackRichTextElement): string { @@ -97,37 +87,37 @@ function renderSlackRichTextLeaf(element: SlackRichTextElement): string { return typeof element.text === "string" ? escapeSlackMrkdwn(element.text) : ""; case "link": return escapeSlackMrkdwn( - readNonEmptyString(element.text) ?? readNonEmptyString(element.url) ?? "", + normalizeOptionalString(element.text) ?? normalizeOptionalString(element.url) ?? "", ); case "user": { - const userId = readNonEmptyString(element.user_id); + const userId = normalizeOptionalString(element.user_id); return userId ? escapeSlackMrkdwn(`<@${userId}>`) : ""; } case "channel": { - const channelId = readNonEmptyString(element.channel_id); + const channelId = normalizeOptionalString(element.channel_id); return channelId ? escapeSlackMrkdwn(`<#${channelId}>`) : ""; } case "usergroup": { - const usergroupId = readNonEmptyString(element.usergroup_id); + const usergroupId = normalizeOptionalString(element.usergroup_id); return usergroupId ? escapeSlackMrkdwn(``) : ""; } case "broadcast": { - const range = readNonEmptyString(element.range); + const range = normalizeOptionalString(element.range); return range ? escapeSlackMrkdwn(``) : ""; } case "emoji": { - const name = readNonEmptyString(element.name); + const name = normalizeOptionalString(element.name); return name ? `:${name}:` : ""; } case "date": - return escapeSlackMrkdwn(readNonEmptyString(element.fallback) ?? ""); + return escapeSlackMrkdwn(normalizeOptionalString(element.fallback) ?? ""); default: return ""; } } function renderSlackRichTextElement(value: unknown): string { - const element = asRecord(value) as SlackRichTextElement | undefined; + const element = asOptionalRecord(value) as SlackRichTextElement | undefined; if (!element) { return ""; } @@ -151,7 +141,7 @@ function renderSlackRichTextElements(value: unknown, separator: string): string } function readImageText(block: SlackBlockLike): string | undefined { - const altText = readNonEmptyString(block.alt_text); + const altText = normalizeOptionalString(block.alt_text); return (altText ? escapeSlackMrkdwn(altText) : undefined) ?? readTextObject(block.title); } @@ -159,7 +149,7 @@ function readVideoText( block: SlackBlockLike, options: RenderSlackBlockFallbackOptions = {}, ): string | undefined { - const altText = readNonEmptyString(block.alt_text); + const altText = normalizeOptionalString(block.alt_text); return readTextObject(block.title, options) ?? (altText ? escapeSlackMrkdwn(altText) : undefined); } @@ -172,8 +162,8 @@ function readContextText( } const parts = block.elements .map((element) => { - const record = asRecord(element); - const altText = readNonEmptyString(record?.alt_text); + const record = asOptionalRecord(element); + const altText = normalizeOptionalString(record?.alt_text); return readTextObject(record, options) ?? (altText ? escapeSlackMrkdwn(altText) : undefined); }) .filter((part): part is string => Boolean(part)); @@ -184,8 +174,8 @@ function readControlElementText( value: unknown, options: RenderSlackBlockFallbackOptions = {}, ): string | undefined { - const element = asRecord(value); - const type = readNonEmptyString(element?.type); + const element = asOptionalRecord(value); + const type = normalizeOptionalString(element?.type); if (type === "button" || type === "workflow_button") { return readTextValue(element?.text, options); } @@ -239,13 +229,13 @@ export function renderSlackBlockFallbackText( raw: unknown, options: RenderSlackBlockFallbackOptions = {}, ): string | undefined { - const block = asRecord(raw) as SlackBlockLike | undefined; + const block = asOptionalRecord(raw) as SlackBlockLike | undefined; if (!block) { return undefined; } switch (block.type) { case "rich_text": - return readNonEmptyString(renderSlackRichTextElements(block.elements, "\n")); + return normalizeOptionalString(renderSlackRichTextElements(block.elements, "\n")); case "header": return readTextObject(block.text, options); case "section": diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index b59fc7d46b33..176464a96bc2 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -1,5 +1,6 @@ import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime"; // Slack tests cover channel plugin behavior. +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { slackPlugin } from "./channel.js"; @@ -158,10 +159,6 @@ function requireSlackListPeers() { return listPeers; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object"); function requireArray(value: unknown, label: string): unknown[] { diff --git a/extensions/slack/src/data-table.ts b/extensions/slack/src/data-table.ts index 6b85cac4846e..3385a203d679 100644 --- a/extensions/slack/src/data-table.ts +++ b/extensions/slack/src/data-table.ts @@ -4,6 +4,7 @@ import { renderMessagePresentationTableFallbackText, type MessagePresentationTableBlock, } from "openclaw/plugin-sdk/interactive-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; import { renderSlackMessagePresentationTableFallbackText } from "./presentation-fallback.js"; @@ -43,12 +44,6 @@ type ParsedSlackDataTable = { cellCharacterCount: number; }; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; } @@ -101,7 +96,7 @@ function readRichTextElements(value: unknown, separator = ""): string { } const parts: string[] = []; for (const rawElement of value) { - const element = asRecord(rawElement); + const element = asOptionalRecord(rawElement); if (!element) { continue; } @@ -124,7 +119,7 @@ function readRichTextElements(value: unknown, separator = ""): string { } function readSlackBasicTableCell(value: unknown): string { - const cell = asRecord(value); + const cell = asOptionalRecord(value); if (!cell) { return ""; } @@ -144,7 +139,7 @@ function readSlackBasicTableCell(value: unknown): string { } function parseSlackBasicTableRows(value: unknown): string[][] | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); if (block?.type !== "table" || !Array.isArray(block.rows)) { return undefined; } @@ -172,7 +167,7 @@ function parseSlackBasicTableRows(value: unknown): string[][] | undefined { } function readSlackDataTableCell(value: unknown, allowRichText: boolean): string | undefined { - const cell = asRecord(value); + const cell = asOptionalRecord(value); if (!cell) { return undefined; } @@ -194,7 +189,7 @@ function parseSlackDataTable( value: unknown, options: { enforceNativeLimits?: boolean } = {}, ): ParsedSlackDataTable | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); const caption = readNonEmptyString(block?.caption); if (block?.type !== "data_table" || !caption || !Array.isArray(block.rows)) { return undefined; @@ -237,7 +232,7 @@ function parseSlackDataTable( /** Detect current native table blocks without depending on unreleased Slack SDK types. */ export function hasSlackDataTableBlock(blocks?: readonly unknown[]): boolean { - return blocks?.some((block) => asRecord(block)?.type === "data_table") ?? false; + return blocks?.some((block) => asOptionalRecord(block)?.type === "data_table") ?? false; } /** Count display characters in one structurally valid native table. */ @@ -352,7 +347,7 @@ export function buildSlackDataTableBlock( /** Extract a deterministic accessible summary from a native Slack table block. */ export function renderSlackDataTableFallbackText(value: unknown): string | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); if (block?.type !== "data_table") { return undefined; } @@ -401,7 +396,7 @@ export function renderSlackTableMrkdwnFallbackText(value: unknown): string | und /** Render each native table cell once for bounded, formatting-disabled delivery. */ export function renderSlackDataTableCompactPlainTextFallback(value: unknown): string | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); if (block?.type !== "data_table") { return undefined; } @@ -418,7 +413,7 @@ export function renderSlackDataTableCompactPlainTextFallback(value: unknown): st /** Render a native table as mrkdwn without activating raw cell control tokens. */ export function renderSlackDataTableMrkdwnFallbackText(value: unknown): string | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); if (block?.type !== "data_table") { return undefined; } diff --git a/extensions/slack/src/data-visualization.ts b/extensions/slack/src/data-visualization.ts index 80a387cbe16a..8fb0fa3bfa7e 100644 --- a/extensions/slack/src/data-visualization.ts +++ b/extensions/slack/src/data-visualization.ts @@ -5,6 +5,7 @@ import { renderMessagePresentationChartFallbackText, type MessagePresentationChartBlock, } from "openclaw/plugin-sdk/interactive-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; import { renderSlackMessagePresentationChartFallbackText } from "./presentation-fallback.js"; @@ -40,15 +41,9 @@ type SlackDataVisualizationBlock = Block & { chart: SlackPieChart | SlackSeriesChart; }; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - /** Detect native chart blocks without depending on unreleased Slack SDK types. */ export function hasSlackDataVisualizationBlock(blocks?: readonly unknown[]): boolean { - return blocks?.some((block) => asRecord(block)?.type === "data_visualization") ?? false; + return blocks?.some((block) => asOptionalRecord(block)?.type === "data_visualization") ?? false; } function isStringWithin(value: unknown, maxLength: number): value is string { @@ -138,7 +133,7 @@ export function buildSlackDataVisualizationBlock( } function readSlackChartDatum(value: unknown): SlackChartDatum | undefined { - const record = asRecord(value); + const record = asOptionalRecord(value); const label = record?.label; const datumValue = record?.value; return typeof label === "string" && typeof datumValue === "number" @@ -149,9 +144,9 @@ function readSlackChartDatum(value: unknown): SlackChartDatum | undefined { function parseSlackDataVisualizationBlock( value: unknown, ): MessagePresentationChartBlock | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); const title = block?.title; - const chart = asRecord(block?.chart); + const chart = asOptionalRecord(block?.chart); if (block?.type !== "data_visualization" || typeof title !== "string" || !chart) { return undefined; } @@ -172,7 +167,7 @@ function parseSlackDataVisualizationBlock( if (chart.type !== "bar" && chart.type !== "area" && chart.type !== "line") { return undefined; } - const axisConfig = asRecord(chart.axis_config); + const axisConfig = asOptionalRecord(chart.axis_config); const categories = axisConfig?.categories; if (!Array.isArray(categories) || !categories.every((category) => typeof category === "string")) { return undefined; @@ -181,7 +176,7 @@ function parseSlackDataVisualizationBlock( return undefined; } const series = chart.series.map((rawSeries) => { - const seriesRecord = asRecord(rawSeries); + const seriesRecord = asOptionalRecord(rawSeries); if (typeof seriesRecord?.name !== "string" || !Array.isArray(seriesRecord.data)) { return undefined; } @@ -223,7 +218,7 @@ function parseSlackDataVisualizationBlock( /** Extract a deterministic accessible summary from a native Slack chart block. */ export function renderSlackDataVisualizationFallbackText(value: unknown): string | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); if (block?.type !== "data_visualization") { return undefined; } @@ -236,7 +231,7 @@ export function renderSlackDataVisualizationFallbackText(value: unknown): string /** Render a native chart as mrkdwn without activating raw data control tokens. */ export function renderSlackDataVisualizationMrkdwnFallbackText(value: unknown): string | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); if (block?.type !== "data_visualization") { return undefined; } diff --git a/extensions/slack/src/monitor/relay-source.ts b/extensions/slack/src/monitor/relay-source.ts index d4a813e9b3f6..9507a40b71e8 100644 --- a/extensions/slack/src/monitor/relay-source.ts +++ b/extensions/slack/src/monitor/relay-source.ts @@ -7,7 +7,10 @@ import { warn, type RuntimeEnv, } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; import WebSocket, { type ClientOptions, type RawData } from "ws"; import type { SlackSendIdentity } from "../send.js"; @@ -292,16 +295,16 @@ export function parseRelayFrame(data: RawData): unknown { function extractRelaySlackMessageEvent( frame: unknown, ): { deliveryId: string; message: SlackMessageEvent; route: SlackRelayRoute } | undefined { - const record = asRecord(frame); + const record = asOptionalRecord(frame); if (!record || record.type !== "slack_event") { return undefined; } const deliveryId = stringValue(record.delivery_id); - const routeRecord = asRecord(record.route); + const routeRecord = asOptionalRecord(record.route); const routeKind = stringValue(routeRecord?.kind); const routeKey = stringValue(routeRecord?.key); - const payload = asRecord(record.payload); - const event = asRecord(payload?.event); + const payload = asOptionalRecord(record.payload); + const event = asOptionalRecord(payload?.event); if (event?.type !== "message" || typeof event.channel !== "string") { return undefined; } @@ -321,7 +324,7 @@ function extractRelaySlackMessageEvent( function extractRelayHello( frame: unknown, ): { identity: SlackRelayIdentity | undefined } | undefined { - const record = asRecord(frame); + const record = asOptionalRecord(frame); if (!record || record.type !== "hello") { return undefined; } @@ -331,7 +334,8 @@ function extractRelayHello( } function extractRelayIdentity(record: Record): SlackRelayIdentity | undefined { - const identityRecord = asRecord(record.slack_identity) ?? asRecord(record.slackIdentity); + const identityRecord = + asOptionalRecord(record.slack_identity) ?? asOptionalRecord(record.slackIdentity); if (!identityRecord) { return undefined; } @@ -378,12 +382,6 @@ function formatRelayClose(code: number, reason: Buffer): string { : `Slack relay websocket closed (${code})`; } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } diff --git a/extensions/slack/src/native-data-blocks.ts b/extensions/slack/src/native-data-blocks.ts index e1bf79c08182..ef2c1fb85e8e 100644 --- a/extensions/slack/src/native-data-blocks.ts +++ b/extensions/slack/src/native-data-blocks.ts @@ -1,5 +1,6 @@ // Shared detection and text fallback for Slack's native chart and table blocks. import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { renderSlackBlockFallbackText } from "./blocks-fallback.js"; import { hasSlackDataTableBlock, @@ -17,12 +18,6 @@ export const SLACK_MALFORMED_NATIVE_DATA_FALLBACK = const SLACK_RESPONSE_URL_BODY_LIMIT_BYTES = 16 * 1024; const SLACK_RESPONSE_URL_BODY_TIMEOUT_MS = 30_000; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - /** Detect a native Slack chart or table block. */ export function hasSlackNativeDataBlock(blocks?: readonly unknown[]): boolean { return hasSlackDataVisualizationBlock(blocks) || hasSlackDataTableBlock(blocks); @@ -31,18 +26,18 @@ export function hasSlackNativeDataBlock(blocks?: readonly unknown[]): boolean { /** Keep every sibling block while removing Slack's native data blocks. */ export function stripSlackNativeDataBlocks(blocks?: readonly T[]): T[] { return (blocks ?? []).filter((block) => { - const type = asRecord(block)?.type; + const type = asOptionalRecord(block)?.type; return type !== "data_table" && type !== "data_visualization"; }); } /** Match Slack's Web API and response_url `invalid_blocks` error shapes. */ export function isSlackInvalidBlocksError(error: unknown): boolean { - const record = asRecord(error); + const record = asOptionalRecord(error); const rawData = record?.data; - const data = asRecord(rawData); - const rawResponseData = asRecord(record?.response)?.data; - const responseData = asRecord(rawResponseData); + const data = asOptionalRecord(rawData); + const rawResponseData = asOptionalRecord(record?.response)?.data; + const responseData = asOptionalRecord(rawResponseData); const code = data?.error ?? (typeof rawData === "string" ? rawData : undefined) ?? @@ -57,8 +52,8 @@ type SlackResponseLike = { }; function isSlackResponseLike(value: unknown): value is SlackResponseLike { - const record = asRecord(value); - const body = asRecord(record?.body); + const record = asOptionalRecord(value); + const body = asOptionalRecord(record?.body); return ( typeof record?.status === "number" && (typeof record.arrayBuffer === "function" || typeof body?.getReader === "function") @@ -90,13 +85,13 @@ export function isSlackNativeResponseUrlRejection(error: unknown): boolean { if (isSlackInvalidBlocksError(error)) { return true; } - const record = asRecord(error); + const record = asOptionalRecord(error); return record?.code === "slack_bolt_respond_error" && record.statusCode === 400; } /** Extract a complete accessible summary from a supported native data block. */ function renderSlackNativeDataFallbackText(value: unknown): string | undefined { - const type = asRecord(value)?.type; + const type = asOptionalRecord(value)?.type; if (type === "data_visualization") { return renderSlackDataVisualizationMrkdwnFallbackText(value); } @@ -161,7 +156,7 @@ function appendSlackNativeDataFallback( } function renderSlackNativeDataPlainTextBlock(value: unknown): string | undefined { - const type = asRecord(value)?.type; + const type = asOptionalRecord(value)?.type; if (type === "data_table") { return renderSlackDataTableCompactPlainTextFallback(value); } diff --git a/extensions/telegram/src/bot-message-dispatch.test-harness.ts b/extensions/telegram/src/bot-message-dispatch.test-harness.ts index 2ac7166f0874..1e0cb15ad635 100644 --- a/extensions/telegram/src/bot-message-dispatch.test-harness.ts +++ b/extensions/telegram/src/bot-message-dispatch.test-harness.ts @@ -226,7 +226,13 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { cfg: resolved.cfg, dispatcherOptions: { ...resolved.dispatcherOptions, - deliver: delivery.deliverWithProviderMessageSending, + deliver: (payload, info) => { + const providerInfo = { + ...info, + onPlatformSendDispatch: async () => undefined, + }; + return delivery.deliverWithProviderMessageSending(payload, providerInfo); + }, onError: delivery.onError, }, toolsAllow: resolved.toolsAllow, diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index d65686f24a6f..197997f10e4f 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -115,9 +115,13 @@ const dispatchChannelInboundTurnMock = vi.fn(async DispatchReplyWithBufferedBlockDispatcherParams["dispatcherOptions"]["deliver"] >[1], ) => { + const providerInfo = { + ...info, + onPlatformSendDispatch: async () => undefined, + }; const result = "deliverWithProviderMessageSending" in plan.delivery - ? await plan.delivery.deliverWithProviderMessageSending(payload, info) + ? await plan.delivery.deliverWithProviderMessageSending(payload, providerInfo) : await plan.delivery.deliver(payload, info); await plan.delivery.onDelivered?.(payload, info, result); return result; diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index cc4e69ba3459..3749d4de3165 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -82,16 +82,23 @@ const deliveryMocks = vi.hoisted(() => ({ const dispatchChannelInboundTurnForTest: TelegramNativeCommandDeps["dispatchChannelInboundTurn"] = async (plan) => { + const delivery = plan.delivery; const dispatchResult = await replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher({ ctx: plan.ctxPayload, cfg: plan.cfg, dispatcherOptions: { ...plan.dispatcherOptions, deliver: - "deliverWithProviderMessageSending" in plan.delivery - ? plan.delivery.deliverWithProviderMessageSending - : plan.delivery.deliver, - onError: plan.delivery.onError, + "deliverWithProviderMessageSending" in delivery + ? (payload, info) => { + const providerInfo = { + ...info, + onPlatformSendDispatch: async () => undefined, + }; + return delivery.deliverWithProviderMessageSending(payload, providerInfo); + } + : delivery.deliver, + onError: delivery.onError, }, replyOptions: plan.replyOptions, }); diff --git a/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts index d7d9941b1f8c..ba58d6b8f76c 100644 --- a/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts +++ b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts @@ -6,7 +6,7 @@ import { setNextSavedMediaPath, telegramBotDepsForTest, telegramMediaHarnessSendMessageSpy, -} from "./bot.media.e2e-harness.js"; +} from "./bot.media.e2e.test-harness.js"; import { TELEGRAM_TEST_TIMINGS, createBotHandler, diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e.test-harness.ts similarity index 100% rename from extensions/telegram/src/bot.media.e2e-harness.ts rename to extensions/telegram/src/bot.media.e2e.test-harness.ts diff --git a/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts index bc83643d31c2..f7456d846583 100644 --- a/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts +++ b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { readRemoteMediaBufferSpy, telegramBotDepsForTest } from "./bot.media.e2e-harness.js"; +import { readRemoteMediaBufferSpy, telegramBotDepsForTest } from "./bot.media.e2e.test-harness.js"; import { TELEGRAM_TEST_TIMINGS, cacheStickerSpy, diff --git a/extensions/telegram/src/bot.media.test-utils.ts b/extensions/telegram/src/bot.media.test-utils.ts index c9de2f6ac852..cc81a4af0656 100644 --- a/extensions/telegram/src/bot.media.test-utils.ts +++ b/extensions/telegram/src/bot.media.test-utils.ts @@ -2,7 +2,7 @@ import * as ssrf from "openclaw/plugin-sdk/ssrf-runtime"; import { afterEach, beforeAll, beforeEach, expect, vi, type Mock } from "vitest"; import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js"; -import * as harness from "./bot.media.e2e-harness.js"; +import * as harness from "./bot.media.e2e.test-harness.js"; type StickerSpy = Mock<(...args: unknown[]) => unknown>; diff --git a/extensions/telegram/src/bot.media.warning-topics.e2e.test.ts b/extensions/telegram/src/bot.media.warning-topics.e2e.test.ts index 203d08f794c1..de4114fcdb98 100644 --- a/extensions/telegram/src/bot.media.warning-topics.e2e.test.ts +++ b/extensions/telegram/src/bot.media.warning-topics.e2e.test.ts @@ -3,7 +3,7 @@ import { readRemoteMediaBufferSpy, telegramBotDepsForTest, telegramMediaHarnessSendMessageSpy, -} from "./bot.media.e2e-harness.js"; +} from "./bot.media.e2e.test-harness.js"; import { createBotHandlerWithOptions } from "./bot.media.test-utils.js"; describe("Telegram single-media warnings", () => { diff --git a/extensions/telegram/src/status-issues.ts b/extensions/telegram/src/status-issues.ts index 10e973bc2ef0..b3cc09fb1616 100644 --- a/extensions/telegram/src/status-issues.ts +++ b/extensions/telegram/src/status-issues.ts @@ -6,12 +6,12 @@ import type { import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; import { appendMatchMetadata, - asString, isRecord, readAccountStatusSnapshot, resolveEnabledConfiguredAccountId, type AccountStatusSnapshot, } from "openclaw/plugin-sdk/status-helpers"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; const TELEGRAM_POLLING_CONNECT_GRACE_MS = 120_000; const TELEGRAM_POLLING_STALE_TRANSPORT_MS = 30 * 60_000; @@ -46,12 +46,12 @@ function asFiniteNumber(value: unknown): number | null { } function appendTelegramRuntimeError(message: string, lastError: unknown): string { - const error = asString(lastError); + const error = normalizeOptionalString(lastError); return error ? `${message}: ${error}` : message; } function isTelegramPollingBacklogStallError(lastError: unknown): boolean { - const error = asString(lastError); + const error = normalizeOptionalString(lastError); return Boolean( error?.includes("isolated polling spool backlog stalled") || error?.includes("isolated polling spool handler timed out"), @@ -65,7 +65,7 @@ function collectTelegramPollingRuntimeIssues(params: { now: number; }) { const { account, accountId, issues, now } = params; - if (account.running !== true || asString(account.mode) !== "polling") { + if (account.running !== true || normalizeOptionalString(account.mode) !== "polling") { return; } @@ -121,7 +121,7 @@ function collectTelegramWebhookRuntimeIssues(params: { now: number; }) { const { account, accountId, issues, now } = params; - if (account.running !== true || asString(account.mode) !== "webhook") { + if (account.running !== true || normalizeOptionalString(account.mode) !== "webhook") { return; } @@ -169,15 +169,15 @@ function readTelegramGroupMembershipAuditSummary( if (!isRecord(entry)) { return null; } - const chatId = asString(entry.chatId); + const chatId = normalizeOptionalString(entry.chatId); if (!chatId) { return null; } const ok = typeof entry.ok === "boolean" ? entry.ok : undefined; - const status = asString(entry.status) ?? null; - const error = asString(entry.error) ?? null; - const matchKey = asString(entry.matchKey) ?? undefined; - const matchSource = asString(entry.matchSource) ?? undefined; + const status = normalizeOptionalString(entry.status) ?? null; + const error = normalizeOptionalString(entry.error) ?? null; + const matchKey = normalizeOptionalString(entry.matchKey); + const matchSource = normalizeOptionalString(entry.matchSource); return { chatId, ok, status, error, matchKey, matchSource }; }) .filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"]) diff --git a/extensions/tlon/src/monitor/cites.ts b/extensions/tlon/src/monitor/cites.ts index 2ccbbbd9a9f6..11dc7c1ee068 100644 --- a/extensions/tlon/src/monitor/cites.ts +++ b/extensions/tlon/src/monitor/cites.ts @@ -1,6 +1,7 @@ // Tlon plugin module implements cites behavior. import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; -import { asRecord, extractCites, extractMessageText, type ParsedCite } from "./utils.js"; +import { asNullableRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { extractCites, extractMessageText, type ParsedCite } from "./utils.js"; type TlonScryApi = { scry: (path: string) => Promise; diff --git a/extensions/tlon/src/monitor/discovery.ts b/extensions/tlon/src/monitor/discovery.ts index 942a677e46bb..5b1854abab8a 100644 --- a/extensions/tlon/src/monitor/discovery.ts +++ b/extensions/tlon/src/monitor/discovery.ts @@ -1,7 +1,8 @@ // Tlon plugin module implements discovery behavior. import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +import { asNullableRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { Foreigns } from "../urbit/foreigns.js"; -import { asRecord, formatErrorMessage } from "./utils.js"; +import { formatErrorMessage } from "./utils.js"; interface InitData { channels: string[]; diff --git a/extensions/tlon/src/monitor/history.ts b/extensions/tlon/src/monitor/history.ts index 0486159d0a5d..80899e9f001b 100644 --- a/extensions/tlon/src/monitor/history.ts +++ b/extensions/tlon/src/monitor/history.ts @@ -1,6 +1,7 @@ // Tlon plugin module implements history behavior. import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; -import { asRecord, extractMessageText, formatErrorMessage } from "./utils.js"; +import { asNullableRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { extractMessageText, formatErrorMessage } from "./utils.js"; /** * Format a number as @ud (with dots every 3 digits from the right) diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 4b13a5cee6ff..79d9c49fa259 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -7,7 +7,11 @@ import { import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; -import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asFiniteNumber, + asNullableRecord as asRecord, + readStringField as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { OpenClawConfig } from "../../runtime-api.js"; import { createLoggerBackedRuntime } from "../../runtime-api.js"; @@ -40,7 +44,7 @@ import { shouldMigrateTlonSetting, } from "./settings-helpers.js"; import { createActiveSnapshotTracker, createParticipatedThreadTracker } from "./tracking.js"; -import { asRecord, formatErrorMessage, readString } from "./utils.js"; +import { formatErrorMessage } from "./utils.js"; import { extractMessageText, formatModelName, @@ -61,10 +65,6 @@ type MonitorTlonOpts = { accountId?: string | null; }; -function readNumber(record: Record | null, key: string): number | undefined { - return asFiniteNumber(record?.[key]); -} - export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { const core = getTlonRuntime(); const cfg = core.config.current() as OpenClawConfig; @@ -793,7 +793,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -function readStringField( - record: Record | null | undefined, - field: string, -): string | undefined { - const value = record?.[field]; - return typeof value === "string" ? value : undefined; -} // Helper to recursively extract text from inline content function renderInlineItem( @@ -272,11 +257,11 @@ function renderInlineItem( if (typeof item === "string") { return item; } - const record = asRecord(item); + const record = asNullableRecord(item); if (!record) { return ""; } - const ship = readString(record, "ship"); + const ship = readStringField(record, "ship"); if (ship) { return ship; } @@ -292,18 +277,18 @@ function renderInlineItem( if (options?.allowBreak && "break" in record) { return "\n"; } - const inlineCode = readString(record, "inline-code"); + const inlineCode = readStringField(record, "inline-code"); if (inlineCode) { return `\`${inlineCode}\``; } - const code = readString(record, "code"); + const code = readStringField(record, "code"); if (code) { return `\`${code}\``; } - const link = asRecord(record.link); - const linkHref = link ? readString(link, "href") : undefined; + const link = asNullableRecord(record.link); + const linkHref = link ? readStringField(link, "href") : undefined; if (link && linkHref) { - const linkContent = readString(link, "content"); + const linkContent = readStringField(link, "content"); return options?.linkMode === "href" ? linkHref : linkContent || linkHref; } if (Array.isArray(record.bold)) { @@ -332,7 +317,7 @@ export function extractMessageText(content: unknown): string { return content .map((verse) => { - const verseRecord = asRecord(verse); + const verseRecord = asNullableRecord(verse); if (!verseRecord) { return ""; } @@ -351,30 +336,30 @@ export function extractMessageText(content: unknown): string { } // Handle block content (images, code blocks, etc.) - const block = asRecord(verseRecord.block); + const block = asNullableRecord(verseRecord.block); if (block) { - const image = asRecord(block.image); + const image = asNullableRecord(block.image); // Image blocks if (image) { - const imageSrc = readString(image, "src"); + const imageSrc = readStringField(image, "src"); if (imageSrc) { - const altText = readString(image, "alt"); + const altText = readStringField(image, "alt"); const alt = altText ? ` (${altText})` : ""; return `\n${imageSrc}${alt}\n`; } } // Code blocks - const codeBlock = asRecord(block.code); + const codeBlock = asNullableRecord(block.code); if (codeBlock) { - const lang = readString(codeBlock, "lang") ?? ""; - const code = readString(codeBlock, "code") ?? ""; + const lang = readStringField(codeBlock, "lang") ?? ""; + const code = readStringField(codeBlock, "code") ?? ""; return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`; } // Header blocks - const header = asRecord(block.header); + const header = asNullableRecord(block.header); if (header) { const headerContent = Array.isArray(header.content) ? header.content : []; const text = @@ -383,14 +368,14 @@ export function extractMessageText(content: unknown): string { } // Cite/quote blocks - parse the reference structure - const cite = asRecord(block.cite); + const cite = asNullableRecord(block.cite); if (cite) { - const chanCite = asRecord(cite.chan); + const chanCite = asNullableRecord(cite.chan); // ChanCite - reference to a channel message if (chanCite) { - const nest = readString(chanCite, "nest"); - const where = readString(chanCite, "where"); + const nest = readStringField(chanCite, "nest"); + const where = readStringField(chanCite, "where"); // where is typically /msg/~author/timestamp const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/); if (whereMatch) { @@ -401,25 +386,25 @@ export function extractMessageText(content: unknown): string { } // GroupCite - reference to a group - const group = readString(cite, "group"); + const group = readStringField(cite, "group"); if (group) { return `\n> [ref: group ${group}]\n`; } // DeskCite - reference to an app/desk - const desk = asRecord(cite.desk); + const desk = asNullableRecord(cite.desk); if (desk) { - const flag = readString(desk, "flag"); + const flag = readStringField(desk, "flag"); if (flag) { return `\n> [ref: ${flag}]\n`; } } // BaitCite - reference with group+graph context - const bait = asRecord(cite.bait); + const bait = asNullableRecord(cite.bait); if (bait) { - const graph = readString(bait, "graph"); - const groupName = readString(bait, "group"); + const graph = readStringField(bait, "graph"); + const groupName = readStringField(bait, "group"); if (graph && groupName) { return `\n> [ref: ${graph} in ${groupName}]\n`; } diff --git a/extensions/tts-local-cli/speech-provider.ts b/extensions/tts-local-cli/speech-provider.ts index b42bed3a9e94..9568eda73da4 100644 --- a/extensions/tts-local-cli/speech-provider.ts +++ b/extensions/tts-local-cli/speech-provider.ts @@ -14,6 +14,7 @@ import type { SpeechSynthesisRequest, SpeechTelephonySynthesisRequest, } from "openclaw/plugin-sdk/speech-core"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -36,18 +37,12 @@ const DEFAULT_TIMEOUT_MS = 120_000; const MAX_AUDIO_OUTPUT_BYTES = 50 * 1024 * 1024; const MAX_CLI_STDERR_BYTES = 1024 * 1024; -function asObject(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function asStringArray(value: unknown): string[] | undefined { return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : undefined; } -function asRecord(value: unknown): Record | undefined { - const obj = asObject(value); +function readStringRecord(value: unknown): Record | undefined { + const obj = asOptionalRecord(value); if (!obj) { return undefined; } @@ -72,8 +67,8 @@ function normalizeOutputFormat(value: unknown): OutputFormat { } function resolveCliProviderConfig(rawConfig: Record): SpeechProviderConfig { - const providers = asObject(rawConfig.providers); - return asObject(providers?.["tts-local-cli"]) ?? asObject(providers?.cli) ?? {}; + const providers = asOptionalRecord(rawConfig.providers); + return asOptionalRecord(providers?.["tts-local-cli"]) ?? asOptionalRecord(providers?.cli) ?? {}; } function getConfig(cfg: SpeechProviderConfig): CliConfig | null { @@ -87,7 +82,7 @@ function getConfig(cfg: SpeechProviderConfig): CliConfig | null { outputFormat: normalizeOutputFormat(cfg.outputFormat), timeoutMs: typeof cfg.timeoutMs === "number" ? cfg.timeoutMs : DEFAULT_TIMEOUT_MS, cwd: typeof cfg.cwd === "string" ? cfg.cwd : undefined, - env: asRecord(cfg.env), + env: readStringRecord(cfg.env), }; } diff --git a/extensions/vault/vault-secret-ref-resolver.js b/extensions/vault/vault-secret-ref-resolver.js index 1e79aa35e9bc..0e861724a8ab 100644 --- a/extensions/vault/vault-secret-ref-resolver.js +++ b/extensions/vault/vault-secret-ref-resolver.js @@ -317,7 +317,7 @@ async function classifyVaultClientToken(baseUrl, vaultToken) { return "unknown"; } -function readStringField(payload, parsedId) { +function readVaultStringField(payload, parsedId) { const record = payload; const data = resolveKvVersion() === 2 ? record?.data?.data : record?.data; const value = data?.[parsedId.field]; @@ -366,7 +366,7 @@ async function readVaultSecret(baseUrl, vaultToken, id) { } throw new Error(`Vault read failed for "${id}" (${response.status}).`); } - return readStringField(payload, parsedId); + return readVaultStringField(payload, parsedId); } async function resolveFromVault(ids) { diff --git a/extensions/voice-call/doctor-contract-api.ts b/extensions/voice-call/doctor-contract-api.ts index c1e68b142443..7b788f308506 100644 --- a/extensions/voice-call/doctor-contract-api.ts +++ b/extensions/voice-call/doctor-contract-api.ts @@ -12,6 +12,7 @@ import { type PluginDoctorStateMigration, type PluginStateKeyedStore, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildVoiceCallLegacyJsonlEventKey, CALL_RECORD_CHUNK_MAX_ENTRIES, @@ -88,12 +89,6 @@ type PluginDoctorStateMigrationParams = Parameters< PluginDoctorStateMigration["detectLegacyState"] >[0]; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - /** Return Voice Call agents whose templated core session stores need migration. */ export function resolveSessionStoreAgentIds(params: { cfg: OpenClawConfig }): string[] { const agentIds = new Set(); @@ -102,14 +97,14 @@ export function resolveSessionStoreAgentIds(params: { cfg: OpenClawConfig }): st if (!entry) { continue; } - const config = entry.config === undefined ? {} : asRecord(entry.config); + const config = entry.config === undefined ? {} : asOptionalRecord(entry.config); if (!config) { continue; } agentIds.add(normalizeAgentId(typeof config.agentId === "string" ? config.agentId : undefined)); - const numbers = asRecord(config.numbers); + const numbers = asOptionalRecord(config.numbers); for (const route of Object.values(numbers ?? {})) { - const agentId = asRecord(route)?.agentId; + const agentId = asOptionalRecord(route)?.agentId; if (typeof agentId === "string") { agentIds.add(normalizeAgentId(agentId)); } diff --git a/extensions/voice-call/src/config-migration.ts b/extensions/voice-call/src/config-migration.ts index ad24803ea350..19609f7ca567 100644 --- a/extensions/voice-call/src/config-migration.ts +++ b/extensions/voice-call/src/config-migration.ts @@ -1,13 +1,13 @@ // Voice Call setup helper migrates legacy config to the canonical schema. -import { asOptionalRecord, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime"; - -const asObject = asOptionalRecord; -const getString = readStringField; +import { + asFiniteNumber, + asOptionalRecord, + readStringField, +} from "openclaw/plugin-sdk/string-coerce-runtime"; /** Read finite numeric config values. */ function getNumber(obj: Record | undefined, key: string): number | undefined { - const value = obj?.[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(obj?.[key]); } /** Merge legacy provider-specific values into the canonical providers map. */ @@ -17,11 +17,11 @@ function mergeProviderConfig( compatValues: Record, ): Record | undefined { if (Object.keys(compatValues).length === 0) { - return asObject(providersValue); + return asOptionalRecord(providersValue); } - const providers = asObject(providersValue) ?? {}; - const existing = asObject(providers[providerId]) ?? {}; + const providers = asOptionalRecord(providersValue) ?? {}; + const existing = asOptionalRecord(providers[providerId]) ?? {}; return { ...providers, [providerId]: { @@ -39,19 +39,19 @@ export function migrateVoiceCallLegacyConfigInput(params: { config: Record; changes: string[]; } { - const raw = asObject(params.value) ?? {}; - const realtime = asObject(raw.realtime); - const realtimeAgentContext = asObject(realtime?.agentContext); - const twilio = asObject(raw.twilio); - const streaming = asObject(raw.streaming); + const raw = asOptionalRecord(params.value) ?? {}; + const realtime = asOptionalRecord(raw.realtime); + const realtimeAgentContext = asOptionalRecord(realtime?.agentContext); + const twilio = asOptionalRecord(raw.twilio); + const streaming = asOptionalRecord(raw.streaming); const configPathPrefix = params.configPathPrefix ?? "plugins.entries.voice-call.config"; const legacyStreamingOpenAICompat: Record = {}; - const streamingOpenAIApiKey = getString(streaming, "openaiApiKey"); + const streamingOpenAIApiKey = readStringField(streaming, "openaiApiKey"); if (streamingOpenAIApiKey) { legacyStreamingOpenAICompat.apiKey = streamingOpenAIApiKey; } - const streamingSttModel = getString(streaming, "sttModel"); + const streamingSttModel = readStringField(streaming, "sttModel"); if (streamingSttModel) { legacyStreamingOpenAICompat.model = streamingSttModel; } @@ -63,8 +63,8 @@ export function migrateVoiceCallLegacyConfigInput(params: { if (streamingVadThreshold !== undefined) { legacyStreamingOpenAICompat.vadThreshold = streamingVadThreshold; } - const streamingProvider = getString(streaming, "provider"); - const legacyStreamingProvider = getString(streaming, "sttProvider"); + const streamingProvider = readStringField(streaming, "provider"); + const legacyStreamingProvider = readStringField(streaming, "sttProvider"); const normalizedStreaming: Record | undefined = streaming ? { diff --git a/extensions/voice-call/src/realtime-agent-context.ts b/extensions/voice-call/src/realtime-agent-context.ts index 6defdfc7cc3c..774bcab1af8d 100644 --- a/extensions/voice-call/src/realtime-agent-context.ts +++ b/extensions/voice-call/src/realtime-agent-context.ts @@ -2,7 +2,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 { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { VoiceCallConfig } from "./config.js"; import type { CoreAgentDeps } from "./core-bridge.js"; @@ -88,12 +88,20 @@ export async function buildRealtimeVoiceInstructions(params: { | VoiceIdentityLike | undefined; const identityLines = [ - normalizeString(identity?.name) ? `- Name: ${normalizeString(identity?.name)}` : undefined, - normalizeString(identity?.emoji) ? `- Emoji: ${normalizeString(identity?.emoji)}` : undefined, - normalizeString(identity?.vibe) ? `- Vibe: ${normalizeString(identity?.vibe)}` : undefined, - normalizeString(identity?.theme) ? `- Theme: ${normalizeString(identity?.theme)}` : undefined, - normalizeString(identity?.creature) - ? `- Creature/persona: ${normalizeString(identity?.creature)}` + normalizeOptionalString(identity?.name) + ? `- Name: ${normalizeOptionalString(identity?.name)}` + : undefined, + normalizeOptionalString(identity?.emoji) + ? `- Emoji: ${normalizeOptionalString(identity?.emoji)}` + : undefined, + normalizeOptionalString(identity?.vibe) + ? `- Vibe: ${normalizeOptionalString(identity?.vibe)}` + : undefined, + normalizeOptionalString(identity?.theme) + ? `- Theme: ${normalizeOptionalString(identity?.theme)}` + : undefined, + normalizeOptionalString(identity?.creature) + ? `- Creature/persona: ${normalizeOptionalString(identity?.creature)}` : undefined, ].filter(Boolean); if (identityLines.length > 0) { diff --git a/extensions/volcengine/speech-provider.ts b/extensions/volcengine/speech-provider.ts index 6b5ad51f8047..eb5c4c10e30a 100644 --- a/extensions/volcengine/speech-provider.ts +++ b/extensions/volcengine/speech-provider.ts @@ -7,12 +7,11 @@ import type { SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; import { - asObject, parseSpeechDirectiveNumberOverride, resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; -import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumberInRange, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { volcengineTTS, type VolcengineTtsEncoding } from "./tts.js"; const DEFAULT_VOICE = "en_female_anna_mars_bigtts"; @@ -59,8 +58,8 @@ function normalizeSpeedRatio(value: unknown): number | undefined { function normalizeVolcengineProviderConfig( rawConfig: Record, ): VolcengineTtsProviderConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.volcengine) ?? asObject(rawConfig.volcengine); + const providers = asOptionalRecord(rawConfig.providers); + const raw = asOptionalRecord(providers?.volcengine) ?? asOptionalRecord(rawConfig.volcengine); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, diff --git a/extensions/vydra/shared.ts b/extensions/vydra/shared.ts index 20b7f26a5ada..b740bf624e61 100644 --- a/extensions/vydra/shared.ts +++ b/extensions/vydra/shared.ts @@ -16,6 +16,7 @@ import { import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { + asOptionalRecord, normalizeOptionalLowercaseString, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -48,17 +49,11 @@ type VydraJobPayload = { error?: string | { message?: string; detail?: string } | null; }; -function asObject(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function addUrlValue(value: unknown, urls: Set): void { - if (typeof value === "string") { - const trimmed = value.trim(); - if (/^https?:\/\//iu.test(trimmed)) { - urls.add(trimmed); + const normalized = normalizeOptionalString(value); + if (normalized !== undefined) { + if (/^https?:\/\//iu.test(normalized)) { + urls.add(normalized); } return; } @@ -69,11 +64,9 @@ function addUrlValue(value: unknown, urls: Set): void { } } -export const trimToUndefined = normalizeOptionalString; - export function normalizeVydraBaseUrl(value: string | undefined): string { const fallback = DEFAULT_VYDRA_BASE_URL; - const trimmed = trimToUndefined(value); + const trimmed = normalizeOptionalString(value); if (!trimmed) { return fallback; } @@ -95,10 +88,10 @@ export function normalizeVydraBaseUrl(value: string | undefined): string { } function resolveVydraBaseUrlFromConfig(cfg: unknown): string { - const models = asObject(asObject(cfg)?.models); - const providers = asObject(models?.providers); - const vydra = asObject(providers?.vydra); - return normalizeVydraBaseUrl(trimToUndefined(vydra?.baseUrl)); + const models = asOptionalRecord(asOptionalRecord(cfg)?.models); + const providers = asOptionalRecord(models?.providers); + const vydra = asOptionalRecord(providers?.vydra); + return normalizeVydraBaseUrl(normalizeOptionalString(vydra?.baseUrl)); } export async function resolveVydraRequestContext(params: { @@ -150,25 +143,27 @@ export async function resolveVydraRequestContext(params: { } export function resolveVydraResponseJobId(payload: unknown): string | undefined { - const object = asObject(payload) as VydraJobPayload | undefined; - return trimToUndefined(object?.jobId) ?? trimToUndefined(object?.id); + const object = asOptionalRecord(payload) as VydraJobPayload | undefined; + return normalizeOptionalString(object?.jobId) ?? normalizeOptionalString(object?.id); } export function resolveVydraResponseStatus(payload: unknown): string | undefined { - return normalizeOptionalLowercaseString(trimToUndefined(asObject(payload)?.status)); + return normalizeOptionalLowercaseString( + normalizeOptionalString(asOptionalRecord(payload)?.status), + ); } function resolveVydraErrorMessage(payload: unknown): string | undefined { - const object = asObject(payload) as VydraJobPayload | undefined; + const object = asOptionalRecord(payload) as VydraJobPayload | undefined; const error = object?.error; if (typeof error === "string" && error.trim()) { return error.trim(); } - const errorObject = asObject(error); + const errorObject = asOptionalRecord(error); return ( - trimToUndefined(errorObject?.message) ?? - trimToUndefined(errorObject?.detail) ?? - trimToUndefined(object?.message) + normalizeOptionalString(errorObject?.message) ?? + normalizeOptionalString(errorObject?.detail) ?? + normalizeOptionalString(object?.message) ); } @@ -193,7 +188,7 @@ export function extractVydraResultUrls(payload: unknown, kind: VydraMediaKind): } return; } - const object = asObject(value); + const object = asOptionalRecord(value); if (!object) { return; } diff --git a/extensions/vydra/speech-provider.ts b/extensions/vydra/speech-provider.ts index f14707e0764a..d65f25eaf1f1 100644 --- a/extensions/vydra/speech-provider.ts +++ b/extensions/vydra/speech-provider.ts @@ -12,7 +12,11 @@ import type { SpeechProviderOverrides, SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; -import { asObject, resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core"; +import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_VYDRA_BASE_URL, DEFAULT_VYDRA_SPEECH_MODEL, @@ -20,7 +24,6 @@ import { downloadVydraAsset, extractVydraResultUrls, normalizeVydraBaseUrl, - trimToUndefined, } from "./shared.js"; type VydraSpeechConfig = { @@ -38,23 +41,23 @@ const VYDRA_SPEECH_VOICES = [ ] as const; function normalizeVydraSpeechConfig(rawConfig: Record): VydraSpeechConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.vydra) ?? asObject(rawConfig.vydra); + const providers = asOptionalRecord(rawConfig.providers); + const raw = asOptionalRecord(providers?.vydra) ?? asOptionalRecord(rawConfig.vydra); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, path: "tts.providers.vydra.apiKey", }), baseUrl: normalizeVydraBaseUrl( - trimToUndefined(raw?.baseUrl) ?? trimToUndefined(process.env.VYDRA_BASE_URL), + normalizeOptionalString(raw?.baseUrl) ?? normalizeOptionalString(process.env.VYDRA_BASE_URL), ), model: - trimToUndefined(raw?.model) ?? - trimToUndefined(process.env.VYDRA_TTS_MODEL) ?? + normalizeOptionalString(raw?.model) ?? + normalizeOptionalString(process.env.VYDRA_TTS_MODEL) ?? DEFAULT_VYDRA_SPEECH_MODEL, voiceId: - trimToUndefined(raw?.voiceId) ?? - trimToUndefined(process.env.VYDRA_TTS_VOICE_ID) ?? + normalizeOptionalString(raw?.voiceId) ?? + normalizeOptionalString(process.env.VYDRA_TTS_VOICE_ID) ?? DEFAULT_VYDRA_VOICE_ID, }; } @@ -62,10 +65,10 @@ function normalizeVydraSpeechConfig(rawConfig: Record): VydraSp function readVydraSpeechConfig(config: SpeechProviderConfig): VydraSpeechConfig { const normalized = normalizeVydraSpeechConfig({}); return { - apiKey: trimToUndefined(config.apiKey) ?? normalized.apiKey, - baseUrl: normalizeVydraBaseUrl(trimToUndefined(config.baseUrl) ?? normalized.baseUrl), - model: trimToUndefined(config.model) ?? normalized.model, - voiceId: trimToUndefined(config.voiceId) ?? normalized.voiceId, + apiKey: normalizeOptionalString(config.apiKey) ?? normalized.apiKey, + baseUrl: normalizeVydraBaseUrl(normalizeOptionalString(config.baseUrl) ?? normalized.baseUrl), + model: normalizeOptionalString(config.model) ?? normalized.model, + voiceId: normalizeOptionalString(config.voiceId) ?? normalized.voiceId, }; } @@ -77,8 +80,8 @@ function readVydraOverrides(overrides: SpeechProviderOverrides | undefined): { return {}; } return { - model: trimToUndefined(overrides.model), - voiceId: trimToUndefined(overrides.voiceId), + model: normalizeOptionalString(overrides.model), + voiceId: normalizeOptionalString(overrides.voiceId), }; } diff --git a/extensions/whatsapp/src/agent-tools-login.ts b/extensions/whatsapp/src/agent-tools-login.ts index eb5227b90991..1f43e0247f70 100644 --- a/extensions/whatsapp/src/agent-tools-login.ts +++ b/extensions/whatsapp/src/agent-tools-login.ts @@ -4,13 +4,14 @@ import { readPositiveIntegerParam, } from "openclaw/plugin-sdk/channel-actions"; import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-contract"; +import { hasNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js"; const QR_DATA_URL_MAX_LENGTH = 16_384; -function readOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; +function readLoginStringPreservingWhitespace(value: unknown): string | undefined { + return hasNonEmptyString(value) ? value : undefined; } export function createWhatsAppLoginTool(): ChannelAgentTool { @@ -55,13 +56,15 @@ export function createWhatsAppLoginTool(): ChannelAgentTool { }; const action = (args as { action?: string })?.action ?? "start"; - const accountId = readOptionalString((args as { accountId?: unknown }).accountId); + const accountId = readLoginStringPreservingWhitespace( + (args as { accountId?: unknown }).accountId, + ); const timeoutMs = readPositiveIntegerParam(args as Record, "timeoutMs"); if (action === "wait") { const result = await waitForWebLogin({ accountId, timeoutMs, - currentQrDataUrl: readOptionalString( + currentQrDataUrl: readLoginStringPreservingWhitespace( (args as { currentQrDataUrl?: unknown }).currentQrDataUrl, ), }); diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 7a6411ed377e..b4a55848348e 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { escapeRegExp, formatEnvelopeTimestamp } from "openclaw/plugin-sdk/channel-test-helpers"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { toErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { getChildLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { getActiveWebListener } from "./active-listener.js"; @@ -252,7 +253,7 @@ describe("web auto-reply connection", () => { }, }; const listenerFactory = vi.fn(async () => { - throw toLintErrorObject(boom428, "Non-Error thrown"); + throw toErrorObject(boom428, "Non-Error thrown"); }); const sleep = vi.fn(async () => {}); @@ -1068,17 +1069,3 @@ describe("web auto-reply connection", () => { expect(content).toMatch(/auto/); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/extensions/whatsapp/src/status-issues.ts b/extensions/whatsapp/src/status-issues.ts index f6a2abc8d707..1046f0235182 100644 --- a/extensions/whatsapp/src/status-issues.ts +++ b/extensions/whatsapp/src/status-issues.ts @@ -5,11 +5,11 @@ import type { } from "openclaw/plugin-sdk/channel-contract"; import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; import { - asString, collectIssuesForEnabledAccounts, isRecord, readAccountStatusSnapshot, } from "openclaw/plugin-sdk/status-helpers"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; const WHATSAPP_ACCOUNT_STATUS_FIELDS = [ "statusState", @@ -25,7 +25,7 @@ const RECENT_DISCONNECT_WARNING_WINDOW_MS = 15 * 60 * 1000; function readLastDisconnect(value: unknown): { at: number | null; error?: string } | null { if (typeof value === "string") { - const error = asString(value); + const error = normalizeOptionalString(value); return error ? { at: null, error } : null; } if (!isRecord(value)) { @@ -33,7 +33,7 @@ function readLastDisconnect(value: unknown): { at: number | null; error?: string } return { at: typeof value.at === "number" ? value.at : null, - error: asString(value.error), + error: normalizeOptionalString(value.error), }; } @@ -52,7 +52,7 @@ export function collectWhatsAppStatusIssues( readAccount: (value) => readAccountStatusSnapshot(value, WHATSAPP_ACCOUNT_STATUS_FIELDS), collectIssues: ({ account, accountId, issues }) => { const linked = account.linked === true; - const statusState = asString(account.statusState); + const statusState = normalizeOptionalString(account.statusState); const running = account.running === true; const connected = account.connected === true; const reconnectAttempts = @@ -60,8 +60,8 @@ export function collectWhatsAppStatusIssues( const lastInboundAt = typeof account.lastInboundAt === "number" ? account.lastInboundAt : null; const lastDisconnect = readLastDisconnect(account.lastDisconnect); - const lastError = asString(account.lastError) ?? lastDisconnect?.error; - const healthState = asString(account.healthState); + const lastError = normalizeOptionalString(account.lastError) ?? lastDisconnect?.error; + const healthState = normalizeOptionalString(account.healthState); if (statusState === "unstable") { issues.push({ diff --git a/extensions/workboard/src/store-card-helpers.ts b/extensions/workboard/src/store-card-helpers.ts index 30710d2d012a..55d2b07bfd5f 100644 --- a/extensions/workboard/src/store-card-helpers.ts +++ b/extensions/workboard/src/store-card-helpers.ts @@ -15,6 +15,7 @@ import { type WorkboardStatus, } from "@openclaw/workboard-contract"; import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { BLOCKED_TOO_LONG_MS, @@ -27,7 +28,6 @@ import type { WorkboardMutationScope } from "./store-inputs.js"; import { metadataIsEmpty, normalizeEvents, - normalizeOptionalString, normalizeTimestamp, removeUndefinedMetadataFields, } from "./store-normalizers.js"; diff --git a/extensions/workboard/src/store-core.ts b/extensions/workboard/src/store-core.ts index bbff7ea07299..990c0399557f 100644 --- a/extensions/workboard/src/store-core.ts +++ b/extensions/workboard/src/store-core.ts @@ -7,6 +7,7 @@ import type { WorkboardMetadata, WorkboardStatus, } from "@openclaw/workboard-contract"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { PersistedWorkboardAttachment, PersistedWorkboardBoard, @@ -55,7 +56,6 @@ import { normalizeLinkType, normalizeMetadata, normalizeNotes, - normalizeOptionalString, normalizePosition, normalizePriority, normalizeStatus, diff --git a/extensions/workboard/src/store-normalizers.ts b/extensions/workboard/src/store-normalizers.ts index 5627dbb68405..55e23f7f237e 100644 --- a/extensions/workboard/src/store-normalizers.ts +++ b/extensions/workboard/src/store-normalizers.ts @@ -46,6 +46,7 @@ import { type WorkboardWorkerProtocol, type WorkboardWorkspace, } from "@openclaw/workboard-contract"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { MAX_ATTACHMENT_BYTES, MAX_CARD_ARTIFACTS, @@ -68,10 +69,6 @@ import type { } from "./store-inputs.js"; import { isAbsoluteWorkspacePath } from "./workspace-path.js"; -export function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - export function normalizeBoardId(value: unknown, fallback?: string): string | undefined { const raw = normalizeBoundedString(value, fallback, 80, "board id"); if (!raw) { diff --git a/extensions/workboard/src/store-workflow.ts b/extensions/workboard/src/store-workflow.ts index d96a42d76d0d..52fe353871c3 100644 --- a/extensions/workboard/src/store-workflow.ts +++ b/extensions/workboard/src/store-workflow.ts @@ -9,6 +9,7 @@ import type { } from "@openclaw/workboard-contract"; import { isFutureDateTimestampMs } from "openclaw/plugin-sdk/number-runtime"; import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { appendEvent, assertCanMutateClaimedCard, @@ -51,7 +52,6 @@ import { normalizeArtifact, normalizeAutomation, normalizeBoundedString, - normalizeOptionalString, normalizeProofInput, normalizeStatus, normalizeStringList, diff --git a/extensions/workboard/src/workspace-access.ts b/extensions/workboard/src/workspace-access.ts index 6f014fc48659..7d3cf8d8f95d 100644 --- a/extensions/workboard/src/workspace-access.ts +++ b/extensions/workboard/src/workspace-access.ts @@ -15,6 +15,7 @@ import { canonicalPathFromExistingAncestor, isPathInside, } from "openclaw/plugin-sdk/security-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export type { WorkboardWorkspaceAccess } from "@openclaw/workboard-contract"; @@ -302,14 +303,8 @@ async function assertWorkspaceAllowed( return undefined; } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - export function containsWorkboardWorkspaceMutation(value: unknown): boolean { - const record = readRecord(value); + const record = asOptionalRecord(value); if (!record) { return false; } @@ -318,7 +313,7 @@ export function containsWorkboardWorkspaceMutation(value: unknown): boolean { } return ( containsWorkboardWorkspaceMutation(record.patch) || - containsWorkboardWorkspaceMutation(readRecord(record.metadata)?.automation) || + containsWorkboardWorkspaceMutation(asOptionalRecord(record.metadata)?.automation) || (Array.isArray(record.children) && record.children.some((child) => containsWorkboardWorkspaceMutation(child))) ); @@ -332,7 +327,7 @@ export function withWorkboardWorkspaceAccess( } export function withoutWorkboardWorkspaceAccess(value: unknown): Record { - const record = readRecord(value) ?? {}; + const record = asOptionalRecord(value) ?? {}; const { workspaceAccess: _untrustedWorkspaceAccess, ...rest } = record; return rest; } @@ -359,7 +354,7 @@ export async function assertWorkboardWorkspaceMutationAccess( if (access.unrestricted) { return; } - const record = readRecord(value); + const record = asOptionalRecord(value); if (!record) { return; } @@ -368,12 +363,12 @@ export async function assertWorkboardWorkspaceMutationAccess( await assertWorkspaceAllowed(record.workspace, access); await assertWorkspaceAllowed(record.defaultWorkspace, access); - const patch = readRecord(record.patch); + const patch = asOptionalRecord(record.patch); if (patch) { await assertWorkboardWorkspaceMutationAccess(patch, access); } - const metadata = readRecord(record.metadata); - const automation = readRecord(metadata?.automation); + const metadata = asOptionalRecord(record.metadata); + const automation = asOptionalRecord(metadata?.automation); if (automation) { await assertWorkboardWorkspaceMutationAccess(automation, access); } diff --git a/extensions/xai/capability-provider-metadata.ts b/extensions/xai/capability-provider-metadata.ts index 0b8799cc1441..903e7966f03c 100644 --- a/extensions/xai/capability-provider-metadata.ts +++ b/extensions/xai/capability-provider-metadata.ts @@ -17,8 +17,8 @@ import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-i import { isRecord, normalizeOptionalString, - parseBooleanValue as readBoolean, - parseFiniteNumber as readFiniteNumber, + parseBooleanValue, + parseFiniteNumber, } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { VideoGenerationProvider, @@ -210,10 +210,10 @@ export function normalizeXaiRealtimeTranscriptionProviderConfig( path: "plugins.entries.voice-call.config.streaming.providers.xai.apiKey", }), baseUrl: normalizeOptionalString(xai.baseUrl), - sampleRate: readFiniteNumber(xai.sampleRate ?? xai.sample_rate), + sampleRate: parseFiniteNumber(xai.sampleRate ?? xai.sample_rate), encoding: normalizeRealtimeTranscriptionEncoding(xai.encoding), - interimResults: readBoolean(xai.interimResults ?? xai.interim_results), - endpointingMs: readFiniteNumber(xai.endpointingMs ?? xai.endpointing ?? xai.silenceDurationMs), + interimResults: parseBooleanValue(xai.interimResults ?? xai.interim_results), + endpointingMs: parseFiniteNumber(xai.endpointingMs ?? xai.endpointing ?? xai.silenceDurationMs), language: normalizeOptionalString(xai.language), }; } diff --git a/extensions/xai/realtime-voice-config.ts b/extensions/xai/realtime-voice-config.ts index a3b23d056a8a..7b7d12093eb3 100644 --- a/extensions/xai/realtime-voice-config.ts +++ b/extensions/xai/realtime-voice-config.ts @@ -9,8 +9,9 @@ import type { import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { asFiniteNumber, + asOptionalObjectRecord, normalizeOptionalString, - parseBooleanValue as readBoolean, + parseBooleanValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { XAI_BASE_URL } from "./model-definitions.js"; @@ -144,14 +145,10 @@ export function serializeXaiRealtimeToolResult(result: unknown): string { throw new Error(message); } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - function readNestedXaiConfig(rawConfig: RealtimeVoiceProviderConfig) { - const raw = readRecord(rawConfig); - const providers = readRecord(raw?.providers); - return readRecord(providers?.xai ?? raw?.xai ?? raw) ?? {}; + const raw = asOptionalObjectRecord(rawConfig); + const providers = asOptionalObjectRecord(raw?.providers); + return asOptionalObjectRecord(providers?.xai ?? raw?.xai ?? raw) ?? {}; } export function normalizeXaiRealtimeBaseUrl(value?: string): string { @@ -207,9 +204,9 @@ export function normalizeXaiRealtimeProviderConfig( vadThreshold: asXaiVadThreshold(raw.vadThreshold), silenceDurationMs: asXaiDurationMs(raw.silenceDurationMs), prefixPaddingMs: asXaiDurationMs(raw.prefixPaddingMs), - interruptResponseOnInputAudio: readBoolean(raw.interruptResponseOnInputAudio), + interruptResponseOnInputAudio: parseBooleanValue(raw.interruptResponseOnInputAudio), reasoningEffort: asXaiReasoningEffort(raw.reasoningEffort), - sessionResumption: readBoolean(raw.sessionResumption), + sessionResumption: parseBooleanValue(raw.sessionResumption), }; } @@ -217,7 +214,7 @@ export function readXaiRealtimeErrorDetail(error: unknown): string { if (typeof error === "string" && error) { return error; } - const record = readRecord(error); + const record = asOptionalObjectRecord(error); return ( normalizeOptionalString(record?.message) ?? normalizeOptionalString(record?.code) ?? diff --git a/extensions/xai/setup-api.ts b/extensions/xai/setup-api.ts index 697f03a21071..aa12e8d181a9 100644 --- a/extensions/xai/setup-api.ts +++ b/extensions/xai/setup-api.ts @@ -1,6 +1,6 @@ // Xai API module exposes the plugin public contract. import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { isRecord } from "./src/tool-config-shared.js"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export default definePluginEntry({ id: "xai", diff --git a/extensions/xai/src/tool-config-shared.ts b/extensions/xai/src/tool-config-shared.ts index 3194c9cedb50..3602affd969a 100644 --- a/extensions/xai/src/tool-config-shared.ts +++ b/extensions/xai/src/tool-config-shared.ts @@ -2,8 +2,6 @@ import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeXaiModelId } from "../model-id.js"; -export { isRecord }; - export function coerceXaiToolConfig( config: Record | undefined, ): Record { diff --git a/extensions/xai/src/web-search-shared.ts b/extensions/xai/src/web-search-shared.ts index 60e188fa334d..314ccbaf6e77 100644 --- a/extensions/xai/src/web-search-shared.ts +++ b/extensions/xai/src/web-search-shared.ts @@ -1,6 +1,7 @@ // Xai plugin module implements web search shared behavior. import { readProviderJsonObjectResponse } from "openclaw/plugin-sdk/provider-http"; import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { XAI_DEFAULT_MODEL_ID } from "../model-definitions.js"; import { normalizeXaiModelId } from "../model-id.js"; import { @@ -8,7 +9,6 @@ import { requireXaiResponseTextCitationsAndInline, resolveXaiResponsesEndpoint, } from "./responses-tool-shared.js"; -import { isRecord } from "./tool-config-shared.js"; import type { XaiWebSearchResponse } from "./web-search-response.types.js"; export { extractXaiWebSearchContent } from "./responses-tool-shared.js"; export type { XaiWebSearchResponse } from "./web-search-response.types.js"; diff --git a/extensions/xai/src/x-search-config.ts b/extensions/xai/src/x-search-config.ts index 62e3c318ceb6..662f0c526d77 100644 --- a/extensions/xai/src/x-search-config.ts +++ b/extensions/xai/src/x-search-config.ts @@ -1,6 +1,6 @@ // Xai helper module supports x search config behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { isRecord } from "./tool-config-shared.js"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; type JsonRecord = Record; diff --git a/extensions/xai/src/xai-user-agent.ts b/extensions/xai/src/xai-user-agent.ts index d3934fffa352..36aa3c5be2a1 100644 --- a/extensions/xai/src/xai-user-agent.ts +++ b/extensions/xai/src/xai-user-agent.ts @@ -1,27 +1,23 @@ // Shared User-Agent for xAI sidecar HTTP/WS requests; mirrors `formatOpenClawUserAgent`. import { OPENCLAW_VERSION as PACKAGE_VERSION } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; const ORIGINATOR = "openclaw"; const UNUSABLE_PACKAGE_VERSION = "0.0.0"; const FALLBACK_VERSION = "unknown"; -function trimToUndefined(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : undefined; -} - function resolveXaiUserAgentVersion(): string { // Env-first matches resolveRuntimeServiceVersion. - const envVersion = trimToUndefined(process.env.OPENCLAW_VERSION); + const envVersion = normalizeOptionalString(process.env.OPENCLAW_VERSION); if (envVersion) { return envVersion; } - const packageVersion = trimToUndefined(PACKAGE_VERSION); + const packageVersion = normalizeOptionalString(PACKAGE_VERSION); if (packageVersion && packageVersion !== UNUSABLE_PACKAGE_VERSION) { return packageVersion; } - return trimToUndefined(process.env.npm_package_version) ?? FALLBACK_VERSION; + return normalizeOptionalString(process.env.npm_package_version) ?? FALLBACK_VERSION; } export function xaiUserAgent(): string { diff --git a/extensions/xai/tts.ts b/extensions/xai/tts.ts index 1c2d55c3dcd1..cb19d3b76ce0 100644 --- a/extensions/xai/tts.ts +++ b/extensions/xai/tts.ts @@ -6,11 +6,12 @@ import { readProviderBinaryResponse, readProviderJsonResponse, } from "openclaw/plugin-sdk/provider-http"; -import { asObject, trimToUndefined, type SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; +import { trimToUndefined, type SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; import WebSocket from "ws"; import { XAI_BASE_URL } from "./model-definitions.js"; @@ -48,12 +49,12 @@ export async function listXaiTtsVoices(params: { const payload = await readProviderJsonResponse(response, "xAI TTS voices", { maxBytes: XAI_TTS_VOICE_LIST_MAX_BYTES, }); - const voices = asObject(payload)?.voices; + const voices = asOptionalRecord(payload)?.voices; if (!Array.isArray(voices)) { throw new Error("xAI TTS voices: malformed JSON response"); } return voices.flatMap((value) => { - const voice = asObject(value); + const voice = asOptionalRecord(value); const id = trimToUndefined(voice?.voice_id); if (!id) { return []; diff --git a/extensions/xiaomi/speech-provider.ts b/extensions/xiaomi/speech-provider.ts index 811836659192..7e054b052b28 100644 --- a/extensions/xiaomi/speech-provider.ts +++ b/extensions/xiaomi/speech-provider.ts @@ -12,15 +12,12 @@ import type { SpeechProviderOverrides, SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; -import { - asObject, - resolveSpeechProviderApiKey, - trimToUndefined, -} from "openclaw/plugin-sdk/speech-core"; +import { resolveSpeechProviderApiKey, trimToUndefined } from "openclaw/plugin-sdk/speech-core"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; const DEFAULT_XIAOMI_TTS_BASE_URL = "https://api.xiaomimimo.com/v1"; const DEFAULT_XIAOMI_TTS_MODEL = "mimo-v2.5-tts"; @@ -76,8 +73,12 @@ function normalizeXiaomiTtsFormat(value: unknown): XiaomiTtsFormat | undefined { function resolveXiaomiTtsConfigRecord( rawConfig: Record, ): Record | undefined { - const providers = asObject(rawConfig.providers); - return asObject(providers?.xiaomi) ?? asObject(providers?.mimo) ?? asObject(rawConfig.xiaomi); + const providers = asOptionalRecord(rawConfig.providers); + return ( + asOptionalRecord(providers?.xiaomi) ?? + asOptionalRecord(providers?.mimo) ?? + asOptionalRecord(rawConfig.xiaomi) + ); } function normalizeXiaomiTtsProviderConfig( @@ -236,11 +237,11 @@ function buildXiaomiTtsAudio(params: { model: string; voice: string; format: Xia } function decodeXiaomiAudioData(body: unknown): Buffer { - const root = asObject(body); + const root = asOptionalRecord(body); const choices = Array.isArray(root?.choices) ? root.choices : []; - const firstChoice = asObject(choices[0]); - const message = asObject(firstChoice?.message); - const audio = asObject(message?.audio); + const firstChoice = asOptionalRecord(choices[0]); + const message = asOptionalRecord(firstChoice?.message); + const audio = asOptionalRecord(message?.audio); const audioData = trimToUndefined(audio?.data); if (!audioData) { throw new Error("Xiaomi TTS API returned no audio data"); diff --git a/package.json b/package.json index 8246491dc76d..c8b460973946 100644 --- a/package.json +++ b/package.json @@ -1535,6 +1535,7 @@ "check:bundled-channel-config-metadata": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check", "check:changed": "node scripts/check-changed.mjs", "check:changelog-attributions": "node --import tsx scripts/check-changelog-attributions.mts", + "check:coercion-helpers": "node --import tsx scripts/check-coercion-helper-declarations.mts", "check:database-first-legacy-stores": "node --import tsx scripts/check-database-first-legacy-stores.mts", "check:deprecated-api-usage": "node --import tsx scripts/check-deprecated-api-usage.mts", "check:deprecated-jsdoc": "node --import tsx scripts/check-deprecated-jsdoc.mts", diff --git a/packages/acp-core/src/meta.test.ts b/packages/acp-core/src/meta.test.ts index 2cd4b057dc36..83ffbb60854a 100644 --- a/packages/acp-core/src/meta.test.ts +++ b/packages/acp-core/src/meta.test.ts @@ -1,10 +1,17 @@ // ACP Core tests cover meta behavior. import { describe, expect, it } from "vitest"; -import { readBool, readNonNegativeInteger, readNumber, readString } from "./meta.js"; +import { + readBool, + readMetadataNumber, + readMetadataString, + readNonNegativeInteger, +} from "./meta.js"; describe("ACP metadata readers", () => { it("returns the first normalized string value", () => { - expect(readString({ old: " ", current: " session-1 " }, ["old", "current"])).toBe("session-1"); + expect(readMetadataString({ old: " ", current: " session-1 " }, ["old", "current"])).toBe( + "session-1", + ); }); it("preserves false boolean values", () => { @@ -12,8 +19,8 @@ describe("ACP metadata readers", () => { }); it("accepts finite numbers and rejects non-numeric values", () => { - expect(readNumber({ first: "1", second: 0 }, ["first", "second"])).toBe(0); - expect(readNumber({ first: Number.POSITIVE_INFINITY }, ["first"])).toBeUndefined(); + expect(readMetadataNumber({ first: "1", second: 0 }, ["first", "second"])).toBe(0); + expect(readMetadataNumber({ first: Number.POSITIVE_INFINITY }, ["first"])).toBeUndefined(); }); it("accepts zero as a non-negative integer", () => { diff --git a/packages/acp-core/src/meta.ts b/packages/acp-core/src/meta.ts index b295ce7f0e1b..4ce22b1478d1 100644 --- a/packages/acp-core/src/meta.ts +++ b/packages/acp-core/src/meta.ts @@ -19,7 +19,7 @@ function readMetaValue( } /** Reads the first present string metadata value from a current-to-legacy key list. */ -export function readString( +export function readMetadataString( meta: Record | null | undefined, keys: string[], ): string | undefined { @@ -35,7 +35,7 @@ export function readBool( } /** Reads the first finite numeric metadata value from a current-to-legacy key list. */ -export function readNumber( +export function readMetadataNumber( meta: Record | null | undefined, keys: string[], ): number | undefined { diff --git a/packages/agent-core/src/harness/messages.test.ts b/packages/agent-core/src/harness/messages.test.ts index e264f8359d41..7926242ec3c9 100644 --- a/packages/agent-core/src/harness/messages.test.ts +++ b/packages/agent-core/src/harness/messages.test.ts @@ -24,6 +24,22 @@ describe("harness message timestamps", () => { expect(message?.timestamp).toBe(Date.parse(timestamp)); }); + it.each(["0", "2026"])( + "preserves Date.parse semantics for numeric-looking persisted timestamp %s", + (timestamp) => { + const [message] = convertToLlm([ + { + role: "compactionSummary", + summary: "older context", + tokensBefore: 123, + timestamp, + }, + ]); + + expect(message?.timestamp).toBe(Date.parse(timestamp)); + }, + ); + it("keeps corrupt persisted compaction timestamps non-fatal", () => { const persistedMessages: Parameters[0] = [ { diff --git a/packages/ai/src/providers/google-shared.convert.test.ts b/packages/ai/src/providers/google-shared.convert.test.ts index 0cff95265318..764fa6b50e3f 100644 --- a/packages/ai/src/providers/google-shared.convert.test.ts +++ b/packages/ai/src/providers/google-shared.convert.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import type { Context, Tool } from "../types.js"; import { convertMessages, convertTools } from "./google-shared.js"; import { - asRecord, + assertRecord, expectConvertedRoles, getFirstToolParameters, makeGeminiCliAssistantMessage, @@ -107,11 +107,11 @@ describe("google-shared convertTools", () => { const params = getFirstToolParameters( converted as Parameters[0], ); - const properties = asRecord(params.properties); - const mode = asRecord(properties.mode); - const options = asRecord(properties.options); - const list = asRecord(properties.list); - const items = asRecord(list.items); + const properties = assertRecord(params.properties); + const mode = assertRecord(properties.mode); + const options = assertRecord(properties.options); + const list = assertRecord(properties.list); + const items = assertRecord(list.items); expect(params.patternProperties).toEqual({ "^x-": { type: "string" } }); expect(params.additionalProperties).toBe(false); @@ -150,11 +150,11 @@ describe("google-shared convertTools", () => { const params = getFirstToolParameters( converted as Parameters[0], ); - const config = asRecord(asRecord(params.properties).config); - const configProps = asRecord(config.properties); - const retries = asRecord(configProps.retries); - const tags = asRecord(configProps.tags); - const items = asRecord(tags.items); + const config = assertRecord(assertRecord(params.properties).config); + const configProps = assertRecord(config.properties); + const retries = assertRecord(configProps.retries); + const tags = assertRecord(configProps.tags); + const items = assertRecord(tags.items); expect(params.type).toBe("object"); expect(config.type).toBe("object"); @@ -456,7 +456,7 @@ describe("google-shared convertMessages", () => { const contents = convertMessagesForTest(model, context); expect(contents).toHaveLength(1); expect(expectDefined(contents[0], "contents[0] test invariant").role).toBe("model"); - const part = asRecord(expectDefined(contents[0], "contents[0] test invariant").parts?.[0]); + const part = assertRecord(expectDefined(contents[0], "contents[0] test invariant").parts?.[0]); expect(part.thought).toBe(true); expect(part.thoughtSignature).toBe("c2ln"); }); @@ -478,7 +478,7 @@ describe("google-shared convertMessages", () => { const contents = convertMessagesForTest(model, context); const parts = contents?.[0]?.parts ?? []; expect(parts).toHaveLength(1); - const part = asRecord(parts[0]); + const part = assertRecord(parts[0]); expect(part.thought).toBe(true); expect(part.thoughtSignature).toBe("c2ln"); }); @@ -558,7 +558,7 @@ describe("google-shared convertMessages", () => { const toolResponsePart = expectDefined(contents[2], "contents[2] test invariant").parts?.find( (part) => typeof part === "object" && part !== null && "functionResponse" in part, ); - const toolResponse = asRecord(toolResponsePart); + const toolResponse = assertRecord(toolResponsePart); expect(requireRecordProperty(toolResponse, "functionResponse").name).toBe("myTool"); expect(expectDefined(contents[3], "contents[3] test invariant").role).toBe("user"); }); @@ -588,7 +588,7 @@ describe("google-shared convertMessages", () => { const toolCallPart = expectDefined(contents[2], "contents[2] test invariant").parts?.find( (part) => typeof part === "object" && part !== null && "functionCall" in part, ); - const toolCall = asRecord(toolCallPart); + const toolCall = assertRecord(toolCallPart); expect(requireRecordProperty(toolCall, "functionCall").name).toBe("myTool"); }); @@ -629,11 +629,11 @@ describe("google-shared convertMessages", () => { (part) => typeof part === "object" && part !== null && "functionResponse" in part, ); - const toolCall = asRecord(toolCallPart); - const toolResponse = asRecord(toolResponsePart); + const toolCall = assertRecord(toolCallPart); + const toolResponse = assertRecord(toolResponsePart); - expect(asRecord(toolCall.functionCall).id).toBeUndefined(); - expect(asRecord(toolResponse.functionResponse).id).toBeUndefined(); + expect(assertRecord(toolCall.functionCall).id).toBeUndefined(); + expect(assertRecord(toolResponse.functionResponse).id).toBeUndefined(); }); it("serializes structured tool results into function responses", () => { @@ -655,8 +655,8 @@ describe("google-shared convertMessages", () => { (part) => typeof part === "object" && part !== null && "functionResponse" in part, ); expect(toolResponsePart).toBeDefined(); - const toolResponse = requireRecordProperty(asRecord(toolResponsePart), "functionResponse"); - expect(asRecord(toolResponse.response).output).toBe( + const toolResponse = requireRecordProperty(assertRecord(toolResponsePart), "functionResponse"); + expect(assertRecord(toolResponse.response).output).toBe( '{"type":"json","payload":{"sessionKey":"current","status":"ok"}}', ); }); diff --git a/packages/ai/src/providers/google-shared.test-helpers.ts b/packages/ai/src/providers/google-shared.test-helpers.ts index 2364755c89e9..5868ed23cd6a 100644 --- a/packages/ai/src/providers/google-shared.test-helpers.ts +++ b/packages/ai/src/providers/google-shared.test-helpers.ts @@ -19,7 +19,7 @@ function makeZeroUsageSnapshot() { }; } -export const asRecord = (value: unknown): Record => { +export const assertRecord = (value: unknown): Record => { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("expected record"); } @@ -34,8 +34,8 @@ type ConvertedTools = ReadonlyArray<{ }>; export const getFirstToolParameters = (converted: ConvertedTools): Record => { - const functionDeclaration = asRecord(converted?.[0]?.functionDeclarations?.[0]); - return asRecord(functionDeclaration.parametersJsonSchema ?? functionDeclaration.parameters); + const functionDeclaration = assertRecord(converted?.[0]?.functionDeclarations?.[0]); + return assertRecord(functionDeclaration.parametersJsonSchema ?? functionDeclaration.parameters); }; export const makeModel = (id: string): Model<"google-generative-ai"> => diff --git a/packages/ai/src/utils/provider-error.test.ts b/packages/ai/src/utils/provider-error.test.ts index 445cdd4d3174..56c527e812d5 100644 --- a/packages/ai/src/utils/provider-error.test.ts +++ b/packages/ai/src/utils/provider-error.test.ts @@ -56,6 +56,23 @@ describe("projectProviderError", () => { ); }); + it("normalizes string and finite-number provider error fields", () => { + expect( + projectProviderError({ + message: " provider failure ", + code: 12.5, + type: " upstream ", + }), + ).toMatchObject({ + errorMessage: "provider failure", + errorCode: "12.5", + errorType: "upstream", + }); + expect( + projectProviderError({ message: "failed", code: Number.POSITIVE_INFINITY }), + ).not.toHaveProperty("errorCode"); + }); + it("bounds repeated aliases without expanding the shared graph", () => { const shared = { detail: "safe" }; diff --git a/packages/ai/src/utils/provider-error.ts b/packages/ai/src/utils/provider-error.ts index 845be603f2de..45a19664924d 100644 --- a/packages/ai/src/utils/provider-error.ts +++ b/packages/ai/src/utils/provider-error.ts @@ -1,4 +1,5 @@ import { asOptionalRecord, stableStringify } from "@openclaw/normalization-core"; +import { normalizeStringifiedOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { projectDiagnosticValue, redactDiagnosticText } from "./credential-redaction.js"; @@ -20,12 +21,14 @@ export function configureProviderErrorRedactor(redactor: ProviderErrorRedactor | return previous; } -function readString(record: Record | undefined, key: string): string | undefined { +function readProviderErrorField( + record: Record | undefined, + key: string, +): string | undefined { const value = record?.[key]; - if (typeof value === "string") { - return value.trim() || undefined; - } - return typeof value === "number" && Number.isFinite(value) ? String(value) : undefined; + return typeof value === "string" || (typeof value === "number" && Number.isFinite(value)) + ? normalizeStringifiedOptionalString(value) + : undefined; } function readCauseCode(error: Record): string | undefined { @@ -36,7 +39,7 @@ function readCauseCode(error: Record): string | undefined { cause = asOptionalRecord(cause.cause) ) { seen.add(cause); - const code = readString(cause, "code"); + const code = readProviderErrorField(cause, "code"); if (code) { return code; } @@ -69,8 +72,8 @@ function buildProjection(snapshot: unknown, signal?: AbortSignal): ProviderError const body = stringifyField(bodyValue, MAX_ERROR_BODY_LENGTH); const originalMessage = (typeof snapshot === "string" ? snapshot : undefined) ?? - readString(error, "message") ?? - readString(nestedError, "message"); + readProviderErrorField(error, "message") ?? + readProviderErrorField(nestedError, "message"); const genericMessage = originalMessage === `${status} status code (no body)`; let errorMessage = status !== undefined && body && (!originalMessage || genericMessage) @@ -93,13 +96,15 @@ function buildProjection(snapshot: unknown, signal?: AbortSignal): ProviderError errorMessage += `\n${rawMetadata}`; } const errorCode = - readString(error, "errorCode") ?? - readString(error, "code") ?? - readString(nestedError, "code") ?? + readProviderErrorField(error, "errorCode") ?? + readProviderErrorField(error, "code") ?? + readProviderErrorField(nestedError, "code") ?? (error ? readCauseCode(error) : undefined) ?? (status === undefined ? undefined : String(status)); const errorType = - readString(error, "errorType") ?? readString(error, "type") ?? readString(nestedError, "type"); + readProviderErrorField(error, "errorType") ?? + readProviderErrorField(error, "type") ?? + readProviderErrorField(nestedError, "type"); return { stopReason: signal?.aborted ? "aborted" : "error", errorMessage: stringifyField(errorMessage, 4096) ?? "Unknown provider error", diff --git a/packages/normalization-core/src/number-coercion.test.ts b/packages/normalization-core/src/number-coercion.test.ts index 0ea660c47b51..4798e5b0b1c2 100644 --- a/packages/normalization-core/src/number-coercion.test.ts +++ b/packages/normalization-core/src/number-coercion.test.ts @@ -10,9 +10,11 @@ import { clampTimerTimeoutMs, finiteSecondsToTimerSafeMilliseconds, isFutureDateTimestampMs, + MAX_DATE_TIMESTAMP_MS, MAX_TIMER_TIMEOUT_MS, MAX_TIMER_TIMEOUT_SECONDS, nonNegativeSecondsToSafeMilliseconds, + parseDateTimestampMs, parseFiniteNumber, positiveSecondsToSafeMilliseconds, resolveIntegerOption, @@ -139,6 +141,35 @@ describe("number-coercion", () => { expect(timestampMsToIsoString("0")).toBeUndefined(); }); + test.each([ + { value: 0, expected: 0 }, + { value: 1_700_000_000_000, expected: 1_700_000_000_000 }, + { value: "0", expected: 0 }, + { value: " 1e3 ", expected: 1_000 }, + { value: "2026-07-13T10:00:00.000Z", expected: 1_783_936_800_000 }, + { value: -MAX_DATE_TIMESTAMP_MS, expected: -MAX_DATE_TIMESTAMP_MS }, + { value: String(MAX_DATE_TIMESTAMP_MS), expected: MAX_DATE_TIMESTAMP_MS }, + ])("parseDateTimestampMs parses $value as milliseconds", ({ value, expected }) => { + expect(parseDateTimestampMs(value)).toBe(expected); + }); + + test.each([ + new Date("2026-07-13T10:00:00.000Z"), + true, + false, + "", + " ", + "123ms", + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + MAX_DATE_TIMESTAMP_MS + 1, + String(MAX_DATE_TIMESTAMP_MS + 1), + "+275761-01-01T00:00:00.000Z", + ])("parseDateTimestampMs rejects %o", (value) => { + expect(parseDateTimestampMs(value)).toBeUndefined(); + }); + test("future timestamp helper rejects invalid Date timestamps", () => { expect(isFutureDateTimestampMs(1_001, { nowMs: 1_000 })).toBe(true); expect(isFutureDateTimestampMs(1_000, { nowMs: 1_000 })).toBe(false); diff --git a/packages/normalization-core/src/number-coercion.ts b/packages/normalization-core/src/number-coercion.ts index dc55b26cb79f..55d23d99a5c1 100644 --- a/packages/normalization-core/src/number-coercion.ts +++ b/packages/normalization-core/src/number-coercion.ts @@ -117,6 +117,18 @@ export function asDateTimestampMs(value: unknown): number | undefined { }); } +/** Parses Date-valid timestamps; numeric values and tokens are always milliseconds. */ +export function parseDateTimestampMs(value: unknown): number | undefined { + const numeric = parseFiniteNumber(value); + if (numeric !== undefined) { + return asDateTimestampMs(numeric); + } + if (typeof value !== "string") { + return undefined; + } + return asDateTimestampMs(Date.parse(value)); +} + /** Checks whether a Date-valid timestamp is after the supplied/current time. */ export function isFutureDateTimestampMs( value: unknown, diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 074a39711418..b81fabb3aaf9 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -85,7 +85,7 @@ function runStatusFromWaitPayload(payload: unknown): RunResult["status"] { timeoutPhase === "post_turn"); const hasTerminalTimeoutMetadata = readOptionalTimestamp(record.endedAt) !== undefined || - (!pendingError && readOptionalString(record.error) !== undefined) || + (!pendingError && readNonEmptyString(record.error) !== undefined) || stopReason.length > 0 || typeof record.livenessState === "string" || record.yielded === true; @@ -132,7 +132,7 @@ function runStatusFromWaitPayload(payload: unknown): RunResult["status"] { return "failed"; } -function readOptionalString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } @@ -659,14 +659,14 @@ export class Run { ); const record = asRecord(raw); const status = runStatusFromWaitPayload(raw); - const error = readOptionalString(record.error) - ? { message: readOptionalString(record.error) ?? "run failed" } + const error = readNonEmptyString(record.error) + ? { message: readNonEmptyString(record.error) ?? "run failed" } : undefined; return { runId: this.id, status, - sessionKey: readOptionalString(record.sessionKey) ?? this.sessionKey, - sessionId: readOptionalString(record.sessionId), + sessionKey: readNonEmptyString(record.sessionKey) ?? this.sessionKey, + sessionId: readNonEmptyString(record.sessionId), startedAt: readOptionalTimestamp(record.startedAt), endedAt: readOptionalTimestamp(record.endedAt), ...(error ? { error } : {}), @@ -702,7 +702,7 @@ export class Session { ...(timeoutMs !== undefined ? { timeoutMs: timeoutMs === 0 ? null : timeoutMs } : {}), }); const record = asRecord(raw); - const runId = readOptionalString(record.runId); + const runId = readNonEmptyString(record.runId); if (!runId) { throw new Error("sessions.send did not return a runId"); } @@ -767,7 +767,7 @@ export class SessionsNamespace { const raw = await this.client.request("sessions.create", params); const record = asRecord(raw); const key = - readOptionalString(record.key) ?? readOptionalString(record.sessionKey) ?? params.key; + readNonEmptyString(record.key) ?? readNonEmptyString(record.sessionKey) ?? params.key; if (!key) { throw new Error("sessions.create did not return a session key"); } @@ -800,11 +800,11 @@ export class RunsNamespace { ...(timeoutMs !== undefined ? { timeoutMs: timeoutMs === 0 ? null : timeoutMs } : {}), }); const record = asRecord(raw); - const runId = readOptionalString(record.runId); + const runId = readNonEmptyString(record.runId); if (!runId) { throw new Error("agent did not return a runId"); } - return new Run(this.client, runId, readOptionalString(record.sessionKey) ?? params.sessionKey); + return new Run(this.client, runId, readNonEmptyString(record.sessionKey) ?? params.sessionKey); } async get(runId: string): Promise { diff --git a/packages/sdk/src/normalize.ts b/packages/sdk/src/normalize.ts index e2d62e8120ef..baccac0c16d2 100644 --- a/packages/sdk/src/normalize.ts +++ b/packages/sdk/src/normalize.ts @@ -1,14 +1,14 @@ // OpenClaw SDK helper module supports normalize behavior. -import { asFiniteNumber as readNumber } from "@openclaw/normalization-core/number-coercion"; +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asRecord } from "@openclaw/normalization-core/record-coerce"; import type { GatewayEvent, JsonObject, OpenClawEvent, OpenClawEventType } from "./types.js"; -function readString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function readLowerString(value: unknown): string | undefined { - return readString(value)?.toLowerCase(); + return readNonEmptyString(value)?.toLowerCase(); } function hasHardTimeoutMetadata(data: JsonObject, statusAlreadyTimeoutAttributed = false): boolean { @@ -68,10 +68,10 @@ function normalizeLifecycleEndEventType(data: JsonObject): OpenClawEventType { } function normalizeAgentEventType(payload: JsonObject): OpenClawEventType { - const stream = readString(payload.stream); + const stream = readNonEmptyString(payload.stream); const data = asRecord(payload.data); - const phase = readString(data.phase); - const status = readString(data.status); + const phase = readNonEmptyString(data.phase); + const status = readNonEmptyString(data.status); if (stream === "assistant") { return data.delta === true || typeof data.delta === "string" @@ -134,7 +134,7 @@ function normalizeNamedEventType(event: GatewayEvent): OpenClawEventType { case "agent": return normalizeAgentEventType(payload); case "sessions.changed": { - const reason = readString(payload.reason); + const reason = readNonEmptyString(payload.reason); if (reason === "create") { return "session.created"; } @@ -164,12 +164,12 @@ function normalizeNamedEventType(event: GatewayEvent): OpenClawEventType { /** Normalize a raw Gateway event into the public SDK event shape. */ export function normalizeGatewayEvent(event: GatewayEvent): OpenClawEvent { const payload = asRecord(event.payload); - const runId = readString(payload.runId); - const sessionId = readString(payload.sessionId); - const sessionKey = readString(payload.sessionKey); - const taskId = readString(payload.taskId); - const agentId = readString(payload.agentId); - const ts = readNumber(payload.ts) ?? Date.now(); + const runId = readNonEmptyString(payload.runId); + const sessionId = readNonEmptyString(payload.sessionId); + const sessionKey = readNonEmptyString(payload.sessionKey); + const taskId = readNonEmptyString(payload.taskId); + const agentId = readNonEmptyString(payload.agentId); + const ts = asFiniteNumber(payload.ts) ?? Date.now(); const idParts = [event.seq ?? "local", event.event, runId, sessionKey, ts].filter( (part) => part !== undefined, ); diff --git a/qa/convex-credential-broker/convex/http.ts b/qa/convex-credential-broker/convex/http.ts index e888c5a46f69..e0cd97fadd17 100644 --- a/qa/convex-credential-broker/convex/http.ts +++ b/qa/convex-credential-broker/convex/http.ts @@ -98,7 +98,7 @@ function assertMaintainerAdminAuth(token: string | null) { throw new BrokerHttpError(401, "AUTH_INVALID", "Credential broker secret is invalid."); } -function asObject(value: unknown) { +function readJsonObject(value: unknown) { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } @@ -112,7 +112,7 @@ async function parseJsonObject(request: Request) { } catch { throw new BrokerHttpError(400, "INVALID_JSON", "Request body must be valid JSON."); } - const body = asObject(parsed); + const body = readJsonObject(parsed); if (!body) { throw new BrokerHttpError(400, "INVALID_BODY", "Request body must be a JSON object."); } @@ -131,7 +131,7 @@ function requireString(body: Record, key: string) { return value; } -function optionalString(body: Record, key: string) { +function readOptionalHttpString(body: Record, key: string) { if (!(key in body) || body[key] === undefined || body[key] === null) { return undefined; } @@ -145,7 +145,7 @@ function optionalString(body: Record, key: string) { function requireObject(body: Record, key: string) { const raw = body[key]; - const parsed = asObject(raw); + const parsed = readJsonObject(raw); if (!parsed) { throw new BrokerHttpError(400, "INVALID_BODY", `Expected "${key}" to be a JSON object.`); } @@ -189,7 +189,7 @@ function optionalBoolean(body: Record, key: string) { } function optionalCredentialStatus(body: Record, key: string) { - const value = optionalString(body, key); + const value = readOptionalHttpString(body, key); if (!value) { return undefined; } @@ -204,7 +204,7 @@ function optionalCredentialStatus(body: Record, key: string) { } function optionalListStatus(body: Record, key: string) { - const value = optionalString(body, key); + const value = readOptionalHttpString(body, key); if (!value) { return undefined; } @@ -406,8 +406,8 @@ http.route({ const result = await ctx.runMutation(internal.credentials.addCredentialSet, { kind, payload, - note: optionalString(body, "note"), - actorId: optionalString(body, "actorId"), + note: readOptionalHttpString(body, "note"), + actorId: readOptionalHttpString(body, "actorId"), status: optionalCredentialStatus(body, "status"), }); return jsonResponse(200, result); @@ -429,7 +429,7 @@ http.route({ credentialId: normalizeCredentialId( requireString(body, "credentialId"), ) as Id<"credential_sets">, - actorId: optionalString(body, "actorId"), + actorId: readOptionalHttpString(body, "actorId"), }); return jsonResponse(200, result); } catch (error) { @@ -447,7 +447,7 @@ http.route({ assertMaintainerAdminAuth(parseBearerToken(request)); const body = await parseJsonObject(request); const result = await ctx.runQuery(internal.credentials.listCredentialSets, { - kind: optionalString(body, "kind"), + kind: readOptionalHttpString(body, "kind"), status: optionalListStatus(body, "status"), includePayload: optionalBoolean(body, "includePayload"), limit: optionalPositiveInteger(body, "limit"), diff --git a/qa/convex-credential-broker/convex/payload_validation.ts b/qa/convex-credential-broker/convex/payload_validation.ts index 346c98bc51ea..8dc11387f9f4 100644 --- a/qa/convex-credential-broker/convex/payload_validation.ts +++ b/qa/convex-credential-broker/convex/payload_validation.ts @@ -163,14 +163,14 @@ function normalizeBuzzCredentialPayload( 'Credential payload for kind "buzz" must use distinct driver and SUT identities.', ); } - const optionalString = (key: "driverAuthTag" | "sutAuthTag") => { + const readOptionalBuzzAuthTag = (key: "driverAuthTag" | "sutAuthTag") => { if (payload[key] === undefined) { return undefined; } return requireBuzzAuthTag(payload, key, createFailure); }; - const driverAuthTag = optionalString("driverAuthTag"); - const sutAuthTag = optionalString("sutAuthTag"); + const driverAuthTag = readOptionalBuzzAuthTag("driverAuthTag"); + const sutAuthTag = readOptionalBuzzAuthTag("sutAuthTag"); return { relayUrl, diff --git a/scripts/bench-agent-concurrency-worker.ts b/scripts/bench-agent-concurrency-worker.ts index 8a7f6750c64c..df90c42dac40 100644 --- a/scripts/bench-agent-concurrency-worker.ts +++ b/scripts/bench-agent-concurrency-worker.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { performance } from "node:perf_hooks"; import { pathToFileURL } from "node:url"; +import { toErrorObject } from "@openclaw/normalization-core/error-coercion"; import type { SubagentRunRecord } from "../src/agents/subagents/registry/subagent-registry.types.js"; import type { DB as OpenClawStateKyselyDatabase } from "../src/state/openclaw-state-db.generated.js"; import { @@ -71,10 +72,6 @@ function processMaxRssBytes(): number { return Math.max(0, Math.round(process.resourceUsage().maxRSS * 1024)); } -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - async function waitForCondition(check: () => boolean): Promise { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { @@ -481,7 +478,7 @@ async function runSpawnSample( } } if (failure) { - throw toError(failure); + throw toErrorObject(failure, "Agent concurrency benchmark failed"); } const postTeardownTasks = await listBenchmarkTaskMemory(); const postTeardownRegistryRows = registry.subagentRuns.size; @@ -809,7 +806,7 @@ async function main(): Promise { } } if (failure) { - throw toError(failure); + throw toErrorObject(failure, "Agent concurrency benchmark failed"); } if (!result) { throw new Error("benchmark worker completed without a result"); diff --git a/scripts/bench-task-registry-sqlite-worker.ts b/scripts/bench-task-registry-sqlite-worker.ts index 7dc581c3472b..524d00883bf1 100644 --- a/scripts/bench-task-registry-sqlite-worker.ts +++ b/scripts/bench-task-registry-sqlite-worker.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { performance } from "node:perf_hooks"; import { pathToFileURL } from "node:url"; +import { toErrorObject } from "@openclaw/normalization-core/error-coercion"; import type { DB as OpenClawStateKyselyDatabase } from "../src/state/openclaw-state-db.generated.js"; import { WORKER_RESULT_SENTINEL, @@ -411,10 +412,6 @@ async function runBenchmark(options: WorkerOptions): Promise { }; } -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - async function main(): Promise { const options = parseOptions(process.argv.slice(2)); const previousStateDir = process.env.OPENCLAW_STATE_DIR; @@ -448,7 +445,7 @@ async function main(): Promise { } } if (failure) { - throw toError(failure); + throw toErrorObject(failure, "Task registry SQLite benchmark failed"); } if (!result) { throw new Error("benchmark worker completed without a result"); diff --git a/scripts/check-changed.mts b/scripts/check-changed.mts index dc45f40c884a..ad6f0a5ef7fc 100644 --- a/scripts/check-changed.mts +++ b/scripts/check-changed.mts @@ -620,6 +620,7 @@ export function createChangedCheckPlan( add("guarded extension wildcard re-exports", ["lint:extensions:no-guarded-wildcard-reexports"]); add("plugin-sdk wildcard re-exports", ["lint:extensions:no-plugin-sdk-wildcard-reexports"]); add("duplicate scan target coverage", ["dup:check:coverage"]); + add("coercion helper declaration guard", ["check:coercion-helpers"]); add("dependency pin guard", ["deps:pins:check"]); if (result.paths.length > 0) { add("format changed files", [ diff --git a/scripts/check-coercion-helper-declarations.mts b/scripts/check-coercion-helper-declarations.mts new file mode 100644 index 000000000000..9a1932f9bbfa --- /dev/null +++ b/scripts/check-coercion-helper-declarations.mts @@ -0,0 +1,417 @@ +#!/usr/bin/env node + +// Prevents local primitive-coercion helpers from regrowing after consolidation. +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; +import { isCodeFile, listRepoFilesSync } from "./check-file-utils.js"; +import { isDirectRunUrl } from "./lib/direct-run.mjs"; +import { runWithFailedTrailer } from "./lib/failed-trailer.mts"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +import { toLine, unwrapExpression } from "./lib/ts-guard-utils.mts"; + +const BANNED_HELPER_NAMES = new Set([ + "asObject", + "asRecord", + "asString", + "isRecord", + "normalizeString", + "optionalString", + "readBoolean", + "readNumber", + "readOptionalString", + "readString", + "timestampMs", + "toError", +]); +const SCAN_ROOTS = [ + "apps", + "deploy", + "examples", + "extensions", + "packages", + "qa", + "scripts", + "security", + "src", + "test", + "ui", +]; +const GENERATED_OR_FIXTURE_PATH_RE = + /(?:^|\/)(?:\.generated|__generated__|build|coverage|dist|generated|fixtures|node_modules|test-fixtures|vendor)(?:\/|$)|(?:^|\/)[^/]*(?:test-)?fixtures?\.[cm]?[jt]sx?$|\.generated\.[^/]+$|\.(?:bundle|min)\.[cm]?[jt]sx?$/u; +const GENERATED_BROWSER_RUNTIME_PATHS = new Set([ + "extensions/browser/chrome-extension/modules/copilot-runtime.js", +]); + +export type BannedCoercionHelperName = + | "asObject" + | "asRecord" + | "asString" + | "isRecord" + | "normalizeString" + | "optionalString" + | "readBoolean" + | "readNumber" + | "readOptionalString" + | "readString" + | "timestampMs" + | "toError"; + +export type CoercionHelperDeclaration = { + file: string; + kind: "function" | "variable"; + line: number; + name: BannedCoercionHelperName; +}; + +export type CoercionHelperCarveOut = { + count: number; + file: string; + name: BannedCoercionHelperName; + reason: string; +}; + +export const COERCION_HELPER_CARVE_OUTS: readonly CoercionHelperCarveOut[] = [ + { + file: "packages/normalization-core/src/record-coerce.ts", + name: "asRecord", + count: 1, + reason: "Canonical object-to-record fallback coercion owned by normalization-core.", + }, + { + file: "packages/normalization-core/src/record-coerce.ts", + name: "isRecord", + count: 1, + reason: "Canonical non-array record predicate owned by normalization-core.", + }, + { + file: "packages/llm-core/src/validation.ts", + name: "isRecord", + count: 1, + reason: + "Dependency-free validator intentionally accepts arrays before JSON type-specific checks.", + }, + { + file: "ui/src/test-helpers/control-ui-e2e.ts", + name: "isRecord", + count: 1, + reason: "Serialized mock Gateway closure cannot capture module imports.", + }, + { + file: "scripts/android-release-signing.mjs", + name: "asRecord", + count: 1, + reason: "Release signing entrypoint runs before workspace dependencies are installed.", + }, + { + file: "scripts/changed-lanes.mts", + name: "isRecord", + count: 1, + reason: "Changed-lane classification also runs in temporary repositories.", + }, + { + file: "scripts/check-built-plugin-control-plane-modules.mts", + name: "isRecord", + count: 1, + reason: "Copied standalone build guard cannot rely on workspace package resolution.", + }, + { + file: "scripts/close-duplicate-prs-after-merge.mjs", + name: "isRecord", + count: 1, + reason: "Plain-Node Actions entrypoint runs in checkout-only jobs without dependencies.", + }, + { + file: "scripts/copy-bundled-plugin-metadata.mts", + name: "isRecord", + count: 1, + reason: "Copied standalone metadata closure cannot rely on workspace package resolution.", + }, + { + file: "scripts/lib/kova-report-gate.mts", + name: "isRecord", + count: 1, + reason: "Copied standalone report gate cannot rely on workspace package resolution.", + }, + { + file: "scripts/lib/plugin-npm-package-manifest.mts", + name: "isRecord", + count: 1, + reason: "Copied standalone package-manifest closure cannot resolve workspace packages.", + }, + { + file: "scripts/lib/record-shared.mjs", + name: "isRecord", + count: 1, + reason: "Plain-Node shared helper serves MJS and E2E callers without package resolution.", + }, + { + file: "scripts/lib/static-extension-assets.mts", + name: "asRecord", + count: 1, + reason: "Copied standalone asset closure cannot rely on workspace package resolution.", + }, + { + file: "scripts/periphery-intersection.mjs", + name: "isRecord", + count: 1, + reason: "Plain-Node Actions entrypoint runs in checkout-only jobs without dependencies.", + }, + { + file: "scripts/pr-lib/process-group-runner.mjs", + name: "toError", + count: 1, + reason: + "Bootstrap process supervisor preserves fallback errors without workspace dependencies.", + }, + { + file: "scripts/stage-bundled-plugin-runtime.mts", + name: "isRecord", + count: 1, + reason: "Copied standalone runtime-staging closure cannot resolve workspace packages.", + }, +]; + +type CarveOutMismatch = CoercionHelperCarveOut & { + actualCount: number; + lines: number[]; +}; + +type CoercionHelperAudit = { + excessDeclarations: CoercionHelperDeclaration[]; + invalidCarveOuts: string[]; + staleCarveOuts: CarveOutMismatch[]; +}; + +type ScriptIo = { + stderr: { write(value: string): unknown }; + stdout: { write(value: string): unknown }; +}; + +function carveOutKey(entry: Pick) { + return `${entry.file}\0${entry.name}`; +} + +function unwrapCallableInitializer(expression: ts.Expression) { + let current = unwrapExpression(expression); + while (ts.isSatisfiesExpression(current)) { + current = unwrapExpression(current.expression); + } + return current; +} + +function unwrapDirectAliasInitializer(expression: ts.Expression): ts.Expression | undefined { + let current = expression; + while (true) { + if (ts.isParenthesizedExpression(current) || ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + if (ts.isSatisfiesExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + return undefined; + } + return current; + } +} + +/** Returns true for tracked source files governed by the declaration guard. */ +export function isGovernedCoercionHelperPath(filePath: string) { + return ( + isCodeFile(filePath) && + !/\.d\.[cm]?ts$/u.test(filePath) && + !GENERATED_BROWSER_RUNTIME_PATHS.has(filePath) && + !GENERATED_OR_FIXTURE_PATH_RE.test(filePath) + ); +} + +/** Finds banned function and callable-variable declarations in one source file. */ +export function findBannedCoercionHelperDeclarations( + source: string, + file = "source.ts", +): CoercionHelperDeclaration[] { + if (![...BANNED_HELPER_NAMES].some((name) => source.includes(name))) { + return []; + } + const scriptKind = file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind); + const declarations: CoercionHelperDeclaration[] = []; + const visit = (node: ts.Node): void => { + if (ts.isFunctionDeclaration(node) && node.name && BANNED_HELPER_NAMES.has(node.name.text)) { + declarations.push({ + file, + kind: "function", + line: toLine(sourceFile, node.name), + name: node.name.text as BannedCoercionHelperName, + }); + } else if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + BANNED_HELPER_NAMES.has(node.name.text) && + node.initializer + ) { + const initializer = unwrapCallableInitializer(node.initializer); + const aliasInitializer = unwrapDirectAliasInitializer(node.initializer); + if ( + ts.isArrowFunction(initializer) || + ts.isFunctionExpression(initializer) || + (aliasInitializer !== undefined && + (ts.isIdentifier(aliasInitializer) || ts.isPropertyAccessExpression(aliasInitializer))) + ) { + declarations.push({ + file, + kind: "variable", + line: toLine(sourceFile, node.name), + name: node.name.text as BannedCoercionHelperName, + }); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return declarations; +} + +/** Checks exact file/name/count carve-outs and rejects stale or excess entries. */ +export function auditCoercionHelperDeclarations( + declarations: readonly CoercionHelperDeclaration[], + carveOuts: readonly CoercionHelperCarveOut[], +): CoercionHelperAudit { + const invalidCarveOuts: string[] = []; + const carveOutByKey = new Map(); + for (const carveOut of carveOuts) { + const key = carveOutKey(carveOut); + if (carveOutByKey.has(key)) { + invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] is listed more than once`); + continue; + } + if (!BANNED_HELPER_NAMES.has(carveOut.name)) { + invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] is not a banned helper name`); + } + if (!Number.isInteger(carveOut.count) || carveOut.count < 1) { + invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] must have a positive count`); + } + if (!carveOut.reason.trim()) { + invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] needs a non-empty reason`); + } + carveOutByKey.set(key, carveOut); + } + + const declarationsByKey = new Map(); + for (const declaration of declarations) { + const key = carveOutKey(declaration); + const current = declarationsByKey.get(key) ?? []; + current.push(declaration); + declarationsByKey.set(key, current); + } + + const excessDeclarations: CoercionHelperDeclaration[] = []; + for (const [key, actual] of declarationsByKey) { + const allowedCount = carveOutByKey.get(key)?.count ?? 0; + if (actual.length > allowedCount) { + excessDeclarations.push(...actual.slice(allowedCount)); + } + } + const staleCarveOuts = carveOuts + .map((carveOut): CarveOutMismatch | null => { + const actual = declarationsByKey.get(carveOutKey(carveOut)) ?? []; + return actual.length < carveOut.count + ? { + ...carveOut, + actualCount: actual.length, + lines: actual.map((entry) => entry.line), + } + : null; + }) + .filter((entry): entry is CarveOutMismatch => entry !== null); + + return { + excessDeclarations: excessDeclarations.toSorted( + (left, right) => + left.file.localeCompare(right.file) || + left.line - right.line || + left.name.localeCompare(right.name), + ), + invalidCarveOuts, + staleCarveOuts, + }; +} + +function writeLine(stream: ScriptIo["stdout"] | ScriptIo["stderr"], value: string) { + stream.write(`${value}\n`); +} + +/** Runs the full tracked-source declaration guard. */ +export function runCoercionHelperDeclarationGuard( + options: { + carveOuts?: readonly CoercionHelperCarveOut[]; + io?: ScriptIo; + repoRoot?: string; + } = {}, +) { + const repoRoot = options.repoRoot ?? resolveRepoRoot(import.meta.url); + const io = options.io ?? { stderr: process.stderr, stdout: process.stdout }; + const carveOuts = options.carveOuts ?? COERCION_HELPER_CARVE_OUTS; + const relativeFiles = listRepoFilesSync(repoRoot, { + roots: SCAN_ROOTS, + includeFile: isGovernedCoercionHelperPath, + }); + const declarations = relativeFiles.flatMap((file) => { + const absolutePath = path.join(repoRoot, file); + if (!fs.existsSync(absolutePath)) { + return []; + } + return findBannedCoercionHelperDeclarations(fs.readFileSync(absolutePath, "utf8"), file); + }); + const audit = auditCoercionHelperDeclarations(declarations, carveOuts); + const failed = + audit.excessDeclarations.length > 0 || + audit.invalidCarveOuts.length > 0 || + audit.staleCarveOuts.length > 0; + if (!failed) { + writeLine( + io.stdout, + `Coercion helper declaration guard passed (${declarations.length} allowlisted declarations).`, + ); + return 0; + } + + if (audit.invalidCarveOuts.length > 0) { + writeLine(io.stderr, "Invalid coercion-helper carve-outs:"); + for (const message of audit.invalidCarveOuts) { + writeLine(io.stderr, `- ${message}`); + } + } + if (audit.excessDeclarations.length > 0) { + writeLine(io.stderr, "Banned local coercion-helper declarations:"); + for (const declaration of audit.excessDeclarations) { + writeLine( + io.stderr, + `- ${declaration.file}:${declaration.line} ${declaration.name} (${declaration.kind} declaration)`, + ); + } + } + if (audit.staleCarveOuts.length > 0) { + writeLine(io.stderr, "Stale coercion-helper carve-outs:"); + for (const carveOut of audit.staleCarveOuts) { + writeLine( + io.stderr, + `- ${carveOut.file} [${carveOut.name}] expected ${carveOut.count}, found ${carveOut.actualCount}; remove or reduce the carve-out`, + ); + } + } + writeLine( + io.stderr, + "Use @openclaw/normalization-core coercion subpaths in core/packages/UI/scripts, or openclaw/plugin-sdk/string-coerce-runtime and error-runtime in plugin production code.", + ); + return 1; +} + +if (isDirectRunUrl(process.argv[1], import.meta.url)) { + await runWithFailedTrailer("check:coercion-helpers", () => { + process.exitCode = runCoercionHelperDeclarationGuard(); + }); +} diff --git a/scripts/check-control-ui-performance.mts b/scripts/check-control-ui-performance.mts index e4901991a55d..e7dcf8385b74 100644 --- a/scripts/check-control-ui-performance.mts +++ b/scripts/check-control-ui-performance.mts @@ -5,7 +5,7 @@ import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; -function isRecord(value: unknown): value is Record { +function isMetricsRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -269,7 +269,7 @@ function isIsoDate(value: string): boolean { function readControlUiStartupBudgetBaseline(baselinePath: string): ControlUiStartupBudgetBaseline { try { const parsed: unknown = JSON.parse(fs.readFileSync(baselinePath, "utf8")); - const record: Record = isRecord(parsed) ? parsed : {}; + const record: Record = isMetricsRecord(parsed) ? parsed : {}; const { startupJsGzipBytes, reason, updatedAt } = record; if ( typeof startupJsGzipBytes !== "number" || diff --git a/scripts/check-file-utils.ts b/scripts/check-file-utils.ts index 6df7f007eef2..8d9878582910 100644 --- a/scripts/check-file-utils.ts +++ b/scripts/check-file-utils.ts @@ -16,6 +16,7 @@ export const REPO_SCAN_SKIPPED_DIR_NAMES: ReadonlySet = new Set([ ]); // Bound the Git lookup before falling back to direct filesystem traversal. const GIT_LS_FILES_TIMEOUT_MS = 30_000; +const GIT_LS_FILES_MAX_BUFFER = 64 * 1024 * 1024; export function isCodeFile(filePath: string): boolean { if (filePath.endsWith(".d.ts")) { @@ -93,6 +94,7 @@ export function listRepoFilesSync( stdio: ["ignore", "pipe", "ignore"], timeout: GIT_LS_FILES_TIMEOUT_MS, killSignal: "SIGKILL", + maxBuffer: GIT_LS_FILES_MAX_BUFFER, }) .split(/\r?\n/u) .filter(Boolean) diff --git a/scripts/check-gateway-cpu-scenarios.mts b/scripts/check-gateway-cpu-scenarios.mts index d280811dc1e4..7c7ef33b047c 100644 --- a/scripts/check-gateway-cpu-scenarios.mts +++ b/scripts/check-gateway-cpu-scenarios.mts @@ -7,6 +7,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mts"; import { parseNonNegativeInt, @@ -71,10 +72,6 @@ type ConcurrencyReport = { }; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function parseArgs(argv: string[]) { const args = stripLeadingPackageManagerSeparator(argv); const options = { diff --git a/scripts/check-memory-fd-repro.mts b/scripts/check-memory-fd-repro.mts index ad3ee8b46395..1d69d2798731 100644 --- a/scripts/check-memory-fd-repro.mts +++ b/scripts/check-memory-fd-repro.mts @@ -126,7 +126,7 @@ function stripPackageManagerSeparatorForKnownFlags(argv: string[]) { /** * Parses a safe non-negative integer option. */ -export function readNumber(value: unknown, label: string) { +export function parseNonNegativeInteger(value: unknown, label: string) { const raw = String(value).trim(); if (!NON_NEGATIVE_INTEGER_PATTERN.test(raw)) { throw new Error(`${label} must be a non-negative integer`); @@ -142,7 +142,7 @@ export function readNumber(value: unknown, label: string) { * Parses a safe positive integer option. */ export function readPositiveNumber(value: unknown, label: string) { - const parsed = readNumber(value, label); + const parsed = parseNonNegativeInteger(value, label); if (parsed <= 0) { throw new Error(`${label} must be greater than 0`); } @@ -151,7 +151,7 @@ export function readPositiveNumber(value: unknown, label: string) { function readNumberEnv(name: string, fallback: number) { const raw = process.env[name]; - return raw == null || raw.trim() === "" ? fallback : readNumber(raw, name); + return raw == null || raw.trim() === "" ? fallback : parseNonNegativeInteger(raw, name); } function readPositiveNumberEnv(name: string, fallback: number) { @@ -166,7 +166,8 @@ function clampTimerTimeoutMs(valueMs: number, minMs = 1) { } function readTimerTimeoutNumber(value: unknown, label: string, minMs = 1) { - const parsed = minMs > 0 ? readPositiveNumber(value, label) : readNumber(value, label); + const parsed = + minMs > 0 ? readPositiveNumber(value, label) : parseNonNegativeInteger(value, label); return clampTimerTimeoutMs(parsed, minMs); } @@ -230,7 +231,7 @@ export function parseArgs(argv: string[]) { mode = "report"; break; case "--max-workspace-reg-fds": - maxWorkspaceRegFds = readNumber(readValue(), "--max-workspace-reg-fds"); + maxWorkspaceRegFds = parseNonNegativeInteger(readValue(), "--max-workspace-reg-fds"); break; case "--min-leaked-fds": minLeakedFds = readPositiveNumber(readValue(), "--min-leaked-fds"); diff --git a/scripts/check-plugin-gateway-gauntlet.mts b/scripts/check-plugin-gateway-gauntlet.mts index 2956bfcef64b..8243d34fb74d 100644 --- a/scripts/check-plugin-gateway-gauntlet.mts +++ b/scripts/check-plugin-gateway-gauntlet.mts @@ -25,6 +25,9 @@ import { readQaSuiteSummary, selectPluginEntries, } from "./lib/plugin-gateway-gauntlet.mts"; +// Termination tests import this entrypoint in a child before publishing readiness. +// Keep its record guard on the dependency-light script seam to avoid startup skew. +import { isRecord } from "./lib/record-shared.mjs"; const DEFAULT_QA_SCENARIOS = [ "channel-chat-baseline", @@ -115,10 +118,6 @@ type GauntletContext = { }; type QaSummary = NonNullable["summary"]>; -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - /** * Parses plugin gateway gauntlet CLI arguments and env defaults. */ diff --git a/scripts/dependency-changes-report.mts b/scripts/dependency-changes-report.mts index 8930ae9ec9c2..cbb25279fe07 100644 --- a/scripts/dependency-changes-report.mts +++ b/scripts/dependency-changes-report.mts @@ -30,7 +30,7 @@ const DEPENDENCY_DIFF_PATHS = [ type DependencyPayload = Record; type DependencyFileChange = { oldPath: string | null; path: string; status: string }; -const optionalString = (value: string | null) => value; +const nullableString = (value: string | null) => value; function payloadFromLockfile(lockfileText: string): DependencyPayload { const packages = collectAllResolvedPackagesFromLockfile(lockfileText); @@ -261,11 +261,11 @@ function readRequiredValue(argv: string[], index: number, flag: string) { export function parseArgs(argv: string[]) { const options = { rootDir: process.cwd(), - baseRef: optionalString(null), - baseLockfile: optionalString(null), + baseRef: nullableString(null), + baseLockfile: nullableString(null), headLockfile: "pnpm-lock.yaml", - jsonPath: optionalString(null), - markdownPath: optionalString(null), + jsonPath: nullableString(null), + markdownPath: nullableString(null), }; const seen = new Set(); const setOnce = (flag: string, key: keyof typeof options, value: string) => { diff --git a/scripts/docker-e2e.mts b/scripts/docker-e2e.mts index 2820ce41dea2..d008c5da42be 100644 --- a/scripts/docker-e2e.mts +++ b/scripts/docker-e2e.mts @@ -1,15 +1,15 @@ // Docker E2E CI helper. // Converts scheduler JSON into GitHub Actions outputs and compact markdown // summaries so the workflow does not duplicate Docker E2E planning logic. -import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { readDockerE2eJsonArtifact } from "./lib/docker-e2e-json-artifacts.mts"; -function asRecord(value: unknown) { - return isRecord(value) ? value : {}; +function recordOrEmpty(value: unknown) { + return asOptionalRecord(value) ?? {}; } function recordArray(value: unknown) { - return Array.isArray(value) ? value.map(asRecord) : []; + return Array.isArray(value) ? value.map(recordOrEmpty) : []; } function usage() { @@ -26,8 +26,8 @@ function boolOutput(value: unknown) { } function githubOutputs(value: unknown) { - const plan = asRecord(value); - const needs = asRecord(plan.needs); + const plan = recordOrEmpty(value); + const needs = recordOrEmpty(plan.needs); const credentials = Array.isArray(plan.credentials) ? plan.credentials.filter( (credential: unknown): credential is string => typeof credential === "string", @@ -76,7 +76,7 @@ function formatSeconds(value: unknown) { } function summaryMarkdown(value: unknown, title: string) { - const summary = asRecord(value); + const summary = recordOrEmpty(value); const lanes = recordArray(summary.lanes); const slowest = lanes .filter((lane) => Number.isFinite(Number(lane.elapsedSeconds))) @@ -127,7 +127,7 @@ function summaryMarkdown(value: unknown, title: string) { } function failedRerunCommands(value: unknown) { - const summary = asRecord(value); + const summary = recordOrEmpty(value); const lanes = recordArray(summary.lanes); return lanes.flatMap((lane) => lane.status !== 0 && lane.rerunCommand ? [String(lane.rerunCommand)] : [], diff --git a/scripts/e2e/lib/npm-telegram-live/prepare-package.mts b/scripts/e2e/lib/npm-telegram-live/prepare-package.mts index 7e0e5b02fb86..af95efd2d428 100644 --- a/scripts/e2e/lib/npm-telegram-live/prepare-package.mts +++ b/scripts/e2e/lib/npm-telegram-live/prepare-package.mts @@ -2,7 +2,7 @@ import fs from "node:fs"; import { privateLocalOnlyPluginSdkEntrypoints } from "../../../lib/plugin-sdk-entries.mts"; -function isRecord(value: unknown): value is Record { +function isPackageJsonRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -16,10 +16,12 @@ if (!packageJsonPath) { throw new Error("trusted harness package.json path is missing"); } const parsedPackageJson: unknown = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); -if (!isRecord(parsedPackageJson)) { +if (!isPackageJsonRecord(parsedPackageJson)) { throw new Error("trusted harness package.json must be an object"); } -const packageExports = isRecord(parsedPackageJson.exports) ? parsedPackageJson.exports : {}; +const packageExports = isPackageJsonRecord(parsedPackageJson.exports) + ? parsedPackageJson.exports + : {}; parsedPackageJson.exports = packageExports; // Private QA builds emit these two harness-only facades outside the regular SDK inventory. diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index dea377a1a4d8..b8fd35c8d362 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -12,6 +12,7 @@ import { finiteSecondsToTimerSafeMilliseconds, } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { readStringValue } from "@openclaw/normalization-core/string-coerce"; import prettyMilliseconds from "pretty-ms"; import { die, @@ -565,10 +566,6 @@ function parseOpenClawPackageSpecVersion(spec: string): string { return resolveOpenClawRegistryVersion(value) || ""; } -function readString(value: unknown): string { - return typeof value === "string" ? value : ""; -} - export function parseRegistryPackageMetadata(raw: string): { gitHead: string; tarball: string; @@ -585,9 +582,9 @@ export function parseRegistryPackageMetadata(raw: string): { } const dist = isRecord(parsed.dist) ? parsed.dist : {}; return { - gitHead: readString(parsed.gitHead), - tarball: readString(parsed["dist.tarball"]) || readString(dist.tarball), - version: readString(parsed.version), + gitHead: readStringValue(parsed.gitHead) ?? "", + tarball: readStringValue(parsed["dist.tarball"]) || readStringValue(dist.tarball) || "", + version: readStringValue(parsed.version) ?? "", }; } catch { return { gitHead: "", tarball: "", version: "" }; diff --git a/scripts/e2e/telegram-bot-api.ts b/scripts/e2e/telegram-bot-api.ts index ecbc19acca6b..709b47b63f23 100644 --- a/scripts/e2e/telegram-bot-api.ts +++ b/scripts/e2e/telegram-bot-api.ts @@ -1,4 +1,5 @@ // Telegram Bot Api script supports OpenClaw repository automation. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { readBoundedResponseText } from "../lib/bounded-response.mjs"; import { readPositiveIntEnv } from "./lib/env-limits.mjs"; @@ -33,11 +34,6 @@ export function readTelegramBotApiLimits( const DEFAULT_LIMITS = readTelegramBotApiLimits(); -function optionalString(source: JsonObject, key: string) { - const value = source[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function taggedError(message: string, code: string) { return Object.assign(new Error(message), { code }); } @@ -90,7 +86,8 @@ export async function telegramBotApi( const payload = parseJsonPayload(rawPayload, label); if (!response.ok || payload.ok !== true) { throw new Error( - optionalString(payload, "description") ?? `${method} failed with HTTP ${response.status}`, + normalizeOptionalString(payload.description) ?? + `${method} failed with HTTP ${response.status}`, ); } return payload.result; diff --git a/scripts/e2e/telegram-user-credential.ts b/scripts/e2e/telegram-user-credential.ts index 994636073848..402be07ac36b 100644 --- a/scripts/e2e/telegram-user-credential.ts +++ b/scripts/e2e/telegram-user-credential.ts @@ -151,7 +151,7 @@ function requireString(source: JsonObject, key: string) { throw new Error(`Missing ${key}.`); } -function optionalString(source: JsonObject, key: string) { +function readOptionalCredentialString(source: JsonObject, key: string) { const value = source[key]; if (typeof value === "number") { return String(value); @@ -274,7 +274,7 @@ function joinBrokerEndpoint(siteUrl: string, endpoint: string) { function assertBrokerSuccess(payload: JsonObject, action: string) { if (payload.status === "error") { throw new Error( - `${action} failed: ${requireString(payload, "code")} ${optionalString(payload, "message") || ""}`.trim(), + `${action} failed: ${requireString(payload, "code")} ${readOptionalCredentialString(payload, "message") || ""}`.trim(), ); } if (payload.status !== "ok") { diff --git a/scripts/full-release-validation-at-sha.mts b/scripts/full-release-validation-at-sha.mts index 9394265562af..6d0270166a81 100644 --- a/scripts/full-release-validation-at-sha.mts +++ b/scripts/full-release-validation-at-sha.mts @@ -52,7 +52,7 @@ type TemporaryRefParams = { evidenceVerified: boolean; }; -function isRecord(value: unknown): value is Record { +function isJsonRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -355,8 +355,8 @@ function findLatestRunId(branch: string, sha: string) { if (!Array.isArray(runs)) { throw new Error("Full Release Validation run list response was not an array"); } - const match = runs.find((runItem: unknown) => isRecord(runItem) && runItem.headSha === sha); - const databaseId = isRecord(match) ? match.databaseId : undefined; + const match = runs.find((runItem: unknown) => isJsonRecord(runItem) && runItem.headSha === sha); + const databaseId = isJsonRecord(match) ? match.databaseId : undefined; return typeof databaseId === "string" || typeof databaseId === "number" ? String(databaseId) : ""; } @@ -367,7 +367,7 @@ function readWorkflowRun(parentRunId: string, workflowSha: string) { const workflowRun: unknown = JSON.parse( execGhRead(["api", `repos/openclaw/openclaw/actions/runs/${parentRunId}`], GH_READ_OPTIONS), ); - if (!isRecord(workflowRun)) { + if (!isJsonRecord(workflowRun)) { throw new Error(`Full Release Validation run ${parentRunId} returned an invalid response`); } if (workflowRun.head_sha !== workflowSha) { @@ -465,10 +465,10 @@ export function assertTrustedWorkflowHarness( ); } if ( - !isRecord(workflow) || - !isRecord(workflow.on) || - !isRecord(workflow.on.workflow_dispatch) || - !isRecord(workflow.on.workflow_dispatch.inputs) || + !isJsonRecord(workflow) || + !isJsonRecord(workflow.on) || + !isJsonRecord(workflow.on.workflow_dispatch) || + !isJsonRecord(workflow.on.workflow_dispatch.inputs) || !Object.hasOwn(workflow.on.workflow_dispatch.inputs, "expected_sha") ) { throw new Error( @@ -508,10 +508,10 @@ function verifyReleaseEvidence(parentRunId: string, workflowSha: string) { run(process.execPath, [verifier, ...releaseEvidenceVerificationArgs(parentRunId)]), ); if ( - !isRecord(evidence) || + !isJsonRecord(evidence) || evidence.valid !== true || - !isRecord(evidence.current) || - !isRecord(evidence.root) + !isJsonRecord(evidence.current) || + !isJsonRecord(evidence.root) ) { throw new Error(`Full Release Validation evidence is invalid for run ${parentRunId}.`); } diff --git a/scripts/lib/export-name-collision-baseline.json b/scripts/lib/export-name-collision-baseline.json index 21dca44ebf0e..8164a987396e 100644 --- a/scripts/lib/export-name-collision-baseline.json +++ b/scripts/lib/export-name-collision-baseline.json @@ -27,15 +27,6 @@ "src/plugins/uninstall.ts" ] }, - { - "name": "asString", - "files": [ - "src/channels/plugins/status-issues/shared.ts", - "src/cli/nodes-media-utils.ts", - "src/tui/tui-formatters.ts" - ], - "sdk": true - }, { "name": "authorizeConfigWrite", "files": [ @@ -1180,13 +1171,6 @@ "src/secrets/shared.ts" ] }, - { - "name": "toError", - "files": [ - "src/agents/prepared-model-runtime.owner.ts", - "src/worker/worker-connection-contract.ts" - ] - }, { "name": "toPosixPath", "files": [ diff --git a/scripts/lib/plugin-gateway-gauntlet.mts b/scripts/lib/plugin-gateway-gauntlet.mts index fdd18f839e17..87c4adda81ea 100644 --- a/scripts/lib/plugin-gateway-gauntlet.mts +++ b/scripts/lib/plugin-gateway-gauntlet.mts @@ -36,13 +36,13 @@ function readPositiveIntEnv(name: string, fallback: number) { return raw === undefined || raw === "" ? fallback : parsePositiveInt(raw, name); } -function normalizeString(value: unknown) { +function normalizeStringOrEmpty(value: unknown) { return typeof value === "string" ? value.trim() : ""; } function normalizeStringArray(value: unknown) { return Array.isArray(value) - ? value.map((entry) => normalizeString(entry)).filter((entry) => entry.length > 0) + ? value.map((entry) => normalizeStringOrEmpty(entry)).filter((entry) => entry.length > 0) : []; } @@ -102,20 +102,20 @@ function collectCommandAliasRecords(manifest: PluginManifest) { return aliases .map((alias) => { if (typeof alias === "string") { - const name = normalizeString(alias); + const name = normalizeStringOrEmpty(alias); return name ? { name, kind: "runtime-slash", cliCommand: null } : null; } if (!isRecord(alias)) { return null; } - const name = normalizeString(alias.name); + const name = normalizeStringOrEmpty(alias.name); if (!name) { return null; } return { name, - kind: normalizeString(alias.kind) || "runtime-slash", - cliCommand: normalizeString(alias.cliCommand) || null, + kind: normalizeStringOrEmpty(alias.kind) || "runtime-slash", + cliCommand: normalizeStringOrEmpty(alias.cliCommand) || null, }; }) .filter((alias) => alias !== null); @@ -124,7 +124,7 @@ function collectCommandAliasRecords(manifest: PluginManifest) { function collectAuthMethods(manifest: PluginManifest) { const auth = Array.isArray(manifest.auth) ? manifest.auth : []; return auth - .map((entry) => (isRecord(entry) ? normalizeString(entry.method) : "")) + .map((entry) => (isRecord(entry) ? normalizeStringOrEmpty(entry.method) : "")) .filter((method) => method.length > 0); } @@ -153,7 +153,7 @@ function buildPluginMatrixEntry(repoRoot: string, manifestPath: string, manifest return { id: manifest.id, buildId: path.basename(pluginDir), - name: normalizeString(manifest.name) || manifest.id, + name: normalizeStringOrEmpty(manifest.name) || manifest.id, dir: path.relative(repoRoot, pluginDir), manifestPath: relativeManifestPath, enabledByDefault: manifest.enabledByDefault === true, @@ -291,7 +291,7 @@ function median(values: unknown[]) { function groupByPhase(rows: JsonRecord[]) { const phases = new Map(); for (const row of rows) { - const phase = normalizeString(row.phase) || "unknown"; + const phase = normalizeStringOrEmpty(row.phase) || "unknown"; const current = phases.get(phase) ?? []; current.push(row); phases.set(phase, current); @@ -515,7 +515,7 @@ function collectGatewayCpuObservations(params: { if (!isRecord(result)) { continue; } - const id = normalizeString(result.id) || "unknown"; + const id = normalizeStringOrEmpty(result.id) || "unknown"; const cpuCoreMax = metricMax(result.summary, "cpuCoreRatio"); const wallMax = metricMax(result.summary, "readyzMs") ?? metricMax(result.summary, "healthzMs"); if ( diff --git a/scripts/lib/release-beta-verifier.ts b/scripts/lib/release-beta-verifier.ts index 356c1a17e767..007e18081751 100644 --- a/scripts/lib/release-beta-verifier.ts +++ b/scripts/lib/release-beta-verifier.ts @@ -92,20 +92,20 @@ const TRUSTED_TOOLING_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ". const NPM_VIEW_ATTEMPTS = 30; const NPM_VIEW_RETRY_MAX_DELAY_MS = 10_000; +function isJsonRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeOptionalText(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + function compareCodeUnits(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } -function isRecord(value: unknown): value is JsonRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function requireString(value: unknown, label: string): string { - const stringValue = readString(value); + const stringValue = normalizeOptionalText(value); if (stringValue === undefined) { throw new Error(`${label} is missing.`); } @@ -120,11 +120,11 @@ function readTrustedClawHubToolchainIdentity(): { const lockPath = resolve(TRUSTED_TOOLING_ROOT, ".github/release/clawhub-cli/package-lock.json"); const lockBytes = readFileSync(lockPath); const lock = parseJson(lockBytes.toString("utf8"), "trusted ClawHub CLI package-lock.json"); - if (!isRecord(lock) || !isRecord(lock.packages)) { + if (!isJsonRecord(lock) || !isJsonRecord(lock.packages)) { throw new Error("Trusted ClawHub CLI package-lock.json is invalid."); } const clawhub = lock.packages["node_modules/clawhub"]; - if (!isRecord(clawhub)) { + if (!isJsonRecord(clawhub)) { throw new Error("Trusted ClawHub CLI package-lock.json is missing clawhub."); } const clawhubToolchainIntegrity = requireString( @@ -200,22 +200,25 @@ export function parseNpmViewFields(raw: string, distTag: string): NpmViewFields const parsed = parseJson(raw, "npm view"); if (Array.isArray(parsed)) { return { - version: readString(parsed[0]), - distTagVersion: readString(parsed[1]), - integrity: readString(parsed[2]), - tarball: readString(parsed[3]), + version: normalizeOptionalText(parsed[0]), + distTagVersion: normalizeOptionalText(parsed[1]), + integrity: normalizeOptionalText(parsed[2]), + tarball: normalizeOptionalText(parsed[3]), }; } - if (!isRecord(parsed)) { + if (!isJsonRecord(parsed)) { throw new Error("npm view returned an unsupported JSON shape."); } - const distTags = isRecord(parsed["dist-tags"]) ? parsed["dist-tags"] : undefined; - const dist = isRecord(parsed.dist) ? parsed.dist : undefined; + const distTags = isJsonRecord(parsed["dist-tags"]) ? parsed["dist-tags"] : undefined; + const dist = isJsonRecord(parsed.dist) ? parsed.dist : undefined; return { - version: readString(parsed.version), - distTagVersion: readString(parsed[`dist-tags.${distTag}`]) ?? readString(distTags?.[distTag]), - integrity: readString(parsed["dist.integrity"]) ?? readString(dist?.integrity), - tarball: readString(parsed["dist.tarball"]) ?? readString(dist?.tarball), + version: normalizeOptionalText(parsed.version), + distTagVersion: + normalizeOptionalText(parsed[`dist-tags.${distTag}`]) ?? + normalizeOptionalText(distTags?.[distTag]), + integrity: + normalizeOptionalText(parsed["dist.integrity"]) ?? normalizeOptionalText(dist?.integrity), + tarball: normalizeOptionalText(parsed["dist.tarball"]) ?? normalizeOptionalText(dist?.tarball), }; } @@ -519,11 +522,11 @@ async function verifyNpmPackage( } function readClawHubTags(detail: unknown): Record { - if (!isRecord(detail)) { + if (!isJsonRecord(detail)) { return {}; } - const packageDetail = isRecord(detail.package) ? detail.package : undefined; - const tags = isRecord(packageDetail?.tags) ? packageDetail.tags : undefined; + const packageDetail = isJsonRecord(detail.package) ? detail.package : undefined; + const tags = isJsonRecord(packageDetail?.tags) ? packageDetail.tags : undefined; const result: Record = {}; for (const [key, value] of Object.entries(tags ?? {})) { if (typeof value === "string") { @@ -576,7 +579,7 @@ function verifyGitHubRelease(params: ReleaseVerifyBetaArgs): string { "tagName,isPrerelease,url", ]); const release = parseJson(raw, "gh release view"); - if (!isRecord(release)) { + if (!isJsonRecord(release)) { throw new Error("GitHub release returned an unsupported JSON shape."); } if (release.tagName !== params.tag) { @@ -609,22 +612,22 @@ function verifyWorkflowRun(params: { "workflowName,headBranch,event,status,conclusion,url,createdAt,updatedAt,jobs", ]); const run = parseJson(raw, `gh run view ${params.id}`); - if (!isRecord(run)) { + if (!isJsonRecord(run)) { throw new Error(`${params.label}: workflow run returned an unsupported JSON shape.`); } - const workflowName = readString(run.workflowName); + const workflowName = normalizeOptionalText(run.workflowName); if (workflowName !== params.expectedWorkflowName) { throw new Error( `${params.label}: run ${params.id} workflow is ${workflowName ?? ""}, expected ${params.expectedWorkflowName}.`, ); } - const event = readString(run.event); + const event = normalizeOptionalText(run.event); if (event !== "workflow_dispatch") { throw new Error( `${params.label}: run ${params.id} event is ${event ?? ""}, expected workflow_dispatch.`, ); } - const headBranch = readString(run.headBranch); + const headBranch = normalizeOptionalText(run.headBranch); const allowedHeadBranches = params.allowedHeadBranches ?? (params.expectedHeadBranch !== undefined ? [params.expectedHeadBranch] : []); @@ -633,11 +636,11 @@ function verifyWorkflowRun(params: { `${params.label}: run ${params.id} branch is ${headBranch ?? ""}, expected ${allowedHeadBranches.join(" or ")}.`, ); } - const status = readString(run.status); - const conclusion = readString(run.conclusion); - const jobs = Array.isArray(run.jobs) ? run.jobs.filter(isRecord) : []; + const status = normalizeOptionalText(run.status); + const conclusion = normalizeOptionalText(run.conclusion); + const jobs = Array.isArray(run.jobs) ? run.jobs.filter(isJsonRecord) : []; const failedJobs = jobs.filter((job) => { - const jobConclusion = readString(job.conclusion); + const jobConclusion = normalizeOptionalText(job.conclusion); return ( jobConclusion !== undefined && jobConclusion !== "success" && jobConclusion !== "skipped" ); @@ -649,13 +652,15 @@ function verifyWorkflowRun(params: { ); } if (status !== "completed" || conclusion !== "success" || failedJobs.length > 0) { - const failedNames = failedJobs.map((job) => readString(job.name) ?? "").join(", "); + const failedNames = failedJobs + .map((job) => normalizeOptionalText(job.name) ?? "") + .join(", "); throw new Error( `${params.label}: run ${params.id} is ${status ?? ""}/${conclusion ?? ""}${failedNames ? `; failed jobs: ${failedNames}` : ""}.`, ); } - const createdAt = readString(run.createdAt); - const updatedAt = readString(run.updatedAt); + const createdAt = normalizeOptionalText(run.createdAt); + const updatedAt = normalizeOptionalText(run.updatedAt); const createdMs = createdAt === undefined ? Number.NaN : Date.parse(createdAt); const updatedMs = updatedAt === undefined ? Number.NaN : Date.parse(updatedAt); const durationSeconds = @@ -665,7 +670,7 @@ function verifyWorkflowRun(params: { return { id: params.id, label: params.label, - url: readString(run.url), + url: normalizeOptionalText(run.url), durationSeconds, }; } @@ -674,7 +679,7 @@ function requirePositiveIntegerString(value: unknown, label: string): string { if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) { return String(value); } - const stringValue = readString(value); + const stringValue = normalizeOptionalText(value); if (stringValue === undefined || !POSITIVE_INTEGER_PATTERN.test(stringValue)) { throw new Error(`${label} must be a positive integer.`); } @@ -733,7 +738,7 @@ function requireArtifactWorkflowRun( throw new Error(`${params.label} is expired or missing its immutable state.`); } const workflowRun = artifact.workflow_run; - if (!isRecord(workflowRun)) { + if (!isJsonRecord(workflowRun)) { throw new Error(`${params.label} is missing workflow_run metadata.`); } if ( @@ -762,7 +767,7 @@ function requireClawHubBootstrapRunBinding( terminalRunAttempt: string; workflowPath: string; } { - if (!isRecord(run)) { + if (!isJsonRecord(run)) { throw new Error("Plugin ClawHub New run metadata is invalid."); } const runId = requirePositiveSafeInteger(run.id, "Plugin ClawHub New run id"); @@ -809,7 +814,7 @@ function requireClawHubReadbackArtifactBinding( artifactName: string; artifactSizeBytes: number; } { - if (!isRecord(artifact)) { + if (!isJsonRecord(artifact)) { throw new Error("Plugin ClawHub New readback artifact metadata is invalid."); } const artifactName = `clawhub-bootstrap-readback-${run.runId}-${run.terminalRunAttempt}`; @@ -839,7 +844,7 @@ function validateBootstrapPackageEvidence( value: unknown, params: { packageName: string; version: string }, ): void { - if (!isRecord(value)) { + if (!isJsonRecord(value)) { throw new Error(`${params.packageName} bootstrap evidence is invalid.`); } if ( @@ -876,7 +881,7 @@ function validateBootstrapPackageEvidence( const npmIntegrity = requireString(value.npmIntegrity, `${params.packageName} npm integrity`); const npmShasum = requireString(value.npmShasum, `${params.packageName} npm shasum`); const metadata = value.artifactMetadata; - if (!isRecord(metadata)) { + if (!isJsonRecord(metadata)) { throw new Error(`${params.packageName} artifact metadata evidence is invalid.`); } if (metadata.kind !== "npm-pack") { @@ -930,7 +935,7 @@ export function validateClawHubBootstrapEvidence(params: { throw new Error("Downloaded Plugin ClawHub New readback artifact digest mismatch."); } - if (!isRecord(params.evidence)) { + if (!isJsonRecord(params.evidence)) { throw new Error("Plugin ClawHub New readback evidence is invalid."); } if (params.evidence.schemaVersion !== 2 || params.evidence.verificationMode !== "postpublish") { @@ -991,7 +996,7 @@ export function validateClawHubBootstrapEvidence(params: { } const evidencePackages = params.evidence.packages .map((entry) => - isRecord(entry) ? requireString(entry.packageName, "bootstrap package name") : "", + isJsonRecord(entry) ? requireString(entry.packageName, "bootstrap package name") : "", ) .toSorted(compareCodeUnits); if (JSON.stringify(evidencePackages) !== JSON.stringify(expectedPackages)) { @@ -1000,13 +1005,13 @@ export function validateClawHubBootstrapEvidence(params: { for (const packageName of expectedPackages) { validateBootstrapPackageEvidence( params.evidence.packages.find( - (entry) => isRecord(entry) && entry.packageName === packageName, + (entry) => isJsonRecord(entry) && entry.packageName === packageName, ), { packageName, version: params.expectedVersion }, ); } - if (!isRecord(params.packageArtifact)) { + if (!isJsonRecord(params.packageArtifact)) { throw new Error("Plugin ClawHub New package artifact metadata is invalid."); } const packageArtifactId = requirePositiveIntegerString( @@ -1053,14 +1058,14 @@ export function validateClawHubBootstrapEvidence(params: { ); } - const createdAt = readString(runBinding.run.created_at); - const updatedAt = readString(runBinding.run.updated_at); + const createdAt = normalizeOptionalText(runBinding.run.created_at); + const updatedAt = normalizeOptionalText(runBinding.run.updated_at); const createdMs = createdAt === undefined ? Number.NaN : Date.parse(createdAt); const updatedMs = updatedAt === undefined ? Number.NaN : Date.parse(updatedAt); return { id: runId, label: "Plugin ClawHub New", - url: readString(runBinding.run.html_url), + url: normalizeOptionalText(runBinding.run.html_url), durationSeconds: Number.isFinite(createdMs) && Number.isFinite(updatedMs) ? Math.max(0, Math.round((updatedMs - createdMs) / 1000)) @@ -1186,13 +1191,13 @@ async function verifyClawHubBootstrapRun(params: { `actions/runs/${params.runId}/artifacts?per_page=100&name=${encodeURIComponent(readbackName)}`, "Plugin ClawHub New readback artifact list", ); - if (!isRecord(artifactList) || !Array.isArray(artifactList.artifacts)) { + if (!isJsonRecord(artifactList) || !Array.isArray(artifactList.artifacts)) { throw new Error("Plugin ClawHub New readback artifact list is invalid."); } const readbackArtifacts = artifactList.artifacts.filter( - (artifact) => isRecord(artifact) && artifact.name === readbackName, + (artifact) => isJsonRecord(artifact) && artifact.name === readbackName, ); - if (readbackArtifacts.length !== 1 || !isRecord(readbackArtifacts[0])) { + if (readbackArtifacts.length !== 1 || !isJsonRecord(readbackArtifacts[0])) { throw new Error( `Plugin ClawHub New run must have exactly one ${readbackName} artifact; found ${readbackArtifacts.length}.`, ); @@ -1205,7 +1210,7 @@ async function verifyClawHubBootstrapRun(params: { readbackArtifact, token: readGitHubToken(), }); - if (!isRecord(downloaded.value)) { + if (!isJsonRecord(downloaded.value)) { throw new Error("Plugin ClawHub New readback evidence is invalid."); } const packageArtifactId = requirePositiveIntegerString( @@ -1236,7 +1241,7 @@ function readRootPackageVersion(rootDir: string): string { readFileSync(resolve(rootDir, "package.json"), "utf8"), "package.json", ); - if (!isRecord(packageJson)) { + if (!isJsonRecord(packageJson)) { throw new Error("package.json returned an unsupported JSON shape."); } return requireString(packageJson.version, "package.json version"); diff --git a/scripts/lib/windows-cmd-helpers-runtime.mts b/scripts/lib/windows-cmd-helpers-runtime.mts index e72a5f177016..228e3de45463 100644 --- a/scripts/lib/windows-cmd-helpers-runtime.mts +++ b/scripts/lib/windows-cmd-helpers-runtime.mts @@ -2,12 +2,12 @@ const runtimeSpecifier = "../windows-cmd-helpers.mjs"; const runtime: unknown = await import(runtimeSpecifier); -function isRecord(value: unknown): value is Record { +function isRuntimeRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function runtimeFunction(name: string): (...args: unknown[]) => unknown { - if (!isRecord(runtime) || !(name in runtime)) { + if (!isRuntimeRecord(runtime) || !(name in runtime)) { throw new Error(`windows command helper is missing ${name}`); } const value = runtime[name]; diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index b90dec0a2459..f1fdc8b16f46 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -267,7 +267,9 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +1: channel-account-bound native approval request selection. // +6: required-capability V2 harness contracts through the focused and runtime barrels, // including the side-question compatibility split. - 4863, + // +1: add the account-aware native approval request selector. + // +3: add canonical coercion exports while retaining the shipped asString compatibility name. + 4866, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -327,7 +329,9 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +7: restore still-existing deprecated inbound-dispatch callable re-exports. // -3: keep the generic plugin-command reply carrier opaque and non-callable. // +1: channel-account-bound native approval request selection. - 2919, + // +1: add the account-aware native approval request selector. + // +3: add canonical coercion exports while retaining the shipped asString compatibility name. + 2922, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/scripts/resolve-openclaw-package-candidate.mts b/scripts/resolve-openclaw-package-candidate.mts index 762dcfa9fb19..cd1131d5e728 100644 --- a/scripts/resolve-openclaw-package-candidate.mts +++ b/scripts/resolve-openclaw-package-candidate.mts @@ -20,6 +20,10 @@ import { fileURLToPath } from "node:url"; import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mts"; import { resolveNpmJsonEntries } from "./lib/npm-json-output.mts"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; + +function isJsonRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} import { resolveWindowsTaskkillPath } from "./lib/windows-taskkill.mjs"; import { resolveNpmRunner } from "./npm-runner.mts"; import { createPrepublishPluginRegistryArtifact } from "./prepublish-plugin-registry-artifact.mjs"; @@ -673,7 +677,7 @@ export async function readArtifactPackageCandidateMetadata(dir: string) { throw error; } const parsed: unknown = JSON.parse(raw); - if (!isRecord(parsed)) { + if (!isJsonRecord(parsed)) { throw new Error(`artifact package-candidate.json must contain a JSON object`); } const packageSourceSha = @@ -825,7 +829,7 @@ async function moveNewestPackedTarball(outputDir: string, packOutput: string, ou if (parsed !== undefined) { const packedEntry = resolveNpmJsonEntries(parsed).find( (entry): entry is Record => - isRecord(entry) && typeof entry.filename === "string", + isJsonRecord(entry) && typeof entry.filename === "string", ); const packedFilename = packedEntry && typeof packedEntry.filename === "string" ? packedEntry.filename : ""; @@ -1138,7 +1142,7 @@ function normalizeTrustedPackageSource(id: string, raw: unknown): TrustedPackage if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(id)) { throw new Error(`Invalid trusted package source id: ${id}`); } - if (!isRecord(raw)) { + if (!isJsonRecord(raw)) { throw new Error(`trusted package source ${id} must be an object`); } const hosts = toUniqueNormalizedHostList(raw.hosts, "hosts", id); @@ -1148,7 +1152,7 @@ function normalizeTrustedPackageSource(id: string, raw: unknown): TrustedPackage const rawAuth = raw.auth; let auth: TrustedPackageSource["auth"]; if (rawAuth !== undefined) { - if (!isRecord(rawAuth) || rawAuth.type !== "bearer") { + if (!isJsonRecord(rawAuth) || rawAuth.type !== "bearer") { throw new Error(`trusted package source ${id} auth must be {"type":"bearer"}`); } const authKeys = Object.keys(rawAuth); @@ -1185,11 +1189,11 @@ export async function loadTrustedPackageSource( cause: error, }); } - if (!isRecord(policy) || policy.schemaVersion !== 1) { + if (!isJsonRecord(policy) || policy.schemaVersion !== 1) { throw new Error(`Trusted package source policy must use schemaVersion 1: ${policyPath}`); } const sources = policy.sources; - if (!isRecord(sources)) { + if (!isJsonRecord(sources)) { throw new Error(`Trusted package source policy must define sources: ${policyPath}`); } if (!Object.hasOwn(sources, sourceId)) { @@ -1282,8 +1286,8 @@ function normalizeLookupResults(results: unknown): LookupAddress[] { const entries = Array.isArray(results) ? results : [results]; return entries .map((entry) => ({ - address: isRecord(entry) && typeof entry.address === "string" ? entry.address : "", - family: Number(isRecord(entry) ? (entry.family ?? 0) : 0), + address: isJsonRecord(entry) && typeof entry.address === "string" ? entry.address : "", + family: Number(isJsonRecord(entry) ? (entry.family ?? 0) : 0), })) .filter((entry) => entry.address && (entry.family === 4 || entry.family === 6)); } @@ -1659,8 +1663,8 @@ async function readPackageJson(tarball: string) { const raw = await run("tar", ["-xOf", tarball, "package/package.json"], { capture: true }); const pkg: unknown = JSON.parse(raw); return { - name: isRecord(pkg) && typeof pkg.name === "string" ? pkg.name : "", - version: isRecord(pkg) && typeof pkg.version === "string" ? pkg.version : "", + name: isJsonRecord(pkg) && typeof pkg.name === "string" ? pkg.name : "", + version: isJsonRecord(pkg) && typeof pkg.version === "string" ? pkg.version : "", }; } @@ -1676,7 +1680,7 @@ export async function readPackageBuildSourceSha(tarball: string) { } const buildInfo: unknown = JSON.parse(raw); const commit = - isRecord(buildInfo) && typeof buildInfo.commit === "string" ? buildInfo.commit.trim() : ""; + isJsonRecord(buildInfo) && typeof buildInfo.commit === "string" ? buildInfo.commit.trim() : ""; return /^[0-9a-f]{40}$/iu.test(commit) ? commit.toLowerCase() : ""; } @@ -1911,10 +1915,6 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me }); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function errorCode(value: unknown) { return isPropertyContainer(value) ? value.code : undefined; } diff --git a/scripts/verify-pr-hosted-gates.mts b/scripts/verify-pr-hosted-gates.mts index 447e594cbd43..f66bf77b574a 100644 --- a/scripts/verify-pr-hosted-gates.mts +++ b/scripts/verify-pr-hosted-gates.mts @@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { isRecord, readStringField } from "@openclaw/normalization-core/record-coerce"; import { minimatch } from "minimatch"; import { parse } from "yaml"; import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mts"; @@ -80,11 +80,6 @@ type HostedGateEvidence = { notApplicableWorkflows?: string[]; }; -function optionalString(record: Record, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - function optionalNumber(record: Record, key: string): number | undefined { const value = record[key]; return typeof value === "number" ? value : undefined; @@ -100,7 +95,7 @@ function optionalNullableString( function toWorkflowRun(run: Record) { const headRepository = isRecord(run.head_repository) - ? { full_name: optionalString(run.head_repository, "full_name") } + ? { full_name: readStringField(run.head_repository, "full_name") } : undefined; const pullRequests = Array.isArray(run.pull_requests) ? run.pull_requests.filter(isRecord).map((pullRequest) => ({ @@ -111,30 +106,30 @@ function toWorkflowRun(run: Record) { id: optionalNumber(run, "id"), run_number: optionalNumber(run, "run_number"), run_attempt: optionalNumber(run, "run_attempt"), - name: optionalString(run, "name"), - event: optionalString(run, "event"), - head_sha: optionalString(run, "head_sha"), - head_branch: optionalString(run, "head_branch"), + name: readStringField(run, "name"), + event: readStringField(run, "event"), + head_sha: readStringField(run, "head_sha"), + head_branch: readStringField(run, "head_branch"), head_repository: headRepository, - path: optionalString(run, "path"), - display_title: optionalString(run, "display_title"), - status: optionalString(run, "status"), + path: readStringField(run, "path"), + display_title: readStringField(run, "display_title"), + status: readStringField(run, "status"), conclusion: optionalNullableString(run, "conclusion"), - created_at: optionalString(run, "created_at"), - updated_at: optionalString(run, "updated_at"), - html_url: optionalString(run, "html_url"), + created_at: readStringField(run, "created_at"), + updated_at: readStringField(run, "updated_at"), + html_url: readStringField(run, "html_url"), pull_requests: pullRequests, }; } function toCiGateJob(job: Record) { return { - name: optionalString(job, "name"), + name: readStringField(job, "name"), run_id: optionalNumber(job, "run_id"), run_attempt: optionalNumber(job, "run_attempt"), - status: optionalString(job, "status"), - conclusion: optionalString(job, "conclusion"), - completed_at: optionalString(job, "completed_at"), + status: readStringField(job, "status"), + conclusion: readStringField(job, "conclusion"), + completed_at: readStringField(job, "completed_at"), }; } @@ -962,17 +957,17 @@ function loadCiReuseCandidateRuns(repo: string, headBranch: string) { return runs.map( (run): WorkflowRun => ({ id: optionalNumber(run, "databaseId"), - name: optionalString(run, "workflowName"), - event: optionalString(run, "event"), - status: optionalString(run, "status"), + name: readStringField(run, "workflowName"), + event: readStringField(run, "event"), + status: readStringField(run, "status"), conclusion: optionalNullableString(run, "conclusion"), - head_sha: optionalString(run, "headSha"), - head_branch: optionalString(run, "headBranch"), + head_sha: readStringField(run, "headSha"), + head_branch: readStringField(run, "headBranch"), path: CI_WORKFLOW_PATH, - created_at: optionalString(run, "createdAt"), - updated_at: optionalString(run, "updatedAt"), - html_url: optionalString(run, "url"), - display_title: optionalString(run, "displayTitle"), + created_at: readStringField(run, "createdAt"), + updated_at: readStringField(run, "updatedAt"), + html_url: readStringField(run, "url"), + display_title: readStringField(run, "displayTitle"), }), ); } @@ -1114,10 +1109,10 @@ function main(argv = process.argv.slice(2)) { const head = isRecord(pullRequest) && isRecord(pullRequest.head) ? pullRequest.head : undefined; const base = isRecord(pullRequest) && isRecord(pullRequest.base) ? pullRequest.base : undefined; const headRepo = head && isRecord(head.repo) ? head.repo : undefined; - const headBranch = head ? optionalString(head, "ref") : undefined; - const headRepository = headRepo ? optionalString(headRepo, "full_name") : undefined; - const baseSha = base ? optionalString(base, "sha") : undefined; - const headSha = head ? optionalString(head, "sha") : undefined; + const headBranch = head ? readStringField(head, "ref") : undefined; + const headRepository = headRepo ? readStringField(headRepo, "full_name") : undefined; + const baseSha = base ? readStringField(base, "sha") : undefined; + const headSha = head ? readStringField(head, "sha") : undefined; if (!headBranch || !headRepository || !baseSha || !headSha) { throw new Error(`PR #${args.pr} is missing head or base metadata.`); } diff --git a/src/acp/session-mapper.ts b/src/acp/session-mapper.ts index 9a58817331c2..13055af2ef4b 100644 --- a/src/acp/session-mapper.ts +++ b/src/acp/session-mapper.ts @@ -1,5 +1,5 @@ /** Resolves ACP request metadata into OpenClaw Gateway session keys and reset behavior. */ -import { readBool, readString } from "@openclaw/acp-core/meta"; +import { readBool, readMetadataString } from "@openclaw/acp-core/meta"; import type { AcpServerOptions } from "@openclaw/acp-core/types"; import type { GatewayClient } from "../gateway/client.js"; @@ -18,8 +18,8 @@ export function parseSessionMeta(meta: unknown): AcpSessionMeta { } const record = meta as Record; return { - sessionKey: readString(record, ["sessionKey", "session", "key"]), - sessionLabel: readString(record, ["sessionLabel", "label"]), + sessionKey: readMetadataString(record, ["sessionKey", "session", "key"]), + sessionLabel: readMetadataString(record, ["sessionLabel", "label"]), resetSession: readBool(record, ["resetSession", "reset"]), requireExisting: readBool(record, ["requireExistingSession", "requireExisting"]), prefixCwd: readBool(record, ["prefixCwd"]), diff --git a/src/acp/translator.prompt-stream.ts b/src/acp/translator.prompt-stream.ts index 6ca3f585a39f..6a53e622f23f 100644 --- a/src/acp/translator.prompt-stream.ts +++ b/src/acp/translator.prompt-stream.ts @@ -8,7 +8,7 @@ import type { PromptResponse, StopReason, } from "@agentclientprotocol/sdk"; -import { readBool, readNonNegativeInteger, readString } from "@openclaw/acp-core/meta"; +import { readBool, readMetadataString, readNonNegativeInteger } from "@openclaw/acp-core/meta"; import type { AcpSessionStore } from "@openclaw/acp-core/session"; import type { AcpServerOptions } from "@openclaw/acp-core/types"; import { normalizeLowercaseStringOrEmpty as normalizedChatSendAckStatus } from "@openclaw/normalization-core/string-coerce"; @@ -192,7 +192,7 @@ export class AcpTranslatorPromptStream { message, attachments: attachments.length > 0 ? attachments : undefined, idempotencyKey: runId, - thinking: readString(params["_meta"], ["thinking", "thinkingLevel"]), + thinking: readMetadataString(params["_meta"], ["thinking", "thinkingLevel"]), deliver: readBool(params["_meta"], ["deliver"]), timeoutMs: readNonNegativeInteger(params["_meta"], ["timeoutMs"]), }; diff --git a/src/acp/translator.session-list.ts b/src/acp/translator.session-list.ts index a81fc47fbde8..2ae38d07fc23 100644 --- a/src/acp/translator.session-list.ts +++ b/src/acp/translator.session-list.ts @@ -1,6 +1,6 @@ /** Cursor and pagination helpers for ACP session/list requests. */ import path from "node:path"; -import { readNumber } from "@openclaw/acp-core/meta"; +import { readMetadataNumber } from "@openclaw/acp-core/meta"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; const ACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE = 100; @@ -74,7 +74,7 @@ export function assertAbsoluteCwd(cwd: string, method: string): void { export function resolveListSessionsPageSize( meta: Record | null | undefined, ): number { - const requested = readNumber(meta, ["limit", "pageSize"]); + const requested = readMetadataNumber(meta, ["limit", "pageSize"]); if (requested === undefined) { return ACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE; } diff --git a/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts b/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts index 68e99313adb3..304ac01b9bd9 100644 --- a/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; // Coverage for model-call diagnostic events around attempt stream functions. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js"; @@ -111,10 +112,6 @@ async function drain(stream: AsyncIterable): Promise { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object-capitalized"); function readRecordField(record: Record, key: string, label: string) { diff --git a/src/agents/embedded-agent-runner/thinking.test.ts b/src/agents/embedded-agent-runner/thinking.test.ts index d03168ae9f55..3f1a4a6041ac 100644 --- a/src/agents/embedded-agent-runner/thinking.test.ts +++ b/src/agents/embedded-agent-runner/thinking.test.ts @@ -1276,5 +1276,26 @@ describe("stripStaleThinkingSignaturesForCompactionReplay", () => { // Same millisecond as compaction: treated as post-compaction; signature preserved expect(result).toBe(messages); }); + + it("parses numeric-looking compaction strings as dates before numeric message timestamps", () => { + const messages: AgentMessage[] = [ + castAgentMessage({ + role: "compactionSummary", + summary: "s", + tokensBefore: 0, + timestamp: "2026", + }), + castAgentMessage({ + role: "assistant", + content: [{ type: "thinking", thinking: "old", thinkingSignature: "stale_sig" }], + timestamp: 1_000_000, + }), + ]; + + const result = stripStaleThinkingSignaturesForCompactionReplay(messages); + expect((result[1] as AssistantMessage).content).toEqual([ + { type: "thinking", thinking: "old" }, + ]); + }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index b415634a9194..42f86e46a838 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -433,7 +433,7 @@ function readField(value: unknown, key: string): unknown { return (value as Record)[key]; } -function readStringField(value: unknown, key: string): string | undefined { +function readErrorStringField(value: unknown, key: string): string | undefined { const field = readField(value, key); return typeof field === "string" ? field : undefined; } @@ -452,17 +452,17 @@ function readMissingToolResultMarker(err: unknown): true | undefined { return true; } for (const key of ["code", "reason", "status"] as const) { - const value = readStringField(err, key); + const value = readErrorStringField(err, key); if (value && isMissingToolResultMarker(value)) { return true; } } - const output = readStringField(err, "output"); + const output = readErrorStringField(err, "output"); if (output && isMissingToolResultMessage(output)) { return true; } - const resultReason = readStringField(readField(err, "result"), "reason"); - const detailReason = readStringField(readField(err, "detail"), "reason"); + const resultReason = readErrorStringField(readField(err, "result"), "reason"); + const detailReason = readErrorStringField(readField(err, "detail"), "reason"); if (resultReason === MISSING_TOOL_RESULT_REASON || detailReason === MISSING_TOOL_RESULT_REASON) { return true; } diff --git a/src/agents/harness/native-hook-relay-codec.ts b/src/agents/harness/native-hook-relay-codec.ts index 5650008c144a..efe3a878fae9 100644 --- a/src/agents/harness/native-hook-relay-codec.ts +++ b/src/agents/harness/native-hook-relay-codec.ts @@ -12,7 +12,7 @@ import type { import { isJsonObject, readOptionalBoolean, - readOptionalString, + readOptionalNonEmptyString, shellQuoteArgs, } from "./native-hook-relay-utils.js"; @@ -82,27 +82,27 @@ export function normalizeNativeHookInvocation(params: { function normalizeCodexHookMetadata(rawPayload: JsonValue): NativeHookRelayInvocationMetadata { const payload = isJsonObject(rawPayload) ? rawPayload : {}; const metadata: NativeHookRelayInvocationMetadata = {}; - const nativeEventName = readOptionalString(payload.hook_event_name); + const nativeEventName = readOptionalNonEmptyString(payload.hook_event_name); if (nativeEventName) { metadata.nativeEventName = nativeEventName; } - const cwd = readOptionalString(payload.cwd); + const cwd = readOptionalNonEmptyString(payload.cwd); if (cwd) { metadata.cwd = cwd; } - const model = readOptionalString(payload.model); + const model = readOptionalNonEmptyString(payload.model); if (model) { metadata.model = model; } - const turnId = readOptionalString(payload.turn_id); + const turnId = readOptionalNonEmptyString(payload.turn_id); if (turnId) { metadata.turnId = turnId; } - const transcriptPath = readOptionalString(payload.transcript_path); + const transcriptPath = readOptionalNonEmptyString(payload.transcript_path); if (transcriptPath) { metadata.transcriptPath = transcriptPath; } - const permissionMode = readOptionalString(payload.permission_mode); + const permissionMode = readOptionalNonEmptyString(payload.permission_mode); if (permissionMode) { metadata.permissionMode = permissionMode; } @@ -110,15 +110,15 @@ function normalizeCodexHookMetadata(rawPayload: JsonValue): NativeHookRelayInvoc if (stopHookActive !== undefined) { metadata.stopHookActive = stopHookActive; } - const lastAssistantMessage = readOptionalString(payload.last_assistant_message); + const lastAssistantMessage = readOptionalNonEmptyString(payload.last_assistant_message); if (lastAssistantMessage) { metadata.lastAssistantMessage = lastAssistantMessage; } - const toolName = readOptionalString(payload.tool_name); + const toolName = readOptionalNonEmptyString(payload.tool_name); if (toolName) { metadata.toolName = toolName; } - const toolUseId = readOptionalString(payload.tool_use_id); + const toolUseId = readOptionalNonEmptyString(payload.tool_use_id); if (toolUseId) { metadata.toolUseId = toolUseId; } @@ -129,7 +129,7 @@ function readCodexToolInput(rawPayload: JsonValue): Record { const payload = isJsonObject(rawPayload) ? rawPayload : {}; const toolInput = payload.tool_input; if (isJsonObject(toolInput)) { - const toolName = readOptionalString(payload.tool_name); + const toolName = readOptionalNonEmptyString(payload.tool_name); return normalizeCodexToolInput( normalizeNativeHookToolName(toolName), toolInput as Record, diff --git a/src/agents/harness/native-hook-relay-permissions.ts b/src/agents/harness/native-hook-relay-permissions.ts index 0790634e2759..55355c5e4450 100644 --- a/src/agents/harness/native-hook-relay-permissions.ts +++ b/src/agents/harness/native-hook-relay-permissions.ts @@ -36,7 +36,7 @@ import type { NativeHookRelayProviderAdapter, NativeHookRelayRegistration, } from "./native-hook-relay-types.js"; -import { readOptionalString, truncateRelayText } from "./native-hook-relay-utils.js"; +import { readOptionalNonEmptyString, truncateRelayText } from "./native-hook-relay-utils.js"; export type NativeHookRelayDeferredToolApprovalRequester = typeof requestDeferredPluginToolApproval; @@ -280,7 +280,7 @@ function nativeHookRelayPermissionAllowAlwaysKey(params: { } function permissionRequestFallbackKey(request: NativeHookRelayPermissionApprovalRequest): string { - const command = readOptionalString(request.toolInput.command); + const command = readOptionalNonEmptyString(request.toolInput.command); if (command) { return `${request.toolName}:command:${truncateRelayText(command, 240)}`; } @@ -590,7 +590,7 @@ function formatPermissionApprovalDescription( } function formatToolInputPreview(toolInput: Record): string | undefined { - const command = readOptionalString(toolInput.command); + const command = readOptionalNonEmptyString(toolInput.command); if (command) { return `Command: ${truncateRelayText(sanitizeApprovalText(command), 240)}`; } diff --git a/src/agents/harness/native-hook-relay-utils.ts b/src/agents/harness/native-hook-relay-utils.ts index a44eba1790a0..e1934ce1094a 100644 --- a/src/agents/harness/native-hook-relay-utils.ts +++ b/src/agents/harness/native-hook-relay-utils.ts @@ -66,7 +66,7 @@ export function readNonEmptyString(value: unknown, name: string): string { throw new Error(`native hook relay ${name} is required`); } -export function readOptionalString(value: unknown): string | undefined { +export function readOptionalNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/src/agents/harness/native-hook-relay.test.ts b/src/agents/harness/native-hook-relay.test.ts index 21b8e1972a21..b4e318e7e1c5 100644 --- a/src/agents/harness/native-hook-relay.test.ts +++ b/src/agents/harness/native-hook-relay.test.ts @@ -4,6 +4,7 @@ import { createServer, request as httpRequest } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; // Covers native hook relay registration, bridge invocation, and approval state. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; @@ -43,10 +44,6 @@ afterEach(() => { testing.clearNativeHookRelaysForTests(); }); -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object-capitalized"); function readRecordField(record: Record, key: string, label: string) { diff --git a/src/agents/openai-strict-tool-setting.ts b/src/agents/openai-strict-tool-setting.ts index e301393d0836..3faac2b65844 100644 --- a/src/agents/openai-strict-tool-setting.ts +++ b/src/agents/openai-strict-tool-setting.ts @@ -19,19 +19,17 @@ type OpenAIStrictToolModel = { compat?: unknown; }; -const optionalString = readStringValue; - function resolvesToNativeOpenAIStrictTools( model: OpenAIStrictToolModel, transport: OpenAITransportKind, ): boolean { const capabilities = resolveProviderRequestCapabilities({ - provider: optionalString(model.provider), - api: optionalString(model.api), - baseUrl: optionalString(model.baseUrl), + provider: readStringValue(model.provider), + api: readStringValue(model.api), + baseUrl: readStringValue(model.baseUrl), capability: "llm", transport, - modelId: optionalString(model.id), + modelId: readStringValue(model.id), compat: model.compat, }); if (!capabilities.usesKnownNativeOpenAIRoute) { diff --git a/src/agents/prepared-model-runtime.owner.ts b/src/agents/prepared-model-runtime.owner.ts index 09ff6aaae263..aa5f1095086e 100644 --- a/src/agents/prepared-model-runtime.owner.ts +++ b/src/agents/prepared-model-runtime.owner.ts @@ -318,10 +318,6 @@ export function hasSameLifecycleInput( ); } -export function toError(error: unknown): Error { - return toPreparedModelRuntimeError(error); -} - export function createPreparedModelRuntimeReplacement(): PreparedModelRuntimeReplacement { let resolve!: () => void; let reject!: (error: Error) => void; @@ -499,7 +495,7 @@ export async function publishPreparedModelRuntimeOwnerBatch(params: { } break; } catch (error) { - const refreshError = toError(error); + const refreshError = toPreparedModelRuntimeError(error); const lostCandidate = attempt.some((candidate) => !candidate.isCurrent()); if ( !(refreshError instanceof PreparedModelRuntimePublicationSupersededError) || @@ -528,7 +524,7 @@ export async function publishPreparedModelRuntimeOwnerBatch(params: { candidate.owner.needsRefresh = false; } } catch (error) { - const refreshError = toError(error); + const refreshError = toPreparedModelRuntimeError(error); for (const candidate of candidates) { if (!candidate.isCurrent()) { continue; @@ -606,7 +602,7 @@ export async function publishModelRuntimeSnapshot( owner.needsRefresh = false; return result.snapshot; } catch (error) { - const refreshError = toError(error); + const refreshError = toPreparedModelRuntimeError(error); if (owner.generation === generation && owners.get(key) === owner) { owner.pending = undefined; owner.needsRefresh = true; diff --git a/src/agents/prepared-model-runtime.ts b/src/agents/prepared-model-runtime.ts index 0319cf5838e2..5ff7c04a30a2 100644 --- a/src/agents/prepared-model-runtime.ts +++ b/src/agents/prepared-model-runtime.ts @@ -4,6 +4,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { isReservedSystemAgentId } from "../system-agent/agent-id.js"; import { registerRuntimeAuthProfileStoreMutationListener } from "./auth-profiles/runtime-snapshots.js"; import { registerPreparedRuntimeAuthMaterializationPublisher } from "./prepared-model-runtime-materializations.js"; +import { toPreparedModelRuntimeError } from "./prepared-model-runtime.errors.js"; import { PreparedModelRuntimeOwnerNotPublishedError, PreparedModelRuntimeOwnerRetention, @@ -22,7 +23,6 @@ import { publishModelRuntimeSnapshot, rebindInputToCommittedConfiguredOwner, resolvePublishedOwner, - toError, type PreparedModelRuntimeOwner, type PreparedModelRuntimeInput, type PreparedModelRuntimePublicationOptions, @@ -513,7 +513,7 @@ export function rejectPendingPreparedModelRuntimeReplacement( return; } pendingModelRuntimeReplacement = undefined; - const replacementError = toError(error); + const replacementError = toPreparedModelRuntimeError(error); replacement.reject(replacementError); notifyPreparedModelRuntimePublication({ phase: "failed", error: replacementError }); } @@ -633,7 +633,7 @@ export function refreshPreparedModelRuntimeSnapshots( } }, (error: unknown) => { - const refreshError = toError(error); + const refreshError = toPreparedModelRuntimeError(error); if (requestEpoch === refreshRequestEpoch) { // Candidate and queued auth builds may finish independently. A failed transaction must // leave no owner from its partially published generation request-visible. @@ -739,7 +739,7 @@ function invalidateForAuthMutation(event: AuthMutationEvent): void { if (error instanceof PreparedModelRuntimePublicationSupersededError) { return; } - const refreshError = toError(error); + const refreshError = toPreparedModelRuntimeError(error); notifyPreparedModelRuntimePublication({ phase: "failed", error: refreshError }); log.warn(`auth-triggered model runtime refresh failed: ${String(refreshError)}`); }); diff --git a/src/agents/session-activity-notes.ts b/src/agents/session-activity-notes.ts index bca1aaaa370b..89b0a0241727 100644 --- a/src/agents/session-activity-notes.ts +++ b/src/agents/session-activity-notes.ts @@ -214,7 +214,7 @@ export function noteSessionActivityEvent( addActivityNote(state, "Run is wrapping up", noteMaxChars); } else if (phase === "end" || phase === "error") { const health = terminalHealthFor(event); - const error = readString(data.error); + const error = readNonBlankString(data.error); addActivityNote(state, error ? `Run ${health}: ${error}` : `Run ${health}`, noteMaxChars); const terminalReply = normalizeAgentRunTerminalReplySnapshot(data.terminalReply); state.terminalReply = terminalReply; @@ -230,7 +230,7 @@ export function noteSessionActivityEvent( if (data.phase !== "start") { return; } - const name = readString(data.name) ?? "tool"; + const name = readNonBlankString(data.name) ?? "tool"; const args = summarizeToolArgs(data.args); addActivityNote(state, args ? `Tool ${name}: ${args}` : `Tool ${name}`, noteMaxChars); return; @@ -239,9 +239,9 @@ export function noteSessionActivityEvent( if (data.phase !== "end") { return; } - const title = readString(data.title) ?? readString(data.name) ?? "command"; + const title = readNonBlankString(data.title) ?? readNonBlankString(data.name) ?? "command"; const exitCode = readFiniteNumber(data.exitCode); - const status = readString(data.status) ?? (exitCode === 0 ? "completed" : "failed"); + const status = readNonBlankString(data.status) ?? (exitCode === 0 ? "completed" : "failed"); addActivityNote( state, `${title}: ${status}${exitCode === undefined ? "" : ` (exit ${exitCode})`}`, @@ -250,9 +250,9 @@ export function noteSessionActivityEvent( return; } case "item": { - const status = readString(data.status); - const title = readString(data.title); - const itemId = readString(data.itemId) ?? title; + const status = readNonBlankString(data.status); + const title = readNonBlankString(data.title); + const itemId = readNonBlankString(data.itemId) ?? title; if (!status || !title || !itemId) { return; } @@ -285,8 +285,8 @@ export function noteSessionActivityEvent( return; } case "assistant": { - const full = readString(data.text); - const delta = readString(data.delta); + const full = readNonBlankString(data.text); + const delta = readNonBlankString(data.delta); if (full) { state.assistantRawBuffer = full; } else if (delta) { @@ -317,7 +317,7 @@ export function noteSessionActivityEvent( } addActivityNote( state, - `Waiting for approval: ${readString(data.title) ?? "user action"}`, + `Waiting for approval: ${readNonBlankString(data.title) ?? "user action"}`, noteMaxChars, ); break; @@ -327,7 +327,7 @@ export function noteSessionActivityEvent( } } -function readString(value: unknown): string | undefined { +function readNonBlankString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } diff --git a/src/agents/subagents/spawn/subagent-spawn-thinking.ts b/src/agents/subagents/spawn/subagent-spawn-thinking.ts index 2f7f9715ae8e..4cbc3f08d00c 100644 --- a/src/agents/subagents/spawn/subagent-spawn-thinking.ts +++ b/src/agents/subagents/spawn/subagent-spawn-thinking.ts @@ -3,14 +3,10 @@ * this helper to patch the child session without leaking invalid caller input. */ import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeThinkLevel } from "../../../auto-reply/thinking.shared.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -function readString(value: Record, key: string): string | undefined { - const raw = value[key]; - return typeof raw === "string" && raw.trim() ? raw.trim() : undefined; -} - /** Resolves subagent thinking override and initial session patch from caller/agent config. */ export function resolveSubagentThinkingOverride(params: { cfg: OpenClawConfig; @@ -27,9 +23,9 @@ export function resolveSubagentThinkingOverride(params: { ); const defaultSubagents = asOptionalObjectRecord(params.cfg.agents?.defaults?.subagents); const resolvedThinkingDefaultRaw = - readString(requesterSubagents ?? {}, "thinking") ?? - readString(targetSubagents ?? {}, "thinking") ?? - readString(defaultSubagents ?? {}, "thinking"); + normalizeOptionalString(requesterSubagents?.thinking) ?? + normalizeOptionalString(targetSubagents?.thinking) ?? + normalizeOptionalString(defaultSubagents?.thinking); const overrideCandidateRaw = params.thinkingOverrideRaw || resolvedThinkingDefaultRaw; if (overrideCandidateRaw) { diff --git a/src/agents/test-helpers/live-cache-regression-runner.ts b/src/agents/test-helpers/live-cache-regression-runner.ts index 677b9d4b2d0a..cc05d5143e46 100644 --- a/src/agents/test-helpers/live-cache-regression-runner.ts +++ b/src/agents/test-helpers/live-cache-regression-runner.ts @@ -111,13 +111,13 @@ function extractFirstToolCall(message: AssistantMessage) { function normalizeCacheUsage(usage: AssistantMessage["usage"] | undefined): CacheUsage { const value = usage as Record | null | undefined; - const readNumber = (key: keyof CacheUsage): number | undefined => + const readUsageNumber = (key: keyof CacheUsage): number | undefined => typeof value?.[key] === "number" ? value[key] : undefined; return { - input: readNumber("input"), - output: readNumber("output"), - cacheRead: readNumber("cacheRead"), - cacheWrite: readNumber("cacheWrite"), + input: readUsageNumber("input"), + output: readUsageNumber("output"), + cacheRead: readUsageNumber("cacheRead"), + cacheWrite: readUsageNumber("cacheWrite"), }; } diff --git a/src/agents/tool-search-config.ts b/src/agents/tool-search-config.ts index 3297b07db209..45983e5c90e9 100644 --- a/src/agents/tool-search-config.ts +++ b/src/agents/tool-search-config.ts @@ -22,7 +22,7 @@ function readToolSearchConfig(config?: OpenClawConfig): Record return isRecord(toolSearch) ? toolSearch : {}; } -function readBoolean(value: unknown, fallback: boolean): boolean { +function resolveBoolean(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } @@ -57,7 +57,7 @@ export function resolveToolSearchConfig(config?: OpenClawConfig): ToolSearchConf Math.min(MAX_TOOL_SEARCH_RESULTS, readInteger(raw.maxSearchLimit, DEFAULT_MAX_SEARCH_LIMIT)), ); return { - enabled: readBoolean(raw.enabled, configured), + enabled: resolveBoolean(raw.enabled, configured), mode, codeTimeoutMs: Math.max( resolveMinCodeTimeoutMs(), diff --git a/src/agents/tools/sessions-tool.ts b/src/agents/tools/sessions-tool.ts index fa7b99b446ba..952b42192ba2 100644 --- a/src/agents/tools/sessions-tool.ts +++ b/src/agents/tools/sessions-tool.ts @@ -137,7 +137,7 @@ type SessionsToolOptions = { hasInProcessGatewayContext?: () => boolean; }; -function readBoolean(params: Record, key: string): boolean | undefined { +function readBooleanParam(params: Record, key: string): boolean | undefined { const value = params[key]; if (value === undefined) { return undefined; @@ -292,7 +292,7 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool archivedOnly: true, expectedSessionId, ...(expectedLifecycleRevision ? { expectedLifecycleRevision } : {}), - deleteTranscript: readBoolean(params, "deleteTranscript") ?? true, + deleteTranscript: readBooleanParam(params, "deleteTranscript") ?? true, }), ); } @@ -345,8 +345,10 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool ...(params.ttlMinutes !== undefined ? { ttlMinutes: readInteger(params, "ttlMinutes") } : {}), - ...(params.pinned !== undefined ? { pinned: readBoolean(params, "pinned") } : {}), - ...(params.archived !== undefined ? { archived: readBoolean(params, "archived") } : {}), + ...(params.pinned !== undefined ? { pinned: readBooleanParam(params, "pinned") } : {}), + ...(params.archived !== undefined + ? { archived: readBooleanParam(params, "archived") } + : {}), ...(params.model !== undefined ? { model: readToolStringParam(params, "model", { required: true }) } : {}), diff --git a/src/agents/tools/terminal-tool.ts b/src/agents/tools/terminal-tool.ts index fbaf0626262e..d4d7e1ecf27a 100644 --- a/src/agents/tools/terminal-tool.ts +++ b/src/agents/tools/terminal-tool.ts @@ -125,7 +125,7 @@ function readShow(params: Record): boolean { return value; } -function readOptionalString( +function readOptionalStringParam( params: Record, key: "command" | "cwd", options: { trim?: boolean } = {}, @@ -187,8 +187,8 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool } if (action === "open") { - const command = readOptionalString(params, "command", { trim: false }); - const cwd = readOptionalString(params, "cwd"); + const command = readOptionalStringParam(params, "command", { trim: false }); + const cwd = readOptionalStringParam(params, "cwd"); const cols = readDimension(params, "cols", DEFAULT_COLS); const rows = readDimension(params, "rows", DEFAULT_ROWS); const show = readShow(params); diff --git a/src/auto-reply/reply/reply-media-paths.test.ts b/src/auto-reply/reply/reply-media-paths.test.ts index 86f0c346d7ef..7548ab17373c 100644 --- a/src/auto-reply/reply/reply-media-paths.test.ts +++ b/src/auto-reply/reply/reply-media-paths.test.ts @@ -31,13 +31,13 @@ type NormalizedReply = { text?: string; }; -function isRecord(value: unknown): value is Record { +function isObjectRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function requireRecord(value: unknown, label: string): Record { - expect(isRecord(value)).toBe(true); - if (!isRecord(value)) { + expect(isObjectRecord(value)).toBe(true); + if (!isObjectRecord(value)) { throw new Error(`${label} was not an object`); } return value; diff --git a/src/channels/plugins/status-issues/shared.ts b/src/channels/plugins/status-issues/shared.ts index 58ad13bff4c1..086e1020d007 100644 --- a/src/channels/plugins/status-issues/shared.ts +++ b/src/channels/plugins/status-issues/shared.ts @@ -8,13 +8,6 @@ import { isRecord } from "../../../utils.js"; import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.public.js"; export { isRecord }; -/** - * Normalizes optional string metadata in status issue helpers. - */ -export function asString(value: unknown): string | undefined { - return typeof value === "string" ? normalizeOptionalString(value) : undefined; -} - /** * Formats optional match metadata for status issue messages. */ @@ -28,7 +21,7 @@ export function formatMatchMetadata(params: { : typeof params.matchKey === "number" ? String(params.matchKey) : undefined; - const matchSource = asString(params.matchSource); + const matchSource = normalizeOptionalString(params.matchSource); const parts = [ matchKey ? `matchKey=${matchKey}` : null, matchSource ? `matchSource=${matchSource}` : null, @@ -55,7 +48,7 @@ export function resolveEnabledConfiguredAccountId(account: { enabled?: unknown; configured?: unknown; }): string | null { - const accountId = asString(account.accountId) ?? "default"; + const accountId = normalizeOptionalString(account.accountId) ?? "default"; const enabled = account.enabled !== false; const configured = account.configured === true; return enabled && configured ? accountId : null; @@ -79,7 +72,7 @@ export function collectIssuesForEnabledAccounts< if (!account || account.enabled === false) { continue; } - const accountId = asString(account.accountId) ?? "default"; + const accountId = normalizeOptionalString(account.accountId) ?? "default"; params.collectIssues({ account, accountId, issues }); } return issues; diff --git a/src/cli/nodes-camera.ts b/src/cli/nodes-camera.ts index 6448f8329255..af403f533448 100644 --- a/src/cli/nodes-camera.ts +++ b/src/cli/nodes-camera.ts @@ -12,7 +12,7 @@ import { asBoolean, asNumber, asRecord, - asString, + readStringValue, resolveTempPathParts, } from "./nodes-media-utils.js"; import { publishOutputFileAtomically } from "./output-file.runtime.js"; @@ -85,9 +85,9 @@ type CameraClipPayload = { /** Validate and normalize an unknown camera still-image payload. */ export function parseCameraSnapPayload(value: unknown): CameraSnapPayload { const obj = asRecord(value); - const format = asString(obj.format); - const base64 = asString(obj.base64); - const url = asString(obj.url); + const format = readStringValue(obj.format); + const base64 = readStringValue(obj.base64); + const url = readStringValue(obj.url); const width = asNumber(obj.width); const height = asNumber(obj.height); if (!format || (!base64 && !url) || width === undefined || height === undefined) { @@ -99,9 +99,9 @@ export function parseCameraSnapPayload(value: unknown): CameraSnapPayload { /** Validate and normalize an unknown camera clip payload. */ export function parseCameraClipPayload(value: unknown): CameraClipPayload { const obj = asRecord(value); - const format = asString(obj.format); - const base64 = asString(obj.base64); - const url = asString(obj.url); + const format = readStringValue(obj.format); + const base64 = readStringValue(obj.base64); + const url = readStringValue(obj.url); const durationMs = asNumber(obj.durationMs); const hasAudio = asBoolean(obj.hasAudio); if (!format || (!base64 && !url) || durationMs === undefined || hasAudio === undefined) { diff --git a/src/cli/nodes-media-utils.test.ts b/src/cli/nodes-media-utils.test.ts index 2f7ec8a23a6e..b5c5639a3ed8 100644 --- a/src/cli/nodes-media-utils.test.ts +++ b/src/cli/nodes-media-utils.test.ts @@ -4,7 +4,7 @@ import { asBoolean, asNumber, asRecord, - asString, + readStringValue, mediaPathMatchesFormat, resolveTempPathParts, } from "./nodes-media-utils.js"; @@ -13,8 +13,8 @@ describe("cli/nodes-media-utils", () => { it("parses primitive helper values", () => { expect(asRecord({ a: 1 })).toEqual({ a: 1 }); expect(asRecord("x")).toStrictEqual({}); - expect(asString("x")).toBe("x"); - expect(asString(1)).toBeUndefined(); + expect(readStringValue("x")).toBe("x"); + expect(readStringValue(1)).toBeUndefined(); expect(asNumber(1)).toBe(1); expect(asNumber(Number.NaN)).toBeUndefined(); expect(asBoolean(true)).toBe(true); diff --git a/src/cli/nodes-media-utils.ts b/src/cli/nodes-media-utils.ts index 2fac48fdff05..f46177ca2a71 100644 --- a/src/cli/nodes-media-utils.ts +++ b/src/cli/nodes-media-utils.ts @@ -3,13 +3,11 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { extnameFromAnyPath } from "@openclaw/media-core/file-name"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -export { asFiniteNumber as asNumber } from "../../packages/normalization-core/src/number-coercion.js"; -import { readStringValue } from "../../packages/normalization-core/src/string-coerce.js"; -export { asRecord } from "../../packages/normalization-core/src/record-coerce.js"; +export { asFiniteNumber as asNumber } from "@openclaw/normalization-core/number-coercion"; +export { asRecord } from "@openclaw/normalization-core/record-coerce"; +export { readStringValue } from "@openclaw/normalization-core/string-coerce"; export { asBoolean } from "../utils/boolean.js"; -export const asString = readStringValue; - function normalizeMediaExtension(value: string): string | undefined { const raw = (value.startsWith(".") ? value.slice(1) : value).toLowerCase(); if (!/^[a-z0-9][a-z0-9_-]{0,15}$/u.test(raw)) { diff --git a/src/cli/nodes-screen.ts b/src/cli/nodes-screen.ts index ff023f303ccf..7efdb895a65b 100644 --- a/src/cli/nodes-screen.ts +++ b/src/cli/nodes-screen.ts @@ -2,7 +2,7 @@ import * as path from "node:path"; import { extnameFromAnyPath } from "@openclaw/media-core/file-name"; import { writeBase64ToFile } from "./nodes-camera.js"; -import { asRecord, asString, resolveTempPathParts } from "./nodes-media-utils.js"; +import { asRecord, readStringValue, resolveTempPathParts } from "./nodes-media-utils.js"; /** Validated payload returned by `nodes screen record` RPC calls. */ type ScreenRecordPayload = { @@ -17,8 +17,8 @@ type ScreenRecordPayload = { /** Validate and normalize an unknown screen-record payload. */ export function parseScreenRecordPayload(value: unknown): ScreenRecordPayload { const obj = asRecord(value); - const format = asString(obj.format); - const base64 = asString(obj.base64); + const format = readStringValue(obj.format); + const base64 = readStringValue(obj.base64); if (!format || !base64) { throw new Error("invalid screen.record payload"); } @@ -61,15 +61,15 @@ type ScreenSnapshotPayload = { /** Validate and normalize an unknown screen-snapshot payload. */ export function parseScreenSnapshotPayload(value: unknown): ScreenSnapshotPayload { const obj = asRecord(value); - const format = asString(obj.format); - const base64 = asString(obj.base64); + const format = readStringValue(obj.format); + const base64 = readStringValue(obj.base64); if (!format || !base64) { throw new Error("invalid screen.snapshot payload"); } return { format, base64, - displayFrameId: asString(obj.displayFrameId) || undefined, + displayFrameId: readStringValue(obj.displayFrameId) || undefined, screenIndex: typeof obj.screenIndex === "number" ? obj.screenIndex : undefined, width: typeof obj.width === "number" ? obj.width : undefined, height: typeof obj.height === "number" ? obj.height : undefined, diff --git a/src/cli/program/register.setup.ts b/src/cli/program/register.setup.ts index 7dce03106620..68d55e2a481c 100644 --- a/src/cli/program/register.setup.ts +++ b/src/cli/program/register.setup.ts @@ -1,3 +1,4 @@ +import { readStringValue } from "@openclaw/normalization-core/string-coerce"; // Setup command registration: system-agent chat for configured systems, onboarding otherwise. import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; @@ -26,9 +27,6 @@ import { const SYSTEM_AGENT_OPTION_NAMES = new Set(["message", "yes", "json"]); const BASELINE_OPTION_NAMES = new Set(["baseline", "workspace", "json"]); -const optionalString = (value: unknown): string | undefined => - typeof value === "string" ? value : undefined; - type SetupRoute = "onboarding" | "system-agent"; export function resolveSetupCommandRoute(input: { @@ -104,7 +102,7 @@ async function runSystemAgentEntry( await import("../../commands/system-agent-with-inference.js"); await runSystemAgentWithInference( { - message: optionalString(options.message), + message: readStringValue(options.message), yes: Boolean(options.yes), json: Boolean(options.json), }, @@ -126,7 +124,7 @@ async function runOnboardingEntry( } const { setupCommand } = await import("../../commands/setup.js"); await setupCommand( - { workspace: optionalString(options.workspace), json: Boolean(options.json) }, + { workspace: readStringValue(options.workspace), json: Boolean(options.json) }, runtime, ); return; @@ -140,7 +138,7 @@ async function runOnboardingEntry( const { setupWizardCommand } = await import("../../commands/onboard.js"); await setupWizardCommand( { - workspace: optionalString(options.workspace), + workspace: readStringValue(options.workspace), nonInteractive: Boolean(options.nonInteractive), acceptRisk: Boolean(options.acceptRisk), classic: Boolean(options.classic), @@ -153,9 +151,9 @@ async function runOnboardingEntry( gatewayPort, gatewayBind: options.gatewayBind as GatewayBind | undefined, gatewayAuth: options.gatewayAuth as GatewayAuthChoice | undefined, - gatewayToken: optionalString(options.gatewayToken), - gatewayTokenRefEnv: optionalString(options.gatewayTokenRefEnv), - gatewayPassword: optionalString(options.gatewayPassword), + gatewayToken: readStringValue(options.gatewayToken), + gatewayTokenRefEnv: readStringValue(options.gatewayTokenRefEnv), + gatewayPassword: readStringValue(options.gatewayPassword), tailscale: options.tailscale as TailscaleMode | undefined, tailscaleResetOnExit, installDaemon, @@ -169,11 +167,11 @@ async function runOnboardingEntry( suppressGatewayTokenOutput: Boolean(options.suppressGatewayTokenOutput), skipHooks: Boolean(options.skipHooks), nodeManager: options.nodeManager as NodeManagerChoice | undefined, - importFrom: optionalString(options.importFrom), - importSource: optionalString(options.importSource), + importFrom: readStringValue(options.importFrom), + importSource: readStringValue(options.importSource), importSecrets: Boolean(options.importSecrets), - remoteUrl: optionalString(options.remoteUrl), - remoteToken: optionalString(options.remoteToken), + remoteUrl: readStringValue(options.remoteUrl), + remoteToken: readStringValue(options.remoteToken), json: Boolean(options.json), }, runtime, diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index a59ac7519f92..0f1ade104197 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -1,5 +1,6 @@ // Channel setup plugin install tests cover install decisions, registry reloads, scoped snapshots, and trust boundaries. import path from "node:path"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { createRequireRecord, bundledPluginRoot, @@ -314,10 +315,6 @@ function expectPluginLoadedFromLocalPath( expect(result.cfg.plugins?.load?.paths).toContain(expectedPath); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object"); function requireArray(value: unknown, label: string): unknown[] { diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index ee66cead1bc6..6eb9f894a118 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -33,14 +33,14 @@ const noteImplicitFallbackClobberWarningsMock = vi.hoisted(() => }), ); const legacyConfigMigrationForTest = vi.hoisted(() => { - function asRecord(value: unknown): Record | null { + function readNullableRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } function ensureRecord(parent: Record, key: string): Record { - const current = asRecord(parent[key]); + const current = readNullableRecord(parent[key]); if (current) { return current; } @@ -50,8 +50,8 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } function migrateThreadBinding(value: unknown, changes: string[], pathLabel: string): void { - const record = asRecord(value); - const bindings = asRecord(record?.threadBindings); + const record = readNullableRecord(value); + const bindings = readNullableRecord(record?.threadBindings); if (!bindings || !("ttlHours" in bindings)) { return; } @@ -74,7 +74,7 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { delete channel.streamMode; return true; } - const streaming = asRecord(channel.streaming) ?? {}; + const streaming = readNullableRecord(channel.streaming) ?? {}; if (!("mode" in streaming)) { streaming.mode = channel.streamMode === "block" @@ -91,8 +91,8 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { function migrateNestedAllowAliases(channel: Record, channelId: string): boolean { let changed = false; if (channelId === "slack") { - for (const room of Object.values(asRecord(channel.channels) ?? {})) { - const roomRecord = asRecord(room); + for (const room of Object.values(readNullableRecord(channel.channels) ?? {})) { + const roomRecord = readNullableRecord(room); if (roomRecord && "allow" in roomRecord) { roomRecord.enabled = roomRecord.allow; delete roomRecord.allow; @@ -101,8 +101,8 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } } if (channelId === "googlechat") { - for (const group of Object.values(asRecord(channel.groups) ?? {})) { - const groupRecord = asRecord(group); + for (const group of Object.values(readNullableRecord(channel.groups) ?? {})) { + const groupRecord = readNullableRecord(group); if (groupRecord && "allow" in groupRecord) { groupRecord.enabled = groupRecord.allow; delete groupRecord.allow; @@ -111,9 +111,11 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } } if (channelId === "discord") { - for (const guild of Object.values(asRecord(channel.guilds) ?? {})) { - for (const room of Object.values(asRecord(asRecord(guild)?.channels) ?? {})) { - const roomRecord = asRecord(room); + for (const guild of Object.values(readNullableRecord(channel.guilds) ?? {})) { + for (const room of Object.values( + readNullableRecord(readNullableRecord(guild)?.channels) ?? {}, + )) { + const roomRecord = readNullableRecord(room); if (roomRecord && "allow" in roomRecord) { roomRecord.enabled = roomRecord.allow; delete roomRecord.allow; @@ -126,14 +128,14 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } function migrate(raw: unknown): { next: Record | null; changes: string[] } { - const root = asRecord(raw); + const root = readNullableRecord(raw); if (!root) { return { next: null, changes: [] }; } const next = structuredClone(root); const changes: string[] = []; - const heartbeat = asRecord(next.heartbeat); + const heartbeat = readNullableRecord(next.heartbeat); if (heartbeat) { const agents = ensureRecord(next, "agents"); const agentDefaults = ensureRecord(agents, "defaults"); @@ -153,13 +155,13 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } if (Object.keys(agentHeartbeat).length > 0) { agentDefaults.heartbeat = { - ...asRecord(agentDefaults.heartbeat), + ...readNullableRecord(agentDefaults.heartbeat), ...agentHeartbeat, }; } if (Object.keys(channelHeartbeat).length > 0) { channelDefaults.heartbeat = { - ...asRecord(channelDefaults.heartbeat), + ...readNullableRecord(channelDefaults.heartbeat), ...channelHeartbeat, }; } @@ -167,14 +169,14 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { changes.push("Moved heartbeat to agents.defaults.heartbeat and channels.defaults.heartbeat."); } - const internalHooks = asRecord(asRecord(next.hooks)?.internal); + const internalHooks = readNullableRecord(readNullableRecord(next.hooks)?.internal); if (internalHooks && "handlers" in internalHooks) { delete internalHooks.handlers; changes.push( "Removed retired hooks.internal.handlers registrations; hook files must be migrated separately.", ); - const entries = asRecord(internalHooks.entries); - const extraDirs = asRecord(internalHooks.load)?.extraDirs; + const entries = readNullableRecord(internalHooks.entries); + const extraDirs = readNullableRecord(internalHooks.load)?.extraDirs; const hasNamedEntries = Boolean(entries && Object.keys(entries).length > 0); const hasExtraDirs = Array.isArray(extraDirs) && @@ -187,7 +189,7 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } } - const gateway = asRecord(next.gateway); + const gateway = readNullableRecord(next.gateway); if (gateway?.bind === "0.0.0.0") { gateway.bind = "lan"; changes.push("Normalized gateway.bind host alias."); @@ -197,17 +199,17 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } migrateThreadBinding(next.session, changes, "session"); - const sessionMaintenance = asRecord(asRecord(next.session)?.maintenance); + const sessionMaintenance = readNullableRecord(readNullableRecord(next.session)?.maintenance); if (sessionMaintenance && "rotateBytes" in sessionMaintenance) { delete sessionMaintenance.rotateBytes; changes.push("Removed deprecated session.maintenance.rotateBytes."); } - const channels = asRecord(next.channels); + const channels = readNullableRecord(next.channels); for (const [channelId, channelRaw] of Object.entries(channels ?? {})) { if (channelId === "defaults") { continue; } - const channel = asRecord(channelRaw); + const channel = readNullableRecord(channelRaw); if (!channel) { continue; } @@ -218,8 +220,10 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { if (migrateNestedAllowAliases(channel, channelId)) { changes.push(`Normalized channels.${channelId} nested allow aliases.`); } - for (const [accountId, accountRaw] of Object.entries(asRecord(channel.accounts) ?? {})) { - const account = asRecord(accountRaw); + for (const [accountId, accountRaw] of Object.entries( + readNullableRecord(channel.accounts) ?? {}, + )) { + const account = readNullableRecord(accountRaw); migrateThreadBinding(account, changes, `channels.${channelId}.accounts.${accountId}`); if (account && migrateStreamingAlias(account, channelId)) { changes.push(`Normalized channels.${channelId}.accounts.${accountId} streaming aliases.`); @@ -227,7 +231,9 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } } - const sandbox = asRecord(asRecord(asRecord(next.agents)?.defaults)?.sandbox); + const sandbox = readNullableRecord( + readNullableRecord(readNullableRecord(next.agents)?.defaults)?.sandbox, + ); if (sandbox && "perSession" in sandbox) { sandbox.scope = sandbox.perSession === true ? "session" : "workspace"; delete sandbox.perSession; @@ -343,7 +349,7 @@ vi.mock("../config/legacy.js", () => { requireSourceLiteral?: boolean; }; - function asRecord(value: unknown): Record | null { + function readNullableRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; @@ -352,7 +358,7 @@ vi.mock("../config/legacy.js", () => { function getPathValue(root: Record, pathParts: readonly string[]): unknown { let cursor: unknown = root; for (const part of pathParts) { - const record = asRecord(cursor); + const record = readNullableRecord(cursor); if (!record) { return undefined; } @@ -384,11 +390,11 @@ vi.mock("../config/legacy.js", () => { return { findLegacyConfigIssues: (raw: unknown, sourceRaw?: unknown, extraRules: LegacyRule[] = []) => { - const root = asRecord(raw); + const root = readNullableRecord(raw); if (!root) { return []; } - const sourceRoot = asRecord(sourceRaw) ?? root; + const sourceRoot = readNullableRecord(sourceRaw) ?? root; const issues: Array<{ path: string; message: string }> = []; if ("heartbeat" in root) { @@ -405,7 +411,7 @@ vi.mock("../config/legacy.js", () => { 'memorySearch is legacy; use memory.search. Run "openclaw doctor --fix".', ); } - const gateway = asRecord(root.gateway); + const gateway = readNullableRecord(root.gateway); if (gateway && "bind" in gateway) { addIssue( issues, @@ -413,7 +419,9 @@ vi.mock("../config/legacy.js", () => { 'gateway.bind host aliases are legacy; use the canonical bind mode. Run "openclaw doctor --fix".', ); } - const sessionThreadBindings = asRecord(asRecord(root.session)?.threadBindings); + const sessionThreadBindings = readNullableRecord( + readNullableRecord(root.session)?.threadBindings, + ); if (sessionThreadBindings && "ttlHours" in sessionThreadBindings) { addIssue( issues, @@ -421,7 +429,7 @@ vi.mock("../config/legacy.js", () => { 'session.threadBindings.ttlHours is legacy; use session.threadBindings.idleHours. Run "openclaw doctor --fix".', ); } - const sessionMaintenance = asRecord(asRecord(root.session)?.maintenance); + const sessionMaintenance = readNullableRecord(readNullableRecord(root.session)?.maintenance); if (sessionMaintenance && "rotateBytes" in sessionMaintenance) { addIssue( issues, @@ -429,7 +437,9 @@ vi.mock("../config/legacy.js", () => { 'session.maintenance.rotateBytes is deprecated and ignored; run "openclaw doctor --fix" to remove it.', ); } - const xSearch = asRecord(asRecord(asRecord(root.tools)?.web)?.x_search); + const xSearch = readNullableRecord( + readNullableRecord(readNullableRecord(root.tools)?.web)?.x_search, + ); if (xSearch && "apiKey" in xSearch) { addIssue( issues, @@ -437,7 +447,9 @@ vi.mock("../config/legacy.js", () => { 'tools.web.x_search.apiKey is legacy; use plugins.entries.xai.config.webSearch.apiKey. Run "openclaw doctor --fix".', ); } - const sandbox = asRecord(asRecord(asRecord(root.agents)?.defaults)?.sandbox); + const sandbox = readNullableRecord( + readNullableRecord(readNullableRecord(root.agents)?.defaults)?.sandbox, + ); if (sandbox && "perSession" in sandbox) { addIssue( issues, @@ -445,7 +457,7 @@ vi.mock("../config/legacy.js", () => { 'agents.defaults.sandbox.perSession is legacy; use agents.defaults.sandbox.scope. Run "openclaw doctor --fix".', ); } - const internalHooks = asRecord(asRecord(root.hooks)?.internal); + const internalHooks = readNullableRecord(readNullableRecord(root.hooks)?.internal); if (internalHooks && "handlers" in internalHooks) { addIssue( issues, @@ -454,12 +466,12 @@ vi.mock("../config/legacy.js", () => { ); } - const channels = asRecord(root.channels); + const channels = readNullableRecord(root.channels); for (const [channelId, channelRaw] of Object.entries(channels ?? {})) { if (channelId === "defaults") { continue; } - const channel = asRecord(channelRaw); + const channel = readNullableRecord(channelRaw); if (!channel) { continue; } @@ -472,7 +484,7 @@ vi.mock("../config/legacy.js", () => { : `channels.${channelId}.streamMode, channels.${channelId}.streaming aliases are legacy. Run "openclaw doctor --fix".`, ); } - const threadBindings = asRecord(channel.threadBindings); + const threadBindings = readNullableRecord(channel.threadBindings); if (threadBindings && "ttlHours" in threadBindings) { addIssue( issues, @@ -481,8 +493,8 @@ vi.mock("../config/legacy.js", () => { ); } if (channelId === "slack") { - for (const roomRaw of Object.values(asRecord(channel.channels) ?? {})) { - if ("allow" in (asRecord(roomRaw) ?? {})) { + for (const roomRaw of Object.values(readNullableRecord(channel.channels) ?? {})) { + if ("allow" in (readNullableRecord(roomRaw) ?? {})) { addIssue( issues, ["channels", "slack"], @@ -492,8 +504,8 @@ vi.mock("../config/legacy.js", () => { } } if (channelId === "googlechat") { - for (const spaceRaw of Object.values(asRecord(channel.groups) ?? {})) { - if ("allow" in (asRecord(spaceRaw) ?? {})) { + for (const spaceRaw of Object.values(readNullableRecord(channel.groups) ?? {})) { + if ("allow" in (readNullableRecord(spaceRaw) ?? {})) { addIssue( issues, ["channels", "googlechat"], @@ -503,10 +515,10 @@ vi.mock("../config/legacy.js", () => { } } if (channelId === "discord") { - for (const guildRaw of Object.values(asRecord(channel.guilds) ?? {})) { - const guild = asRecord(guildRaw); - for (const roomRaw of Object.values(asRecord(guild?.channels) ?? {})) { - if ("allow" in (asRecord(roomRaw) ?? {})) { + for (const guildRaw of Object.values(readNullableRecord(channel.guilds) ?? {})) { + const guild = readNullableRecord(guildRaw); + for (const roomRaw of Object.values(readNullableRecord(guild?.channels) ?? {})) { + if ("allow" in (readNullableRecord(roomRaw) ?? {})) { addIssue( issues, ["channels", "discord"], @@ -516,9 +528,11 @@ vi.mock("../config/legacy.js", () => { } } } - for (const [accountId, accountRaw] of Object.entries(asRecord(channel.accounts) ?? {})) { - const account = asRecord(accountRaw); - const accountThreadBindings = asRecord(account?.threadBindings); + for (const [accountId, accountRaw] of Object.entries( + readNullableRecord(channel.accounts) ?? {}, + )) { + const account = readNullableRecord(accountRaw); + const accountThreadBindings = readNullableRecord(account?.threadBindings); if (accountThreadBindings && "ttlHours" in accountThreadBindings) { addIssue( issues, @@ -854,14 +868,14 @@ vi.mock("./doctor/channel-capabilities.js", () => { }); vi.mock("../plugins/doctor-contract-registry.js", () => { - function asRecord(value: unknown): Record | null { + function readNullableRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } function hasLegacyTalkFields(value: unknown): boolean { - const talk = asRecord(value); + const talk = readNullableRecord(value); return Boolean( talk && ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"].some((key) => @@ -905,7 +919,7 @@ vi.mock("../plugins/doctor-contract-registry.js", () => { } let changed = false; - const streaming = asRecord(entry.streaming) ?? {}; + const streaming = readNullableRecord(entry.streaming) ?? {}; if (!("mode" in streaming) && ("streamMode" in entry || typeof entry.streaming !== "object")) { const mode = resolveDiscordStreamMode(entry); streaming.mode = mode; @@ -926,7 +940,7 @@ vi.mock("../plugins/doctor-contract-registry.js", () => { changes.push(`Moved ${pathPrefix}.chunkMode → ${pathPrefix}.streaming.chunkMode.`); changed = true; } - const block = asRecord(streaming.block) ?? {}; + const block = readNullableRecord(streaming.block) ?? {}; if ("blockStreaming" in entry && !("enabled" in block)) { block.enabled = entry.blockStreaming; delete entry.blockStreaming; @@ -944,7 +958,7 @@ vi.mock("../plugins/doctor-contract-registry.js", () => { if (Object.keys(block).length > 0) { streaming.block = block; } - const preview = asRecord(streaming.preview) ?? {}; + const preview = readNullableRecord(streaming.preview) ?? {}; if ("draftChunk" in entry && !("chunk" in preview)) { preview.chunk = entry.draftChunk; delete entry.draftChunk; @@ -962,23 +976,23 @@ vi.mock("../plugins/doctor-contract-registry.js", () => { config: unknown; changes: string[]; } { - const root = asRecord(cfg); - const discord = asRecord(asRecord(root?.channels)?.discord); + const root = readNullableRecord(cfg); + const discord = readNullableRecord(readNullableRecord(root?.channels)?.discord); if (!root || !discord) { return { config: cfg, changes: [] }; } const next = structuredClone(root); - const nextDiscord = asRecord(asRecord(next.channels)?.discord); + const nextDiscord = readNullableRecord(readNullableRecord(next.channels)?.discord); if (!nextDiscord) { return { config: cfg, changes: [] }; } const changes: string[] = []; normalizeDiscordStreamingEntry(nextDiscord, "channels.discord", changes); - const accounts = asRecord(nextDiscord.accounts); + const accounts = readNullableRecord(nextDiscord.accounts); for (const [accountId, accountRaw] of Object.entries(accounts ?? {})) { - const account = asRecord(accountRaw); + const account = readNullableRecord(accountRaw); if (account) { normalizeDiscordStreamingEntry(account, `channels.discord.accounts.${accountId}`, changes); } @@ -989,8 +1003,8 @@ vi.mock("../plugins/doctor-contract-registry.js", () => { return { collectRelevantDoctorPluginIds: (raw: unknown): string[] => { const ids = new Set(); - const root = asRecord(raw); - const channels = asRecord(root?.channels); + const root = readNullableRecord(raw); + const channels = readNullableRecord(root?.channels); for (const channelId of Object.keys(channels ?? {})) { if (channelId !== "defaults") { ids.add(channelId); @@ -1055,7 +1069,7 @@ vi.mock("../plugins/setup-registry.js", () => ({ })); vi.mock("./doctor/shared/channel-doctor.js", () => { - function asRecord(value: unknown): Record | null { + function readNullableRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; @@ -1076,7 +1090,7 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { "roles", ]); const visit = (value: unknown) => { - const record = asRecord(value); + const record = readNullableRecord(value); if (!record) { return; } @@ -1103,10 +1117,10 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { function collectCompatibilityMutations(cfg: { channels?: Record }) { const next = structuredClone(cfg); const changes: string[] = []; - const telegram = asRecord(next.channels?.telegram); + const telegram = readNullableRecord(next.channels?.telegram); if (telegram && "groupMentionsOnly" in telegram) { - const groups = asRecord(telegram.groups) ?? {}; - const defaultGroup = asRecord(groups["*"]) ?? {}; + const groups = readNullableRecord(telegram.groups) ?? {}; + const defaultGroup = readNullableRecord(groups["*"]) ?? {}; if (defaultGroup.requireMention === undefined) { defaultGroup.requireMention = telegram.groupMentionsOnly; } @@ -1121,20 +1135,20 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { } function collectInactiveTelegramWarnings(cfg: { channels?: Record }): string[] { - const telegram = asRecord(cfg.channels?.telegram); + const telegram = readNullableRecord(cfg.channels?.telegram); if (!telegram) { return []; } - const accounts = asRecord(telegram.accounts); + const accounts = readNullableRecord(telegram.accounts); if (!accounts) { return []; } return Object.entries(accounts).flatMap(([accountId, accountRaw]) => { - const account = asRecord(accountRaw); + const account = readNullableRecord(accountRaw); if ( !account || account.enabled !== false || - !asRecord(account.botToken) || + !readNullableRecord(account.botToken) || !hasOwnStringArray(account.allowFrom) ) { return []; @@ -1163,7 +1177,8 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { if (!botToken) { return false; } - const groups = asRecord(params.account.groups) ?? asRecord(params.parent?.groups); + const groups = + readNullableRecord(params.account.groups) ?? readNullableRecord(params.parent?.groups); const groupAllowFrom = params.account.groupAllowFrom ?? params.parent?.groupAllowFrom; return !groups && !hasOwnStringArray(groupAllowFrom); } @@ -1190,16 +1205,16 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { collectChannelDoctorEmptyAllowlistExtraWarnings: vi.fn(collectTelegramFirstTimeExtraWarnings), collectChannelDoctorMutableAllowlistWarnings: vi.fn( ({ cfg }: { cfg: { channels?: Record } }) => { - const zalouser = asRecord(cfg.channels?.zalouser); + const zalouser = readNullableRecord(cfg.channels?.zalouser); if (!zalouser || zalouser.dangerouslyAllowNameMatching === true) { return []; } - const groups = asRecord(zalouser.groups); + const groups = readNullableRecord(zalouser.groups); if (!groups) { return []; } return Object.entries(groups).flatMap(([name, group]) => - asRecord(group)?.allow === true + readNullableRecord(group)?.allow === true ? [ `- Found mutable allowlist entry across zalouser while name matching is disabled by default: channels.zalouser.groups: ${name}.`, ] @@ -1211,10 +1226,10 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { collectChannelDoctorRepairMutations: vi.fn( async ({ cfg }: { cfg: { channels?: Record } }) => { const mutations: Array<{ config: unknown; changes: string[]; warnings?: string[] }> = []; - const discord = asRecord(cfg.channels?.discord); + const discord = readNullableRecord(cfg.channels?.discord); if (discord) { const next = structuredClone(cfg); - const nextDiscord = asRecord(next.channels?.discord); + const nextDiscord = readNullableRecord(next.channels?.discord); if (nextDiscord && stringifySelectedArrays(nextDiscord)) { mutations.push({ config: next, @@ -1244,7 +1259,7 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { }); vi.mock("./doctor/shared/preview-warnings.js", () => { - function asRecord(value: unknown): Record | null { + function readNullableRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; @@ -1269,7 +1284,11 @@ vi.mock("./doctor/shared/preview-warnings.js", () => { return []; } const botToken = params.account.botToken ?? params.parent?.botToken; - if (!botToken || asRecord(params.account.groups) || asRecord(params.parent?.groups)) { + if ( + !botToken || + readNullableRecord(params.account.groups) || + readNullableRecord(params.parent?.groups) + ) { return []; } if (hasStringEntries(params.account.groupAllowFrom ?? params.parent?.groupAllowFrom)) { @@ -1290,7 +1309,7 @@ vi.mock("./doctor/shared/preview-warnings.js", () => { doctorFixCommand: string; }): Promise { const warnings: string[] = []; - const telegram = asRecord(cfg.channels?.telegram); + const telegram = readNullableRecord(cfg.channels?.telegram); if (telegram) { const telegramBlocked = cfg.plugins?.enabled === false || cfg.plugins?.entries?.telegram?.enabled === false; @@ -1307,9 +1326,9 @@ vi.mock("./doctor/shared/preview-warnings.js", () => { prefix: "channels.telegram", }), ); - const accounts = asRecord(telegram.accounts); + const accounts = readNullableRecord(telegram.accounts); for (const [accountId, accountRaw] of Object.entries(accounts ?? {})) { - const account = asRecord(accountRaw); + const account = readNullableRecord(accountRaw); if (account) { warnings.push( ...telegramFirstTimeWarnings({ @@ -1322,7 +1341,7 @@ vi.mock("./doctor/shared/preview-warnings.js", () => { } } } - const imessage = asRecord(cfg.channels?.imessage); + const imessage = readNullableRecord(cfg.channels?.imessage); if (imessage?.groupPolicy === "allowlist" && !hasStringEntries(imessage.groupAllowFrom)) { warnings.push( '- channels.imessage.groupPolicy is "allowlist" but groupAllowFrom is empty — this channel does not fall back to allowFrom, so all group messages will be silently dropped.', diff --git a/src/commands/doctor/cron/legacy-store-migration.ts b/src/commands/doctor/cron/legacy-store-migration.ts index d6bb4733b042..765f88136660 100644 --- a/src/commands/doctor/cron/legacy-store-migration.ts +++ b/src/commands/doctor/cron/legacy-store-migration.ts @@ -2,11 +2,11 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { isRecord } from "../../../../packages/normalization-core/src/record-coerce.js"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString, normalizeOptionalStringifiedId, -} from "../../../../packages/normalization-core/src/string-coerce.js"; +} from "@openclaw/normalization-core/string-coerce"; import { coerceFiniteScheduleNumber } from "../../../cron/schedule-number.js"; import { normalizeCronStaggerMs } from "../../../cron/stagger.js"; import type { @@ -332,11 +332,11 @@ function parseCronStateFile(raw: string): { } } -function readString(record: Record, key: string): string | undefined { +function readScheduleString(record: Record, key: string): string | undefined { return normalizeOptionalString(record[key]); } -function readNumber(record: Record, key: string): number | undefined { +function readScheduleNumber(record: Record, key: string): number | undefined { return coerceFiniteScheduleNumber(record[key]); } @@ -347,13 +347,13 @@ function legacySchedulePayloadFromRecord( | { kind: "every"; everyMs: number; anchorMs?: number } | { kind: "cron"; expr: string; tz?: string; staggerMs?: number } | undefined { - const rawKind = readString(schedule, "kind")?.toLowerCase(); - const expr = readString(schedule, "expr") ?? readString(schedule, "cron"); - const at = readString(schedule, "at"); - const atMs = readNumber(schedule, "atMs"); - const everyMs = readNumber(schedule, "everyMs"); - const anchorMs = readNumber(schedule, "anchorMs"); - const tz = readString(schedule, "tz"); + const rawKind = readScheduleString(schedule, "kind")?.toLowerCase(); + const expr = readScheduleString(schedule, "expr") ?? readScheduleString(schedule, "cron"); + const at = readScheduleString(schedule, "at"); + const atMs = readScheduleNumber(schedule, "atMs"); + const everyMs = readScheduleNumber(schedule, "everyMs"); + const anchorMs = readScheduleNumber(schedule, "anchorMs"); + const tz = readScheduleString(schedule, "tz"); const staggerMs = normalizeCronStaggerMs(schedule.staggerMs); const kind = rawKind === "at" || rawKind === "every" || rawKind === "cron" diff --git a/src/commands/export-trajectory.ts b/src/commands/export-trajectory.ts index 53fdf5c12690..33f3ab074496 100644 --- a/src/commands/export-trajectory.ts +++ b/src/commands/export-trajectory.ts @@ -36,7 +36,7 @@ type EncodedExportTrajectoryRequest = { const ENCODED_EXPORT_REQUEST_RE = /^[A-Za-z0-9_-]{1,65536}$/u; -function readOptionalString(value: unknown): string | undefined { +function readNonBlankString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; } @@ -59,23 +59,23 @@ function decodeExportTrajectoryRequest(encoded: string): Partial = {}; - const sessionKey = readOptionalString(request.sessionKey); + const sessionKey = readNonBlankString(request.sessionKey); if (sessionKey !== undefined) { opts.sessionKey = sessionKey; } - const output = readOptionalString(request.output); + const output = readNonBlankString(request.output); if (output !== undefined) { opts.output = output; } - const store = readOptionalString(request.store); + const store = readNonBlankString(request.store); if (store !== undefined) { opts.store = store; } - const agent = readOptionalString(request.agent); + const agent = readNonBlankString(request.agent); if (agent !== undefined) { opts.agent = agent; } - const workspace = readOptionalString(request.workspace); + const workspace = readNonBlankString(request.workspace); if (workspace !== undefined) { opts.workspace = workspace; } diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index 8319cc07c604..dcc3e08b95f1 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -2,6 +2,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { ProviderPlugin } from "../../plugins/types.js"; @@ -189,8 +190,6 @@ vi.mock("../../plugins/provider-auth-choice-helpers.js", async (importOriginal) const actual = await importOriginal(); const normalize = (value: string | undefined) => value?.trim().toLowerCase() ?? ""; - const isRecord = (value: unknown): value is Record => - Boolean(value && typeof value === "object" && !Array.isArray(value)); const mergePatch = (base: T, patch: unknown): T => { if (!isRecord(base) || !isRecord(patch)) { return patch as T; diff --git a/src/config/sessions/lifecycle.test.ts b/src/config/sessions/lifecycle.test.ts index 80dc6e1ab18e..dc5899736c28 100644 --- a/src/config/sessions/lifecycle.test.ts +++ b/src/config/sessions/lifecycle.test.ts @@ -132,9 +132,9 @@ describe("terminal main session transcript freshness", () => { expect(check(entry, sessionKey)).toBe(true); }); - it("reads the transcript header when the caller has no session key", async () => { + it("preserves Date.parse semantics for a numeric-looking transcript header", async () => { const sessionId = "session-header-without-key"; - const timestamp = "2026-01-01T12:00:00.000Z"; + const timestamp = "2026"; await appendTranscriptEvent( { agentId: "main", sessionId, sessionKey: "agent:main:header", storePath }, { type: "session", version: 3, id: sessionId, timestamp, cwd: stateDir }, diff --git a/src/config/sessions/lifecycle.ts b/src/config/sessions/lifecycle.ts index 4d85c821a262..03d33405028e 100644 --- a/src/config/sessions/lifecycle.ts +++ b/src/config/sessions/lifecycle.ts @@ -1,6 +1,6 @@ // Session lifecycle timestamps prefer store metadata and fall back to transcript headers. +import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; -import { asDateTimestampMs } from "../../shared/number-coercion.js"; import { canonicalizeMainSessionAlias } from "./main-session.js"; import { loadTranscriptHeaderSync, readTranscriptStatsSync } from "./session-accessor.js"; import { isTerminalSessionStatus, type SessionEntry, type SessionScope } from "./types.js"; diff --git a/src/cron/schedule-identity.ts b/src/cron/schedule-identity.ts index 182a430a5e08..57ba363e279a 100644 --- a/src/cron/schedule-identity.ts +++ b/src/cron/schedule-identity.ts @@ -13,15 +13,11 @@ type CronScheduleIdentityInput = { schedule?: unknown; enabled?: unknown } & Rec unknown >; -function readString(record: Record, key: string): string | undefined { - return normalizeOptionalString(record[key]); -} - function readScheduleTime(record: Record, key: string): number | undefined { return coerceFiniteScheduleNumber(record[key]); } -function readNumber(record: Record, key: string): number | undefined { +function readScheduleInteger(record: Record, key: string): number | undefined { const parsed = parseStrictFiniteNumber(record[key]); return asSafeIntegerInRange(parsed, { min: Number.MIN_SAFE_INTEGER, @@ -44,12 +40,12 @@ function schedulePayloadFromRecord(schedule: Record): maxBatchBytes?: number; } | undefined { - const rawKind = readString(schedule, "kind")?.toLowerCase(); - const expr = readString(schedule, "expr"); - const at = readString(schedule, "at"); + const rawKind = normalizeOptionalString(schedule.kind)?.toLowerCase(); + const expr = normalizeOptionalString(schedule.expr); + const at = normalizeOptionalString(schedule.at); const everyMs = readScheduleTime(schedule, "everyMs"); const anchorMs = readScheduleTime(schedule, "anchorMs"); - const tz = readString(schedule, "tz"); + const tz = normalizeOptionalString(schedule.tz); const staggerMs = normalizeCronStaggerMs(schedule.staggerMs); const kind = // Infer legacy shorthand schedule shapes when kind is missing so timer @@ -78,8 +74,10 @@ function schedulePayloadFromRecord(schedule: Record): return { kind: "cron", expr, tz, staggerMs }; } if (kind === "on-exit") { - const command = readString(schedule, "command"); - return command ? { kind: "on-exit", command, cwd: readString(schedule, "cwd") } : undefined; + const command = normalizeOptionalString(schedule.command); + return command + ? { kind: "on-exit", command, cwd: normalizeOptionalString(schedule.cwd) } + : undefined; } if (kind === "stream") { const command = schedule.command; @@ -90,15 +88,15 @@ function schedulePayloadFromRecord(schedule: Record): ) { return undefined; } - const mode = readString(schedule, "mode"); + const mode = normalizeOptionalString(schedule.mode); return { kind: "stream", command: [...command], - cwd: readString(schedule, "cwd"), + cwd: normalizeOptionalString(schedule.cwd), mode: mode === "line" || mode === "match" ? mode : undefined, match: typeof schedule.match === "string" ? schedule.match : undefined, - batchMs: readNumber(schedule, "batchMs"), - maxBatchBytes: readNumber(schedule, "maxBatchBytes"), + batchMs: readScheduleInteger(schedule, "batchMs"), + maxBatchBytes: readScheduleInteger(schedule, "maxBatchBytes"), }; } return undefined; diff --git a/src/fleet/containers.runtime.ts b/src/fleet/containers.runtime.ts index c499f3699aeb..f7ed119cc6ed 100644 --- a/src/fleet/containers.runtime.ts +++ b/src/fleet/containers.runtime.ts @@ -135,7 +135,7 @@ function requireNonNegativeNumber(value: unknown): number { return value; } -function readOptionalString(value: unknown): string | undefined { +function readOptionalInspectString(value: unknown): string | undefined { if (value === undefined || value === null || value === "") { return undefined; } @@ -194,7 +194,7 @@ function readRestartPolicy(value: unknown): string | undefined { if (value === undefined || value === null) { return undefined; } - return readOptionalString(requireRecord(value).Name); + return readOptionalInspectString(requireRecord(value).Name); } function readPortBindings( @@ -231,7 +231,7 @@ function readNetworkAttachments(value: unknown): Array<{ id: string; name?: stri throw new InvalidInspectOutputError(); } const attachment = requireRecord(rawAttachment); - const name = readOptionalString(attachment.Name ?? attachment.name); + const name = readOptionalInspectString(attachment.Name ?? attachment.name); const normalized: { id: string; name?: string } = { id }; if (name) { normalized.name = name; @@ -284,8 +284,8 @@ function parseInspectOutput(stdout: string): Extract { - return typeof value === "object" && value !== null ? (value as Record) : {}; -} - function expectRecord(value: unknown, label: string): Record { - if (!value || typeof value !== "object") { + if (!isRecord(value)) { throw new Error(`expected ${label}`); } - expect(Array.isArray(value), label).toBe(false); - return value as Record; -} - -function readString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + return value; } function expectNonEmptyString(value: unknown, label: string): string { - const text = readString(value); + const text = normalizeNullableString(value); if (text === null) { throw new Error(`expected ${label}`); } @@ -97,7 +90,7 @@ function parseErrorCode(message: string): string { } function readGatewayErrorCode(err: unknown, fallbackMessage: string): string { - const byField = readString(asRecord(err).gatewayCode); + const byField = normalizeNullableString(asRecord(err).gatewayCode); if (byField) { return byField; } @@ -362,13 +355,13 @@ const COMMAND_PROFILES: Record = { async function resolveGatewayConnection() { const cfg = await readLiveTestConfig(); - const urlOverride = readString(process.env.OPENCLAW_ANDROID_GATEWAY_URL); + const urlOverride = normalizeNullableString(process.env.OPENCLAW_ANDROID_GATEWAY_URL); const details = buildGatewayConnectionDetails({ config: cfg, ...(urlOverride ? { url: urlOverride } : {}), }); - const tokenOverride = readString(process.env.OPENCLAW_ANDROID_GATEWAY_TOKEN); - const passwordOverride = readString(process.env.OPENCLAW_ANDROID_GATEWAY_PASSWORD); + const tokenOverride = normalizeNullableString(process.env.OPENCLAW_ANDROID_GATEWAY_TOKEN); + const passwordOverride = normalizeNullableString(process.env.OPENCLAW_ANDROID_GATEWAY_PASSWORD); const creds = resolveGatewayCredentialsFromConfig({ cfg, explicitAuth: { @@ -482,16 +475,16 @@ async function connectGatewayClient(params: { } function isAndroidNode(node: NodeListNode): boolean { - const platform = readString(node.platform)?.toLowerCase(); + const platform = normalizeNullableString(node.platform)?.toLowerCase(); if (platform === "android") { return true; } - const displayName = readString(node.displayName)?.toLowerCase(); + const displayName = normalizeNullableString(node.displayName)?.toLowerCase(); return displayName?.includes("android") === true; } function selectTargetNode(nodes: NodeListNode[]): NodeListNode { - const nodeIdOverride = readString(process.env.OPENCLAW_ANDROID_NODE_ID); + const nodeIdOverride = normalizeNullableString(process.env.OPENCLAW_ANDROID_NODE_ID); if (nodeIdOverride) { const match = nodes.find((node) => node.nodeId === nodeIdOverride); if (!match) { @@ -500,10 +493,12 @@ function selectTargetNode(nodes: NodeListNode[]): NodeListNode { return match; } - const nodeNameOverride = readString(process.env.OPENCLAW_ANDROID_NODE_NAME)?.toLowerCase(); + const nodeNameOverride = normalizeNullableString( + process.env.OPENCLAW_ANDROID_NODE_NAME, + )?.toLowerCase(); if (nodeNameOverride) { const match = nodes.find( - (node) => readString(node.displayName)?.toLowerCase() === nodeNameOverride, + (node) => normalizeNullableString(node.displayName)?.toLowerCase() === nodeNameOverride, ); if (!match) { throw new Error(`OPENCLAW_ANDROID_NODE_NAME not found in node.list: ${nodeNameOverride}`); diff --git a/src/gateway/android-node.capabilities.policy-config.test.ts b/src/gateway/android-node.capabilities.policy-config.test.ts index 108a1e1fa328..3a90fb0f5aa5 100644 --- a/src/gateway/android-node.capabilities.policy-config.test.ts +++ b/src/gateway/android-node.capabilities.policy-config.test.ts @@ -1,13 +1,10 @@ +import { asRecord } from "@openclaw/normalization-core/record-coerce"; /** * Android node capability policy-config regression tests. */ import { describe, expect, it } from "vitest"; import { unwrapRemoteConfigSnapshot } from "../../test/helpers/gateway/android-node-capabilities-policy-config.js"; -function asRecord(value: unknown): Record { - return typeof value === "object" && value !== null ? (value as Record) : {}; -} - describe("unwrapRemoteConfigSnapshot", () => { it("reads direct config snapshot payload from GatewayClient.request", () => { const cfg = unwrapRemoteConfigSnapshot({ gateway: { bind: "127.0.0.1" } }); diff --git a/src/gateway/cli-session-history.claude.ts b/src/gateway/cli-session-history.claude.ts index fbce81bcb4c4..18cf37068035 100644 --- a/src/gateway/cli-session-history.claude.ts +++ b/src/gateway/cli-session-history.claude.ts @@ -69,11 +69,7 @@ export function resolveClaudeCliBindingSessionId( } export function resolveClaudeCliTimestampMs(value: unknown): number | undefined { - if (typeof value !== "string") { - return undefined; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; + return typeof value === "string" ? asFiniteNumber(Date.parse(value)) : undefined; } function resolveClaudeCliUsage(raw: ClaudeCliUsage) { diff --git a/src/gateway/cli-session-history.test.ts b/src/gateway/cli-session-history.test.ts index 1c7d0dd7826f..195a3ecf6d9a 100644 --- a/src/gateway/cli-session-history.test.ts +++ b/src/gateway/cli-session-history.test.ts @@ -246,6 +246,34 @@ describe("cli session history", () => { }); }); + it("preserves Date.parse semantics for numeric-looking Claude timestamps", async () => { + await withClaudeProjectsDir(async ({ homeDir, sessionId, filePath }) => { + await fs.writeFile( + filePath, + [ + { timestamp: "0", uuid: "numeric-zero", content: "zero" }, + { timestamp: "2026", uuid: "numeric-year", content: "year" }, + ] + .map((entry) => + JSON.stringify({ + type: "user", + uuid: entry.uuid, + timestamp: entry.timestamp, + message: { role: "user", content: entry.content }, + }), + ) + .join("\n"), + "utf-8", + ); + + const messages = readClaudeCliSessionMessages({ cliSessionId: sessionId, homeDir }); + expect(messages.map((message) => message.timestamp)).toEqual([ + Date.parse("0"), + Date.parse("2026"), + ]); + }); + }); + it("assigns stable source-line ids when Claude entries have no uuid", async () => { await withClaudeProjectsDir(async ({ homeDir, sessionId, filePath }) => { await fs.writeFile( diff --git a/src/gateway/control-ui-github-api.ts b/src/gateway/control-ui-github-api.ts index 5765d599deab..bdd7df4be979 100644 --- a/src/gateway/control-ui-github-api.ts +++ b/src/gateway/control-ui-github-api.ts @@ -28,7 +28,10 @@ export function requiredString(record: Record, key: string): st return value; } -export function optionalString(record: Record, key: string): string | undefined { +export function readOptionalGitHubString( + record: Record, + key: string, +): string | undefined { const value = record[key]; return typeof value === "string" && value.trim() ? value : undefined; } diff --git a/src/gateway/control-ui-github-preview.ts b/src/gateway/control-ui-github-preview.ts index 349d93050c4d..93ad50f21932 100644 --- a/src/gateway/control-ui-github-preview.ts +++ b/src/gateway/control-ui-github-preview.ts @@ -10,7 +10,7 @@ import { githubApiToken, isRecord, optionalNumber, - optionalString, + readOptionalGitHubString, readBoundedResponse, readGitHubJsonResponse, requiredString, @@ -159,19 +159,19 @@ function parseGitHubResponse( ...target, additions: optionalNumber(value, "additions"), changedFiles: optionalNumber(value, "changed_files"), - closedAt: optionalString(value, "closed_at"), + closedAt: readOptionalGitHubString(value, "closed_at"), comments: optionalNumber(value, "comments"), createdAt: requiredString(value, "created_at"), deletions: optionalNumber(value, "deletions"), draft: typeof value.draft === "boolean" ? value.draft : undefined, - login: optionalString(user, "login") ?? "ghost", - mergedAt: optionalString(value, "merged_at"), + login: readOptionalGitHubString(user, "login") ?? "ghost", + mergedAt: readOptionalGitHubString(value, "merged_at"), state: requiredString(value, "state"), - stateReason: optionalString(value, "state_reason"), + stateReason: readOptionalGitHubString(value, "state_reason"), title: requiredString(value, "title"), updatedAt: requiredString(value, "updated_at"), }, - avatarUrl: optionalString(user, "avatar_url"), + avatarUrl: readOptionalGitHubString(user, "avatar_url"), }; } diff --git a/src/gateway/control-ui-session-prs.ts b/src/gateway/control-ui-session-prs.ts index e0887e0a755c..b081fdfaad36 100644 --- a/src/gateway/control-ui-session-prs.ts +++ b/src/gateway/control-ui-session-prs.ts @@ -19,7 +19,7 @@ import { githubApiToken, isRecord, optionalNumber, - optionalString, + readOptionalGitHubString, } from "./control-ui-github-api.js"; import { gitOutput, @@ -314,7 +314,7 @@ async function resolveSessionBranch( } function derivePullState(value: Record): ControlUiSessionPullRequest["state"] { - if (optionalString(value, "merged_at")) { + if (readOptionalGitHubString(value, "merged_at")) { return "merged"; } if (value.state !== "open") { @@ -328,13 +328,13 @@ function parsePullListItem(value: unknown): PullListItem | null { return null; } const number = optionalNumber(value, "number"); - const title = optionalString(value, "title"); - const url = optionalString(value, "html_url"); + const title = readOptionalGitHubString(value, "title"); + const url = readOptionalGitHubString(value, "html_url"); const base = isRecord(value.base) ? value.base : {}; const baseRepo = isRecord(base.repo) ? base.repo : {}; const baseOwner = isRecord(baseRepo.owner) ? baseRepo.owner : {}; - const owner = optionalString(baseOwner, "login"); - const repo = optionalString(baseRepo, "name"); + const owner = readOptionalGitHubString(baseOwner, "login"); + const repo = readOptionalGitHubString(baseRepo, "name"); const head = isRecord(value.head) ? value.head : {}; if (!number || !Number.isSafeInteger(number) || number < 1 || !title || !url || !owner || !repo) { return null; @@ -346,9 +346,9 @@ function parsePullListItem(value: unknown): PullListItem | null { owner, repo, state: derivePullState(value), - headSha: optionalString(head, "sha"), - baseRef: optionalString(base, "ref"), - mergeCommitSha: optionalString(value, "merge_commit_sha"), + headSha: readOptionalGitHubString(head, "sha"), + baseRef: readOptionalGitHubString(base, "ref"), + mergeCommitSha: readOptionalGitHubString(value, "merge_commit_sha"), }; } @@ -378,8 +378,8 @@ async function fetchParentRepo( return null; } const parentOwner = isRecord(value.parent.owner) ? value.parent.owner : {}; - const parentLogin = optionalString(parentOwner, "login"); - const parentName = optionalString(value.parent, "name"); + const parentLogin = readOptionalGitHubString(parentOwner, "login"); + const parentName = readOptionalGitHubString(value.parent, "name"); return parentLogin && parentName ? { owner: parentLogin, repo: parentName } : null; } @@ -431,7 +431,7 @@ function rollupCheckRuns(value: unknown): ControlUiSessionPullRequest["checks"] let running = 0; for (const runValue of value.check_runs) { const run = isRecord(runValue) ? runValue : {}; - const conclusion = optionalString(run, "conclusion"); + const conclusion = readOptionalGitHubString(run, "conclusion"); if (conclusion && FAILING_CHECK_CONCLUSIONS.has(conclusion)) { failed += 1; continue; diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 15092812c017..3bfc8813baf8 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -12,6 +12,7 @@ import path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { expectDefined } from "@openclaw/normalization-core"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { clampThinkingLevel, type Api, @@ -1271,10 +1272,6 @@ function resolveBedrockDiscoveryRegion(cfg: OpenClawConfig | undefined): string return typeof region === "string" ? normalizeOptionalEnvValue(region) : undefined; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function resolveAwsProfileRegion(env: NodeJS.ProcessEnv): string | undefined { const profile = normalizeOptionalEnvValue(env.AWS_PROFILE) ?? "default"; const homeDir = normalizeOptionalEnvValue(env.HOME) ?? os.homedir(); diff --git a/src/gateway/mcp-app-reconstruction.ts b/src/gateway/mcp-app-reconstruction.ts index 86d606ec9ecd..911e16103d30 100644 --- a/src/gateway/mcp-app-reconstruction.ts +++ b/src/gateway/mcp-app-reconstruction.ts @@ -1,5 +1,6 @@ import { type CallToolResult, ContentBlockSchema } from "@modelcontextprotocol/sdk/types.js"; -import { asOptionalRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { BoardMcpAppDescriptor } from "../../packages/gateway-protocol/src/index.js"; import { getOrCreateSessionMcpRuntime } from "../agents/agent-bundle-mcp-runtime.js"; import type { SessionMcpRuntime } from "../agents/agent-bundle-mcp-types.js"; @@ -45,18 +46,13 @@ type TranscriptResultRead = | { kind: "restorable"; value: TranscriptResult } | { kind: "unavailable" }; -function readString(record: Record | undefined, key: string): string | undefined { - const value = record?.[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readDescriptor(value: unknown): McpAppDescriptor | undefined { - const record = asRecord(value); - const viewId = readString(record, "viewId"); - const serverName = readString(record, "serverName"); - const toolName = readString(record, "toolName"); - const uiResourceUri = readString(record, "uiResourceUri"); - const toolCallId = readString(record, "toolCallId"); + const record = asOptionalRecord(value); + const viewId = normalizeOptionalString(record?.viewId); + const serverName = normalizeOptionalString(record?.serverName); + const toolName = normalizeOptionalString(record?.toolName); + const uiResourceUri = normalizeOptionalString(record?.uiResourceUri); + const toolCallId = normalizeOptionalString(record?.toolCallId); const rawResultMetaState = record?.resultMetaState; const resultMetaState = rawResultMetaState === "unavailable" ? rawResultMetaState : undefined; if ( @@ -89,22 +85,27 @@ function readToolInputFromMessage( toolCallId: string, modelToolName: string, ): { found: true; input: unknown } | undefined { - const message = asRecord(value); - if (readString(message, "role")?.toLowerCase() !== "assistant") { + const message = asOptionalRecord(value); + if (normalizeOptionalString(message?.role)?.toLowerCase() !== "assistant") { return undefined; } const content = Array.isArray(message?.content) ? message.content : []; for (const blockValue of content) { - const block = asRecord(blockValue); - if ((readString(block, "id") ?? readString(block, "toolCallId")) !== toolCallId) { + const block = asOptionalRecord(blockValue); + if ( + (normalizeOptionalString(block?.id) ?? normalizeOptionalString(block?.toolCallId)) !== + toolCallId + ) { continue; } - const type = readString(block, "type")?.toLowerCase(); + const type = normalizeOptionalString(block?.type)?.toLowerCase(); if (type !== "toolcall" && type !== "tool_call" && type !== "tooluse" && type !== "tool_use") { continue; } const blockToolName = - readString(block, "name") ?? readString(block, "toolName") ?? readString(block, "tool_name"); + normalizeOptionalString(block?.name) ?? + normalizeOptionalString(block?.toolName) ?? + normalizeOptionalString(block?.tool_name); if (blockToolName !== modelToolName) { continue; } @@ -134,14 +135,14 @@ function matchesLookup( lookup: TranscriptLookup, ): boolean { if ("viewId" in lookup) { - return readString(rawDescriptor, "viewId") === lookup.viewId; + return normalizeOptionalString(rawDescriptor?.viewId) === lookup.viewId; } const descriptor = lookup.descriptor; return ( - readString(rawDescriptor, "serverName") === descriptor.serverName && - readString(rawDescriptor, "toolName") === descriptor.toolName && - readString(rawDescriptor, "uiResourceUri") === descriptor.uiResourceUri && - readString(rawDescriptor, "toolCallId") === descriptor.toolCallId + normalizeOptionalString(rawDescriptor?.serverName) === descriptor.serverName && + normalizeOptionalString(rawDescriptor?.toolName) === descriptor.toolName && + normalizeOptionalString(rawDescriptor?.uiResourceUri) === descriptor.uiResourceUri && + normalizeOptionalString(rawDescriptor?.toolCallId) === descriptor.toolCallId ); } @@ -149,28 +150,29 @@ function readTranscriptResult( value: unknown, lookup: TranscriptLookup, ): TranscriptResultRead | undefined { - const message = asRecord(value); - if (!message || readString(message, "role")?.toLowerCase() !== "toolresult") { + const message = asOptionalRecord(value); + if (!message || normalizeOptionalString(message.role)?.toLowerCase() !== "toolresult") { return undefined; } - const details = asRecord(message.details); + const details = asOptionalRecord(message.details); if (!details) { return undefined; } - const preview = asRecord(details.mcpAppPreview); - const rawDescriptor = asRecord(preview?.mcpApp); + const preview = asOptionalRecord(details.mcpAppPreview); + const rawDescriptor = asOptionalRecord(preview?.mcpApp); if (!matchesLookup(rawDescriptor, lookup)) { return undefined; } const descriptor = readDescriptor(rawDescriptor); - const modelToolName = readString(message, "toolName") ?? readString(message, "tool_name"); + const modelToolName = + normalizeOptionalString(message.toolName) ?? normalizeOptionalString(message.tool_name); if (!descriptor || !modelToolName) { return { kind: "unavailable" }; } if ( - readString(message, "toolCallId") !== descriptor.toolCallId || - readString(details, "mcpServer") !== descriptor.serverName || - readString(details, "mcpTool") !== descriptor.toolName || + normalizeOptionalString(message.toolCallId) !== descriptor.toolCallId || + normalizeOptionalString(details.mcpServer) !== descriptor.serverName || + normalizeOptionalString(details.mcpTool) !== descriptor.toolName || descriptor.resultMetaState === "unavailable" ) { return { kind: "unavailable" }; diff --git a/src/gateway/mcp-app-standalone.ts b/src/gateway/mcp-app-standalone.ts index 09b67c00109b..3734c0d71b49 100644 --- a/src/gateway/mcp-app-standalone.ts +++ b/src/gateway/mcp-app-standalone.ts @@ -276,12 +276,12 @@ function runStandaloneMcpAppHost(config: { let sandboxOrigin: string | undefined; let teardownId: JsonRpcId | undefined; - const asRecord = (value: unknown): Record | undefined => + const asStandaloneRecord = (value: unknown): Record | undefined => value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; const errorMessage = (error: unknown, timeoutMessage: string) => - asRecord(error)?.name === "TimeoutError" + asStandaloneRecord(error)?.name === "TimeoutError" ? timeoutMessage : error instanceof Error ? error.message @@ -380,23 +380,23 @@ function runStandaloneMcpAppHost(config: { } initialized = true; notify("ui/notifications/tool-input", { - arguments: asRecord(payload.toolInput) ?? {}, + arguments: asStandaloneRecord(payload.toolInput) ?? {}, }); notify("ui/notifications/tool-result", payload.toolResult); }; const isValidInitialize = (params: unknown) => { - const record = asRecord(params); - const appInfo = asRecord(record?.appInfo); + const record = asStandaloneRecord(params); + const appInfo = asStandaloneRecord(record?.appInfo); return ( typeof record?.protocolVersion === "string" && typeof appInfo?.name === "string" && typeof appInfo?.version === "string" && - asRecord(record?.appCapabilities) !== undefined + asStandaloneRecord(record?.appCapabilities) !== undefined ); }; browser.addEventListener("message", (event) => { - const message = asRecord(event.data) as JsonRpcMessage | undefined; + const message = asStandaloneRecord(event.data) as JsonRpcMessage | undefined; if ( event.source !== frame?.contentWindow || event.origin !== sandboxOrigin || @@ -453,7 +453,7 @@ function runStandaloneMcpAppHost(config: { return; } if (message.method === "ui/notifications/size-changed") { - const height = asRecord(message.params)?.height; + const height = asStandaloneRecord(message.params)?.height; if (frame && typeof height === "number" && Number.isFinite(height)) { frame.style.height = `${Math.min(1200, Math.max(160, Math.round(height)))}px`; } diff --git a/src/gateway/node-normalize.ts b/src/gateway/node-normalize.ts deleted file mode 100644 index f3a8ebb41435..000000000000 --- a/src/gateway/node-normalize.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Normalize optional string-ish websocket fields. Leaf module (no gateway imports). */ -export function normalizeString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} diff --git a/src/gateway/node-plugin-tool-snapshot.ts b/src/gateway/node-plugin-tool-snapshot.ts index a515a30d8089..f343b303462c 100644 --- a/src/gateway/node-plugin-tool-snapshot.ts +++ b/src/gateway/node-plugin-tool-snapshot.ts @@ -1,5 +1,6 @@ /** Connected node-hosted plugin tools available to agent tool resolution. */ import { asOptionalRecord as normalizeRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js"; import { NODE_MCP_TOOLS_CALL_COMMAND } from "../infra/node-commands.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -41,10 +42,6 @@ function bumpSnapshotVersion(): void { snapshotVersion += 1; } -function normalizeString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - function defaultParameters(): Record { return { type: "object", properties: {}, additionalProperties: true }; } @@ -59,14 +56,14 @@ export function createRegisteredNodePluginToolDescriptorMap( const descriptors = new Map(); for (const entry of commands ?? []) { const agentTool = entry.command.agentTool; - const name = normalizeString(agentTool?.name); - const description = normalizeString(agentTool?.description); - const command = normalizeString(entry.command.command); + const name = normalizeOptionalString(agentTool?.name) ?? ""; + const description = normalizeOptionalString(agentTool?.description) ?? ""; + const command = normalizeOptionalString(entry.command.command) ?? ""; if (!isProviderSafeToolName(name) || !description || !command) { continue; } - const mcpServer = normalizeString(agentTool?.mcp?.server); - const mcpTool = normalizeString(agentTool?.mcp?.tool); + const mcpServer = normalizeOptionalString(agentTool?.mcp?.server) ?? ""; + const mcpTool = normalizeOptionalString(agentTool?.mcp?.tool) ?? ""; descriptors.set(`${entry.pluginId}\0${name}\0${command}`, { pluginId: entry.pluginId, name, @@ -104,13 +101,13 @@ export function normalizeNodePluginToolDescriptors(params: { // Paired nodes are the trust boundary for descriptors. Operators can disable // publication globally with gateway.nodes.pluginTools.enabled. for (const tool of params.tools ?? []) { - const pluginId = normalizeString(tool.pluginId); - const name = normalizeString(tool.name); - const description = normalizeString(tool.description).slice( + const pluginId = normalizeOptionalString(tool.pluginId) ?? ""; + const name = normalizeOptionalString(tool.name) ?? ""; + const description = (normalizeOptionalString(tool.description) ?? "").slice( 0, NODE_PLUGIN_TOOL_DESCRIPTION_MAX_LENGTH, ); - const command = normalizeString(tool.command); + const command = normalizeOptionalString(tool.command) ?? ""; if ( !pluginId || !isProviderSafeToolName(name) || @@ -134,12 +131,12 @@ export function normalizeNodePluginToolDescriptors(params: { `${pluginId}\0${name}\0${command}`, ); const descriptor = registeredDescriptor ?? tool; - const descriptorDescription = normalizeString(descriptor.description).slice( + const descriptorDescription = (normalizeOptionalString(descriptor.description) ?? "").slice( 0, NODE_PLUGIN_TOOL_DESCRIPTION_MAX_LENGTH, ); - const mcpServer = normalizeString(descriptor.mcp?.server); - const mcpTool = normalizeString(descriptor.mcp?.tool); + const mcpServer = normalizeOptionalString(descriptor.mcp?.server) ?? ""; + const mcpTool = normalizeOptionalString(descriptor.mcp?.tool) ?? ""; normalized.push({ descriptor: { pluginId, diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index 8d2ddb8ee4bc..515e0a2ca9fb 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -7,6 +7,7 @@ import { resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs, } from "@openclaw/normalization-core/number-coercion"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { GATEWAY_CLIENT_IDS } from "../../packages/gateway-protocol/src/client-info.js"; // NodeSession is plugin-SDK-reachable; importing these types from the // gateway-protocol index would retain the whole ProtocolSchemas registry in @@ -19,7 +20,6 @@ import { setActiveNodeContext } from "../infra/active-node-context.js"; import type { PairedDeviceNodeBinding } from "../infra/device-pairing-node-state.js"; import { NODE_MCP_TOOLS_CALL_COMMAND } from "../infra/node-commands.js"; import { logRejectedLargePayload } from "../logging/diagnostic-payload.js"; -import { normalizeString } from "./node-normalize.js"; import { createRegisteredNodePluginToolDescriptorMap, normalizeNodePluginToolDescriptors, @@ -106,12 +106,12 @@ function resolvePendingSystemRunEvent(params: { return undefined; } const obj = params.params as Record; - const runId = normalizeString(obj.runId); + const runId = normalizeOptionalString(obj.runId) ?? ""; if (!runId) { return undefined; } const timeoutMs = normalizeSystemRunTimeoutMs(obj.timeoutMs); - const sessionKey = normalizeString(obj.sessionKey); + const sessionKey = normalizeOptionalString(obj.sessionKey) ?? ""; return { runId, ...(sessionKey ? { sessionKey } : {}), @@ -132,7 +132,7 @@ function normalizeSystemRunInvokeParams(params: { command: string; params?: unkn const obj = params.params as Record; const normalized: Record = { ...obj, - runId: normalizeString(obj.runId) || randomUUID(), + runId: normalizeOptionalString(obj.runId) || randomUUID(), }; const timeoutMs = normalizeSystemRunTimeoutMs(obj.timeoutMs); if (timeoutMs === undefined) { @@ -1168,7 +1168,7 @@ export class NodeRegistry { "params" in params && invokeParams !== undefined ? JSON.stringify(invokeParams) : null, timeoutMs, idempotencyKey: params.idempotencyKey, - sessionKey: normalizeString(params.sessionKey) || undefined, + sessionKey: normalizeOptionalString(params.sessionKey), }; const systemRunEvent = resolvePendingSystemRunEvent({ command: params.command, diff --git a/src/gateway/operator-approval-store.ts b/src/gateway/operator-approval-store.ts index fae2d49503f8..6e497073c663 100644 --- a/src/gateway/operator-approval-store.ts +++ b/src/gateway/operator-approval-store.ts @@ -1,3 +1,4 @@ +import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce"; // Persistent operator approval lifecycle and first-answer-wins transitions. import type { Selectable } from "kysely"; import { @@ -211,12 +212,12 @@ CREATE TABLE IF NOT EXISTS operator_approval_execution_identities ( function normalizeExecutionIdentityBinding(input: NewOperatorApproval) { const binding = input.executionIdentityToken; - const sourceRunId = normalizeString(input.source?.runId); - if (!binding || normalizeString(binding.runId) !== sourceRunId) { + const sourceRunId = normalizeNullableString(input.source?.runId); + if (!binding || normalizeNullableString(binding.runId) !== sourceRunId) { return undefined; } - const sourceContextId = normalizeString(binding.contextId); - const sourceExecutionId = normalizeString(binding.executionId); + const sourceContextId = normalizeNullableString(binding.contextId); + const sourceExecutionId = normalizeNullableString(binding.executionId); if ( !sourceContextId || !sourceExecutionId || @@ -252,13 +253,8 @@ function parseStringArray(raw: string): string[] | null { } } -function normalizeString(value: string | null | undefined): string | null { - const normalized = value?.trim(); - return normalized ? normalized : null; -} - function requireString(value: string, label: string): string { - const normalized = normalizeString(value); + const normalized = normalizeNullableString(value); if (!normalized) { throw new Error(`${label} must not be empty`); } @@ -307,7 +303,7 @@ function decodeOperatorApprovalHistoryCursor(raw: string): OperatorApprovalHisto function normalizeStringArray(values: readonly string[] | undefined): string[] { const result: string[] = []; for (const value of values ?? []) { - const normalized = normalizeString(value); + const normalized = normalizeNullableString(value); if (normalized && !result.includes(normalized)) { result.push(normalized); } @@ -617,16 +613,16 @@ function inputMatchesExistingRow( row.status === "pending" && row.kind === input.kind && row.presentation_json === serialized.presentationJson && - row.requested_by_device_id === normalizeString(input.requester?.deviceId) && - row.requested_by_client_id === normalizeString(input.requester?.clientId) && + row.requested_by_device_id === normalizeNullableString(input.requester?.deviceId) && + row.requested_by_client_id === normalizeNullableString(input.requester?.clientId) && row.requested_by_device_token_auth === (input.requester?.deviceTokenAuth === true ? 1 : 0) && row.reviewer_device_ids_json === serialized.reviewerDeviceIdsJson && - row.source_agent_id === normalizeString(source.agentId) && - row.source_session_key === normalizeString(source.sessionKey) && - row.source_session_id === normalizeString(source.sessionId) && - row.source_run_id === normalizeString(source.runId) && - row.source_tool_call_id === normalizeString(source.toolCallId) && - row.source_tool_name === normalizeString(source.toolName) && + row.source_agent_id === normalizeNullableString(source.agentId) && + row.source_session_key === normalizeNullableString(source.sessionKey) && + row.source_session_id === normalizeNullableString(source.sessionId) && + row.source_run_id === normalizeNullableString(source.runId) && + row.source_tool_call_id === normalizeNullableString(source.toolCallId) && + row.source_tool_name === normalizeNullableString(source.toolName) && row.audience_session_keys_json === serialized.audienceSessionKeysJson && row.runtime_epoch === input.runtimeEpoch.trim() && row.created_at_ms === input.createdAtMs && @@ -694,16 +690,16 @@ export function insertOperatorApproval(params: { kind: input.kind, status: "pending", presentation_json: presentationJson, - requested_by_device_id: normalizeString(input.requester?.deviceId), - requested_by_client_id: normalizeString(input.requester?.clientId), + requested_by_device_id: normalizeNullableString(input.requester?.deviceId), + requested_by_client_id: normalizeNullableString(input.requester?.clientId), requested_by_device_token_auth: input.requester?.deviceTokenAuth === true ? 1 : 0, reviewer_device_ids_json: reviewerDeviceIdsJson, - source_agent_id: normalizeString(source.agentId), - source_session_key: normalizeString(source.sessionKey), - source_session_id: normalizeString(source.sessionId), - source_run_id: normalizeString(source.runId), - source_tool_call_id: normalizeString(source.toolCallId), - source_tool_name: normalizeString(source.toolName), + source_agent_id: normalizeNullableString(source.agentId), + source_session_key: normalizeNullableString(source.sessionKey), + source_session_id: normalizeNullableString(source.sessionId), + source_run_id: normalizeNullableString(source.runId), + source_tool_call_id: normalizeNullableString(source.toolCallId), + source_tool_name: normalizeNullableString(source.toolName), audience_session_keys_json: audienceSessionKeysJson, runtime_epoch: runtimeEpoch, created_at_ms: input.createdAtMs, @@ -982,7 +978,7 @@ export function resolveOperatorApproval(params: { databaseOptions?: OpenClawStateDatabaseOptions; }): ResolveOperatorApprovalResult { const id = requireApprovalId(params.id); - const resolverId = normalizeString(params.resolver.id); + const resolverId = normalizeNullableString(params.resolver.id); const runtimeEpoch = params.runtimeEpoch === undefined ? undefined @@ -1130,7 +1126,7 @@ export function forceDenyOperatorApproval(params: { terminal_reason: params.reason, resolved_at_ms: auditTimestampMs, resolver_kind: params.resolver.kind, - resolver_id: normalizeString(params.resolver.id), + resolver_id: normalizeNullableString(params.resolver.id), updated_at_ms: auditTimestampMs, }) .where("approval_id", "=", id) diff --git a/src/gateway/server-methods/attach.ts b/src/gateway/server-methods/attach.ts index 1276e1f0fb27..3361d2198a52 100644 --- a/src/gateway/server-methods/attach.ts +++ b/src/gateway/server-methods/attach.ts @@ -1,3 +1,5 @@ +import { asRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import { resolveMainSessionKey } from "../../config/sessions.js"; import { resolveSessionEntryAccessTarget } from "../../config/sessions/session-accessor.js"; @@ -14,15 +16,6 @@ import { } from "../mcp-http.loopback-runtime.js"; import type { GatewayRequestHandlers } from "./types.js"; -function paramRecord(params: unknown): Record { - return params && typeof params === "object" ? (params as Record) : {}; -} - -function readString(params: Record, key: string): string | undefined { - const value = params[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readPositiveNumber(params: Record, key: string): number | undefined { const value = params[key]; return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; @@ -30,10 +23,12 @@ function readPositiveNumber(params: Record, key: string): numbe export const attachHandlers: GatewayRequestHandlers = { "attach.grant": async ({ params, respond, context }) => { - const grantParams = paramRecord(params); + const grantParams = asRecord(params); const cfg = context.getRuntimeConfig(); - const sessionKey = readString(grantParams, "sessionKey") ?? resolveMainSessionKey(cfg); - const agentId = sessionKey === "global" ? readString(grantParams, "agentId") : undefined; + const sessionKey = + normalizeOptionalString(grantParams.sessionKey) ?? resolveMainSessionKey(cfg); + const agentId = + sessionKey === "global" ? normalizeOptionalString(grantParams.agentId) : undefined; const harnessEntry = isAgentHarnessSessionKey(sessionKey) ? resolveSessionEntryAccessTarget({ cfg, sessionKey }).entry : undefined; @@ -74,7 +69,7 @@ export const attachHandlers: GatewayRequestHandlers = { }); }, "attach.revoke": async ({ params, respond }) => { - const token = readString(paramRecord(params), "token"); + const token = normalizeOptionalString(asRecord(params).token); if (!token) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "token is required")); return; diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 7ed882708a38..562cf35cfedd 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -1,5 +1,6 @@ // Gateway plugin tests cover plugin loading, auto-enable, runtime registry setup, // request-scope injection, diagnostics, and handler dispatch integration. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { @@ -240,10 +241,6 @@ function createTestContext(label: string): GatewayRequestContext { return { label } as unknown as GatewayRequestContext; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object-capitalized"); function getLastMockFirstArg( diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index b581bfd0c02c..1bc8c380788b 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -41,13 +41,13 @@ type MockCalls = { type SessionStoreEntryOptions = Parameters[1]; type MutationMethod = "sessions.patch" | "sessions.compact"; -function isRecord(value: unknown): value is Record { +function isObjectRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function requireRecord(value: unknown, label: string): Record { - expect(isRecord(value), `${label} should be an object`).toBe(true); - if (!isRecord(value)) { + expect(isObjectRecord(value), `${label} should be an object`).toBe(true); + if (!isObjectRecord(value)) { throw new Error(`${label} should be an object`); } return value; @@ -70,7 +70,7 @@ function expectFields(record: Record, expected: Record - isRecord(event) && isRecord(event.message) ? event.message.content : undefined, + isObjectRecord(event) && isObjectRecord(event.message) ? event.message.content : undefined, ) .filter((content) => content !== undefined); } @@ -90,7 +90,7 @@ function findSession( const sessions = requireArray(payload.sessions, "response sessions"); const session = sessions.find( (candidate): candidate is Record => - isRecord(candidate) && candidate.key === sessionKey, + isObjectRecord(candidate) && candidate.key === sessionKey, ); if (!session) { throw new Error(`Missing session ${sessionKey}`); diff --git a/src/gateway/server.shared-token-session-rotation.test.ts b/src/gateway/server.shared-token-session-rotation.test.ts index 03170893259c..f748074a0d3d 100644 --- a/src/gateway/server.shared-token-session-rotation.test.ts +++ b/src/gateway/server.shared-token-session-rotation.test.ts @@ -2,6 +2,7 @@ * Shared gateway-token session rotation tests. */ import fs from "node:fs/promises"; +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { loadGatewayConfig, @@ -80,17 +81,11 @@ afterAll(async () => { await server.close(); }); -function toRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function buildConfigSetWithRotatedToken(config: Record): Record { const next = structuredClone(config); - const gateway = { ...toRecord(next.gateway) }; - const auth = { ...toRecord(gateway.auth), mode: "token", token: NEW_TOKEN }; - const reload = { ...toRecord(gateway.reload), mode: "off" }; + const gateway = { ...asOptionalRecord(next.gateway) }; + const auth = { ...asOptionalRecord(gateway.auth), mode: "token", token: NEW_TOKEN }; + const reload = { ...asOptionalRecord(gateway.reload), mode: "off" }; gateway.auth = auth; gateway.reload = reload; next.gateway = gateway; diff --git a/src/gateway/session-transcript-readers.test.ts b/src/gateway/session-transcript-readers.test.ts index 507387688645..e5977f34fbb6 100644 --- a/src/gateway/session-transcript-readers.test.ts +++ b/src/gateway/session-transcript-readers.test.ts @@ -202,6 +202,33 @@ describe("session transcript reader facade", () => { }); }); + test("preserves Date.parse semantics for numeric-looking record timestamps", async () => { + const scope = await writeTranscript("reader-numeric-looking-timestamps", [ + { type: "session", version: 3, id: "reader-numeric-looking-timestamps" }, + { + type: "message", + id: "numeric-zero", + parentId: null, + timestamp: "0", + message: { role: "user", content: "zero" }, + }, + { + type: "message", + id: "numeric-year", + parentId: "numeric-zero", + timestamp: "2026", + message: { role: "assistant", content: "year" }, + }, + ]); + + await expect( + readSessionMessagesAsync(scope, { mode: "full", reason: "timestamp contract test" }), + ).resolves.toMatchObject([ + { __openclaw: { recordTimestampMs: Date.parse("0") } }, + { __openclaw: { recordTimestampMs: Date.parse("2026") } }, + ]); + }); + test("finds an anchored reset-archive message by historical session id", async () => { const sessionId = "reader-file-archive-anchor"; const scope = await writeTranscript(sessionId, [ diff --git a/src/logging/diagnostic-stability-bundle.ts b/src/logging/diagnostic-stability-bundle.ts index cf061612a3de..c43804ff3375 100644 --- a/src/logging/diagnostic-stability-bundle.ts +++ b/src/logging/diagnostic-stability-bundle.ts @@ -256,7 +256,7 @@ function readObject(value: unknown, label: string): Record { return value as Record; } -function readNumber(value: unknown, label: string): number { +function readRequiredNumber(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isFinite(value)) { throw new Error(`Invalid stability bundle: ${label} must be a finite number`); } @@ -267,12 +267,12 @@ function readOptionalPositiveInteger(value: unknown, label: string): number | un if (value === undefined) { return undefined; } - const parsed = readNumber(value, label); + const parsed = readRequiredNumber(value, label); return parsed >= 0 ? Math.floor(parsed) : undefined; } function readTimestampMs(value: unknown, label: string): number { - const timestamp = readNumber(value, label); + const timestamp = readRequiredNumber(value, label); if (Number.isNaN(new Date(timestamp).getTime())) { throw new Error(`Invalid stability bundle: ${label} must be a valid timestamp`); } @@ -283,10 +283,10 @@ function readOptionalNumber(value: unknown, label: string): number | undefined { if (value === undefined) { return undefined; } - return readNumber(value, label); + return readRequiredNumber(value, label); } -function readString(value: unknown, label: string): string { +function readRequiredString(value: unknown, label: string): string { if (typeof value !== "string") { throw new Error(`Invalid stability bundle: ${label} must be a string`); } @@ -294,7 +294,7 @@ function readString(value: unknown, label: string): string { } function readTimestampString(value: unknown, label: string): string { - const timestamp = readString(value, label); + const timestamp = readRequiredString(value, label); if (Number.isNaN(new Date(timestamp).getTime())) { throw new Error(`Invalid stability bundle: ${label} must be a valid timestamp`); } @@ -302,7 +302,7 @@ function readTimestampString(value: unknown, label: string): string { } function readCodeString(value: unknown, label: string): string { - const code = readString(value, label); + const code = readRequiredString(value, label); if (!SAFE_REASON_CODE.test(code)) { throw new Error(`Invalid stability bundle: ${label} must be a safe diagnostic code`); } @@ -313,7 +313,7 @@ function readOptionalCodeString(value: unknown, label: string): string | undefin if (value === undefined) { return undefined; } - const code = readString(value, label); + const code = readRequiredString(value, label); return SAFE_REASON_CODE.test(code) ? code : undefined; } @@ -351,11 +351,11 @@ function assignOptionalCodeString( function readMemoryUsage(value: unknown, label: string): DiagnosticMemoryUsage { const memory = readObject(value, label); return { - rssBytes: readNumber(memory.rssBytes, `${label}.rssBytes`), - heapTotalBytes: readNumber(memory.heapTotalBytes, `${label}.heapTotalBytes`), - heapUsedBytes: readNumber(memory.heapUsedBytes, `${label}.heapUsedBytes`), - externalBytes: readNumber(memory.externalBytes, `${label}.externalBytes`), - arrayBuffersBytes: readNumber(memory.arrayBuffersBytes, `${label}.arrayBuffersBytes`), + rssBytes: readRequiredNumber(memory.rssBytes, `${label}.rssBytes`), + heapTotalBytes: readRequiredNumber(memory.heapTotalBytes, `${label}.heapTotalBytes`), + heapUsedBytes: readRequiredNumber(memory.heapUsedBytes, `${label}.heapUsedBytes`), + externalBytes: readRequiredNumber(memory.externalBytes, `${label}.externalBytes`), + arrayBuffersBytes: readRequiredNumber(memory.arrayBuffersBytes, `${label}.arrayBuffersBytes`), }; } @@ -519,7 +519,7 @@ function readSessionFiles(value: unknown): DiagnosticSessionFileSummary[] | unde const files: DiagnosticSessionFileSummary[] = []; for (const [index, entry] of value.entries()) { const source = readObject(entry, `evidence.memoryPressure.topSessionFiles[${index}]`); - const relativePath = readString( + const relativePath = readRequiredString( source.relativePath, `evidence.memoryPressure.topSessionFiles[${index}].relativePath`, ); @@ -577,7 +577,7 @@ function readMemoryPressureEvidence( memory: readMemoryUsage(pressure.memory, "evidence.memoryPressure.memory"), ...(pressure.thresholdBytes !== undefined ? { - thresholdBytes: readNumber( + thresholdBytes: readRequiredNumber( pressure.thresholdBytes, "evidence.memoryPressure.thresholdBytes", ), @@ -585,14 +585,14 @@ function readMemoryPressureEvidence( : {}), ...(pressure.rssGrowthBytes !== undefined ? { - rssGrowthBytes: readNumber( + rssGrowthBytes: readRequiredNumber( pressure.rssGrowthBytes, "evidence.memoryPressure.rssGrowthBytes", ), } : {}), ...(pressure.windowMs !== undefined - ? { windowMs: readNumber(pressure.windowMs, "evidence.memoryPressure.windowMs") } + ? { windowMs: readRequiredNumber(pressure.windowMs, "evidence.memoryPressure.windowMs") } : {}), ...(heapStatistics ? { heapStatistics } : {}), ...(heapSpaces ? { heapSpaces } : {}), @@ -618,7 +618,7 @@ function readNumberMap(value: unknown, label: string): Record { if (!SAFE_REASON_CODE.test(key)) { continue; } - result[key] = readNumber(entry, `${label}.${key}`); + result[key] = readRequiredNumber(entry, `${label}.${key}`); } return result; } @@ -637,17 +637,25 @@ function readOptionalMemorySummary( return { ...(latest ? { latest } : {}), ...(memory.maxRssBytes !== undefined - ? { maxRssBytes: readNumber(memory.maxRssBytes, "snapshot.summary.memory.maxRssBytes") } + ? { + maxRssBytes: readRequiredNumber( + memory.maxRssBytes, + "snapshot.summary.memory.maxRssBytes", + ), + } : {}), ...(memory.maxHeapUsedBytes !== undefined ? { - maxHeapUsedBytes: readNumber( + maxHeapUsedBytes: readRequiredNumber( memory.maxHeapUsedBytes, "snapshot.summary.memory.maxHeapUsedBytes", ), } : {}), - pressureCount: readNumber(memory.pressureCount, "snapshot.summary.memory.pressureCount"), + pressureCount: readRequiredNumber( + memory.pressureCount, + "snapshot.summary.memory.pressureCount", + ), }; } @@ -659,10 +667,13 @@ function readOptionalPayloadLargeSummary( } const payloadLarge = readObject(value, "snapshot.summary.payloadLarge"); return { - count: readNumber(payloadLarge.count, "snapshot.summary.payloadLarge.count"), - rejected: readNumber(payloadLarge.rejected, "snapshot.summary.payloadLarge.rejected"), - truncated: readNumber(payloadLarge.truncated, "snapshot.summary.payloadLarge.truncated"), - chunked: readNumber(payloadLarge.chunked, "snapshot.summary.payloadLarge.chunked"), + count: readRequiredNumber(payloadLarge.count, "snapshot.summary.payloadLarge.count"), + rejected: readRequiredNumber(payloadLarge.rejected, "snapshot.summary.payloadLarge.rejected"), + truncated: readRequiredNumber( + payloadLarge.truncated, + "snapshot.summary.payloadLarge.truncated", + ), + chunked: readRequiredNumber(payloadLarge.chunked, "snapshot.summary.payloadLarge.chunked"), bySurface: readNumberMap(payloadLarge.bySurface, "snapshot.summary.payloadLarge.bySurface"), }; } @@ -673,7 +684,7 @@ function readStabilityEventRecord( ): DiagnosticStabilitySnapshot["events"][number] { const record = readObject(value, label); const sanitized: DiagnosticStabilitySnapshot["events"][number] = { - seq: readNumber(record.seq, `${label}.seq`), + seq: readRequiredNumber(record.seq, `${label}.seq`), ts: readTimestampMs(record.ts, `${label}.ts`), type: readCodeString( record.type, @@ -777,9 +788,9 @@ function readStabilityEventRecord( if (record.webhooks !== undefined) { const webhooks = readObject(record.webhooks, `${label}.webhooks`); sanitized.webhooks = { - received: readNumber(webhooks.received, `${label}.webhooks.received`), - processed: readNumber(webhooks.processed, `${label}.webhooks.processed`), - errors: readNumber(webhooks.errors, `${label}.webhooks.errors`), + received: readRequiredNumber(webhooks.received, `${label}.webhooks.received`), + processed: readRequiredNumber(webhooks.processed, `${label}.webhooks.processed`), + errors: readRequiredNumber(webhooks.errors, `${label}.webhooks.errors`), }; } if (record.memory !== undefined) { @@ -789,22 +800,22 @@ function readStabilityEventRecord( const usage = readObject(record.usage, `${label}.usage`); sanitized.usage = { ...(usage.input !== undefined - ? { input: readNumber(usage.input, `${label}.usage.input`) } + ? { input: readRequiredNumber(usage.input, `${label}.usage.input`) } : {}), ...(usage.output !== undefined - ? { output: readNumber(usage.output, `${label}.usage.output`) } + ? { output: readRequiredNumber(usage.output, `${label}.usage.output`) } : {}), ...(usage.cacheRead !== undefined - ? { cacheRead: readNumber(usage.cacheRead, `${label}.usage.cacheRead`) } + ? { cacheRead: readRequiredNumber(usage.cacheRead, `${label}.usage.cacheRead`) } : {}), ...(usage.cacheWrite !== undefined - ? { cacheWrite: readNumber(usage.cacheWrite, `${label}.usage.cacheWrite`) } + ? { cacheWrite: readRequiredNumber(usage.cacheWrite, `${label}.usage.cacheWrite`) } : {}), ...(usage.promptTokens !== undefined - ? { promptTokens: readNumber(usage.promptTokens, `${label}.usage.promptTokens`) } + ? { promptTokens: readRequiredNumber(usage.promptTokens, `${label}.usage.promptTokens`) } : {}), ...(usage.total !== undefined - ? { total: readNumber(usage.total, `${label}.usage.total`) } + ? { total: readRequiredNumber(usage.total, `${label}.usage.total`) } : {}), }; } @@ -812,10 +823,10 @@ function readStabilityEventRecord( const context = readObject(record.context, `${label}.context`); sanitized.context = { ...(context.limit !== undefined - ? { limit: readNumber(context.limit, `${label}.context.limit`) } + ? { limit: readRequiredNumber(context.limit, `${label}.context.limit`) } : {}), ...(context.used !== undefined - ? { used: readNumber(context.used, `${label}.context.used`) } + ? { used: readRequiredNumber(context.used, `${label}.context.used`) } : {}), }; } @@ -826,9 +837,9 @@ function readStabilityEventRecord( function readStabilitySnapshot(value: unknown): DiagnosticStabilitySnapshot { const snapshot = readObject(value, "snapshot"); const generatedAt = readTimestampString(snapshot.generatedAt, "snapshot.generatedAt"); - const capacity = readNumber(snapshot.capacity, "snapshot.capacity"); - const count = readNumber(snapshot.count, "snapshot.count"); - const dropped = readNumber(snapshot.dropped, "snapshot.dropped"); + const capacity = readRequiredNumber(snapshot.capacity, "snapshot.capacity"); + const count = readRequiredNumber(snapshot.count, "snapshot.count"); + const dropped = readRequiredNumber(snapshot.dropped, "snapshot.dropped"); const firstSeq = readOptionalNumber(snapshot.firstSeq, "snapshot.firstSeq"); const lastSeq = readOptionalNumber(snapshot.lastSeq, "snapshot.lastSeq"); if (!Array.isArray(snapshot.events)) { @@ -870,13 +881,13 @@ function parseDiagnosticStabilityBundle(value: unknown): DiagnosticStabilityBund return { version: DIAGNOSTIC_STABILITY_BUNDLE_VERSION, generatedAt: readTimestampString(bundle.generatedAt, "generatedAt"), - reason: normalizeReason(readString(bundle.reason, "reason")), + reason: normalizeReason(readRequiredString(bundle.reason, "reason")), process: { - pid: readNumber(processInfo.pid, "process.pid"), + pid: readRequiredNumber(processInfo.pid, "process.pid"), platform: readCodeString(processInfo.platform, "process.platform") as NodeJS.Platform, arch: readCodeString(processInfo.arch, "process.arch"), node: readCodeString(processInfo.node, "process.node"), - uptimeMs: readNumber(processInfo.uptimeMs, "process.uptimeMs"), + uptimeMs: readRequiredNumber(processInfo.uptimeMs, "process.uptimeMs"), }, host: { hostname: REDACTED_HOSTNAME, diff --git a/src/meeting-bot/node-host.ts b/src/meeting-bot/node-host.ts index 479ee36ccb9a..ff0400284cde 100644 --- a/src/meeting-bot/node-host.ts +++ b/src/meeting-bot/node-host.ts @@ -80,11 +80,11 @@ export type MeetingNodeHostOptions = { }; }; -function readString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function readNumber(value: unknown, fallback: number): number { +function readPositiveNumberOr(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; } @@ -354,7 +354,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }; const pullAudio = async (params: Record) => { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); if (!bridgeId) { throw new Error("bridgeId required"); } @@ -362,7 +362,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { if (!session) { throw new Error(`unknown bridgeId: ${bridgeId}`); } - const timeoutMs = Math.min(readNumber(params.timeoutMs, 250), 2_000); + const timeoutMs = Math.min(readPositiveNumberOr(params.timeoutMs, 250), 2_000); if (session.chunks.length === 0 && !session.closed) { await session.waiters.wait(timeoutMs); } @@ -429,8 +429,8 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }); const pushAudio = async (params: Record) => { - const bridgeId = readString(params.bridgeId); - const base64 = readString(params.base64); + const bridgeId = readNonEmptyString(params.bridgeId); + const base64 = readNonEmptyString(params.base64); if (!bridgeId || !base64) { throw new Error("bridgeId and base64 required"); } @@ -473,7 +473,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }; const clearAudio = (params: Record) => { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); if (!bridgeId) { throw new Error("bridgeId required"); } @@ -503,8 +503,8 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { const startBrowser = async (params: Record) => { const url = options.normalizeUrl(params.url); - const timeoutMs = readNumber(params.joinTimeoutMs, 30_000); - const mode = readString(params.mode); + const timeoutMs = readPositiveNumberOr(params.joinTimeoutMs, 30_000); + const mode = readNonEmptyString(params.mode); let audioRuntime: MeetingAudioRuntime | undefined; let bridgeId: string | undefined; let audioBridge: @@ -550,7 +550,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { if (params.launch !== false) { const argv = ["open", "-a", options.browser.application, url]; - const browserProfile = readString(params.browserProfile); + const browserProfile = readNonEmptyString(params.browserProfile); if (browserProfile) { argv.push(...options.browser.buildProfileArgs(browserProfile)); } @@ -589,7 +589,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }; const bridgeStatus = (params: Record) => { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); const session = bridgeId ? sessions.get(bridgeId) : undefined; return { bridge: session @@ -625,8 +625,8 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }); const listSessions = (params: Record) => { - const urlKey = options.normalizeMeetingKey(readString(params.url)); - const mode = readString(params.mode); + const urlKey = options.normalizeMeetingKey(readNonEmptyString(params.url)); + const mode = readNonEmptyString(params.mode); const bridges = [...sessions.values()] .filter((session) => !session.stopping && !session.closed) .filter((session) => !urlKey || options.normalizeMeetingKey(session.url) === urlKey) @@ -636,12 +636,12 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }; const stopSessionsByUrl = async (params: Record) => { - const urlKey = options.normalizeMeetingKey(readString(params.url)); + const urlKey = options.normalizeMeetingKey(readNonEmptyString(params.url)); if (!urlKey) { throw new Error("url required"); } - const mode = readString(params.mode); - const exceptBridgeId = readString(params.exceptBridgeId); + const mode = readNonEmptyString(params.mode); + const exceptBridgeId = readNonEmptyString(params.exceptBridgeId); let stopped = 0; const stopping: Promise[] = []; for (const [bridgeId, session] of sessions) { @@ -665,7 +665,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }; const stopBrowser = async (params: Record) => { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); if (!bridgeId) { return { ok: true, stopped: false }; } @@ -689,7 +689,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { } } const params = asOptionalRecord(raw) ?? {}; - const action = readString(params.action); + const action = readNonEmptyString(params.action); let result: unknown; switch (action) { case "setup": diff --git a/src/meeting-bot/node-invoke-policy.ts b/src/meeting-bot/node-invoke-policy.ts index 5145511a4b86..051e391e4d8b 100644 --- a/src/meeting-bot/node-invoke-policy.ts +++ b/src/meeting-bot/node-invoke-policy.ts @@ -38,7 +38,7 @@ type PolicyDecision = | { approved: true; params: Record } | { approved: false; result: OpenClawPluginNodeInvokePolicyResult }; -function readString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } @@ -123,7 +123,7 @@ function buildStartParams( ), }; } - const mode = readString(params.mode); + const mode = readNonEmptyString(params.mode); if (mode && !options.supportedModes.has(mode)) { return { approved: false, @@ -159,18 +159,18 @@ function buildForwardParams( params: Record, options: MeetingBrowserNodePolicyOptions, ): PolicyDecision | null { - const action = readString(params.action); + const action = readNonEmptyString(params.action); switch (action) { case "setup": return approved({ action }); case "status": { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); return approved(bridgeId ? { action, bridgeId } : { action }); } case "list": { const forwarded: Record = { action }; - const url = readString(params.url); - const mode = readString(params.mode); + const url = readNonEmptyString(params.url); + const mode = readNonEmptyString(params.mode); if (url) { try { forwarded.url = options.normalizeUrl(url); @@ -191,9 +191,9 @@ function buildForwardParams( } case "stopByUrl": { const forwarded: Record = { action }; - const url = readString(params.url); - const mode = readString(params.mode); - const exceptBridgeId = readString(params.exceptBridgeId); + const url = readNonEmptyString(params.url); + const mode = readNonEmptyString(params.mode); + const exceptBridgeId = readNonEmptyString(params.exceptBridgeId); if (!url) { return denyMissing(options, action, "url"); } @@ -218,7 +218,7 @@ function buildForwardParams( } case "pullAudio": { const forwarded: Record = { action }; - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); const timeoutMs = readPositiveNumber(params.timeoutMs); if (!bridgeId) { return denyMissing(options, action, "bridgeId"); @@ -231,8 +231,8 @@ function buildForwardParams( } case "pushAudio": { const forwarded: Record = { action }; - const bridgeId = readString(params.bridgeId); - const base64 = readString(params.base64); + const bridgeId = readNonEmptyString(params.bridgeId); + const base64 = readNonEmptyString(params.base64); if (!bridgeId) { return denyMissing(options, action, "bridgeId"); } @@ -260,7 +260,7 @@ function buildForwardParams( return approved(forwarded); } case "clearAudio": { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); if (!bridgeId) { return denyMissing(options, action, "bridgeId"); } @@ -278,7 +278,7 @@ function buildForwardParams( }); } case "stop": { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); return approved(bridgeId ? { action, bridgeId } : { action }); } default: @@ -297,7 +297,7 @@ export function createMeetingBrowserNodeInvokePolicy( return denied(options, `unsupported ${options.displayName} node command: ${ctx.command}`); } const params = asOptionalRecord(ctx.params) ?? {}; - const action = readString(params.action); + const action = readNonEmptyString(params.action); if (action === "setup" && options.useConfiguredSetupCommands) { const setupParams: Record = { action }; copyConfiguredAudio(setupParams, options.start); diff --git a/src/meeting-bot/realtime-node-audio-transport.ts b/src/meeting-bot/realtime-node-audio-transport.ts index 5a9391b5dbcf..fcd0208b26c2 100644 --- a/src/meeting-bot/realtime-node-audio-transport.ts +++ b/src/meeting-bot/realtime-node-audio-transport.ts @@ -10,7 +10,7 @@ const NODE_OUTPUT_GENERATION_CAPABILITY = Symbol.for( "openclaw.internal.meeting-node-output-generation.v1", ); -function readString(value: unknown): string | undefined { +function readNonBlankString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } @@ -82,7 +82,7 @@ export function createNodeMeetingRealtimeAudioTransport(params: { }); const rawRecord = asOptionalRecord(raw); const result = asOptionalRecord(rawRecord?.payload ?? raw) ?? {}; - const base64 = readString(result.base64); + const base64 = readNonBlankString(result.base64); if (base64) { const audio = decodeMeetingAudioBase64(base64, "pullAudio"); outputLoopbackVerifier.recordInput(audio); diff --git a/src/node-host/plugin-node-host.ts b/src/node-host/plugin-node-host.ts index c514a90d1706..d2230094fbd3 100644 --- a/src/node-host/plugin-node-host.ts +++ b/src/node-host/plugin-node-host.ts @@ -1,5 +1,6 @@ /** Plugin node-host bridge for loading plugin registry commands and dispatching node capabilities. */ import { asOptionalRecord as normalizeRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { @@ -111,10 +112,6 @@ export function watchRegisteredNodeHostCommandAvailability( }); } -function normalizeString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - function isProviderSafeToolName(value: string): boolean { return /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value); } @@ -126,13 +123,13 @@ function buildNodePluginToolDescriptor( if (!agentTool) { return null; } - const name = normalizeString(agentTool.name); - const description = normalizeString(agentTool.description); + const name = normalizeOptionalString(agentTool.name) ?? ""; + const description = normalizeOptionalString(agentTool.description) ?? ""; if (!isProviderSafeToolName(name) || !description) { return null; } - const mcpServer = normalizeString(agentTool.mcp?.server); - const mcpTool = normalizeString(agentTool.mcp?.tool); + const mcpServer = normalizeOptionalString(agentTool.mcp?.server) ?? ""; + const mcpTool = normalizeOptionalString(agentTool.mcp?.tool) ?? ""; return { pluginId: entry.pluginId, name, diff --git a/src/plugin-sdk/number-runtime.ts b/src/plugin-sdk/number-runtime.ts index 6f40bb3cb528..9cacf6b50df1 100644 --- a/src/plugin-sdk/number-runtime.ts +++ b/src/plugin-sdk/number-runtime.ts @@ -6,6 +6,7 @@ export { asFiniteNumberInRange, asSafeIntegerInRange, isFutureDateTimestampMs, + parseDateTimestampMs, parseFiniteNumber, clampTimerTimeoutMs, clampPositiveTimerTimeoutMs, diff --git a/src/plugin-sdk/provider-stream-shared.ts b/src/plugin-sdk/provider-stream-shared.ts index aa5bd2e263be..50be6e481105 100644 --- a/src/plugin-sdk/provider-stream-shared.ts +++ b/src/plugin-sdk/provider-stream-shared.ts @@ -1,6 +1,7 @@ // Provider stream shared helpers implement reusable stream wrappers and payload policies. import { resolveOpenAIReasoningEffortForModel } from "@openclaw/ai/internal/openai"; import { resolveOpenAIReasoningEffortMap } from "@openclaw/ai/transports"; +import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { createPromotedPlainTextToolCallBlock, createPromotedPlainTextToolCallEvents, @@ -46,10 +47,6 @@ export function composeProviderStreamWrappers( ); } -function toRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - function resolveContextToolNames(context: Parameters[1]): Set { const tools = (context as { tools?: unknown }).tools; if (!Array.isArray(tools)) { @@ -57,7 +54,7 @@ function resolveContextToolNames(context: Parameters[1]): Set } const names = tools .map((tool) => { - const record = toRecord(tool); + const record = asOptionalObjectRecord(tool); return typeof record?.name === "string" && record.name.trim() ? record.name : undefined; }) .filter((name): name is string => Boolean(name)); @@ -68,10 +65,10 @@ function promotePlainTextToolCalls( message: unknown, toolNames: Set, ): PlainTextToolCallMessageProjection | undefined { - const messageRecord = toRecord(message); + const messageRecord = asOptionalObjectRecord(message); if ( Array.isArray(messageRecord?.content) && - messageRecord.content.some((block) => toRecord(block)?.type === "toolCall") + messageRecord.content.some((block) => asOptionalObjectRecord(block)?.type === "toolCall") ) { return undefined; } diff --git a/src/plugin-sdk/status-helpers.test.ts b/src/plugin-sdk/status-helpers.test.ts index e8e8e57d3b73..4b3a46636665 100644 --- a/src/plugin-sdk/status-helpers.test.ts +++ b/src/plugin-sdk/status-helpers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { evaluateChannelHealth } from "../gateway/channel-health-policy.js"; import { + asString, createAsyncComputedAccountStatusAdapter, buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, @@ -19,6 +20,12 @@ import { } from "./status-helpers.js"; describe("status issue composition", () => { + it("preserves the shipped asString compatibility semantics", () => { + expect(asString(" work ")).toBe("work"); + expect(asString(" ")).toBeUndefined(); + expect(asString(42)).toBeUndefined(); + }); + it("coerces only standard and requested account fields", () => { expect( readAccountStatusSnapshot( diff --git a/src/plugin-sdk/status-helpers.ts b/src/plugin-sdk/status-helpers.ts index 1425c4cd59c5..57df8da28713 100644 --- a/src/plugin-sdk/status-helpers.ts +++ b/src/plugin-sdk/status-helpers.ts @@ -8,12 +8,13 @@ import { resolveChannelAccountState, } from "../channels/status/account-state.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Preserve the shipped Plugin SDK name while the implementation stays canonical. +export { normalizeOptionalString as asString }; export type { ChannelAccountSnapshot } from "../channels/plugins/types.core.js"; export type { ChannelStatusIssue } from "../channels/plugins/types.public.js"; export { isRecord } from "../channels/plugins/status-issues/shared.js"; export { appendMatchMetadata, - asString, collectIssuesForEnabledAccounts, formatMatchMetadata, resolveEnabledConfiguredAccountId, diff --git a/src/plugin-sdk/string-coerce-runtime.ts b/src/plugin-sdk/string-coerce-runtime.ts index 32081a863d0f..af3e6a71a378 100644 --- a/src/plugin-sdk/string-coerce-runtime.ts +++ b/src/plugin-sdk/string-coerce-runtime.ts @@ -19,6 +19,7 @@ export { asFiniteNumber, asPositiveSafeInteger, asSafeIntegerInRange, + parseDateTimestampMs, parseFiniteNumber, parseStrictFiniteNumber, parseStrictInteger, @@ -29,10 +30,12 @@ export { asBoolean, parseBooleanValue } from "../utils/boolean.js"; export { asRecord, asNullableRecord, + asNullableObjectRecord, asOptionalRecord, + asOptionalObjectRecord, + isRecord, readStringField, } from "../../packages/normalization-core/src/record-coerce.js"; -export { isRecord } from "../utils.js"; export { normalizeAtHashSlug, normalizeHyphenSlug, diff --git a/src/plugin-sdk/text-runtime.ts b/src/plugin-sdk/text-runtime.ts index cb4e01868980..10962cbe8bc5 100644 --- a/src/plugin-sdk/text-runtime.ts +++ b/src/plugin-sdk/text-runtime.ts @@ -13,6 +13,7 @@ export * from "../../packages/markdown-core/src/render-aware-chunking.js"; export * from "../../packages/markdown-core/src/render.js"; export * from "../../packages/markdown-core/src/tables.js"; export { resolveGlobalMap, resolveGlobalSingleton } from "../shared/global-singleton.js"; +// Public compatibility: this wildcard intentionally keeps isRecord on text-runtime. export * from "../../packages/normalization-core/src/record-coerce.js"; export * from "../shared/scoped-expiring-id-cache.js"; export * from "../../packages/normalization-core/src/string-coerce.js"; @@ -50,7 +51,6 @@ export { displayString, ensureDir, escapeRegExp, - isRecord, normalizeE164, pathExists, resolveConfigDir, diff --git a/src/plugins/contracts/config-footprint-guardrails.test.ts b/src/plugins/contracts/config-footprint-guardrails.test.ts index 87400f2999ed..75511e08e629 100644 --- a/src/plugins/contracts/config-footprint-guardrails.test.ts +++ b/src/plugins/contracts/config-footprint-guardrails.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { describe, expect, it } from "vitest"; import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "../../config/bundled-channel-config-metadata.generated.js"; import { computeBaseConfigSchemaResponse } from "../../config/schema-base.js"; @@ -54,21 +55,20 @@ function collectSchemaPaths(schema: unknown, prefix = ""): string[] { return out; } -function asRecord(value: unknown): Record { - if (!value || typeof value !== "object") { +function assertRecord(value: unknown): Record { + if (!isRecord(value)) { throw new Error("expected record"); } - expect(Array.isArray(value)).toBe(false); - return value as Record; + return value; } describe("config footprint guardrails", () => { it("keeps plugin entry config generic in the generated base schema", () => { - const root = asRecord(BASE_CONFIG_SCHEMA.schema); - const plugins = asRecord(asRecord(root.properties).plugins); - const entries = asRecord(asRecord(plugins.properties).entries); - const entry = asRecord(entries.additionalProperties); - const pluginConfig = asRecord(asRecord(entry.properties).config); + const root = assertRecord(BASE_CONFIG_SCHEMA.schema); + const plugins = assertRecord(assertRecord(root.properties).plugins); + const entries = assertRecord(assertRecord(plugins.properties).entries); + const entry = assertRecord(entries.additionalProperties); + const pluginConfig = assertRecord(assertRecord(entry.properties).config); expect(pluginConfig.type).toBe("object"); expect(pluginConfig.additionalProperties).toStrictEqual({}); diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index bbee1015cc92..615f6bf64a75 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; // Covers installed plugin index read, write, and policy behavior. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginCandidate } from "./discovery.js"; @@ -48,10 +49,6 @@ function writeRuntimeEntry(rootDir: string) { ); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object-capitalized"); function readRecordField(record: Record, key: string, label: string) { diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index cd23f97b6991..c2c3df9c5503 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; /** Exercises provider runtime loading, ordering, and manifest-backed discovery paths. */ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; @@ -161,10 +162,6 @@ function createOpenAiCatalogProviderPlugin( }; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object-capitalized"); function firstMockArg(mock: { mock: { calls: unknown[][] } }): unknown { diff --git a/src/plugins/session-catalog.test.ts b/src/plugins/session-catalog.test.ts index ef784f90a15c..4368ffa9ab40 100644 --- a/src/plugins/session-catalog.test.ts +++ b/src/plugins/session-catalog.test.ts @@ -98,6 +98,8 @@ describe("importSessionCatalogHistory", () => { const { result } = importHistory( [ { id: "u-1", type: "userMessage", text: "repeat me", timestamp: "not-a-date" }, + { id: "u-2", type: "userMessage", text: "numeric year", timestamp: "2026" }, + { id: "u-3", type: "userMessage", text: "numeric zero", timestamp: "0" }, { id: "r-empty", type: "reasoning" }, { id: "r-1", @@ -118,6 +120,8 @@ describe("importSessionCatalogHistory", () => { expect(transcript.messages.map(messageText)).toEqual([ "repeat me", + "numeric year", + "numeric zero", "Thinking\n\ncareful", "answer", "Tool call\n\nbash", @@ -126,10 +130,15 @@ describe("importSessionCatalogHistory", () => { expect(transcript.messages[0]?.["__openclaw"]).toEqual({ mirrorOrigin: "pi-catalog-import", }); - expect(transcript.messages[1]?.timestamp).toBe(-1_000); - expect(transcript.messages[2]?.model).toBe("anthropic/claude"); + expect(transcript.messages[0]?.timestamp).toBe(Date.parse("2026-07-25T12:00:00.000Z")); + expect(transcript.messages[1]?.timestamp).toBe(Date.parse("2026")); + expect(transcript.messages[2]?.timestamp).toBe(Date.parse("0")); + expect(transcript.messages[3]?.timestamp).toBe(-1_000); + expect(transcript.messages[4]?.model).toBe("anthropic/claude"); expect(transcript.messages.map((message) => message.idempotencyKey)).toEqual([ "pi-catalog:thread-1:u-1", + "pi-catalog:thread-1:u-2", + "pi-catalog:thread-1:u-3", "pi-catalog:thread-1:r-1", "pi-catalog:thread-1:a-1", "pi-catalog:thread-1:t-1", diff --git a/src/skills/lifecycle/upload-store.ts b/src/skills/lifecycle/upload-store.ts index b8c549165505..e60f6087adf0 100644 --- a/src/skills/lifecycle/upload-store.ts +++ b/src/skills/lifecycle/upload-store.ts @@ -319,7 +319,7 @@ function assembleArchive( return Buffer.concat(buffers, expectedSize); } -function toRecord(row: SkillUploadRow, archivePath: string): SkillUploadRecord { +function toSkillUploadRecord(row: SkillUploadRow, archivePath: string): SkillUploadRecord { return { version: 1, kind: "skill-archive", @@ -704,7 +704,7 @@ function createSkillUploadStore(options?: SkillUploadStoreOptions) { async (tmp) => { const archivePath = path.join(tmp.dir, "archive.zip"); await fs.writeFile(archivePath, Buffer.from(row.archive_blob), { mode: 0o600 }); - return await action(toRecord(row, archivePath), { + return await action(toSkillUploadRecord(row, archivePath), { remove: async () => { // Only the callback that still owns the install lease may consume the upload. // A stalled callback must not erase a successor Gateway's replacement lease. diff --git a/src/skills/workshop/config.ts b/src/skills/workshop/config.ts index cdb4f55f1a56..8ec1181ce233 100644 --- a/src/skills/workshop/config.ts +++ b/src/skills/workshop/config.ts @@ -24,7 +24,7 @@ const DEFAULT_CONFIG: SkillWorkshopConfig = { maxSkillBytes: 40_000, }; -function readBoolean(value: unknown, fallback: boolean): boolean { +function readBooleanOr(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } @@ -52,7 +52,7 @@ export function resolveSkillWorkshopConfig(config?: OpenClawConfig): SkillWorksh autonomous: { mode: readAutonomousMode(autonomous.mode, DEFAULT_CONFIG.autonomous.mode), }, - allowSymlinkTargetWrites: readBoolean( + allowSymlinkTargetWrites: readBooleanOr( raw.allowSymlinkTargetWrites, DEFAULT_CONFIG.allowSymlinkTargetWrites, ), diff --git a/src/skills/workshop/policy.ts b/src/skills/workshop/policy.ts index 683054d7e3ec..f8417a40e41b 100644 --- a/src/skills/workshop/policy.ts +++ b/src/skills/workshop/policy.ts @@ -1,5 +1,6 @@ // Workshop policy helpers validate generated skill drafts against workspace policy. import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -64,14 +65,6 @@ function lifecycleApprovalText(action: SkillWorkshopLifecycleAction): { }; } -function readOptionalString( - record: Record | null, - key: string, -): string | undefined { - const value = record?.[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function formatBodySizeKb(content: string): string { return (Buffer.byteLength(content, "utf8") / 1024).toFixed(1); } @@ -126,8 +119,8 @@ async function resolveLifecycleApprovalDescription(params: { const toolParams = asNullableRecord(params.toolParams); try { const proposal = await resolvePendingSkillProposal({ - proposalId: readOptionalString(toolParams, "proposal_id"), - name: readOptionalString(toolParams, "name"), + proposalId: normalizeOptionalString(toolParams?.proposal_id), + name: normalizeOptionalString(toolParams?.name), workspaceDir: params.workspaceDir, }); const record = proposal.record; diff --git a/src/state/openclaw-database-verify.impl.ts b/src/state/openclaw-database-verify.impl.ts index 5b675f435d54..10ee83e98cbe 100644 --- a/src/state/openclaw-database-verify.impl.ts +++ b/src/state/openclaw-database-verify.impl.ts @@ -28,7 +28,7 @@ const DATABASE_VERIFY_CHILD_ARG = "--openclaw-database-verify-child"; const ERROR_OWNED_FIELDS = new Set(["cause", "message", "name", "stack"]); const PROTOTYPE_MUTATING_FIELDS = new Set(["__proto__", "constructor", "prototype"]); -function toError(error: unknown): Error { +function toDatabaseVerifyError(error: unknown): Error { if (error instanceof Error) { return error; } @@ -104,7 +104,7 @@ export function runDatabaseVerifyWorker( stdio: ["ignore", "ignore", "ignore", "ipc"], }); } catch (error) { - return Promise.reject(toError(error)); + return Promise.reject(toDatabaseVerifyError(error)); } options.onWorker?.(worker); @@ -156,7 +156,7 @@ export function runDatabaseVerifyWorker( } result = message; }); - worker.once("error", (error) => settle(() => reject(toError(error)))); + worker.once("error", (error) => settle(() => reject(toDatabaseVerifyError(error)))); worker.once("disconnect", () => { disconnected = true; settleAfterExitAndDisconnect(); @@ -171,7 +171,7 @@ export function runDatabaseVerifyWorker( return; } worker.kill(); - settle(() => reject(toError(error))); + settle(() => reject(toDatabaseVerifyError(error))); }); }); } diff --git a/src/tasks/task-registry-activity.ts b/src/tasks/task-registry-activity.ts index 8c32fb181ed1..bfd24ef872a4 100644 --- a/src/tasks/task-registry-activity.ts +++ b/src/tasks/task-registry-activity.ts @@ -1,5 +1,8 @@ import path from "node:path"; -import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; +import { + asOptionalObjectRecord, + readStringField, +} from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { extractApplyPatchTargetPaths } from "../agents/apply-patch-paths.js"; @@ -85,15 +88,6 @@ function updateStreamText( return lastLineSnippet(cumulative); } -function readString(record: Record, keys: string[]): string | undefined { - for (const key of keys) { - if (typeof record[key] === "string") { - return record[key]; - } - } - return undefined; -} - function readTarget(record: Record): string | undefined { const target = normalizeOptionalString(record.path ?? record.file_path ?? record.filePath); return target ? path.resolve(target) : undefined; @@ -112,8 +106,8 @@ function readEditPairs(args: Record): EditPair[] { if (!edit) { continue; } - const oldText = readString(edit, ["oldText", "old_string"]); - const newText = readString(edit, ["newText", "new_string"]); + const oldText = readStringField(edit, "oldText") ?? readStringField(edit, "old_string"); + const newText = readStringField(edit, "newText") ?? readStringField(edit, "new_string"); if (oldText !== undefined && newText !== undefined) { pairs.push({ oldText, newText }); } diff --git a/src/trajectory/export.test.ts b/src/trajectory/export.test.ts index 6f24f14d9493..04d77a93df3e 100644 --- a/src/trajectory/export.test.ts +++ b/src/trajectory/export.test.ts @@ -546,6 +546,43 @@ describe("exportTrajectoryBundle", () => { expect(bundle.manifest.sourceFiles.session).toBe("$WORKSPACE_DIR/session.jsonl"); }); + it("keeps runtime timestamp validation on the Date.parse string contract", async () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); + writeSimpleSessionFile(sessionFile); + const runtimeEvents: TrajectoryEvent[] = [ + { ts: "2026", type: "date-compatible" }, + { ts: "999999", type: "numeric-milliseconds-only" }, + ].map(({ ts, type }, index) => ({ + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type, + ts, + seq: index + 1, + sourceSeq: index + 1, + sessionId: "session-1", + })); + fs.writeFileSync( + runtimeFile, + `${runtimeEvents.map((event) => JSON.stringify(event)).join("\n")}\n`, + "utf8", + ); + + const bundle = await exportTrajectoryBundle({ + outputDir: path.join(tmpDir, "bundle"), + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + runtimeFile, + }); + + expect(eventTypes(bundle.events)).toContain("date-compatible"); + expect(eventTypes(bundle.events)).not.toContain("numeric-milliseconds-only"); + }); + it("rejects an incomplete target that conflicts with a legacy marker", async () => { const tmpDir = makeTempDir(); diff --git a/src/trajectory/export.ts b/src/trajectory/export.ts index e27338c6ef31..b87f08dba285 100644 --- a/src/trajectory/export.ts +++ b/src/trajectory/export.ts @@ -530,7 +530,7 @@ function isRuntimeTrajectoryEvent(value: unknown): value is TrajectoryEvent { value.source === "runtime" && typeof value.type === "string" && typeof value.ts === "string" && - !Number.isNaN(Date.parse(value.ts)) && + Number.isFinite(Date.parse(value.ts)) && isFiniteNumber(value.seq) && typeof value.sessionId === "string" && (!("data" in value) || value.data === undefined || isRecord(value.data)) diff --git a/src/trajectory/runtime-store.sqlite.test.ts b/src/trajectory/runtime-store.sqlite.test.ts index 6c3c5251266d..3ee438c74159 100644 --- a/src/trajectory/runtime-store.sqlite.test.ts +++ b/src/trajectory/runtime-store.sqlite.test.ts @@ -69,6 +69,26 @@ describe("SQLite trajectory runtime store", () => { ]); }); + it.each(["2026", "0", "1969-12-31T23:59:59.000Z"])( + "stores Date.parse-compatible trajectory timestamp %s", + (timestamp) => { + appendSqliteTrajectoryRuntimeEvents({ sessionId: "session-1", storePath }, [ + createTrajectoryEvent({ ts: timestamp, type: "timestamp-contract" }), + ]); + + const database = openOpenClawAgentDatabase({ agentId: "main", path: sqlitePath() }); + const db = getNodeSqliteKysely(database.db); + const rows = executeSqliteQuerySync( + database.db, + db + .selectFrom("trajectory_runtime_events") + .select(["created_at"]) + .where("session_id", "=", "session-1"), + ).rows; + expect(rows).toEqual([{ created_at: Date.parse(timestamp) }]); + }, + ); + it("trims oldest rows beyond the configured byte window", async () => { appendSqliteTrajectoryRuntimeEvents( { maxRuntimeBytes: 900, sessionId: "session-1", storePath }, diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index fdfeb18aa9b1..b2b813cf0436 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -5,7 +5,7 @@ import { type SessionProjectionRunStatus, } from "../../packages/gateway-client/src/session-projection.js"; import { - asString, + formatPrimitiveString, extractTextFromMessage, extractTuiAbortedText, formatTuiAbortDiagnostic, @@ -568,7 +568,7 @@ export function createEventHandlers(context: EventHandlerContext) { !finalizedRuns.has(evt.runId); if ( evt.stream === "lifecycle" && - asString(evt.data?.phase, "") === "start" && + formatPrimitiveString(evt.data?.phase, "") === "start" && !finalizedRuns.has(evt.runId) && !(isLocalBtwRunId?.(evt.runId) ?? false) && matchesSelectedTuiSession(state, evt) @@ -610,9 +610,9 @@ export function createEventHandlers(context: EventHandlerContext) { return; } const data = evt.data ?? {}; - const phase = asString(data.phase, ""); - const toolCallId = asString(data.toolCallId, ""); - const toolName = asString(data.name, "tool"); + const phase = formatPrimitiveString(data.phase, ""); + const toolCallId = formatPrimitiveString(data.toolCallId, ""); + const toolName = formatPrimitiveString(data.name, "tool"); if (!toolCallId) { return; } diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index 76992066a11b..1c56440a8dfe 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -748,7 +748,7 @@ export function formatContextUsageLine(params: { return `tokens ${totalLabel}/${ctxLabel}${extra ? ` (${extra})` : ""}`; } -export function asString(value: unknown, fallback = ""): string { +export function formatPrimitiveString(value: unknown, fallback = ""): string { if (typeof value === "string") { return value; } diff --git a/src/tui/tui-run-lifecycle.ts b/src/tui/tui-run-lifecycle.ts index 71bef9833a2d..640787f16b9e 100644 --- a/src/tui/tui-run-lifecycle.ts +++ b/src/tui/tui-run-lifecycle.ts @@ -1,7 +1,7 @@ // Coordinates active TUI runs, watchdogs, terminal errors, and history refresh. import { classifyFailoverReason, isAuthErrorMessage } from "../agents/embedded-agent-helpers.js"; import { formatRawAssistantErrorForUi } from "../shared/assistant-error-format.js"; -import { asString } from "./tui-formatters.js"; +import { formatPrimitiveString } from "./tui-formatters.js"; import type { TuiSessionRunCoordinator } from "./tui-session-run-coordinator.js"; import { clearPendingSubmit, @@ -179,7 +179,7 @@ export function createTuiRunLifecycle(context: TuiRunLifecycleContext) { const applyFallbackStepModelUpdate = (event: AgentEvent): boolean => { const data = event.data ?? {}; - if (event.stream !== "lifecycle" || asString(data.phase, "") !== "fallback_step") { + if (event.stream !== "lifecycle" || formatPrimitiveString(data.phase, "") !== "fallback_step") { return false; } if (typeof data.fallbackStepToModel !== "string") { diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 057a9cdcc99b..fa5554a78d15 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -14,7 +14,7 @@ import type { ChatLog } from "./components/chat-log.js"; import { refreshTuiAgentList } from "./tui-agent-list-refresh.js"; import type { TuiAgentsList, TuiBackend, TuiSessionMutationResult } from "./tui-backend.js"; import { - asString, + formatPrimitiveString, extractTextFromMessage, formatTuiErrorMessage, isCommandMessage, @@ -528,8 +528,8 @@ export function createSessionActions(context: SessionActionContext) { continue; } if (message.role === "toolResult") { - const toolCallId = asString(message.toolCallId, ""); - const toolName = asString(message.toolName, "tool"); + const toolCallId = formatPrimitiveString(message.toolCallId, ""); + const toolName = formatPrimitiveString(message.toolName, "tool"); const component = chatLog.startTool(toolCallId, toolName, {}); component.setResult( { @@ -561,8 +561,8 @@ export function createSessionActions(context: SessionActionContext) { // we stopped watching after switching away, so the persisted history above // does not contain it; render the partial and re-adopt the run so further // deltas (now that this session is active again) continue it. - const inFlightRunId = asString(record.inFlightRun?.runId, ""); - const inFlightText = asString(record.inFlightRun?.text, ""); + const inFlightRunId = formatPrimitiveString(record.inFlightRun?.runId, ""); + const inFlightText = formatPrimitiveString(record.inFlightRun?.text, ""); if (inFlightRunId) { // Render any buffered partial (embedded runtimes); Codex has none mid-run. if (inFlightText) { diff --git a/src/worker/embedded-agent.runtime.ts b/src/worker/embedded-agent.runtime.ts index 849ddfeec512..f6d8d68bc301 100644 --- a/src/worker/embedded-agent.runtime.ts +++ b/src/worker/embedded-agent.runtime.ts @@ -42,7 +42,7 @@ import { } from "./tool-authority.js"; import { WORKER_PROVIDER_REPLAY_LOCAL_RETRY_MESSAGE } from "./transcript-message.js"; -function toError(value: unknown, fallback: string): Error { +function toWorkerAgentError(value: unknown, fallback: string): Error { return value instanceof Error ? value : new Error(fallback, { cause: value }); } @@ -264,7 +264,7 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams) let runFailure: Error | undefined; try { if (params.signal?.aborted) { - throw toError(params.signal.reason, "Worker agent turn aborted."); + throw toWorkerAgentError(params.signal.reason, "Worker agent turn aborted."); } await session.agent.prompt({ role: "user", @@ -273,7 +273,7 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams) }); await session.agent.waitForIdle(); if (params.signal?.aborted) { - throw toError(params.signal.reason, "Worker agent turn aborted."); + throw toWorkerAgentError(params.signal.reason, "Worker agent turn aborted."); } const terminalAssistant = session.agent.state.messages .toReversed() @@ -286,8 +286,8 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams) } } catch (error) { runFailure = params.signal?.aborted - ? toError(params.signal.reason, "Worker agent turn aborted.") - : toError(error, "Worker agent turn failed."); + ? toWorkerAgentError(params.signal.reason, "Worker agent turn aborted.") + : toWorkerAgentError(error, "Worker agent turn failed."); liveRuntime.enqueueRunFailure({ aborted: params.signal?.aborted === true, error: runFailure, @@ -299,7 +299,7 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams) try { await transcriptRuntime.withSessionWriteSettlement(() => undefined); } catch (error) { - finalTranscriptFailure = toError(error, "Worker transcript flush failed."); + finalTranscriptFailure = toWorkerAgentError(error, "Worker transcript flush failed."); } await liveRuntime.flush(); if (finalTranscriptFailure === undefined) { diff --git a/src/worker/worker-connection-admission.ts b/src/worker/worker-connection-admission.ts index b603389e8496..82fa9f89f72e 100644 --- a/src/worker/worker-connection-admission.ts +++ b/src/worker/worker-connection-admission.ts @@ -16,7 +16,7 @@ import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/version.js import { WorkerAdmissionError, WorkerConnectionInterruptedError, - toError, + toWorkerConnectionError, type WorkerConnectionOptions, } from "./worker-connection-contract.js"; import { closeInvalidWorkerFrame } from "./worker-connection-frames.js"; @@ -113,7 +113,7 @@ export function connectWorkerConnectionAttempt( socket.on("error", (error) => { if (!admitted) { - rejectAttempt(new WorkerConnectionInterruptedError(toError(error).message)); + rejectAttempt(new WorkerConnectionInterruptedError(toWorkerConnectionError(error).message)); } }); socket.on("open", () => { diff --git a/src/worker/worker-connection-contract.ts b/src/worker/worker-connection-contract.ts index 71fab6e75591..1512ee2c852c 100644 --- a/src/worker/worker-connection-contract.ts +++ b/src/worker/worker-connection-contract.ts @@ -97,7 +97,7 @@ export function resolvePositiveTimeout(value: number | undefined, fallback: numb return value; } -export function toError(error: unknown): Error { +export function toWorkerConnectionError(error: unknown): Error { if (error instanceof Error) { return error; } diff --git a/src/worker/worker-connection-frames.ts b/src/worker/worker-connection-frames.ts index 8802d3480aad..cc27001bb007 100644 --- a/src/worker/worker-connection-frames.ts +++ b/src/worker/worker-connection-frames.ts @@ -32,7 +32,10 @@ import { createPendingRequestRegistry, type PendingRequestEntry, } from "../shared/pending-request-registry.js"; -import { WorkerConnectionInterruptedError, toError } from "./worker-connection-contract.js"; +import { + WorkerConnectionInterruptedError, + toWorkerConnectionError, +} from "./worker-connection-contract.js"; const WORKER_REQUEST_SPECS = { heartbeat: { @@ -195,7 +198,7 @@ export class WorkerConnectionFrameDispatcher { try { completed.value.beforeResolve?.(response); } catch (error) { - completed.reject(toError(error)); + completed.reject(toWorkerConnectionError(error)); return true; } completed.resolve(response); @@ -220,7 +223,7 @@ export class WorkerConnectionFrameDispatcher { try { encoded = JSON.stringify(frame); } catch (error) { - return Promise.reject(toError(error)); + return Promise.reject(toWorkerConnectionError(error)); } const payloadLimit = value.kind === "inference-start" @@ -254,7 +257,7 @@ export class WorkerConnectionFrameDispatcher { } catch (error) { this.pending .take(id, pending) - ?.reject(new WorkerConnectionInterruptedError(toError(error).message)); + ?.reject(new WorkerConnectionInterruptedError(toWorkerConnectionError(error).message)); this.options.interruptReadySocket(readySocket); } return pending.promise; diff --git a/src/worker/worker-connection.test.ts b/src/worker/worker-connection.test.ts index b0c0b3b49237..05a6e26f5ec2 100644 --- a/src/worker/worker-connection.test.ts +++ b/src/worker/worker-connection.test.ts @@ -13,7 +13,7 @@ import type { WorkerInferenceEventFrame, WorkerInferenceTerminalFrame, } from "../../packages/gateway-protocol/src/schema/worker-inference.js"; -import { toError } from "./worker-connection-contract.js"; +import { toWorkerConnectionError } from "./worker-connection-contract.js"; import { WorkerConnectionFrameDispatcher } from "./worker-connection-frames.js"; import { createWorkerConnection, type WorkerConnectionState } from "./worker-connection.js"; @@ -143,7 +143,7 @@ describe("worker connection error coercion", () => { original.name = "WorkerFailure"; const originalStack = original.stack; - const error = toError(original); + const error = toWorkerConnectionError(original); expect(error).toBe(original); expect(error).toMatchObject({ @@ -157,7 +157,7 @@ describe("worker connection error coercion", () => { it("preserves structured non-Error causes", () => { const cause = { code: "ECONNRESET", status: 503 }; - const error = toError(cause); + const error = toWorkerConnectionError(cause); expect(error.message).toBe("[object Object]"); expect(error.cause).toBe(cause); @@ -174,7 +174,7 @@ describe("worker connection error coercion", () => { let error: Error | undefined; expect(() => { - error = toError(cause); + error = toWorkerConnectionError(cause); }).not.toThrow(); expect(error).toMatchObject({ code: "ECONNRESET" }); expect(error).not.toHaveProperty("details"); @@ -202,7 +202,7 @@ describe("worker connection error coercion", () => { for (const handler of handlers) { const cause = new Proxy({ code: "ECONNRESET", status: 503 }, handler); - const error = toError(cause); + const error = toWorkerConnectionError(cause); expect(error).toMatchObject({ name: "Error", message: "[object Object]" }); expect(error.cause).toBe(cause); @@ -236,7 +236,7 @@ describe("worker connection error coercion", () => { [detailKey]: "symbol detail", }; - const error = toError(cause); + const error = toWorkerConnectionError(cause); expect(reservedReads).toBe(0); expect(error.message).toBe("[object Object]"); @@ -254,7 +254,7 @@ describe("worker connection error coercion", () => { enumerable: true, }); - const error = toError(cause); + const error = toWorkerConnectionError(cause); expect(Object.getPrototypeOf(error)).toBe(Error.prototype); expect(Object.hasOwn(error, "__proto__")).toBe(false); diff --git a/src/worker/worker-connection.ts b/src/worker/worker-connection.ts index 9fc9c867409e..06272e09d7ce 100644 --- a/src/worker/worker-connection.ts +++ b/src/worker/worker-connection.ts @@ -32,7 +32,7 @@ import { WorkerFencedError, isFencedCloseReason, resolvePositiveTimeout, - toError, + toWorkerConnectionError, type WorkerConnectionExit, type WorkerConnectionOptions, type WorkerConnectionState, @@ -228,7 +228,7 @@ export class WorkerConnection { this.reconnectAbort.signal, ); } catch (error) { - throw this.isTerminal() ? this.terminalError() : toError(error); + throw this.isTerminal() ? this.terminalError() : toWorkerConnectionError(error); } remainingMs = this.admissionDeadlineMs - (Date.now() - startedAt); if (remainingMs <= 0) { @@ -310,7 +310,7 @@ export class WorkerConnection { await this.connectUntilReady(); } catch (error) { if (!this.isTerminal()) { - this.finishFailed(toError(error)); + this.finishFailed(toWorkerConnectionError(error)); } } finally { this.reconnectPromise = undefined; @@ -358,7 +358,7 @@ export class WorkerConnection { } } catch (error) { if (!(error instanceof WorkerConnectionInterruptedError) && !this.isTerminal()) { - this.finishFailed(toError(error)); + this.finishFailed(toWorkerConnectionError(error)); return; } } diff --git a/src/worker/worker.runtime.test.ts b/src/worker/worker.runtime.test.ts index aa8dc9d6e602..7b01f8f0563b 100644 --- a/src/worker/worker.runtime.test.ts +++ b/src/worker/worker.runtime.test.ts @@ -3,6 +3,7 @@ import { createServer, type Server } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; import { rawDataToString } from "@openclaw/gateway-client/websocket-data"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { Type } from "typebox"; import { Value } from "typebox/value"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -120,10 +121,6 @@ type FakeGatewayOptions = { heartbeatIntervalMs?: number; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function assistantMessage( content: WorkerDoneMessage["content"], stopReason: WorkerDoneMessage["stopReason"], diff --git a/src/worker/worker.runtime.ts b/src/worker/worker.runtime.ts index a9c1d43e0f3b..854eef53c20a 100644 --- a/src/worker/worker.runtime.ts +++ b/src/worker/worker.runtime.ts @@ -23,7 +23,7 @@ export type WorkerRuntimeResult = const WORKER_REMOTE_CANCEL_GRACE_MS = 1_000; -function toError(value: unknown, fallback: string): Error { +function toWorkerRuntimeError(value: unknown, fallback: string): Error { return value instanceof Error ? value : new Error(fallback, { cause: value }); } @@ -164,7 +164,7 @@ export async function runWorkerDescriptor( signal: abortController.signal, }); if (options.signal?.aborted) { - throw toError(options.signal.reason, "worker interrupted"); + throw toWorkerRuntimeError(options.signal.reason, "worker interrupted"); } } catch (error) { const fenced = fencedResult(connection.state); @@ -172,7 +172,7 @@ export async function runWorkerDescriptor( return fenced; } if (options.signal?.aborted) { - throw toError(options.signal.reason, "worker interrupted"); + throw toWorkerRuntimeError(options.signal.reason, "worker interrupted"); } if (resultFenceAcked && connection.state.kind === "ready") { return { @@ -182,7 +182,7 @@ export async function runWorkerDescriptor( transcriptNextSeq: transcript.nextSeq, }; } - throw toError(error, "worker session failed"); + throw toWorkerRuntimeError(error, "worker session failed"); } const fenced = fencedResult(connection.state); if (fenced) { diff --git a/test/e2e/qa-lab/runtime/cloud-worker-disappearance-proof.ts b/test/e2e/qa-lab/runtime/cloud-worker-disappearance-proof.ts index 4737f7b5f393..d2d95aa1c65b 100644 --- a/test/e2e/qa-lab/runtime/cloud-worker-disappearance-proof.ts +++ b/test/e2e/qa-lab/runtime/cloud-worker-disappearance-proof.ts @@ -31,7 +31,7 @@ type ProducerOptions = { artifactBase: string; repoRoot: string }; type Gateway = Awaited>; type SessionIdentity = { agentId: string; sessionId: string; sessionKey: string }; -function asRecord(value: unknown, label: string): Record { +function requireRecord(value: unknown, label: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} was not an object`); } @@ -74,13 +74,13 @@ async function createQaSession( senderId: string, marker: string, ): Promise { - const beforePayload = asRecord( + const beforePayload = requireRecord( await gateway.call("sessions.list", { limit: 100 }), "sessions.list before inbound", ); const beforeIds = new Set( (Array.isArray(beforePayload.sessions) ? beforePayload.sessions : []).flatMap((entry) => { - const row = asRecord(entry, "session row"); + const row = requireRecord(entry, "session row"); return typeof row.sessionId === "string" ? [row.sessionId] : []; }), ); @@ -92,9 +92,12 @@ async function createQaSession( text: `Reply exactly: ${marker}`, }); await waitForOutbound(state, cursor, marker); - const payload = asRecord(await gateway.call("sessions.list", { limit: 100 }), "sessions.list"); + const payload = requireRecord( + await gateway.call("sessions.list", { limit: 100 }), + "sessions.list", + ); const sessions = Array.isArray(payload.sessions) ? payload.sessions : []; - const rows = sessions.map((entry) => asRecord(entry, "session row")); + const rows = sessions.map((entry) => requireRecord(entry, "session row")); const session = rows.find((entry) => typeof entry.sessionId === "string" && !beforeIds.has(entry.sessionId)) ?? rows.find((entry) => @@ -203,12 +206,12 @@ function seedUnknownWorkerState( } async function describePlacement(gateway: Gateway, session: SessionIdentity) { - const payload = asRecord( + const payload = requireRecord( await gateway.call("sessions.describe", { key: session.sessionKey }), "sessions.describe", ); - const row = asRecord(payload.session, "described session"); - return asRecord(row.placement, "session placement"); + const row = requireRecord(payload.session, "described session"); + return requireRecord(row.placement, "session placement"); } async function waitForFailedPlacement(gateway: Gateway, session: SessionIdentity) { @@ -301,15 +304,17 @@ async function runProof(options: ProducerOptions) { } } - const environmentPayload = asRecord( + const environmentPayload = requireRecord( await gateway.call("environments.list", {}), "environments.list", ); const environments = Array.isArray(environmentPayload.environments) - ? environmentPayload.environments.map((entry) => asRecord(entry, "environment")) + ? environmentPayload.environments.map((entry) => requireRecord(entry, "environment")) : []; const environment = environments.find((entry) => entry.id === ENVIRONMENT_ID); - const worker = environment ? asRecord(environment.worker, "worker environment") : undefined; + const worker = environment + ? requireRecord(environment.worker, "worker environment") + : undefined; if (worker?.state !== "orphaned" || typeof worker.error !== "string") { throw new Error("environment-level orphaned diagnostic was not preserved"); } diff --git a/test/e2e/qa-lab/runtime/gateway-rpc-account-health.ts b/test/e2e/qa-lab/runtime/gateway-rpc-account-health.ts index 9c31b83754e6..b75827b71e77 100644 --- a/test/e2e/qa-lab/runtime/gateway-rpc-account-health.ts +++ b/test/e2e/qa-lab/runtime/gateway-rpc-account-health.ts @@ -62,7 +62,7 @@ export function withSiblingAccount(config: OpenClawConfig, baseUrl?: string): Op }; } -function asRecord(value: unknown): Record { +function assertRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`expected record, got ${JSON.stringify(value)}`); } @@ -70,33 +70,33 @@ function asRecord(value: unknown): Record { } function readHealthAccounts(payload: unknown): Record { - const channels = asRecord(asRecord(payload).channels); - const channel = asRecord(channels[CHANNEL_ID]); - const accounts = asRecord(channel.accounts); + const channels = assertRecord(assertRecord(payload).channels); + const channel = assertRecord(channels[CHANNEL_ID]); + const accounts = assertRecord(channel.accounts); return Object.fromEntries( Object.entries(accounts).map(([accountId, state]) => [ accountId, - asRecord(state) as AccountState, + assertRecord(state) as AccountState, ]), ); } function readStatusAccounts(payload: unknown): Record { - const channelAccounts = asRecord(asRecord(payload).channelAccounts); + const channelAccounts = assertRecord(assertRecord(payload).channelAccounts); const accounts = channelAccounts[CHANNEL_ID]; if (!Array.isArray(accounts)) { throw new Error(`channels.status omitted ${CHANNEL_ID} accounts`); } return Object.fromEntries( accounts.map((state) => { - const record = asRecord(state) as AccountState; + const record = assertRecord(state) as AccountState; return [String(record.accountId), record]; }), ); } export function statusSummaryMentions(payload: unknown, ...needles: string[]) { - const channelSummary = asRecord(payload).channelSummary; + const channelSummary = assertRecord(payload).channelSummary; if (!Array.isArray(channelSummary) || channelSummary.some((line) => typeof line !== "string")) { throw new Error(`status omitted channelSummary lines: ${JSON.stringify(payload)}`); } @@ -127,8 +127,8 @@ async function waitForAccounts( } function readChannelConfig(payload: unknown) { - const config = asRecord(asRecord(payload).config); - return structuredClone(asRecord(asRecord(config.channels)[CHANNEL_ID])); + const config = assertRecord(assertRecord(payload).config); + return structuredClone(assertRecord(assertRecord(config.channels)[CHANNEL_ID])); } async function waitForAppliedConfig( @@ -137,7 +137,7 @@ async function waitForAppliedConfig( ) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { - const payload = asRecord(await gateway.call("config.get", {})); + const payload = assertRecord(await gateway.call("config.get", {})); if ( payload.hash === hash && typeof payload.appliedConfigHash === "string" && @@ -178,10 +178,10 @@ export async function runGatewayRpcAccountHealthProof( const initialHealth = await gateway.call("health", { probe: true }); const initialStatus = await gateway.call("status", { includeChannelSummary: true }); const initialAccounts = readHealthAccounts(initialHealth); - const beforeConfig = asRecord(await gateway.call("config.get", {})); + const beforeConfig = assertRecord(await gateway.call("config.get", {})); const beforeChannelConfig = readChannelConfig(beforeConfig); - const patchResult = asRecord( + const patchResult = assertRecord( await gateway.call("config.patch", { raw: JSON.stringify({ channels: { @@ -202,9 +202,9 @@ export async function runGatewayRpcAccountHealthProof( const appliedConfig = await waitForAppliedConfig(gateway, patchResult.hash); const afterChannelConfig = readChannelConfig(appliedConfig); const expectedChannelConfig = structuredClone(beforeChannelConfig); - const expectedAccounts = asRecord(expectedChannelConfig.accounts); + const expectedAccounts = assertRecord(expectedChannelConfig.accounts); expectedAccounts[TARGET_ACCOUNT_ID] = { - ...asRecord(expectedAccounts[TARGET_ACCOUNT_ID]), + ...assertRecord(expectedAccounts[TARGET_ACCOUNT_ID]), enabled: false, }; @@ -222,7 +222,7 @@ export async function runGatewayRpcAccountHealthProof( const finalAccounts = readHealthAccounts(finalHealth); return { - initialHealthOk: asRecord(initialHealth).ok === true, + initialHealthOk: assertRecord(initialHealth).ok === true, initialStatusVisible: statusSummaryMentions(initialStatus, CHANNEL_LABEL, "default", TARGET_ACCOUNT_ID) && initialChannelAccounts.default?.running === true && diff --git a/test/helpers/gateway/android-node-capabilities-policy-config.ts b/test/helpers/gateway/android-node-capabilities-policy-config.ts index be7e4f59a3c7..ab856cfdb018 100644 --- a/test/helpers/gateway/android-node-capabilities-policy-config.ts +++ b/test/helpers/gateway/android-node-capabilities-policy-config.ts @@ -1,13 +1,9 @@ // Android node capability policy config fixture describes gateway policy config. +import { asRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawConfig } from "../../../src/config/config.js"; // Test helper for unwrapping gateway config.get response shapes. -/** Narrow unknown payloads to plain records for fixture parsing. */ -function asRecord(value: unknown): Record { - return typeof value === "object" && value !== null ? (value as Record) : {}; -} - /** Unwrap current and legacy remote config snapshot envelopes. */ export function unwrapRemoteConfigSnapshot(raw: unknown): OpenClawConfig { const rawObj = asRecord(raw); diff --git a/test/helpers/openclaw-test-instance.ts b/test/helpers/openclaw-test-instance.ts index 13632704e3a8..35ab3a117d10 100644 --- a/test/helpers/openclaw-test-instance.ts +++ b/test/helpers/openclaw-test-instance.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises"; import net from "node:net"; import path from "node:path"; import type { Readable } from "node:stream"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { BUILD_STAMP_FILE, RUNTIME_POSTBUILD_STAMP_FILE, @@ -395,10 +396,6 @@ function hasChildExited(child: Pick { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function mergeConfig( base: Record, override: Record | undefined, diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 852d2beb28d1..71ffb7ad7156 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -1417,6 +1417,7 @@ describe("scripts/changed-lanes", () => { "guarded extension wildcard re-exports", "plugin-sdk wildcard re-exports", "duplicate scan target coverage", + "coercion helper declaration guard", "dependency pin guard", "format changed files", "deprecated API usage", @@ -1600,6 +1601,7 @@ describe("scripts/changed-lanes", () => { "lint:extensions:no-guarded-wildcard-reexports", "lint:extensions:no-plugin-sdk-wildcard-reexports", "dup:check:coverage", + "check:coercion-helpers", "deps:pins:check", "format:check", "--import", @@ -2352,6 +2354,7 @@ describe("scripts/changed-lanes", () => { args: ["lint:extensions:no-plugin-sdk-wildcard-reexports"], }, { name: "duplicate scan target coverage", args: ["dup:check:coverage"] }, + { name: "coercion helper declaration guard", args: ["check:coercion-helpers"] }, { name: "dependency pin guard", args: ["deps:pins:check"] }, { name: "package patch guard", args: ["deps:patches:check"] }, ]); @@ -2375,6 +2378,7 @@ describe("scripts/changed-lanes", () => { args: ["lint:extensions:no-plugin-sdk-wildcard-reexports"], }, { name: "duplicate scan target coverage", args: ["dup:check:coverage"] }, + { name: "coercion helper declaration guard", args: ["check:coercion-helpers"] }, { name: "dependency pin guard", args: ["deps:pins:check"] }, { name: "format changed files", diff --git a/test/scripts/check-coercion-helper-declarations.test.ts b/test/scripts/check-coercion-helper-declarations.test.ts new file mode 100644 index 000000000000..49aa24600495 --- /dev/null +++ b/test/scripts/check-coercion-helper-declarations.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { + auditCoercionHelperDeclarations, + findBannedCoercionHelperDeclarations, + isGovernedCoercionHelperPath, + type CoercionHelperCarveOut, + type CoercionHelperDeclaration, +} from "../../scripts/check-coercion-helper-declarations.mts"; + +describe("coercion helper declaration AST guard", () => { + it("finds nested, exported, async, and callable-variable declarations", () => { + const source = [ + "export async function readString() {}", + "if (true) {", + " function asRecord() {}", + "}", + "const isRecord = (value: unknown) => Boolean(value);", + "let toError = function (value: unknown) { return value; };", + "var optionalString = async (value: unknown) => value;", + "const readString = (((value: unknown) => String(value)) satisfies ((value: unknown) => string));", + "function readNumber(record: Record, key: string) { return record[key]; }", + "const timestampMs = (value: unknown) => Number(value);", + "function readBoolean() {}", + "function readOptionalString() {}", + "function normalizeString() {}", + "const asString = (value: unknown) => String(value);", + "function asObject() {}", + "const readOptionalString = normalizeOptionalString;", + "const optionalString = helpers.readStringValue;", + "const asObject = helpers.asOptionalRecord;", + ].join("\n"); + + expect(findBannedCoercionHelperDeclarations(source, "src/example.ts")).toEqual([ + { file: "src/example.ts", kind: "function", line: 1, name: "readString" }, + { file: "src/example.ts", kind: "function", line: 3, name: "asRecord" }, + { file: "src/example.ts", kind: "variable", line: 5, name: "isRecord" }, + { file: "src/example.ts", kind: "variable", line: 6, name: "toError" }, + { file: "src/example.ts", kind: "variable", line: 7, name: "optionalString" }, + { file: "src/example.ts", kind: "variable", line: 8, name: "readString" }, + { file: "src/example.ts", kind: "function", line: 9, name: "readNumber" }, + { file: "src/example.ts", kind: "variable", line: 10, name: "timestampMs" }, + { file: "src/example.ts", kind: "function", line: 11, name: "readBoolean" }, + { file: "src/example.ts", kind: "function", line: 12, name: "readOptionalString" }, + { file: "src/example.ts", kind: "function", line: 13, name: "normalizeString" }, + { file: "src/example.ts", kind: "variable", line: 14, name: "asString" }, + { file: "src/example.ts", kind: "function", line: 15, name: "asObject" }, + { file: "src/example.ts", kind: "variable", line: 16, name: "readOptionalString" }, + { file: "src/example.ts", kind: "variable", line: 17, name: "optionalString" }, + { file: "src/example.ts", kind: "variable", line: 18, name: "asObject" }, + ]); + }); + + it("ignores imports, aliases, methods, properties, callbacks, and inert text", () => { + const source = [ + 'import { isRecord, readString as importedReadString } from "./helpers.js";', + "const alias = isRecord;", + "const { asRecord } = helpers;", + "class Example { isRecord() {} readString = () => true; }", + "const object = { optionalString() {}, toError: () => new Error() };", + "values.map(function readString(value) { return value; });", + "const aliasWithInternalName = function isRecord(value) { return value; };", + "const asRecord = raw as Record;", + "const optionalString = value as string;", + "// function asRecord() {}", + 'const fixture = "function toError() {}";', + ].join("\n"); + + expect(findBannedCoercionHelperDeclarations(source, "src/example.ts")).toEqual([]); + }); + + it("checks exact counts and rejects excess, stale, or malformed carve-outs", () => { + const declarations: CoercionHelperDeclaration[] = [ + { file: "src/allowed.ts", kind: "function", line: 2, name: "isRecord" }, + { file: "src/allowed.ts", kind: "variable", line: 8, name: "isRecord" }, + { file: "src/new.ts", kind: "function", line: 4, name: "readString" }, + ]; + const carveOuts: CoercionHelperCarveOut[] = [ + { + file: "src/allowed.ts", + name: "isRecord", + count: 1, + reason: "Dependency-free protocol boundary.", + }, + { + file: "src/removed.ts", + name: "toError", + count: 1, + reason: "Hostile object trap semantics.", + }, + { file: "src/blank.ts", name: "asRecord", count: 0, reason: "" }, + ]; + + expect(auditCoercionHelperDeclarations(declarations, carveOuts)).toEqual({ + excessDeclarations: [ + { file: "src/allowed.ts", kind: "variable", line: 8, name: "isRecord" }, + { file: "src/new.ts", kind: "function", line: 4, name: "readString" }, + ], + invalidCarveOuts: [ + "src/blank.ts [asRecord] must have a positive count", + "src/blank.ts [asRecord] needs a non-empty reason", + ], + staleCarveOuts: [ + { + file: "src/removed.ts", + name: "toError", + count: 1, + reason: "Hostile object trap semantics.", + actualCount: 0, + lines: [], + }, + ], + }); + }); + + it("excludes declarations, fixtures, generated sources, and browser bundles", () => { + expect(isGovernedCoercionHelperPath("src/runtime.ts")).toBe(true); + expect(isGovernedCoercionHelperPath("extensions/demo/runtime.jsx")).toBe(true); + expect(isGovernedCoercionHelperPath("src/runtime.d.ts")).toBe(false); + expect(isGovernedCoercionHelperPath("scripts/runtime.d.mts")).toBe(false); + expect(isGovernedCoercionHelperPath("test/fixtures/example.ts")).toBe(false); + expect(isGovernedCoercionHelperPath("extensions/demo/dist/index.js")).toBe(false); + expect(isGovernedCoercionHelperPath("src/example.test-fixtures.ts")).toBe(false); + expect(isGovernedCoercionHelperPath("src/schema.generated.ts")).toBe(false); + expect(isGovernedCoercionHelperPath("ui/src/vendor.bundle.js")).toBe(false); + expect( + isGovernedCoercionHelperPath( + "extensions/browser/chrome-extension/modules/copilot-runtime.js", + ), + ).toBe(false); + }); +}); diff --git a/test/scripts/check-file-utils.test.ts b/test/scripts/check-file-utils.test.ts index 7342fd848159..89d94964e85e 100644 --- a/test/scripts/check-file-utils.test.ts +++ b/test/scripts/check-file-utils.test.ts @@ -92,6 +92,7 @@ describe("scripts/check-file-utils listRepoFilesSync", () => { expect.objectContaining({ timeout: 30_000, killSignal: "SIGKILL", + maxBuffer: 64 * 1024 * 1024, }), ); }); diff --git a/test/scripts/check-memory-fd-repro.test.ts b/test/scripts/check-memory-fd-repro.test.ts index 47277d596b71..4aa06bdf5212 100644 --- a/test/scripts/check-memory-fd-repro.test.ts +++ b/test/scripts/check-memory-fd-repro.test.ts @@ -14,8 +14,8 @@ import { hasChildExited, invokeMemorySearch, parseArgs, + parseNonNegativeInteger, readBoundedResponseText, - readNumber, readPositiveNumber, stopGatewayWithRuntime, updateGatewayReadyOutputState, @@ -42,13 +42,19 @@ async function listen(server: Server): Promise { describe("check-memory-fd-repro", () => { it("parses file, fd, and timing limits as strict integers", () => { - expect(readNumber("0", "limit")).toBe(0); - expect(readNumber(" 42 ", "limit")).toBe(42); + expect(parseNonNegativeInteger("0", "limit")).toBe(0); + expect(parseNonNegativeInteger(" 42 ", "limit")).toBe(42); expect(readPositiveNumber("1", "limit")).toBe(1); - expect(() => readNumber("1.5", "limit")).toThrow("limit must be a non-negative integer"); - expect(() => readNumber("1e3", "limit")).toThrow("limit must be a non-negative integer"); - expect(() => readNumber("10files", "limit")).toThrow("limit must be a non-negative integer"); + expect(() => parseNonNegativeInteger("1.5", "limit")).toThrow( + "limit must be a non-negative integer", + ); + expect(() => parseNonNegativeInteger("1e3", "limit")).toThrow( + "limit must be a non-negative integer", + ); + expect(() => parseNonNegativeInteger("10files", "limit")).toThrow( + "limit must be a non-negative integer", + ); expect(() => readPositiveNumber("0", "limit")).toThrow("limit must be greater than 0"); }); diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 7aa439e77d3f..7cf449698bc8 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -4896,6 +4896,14 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" ); expect(npmLockGuards).toContain("pnpm deps:npm-lock:check"); expect(preflightGuards).toContain("pnpm deps:patches:check"); + expect(preflightGuards).toContain('has_package_script "check:coercion-helpers"'); + expect(preflightGuards).toContain("pnpm check:coercion-helpers"); + expect(preflightGuards).toContain( + "[skip] historical target predates the coercion-helper declaration guard", + ); + expect(preflightGuards).toContain( + "Current CI targets must provide the check:coercion-helpers package script.", + ); expect(parsedWorkflow.jobs.preflight.outputs.diff_base_revision).toBe( "${{ steps.diff_base.outputs.sha }}", ); diff --git a/test/scripts/k8s-manifests.test.ts b/test/scripts/k8s-manifests.test.ts index 388165b3f1f6..8bebeaf060e3 100644 --- a/test/scripts/k8s-manifests.test.ts +++ b/test/scripts/k8s-manifests.test.ts @@ -13,7 +13,7 @@ function readManifest(name: string): Manifest { return parsed as Manifest; } -function asRecord(value: unknown, label: string): Record { +function assertRecord(value: unknown, label: string): Record { expect(value, label).toBeTypeOf("object"); expect(value, label).not.toBeNull(); expect(Array.isArray(value), label).toBe(false); @@ -58,14 +58,14 @@ describe("k8s manifests", () => { it("keeps gateway service selectors and ports aligned with deployment labels", () => { const deployment = readManifest("deployment.yaml"); const service = readManifest("service.yaml"); - const deploymentSpec = asRecord(deployment.spec, "deployment spec"); - const selector = asRecord(deploymentSpec.selector, "deployment selector"); - const matchLabels = asRecord(selector.matchLabels, "deployment match labels"); - const template = asRecord(deploymentSpec.template, "deployment template"); - const templateMetadata = asRecord(template.metadata, "deployment template metadata"); - const templateLabels = asRecord(templateMetadata.labels, "deployment template labels"); - const serviceSpec = asRecord(service.spec, "service spec"); - const serviceSelector = asRecord(serviceSpec.selector, "service selector"); + const deploymentSpec = assertRecord(deployment.spec, "deployment spec"); + const selector = assertRecord(deploymentSpec.selector, "deployment selector"); + const matchLabels = assertRecord(selector.matchLabels, "deployment match labels"); + const template = assertRecord(deploymentSpec.template, "deployment template"); + const templateMetadata = assertRecord(template.metadata, "deployment template metadata"); + const templateLabels = assertRecord(templateMetadata.labels, "deployment template labels"); + const serviceSpec = assertRecord(service.spec, "service spec"); + const serviceSelector = assertRecord(serviceSpec.selector, "service selector"); const ports = asRecords(serviceSpec.ports, "service ports"); expect(deployment).toMatchObject({ @@ -86,14 +86,14 @@ describe("k8s manifests", () => { it("keeps deployment mounts, secrets, and security posture deployable", () => { const deployment = readManifest("deployment.yaml"); - const spec = asRecord(deployment.spec, "deployment spec"); - const template = asRecord(spec.template, "deployment template"); - const podSpec = asRecord(template.spec, "pod spec"); + const spec = assertRecord(deployment.spec, "deployment spec"); + const template = assertRecord(spec.template, "deployment template"); + const podSpec = assertRecord(template.spec, "pod spec"); const containers = asRecords(podSpec.containers, "containers"); const gateway = findNamed(containers, "gateway"); const env = asRecords(gateway.env, "gateway env"); const volumes = asRecords(podSpec.volumes, "pod volumes"); - const securityContext = asRecord(gateway.securityContext, "gateway security context"); + const securityContext = assertRecord(gateway.securityContext, "gateway security context"); expect(gateway.command).toEqual(["node", "/app/dist/index.js", "gateway", "run"]); expect(findNamed(env, "HOME")).toMatchObject({ value: "/home/node" }); @@ -115,15 +115,15 @@ describe("k8s manifests", () => { it("keeps config and persistence manifests aligned with the gateway", () => { const configMap = readManifest("configmap.yaml"); const pvc = readManifest("pvc.yaml"); - const data = asRecord(configMap.data, "configmap data"); + const data = assertRecord(configMap.data, "configmap data"); const config = JSON.parse(String(data["openclaw.json"])) as Record; - const gateway = asRecord(config.gateway, "openclaw config gateway"); - const auth = asRecord(gateway.auth, "openclaw config auth"); - const agents = asRecord(config.agents, "openclaw config agents"); - const defaults = asRecord(agents.defaults, "openclaw config agent defaults"); - const pvcSpec = asRecord(pvc.spec, "pvc spec"); - const resources = asRecord(pvcSpec.resources, "pvc resources"); - const requests = asRecord(resources.requests, "pvc resource requests"); + const gateway = assertRecord(config.gateway, "openclaw config gateway"); + const auth = assertRecord(gateway.auth, "openclaw config auth"); + const agents = assertRecord(config.agents, "openclaw config agents"); + const defaults = assertRecord(agents.defaults, "openclaw config agent defaults"); + const pvcSpec = assertRecord(pvc.spec, "pvc spec"); + const resources = assertRecord(pvcSpec.resources, "pvc resources"); + const requests = assertRecord(resources.requests, "pvc resource requests"); expect(configMap).toMatchObject({ apiVersion: "v1", diff --git a/ui/src/components/browser/browser-client.ts b/ui/src/components/browser/browser-client.ts index 66af5d6d02d0..307ceb4b0443 100644 --- a/ui/src/components/browser/browser-client.ts +++ b/ui/src/components/browser/browser-client.ts @@ -5,6 +5,7 @@ // locally or via a browser-capable node. This module narrows the handful of // routes the browser panel needs and keeps route-path knowledge in one place. import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { readStringValue } from "@openclaw/normalization-core/string-coerce"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { t } from "../../i18n/index.ts"; @@ -66,26 +67,26 @@ function browserRequest(client: GatewayBrowserClient, envelope: BrowserReques return client.request(BROWSER_REQUEST_METHOD, envelope); } -function asString(value: unknown): string { - return typeof value === "string" ? value : ""; +function stringOrEmpty(value: unknown): string { + return readStringValue(value) ?? ""; } -function asFiniteNumber(value: unknown): number | null { +function asNullableFiniteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } function normalizeTab(value: unknown): BrowserPanelTab | null { const record = asRecord(value); - const targetId = asString(record?.targetId); + const targetId = stringOrEmpty(record?.targetId); if (!targetId) { return null; } - const tabId = asString(record?.tabId); + const tabId = stringOrEmpty(record?.tabId); return { id: tabId || targetId, targetId, - title: asString(record?.title), - url: asString(record?.url), + title: stringOrEmpty(record?.title), + url: stringOrEmpty(record?.url), }; } @@ -129,8 +130,8 @@ export async function navigateBrowser( await browserRequest(client, { method: "POST", path: "/navigate", body: params }), ); return { - targetId: asString(result?.targetId) || params.targetId || "", - url: asString(result?.url) || params.url, + targetId: stringOrEmpty(result?.targetId) || params.targetId || "", + url: stringOrEmpty(result?.url) || params.url, }; } @@ -145,14 +146,14 @@ export async function captureBrowserScreenshot( body: { targetId, type: "png" }, }), ); - const path = asString(result?.path); + const path = stringOrEmpty(result?.path); if (!path) { throw new Error(t("browser.errors.screenshotPathMissing")); } return { path, - targetId: asString(result?.targetId) || targetId, - url: asString(result?.url), + targetId: stringOrEmpty(result?.targetId) || targetId, + url: stringOrEmpty(result?.url), }; } @@ -235,16 +236,16 @@ export async function readBrowserPageMetrics( fn: "() => ({ cssWidth: window.innerWidth, cssHeight: window.innerHeight, title: document.title, url: location.href })", }), ); - const cssWidth = asFiniteNumber(result?.cssWidth); - const cssHeight = asFiniteNumber(result?.cssHeight); + const cssWidth = asNullableFiniteNumber(result?.cssWidth); + const cssHeight = asNullableFiniteNumber(result?.cssHeight); if (!cssWidth || !cssHeight || cssWidth <= 0 || cssHeight <= 0) { return null; } return { cssWidth, cssHeight, - title: asString(result?.title), - url: asString(result?.url), + title: stringOrEmpty(result?.title), + url: stringOrEmpty(result?.url), }; } @@ -284,18 +285,18 @@ export async function inspectBrowserElementAt( } const rect = asRecord(result.rect); return { - tag: asString(result.tag), - id: asString(result.id), + tag: stringOrEmpty(result.tag), + id: stringOrEmpty(result.id), classes: Array.isArray(result.classes) ? result.classes.filter((value): value is string => typeof value === "string") : [], - role: asString(result.role), - name: asString(result.name), + role: stringOrEmpty(result.role), + name: stringOrEmpty(result.name), rect: { - x: asFiniteNumber(rect?.x) ?? 0, - y: asFiniteNumber(rect?.y) ?? 0, - width: asFiniteNumber(rect?.width) ?? 0, - height: asFiniteNumber(rect?.height) ?? 0, + x: asNullableFiniteNumber(rect?.x) ?? 0, + y: asNullableFiniteNumber(rect?.y) ?? 0, + width: asNullableFiniteNumber(rect?.width) ?? 0, + height: asNullableFiniteNumber(rect?.height) ?? 0, }, focusable: result.focusable === true, }; diff --git a/ui/src/components/github-link-hovercard.ts b/ui/src/components/github-link-hovercard.ts index 6b5eb2a02c22..a86142da9621 100644 --- a/ui/src/components/github-link-hovercard.ts +++ b/ui/src/components/github-link-hovercard.ts @@ -46,7 +46,10 @@ function requiredString(record: Record, key: string): string { return value; } -function optionalString(record: Record, key: string): string | undefined { +function readOptionalGitHubString( + record: Record, + key: string, +): string | undefined { const value = record[key]; return typeof value === "string" && value.trim() ? value : undefined; } @@ -122,19 +125,19 @@ function parsePreviewResponse(target: GitHubLinkTarget, value: unknown): GitHubP additions: optionalNumber(value, "additions"), avatarDataUrl: safeAvatarDataUrl(value.avatarDataUrl), changedFiles: optionalNumber(value, "changedFiles"), - closedAt: optionalString(value, "closedAt"), + closedAt: readOptionalGitHubString(value, "closedAt"), comments: optionalNumber(value, "comments"), createdAt: requiredString(value, "createdAt"), deletions: optionalNumber(value, "deletions"), draft: typeof value.draft === "boolean" ? value.draft : undefined, kind: target.kind, - login: optionalString(value, "login") ?? "ghost", - mergedAt: optionalString(value, "mergedAt"), + login: readOptionalGitHubString(value, "login") ?? "ghost", + mergedAt: readOptionalGitHubString(value, "mergedAt"), number: target.number, owner: target.owner, repo: target.repo, state: requiredString(value, "state"), - stateReason: optionalString(value, "stateReason"), + stateReason: readOptionalGitHubString(value, "stateReason"), title: requiredString(value, "title"), updatedAt: requiredString(value, "updatedAt"), }; diff --git a/ui/src/e2e/control-ui-auth-transports.e2e.test.ts b/ui/src/e2e/control-ui-auth-transports.e2e.test.ts index 6254ac3188b0..1aad2e912905 100644 --- a/ui/src/e2e/control-ui-auth-transports.e2e.test.ts +++ b/ui/src/e2e/control-ui-auth-transports.e2e.test.ts @@ -3,6 +3,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { createServer, type IncomingMessage } from "node:http"; import net from "node:net"; import path from "node:path"; +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { WebSocket, WebSocketServer, type RawData } from "ws"; @@ -92,12 +93,6 @@ let gateway: RealGateway; let proxy: RealTransportProxy; const openContexts = new Set(); -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function stringValue(value: unknown): string | null { return typeof value === "string" ? value : null; } @@ -115,7 +110,7 @@ function parseJsonFrame(data: RawData): Record | null { : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : data.toString("utf8"); - return asRecord(JSON.parse(text)); + return asNullableRecord(JSON.parse(text)); } catch { return null; } @@ -128,14 +123,14 @@ function captureBrowserConnect( if (frame.type !== "req" || frame.method !== "connect") { return null; } - const params = asRecord(frame.params); - const client = asRecord(params?.client); - const auth = asRecord(params?.auth); + const params = asNullableRecord(frame.params); + const client = asNullableRecord(params?.client); + const auth = asNullableRecord(params?.auth); evidence.browserConnect = { authFields: auth ? Object.keys(auth).toSorted() : [], clientId: stringValue(client?.id), clientMode: stringValue(client?.mode), - hasDevice: asRecord(params?.device) !== null, + hasDevice: asNullableRecord(params?.device) !== null, scopes: stringArray(params?.scopes).toSorted(), }; return stringValue(frame.id); @@ -149,10 +144,10 @@ function captureGatewayResult( if (frame.type !== "res" || stringValue(frame.id) !== connectRequestId) { return; } - const error = asRecord(frame.error); - const details = asRecord(error?.details); - const payload = asRecord(frame.payload); - const auth = asRecord(payload?.auth); + const error = asNullableRecord(frame.error); + const details = asNullableRecord(error?.details); + const payload = asNullableRecord(frame.payload); + const auth = asNullableRecord(payload?.auth); evidence.gatewayResult = { errorCode: stringValue(details?.code) ?? stringValue(error?.code), errorReason: stringValue(details?.authReason) ?? stringValue(details?.reason), diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index 573a60be3ddd..a6d5580dc3d9 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -74,7 +74,7 @@ describe("i18n", () => { }); } - function readString(value: unknown, path: string): string { + function readTranslationString(value: unknown, path: string): string { let cursor = value; for (const part of path.split(".")) { cursor = @@ -193,7 +193,7 @@ describe("i18n", () => { await vi.waitFor(() => expect(fresh.i18n.getLocale()).toBe(expectedLocale)); expect(fresh.t("common.health")).toBe( - readString(expectedLocale === "zh-TW" ? zh_TW : zh_CN, "common.health"), + readTranslationString(expectedLocale === "zh-TW" ? zh_TW : zh_CN, "common.health"), ); }, ); @@ -266,7 +266,9 @@ describe("i18n", () => { zh_TW, })) { for (const key of checkedKeys) { - expect(readString(value, key), `${locale}:${key}`).not.toBe(readString(en, key)); + expect(readTranslationString(value, key), `${locale}:${key}`).not.toBe( + readTranslationString(en, key), + ); } } }); @@ -278,7 +280,9 @@ describe("i18n", () => { for (const [locale, value] of Object.entries(shippedLocales)) { for (const key of checkedKeys) { - expect(readString(value, key), `${locale}:${key}`).not.toBe(readString(en, key)); + expect(readTranslationString(value, key), `${locale}:${key}`).not.toBe( + readTranslationString(en, key), + ); } } }); @@ -288,7 +292,9 @@ describe("i18n", () => { for (const [locale, value] of Object.entries(shippedLocales)) { for (const key of checkedKeys) { - expect(readString(value, key), `${locale}:${key}`).not.toBe(readString(en, key)); + expect(readTranslationString(value, key), `${locale}:${key}`).not.toBe( + readTranslationString(en, key), + ); } } }); diff --git a/ui/src/lib/board/widget-bridge.ts b/ui/src/lib/board/widget-bridge.ts index 114b115bf09c..45c8c57b5263 100644 --- a/ui/src/lib/board/widget-bridge.ts +++ b/ui/src/lib/board/widget-bridge.ts @@ -34,7 +34,7 @@ export function isBoardWidgetBridgeRequest(value: unknown): value is BoardWidget ); } -function asRecord(value: unknown): Record { +function assertWidgetRequestRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("widget host request params are invalid"); } @@ -133,7 +133,7 @@ export class BoardWidgetBridgeController { if (request.ticket !== this.ticket) { throw new Error("widget view ticket does not match the active frame"); } - const params = asRecord(request.params); + const params = assertWidgetRequestRecord(request.params); switch (request.method) { case "prompt.send": { if (options.promptUserActivated !== true) { diff --git a/ui/src/lib/chat/tool-call-patch.ts b/ui/src/lib/chat/tool-call-patch.ts index e7682dbb952f..3b9b2c88cd28 100644 --- a/ui/src/lib/chat/tool-call-patch.ts +++ b/ui/src/lib/chat/tool-call-patch.ts @@ -36,7 +36,7 @@ type PatchViewData = { move?: { from: string; to: string }; }; -function readString(value: unknown): string | undefined { +function readNonBlankString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } @@ -371,13 +371,13 @@ function parseStructuredPatch(changes: unknown[]): PatchViewData | null { const collector: PatchCollector = { sections: [], storedRows: 0, truncated: false }; for (const value of changes) { const record = asRecord(value); - const sourcePath = readString(record?.path)?.trim(); + const sourcePath = readNonBlankString(record?.path)?.trim(); if (!record || !sourcePath) { continue; } const operation = readOperation(record.kind); const section = startSection(collector, operation, sourcePath); - const movePath = readString( + const movePath = readNonBlankString( asRecord(record.kind)?.move_path ?? asRecord(record.kind)?.movePath, ); if (movePath) { @@ -413,7 +413,10 @@ export function parsePatchView(args: unknown): PatchViewData | null { return structured; } } - const text = readString(record.patch) ?? readString(record.input) ?? readString(record.diff); + const text = + readNonBlankString(record.patch) ?? + readNonBlankString(record.input) ?? + readNonBlankString(record.diff); if (!text) { return null; } diff --git a/ui/src/lib/chat/tool-call-view.ts b/ui/src/lib/chat/tool-call-view.ts index 36070f3bb17f..f63f85820b66 100644 --- a/ui/src/lib/chat/tool-call-view.ts +++ b/ui/src/lib/chat/tool-call-view.ts @@ -57,7 +57,7 @@ const SEARCH_TOOL_NAMES = new Set(["grep", "find", "glob", "ls", "list", "codeba const FETCH_TOOL_NAMES = new Set(["web_fetch", "webfetch", "fetch"]); const PATCH_TOOL_NAMES = new Set(["apply_patch", "applypatch", "patch"]); -function readString(value: unknown): string | undefined { +function readNonBlankString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } @@ -66,13 +66,13 @@ function resolvePathArg(args: Record | null): string | undefine return undefined; } return ( - readString(args.path) ?? - readString(args.file_path) ?? - readString(args.filePath) ?? - readString(args.file) ?? - readString(args.filepath) ?? - readString(args.filename) ?? - readString(args.notebook_path) + readNonBlankString(args.path) ?? + readNonBlankString(args.file_path) ?? + readNonBlankString(args.filePath) ?? + readNonBlankString(args.file) ?? + readNonBlankString(args.filepath) ?? + readNonBlankString(args.filename) ?? + readNonBlankString(args.notebook_path) ); } @@ -136,7 +136,7 @@ function readEditPairs(args: Record): { pairs: EditPair[]; trun function readDetailsDiff(details: unknown): ResolvedEditDiff | null { const record = asRecord(details); - const diffText = record ? readString(record.diff) : undefined; + const diffText = record ? readNonBlankString(record.diff) : undefined; if (!diffText) { return null; } @@ -200,7 +200,7 @@ function resolveInsertionDiff( if (fromDetails) { return fromDetails; } - const insertText = args ? readString(args.insert_text) : undefined; + const insertText = args ? readNonBlankString(args.insert_text) : undefined; if (!insertText) { return null; } @@ -256,7 +256,7 @@ function normalizeKey(name: string): string { type TextEditorCommand = "view" | "str_replace" | "create" | "insert" | "undo_edit"; function resolveTextEditorCommand(args: unknown): TextEditorCommand | undefined { - const command = readString(asRecord(args)?.command)?.trim().toLowerCase(); + const command = readNonBlankString(asRecord(args)?.command)?.trim().toLowerCase(); switch (command) { case "view": case "str_replace": @@ -367,7 +367,7 @@ function buildToolCallView( : undefined; if (kind === "command") { - const command = args ? readString(args.command) : undefined; + const command = args ? readNonBlankString(args.command) : undefined; return { kind, command: command ? unwrapShellWrapperCommand(command) : command }; } @@ -425,8 +425,8 @@ function buildToolCallView( } const content = args ? editorCommand === "create" - ? readString(args.file_text) - : readString(args.content) + ? readNonBlankString(args.file_text) + : readNonBlankString(args.content) : undefined; if (!content) { return { kind, target: base, targetDetail: dir }; @@ -446,9 +446,11 @@ function buildToolCallView( if (kind === "search") { const pattern = args - ? (readString(args.pattern) ?? readString(args.query) ?? readString(args.glob)) + ? (readNonBlankString(args.pattern) ?? + readNonBlankString(args.query) ?? + readNonBlankString(args.glob)) : undefined; - const path = resolvePathArg(args) ?? (args ? readString(args.path) : undefined); + const path = resolvePathArg(args) ?? (args ? readNonBlankString(args.path) : undefined); if (!pattern && !path) { return { kind: "generic" }; } @@ -456,7 +458,7 @@ function buildToolCallView( } if (kind === "fetch") { - const url = args ? readString(args.url) : undefined; + const url = args ? readNonBlankString(args.url) : undefined; if (!url) { return { kind: "generic" }; } diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 502c2de4421a..7d95d07ae487 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -955,6 +955,8 @@ function installControlUiMockGateway( return names; } + // This function is serialized with installControlUiMockGateway.toString(). + // Keep the guard local so the generated script captures no module imports. function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }