From edecdbd05efc98c4f580309ac89e8459462f00c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Jul 2026 20:28:43 -0700 Subject: [PATCH] =?UTF-8?q?refactor(config):=20config-surface=20reduction?= =?UTF-8?q?=20tranche=203=20=E2=80=94=20product=20consolidations=20(review?= =?UTF-8?q?=20request)=20(#111527)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(config): consolidate media model lists * refactor(config): unify memory configuration * refactor(config): consolidate TTS ownership * refactor(config): move typing policy to agents * refactor(config): retire product-level config surfaces * refactor(config): share scoped tool policy type * chore(config): refresh generated baselines * fix(config): honor agent typing overrides * fix(config): migrate sibling config consumers * refactor(infra): keep base64url decoder private * fix(config): strip invalid legacy TTS values * chore(config): refresh rebased baseline hash * fix(doctor): route legacy messages.tts.realtime voice to talk during tts move * refactor(config): polish final layout names * refactor(config): freeze retired tuning defaults * feat(config): add fast mode default symmetry * refactor(config): key agent entries by id * docs(config): update final layout reference * test(config): cover final layout migrations * chore(config): refresh final layout baselines * fix(config): align final layout runtime readers * fix(config): align remaining readers * fix(config): stabilize final layout migrations * fix(config): finalize config projection proof * fix(config): address final layout review * docs(release): preserve historical config names * fix(config): complete keyed agent migration * fix(config): close final migration gaps * fix(config): finish full-branch review * fix(config): complete runtime secret detection * fix(config): close final review findings * fix(config): finish canonical docs and heartbeat migration * fix(config): integrate latest main after rebase * refactor(env): isolate test-only controls * refactor(env): isolate build and development controls * refactor(env): collapse process identity indirection * refactor(env): remove duplicate config and temp aliases * docs(env): define the operator-facing allowlist * ci(env): ratchet production variable count * fix(env): remove stale provider helper import * fix(env): make ratchet sorting explicit * test(env): keep test seam in dead-code audit * test(env): cover ratchet growth and boundary; document surface budgets * docs(config): document tier-eval consolidations * docs(config): clarify speech preference ownership * test(memory): align retired tuning fixtures * refactor(memory): freeze engine heuristics * refactor(config): apply tier-eval tranche * refactor(tts): move persona shaping to providers * refactor(compaction): move prompt policy to providers * test(config): align hookified prompt fixtures * chore(deadcode): classify test-only exports * chore(github): remove unused spawn helper * chore(deadcode): classify queue diagnostics * chore(deadcode): remove unused lane snapshot export * chore(plugin-sdk): ratchet consolidated surface * fix(config): integrate latest main after rebase --- .../scripts/secret-scanning.mjs | 31 +- config/env-var-count-budget.txt | 3 + config/knip.config.ts | 7 + docs/.generated/config-baseline.counts.json | 6 +- docs/.generated/config-baseline.sha256 | 8 +- .../.generated/plugin-sdk-api-baseline.sha256 | 12 +- docs/automation/index.md | 21 +- docs/channels/ambient-room-events.md | 2 +- docs/channels/broadcast-groups.md | 4 +- docs/channels/channel-routing.md | 4 +- docs/channels/discord.md | 34 +- docs/channels/feishu.md | 4 +- docs/channels/googlechat.md | 6 +- docs/channels/group-messages.md | 11 +- docs/channels/groups.md | 9 +- docs/channels/imessage.md | 4 +- docs/channels/matrix.md | 2 +- docs/channels/qqbot.md | 13 +- docs/channels/signal.md | 7 +- docs/channels/slack.md | 8 +- docs/channels/telegram.md | 6 +- docs/channels/whatsapp.md | 6 +- docs/ci.md | 20 + docs/cli/agents.md | 4 +- docs/cli/approvals.md | 3 +- docs/cli/commitments.md | 11 +- docs/cli/config.md | 12 +- docs/cli/hooks.md | 4 +- docs/cli/memory.md | 4 +- docs/cli/openclaw.md | 26 +- docs/cli/plugins.md | 2 +- docs/cli/policy.md | 44 +- docs/cli/sandbox.md | 2 +- docs/cli/security.md | 4 +- docs/cli/status.md | 2 +- docs/concepts/active-memory.md | 27 +- docs/concepts/agent-runtimes.md | 4 +- docs/concepts/agent-workspace.md | 6 +- docs/concepts/agent.md | 2 +- docs/concepts/commitments.md | 150 +--- docs/concepts/compaction.md | 2 +- docs/concepts/experimental-features.md | 17 +- docs/concepts/memory-builtin.md | 28 +- docs/concepts/memory-qmd.md | 38 +- docs/concepts/memory-search.md | 29 +- docs/concepts/memory.md | 17 +- docs/concepts/messages.md | 2 +- docs/concepts/model-failover.md | 2 +- docs/concepts/models.md | 10 +- docs/concepts/multi-agent.md | 55 +- docs/concepts/session.md | 6 +- docs/concepts/streaming.md | 2 +- docs/concepts/system-prompt.md | 12 +- docs/concepts/typing-indicators.md | 13 +- docs/docs_map.md | 98 ++- docs/gateway/background-process.md | 28 +- docs/gateway/bonjour.md | 10 +- docs/gateway/cli-backends.md | 10 +- docs/gateway/config-agents.md | 190 ++--- docs/gateway/config-channels.md | 11 +- docs/gateway/config-tools.md | 48 +- docs/gateway/configuration-examples.md | 102 ++- docs/gateway/configuration-reference.md | 48 +- docs/gateway/configuration.md | 12 +- docs/gateway/doctor.md | 44 +- docs/gateway/health.md | 2 +- docs/gateway/heartbeat.md | 16 +- docs/gateway/logging.md | 4 +- docs/gateway/pairing.md | 4 +- docs/gateway/protocol.md | 15 +- .../sandbox-vs-tool-policy-vs-elevated.md | 24 +- docs/gateway/sandboxing.md | 6 +- docs/gateway/secrets.md | 2 +- docs/gateway/security/audit-checks.md | 242 +++--- docs/gateway/security/index.md | 10 +- docs/help/debugging.md | 4 +- docs/help/environment.md | 62 ++ docs/help/faq.md | 40 +- docs/help/troubleshooting.md | 2 +- docs/logging.md | 12 +- docs/nodes/audio.md | 86 +-- docs/nodes/camera.md | 2 +- docs/nodes/computer-use.md | 2 +- docs/nodes/images.md | 9 +- docs/nodes/index.md | 42 +- docs/nodes/media-understanding.md | 50 +- docs/nodes/troubleshooting.md | 4 +- docs/platforms/ios-healthkit.md | 12 +- docs/platforms/linux.md | 2 +- docs/platforms/windows.md | 2 +- docs/plugins/adding-capabilities.md | 2 +- docs/plugins/architecture-internals.md | 2 +- docs/plugins/cli-backend-plugins.md | 4 +- docs/plugins/codex-harness-reference.md | 4 +- docs/plugins/codex-harness-runtime.md | 4 +- docs/plugins/codex-harness.md | 4 +- docs/plugins/google-meet.md | 22 +- docs/plugins/llama-cpp.md | 18 +- docs/plugins/logbook.md | 7 +- docs/plugins/memory-lancedb.md | 6 +- docs/plugins/reference/policy.md | 2 +- docs/plugins/sdk-overview.md | 2 +- docs/plugins/sdk-runtime.md | 4 +- docs/plugins/voice-call.md | 14 +- docs/providers/anthropic.md | 4 +- docs/providers/azure-speech.md | 26 +- docs/providers/bedrock.md | 12 +- docs/providers/deepgram.md | 11 +- docs/providers/deepinfra.md | 18 +- docs/providers/elevenlabs.md | 18 +- docs/providers/github-copilot.md | 14 +- docs/providers/google.md | 20 +- docs/providers/gradium.md | 44 +- docs/providers/inworld.md | 38 +- docs/providers/litellm.md | 2 +- docs/providers/minimax.md | 20 +- docs/providers/mistral.md | 6 +- docs/providers/ollama.md | 36 +- docs/providers/openai.md | 40 +- docs/providers/openrouter.md | 28 +- docs/providers/pixverse.md | 6 +- docs/providers/runway.md | 2 +- docs/providers/senseaudio.md | 14 +- docs/providers/volcengine.md | 22 +- docs/providers/vydra.md | 14 +- docs/providers/xai.md | 18 +- docs/providers/xiaomi.md | 40 +- docs/refactor/acp.md | 9 +- docs/reference/api-usage-costs.md | 6 +- docs/reference/memory-config.md | 242 ++---- docs/reference/prompt-caching.md | 4 +- .../reference/secretref-credential-surface.md | 9 +- ...tref-user-supplied-credentials-matrix.json | 39 +- .../session-management-compaction.md | 6 +- docs/reference/token-use.md | 13 +- docs/reference/wizard.md | 2 +- docs/security/network-proxy.md | 18 +- docs/start/wizard-cli-automation.md | 2 +- docs/start/wizard-cli-reference.md | 2 +- docs/start/wizard.md | 6 +- docs/tools/acp-agents-setup.md | 14 +- docs/tools/acp-agents.md | 26 +- docs/tools/browser.md | 2 +- docs/tools/elevated.md | 4 +- docs/tools/exec-approvals-advanced.md | 6 +- docs/tools/exec-approvals.md | 18 +- docs/tools/exec.md | 54 +- docs/tools/image-generation.md | 9 +- docs/tools/index.md | 2 +- docs/tools/loop-detection.md | 2 +- docs/tools/media-overview.md | 2 +- docs/tools/multi-agent-sandbox-tools.md | 42 +- docs/tools/music-generation.md | 8 +- docs/tools/pdf.md | 12 +- docs/tools/skills-config.md | 8 +- docs/tools/skills.md | 6 +- docs/tools/slash-commands.md | 18 +- docs/tools/subagents.md | 20 +- docs/tools/swarm.md | 2 +- docs/tools/thinking.md | 10 +- docs/tools/tts.md | 495 ++++++------ docs/tools/video-generation.md | 12 +- docs/web/control-ui.md | 46 +- extensions/acpx/src/codex-auth-bridge.ts | 7 +- extensions/acpx/src/process-lease.test.ts | 18 +- extensions/acpx/src/process-lease.ts | 19 +- extensions/acpx/src/runtime.test.ts | 4 +- extensions/active-memory/index.test.ts | 16 +- .../memory-embedding-adapter.ts | 2 +- extensions/anthropic/cli-backend.ts | 2 +- extensions/anthropic/cli-shared.test.ts | 18 +- extensions/anthropic/cli-shared.ts | 11 +- extensions/anthropic/index.test.ts | 2 +- extensions/anthropic/stream-wrappers.test.ts | 18 +- extensions/azure-speech/speech-provider.ts | 2 +- extensions/browser/src/browser-tool.test.ts | 18 +- .../src/browser/cdp-reachability-policy.ts | 8 +- .../browser/src/browser/cdp.helpers.test.ts | 53 +- extensions/browser/src/browser/cdp.helpers.ts | 7 +- .../src/browser/chrome-mcp.ownership.test.ts | 2 +- .../browser/src/browser/config-mutations.ts | 10 +- extensions/browser/src/browser/config.test.ts | 23 +- extensions/browser/src/browser/config.ts | 50 +- .../src/browser/navigation-guard.test.ts | 6 +- .../browser/src/browser/navigation-guard.ts | 10 +- .../src/browser/profiles-service.test.ts | 2 - .../browser/src/browser/profiles.test.ts | 89 +-- extensions/browser/src/browser/profiles.ts | 38 - .../browser/pw-session.connections.test.ts | 4 +- .../src/browser/routes/permissions.test.ts | 1 - ...wser-available.waits-for-cdp-ready.test.ts | 2 - .../server-context.loopback-direct-ws.test.ts | 2 +- ...xt.remote-profile-tab-ops.fallback.test.ts | 1 - ....remote-profile-tab-ops.playwright.test.ts | 3 +- .../src/browser/ssrf-policy-helpers.ts | 1 - extensions/browser/src/browser/vision.test.ts | 12 +- .../codex/src/app-server/bounded-turn.ts | 8 +- .../app-server/dynamic-tool-execution.test.ts | 56 +- .../src/app-server/dynamic-tool-execution.ts | 15 +- .../src/app-server/run-attempt.hooks.test.ts | 11 +- extensions/discord/src/config-schema.test.ts | 1 - extensions/discord/src/config-ui-hints.ts | 22 +- .../monitor/acp-bind-here.integration.test.ts | 10 +- .../monitor/agent-components-guild-auth.ts | 2 +- .../discord/src/monitor/auto-presence.ts | 21 +- .../src/monitor/dm-command-auth.test.ts | 18 +- .../message-handler.process-reactions.ts | 6 +- .../message-handler.process.ack.test.ts | 134 +--- .../message-handler.process.test-helpers.ts | 14 - .../src/monitor/message-handler.process.ts | 8 +- .../src/monitor/native-command-auth.ts | 2 +- .../native-command.commands-allowfrom.test.ts | 17 +- .../native-command.plugin-dispatch.test.ts | 16 +- .../native-command.status-direct.test.ts | 3 +- .../discord/src/monitor/native-command.ts | 2 +- extensions/discord/src/monitor/provider.ts | 13 +- extensions/discord/src/security-audit.test.ts | 34 - extensions/discord/src/security-audit.ts | 15 +- .../discord/src/subagent-progress-config.ts | 5 - extensions/discord/src/subagent-progress.ts | 29 +- extensions/discord/src/ui-colors.ts | 7 +- extensions/discord/src/voice/access.ts | 2 +- .../discord/src/voice/manager.e2e.test.ts | 30 +- extensions/elevenlabs/speech-provider.ts | 2 +- .../exa/src/exa-web-search-provider.test.ts | 18 +- extensions/feishu/src/channel.test.ts | 6 +- extensions/feishu/src/channel.ts | 8 +- extensions/feishu/src/config-schema.ts | 2 +- extensions/feishu/src/doctor-contract.test.ts | 25 + extensions/feishu/src/doctor-contract.ts | 40 +- extensions/github-copilot/embeddings.test.ts | 2 +- extensions/github-copilot/embeddings.ts | 2 +- extensions/google/embedding-provider.ts | 2 +- extensions/google/speech-provider.test.ts | 24 - extensions/google/speech-provider.ts | 48 +- extensions/googlechat/src/accounts.ts | 9 +- .../googlechat/src/config-schema.test.ts | 6 +- .../googlechat/src/monitor-webhook.test.ts | 1 + .../googlechat/src/secret-contract.test.ts | 2 +- extensions/googlechat/src/secret-contract.ts | 48 +- extensions/googlechat/src/setup.test.ts | 2 +- extensions/gradium/speech-provider.ts | 2 +- .../imessage/src/monitor.last-route.test.ts | 90 +-- .../imessage/src/monitor/monitor-provider.ts | 10 +- extensions/inworld/speech-provider.ts | 2 +- extensions/llama-cpp/README.md | 4 +- extensions/lmstudio/src/embedding-provider.ts | 2 +- extensions/logbook/src/service.test.ts | 10 +- extensions/logbook/src/service.ts | 2 +- extensions/matrix/src/config-schema.test.ts | 1 + extensions/matrix/src/config-schema.ts | 1 + .../matrix/src/matrix/monitor/handler.test.ts | 17 +- .../matrix/src/matrix/monitor/handler.ts | 2 +- extensions/matrix/src/types.ts | 4 +- .../src/mattermost/monitor.authz.test.ts | 15 +- .../monitor.inbound-system-event.test.ts | 7 +- .../memory-core/doctor-contract-api.test.ts | 220 +++--- extensions/memory-core/doctor-contract-api.ts | 35 +- extensions/memory-core/index.test.ts | 33 +- extensions/memory-core/src/cli.runtime.ts | 5 +- extensions/memory-core/src/cli.test.ts | 21 +- .../memory-core/src/dreaming-phases.test.ts | 9 +- extensions/memory-core/src/flush-plan.ts | 8 +- .../memory-core/src/memory/index.test.ts | 125 ++- .../src/memory/manager-watch-ops.ts | 2 +- .../memory/manager.fts-only-reindex.test.ts | 15 +- .../memory/manager.reindex-recovery.test.ts | 22 +- ...manager.self-heal-missing-identity.test.ts | 18 +- extensions/memory-core/src/memory/manager.ts | 8 +- .../src/memory/manager.watcher-config.test.ts | 22 +- .../src/memory/qmd-manager.test.ts | 212 +++-- .../memory-core/src/memory/qmd-manager.ts | 2 +- .../src/memory/search-manager.test.ts | 27 +- .../src/memory/test-runtime-mocks.ts | 22 +- extensions/memory-core/src/tools.test.ts | 79 +- extensions/memory-lancedb/index.test.ts | 12 +- extensions/memory-lancedb/index.ts | 5 +- extensions/minimax/speech-provider.ts | 2 +- .../msteams/src/attachments.graph.test.ts | 10 +- .../msteams/src/attachments/shared.test.ts | 7 +- .../message-handler.authz.test.ts | 40 - .../src/monitor-handler/message-handler.ts | 3 +- extensions/mxc/test/mxc-backend.test.ts | 2 +- extensions/nostr/src/channel.inbound.test.ts | 4 +- extensions/ollama/index.test.ts | 7 +- .../ollama/src/embedding-provider.test.ts | 2 +- extensions/ollama/src/embedding-provider.ts | 2 +- extensions/openai/speech-provider.test.ts | 34 - extensions/openai/speech-provider.ts | 50 +- extensions/phone-control/index.test.ts | 151 ++-- extensions/phone-control/index.ts | 13 +- extensions/pixverse/index.test.ts | 6 +- extensions/pixverse/onboard.ts | 7 +- extensions/policy/src/cli.test.ts | 2 +- extensions/policy/src/doctor/fix-metadata.ts | 2 +- .../src/doctor/register.base.test-utils.ts | 28 +- ...r.gateway-data-and-approvals.test-utils.ts | 79 +- ...register.ingress-and-secrets.test-utils.ts | 33 +- .../register.models-and-mcp.test-utils.ts | 46 -- .../register.sandbox-and-tools.test-utils.ts | 22 +- .../src/doctor/review-required-repairs.ts | 6 +- .../policy/src/doctor/scopes/gateway.ts | 6 +- .../policy/src/policy-state-data.test.ts | 76 ++ extensions/policy/src/policy-state-data.ts | 130 ++-- extensions/policy/src/policy-state-gateway.ts | 27 +- .../policy/src/policy-state-tool-posture.ts | 2 +- extensions/qa-lab/src/docker-harness.test.ts | 2 +- .../matrix/matrix-scenario-flows.test.ts | 4 +- .../matrix/substrate/config.ts | 21 +- .../shared/live-gateway.runtime.test.ts | 22 +- .../shared/live-gateway.runtime.ts | 20 +- .../whatsapp/whatsapp-live.config.ts | 14 +- .../whatsapp/whatsapp-live.runtime.test.ts | 8 +- .../qa-lab/src/qa-gateway-config.test.ts | 20 +- extensions/qa-lab/src/qa-gateway-config.ts | 29 +- .../qa-lab/src/scenario-catalog.test.ts | 6 +- .../qa-lab/src/tool-search-gateway.fixture.ts | 37 +- extensions/qqbot/src/engine/utils/stt.test.ts | 12 +- extensions/qqbot/src/engine/utils/stt.ts | 6 +- extensions/reef/src/config-schema.ts | 1 - extensions/signal/src/accounts.ts | 6 +- extensions/signal/src/core.test.ts | 15 + .../src/monitor.tool-result.autostart.test.ts | 17 + extensions/signal/src/monitor.ts | 11 +- .../signal/src/monitor/event-handler.ts | 4 +- extensions/signal/src/setup-core.ts | 17 +- extensions/slack/src/account-inspect.test.ts | 4 +- extensions/slack/src/account-inspect.ts | 2 +- extensions/slack/src/accounts.test.ts | 4 +- extensions/slack/src/accounts.ts | 4 +- extensions/slack/src/action-runtime.test.ts | 4 +- ...nnel-actions-setup-status.contract.test.ts | 10 +- extensions/slack/src/channel-type.test.ts | 2 +- extensions/slack/src/channel.test.ts | 6 +- extensions/slack/src/channel.ts | 4 +- extensions/slack/src/config-schema.test.ts | 75 +- extensions/slack/src/config-ui-hints.ts | 18 +- extensions/slack/src/doctor.test.ts | 8 +- extensions/slack/src/message-tools.test.ts | 2 +- .../src/monitor/message-handler/dispatch.ts | 2 +- .../message-handler/prepare.test-helpers.ts | 2 +- .../monitor/message-handler/prepare.test.ts | 13 +- .../slack/src/monitor/provider-support.ts | 14 +- .../monitor/provider.auth-test-token.test.ts | 10 +- .../src/monitor/provider.interop.test.ts | 11 +- extensions/slack/src/monitor/provider.ts | 5 +- extensions/slack/src/security-audit.test.ts | 18 +- extensions/slack/src/security-audit.ts | 13 - extensions/slack/src/send.reconcile.test.ts | 2 +- extensions/slack/src/setup-core.ts | 32 +- extensions/slack/src/setup-surface.test.ts | 26 +- extensions/slack/src/setup-surface.ts | 2 +- extensions/sms/src/inbound.test.ts | 30 +- .../src/bot-message-context.body.test.ts | 10 +- .../telegram/src/bot-message-context.body.ts | 2 +- .../telegram/src/bot-message-context.ts | 4 +- .../bot-native-commands.session-meta.test.ts | 22 +- .../telegram/src/bot-native-commands.ts | 4 +- .../bot.create-telegram-bot.test-harness.ts | 2 +- .../src/bot.create-telegram-bot.test.ts | 11 +- extensions/telegram/src/bot.test.ts | 4 +- extensions/telegram/src/target-writeback.ts | 2 +- extensions/tlon/src/monitor/index.ts | 2 +- extensions/voice-call/README.md | 2 +- extensions/voice-call/index.ts | 2 +- extensions/voice-call/openclaw.plugin.json | 2 +- extensions/voice-call/src/config.ts | 2 +- extensions/voice-call/src/core-bridge.ts | 10 +- .../src/gateway-continue-operation.ts | 2 +- extensions/voice-call/src/runtime.test.ts | 2 +- .../voice-call/src/telephony-tts.test.ts | 8 +- extensions/voice-call/src/telephony-tts.ts | 2 +- extensions/volcengine/speech-provider.ts | 6 +- extensions/vydra/onboard.ts | 7 +- extensions/vydra/speech-provider.ts | 2 +- extensions/whatsapp/index.test.ts | 6 +- extensions/whatsapp/src/accounts.test.ts | 2 +- extensions/whatsapp/src/accounts.ts | 2 +- extensions/whatsapp/src/auto-reply/monitor.ts | 3 +- .../src/auto-reply/monitor/ack-emoji.test.ts | 4 +- .../src/auto-reply/monitor/ack-emoji.ts | 17 +- .../auto-reply/monitor/ack-reaction.test.ts | 23 +- .../src/auto-reply/monitor/ack-reaction.ts | 11 +- .../monitor/inbound-dispatch.test.ts | 10 + .../auto-reply/monitor/inbound-dispatch.ts | 7 +- .../src/auto-reply/monitor/message-line.ts | 4 +- .../process-message.audio-preflight.test.ts | 11 +- .../src/auto-reply/monitor/process-message.ts | 4 +- .../monitor/status-reaction.test.ts | 24 +- .../src/auto-reply/monitor/status-reaction.ts | 13 +- .../auto-reply/web-auto-reply-monitor.test.ts | 6 +- extensions/whatsapp/src/channel.setup.test.ts | 15 + .../whatsapp/src/doctor-contract.test.ts | 5 +- extensions/whatsapp/src/doctor.test.ts | 29 - extensions/whatsapp/src/doctor.ts | 41 +- extensions/whatsapp/src/heartbeat.ts | 2 +- extensions/whatsapp/src/inbound-policy.ts | 2 +- extensions/whatsapp/src/inbound.media.test.ts | 4 - extensions/whatsapp/src/shared.ts | 12 +- extensions/xai/doctor-contract-api.test.ts | 24 +- extensions/xai/doctor-contract-api.ts | 5 +- extensions/xai/speech-provider.ts | 4 +- extensions/xiaomi/speech-provider.ts | 4 +- .../src/monitor.group-policy.test-support.ts | 2 +- package.json | 1 + packages/gateway-client/src/timeouts.ts | 19 +- .../memory-host-sdk/src/engine-foundation.ts | 1 - .../src/host/backend-config.test.ts | 157 ++-- .../src/host/backend-config.ts | 73 +- .../src/host/config-utils.test.ts | 17 + .../memory-host-sdk/src/host/config-utils.ts | 59 +- .../src/host/embeddings-remote-client.ts | 2 +- .../src/host/openclaw-runtime-config.ts | 1 - .../src/host/openclaw-runtime.ts | 1 - .../src/host/session-files.test.ts | 2 +- .../src/host/sqlite-vec.test.ts | 10 +- .../memory-host-sdk/src/host/sqlite-vec.ts | 2 +- .../src/configured-model-refs.test.ts | 19 +- .../src/configured-model-refs.ts | 20 +- packages/speech-core/runtime-api.ts | 1 + packages/speech-core/src/tts-settings.ts | 12 +- packages/speech-core/src/tts.test.ts | 519 ++++++------- packages/speech-core/src/tts.ts | 5 +- .../matrix-voice-preflight-mention.yaml | 14 +- .../memory/remember-across-conversations.yaml | 23 +- .../memory/session-memory-ranking.yaml | 36 +- scripts/bench-gateway-restart.ts | 1 - scripts/bench-gateway-startup.ts | 1 - scripts/check-changed.mjs | 14 + scripts/check-env-var-count.d.mts | 4 + scripts/check-env-var-count.mjs | 126 +++ scripts/check-memory-fd-repro.mjs | 33 +- scripts/check.mjs | 1 + scripts/e2e/commitments-safety-docker.sh | 1 - scripts/e2e/cron-mcp-cleanup-seed.ts | 1 - scripts/e2e/kitchen-sink-rpc-walk.mjs | 17 +- .../runtime-smoke.mjs | 35 +- .../e2e/lib/codex-media-path/write-config.mjs | 7 +- scripts/e2e/lib/fixtures/workspace.mjs | 8 +- scripts/e2e/mcp-channels-seed.ts | 1 - scripts/e2e/mcp-code-mode-gateway-seed.ts | 17 +- .../e2e/system-agent-rescue-docker-client.ts | 4 +- scripts/e2e/telegram-user-crabbox-proof.ts | 9 +- scripts/generate-docs-map.mjs | 13 +- scripts/lib/openclaw-test-state.mjs | 10 +- scripts/lib/plain-gh.d.mts | 16 - scripts/lib/plain-gh.mjs | 12 +- scripts/lib/policy-config-coverage.jsonc | 110 +-- scripts/mcp-code-mode-gateway-e2e.ts | 19 +- scripts/plugin-sdk-surface-report.mjs | 4 +- scripts/test-projects.test-support.mjs | 3 + .../manager.runtime-handles.test.ts | 4 +- src/agents/acp-spawn.test.ts | 32 +- src/agents/agent-create.test.ts | 19 +- .../compaction-safeguard-runtime.ts | 2 +- src/agents/agent-runtime-config.ts | 13 +- src/agents/agent-scope-config.ts | 14 +- src/agents/agent-scope.ts | 11 +- .../agent-tools-agent-config.exec.test.ts | 11 +- src/agents/agent-tools.policy.test.ts | 8 +- src/agents/agent-tools.safe-bins.test.ts | 3 +- .../auth-profiles.external-cli-scope.test.ts | 9 +- .../auth-profiles/external-cli-scope.ts | 9 +- src/agents/bootstrap-files.test.ts | 16 +- src/agents/cache-trace.test.ts | 8 +- src/agents/cache-trace.ts | 9 +- src/agents/cli-backends.test.ts | 26 +- src/agents/cli-runner.reliability.test.ts | 2 +- src/agents/cli-runner.spawn.test.ts | 26 +- ...aude-live-session.background-tasks.test.ts | 2 +- src/agents/cli-runner/claude-live-session.ts | 12 +- src/agents/cli-runner/execute.ts | 7 - .../helpers.system-prompt-resume.test.ts | 6 +- src/agents/cli-runner/helpers.ts | 2 - .../cli-runner/model-call-diagnostics.test.ts | 7 +- src/agents/command/delivery.test.ts | 8 +- src/agents/compaction.ts | 2 +- src/agents/current-time.test.ts | 2 +- src/agents/current-time.ts | 11 +- src/agents/date-time.ts | 3 +- ...helpers.buildbootstrapcontextfiles.test.ts | 6 +- .../embedded-agent-helpers/bootstrap.ts | 6 +- .../embedded-agent-runner.guard.test.ts | 1 - src/agents/embedded-agent-runner/compact.ts | 2 +- .../embedded-agent-runner/extensions.ts | 2 - .../run/attempt-session-boundary.ts | 3 +- .../run/attempt.abort-settle-timeout.ts | 3 +- .../run/attempt.async-tasks.ts | 3 +- .../embedded-agent-subscribe.tools.test.ts | 1 - src/agents/exec-defaults.test.ts | 17 +- src/agents/exec-defaults.ts | 2 +- src/agents/gpt5-prompt-overlay.ts | 1 - src/agents/heartbeat-system-prompt.test.ts | 10 +- src/agents/heartbeat-system-prompt.ts | 3 - .../identity.per-channel-prefix.test.ts | 70 +- src/agents/identity.ts | 3 +- src/agents/lazy-exec-tool.ts | 2 +- src/agents/mcp-transport-config.ts | 20 +- src/agents/memory-search.test.ts | 509 ++++++------ src/agents/memory-search.ts | 81 +- src/agents/models-config.e2e-harness.ts | 1 - .../models-config.providers.implicit.ts | 10 +- src/agents/models.profiles.live.test.ts | 51 +- .../openclaw-tools.media-factory-plan.test.ts | 24 +- .../openclaw-tools.media-factory-plan.ts | 6 +- src/agents/openclaw-tools.tts-config.test.ts | 24 +- src/agents/owner-display.test.ts | 28 +- src/agents/owner-display.ts | 34 +- .../prompt-overlay-runtime-contract.test.ts | 6 +- src/agents/runtime-capabilities.test.ts | 16 +- src/agents/sandbox-merge.test.ts | 6 +- src/agents/sandbox/browser.create.test.ts | 6 +- src/agents/sandbox/browser.ts | 4 +- src/agents/sandbox/config-hash.test.ts | 10 +- src/agents/sandbox/config-hash.ts | 2 +- src/agents/sandbox/config.ts | 2 +- .../docker.config-hash-recreate.test.ts | 2 +- src/agents/sandbox/novnc-auth.ts | 4 +- src/agents/sandbox/prune.test.ts | 2 +- src/agents/sandbox/ssh-backend.test.ts | 2 +- src/agents/sandbox/types.ts | 2 +- src/agents/session-tool-result-guard.test.ts | 2 +- src/agents/subagent-announce-delivery.ts | 7 +- src/agents/subagent-announce-output.ts | 3 +- src/agents/subagent-registry-run-manager.ts | 3 +- src/agents/subagent-registry-state.ts | 6 +- src/agents/subagent-registry.ts | 9 +- .../subagent-spawn.in-process-gateway.test.ts | 2 +- src/agents/subagent-spawn.ts | 10 +- src/agents/swarm-scheduler.ts | 4 +- src/agents/system-prompt-params.ts | 2 +- src/agents/tools/computer-tool.test.ts | 6 +- src/agents/tools/computer-tool.ts | 6 +- src/agents/tools/image-generate-tool.test.ts | 54 +- src/agents/tools/image-generate-tool.ts | 10 +- .../tools/image-tool.ollama.live.test.ts | 9 +- .../tools/image-tool.providers.live.test.ts | 9 +- src/agents/tools/image-tool.test.ts | 19 +- src/agents/tools/image-tool.ts | 12 +- .../media-generation-config.test-support.ts | 29 + src/agents/tools/media-tool-shared.ts | 19 +- src/agents/tools/music-generate-tool.test.ts | 17 +- src/agents/tools/music-generate-tool.ts | 6 +- src/agents/tools/pdf-tool.ts | 2 +- src/agents/tools/sessions-spawn-tool.test.ts | 24 +- src/agents/tools/video-generate-tool.test.ts | 23 +- src/agents/tools/video-generate-tool.ts | 8 +- src/agents/transcript-redact.test.ts | 34 +- src/agents/transcript-redact.ts | 14 +- src/audit/audit-config.test.ts | 12 +- src/audit/audit-config.ts | 6 +- src/auto-reply/envelope.test.ts | 8 +- src/auto-reply/envelope.ts | 6 +- src/auto-reply/reply/agent-runner.ts | 12 +- src/auto-reply/reply/bash-command.ts | 2 +- src/auto-reply/reply/commands-acp.test.ts | 35 +- src/auto-reply/reply/commands-diagnostics.ts | 2 +- .../reply/commands-export-trajectory.ts | 2 +- .../reply/commands-plugins.install.test.ts | 2 - src/auto-reply/reply/commands-tts.test.ts | 2 +- .../reply/conversation-turn-capture.test.ts | 1 - .../reply/dispatch-acp-delivery.test.ts | 6 +- src/auto-reply/reply/followup-runner.test.ts | 4 +- src/auto-reply/reply/get-reply-directives.ts | 3 +- src/auto-reply/reply/get-reply-fast-path.ts | 4 +- src/auto-reply/reply/get-reply-run.ts | 47 +- .../reply/get-reply.fast-path.test.ts | 8 +- .../reply/get-reply.message-hooks.test.ts | 15 +- src/auto-reply/reply/get-reply.ts | 16 +- src/auto-reply/reply/route-reply.test.ts | 3 +- src/auto-reply/reply/route-reply.ts | 14 +- src/auto-reply/reply/session-system-events.ts | 2 +- src/auto-reply/reply/session-updates.ts | 3 +- src/auto-reply/reply/session.test.ts | 4 +- src/auto-reply/status.test.ts | 22 +- src/channels/direct-dm-access.ts | 2 +- src/channels/inbound-event/envelope.test.ts | 6 +- src/channels/message-access/runtime-types.ts | 4 +- src/channels/message-access/runtime.ts | 2 +- src/channels/thread-bindings-policy.test.ts | 4 +- src/channels/thread-bindings-policy.ts | 6 +- src/claws/add.ts | 15 +- src/claws/lifecycle-config-removal.ts | 10 +- src/claws/lifecycle-delete-support.ts | 4 +- src/claws/lifecycle-state.test.ts | 17 +- src/claws/lifecycle-state.ts | 5 +- src/claws/lifecycle.e2e.test.ts | 22 +- src/claws/lifecycle.ts | 2 +- src/claws/provenance.test.ts | 35 +- src/claws/workspace.test.ts | 4 +- src/cli/banner-config-lite.ts | 19 +- src/cli/banner.test.ts | 35 +- src/cli/banner.ts | 4 +- src/cli/capability-cli.test.ts | 58 +- src/cli/capability-cli/audio.ts | 2 +- src/cli/capability-cli/image.ts | 4 +- src/cli/capability-cli/shared.ts | 2 +- src/cli/capability-cli/tts-runtime.ts | 10 +- src/cli/capability-cli/video.ts | 2 +- src/cli/claws-cli.runtime.ts | 12 +- src/cli/claws-cli.test.ts | 18 +- src/cli/command-secret-gateway.test.ts | 42 +- src/cli/command-secret-targets.import.test.ts | 6 +- src/cli/command-secret-targets.test.ts | 22 +- src/cli/command-secret-targets.ts | 20 +- src/cli/config-cli.integration.test.ts | 10 +- src/cli/config-cli.test.ts | 81 +- src/cli/config-cli.ts | 2 +- src/cli/config-model-validation.test.ts | 139 ++-- src/cli/config-model-validation.ts | 107 ++- src/cli/dns-cli.ts | 4 +- src/cli/exec-policy-cli.test.ts | 37 +- src/cli/exec-policy-cli.ts | 27 +- src/cli/plugins-cli-test-helpers.ts | 8 + .../plugins-cli.marketplace-entries.test.ts | 19 +- .../plugins-cli.marketplace-refresh.test.ts | 53 +- src/cli/plugins-cli.runtime.ts | 8 +- src/cli/plugins-cli.update.test.ts | 236 +----- src/cli/plugins-update-command.ts | 5 +- src/cli/proxy-cli.runtime.test.ts | 4 +- src/cli/proxy-cli.runtime.ts | 5 - src/cli/run-main.exit.test.ts | 8 +- src/commands/agent.runtime-config.test.ts | 75 ++ src/commands/agents.commands.identity.ts | 7 +- src/commands/agents.config.ts | 17 +- src/commands/agents.delete.test.ts | 46 +- src/commands/agents.identity.test.ts | 41 +- src/commands/agents.test.ts | 48 +- src/commands/commitments.test.ts | 8 +- src/commands/daemon-install-helpers.test.ts | 5 - src/commands/doctor-bootstrap-size.test.ts | 2 +- src/commands/doctor-bootstrap-size.ts | 4 +- src/commands/doctor-config-analysis.test.ts | 20 +- src/commands/doctor-config-flow.test.ts | 11 +- ...r-config-preflight.state-migration.test.ts | 98 ++- src/commands/doctor-gateway-health.test.ts | 4 +- src/commands/doctor-install-policy.test.ts | 7 +- .../doctor-legacy-config.migrations.test.ts | 10 +- src/commands/doctor-memory-search.test.ts | 44 +- src/commands/doctor-memory-search.ts | 24 +- src/commands/doctor-security.test.ts | 47 +- src/commands/doctor-security.ts | 26 +- src/commands/doctor-state-integrity.test.ts | 6 +- src/commands/doctor/cron/index.test.ts | 10 +- src/commands/doctor/cron/index.ts | 9 +- src/commands/doctor/cron/legacy-repair.ts | 19 +- .../channel-legacy-config-migrate.test.ts | 43 +- .../shared/channel-legacy-config-migrate.ts | 54 ++ .../shared/codex-route-agent-entries.ts | 42 + .../shared/codex-route-config-repair.ts | 31 +- .../doctor/shared/codex-route-config-scan.ts | 45 +- .../doctor/shared/codex-route-model-ref.ts | 11 +- .../shared/codex-route-runtime-policy.ts | 34 +- .../shared/codex-route-warnings.test.ts | 60 +- .../configured-provider-selection-ids.ts | 3 - .../doctor/shared/deprecation-compat.ts | 110 ++- .../shared/legacy-config-core-migrate.ts | 6 + .../shared/legacy-config-core-normalizers.ts | 9 +- .../shared/legacy-config-migrate.e2e.test.ts | 173 +++++ ...acy-config-migrate.provider-shapes.test.ts | 105 ++- .../shared/legacy-config-migrate.test.ts | 258 +++++-- .../shared/legacy-config-migrations.audio.ts | 25 +- ...legacy-config-migrations.runtime.agents.ts | 161 ++-- ...egacy-config-migrations.runtime.entries.ts | 64 ++ .../legacy-config-migrations.runtime.mcp.ts | 46 ++ ...acy-config-migrations.runtime.providers.ts | 33 - ...config-migrations.runtime.retired-media.ts | 408 ++++++++++ ...-config-migrations.runtime.retired.test.ts | 475 +++++++++++- ...egacy-config-migrations.runtime.retired.ts | 727 +++++++++++------- ...ig-migrations.runtime.system-agent.test.ts | 18 +- ...-config-migrations.runtime.system-agent.ts | 28 +- ...acy-config-migrations.runtime.tier-eval.ts | 593 ++++++++++++++ .../legacy-config-migrations.runtime.ts | 2 + .../legacy-config-migrations.runtime.tts.ts | 66 +- .../missing-configured-plugin-install.test.ts | 32 +- .../doctor/shared/preview-warnings.test.ts | 18 +- .../shared/pristine-startup-state.test.ts | 6 +- ...release-configured-plugin-installs.test.ts | 20 +- .../shared/startup-plugin-convergence-plan.ts | 12 +- src/commands/onboard-config.ts | 5 +- .../onboarding-plugin-install.test.ts | 3 - src/commands/status.scan.shared.test.ts | 51 +- src/commands/status.scan.shared.ts | 12 +- src/commands/status.scan.test-helpers.ts | 12 +- src/commands/tasks.ts | 2 +- ...commitments-full-chain.integration.test.ts | 17 +- .../commitments-heartbeat-policy.e2e.test.ts | 17 +- src/commitments/config.ts | 13 +- src/commitments/extraction.test.ts | 24 +- src/commitments/runtime.test-support.ts | 52 +- src/commitments/runtime.test.ts | 42 +- src/commitments/runtime.ts | 18 +- src/commitments/store.test.ts | 41 +- src/config/agent-dirs.ts | 2 +- ...ndled-channel-config-metadata.generated.ts | 36 +- src/config/channel-configured.test.ts | 2 +- src/config/config-misc.test.ts | 130 +--- src/config/config.acp-binding-cutover.test.ts | 9 +- src/config/config.compaction-settings.test.ts | 10 +- src/config/config.hooks-module-paths.test.ts | 12 +- src/config/config.identity-avatar.test.ts | 8 +- ...etection.rejects-routing-allowfrom.test.ts | 9 +- .../config.meta-timestamp-coercion.test.ts | 43 +- ...ig.multi-agent-agentdir-validation.test.ts | 19 +- src/config/config.node-skills.test.ts | 10 +- src/config/config.plugin-validation.test.ts | 40 +- src/config/config.sandbox-docker.test.ts | 9 +- src/config/config.schema-regressions.test.ts | 139 ++-- src/config/config.secrets-schema.test.ts | 4 +- .../config.skills-entries-config.test.ts | 6 +- src/config/config.tools-alsoAllow.test.ts | 25 +- src/config/control-ui-css.ts | 2 +- src/config/dead-config-keys.test.ts | 171 +++- src/config/defaults.test.ts | 2 +- src/config/defaults.ts | 15 +- src/config/future-version-guard.ts | 20 +- src/config/gateway-env-selection.ts | 1 - .../heartbeat-config-honor.inventory.test.ts | 4 - src/config/io.meta.test.ts | 10 +- src/config/io.meta.ts | 14 +- src/config/io.observe-recovery.test.ts | 8 +- src/config/io.write-config.test.ts | 186 +---- src/config/io.write-prepare.test.ts | 24 +- src/config/io.write-prepare.ts | 47 +- src/config/io.write.ts | 17 +- src/config/mcp-config-normalize.ts | 23 +- src/config/media-audio-field-metadata.ts | 86 +-- src/config/merge-patch.test.ts | 12 +- .../model-policy-allowlist-migration.ts | 11 +- src/config/paths.ts | 7 +- src/config/plugin-auto-enable.core.test.ts | 27 +- src/config/redact-snapshot.schema.test.ts | 40 +- src/config/redact-snapshot.test.ts | 3 +- src/config/runtime-overrides.test.ts | 6 +- src/config/schema.base.generated.test.ts | 18 +- src/config/schema.help.agents.ts | 62 +- src/config/schema.help.automation.ts | 53 +- src/config/schema.help.core.ts | 163 ++-- src/config/schema.help.models.ts | 148 +--- .../schema.help.quality.test-fixtures.ts | 166 ++-- src/config/schema.help.quality.test.ts | 35 +- src/config/schema.help.runtime.ts | 126 ++- src/config/schema.hints.test.ts | 4 +- src/config/schema.labels.ts | 358 ++++----- src/config/schema.node-capabilities.ts | 8 +- src/config/schema.tags.ts | 6 +- src/config/schema.test.ts | 110 ++- src/config/sessions/reset-policy.test.ts | 14 - src/config/sessions/reset-policy.ts | 13 +- .../sessions/transcript-append-redact.test.ts | 39 +- src/config/types.agent-defaults.ts | 97 +-- src/config/types.agents.ts | 23 +- src/config/types.base.ts | 20 +- src/config/types.browser.ts | 17 +- src/config/types.channel-messaging-common.ts | 2 + src/config/types.channels.ts | 4 +- src/config/types.cli.ts | 14 - src/config/types.cloud-workers.ts | 9 - src/config/types.commitments.ts | 7 - src/config/types.cron.ts | 5 +- src/config/types.discord.ts | 42 +- src/config/types.gateway.ts | 34 +- src/config/types.googlechat.ts | 2 - src/config/types.hooks.ts | 2 - src/config/types.imessage.ts | 1 - src/config/types.marketplaces.ts | 34 - src/config/types.mcp.ts | 10 - src/config/types.memory.ts | 125 ++- src/config/types.messages.ts | 36 +- src/config/types.openclaw.ts | 53 +- src/config/types.plugins.ts | 2 - src/config/types.sandbox.ts | 2 + src/config/types.secrets.ts | 3 - src/config/types.signal.ts | 4 - src/config/types.slack.ts | 20 +- src/config/types.system-agent.ts | 21 - src/config/types.telegram.ts | 2 +- src/config/types.tools.ts | 210 +---- src/config/types.ts | 3 - src/config/types.tts.ts | 12 - src/config/types.whatsapp.ts | 2 +- src/config/validation.allowed-values.test.ts | 4 +- .../validation.channel-metadata.test.ts | 14 - src/config/validation.ts | 29 +- src/config/zod-schema.agent-defaults.test.ts | 55 +- src/config/zod-schema.agent-defaults.ts | 45 +- src/config/zod-schema.agent-runtime.ts | 78 +- src/config/zod-schema.agents.ts | 9 +- .../zod-schema.channel-messaging-common.ts | 2 +- src/config/zod-schema.channels-config.ts | 2 +- src/config/zod-schema.cloud-workers.test.ts | 10 +- src/config/zod-schema.cloud-workers.ts | 14 +- src/config/zod-schema.core.ts | 48 +- src/config/zod-schema.gateway.ts | 26 +- src/config/zod-schema.hooks.ts | 9 - src/config/zod-schema.installs.ts | 2 +- src/config/zod-schema.marketplaces.test.ts | 247 ------ src/config/zod-schema.providers-core.ts | 36 +- src/config/zod-schema.providers-googlechat.ts | 1 - src/config/zod-schema.providers-whatsapp.ts | 11 - src/config/zod-schema.proxy.test.ts | 7 +- src/config/zod-schema.root-shape.ts | 107 +-- src/config/zod-schema.root-support.ts | 179 +---- src/config/zod-schema.session.ts | 29 - src/config/zod-schema.ts | 8 +- src/config/zod-schema.tts.test.ts | 36 - src/config/zod-schema.typing-mode.test.ts | 18 +- .../delivery-dispatch.double-announce.test.ts | 8 +- src/cron/isolated-agent/delivery-dispatch.ts | 7 +- src/cron/isolated-agent/helpers.ts | 5 +- src/cron/isolated-agent/run-config.ts | 4 +- ...run.memory-search-config-preserved.test.ts | 31 +- src/cron/isolated-agent/run.ts | 3 +- src/cron/isolated-agent/subagent-followup.ts | 3 +- src/cron/service/startup-run-repair.ts | 2 +- src/cron/service/timer.ts | 4 +- src/cron/store.ts | 6 +- src/cron/store/config-state.ts | 7 + src/flows/doctor-core-checks.runtime.ts | 6 +- src/flows/doctor-core-checks.test.ts | 9 +- src/flows/doctor-health-contributions.test.ts | 9 +- src/flows/doctor-health-contributions.ts | 12 +- .../android-node.capabilities.live.test.ts | 4 +- src/gateway/call.ts | 3 +- src/gateway/client.test.ts | 2 - src/gateway/client.watchdog.test.ts | 1 - src/gateway/config-reload-settings.ts | 5 +- src/gateway/config-reload.test.ts | 72 +- src/gateway/config-reload.ts | 16 - src/gateway/control-ui.http.test.ts | 6 +- src/gateway/control-ui.ts | 4 +- src/gateway/embeddings-http.test.ts | 69 +- src/gateway/gateway-misc.test.ts | 4 +- src/gateway/local-request-context.ts | 2 +- src/gateway/model-pricing-cache.ts | 27 +- src/gateway/node-command-policy.test.ts | 31 +- src/gateway/node-command-policy.ts | 12 +- src/gateway/node-connect-reconcile.test.ts | 2 +- src/gateway/node-connect-reconcile.ts | 2 +- src/gateway/node-invoke-plugin-policy.test.ts | 8 +- src/gateway/server-chat.agent-events.test.ts | 9 +- src/gateway/server-chat.ts | 10 +- src/gateway/server-cron-lazy.ts | 2 +- src/gateway/server-cron.test.ts | 98 ++- src/gateway/server-cron.ts | 13 +- src/gateway/server-discovery-runtime.test.ts | 2 +- src/gateway/server-discovery-runtime.ts | 4 +- src/gateway/server-methods/agent-timestamp.ts | 2 +- .../chat.inject.parentid.test.ts | 2 +- .../server-methods/config-write-flow.ts | 5 +- .../server-methods/config.shared-auth.test.ts | 33 +- src/gateway/server-methods/doctor.test.ts | 21 +- .../server-methods/exec-approvals.test.ts | 2 +- src/gateway/server-methods/fs.test.ts | 2 +- .../node-command-rejection-hint.ts | 6 +- .../server-methods/nodes.invoke-wake.test.ts | 34 +- src/gateway/server-methods/plugins.test.ts | 2 +- src/gateway/server-methods/plugins.ts | 2 +- .../server-methods/server-methods.test.ts | 26 +- src/gateway/server-methods/talk.test.ts | 48 +- src/gateway/server-methods/talk.ts | 11 +- src/gateway/server-methods/tts.test.ts | 2 +- src/gateway/server-methods/tts.ts | 2 +- src/gateway/server-plugins.test.ts | 2 +- src/gateway/server-reload-handlers.test.ts | 8 +- .../server-startup-config.secrets.test.ts | 46 +- src/gateway/server-startup-early.ts | 2 +- src/gateway/server-startup-log.test.ts | 10 +- src/gateway/server-startup-memory.test.ts | 271 +------ src/gateway/server-startup-memory.ts | 4 +- src/gateway/server-startup-outcomes.test.ts | 2 +- src/gateway/server-startup-plugins.test.ts | 82 +- src/gateway/server-startup-plugins.ts | 4 +- .../server-startup-post-attach.test.ts | 70 -- src/gateway/server-startup-post-attach.ts | 17 +- ...ver-startup-secret-owner-isolation.test.ts | 100 ++- src/gateway/server.auth.control-ui.suite.ts | 211 ----- src/gateway/server.auth.test-helpers.ts | 18 - .../server.chat.gateway-server-chat-b.test.ts | 26 +- src/gateway/server.config-patch.test.ts | 103 +-- src/gateway/server.cron.test.ts | 18 +- src/gateway/server.hooks.test.ts | 4 +- src/gateway/server.impl.ts | 6 +- ...server.node-invoke-approval-bypass.test.ts | 16 +- .../server.roles-allowlist-update.test.ts | 6 +- src/gateway/server.talk-config.test.ts | 28 +- src/gateway/server.talk-runtime.test.ts | 10 +- .../server/preauth-connection-budget.ts | 3 +- .../server/ws-connection/connect-auth.ts | 8 +- .../ws-connection/connect-policy.test.ts | 22 +- .../server/ws-connection/connect-policy.ts | 48 +- ...essage-handler.post-connect-health.test.ts | 25 +- src/gateway/test-helpers.config-runtime.ts | 3 +- src/gateway/test-temp-config.ts | 36 +- .../worker-environments/service.test.ts | 2 - src/gateway/worker-environments/service.ts | 1 - .../transcript-commit.test.ts | 4 +- .../bundled/session-memory/handler.test.ts | 128 --- src/hooks/bundled/session-memory/handler.ts | 7 +- src/hooks/configured.ts | 21 +- src/hooks/installs.test.ts | 25 + src/hooks/installs.ts | 61 +- src/hooks/loader.test.ts | 5 - src/hooks/update.test.ts | 44 +- src/hooks/update.ts | 4 +- .../live-test-helpers.test.ts | 8 +- src/image-generation/live-test-helpers.ts | 2 +- src/image-generation/runtime.test.ts | 22 +- src/image-generation/runtime.ts | 6 +- src/infra/backup-create.test.ts | 6 +- src/infra/diagnostic-llm-content.test.ts | 26 +- src/infra/diagnostic-llm-content.ts | 12 +- src/infra/ed25519-signature.test.ts | 8 +- src/infra/ed25519-signature.ts | 2 +- src/infra/env.test.ts | 29 +- src/infra/env.ts | 12 +- ...nner.clears-pending-final-delivery.test.ts | 8 +- .../heartbeat-runner.commitments.test.ts | 24 +- src/infra/heartbeat-runner.identity.test.ts | 6 +- .../heartbeat-runner.model-override.test.ts | 8 +- ...espects-ackmaxchars-heartbeat-acks.test.ts | 24 +- ...at-runner.response-prefix-template.test.ts | 2 +- ...tbeat-runner.returns-default-unset.test.ts | 13 +- ...eat-runner.skips-busy-session-lane.test.ts | 21 +- src/infra/heartbeat-runner.ts | 139 +--- src/infra/heartbeat-runner.typing.test.ts | 41 +- src/infra/heartbeat-summary.ts | 10 +- src/infra/heartbeat-visibility.test.ts | 30 +- src/infra/heartbeat-visibility.ts | 12 +- src/infra/net/proxy/proxy-lifecycle.test.ts | 49 +- src/infra/net/proxy/proxy-lifecycle.ts | 7 +- src/infra/net/proxy/proxy-validation.test.ts | 113 +-- src/infra/net/proxy/proxy-validation.ts | 40 +- .../message-action-runner.core-send.test.ts | 29 +- ...sage-action-runner.plugin-dispatch.test.ts | 19 +- src/infra/push-apns.test.ts | 4 +- ...te-migrations.config-machine-state.test.ts | 109 +++ .../state-migrations.config-machine-state.ts | 65 ++ src/infra/state-migrations.doctor.ts | 13 + src/infra/tailscale.ts | 10 +- src/infra/test-runtime-env.ts | 19 + .../tmp-openclaw-dir.browser-import.test.ts | 4 +- src/infra/tmp-openclaw-dir.test.ts | 54 +- src/infra/tmp-openclaw-dir.ts | 4 +- src/infra/update-runner-git-commands.ts | 1 - src/infra/update-runner.test.ts | 1 - src/logging/console.ts | 2 +- src/logging/log-file-path.ts | 4 +- src/logging/logger-redaction-behavior.test.ts | 10 +- src/logging/logger.ts | 14 +- src/logging/redact.test.ts | 4 +- src/logging/redact.ts | 2 +- src/media-generation/runtime-shared.test.ts | 9 +- src/media-generation/runtime-shared.ts | 5 +- .../apply.echo-transcript.test.ts | 2 +- src/media-understanding/apply.test.ts | 126 +-- src/media-understanding/resolve.test.ts | 61 +- src/media-understanding/resolve.ts | 47 +- .../runner.auto-audio.test.ts | 23 +- .../runner.deepgram.test.ts | 47 +- .../runner.entries.guards.test.ts | 12 +- src/media-understanding/runner.entries.ts | 6 +- .../runner.local-no-auth.test.ts | 21 +- src/media-understanding/runner.proxy.test.ts | 4 +- src/media-understanding/runner.ts | 18 +- src/media-understanding/runner.video.test.ts | 32 +- .../runner.vision-skip.test.ts | 65 +- src/media-understanding/runtime.ts | 12 +- src/music-generation/live-test-helpers.ts | 2 +- src/music-generation/runtime.test.ts | 20 +- src/music-generation/runtime.ts | 6 +- .../linux-node-plugin.integration.test.ts | 2 +- src/plugin-sdk/channel-reply-pipeline.test.ts | 2 +- src/plugin-sdk/command-auth.ts | 2 +- src/plugin-sdk/config-runtime.ts | 1 - src/plugin-sdk/exec-approvals-runtime.ts | 1 + .../agents/prompt-overlay-runtime-contract.ts | 12 +- .../test-helpers/sandbox-fixtures.ts | 2 +- src/plugins/bundled-channel-runtime.ts | 3 +- src/plugins/bundled-compat.test.ts | 15 +- src/plugins/bundled-compat.ts | 3 +- src/plugins/bundled-dir.test.ts | 13 +- src/plugins/bundled-dir.ts | 3 +- src/plugins/bundled-discovery-state.ts | 10 + src/plugins/bundled-plugin-metadata.test.ts | 7 +- .../capability-provider-runtime.test.ts | 17 +- src/plugins/capability-provider-runtime.ts | 7 +- src/plugins/channel-plugin-ids.test.ts | 218 +++--- src/plugins/contracts/tts-contract-suites.ts | 130 ++-- src/plugins/doctor-contract-registry.test.ts | 13 +- src/plugins/doctor-contract-registry.ts | 2 + src/plugins/effective-plugin-ids.ts | 3 +- src/plugins/gateway-startup-plugin-ids.ts | 39 +- src/plugins/install.npm-spec.e2e.test.ts | 1 - src/plugins/install.test.ts | 14 +- ...plugin-index-generation-precedence.test.ts | 6 +- src/plugins/management-service.ts | 48 +- .../official-external-plugin-catalog.test.ts | 10 +- .../official-external-plugin-catalog.ts | 17 +- .../openai-compatible-embedding-provider.ts | 2 +- src/plugins/plugin-registration.types.ts | 2 +- src/plugins/provider-runtime.test.ts | 13 +- ...provider-public-artifacts.fallback.test.ts | 8 +- src/plugins/web-provider-public-artifacts.ts | 3 +- src/process/command-queue.test.ts | 36 - src/process/command-queue.ts | 6 - src/secrets/apply.test.ts | 60 +- src/secrets/command-config.test.ts | 34 +- src/secrets/configure.ts | 26 - src/secrets/exec-secret-ref-id-parity.test.ts | 6 + src/secrets/provider-integrations.test.ts | 2 - src/secrets/provider-integrations.ts | 9 +- src/secrets/resolve.test.ts | 65 +- src/secrets/resolve.ts | 5 +- .../runtime-channel-inactive-variants.test.ts | 4 +- src/secrets/runtime-config-collectors-core.ts | 69 +- .../runtime-config-collectors-memory.ts | 56 +- .../runtime-config-collectors-sandbox.ts | 20 +- .../runtime-config-collectors-tts.test.ts | 4 +- src/secrets/runtime-core-snapshots.test.ts | 16 +- .../runtime-degradation-attribution.test.ts | 34 +- .../runtime-external-channel-audit.test.ts | 16 +- src/secrets/runtime-fast-path.ts | 1 - .../runtime-inactive-core-surfaces.test.ts | 21 +- ...untime-provider-and-media-surfaces.test.ts | 100 +-- .../runtime-provider-failure-fanout.test.ts | 6 +- .../runtime-request-secret-refs.test.ts | 39 +- src/secrets/runtime.coverage.test.ts | 8 +- src/secrets/runtime.test.ts | 99 +-- src/secrets/target-registry-data.ts | 28 +- src/secrets/target-registry-pattern.test.ts | 37 +- src/secrets/target-registry.fast-path.test.ts | 5 +- src/secrets/target-registry.test.ts | 3 +- .../unsupported-surface-policy.test.ts | 3 - src/secrets/unsupported-surface-policy.ts | 2 - src/security/audit-config-basics.test.ts | 34 +- src/security/audit-exec-surface.test.ts | 8 +- src/security/audit-extra.sync.ts | 22 +- src/security/audit-gateway-config.ts | 22 - src/security/audit-gateway-exposure.test.ts | 35 - src/security/audit-loopback-logging.test.ts | 6 +- .../audit-node-command-findings.test.ts | 44 +- src/security/audit-plugins-trust.test.ts | 100 +-- src/security/audit-plugins-trust.ts | 5 +- src/security/audit.ts | 49 +- src/security/core-dangerous-config-flags.ts | 6 - src/security/fix.test.ts | 2 - src/security/fix.ts | 5 - src/security/install-policy.test.ts | 21 +- src/security/install-policy.ts | 12 +- src/skills/lifecycle/source-install.test.ts | 8 +- src/state/config-machine-state.ts | 167 ++++ src/state/openclaw-state-db.generated.d.ts | 7 + src/state/openclaw-state-schema.generated.ts | 6 + src/state/openclaw-state-schema.sql | 6 + src/system-agent/agent-turn.test.ts | 14 +- src/system-agent/inference-route.ts | 47 +- src/system-agent/operations.setup.test.ts | 15 +- src/system-agent/overview.ts | 3 +- src/system-agent/rescue-channel.live.test.ts | 5 +- src/system-agent/rescue-message.test.ts | 54 +- src/system-agent/rescue-policy.test.ts | 12 +- src/system-agent/rescue-policy.ts | 53 +- src/system-agent/setup-apply.test.ts | 37 +- src/system-agent/setup-apply.ts | 21 +- src/system-agent/setup-config-snapshot.ts | 3 +- src/system-agent/setup-inference.test.ts | 181 +++-- src/system-agent/setup-inference.ts | 33 +- src/tts/openai-compatible-speech-provider.ts | 2 +- src/tts/status-config.test.ts | 156 ++-- src/tts/tts-config.test.ts | 64 +- src/tts/tts-config.ts | 16 +- src/tts/tts.test.ts | 2 +- src/tts/tts.ts | 8 +- src/tui/gateway-chat.test.ts | 9 +- src/tui/gateway-chat.ts | 12 +- src/tui/tui-pty-local.e2e.test.ts | 27 +- src/tui/tui.ts | 9 +- src/utils/queue-helpers.ts | 3 +- .../live-test-helpers.test.ts | 8 +- src/video-generation/live-test-helpers.ts | 2 +- src/video-generation/runtime.test.ts | 22 +- src/video-generation/runtime.ts | 6 +- src/wizard/setup.gateway-config.test.ts | 61 +- src/wizard/setup.gateway-config.ts | 26 +- src/wizard/setup.migration-recovery.test.ts | 7 - taxonomy.yaml | 4 +- .../system-agent-first-run-docker-client.ts | 2 +- test/helpers/config/config-honor-audit.ts | 13 +- .../heartbeat-config-honor.inventory.ts | 76 +- .../config/redact-snapshot-test-hints.ts | 4 +- .../infra/heartbeat-runner-channel-plugins.ts | 2 +- test/scripts/changed-lanes.test.ts | 16 + test/scripts/check-env-var-count.test.ts | 132 ++++ test/scripts/check-memory-fd-repro.test.ts | 8 +- test/scripts/generate-docs-map.test.ts | 9 + test/scripts/lint-suppressions.test.ts | 1 + 1097 files changed, 13741 insertions(+), 14588 deletions(-) create mode 100644 config/env-var-count-budget.txt create mode 100644 extensions/policy/src/policy-state-data.test.ts create mode 100644 packages/memory-host-sdk/src/host/config-utils.test.ts create mode 100644 scripts/check-env-var-count.d.mts create mode 100644 scripts/check-env-var-count.mjs create mode 100644 src/agents/tools/media-generation-config.test-support.ts create mode 100644 src/commands/doctor/shared/codex-route-agent-entries.ts create mode 100644 src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts create mode 100644 src/commands/doctor/shared/legacy-config-migrations.runtime.entries.ts create mode 100644 src/commands/doctor/shared/legacy-config-migrations.runtime.retired-media.ts create mode 100644 src/commands/doctor/shared/legacy-config-migrations.runtime.tier-eval.ts delete mode 100644 src/config/types.cli.ts delete mode 100644 src/config/types.commitments.ts delete mode 100644 src/config/types.marketplaces.ts delete mode 100644 src/config/types.system-agent.ts delete mode 100644 src/config/zod-schema.marketplaces.test.ts create mode 100644 src/cron/store/config-state.ts create mode 100644 src/hooks/installs.test.ts create mode 100644 src/infra/state-migrations.config-machine-state.test.ts create mode 100644 src/infra/state-migrations.config-machine-state.ts create mode 100644 src/infra/test-runtime-env.ts create mode 100644 src/plugins/bundled-discovery-state.ts create mode 100644 src/state/config-machine-state.ts create mode 100644 test/scripts/check-env-var-count.test.ts diff --git a/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs b/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs index fa1f6a51b39c..72026867e460 100644 --- a/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs +++ b/.agents/skills/openclaw-secret-scanning-maintainer/scripts/secret-scanning.mjs @@ -9,7 +9,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { spawnPlainGh } from "../../../../scripts/lib/plain-gh.mjs"; +import { execPlainGh } from "../../../../scripts/lib/plain-gh.mjs"; const REPO = "openclaw/openclaw"; const REPO_URL = `https://github.com/${REPO}`; @@ -29,25 +29,30 @@ function tmpFile(purpose) { } function gh(args, { json = true, allowFailure = false } = {}) { - const proc = spawnPlainGh(args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }); - if (proc.status !== 0 && !allowFailure) { - fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`); - } - if (proc.status !== 0) { - return { + let stdout; + try { + stdout = execPlainGh(args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }); + } catch (error) { + const failure = { gh_failed: true, - status: proc.status, - stdout: proc.stdout, - stderr: proc.stderr, + status: error?.status ?? 1, + stdout: String(error?.stdout ?? ""), + stderr: String(error?.stderr ?? ""), }; + if (!allowFailure) { + fail( + `gh ${args.slice(0, 3).join(" ")} failed:\n${(failure.stderr || failure.stdout).trim()}`, + ); + } + return failure; } if (!json) { - return proc.stdout; + return stdout; } try { - return JSON.parse(proc.stdout); + return JSON.parse(stdout); } catch { - return proc.stdout; + return stdout; } } diff --git a/config/env-var-count-budget.txt b/config/env-var-count-budget.txt new file mode 100644 index 000000000000..5e27e882739b --- /dev/null +++ b/config/env-var-count-budget.txt @@ -0,0 +1,3 @@ +# Distinct OPENCLAW_* names in production source under src, packages, and extensions. +# Ratchet: lower this number when cleanup removes names; never raise it. +530 diff --git a/config/knip.config.ts b/config/knip.config.ts index 3c1964c83302..75a45b5fa97f 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -357,6 +357,9 @@ const config = { "src/boards/board-layout.ts": ["types"], "src/boards/board-notices.ts": ["exports"], "src/boards/board-store.ts": ["exports"], + // Test and E2E callers reach these hooks through runtime.test-support.ts; + // the full-tree companion config still audits their actual consumers. + "src/commitments/runtime.ts": ["exports"], "src/gateway/board-view-ticket.ts": ["exports"], // GatewayBoardProvider and boardExists are constructed/asserted by the // focused Control UI provider tests, not by a separate production module. @@ -364,6 +367,10 @@ const config = { // Greeting cache/fact contracts (hash, alert text, store shapes) are // asserted by the focused greeting unit tests, not by another prod module. "src/system-agent/greeting.ts": ["exports", "types"], + // Focused tests consume these diagnostic/test seams; production code uses + // the surrounding runtime helpers rather than importing the exports. + "extensions/signal/src/setup-core.ts": ["exports"], + "src/infra/heartbeat-wake.ts": ["exports"], }, workspaces: { ".": { diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 04bcf195f0b6..6edc071140f6 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2851, - "channel": 3680, - "plugin": 3537 + "core": 2351, + "channel": 3627, + "plugin": 3556 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 10c54aab6747..4c9d078d1966 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -a8e315e49d62b95c54c752041cc27628260686f0a99de646c24505edc977a3c3 config-baseline.json -d3da432d372bc652d432be978495a9f28b3478da1034cbe888550c8bf6cb9dd4 config-baseline.core.json -dbf05e8852c873d48288336dbbb05d7a7b3a1bcbee8cb8ece5e40f204d2ab9db config-baseline.channel.json -0e0592b95e4958477e539abd5ccb336c63735c7d69133199953c48ff3c0845a8 config-baseline.plugin.json +efd6e8ac56ba46833230deef3552065b6e5588d4ac28efe856332e0e56eb2668 config-baseline.json +dbf9485e8f4bb6ac112ef1a9cd71941abd00cd9593c8094dfa1c16517456017e config-baseline.core.json +0ab331946d642d2e1ebf69033f0a79ffad53472ecccf4b7cc96211bea79f3788 config-baseline.channel.json +fab3420186223207228066225cf4577702fdcbaf40ba0c35f8315d24019acbc4 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 2b5adb213257..8833bad4ecdb 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -36,7 +36,7 @@ c97dd36cdf8f83c2893c33e9430a93cd131a03d855725783ca5b545de0cf84f8 module/channel e2b4d1923a19b927e912622576d43d20a6e188c124a7bc588fe567cd3c1c924e module/channel-outbound 50c61de5d522abadcae79841335aeac25e3791e6dbb0cfc3d64b2720a8c514ce module/channel-pairing ee4292b069d4d48cce4fc2dc26df5b5c87eb1fa4769f1f6be9a10c3e1221e1a9 module/channel-plugin-common -bb82ca1819308189c4ba7b1e83e0c39fb0fb8b5a7081ee874480d64426778454 module/channel-policy +01b95b0b8aca5005594969f0df221111a76be454b70102e4b452051c87b6c179 module/channel-policy cc0a77137b304b27a313791aa30e43efb4acc254da7590bd47493d81e7104fba module/channel-reply-pipeline bba5540be7cf9613a163663decdb2affe2af9bbd3ad7914989ab186f9c2abec1 module/channel-runtime-context 17cec26bc71fc43a066049ef63f95bf29737113c26ab13689ceff602b9aa11d6 module/channel-secret-basic-runtime @@ -51,16 +51,16 @@ c89ec1b194b76f67a6f4dd108dccf460da6065646cba31374c8aa748f23a39e4 module/collect fa2df02bede6ed8843e5c2bd605c6ea5cd20313c305593fbd371dba6f1b931c3 module/command-detection 28a0cae8dee664aef14b1a64c86a46d0fb5637cf9f671790d4fc9ba205e42820 module/command-primitives-runtime eb4c757fe0086c1dbfa4c3f3caf3dcff0d3cab3924c608237f08f740a6ee5f59 module/command-status -235a9e4d983042c3db156efcab0e333984cbbf13ebdfcc44a3a5ab40cf3edb4f module/config-contracts +0ef109bca0630cab8578b6d1ad1eb0f92e76dc3995f4a180e34f03212d42f1e0 module/config-contracts 20f3f8042de53e4eee61b64de9102c8c202b9299e6a29235647a4729f70145f2 module/config-mutation -6f23fd2d777f7189c38b34a51c6899abdad4ee4e6d74458eb3183c8ce03b8677 module/config-runtime +189fa5a240cad0404cd281ad0a14a105a8f3231278d87b71cfbc4f96fb8e48ef module/config-runtime c1ea9510dfda047609a99d5d2cd1f1560f5d469a36e6b695766213d695c25b0f module/conversation-runtime d9267aacc65aeebf0046d4eef691e5a510764063e6c7942b1d6bc7a282ca67a1 module/core 4af19d59c2f18674e7d7f7dc1b358b644dc707e6bd601dc47168bd9e4a669940 module/dedupe-runtime f70c93d28053ca2e8353e45e6515ce7acef188097c6117d1545965d0699c8004 module/device-bootstrap 6215d3af5923bf5a616d73062534968b69f448e3e30adc64ae9caebdd1a46d71 module/diagnostic-runtime ea81ef06956c1bc0853fa00afbbc2b5a4019116aaf8a436e1b27d06f7a2c9e88 module/directory-runtime -c7c9119cd43e2ed2eed4fe8bd57a6b75d2ee9a1f7926c432634c49b54ff7f9a6 module/discord +2cf82472e3e7645fabc581b4531c91732374f6844969e763683401728f284c88 module/discord 46c05a90b66032d1d7ad08445840a4bf81aa2bd325348f87710ad4538daf38f6 module/error-runtime b013053a61e7d9be3d0c683c02baf57fa7e4393ec54e0df6a46ab0f2fe2348fd module/extension-shared ceacad83db01c66e7be6aa21a291597020f13f737b697690eae7d47098e6499a module/gateway-method-runtime @@ -70,7 +70,7 @@ ceacad83db01c66e7be6aa21a291597020f13f737b697690eae7d47098e6499a module/gateway 182dc685f2103ff66c1a4839a48f4f40d2eeb0070cd74e449b47aacc4e6f1c22 module/hook-runtime f6e3c44e7d1090a97aca554a3c247219b8de78b3cb4399cac5efde8a0a6c1156 module/inbound-envelope 67142e8e63ea860a03b0cbb5660f63405a83ec575f2ef7a96f9b87f1be362808 module/inbound-reply-dispatch -841de925321fbc69c8dcc1beed2468dfcd5f28cc38a8aad6fcde20d85158a985 module/infra-runtime +e6abca6089332c08bdbb049a41d258bd0aedb0dacb5757b5833d67af67aa3117 module/infra-runtime 2e717cccb3db127aed0287d4ea14c41a8e64e46d60c728638153e31e2fb0d296 module/ingress-effect-once 74fee62a94618d830a5282a7816a73b85570b2e4ce17b90078078734510511d7 module/interactive-runtime 9dd66baf2def46386ad4706380e57f9068fa9bb3878be2d9ba961ec2f46d3d87 module/json-store @@ -83,7 +83,7 @@ a5f59c9acbcaa3f82247bf806eb5ba08032373fb853719f0ec9457690f16fc70 module/media-m c5e3eb1a584f4b8126d9d6c177a840ec9103671e8d1242634ee67db9b5b9e573 module/media-understanding c0ffaed532578cf33493992e1ff806b2268b8e3774a92edbaede5cf5bda162a6 module/media-understanding-runtime 075b7a2783cf22c3210cfce630b815c2e3b7e85c1f5606f0dde4730d47242365 module/meeting-runtime -4205ab767b79e740c7c7137079b19b315c85f7f8c748592840f00bbf70a71709 module/memory-core-host-engine-foundation +d09ddb38c9d20a41fcf4cedaf6a2c28eb5333a9fba2287e34b458fa5caba751c module/memory-core-host-engine-foundation 646773d8282a2ac6a89101685c406200935ddb3459a2830e820da132fa433b3c module/memory-host-core 87b7a3206346c0d4b294fb3a2395cbaabc3e73ff8b1b9ea925bc3aade3e52687 module/messaging-targets 09f842a2787b87117d88ba6545aa0441ff8afdb85e2e66ae4d1838ebecb16148 module/model-session-runtime diff --git a/docs/automation/index.md b/docs/automation/index.md index 1899e1490b0b..a5367e2725d5 100644 --- a/docs/automation/index.md +++ b/docs/automation/index.md @@ -3,14 +3,13 @@ doc-schema-version: 1 summary: "Overview of automation mechanisms: tasks, cron, hooks, standing orders, and Task Flow" read_when: - Deciding how to automate work with OpenClaw - - Choosing between heartbeat, cron, commitments, hooks, and standing orders + - Choosing between heartbeat, cron, hooks, and standing orders - Looking for the right automation entry point title: "Automation" --- -OpenClaw runs work in the background through tasks, scheduled jobs, inferred -commitments, event hooks, and standing instructions. Use this page to pick the -right mechanism. +OpenClaw runs work in the background through tasks, scheduled jobs, event hooks, +and standing instructions. Use this page to pick the right mechanism. ## Quick decision guide @@ -21,7 +20,6 @@ flowchart TD START --> Q3{Orchestrate multi-step flows?} START --> Q4{React to lifecycle events?} START --> Q5{Give the agent persistent instructions?} - START --> Q6{Remember a natural follow-up?} Q1 -->|Yes| Q1a{Exact timing or flexible?} Q1a -->|Exact| CRON["Scheduled Tasks (Cron)"] @@ -31,7 +29,6 @@ flowchart TD Q3 -->|Yes| FLOW[Task Flow] Q4 -->|Yes| HOOKS[Hooks] Q5 -->|Yes| SO[Standing Orders] - Q6 -->|Yes| COMMITMENTS[Inferred Commitments] ``` | Use case | Recommended | Why | @@ -41,8 +38,6 @@ flowchart TD | Run weekly deep analysis | Scheduled Tasks (Cron) | Standalone task, can use different model | | Check inbox every 30 min | Heartbeat | Batches with other checks, context-aware | | Monitor calendar for upcoming events | Heartbeat | Natural fit for periodic awareness | -| Check in after a mentioned interview | Inferred Commitments | Memory-like follow-up, no exact reminder request | -| Gentle care check-in after user context | Inferred Commitments | Scoped to the same agent and channel | | Inspect status of a subagent or ACP run | Background Tasks | Tasks ledger tracks all detached work | | Audit what ran and when | Background Tasks | `openclaw tasks list` and `openclaw tasks audit` | | Multi-step research then summarize | Task Flow | Durable orchestration with revision tracking | @@ -76,15 +71,6 @@ The background task ledger tracks all detached work: ACP runs, subagent spawns, See [Background Tasks](/automation/tasks). -### Inferred commitments - -Commitments are opt-in, short-lived follow-up memories. OpenClaw infers them -from normal conversations, scopes them to the same agent and channel, and -delivers due check-ins through heartbeat. Exact user-requested reminders still -belong to cron. - -See [Inferred Commitments](/concepts/commitments). - ### Task Flow Task Flow is the flow orchestration substrate above background tasks. It manages durable multi-step flows with managed and mirrored sync modes, revision tracking, and `openclaw tasks flow list|show|cancel` for inspection. @@ -125,7 +111,6 @@ See [Heartbeat](/gateway/heartbeat). ## Related - [Scheduled Tasks](/automation/cron-jobs) — precise scheduling and one-shot reminders -- [Inferred Commitments](/concepts/commitments) — memory-like follow-up check-ins - [Background Tasks](/automation/tasks) — task ledger for all detached work - [Task Flow](/automation/taskflow) — durable multi-step flow orchestration - [Hooks](/automation/hooks) — event-driven lifecycle scripts diff --git a/docs/channels/ambient-room-events.md b/docs/channels/ambient-room-events.md index fa5abba0bc5b..57df25079b7a 100644 --- a/docs/channels/ambient-room-events.md +++ b/docs/channels/ambient-room-events.md @@ -173,7 +173,7 @@ Use an agent override when several agents share the same room but only one shoul } ``` -The agent-specific `agents.list[].groupChat.unmentionedInbound` value overrides `messages.groupChat.unmentionedInbound` for that agent. +The agent-specific `agents.entries.*.groupChat.unmentionedInbound` value overrides `messages.groupChat.unmentionedInbound` for that agent. ## Visible reply modes diff --git a/docs/channels/broadcast-groups.md b/docs/channels/broadcast-groups.md index 1624ab5a9361..3f1f58f0d96b 100644 --- a/docs/channels/broadcast-groups.md +++ b/docs/channels/broadcast-groups.md @@ -39,7 +39,7 @@ Add a top-level `broadcast` section (next to `bindings`). Keys are WhatsApp peer **Result:** when OpenClaw would reply in this chat, it runs all three agents. -Every listed agent id must exist in `agents.list`: config validation reports unknown ids, and the runtime skips them with a `Broadcast agent not found in agents.list; skipping` warning. +Every listed agent id must exist in `agents.entries`: config validation reports unknown ids, and the runtime skips them with a `Broadcast agent not found in agents.entries; skipping` warning. ### Processing strategy @@ -247,7 +247,7 @@ Broadcast groups work alongside existing routing: **Check:** - 1. Agent IDs exist in `agents.list` (config validation rejects unknown ids). + 1. Agent IDs exist in `agents.entries` (config validation rejects unknown ids). 2. Peer ID format is correct (group JID like `120363403215116621@g.us`, or E.164 like `+15551234567` for DMs). 3. The message passed normal gating (mention/activation rules still apply). diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index 574f32532217..2b5b3cb91161 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -90,7 +90,7 @@ Routing picks **one agent** for each inbound message: 6. **Team match** (Slack) via `teamId`. 7. **Account match** (`accountId` on the channel). 8. **Channel match** (any account on that channel, `accountId: "*"`). -9. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`). +9. **Default agent** (`agents.entries.*.default`, else first list entry, fallback to `main`). When a binding includes multiple match fields (`peer`, `guildId`, `teamId`, `roles`), **all provided fields must match** for that binding to apply. @@ -116,7 +116,7 @@ See: [Broadcast Groups](/channels/broadcast-groups). ## Config overview -- `agents.list`: named agent definitions (workspace, model, etc.). +- `agents.entries`: named agent definitions (workspace, model, etc.). - `bindings`: map inbound channels/accounts/peers to agents. Example: diff --git a/docs/channels/discord.md b/docs/channels/discord.md index e975903b13ac..68b5c2a5f0bc 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -574,7 +574,7 @@ Example: Mention detection includes: - explicit bot mention - - configured mention patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) + - configured mention patterns (`agents.entries.*.groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - implicit reply-to-bot behavior in supported cases When writing outbound Discord messages, use canonical mention syntax: `<@USER_ID>` for users, `<#CHANNEL_ID>` for channels, and `<@&ROLE_ID>` for roles. Do not use the legacy `<@!USER_ID>` nickname mention form. @@ -762,17 +762,8 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior enabled: true, idleHours: 24, maxAgeHours: 0, - }, - }, - channels: { - discord: { - threadBindings: { - enabled: true, - idleHours: 24, - maxAgeHours: 0, - spawnSessions: true, - defaultSpawnContext: "fork", - }, + spawnSessions: true, + defaultSpawnContext: "fork", }, }, } @@ -780,11 +771,11 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior Notes: - - `session.threadBindings.*` sets global defaults; `channels.discord.threadBindings.*` overrides Discord behavior. + - `session.threadBindings.*` is the canonical policy for Discord and Telegram. - `spawnSessions` controls auto-create/bind threads for `sessions_spawn({ thread: true })` and ACP thread spawns. Default: `true`. - `defaultSpawnContext` controls native subagent context for thread-bound spawns. Default: `"fork"`. - Deprecated `spawnSubagentSessions`/`spawnAcpSessions` keys are migrated by `openclaw doctor --fix`. - - If thread bindings are disabled for an account, `/focus` and related thread binding operations are unavailable. + - If thread bindings are disabled, `/focus` and related operations are unavailable. See [Sub-agents](/tools/subagents), [ACP Agents](/tools/acp-agents), and [Configuration Reference](/gateway/configuration-reference). @@ -817,9 +808,8 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior ```json5 { agents: { - list: [ - { - id: "codex", + entries: { + codex: { runtime: { type: "acp", acp: { @@ -830,7 +820,7 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior }, }, }, - ], + }, }, bindings: [ { @@ -918,7 +908,7 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior - `channels.discord.accounts..ackReaction` - `channels.discord.ackReaction` - `messages.ackReaction` - - agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀") + - agent identity emoji fallback (`agents.entries.*.identity.emoji`, else "👀") Notes: @@ -1263,7 +1253,7 @@ Notes: - The OpenAI realtime provider accepts current Realtime 2 event names and legacy Codex-compatible aliases for output audio and transcript events, so compatible provider snapshots can drift without dropping assistant audio. - `voice.realtime.bargeIn` controls whether Discord speaker-start events interrupt active realtime playback. If unset, it follows the realtime provider's input-audio interruption setting. - `voice.realtime.minBargeInAudioEndMs` controls the minimum assistant playback duration before an OpenAI realtime barge-in truncates audio. Default: `250`. Set `0` for immediate interruption in low-echo rooms, or raise it for echo-heavy speaker setups. -- `voice.tts` overrides `messages.tts` for `stt-tts` voice playback only; realtime modes use `voice.realtime.speakerVoice` instead. For an OpenAI voice on Discord playback, set `voice.tts.provider: "openai"` and choose a Text-to-speech voice under `voice.tts.providers.openai.speakerVoice`. `cedar` is a good masculine-sounding choice on the current OpenAI TTS model. +- `voice.tts` overrides `tts` for `stt-tts` voice playback only; realtime modes use `voice.realtime.speakerVoice` instead. For an OpenAI voice on Discord playback, set `voice.tts.provider: "openai"` and choose a Text-to-speech voice under `voice.tts.providers.openai.speakerVoice`. `cedar` is a good masculine-sounding choice on the current OpenAI TTS model. - Per-channel Discord `systemPrompt` overrides apply to voice transcript turns for that voice channel. - When OpenClaw joins a voice channel, the routed agent session receives a silent system event with the current participant roster. Later participant joins and leaves update that session without triggering an unsolicited spoken reply; Discord display names are treated as untrusted labels. Authorized voice turns also receive a fresh roster snapshot. - Voice transcript turns and `/vc` commands use Discord entries in `commands.ownerAllowFrom` for owner status. When no Discord command owner is configured, the selected Discord account's `allowFrom` (or legacy `dm.allowFrom`) can still authorize voice access without granting owner status. Agent tool visibility follows the configured tool policy for the routed session. @@ -1337,7 +1327,7 @@ STT plus TTS pipeline: - `tools.media.audio` handles STT, for example `openai/gpt-4o-mini-transcribe`. - The transcript is sent through Discord ingress and routing while the response LLM runs with a voice-output policy that hides the agent `tts` tool and asks for returned text, because Discord voice owns final TTS playback. - `voice.model`, when set, overrides only the response LLM for this voice-channel turn. -- `voice.tts` is merged over `messages.tts`; streaming-capable providers feed the player directly, otherwise the resulting audio file is played in the joined channel. +- `voice.tts` is merged over `tts`; streaming-capable providers feed the player directly, otherwise the resulting audio file is played in the joined channel. Default agent-proxy voice-channel session example: @@ -1522,7 +1512,7 @@ Common patterns: - `capture ignored during playback (barge-in disabled)` means OpenClaw intentionally dropped input while assistant audio was active. Enable `voice.realtime.bargeIn` if you want speech to interrupt playback. - `barge-in ignored ... outputActive=false` means Discord or provider VAD reported speech, but OpenClaw had no active playback to interrupt. This should not cut off audio. -Credentials are resolved per component: LLM route auth for `voice.model`, STT auth for `tools.media.audio`, TTS auth for `messages.tts`/`voice.tts`, and realtime provider auth for `voice.realtime.providers` or the provider's normal auth config. +Credentials are resolved per component: LLM route auth for `voice.model`, STT auth for `tools.media.audio`, TTS auth for `tts`/`voice.tts`, and realtime provider auth for `voice.realtime.providers` or the provider's normal auth config. ### Voice messages diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 323772ed69dd..6853c08192c2 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -311,7 +311,7 @@ The official `lark-cli` VC agent skill currently marks meeting-bot actions as a ``` `defaultAccount` controls which account is used when outbound APIs do not specify an `accountId`. Account entries inherit top-level settings; most top-level keys can be overridden per account. -`accounts..tts` uses the same shape as `messages.tts` and deep-merges over global TTS config, so multi-bot Feishu setups can keep shared provider credentials globally while overriding only voice, model, persona, or auto mode per account. +`accounts..tts` uses the same shape as `tts` and deep-merges over global TTS config, so multi-bot Feishu setups can keep shared provider credentials globally while overriding only voice, model, persona, or auto mode per account. ### Message limits @@ -642,7 +642,7 @@ Full configuration: [Gateway configuration](/gateway/configuration) | `channels.feishu.accounts..appId` | App ID | - | | `channels.feishu.accounts..appSecret` | App Secret | - | | `channels.feishu.accounts..domain` | Per-account domain override | `feishu` | -| `channels.feishu.accounts..tts` | Per-account TTS override | `messages.tts` | +| `channels.feishu.accounts..tts` | Per-account TTS override | `tts` | | `channels.feishu.dmPolicy` | DM policy (`pairing`, `allowlist`, `open`) | `pairing` | | `channels.feishu.allowFrom` | DM allowlist (open_id list) | - | | `channels.feishu.groupPolicy` | Group policy (`open`, `allowlist`, `disabled`) | `allowlist` | diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index eb57a10c9153..7f83f84c5356 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -172,10 +172,8 @@ Use these identifiers for delivery and allowlists: webhookPath: "/googlechat", botUser: "users/1234567890", // optional; helps mention detection allowBots: false, - dm: { - policy: "pairing", - allowFrom: ["users/1234567890"], - }, + dmPolicy: "pairing", + allowFrom: ["users/1234567890"], groupPolicy: "allowlist", groups: { "spaces/AAAA": { diff --git a/docs/channels/group-messages.md b/docs/channels/group-messages.md index 44fccabcd6c1..ab2632a2505d 100644 --- a/docs/channels/group-messages.md +++ b/docs/channels/group-messages.md @@ -13,7 +13,7 @@ For the cross-channel groups model (Discord, iMessage, Matrix, Microsoft Teams, Goal: let OpenClaw sit in WhatsApp groups, wake up only when pinged, and keep that thread separate from the personal DM session. -`agents.list[].groupChat.mentionPatterns` is shared with the other channels' mention gating. For multi-agent setups, set it per agent, or use `messages.groupChat.mentionPatterns` as a global fallback. With neither set, patterns are derived from the agent identity name/emoji. +`agents.entries.*.groupChat.mentionPatterns` is shared with the other channels' mention gating. For multi-agent setups, set it per agent, or use `messages.groupChat.mentionPatterns` as a global fallback. With neither set, patterns are derived from the agent identity name/emoji. ## Behavior @@ -42,14 +42,13 @@ Make display-name pings work even when WhatsApp strips the visual `@` from the t }, }, agents: { - list: [ - { - id: "main", + entries: { + main: { groupChat: { mentionPatterns: ["@?openclaw", "\\+?15555550123"], }, }, - ], + }, }, } ``` @@ -88,7 +87,7 @@ Only owner numbers (from `channels.whatsapp.allowFrom`, or the bot's own E.164 w - Heartbeats run in the agent's main session; group sessions never get heartbeat runs. - Echo suppression remembers the combined prompt (history + current message) per session so the bot's own delivered messages do not retrigger it; an identical repeated batch can be skipped as an echo. - Session store entries appear as `agent::whatsapp:group:` in the per-agent SQLite session store; a missing entry just means the group has not triggered a run yet. -- Typing indicators follow `session.typingMode` / `agents.defaults.typingMode`. When visible replies are opted into message-tool-only mode, typing starts immediately by default so group members can see the agent working even if no automatic final reply is posted. Explicit typing-mode config still wins. +- Typing indicators follow `agents.entries.*.typingMode` / `agents.defaults.typingMode`. When visible replies are opted into message-tool-only mode, typing starts immediately by default so group members can see the agent working even if no automatic final reply is posted. Explicit typing-mode config still wins. ## Related diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 353baa9d319e..0f2f5335bf6b 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -342,15 +342,14 @@ Each fact defaults to enabled when the channel produces it. Set the correspondin }, }, agents: { - list: [ - { - id: "main", + entries: { + main: { groupChat: { mentionPatterns: ["@openclaw", "openclaw", "\\+15555550123"], historyLimit: 50, }, }, - ], + }, }, } ``` @@ -429,7 +428,7 @@ Account-level channel configs can set the same policy under `channels.. - `mentionPatterns` are case-insensitive safe regex patterns; invalid patterns and unsafe nested-repetition forms are ignored (with a warning). - - Pattern precedence: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group) overrides `messages.groupChat.mentionPatterns`; when neither is set, patterns are derived from the agent identity name/emoji. + - Pattern precedence: `agents.entries.*.groupChat.mentionPatterns` (useful when multiple agents share a group) overrides `messages.groupChat.mentionPatterns`; when neither is set, patterns are derived from the agent identity name/emoji. - Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured). - Allowlisting a group or sender does not disable mention gating; set that group's `requireMention` to `false` when all messages should trigger. - Automatic group chat prompt context carries the resolved silent-reply instruction every turn; workspace files should not duplicate `NO_REPLY` mechanics. diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index ff4cdc5ca37e..91996709761b 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -323,7 +323,7 @@ If disabling SIP is not acceptable for your threat model: Mention gating for groups: - iMessage has no native mention metadata - - mention detection uses regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) + - mention detection uses regex patterns (`agents.entries.*.groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - with no configured patterns, mention gating cannot be enforced - control commands from authorized senders bypass mention gating @@ -840,7 +840,7 @@ openclaw channels status --probe --channel imessage - `channels.imessage.groupPolicy` - `channels.imessage.groupAllowFrom` - `channels.imessage.groups` allowlist behavior - - mention pattern configuration (`agents.list[].groupChat.mentionPatterns`) + - mention pattern configuration (`agents.entries.*.groupChat.mentionPatterns`) diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 45cf47e1df57..7e72ba810afc 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -864,7 +864,7 @@ Room allowlist keys (`groups`, legacy `rooms`) should be room IDs or aliases. Pl - `groupPolicy`: `"open"`, `"allowlist"`, or `"disabled"`. Default: `"allowlist"`. - `groupAllowFrom`: allowlist of user IDs for room traffic. -- `mentionPatterns`: scoped regex patterns for room mentions. Object with `{ mode: "allow"|"deny", allowIn: [roomId, ...], denyIn: [roomId, ...] }`. Controls whether configured `agents.list[].groupChat.mentionPatterns` apply per-room. +- `mentionPatterns`: scoped regex patterns for room mentions. Object with `{ mode: "allow"|"deny", allowIn: [roomId, ...], denyIn: [roomId, ...] }`. Controls whether configured `agents.entries.*.groupChat.mentionPatterns` apply per-room. - `dm.enabled`: when `false`, ignore all DMs. Default: `true`. - `dm.policy`: `"pairing"` (default), `"allowlist"`, `"open"`, or `"disabled"`. Applies after the bot has joined and classified the room as a DM; it does not affect invite handling. - `dm.allowFrom`: allowlist of user IDs for DM traffic. diff --git a/docs/channels/qqbot.md b/docs/channels/qqbot.md index 873615254022..34d377706b0b 100644 --- a/docs/channels/qqbot.md +++ b/docs/channels/qqbot.md @@ -252,10 +252,10 @@ commands run one by one, independent of any merge batch. STT and TTS support two-level configuration with priority fallback: -| Setting | Plugin-specific | Framework fallback | -| ------- | -------------------------------------------------------- | ----------------------------- | -| STT | `channels.qqbot.stt` | `tools.media.audio.models[0]` | -| TTS | `channels.qqbot.tts`, `channels.qqbot.accounts..tts` | `messages.tts` | +| Setting | Plugin-specific | Framework fallback | +| ------- | -------------------------------------------------------- | ------------------------------------------------ | +| STT | `channels.qqbot.stt` | first audio-capable `tools.media.models[]` entry | +| TTS | `channels.qqbot.tts`, `channels.qqbot.accounts..tts` | `tts` | ```json5 { @@ -285,12 +285,11 @@ STT and TTS support two-level configuration with priority fallback: ``` Set `enabled: false` on either to disable. Account-level TTS overrides use the -same shape as `messages.tts` and deep-merge over channel/global TTS config. +same shape as `tts` and deep-merge over channel/global TTS config. STT requests time out after 60 seconds by default. Plugin-specific STT uses the selected `models.providers..timeoutSeconds` override. Framework audio STT -uses `tools.media.audio.models[0].timeoutSeconds`, then -`tools.media.audio.timeoutSeconds`, then the selected provider override. +uses the selected audio-capable `tools.media.models[]` entry's `timeoutSeconds`, then the selected provider override. Inbound QQ voice attachments are exposed to agents as audio media metadata while keeping raw voice files out of generic `MediaPaths`. `[[audio_as_voice]]` diff --git a/docs/channels/signal.md b/docs/channels/signal.md index fe8a0f97a99f..1088527a43c6 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -453,8 +453,7 @@ Provider options: - `channels.signal.accountUuid`: optional bot account UUID for native @mention detection and loop protection. - `channels.signal.cliPath`: path to `signal-cli`. - `channels.signal.configPath`: optional `signal-cli --config` directory. -- `channels.signal.httpUrl`: full daemon URL (overrides host/port). -- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default `127.0.0.1:8080`). +- `channels.signal.httpUrl`: full daemon URL and canonical daemon bind (default `http://127.0.0.1:8080`). - `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset). - `channels.signal.startupTimeoutMs`: startup wait timeout in ms (min 1000, cap 120000; default 30000). - `channels.signal.receiveMode`: `on-start | manual`. @@ -484,9 +483,9 @@ Provider options: Related global options: -- `agents.list[].groupChat.mentionPatterns` (plain-text fallback; Signal native @mentions are detected from structured metadata when the bot account identity is configured). +- `agents.entries.*.groupChat.mentionPatterns` (plain-text fallback; Signal native @mentions are detected from structured metadata when the bot account identity is configured). - `messages.groupChat.mentionPatterns` (global fallback). -- `messages.responsePrefix`. +- `channels.signal.responsePrefix` or an account-level `responsePrefix`. ## Related diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 011f4c0929df..4bc7740e408a 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -1298,7 +1298,7 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r - explicit app mention (`<@botId>`) - Slack user-group mention (``) when the bot user is a member of that user group; requires `usergroups:read` - - mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) + - mention regex patterns (`agents.entries.*.groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - replies to the bot's own Slack message (`implicitMentions.replyToBot`) - follow-ups in threads where the bot participated (`implicitMentions.threadParticipation`) @@ -1372,7 +1372,7 @@ Resolution order: - `channels.slack.accounts..ackReaction` - `channels.slack.ackReaction` - `messages.ackReaction` -- agent identity emoji fallback (`agents.list[].identity.emoji`, else `"eyes"` / 👀) +- agent identity emoji fallback (`agents.entries.*.identity.emoji`, else `"eyes"` / 👀) Notes: @@ -1507,7 +1507,7 @@ To speak to OpenClaw in Slack today, send a Slack audio clip to the OpenClaw app Audio clips and Slackbot dictation have different privacy semantics: clips follow Slack file-retention policy and OpenClaw downloads them for transcription, while Slack says dictation audio is not stored. -In a channel with `requireMention: true`, a captionless audio clip can satisfy the gate by speaking a configured mention pattern (`agents.list[].groupChat.mentionPatterns`, falling back to `messages.groupChat.mentionPatterns`). OpenClaw authorizes the sender before downloading or transcribing the clip, then admits it only when the transcript matches. A failed or nonmatching speculative transcript is discarded with the downloaded clip; it is not retained in channel history. Native Slack `@bot` identity cannot be inferred from speech, so configure a spoken-name pattern or include a typed mention. If transcript echoing is enabled, the echo is sent only after admission. +In a channel with `requireMention: true`, a captionless audio clip can satisfy the gate by speaking a configured mention pattern (`agents.entries.*.groupChat.mentionPatterns`, falling back to `messages.groupChat.mentionPatterns`). OpenClaw authorizes the sender before downloading or transcribing the clip, then admits it only when the transcript matches. A failed or nonmatching speculative transcript is discarded with the downloaded clip; it is not retained in channel history. Native Slack `@bot` identity cannot be inferred from speech, so configure a spoken-name pattern or include a typed mention. If transcript echoing is enabled, the echo is sent only after admission. ## Media, chunking, and delivery @@ -2057,7 +2057,7 @@ When a single Slack message contains multiple file attachments: ### Size, download, and model limits - **Size cap**: Default 20 MB per file. Configurable via `channels.slack.mediaMaxMb`. -- **Audio transcription cap**: `tools.media.audio.maxBytes` also applies when the downloaded file is sent to a transcription provider or CLI. +- **Audio transcription cap**: the selected audio-capable `tools.media.models[]` entry's `maxBytes` also applies when the downloaded file is sent to a transcription provider or CLI. - **Download failures**: Files that Slack cannot serve, expired URLs, inaccessible files, oversize files, and Slack auth/login HTML responses are skipped instead of being reported as unsupported formats. - **Vision model**: Image analysis uses the active reply model when it supports vision, or the image model configured at `agents.defaults.imageModel`. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 2dddc0db885e..d42243088a45 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -250,7 +250,7 @@ curl "https://api.telegram.org/bot/getUpdates" Group replies require mention by default. A mention can come from: - a native `@botusername` mention, or - - a mention pattern in `agents.list[].groupChat.mentionPatterns` or `messages.groupChat.mentionPatterns` + - a mention pattern in `agents.entries.*.groupChat.mentionPatterns` or `messages.groupChat.mentionPatterns` Session-level toggles (state only, not persisted): `/activation always`, `/activation mention`. Use config for persistence: @@ -578,7 +578,7 @@ curl "https://api.telegram.org/bot/getUpdates" **Persistent ACP topic binding**: forum topics can pin ACP harness sessions through top-level typed bindings (`bindings[]` with `type: "acp"`, `match.channel: "telegram"`, `peer.kind: "group"`, and a topic-qualified id like `-1001234567890:topic:42`). Currently scoped to forum topics in groups/supergroups. See [ACP Agents](/tools/acp-agents). - **Thread-bound ACP spawn from chat**: `/acp spawn --thread here|auto` binds the current topic to a new ACP session; follow-ups route there directly, and OpenClaw pins the spawn confirmation in-topic. Requires `channels.telegram.threadBindings.spawnSessions` (default: `true`). + **Thread-bound ACP spawn from chat**: `/acp spawn --thread here|auto` binds the current topic to a new ACP session; follow-ups route there directly, and OpenClaw pins the spawn confirmation in-topic. Controlled by `session.threadBindings.spawnSessions` (default: `true`). Template context exposes `MessageThreadId` and `IsForum`. DM chats with `message_thread_id` keep reply metadata but only use thread-aware session keys when Telegram `getMe` reports `has_topics_enabled: true`. The retired `dm.threadReplies` and `direct.*.threadReplies` overrides are gone; BotFather threaded mode is the single source of truth. Run `openclaw doctor --fix` to remove stale config keys. @@ -699,7 +699,7 @@ curl "https://api.telegram.org/bot/getUpdates" - `channels.telegram.accounts..ackReaction` - `channels.telegram.ackReaction` - `messages.ackReaction` - - agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀") + - agent identity emoji fallback (`agents.entries.*.identity.emoji`, else "👀") Telegram expects a unicode emoji (for example "👀"); use `""` to disable the reaction for a channel or account. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 0d9c548497fe..0b3a82802828 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -295,7 +295,7 @@ Scope the opt-in to one account under `channels.whatsapp.accounts..pluginHoo Group replies require a mention by default. Mention detection includes: - explicit WhatsApp mentions of the bot identity - - configured mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) + - configured mention regex patterns (`agents.entries.*.groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - inbound voice-note transcripts for authorized group messages - implicit reply-to-bot detection (reply sender matches bot identity) @@ -339,7 +339,7 @@ Direct chats match E.164 numbers; groups match WhatsApp group JIDs. Group allowl ## Personal-number and self-chat behavior -When the linked self number is also present in `allowFrom`, self-chat safeguards activate: skip read receipts for self-chat turns, ignore mention-JID auto-trigger behavior that would ping yourself, and default replies to `[{identity.name}]` (or `[openclaw]`) when `messages.responsePrefix` is unset. +When the linked self number is also present in `allowFrom`, self-chat safeguards activate: skip read receipts for self-chat turns, ignore mention-JID auto-trigger behavior that would ping yourself, and default replies to `[{identity.name}]` (or `[openclaw]`) when the channel/account `responsePrefix` is unset. ## Message normalization and context @@ -668,7 +668,7 @@ Primary reference: [Configuration reference - WhatsApp](/gateway/config-channels | Access | `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups` | | Delivery | `textChunkLimit`, `streaming.chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel` | | Multi-account | `accounts..enabled`, `accounts..authDir`, and other per-account overrides | -| Operations | `configWrites`, `debounceMs`, `web.enabled` | +| Operations | `configWrites`, `debounceMs`, `enabled` | | Session behavior | `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms..historyLimit` | | Prompts | `groups..systemPrompt`, `groups["*"].systemPrompt`, `direct..systemPrompt`, `direct["*"].systemPrompt` | diff --git a/docs/ci.md b/docs/ci.md index 1cfce5afdd03..d42f1769e27b 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -207,6 +207,22 @@ The changed-target PR plan reduces the common Node test burst from 14 Blacksmith Canonical-repo CI keeps Blacksmith as the default runner path for normal push and pull-request runs. `workflow_dispatch` and non-canonical repository runs use GitHub-hosted runners, but normal canonical runs do not currently probe Blacksmith queue health or automatically fall back to GitHub-hosted labels when Blacksmith is unavailable. +## Surface ratchets + +Two shrink-only budgets guard the configuration surface. Both fail CI on growth +until the budget file is consciously updated in the same PR, and both demand a +ratchet-down when cleanup lowers the real count. + +- `config/env-var-count-budget.txt` caps the number of distinct `OPENCLAW_*` + names in production source under `src/`, `packages/`, and `extensions/` + (tests and QA Lab excluded). Checked by `node scripts/check-env-var-count.mjs`. + Removing env vars: lower the number in the same PR. Adding one is a + config-surface decision — justify it in the PR body. +- `docs/.generated/config-baseline.counts.json` caps the per-kind + (core/channel/plugin) `openclaw.json` schema entry counts. Checked by + `pnpm config:docs:check`; regenerate with `pnpm config:docs:gen` after any + schema change. + ## Local equivalents ```bash @@ -632,6 +648,10 @@ gh workflow run duplicate-after-merge.yml \ ## Local check gates and changed routing +### Config baseline count ratchet + +`pnpm config:docs:check` rejects undocumented config-surface growth and corrupt or stale count snapshots. When a reviewed product change intentionally adds schema paths, run `pnpm config:docs:gen`, inspect the core/channel/plugin count deltas and generated SHA-256 files, and commit the conscious baseline bump with the schema, help, labels, migration, and tests. Do not hand-edit the counts file to bypass the ratchet. + Local changed-lane logic lives in `scripts/changed-lanes.mjs` and is executed by `scripts/check-changed.mjs`. That local check gate is stricter about architecture boundaries than the broad CI platform scope: - core production changes run core prod and core test typecheck plus core lint/guards; diff --git a/docs/cli/agents.md b/docs/cli/agents.md index f54cf0f60eaa..ff9c8b16027c 100644 --- a/docs/cli/agents.md +++ b/docs/cli/agents.md @@ -76,7 +76,7 @@ Options: `--force`, `--json`. Use routing bindings to pin inbound channel traffic to a specific agent. -If you also want different visible skills per agent, configure `agents.defaults.skills` and `agents.list[].skills` in `openclaw.json`. See [Skills config](/tools/skills-config) and [Configuration reference](/gateway/config-agents#agentsdefaultsskills). +If you also want different visible skills per agent, configure `agents.defaults.skills` and `agents.entries.*.skills` in `openclaw.json`. See [Skills config](/tools/skills-config) and [Configuration reference](/gateway/config-agents#agentsdefaultsskills). List bindings: @@ -152,7 +152,7 @@ Avatar paths resolve relative to the workspace root and cannot escape it, even t ## Set identity -`set-identity` writes fields into `agents.list[].identity`: `name`, `theme`, `emoji`, `avatar` (workspace-relative path, http(s) URL, or data URI). +`set-identity` writes fields into `agents.entries.*.identity`: `name`, `theme`, `emoji`, `avatar` (workspace-relative path, http(s) URL, or data URI). - `--agent` or `--workspace` selects the target agent. If `--workspace` matches more than one agent, the command fails and asks you to pass `--agent`. - Local workspace-relative avatar image files are limited to 2 MB. HTTP(S) URLs and `data:` URIs are not checked against the local file-size limit. diff --git a/docs/cli/approvals.md b/docs/cli/approvals.md index ca2058b28089..ecb0b38f36e7 100644 --- a/docs/cli/approvals.md +++ b/docs/cli/approvals.md @@ -140,8 +140,7 @@ This changes the **host approvals file** only. To keep the requested OpenClaw po ```bash openclaw config set tools.exec.host gateway -openclaw config set tools.exec.security full -openclaw config set tools.exec.ask off +openclaw config set tools.exec.mode full ``` `tools.exec.host=gateway` is explicit here because `host=auto` still means "sandbox when available, otherwise gateway": YOLO is about approvals, not routing. Use `gateway` (or `/exec host=gateway`) when you want host exec even with a sandbox configured. diff --git a/docs/cli/commitments.md b/docs/cli/commitments.md index 5e416049b3dd..98fe07021c83 100644 --- a/docs/cli/commitments.md +++ b/docs/cli/commitments.md @@ -7,11 +7,9 @@ read_when: title: "`openclaw commitments`" --- -List and manage inferred follow-up commitments. - -Commitments are opt-in (`commitments.enabled`), short-lived follow-up memories -created from conversation context and delivered by heartbeat. See -[Inferred commitments](/concepts/commitments) for the conceptual guide and config. +Inspect and dismiss records left by the retired inferred commitments experiment. +OpenClaw no longer creates or delivers new commitments, but keeps the maintenance +command so upgrades can audit and clean up existing SQLite rows. With no subcommand, `openclaw commitments` lists pending commitments. @@ -31,8 +29,7 @@ openclaw commitments dismiss [--json] `dismissed`, `snoozed`, or `expired`. Unknown values exit with an error. - `--json`: output machine-readable JSON. -`dismiss` marks the given commitment ids as `dismissed` so heartbeat will not -deliver them. +`dismiss` marks the given commitment ids as `dismissed`. ## Examples diff --git a/docs/cli/config.md b/docs/cli/config.md index ec9504c8ec26..6c6851af9e08 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -31,7 +31,7 @@ openclaw config get browser.executablePath openclaw config set browser.executablePath "/usr/bin/google-chrome" openclaw config set browser.profiles.work.executablePath "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" openclaw config set agents.defaults.heartbeat.every "2h" -openclaw config set 'agents.list[0].tools.exec.node' "node-id-or-name" +openclaw config set 'agents.entries.main.tools.exec.node' "node-id-or-name" openclaw config set agents.defaults.models '{"openai/gpt-5.4":{}}' --strict-json --merge openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN openclaw config set secrets.providers.vaultfile --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json @@ -48,9 +48,9 @@ Dot or bracket notation. Quote bracket paths in shell examples so zsh does not g ```bash openclaw config get agents.defaults.workspace -openclaw config get 'agents.list[0].id' -openclaw config get agents.list -openclaw config set 'agents.list[1].tools.exec.node' "node-id-or-name" +openclaw config get agents.entries.main +openclaw config get agents.entries +openclaw config set 'agents.entries.work.tools.exec.node' "node-id-or-name" ``` ### `config get` @@ -117,10 +117,10 @@ openclaw config set channels.whatsapp.groups '["*"]' --strict-json `config get --json` prints the raw value as JSON instead of terminal-formatted text. -When a write changes `agents.defaults.model` or a per-agent `agents.list[].model`, OpenClaw resolves each changed primary or fallback through the configured provider catalogs before writing. Unknown model references are rejected without changing the active config; run `openclaw models list` to see available models. +When a write changes `agents.defaults.model` or a per-agent `agents.entries.*.model`, OpenClaw resolves each changed primary or fallback through the configured provider catalogs before writing. Unknown model references are rejected without changing the active config; run `openclaw models list` to see available models. -Object assignment replaces the target path by default. Protected paths that commonly hold user-added entries refuse replacements that would remove existing entries unless you pass `--replace`: `agents.defaults.models`, `agents.list`, `models.providers`, `models.providers.`, `models.providers..models`, `plugins.entries`, and `auth.profiles`. +Object assignment replaces the target path by default. Protected paths that commonly hold user-added entries refuse replacements that would remove existing entries unless you pass `--replace`: `agents.defaults.models`, `agents.entries`, `models.providers`, `models.providers.`, `models.providers..models`, `plugins.entries`, and `auth.profiles`. Use `--merge` when adding entries to those maps: diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index dd82f0df8b5e..b337f2210223 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -90,8 +90,8 @@ Hook packs install through the unified plugins installer/updater; `openclaw hook - Bare specs and `@latest` stay on the stable track; if npm resolves to a prerelease, OpenClaw stops and asks you to opt in explicitly (`@beta`, `@rc`, or an exact prerelease version). - Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`. - `-l, --link` links a local directory instead of copying it (adds it to `hooks.internal.load.extraDirs`); linked hook packs are managed hooks from an operator-configured directory, not workspace hooks. -- `--pin` records npm installs as an exact resolved `name@version` in `hooks.internal.installs`. -- Install copies the pack into `~/.openclaw/hooks/`, enables its hooks under `hooks.internal.entries.*`, and records the install under `hooks.internal.installs`. +- `--pin` records npm installs as an exact resolved `name@version` in shared SQLite state. +- Install copies the pack into `~/.openclaw/hooks/`, enables its hooks under `hooks.internal.entries.*`, and records install provenance in shared SQLite state. - If a stored integrity hash no longer matches the fetched artifact, OpenClaw warns and prompts before continuing; pass global `--yes` to bypass the prompt (for example in CI). ## Bundled hooks diff --git a/docs/cli/memory.md b/docs/cli/memory.md index bcd498875981..fdc4a62522d4 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -24,7 +24,7 @@ Related: [Memory](/concepts/memory) concept, [Dreaming](/concepts/dreaming), openclaw memory status [--agent ] [--deep] [--index] [--fix] [--json] [--verbose] ``` -Without `--agent`, runs for every agent in `agents.list`; if no agent list is +Without `--agent`, runs for every agent in `agents.entries`; if no agent list is configured, falls back to the default agent. | Flag | Effect | @@ -40,7 +40,7 @@ scheduled sweeps never seem to run, the managed dreaming cron depends on the default agent's heartbeat firing to trigger reconciliation. See [Dreaming](/concepts/dreaming) for scheduling details. -Status also lists any extra search paths from `agents.defaults.memorySearch.extraPaths`. +Status also lists any extra search paths from `memory.search.extraPaths`. ## `memory index` diff --git a/docs/cli/openclaw.md b/docs/cli/openclaw.md index 6998a855b25b..57892e129294 100644 --- a/docs/cli/openclaw.md +++ b/docs/cli/openclaw.md @@ -303,7 +303,7 @@ Security contract for remote rescue: - Disabled when sandboxing is active for the agent/session; OpenClaw refuses remote rescue and points to local CLI repair. - Default effective state is `auto`: allow remote rescue only in trusted YOLO operation, where the runtime already has unsandboxed local authority (`tools.exec.security` resolves to `full` and `tools.exec.ask` resolves to `off`, with sandbox mode `off`). - Requires an explicit owner identity; no wildcard sender rules, open group policy, unauthenticated webhooks, or anonymous channels. -- Owner DMs only by default; group/channel rescue needs explicit opt-in. +- Rescue is limited to owner DMs. - Plugin search and list are read-only. Plugin install is always local-only (blocked in rescue, even when otherwise enabled) because it downloads executable code. Plugin uninstall is refused in both local OpenClaw and rescue; run `openclaw plugins uninstall ` from a terminal. - Remote rescue cannot open the local TUI or switch into an interactive agent session; use local `openclaw` for agent handoff. - Persistent writes still require approval, even in rescue mode. @@ -312,26 +312,10 @@ Security contract for remote rescue: - Secrets are never echoed. SecretRef inspection reports availability, not values. - If the Gateway is alive, rescue prefers Gateway typed operations; if it is dead, rescue uses only the minimal local repair surface that does not depend on the normal agent loop. -Config shape: - -```jsonc -{ - "systemAgent": { - "rescue": { - "enabled": "auto", - "ownerDmOnly": true, - "pendingTtlMinutes": 15, - }, - }, -} -``` - -- `enabled`: `"auto"` (default) allows rescue only when the effective runtime is YOLO and sandboxing is off; `false` never allows message-channel rescue; `true` explicitly allows rescue when owner/channel checks pass (still subject to the sandboxing denial). -- `ownerDmOnly`: restrict rescue to owner direct messages. Default `true`. -- `pendingTtlMinutes`: how long a pending rescue write stays open for `/openclaw yes` approval before expiring. Default `15`. - -`openclaw doctor --fix` migrates the legacy `crestodian` config block to -`systemAgent`. Runtime reads only the canonical block. +Rescue policy is built in: it is available only when the effective runtime is +YOLO, sandboxing is off, and the request is an owner DM. Pending write approvals +expire after 15 minutes. `openclaw doctor --fix` removes the retired +`systemAgent` and `crestodian` config blocks. Remote rescue is covered by the Docker lane: diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index a5b96b35e618..a90748859d30 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -436,7 +436,7 @@ openclaw plugins update openclaw-codex-app-server --acknowledge-clawhub-risk openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install ``` -Updates apply to tracked plugin installs in the managed plugin index and tracked hook-pack installs in `hooks.internal.installs`. They reuse the source that the user already chose when installing the plugin, so they do not require a second source acknowledgement. +Updates apply to tracked plugin installs in the managed plugin index and tracked hook-pack installs in shared SQLite state. They reuse the source that the user already chose when installing the plugin, so they do not require a second source acknowledgement. diff --git a/docs/cli/policy.md b/docs/cli/policy.md index 8cc71cc529cd..830df810694b 100644 --- a/docs/cli/policy.md +++ b/docs/cli/policy.md @@ -224,7 +224,7 @@ and the scoped rule can add its own finding against the same evidence. | `agentIds` | `tools`, `agents.workspace`, `sandbox`, `dataHandling.memory`, `execApprovals` | One or more runtime agents need stricter rules. | | `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. | -If an `agentIds` entry is not present in `agents.list[]`, OpenClaw evaluates +If an `agentIds` entry is not present in `agents.entries.*`, OpenClaw evaluates the scoped rule against inherited global/default posture for that runtime agent id instead of skipping it. @@ -375,20 +375,20 @@ private messages. | `gateway.remote.allow` | Remote Gateway mode/config | Set to `false` to deny remote Gateway mode. | | `gateway.http.denyEndpoints` | Gateway HTTP API endpoints | Deny endpoint ids such as `chatCompletions` or `responses`. | | `gateway.http.requireUrlAllowlists` | Gateway HTTP URL-fetch inputs | Set to `true` to require URL allowlists on URL-fetch inputs. | -| `gateway.nodes.denyCommands` | `gateway.nodes.denyCommands` | Require exact node command ids such as `system.run` to be denied in OpenClaw config. | +| `gateway.nodes.denyCommands` | `gateway.nodes.commands.deny` | Require exact node command ids such as `system.run` to be denied in OpenClaw config. | -`gateway.nodes.denyCommands` is an exact, case-sensitive deny-superset rule. +`gateway.nodes.denyCommands` is an exact, case-sensitive policy deny-superset rule. Use it when policy must prove that privileged node commands are explicitly denied by OpenClaw config. A deployment that intentionally allows a privileged node command should update `policy.jsonc` after review instead of relying on -`gateway.nodes.allowCommands` alone. +`gateway.nodes.commands.allow` alone. #### Agent workspace -| Policy field | Observed state | Use when | -| -------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| `agents.workspace.allowedAccess` | `agents.defaults.sandbox.workspaceAccess` and `agents.list[].sandbox.workspaceAccess` | Allow only sandbox workspace access values such as `none` or `ro`. | -| `agents.workspace.denyTools` | Global and per-agent tool deny config | Require mutation tools (`exec`, `process`, `write`, `edit`, `apply_patch`) to be denied. | +| Policy field | Observed state | Use when | +| -------------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `agents.workspace.allowedAccess` | `agents.defaults.sandbox.workspaceAccess` and `agents.entries.*.sandbox.workspaceAccess` | Allow only sandbox workspace access values such as `none` or `ro`. | +| `agents.workspace.denyTools` | Global and per-agent tool deny config | Require mutation tools (`exec`, `process`, `write`, `edit`, `apply_patch`) to be denied. | #### Sandbox posture @@ -409,12 +409,12 @@ allowlist such as `["all"]`. #### Data Handling -| Policy field | Observed state | Use when | -| --------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | -| `dataHandling.sensitiveLogging.requireRedaction` | `logging.redactSensitive` | Set to `true` to reject `logging.redactSensitive: "off"`. | -| `dataHandling.telemetry.denyContentCapture` | `diagnostics.otel.captureContent` | Set to `true` to reject telemetry content capture. | -| `dataHandling.retention.requireSessionMaintenance` | `session.maintenance.mode` | Set to `true` to require effective session maintenance mode `enforce`. | -| `dataHandling.memory.denySessionTranscriptIndexing` | `memory.qmd.sessions.enabled` and `agents.*.memorySearch.experimental.sessionMemory` | Set to `true` to reject session transcript indexing into memory. | +| Policy field | Observed state | Use when | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `dataHandling.sensitiveLogging.requireRedaction` | `logging.redactSensitive` | Set to `true` to reject `logging.redactSensitive: "off"`. | +| `dataHandling.telemetry.denyContentCapture` | `diagnostics.otel.captureContent` | Set to `true` to reject telemetry content capture. | +| `dataHandling.retention.requireSessionMaintenance` | `session.maintenance.mode` | Set to `true` to require effective session maintenance mode `enforce`. | +| `dataHandling.memory.denySessionTranscriptIndexing` | `memory.qmd.sessions.enabled`, `memory.search.experimental.sessionMemory`, and per-agent overrides | Set to `true` to reject session transcript indexing into memory. | #### Secrets @@ -502,14 +502,14 @@ only reviewed exec approval posture for selected agents. | Policy field | Observed state | Use when | | ------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| `tools.profiles.allow` | `tools.profile` and `agents.list[].tools.profile` | Allow only tool profile ids such as `minimal`, `messaging`, or `coding`. | +| `tools.profiles.allow` | `tools.profile` and `agents.entries.*.tools.profile` | Allow only tool profile ids such as `minimal`, `messaging`, or `coding`. | | `tools.fs.requireWorkspaceOnly` | `tools.fs.workspaceOnly` and per-agent `tools.fs` overrides | Set to `true` to require workspace-only filesystem tool posture. | | `tools.exec.allowSecurity` | `tools.exec.security` and per-agent exec security | Allow only exec security modes such as `deny` or `allowlist`. | | `tools.exec.requireAsk` | `tools.exec.ask` and per-agent exec ask mode | Require approval posture such as `always`. | | `tools.exec.allowHosts` | `tools.exec.host` and per-agent exec host routing | Allow only exec host routing modes such as `sandbox`. | | `tools.elevated.allow` | `tools.elevated.enabled` and per-agent elevated posture | Set to `false` to require elevated tool mode to stay disabled. | | `tools.alsoAllow.expected` | `tools.alsoAllow` and per-agent `tools.alsoAllow` | Require exact `alsoAllow` entries and report missing or unexpected additive tool grants. | -| `tools.denyTools` | `tools.deny` and `agents.list[].tools.deny` | Require configured tool deny lists to include tool ids or groups such as `group:runtime` and `group:fs`. | +| `tools.denyTools` | `tools.deny` and `agents.entries.*.tools.deny` | Require configured tool deny lists to include tool ids or groups such as `group:runtime` and `group:fs`. | ## Run checks @@ -961,10 +961,10 @@ Example findings: "message": "Gateway node command 'system.run' is denied by policy but not denied by OpenClaw config.", "source": "policy", "path": "openclaw config", - "ocPath": "oc://openclaw.config/gateway/nodes/denyCommands", - "target": "oc://openclaw.config/gateway/nodes/denyCommands", + "ocPath": "oc://openclaw.config/gateway/nodes/commands/deny", + "target": "oc://openclaw.config/gateway/nodes/commands/deny", "requirement": "oc://policy.jsonc/gateway/nodes/denyCommands", - "fixHint": "Add 'system.run' to gateway.nodes.denyCommands or update policy after review." + "fixHint": "Add 'system.run' to gateway.nodes.commands.deny or update policy after review." } ``` @@ -996,7 +996,7 @@ workspace config: - set `tools.elevated.enabled=false` when a global policy forbids elevated tools - add missing required-deny tool ids to `tools.deny` or - `agents.list[].tools.deny` when policy requires those tools to be denied + `agents.entries.*.tools.deny` when policy requires those tools to be denied - set insecure `gateway.controlUi.*` toggles to `false` - set `gateway.mode=local` when policy denies remote gateway mode - set reported `gateway.http.endpoints.*.enabled` paths to `false` when policy @@ -1019,7 +1019,7 @@ target. Scoped required-deny repairs are skipped when the finding reports inherited root `tools.deny`, because adding the required tool to root config would affect more than the scoped policy target. Agent-local required-deny repairs can update -the reported `agents.list[].tools.deny` path. +the reported `agents.entries.*.tools.deny` path. Scoped channel ingress repairs are skipped when the finding reports inherited `channels.defaults.*`, because changing the shared channel default would affect @@ -1030,7 +1030,7 @@ allowlist values. Gateway bind and node-command findings stay review-required. When `policy/gateway-non-loopback-bind` or `policy/gateway-node-command-denied` can be mapped to a config path, `doctor --fix` reports the proposed -`gateway.bind` or `gateway.nodes.denyCommands` change as skipped preview +`gateway.bind` or `gateway.nodes.commands.deny` change as skipped preview guidance. It does not apply the change, and the finding does not count as repaired until an operator reviews and updates config or policy. diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index e84567bbb290..6507b85888a5 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -92,7 +92,7 @@ Run `openclaw doctor --fix` to migrate valid legacy entries into SQLite. Invalid ## Configuration -Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sandbox` (per-agent overrides go in `agents.list[].sandbox`): +Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sandbox` (per-agent overrides go in `agents.entries.*.sandbox`): ```jsonc { diff --git a/docs/cli/security.md b/docs/cli/security.md index e5c5f9597b6a..9773405a69b0 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -50,8 +50,8 @@ Run `openclaw doctor --fix` to rotate a persisted reused `hooks.token`, then upd **Sandbox/tools** - Warns when sandbox Docker settings are configured while sandbox mode is off. -- Warns when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (matching is exact node command-name only, not shell-text filtering). -- Warns when `gateway.nodes.allowCommands` explicitly enables dangerous node commands. +- Warns when `gateway.nodes.commands.deny` uses ineffective pattern-like/unknown entries (matching is exact node command-name only, not shell-text filtering). +- Warns when `gateway.nodes.commands.allow` explicitly enables dangerous node commands. - Warns when global `tools.profile="minimal"` is overridden by agent tool profiles. - Warns when write/edit tools are disabled but `exec` is still available without a constraining sandbox filesystem boundary. - Warns when open DMs or groups expose runtime/filesystem tools without sandbox/workspace guards. diff --git a/docs/cli/status.md b/docs/cli/status.md index dc36b09ba935..3ba5ed2687b5 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -96,7 +96,7 @@ and `openclaw memory status --deep`. `status --json --all` reports memory details from the active memory plugin runtime selected by `plugins.slots.memory`. Custom memory plugins can leave -built-in `agents.defaults.memorySearch.enabled` disabled and still report +built-in `memory.search.enabled` disabled and still report their own files, chunks, vector, and FTS state. ## Related diff --git a/docs/concepts/active-memory.md b/docs/concepts/active-memory.md index 57d3f01ec308..128ea2a173e7 100644 --- a/docs/concepts/active-memory.md +++ b/docs/concepts/active-memory.md @@ -23,14 +23,15 @@ private conversations with one per-agent setting: ```json5 { agents: { - list: [ - { - id: "personal", - memorySearch: { - rememberAcrossConversations: true, + entries: { + personal: { + memory: { + search: { + rememberAcrossConversations: true, + }, }, }, - ], + }, }, } ``` @@ -158,7 +159,7 @@ personalization would be surprising. Active Memory has two activation paths: 1. **Remember across conversations** automatically targets agents whose - effective `memorySearch.rememberAcrossConversations` setting is enabled, but + effective `memory.search.rememberAcrossConversations` setting is enabled, but only for private direct or persistent explicit UI conversations. 2. **Advanced Active Memory** targets agent IDs listed in `plugins.entries.active-memory.config.agents` and applies the plugin's chat @@ -227,7 +228,7 @@ config: This only affects the current session; it does not change `plugins.entries.active-memory.config.enabled`, an agent's -`memorySearch.rememberAcrossConversations` setting, or other global +`memory.search.rememberAcrossConversations` setting, or other global configuration. To pause/resume for all sessions instead, use the global form (requires @@ -494,7 +495,7 @@ Memory automatically uses `memory_recall`; no explicit `toolsAllow` is needed: ``` This is the advanced Active Memory path for LanceDB's own stored memories. -`memorySearch.rememberAcrossConversations` does not expose private session +`memory.search.rememberAcrossConversations` does not expose private session transcripts through `memory_recall`. Use LanceDB's auto-recall or the advanced configuration above when LanceDB is the active memory provider. @@ -730,7 +731,7 @@ If active memory is not showing up where you expect: 1. Confirm the plugin is enabled under `plugins.entries.active-memory.enabled`. 2. For Remember across conversations, confirm the agent's effective - `memorySearch.rememberAcrossConversations` setting is enabled, run + `memory.search.rememberAcrossConversations` setting is enabled, run `openclaw doctor` to verify the current memory provider supports protected transcript recall, and confirm `config.toolsAllow` includes `memory_search` when explicitly configured. For advanced Active Memory, confirm the agent ID @@ -757,14 +758,14 @@ path. - If `memorySearch.provider` is unset, OpenClaw uses OpenAI embeddings. Set - `memorySearch.provider` explicitly for Bedrock, DeepInfra, Gemini, GitHub + If `memory.search.provider` is unset, OpenClaw uses OpenAI embeddings. Set + `memory.search.provider` explicitly for Bedrock, DeepInfra, Gemini, GitHub Copilot, LM Studio, local, Mistral, Ollama, Voyage, or OpenAI-compatible embeddings. If the configured provider cannot run, `memory_search` may degrade to lexical-only retrieval; runtime failures after a provider is already selected do not fall back automatically. - Set an optional `memorySearch.fallback` only when you want a deliberate + Set an optional `memory.search.fallback` only when you want a deliberate single fallback. See [Memory Search](/concepts/memory-search) for the full list of providers and examples. diff --git a/docs/concepts/agent-runtimes.md b/docs/concepts/agent-runtimes.md index ff50a296c73e..7d902d2d53d5 100644 --- a/docs/concepts/agent-runtimes.md +++ b/docs/concepts/agent-runtimes.md @@ -133,7 +133,7 @@ this order: 1. **Model-scoped runtime policy** wins. This lives in a configured provider model entry, or in `agents.defaults.models["provider/model"].agentRuntime` - / `agents.list[].models["provider/model"].agentRuntime`. A provider + / `agents.entries.*.models["provider/model"].agentRuntime`. A provider wildcard such as `agents.defaults.models["vllm/*"].agentRuntime` applies after exact model policy, so dynamically discovered provider models can share one runtime without overriding exact per-model exceptions. @@ -145,7 +145,7 @@ this order: Whole-session and whole-agent runtime pins are ignored: `OPENCLAW_AGENT_RUNTIME`, session `agentHarnessId`/`agentRuntimeOverride` state, `agents.defaults.agentRuntime`, -and `agents.list[].agentRuntime`. Run `openclaw doctor --fix` to remove stale +and `agents.entries.*.agentRuntime`. Run `openclaw doctor --fix` to remove stale whole-agent runtime config and convert legacy runtime model refs where intent can be preserved. diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index acc5dc007df3..5cd185ebfe05 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -23,7 +23,7 @@ When sandboxing is enabled and `workspaceAccess` is not `"rw"`, tools operate in - Default: `~/.openclaw/workspace` - If `OPENCLAW_PROFILE` is set and not `"default"`, the default becomes `~/.openclaw/workspace-`. - `OPENCLAW_WORKSPACE_DIR` overrides both of the above when set. -- Non-default agents (`agents.list[]`) without an explicit workspace resolve to `/workspace-`, not the shared default workspace. +- Non-default agents (`agents.entries.*`) without an explicit workspace resolve to `/workspace-`, not the shared default workspace. Override in `~/.openclaw/openclaw.json`: @@ -37,7 +37,7 @@ Override in `~/.openclaw/openclaw.json`: } ``` -Per-agent override: `agents.list[].workspace`. +Per-agent override: `agents.entries.*.workspace`. `openclaw onboard`, `openclaw configure`, or `openclaw setup` create the workspace and seed the bootstrap files if they are missing. @@ -233,7 +233,7 @@ Suggested `.gitignore` starter: ## Advanced notes -- Multi-agent routing can use different workspaces per agent via `agents.list[].workspace`. See [Channel routing](/channels/channel-routing) for routing configuration. +- Multi-agent routing can use different workspaces per agent via `agents.entries.*.workspace`. See [Channel routing](/channels/channel-routing) for routing configuration. - If `agents.defaults.sandbox` is enabled, non-main sessions can use per-session sandbox workspaces under `agents.defaults.sandbox.workspaceRoot`. ## Related diff --git a/docs/concepts/agent.md b/docs/concepts/agent.md index 4e01450b6f2f..d22436c79874 100644 --- a/docs/concepts/agent.md +++ b/docs/concepts/agent.md @@ -15,7 +15,7 @@ contain, which files get injected, and how sessions bootstrap against it. ## Workspace (required) Each agent uses a single workspace directory (`agents.defaults.workspace`, or -`agents.list[].workspace` per agent) as its **only** working directory (`cwd`) +`agents.entries.*.workspace` per agent) as its **only** working directory (`cwd`) for tools and context. Recommended: use `openclaw setup` to create `~/.openclaw/openclaw.json` if missing and initialize the workspace files. diff --git a/docs/concepts/commitments.md b/docs/concepts/commitments.md index 51ecd876b968..126feb09dd3b 100644 --- a/docs/concepts/commitments.md +++ b/docs/concepts/commitments.md @@ -1,152 +1,36 @@ --- -summary: "Inferred follow-up memory for check-ins that are not exact reminders" +summary: "Status and cleanup guidance for retired inferred follow-up commitments" title: "Inferred commitments" sidebarTitle: "Commitments" read_when: - - You want OpenClaw to remember natural follow-ups - - You want to understand how inferred check-ins differ from reminders - - You want to review or dismiss follow-up commitments + - You are upgrading a configuration that used inferred commitments + - You want to inspect or dismiss previously stored follow-up records --- -Commitments are short-lived follow-up memories. When enabled, OpenClaw can -notice that a conversation created a future check-in opportunity and remember -to bring it back later. +The inferred commitments experiment is retired. OpenClaw no longer extracts new +conversation follow-ups or delivers them through heartbeat, and the former +`commitments` config block is removed by `openclaw doctor --fix`. -Examples: +Exact reminders and scheduled work continue to use +[scheduled tasks](/automation/cron-jobs). Durable conversational facts belong in +[memory](/concepts/memory). -- You mention an interview tomorrow. OpenClaw may check in afterward. -- You say you are exhausted. OpenClaw may ask later whether you slept. -- The agent says it will follow up after something changes. OpenClaw may track - that open loop. +## Existing records -Commitments are not durable facts like `MEMORY.md`, and they are not exact -reminders. They sit between memory and automation: OpenClaw remembers a -conversation-bound obligation, then heartbeat delivers it when it is due. - -## Enable commitments - -Commitments are off by default (`commitments.enabled: false`). Enable them in config: +Previously stored commitments remain in the shared SQLite state database so an +upgrade does not destroy operator-visible history. Use the legacy maintenance +CLI to inspect or dismiss those rows: ```bash -openclaw config set commitments.enabled true -openclaw config set commitments.maxPerDay 3 -``` - -Equivalent `openclaw.json`: - -```json -{ - "commitments": { - "enabled": true, - "maxPerDay": 3 - } -} -``` - -`commitments.maxPerDay` limits how many inferred follow-ups can be delivered -per agent session in a rolling day. The default is `3`. - -## How it works - -After an agent reply, OpenClaw may run a hidden background extraction pass in a -separate context, with tools disabled. That pass looks only for inferred follow-up commitments. It -does not write into the visible conversation and it does not ask the main agent -to reason about the extraction. - -When it finds a high-confidence candidate, OpenClaw stores a commitment with: - -- the agent id -- the session key -- the original channel and delivery target -- a due window -- a short suggested check-in -- non-instructional metadata for heartbeat to decide whether to send it - -Delivery happens through heartbeat. When a commitment becomes due, heartbeat -adds the commitment to the heartbeat turn for the same agent and channel scope. -The prompt explicitly warns that commitment metadata is untrusted and instructs -the model not to follow instructions in it or use tools because of it. The -model can send one natural check-in or reply `HEARTBEAT_OK` to dismiss it. -If heartbeat is configured with `target: "none"`, due commitments remain -internal and do not send external check-ins. Commitment delivery prompts do not -replay the original conversation text, only the suggested check-in and -metadata, and due-commitment heartbeat turns run without OpenClaw tools. - -OpenClaw never delivers an inferred commitment immediately after writing it. -The due time is clamped to at least one heartbeat interval after the commitment -is created, so the follow-up cannot echo back in the same moment it was -inferred. - -## Scope - -Commitments are scoped to the exact agent and channel context where they were -created. A follow-up inferred while talking to one agent in Discord is not -delivered by another agent, another channel, or an unrelated session. - -This scope is part of the feature. Natural check-ins should feel like the same -conversation continuing, not like a global reminder system. - -## Commitments vs reminders - -| Need | Use | -| ----------------------------------------------- | ---------------------------------------- | -| "Remind me at 3 PM" | [Scheduled tasks](/automation/cron-jobs) | -| "Ping me in 20 minutes" | [Scheduled tasks](/automation/cron-jobs) | -| "Run this report every weekday" | [Scheduled tasks](/automation/cron-jobs) | -| "I have an interview tomorrow" | Commitments | -| "I was up all night" | Commitments | -| "Follow up if I do not answer this open thread" | Commitments | - -Exact user requests already belong to the scheduler path. Commitments are only -for inferred follow-ups: the moments where the user did not ask for a reminder, -but the conversation clearly created a useful future check-in. - -## Manage commitments - -Use the CLI to inspect and clear stored commitments: - -```bash -openclaw commitments openclaw commitments --all -openclaw commitments --agent main -openclaw commitments --status snoozed openclaw commitments dismiss cm_abc123 ``` -See [`openclaw commitments`](/cli/commitments) for the full command reference. - -## Privacy and cost - -Commitment extraction uses an LLM pass, so enabling it adds background model -usage after eligible turns. The pass is hidden from the user-visible -conversation, but it can read the recent exchange needed to decide whether a -follow-up exists. - -Stored commitments are local OpenClaw operational memory in the shared SQLite -state database, not long-term memory. Disable the feature with: - -```bash -openclaw config set commitments.enabled false -``` - -## Troubleshooting - -If expected follow-ups are not appearing: - -- Confirm `commitments.enabled` is `true`. -- Check `openclaw commitments --all` for pending, dismissed, snoozed, or expired - records. -- Make sure heartbeat is running for the agent. -- Check whether `commitments.maxPerDay` has already been reached for that - agent session. -- Remember that exact reminders are skipped by commitment extraction and should - appear under [scheduled tasks](/automation/cron-jobs) instead. +See [`openclaw commitments`](/cli/commitments) for the maintenance command +reference. ## Related -- [Memory overview](/concepts/memory) -- [Active memory](/concepts/active-memory) -- [Heartbeat](/gateway/heartbeat) - [Scheduled tasks](/automation/cron-jobs) -- [`openclaw commitments`](/cli/commitments) -- [Configuration reference](/gateway/configuration-reference#commitments) +- [Memory overview](/concepts/memory) +- [Heartbeat](/gateway/heartbeat) diff --git a/docs/concepts/compaction.md b/docs/concepts/compaction.md index da855bedfafc..761cd0712556 100644 --- a/docs/concepts/compaction.md +++ b/docs/concepts/compaction.md @@ -100,7 +100,7 @@ When unset, compaction starts with the active session model. If summarization fa ### Identifier preservation -Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). Override with `identifierPolicy: "off"` to disable, or `identifierPolicy: "custom"` plus `identifierInstructions` for custom guidance. +Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). Override with `identifierPolicy: "off"` to disable. Custom guidance belongs in a compaction provider's `summarize()` implementation. ### Active transcript byte guard diff --git a/docs/concepts/experimental-features.md b/docs/concepts/experimental-features.md index abcb7df5c876..9c8a3fa1c2c9 100644 --- a/docs/concepts/experimental-features.md +++ b/docs/concepts/experimental-features.md @@ -16,14 +16,13 @@ Experimental features are preview surfaces behind explicit flags. They need more ## Currently documented flags -| Surface | Key | Use it when | More | -| ------------------------ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| Local model runtime | `agents.defaults.experimental.localModelLean`, `agents.list[].experimental.localModelLean` | A smaller or stricter local backend chokes on OpenClaw's full default tool surface | [Local Models](/gateway/local-models) | -| Memory search | `agents.defaults.memorySearch.experimental.sessionMemory` | You want `memory_search` to index prior session transcripts and accept the extra storage/indexing cost | [Memory configuration reference](/reference/memory-config#session-memory-search-experimental) | -| Codex harness | `plugins.entries.codex.config.appServer.experimental.sandboxExecServer` | You want native Codex app-server 0.132.0 or newer to target an OpenClaw sandbox-backed exec-server instead of disabling Code Mode | [Codex harness reference](/plugins/codex-harness-reference#sandboxed-native-execution) | -| Structured planning tool | `tools.experimental.planTool` | You want the structured `update_plan` tool exposed for multi-step work tracking in compatible runtimes and UIs | [Gateway configuration reference](/gateway/config-tools#toolsexperimental) | -| Code Mode | `tools.codeMode.enabled` | You want compact code-orchestrated access to a hidden OpenClaw tool catalog | [Code Mode](/tools/code-mode) | -| Swarm | `tools.swarm.enabled` | You want Code Mode scripts to orchestrate bounded groups of sub-agents in parallel | [Swarm](/tools/swarm) | +| Surface | Key | Use it when | More | +| ------------------------ | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Local model runtime | `agents.defaults.experimental.localModelLean`, `agents.entries.*.experimental.localModelLean` | A smaller or stricter local backend chokes on OpenClaw's full default tool surface | [Local Models](/gateway/local-models) | +| Codex harness | `plugins.entries.codex.config.appServer.experimental.sandboxExecServer` | You want native Codex app-server 0.132.0 or newer to target an OpenClaw sandbox-backed exec-server instead of disabling Code Mode | [Codex harness reference](/plugins/codex-harness-reference#sandboxed-native-execution) | +| Structured planning tool | `tools.experimental.planTool` | You want the structured `update_plan` tool exposed for multi-step work tracking in compatible runtimes and UIs | [Gateway configuration reference](/gateway/config-tools#toolsexperimental) | +| Code Mode | `tools.codeMode.enabled` | You want compact code-orchestrated access to a hidden OpenClaw tool catalog | [Code Mode](/tools/code-mode) | +| Swarm | `tools.swarm.enabled` | You want Code Mode scripts to orchestrate bounded groups of sub-agents in parallel | [Swarm](/tools/swarm) | ## Control UI Labs @@ -38,7 +37,7 @@ runs without restarting the Gateway. ## Local model lean mode -`agents.defaults.experimental.localModelLean: true` drops heavyweight optional tools from the agent's direct surface every turn: `browser`, `cron`, `message`, `image_generate`, `music_generate`, `video_generate`, `tts`, and `pdf`. Explicitly allowed or delivery-required tools remain available, though Tool Search may catalog them instead of exposing them directly. Lean mode also defaults plugin/MCP/client catalogs to structured Tool Search (`tool_search`, `tool_describe`, `tool_call`) when `tools.toolSearch` is not already set. Use `agents.list[].experimental.localModelLean` to scope this to one agent. +`agents.defaults.experimental.localModelLean: true` drops heavyweight optional tools from the agent's direct surface every turn: `browser`, `cron`, `message`, `image_generate`, `music_generate`, `video_generate`, `tts`, and `pdf`. Explicitly allowed or delivery-required tools remain available, though Tool Search may catalog them instead of exposing them directly. Lean mode also defaults plugin/MCP/client catalogs to structured Tool Search (`tool_search`, `tool_describe`, `tool_call`) when `tools.toolSearch` is not already set. Use `agents.entries.*.experimental.localModelLean` to scope this to one agent. During onboarding, a verified `ollama` or `lmstudio` inference route automatically sets `agents.defaults.experimental.localModelLean: true` when that value is absent. OpenClaw records that the setting came from onboarding, so a later verified non-local route lifts only the automatic setting. An explicitly configured `true` or `false` is preserved. Other self-hosted and OpenAI-compatible providers are not inferred from model names or URLs. diff --git a/docs/concepts/memory-builtin.md b/docs/concepts/memory-builtin.md index 3b72ab20f731..772402d58e8e 100644 --- a/docs/concepts/memory-builtin.md +++ b/docs/concepts/memory-builtin.md @@ -28,11 +28,9 @@ To set a provider explicitly: ```json5 { - agents: { - defaults: { - memorySearch: { - provider: "openai", - }, + memory: { + search: { + provider: "openai", }, }, } @@ -49,14 +47,12 @@ openclaw plugins install @openclaw/llama-cpp-provider ```json5 { - agents: { - defaults: { - memorySearch: { - provider: "local", - fallback: "none", - local: { - modelPath: "~/.node-llama-cpp/models/embeddinggemma-300m-qat-Q8_0.gguf", - }, + memory: { + search: { + provider: "local", + fallback: "none", + local: { + modelPath: "~/.node-llama-cpp/models/embeddinggemma-300m-qat-Q8_0.gguf", }, }, }, @@ -79,7 +75,7 @@ openclaw plugins install @openclaw/llama-cpp-provider | OpenAI-compatible | `openai-compatible` | Generic `/v1/embeddings` endpoint | | Voyage | `voyage` | | -Set `memorySearch.provider` to switch away from OpenAI. +Set `memory.search.provider` to switch away from OpenAI. ## How indexing works @@ -98,7 +94,7 @@ OpenClaw indexes `MEMORY.md` and `memory/*.md` into chunks (400 tokens with You can also index Markdown files outside the workspace with -`memorySearch.extraPaths`. See the +`memory.search.extraPaths`. See the [configuration reference](/reference/memory-config#additional-memory-paths). @@ -130,7 +126,7 @@ openclaw memory index --force --agent main ``` Both standalone CLI commands and the Gateway use the same `local` provider id. -Set `memorySearch.provider: "local"` when you want local embeddings. +Set `memory.search.provider: "local"` when you want local embeddings. **Stale results?** Run `openclaw memory index --force` to rebuild. The watcher may miss changes in rare edge cases. diff --git a/docs/concepts/memory-qmd.md b/docs/concepts/memory-qmd.md index 270e2e202fe4..07a18d481694 100644 --- a/docs/concepts/memory-qmd.md +++ b/docs/concepts/memory-qmd.md @@ -50,12 +50,9 @@ present. ## How the sidecar works -- OpenClaw creates collections from your workspace memory files and any - configured `memory.qmd.paths`, then runs `qmd update` when the QMD manager - opens and periodically afterward (`memory.qmd.update.interval`, default - `5m`). Refreshes run through QMD subprocesses, not an in-process filesystem - crawl. Semantic search modes also run `qmd embed` - (`memory.qmd.update.embedInterval`, default `60m`). +- OpenClaw creates collections from workspace memory files and configured + `memory.qmd.paths`. The QMD adapter owns update, embedding, debounce, and + timeout heuristics; these are not user configuration. - QMD continues to own its `index.sqlite`, YAML collection config, and model downloads under the per-agent QMD home; these are external-tool artifacts, not OpenClaw state tables. OpenClaw-owned coordination lives only in SQLite: @@ -71,15 +68,8 @@ present. tree. Lowercase `memory.md` is not indexed as a root memory file. - QMD's own scanner ignores hidden paths and common dependency/build directories such as `.git`, `.cache`, `node_modules`, `vendor`, `dist`, and - `build`. Gateway startup does not initialize QMD by default - (`memory.qmd.update.startup` defaults to `off`), so cold boot avoids - importing the memory runtime or creating the long-lived watcher before - memory is first used. -- Set `memory.qmd.update.startup` to `idle` or `immediate` to initialize QMD - at gateway start anyway. `memory.qmd.update.onBoot` defaults to `true` and - runs the initial refresh at startup; set it to `false` to skip that - immediate refresh (the long-lived manager still opens when update or embed - intervals are configured, so QMD keeps owning its regular watcher/timers). + `build`. Gateway startup keeps QMD lazy; the manager initializes when memory + is first used. - Searches use the configured `searchMode` (default: `search`; also supports `vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic vector readiness probes and embedding maintenance in that mode. If a mode @@ -169,20 +159,16 @@ correct collection root. ## Indexing session transcripts Enable session indexing to recall earlier conversations. QMD needs both the -general `memorySearch` session source and the QMD transcript exporter: +general `memory.search` session source and the QMD transcript exporter: ```json5 { - agents: { - defaults: { - memorySearch: { - experimental: { sessionMemory: true }, - sources: ["memory", "sessions"], - }, - }, - }, memory: { backend: "qmd", + search: { + experimental: { sessionMemory: true }, + sources: ["memory", "sessions"], + }, qmd: { sessions: { enabled: true }, }, @@ -192,8 +178,8 @@ general `memorySearch` session source and the QMD transcript exporter: Transcripts export as sanitized User/Assistant turns into a dedicated QMD collection under `~/.openclaw/agents//qmd/sessions/`. Setting only -`memorySearch.experimental.sessionMemory` does not export transcripts into -QMD. +`sources: ["sessions"]` does not export transcripts into QMD; also enable +`rememberAcrossConversations` or explicit QMD session export. Session hits are still filtered by [`tools.sessions.visibility`](/gateway/config-tools#toolssessions). The diff --git a/docs/concepts/memory-search.md b/docs/concepts/memory-search.md index ef21a1857905..0d2c8998e667 100644 --- a/docs/concepts/memory-search.md +++ b/docs/concepts/memory-search.md @@ -18,11 +18,9 @@ explicitly: ```json5 { - agents: { - defaults: { - memorySearch: { - provider: "openai", // or "gemini", "voyage", "mistral", "bedrock", "local", "ollama", "lmstudio", "github-copilot", "openai-compatible" - }, + memory: { + search: { + provider: "openai", // or "gemini", "voyage", "mistral", "bedrock", "local", "ollama", "lmstudio", "github-copilot", "openai-compatible" }, }, } @@ -132,14 +130,12 @@ different daily notes. ```json5 { - agents: { - defaults: { - memorySearch: { - query: { - hybrid: { - mmr: { enabled: true }, - temporalDecay: { enabled: true }, - }, + memory: { + search: { + query: { + hybrid: { + mmr: { enabled: true }, + temporalDecay: { enabled: true }, }, }, }, @@ -150,7 +146,7 @@ different daily notes. ## Multimodal memory With `gemini-embedding-2-preview`, you can index images and audio alongside -Markdown. This only applies to files under `memorySearch.extraPaths`; default +Markdown. This only applies to files under `memory.search.extraPaths`; default memory roots (`MEMORY.md`, `memory/*.md`) stay Markdown-only. Search queries remain text, but they match against visual and audio content. See [Memory configuration reference](/reference/memory-config#multimodal-memory-gemini) @@ -187,9 +183,8 @@ and `sources` alone do not export transcripts into QMD. See **Only keyword matches?** Your embedding provider may not be configured. Check `openclaw memory status --deep`. -**Local embeddings time out?** `ollama`, `lmstudio`, and `local` use a longer -inline batch timeout by default. If the host is just slow, set -`agents.defaults.memorySearch.sync.embeddingBatchTimeoutSeconds` and rerun +**Local embeddings time out?** `ollama`, `lmstudio`, and `local` use longer +provider-owned batch deadlines. Check provider health and rerun `openclaw memory index --force`. **CJK text not found?** Rebuild the FTS index with diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index fc26b2ff94ad..d80caf2d320b 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -123,21 +123,20 @@ This is not a required schema for every memory; simple facts can stay concise. Use action-sensitive boundaries when losing timing, authority, expiry, or safe-to-act context could cause the agent to do the wrong thing later. -Use [commitments](/concepts/commitments) for inferred, short-lived follow-ups. Use [scheduled tasks](/automation/cron-jobs) for exact reminders, timed checks, -and recurring work. Memory can still summarize the durable context around -either path. +and recurring work. Memory can still summarize the durable context around that +work. -## Inferred commitments +## Retired inferred commitments Some future follow-ups are not durable facts. If you mention an interview tomorrow, the useful memory may be "check in after the interview," not "store this forever in `MEMORY.md`." -[Commitments](/concepts/commitments) are opt-in, short-lived follow-up -memories for that case. OpenClaw infers them in a hidden background pass, -scopes them to the same agent and channel, and delivers due check-ins through -heartbeat. Explicit reminders still use [scheduled tasks](/automation/cron-jobs). +The inferred commitments experiment is retired. OpenClaw no longer extracts or +delivers those follow-ups. Use [scheduled tasks](/automation/cron-jobs) for +future actions; the legacy `openclaw commitments` command remains available to +inspect or dismiss existing stored rows. ## Memory tools @@ -158,7 +157,7 @@ for any supported provider. OpenClaw uses OpenAI embeddings by default. Set -`agents.defaults.memorySearch.provider` explicitly to use Gemini, Voyage, +`memory.search.provider` explicitly to use Gemini, Voyage, Mistral, Bedrock, DeepInfra, local GGUF, Ollama, LM Studio, GitHub Copilot, or a generic OpenAI-compatible endpoint. diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index cc27ac8ca7b0..b94fd0e25540 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -139,7 +139,7 @@ Details: [Thinking + reasoning directives](/tools/thinking) and [Token use](/ref ## Prefixes, threading, and replies -- Outbound prefix cascade: `messages.responsePrefix`, `channels..responsePrefix`, `channels..accounts..responsePrefix`. WhatsApp also has `channels.whatsapp.messagePrefix` for an inbound prefix. +- Outbound prefixes live at `channels..responsePrefix` and `channels..accounts..responsePrefix`. Account values win. Doctor copies the global fallback into configured channel blocks when those canonical fields are unset; `messages.responsePrefix` remains as a fallback for implicit and custom channels. - Reply threading via `replyToMode` and per-channel defaults. Details: [Configuration](/gateway/config-agents#messages) and channel docs. diff --git a/docs/concepts/model-failover.md b/docs/concepts/model-failover.md index 496427c7d555..b1d93732b397 100644 --- a/docs/concepts/model-failover.md +++ b/docs/concepts/model-failover.md @@ -46,7 +46,7 @@ Fallback execution is turn-local. The reply runner persists only fallback notice The selection source controls whether the fallback chain is allowed: - **Configured default**: `agents.defaults.model.primary` uses `agents.defaults.model.fallbacks`. -- **Agent primary**: `agents.list[].model` is strict unless that agent's model object includes its own `fallbacks`. Use `fallbacks: []` to make the strict behavior explicit, or a non-empty list to opt that agent into model fallback. +- **Agent primary**: `agents.entries.*.model` is strict unless that agent's model object includes its own `fallbacks`. Use `fallbacks: []` to make the strict behavior explicit, or a non-empty list to opt that agent into model fallback. - **Runtime fallback**: the fallback candidate applies only to the current turn. The next turn starts from the selected primary again. OpenClaw still recognizes previously stored `modelOverrideSource: "auto"` entries, probes their configured origin every 5 minutes, and clears them once the origin recovers. `/new`, `/reset`, and `sessions.reset` also clear those entries. - **User session override**: `/model`, the model picker, `session_status(model=...)`, and `sessions.patch` write `modelOverrideSource: "user"`. This is an exact session selection. If the selected provider/model fails before producing a reply, OpenClaw reports the failure instead of answering from an unrelated configured fallback. - **Legacy session override**: older session entries may have `modelOverride` without `modelOverrideSource`. OpenClaw treats those as user overrides so an explicit old selection is not silently converted into fallback behavior. diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 8ae30937449f..f855af86304b 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -56,12 +56,12 @@ OpenAI API-key and ChatGPT/Codex subscription credentials remain distinct. See Related model-config surfaces: - `agents.defaults.models` stores aliases and per-model settings. Adding an entry does not restrict model overrides. -- `agents.defaults.modelPolicy.allow` is the optional override allowlist. Use exact refs or trailing prefix wildcards such as `provider/*` and `provider/namespace/*`; omit it or set `[]` to allow any model. Per-agent `agents.list[].modelPolicy.allow` replaces the default policy for that agent. -- `agents.defaults.utilityModel` is an optional lower-cost model for short internal tasks such as generated dashboard session titles, supported channel thread/topic titles, and progress narration. Per-agent `agents.list[].utilityModel` overrides it. When unset, OpenClaw uses the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`), otherwise the agent's primary model; set it to an empty string to disable utility routing. Generated titles retry once with the primary model when a distinct utility model fails. For dashboard titles, automatic utility derivation and the regular fallback follow the effective session provider and auth profile; an explicit utility model keeps its configured provider/auth. An empty utility model skips only the alternate small-model route, not dashboard title generation. Utility tasks are separate model calls and may send bounded task content to the selected model provider. +- `agents.defaults.modelPolicy.allow` is the optional override allowlist. Use exact refs or trailing prefix wildcards such as `provider/*` and `provider/namespace/*`; omit it or set `[]` to allow any model. Per-agent `agents.entries.*.modelPolicy.allow` replaces the default policy for that agent. +- `agents.defaults.utilityModel` is an optional lower-cost model for short internal tasks such as generated dashboard session titles, supported channel thread/topic titles, and progress narration. Per-agent `agents.entries.*.utilityModel` overrides it. When unset, OpenClaw uses the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`), otherwise the agent's primary model; set it to an empty string to disable utility routing. Generated titles retry once with the primary model when a distinct utility model fails. For dashboard titles, automatic utility derivation and the regular fallback follow the effective session provider and auth profile; an explicit utility model keeps its configured provider/auth. An empty utility model skips only the alternate small-model route, not dashboard title generation. Utility tasks are separate model calls and may send bounded task content to the selected model provider. - `agents.defaults.imageModel` is used only when the primary model cannot accept images. - `agents.defaults.pdfModel` is used by the `pdf` tool. If unset, the tool falls back to `imageModel`, then the resolved session/default model. -- `agents.defaults.imageGenerationModel`, `musicGenerationModel`, and `videoGenerationModel` back the shared media-generation tools. If unset, each tool infers an auth-backed provider default: current default provider first, then the remaining registered providers for that capability in provider-id order. Set `agents.defaults.mediaGenerationAutoProviderFallback: false` to disable that cross-provider inference while keeping explicit fallbacks. -- Per-agent `agents.list[].model` (plus bindings) overrides `agents.defaults.model` — see [Multi-agent routing](/concepts/multi-agent). +- `agents.defaults.mediaModels.{image,music,video}` backs the shared media-generation tools. If unset, each tool infers an auth-backed provider default: current default provider first, then the remaining registered providers for that capability in provider-id order. Cross-provider fallback is the fixed default behavior. +- Per-agent `agents.entries.*.model` (plus bindings) overrides `agents.defaults.model` — see [Multi-agent routing](/concepts/multi-agent). Full key reference, defaults, and JSON5 examples: [Configuration reference](/gateway/config-agents#agent-defaults). @@ -108,7 +108,7 @@ Reauthentication preserves an existing explicit primary model, including ## "Model is not allowed" (and why replies stop) -If `agents.defaults.modelPolicy.allow` is non-empty, it becomes the allowlist for `/model`, session overrides, and `--model`. Selecting a model outside that allowlist returns before any normal reply is generated. A per-agent `agents.list[].modelPolicy.allow` replaces the default policy for that agent. +If `agents.defaults.modelPolicy.allow` is non-empty, it becomes the allowlist for `/model`, session overrides, and `--model`. Selecting a model outside that allowlist returns before any normal reply is generated. A per-agent `agents.entries.*.modelPolicy.allow` replaces the default policy for that agent. ```text Model override "provider/model" is not allowed by agents.defaults.modelPolicy.allow. diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 170fe427eda6..70c794be5062 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -32,7 +32,7 @@ Auth profiles are per-agent, read from: Never reuse `agentDir` across agents — it causes auth/session state collisions. When a secondary agent's local OAuth credential is expired or its refresh fails, OpenClaw reads through to the default/main agent's credential for the same profile id and adopts whichever token is freshest, without copying the refresh token into the secondary agent's store. If you want a fully independent OAuth account, sign in from that agent. If you copy credentials manually, copy only portable static `api_key` or `token` profiles — OAuth refresh material is not portable by default (`copyToAgents` can opt a profile in explicitly). -Skills load from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filter by the effective agent skill allowlist. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for a per-agent replacement (explicit entries replace the default, they do not merge). See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent allowlists](/tools/skills#agent-allowlists). +Skills load from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filter by the effective agent skill allowlist. Use `agents.defaults.skills` for a shared baseline and `agents.entries.*.skills` for a per-agent replacement (explicit entries replace the default, they do not merge). See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent allowlists](/tools/skills#agent-allowlists). Plugin-owned storage follows that plugin's configuration; adding a second agent does not automatically split every global plugin store. For example, configure @@ -45,15 +45,15 @@ when personas must not share compiled wiki knowledge. ## Paths -| What | Default | Override | -| -------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| Config | `~/.openclaw/openclaw.json` | `OPENCLAW_CONFIG_PATH` | -| State dir | `~/.openclaw` | `OPENCLAW_STATE_DIR` | -| Default agent's workspace | `~/.openclaw/workspace` (or `workspace-` when `OPENCLAW_PROFILE` is set) | `agents.list[].workspace`, then `agents.defaults.workspace`, or `OPENCLAW_WORKSPACE_DIR` | -| Other agents' workspace | `/workspace-` (or `/` when set) | `agents.list[].workspace` | -| Agent dir | `~/.openclaw/agents//agent` | `agents.list[].agentDir` | -| Sessions and transcripts | `~/.openclaw/agents//agent/openclaw-agent.sqlite` | — | -| Legacy/archive session artifacts | `~/.openclaw/agents//sessions` | — | +| What | Default | Override | +| -------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Config | `~/.openclaw/openclaw.json` | `OPENCLAW_CONFIG_PATH` | +| State dir | `~/.openclaw` | `OPENCLAW_STATE_DIR` | +| Default agent's workspace | `~/.openclaw/workspace` (or `workspace-` when `OPENCLAW_PROFILE` is set) | `agents.entries.*.workspace`, then `agents.defaults.workspace`, or `OPENCLAW_WORKSPACE_DIR` | +| Other agents' workspace | `/workspace-` (or `/` when set) | `agents.entries.*.workspace` | +| Agent dir | `~/.openclaw/agents//agent` | `agents.entries.*.agentDir` | +| Sessions and transcripts | `~/.openclaw/agents//agent/openclaw-agent.sqlite` | — | +| Legacy/archive session artifacts | `~/.openclaw/agents//sessions` | — | ### Single-agent mode (default) @@ -107,7 +107,7 @@ openclaw agents list --bindings - Add agents under `agents.list`, channel accounts under `channels..accounts`, and connect them with `bindings` (examples below). + Add agents under `agents.entries`, channel accounts under `channels..accounts`, and connect them with `bindings` (examples below). ```bash @@ -161,34 +161,35 @@ filtering, migration, and trust-boundary details. ## Cross-agent QMD memory search -To let one agent search another agent's QMD session transcripts, add extra collections under `agents.list[].memorySearch.qmd.extraCollections`. Use `agents.defaults.memorySearch.qmd.extraCollections` when every agent should share the same collections. +To let one agent search another agent's QMD session transcripts, add extra collections under `agents.entries.*.memory.search.qmd.extraCollections`. Use `memory.search.qmd.extraCollections` when every agent should share the same collections. ```json5 { agents: { defaults: { workspace: "~/workspaces/main", - memorySearch: { - qmd: { - extraCollections: [{ path: "~/agents/family/sessions", name: "family-sessions" }], - }, - }, }, - list: [ - { - id: "main", + entries: { + main: { workspace: "~/workspaces/main", - memorySearch: { - qmd: { - extraCollections: [{ path: "notes" }], // resolves inside workspace -> collection named "notes-main" + memory: { + search: { + qmd: { + extraCollections: [{ path: "notes" }], // resolves inside workspace -> collection named "notes-main" + }, }, }, }, - { id: "family", workspace: "~/workspaces/family" }, - ], + family: { workspace: "~/workspaces/family" }, + }, }, memory: { backend: "qmd", + search: { + qmd: { + extraCollections: [{ path: "~/agents/family/sessions", name: "family-sessions" }], + }, + }, qmd: { includeDefaultMemory: false }, }, } @@ -536,7 +537,7 @@ Channels supporting multiple accounts: `discord`, `feishu`, `googlechat`, `imess } ``` - Tool allow/deny lists are **tools**, not skills. If a skill needs to run a binary, ensure `exec` is allowed and the binary exists in the sandbox. For stricter gating, set `agents.list[].groupChat.mentionPatterns` and keep group allowlists enabled for the channel. + Tool allow/deny lists are **tools**, not skills. If a skill needs to run a binary, ensure `exec` is allowed and the binary exists in the sandbox. For stricter gating, set `agents.entries.*.groupChat.mentionPatterns` and keep group allowlists enabled for the channel. @@ -589,7 +590,7 @@ This gives you: - **Flexible policies**: different permissions per agent. -`tools.elevated` has both a global gate (`tools.elevated.enabled`/`allowFrom`) and a per-agent gate (`agents.list[].tools.elevated.enabled`/`allowFrom`). The per-agent gate can only further restrict the global one — both must allow a sender for elevated commands to run. For group targeting, use `agents.list[].groupChat.mentionPatterns` so @mentions map cleanly to the intended agent. +`tools.elevated` has both a global gate (`tools.elevated.enabled`/`allowFrom`) and a per-agent gate (`agents.entries.*.tools.elevated.enabled`/`allowFrom`). The per-agent gate can only further restrict the global one — both must allow a sender for elevated commands to run. For group targeting, use `agents.entries.*.groupChat.mentionPatterns` so @mentions map cleanly to the intended agent. See [Multi-agent sandbox and tools](/tools/multi-agent-sandbox-tools) for detailed examples. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index ef29959fe934..27eb13835ee3 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -71,7 +71,7 @@ Verify your setup with `openclaw security audit`. ## Remember across conversations Separate transcripts control each conversation's local history. For a personal -or fully trusted agent, `memorySearch.rememberAcrossConversations: true` +or fully trusted agent, `memory.search.rememberAcrossConversations: true` adds an optional retrieval step across that agent's other private conversations; it does not combine their transcripts. @@ -134,9 +134,7 @@ Opt into automatic resets globally, then override them per chat type or channel: } ``` -`resetByType` supports `direct` (legacy alias `dm`), `group`, and `thread`. -Legacy top-level `session.idleMinutes` still works as a compatibility alias for -an idle-mode default when no `session.reset`/`resetByType` block is set. +`resetByType` supports `direct`, `group`, and `thread`. Doctor migrates legacy `dm` entries to `direct` and `session.idleMinutes` to `session.reset.idleMinutes`; the schema rejects both retired forms. ## Where state lives diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index a5a1593cd1c4..bf32a9320d55 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -120,7 +120,7 @@ replies, after the first block, so multi-bubble responses feel more natural. | `natural` | 800-2500ms random pause | | `custom` | `minMs`/`maxMs` | -Override per agent via `agents.list[].humanDelay`. Applies only to **block +Override per agent via `agents.entries.*.humanDelay`. Applies only to **block replies**, not final replies or tool summaries. ## "Stream chunks or everything" diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 6cdeb20b10d5..edbe2d01e5d8 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -35,7 +35,7 @@ The prompt is compact, with fixed sections: - **Safety**: short guardrail reminder against power-seeking behavior or bypassing oversight. - **Skills** (when available): tells the model how to load skill instructions on demand. - **OpenClaw Control**: prefer the `gateway` tool for config/restart work; do not invent CLI commands. -- **OpenClaw Self-Update**: inspect config safely with `config.schema.lookup`, patch with `config.patch`, replace the full config with `config.apply`, and run `update.run` only on explicit user request. The agent-facing `gateway` tool refuses to rewrite `tools.exec.ask` / `tools.exec.security`, including legacy `tools.bash.*` aliases that normalize to those protected paths. +- **OpenClaw Self-Update**: inspect config safely with `config.schema.lookup`, patch with `config.patch`, replace the full config with `config.apply`, and run `update.run` only on explicit user request. The agent-facing `gateway` tool refuses to rewrite `tools.exec.mode`. - **Workspace**: working directory (`agents.defaults.workspace`). - **Documentation**: local docs/source path and when to read them. - **Workspace Files (injected)**: notes that bootstrap files are included below. @@ -147,7 +147,7 @@ Native Codex turns receive this list as turn-scoped collaboration developer inst The location can point at a nested skill, such as `skills/personal/foo/SKILL.md`. Nesting is only organizational; the prompt uses the flat skill name from `SKILL.md` frontmatter. -Eligibility includes skill metadata gates, runtime environment/config checks, and the effective agent skill allowlist when `agents.defaults.skills` or `agents.list[].skills` is configured. Plugin-bundled skills are eligible only when their owning plugin is enabled, letting tool plugins expose deeper operating guides without embedding all of that guidance in every tool description. +Eligibility includes skill metadata gates, runtime environment/config checks, and the effective agent skill allowlist when `agents.defaults.skills` or `agents.entries.*.skills` is configured. Plugin-bundled skills are eligible only when their owning plugin is enabled, letting tool plugins expose deeper operating guides without embedding all of that guidance in every tool description. ```xml @@ -162,10 +162,10 @@ Eligibility includes skill metadata gates, runtime environment/config checks, an This keeps the base prompt small while still enabling targeted skill usage. Sizing is owned by the skills subsystem, separate from generic runtime read/injection sizing: -| Scope | Skills prompt budget | Runtime excerpt budget | -| --------- | ------------------------------------------------- | --------------------------------- | -| Global | `skills.limits.maxSkillsPromptChars` | `agents.defaults.contextLimits.*` | -| Per-agent | `agents.list[].skillsLimits.maxSkillsPromptChars` | `agents.list[].contextLimits.*` | +| Scope | Skills prompt budget | Runtime excerpt budget | +| --------- | ---------------------------------------------------- | ---------------------------------- | +| Global | `skills.limits.maxSkillsPromptChars` | `agents.defaults.contextLimits.*` | +| Per-agent | `agents.entries.*.skillsLimits.maxSkillsPromptChars` | `agents.entries.*.contextLimits.*` | The runtime excerpt budget covers `memory_get`, live tool results, and post-compaction `AGENTS.md` refreshes. diff --git a/docs/concepts/typing-indicators.md b/docs/concepts/typing-indicators.md index b2d8d70cfe20..96f9386cdfde 100644 --- a/docs/concepts/typing-indicators.md +++ b/docs/concepts/typing-indicators.md @@ -42,12 +42,17 @@ Set the agent-level default: } ``` -Override the mode per session: +Override the policy for one agent: ```json5 { - session: { - typingMode: "message", + agents: { + entries: { + support: { + typingMode: "message", + typingIntervalSeconds: 8, + }, + }, }, } ``` @@ -58,7 +63,7 @@ Override the mode per session: - `thinking` still reacts to streamed reasoning (`reasoningLevel: "stream"`), and can also start from active execution before reasoning deltas arrive. - Heartbeat typing is a liveness signal for the resolved delivery target. It starts at heartbeat run start instead of following `message` or `thinking` stream timing. Set `typingMode: "never"` to disable it. - Heartbeats do not show typing when the heartbeat target is `"none"`, when the target cannot be resolved, when chat delivery is disabled for the heartbeat, or when the channel does not support typing. -- `agents.defaults.typingIntervalSeconds` controls the **refresh cadence**, not the start time. Default: 6 seconds. +- `agents.defaults.typingIntervalSeconds` controls the **refresh cadence**, not the start time. Default: 6 seconds. `agents.entries.*.typingIntervalSeconds` can override it per agent. ## Related diff --git a/docs/docs_map.md b/docs/docs_map.md index 311814a9d39f..69c906cd4a24 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -143,7 +143,6 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Core concepts - H3: Scheduled tasks (cron) - H3: Tasks - - H3: Inferred commitments - H3: Task Flow - H3: Standing orders - H3: Hooks @@ -384,8 +383,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Restrict senders within a group - H3: Bot-authored messages - H2: Get group/user IDs - - H3: Group IDs (chatid, format: ocxxx) - - H3: User IDs (openid, format: ouxxx) + - H3: Group IDs (`chat_id`, format: `oc_xxx`) + - H3: User IDs (`open_id`, format: `ou_xxx`) - H2: Common commands - H2: Troubleshooting - H3: Bot does not respond in group chats @@ -1178,6 +1177,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Manual dispatches - H2: Runners - H2: Runner registration budget + - H2: Surface ratchets - H2: Local equivalents - H2: OpenClaw Performance - H2: Full Release Validation @@ -1204,6 +1204,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Test Performance Agent - H3: Duplicate PRs After Merge - H2: Local check gates and changed routing + - H3: Config baseline count ratchet - H2: Testbox validation - H2: Related @@ -1267,7 +1268,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Examples - H2: Command surface - H3: agents list - - H3: agents add [name] + - H3: `agents add [name]` - H3: agents bindings - H3: agents bind - H3: agents unbind @@ -1490,16 +1491,16 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Common options - H2: Commands - H3: openclaw devices list - - H3: openclaw devices approve [requestId] [--latest] + - H3: `openclaw devices approve [requestId] [--latest]` - H3: openclaw devices reject <requestId> - H3: openclaw devices remove <deviceId> - H3: openclaw devices rename --device <id> --name <label> - - H3: openclaw devices clear --yes [--pending] - - H3: openclaw devices rotate --device <id> --role <role> [--scope <scope...>] + - H3: `openclaw devices clear --yes [--pending]` + - H3: `openclaw devices rotate --device <id> --role <role> [--scope <scope...>]` - H3: openclaw devices revoke --device <id> --role <role> - H2: Notes - H2: Token drift recovery checklist - - H2: Paperclip / openclawgateway first-run approval + - H2: Paperclip / `openclaw_gateway` first-run approval - H2: Related ## cli/directory.md @@ -2370,13 +2371,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /concepts/commitments - Headings: - - H2: Enable commitments - - H2: How it works - - H2: Scope - - H2: Commitments vs reminders - - H2: Manage commitments - - H2: Privacy and cost - - H2: Troubleshooting + - H2: Existing records - H2: Related ## concepts/compaction.md @@ -2644,7 +2639,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: What goes where - H2: Import from coding assistants - H2: Action-sensitive memories - - H2: Inferred commitments + - H2: Retired inferred commitments - H2: Memory tools - H2: Memory search - H2: Memory backends @@ -3307,9 +3302,9 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Context budget ownership map - H4: agents.defaults.startupContext - H4: agents.defaults.contextLimits - - H4: agents.list[].contextLimits + - H4: `agents.entries.*.contextLimits` - H4: skills.limits.maxSkillsPromptChars - - H4: agents.list[].skillsLimits.maxSkillsPromptChars + - H4: `agents.entries.*.skillsLimits.maxSkillsPromptChars` - H3: agents.defaults.imageMaxDimensionPx - H3: agents.defaults.imageQuality - H3: agents.defaults.userTimezone @@ -3324,7 +3319,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Block streaming - H3: Typing indicators - H3: agents.defaults.sandbox - - H3: agents.list (per-agent overrides) + - H3: agents.entries (per-agent overrides) - H2: Multi-agent routing - H3: Binding match fields - H3: Per-agent access profiles @@ -3385,7 +3380,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: tools.media - H3: tools.agentToAgent - H3: tools.sessions - - H3: tools.sessionsspawn + - H3: `tools.sessions_spawn` - H3: tools.experimental - H3: agents.defaults.subagents - H2: Custom providers and base URLs @@ -3425,7 +3420,6 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Skills - H2: Plugins - H3: Codex harness plugin config - - H2: Commitments - H2: Browser - H2: UI - H2: Gateway @@ -3455,7 +3449,6 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Diagnostics - H2: Update - H2: ACP - - H2: CLI - H2: Wizard - H2: Identity - H2: Bridge (legacy, removed) @@ -3726,11 +3719,11 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Request shape - H2: Items (input) - H3: message - - H3: functioncalloutput (turn-based tools) - - H3: reasoning and itemreference + - H3: `function_call_output` (turn-based tools) + - H3: reasoning and `item_reference` - H2: Tools (client-side function tools) - - H2: Images (inputimage) - - H2: Files (inputfile) + - H2: Images (`input_image`) + - H2: Files (`input_file`) - H2: File + image limits - H2: Streaming (SSE) - H2: Usage @@ -4199,6 +4192,12 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /help/environment - Headings: - H2: Precedence (highest to lowest) + - H2: Supported operator-facing variables + - H3: Paths and instances + - H3: Gateway and authentication + - H3: Provider credentials + - H3: Logging and diagnostics + - H3: Feature and runtime toggles - H2: Provider credentials and workspace .env - H2: Config env block - H2: Shell env import @@ -4210,7 +4209,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Path-related env vars - H2: Agent helper tool downloads - H2: Logging - - H3: OPENCLAWHOME + - H3: `OPENCLAW_HOME` - H2: nvm users: webfetch TLS failures - H2: Legacy environment variables - H2: Related @@ -4916,7 +4915,6 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Auto-detection (default) - H2: Config examples - H3: Provider + CLI fallback (OpenAI + Whisper CLI) - - H3: Provider-only with scope gating - H3: Provider-only (Deepgram) - H3: Provider-only (Mistral Voxtral) - H3: Provider-only (SenseAudio) @@ -5067,7 +5065,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H2: Behavior (macOS) - H2: Voice directives in replies - - H2: Config (/.openclaw/openclaw.json) + - H2: Config (`~/.openclaw/openclaw.json`) - H2: macOS UI - H2: Android UI - H2: Notes @@ -5159,10 +5157,10 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: 2. Decisions (maintainer, 2026-07-17) - H2: 3. Architecture overview - H2: 4. Config gate (v1) - - H2: 5. Core: collector-mode spawn + agentswait (v1) - - H3: 5.1 sessionsspawn additions (all gated on swarm enabled) + - H2: 5. Core: collector-mode spawn + `agents_wait` (v1) + - H3: 5.1 `sessions_spawn` additions (all gated on swarm enabled) - H3: 5.2 Approvals fail-closed - - H3: 5.3 agentswait tool (new, gated) + - H3: 5.3 `agents_wait` tool (new, gated) - H3: 5.4 Caps enforcement - H2: 6. Testing contract (v1, lane A) - H2: 7. QuickJS guest surface (lane B, after core) @@ -5267,7 +5265,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Troubleshooting - H3: Command is not declared by the node - H3: Command requires explicit opt-in - - H3: HEALTHACCESSDISABLED + - H3: `HEALTH_ACCESS_DISABLED` - H3: Summary succeeds but metrics are missing - H3: Older ranges fail - H2: Related @@ -7752,8 +7750,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Security model - H2: Request format - H2: Supported actions - - H3: createflow - - H3: runtask + - H3: `create_flow` + - H3: `run_task` - H2: Response shape - H2: Related @@ -8827,7 +8825,6 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: API key resolution - H2: Remote endpoint config - H2: Provider-specific config - - H3: Inline embedding timeout - H2: Indexing behavior - H2: Hybrid search config - H3: Full example @@ -8835,11 +8832,10 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Multimodal memory (Gemini) - H2: Embedding cache - H2: Batch indexing - - H2: Session memory search (experimental) + - H2: Session memory search - H2: SQLite vector acceleration (sqlite-vec) - H2: Index storage - H2: QMD backend config - - H3: mcporter integration - H3: Full QMD example - H2: Dreaming - H3: User settings @@ -8929,7 +8925,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /reference/rich-output-protocol - Headings: - H2: Media attachments - - H2: [embed ...] + - H2: `[embed ...]` - H2: Stored rendering shape - H2: Related @@ -8981,7 +8977,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Compaction settings - H2: Pluggable compaction providers - H2: User-visible surfaces - - H2: Silent housekeeping (NOREPLY) + - H2: Silent housekeeping (`NO_REPLY`) - H2: Pre-compaction memory flush - H2: Troubleshooting checklist - H2: Related @@ -9624,7 +9620,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Example - H3: Behavior - H2: Start ACP sessions - - H3: sessionsspawn parameters + - H3: `sessions_spawn` parameters - H2: Spawn bind and thread modes - H2: Delivery model - H2: Sandbox compatibility @@ -10012,10 +10008,10 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Configure Firecrawl webfetch fallback - H3: Self-hosted Firecrawl - H2: Firecrawl plugin tools - - H3: firecrawlsearch - - H3: firecrawlscrape + - H3: `firecrawl_search` + - H3: `firecrawl_scrape` - H2: Stealth / bot circumvention - - H2: How webfetch uses Firecrawl + - H2: How `web_fetch` uses Firecrawl - H2: Related ## tools/gemini-search.md @@ -10447,11 +10443,11 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Thread binding controls - H3: Spawn behavior - H2: Context modes - - H2: Tool: sessionsspawn + - H2: Tool: `sessions_spawn` - H3: Delegation prompt mode - H3: Tool parameters - H3: Task names and targeting - - H2: Tool: sessionsyield + - H2: Tool: `sessions_yield` - H2: Tool: subagents - H2: Thread-bound sessions - H3: Thread supporting channels @@ -10471,7 +10467,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Announce - H3: Announce context - H3: Stats line - - H3: Why prefer sessionshistory + - H3: Why prefer `sessions_history` - H2: Tool policy - H3: Override via config - H2: Concurrency @@ -10503,8 +10499,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H2: Getting started - H2: Tool reference - - H3: tavilysearch - - H3: tavilyextract + - H3: `tavily_search` + - H3: `tavily_extract` - H2: Choosing the right tool - H2: Advanced configuration - H2: Related @@ -10576,9 +10572,9 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Per-agent voice overrides - H2: Personas - H3: Minimal persona - - H3: Full persona (provider-neutral prompt) + - H3: Full persona (provider-specific shaping) - H3: Persona resolution - - H3: How providers use persona prompts + - H3: Custom persona shaping - H3: Fallback policy - H2: Model-driven directives - H2: Slash commands diff --git a/docs/gateway/background-process.md b/docs/gateway/background-process.md index 19615df7ca7c..0bada91eeb65 100644 --- a/docs/gateway/background-process.md +++ b/docs/gateway/background-process.md @@ -12,24 +12,24 @@ OpenClaw runs shell commands through the `exec` tool and keeps long-running task Parameters: -| Parameter | Description | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `command` | Required. Shell command to run. | -| `workdir` | Working directory; omit to use the default cwd. | -| `env` | Extra environment variables for the command. | -| `yieldMs` | Milliseconds to wait before backgrounding (default 10000). | -| `background` | Run in background immediately. | -| `timeout` | Timeout in seconds (default `tools.exec.timeoutSec`); kills the process on expiry. Set `timeout: 0` to disable the exec process timeout for that call. | -| `pty` | Run in a pseudo-terminal when available (TTY-required CLIs, coding agents). | -| `elevated` | Run outside the sandbox if elevated mode is enabled/allowed (`gateway` by default, or `node` when the exec target is `node`). | -| `host` | Exec target: `auto`, `sandbox`, `gateway`, or `node`. | -| `node` | Node id/name, used with `host: "node"`. | +| Parameter | Description | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `command` | Required. Shell command to run. | +| `workdir` | Working directory; omit to use the default cwd. | +| `env` | Extra environment variables for the command. | +| `yieldMs` | Milliseconds to wait before backgrounding (default 10000). | +| `background` | Run in background immediately. | +| `timeout` | Timeout in seconds (default `tools.exec.timeoutSeconds`); kills the process on expiry. Set `timeout: 0` to disable the exec process timeout for that call. | +| `pty` | Run in a pseudo-terminal when available (TTY-required CLIs, coding agents). | +| `elevated` | Run outside the sandbox if elevated mode is enabled/allowed (`gateway` by default, or `node` when the exec target is `node`). | +| `host` | Exec target: `auto`, `sandbox`, `gateway`, or `node`. | +| `node` | Node id/name, used with `host: "node"`. | Behavior: - Foreground runs return output directly. - When backgrounded (explicit or via `yieldMs` timeout), the tool returns `status: "running"` + `sessionId` and a short output tail. -- Backgrounded and `yieldMs` runs inherit `tools.exec.timeoutSec` unless the call passes an explicit `timeout`. +- Backgrounded and `yieldMs` runs inherit `tools.exec.timeoutSeconds` unless the call passes an explicit `timeout`. - Output stays in memory until the session is polled or cleared. - If the `process` tool is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`. - Spawned exec commands receive `OPENCLAW_SHELL=exec` for context-aware shell/profile rules. @@ -52,7 +52,7 @@ Behavior: | Key | Default | Effect | | ------------------------------------- | ------- | ------------------------------------------------------------------------------- | | `tools.exec.backgroundMs` | 10000 | Same as `OPENCLAW_BASH_YIELD_MS`. | -| `tools.exec.timeoutSec` | 1800 | Default per-call timeout. | +| `tools.exec.timeoutSeconds` | 1800 | Default per-call timeout. | | `tools.exec.cleanupMs` | 1800000 | Same as `OPENCLAW_BASH_JOB_TTL_MS`. | | `tools.exec.notifyOnExit` | true | Enqueue a system event + request heartbeat when a backgrounded exec exits. | | `tools.exec.notifyOnExitEmptySuccess` | false | Also enqueue completion events for successful backgrounded runs with no output. | diff --git a/docs/gateway/bonjour.md b/docs/gateway/bonjour.md index 30b173d1b854..84595acebfe6 100644 --- a/docs/gateway/bonjour.md +++ b/docs/gateway/bonjour.md @@ -141,11 +141,11 @@ openclaw plugins enable bonjour When enabled, Bonjour uses `discovery.mdns.mode` to decide how much TXT metadata to publish; the same mode controls optional TXT hints in wide-area DNS-SD records. Modes: -| Mode | Behavior | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `minimal` (default) | Core TXT keys only; omits `sshPort`, `cliPath`, `tailnetDns`. | -| `full` | Adds `sshPort`, `cliPath`, `tailnetDns` — use when clients need those hints. | -| `off` | Suppresses LAN multicast without changing plugin enablement; wide-area DNS-SD can still publish the minimal beacon when `discovery.wideArea.enabled` is true. | +| Mode | Behavior | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `minimal` (default) | Core TXT keys only; omits `sshPort`, `cliPath`, `tailnetDns`. | +| `full` | Adds `sshPort`, `cliPath`, `tailnetDns` — use when clients need those hints. | +| `off` | Suppresses LAN multicast without changing plugin enablement; wide-area DNS-SD can still publish when `discovery.wideArea.domain` is set. | ## When to disable Bonjour diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 6347c56f536a..9219a3cf217f 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -90,7 +90,7 @@ All CLI backends live under `agents.defaults.cliBackends`, keyed by provider id "claude-opus-4-6": "opus", "claude-sonnet-4-6": "sonnet", }, - sessionArg: "--session", + sessionArgs: ["--session", "{sessionId}"], sessionMode: "existing", sessionIdFields: ["session_id", "conversation_id"], systemPromptArg: "--system", @@ -146,7 +146,7 @@ The `openclaw agent` command also has its own request deadline. Its 600-second f The bundled `claude-cli` backend prefers Claude Code's native skill resolver. When the current skills snapshot has at least one selected skill with a materialized path, OpenClaw passes a temporary Claude Code plugin via `--plugin-dir` and omits the duplicate OpenClaw skills catalog from the appended system prompt. Without a materialized plugin skill, OpenClaw keeps the prompt catalog as a fallback. Skill env/API key overrides still apply to the child process environment for the run. -Claude CLI has its own noninteractive permission mode; OpenClaw maps that to the existing exec policy instead of adding Claude-specific config. For OpenClaw-managed Claude live sessions, the effective exec policy is authoritative: YOLO (`tools.exec.security: "full"` and `tools.exec.ask: "off"`) normally launches Claude with `--permission-mode bypassPermissions`, while a restrictive policy launches it with `--permission-mode default`. Root-run gateways also use `default` because Claude Code rejects bypass mode for root; OpenClaw still answers Claude's stdio tool-control requests from the configured exec policy. Per-agent `agents.list[].tools.exec` settings override the global `tools.exec` for that agent. Raw backend args may still include `--permission-mode`, but live Claude launches normalize that flag to match the effective policy and host restriction. +Claude CLI has its own noninteractive permission mode; OpenClaw maps that to the existing exec policy instead of adding Claude-specific config. For OpenClaw-managed Claude live sessions, the effective exec policy is authoritative: YOLO (`tools.exec.mode: "full"`) normally launches Claude with `--permission-mode bypassPermissions`, while a restrictive policy launches it with `--permission-mode default`. Root-run gateways also use `default` because Claude Code rejects bypass mode for root; OpenClaw still answers Claude's stdio tool-control requests from the configured exec policy. Per-agent `agents.entries.*.tools.exec` settings override the global `tools.exec` for that agent. Raw backend args may still include `--permission-mode`, but live Claude launches normalize that flag to match the effective policy and host restriction. The backend also maps OpenClaw `/think` levels to Claude Code's native `--effort` flag: `minimal`/`low` -> `low`, `medium` -> `medium`, and `high`/`xhigh`/`max` pass through directly. This keeps the supported Fable 5 effort levels the same for subscription-backed Claude CLI and API-key routes. `adaptive` removes configured `--effort` flags and supplies no replacement, so Claude Code resolves effective effort from its own environment, settings, and model defaults. Other CLI backends need their owning plugin to declare an equivalent argv mapper before `/think` affects the spawned CLI. @@ -164,7 +164,7 @@ Set `agents.defaults.cliBackends.claude-cli.command` only when the `claude` bina ## Sessions -- If the CLI supports sessions, set `sessionArg` (e.g. `--session-id`), or `sessionArgs` (placeholder `{sessionId}`) when the id needs to land in multiple flags. +- If the CLI supports sessions, set `sessionArgs` with a `{sessionId}` placeholder (for example `["--session-id", "{sessionId}"]`). - If the CLI uses a resume subcommand with different flags, set `resumeArgs` (replaces `args` when resuming) and optionally `resumeOutput` for non-JSON resumes. - `sessionMode`: - `always`: always send a session id (new UUID if none stored). @@ -230,7 +230,7 @@ The bundled Anthropic plugin registers for `claude-cli`: | `output` | `jsonl` | | `input` | `stdin` | | `modelArg` | `--model` | -| `sessionArg` | `--session-id` | +| `sessionArgs` | `["--session-id", "{sessionId}"]` | | `sessionMode` | `always` | | `imageArg` | `@` | | `imagePathScope` | `workspace` | @@ -337,7 +337,7 @@ Claude CLI backends scale this cap with the resolved Claude context window inste | --------------------- | ----------------------------------------------------------------- | | CLI not found | Set `command` to a full path. | | Wrong model name | Use `modelAliases` to map `provider/model` to the CLI's model id. | -| No session continuity | Ensure `sessionArg` is set and `sessionMode` is not `none`. | +| No session continuity | Ensure `sessionArgs` is set and `sessionMode` is not `none`. | | Images ignored | Set `imageArg` and verify the CLI supports file paths. | ## Related diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 638f42b1ca41..10d342b8c56e 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -40,7 +40,7 @@ Optional repository root shown in the system prompt's Runtime line. If unset, Op ### `agents.defaults.skills` Optional default skill allowlist for agents that do not set -`agents.list[].skills`. +`agents.entries.*.skills`. ```json5 { @@ -56,9 +56,9 @@ Optional default skill allowlist for agents that do not set ``` - Omit `agents.defaults.skills` for unrestricted skills by default. -- Omit `agents.list[].skills` to inherit the defaults. -- Set `agents.list[].skills: []` for no skills. -- A non-empty `agents.list[].skills` list is the final set for that agent; it +- Omit `agents.entries.*.skills` to inherit the defaults. +- Set `agents.entries.*.skills: []` for no skills. +- A non-empty `agents.entries.*.skills` list is the final set for that agent; it does not merge with defaults. ### `agents.defaults.skipBootstrap` @@ -98,7 +98,7 @@ Controls when workspace bootstrap files are injected into the system prompt. Def } ``` -Per-agent override: `agents.list[].contextInjection`. Omitted values inherit +Per-agent override: `agents.entries.*.contextInjection`. Omitted values inherit `agents.defaults.contextInjection`. ### `agents.defaults.bootstrapMaxChars` @@ -111,7 +111,7 @@ Max characters per workspace bootstrap file before truncation. Default: `20000`. } ``` -Per-agent override: `agents.list[].bootstrapMaxChars`. Omitted values inherit +Per-agent override: `agents.entries.*.bootstrapMaxChars`. Omitted values inherit `agents.defaults.bootstrapMaxChars`. ### `agents.defaults.bootstrapTotalMaxChars` @@ -124,7 +124,7 @@ Max total characters injected across all workspace bootstrap files. Default: `60 } ``` -Per-agent override: `agents.list[].bootstrapTotalMaxChars`. Omitted values +Per-agent override: `agents.entries.*.bootstrapTotalMaxChars`. Omitted values inherit `agents.defaults.bootstrapTotalMaxChars`. ### Per-agent bootstrap profile overrides @@ -188,11 +188,11 @@ knob. Matching per-agent overrides: -- `agents.list[].skillsLimits.maxSkillsPromptChars` -- `agents.list[].contextInjection` -- `agents.list[].bootstrapMaxChars` -- `agents.list[].bootstrapTotalMaxChars` -- `agents.list[].contextLimits.*` +- `agents.entries.*.skillsLimits.maxSkillsPromptChars` +- `agents.entries.*.contextInjection` +- `agents.entries.*.bootstrapMaxChars` +- `agents.entries.*.bootstrapTotalMaxChars` +- `agents.entries.*.contextLimits.*` #### `agents.defaults.startupContext` @@ -249,7 +249,7 @@ Shared defaults for bounded runtime context surfaces. - `postCompactionMaxChars`: AGENTS.md excerpt cap used during post-compaction refresh injection. -#### `agents.list[].contextLimits` +#### `agents.entries.*.contextLimits` Per-agent override for the shared `contextLimits` knobs. Omitted fields inherit from `agents.defaults.contextLimits`. @@ -284,7 +284,7 @@ does not affect reading `SKILL.md` files on demand. } ``` -#### `agents.list[].skillsLimits.maxSkillsPromptChars` +#### `agents.entries.*.skillsLimits.maxSkillsPromptChars` Per-agent override for the skills prompt budget. @@ -369,20 +369,22 @@ Time format in system prompt. Default: `auto` (OS preference). primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free", fallbacks: ["openrouter/google/gemini-2.0-flash-vision:free"], }, - imageGenerationModel: { - primary: "openai/gpt-image-2", - fallbacks: ["google/gemini-3.1-flash-image"], - }, - videoGenerationModel: { - primary: "qwen/wan2.6-t2v", - fallbacks: ["qwen/wan2.6-i2v"], + mediaModels: { + image: { + primary: "openai/gpt-image-2", + fallbacks: ["google/gemini-3.1-flash-image"], + }, + video: { + primary: "qwen/wan2.6-t2v", + fallbacks: ["qwen/wan2.6-i2v"], + }, }, pdfModel: { primary: "anthropic/claude-opus-4-6", fallbacks: ["openai/gpt-5.4-mini"], }, params: { cacheRetention: "long" }, // global default provider params - pdfMaxBytesMb: 10, + pdfMaxMb: 10, pdfMaxPages: 20, thinkingDefault: "low", verboseDefault: "off", @@ -401,22 +403,22 @@ Time format in system prompt. Default: `auto` (OS preference). - `model`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - String form sets only the primary model. - Object form sets primary plus ordered failover models. -- `utilityModel`: optional `provider/model` ref or alias for short internal tasks. It currently powers generated Control UI session titles, Telegram DM topic titles, Discord auto-thread titles, and [progress-draft narration](/concepts/progress-drafts#narrated-status). When unset, OpenClaw derives the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`); title tasks otherwise use the agent's primary model, and narration stays off. If a distinct utility model cannot prepare or complete a generated title, OpenClaw retries that title once with the primary model. For dashboard titles, automatic utility derivation and the regular fallback use the effective session provider and auth profile; an explicit utility model keeps its configured provider/auth. Set `utilityModel: ""` to skip the alternate utility route; dashboard title generation still proceeds directly to the regular session model. `agents.list[].utilityModel` overrides the default, and an operation-specific model override wins over both. Utility tasks make separate model calls and send task-specific content to the selected model provider. Dashboard title generation sends at most the first 1,000 characters of the first non-command message; narration sends the inbound request plus compact redacted tool summaries. Choose a provider that matches your cost and data-handling requirements. +- `utilityModel`: optional `provider/model` ref or alias for short internal tasks. It currently powers generated Control UI session titles, Telegram DM topic titles, Discord auto-thread titles, and [progress-draft narration](/concepts/progress-drafts#narrated-status). When unset, OpenClaw derives the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`); title tasks otherwise use the agent's primary model, and narration stays off. If a distinct utility model cannot prepare or complete a generated title, OpenClaw retries that title once with the primary model. For dashboard titles, automatic utility derivation and the regular fallback use the effective session provider and auth profile; an explicit utility model keeps its configured provider/auth. Set `utilityModel: ""` to skip the alternate utility route; dashboard title generation still proceeds directly to the regular session model. `agents.entries.*.utilityModel` overrides the default, and an operation-specific model override wins over both. Utility tasks make separate model calls and send task-specific content to the selected model provider. Dashboard title generation sends at most the first 1,000 characters of the first non-command message; narration sends the inbound request plus compact redacted tool summaries. Choose a provider that matches your cost and data-handling requirements. - `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the `image` tool path as its vision-model config when the active model cannot accept images. Native-vision models receive loaded image bytes directly instead. - Also used as fallback routing when the selected/default model cannot accept image input. - Prefer explicit `provider/model` refs. Bare IDs are accepted for compatibility; if a bare ID uniquely matches a configured image-capable entry in `models.providers.*.models`, OpenClaw qualifies it to that provider. Ambiguous configured matches require an explicit provider prefix. -- `imageGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). +- `mediaModels.image`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the shared image-generation capability and any future tool/plugin surface that generates images. - Typical values: `google/gemini-3.1-flash-image` for native Gemini image generation, `fal/fal-ai/flux/dev` for fal, `openai/gpt-image-2` for OpenAI Images, or `openai/gpt-image-1.5` for transparent-background OpenAI PNG/WebP output. - If you select a provider/model directly, configure matching provider auth too (for example `GEMINI_API_KEY` or `GOOGLE_API_KEY` for `google/*`, `OPENAI_API_KEY` or OpenAI Codex OAuth for `openai/gpt-image-2` / `openai/gpt-image-1.5`, `FAL_KEY` for `fal/*`). - If omitted, `image_generate` can still infer an auth-backed provider default. It tries the current default provider first, then the remaining registered image-generation providers in provider-id order. -- `musicGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). +- `mediaModels.music`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the shared music-generation capability and the built-in `music_generate` tool. - Typical values: `google/lyria-3-clip-preview`, `google/lyria-3-pro-preview`, or `minimax/music-2.6`. - If omitted, `music_generate` can still infer an auth-backed provider default. It tries the current default provider first, then the remaining registered music-generation providers in provider-id order. - If you select a provider/model directly, configure the matching provider auth/API key too. -- `videoGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). +- `mediaModels.video`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the shared video-generation capability and the built-in `video_generate` tool. - Typical values: `qwen/wan2.6-t2v`, `qwen/wan2.6-i2v`, `qwen/wan2.6-r2v`, `qwen/wan2.6-r2v-flash`, or `qwen/wan2.7-r2v`. - If omitted, `video_generate` can still infer an auth-backed provider default. It tries the current default provider first, then the remaining registered video-generation providers in provider-id order. @@ -425,22 +427,22 @@ Time format in system prompt. Default: `auto` (OS preference). - `pdfModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`). - Used by the `pdf` tool for model routing. - If omitted, the PDF tool falls back to `imageModel`, then to the resolved session/default model. -- `pdfMaxBytesMb`: default PDF size limit for the `pdf` tool when `maxBytesMb` is not passed at call time. +- `pdfMaxMb`: default PDF size limit for the `pdf` tool when `maxBytesMb` is not passed at call time. - `pdfMaxPages`: default maximum pages considered by extraction fallback mode in the `pdf` tool. - `verboseDefault`: default verbose level for agents. Values: `"off"`, `"on"`, `"full"`. Default: `"off"`. -- `toolProgressDetail`: detail mode for `/verbose` tool summaries and progress-draft tool lines. Values: `"explain"` (default, compact human labels) or `"raw"` (append raw command/detail when available). Per-agent `agents.list[].toolProgressDetail` overrides this default. -- `reasoningDefault`: default reasoning visibility for agents. Values: `"off"`, `"on"`, `"stream"`. Per-agent `agents.list[].reasoningDefault` overrides this default. Configured reasoning defaults are only applied for owners, authorized senders, or operator-admin gateway contexts when no per-message or session reasoning override is set. +- `toolProgressDetail`: detail mode for `/verbose` tool summaries and progress-draft tool lines. Values: `"explain"` (default, compact human labels) or `"raw"` (append raw command/detail when available). Per-agent `agents.entries.*.toolProgressDetail` overrides this default. +- `reasoningDefault`: default reasoning visibility for agents. Values: `"off"`, `"on"`, `"stream"`. Per-agent `agents.entries.*.reasoningDefault` overrides this default. Configured reasoning defaults are only applied for owners, authorized senders, or operator-admin gateway contexts when no per-message or session reasoning override is set. - `elevatedDefault`: default elevated-output level for agents. Values: `"off"`, `"on"`, `"ask"`, `"full"`. Default: `"on"`. - `model.primary`: format `provider/model` (e.g. `openai/gpt-5.6-sol` for Codex OAuth access). If you omit the provider, OpenClaw tries an alias first, then a unique configured-provider match for that exact model id, and only then falls back to the configured default provider (deprecated compatibility behavior, so prefer explicit `provider/model`). If that provider no longer exposes the configured default model, OpenClaw falls back to the first configured provider/model instead of surfacing a stale removed-provider default. - `models`: configured aliases and per-model settings. Each entry can include `alias` (shortcut) and `params` (provider-specific, for example `temperature`, `maxTokens`, `cacheRetention`, `context1m`, `responsesServerCompaction`, `responsesCompactThreshold`, OpenRouter `provider` routing, `chat_template_kwargs`, `extra_body`/`extraBody`). Adding entries does not restrict model overrides. - Use `provider/*` entries such as `"openai/*": {}` or `"vllm/*": {}` to show all discovered models for selected providers without manually listing every model id. - Add `agentRuntime` to a `provider/*` entry when every dynamically discovered model for that provider should use the same runtime. Exact `provider/model` runtime policy still wins over the wildcard. - Safe metadata edits: use `openclaw config set agents.defaults.models '' --strict-json --merge` to add entries. `config set` refuses replacements that would remove existing entries unless you pass `--replace`. -- `modelPolicy.allow`: explicit override allowlist. Accepts aliases, exact `provider/model` refs, and trailing prefix wildcards such as `openai/*` or `clawrouter/anthropic/*`. Omit it or use `[]` to allow any model. `agents.list[].modelPolicy.allow` replaces the default policy for that agent; an explicit empty list opts that agent into allow-any. +- `modelPolicy.allow`: explicit override allowlist. Accepts aliases, exact `provider/model` refs, and trailing prefix wildcards such as `openai/*` or `clawrouter/anthropic/*`. Omit it or use `[]` to allow any model. `agents.entries.*.modelPolicy.allow` replaces the default policy for that agent; an explicit empty list opts that agent into allow-any. - Provider-scoped configure/onboarding flows merge selected provider models into this map and preserve unrelated providers already configured. - For direct OpenAI Responses models, server-side compaction is enabled automatically. Use `params.responsesServerCompaction: false` to stop injecting `context_management`, or `params.responsesCompactThreshold` to override the threshold. See [OpenAI server-side compaction](/providers/openai#advanced-configuration). - `params`: global default provider parameters applied to all models. Set at `agents.defaults.params` (e.g. `{ cacheRetention: "long" }`). -- `params` merge precedence (config): `agents.defaults.params` (global base) is overridden by `agents.defaults.models["provider/model"].params` (per-model), then `agents.list[].params` (matching agent id) overrides by key. See [Prompt Caching](/reference/prompt-caching) for details. +- `params` merge precedence (config): `agents.defaults.params` (global base) is overridden by `agents.defaults.models["provider/model"].params` (per-model), then `agents.entries.*.params` (matching agent id) overrides by key. See [Prompt Caching](/reference/prompt-caching) for details. - `models.providers.openrouter.params.provider`: OpenRouter-wide default provider-routing policy. OpenClaw forwards this to OpenRouter's request `provider` object; per-model `agents.defaults.models["openrouter/"].params.provider` and agent params override by key. See [OpenRouter provider routing](/providers/openrouter#advanced-configuration). - `params.extra_body`/`params.extraBody`: advanced pass-through JSON merged into `api: "openai-completions"` request bodies for OpenAI-compatible proxies. If it collides with generated request keys, the extra body wins; non-native completions routes still strip OpenAI-only `store` afterward. - `params.chat_template_kwargs`: vLLM/OpenAI-compatible chat-template arguments merged into top-level `api: "openai-completions"` request bodies. For `vllm/nemotron-3-*` with thinking off, the bundled vLLM plugin automatically sends `enable_thinking: false` and `force_nonempty_content: true`; explicit `chat_template_kwargs` override generated defaults, and `extra_body.chat_template_kwargs` still has final precedence. Configured vLLM Qwen and Nemotron thinking models expose binary `/think` choices (`off`, `on`) instead of the multi-level effort ladder. @@ -448,7 +450,7 @@ Time format in system prompt. Default: `auto` (OS preference). - `compat.supportedReasoningEfforts`: per-model OpenAI-compatible reasoning effort list. Include `"xhigh"` for custom endpoints that truly accept it; OpenClaw then exposes `/think xhigh` in command menus, Gateway session rows, session patch validation, agent CLI validation, and `llm-task` validation for that configured provider/model. Use `compat.reasoningEffortMap` when the backend wants a provider-specific value for a canonical level. - `params.preserveThinking`: Z.AI-only opt-in for preserved thinking. When enabled and thinking is on, OpenClaw sends `thinking.clear_thinking: false` and replays prior `reasoning_content`; see [Z.AI thinking and preserved thinking](/providers/zai#advanced-configuration). - `localService`: optional provider-level process manager for local/self-hosted model servers. When the selected model belongs to that provider, OpenClaw probes `healthUrl` (or `baseUrl + "/models"`), starts `command` with `args` if the endpoint is down, waits up to `readyTimeoutMs`, then sends the model request. `command` must be an absolute path. `idleStopMs: 0` keeps the process alive until OpenClaw exits; a positive value stops the OpenClaw-spawned process after that many idle milliseconds. See [Local model services](/gateway/local-model-services). -- Runtime policy belongs on providers or models, not on `agents.defaults`. Use `models.providers..agentRuntime` for provider-wide rules or `agents.defaults.models["provider/model"].agentRuntime` / `agents.list[].models["provider/model"].agentRuntime` for model-specific rules. A provider/model prefix alone never selects a harness. With runtime unset or `auto`, OpenAI may select Codex implicitly only for an exact official HTTPS Platform Responses or ChatGPT Responses route with no authored request override. See [OpenAI implicit agent runtime](/providers/openai#implicit-agent-runtime). +- Runtime policy belongs on providers or models, not on `agents.defaults`. Use `models.providers..agentRuntime` for provider-wide rules or `agents.defaults.models["provider/model"].agentRuntime` / `agents.entries.*.models["provider/model"].agentRuntime` for model-specific rules. A provider/model prefix alone never selects a harness. With runtime unset or `auto`, OpenAI may select Codex implicitly only for an exact official HTTPS Platform Responses or ChatGPT Responses route with no authored request override. See [OpenAI implicit agent runtime](/providers/openai#implicit-agent-runtime). - Config writers that mutate these fields (for example `/models set`, `/models set-image`, and fallback add/remove commands) save canonical object form and preserve existing fallback lists when possible. - `maxConcurrent`: max parallel agent runs across sessions (each session still serialized). Default: `4`. @@ -482,8 +484,8 @@ Time format in system prompt. Default: `auto` (OS preference). - `id`: `"auto"`, `"openclaw"`, a registered plugin harness id, or a supported CLI backend alias. The bundled Codex plugin registers `codex`; the bundled Anthropic plugin provides the `claude-cli` CLI backend. - `id: "auto"` lets registered plugin harnesses claim effective routes that declare or otherwise satisfy their support contract, and uses OpenClaw when no harness matches. An explicit plugin runtime such as `id: "codex"` requires that harness and a compatible effective route; it fails closed if either is unavailable or if execution fails. - `id: "pi"` is accepted only as a deprecated alias for `openclaw` to preserve shipped configs from v2026.5.22 and earlier. New config should use `openclaw`. -- Runtime precedence is exact model policy first (`agents.list[].models["provider/model"]`, `agents.defaults.models["provider/model"]`, or `models.providers..models[]`), then `agents.list[]` / `agents.defaults.models["provider/*"]`, then provider-wide policy at `models.providers..agentRuntime`. -- Whole-agent runtime keys are legacy. `agents.defaults.agentRuntime`, `agents.list[].agentRuntime`, session runtime pins, and `OPENCLAW_AGENT_RUNTIME` are ignored by runtime selection. Run `openclaw doctor --fix` to remove stale values. +- Runtime precedence is exact model policy first (`agents.entries.*.models["provider/model"]`, `agents.defaults.models["provider/model"]`, or `models.providers..models[]`), then `agents.entries.*` / `agents.defaults.models["provider/*"]`, then provider-wide policy at `models.providers..agentRuntime`. +- Whole-agent runtime keys are legacy. `agents.defaults.agentRuntime`, `agents.entries.*.agentRuntime`, session runtime pins, and `OPENCLAW_AGENT_RUNTIME` are ignored by runtime selection. Run `openclaw doctor --fix` to remove stale values. - Eligible exact official HTTPS OpenAI Responses/ChatGPT routes with no authored request override may use the Codex harness implicitly. Provider/model `agentRuntime.id: "codex"` makes Codex a fail-closed requirement but does not make an incompatible route compatible. - For Claude CLI deployments, prefer `model: "anthropic/claude-opus-4-8"` plus model-scoped `agentRuntime.id: "claude-cli"`. Legacy `claude-cli/` refs still work for compatibility, but new config should keep provider/model selection canonical and put the execution backend in provider/model runtime policy. - This only controls text agent-turn execution. Media generation, vision, PDF, music, video, and TTS still use their provider/model settings. @@ -524,7 +526,7 @@ Optional CLI backends for text-only fallback runs (no tool calls). Useful as a b args: ["--json"], output: "json", modelArg: "--model", - sessionArg: "--session", + sessionArgs: ["--session", "{sessionId}"], sessionMode: "existing", systemPromptArg: "--system", // Or use systemPromptFileArg when the CLI accepts a prompt file flag. @@ -539,7 +541,7 @@ Optional CLI backends for text-only fallback runs (no tool calls). Useful as a b ``` - CLI backends are text-first; tools are always disabled. -- Sessions supported when `sessionArg` is set. +- Sessions are supported when `sessionArgs` includes `{sessionId}`. - Image pass-through supported when `imageArg` accepts file paths. - `reseedFromRawTranscriptWhenUncompacted: true` lets a backend recover safe invalidated sessions from a bounded raw OpenClaw transcript tail before the @@ -606,7 +608,7 @@ Periodic heartbeat runs. - `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files. - `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens. - `skipWhenBusy`: when true, heartbeat runs defer on that agent's extra busy lanes: its own session-keyed subagent or nested command work. Cron lanes always defer heartbeats, even without this flag. -- Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. +- Per-agent: set `agents.entries.*.heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - Heartbeats run full agent turns — shorter intervals burn more tokens. ### `agents.defaults.compaction` @@ -622,12 +624,11 @@ Periodic heartbeat runs. timeoutSeconds: 180, keepRecentTokens: 50000, recentTurnsPreserve: 3, - identifierPolicy: "strict", // strict | off | custom - identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom + identifierPolicy: "strict", // strict | off qualityGuard: { enabled: true, maxRetries: 1 }, midTurnPrecheck: { enabled: false }, // optional tool-loop pressure check postIndexSync: "async", // off | async | await - postCompactionSections: ["Session Startup", "Red Lines"], // opt in to AGENTS.md section reinjection + postCompactionSections: ["Session Startup", "Red Lines"], model: "openrouter/anthropic/claude-sonnet-4-6", // optional compaction-only model override truncateAfterCompaction: true, // rotate to a smaller successor JSONL after compaction maxActiveTranscriptBytes: "20mb", // optional preflight local compaction trigger @@ -637,8 +638,6 @@ Periodic heartbeat runs. model: "ollama/qwen3:8b", // optional memory-flush-only model override softThresholdTokens: 6000, forceFlushTranscriptBytes: "2mb", - systemPrompt: "Session nearing compaction. Store durable memories now.", - prompt: "Write any lasting notes to memory/YYYY-MM-DD.md; reply with the exact silent token NO_REPLY if nothing to store.", }, }, }, @@ -652,18 +651,23 @@ Periodic heartbeat runs. - `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `180`. - `keepRecentTokens`: agent cut-point budget for keeping the most recent transcript tail verbatim. Manual `/compact` honors this when explicitly set; otherwise manual compaction is a hard checkpoint. - `recentTurnsPreserve`: number of most recent user/assistant turns kept verbatim outside safeguard summarization. Default: `3`. -- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization. -- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`. +- `identifierPolicy`: `strict` (default) or `off`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization. - `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit. - `midTurnPrecheck`: optional tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled. - `postIndexSync`: post-compaction session-memory reindex mode. Default: `"async"`. Use `"await"` for strongest freshness, `"async"` for lower compaction latency, or `"off"` only when session-memory sync is handled elsewhere. -- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Reinjection is disabled when unset or set to `[]`. Explicitly setting `["Session Startup", "Red Lines"]` enables that pair and preserves the legacy `Every Session`/`Safety` fallback. Enable this only when the extra context is worth the risk of duplicating project guidance already captured in the compaction summary. +- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Leave unset or use `[]` to disable. - `model`: optional `provider/model-id` or bare alias from `agents.defaults.models` for compaction summarization only. Bare aliases resolve before dispatch; configured literal model IDs retain precedence on collisions. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model. - `truncateAfterCompaction`: rotates the active session transcript after compaction so future turns load only the summary and unsummarized tail, while the previous full transcript remains archived. Prevents unbounded active transcript growth in long-running sessions. Default: `false`. - `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when transcript history grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`. - `notifyUser`: when `true`, sends brief context-maintenance notices to the user: when compaction starts and completes (for example, "Compacting context..." and "Compaction complete"), and when a pre-compaction memory flush is exhausted so the reply continues in a degraded state (for example, "Memory maintenance temporarily failed; continuing your reply."). Disabled by default to keep these notices silent. - `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. `forceFlushTranscriptBytes` forces the flush when transcript size reaches the threshold even if token counters are stale. Skipped when workspace is read-only. +Custom compaction instructions are code-owned. Implement a compaction provider +plugin with `summarize()` for custom summary construction, and use +`before_prompt_build` when post-compaction context must be injected into later +model prompts. Doctor strips the retired instruction fields and points to these +seams. + ### `agents.defaults.contextPruning` Prunes **old tool results** from in-memory context before sending to the LLM. Does **not** modify session history on disk. Disabled by default; set `mode: "cache-ttl"` to enable. @@ -718,7 +722,7 @@ See [Session Pruning](/concepts/session-pruning) for behavior details. - Non-Telegram channels require explicit `*.streaming.block.enabled: true` to enable block replies. QQ Bot is the exception: it has no `streaming.block` keys and streams block replies unless `channels.qqbot.streaming.mode` is `"off"`. - Channel overrides: `channels..streaming.block.coalesce` (and per-account variants). Discord, Google Chat, Mattermost, MS Teams, Signal, and Slack default `minChars: 1500` / `idleMs: 1000`. - `blockStreamingChunk.breakPreference`: preferred chunk boundary (`"paragraph" | "newline" | "sentence"`). -- `humanDelay`: randomized pause between block replies. Default: `off`. `natural` = 800-2500ms. `custom` uses `minMs`/`maxMs` (falls back to the natural range for any unset bound). Per-agent override: `agents.list[].humanDelay`. +- `humanDelay`: randomized pause between block replies. Default: `off`. `natural` = 800-2500ms. `custom` uses `minMs`/`maxMs` (falls back to the natural range for any unset bound). Per-agent override: `agents.entries.*.humanDelay`. See [Streaming](/concepts/streaming) for behavior + chunking details. @@ -737,7 +741,7 @@ See [Streaming](/concepts/streaming) for behavior + chunking details. - Defaults: `instant` for direct chats/mentions, `message` for unmentioned group chats. - `typingIntervalSeconds` default: `6`. -- Per-session overrides: `session.typingMode`. +- Per-agent overrides: `agents.entries.*.typingMode` and `agents.entries.*.typingIntervalSeconds`. See [Typing Indicators](/concepts/typing-indicators). @@ -983,11 +987,11 @@ scripts/sandbox-browser-setup.sh # optional browser image For npm installs without a source checkout, see [Sandboxing § Images and setup](/gateway/sandboxing#images-and-setup) for inline `docker build` commands. -### `agents.list` (per-agent overrides) +### `agents.entries` (per-agent overrides) -Use `agents.list[].tts` to give an agent its own TTS provider, voice, model, +Use `agents.entries.*.tts` to give an agent its own TTS provider, voice, model, style, or auto-TTS mode. The agent block deep-merges over global -`messages.tts`, so shared credentials can stay in one place while individual +`tts`, so shared credentials can stay in one place while individual agents override only the voice or provider fields they need. The active agent's override applies to automatic spoken replies, `/tts audio`, `/tts status`, and the `tts` agent tool. See [Text-to-speech](/tools/tts#per-agent-voice-overrides) @@ -1050,7 +1054,7 @@ for provider examples and precedence. - `model`: string form sets a strict per-agent primary with no model fallback; object form `{ primary }` is also strict unless you add `fallbacks`. Use `{ primary, fallbacks: [...] }` to opt that agent into fallback, or `{ primary, fallbacks: [] }` to make strict behavior explicit. Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`. - `utilityModel`: optional per-agent override for short internal tasks such as generated session and thread titles. Falls back to `agents.defaults.utilityModel`, then the effective session provider's declared small-model default. Dashboard titles retry once with the effective regular session model. An empty string skips the alternate utility route for this agent without disabling dashboard title generation. - `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog. -- `tts`: optional per-agent text-to-speech overrides. The block deep-merges over `messages.tts`, so keep shared provider credentials and fallback policy in `messages.tts` and set only persona-specific values such as provider, voice, model, style, or auto mode here. +- `tts`: optional per-agent text-to-speech overrides. The block deep-merges over `tts`, so keep shared provider credentials and fallback policy in `tts` and set only persona-specific values such as provider, voice, model, style, or auto mode here. - `skills`: optional per-agent skill allowlist. If omitted, the agent inherits `agents.defaults.skills` when set; an explicit list replaces defaults instead of merging, and `[]` means no skills. - `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive | max`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set. The selected provider/model profile controls which values are valid; for Google Gemini, `adaptive` keeps provider-owned dynamic thinking (`thinkingLevel` omitted on Gemini 3/3.1, `thinkingBudget: -1` on Gemini 2.5). - `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Overrides `agents.defaults.reasoningDefault` for this agent when no per-message or session reasoning override is set. @@ -1060,7 +1064,7 @@ for provider examples and precedence. - `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI. - Local workspace-relative `identity.avatar` image files are limited to 2 MB. `http(s)` URLs and `data:` URIs are not checked against the local file-size limit. - `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`. -- `subagents.allowAgents`: allowlist of configured agent ids for explicit `sessions_spawn.agentId` targets (`["*"]` = any configured target; default: same agent only). Include the requester id when self-targeted `agentId` calls should be allowed. Stale entries whose agent config was deleted are rejected by `sessions_spawn` and omitted from `agents_list`; run `openclaw doctor --fix` to clean them up, or add a minimal `agents.list[]` entry if that target should remain spawnable while inheriting defaults. +- `subagents.allowAgents`: allowlist of configured agent ids for explicit `sessions_spawn.agentId` targets (`["*"]` = any configured target; default: same agent only). Include the requester id when self-targeted `agentId` calls should be allowed. Stale entries whose agent config was deleted are rejected by `sessions_spawn` and omitted from `agents_list`; run `openclaw doctor --fix` to clean them up, or add a minimal `agents.entries.*` entry if that target should remain spawnable while inheriting defaults. - Sandbox inheritance guard: if the requester session is sandboxed, `sessions_spawn` rejects targets that would run unsandboxed. - `subagents.requireAgentId`: when true, block `sessions_spawn` calls that omit `agentId` (forces explicit profile selection; default: false). - `subagents.maxConcurrent`: max concurrent child-agent runs across subagent execution. Default: `8`. @@ -1269,7 +1273,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - `per-account-channel-peer`: isolate per account + channel + sender (recommended for multi-account). - **`identityLinks`**: map canonical ids to provider-prefixed peers for cross-channel session sharing. Dock commands such as `/dock_discord` use the same map to switch the active session's reply route to another linked channel peer; see [Channel docking](/concepts/channel-docking). - **`reset`**: primary reset policy. `none` disables automatic reset and is the default; compaction bounds active context instead. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins. `/new` and `/reset` remain available in every mode. Daily reset freshness uses the session row's `sessionStartedAt`; idle reset freshness uses `lastInteractionAt`. Background/system-event writes such as heartbeat, cron wakeups, exec notifications, and gateway bookkeeping can update `updatedAt`, but they do not keep daily/idle sessions fresh. -- **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy `dm` accepted as alias for `direct`. + - **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Doctor migrates legacy `dm` entries to `direct`; the schema rejects `dm`. - **`resetByChannel`**: per-channel reset overrides keyed by provider/channel id. When the session's channel has a matching entry, it wins outright over `resetByType`/`reset` for that session. Use only when one channel needs reset behavior different from the type-level policy. - **`mainKey`**: legacy field. Runtime always uses `"main"` for the main direct-chat bucket. - **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins. @@ -1283,7 +1287,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - `maxDiskBytes`: optional sessions-directory disk budget. In `warn` mode it logs warnings; in `enforce` mode it removes oldest artifacts/sessions first. - `highWaterBytes`: optional target after budget cleanup. Defaults to `80%` of `maxDiskBytes`. - **`threadBindings`**: global defaults for thread-bound session features. - - `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`) + - `enabled`: master switch for supported channel thread bindings - `idleHours`: default inactivity auto-unfocus in hours (`0` disables; providers can override) - `maxAgeHours`: default hard max age in hours (`0` disables; providers can override) - `spawnSessions`: default gate for creating thread-bound work sessions from `sessions_spawn` and ACP thread spawns. Defaults to `true` when thread bindings are enabled; providers/accounts can override. @@ -1377,7 +1381,7 @@ Batches rapid text-only messages from the same sender into a single agent turn. ### Other message keys -- `channels.whatsapp.messagePrefix`: WhatsApp-only prefix prepended to inbound user messages before they reach the agent runtime. +- `channels.whatsapp.responsePrefix`: outbound WhatsApp reply prefix. Doctor moves the retired inbound `messagePrefix` value here only when this canonical value is unset. - `messages.visibleReplies`: controls visible source replies across direct, group, and channel conversations (`"message_tool"` requires `message(action=send)` for visible output; `"automatic"` posts normal replies as before). - `messages.usageTemplate` / `messages.responseUsage`: custom `/usage` footer template and default per-reply usage mode (`off | tokens | full`, plus legacy `on` alias for `tokens`). - `messages.groupChat.mentionPatterns` / `historyLimit`: group-message mention triggers and history window sizing. @@ -1387,50 +1391,52 @@ Batches rapid text-only messages from the same sender into a single agent turn. ```json5 { - messages: { - tts: { - auto: "off", // off (default) | always | inbound | tagged - mode: "final", // final | all - provider: "elevenlabs", - summaryModel: "openai/gpt-5.4-mini", - modelOverrides: { enabled: true }, - maxTextLength: 4000, - timeoutMs: 30000, - prefsPath: "~/.openclaw/settings/tts.json", - providers: { - elevenlabs: { - apiKey: "elevenlabs_api_key", - baseUrl: "https://api.elevenlabs.io", - speakerVoiceId: "voice_id", - modelId: "eleven_multilingual_v2", - seed: 42, - applyTextNormalization: "auto", - languageCode: "en", - voiceSettings: { - stability: 0.5, - similarityBoost: 0.75, - style: 0.0, - useSpeakerBoost: true, - speed: 1.0, - }, - }, - microsoft: { - speakerVoice: "en-US-MichelleNeural", - lang: "en-US", - outputFormat: "audio-24khz-48kbitrate-mono-mp3", - }, - openai: { - apiKey: "openai_api_key", - baseUrl: "https://api.openai.com/v1", - model: "gpt-4o-mini-tts", - speakerVoice: "coral", + tts: { + auto: "off", // off (default) | always | inbound | tagged + mode: "final", // final | all + provider: "elevenlabs", + summaryModel: "openai/gpt-5.4-mini", + modelOverrides: { enabled: true }, + maxTextLength: 4000, + timeoutMs: 30000, + providers: { + elevenlabs: { + apiKey: "example-elevenlabs-api-key", + baseUrl: "https://api.elevenlabs.io", + speakerVoiceId: "voice_id", + modelId: "eleven_multilingual_v2", + seed: 42, + applyTextNormalization: "auto", + languageCode: "en", + voiceSettings: { + stability: 0.5, + similarityBoost: 0.75, + style: 0.0, + useSpeakerBoost: true, + speed: 1.0, }, }, + microsoft: { + speakerVoice: "en-US-MichelleNeural", + lang: "en-US", + outputFormat: "audio-24khz-48kbitrate-mono-mp3", + }, + openai: { + apiKey: "example-openai-api-key", + baseUrl: "https://api.openai.com/v1", + model: "gpt-4o-mini-tts", + speakerVoice: "coral", + }, }, }, } ``` +The global preferences path is machine state (default +`~/.openclaw/settings/tts.json`; override with `OPENCLAW_TTS_PREFS`). Advanced +multi-agent setups can set `agents.entries..tts.prefsPath` for distinct +per-agent preference stores. + - `auto` controls the default auto-TTS mode: `off`, `always`, `inbound`, or `tagged`. `/tts on|off` can override local prefs, and `/tts status` shows the effective state. - `summaryModel` overrides `agents.defaults.model.primary` for auto-summary. - `modelOverrides` is enabled by default (`enabled !== false`); `modelOverrides.allowProvider` is opt-in. diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index 296900c6e8c7..046d7ff41d11 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -394,11 +394,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat audience: "https://gateway.example.com/googlechat", webhookPath: "/googlechat", botUser: "users/1234567890", - dm: { - enabled: true, - policy: "pairing", - allowFrom: ["users/1234567890"], - }, + dmPolicy: "pairing", + allowFrom: ["users/1234567890"], groupPolicy: "allowlist", groups: { "spaces/AAAA": { allow: true, requireMention: true }, @@ -412,7 +409,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat ``` - Service account JSON: inline (`serviceAccount`) or file-based (`serviceAccountFile`). -- Service account SecretRef is also supported (`serviceAccountRef`). +- `serviceAccount` accepts a SecretRef directly. - Env fallbacks: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE` (default account only). - Use `spaces/` or `users/` for delivery targets. - `channels.googlechat.dangerouslyAllowNameMatching` re-enables mutable email principal matching (break-glass compatibility mode). @@ -839,7 +836,7 @@ Fix: either pick a stronger tool-calling model, remove the explicit `"message_to **Mention types:** - **Metadata mentions**: Native platform @-mentions. Ignored in WhatsApp self-chat mode. -- **Text patterns**: Safe regex patterns in `agents.list[].groupChat.mentionPatterns`. Invalid patterns and unsafe nested repetition are ignored. +- **Text patterns**: Safe regex patterns in `agents.entries.*.groupChat.mentionPatterns`. Invalid patterns and unsafe nested repetition are ignored. - Mention gating is enforced only when detection is possible (native mentions or at least one pattern). ```json5 diff --git a/docs/gateway/config-tools.md b/docs/gateway/config-tools.md index b235226070df..2f23c6d1c9ba 100644 --- a/docs/gateway/config-tools.md +++ b/docs/gateway/config-tools.md @@ -133,7 +133,7 @@ Global tool allow/deny policy (deny wins). Case-insensitive, supports `*` wildca ``` -`allow` and `alsoAllow` cannot both be set in the same scope (`tools`, `tools.byProvider.`, `agents.list[].tools`) — config validation rejects it. Merge `alsoAllow` entries into `allow`, or drop `allow` and use `profile` + `alsoAllow` instead. +`allow` and `alsoAllow` cannot both be set in the same scope (`tools`, `tools.byProvider.`, `agents.entries.*.tools`) — config validation rejects it. Merge `alsoAllow` entries into `allow`, or drop `allow` and use `profile` + `alsoAllow` instead. ### `tools.byProvider` @@ -170,7 +170,7 @@ Restricts tools for a specific requester identity. This is defense-in-depth on t Keys use explicit prefixes: `channel::`, `id:`, `e164:`, `username:`, `name:`, or `"*"`. Channel ids are canonical OpenClaw ids; aliases such as `teams` normalize to `msteams`. Legacy unprefixed keys are accepted as `id:` only. Matching order is channel+id, id, e164, username, name, then wildcard. -Per-agent `agents.list[].tools.toolsBySender` overrides the global sender match when it matches, even with an empty `{}` policy. +Per-agent `agents.entries.*.tools.toolsBySender` overrides the global sender match when it matches, even with an empty `{}` policy. ### `tools.elevated` @@ -190,7 +190,7 @@ Controls elevated exec access outside the sandbox: } ``` -- Per-agent override (`agents.list[].tools.elevated`) can only further restrict. +- Per-agent override (`agents.entries.*.tools.elevated`) can only further restrict. - `/elevated on|off|ask|full` stores state per session; inline directives apply to single message. - Elevated `exec` bypasses sandboxing and uses the configured escape path (`gateway` by default, or `node` when the exec target is `node`). @@ -220,7 +220,7 @@ Values shown are defaults except `applyPatch.allowModels` (empty/unset by defaul ### `tools.loopDetection` -Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection. Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.list[].tools.loopDetection`. +Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection. Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.entries.*.tools.loopDetection`. ```json5 { @@ -273,34 +273,26 @@ Configures inbound media understanding (image/audio/video): tools: { media: { concurrency: 2, - audio: { - enabled: true, - maxBytes: 20971520, - scope: { - default: "deny", - rules: [{ action: "allow", match: { chatType: "direct" } }], + models: [ + { provider: "openai", model: "gpt-4o-mini-transcribe", capabilities: ["audio"] }, + { + type: "cli", + command: "whisper", + args: ["--model", "base", "{{MediaPath}}"], + capabilities: ["audio"], }, - models: [ - { provider: "openai", model: "gpt-4o-mini-transcribe" }, - { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }, - ], - }, - image: { - enabled: true, - timeoutSeconds: 180, - models: [{ provider: "ollama", model: "gemma4:26b", timeoutSeconds: 300 }], - }, - video: { - enabled: true, - maxBytes: 52428800, - models: [{ provider: "google", model: "gemini-3-flash-preview" }], - }, + { provider: "ollama", model: "gemma4:26b", capabilities: ["image"] }, + { provider: "google", model: "gemini-3-flash-preview", capabilities: ["video"] }, + ], + audio: { enabled: true, preferredModel: "openai/gpt-4o-mini-transcribe" }, + image: { enabled: true, preferredModel: "ollama/gemma4:26b" }, + video: { enabled: true }, }, }, } ``` -`concurrency` (default `2`), `audio.maxBytes` (default 20 MB), and `video.maxBytes` (default 50 MB) are shown at their defaults; `image.maxBytes` defaults to 10 MB. Per-capability request timeout defaults: image/audio `60`s, video `120`s. +`tools.media.models` is the only configured model list. Every entry declares the capabilities it handles. The optional `preferredModel` selector accepts `provider/model`, a model id, `provider:` for provider-default entries, or `cli:command`; matching entries move to the front of that capability's fallback order. Per-capability prompts, limits, request settings, scope, attachment policy, and audio transcript echo remain defaults for configured and auto-detected models; a model entry can override model-specific fields. @@ -317,9 +309,9 @@ Configures inbound media understanding (image/audio/video): **Common fields:** - - `capabilities`: optional list (`image`, `audio`, `video`). Each provider plugin declares its own default capability set; for example the bundled `openai` provider defaults to image+audio, `anthropic`/`minimax` to image, `google` to image+audio+video, and `groq` to audio. + - `capabilities`: list containing one or more of `image`, `audio`, and `video`. - `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`: per-entry overrides. - - `tools.media.image.timeoutSeconds` and matching image model `timeoutSeconds` entries also apply when the agent calls the explicit `image` tool. For image understanding, this timeout applies to the request itself and is not reduced by earlier preparation work. + - Matching image model `timeoutSeconds` entries also apply when the agent calls the explicit `image` tool. For image understanding, this timeout applies to the request itself and is not reduced by earlier preparation work. - Failures fall back to the next entry. Provider auth follows standard order: `auth-profiles.json` → env vars → `models.providers.*.apiKey`. diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 5b59cefe060a..2be53267af13 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -31,16 +31,15 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. workspace: "~/.openclaw/workspace", model: { primary: "anthropic/claude-sonnet-4-6" }, }, - list: [ - { - id: "main", + entries: { + main: { identity: { name: "Clawd", theme: "helpful assistant", emoji: "🦞", }, }, - ], + }, }, channels: { whatsapp: { @@ -90,7 +89,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. }, }, - // Identity is per agent — set it on agents.list[].identity below. + // Identity is per agent — set it on agents.entries..identity below. // Logging logging: { @@ -103,7 +102,6 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. // Message formatting messages: { - messagePrefix: "[openclaw]", visibleReplies: "automatic", responsePrefix: ">", ackReaction: "👀", @@ -115,7 +113,6 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. }, queue: { mode: "followup", - debounceMs: 500, cap: 20, drop: "summarize", byChannel: { @@ -130,27 +127,6 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. }, }, - // Tooling - tools: { - media: { - audio: { - enabled: true, - maxBytes: 20971520, - models: [ - { provider: "openai", model: "gpt-4o-transcribe" }, - // Optional CLI fallback (Whisper binary): - // { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] } - ], - timeoutSeconds: 120, - }, - video: { - enabled: true, - maxBytes: 52428800, - models: [{ provider: "google", model: "gemini-3-flash-preview" }], - }, - }, - }, - // Session behavior session: { scope: "per-sender", @@ -201,7 +177,8 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. discord: { enabled: true, token: "YOUR_DISCORD_BOT_TOKEN", - dm: { enabled: true, allowFrom: ["123456789012345678"] }, + dmPolicy: "allowlist", + allowFrom: ["123456789012345678"], guilds: { "123456789012345678": { slug: "friends-of-openclaw", @@ -221,7 +198,8 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. channels: { "#general": { enabled: true, requireMention: true }, }, - dm: { enabled: true, allowFrom: ["U123"] }, + dmPolicy: "allowlist", + allowFrom: ["U123"], slashCommand: { enabled: true, name: "openclaw", @@ -280,14 +258,6 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. prompt: "HEARTBEAT", ackMaxChars: 300, }, - memorySearch: { - provider: "gemini", - model: "gemini-embedding-001", - remote: { - apiKey: "${GEMINI_API_KEY}", - }, - extraPaths: ["../team-docs", "/srv/shared-notes"], - }, sandbox: { mode: "non-main", scope: "session", // preferred over legacy perSession: true @@ -305,9 +275,8 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. }, }, }, - list: [ - { - id: "main", + entries: { + main: { default: true, identity: { name: "Samantha", @@ -322,21 +291,39 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. reasoningDefault: "on", // per-agent reasoning visibility fastModeDefault: false, // per-agent fast mode }, - { - id: "quick", + quick: { skills: [], // no skills for this agent fastModeDefault: true, // this agent always runs fast thinkingDefault: "off", }, - ], + }, + }, + + memory: { + search: { + provider: "gemini", + model: "gemini-embedding-001", + remote: { + apiKey: "${GEMINI_API_KEY}", + }, + extraPaths: ["../team-docs", "/srv/shared-notes"], + }, }, tools: { + media: { + models: [ + { provider: "openai", model: "gpt-4o-transcribe", capabilities: ["audio"] }, + { provider: "google", model: "gemini-3-flash-preview", capabilities: ["video"] }, + ], + audio: { enabled: true, maxBytes: 20971520, timeoutSeconds: 120 }, + video: { enabled: true, maxBytes: 52428800 }, + }, allow: ["exec", "process", "read", "write", "edit", "apply_patch"], deny: ["browser", "canvas"], exec: { backgroundMs: 10000, - timeoutSec: 1800, + timeoutSeconds: 1800, cleanupMs: 1800000, }, elevated: { @@ -442,7 +429,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. }, tailscale: { mode: "serve", resetOnExit: false }, remote: { url: "ws://gateway-host.ts.net:18789", token: "remote-token" }, - reload: { mode: "hybrid", debounceMs: 300 }, + reload: { mode: "hybrid" }, }, skills: { @@ -501,16 +488,16 @@ example `~/.agents/skills/manager -> ~/Projects/manager/skills`. workspace: "~/.openclaw/workspace", skills: ["github", "weather"], }, - list: [ - { id: "main", default: true }, - { id: "docs", workspace: "~/.openclaw/workspace-docs", skills: ["docs-search"] }, - ], + entries: { + main: { default: true }, + docs: { workspace: "~/.openclaw/workspace-docs", skills: ["docs-search"] }, + }, }, } ``` - `agents.defaults.skills` is the shared baseline. -- `agents.list[].skills` replaces that baseline for one agent. +- `agents.entries.*.skills` replaces that baseline for one agent. - Use `skills: []` when an agent should see no skills. ### Multi-platform setup @@ -519,7 +506,7 @@ example `~/.agents/skills/manager -> ~/Projects/manager/skills`. { agents: { defaults: { workspace: "~/.openclaw/workspace" } }, channels: { - whatsapp: { allowFrom: ["+15555550123"] }, + whatsapp: { allowFrom: ["+15555550123"], responsePrefix: "[openclaw]" }, telegram: { enabled: true, botToken: "YOUR_TOKEN", @@ -528,7 +515,7 @@ example `~/.agents/skills/manager -> ~/Projects/manager/skills`. discord: { enabled: true, token: "YOUR_TOKEN", - dm: { allowFrom: ["123456789012345678"] }, + allowFrom: ["123456789012345678"], }, }, } @@ -576,7 +563,7 @@ If more than one person can DM your bot (multiple entries in `allowFrom`, pairin discord: { enabled: true, token: "YOUR_DISCORD_BOT_TOKEN", - dm: { enabled: true, allowFrom: ["123456789012345678", "987654321098765432"] }, + allowFrom: ["123456789012345678", "987654321098765432"], }, }, } @@ -630,15 +617,14 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero workspace: "~/work-openclaw", elevatedDefault: "off", }, - list: [ - { - id: "main", + entries: { + main: { identity: { name: "WorkBot", theme: "professional assistant", }, }, - ], + }, }, channels: { slack: { diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 3d0e854b0d30..58367861d842 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -18,7 +18,7 @@ Code truth beats this page: Dedicated deep references: -- [Memory configuration reference](/reference/memory-config) for `agents.defaults.memorySearch.*`, `memory.qmd.*`, `memory.citations`, and dreaming config under `plugins.entries.memory-core.config.dreaming`. +- [Memory configuration reference](/reference/memory-config) for `memory.search.*`, `memory.qmd.*`, `memory.citations`, and dreaming config under `plugins.entries.memory-core.config.dreaming`. - [Slash commands](/tools/slash-commands) for the current built-in + bundled command catalog. - Owning channel/plugin pages for channel-specific command surfaces. @@ -374,7 +374,8 @@ read, account-wide exposure fails closed. - `model`: optional Dream Diary subagent model override. Requires `plugins.entries.memory-core.subagent.allowModelOverride: true`; pair with `allowedModels` to restrict targets. Model-unavailable errors retry once with the session default model; trust or allowlist failures do not fall back silently. - phase policy and thresholds are implementation details (not user-facing config keys). - Full memory config lives in [Memory configuration reference](/reference/memory-config): - - `agents.defaults.memorySearch.*` + - `memory.search.*` + - `agents.entries.*.memory.search.*` for per-agent overrides - `memory.backend` - `memory.citations` - `memory.qmd.*` @@ -387,17 +388,6 @@ See [Plugins](/tools/plugin). --- -## Commitments - -`commitments` controls inferred follow-up memory: OpenClaw can detect check-ins from conversation turns and deliver them through heartbeat runs. - -- `commitments.enabled`: enable hidden LLM extraction, storage, and heartbeat delivery for inferred follow-up commitments. Default: `false`. -- `commitments.maxPerDay`: maximum inferred follow-up commitments delivered per agent session in a rolling day. Default: `3`. - -See [Inferred commitments](/concepts/commitments). - ---- - ## Browser ```json5 @@ -578,8 +568,6 @@ See [Inferred commitments](/concepts/commitments). // chatMessageMaxWidth: "min(1280px, 82%)", // optional centered chat transcript max-width // allowedOrigins: ["https://control.example.com"], // required for non-loopback Control UI // dangerouslyAllowHostHeaderOriginFallback: false, // dangerous Host-header origin fallback mode - // allowInsecureAuth: false, - // dangerouslyDisableDeviceAuth: false, }, terminal: { enabled: false, @@ -605,8 +593,10 @@ See [Inferred commitments](/concepts/commitments). // timeoutMs, cidrs }. sshVerify: true, }, - allowCommands: ["canvas.navigate"], - denyCommands: ["system.run"], + commands: { + allow: ["canvas.navigate"], + deny: ["system.run"], + }, }, tools: { // Additional /tools/invoke HTTP denies @@ -680,7 +670,7 @@ See [Inferred commitments](/concepts/commitments). - `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior. - `gateway.nodes.pairing.autoApproveCidrs`: optional CIDR/IP allowlist for auto-approving first-time node device pairing with no requested scopes. It is disabled when unset. This does not auto-approve operator/browser/Control UI/WebChat pairing, and it does not auto-approve role, scope, metadata, or public-key upgrades. - `gateway.nodes.pairing.sshVerify`: SSH-verified auto-approval for first-time node device pairing (default: enabled). The gateway SSHes back to the pairing host (BatchMode, strict host keys) and approves only on an exact `openclaw node identity` device-key match. Same eligibility floor as `autoApproveCidrs`; probes are limited to private/CGNAT source addresses unless `cidrs` overrides them. Set `false` to disable, or `{ user, identity, timeoutMs, cidrs }` to tune. See [Node pairing](/gateway/pairing#ssh-verified-device-auto-approval-default). -- `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`: global allow/deny shaping for declared node commands after pairing and platform allowlist evaluation. Use `allowCommands` to opt into dangerous node commands such as `camera.snap`, `camera.clip`, `screen.record`, `health.summary`, `sms.search`, and `sms.send`; `denyCommands` removes a command even if a platform default or explicit allow would otherwise include it. iOS Health permission, Android SMS permission, and Gateway command authorization are independent. After a node changes its declared command list, reject and re-approve that device pairing so the gateway stores the updated command snapshot. +- `gateway.nodes.commands.allow` / `gateway.nodes.commands.deny`: global allow/deny shaping for declared node commands after pairing and platform allowlist evaluation. Use `commands.allow` to opt into dangerous node commands such as `camera.snap`, `camera.clip`, `screen.record`, `health.summary`, `sms.search`, and `sms.send`; `commands.deny` removes a command even if a platform default or explicit allow would otherwise include it. iOS Health permission, Android SMS permission, and Gateway command authorization are independent. After a node changes its declared command list, reject and re-approve that device pairing so the gateway stores the updated command snapshot. - `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list). - `gateway.tools.allow`: remove tool names from the default HTTP deny list for owner/admin callers. This does not upgrade identity-bearing `operator.write` @@ -1371,26 +1361,6 @@ writer is best-effort, not a lossless compliance archive. --- -## CLI - -```json5 -{ - cli: { - banner: { - taglineMode: "off", // random | default | off - }, - }, -} -``` - -- `cli.banner.taglineMode` controls banner tagline style: - - `"random"` (default): rotating funny/seasonal taglines. - - `"default"`: fixed neutral tagline (`All your chats, one OpenClaw.`). - - `"off"`: no tagline text (banner title/version still shown). -- To hide the entire banner (not just taglines), set env `OPENCLAW_HIDE_BANNER=1`. - ---- - ## Wizard Behavior and metadata for CLI guided setup flows (`onboard`, `configure`, `doctor`): @@ -1418,7 +1388,7 @@ Behavior and metadata for CLI guided setup flows (`onboard`, `configure`, `docto ## Identity -See `agents.list` identity fields under [Agent defaults](/gateway/config-agents#agent-defaults). +See `agents.entries` identity fields under [Agent defaults](/gateway/config-agents#agent-defaults). --- diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 8dff9e264e96..cd5cd398d3d0 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -19,6 +19,8 @@ Common reasons to add a config: See the [full reference](/gateway/configuration-reference) for every available field. +Configuration follows a two-bucket rule: root siblings hold infrastructure and cross-agent defaults, while `agents.defaults` holds agent-loop behavior. Entries under `agents.entries` may override either bucket where the schema supports a per-agent override. + Agents and automation should use `config.schema.lookup` for exact field-level docs before editing config. Use this page for task-oriented guidance and [Configuration reference](/gateway/configuration-reference) for the broader @@ -213,7 +215,7 @@ candidate contains a redacted secret placeholder such as `***` or `[redacted]`. Use `agents.defaults.skills` for a shared baseline, then override specific - agents with `agents.list[].skills`: + agents with `agents.entries.*.skills`: ```json5 { @@ -231,8 +233,8 @@ candidate contains a redacted secret placeholder such as `***` or `[redacted]`. ``` - Omit `agents.defaults.skills` for unrestricted skills by default. - - Omit `agents.list[].skills` to inherit the defaults. - - Set `agents.list[].skills: []` for no skills. + - Omit `agents.entries.*.skills` to inherit the defaults. + - Set `agents.entries.*.skills: []` for no skills. - See [Skills](/tools/skills), [Skills config](/tools/skills-config), and the [Configuration Reference](/gateway/config-agents#agents-defaults-skills). @@ -610,7 +612,7 @@ config file already exists (a first write with no existing config skips the chec replacement is intentional. If a patch would replace or delete an existing array with fewer entries, the Gateway rejects the write unless that exact path appears in `replacePaths`; nested arrays under array entries use `[]`, such as -`agents.list[].skills`. This prevents truncated `config.get` snapshots from +`agents.entries.*.skills`. This prevents truncated `config.get` snapshots from silently clobbering routing or allowlist arrays. Use `config.apply` when you intend to replace the full config. @@ -689,7 +691,7 @@ Rules: }, channels: { googlechat: { - serviceAccountRef: { + serviceAccount: { source: "exec", provider: "vault", id: "channels/googlechat/serviceAccount", diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index d9f0c89126ea..4df8bbf1e117 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -163,7 +163,7 @@ Flags: - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). - Legacy plugin manifest contract key migration (`speechProviders`, `realtimeTranscriptionProviders`, `realtimeVoiceProviders`, `mediaUnderstandingProviders`, `imageGenerationProviders`, `videoGenerationProviders`, `webFetchProviders`, `webSearchProviders` → `contracts`). - Legacy cron store migration (`jobId`, `schedule.cron`, top-level delivery/payload fields, payload `provider`, `notify: true` webhook fallback jobs). - - Codex CLI runtime pin repair (`agentRuntime.id: "codex-cli"` → `"codex"`) across `agents.defaults`, `agents.list[]`, and `models.providers.*` (including per-model entries). + - Codex CLI runtime pin repair (`agentRuntime.id: "codex-cli"` → `"codex"`) across `agents.defaults`, `agents.entries.*`, and `models.providers.*` (including per-model entries). - Stale plugin config cleanup when plugins are enabled; when `plugins.enabled=false`, stale plugin references are preserved as inert containment config. @@ -267,8 +267,14 @@ That stages grounded durable candidates into the short-term dreaming store while | `session.threadBindings.ttlHours`, `channels..threadBindings.ttlHours` (and per-account) | `...threadBindings.idleHours` | | legacy `talk.voiceId`/`talk.voiceAliases`/`talk.modelId`/`talk.outputFormat`/`talk.apiKey` | `talk.provider` + `talk.providers.` | | legacy top-level realtime Talk selectors (`talk.mode`/`talk.transport`/`talk.brain`/`talk.model`/`talk.voice`) | `talk.realtime` | - | `messages.tts.` (`openai`/`elevenlabs`/`microsoft`/`edge`) | `messages.tts.providers.` | - | `messages.tts.provider: "edge"` / `messages.tts.providers.edge` | `messages.tts.provider: "microsoft"` / `messages.tts.providers.microsoft` | + | `messages.tts` | top-level `tts` | + | `messages.tts.` (`openai`/`elevenlabs`/`microsoft`/`edge`) | `tts.providers.` | + | `messages.tts.provider: "edge"` / `messages.tts.providers.edge` | `tts.provider: "microsoft"` / `tts.providers.microsoft` | + | `tools.exec.security` + `tools.exec.ask` | `tools.exec.mode` | + | `session.idleMinutes` | `session.reset.idleMinutes` | + | `messages.responsePrefix` with explicit channel blocks | copied to configured channel/account `responsePrefix`; global fallback retained for implicit/custom channels | + | `web.enabled` | `channels.whatsapp.enabled` | + | `meta.lastTouchedAt`, hook installs, cron store, bundled discovery, global TTS prefs path | shared SQLite state | | TTS speaker fields `voice`/`voiceName`/`voiceId` | `speakerVoice`/`speakerVoiceId` | | `channels..tts.` / `channels..accounts..tts.` (all channels except Discord) | `...tts.providers.` | | `channels..voice.tts.` / `channels..accounts..voice.tts.` (all channels, including Discord) | `...voice.tts.providers.` | @@ -285,8 +291,31 @@ That stages grounded durable candidates into the short-term dreaming store while | `mcp.servers.*.type` (CLI-native aliases) | `mcp.servers.*.transport` | | `mcp.servers.*.disabled` | inverse `mcp.servers.*.enabled` | | MCP timeout aliases `connectTimeout`/`connect_timeout`/`timeout` | `connectionTimeoutMs`/`requestTimeoutMs` | + | MCP snake-case server fields | camelCase MCP server fields | + | `tools.media.image/audio/video.models` | capability-tagged `tools.media.models` | + | `tools.media.asyncCompletion` | removed | + | `tools.message.allowCrossContextSend` | `tools.message.crossContext` | + | media model `deepgram` options | `providerOptions.deepgram` | + | `talk.realtime.voice`, Discord realtime `voice` | `speakerVoice` | + | `agents.defaults.pdfMaxBytesMb` | `agents.defaults.pdfMaxMb` | + | `tools.exec.timeoutSec` | `tools.exec.timeoutSeconds` | + | `browser.ssrfPolicy.hostnameAllowlist` | wildcard-aware `browser.ssrfPolicy.allowedHostnames` | + | sandbox browser `enableNoVnc` | `noVncEnabled` | + | root `media` | `attachments` | + | channel/account `heartbeat` visibility blocks | `heartbeatVisibility` | + | `channels.slack.identity` | `channels.slack.postAs` | + | root `audit` | `logging.audit` | + | `gateway.nodes.skills.enabled` | `gateway.nodes.allowSkills` | + | `gateway.nodes.allowCommands`/`denyCommands` | `gateway.nodes.commands.allow`/`deny` | + | generation model defaults | `agents.defaults.mediaModels.{image,video,music}` | + | retired final-layout tuning knobs | built-in default behavior | + | `channels.whatsapp.messagePrefix` and legacy `messages.messagePrefix` | `channels.whatsapp.responsePrefix` | + | `channels.whatsapp.ackReaction` | global `messages.ackReaction` and `ackReactionScope` where translatable | + | `cron.failureDestination` | destination fields on `cron.failureAlert` | + | `gateway.controlUi.chatMessageMaxWidth` | `ui.prefs.chatMessageMaxWidth` | + | `agents.list` | keyed `agents.entries` | | top-level `defaultModel` | `agents.defaults.model` | - | `messages.messagePrefix` | `channels.whatsapp.messagePrefix` | + | `messages.messagePrefix` | `channels.whatsapp.responsePrefix` | | `session.maintenance.pruneDays`, `session.resetByType.dm` | `session.maintenance.pruneAfter`, `session.resetByType.direct` | | top-level `tui` | removed (the TUI footer uses the compact default) | | `plugins.entries.codex.config.codexDynamicToolsProfile` | removed (Codex app-server always keeps Codex-native workspace tools native) | @@ -296,7 +325,8 @@ That stages grounded durable candidates into the short-term dreaming store while | `agents.defaults/list[].embeddedPi` | `embeddedAgent` | | `agents.defaults/list[].sandbox.perSession` | `sandbox.scope` | | `agents.defaults.llm` | removed (use `models.providers..timeoutSeconds` for slow model/provider timeouts, kept below the agent/run timeout ceiling) | - | top-level `memorySearch` | `agents.defaults.memorySearch` | + | top-level `memorySearch`, `agents.defaults.memorySearch` | `memory.search` | + | `agents.entries.*.memorySearch` | `agents.entries.*.memory.search` | | `memorySearch.provider: "auto"` | `"openai"` | | `memorySearch.store.path` (any level) | removed (memory indexes live in each agent database) | | top-level `heartbeat` | `agents.defaults.heartbeat` / `channels.defaults.heartbeat` | @@ -354,7 +384,7 @@ That stages grounded durable candidates into the short-term dreaming store while - Existing provider/model runtime policy is preserved unless the repaired legacy model ref needs Codex routing to keep the old auth path. - Existing model fallback lists are preserved with their legacy entries rewritten; copied per-model settings move from the legacy key to the canonical `openai/*` key. - Persisted session `modelProvider`/`providerOverride`, `model`/`modelOverride`, fallback notices, and auth-profile pins are repaired across all discovered agent session stores. - - Doctor separately repairs stale `agentRuntime.id: "codex-cli"` pins (a distinct legacy runtime id) to `"codex"` across `agents.defaults`, `agents.list[]`, and `models.providers.*` model entries. + - Doctor separately repairs stale `agentRuntime.id: "codex-cli"` pins (a distinct legacy runtime id) to `"codex"` across `agents.defaults`, `agents.entries.*`, and `models.providers.*` model entries. - `/codex ...` means "control or bind a native Codex conversation from chat." - `/acp ...` or `runtime: "acp"` means "use the external ACP/acpx adapter." @@ -380,7 +410,7 @@ That stages grounded durable candidates into the short-term dreaming store while Doctor scans all installed plugin manifests for deprecated top-level capability keys (`speechProviders`, `realtimeTranscriptionProviders`, `realtimeVoiceProviders`, `mediaUnderstandingProviders`, `imageGenerationProviders`, `videoGenerationProviders`, `webFetchProviders`, `webSearchProviders`). When found, it offers to move them into the `contracts` object and rewrite the manifest file in-place. This migration is idempotent; if `contracts` already has the same values, the legacy key is removed without duplicating data. - Doctor also checks the cron job store (`~/.openclaw/cron/jobs.json` by default, or `cron.store` when overridden) for old job shapes that the scheduler still accepts for compatibility. + Doctor also checks the legacy cron job store (`~/.openclaw/cron/jobs.json`) for old job shapes before importing canonical rows into SQLite. Current cron cleanups include: diff --git a/docs/gateway/health.md b/docs/gateway/health.md index 9c4fe84fb283..9328dac19a41 100644 --- a/docs/gateway/health.md +++ b/docs/gateway/health.md @@ -60,7 +60,7 @@ When no `x-openclaw-session-key` header or `user` field is provided, `/v1/chat/c - `logged out` or status 409-515 -> relink with `openclaw channels logout` then `openclaw channels login`. - Gateway unreachable -> start it: `openclaw gateway --port 18789` (use `--force` if the port is busy). -- No inbound messages -> confirm linked phone is online and the sender is allowed (`channels.whatsapp.allowFrom`); for group chats, ensure allowlist + mention rules match (`channels.whatsapp.groups`, `agents.list[].groupChat.mentionPatterns`). +- No inbound messages -> confirm linked phone is online and the sender is allowed (`channels.whatsapp.allowFrom`); for group chats, ensure allowlist + mention rules match (`channels.whatsapp.groups`, `agents.entries.*.groupChat.mentionPatterns`). ## Dedicated "health" command diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 46f23fe043f2..13725e75fde0 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -61,9 +61,9 @@ Example config: ## Defaults -- Interval: `30m`. Applying Anthropic provider defaults bumps this to `1h` when the resolved auth mode is OAuth/token (including Claude CLI reuse), but only while `heartbeat.every` is unset. Set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable. +- Interval: `30m`. Applying Anthropic provider defaults bumps this to `1h` when the resolved auth mode is OAuth/token (including Claude CLI reuse), but only while `heartbeat.every` is unset. Set `agents.defaults.heartbeat.every` or per-agent `agents.entries.*.heartbeat.every`; use `0m` to disable. - Prompt body (configurable via `agents.defaults.heartbeat.prompt`): `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.` -- Timeout: unset heartbeat turns use `agents.defaults.timeoutSeconds` when set. Otherwise, they use the heartbeat cadence capped at 600 seconds. Set `agents.defaults.heartbeat.timeoutSeconds` or per-agent `agents.list[].heartbeat.timeoutSeconds` for longer heartbeat work. +- Timeout: unset heartbeat turns use `agents.defaults.timeoutSeconds` when set. Otherwise, they use the heartbeat cadence capped at 600 seconds. Set `agents.defaults.heartbeat.timeoutSeconds` or per-agent `agents.entries.*.heartbeat.timeoutSeconds` for longer heartbeat work. - The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a "Heartbeats" section only when heartbeats are enabled for the default agent (and `includeSystemPromptSection` is not `false`), and the run is flagged internally. - When heartbeats are disabled with `0m`, normal runs also omit `HEARTBEAT.md` from bootstrap context so the model does not see heartbeat-only instructions. - Active hours (`heartbeat.activeHours`) are checked in the configured timezone. Outside the window, heartbeats are skipped until the next tick inside the window. @@ -78,7 +78,7 @@ The default prompt is intentionally broad: Heartbeat can react to completed [background tasks](/automation/tasks), but a heartbeat run itself does not create a task record. -If you want a heartbeat to do something very specific (e.g. "check Gmail PubSub stats" or "verify gateway health"), set `agents.defaults.heartbeat.prompt` (or `agents.list[].heartbeat.prompt`) to a custom body (sent verbatim). +If you want a heartbeat to do something very specific (e.g. "check Gmail PubSub stats" or "verify gateway health"), set `agents.defaults.heartbeat.prompt` (or `agents.entries.*.heartbeat.prompt`) to a custom body (sent verbatim). ## Response contract @@ -119,14 +119,14 @@ Outside heartbeats, stray `HEARTBEAT_OK` at the start/end of a message is stripp ### Scope and precedence - `agents.defaults.heartbeat` sets global heartbeat behavior. -- `agents.list[].heartbeat` merges on top; if any agent has a `heartbeat` block, **only those agents** run heartbeats. -- `channels.defaults.heartbeat` sets visibility defaults for all channels. -- `channels..heartbeat` overrides channel defaults. -- `channels..accounts..heartbeat` (multi-account channels) overrides per-channel settings. +- `agents.entries.*.heartbeat` merges on top; if any agent has a `heartbeat` block, **only those agents** run heartbeats. +- `channels.defaults.heartbeatVisibility` sets visibility defaults for all channels. +- `channels..heartbeatVisibility` overrides channel defaults. +- `channels..accounts..heartbeatVisibility` (multi-account channels) overrides per-channel settings. ### Per-agent heartbeats -If any `agents.list[]` entry includes a `heartbeat` block, **only those agents** run heartbeats. The per-agent block merges on top of `agents.defaults.heartbeat` (so you can set shared defaults once and override per agent). +If any `agents.entries.*` entry includes a `heartbeat` block, **only those agents** run heartbeats. The per-agent block merges on top of `agents.defaults.heartbeat` (so you can set shared defaults once and override per agent). Example: two agents, only the second agent runs heartbeats. diff --git a/docs/gateway/logging.md b/docs/gateway/logging.md index edf9729e6d60..f37965219c86 100644 --- a/docs/gateway/logging.md +++ b/docs/gateway/logging.md @@ -58,13 +58,13 @@ Tune console verbosity independently: OpenClaw masks sensitive tokens before log or transcript output leaves the process. This redaction policy applies at console, file-log, OTLP log-record, and session transcript text sinks, so matching secret values are masked before JSONL lines or messages are written to disk. -- `logging.redactSensitive`: `off` | `tools` (default: `tools`) +- Sensitive-value redaction is always enabled. - `logging.redactPatterns`: array of regex strings (overrides defaults) - Use raw regex strings (auto `gi`), or `/pattern/flags` for custom flags. - Matches are masked keeping the first 6 + last 4 chars (values >= 18 chars); shorter values become `***`. - Defaults cover common key assignments, CLI flags, JSON fields, bearer headers, PEM blocks, popular vendor token prefixes, and payment credential field names (card number, CVC/CVV, shared payment token, payment credential). -Some safety boundaries always redact regardless of `logging.redactSensitive`: Control UI tool-call events, `sessions_history` tool output, diagnostics support exports, provider error observations, exec approval command display, and Gateway WebSocket protocol logs. These surfaces still honor `logging.redactPatterns` as additional patterns, but `redactSensitive: "off"` does not make them emit raw secrets. +Safety boundaries such as Control UI tool-call events, `sessions_history` output, diagnostics exports, provider errors, exec approval display, and Gateway WebSocket logs always redact. `logging.redactPatterns` adds deployment-specific patterns. ## Gateway WebSocket logs diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md index 2e0c6aae9baa..7a496350b3a9 100644 --- a/docs/gateway/pairing.md +++ b/docs/gateway/pairing.md @@ -98,8 +98,8 @@ Notes: Node pairing approval records the trusted capability surface. It does **not** pin the live node command surface per node. - Live node commands come from what the node declares on connect, filtered by - the gateway's global node command policy (`gateway.nodes.allowCommands` and - `denyCommands`). + the gateway's global node command policy (`gateway.nodes.commands.allow` and + `gateway.nodes.commands.deny`). - Per-node `system.run` allow and ask policy lives on the node in `exec.approvals.node.*`, not in the pairing record. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 840821f17da9..e826e915f0c4 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -355,7 +355,7 @@ path; skills are not accepted in `connect` params. Each descriptor contains a safe name, description, and bounded `SKILL.md` content. The Gateway parses that content with the normal skills loader, includes it in agent skill snapshots while the node is connected, and removes it on disconnect. Set -`gateway.nodes.skills.enabled: false` to ignore node-published skills. +`gateway.nodes.allowSkills: false` to ignore node-published skills. ## Presence @@ -531,7 +531,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `tts.enable` and `tts.disable` toggle TTS prefs state. - `tts.setProvider` updates the preferred TTS provider. - `tts.convert` runs one-shot text-to-speech conversion. - - `tts.speak` (`operator.write`) renders non-empty `text` with the configured general TTS provider chain and returns one whole clip inline as `audioBase64`, plus `provider` and optional `outputFormat`, `mimeType`, and `fileExtension` metadata. Unlike `tts.convert`, it does not return a Gateway-local path; unlike `talk.speak`, it does not require a Talk provider. Text above `messages.tts.maxTextLength` returns `INVALID_REQUEST`; synthesis failures return `UNAVAILABLE`. + - `tts.speak` (`operator.write`) renders non-empty `text` with the configured general TTS provider chain and returns one whole clip inline as `audioBase64`, plus `provider` and optional `outputFormat`, `mimeType`, and `fileExtension` metadata. Unlike `tts.convert`, it does not return a Gateway-local path; unlike `talk.speak`, it does not require a Talk provider. Text above `tts.maxTextLength` returns `INVALID_REQUEST`; synthesis failures return `UNAVAILABLE`. @@ -540,7 +540,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `secrets.resolve` resolves command-target secret assignments for a specific command/target set. - `config.get` returns the current on-disk config snapshot, raw root-file `hash`, resolved `configRevisionHash`, and optional `appliedConfigHash` for the resolved revision accepted by the active Gateway runtime. - `config.set` writes a validated config payload. - - `config.patch` merges a partial config update. Destructive array replacement requires the affected path in `replacePaths`; nested arrays under array entries use `[]` paths such as `agents.list[].skills`. + - `config.patch` merges a partial config update. Destructive array replacement requires the affected path in `replacePaths`; nested arrays under array entries use `[]` paths such as `agents.entries.*.skills`. - `config.apply` validates + replaces the full config payload. - `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, generation metadata, plugin + channel schema metadata when loadable. It includes `title` / `description` metadata from the same labels/help text as the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists. - `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` (`src/config/schema.ts`) and mirrors the gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`. @@ -607,7 +607,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `node.rename` updates a paired node label. - `node.invoke` forwards a command to a connected node. - `node.invoke.result` returns the result for an invoke request. - - `mcp.tools.call.v1` is the headless node-host command for calling a configured node-local MCP tool. It is carried through `node.invoke`, requires the node to declare the command, and remains subject to pairing approval and `gateway.nodes.denyCommands`. + - `mcp.tools.call.v1` is the headless node-host command for calling a configured node-local MCP tool. It is carried through `node.invoke`, requires the node to declare the command, and remains subject to pairing approval and `gateway.nodes.commands.deny`. - `node.event` carries node-originated events back into the gateway. - `node.pluginTools.update` is the only publication path for replacing the connected node's agent-visible plugin/MCP tool descriptors; `connect` params do not carry them. - `node.pending.pull` and `node.pending.ack` are the connected-node queue APIs. @@ -1107,11 +1107,7 @@ not replay rejected requests after reconnecting. and require approval. - WS clients normally include `device` identity during `connect` (operator + node). The only device-less operator exceptions are explicit trust paths: - - `gateway.controlUi.allowInsecureAuth=true` for localhost-only insecure - HTTP compatibility. - successful `gateway.auth.mode: "trusted-proxy"` operator Control UI auth. - - `gateway.controlUi.dangerouslyDisableDeviceAuth=true` (break-glass, severe - security downgrade). - direct-loopback `gateway-client` backend RPCs on the reserved internal helper path. - Omitting device identity has scope consequences. When a device-less @@ -1119,9 +1115,6 @@ not replay rejected requests after reconnecting. still clears self-declared scopes to an empty set unless that path has a named scope-preservation exception. Scope-gated methods then fail with `missing scope`. -- `gateway.controlUi.dangerouslyDisableDeviceAuth=true` is a Control UI - break-glass scope-preservation path. It does not grant scopes to arbitrary - custom backend or CLI-shaped WebSocket clients. - The reserved direct-loopback `gateway-client` backend helper path preserves scopes only for internal local control-plane RPCs; custom backend IDs do not receive this exception. diff --git a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md index 9af0e7fcfcda..86c600c32fea 100644 --- a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md +++ b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @@ -7,9 +7,9 @@ status: active OpenClaw has three related but different controls: -1. **Sandbox** (`agents.defaults.sandbox.*` / `agents.list[].sandbox.*`) decides **where tools run** (sandbox backend vs host). -2. **Tool policy** (`tools.*`, `tools.sandbox.tools.*`, `agents.list[].tools.*`) decides **which tools are available/allowed**. -3. **Elevated** (`tools.elevated.*`, `agents.list[].tools.elevated.*`) is an **exec-only escape hatch** to run outside the sandbox when you are sandboxed (`gateway` by default, or `node` when the exec target is configured to `node`). +1. **Sandbox** (`agents.defaults.sandbox.*` / `agents.entries.*.sandbox.*`) decides **where tools run** (sandbox backend vs host). +2. **Tool policy** (`tools.*`, `tools.sandbox.tools.*`, `agents.entries.*.tools.*`) decides **which tools are available/allowed**. +3. **Elevated** (`tools.elevated.*`, `agents.entries.*.tools.elevated.*`) is an **exec-only escape hatch** to run outside the sandbox when you are sandboxed (`gateway` by default, or `node` when the exec target is configured to `node`). ## Quick debug @@ -57,11 +57,11 @@ For a per-agent configuration with several host folders, access modes, and the e Two layers matter: -- **Tool profile**: `tools.profile` and `agents.list[].tools.profile` (base allowlist) -- **Provider tool profile**: `tools.byProvider[provider].profile` and `agents.list[].tools.byProvider[provider].profile` -- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny` -- **Provider tool policy**: `tools.byProvider[provider].allow/deny` and `agents.list[].tools.byProvider[provider].allow/deny` -- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*` +- **Tool profile**: `tools.profile` and `agents.entries.*.tools.profile` (base allowlist) +- **Provider tool profile**: `tools.byProvider[provider].profile` and `agents.entries.*.tools.byProvider[provider].profile` +- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.entries.*.tools.allow`/`agents.entries.*.tools.deny` +- **Provider tool policy**: `tools.byProvider[provider].allow/deny` and `agents.entries.*.tools.byProvider[provider].allow/deny` +- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.entries.*.tools.sandbox.tools.*` Rules of thumb: @@ -126,8 +126,8 @@ Elevated does **not** grant extra tools; it only affects `exec`. Gates: -- Enablement: `tools.elevated.enabled` (and optionally `agents.list[].tools.elevated.enabled`) -- Sender allowlists: `tools.elevated.allowFrom.` (and optionally `agents.list[].tools.elevated.allowFrom.`) +- Enablement: `tools.elevated.enabled` (and optionally `agents.entries.*.tools.elevated.enabled`) +- Sender allowlists: `tools.elevated.allowFrom.` (and optionally `agents.entries.*.tools.elevated.allowFrom.`) See [Elevated Mode](/tools/elevated). @@ -137,9 +137,9 @@ See [Elevated Mode](/tools/elevated). Fix-it keys (pick one): -- Disable sandbox: `agents.defaults.sandbox.mode=off` (or per-agent `agents.list[].sandbox.mode=off`) +- Disable sandbox: `agents.defaults.sandbox.mode=off` (or per-agent `agents.entries.*.sandbox.mode=off`) - Allow the tool inside sandbox: - - remove it from `tools.sandbox.tools.deny` (or per-agent `agents.list[].tools.sandbox.tools.deny`) + - remove it from `tools.sandbox.tools.deny` (or per-agent `agents.entries.*.tools.sandbox.tools.deny`) - or add it to `tools.sandbox.tools.allow` (or per-agent allow) - Check `openclaw logs` for the `agents/tool-policy` entry. It records the sandbox mode and whether the allow or deny rule blocked the tool. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 2c4dcd4d2f5a..f939f0dd3df7 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -6,7 +6,7 @@ read_when: "You want a dedicated explanation of sandboxing or need to tune agent status: active --- -OpenClaw can run tool execution inside a sandbox backend to reduce blast radius. Sandboxing is off by default and controlled by `agents.defaults.sandbox` (global) or `agents.list[].sandbox` (per-agent). The Gateway process always stays on the host; only tool execution moves into the sandbox when enabled. +OpenClaw can run tool execution inside a sandbox backend to reduce blast radius. Sandboxing is off by default and controlled by `agents.defaults.sandbox` (global) or `agents.entries.*.sandbox` (per-agent). The Gateway process always stays on the host; only tool execution moves into the sandbox when enabled. This is not a perfect security boundary, but it materially limits filesystem and process access when the model does something dumb. @@ -396,7 +396,7 @@ For Docker gateway deployments, `scripts/docker/setup.sh` can bootstrap sandbox Paths: - Global: `agents.defaults.sandbox.docker.setupCommand` -- Per-agent: `agents.list[].sandbox.docker.setupCommand` +- Per-agent: `agents.entries.*.sandbox.docker.setupCommand` @@ -425,7 +425,7 @@ Debugging: ## Multi-agent overrides -Each agent can override sandbox + tools: `agents.list[].sandbox` and `agents.list[].tools` (plus `agents.list[].tools.sandbox.tools` for sandbox tool policy). See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for precedence. +Each agent can override sandbox + tools: `agents.entries.*.sandbox` and `agents.entries.*.tools` (plus `agents.entries.*.tools.sandbox.tools` for sandbox tool policy). See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for precedence. ## Minimal enable example diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 4950da7b4987..b13511c68bad 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -575,7 +575,7 @@ Warning and audit signals: - `SECRETS_REF_OVERRIDES_PLAINTEXT` (runtime warning) - `REF_SHADOWED` (audit finding when `auth-profiles.json` credentials take precedence over `openclaw.json` refs) -Google Chat compatibility: `serviceAccountRef` takes precedence over plaintext `serviceAccount`; the plaintext value is ignored once the sibling ref is set. +Google Chat `serviceAccount` accepts inline JSON or a SecretRef. Doctor moves the retired sibling `serviceAccountRef` into this canonical field when it is unset. ## Activation triggers diff --git a/docs/gateway/security/audit-checks.md b/docs/gateway/security/audit-checks.md index 624c9e55ea03..8c2b0e11f1a1 100644 --- a/docs/gateway/security/audit-checks.md +++ b/docs/gateway/security/audit-checks.md @@ -21,127 +21,127 @@ either level depending on config (for example, whether the Gateway is remotely exposed). High-signal values you will most likely see in real deployments (not exhaustive): -| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix | -| --------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------- | -| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes | -| `fs.state_dir.perms_group_writable` | warn | Group users can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes | -| `fs.state_dir.perms_readable` | warn | State dir is readable by others | filesystem perms on `~/.openclaw` | yes | -| `fs.state_dir.symlink` | warn | State dir target becomes another trust boundary | state dir filesystem layout | no | -| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes | -| `fs.config.symlink` | warn | Symlinked config files are unsupported for writes and add another trust boundary | replace with a regular config file or point `OPENCLAW_CONFIG_PATH` at the real file | no | -| `fs.config.perms_group_readable` | warn | Group users can read config tokens/settings | filesystem perms on config file | yes | -| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes | -| `fs.config_include.perms_writable` | critical | Config include file can be modified by others | include-file perms referenced from `openclaw.json` | yes | -| `fs.config_include.perms_group_readable` | warn | Group users can read included secrets/settings | include-file perms referenced from `openclaw.json` | yes | -| `fs.config_include.perms_world_readable` | critical | Included secrets/settings are world-readable | include-file perms referenced from `openclaw.json` | yes | -| `fs.auth_profiles.perms_writable` | critical | Others can inject or replace stored model credentials | `agents//agent/auth-profiles.json` perms | yes | -| `fs.auth_profiles.perms_readable` | warn | Others can read API keys and OAuth tokens | `agents//agent/auth-profiles.json` perms | yes | -| `fs.credentials_dir.perms_writable` | critical | Others can modify channel pairing/credential state | filesystem perms on `~/.openclaw/credentials` | yes | -| `fs.credentials_dir.perms_readable` | warn | Others can read channel credential state | filesystem perms on `~/.openclaw/credentials` | yes | -| `fs.sessions_store.perms_readable` | warn | Others can read session transcripts/metadata | session store perms | yes | -| `fs.log_file.perms_readable` | warn | Others can read redacted-but-still-sensitive logs | gateway log file perms | yes | -| `fs.synced_dir` | warn | State/config in iCloud/Dropbox/Drive broadens token/transcript exposure | move config/state off synced folders | no | -| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no | -| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no | -| `gateway.trusted_proxies_missing` | warn | Reverse-proxy headers are present but not trusted | `gateway.trustedProxies` | no | -| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*`, `plugins.entries.admin-http-rpc` | no | -| `gateway.http.session_key_override_enabled` | info | HTTP API callers can override `sessionKey` | `gateway.http.allowSessionKeyOverride` | no | -| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API for owner/admin callers | `gateway.tools.allow` | no | -| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (desktop input/camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no | -| `gateway.nodes.deny_commands_ineffective` | warn | Pattern-like deny entries do not match shell text or groups | `gateway.nodes.denyCommands` | no | -| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | -| `gateway.tailscale_serve` | info | Tailnet exposure is enabled via Serve | `gateway.tailscale.mode` | no | -| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no | -| `gateway.control_ui.allowed_origins_wildcard` | warn/critical | `allowedOrigins=["*"]` disables browser-origin allowlisting | `gateway.controlUi.allowedOrigins` | no | -| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no | -| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no | -| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no | -| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no | -| `gateway.token_too_short` | warn | Short shared token is easier to brute force | `gateway.auth.token` | no | -| `gateway.auth_no_rate_limit` | warn | Exposed auth without rate limiting increases brute-force risk | `gateway.auth.rateLimit` | no | -| `gateway.trusted_proxy_auth` | critical | Proxy identity now becomes the auth boundary | `gateway.auth.mode="trusted-proxy"` | no | -| `gateway.trusted_proxy_no_proxies` | critical | Trusted-proxy auth without trusted proxy IPs is unsafe | `gateway.trustedProxies` | no | -| `gateway.trusted_proxy_no_user_header` | critical | Trusted-proxy auth cannot resolve user identity safely | `gateway.auth.trustedProxy.userHeader` | no | -| `gateway.trusted_proxy_no_allowlist` | warn | Trusted-proxy auth accepts any authenticated upstream user | `gateway.auth.trustedProxy.allowUsers` | no | -| `gateway.trusted_proxy_allow_loopback` | warn | Trusted-proxy auth accepts explicitly allowed loopback proxy sources | `gateway.auth.trustedProxy.allowLoopback` | no | -| `gateway.probe_auth_secretref_unavailable` | warn | Deep probe could not resolve auth SecretRefs in this command path | deep-probe auth source / SecretRef availability | no | -| `gateway.probe_failed` | warn | Live Gateway probe failed (`--deep` only) | gateway reachability/auth | no | -| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no | -| `config.insecure_or_dangerous_flags` | warn | One insecure/dangerous debug flag is enabled | key named in finding detail | no | -| `security.audit.suppressions.active` | info | Audit output has configured suppressions and may be filtered | `security.audit.suppressions` | no | -| `config.secrets.gateway_password_in_config` | warn | Gateway password is stored directly in config | `gateway.auth.password` | no | -| `config.secrets.hooks_token_in_config` | warn | Hook bearer token is stored directly in config | `hooks.token` | no | -| `hooks.token_reuse_gateway_token` | critical | Hook ingress token also unlocks Gateway auth | `hooks.token`, `gateway.auth.token`, `gateway.auth.password` | no | -| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no | -| `hooks.default_session_key_unset` | warn | Hook agent runs fan out into generated per-request sessions | `hooks.defaultSessionKey` | no | -| `hooks.allowed_agent_ids_unrestricted` | warn/critical | Authenticated hook callers may route to any configured agent | `hooks.allowedAgentIds` | no | -| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no | -| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no | -| `hooks.path_root` | critical | Hook path is `/`, making ingress easier to collide or misroute | `hooks.path` | no | -| `hooks.installs_unpinned_npm_specs` | warn | Hook install records are not pinned to immutable npm specs | hook install metadata | no | -| `hooks.installs_missing_integrity` | warn | Hook install records lack integrity metadata | hook install metadata | no | -| `hooks.installs_version_drift` | warn | Hook install records drift from installed packages | hook install metadata | no | -| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes | -| `browser.control_invalid_config` | warn | Browser control config is invalid before runtime | `browser.*` | no | -| `browser.control_no_auth` | critical | Browser control exposed without token/password auth | `gateway.auth.*` | no | -| `browser.remote_cdp_http` | warn | Remote CDP over plain HTTP lacks transport encryption | browser profile `cdpUrl` | no | -| `browser.remote_cdp_private_host` | warn | Remote CDP targets a private/internal host | browser profile `cdpUrl`, `browser.ssrfPolicy.*` | no | -| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no | -| `sandbox.bind_mount_non_absolute` | warn | Relative bind mounts can resolve unpredictably | `agents.*.sandbox.docker.binds[]` | no | -| `sandbox.dangerous_bind_mount` | critical | Sandbox bind mount targets blocked system, credential, or Docker socket paths | `agents.*.sandbox.docker.binds[]` | no | -| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no | -| `sandbox.dangerous_seccomp_profile` | critical | Sandbox seccomp profile weakens container isolation | `agents.*.sandbox.docker.securityOpt` | no | -| `sandbox.dangerous_apparmor_profile` | critical | Sandbox AppArmor profile weakens container isolation | `agents.*.sandbox.docker.securityOpt` | no | -| `sandbox.browser_cdp_bridge_unrestricted` | warn | Sandbox browser bridge is exposed without source-range restriction | `sandbox.browser.cdpSourceRange` | no | -| `sandbox.browser_container.non_loopback_publish` | critical | Existing browser container publishes CDP on non-loopback interfaces | browser sandbox container publish config | no | -| `sandbox.browser_container.hash_label_missing` | warn | Existing browser container predates current config-hash labels | `openclaw sandbox recreate --browser --all` | no | -| `sandbox.browser_container.hash_epoch_stale` | warn | Existing browser container predates current browser config epoch | `openclaw sandbox recreate --browser --all` | no | -| `sandbox.browser_container.docker_probe_timeout` | warn | Docker label probe for the browser container timed out | Docker daemon reachability | no | -| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` fails closed when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no | -| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` fails closed when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no | -| `tools.exec.security_full_configured` | warn/critical | Host exec is running with `security="full"` | `tools.exec.security`, `agents.list[].tools.exec.security` | no | -| `tools.exec.agent_skill_mcp_boundary_drift` | warn | Agent skill allowlists are present while host exec can reach MCP clients/registries | `agents.list[].tools.exec.*`, sandbox/OS isolation, MCP server credentials | no | -| `tools.exec.fs_tools_disabled_but_exec_enabled` | warn | Filesystem tool policy does not make shell execution read-only | `tools.deny`, `agents.list[].tools.deny`, `agents.*.sandbox.workspaceAccess` | no | -| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | host approvals file | no | -| `tools.exec.allowlist_interpreter_without_strict_inline_eval` | warn | Interpreter allowlists permit inline eval without forced reapproval | `tools.exec.strictInlineEval`, `agents.list[].tools.exec.strictInlineEval`, exec approvals allowlist | no | -| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no | -| `tools.exec.safe_bins_broad_behavior` | warn | Broad-behavior tools in `safeBins` weaken the low-risk stdin-filter trust model | `tools.exec.safeBins`, `agents.list[].tools.exec.safeBins` | no | -| `tools.exec.safe_bin_trusted_dirs_risky` | warn | `safeBinTrustedDirs` includes mutable or risky directories | `tools.exec.safeBinTrustedDirs`, `agents.list[].tools.exec.safeBinTrustedDirs` | no | -| `tools.elevated.allowFrom..wildcard` | critical | `tools.elevated.allowFrom.` includes `"*"`, approving every sender | `tools.elevated.allowFrom.` | no | -| `tools.elevated.allowFrom..large` | warn | Elevated allowlist for `` has more than 25 entries | `tools.elevated.allowFrom.` | no | -| `agents.claude_cli.permission_mode_overridden_by_yolo` | warn | Claude CLI `--permission-mode` is ignored because OpenClaw exec is fully unattended | `tools.exec.security`, `tools.exec.ask`, `cliBackends.claude-cli` args | no | -| `skills.workspace.symlink_escape` | warn | Workspace `skills/**/SKILL.md` resolves outside workspace root (symlink-chain drift) | workspace `skills/**` filesystem state | no | -| `skills.workspace.scan_truncated` | warn | Workspace skill scan hit its directory-visit cap before finishing | flatten/simplify the workspace `skills/` directory tree | no | -| `plugins.extensions_no_allowlist` | warn | Plugins are installed without an explicit plugin allowlist | `plugins.allowlist` | no | -| `plugins.allow_phantom_entries` | warn | `plugins.allow` lists an ID with no matching installed plugin | `plugins.allow` | no | -| `plugins.installs_unpinned_npm_specs` | warn | Plugin index records are not pinned to immutable npm specs | plugin install metadata | no | -| `plugins.installs_missing_integrity` | warn | Plugin index records lack integrity metadata | plugin install metadata | no | -| `plugins.installs_version_drift` | warn | Plugin index records drift from installed packages | plugin install metadata | no | -| `plugins.code_safety` | warn/critical | Plugin code scan found suspicious or dangerous patterns (`--deep` only) | plugin code / install source | no | -| `plugins.code_safety.entry_path` | warn | Plugin entry path points into hidden or `node_modules` locations | plugin manifest `entry` | no | -| `plugins.code_safety.entry_escape` | critical | Plugin entry escapes the plugin directory | plugin manifest `entry` | no | -| `plugins.code_safety.manifest_parse_error` | warn | Plugin manifest could not be parsed during the code-safety scan | plugin manifest file | no | -| `plugins.code_safety.scan_failed` | warn | Plugin code scan could not complete (`--deep` only) | plugin path / scan environment | no | -| `plugins..security_audit_failed` | warn | A plugin-owned security audit collector threw an error | that plugin's security-audit collector | no | -| `skills.code_safety` | warn/critical | Skill installer metadata/code contains suspicious or dangerous patterns (`--deep` only) | skill install source | no | -| `skills.code_safety.scan_failed` | warn | Skill code scan could not complete (`--deep` only) | skill scan environment | no | -| `security.exposure.open_channels_with_exec` | warn/critical | Shared/public rooms can reach exec-enabled agents | `channels.*.dmPolicy`, `channels.*.groupPolicy`, `tools.exec.*`, `agents.list[].tools.exec.*` | no | -| `security.exposure.open_groups_with_elevated` | critical | Open DMs/groups + elevated tools create high-impact prompt-injection paths | top-level or nested DM policy paths, account overrides, `channels.*.groupPolicy` | no | -| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open DMs/groups can reach command/file tools without sandbox/workspace guards | DM/group policy paths, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no | -| `security.exposure.open_groups_with_control_plane_tools` | critical | Open DMs/groups can reach gateway/cron control-plane tools | DM/group policy paths, `tools.allow`, `tools.alsoAllow`, `tools.profile`, `gateway`, `cron` | no | -| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no | -| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no | -| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no | -| `models.legacy` | warn | Legacy model families are still configured | model selection | no | -| `models.weak_tier` | warn | Configured models are below current recommended tiers | model selection | no | -| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no | -| `channels..dm.open` | critical | `` DM policy is `"open"`; anyone can DM the bot | `channels..dmPolicy`, `.allowFrom` | no | -| `channels..dm.open_invalid` | warn | `dmPolicy="open"` without `"*"` in `allowFrom` is inconsistent | `channels..allowFrom` | no | -| `channels..dm.scope_main_multiuser` | warn | Multiple DM senders currently share the main session | `session.dmScope` | no | -| `channels..allowFrom.dangerous_name_matching_enabled` | info | `dangerouslyAllowNameMatching` re-enables mutable name/email/tag sender matching | disable `dangerouslyAllowNameMatching`, use stable sender IDs | no | -| `channels..account.read_only_resolution` | warn | A channel account could not be fully resolved for audit (missing secret/gateway) | ensure referenced secrets are resolvable, or run against a live gateway snapshot | no | -| `channels..warning.` | info/warn/critical | Provider-specific security warning, classified from free-form plugin text | see finding detail | no | -| `summary.attack_surface` | info | Roll-up summary of auth, channel, tool, and exposure posture | multiple keys (see finding detail) | no | +| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix | +| --------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------- | +| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes | +| `fs.state_dir.perms_group_writable` | warn | Group users can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes | +| `fs.state_dir.perms_readable` | warn | State dir is readable by others | filesystem perms on `~/.openclaw` | yes | +| `fs.state_dir.symlink` | warn | State dir target becomes another trust boundary | state dir filesystem layout | no | +| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes | +| `fs.config.symlink` | warn | Symlinked config files are unsupported for writes and add another trust boundary | replace with a regular config file or point `OPENCLAW_CONFIG_PATH` at the real file | no | +| `fs.config.perms_group_readable` | warn | Group users can read config tokens/settings | filesystem perms on config file | yes | +| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes | +| `fs.config_include.perms_writable` | critical | Config include file can be modified by others | include-file perms referenced from `openclaw.json` | yes | +| `fs.config_include.perms_group_readable` | warn | Group users can read included secrets/settings | include-file perms referenced from `openclaw.json` | yes | +| `fs.config_include.perms_world_readable` | critical | Included secrets/settings are world-readable | include-file perms referenced from `openclaw.json` | yes | +| `fs.auth_profiles.perms_writable` | critical | Others can inject or replace stored model credentials | `agents//agent/auth-profiles.json` perms | yes | +| `fs.auth_profiles.perms_readable` | warn | Others can read API keys and OAuth tokens | `agents//agent/auth-profiles.json` perms | yes | +| `fs.credentials_dir.perms_writable` | critical | Others can modify channel pairing/credential state | filesystem perms on `~/.openclaw/credentials` | yes | +| `fs.credentials_dir.perms_readable` | warn | Others can read channel credential state | filesystem perms on `~/.openclaw/credentials` | yes | +| `fs.sessions_store.perms_readable` | warn | Others can read session transcripts/metadata | session store perms | yes | +| `fs.log_file.perms_readable` | warn | Others can read redacted-but-still-sensitive logs | gateway log file perms | yes | +| `fs.synced_dir` | warn | State/config in iCloud/Dropbox/Drive broadens token/transcript exposure | move config/state off synced folders | no | +| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no | +| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no | +| `gateway.trusted_proxies_missing` | warn | Reverse-proxy headers are present but not trusted | `gateway.trustedProxies` | no | +| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*`, `plugins.entries.admin-http-rpc` | no | +| `gateway.http.session_key_override_enabled` | info | HTTP API callers can override `sessionKey` | `gateway.http.allowSessionKeyOverride` | no | +| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API for owner/admin callers | `gateway.tools.allow` | no | +| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (desktop input/camera/screen/contacts/calendar/SMS) | `gateway.nodes.commands.allow` | no | +| `gateway.nodes.deny_commands_ineffective` | warn | Pattern-like deny entries do not match shell text or groups | `gateway.nodes.commands.deny` | no | +| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no | +| `gateway.tailscale_serve` | info | Tailnet exposure is enabled via Serve | `gateway.tailscale.mode` | no | +| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no | +| `gateway.control_ui.allowed_origins_wildcard` | warn/critical | `allowedOrigins=["*"]` disables browser-origin allowlisting | `gateway.controlUi.allowedOrigins` | no | +| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no | +| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no | +| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no | +| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no | +| `gateway.token_too_short` | warn | Short shared token is easier to brute force | `gateway.auth.token` | no | +| `gateway.auth_no_rate_limit` | warn | Exposed auth without rate limiting increases brute-force risk | `gateway.auth.rateLimit` | no | +| `gateway.trusted_proxy_auth` | critical | Proxy identity now becomes the auth boundary | `gateway.auth.mode="trusted-proxy"` | no | +| `gateway.trusted_proxy_no_proxies` | critical | Trusted-proxy auth without trusted proxy IPs is unsafe | `gateway.trustedProxies` | no | +| `gateway.trusted_proxy_no_user_header` | critical | Trusted-proxy auth cannot resolve user identity safely | `gateway.auth.trustedProxy.userHeader` | no | +| `gateway.trusted_proxy_no_allowlist` | warn | Trusted-proxy auth accepts any authenticated upstream user | `gateway.auth.trustedProxy.allowUsers` | no | +| `gateway.trusted_proxy_allow_loopback` | warn | Trusted-proxy auth accepts explicitly allowed loopback proxy sources | `gateway.auth.trustedProxy.allowLoopback` | no | +| `gateway.probe_auth_secretref_unavailable` | warn | Deep probe could not resolve auth SecretRefs in this command path | deep-probe auth source / SecretRef availability | no | +| `gateway.probe_failed` | warn | Live Gateway probe failed (`--deep` only) | gateway reachability/auth | no | +| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no | +| `config.insecure_or_dangerous_flags` | warn | One insecure/dangerous debug flag is enabled | key named in finding detail | no | +| `security.audit.suppressions.active` | info | Audit output has configured suppressions and may be filtered | `security.audit.suppressions` | no | +| `config.secrets.gateway_password_in_config` | warn | Gateway password is stored directly in config | `gateway.auth.password` | no | +| `config.secrets.hooks_token_in_config` | warn | Hook bearer token is stored directly in config | `hooks.token` | no | +| `hooks.token_reuse_gateway_token` | critical | Hook ingress token also unlocks Gateway auth | `hooks.token`, `gateway.auth.token`, `gateway.auth.password` | no | +| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no | +| `hooks.default_session_key_unset` | warn | Hook agent runs fan out into generated per-request sessions | `hooks.defaultSessionKey` | no | +| `hooks.allowed_agent_ids_unrestricted` | warn/critical | Authenticated hook callers may route to any configured agent | `hooks.allowedAgentIds` | no | +| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no | +| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no | +| `hooks.path_root` | critical | Hook path is `/`, making ingress easier to collide or misroute | `hooks.path` | no | +| `hooks.installs_unpinned_npm_specs` | warn | Hook install records are not pinned to immutable npm specs | hook install metadata | no | +| `hooks.installs_missing_integrity` | warn | Hook install records lack integrity metadata | hook install metadata | no | +| `hooks.installs_version_drift` | warn | Hook install records drift from installed packages | hook install metadata | no | +| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes | +| `browser.control_invalid_config` | warn | Browser control config is invalid before runtime | `browser.*` | no | +| `browser.control_no_auth` | critical | Browser control exposed without token/password auth | `gateway.auth.*` | no | +| `browser.remote_cdp_http` | warn | Remote CDP over plain HTTP lacks transport encryption | browser profile `cdpUrl` | no | +| `browser.remote_cdp_private_host` | warn | Remote CDP targets a private/internal host | browser profile `cdpUrl`, `browser.ssrfPolicy.*` | no | +| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no | +| `sandbox.bind_mount_non_absolute` | warn | Relative bind mounts can resolve unpredictably | `agents.*.sandbox.docker.binds[]` | no | +| `sandbox.dangerous_bind_mount` | critical | Sandbox bind mount targets blocked system, credential, or Docker socket paths | `agents.*.sandbox.docker.binds[]` | no | +| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no | +| `sandbox.dangerous_seccomp_profile` | critical | Sandbox seccomp profile weakens container isolation | `agents.*.sandbox.docker.securityOpt` | no | +| `sandbox.dangerous_apparmor_profile` | critical | Sandbox AppArmor profile weakens container isolation | `agents.*.sandbox.docker.securityOpt` | no | +| `sandbox.browser_cdp_bridge_unrestricted` | warn | Sandbox browser bridge is exposed without source-range restriction | `sandbox.browser.cdpSourceRange` | no | +| `sandbox.browser_container.non_loopback_publish` | critical | Existing browser container publishes CDP on non-loopback interfaces | browser sandbox container publish config | no | +| `sandbox.browser_container.hash_label_missing` | warn | Existing browser container predates current config-hash labels | `openclaw sandbox recreate --browser --all` | no | +| `sandbox.browser_container.hash_epoch_stale` | warn | Existing browser container predates current browser config epoch | `openclaw sandbox recreate --browser --all` | no | +| `sandbox.browser_container.docker_probe_timeout` | warn | Docker label probe for the browser container timed out | Docker daemon reachability | no | +| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` fails closed when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no | +| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` fails closed when sandbox is off | `agents.entries.*.tools.exec.host`, `agents.entries.*.sandbox.mode` | no | +| `tools.exec.security_full_configured` | warn/critical | Host exec is running with `security="full"` | `tools.exec.security`, `agents.entries.*.tools.exec.security` | no | +| `tools.exec.agent_skill_mcp_boundary_drift` | warn | Agent skill allowlists are present while host exec can reach MCP clients/registries | `agents.entries.*.tools.exec.*`, sandbox/OS isolation, MCP server credentials | no | +| `tools.exec.fs_tools_disabled_but_exec_enabled` | warn | Filesystem tool policy does not make shell execution read-only | `tools.deny`, `agents.entries.*.tools.deny`, `agents.*.sandbox.workspaceAccess` | no | +| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | host approvals file | no | +| `tools.exec.allowlist_interpreter_without_strict_inline_eval` | warn | Interpreter allowlists permit inline eval without forced reapproval | `tools.exec.strictInlineEval`, `agents.entries.*.tools.exec.strictInlineEval`, exec approvals allowlist | no | +| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.entries.*.tools.exec.*` | no | +| `tools.exec.safe_bins_broad_behavior` | warn | Broad-behavior tools in `safeBins` weaken the low-risk stdin-filter trust model | `tools.exec.safeBins`, `agents.entries.*.tools.exec.safeBins` | no | +| `tools.exec.safe_bin_trusted_dirs_risky` | warn | `safeBinTrustedDirs` includes mutable or risky directories | `tools.exec.safeBinTrustedDirs`, `agents.entries.*.tools.exec.safeBinTrustedDirs` | no | +| `tools.elevated.allowFrom..wildcard` | critical | `tools.elevated.allowFrom.` includes `"*"`, approving every sender | `tools.elevated.allowFrom.` | no | +| `tools.elevated.allowFrom..large` | warn | Elevated allowlist for `` has more than 25 entries | `tools.elevated.allowFrom.` | no | +| `agents.claude_cli.permission_mode_overridden_by_yolo` | warn | Claude CLI `--permission-mode` is ignored because OpenClaw exec is fully unattended | `tools.exec.security`, `tools.exec.ask`, `cliBackends.claude-cli` args | no | +| `skills.workspace.symlink_escape` | warn | Workspace `skills/**/SKILL.md` resolves outside workspace root (symlink-chain drift) | workspace `skills/**` filesystem state | no | +| `skills.workspace.scan_truncated` | warn | Workspace skill scan hit its directory-visit cap before finishing | flatten/simplify the workspace `skills/` directory tree | no | +| `plugins.extensions_no_allowlist` | warn | Plugins are installed without an explicit plugin allowlist | `plugins.allowlist` | no | +| `plugins.allow_phantom_entries` | warn | `plugins.allow` lists an ID with no matching installed plugin | `plugins.allow` | no | +| `plugins.installs_unpinned_npm_specs` | warn | Plugin index records are not pinned to immutable npm specs | plugin install metadata | no | +| `plugins.installs_missing_integrity` | warn | Plugin index records lack integrity metadata | plugin install metadata | no | +| `plugins.installs_version_drift` | warn | Plugin index records drift from installed packages | plugin install metadata | no | +| `plugins.code_safety` | warn/critical | Plugin code scan found suspicious or dangerous patterns (`--deep` only) | plugin code / install source | no | +| `plugins.code_safety.entry_path` | warn | Plugin entry path points into hidden or `node_modules` locations | plugin manifest `entry` | no | +| `plugins.code_safety.entry_escape` | critical | Plugin entry escapes the plugin directory | plugin manifest `entry` | no | +| `plugins.code_safety.manifest_parse_error` | warn | Plugin manifest could not be parsed during the code-safety scan | plugin manifest file | no | +| `plugins.code_safety.scan_failed` | warn | Plugin code scan could not complete (`--deep` only) | plugin path / scan environment | no | +| `plugins..security_audit_failed` | warn | A plugin-owned security audit collector threw an error | that plugin's security-audit collector | no | +| `skills.code_safety` | warn/critical | Skill installer metadata/code contains suspicious or dangerous patterns (`--deep` only) | skill install source | no | +| `skills.code_safety.scan_failed` | warn | Skill code scan could not complete (`--deep` only) | skill scan environment | no | +| `security.exposure.open_channels_with_exec` | warn/critical | Shared/public rooms can reach exec-enabled agents | `channels.*.dmPolicy`, `channels.*.groupPolicy`, `tools.exec.*`, `agents.entries.*.tools.exec.*` | no | +| `security.exposure.open_groups_with_elevated` | critical | Open DMs/groups + elevated tools create high-impact prompt-injection paths | top-level or nested DM policy paths, account overrides, `channels.*.groupPolicy` | no | +| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open DMs/groups can reach command/file tools without sandbox/workspace guards | DM/group policy paths, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no | +| `security.exposure.open_groups_with_control_plane_tools` | critical | Open DMs/groups can reach gateway/cron control-plane tools | DM/group policy paths, `tools.allow`, `tools.alsoAllow`, `tools.profile`, `gateway`, `cron` | no | +| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no | +| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.entries.*.tools.profile` | no | +| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no | +| `models.legacy` | warn | Legacy model families are still configured | model selection | no | +| `models.weak_tier` | warn | Configured models are below current recommended tiers | model selection | no | +| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no | +| `channels..dm.open` | critical | `` DM policy is `"open"`; anyone can DM the bot | `channels..dmPolicy`, `.allowFrom` | no | +| `channels..dm.open_invalid` | warn | `dmPolicy="open"` without `"*"` in `allowFrom` is inconsistent | `channels..allowFrom` | no | +| `channels..dm.scope_main_multiuser` | warn | Multiple DM senders currently share the main session | `session.dmScope` | no | +| `channels..allowFrom.dangerous_name_matching_enabled` | info | `dangerouslyAllowNameMatching` re-enables mutable name/email/tag sender matching | disable `dangerouslyAllowNameMatching`, use stable sender IDs | no | +| `channels..account.read_only_resolution` | warn | A channel account could not be fully resolved for audit (missing secret/gateway) | ensure referenced secrets are resolvable, or run against a live gateway snapshot | no | +| `channels..warning.` | info/warn/critical | Provider-specific security warning, classified from free-form plugin text | see finding detail | no | +| `summary.attack_surface` | info | Roll-up summary of auth, channel, tool, and exposure posture | multiple keys (see finding detail) | no | `channels..*` and `tools.elevated.allowFrom..*` checkIds are generated per configured channel/provider, so `` is a real channel id diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 3a056c665bdf..48a06fe4d568 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -51,7 +51,7 @@ openclaw security audit --json - **Browser control exposure** - remote nodes, relay ports, remote CDP endpoints. - **Local disk hygiene** - permissions, symlinks, config includes, synced-folder paths. - **Plugins** - loading without an explicit allowlist. -- **Policy drift** - sandbox Docker settings configured but sandbox mode off; `gateway.nodes.denyCommands` entries that look effective but only match exact command IDs (for example `system.run`), not shell text inside the payload; dangerous `gateway.nodes.allowCommands` entries; global `tools.profile="minimal"` overridden per agent; plugin-owned tools reachable under a permissive policy. +- **Policy drift** - sandbox Docker settings configured but sandbox mode off; `gateway.nodes.commands.deny` entries that look effective but only match exact command IDs (for example `system.run`), not shell text inside the payload; dangerous `gateway.nodes.commands.allow` entries; global `tools.profile="minimal"` overridden per agent; plugin-owned tools reachable under a permissive policy. - **Runtime expectation drift** - assuming implicit exec still means `sandbox` when `tools.exec.host` now defaults to `auto`, or setting `tools.exec.host="sandbox"` while sandbox mode is off. - **Model hygiene** - warns on legacy configured models (soft warning, not a hard block). @@ -173,7 +173,7 @@ Treat `dmPolicy="open"` and `groupPolicy="open"` as last-resort settings; prefer - **DM allowlist** (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`): who can DM the bot. When `dmPolicy="pairing"`, approvals write to `~/.openclaw/credentials/-allowFrom.json` (default account) or `--allowFrom.json` (non-default accounts), merged with config allowlists. - **Group allowlist** (channel-specific): which groups/channels/guilds the bot accepts at all. - - `channels.whatsapp.groups`, `channels.telegram.groups`, `channels.imessage.groups`: per-group defaults like `requireMention`; when set, also acts as a group allowlist (include `"*"` to keep allow-all behavior). Customize mention triggers with `agents.list[].groupChat.mentionPatterns` (for example `["@openclaw", "@mybot"]`) so `requireMention` gates on your own bot names. + - `channels.whatsapp.groups`, `channels.telegram.groups`, `channels.imessage.groups`: per-group defaults like `requireMention`; when set, also acts as a group allowlist (include `"*"` to keep allow-all behavior). Customize mention triggers with `agents.entries.*.groupChat.mentionPatterns` (for example `["@openclaw", "@mybot"]`) so `requireMention` gates on your own bot names. - `groupPolicy="allowlist"` + `groupAllowFrom`: restrict who can trigger the bot inside a group session (WhatsApp/Telegram/Signal/iMessage/Microsoft Teams). - `channels.discord.guilds` / `channels.slack.channels`: per-surface allowlists + mention defaults. - Check order: `groupPolicy`/group allowlists first, then mention/reply activation. Replying to a bot message (implicit mention) does **not** bypass `groupAllowFrom`. @@ -311,7 +311,7 @@ For any agent/surface handling untrusted content, deny these by default: If a macOS node is paired, the Gateway can invoke `system.run` on it - this is remote code execution on that Mac. - Requires node pairing (approval + token). Pairing establishes node identity/trust and token issuance; it is not a per-command approval surface. -- The Gateway applies a coarse global node command policy via `gateway.nodes.allowCommands` / `denyCommands`. `denyCommands` matches exact node command names only (for example `system.run`), not shell text inside a command payload - a reconnecting node advertising a different command list is not, by itself, a vulnerability if the gateway global policy and the node's own exec approvals still enforce the boundary. +- The Gateway applies a coarse global node command policy via `gateway.nodes.commands.allow` / `gateway.nodes.commands.deny`. The deny list matches exact node command names only (for example `system.run`), not shell text inside a command payload - a reconnecting node advertising a different command list is not, by itself, a vulnerability if the gateway global policy and the node's own exec approvals still enforce the boundary. - The per-node `system.run` policy is the node's own exec approvals file (`exec.approvals.node.*`), controlled on the Mac via Settings -> Exec approvals (security + ask + allowlist); it can be stricter or looser than the gateway's global command-ID policy. - A node running `security="full"` and `ask="off"` follows the default trusted-operator model - expected behavior, not a bug, unless your deployment needs a tighter stance. - Approval mode binds exact request context and, when possible, one concrete local script/file operand. If OpenClaw cannot identify exactly one direct local file for an interpreter/runtime command, approval-backed execution is denied rather than promising full semantic coverage. @@ -360,7 +360,7 @@ Agent workspace access inside the sandbox (`agents.defaults.sandbox.workspaceAcc Extra `sandbox.docker.binds` are validated against normalized, canonicalized source paths. A blocked-path denylist covers `/etc`, `/private/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/boot`, and directories that commonly contain or alias the Docker socket (`/run`, `/var/run`, and `docker.sock` under them), plus HOME credential subpaths (`.aws`, `.cargo`, `.config`, `.docker`, `.gnupg`, `.netrc`, `.npm`, `.ssh`). Parent-symlink tricks and canonical home aliases are resolved through existing ancestors and re-checked, so they still fail closed if they resolve into a blocked root. -`tools.elevated` is the global baseline escape hatch that runs exec outside the sandbox. The effective host is `gateway` by default, or `node` when the exec target is configured to `node`. Keep `tools.elevated.allowFrom` tight and do not enable it for strangers. Further restrict per agent via `agents.list[].tools.elevated`. See [Elevated mode](/tools/elevated). +`tools.elevated` is the global baseline escape hatch that runs exec outside the sandbox. The effective host is `gateway` by default, or `node` when the exec target is configured to `node`. Keep `tools.elevated.allowFrom` tight and do not enable it for strangers. Further restrict per agent via `agents.entries.*.tools.elevated`. See [Elevated mode](/tools/elevated). ### Sub-agent delegation guardrail @@ -368,7 +368,7 @@ Extra `sandbox.docker.binds` are validated against normalized, canonicalized sou If you allow session tools, treat delegated sub-agent runs as another boundary decision: - Deny `sessions_spawn` unless the agent truly needs delegation. -- Keep `agents.defaults.subagents.allowAgents` and any per-agent `agents.list[].subagents.allowAgents` overrides restricted to known-safe target agents. +- Keep `agents.defaults.subagents.allowAgents` and any per-agent `agents.entries.*.subagents.allowAgents` overrides restricted to known-safe target agents. - For workflows that must remain sandboxed, call `sessions_spawn` with `sandbox: "require"` (default is `"inherit"`); `"require"` fails fast when the target child runtime is not sandboxed. ### Read-only mode diff --git a/docs/help/debugging.md b/docs/help/debugging.md index 791f54b43ae0..d3a25c54c3d0 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -15,8 +15,8 @@ Debugging helpers for streaming output, gateway iteration, and startup profiling ```text /debug show -/debug set messages.responsePrefix="[openclaw]" -/debug unset messages.responsePrefix +/debug set channels.whatsapp.responsePrefix="[openclaw]" +/debug unset channels.whatsapp.responsePrefix /debug reset ``` diff --git a/docs/help/environment.md b/docs/help/environment.md index a453b8a8a2a2..0a10c7025502 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -22,6 +22,68 @@ On fresh Ubuntu installs that use the default state dir, OpenClaw also treats `~ If the config file is missing entirely, step 4 is skipped; shell import still runs if enabled. +## Supported operator-facing variables + +The variables below are the supported environment contract for operators. Undocumented `OPENCLAW_*` variables are internal implementation details and may disappear without notice. + +### Paths and instances + +| Variable | Purpose | +| ------------------------ | ----------------------------------------------------------------- | +| `OPENCLAW_HOME` | Override the home directory used for OpenClaw path defaults. | +| `OPENCLAW_STATE_DIR` | Override the mutable state directory. | +| `OPENCLAW_CONFIG_PATH` | Override the active config file path. | +| `OPENCLAW_WORKSPACE_DIR` | Override the default agent workspace. | +| `OPENCLAW_PROFILE` | Select a named profile and its isolated defaults. | +| `OPENCLAW_GIT_DIR` | Override the source checkout used by development-channel updates. | +| `OPENCLAW_INCLUDE_ROOTS` | Allow `$include` to resolve from additional roots. | + +### Gateway and authentication + +| Variable | Purpose | +| --------------------------- | --------------------------------------------------------------- | +| `OPENCLAW_GATEWAY_URL` | Override the remote Gateway URL used by clients. | +| `OPENCLAW_GATEWAY_PORT` | Override the local Gateway port. | +| `OPENCLAW_GATEWAY_TOKEN` | Supply token authentication for Gateway servers and clients. | +| `OPENCLAW_GATEWAY_PASSWORD` | Supply password authentication for Gateway servers and clients. | + +### Provider credentials + +Core and bundled provider plugins recognize the following credential and provider-selection variables. Prefer each provider's config or SecretRef fields when you need scoped credentials rather than one process-wide value. + +`AI_GATEWAY_API_KEY`, `ANTHROPIC_ADMIN_API_KEY`, `ANTHROPIC_ADMIN_KEY`, `ANTHROPIC_API_KEY`, `ANTHROPIC_OAUTH_TOKEN`, `ARCEEAI_API_KEY`, `AZURE_OPENAI_API_KEY`, `AZURE_SPEECH_API_KEY`, `AZURE_SPEECH_KEY`, `AZURE_SPEECH_REGION`, `BASETEN_API_KEY`, `BRAVE_API_KEY`, `BYTEPLUS_API_KEY`, `BYTEPLUS_SEED_SPEECH_API_KEY`, `CEREBRAS_API_KEY`, `CHUTES_API_KEY`, `CHUTES_OAUTH_TOKEN`, `CLAWROUTER_API_KEY`, `CLOUDFLARE_AI_GATEWAY_API_KEY`, `CODEX_API_KEY`, `COHERE_API_KEY`, `COMFY_API_KEY`, `COMFY_CLOUD_API_KEY`, `COPILOT_GITHUB_TOKEN`, `DASHSCOPE_API_KEY`, `DEEPGRAM_API_KEY`, `DEEPINFRA_API_KEY`, `DEEPSEEK_API_KEY`, `ELEVENLABS_API_KEY`, `EXA_API_KEY`, `FAL_API_KEY`, `FAL_KEY`, `FEATHERLESS_API_KEY`, `FIRECRAWL_API_KEY`, `FIREWORKS_API_KEY`, `GCLOUD_PROJECT`, `GEMINI_API_KEY`, `GH_TOKEN`, `GITHUB_TOKEN`, `GMI_API_KEY`, `GOOGLE_API_KEY`, `GOOGLE_APPLICATION_CREDENTIALS`, `GOOGLE_CLOUD_API_KEY`, `GOOGLE_CLOUD_LOCATION`, `GOOGLE_CLOUD_PROJECT`, `GRADIUM_API_KEY`, `GROQ_API_KEY`, `HF_TOKEN`, `HUGGINGFACE_HUB_TOKEN`, `INWORLD_API_KEY`, `KILOCODE_API_KEY`, `KIMICODE_API_KEY`, `KIMI_API_KEY`, `LITELLM_API_KEY`, `LM_API_TOKEN`, `LONGCAT_API_KEY`, `MINIMAX_API_KEY`, `MINIMAX_CODE_PLAN_KEY`, `MINIMAX_CODING_API_KEY`, `MINIMAX_OAUTH_TOKEN`, `MISTRAL_API_KEY`, `MODELSTUDIO_API_KEY`, `MODEL_API_KEY`, `MOONSHOT_API_KEY`, `NOVITA_API_KEY`, `NVIDIA_API_KEY`, `OLLAMA_API_KEY`, `OPENAI_ADMIN_KEY`, `OPENAI_API_KEY`, `OPENCODE_API_KEY`, `OPENCODE_ZEN_API_KEY`, `OPENROUTER_API_KEY`, `PARALLEL_API_KEY`, `PERPLEXITY_API_KEY`, `PIXVERSE_API_KEY`, `QIANFAN_API_KEY`, `QWEN_API_KEY`, `QWEN_TOKEN_PLAN_API_KEY`, `RUNWAYML_API_SECRET`, `RUNWAY_API_KEY`, `SENSEAUDIO_API_KEY`, `SGLANG_API_KEY`, `SPEECH_KEY`, `SPEECH_REGION`, `STEPFUN_API_KEY`, `SYNTHETIC_API_KEY`, `TAVILY_API_KEY`, `TOGETHER_API_KEY`, `TOKENHUB_API_KEY`, `TOKENPLAN_API_KEY`, `VENICE_API_KEY`, `VLLM_API_KEY`, `VOLCANO_ENGINE_API_KEY`, `VOLCENGINE_TTS_API_KEY`, `VOLCENGINE_TTS_APPID`, `VOLCENGINE_TTS_TOKEN`, `VOYAGE_API_KEY`, `VYDRA_API_KEY`, `XAI_API_KEY`, `XIAOMI_API_KEY`, `XIAOMI_TOKEN_PLAN_API_KEY`, `XI_API_KEY`, `ZAI_API_KEY`, and `Z_AI_API_KEY`. + +Installed third-party plugins may declare additional credential variables in their plugin manifests; those variables are contracts of the plugin that declares them, not core OpenClaw variables. + +### Logging and diagnostics + +| Variable | Purpose | +| ------------------------------------ | ------------------------------------------------------------- | +| `OPENCLAW_LOG_LEVEL` | Override file and console log levels. | +| `OPENCLAW_DEBUG_MODEL_TRANSPORT` | Enable model transport timing diagnostics. | +| `OPENCLAW_DEBUG_MODEL_PAYLOAD` | Select redacted model payload diagnostics. | +| `OPENCLAW_DEBUG_SSE` | Select SSE timing or event-peek diagnostics. | +| `OPENCLAW_DEBUG_CODE_MODE` | Enable code-mode surface diagnostics. | +| `OPENCLAW_DIAGNOSTICS` | Enable named diagnostic flags, or disable all flags with `0`. | +| `OPENCLAW_DIAGNOSTICS_TIMELINE_PATH` | Select the JSONL path for timeline diagnostics. | +| `OPENCLAW_DIAGNOSTICS_EVENT_LOOP` | Add event-loop samples to timeline diagnostics. | + +### Feature and runtime toggles + +| Variable | Purpose | +| ------------------------------------ | ---------------------------------------------------------------------------- | +| `OPENCLAW_LOAD_SHELL_ENV` | Import missing expected variables from the login shell. | +| `OPENCLAW_SHELL_ENV_TIMEOUT_MS` | Set the login-shell import timeout. | +| `OPENCLAW_EXEC_SHELL_SNAPSHOT` | Disable exec shell snapshots with `0`. | +| `OPENCLAW_OFFLINE` | Prevent downloads of pinned agent helper binaries. | +| `OPENCLAW_BROWSER_HEADLESS` | Force managed browser launches headed (`0`) or headless (`1`). | +| `OPENCLAW_DISABLE_BONJOUR` | Force Bonjour advertising on (`0`) or off (`1`). | +| `OPENCLAW_NO_AUTO_UPDATE` | Disable automatic update applies. | +| `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS` | Allow trusted private-DNS `ws://` connections as a break-glass override. | +| `OPENCLAW_ALLOW_MULTI_GATEWAY` | Allow multiple Gateway processes while preserving per-state ownership locks. | +| `OPENCLAW_SKIP_CHANNELS` | Start the Gateway without channel transports for troubleshooting. | +| `OPENCLAW_THEME` | Force the TUI palette to `light` or `dark`. | + ## Provider credentials and workspace `.env` Do not keep provider API keys only in a workspace `.env`. OpenClaw blocks a large set of provider credential and endpoint-redirect keys from workspace `.env` files, including every known provider auth env var (for example `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `XAI_API_KEY`, `MISTRAL_API_KEY`, `GROQ_API_KEY`, `DEEPSEEK_API_KEY`, `PERPLEXITY_API_KEY`, `BRAVE_API_KEY`, `TAVILY_API_KEY`, `EXA_API_KEY`, `FIRECRAWL_API_KEY`), plus any key ending in `_API_HOST`, `_BASE_URL`, `_ENDPOINT`, or `_HOMESERVER`, and the entire `OPENCLAW_*`, `CLAWHUB_*`, `ANTHROPIC_API_KEY_*`, and `OPENAI_API_KEY_*` namespaces. diff --git a/docs/help/faq.md b/docs/help/faq.md index 53fbc0b57676..d8574e061d9d 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -128,11 +128,11 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - - Use managed overrides instead of editing the repo copy. Put changes in `~/.openclaw/skills//SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.openclaw/openclaw.json`). Precedence: `/skills` -> `/.agents/skills` -> `~/.agents/skills` -> `~/.openclaw/skills` -> bundled -> `skills.load.extraDirs`, so managed overrides win over bundled skills without touching git. To install globally but limit visibility to some agents, keep the shared copy in `~/.openclaw/skills` and control visibility with `agents.defaults.skills` / `agents.list[].skills`. Only upstream-worthy edits should go out as PRs against the repo copy. + Use managed overrides instead of editing the repo copy. Put changes in `~/.openclaw/skills//SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.openclaw/openclaw.json`). Precedence: `/skills` -> `/.agents/skills` -> `~/.agents/skills` -> `~/.openclaw/skills` -> bundled -> `skills.load.extraDirs`, so managed overrides win over bundled skills without touching git. To install globally but limit visibility to some agents, keep the shared copy in `~/.openclaw/skills` and control visibility with `agents.defaults.skills` / `agents.entries.*.skills`. Only upstream-worthy edits should go out as PRs against the repo copy. - Yes: add directories via `skills.load.extraDirs` in `~/.openclaw/openclaw.json` (lowest precedence in the order above). `clawhub` installs into `./skills` by default, which OpenClaw treats as `/skills` on the next session. To limit visibility to certain agents, pair with `agents.defaults.skills` or `agents.list[].skills`. + Yes: add directories via `skills.load.extraDirs` in `~/.openclaw/openclaw.json` (lowest precedence in the order above). `clawhub` installs into `./skills` by default, which OpenClaw treats as `/skills` on the next session. To limit visibility to certain agents, pair with `agents.defaults.skills` or `agents.entries.*.skills`. @@ -165,7 +165,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - } ``` - Put shared per-model defaults in `agents.defaults.models["provider/model"].params`, then agent-specific overrides in flat `agents.list[].params`. Do not duplicate the same model under nested `agents.list[].models["provider/model"].params`; that path is for per-agent model catalog and runtime overrides. + Put shared per-model defaults in `agents.defaults.models["provider/model"].params`, then agent-specific overrides in flat `agents.entries.*.params`. Do not duplicate the same model under nested `agents.entries.*.models["provider/model"].params`; that path is for per-agent model catalog and runtime overrides. See [Cron jobs](/automation/cron-jobs), [Multi-Agent Routing](/concepts/multi-agent), [Configuration](/gateway/config-agents), [Slash commands](/tools/slash-commands). @@ -189,7 +189,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - - `/session idle ` and `/session max-age ` control auto-unfocus. - `/unfocus` detaches the thread. - Config: `session.threadBindings.enabled` (global switch), `session.threadBindings.idleHours` (default `24`, `0` disables), `session.threadBindings.maxAgeHours` (default `0` = no hard cap), and per-channel overrides `channels.discord.threadBindings.{enabled,idleHours,maxAgeHours}`. `channels.discord.threadBindings.spawnSessions` gates auto-bind on spawn (default `true`). + Config: `session.threadBindings.enabled` (global switch), `session.threadBindings.idleHours` (default `24`, `0` disables), `session.threadBindings.maxAgeHours` (default `0` = no hard cap), and `session.threadBindings.spawnSessions` for auto-bind on spawn (default `true`). Docs: [Sub-agents](/tools/subagents), [Discord](/channels/discord), [Configuration Reference](/gateway/configuration-reference), [Slash commands](/tools/slash-commands). @@ -279,7 +279,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - openclaw skills check ``` - Native `openclaw skills install` writes into the active workspace `skills/` directory by default. Add `--global` to install into the shared managed skills directory for all local agents. Install the separate `clawhub` CLI only to publish or sync your own skills. Use `agents.defaults.skills` or `agents.list[].skills` to narrow which agents see shared skills. + Native `openclaw skills install` writes into the active workspace `skills/` directory by default. Add `--global` to install into the shared managed skills directory for all local agents. Install the separate `clawhub` CLI only to publish or sync your own skills. Use `agents.defaults.skills` or `agents.entries.*.skills` to narrow which agents see shared skills. @@ -339,7 +339,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - openclaw skills update --all ``` - Native installs land in the active workspace `skills/` directory; use `--global` for all local agents, or configure `agents.defaults.skills` / `agents.list[].skills` to limit visibility. Some skills expect Homebrew-installed binaries; on Linux that means Linuxbrew. + Native installs land in the active workspace `skills/` directory; use `--global` for all local agents, or configure `agents.defaults.skills` / `agents.entries.*.skills` to limit visibility. Some skills expect Homebrew-installed binaries; on Linux that means Linuxbrew. See [Skills](/tools/skills), [Skills config](/tools/skills-config), [ClawHub](/tools/clawhub). @@ -432,7 +432,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - Only if you use **OpenAI embeddings**, which is the default provider. Codex OAuth covers chat/completions and does **not** grant embeddings access, so signing in with Codex (OAuth or the Codex CLI login) does not enable semantic memory search. OpenAI embeddings still need a real API key (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). - To stay local, set `agents.defaults.memorySearch.provider: "local"` (GGUF/llama.cpp). Other supported providers: Bedrock, DeepInfra, Gemini (`GEMINI_API_KEY` or `memorySearch.remote.apiKey`), GitHub Copilot, LM Studio, Mistral, Ollama, OpenAI-compatible, and Voyage. See [Memory](/concepts/memory) and [Memory search](/concepts/memory-search) for setup details. + To stay local, set `memory.search.provider: "local"` (GGUF/llama.cpp). Other supported providers: Bedrock, DeepInfra, Gemini (`GEMINI_API_KEY` or `memory.search.remote.apiKey`), GitHub Copilot, LM Studio, Mistral, Ollama, OpenAI-compatible, and Voyage. See [Memory](/concepts/memory) and [Memory search](/concepts/memory-search) for setup details. @@ -510,7 +510,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - } ``` - Or override one agent under `agents.list[].bootstrapMaxChars` / `bootstrapTotalMaxChars`. + Or override one agent under `agents.entries.*.bootstrapMaxChars` / `bootstrapTotalMaxChars`. Use `/context` to check raw vs injected sizes and whether truncation happened. Keep `SOUL.md` focused on voice, stance, and personality; put operating rules in `AGENTS.md` and durable facts in memory. @@ -592,26 +592,6 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - The Gateway watches the config and supports hot-reload: `gateway.reload.mode: "hybrid"` (default) hot-applies safe changes and restarts for critical ones. `hot`, `restart`, and `off` are also supported. Most `tools.*`, `agents.*` policy, `session.*`, and `messages.*` changes apply immediately with no reload action at all; `gateway.*` binding/port changes require a restart. - - Set `cli.banner.taglineMode`: - - ```json5 - { - cli: { - banner: { - taglineMode: "off", // random | default | off - }, - }, - } - ``` - - - `off`: hides tagline text but keeps the banner title/version line. - - `default`: always uses `All your chats, one OpenClaw.`. - - `random`: rotating funny/seasonal taglines (default behavior). - - For no banner at all, set env `OPENCLAW_HIDE_BANNER=1`. - - - `web_fetch` works without an API key. `web_search` depends on your selected provider: @@ -977,7 +957,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - } ``` - `resetByType` supports `direct` (legacy alias `dm`), `group`, and `thread`. Legacy top-level `session.idleMinutes` still works as a compatibility alias for an idle-mode default when no `session.reset`/`resetByType` block is set. See [Session management](/concepts/session) for the full lifecycle. + `resetByType` supports `direct`, `group`, and `thread`. Doctor migrates legacy `dm` entries to `direct`; the schema rejects `dm`. Legacy top-level `session.idleMinutes` still works as a compatibility alias for an idle-mode default when no `session.reset`/`resetByType` block is set. See [Session management](/concepts/session) for the full lifecycle. @@ -1056,7 +1036,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures - If `HEARTBEAT.md` exists but is effectively empty (only blank lines, Markdown/HTML comments, ATX headings, fence markers, or empty list-item stubs), OpenClaw skips the heartbeat run to save API calls. If the file is missing, the heartbeat still runs and the model decides what to do. - Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat). + Per-agent overrides use `agents.entries.*.heartbeat`. Docs: [Heartbeat](/gateway/heartbeat). diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 01bb1e7f5ace..8eb36e6b9f89 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -56,7 +56,7 @@ Common causes: shell, and runtime work). - `tools.profile: "full"` removes profile restrictions; limit to trusted operator-controlled agents. -- Per-agent `agents.list[].tools` overrides narrow or expand the root profile +- Per-agent `agents.entries.*.tools` overrides narrow or expand the root profile for one agent. Change the profile, restart or reload the Gateway, then recheck with diff --git a/docs/logging.md b/docs/logging.md index b142db9142ad..b68a82004030 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -283,7 +283,7 @@ OTLP log records, persisted session transcript text, or Control UI tool event payloads (tool start args, partial/final result payloads, derived exec output, and patch summaries): -- `logging.redactSensitive`: `off` | `tools` (default: `tools`) +- Sensitive-value redaction is always enabled. - `logging.redactPatterns`: list of regex strings that replaces the default set for log/transcript output. For Control UI tool payloads, custom patterns apply on top of the built-in defaults, so adding a pattern never weakens redaction of values already caught by the defaults. File logs and session transcripts stay JSONL, but matching secret values are @@ -295,13 +295,9 @@ The built-in defaults cover common API credentials and payment-credential field names such as card number, CVC/CVV, shared payment token, and payment credential when they appear as JSON fields, URL parameters, CLI flags, or assignments. -`logging.redactSensitive: "off"` only disables this general log/transcript -policy. OpenClaw still redacts safety-boundary payloads that can be shown to UI -clients, support bundles, diagnostics observers, approval prompts, or agent -tools. Examples include Control UI tool-call events, `sessions_history` output, -diagnostics support exports, provider error observations, exec approval command -display, and Gateway WebSocket protocol logs. Custom `logging.redactPatterns` -can still add project-specific patterns on those surfaces. +OpenClaw also redacts safety-boundary payloads shown to UI clients, support +bundles, diagnostics observers, approval prompts, or agent tools. Custom +`logging.redactPatterns` can add project-specific patterns on those surfaces. ## Diagnostics and OpenTelemetry diff --git a/docs/nodes/audio.md b/docs/nodes/audio.md index 6d1c0ffcf9ab..52a59217660d 100644 --- a/docs/nodes/audio.md +++ b/docs/nodes/audio.md @@ -35,7 +35,7 @@ Auto-detected whisper.cpp keeps its normal model-run logs enabled so OpenClaw ca Gemini CLI auto-detect for media understanding was replaced by a sandboxed Antigravity CLI (`agy`) fallback for image/video; audio does not use a CLI fallback beyond the local binaries above. -To disable auto-detection, set `tools.media.audio.enabled: false`. To customize, set `tools.media.audio.models`. +To disable auto-detection, set `tools.media.audio.enabled: false`. To customize, add capability-tagged entries to `tools.media.models`. Binary detection is best-effort across macOS/Linux/Windows. Make sure the CLI is on `PATH` (`~` is expanded), or set an explicit CLI model with a full command path. @@ -48,7 +48,7 @@ openclaw capability audio providers openclaw doctor --lint --only core/doctor/local-audio-acceleration --severity-min info ``` -The provider inventory reports the local fallback winner separately from global provider selection, plus capable, requested, and observed backend fields. After transcription runs, `/status` reports the requested or observed backend in the media line. Explicit `tools.media.audio.models` CLI entries still bypass auto-selection; use their backend-specific flags such as sherpa `--provider=cuda` or whisper.cpp `--no-gpu`/`--device`. +The provider inventory reports the local fallback winner separately from global provider selection, plus capable, requested, and observed backend fields. After transcription runs, `/status` reports the requested or observed backend in the media line. Explicit audio-capable `tools.media.models` CLI entries still bypass auto-selection; use their backend-specific flags such as sherpa `--provider=cuda` or whisper.cpp `--no-gpu`/`--device`. ## Config examples @@ -58,38 +58,17 @@ The provider inventory reports the local fallback winner separately from global { tools: { media: { - audio: { - enabled: true, - maxBytes: 20971520, - models: [ - { provider: "openai", model: "gpt-4o-transcribe" }, - { - type: "cli", - command: "whisper", - args: ["--model", "base", "{{MediaPath}}"], - timeoutSeconds: 45, - }, - ], - }, - }, - }, -} -``` - -### Provider-only with scope gating - -```json5 -{ - tools: { - media: { - audio: { - enabled: true, - scope: { - default: "allow", - rules: [{ action: "deny", match: { chatType: "group" } }], + models: [ + { provider: "openai", model: "gpt-4o-transcribe", capabilities: ["audio"] }, + { + type: "cli", + command: "whisper", + args: ["--model", "base", "{{MediaPath}}"], + timeoutSeconds: 45, + capabilities: ["audio"], }, - models: [{ provider: "openai", model: "gpt-4o-transcribe" }], - }, + ], + audio: { enabled: true, preferredModel: "openai/gpt-4o-transcribe" }, }, }, } @@ -101,10 +80,8 @@ The provider inventory reports the local fallback winner separately from global { tools: { media: { - audio: { - enabled: true, - models: [{ provider: "deepgram", model: "nova-3" }], - }, + models: [{ provider: "deepgram", model: "nova-3", capabilities: ["audio"] }], + audio: { enabled: true }, }, }, } @@ -116,10 +93,8 @@ The provider inventory reports the local fallback winner separately from global { tools: { media: { - audio: { - enabled: true, - models: [{ provider: "mistral", model: "voxtral-mini-latest" }], - }, + models: [{ provider: "mistral", model: "voxtral-mini-latest", capabilities: ["audio"] }], + audio: { enabled: true }, }, }, } @@ -131,10 +106,14 @@ The provider inventory reports the local fallback winner separately from global { tools: { media: { - audio: { - enabled: true, - models: [{ provider: "senseaudio", model: "senseaudio-asr-pro-1.5-260319" }], - }, + models: [ + { + provider: "senseaudio", + model: "senseaudio-asr-pro-1.5-260319", + capabilities: ["audio"], + }, + ], + audio: { enabled: true }, }, }, } @@ -148,9 +127,8 @@ The provider inventory reports the local fallback winner separately from global media: { audio: { enabled: true, - echoTranscript: true, // default is false - echoFormat: '📝 "{transcript}"', // optional, supports {transcript} - models: [{ provider: "openai", model: "gpt-4o-transcribe" }], + echoTranscript: true, + echoFormat: '📝 "{transcript}"', }, }, }, @@ -164,17 +142,15 @@ The provider inventory reports the local fallback winner separately from global - Deepgram picks up `DEEPGRAM_API_KEY` when `provider: "deepgram"` is used. Setup details: [Deepgram](/providers/deepgram). - Mistral setup details: [Mistral](/providers/mistral). - SenseAudio picks up `SENSEAUDIO_API_KEY` when `provider: "senseaudio"` is used. Setup details: [SenseAudio](/providers/senseaudio). -- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`. -- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried. +- Audio providers can use defaults under `tools.media.audio` or override `baseUrl`, `headers`, `providerOptions`, and limits on their `tools.media.models[]` entry. +- The built-in audio size cap is 20MB. An entry-level `maxBytes` override can change it; oversize audio is skipped for that model and the next entry is tried. - Audio files below 1024 bytes are skipped before provider/CLI transcription. -- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or a per-entry `maxChars` to trim output. +- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output. - OpenAI auto-detect default is `gpt-4o-transcribe`; set `model: "gpt-4o-mini-transcribe"` for a cheaper/faster option. -- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` plus `maxAttachments`, default 1). - Transcript is available to templates as `{{Transcript}}`. -- `tools.media.audio.echoTranscript` is off by default; enable it to send a transcript confirmation back to the originating chat before agent processing. -- `tools.media.audio.echoFormat` customizes the echo text (placeholder: `{transcript}`; default `📝 "{transcript}"`). +- `tools.media.audio.echoTranscript` is off by default; `echoFormat` accepts a `{transcript}` placeholder. - CLI stdout is capped at 5MB; keep CLI output concise. -- CLI `args` should use `{{MediaPath}}` for the local audio file path. Run `openclaw doctor --fix` to migrate deprecated `{input}` placeholders from older `audio.transcription.command` configs (retired key: `audio.transcription`, replaced by `tools.media.audio.models`). +- CLI `args` should use `{{MediaPath}}` for the local audio file path. Run `openclaw doctor --fix` to migrate deprecated `{input}` placeholders from older `audio.transcription.command` configs (retired key: `audio.transcription`, replaced by `tools.media.models`). - `tools.media.concurrency` bounds media tasks; it is not a GPU scheduler. ### Resident local STT diff --git a/docs/nodes/camera.md b/docs/nodes/camera.md index e7b3a6ee1881..726f83c84c59 100644 --- a/docs/nodes/camera.md +++ b/docs/nodes/camera.md @@ -154,7 +154,7 @@ Linux returns capture-capable, readable V4L2 device paths from `camera.list`; FF The plugin uses `libx264` for MP4 video and does not silently change codecs. An FFmpeg build without the required input or encoders returns `CAMERA_UNAVAILABLE`. Photos and clips that would exceed the 25MB base64 payload budget fail with `PAYLOAD_TOO_LARGE`. -`camera.snap` and `camera.clip` remain dangerous commands. Add them to `gateway.nodes.allowCommands` only when you intend to arm capture; enabling the plugin alone does not bypass Gateway policy. +`camera.snap` and `camera.clip` remain dangerous commands. Add them to `gateway.nodes.commands.allow` only when you intend to arm capture; enabling the plugin alone does not bypass Gateway policy. ## Safety + practical limits diff --git a/docs/nodes/computer-use.md b/docs/nodes/computer-use.md index d5492d381e8c..bcc1b870a9f4 100644 --- a/docs/nodes/computer-use.md +++ b/docs/nodes/computer-use.md @@ -89,7 +89,7 @@ Reads reuse `screen.snapshot`; there is no second capture path. See [Camera and Arming requires `operator.admin` (or the owner) and auto-expires. The legacy `/phone arm all` group intentionally excludes desktop control; use the explicit `computer` group. Arming only toggles what the gateway may invoke; the node app still enforces its platform-specific settings and OS permissions, including **Allow Computer Control**, Accessibility, and Screen Recording on macOS. -For persistent authorization, add `computer.act` to `gateway.nodes.allowCommands` **and remove it from** `gateway.nodes.denyCommands`; the deny list wins. Persistent authorization does not auto-expire. Entries already present before `/phone arm` remain after `/phone disarm`; do not convert a temporary grant to persistent while it is armed. +For persistent authorization, add `computer.act` to `gateway.nodes.commands.allow` **and remove it from** `gateway.nodes.commands.deny`; the deny list wins. Persistent authorization does not auto-expire. Entries already present before `/phone arm` remain after `/phone disarm`; do not convert a temporary grant to persistent while it is armed. Authorization is deliberately split between enabling and use. Arming or persistently configuring `computer.act` requires administrative authority. diff --git a/docs/nodes/images.md b/docs/nodes/images.md index f6762502d485..4a2e3d0cbf23 100644 --- a/docs/nodes/images.md +++ b/docs/nodes/images.md @@ -57,7 +57,7 @@ The 16MB audio/video and 100MB document figures above are the shared per-kind me - Audio sets `{{Transcript}}` and uses the transcript for command parsing so slash commands still work. - Video and image descriptions preserve any caption text for command parsing. - If the active primary model already supports vision natively, OpenClaw skips the `[Image]` summary block and passes the original image to the model instead. -- By default only the first matching image/audio/video attachment is processed; set `tools.media..attachments` to process multiple attachments. +- By default only the first matching image/audio/video attachment is processed; use `tools.media..attachments` to select multiple attachments. ## Limits and errors @@ -70,9 +70,10 @@ The 16MB audio/video and 100MB document figures above are the shared per-kind me **Media understanding caps (transcription/description)** -- Image default: 10MB (`tools.media.image.maxBytes`). -- Audio default: 20MB (`tools.media.audio.maxBytes`). -- Video default: 50MB (`tools.media.video.maxBytes`). +- Image default: 10MB (override with `tools.media.image.maxBytes`, or per + `tools.media.models[]` entry with `maxBytes`). +- Audio default: 20MB (override with `tools.media.audio.maxBytes`, or per entry). +- Video default: 50MB (override with `tools.media.video.maxBytes`, or per entry). - Oversize media skips understanding, but the reply still goes through with the original body. ## Notes for Tests diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 64cbe66f26e9..e6b5263b9212 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -194,7 +194,7 @@ the node host does not watch this config. Gateway operators can ignore all agent-visible tools published by paired nodes, including node-hosted MCP tools, with `gateway.nodes.pluginTools.enabled: false`. Exact command denies such as -`gateway.nodes.denyCommands: ["mcp.tools.call.v1"]` also block execution. +`gateway.nodes.commands.deny: ["mcp.tools.call.v1"]` also block execution. ### Node-hosted skills @@ -228,7 +228,7 @@ out of that agent's snapshot. Set `nodeHost.skills.enabled: false` on the node to stop publication. Gateway operators can ignore skills from every paired node with -`gateway.nodes.skills.enabled: false`. +`gateway.nodes.allowSkills: false`. ### Headless identity state @@ -266,7 +266,7 @@ Configure defaults (gateway config): ```bash openclaw config set tools.exec.host node -openclaw config set tools.exec.security allowlist +openclaw config set tools.exec.mode allowlist openclaw config set tools.exec.node "" ``` @@ -450,7 +450,7 @@ Node commands must pass two gates before they can be invoked: 1. The node must declare the command in its authenticated connect metadata (`connect.commands`). 2. The gateway's platform-and-approval-derived allowlist must include the declared command. -Default allowlists by platform (before plugin defaults and `allowCommands`/`denyCommands` overrides): +Default allowlists by platform (before plugin defaults and `commands.allow`/`commands.deny` overrides): | Platform | Commands allowed by default | | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -469,9 +469,9 @@ These rows describe the Gateway policy ceiling, not the commands implemented by Desktop host commands (`system.run`, `system.run.prepare`, `system.which`, `browser.proxy`, `mcp.tools.call.v1`, and `screen.snapshot` on macOS/Windows/Linux) are not part of the static platform-default table above. They become available once the operator approves a pairing request that declares them, after which the node's approved command set carries them forward on reconnect. -Dangerous or privacy-heavy commands still require explicit opt-in with `gateway.nodes.allowCommands`, even if a node declares them: `camera.snap`, `camera.clip`, `screen.record`, `computer.act`, `contacts.add`, `calendar.add`, `reminders.add`, `health.summary`, `sms.send`, `sms.search`. `gateway.nodes.denyCommands` always wins over defaults and extra allowlist entries. See [HealthKit summaries](/platforms/ios-healthkit) for the iPhone consent gate and [Computer use](/nodes/computer-use) for the additional capability, tool-policy, arming, and platform-fulfiller gates around desktop input. +Dangerous or privacy-heavy commands still require explicit opt-in with `gateway.nodes.commands.allow`, even if a node declares them: `camera.snap`, `camera.clip`, `screen.record`, `computer.act`, `contacts.add`, `calendar.add`, `reminders.add`, `health.summary`, `sms.send`, `sms.search`. `gateway.nodes.commands.deny` always wins over defaults and extra allowlist entries. See [HealthKit summaries](/platforms/ios-healthkit) for the iPhone consent gate and [Computer use](/nodes/computer-use) for the additional capability, tool-policy, arming, and platform-fulfiller gates around desktop input. -Plugin-owned node commands can add a Gateway node-invoke policy. That policy runs after the allowlist check and before forwarding to the node, so raw `node.invoke`, CLI helpers, and dedicated agent tools share the same plugin permission boundary. Dangerous plugin node commands still require explicit `gateway.nodes.allowCommands` opt-in. +Plugin-owned node commands can add a Gateway node-invoke policy. That policy runs after the allowlist check and before forwarding to the node, so raw `node.invoke`, CLI helpers, and dedicated agent tools share the same plugin permission boundary. Dangerous plugin node commands still require explicit `gateway.nodes.commands.allow` opt-in. After a node changes its declared command list, reject the old device pairing and approve the new request so the gateway stores the updated command snapshot. @@ -497,9 +497,11 @@ Node-related settings live under `gateway.nodes` and `tools.exec`: enabled: true, }, // Opt into dangerous/privacy-heavy node commands (camera.snap, etc.). - allowCommands: ["camera.snap", "screen.record"], - // Block exact command names even if defaults or allowCommands include them. - denyCommands: ["camera.clip"], + commands: { + allow: ["camera.snap", "screen.record"], + // Block exact command names even if defaults or commands.allow include them. + deny: ["camera.clip"], + }, }, }, tools: { @@ -515,7 +517,7 @@ Node-related settings live under `gateway.nodes` and `tools.exec`: } ``` -Use exact node command names. `denyCommands` removes a command even when a platform default or `allowCommands` entry would otherwise allow it. Paired nodes may publish agent-visible plugin tool descriptors by default, but each descriptor's command must still be in the node's approved command surface. Set `gateway.nodes.pluginTools.enabled: false` to ignore all such descriptors. See [Gateway configuration reference](/gateway/configuration-reference#gateway) for gateway node pairing and command-policy field details. +Use exact node command names. `commands.deny` removes a command even when a platform default or `commands.allow` entry would otherwise allow it. Paired nodes may publish agent-visible plugin tool descriptors by default, but each descriptor's command must still be in the node's approved command surface. Set `gateway.nodes.pluginTools.enabled: false` to ignore all such descriptors. See [Gateway configuration reference](/gateway/configuration-reference#gateway) for gateway node pairing and command-policy field details. Per-agent exec node override: @@ -633,7 +635,7 @@ Notes: ## SMS (Android nodes) -Android nodes can expose `sms.send` and `sms.search` when the user grants **SMS** permission and the device supports telephony. Both commands are dangerous-by-default: the gateway operator must also add them to `gateway.nodes.allowCommands` before they can be invoked (see [Command policy](#command-policy)). +Android nodes can expose `sms.send` and `sms.search` when the user grants **SMS** permission and the device supports telephony. Both commands are dangerous-by-default: the gateway operator must also add them to `gateway.nodes.commands.allow` before they can be invoked (see [Command policy](#command-policy)). For read-only SMS search, opt in explicitly in `openclaw.json`: @@ -641,7 +643,7 @@ For read-only SMS search, opt in explicitly in `openclaw.json`: { gateway: { nodes: { - allowCommands: ["sms.search"], + commands: { allow: ["sms.search"] }, }, }, } @@ -659,7 +661,7 @@ Notes: - `sms.search` may be declared before `READ_SMS` is granted so an invocation can return a permission diagnostic; reading messages still requires that Android permission. - Wi-Fi-only devices without telephony will not advertise `sms.send`. -- A `requires explicit gateway.nodes.allowCommands opt-in` error means the phone declared the command but the Gateway operator has not authorized it. +- A `requires explicit gateway.nodes.commands.allow opt-in` error means the phone declared the command but the Gateway operator has not authorized it. ## Device and personal data commands @@ -672,9 +674,9 @@ Available families: - `device.apps` — Android, macOS, and headless-mac nodes. Android requires Installed Apps sharing in Settings and returns launcher-visible apps by default. TypeScript node hosts keep sharing off by default and accept `query`, `limit`, and `includeSystem`; macOS results contain `label`, `bundleId`, `path`, and `system`. - `notifications.list`, `notifications.actions` — Android only. - `photos.latest` — iOS, Android. -- `contacts.search` — iOS, Android (read-only default); `contacts.add` is dangerous and needs `gateway.nodes.allowCommands`. -- `calendar.events` — iOS, Android (read-only default); `calendar.add` is dangerous and needs `gateway.nodes.allowCommands`. -- `reminders.list` — iOS, Android (read-only default); `reminders.add` is dangerous and needs `gateway.nodes.allowCommands`. +- `contacts.search` — iOS, Android (read-only default); `contacts.add` is dangerous and needs `gateway.nodes.commands.allow`. +- `calendar.events` — iOS, Android (read-only default); `calendar.add` is dangerous and needs `gateway.nodes.commands.allow`. +- `reminders.list` — iOS, Android (read-only default); `reminders.add` is dangerous and needs `gateway.nodes.commands.allow`. - `callLog.search` — Android only. - `motion.activity`, `motion.pedometer` — iOS, Android; capability-gated by available sensors. @@ -705,7 +707,7 @@ Notes: - `nodes invoke` does not expose `system.run` or `system.run.prepare`; those stay on the exec path only. - The exec path prepares a canonical `systemRunPlan` before approval. Once an approval is granted, the gateway forwards that stored plan, not any later caller-edited command/cwd/session fields. - `system.notify` respects notification permission state on the macOS app; supports `--priority ` and `--delivery `. -- Unrecognized node `platform` / `deviceFamily` metadata uses a conservative default allowlist that excludes `system.run` and `system.which`. If you intentionally need those commands for an unknown platform, add them explicitly via `gateway.nodes.allowCommands`. +- Unrecognized node `platform` / `deviceFamily` metadata uses a conservative default allowlist that excludes `system.run` and `system.which`. If you intentionally need those commands for an unknown platform, add them explicitly via `gateway.nodes.commands.allow`. - `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`. - For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped `--env` values are reduced to an explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`). - For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `flock`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically. @@ -727,15 +729,15 @@ openclaw config set tools.exec.node "node-id-or-name" Per-agent override: ```bash -openclaw config get agents.list -openclaw config set 'agents.list[0].tools.exec.node' "node-id-or-name" +openclaw config get agents.entries +openclaw config set 'agents.entries.main.tools.exec.node' "node-id-or-name" ``` Unset to allow any node: ```bash openclaw config unset tools.exec.node -openclaw config unset 'agents.list[0].tools.exec.node' +openclaw config unset 'agents.entries.main.tools.exec.node' ``` ## Permissions map diff --git a/docs/nodes/media-understanding.md b/docs/nodes/media-understanding.md index 482cd43e449f..e8a180cf46b7 100644 --- a/docs/nodes/media-understanding.md +++ b/docs/nodes/media-understanding.md @@ -33,21 +33,20 @@ Vendor plugins register capability metadata (which provider supports which media ## Config -`tools.media` holds a shared model list plus per-capability overrides: +`tools.media` holds one capability-tagged model list plus small per-capability controls: ```json5 { tools: { media: { concurrency: 2, // max concurrent capability runs (default) - models: [/* shared list, gate with capabilities */], - image: {/* optional overrides */}, - audio: { - /* optional overrides */ - echoTranscript: true, - echoFormat: '📝 "{transcript}"', - }, - video: {/* optional overrides */}, + models: [ + { provider: "openai", model: "gpt-4o-mini-transcribe", capabilities: ["audio"] }, + { provider: "google", model: "gemini-3-flash-preview", capabilities: ["image", "video"] }, + ], + image: { preferredModel: "google/gemini-3-flash-preview" }, + audio: { enabled: true }, + video: { enabled: true }, }, }, } @@ -55,22 +54,21 @@ Vendor plugins register capability metadata (which provider supports which media Per-capability (`image`/`audio`/`video`) keys: -| Key | Type | Default | Notes | -| ----------------------------------------------- | --------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `enabled` | `boolean` | auto (`false` disables) | Set `false` to turn off auto-detect for this capability | -| `models` | array | none | Preferred before the shared `tools.media.models` list | -| `prompt` | `string` | `"Describe the {media}."` (+ maxChars guidance) | Image/video only by default | -| `maxChars` | `number` | `500` (image/video), unset (audio) | Output is trimmed if the model returns more | -| `maxBytes` | `number` | image `10485760`, audio `20971520`, video `52428800` | Oversized media skips to the next model | -| `timeoutSeconds` | `number` | `60` (image/audio), `120` (video) | | -| `language` | `string` | unset | Audio transcription hint | -| `baseUrl`/`headers`/`providerOptions`/`request` | - | - | Provider request overrides; see [Tools and custom providers](/gateway/config-tools) | -| `attachments` | object | `{ mode: "first", maxAttachments: 1 }` | See [Attachment policy](#attachment-policy) | -| `scope` | object | unset | Gate by channel/chatType/keyPrefix | -| `echoTranscript` | `boolean` | `false` | Audio only: echo the transcript back to the chat before agent processing | -| `echoFormat` | `string` | `'📝 "{transcript}"'` | Audio only: `{transcript}` placeholder | +| Key | Type | Default | Notes | +| ---------------- | --------- | -------------------------------------- | -------------------------------------------------------------------- | +| `enabled` | `boolean` | auto (`false` disables) | Set `false` to turn off auto-detect for this capability | +| `preferredModel` | `string` | first compatible entry | Prefer `provider/model`, model id, `provider:`, or `cli:command` | +| `prompt` | `string` | capability default | Default prompt when an entry does not override it | +| `maxChars` | `number` | `500` image/video, unset audio | Default output limit | +| `maxBytes` | `number` | 10MB image, 20MB audio, 50MB video | Default input limit | +| `timeoutSeconds` | `number` | `60` image/audio, `120` video | Default request timeout | +| `language` | `string` | unset | Audio transcription hint | +| `scope` | object | unset | Gate by channel/chat type/source key | +| `attachments` | object | `{ mode: "first", maxAttachments: 1 }` | Select which matching attachments are processed | +| `echoTranscript` | `boolean` | `false` | Audio only: echo the transcript before agent processing | +| `echoFormat` | `string` | `'📝 "{transcript}"'` | Audio only: format for the echoed transcript | -Deepgram-specific options go under `providerOptions.deepgram` (the top-level `deepgram: { detectLanguage, punctuate, smartFormat }` field is deprecated but still read). +Prompts, limits, language hints, request overrides, and provider options can be set as capability defaults or overridden on individual `tools.media.models[]` entries. Capability defaults also cover auto-detected providers when no explicit model is configured. ### Model entries @@ -87,7 +85,7 @@ Each `models[]` entry is a **provider** entry (default) or a **CLI** entry: maxChars: 500, maxBytes: 10485760, timeoutSeconds: 60, - capabilities: ["image"], // optional, for multi-modal shared entries + capabilities: ["image"], profile: "vision-profile", preferredProfile: "vision-fallback", } @@ -119,7 +117,7 @@ Each `models[]` entry is a **provider** entry (default) or a **CLI** entry: ### Provider credentials -Provider media understanding uses the same auth resolution as normal model calls: auth profiles, environment variables, then `models.providers..apiKey`. `tools.media.*.models[]` entries do not accept an inline `apiKey` field. +Provider media understanding uses the same auth resolution as normal model calls: auth profiles, environment variables, then `models.providers..apiKey`. `tools.media.models[]` entries do not accept an inline `apiKey` field. ```json5 { diff --git a/docs/nodes/troubleshooting.md b/docs/nodes/troubleshooting.md index 2eb7390994b0..cc8474749f27 100644 --- a/docs/nodes/troubleshooting.md +++ b/docs/nodes/troubleshooting.md @@ -61,7 +61,7 @@ If you see `NODE_BACKGROUND_UNAVAILABLE`, bring the node app to the foreground a Three separate gates control whether a node command succeeds: 1. **Device pairing**: can this node connect to the gateway? -2. **Gateway node command policy**: is the RPC command ID allowed by `gateway.nodes.allowCommands` / `denyCommands` and platform defaults? +2. **Gateway node command policy**: is the RPC command ID allowed by `gateway.nodes.commands.allow` / `gateway.nodes.commands.deny` and platform defaults? 3. **Exec approvals**: can this node run a specific shell command locally? Node pairing is an identity/trust gate, not a per-command approval surface. For `system.run`, the per-node policy lives in that node's exec approvals file (`openclaw approvals get --node ...`), not in the gateway pairing record. @@ -112,7 +112,7 @@ If still stuck: - Re-grant OS permissions. - Recreate/adjust the exec approval policy. -For computer control, also verify that a vision-capable agent exposes the `computer` tool, `screen.snapshot` succeeds with Screen Recording permission, and `/phone status` shows the temporary or persistent gateway authorization you intended. A `gateway.nodes.denyCommands` entry always overrides `allowCommands`. +For computer control, also verify that a vision-capable agent exposes the `computer` tool, `screen.snapshot` succeeds with Screen Recording permission, and `/phone status` shows the temporary or persistent gateway authorization you intended. A `gateway.nodes.commands.deny` entry always overrides `gateway.nodes.commands.allow`. ## Related diff --git a/docs/platforms/ios-healthkit.md b/docs/platforms/ios-healthkit.md index ed24ca71f799..4cf997f61375 100644 --- a/docs/platforms/ios-healthkit.md +++ b/docs/platforms/ios-healthkit.md @@ -32,21 +32,21 @@ authorization on the Gateway. ### 1. Authorize the Gateway command -Add `health.summary` to the existing `gateway.nodes.allowCommands` array in +Add `health.summary` to the existing `gateway.nodes.commands.allow` array in `openclaw.json`. Preserve any commands already present: ```json5 { gateway: { nodes: { - allowCommands: ["health.summary"], + commands: { allow: ["health.summary"] }, }, }, } ``` `health.summary` is classified as privacy-heavy and is never allowed by the -iOS platform default. An entry in `gateway.nodes.denyCommands` overrides the +iOS platform default. An entry in `gateway.nodes.commands.deny` overrides the allow entry. See [Node command policy](/nodes#command-policy). ### 2. Enable sharing on the iOS device @@ -137,7 +137,7 @@ calculated, so the same minute is not counted twice. To stop sharing, return to **Apple Health Summaries** and tap **Turn Off Summaries**. The iOS device then removes the Health capability and `health.summary` command from its node surface. You can also remove `health.summary` from -`gateway.nodes.allowCommands` to close the Gateway side of the gate. +`gateway.nodes.commands.allow` to close the Gateway side of the gate. ## Troubleshooting @@ -149,8 +149,8 @@ Run `openclaw nodes pending` and approve any capability update, then inspect ### Command requires explicit opt-in -Add `health.summary` to `gateway.nodes.allowCommands`. Also check that -`gateway.nodes.denyCommands` does not contain it; the deny list wins. +Add `health.summary` to `gateway.nodes.commands.allow`. Also check that +`gateway.nodes.commands.deny` does not contain it; the deny list wins. ### `HEALTH_ACCESS_DISABLED` diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index 70068cf398f0..dc7832d90136 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -150,7 +150,7 @@ A node can be connected and device-paired while its effective `caps` and `comman Camera devices must be readable by the service user, commonly through the `video` group. Camera clips use the default PulseAudio or PipeWire source when `includeAudio` is true; microphone audio exists only as that clip track, not as a standalone command. Location requires the node-service user to be permitted by the host's GeoClue policy. -`camera.snap` and `camera.clip` also require explicit Gateway arming through `gateway.nodes.allowCommands`. See [Camera capture](/nodes/camera) and [Location command](/nodes/location-command) for payloads, limits, and errors. +`camera.snap` and `camera.clip` also require explicit Gateway arming through `gateway.nodes.commands.allow`. See [Camera capture](/nodes/camera) and [Location command](/nodes/location-command) for payloads, limits, and errors. ## Install diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index beb028dbafbf..49b36e1384ef 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -96,7 +96,7 @@ openclaw nodes status The Gateway only forwards commands the node declares and server policy allows. Privacy-sensitive commands such as `screen.record`, `camera.snap`, -and `camera.clip` need explicit `gateway.nodes.allowCommands` opt-in. +and `camera.clip` need explicit `gateway.nodes.commands.allow` opt-in. ## Local MCP mode diff --git a/docs/plugins/adding-capabilities.md b/docs/plugins/adding-capabilities.md index b2831b3c6aad..be719e27ae82 100644 --- a/docs/plugins/adding-capabilities.md +++ b/docs/plugins/adding-capabilities.md @@ -96,7 +96,7 @@ Image generation follows the standard shape: The config key is intentionally separate from vision-analysis routing: - `agents.defaults.imageModel` analyzes images. -- `agents.defaults.imageGenerationModel` generates images. +- `agents.defaults.mediaModels.image` generates images. Keep those separate so fallback and policy remain explicit. diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 4059c2236da3..084aad7ba9e6 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -444,7 +444,7 @@ const voices = await api.runtime.tts.listVoices({ Notes: - `textToSpeech` returns the normal core TTS output payload for file/voice-note surfaces. -- Uses core `messages.tts` configuration and provider selection. +- Uses core `tts` configuration and provider selection. - Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers. - `listVoices` is optional per provider. Use it for vendor-owned voice pickers or setup flows. - Core passes a resolved request deadline to provider `listVoices` hooks; provider-specific timeout settings may override it. diff --git a/docs/plugins/cli-backend-plugins.md b/docs/plugins/cli-backend-plugins.md index ff40508c356d..c137e5d69cee 100644 --- a/docs/plugins/cli-backend-plugins.md +++ b/docs/plugins/cli-backend-plugins.md @@ -133,7 +133,7 @@ runtime behavior. Runtime behavior starts when the plugin entry calls output: "json", input: "stdin", modelArg: "--model", - sessionArg: "--session", + sessionArgs: ["--session", "{sessionId}"], sessionMode: "existing", sessionIdFields: ["session_id", "conversation_id"], systemPromptFileArg: "--system-file", @@ -185,7 +185,7 @@ runtime behavior. Runtime behavior starts when the plugin entry calls | `env` / `clearEnv` | Extra env vars to inject, or names to strip before launch | | `modelArg` | Flag used before the model id | | `modelAliases` | Map OpenClaw model ids to CLI-native ids | -| `sessionArg` / `sessionArgs` | How to pass a session id | +| `sessionArgs` | How to pass a session id using `{sessionId}` | | `sessionMode` | `always`, `existing`, or `none` | | `sessionIdFields` | JSON fields OpenClaw reads from CLI output | | `systemPromptArg` / `systemPromptFileArg` | System prompt transport | diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index e14ba9268081..d69527f68181 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -527,10 +527,10 @@ OpenClaw-owned dynamic tool calls are bounded independently from first available timeout in this order: - A positive per-call `timeoutMs` argument. -- For `image_generate`, `agents.defaults.imageGenerationModel.timeoutMs`. +- For `image_generate`, `agents.defaults.mediaModels.image.timeoutMs`. - For `image_generate` without a configured timeout, the 120 second image-generation default. -- For the media-understanding `image` tool, `tools.media.image.timeoutSeconds` +- For the media-understanding `image` tool, the selected image-capable `tools.media.models[]` entry's `timeoutSeconds` converted to milliseconds, or the 60 second media default. For image understanding, this applies to the request itself and is not reduced by earlier preparation work. diff --git a/docs/plugins/codex-harness-runtime.md b/docs/plugins/codex-harness-runtime.md index 1126087208b1..68805f69514e 100644 --- a/docs/plugins/codex-harness-runtime.md +++ b/docs/plugins/codex-harness-runtime.md @@ -317,8 +317,8 @@ writes an OpenClaw-owned session transcript tool result. OpenClaw continues to own media delivery and media provider selection. Image, video, music, PDF, TTS, and media understanding use matching provider/model -settings such as `agents.defaults.imageGenerationModel`, -`videoGenerationModel`, `pdfModel`, and `messages.tts`. +settings such as `agents.defaults.mediaModels.image`, +`agents.defaults.mediaModels.video`, `pdfModel`, and `tts`. Text, images, video, music, TTS, approvals, and messaging-tool output continue through the normal OpenClaw delivery path; media generation does not require diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 9941279a40e0..247a5113fd40 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -764,10 +764,10 @@ OpenClaw-owned dynamic tool calls are bounded independently from `appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second OpenClaw watchdog by default. A positive per-call `timeoutMs` argument extends or shortens that specific tool budget, capped at 600000 ms. -The `image_generate` tool uses `agents.defaults.imageGenerationModel.timeoutMs` +The `image_generate` tool uses `agents.defaults.mediaModels.image.timeoutMs` when the tool call does not provide its own timeout, or a 120 second image-generation default otherwise. The media-understanding `image` tool -uses `tools.media.image.timeoutSeconds` or its 60 second media default; for +uses the selected image-capable `tools.media.models[]` entry's `timeoutSeconds` or its 60 second media default; for image understanding, that timeout applies to the request itself and is not reduced by earlier preparation work. On timeout, OpenClaw aborts the tool signal where supported and returns a failed dynamic-tool response to Codex diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index 2883480a5eb9..58126cafb392 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -199,7 +199,7 @@ Route Meet through that node: { gateway: { nodes: { - allowCommands: ["googlemeet.chrome", "browser.proxy"], + commands: { allow: ["googlemeet.chrome", "browser.proxy"] }, }, }, plugins: { @@ -244,7 +244,7 @@ If `chromeNode.node` is omitted, OpenClaw auto-selects only when exactly one con | Symptom | Fix | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Configured Google Meet node ... is not usable: offline` | The pinned node is known but unavailable. Report the setup blocker; do not silently fall back to another transport unless asked. | -| `No connected Google Meet-capable node` | Run `openclaw node run` in the VM, approve pairing, and run `openclaw plugins enable google-meet` and `openclaw plugins enable browser` there. Confirm `gateway.nodes.allowCommands` includes `googlemeet.chrome` and `browser.proxy`. | +| `No connected Google Meet-capable node` | Run `openclaw node run` in the VM, approve pairing, and run `openclaw plugins enable google-meet` and `openclaw plugins enable browser` there. Confirm `gateway.nodes.commands.allow` includes `googlemeet.chrome` and `browser.proxy`. | | `BlackHole 2ch audio device not found` | Install `blackhole-2ch` on the host being checked and reboot. | | `BlackHole 2ch audio device not found on the node` | Install `blackhole-2ch` in the VM and reboot the VM. | | Chrome opens but cannot join | Sign in to the browser profile in the VM, or keep `chrome.guestName` set. Guest auto-join uses OpenClaw browser automation through the node browser proxy; point the node's `browser.defaultProfile` (or a named existing-session profile) at the profile you want. | @@ -762,14 +762,12 @@ ElevenLabs for both agent-mode listening and speaking: ```json5 { - messages: { - tts: { - provider: "elevenlabs", - providers: { - elevenlabs: { - modelId: "eleven_v3", - speakerVoiceId: "pMsXgVXv3BLzUgSXRplE", - }, + tts: { + provider: "elevenlabs", + providers: { + elevenlabs: { + modelId: "eleven_v3", + speakerVoiceId: "pMsXgVXv3BLzUgSXRplE", }, }, }, @@ -795,7 +793,7 @@ ElevenLabs for both agent-mode listening and speaking: } ``` -The persistent Meet voice comes from `messages.tts.providers.elevenlabs.speakerVoiceId`. Agent replies can also use per-reply `[[tts:speakerVoiceId=... model=eleven_v3]]` directives when TTS model overrides are enabled, but config is the deterministic default for meetings. On join, logs show `transcriptionProvider=elevenlabs`, and each spoken reply logs `provider=elevenlabs model=eleven_v3 speakerVoiceId=`. +The persistent Meet voice comes from `tts.providers.elevenlabs.speakerVoiceId`. Agent replies can also use per-reply `[[tts:speakerVoiceId=... model=eleven_v3]]` directives when TTS model overrides are enabled, but config is the deterministic default for meetings. On join, logs show `transcriptionProvider=elevenlabs`, and each spoken reply logs `provider=elevenlabs model=eleven_v3 speakerVoiceId=`. Twilio-only config: @@ -1038,7 +1036,7 @@ The node must be connected and list `googlemeet.chrome` plus `browser.proxy`; th { gateway: { nodes: { - allowCommands: ["browser.proxy", "googlemeet.chrome"], + commands: { allow: ["browser.proxy", "googlemeet.chrome"] }, }, }, } diff --git a/docs/plugins/llama-cpp.md b/docs/plugins/llama-cpp.md index cb06cb91eac3..a6c8e41a20d8 100644 --- a/docs/plugins/llama-cpp.md +++ b/docs/plugins/llama-cpp.md @@ -3,7 +3,7 @@ summary: "Run local GGUF text inference and memory embeddings in OpenClaw with l read_when: - You want local text inference without an API key or model server - You want memory search embeddings from a local GGUF model - - You are configuring memorySearch.provider = "local" + - You are configuring memory.search.provider = "local" - You need the OpenClaw plugin that owns the node-llama-cpp runtime title: "llama.cpp Provider" sidebarTitle: "llama.cpp Provider" @@ -95,17 +95,15 @@ own read-only cache resolver, including repository, branch, and split-file namin ## Memory embedding configuration -Set `memorySearch.provider` to `local`: +Set `memory.search.provider` to `local`: ```json5 { - agents: { - defaults: { - memorySearch: { - provider: "local", - local: { - modelPath: "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf", - }, + memory: { + search: { + provider: "local", + local: { + modelPath: "hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf", }, }, }, @@ -156,5 +154,5 @@ with: For local inference without an in-process native dependency, use the Ollama or LM Studio provider instead. For lower-friction local embeddings, set -`memorySearch.provider` to a remote embedding provider such as `lmstudio`, +`memory.search.provider` to a remote embedding provider such as `lmstudio`, `ollama`, `openai`, or `voyage` instead. diff --git a/docs/plugins/logbook.md b/docs/plugins/logbook.md index 08c7d8032583..9feef583e798 100644 --- a/docs/plugins/logbook.md +++ b/docs/plugins/logbook.md @@ -174,8 +174,7 @@ Gateway restarts; use `captureEnabled: false` for a persistent stop. Logbook resolves the observation model in this order: 1. `plugins.entries.logbook.config.visionModel` -2. the first image-capable Codex entry under `tools.media.image.models` -3. the first image-capable Codex entry under `tools.media.models` +2. the first image-capable Codex entry under `tools.media.models` Other media providers are skipped because they do not currently expose the structured extraction contract Logbook requires. Setting @@ -227,7 +226,7 @@ the derived-text methods directly. model when you need a fully local pipeline. - Frames, the timeline database, and temporary captures are written with owner-only file permissions. -- Adding `screen.snapshot` to `gateway.nodes.denyCommands` is the +- Adding `screen.snapshot` to `gateway.nodes.commands.deny` is the screen-capture kill switch: it blocks app-node capture and Logbook's own `logbook.snapshot` command alike. - Setting `tools.media.image.enabled: false` also stops Logbook from borrowing @@ -259,7 +258,7 @@ openclaw logs --follow - Confirm the node exposes `screen.snapshot` or `logbook.snapshot`. - Grant Screen Recording permission on the capture Mac. - If `nodeId` is configured, confirm it matches the node id or display name. -- Check that `gateway.nodes.denyCommands` does not contain +- Check that `gateway.nodes.commands.deny` does not contain `screen.snapshot`. After three consecutive failures, Logbook backs off for ten capture ticks and diff --git a/docs/plugins/memory-lancedb.md b/docs/plugins/memory-lancedb.md index b1b6f199188f..e167343f9089 100644 --- a/docs/plugins/memory-lancedb.md +++ b/docs/plugins/memory-lancedb.md @@ -33,7 +33,7 @@ but only one plugin owns the active memory slot at a time. LanceDB's `memory_recall` does not receive the protected private transcript -authorization used by `memorySearch.rememberAcrossConversations`. Use LanceDB's +authorization used by `memory.search.rememberAcrossConversations`. Use LanceDB's `autoRecall` or its `memory_recall` tool through [advanced Active Memory](/concepts/active-memory#lancedb-memory). `openclaw doctor` reports when Remember across conversations is unavailable @@ -217,8 +217,8 @@ and caps at 3 captured memories per agent turn. Every memory is owned by one agent. Recall, duplicate detection, capture, listing, raw queries, and deletion all enforce that owner before returning or -mutating rows. An agent with `memorySearch.enabled: false` (in `agents.list[]` -or via `agents.defaults`) also gets none of the `memory_recall`, `memory_store`, +mutating rows. An agent with `memory.search.enabled: false` in its `agents.entries.*` +entry, or one inheriting a disabled top-level search, also gets none of the `memory_recall`, `memory_store`, or `memory_forget` tools and does not participate in automatic recall or capture, even when the plugin-level `autoRecall`/`autoCapture` flags are on. diff --git a/docs/plugins/reference/policy.md b/docs/plugins/reference/policy.md index d02cc0324073..45550063c486 100644 --- a/docs/plugins/reference/policy.md +++ b/docs/plugins/reference/policy.md @@ -65,7 +65,7 @@ Named policy scopes under `scopes.` can add stricter normal policy sections for the selector they list. `agentIds` supports `tools`, `agents.workspace`, `sandbox`, and `dataHandling.memory`; `channelIds` supports `ingress.channels`. -Runtime agent ids that are not explicitly listed in `agents.list[]` are checked +Runtime agent ids that are not explicitly listed in `agents.entries.*` are checked against inherited global/default posture rather than silently passing with no evidence. Every scope present in `policy.jsonc` must be valid and enforceable for its selector. Overlay rules are additional claims, so they do not weaken diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index ca24b7f4ac5d..f66462702bef 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -184,7 +184,7 @@ successful Gateway connect; the Gateway exposes it to agent runs only while that node is connected and only if the descriptor's `command` is in the node's approved command surface. Set `agentTool.defaultPlatforms` to opt a non-dangerous command into the default node command allowlist; otherwise require -explicit `gateway.nodes.allowCommands` or a node-invoke policy. `agentTool.name` +explicit `gateway.nodes.commands.allow` or a node-invoke policy. `agentTool.name` must be provider-safe: start with a letter, use only letters, digits, underscores, or hyphens, and stay within 64 characters. MCP-backed node tools can set `agentTool.mcp` metadata so catalog and tool-search surfaces can show diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 7cea06e8fb65..fdbc51a41ae5 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -396,7 +396,7 @@ two-party event loops that do not go through the shared inbound reply runner. Inside the Gateway this runtime is in-process. In plugin CLI commands it calls the configured Gateway over RPC, so commands such as `openclaw googlemeet recover-tab` can inspect paired nodes from the terminal. Node commands still go through normal Gateway node pairing, command allowlists, plugin node-invoke policies, and node-local command handling. - Plugins that expose node-hosted agent tools can set `agentTool.defaultPlatforms` for non-dangerous commands that should be allowlisted by default. Omit it when operators must opt in with `gateway.nodes.allowCommands`. Dangerous node-host commands should register a node-invoke policy with `api.registerNodeInvokePolicy(...)`; the policy runs in the Gateway after command allowlist checks and before the command is forwarded to the node, so direct `node.invoke` calls, node-hosted plugin tools, and higher-level plugin tools share the same enforcement path. + Plugins that expose node-hosted agent tools can set `agentTool.defaultPlatforms` for non-dangerous commands that should be allowlisted by default. Omit it when operators must opt in with `gateway.nodes.commands.allow`. Dangerous node-host commands should register a node-invoke policy with `api.registerNodeInvokePolicy(...)`; the policy runs in the Gateway after command allowlist checks and before the command is forwarded to the node, so direct `node.invoke` calls, node-hosted plugin tools, and higher-level plugin tools share the same enforcement path. The optional `scopes` field requests Gateway operator scopes for the invocation. OpenClaw honors it only for bundled plugins and trusted official plugin installations; requests from other plugins do not elevate the call. Use it only when a trusted plugin must invoke a node command with a stricter Gateway scope, such as `operator.admin`. @@ -465,7 +465,7 @@ two-party event loops that do not go through the shared inbound reply runner. }); ``` - Uses core `messages.tts` configuration and provider selection. Returns PCM audio buffer + sample rate. `textToSpeechStream` is also available for streaming synthesis. + Uses core `tts` configuration and provider selection. Returns PCM audio buffer + sample rate. `textToSpeechStream` is also available for streaming synthesis. diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index 3d470bd18721..4a877b75a85d 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -483,9 +483,9 @@ Current runtime behavior: ## TTS for calls -Voice Call uses the core `messages.tts` configuration for streaming speech on +Voice Call uses the core `tts` configuration for streaming speech on calls. You can override it under the plugin config with the **same shape** — -it deep-merges with `messages.tts`. +it deep-merges with `tts`. ```json5 { @@ -522,12 +522,10 @@ Behavior notes: ```json5 { - messages: { - tts: { - provider: "openai", - providers: { - openai: { speakerVoice: "alloy" }, - }, + tts: { + provider: "openai", + providers: { + openai: { speakerVoice: "alloy" }, }, }, } diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index b8611994cc20..bdda2be8e30f 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -428,7 +428,7 @@ OpenClaw supports Anthropic's prompt caching feature for API-key auth. - Use model-level params as your baseline, then override specific agents via `agents.list[].params`: + Use model-level params as your baseline, then override specific agents via `agents.entries.*.params`: ```json5 { @@ -452,7 +452,7 @@ OpenClaw supports Anthropic's prompt caching feature for API-key auth. Config merge order: 1. `agents.defaults.models["provider/model"].params` - 2. `agents.list[].params` (matching `id`, overrides by key) + 2. `agents.entries.*.params` (matching `id`, overrides by key) This lets one agent keep a long-lived cache while another agent on the same model disables caching for bursty/low-reuse traffic. diff --git a/docs/providers/azure-speech.md b/docs/providers/azure-speech.md index 4beee31e1946..0a898d231298 100644 --- a/docs/providers/azure-speech.md +++ b/docs/providers/azure-speech.md @@ -36,18 +36,16 @@ output format through the `X-Microsoft-OutputFormat` header. ``` - + ```json5 { - messages: { - tts: { - auto: "always", - provider: "azure-speech", - providers: { - "azure-speech": { - voice: "en-US-JennyNeural", - lang: "en-US", - }, + tts: { + auto: "always", + provider: "azure-speech", + providers: { + "azure-speech": { + voice: "en-US-JennyNeural", + lang: "en-US", }, }, }, @@ -63,7 +61,7 @@ output format through the `X-Microsoft-OutputFormat` header. ## Configuration options -All options live under `messages.tts.providers["azure-speech"]`. +All options live under `tts.providers["azure-speech"]`. | Option | Description | | ----------------------- | ----------------------------------------------------------------------------------------------------- | @@ -75,7 +73,7 @@ All options live under `messages.tts.providers["azure-speech"]`. | `lang` | SSML language code (default `en-US`). | | `outputFormat` | Audio-file output format (default `audio-24khz-48kbitrate-mono-mp3`). | | `voiceNoteOutputFormat` | Voice-note output format (default `ogg-24khz-16bit-mono-opus`). | -| `timeoutMs` | Request timeout override in milliseconds. Falls back to the global `messages.tts.timeoutMs`. | +| `timeoutMs` | Request timeout override in milliseconds. Falls back to the global `tts.timeoutMs`. | The provider is considered configured once `apiKey` is set plus one of `region`, `endpoint`, or `baseUrl`. Env vars are only checked as a fallback @@ -116,10 +114,10 @@ or explicit config for endpoint routing. - TTS overview, providers, and `messages.tts` config. + TTS overview, providers, and `tts` config. - Full config reference including `messages.tts` settings. + Full config reference including `tts` settings. All bundled OpenClaw providers. diff --git a/docs/providers/bedrock.md b/docs/providers/bedrock.md index e5cc5254d26e..f8556fb72934 100644 --- a/docs/providers/bedrock.md +++ b/docs/providers/bedrock.md @@ -421,16 +421,14 @@ openclaw models list Bedrock can also serve as the embedding provider for [memory search](/concepts/memory-search). This is configured separately from the - inference provider -- set `agents.defaults.memorySearch.provider` to `"bedrock"`: + inference provider -- set `memory.search.provider` to `"bedrock"`: ```json5 { - agents: { - defaults: { - memorySearch: { - provider: "bedrock", - model: "amazon.titan-embed-text-v2:0", // default - }, + memory: { + search: { + provider: "bedrock", + model: "amazon.titan-embed-text-v2:0", // default }, }, } diff --git a/docs/providers/deepgram.md b/docs/providers/deepgram.md index 8cf35279e919..dd53a71eb180 100644 --- a/docs/providers/deepgram.md +++ b/docs/providers/deepgram.md @@ -54,10 +54,10 @@ returns them. ## Configuration options -| Option | Path | Description | -| ---------- | ------------------------------------- | ------------------------------------- | -| `model` | `tools.media.audio.models[].model` | Deepgram model id (default: `nova-3`) | -| `language` | `tools.media.audio.models[].language` | Language hint (optional) | +| Option | Path | Description | +| ---------- | ------------------------------- | ------------------------------------- | +| `model` | `tools.media.models[].model` | Deepgram model id (default: `nova-3`) | +| `language` | `tools.media.models[].language` | Language hint (optional) | `providerOptions.deepgram` merges extra query params directly into the Deepgram `/listen` request, so any Deepgram-supported param name works @@ -162,8 +162,7 @@ Twilio media frames can be forwarded directly. the simplest path. - Override endpoints or headers with `tools.media.audio.baseUrl` and - `tools.media.audio.headers` when using a proxy. + Override endpoints or headers on the Deepgram `tools.media.models[]` entry when using a proxy. Output follows the same audio rules as other providers (size caps, timeouts, diff --git a/docs/providers/deepinfra.md b/docs/providers/deepinfra.md index fdcec1a00f32..e8ea14fbe9d6 100644 --- a/docs/providers/deepinfra.md +++ b/docs/providers/deepinfra.md @@ -54,15 +54,15 @@ live from `https://api.deepinfra.com/v1/openai/models?sort_by=openclaw&filter=wi once `DEEPINFRA_API_KEY` is configured. Other surfaces use the static defaults below until they move onto the same live catalog. -| Surface | Default model | OpenClaw config/tool | -| ------------------------ | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -| Chat / model provider | first chat-tagged entry from live catalog (static fallback `deepseek-ai/DeepSeek-V4-Flash`) | `agents.defaults.model` | -| Image generation/editing | first `image-gen`-tagged entry from live catalog (static fallback `black-forest-labs/FLUX-1-schnell`) | `image_generate`, `agents.defaults.imageGenerationModel` | -| Media understanding | `moonshotai/Kimi-K2.5` for images | inbound image understanding | -| Speech-to-text | `openai/whisper-large-v3-turbo` | inbound audio transcription | -| Text-to-speech | `hexgrad/Kokoro-82M` | `messages.tts.provider: "deepinfra"` | -| Video generation | static fallback `Pixverse/Pixverse-T2V` (no live video-gen rows from DeepInfra today) | `video_generate`, `agents.defaults.videoGenerationModel` | -| Memory embeddings | `BAAI/bge-m3` | `agents.defaults.memorySearch.provider: "deepinfra"` | +| Surface | Default model | OpenClaw config/tool | +| ------------------------ | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| Chat / model provider | first chat-tagged entry from live catalog (static fallback `deepseek-ai/DeepSeek-V4-Flash`) | `agents.defaults.model` | +| Image generation/editing | first `image-gen`-tagged entry from live catalog (static fallback `black-forest-labs/FLUX-1-schnell`) | `image_generate`, `agents.defaults.mediaModels.image` | +| Media understanding | `moonshotai/Kimi-K2.5` for images | inbound image understanding | +| Speech-to-text | `openai/whisper-large-v3-turbo` | inbound audio transcription | +| Text-to-speech | `hexgrad/Kokoro-82M` | `tts.provider: "deepinfra"` | +| Video generation | static fallback `Pixverse/Pixverse-T2V` (no live video-gen rows from DeepInfra today) | `video_generate`, `agents.defaults.mediaModels.video` | +| Memory embeddings | `BAAI/bge-m3` | `memory.search.provider: "deepinfra"` | DeepInfra also exposes reranking, classification, object-detection, and other native model types. OpenClaw has no provider contract for those categories diff --git a/docs/providers/elevenlabs.md b/docs/providers/elevenlabs.md index 7f77c520a1e8..cc9b31c15164 100644 --- a/docs/providers/elevenlabs.md +++ b/docs/providers/elevenlabs.md @@ -13,7 +13,7 @@ enabled by default; no `plugins install` step is needed. | Capability | OpenClaw surface | Default | | ------------------------ | -------------------------------------------------------------------- | ------------------------ | -| Text-to-speech | `messages.tts` / `talk` | `eleven_multilingual_v2` | +| Text-to-speech | `tts` / `talk` | `eleven_multilingual_v2` | | Batch speech-to-text | `tools.media.audio` | `scribe_v2` | | Streaming speech-to-text | Voice Call streaming or Google Meet `realtime.transcriptionProvider` | `scribe_v2_realtime` | @@ -30,14 +30,12 @@ export ELEVENLABS_API_KEY="..." ```json5 { - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: "${ELEVENLABS_API_KEY}", - voiceId: "pMsXgVXv3BLzUgSXRplE", - modelId: "eleven_multilingual_v2", - }, + tts: { + providers: { + elevenlabs: { + apiKey: "${ELEVENLABS_API_KEY}", + voiceId: "pMsXgVXv3BLzUgSXRplE", + modelId: "eleven_multilingual_v2", }, }, }, @@ -48,7 +46,7 @@ Set `modelId` to `eleven_v3` to use ElevenLabs v3 TTS. OpenClaw keeps `eleven_multilingual_v2` as the default for existing installs. Discord voice channels use ElevenLabs' streaming TTS endpoint when ElevenLabs -is the selected `voice.tts`/`messages.tts` provider: playback starts from the +is the selected `voice.tts`/`tts` provider: playback starts from the returned audio stream instead of waiting for OpenClaw to download the whole audio file first. `latencyTier` maps to ElevenLabs' `optimize_streaming_latency` query parameter for models that accept it; OpenClaw omits that parameter for diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md index db6f966843f6..86aa7831c94a 100644 --- a/docs/providers/github-copilot.md +++ b/docs/providers/github-copilot.md @@ -291,19 +291,17 @@ have logged in, OpenClaw can use it for embeddings without a separate API key. ### Config -Set `memorySearch.provider` explicitly to use GitHub Copilot embeddings. If a +Set `memory.search.provider` explicitly to use GitHub Copilot embeddings. If a GitHub token is available, OpenClaw discovers available embedding models from the Copilot API and picks the best one automatically. ```json5 { - agents: { - defaults: { - memorySearch: { - provider: "github-copilot", - // Optional: override the auto-discovered model - model: "text-embedding-3-small", - }, + memory: { + search: { + provider: "github-copilot", + // Optional: override the auto-discovered model + model: "text-embedding-3-small", }, }, } diff --git a/docs/providers/google.md b/docs/providers/google.md index e730ac6c249b..6d23566d88b1 100644 --- a/docs/providers/google.md +++ b/docs/providers/google.md @@ -300,7 +300,7 @@ The bundled `google` speech provider uses the Gemini API TTS path with `gemini-3.1-flash-tts-preview`. - Default voice: `Kore` -- Auth: `messages.tts.providers.google.apiKey`, `models.providers.google.apiKey`, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` +- Auth: `tts.providers.google.apiKey`, `models.providers.google.apiKey`, `GEMINI_API_KEY`, or `GOOGLE_API_KEY` - Output: WAV for regular TTS attachments, Opus for voice-note targets, PCM for Talk/telephony - Voice-note output: Google PCM is wrapped as WAV and transcoded to 48 kHz Opus with `ffmpeg` @@ -313,16 +313,14 @@ To use Google as the default TTS provider: ```json5 { - messages: { - tts: { - auto: "always", - provider: "google", - providers: { - google: { - model: "gemini-3.1-flash-tts-preview", - speakerVoice: "Kore", - audioProfile: "Speak professionally with a calm tone.", - }, + tts: { + auto: "always", + provider: "google", + providers: { + google: { + model: "gemini-3.1-flash-tts-preview", + speakerVoice: "Kore", + audioProfile: "Speak professionally with a calm tone.", }, }, }, diff --git a/docs/providers/gradium.md b/docs/providers/gradium.md index 42c4e91abbee..6b2432aa8b7e 100644 --- a/docs/providers/gradium.md +++ b/docs/providers/gradium.md @@ -38,14 +38,12 @@ Create a Gradium API key, then expose it with an env var or the config key. Conf ```json5 { - messages: { - tts: { - auto: "always", - provider: "gradium", - providers: { - gradium: { - apiKey: "${GRADIUM_API_KEY}", - }, + tts: { + auto: "always", + provider: "gradium", + providers: { + gradium: { + apiKey: "${GRADIUM_API_KEY}", }, }, }, @@ -58,27 +56,25 @@ Create a Gradium API key, then expose it with an env var or the config key. Conf ```json5 { - messages: { - tts: { - auto: "always", - provider: "gradium", - providers: { - gradium: { - speakerVoiceId: "YTpq7expH9539ERJ", - // apiKey: "${GRADIUM_API_KEY}", - // baseUrl: "https://api.gradium.ai", - }, + tts: { + auto: "always", + provider: "gradium", + providers: { + gradium: { + speakerVoiceId: "YTpq7expH9539ERJ", + // apiKey: "${GRADIUM_API_KEY}", + // baseUrl: "https://api.gradium.ai", }, }, }, } ``` -| Key | Type | Description | -| ----------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------- | -| `messages.tts.providers.gradium.apiKey` | string | Resolved API key. Supports `${ENV}` and secret refs. | -| `messages.tts.providers.gradium.baseUrl` | string | HTTPS Gradium API URL on `api.gradium.ai`. Trailing slashes stripped. Default `https://api.gradium.ai`. | -| `messages.tts.providers.gradium.speakerVoiceId` | string | Default voice id used when no directive override is present. | +| Key | Type | Description | +| -------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------- | +| `tts.providers.gradium.apiKey` | string | Resolved API key. Supports `${ENV}` and secret refs. | +| `tts.providers.gradium.baseUrl` | string | HTTPS Gradium API URL on `api.gradium.ai`. Trailing slashes stripped. Default `https://api.gradium.ai`. | +| `tts.providers.gradium.speakerVoiceId` | string | Default voice id used when no directive override is present. | Output format is chosen automatically by target surface (see [Output](#output)) and is not configurable in `openclaw.json`. @@ -120,7 +116,7 @@ Output format is selected by target surface; the provider does not synthesize ot ## Auto-select order -Among configured TTS providers, Gradium's auto-select order is `30`. See [Text-to-Speech](/tools/tts) for how OpenClaw picks the active provider when `messages.tts.provider` is not pinned. +Among configured TTS providers, Gradium's auto-select order is `30`. See [Text-to-Speech](/tools/tts) for how OpenClaw picks the active provider when `tts.provider` is not pinned. ## Related diff --git a/docs/providers/inworld.md b/docs/providers/inworld.md index f8693e62204c..4768b0ab2be3 100644 --- a/docs/providers/inworld.md +++ b/docs/providers/inworld.md @@ -41,18 +41,16 @@ openclaw gateway restart ``` - + ```json5 { - messages: { - tts: { - auto: "always", - provider: "inworld", - providers: { - inworld: { - voiceId: "Sarah", - modelId: "inworld-tts-1.5-max", - }, + tts: { + auto: "always", + provider: "inworld", + providers: { + inworld: { + voiceId: "Sarah", + modelId: "inworld-tts-1.5-max", }, }, }, @@ -66,13 +64,13 @@ openclaw gateway restart ## Configuration options -| Option | Path | Description | -| ------------- | -------------------------------------------- | ------------------------------------------------------------------- | -| `apiKey` | `messages.tts.providers.inworld.apiKey` | Base64 dashboard credential. Falls back to `INWORLD_API_KEY`. | -| `baseUrl` | `messages.tts.providers.inworld.baseUrl` | Override Inworld API base URL (default `https://api.inworld.ai`). | -| `voiceId` | `messages.tts.providers.inworld.voiceId` | Voice identifier (default `Sarah`). Legacy alias: `speakerVoiceId`. | -| `modelId` | `messages.tts.providers.inworld.modelId` | TTS model id (default `inworld-tts-1.5-max`). | -| `temperature` | `messages.tts.providers.inworld.temperature` | Sampling temperature, `0` (exclusive) to `2` (optional). | +| Option | Path | Description | +| ------------- | ----------------------------------- | ------------------------------------------------------------------- | +| `apiKey` | `tts.providers.inworld.apiKey` | Base64 dashboard credential. Falls back to `INWORLD_API_KEY`. | +| `baseUrl` | `tts.providers.inworld.baseUrl` | Override Inworld API base URL (default `https://api.inworld.ai`). | +| `voiceId` | `tts.providers.inworld.voiceId` | Voice identifier (default `Sarah`). Legacy alias: `speakerVoiceId`. | +| `modelId` | `tts.providers.inworld.modelId` | TTS model id (default `inworld-tts-1.5-max`). | +| `temperature` | `tts.providers.inworld.temperature` | Sampling temperature, `0` (exclusive) to `2` (optional). | ## Notes @@ -87,7 +85,7 @@ openclaw gateway restart Replies use MP3 by default. When the channel target is `voice-note`, OpenClaw asks Inworld for `OGG_OPUS` so the audio plays as a native voice bubble. Telephony synthesis uses raw `PCM` at 22050 Hz to feed the telephony bridge. - Override the API host with `messages.tts.providers.inworld.baseUrl`. Trailing slashes are stripped before requests are sent. + Override the API host with `tts.providers.inworld.baseUrl`. Trailing slashes are stripped before requests are sent. @@ -95,10 +93,10 @@ openclaw gateway restart - TTS overview, providers, and `messages.tts` config. + TTS overview, providers, and `tts` config. - Full config reference including `messages.tts` settings. + Full config reference including `tts` settings. All supported OpenClaw providers. diff --git a/docs/providers/litellm.md b/docs/providers/litellm.md index 70c7124ad294..0629bc4511a3 100644 --- a/docs/providers/litellm.md +++ b/docs/providers/litellm.md @@ -90,7 +90,7 @@ The default model onboarding writes is `litellm/claude-opus-4-6`. LiteLLM can back the `image_generate` tool through OpenAI-compatible `/images/generations` and `/images/edits` routes. Default image model is `gpt-image-2`; configure a different one under -`agents.defaults.imageGenerationModel`: +`agents.defaults.mediaModels.image`: ```json5 { diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index 7423b3636765..6d898cf02b39 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -227,24 +227,24 @@ See [Image Generation](/tools/image-generation) for shared tool parameters, prov ### Text-to-speech -The bundled `minimax` plugin registers MiniMax T2A v2 as a speech provider for `messages.tts`. +The bundled `minimax` plugin registers MiniMax T2A v2 as a speech provider for `tts`. - Default TTS model: `speech-2.8-hd` - Default voice: `English_expressive_narrator` - Bundled model ids: `speech-2.8-hd`, `speech-2.8-turbo`, `speech-2.6-hd`, `speech-2.6-turbo`, `speech-02-hd`, `speech-02-turbo`, `speech-01-hd`, `speech-01-turbo` -- Auth resolution order: `messages.tts.providers.minimax.apiKey`, then `minimax-portal` OAuth/token auth profiles, then Token Plan environment keys (`MINIMAX_OAUTH_TOKEN`, `MINIMAX_CODE_PLAN_KEY`, `MINIMAX_CODING_API_KEY`), then `MINIMAX_API_KEY` +- Auth resolution order: `tts.providers.minimax.apiKey`, then `minimax-portal` OAuth/token auth profiles, then Token Plan environment keys (`MINIMAX_OAUTH_TOKEN`, `MINIMAX_CODE_PLAN_KEY`, `MINIMAX_CODING_API_KEY`), then `MINIMAX_API_KEY` - If no TTS host is configured, OpenClaw reuses the configured `minimax-portal` OAuth host and strips Anthropic-compatible path suffixes such as `/anthropic` - Normal audio attachments stay MP3. Voice-note targets (Feishu, Telegram, and other channels that request a voice-note-compatible attachment) are transcoded from MiniMax MP3 to 48kHz Opus with `ffmpeg`, because e.g. the Feishu/Lark file API only accepts `file_type: "opus"` for native audio messages - MiniMax T2A accepts fractional `speed` and `vol`, but `pitch` is sent as an integer; OpenClaw truncates fractional `pitch` values before the API request -| Setting | Env var | Default | Description | -| ---------------------------------------- | ---------------------- | ----------------------------- | -------------------------------- | -| `messages.tts.providers.minimax.baseUrl` | `MINIMAX_API_HOST` | `https://api.minimax.io` | MiniMax T2A API host. | -| `messages.tts.providers.minimax.model` | `MINIMAX_TTS_MODEL` | `speech-2.8-hd` | TTS model id. | -| `messages.tts.providers.minimax.voiceId` | `MINIMAX_TTS_VOICE_ID` | `English_expressive_narrator` | Voice id used for speech output. | -| `messages.tts.providers.minimax.speed` | | `1.0` | Playback speed, `0.5..2.0`. | -| `messages.tts.providers.minimax.vol` | | `1.0` | Volume, `(0, 10]`. | -| `messages.tts.providers.minimax.pitch` | | `0` | Integer pitch shift, `-12..12`. | +| Setting | Env var | Default | Description | +| ------------------------------- | ---------------------- | ----------------------------- | -------------------------------- | +| `tts.providers.minimax.baseUrl` | `MINIMAX_API_HOST` | `https://api.minimax.io` | MiniMax T2A API host. | +| `tts.providers.minimax.model` | `MINIMAX_TTS_MODEL` | `speech-2.8-hd` | TTS model id. | +| `tts.providers.minimax.voiceId` | `MINIMAX_TTS_VOICE_ID` | `English_expressive_narrator` | Voice id used for speech output. | +| `tts.providers.minimax.speed` | | `1.0` | Playback speed, `0.5..2.0`. | +| `tts.providers.minimax.vol` | | `1.0` | Volume, `(0, 10]`. | +| `tts.providers.minimax.pitch` | | `0` | Integer pitch shift, `-12..12`. | ### Music generation diff --git a/docs/providers/mistral.md b/docs/providers/mistral.md index af670e364bfd..d358bac96ae7 100644 --- a/docs/providers/mistral.md +++ b/docs/providers/mistral.md @@ -190,10 +190,8 @@ OpenClaw defaults Mistral realtime STT to `pcm_mulaw` at 8 kHz so Voice Call can ```json5 { - agents: { - defaults: { - memorySearch: { provider: "mistral" }, - }, + memory: { + search: { provider: "mistral" }, }, } ``` diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index 3b04aa4db451..5ffaff2a10de 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -37,7 +37,7 @@ OpenAI-SDK-style examples, but new config should use `baseUrl`. Public remote hosts and `https://ollama.com` require a real credential: `OLLAMA_API_KEY`, an auth profile, or the provider's `apiKey`. For direct hosted use, prefer the `ollama-cloud` provider. - A custom provider with `api: "ollama"` follows the same rules. For example, an `ollama-remote` provider pointed at a private LAN host can use `apiKey: "ollama-local"`; sub-agents resolve that marker through the Ollama provider hook instead of treating it as a missing credential. `agents.defaults.memorySearch.provider` can also point at a custom provider id so embeddings use that Ollama endpoint. + A custom provider with `api: "ollama"` follows the same rules. For example, an `ollama-remote` provider pointed at a private LAN host can use `apiKey: "ollama-local"`; sub-agents resolve that marker through the Ollama provider hook instead of treating it as a missing credential. `memory.search.provider` can also point at a custom provider id so embeddings use that Ollama endpoint. `auth-profiles.json` stores the credential for a provider id; put endpoint settings (`baseUrl`, `api`, models, headers, timeouts) in `models.providers.`. Older flat files such as `{ "ollama-windows": { "apiKey": "ollama-local" } }` are not a runtime format; `openclaw doctor --fix` rewrites them into a canonical `ollama-windows:default` API-key profile with a backup. A `baseUrl` value in that legacy file is noise and should move to provider config. @@ -46,7 +46,7 @@ OpenAI-SDK-style examples, but new config should use `baseUrl`. Bearer auth for Ollama memory embeddings is scoped to the host it was declared for: - A provider-level key is sent only to that provider's host. - - `agents.*.memorySearch.remote.apiKey` is sent only to its remote embedding host. + - `memory.search.remote.apiKey` and per-agent overrides are sent only to their remote embedding host. - A pure `OLLAMA_API_KEY` env value is treated as the Ollama Cloud convention and is not sent to local/self-hosted hosts by default. @@ -1086,14 +1086,12 @@ For full setup and behavior, see [Ollama Web Search](/tools/ollama-search). ```json5 { - agents: { - defaults: { - memorySearch: { - provider: "ollama", - remote: { - // Default for Ollama. Raise on larger hosts if reindexing is too slow. - nonBatchConcurrency: 1, - }, + memory: { + search: { + provider: "ollama", + remote: { + // Default for Ollama. Raise on larger hosts if reindexing is too slow. + nonBatchConcurrency: 1, }, }, }, @@ -1104,16 +1102,14 @@ For full setup and behavior, see [Ollama Web Search](/tools/ollama-search). ```json5 { - agents: { - defaults: { - memorySearch: { - provider: "ollama", - model: "nomic-embed-text", - remote: { - baseUrl: "http://gpu-box.local:11434", - apiKey: "ollama-local", - nonBatchConcurrency: 2, - }, + memory: { + search: { + provider: "ollama", + model: "nomic-embed-text", + remote: { + baseUrl: "http://gpu-box.local:11434", + apiKey: "ollama-local", + nonBatchConcurrency: 2, }, }, }, diff --git a/docs/providers/openai.md b/docs/providers/openai.md index fa395eb6561f..f0ad7556b1ef 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -151,7 +151,7 @@ explicit runtime config. | Server-side web search | Native OpenAI Responses tool | Yes, when web search is enabled and no other provider is pinned | | Images | `image_generate` | Yes | | Videos | `video_generate` | Yes | -| Text-to-speech | `messages.tts.provider: "openai"` / `tts` | Yes | +| Text-to-speech | `tts.provider: "openai"` / `tts` | Yes | | Batch speech-to-text | `tools.media.audio` / media understanding | Yes | | Streaming speech-to-text | Voice Call `streaming.provider: "openai"` | Yes | | Realtime voice | Voice Call `realtime.provider: "openai"` / Control UI Talk `talk.realtime.provider: "openai"` | Yes (OpenAI Platform API key) | @@ -185,19 +185,17 @@ OpenClaw can use OpenAI, or an OpenAI-compatible embedding endpoint, for ```json5 { - agents: { - defaults: { - memorySearch: { - provider: "openai", - model: "text-embedding-3-small", - }, + memory: { + search: { + provider: "openai", + model: "text-embedding-3-small", }, }, } ``` For OpenAI-compatible endpoints that require asymmetric embedding labels, set -`queryInputType` and `documentInputType` under `memorySearch`. OpenClaw +`queryInputType` and `documentInputType` under `memory.search`. OpenClaw forwards these as provider-specific `input_type` request fields: query embeddings use `queryInputType`; indexed memory chunks and batch indexing use `documentInputType`. See the @@ -736,18 +734,18 @@ compatibility fallback when the shared The bundled `openai` plugin registers speech synthesis for the - `messages.tts` surface. + `tts` surface. | Setting | Config path | Default | | ------------- | --------------------------------------------------------- | ----------------------------------- | - | Model | `messages.tts.providers.openai.model` | `gpt-4o-mini-tts` | - | Voice | `messages.tts.providers.openai.speakerVoice` | `coral` | - | Speed | `messages.tts.providers.openai.speed` | (unset) | - | Instructions | `messages.tts.providers.openai.instructions` | (unset, `gpt-4o-mini-tts` only) | - | Format | `messages.tts.providers.openai.responseFormat` | `opus` for voice notes, `mp3` for files | - | API key | `messages.tts.providers.openai.apiKey` | Falls back to `OPENAI_API_KEY` | - | Base URL | `messages.tts.providers.openai.baseUrl` | `https://api.openai.com/v1` | - | Extra body | `messages.tts.providers.openai.extraBody` / `extra_body` | (unset) | + | Model | `tts.providers.openai.model` | `gpt-4o-mini-tts` | + | Voice | `tts.providers.openai.speakerVoice` | `coral` | + | Speed | `tts.providers.openai.speed` | (unset) | + | Instructions | `tts.providers.openai.instructions` | (unset, `gpt-4o-mini-tts` only) | + | Format | `tts.providers.openai.responseFormat` | `opus` for voice notes, `mp3` for files | + | API key | `tts.providers.openai.apiKey` | Falls back to `OPENAI_API_KEY` | + | Base URL | `tts.providers.openai.baseUrl` | `https://api.openai.com/v1` | + | Extra body | `tts.providers.openai.extraBody` / `extra_body` | (unset) | Available models: `gpt-4o-mini-tts`, `tts-1`, `tts-1-hd`. Available voices: `alloy`, `ash`, `ballad`, `cedar`, `coral`, `echo`, `fable`, `juniper`, @@ -759,11 +757,9 @@ compatibility fallback when the shared ```json5 { - messages: { - tts: { - providers: { - openai: { model: "gpt-4o-mini-tts", speakerVoice: "coral" }, - }, + tts: { + providers: { + openai: { model: "gpt-4o-mini-tts", speakerVoice: "coral" }, }, }, } diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md index b1c4fa3866e4..9330145d3e37 100644 --- a/docs/providers/openrouter.md +++ b/docs/providers/openrouter.md @@ -97,7 +97,7 @@ dynamically against OpenRouter's live model catalog. ## Image generation OpenRouter can back the `image_generate` tool. Set an OpenRouter image model -under `agents.defaults.imageGenerationModel`: +under `agents.defaults.mediaModels.image`: ```json5 { @@ -116,14 +116,14 @@ under `agents.defaults.imageGenerationModel`: OpenClaw sends image requests to OpenRouter's chat-completions image API with `modalities: ["image", "text"]`. Gemini image models additionally receive `aspectRatio` and `resolution` hints through OpenRouter's `image_config`; other -image models do not. Use `agents.defaults.imageGenerationModel.timeoutMs` for +image models do not. Use `agents.defaults.mediaModels.image.timeoutMs` for slower models; the `image_generate` tool's per-call `timeoutMs` still wins. ## Video generation OpenRouter can back the `video_generate` tool through its asynchronous `/videos` API. Set an OpenRouter video model under -`agents.defaults.videoGenerationModel`: +`agents.defaults.mediaModels.video`: ```json5 { @@ -151,7 +151,7 @@ references. OpenRouter can back the `music_generate` tool through chat-completions audio output. Set an OpenRouter audio model under -`agents.defaults.musicGenerationModel`: +`agents.defaults.mediaModels.music`: ```json5 { @@ -182,23 +182,21 @@ OpenRouter can act as a TTS provider through its OpenAI-compatible ```json5 { - messages: { - tts: { - auto: "always", - provider: "openrouter", - providers: { - openrouter: { - model: "hexgrad/kokoro-82m", - speakerVoice: "af_alloy", - responseFormat: "mp3", - }, + tts: { + auto: "always", + provider: "openrouter", + providers: { + openrouter: { + model: "hexgrad/kokoro-82m", + speakerVoice: "af_alloy", + responseFormat: "mp3", }, }, }, } ``` -If `messages.tts.providers.openrouter.apiKey` is omitted, TTS falls back to +If `tts.providers.openrouter.apiKey` is omitted, TTS falls back to `models.providers.openrouter.apiKey`, then `OPENROUTER_API_KEY`. ## Speech-to-text (inbound audio) diff --git a/docs/providers/pixverse.md b/docs/providers/pixverse.md index b7d1639a60ab..78e9bf5a8db7 100644 --- a/docs/providers/pixverse.md +++ b/docs/providers/pixverse.md @@ -39,13 +39,13 @@ OpenClaw provides `pixverse` as an official external plugin for hosted PixVerse Non-interactive runs (key from `--pixverse-api-key` or `PIXVERSE_API_KEY`) default to International. - Onboarding also sets `agents.defaults.videoGenerationModel.primary` to + Onboarding also sets `agents.defaults.mediaModels.video.primary` to `pixverse/v6` when no default video model is configured yet. ```bash - openclaw config set agents.defaults.videoGenerationModel.primary "pixverse/v6" + openclaw config set agents.defaults.mediaModels.video.primary "pixverse/v6" ``` @@ -154,7 +154,7 @@ The video provider accepts these optional provider-specific keys: PixVerse returns a `video_id` from the generation request. OpenClaw polls `/openapi/v2/video/result/{video_id}` every 5 seconds until the task succeeds, fails, or hits the timeout (default 5 minutes; override with - `agents.defaults.videoGenerationModel.timeoutMs`). + `agents.defaults.mediaModels.video.timeoutMs`). diff --git a/docs/providers/runway.md b/docs/providers/runway.md index 70c9b6d162cb..0fba1b591957 100644 --- a/docs/providers/runway.md +++ b/docs/providers/runway.md @@ -29,7 +29,7 @@ OpenClaw ships a bundled `runway` provider for hosted video generation, enabled ```bash - openclaw config set agents.defaults.videoGenerationModel.primary "runway/gen4.5" + openclaw config set agents.defaults.mediaModels.video.primary "runway/gen4.5" ``` diff --git a/docs/providers/senseaudio.md b/docs/providers/senseaudio.md index 126a9551d9ab..e927ca04b42c 100644 --- a/docs/providers/senseaudio.md +++ b/docs/providers/senseaudio.md @@ -49,13 +49,13 @@ SenseAudio transcribes inbound audio and voice-note attachments through OpenClaw ## Options -| Option | Path | Description | -| ---------- | ------------------------------------- | ----------------------------------- | -| `model` | `tools.media.audio.models[].model` | SenseAudio ASR model id | -| `language` | `tools.media.audio.models[].language` | Optional language hint | -| `prompt` | `tools.media.audio.prompt` | Optional transcription prompt | -| `baseUrl` | `tools.media.audio.baseUrl` or model | Override the OpenAI-compatible base | -| `headers` | `tools.media.audio.request.headers` | Extra request headers | +| Option | Path | Description | +| ---------- | ------------------------------- | ----------------------------------- | +| `model` | `tools.media.models[].model` | SenseAudio ASR model id | +| `language` | `tools.media.models[].language` | Optional language hint | +| `prompt` | `tools.media.models[].prompt` | Optional transcription prompt | +| `baseUrl` | `tools.media.models[].baseUrl` | Override the OpenAI-compatible base | +| `headers` | `tools.media.models[].headers` | Extra request headers | SenseAudio is batch STT only in OpenClaw. Voice Call realtime transcription diff --git a/docs/providers/volcengine.md b/docs/providers/volcengine.md index 715ef902c586..5a13d1120946 100644 --- a/docs/providers/volcengine.md +++ b/docs/providers/volcengine.md @@ -106,23 +106,21 @@ Then enable it in `openclaw.json`: ```json5 { - messages: { - tts: { - auto: "always", - provider: "volcengine", - providers: { - volcengine: { - apiKey: "byteplus_seed_speech_api_key", - voice: "en_female_anna_mars_bigtts", - speedRatio: 1.0, - }, + tts: { + auto: "always", + provider: "volcengine", + providers: { + volcengine: { + apiKey: "byteplus_seed_speech_api_key", + voice: "en_female_anna_mars_bigtts", + speedRatio: 1.0, }, }, }, } ``` -Available fields under `messages.tts.providers.volcengine`: `apiKey`, `voice`, `speedRatio` (0.2-3.0), `emotion`, `cluster`, `resourceId`, `appKey`, and `baseUrl`. `!emotion=` also works as an inline voice directive when voice-setting overrides are allowed. +Available fields under `tts.providers.volcengine`: `apiKey`, `voice`, `speedRatio` (0.2-3.0), `emotion`, `cluster`, `resourceId`, `appKey`, and `baseUrl`. `!emotion=` also works as an inline voice directive when voice-setting overrides are allowed. For voice-note targets, OpenClaw requests provider-native `ogg_opus`. For normal audio attachments, it requests `mp3`. Provider aliases `bytedance` and `doubao` also resolve to this speech provider. @@ -140,7 +138,7 @@ export VOLCENGINE_TTS_TOKEN="speech_access_token" export VOLCENGINE_TTS_CLUSTER="volcano_tts" ``` -Other optional TTS env vars: `VOLCENGINE_TTS_VOICE`, `VOLCENGINE_TTS_APP_KEY`, and `VOLCENGINE_TTS_BASE_URL` override the corresponding `messages.tts.providers.volcengine` config fields when set. +Other optional TTS env vars: `VOLCENGINE_TTS_VOICE`, `VOLCENGINE_TTS_APP_KEY`, and `VOLCENGINE_TTS_BASE_URL` override the corresponding `tts.providers.volcengine` config fields when set. ## Advanced configuration diff --git a/docs/providers/vydra.md b/docs/providers/vydra.md index 7d8a217c4307..acacc06c9950 100644 --- a/docs/providers/vydra.md +++ b/docs/providers/vydra.md @@ -137,14 +137,12 @@ Use `https://www.vydra.ai/api/v1` as the base URL. Vydra's apex host (`https://v ```json5 { - messages: { - tts: { - provider: "vydra", - providers: { - vydra: { - apiKey: "${VYDRA_API_KEY}", - voiceId: "21m00Tcm4TlvDq8ikWAM", - }, + tts: { + provider: "vydra", + providers: { + vydra: { + apiKey: "${VYDRA_API_KEY}", + voiceId: "21m00Tcm4TlvDq8ikWAM", }, }, }, diff --git a/docs/providers/xai.md b/docs/providers/xai.md index 09454ae7aacb..bd80cde18dea 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -143,7 +143,7 @@ below or under known limits. | Server-side code execution | `code_execution` tool | Yes | | Images | `image_generate` | Yes | | Videos | `video_generate` | Yes | -| Batch text-to-speech | `messages.tts.provider: "xai"` / `tts` | Yes | +| Batch text-to-speech | `tts.provider: "xai"` / `tts` | Yes | | Streaming TTS | `textToSpeechStream` | Yes via `wss://api.x.ai/v1/tts` (not realtime voice) | | Batch speech-to-text | `tools.media.audio` media understanding | Yes | | Streaming speech-to-text | Voice Call `streaming.provider: "xai"` | Yes | @@ -244,7 +244,7 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20 - Video edit/extend inherit the input video's aspect ratio and resolution; those operations do not accept geometry overrides - Default operation timeout: 600 seconds unless `video_generate.timeoutMs` - or `agents.defaults.videoGenerationModel.timeoutMs` is set + or `agents.defaults.mediaModels.video.timeoutMs` is set Local video buffers are not accepted. Use remote `http(s)` URLs for video @@ -290,7 +290,7 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20 - Resolutions: `1K`, `2K` - Count: up to 4 images - Default operation timeout: 600 seconds unless `image_generate.timeoutMs` - or `agents.defaults.imageGenerationModel.timeoutMs` is set + or `agents.defaults.mediaModels.image.timeoutMs` is set OpenClaw asks xAI for `b64_json` image responses so generated media can be stored and delivered through the normal channel attachment path. Local @@ -338,13 +338,11 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20 ```json5 { - messages: { - tts: { - provider: "xai", - providers: { - xai: { - voiceId: "eve", - }, + tts: { + provider: "xai", + providers: { + xai: { + voiceId: "eve", }, }, }, diff --git a/docs/providers/xiaomi.md b/docs/providers/xiaomi.md index c481bc30f301..c5824442b3c6 100644 --- a/docs/providers/xiaomi.md +++ b/docs/providers/xiaomi.md @@ -101,7 +101,7 @@ OpenClaw's [`/think` directive](/tools/thinking) with levels `off`, ## Text-to-speech The bundled `xiaomi` plugin also registers Xiaomi MiMo as a speech provider -for `messages.tts`. It calls Xiaomi's chat-completions TTS contract with the +for `tts`. It calls Xiaomi's chat-completions TTS contract with the text as an `assistant` message and optional style guidance as a `user` message. @@ -115,18 +115,16 @@ message. ```json5 { - messages: { - tts: { - auto: "always", - provider: "xiaomi", - providers: { - xiaomi: { - apiKey: "xiaomi_api_key", - model: "mimo-v2.5-tts", - speakerVoice: "mimo_default", - format: "mp3", - style: "Bright, natural, conversational tone.", - }, + tts: { + auto: "always", + provider: "xiaomi", + providers: { + xiaomi: { + apiKey: "xiaomi_api_key", + model: "mimo-v2.5-tts", + speakerVoice: "mimo_default", + format: "mp3", + style: "Bright, natural, conversational tone.", }, }, }, @@ -145,15 +143,13 @@ model. ```json5 { - messages: { - tts: { - provider: "xiaomi", - providers: { - xiaomi: { - model: "mimo-v2.5-tts-voicedesign", - format: "wav", - style: "Warm, natural female voice with clear pronunciation.", - }, + tts: { + provider: "xiaomi", + providers: { + xiaomi: { + model: "mimo-v2.5-tts-voicedesign", + format: "wav", + style: "Warm, natural female voice with clear pronunciation.", }, }, }, diff --git a/docs/refactor/acp.md b/docs/refactor/acp.md index 2c9fa2e15b5f..c91746aabb8f 100644 --- a/docs/refactor/acp.md +++ b/docs/refactor/acp.md @@ -105,12 +105,11 @@ type AcpxProcessLease = { }; ``` -The wrapper process should receive the lease id and gateway instance id in its -environment: +The wrapper process receives the lease id and gateway instance id as portable +arguments: ```sh -OPENCLAW_ACPX_LEASE_ID=... -OPENCLAW_GATEWAY_INSTANCE_ID=... +--openclaw-acpx-lease-id ... --openclaw-gateway-instance-id ... ``` When the platform allows it, verification should prefer live process metadata @@ -119,7 +118,7 @@ that cannot be confused by command quoting: - root PID still exists - live wrapper path is under `wrapperRoot` - process group matches the lease when available -- environment contains the expected lease id when readable +- arguments contain the expected lease id - command hash or executable path matches the lease If the live process cannot be verified, cleanup fails closed. diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index c41a89df5839..75efb6a7ae19 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -48,7 +48,7 @@ Anthropic has confirmed that Claude CLI reuse (including `claude -p`) is a sanct - **Auth profiles**: per-agent, stored in `auth-profiles.json`. - **Environment variables**: for example `OPENAI_API_KEY`, `BRAVE_API_KEY`, `FIRECRAWL_API_KEY`. -- **Config**: `models.providers.*.apiKey`, `plugins.entries.*.config.webSearch.apiKey`, `plugins.entries.firecrawl.config.webFetch.apiKey`, `agents.defaults.memorySearch.*`, `talk.providers.*.apiKey`. +- **Config**: `models.providers.*.apiKey`, `plugins.entries.*.config.webSearch.apiKey`, `plugins.entries.firecrawl.config.webFetch.apiKey`, `memory.search.*`, `talk.providers.*.apiKey`. - **Skills**: `skills.entries..apiKey`, which may export the key to the skill process env. ## Features that can spend keys @@ -65,13 +65,13 @@ Inbound media can be summarized or transcribed via a provider API before the rep ### Image and video generation -`image_generate` and `video_generate` route to whichever configured provider is available. Image generation can infer an auth-backed provider default when `agents.defaults.imageGenerationModel` is unset; video generation requires an explicit `agents.defaults.videoGenerationModel` (for example `qwen/wan2.6-t2v`). +`image_generate` and `video_generate` route to whichever authenticated provider is available. Both can infer an auth-backed provider default when their `agents.defaults.mediaModels` entry is unset. See [Image generation](/tools/image-generation) and [Video generation](/tools/video-generation) for the current provider list. ### Memory embeddings and semantic search -Semantic memory search uses embedding APIs when `agents.defaults.memorySearch.provider` names a remote adapter (for example `openai`, `gemini`, `voyage`, `mistral`, `deepinfra`, `github-copilot`, `amazon-bedrock`). `memorySearch.provider = "lmstudio"` or `"ollama"` runs against a local/self-hosted server and typically has no hosted billing. `memorySearch.provider = "local"` keeps everything on-device with no API usage. An optional `memorySearch.fallback` provider can cover local-embedding failures. +Semantic memory search uses embedding APIs when `memory.search.provider` names a remote adapter (for example `openai`, `gemini`, `voyage`, `mistral`, `deepinfra`, `github-copilot`, `amazon-bedrock`). `memory.search.provider = "lmstudio"` or `"ollama"` runs against a local/self-hosted server and typically has no hosted billing. `memory.search.provider = "local"` keeps everything on-device with no API usage. An optional `memory.search.fallback` provider can cover local-embedding failures. See [Memory](/concepts/memory). diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index a6bc25c3cb8c..d1ce46a64aa9 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -29,11 +29,11 @@ This page lists every configuration knob for OpenClaw memory search. For concept -All memory search settings live under `agents.defaults.memorySearch` in `openclaw.json` (or a per-agent `agents.list[].memorySearch` override) unless noted otherwise. +All shared memory settings live under top-level `memory` in `openclaw.json`. Search defaults use `memory.search`; per-agent search overrides use `agents.entries.*.memory.search`. For the recommended personal-agent workflow, use -`memorySearch.rememberAcrossConversations`. Advanced Active Memory targeting, +`memory.search.rememberAcrossConversations`. Advanced Active Memory targeting, model, prompt, and latency controls live under `plugins.entries.active-memory`. See [Active Memory](/concepts/active-memory) for both activation paths, @@ -54,19 +54,20 @@ cross-conversation transcript recall: ```json5 { agents: { - list: [ - { - id: "personal", - memorySearch: { - rememberAcrossConversations: true, + entries: { + personal: { + memory: { + search: { + rememberAcrossConversations: true, + }, }, }, - ], + }, }, } ``` -The value follows normal `agents.defaults.memorySearch` inheritance with a +The value follows normal `memory.search` inheritance with a per-agent override. When unset, it defaults on only if global `session.dmScope` is unset or `"main"` and no binding has a `session.dmScope` override. Any configured DM isolation defaults it off. An explicit `true` or @@ -125,7 +126,7 @@ When `provider` is unset, legacy `provider: "auto"` is present, or `provider: "none"` intentionally selects FTS-only mode, memory recall can still use lexical FTS ranking when embeddings are unavailable. -Explicit non-local providers fail closed. If you set `memorySearch.provider` to +Explicit non-local providers fail closed. If you set `memory.search.provider` to a concrete remote-backed provider such as Bedrock, DeepInfra, Gemini, GitHub Copilot, LM Studio, Mistral, Ollama, OpenAI, Voyage, or an OpenAI-compatible custom provider, and that provider is unavailable at runtime, `memory_search` @@ -135,7 +136,7 @@ provider/auth configuration, switch to a reachable provider, or set ### Custom provider ids -`memorySearch.provider` can point at a custom `models.providers.` entry for memory-specific provider adapters such as `ollama`, or for OpenAI-compatible model APIs such as `openai-responses` / `openai-completions`. OpenClaw resolves that provider's `api` owner for the embedding adapter while preserving the custom provider id for endpoint, auth, and model-prefix handling. This lets multi-GPU or multi-host setups dedicate memory embeddings to a specific local endpoint: +`memory.search.provider` can point at a custom `models.providers.` entry for memory-specific provider adapters such as `ollama`, or for OpenAI-compatible model APIs such as `openai-responses` / `openai-completions`. OpenClaw resolves that provider's `api` owner for the embedding adapter while preserving the custom provider id for endpoint, auth, and model-prefix handling. This lets multi-GPU or multi-host setups dedicate memory embeddings to a specific local endpoint: ```json5 { @@ -149,12 +150,10 @@ provider/auth configuration, switch to a reachable provider, or set }, }, }, - agents: { - defaults: { - memorySearch: { - provider: "ollama-5080", - model: "qwen3-embedding:0.6b", - }, + memory: { + search: { + provider: "ollama-5080", + model: "qwen3-embedding:0.6b", }, }, } @@ -198,15 +197,13 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible ```json5 { - agents: { - defaults: { - memorySearch: { - provider: "openai-compatible", - model: "text-embedding-3-small", - remote: { - baseUrl: "https://api.example.com/v1/", - apiKey: "YOUR_KEY", - }, + memory: { + search: { + provider: "openai-compatible", + model: "text-embedding-3-small", + remote: { + baseUrl: "https://api.example.com/v1/", + apiKey: "YOUR_KEY", }, }, }, @@ -241,18 +238,16 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible ```json5 { - agents: { - defaults: { - memorySearch: { - provider: "openai-compatible", - remote: { - baseUrl: "https://embeddings.example/v1", - apiKey: "${EMBEDDINGS_API_KEY}", - }, - model: "asymmetric-embedder", - queryInputType: "query", - documentInputType: "passage", + memory: { + search: { + provider: "openai-compatible", + remote: { + baseUrl: "https://embeddings.example/v1", + apiKey: "${EMBEDDINGS_API_KEY}", }, + model: "asymmetric-embedder", + queryInputType: "query", + documentInputType: "passage", }, }, } @@ -268,12 +263,10 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible ```json5 { - agents: { - defaults: { - memorySearch: { - provider: "bedrock", - model: "amazon.titan-embed-text-v2:0", - }, + memory: { + search: { + provider: "bedrock", + model: "amazon.titan-embed-text-v2:0", }, }, } @@ -301,7 +294,7 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible Throughput-suffixed variants (e.g., `amazon.titan-embed-text-v1:2:8k`) and region-prefixed inference profile IDs (e.g., `us.amazon.titan-embed-text-v2:0`) inherit the base model's configuration. - **Region:** resolved in this order: the `memorySearch.remote.baseUrl` override, the `models.providers.amazon-bedrock.baseUrl` config, `AWS_REGION`, `AWS_DEFAULT_REGION`, then a default of `us-east-1`. + **Region:** resolved in this order: the `memory.search.remote.baseUrl` override, the `models.providers.amazon-bedrock.baseUrl` config, `AWS_REGION`, `AWS_DEFAULT_REGION`, then a default of `us-east-1`. **Authentication:** OpenClaw checks for `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` or `AWS_BEARER_TOKEN_BEDROCK` first, then falls through to the standard AWS SDK default credential provider chain: @@ -353,75 +346,33 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible -### Inline embedding timeout - - - Override the timeout for inline embedding batches during memory indexing. - -Unset uses the provider default: 600 seconds for local/self-hosted providers such as `local`, `ollama`, and `lmstudio`, and 120 seconds for hosted providers. Increase this when local CPU-bound embedding batches are healthy but slow. - - ---- - ## Indexing behavior -All under `memorySearch.sync` unless noted: - -| Key | Type | Default | Description | -| ------------------------------ | --------- | ------- | --------------------------------------------------------------------- | -| `onSessionStart` | `boolean` | `true` | Sync the memory index when a session starts | -| `onSearch` | `boolean` | `true` | Sync lazily on search after detecting content changes | -| `watch` | `boolean` | `true` | Watch memory files (chokidar) and schedule reindex on changes | -| `sessions.postCompactionForce` | `boolean` | `true` | Force a session reindex after compaction-triggered transcript updates | - ---- +Memory engines own synchronization, batching, watch, and post-compaction +indexing heuristics. OpenClaw keeps these behaviors enabled with maintained +defaults rather than exposing per-install timing switches. ## Hybrid search config -All under `memorySearch.query`: +All under `memory.search.query`: | Key | Type | Default | Description | | ------------ | -------- | ------- | ----------------------------------------- | | `maxResults` | `number` | `6` | Max memory hits returned before injection | | `minScore` | `number` | `0.35` | Minimum relevance score to include a hit | -And under `memorySearch.query.hybrid`: - -| Key | Type | Default | Description | -| --------- | --------- | ------- | ---------------------------------- | -| `enabled` | `boolean` | `true` | Enable hybrid BM25 + vector search | - - - - | Key | Type | Default | Description | - | ------------- | --------- | ------- | --------------------- | - | `mmr.enabled` | `boolean` | `false` | Enable MMR re-ranking | - - - | Key | Type | Default | Description | - | ----------------------- | --------- | ------- | -------------------- | - | `temporalDecay.enabled` | `boolean` | `false` | Enable recency boost | - - Evergreen files (`MEMORY.md`, non-dated files in `memory/`) are never decayed. - - - +Hybrid retrieval remains enabled; MMR and temporal decay remain disabled by +the built-in engine policy. ### Full example ```json5 { - agents: { - defaults: { - memorySearch: { - query: { - maxResults: 6, - minScore: 0.35, - hybrid: { - mmr: { enabled: true }, - temporalDecay: { enabled: true }, - }, - }, + memory: { + search: { + query: { + maxResults: 6, + minScore: 0.35, }, }, }, @@ -438,11 +389,9 @@ And under `memorySearch.query.hybrid`: ```json5 { - agents: { - defaults: { - memorySearch: { - extraPaths: ["../team-docs", "/srv/shared-notes"], - }, + memory: { + search: { + extraPaths: ["../team-docs", "/srv/shared-notes"], }, }, } @@ -450,7 +399,7 @@ And under `memorySearch.query.hybrid`: Paths can be absolute or workspace-relative. Directories are scanned recursively for `.md` files. Symlink handling depends on the active backend: the builtin engine skips symlinks, while QMD follows the underlying QMD scanner behavior. -For agent-scoped cross-agent transcript search, use `agents.list[].memorySearch.qmd.extraCollections` instead of `memory.qmd.paths`. Those extra collections follow the same `{ path, name, pattern? }` shape, but they are merged per agent and can preserve explicit shared names when the path points outside the current workspace. If the same resolved path appears in both `memory.qmd.paths` and `memorySearch.qmd.extraCollections`, QMD keeps the first entry and skips the duplicate. +For agent-scoped cross-agent transcript search, use `agents.entries.*.memory.search.qmd.extraCollections` instead of `memory.qmd.paths`. Those extra collections follow the same `{ path, name, pattern? }` shape, but they are merged per agent and can preserve explicit shared names when the path points outside the current workspace. If the same resolved path appears in both `memory.qmd.paths` and `memory.search.qmd.extraCollections`, QMD keeps the first entry and skips the duplicate. --- @@ -484,33 +433,25 @@ Prevents re-embedding unchanged text during reindex or transcript updates. ## Batch indexing -| Key | Type | Default | Description | -| ----------------------------- | --------- | ------- | -------------------------- | -| `remote.nonBatchConcurrency` | `number` | `4` | Parallel inline embeddings | -| `remote.batch.enabled` | `boolean` | `false` | Enable batch embedding API | -| `remote.batch.concurrency` | `number` | `2` | Parallel batch jobs | -| `remote.batch.wait` | `boolean` | `true` | Wait for batch completion | -| `remote.batch.pollIntervalMs` | `number` | `2000` | Poll interval | -| `remote.batch.timeoutMinutes` | `number` | `60` | Batch timeout | +| Key | Type | Default | Description | +| ---------------------------- | --------- | ------- | -------------------------- | +| `remote.nonBatchConcurrency` | `number` | `4` | Parallel inline embeddings | +| `remote.batch.enabled` | `boolean` | `false` | Enable batch embedding API | Available for `gemini`, `openai`, and `voyage`. OpenAI batch is typically fastest and cheapest for large backfills. -`remote.nonBatchConcurrency` controls inline embedding calls used by local/self-hosted providers and hosted providers when provider batch APIs are not active. Ollama defaults to `1` for non-batch indexing to avoid overwhelming smaller local hosts; set a higher value on larger machines. - -This is separate from `sync.embeddingBatchTimeoutSeconds`, which controls the timeout for inline embedding calls. +Concurrency, polling, and timeout behavior are provider-owned. --- -## Session memory search (experimental) +## Session memory search Index session transcripts and surface them via `memory_search`: -| Key | Type | Default | Description | -| ----------------------------- | ---------- | ------------ | --------------------------------------- | -| `experimental.sessionMemory` | `boolean` | `false` | Enable session indexing | -| `sources` | `string[]` | `["memory"]` | Add `"sessions"` to include transcripts | -| `sync.sessions.deltaBytes` | `number` | `100000` | Byte threshold for reindex | -| `sync.sessions.deltaMessages` | `number` | `50` | Message threshold for reindex | +| Key | Type | Default | Description | +| ----------------------------- | ---------- | ------------ | ---------------------------------------- | +| `rememberAcrossConversations` | `boolean` | `false` | Permit private cross-conversation recall | +| `sources` | `string[]` | `["memory"]` | Add `"sessions"` to include transcripts | Session indexing is opt-in and runs asynchronously. Results can be slightly stale. Session logs live on disk, so treat filesystem access as the trust boundary. @@ -527,8 +468,8 @@ recall is also required and agent-to-agent policy allows it). separate runtime-only authorization limited to same-agent private transcripts during the bounded Active Memory pass. -The examples below place these settings under `agents.defaults`. You can also -apply equivalent `memorySearch` settings in a per-agent override when only one +The examples below place these settings under top-level `memory.search`. You can also +apply equivalent settings in a per-agent `memory.search` override when only one agent should index and search session transcripts. For same-agent gateway-to-DM recall: @@ -537,12 +478,10 @@ For same-agent gateway-to-DM recall: ```json5 { - agents: { - defaults: { - memorySearch: { - experimental: { sessionMemory: true }, - sources: ["memory", "sessions"], - }, + memory: { + search: { + experimental: { sessionMemory: true }, + sources: ["memory", "sessions"], }, }, tools: { @@ -554,16 +493,12 @@ For same-agent gateway-to-DM recall: ```json5 { - agents: { - defaults: { - memorySearch: { - experimental: { sessionMemory: true }, - sources: ["memory", "sessions"], - }, - }, - }, memory: { backend: "qmd", + search: { + experimental: { sessionMemory: true }, + sources: ["memory", "sessions"], + }, qmd: { sessions: { enabled: true }, }, @@ -576,8 +511,7 @@ For same-agent gateway-to-DM recall: -When using QMD, `agents.defaults.memorySearch.experimental.sessionMemory` and -`sources: ["sessions"]` do not by themselves export transcripts into QMD. Set +When using QMD, `sources: ["sessions"]` does not by itself export transcripts into QMD. Set `memory.qmd.sessions.enabled: true` as well. The higher-level `rememberAcrossConversations: true` setting is the exception: it implies the required QMD session export for that agent. Implied exports stay private: @@ -637,33 +571,7 @@ OpenClaw prefers current QMD collection and MCP query shapes, but keeps older QM QMD model overrides stay on the QMD side, not OpenClaw config. If you need to override QMD's models globally, set environment variables such as `QMD_EMBED_MODEL`, `QMD_RERANK_MODEL`, and `QMD_GENERATE_MODEL` in the gateway runtime environment. -### mcporter integration - -All under `memory.qmd.mcporter`. Routes QMD searches through a long-lived `mcporter` MCP daemon instead of spawning `qmd` per query, cutting cold-start overhead for larger models. - -| Key | Type | Default | Description | -| ------------- | --------- | ------- | ---------------------------------------------------------------------- | -| `enabled` | `boolean` | `false` | Route QMD calls through mcporter instead of spawning `qmd` per request | -| `serverName` | `string` | `qmd` | mcporter server name that runs `qmd mcp` with `lifecycle: keep-alive` | -| `startDaemon` | `boolean` | `true` | Automatically start the mcporter daemon when `enabled` is true | - -Requires `mcporter` installed and on PATH, plus a configured mcporter server that runs `qmd mcp`. Keep disabled for simpler local setups where per-query process spawn cost is acceptable. - - - | Key | Type | Default | Description | - | --------------------------- | --------- | -------- | ---------------------------------------- | - | `update.interval` | `string` | `5m` | Refresh interval | - | `update.debounceMs` | `number` | `15000` | Debounce file changes | - | `update.onBoot` | `boolean` | `true` | Refresh when the long-lived QMD manager opens; set false to skip the immediate boot update | - | `update.startup` | `string` | `off` | Optional gateway-start QMD initialization: `off`, `idle`, or `immediate` | - | `update.startupDelayMs` | `number` | `120000` | Delay before `startup: "idle"` refresh runs | - | `update.waitForBootSync` | `boolean` | `false` | Block manager opening until its initial refresh completes | - | `update.embedInterval` | `string` | `60m` | Separate embed cadence | - | `update.commandTimeoutMs` | `number` | `30000` | Timeout for QMD maintenance commands (collection list/add) | - | `update.updateTimeoutMs` | `number` | `120000` | Timeout for each `qmd update` cycle | - | `update.embedTimeoutMs` | `number` | `120000` | Timeout for each `qmd embed` cycle | - | Key | Type | Default | Description | | --------------------------- | -------- | ------- | ------------------------------ | @@ -703,7 +611,7 @@ Requires `mcporter` installed and on PATH, plus a configured mcporter server tha -When gateway-start QMD initialization is enabled, OpenClaw starts QMD only for eligible agents. If `update.onBoot` is true and no interval/embed maintenance is configured, startup uses a one-shot manager for the boot refresh and closes it. If an update or embed interval is configured, startup opens the long-lived QMD manager so it can own the watcher and interval timers; `update.onBoot: false` skips only the immediate boot refresh. +QMD initializes lazily when memory is first used; its adapter owns refresh and embedding schedules. ### Full QMD example @@ -730,7 +638,7 @@ When gateway-start QMD initialization is enabled, OpenClaw starts QMD only for e ## Dreaming -Dreaming is configured under `plugins.entries.memory-core.config.dreaming`, not under `agents.defaults.memorySearch`. +Dreaming is configured under `plugins.entries.memory-core.config.dreaming`, not under `memory.search`. Dreaming runs as one scheduled sweep and uses internal light/deep/REM phases as an implementation detail. diff --git a/docs/reference/prompt-caching.md b/docs/reference/prompt-caching.md index 5f12c7ac2c34..1943dfd9a610 100644 --- a/docs/reference/prompt-caching.md +++ b/docs/reference/prompt-caching.md @@ -42,7 +42,7 @@ Merge order (later wins): 1. `agents.defaults.params` - global default for all models 2. `agents.defaults.models["provider/model"].params` - per-model override -3. `agents.list[].params` - per-agent override, matched by agent id +3. `agents.entries.*.params` - per-agent override, matched by agent id Source: `src/agents/embedded-agent-runner/extra-params.ts` (`resolveExtraParams`). @@ -62,7 +62,7 @@ See [Session pruning](/concepts/session-pruning) for full behavior. ### Heartbeat keep-warm -Heartbeat can keep cache windows warm and reduce repeated cache writes after idle gaps. Configurable globally (`agents.defaults.heartbeat`) or per agent (`agents.list[].heartbeat`). +Heartbeat can keep cache windows warm and reduce repeated cache writes after idle gaps. Configurable globally (`agents.defaults.heartbeat`) or per agent (`agents.entries.*.heartbeat`). ```yaml agents: diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index 02ed63c358be..7ab23006739e 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -36,12 +36,12 @@ The lists below are generated from the source target registry and checked agains - `models.providers.*.request.tls.key` - `models.providers.*.request.tls.passphrase` - `skills.entries.*.apiKey` -- `agents.defaults.memorySearch.remote.apiKey` -- `agents.list[].tts.providers.*.apiKey` -- `agents.list[].memorySearch.remote.apiKey` +- `memory.search.remote.apiKey` +- `agents.entries.*.tts.providers.*.apiKey` +- `agents.entries.*.memory.search.remote.apiKey` - `talk.providers.*.apiKey` - `talk.realtime.providers.*.apiKey` -- `messages.tts.providers.*.apiKey` +- `tts.providers.*.apiKey` - `plugins.entries.acpx.config.mcpServers.*.env.*` - `plugins.entries.brave.config.webSearch.apiKey` - `plugins.entries.codex.config.appServer.authToken` @@ -144,7 +144,6 @@ These credentials are minted, rotated, session-bearing, or OAuth-durable classes [//]: # "secretref-unsupported-list-start" -- `commands.ownerDisplaySecret` - `hooks.token` - `hooks.gmail.pushToken` - `hooks.mappings[].sessionKey` diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index f79d9c832ca1..3fac80077cb7 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -4,7 +4,6 @@ "pathSyntax": "Dot path with \"*\" for map keys and \"[]\" for arrays.", "scope": "Credentials that are strictly user-supplied and not minted/rotated by OpenClaw runtime.", "excludedMutableOrRuntimeManaged": [ - "commands.ownerDisplaySecret", "hooks.token", "hooks.gmail.pushToken", "hooks.mappings[].sessionKey", @@ -16,23 +15,16 @@ ], "entries": [ { - "id": "agents.defaults.memorySearch.remote.apiKey", + "id": "agents.entries.*.memory.search.remote.apiKey", "configFile": "openclaw.json", - "path": "agents.defaults.memorySearch.remote.apiKey", + "path": "agents.entries.*.memory.search.remote.apiKey", "secretShape": "secret_input", "optIn": true }, { - "id": "agents.list[].memorySearch.remote.apiKey", + "id": "agents.entries.*.tts.providers.*.apiKey", "configFile": "openclaw.json", - "path": "agents.list[].memorySearch.remote.apiKey", - "secretShape": "secret_input", - "optIn": true - }, - { - "id": "agents.list[].tts.providers.*.apiKey", - "configFile": "openclaw.json", - "path": "agents.list[].tts.providers.*.apiKey", + "path": "agents.entries.*.tts.providers.*.apiKey", "secretShape": "secret_input", "optIn": true }, @@ -162,19 +154,15 @@ "id": "channels.googlechat.accounts.*.serviceAccount", "configFile": "openclaw.json", "path": "channels.googlechat.accounts.*.serviceAccount", - "refPath": "channels.googlechat.accounts.*.serviceAccountRef", - "secretShape": "sibling_ref", - "optIn": true, - "notes": "Compatibility exception: sibling ref field remains canonical." + "secretShape": "secret_input", + "optIn": true }, { "id": "channels.googlechat.serviceAccount", "configFile": "openclaw.json", "path": "channels.googlechat.serviceAccount", - "refPath": "channels.googlechat.serviceAccountRef", - "secretShape": "sibling_ref", - "optIn": true, - "notes": "Compatibility exception: sibling ref field remains canonical." + "secretShape": "secret_input", + "optIn": true }, { "id": "channels.irc.accounts.*.nickserv.password", @@ -471,9 +459,9 @@ "optIn": true }, { - "id": "messages.tts.providers.*.apiKey", + "id": "memory.search.remote.apiKey", "configFile": "openclaw.json", - "path": "messages.tts.providers.*.apiKey", + "path": "memory.search.remote.apiKey", "secretShape": "secret_input", "optIn": true }, @@ -728,6 +716,13 @@ "path": "talk.realtime.providers.*.apiKey", "secretShape": "secret_input", "optIn": true + }, + { + "id": "tts.providers.*.apiKey", + "configFile": "openclaw.json", + "path": "tts.providers.*.apiKey", + "secretShape": "secret_input", + "optIn": true } ] } diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index fdff113c09ab..8c21aca0078a 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -198,7 +198,7 @@ Compaction summarizes older conversation into a persisted `compaction` entry in Embedded OpenClaw compaction inherits the session thinking level by default. Set `agents.defaults.compaction.thinkingLevel` to use a separate level for summary calls; the runtime clamps it to each concrete compaction model or fallback. Native Codex app-server compaction owns its compact request and cannot accept a per-compaction thinking override, so OpenClaw warns and leaves that setting to Codex. -AGENTS.md section reinjection after compaction is opt-in via `agents.defaults.compaction.postCompactionSections`; when unset or `[]`, OpenClaw does not append AGENTS.md excerpts on top of the compaction summary. +AGENTS.md section reinjection after compaction remains opt-in via `agents.defaults.compaction.postCompactionSections`. Plugins can add other prompt context through `before_prompt_build`. ### Chunk boundaries and tool pairing @@ -282,12 +282,10 @@ Config (`agents.defaults.compaction.memoryFlush`), full reference at [/gateway/c | `model` | unset | exact provider/model override for the flush turn only, for example `ollama/qwen3:8b` | | `softThresholdTokens` | `4000` | gap below the compaction threshold that triggers a flush | | `forceFlushTranscriptBytes` | unset (disabled) | force a flush once the transcript file reaches this byte size (or string like `"2mb"`), even if token counters are stale; `0` disables | -| `prompt` | built-in | user message for the flush turn | -| `systemPrompt` | built-in | extra system prompt appended for the flush turn | Notes: -- The default prompt/system prompt include a `NO_REPLY` hint to suppress delivery. +- The built-in prompt and system prompt include a `NO_REPLY` hint to suppress delivery. - When `model` is set, the flush turn uses that model without inheriting the active session's fallback chain, so local-only housekeeping does not silently fall back to a paid conversation model on failure. - The flush runs once per compaction cycle (tracked in the session row). - The flush runs only for embedded OpenClaw sessions; CLI backends and heartbeat turns skip it. diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 9bf4380cab53..0c8509f78576 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -18,7 +18,7 @@ OpenClaw assembles its own system prompt on every run. It includes: Codex turns get the compact skills block as turn-scoped collaboration developer instructions; other harnesses get it in the normal prompt surface. Bounded by `skills.limits.maxSkillsPromptChars`, with optional per-agent - override at `agents.list[].skillsLimits.maxSkillsPromptChars`. + override at `agents.entries.*.skillsLimits.maxSkillsPromptChars`. - Self-update instructions - Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus @@ -40,8 +40,9 @@ OpenClaw assembles its own system prompt on every run. It includes: daily memory for that first turn, controlled by `agents.defaults.startupContext`. Bare chat `/new` and `/reset` are acknowledged without invoking the model. - - Post-compaction `AGENTS.md` excerpts are separate and require explicit - `agents.defaults.compaction.postCompactionSections` opt-in. + - Post-compaction `AGENTS.md` excerpts require explicit + `agents.defaults.compaction.postCompactionSections` opt-in; plugins can add + other context through `before_prompt_build`. - Time (UTC + user timezone) - Reply tags + heartbeat behavior - Runtime metadata (host/OS/model/thinking) @@ -65,7 +66,7 @@ Everything the model receives counts toward the context limit: Runtime-heavy surfaces have their own explicit caps under `agents.defaults.contextLimits` (per-agent overrides under -`agents.list[].contextLimits`): +`agents.entries.*.contextLimits`): | Key | Purpose | | ------------------------ | ------------------------------------------------------------------------ | @@ -199,7 +200,7 @@ TTL is `1h`, setting the heartbeat interval just under that (e.g., `55m`) can avoid re-caching the full prompt, reducing cache write costs. In multi-agent setups, you can keep one shared model config and tune cache -behavior per agent with `agents.list[].params.cacheRetention`. +behavior per agent with `agents.entries.*.params.cacheRetention`. For a full knob-by-knob guide, see [Prompt Caching](/reference/prompt-caching). @@ -244,7 +245,7 @@ agents: cacheRetention: "none" # avoid cache writes for bursty notifications ``` -`agents.list[].params` merges on top of the selected model's `params`, so you +`agents.entries.*.params` merges on top of the selected model's `params`, so you can override only `cacheRetention` and inherit other model defaults unchanged. diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 340fab43e0c4..79156d4e88ef 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -251,7 +251,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `wizard.lastRunMode` - `wizard.securityAcknowledgedAt` -`openclaw agents add` writes `agents.list[]` and optional `bindings`. +`openclaw agents add` writes `agents.entries.*` and optional `bindings`. WhatsApp credentials go under `~/.openclaw/credentials/whatsapp//`. Active sessions and transcripts are stored in diff --git a/docs/security/network-proxy.md b/docs/security/network-proxy.md index 6b12534037a9..3a574e520874 100644 --- a/docs/security/network-proxy.md +++ b/docs/security/network-proxy.md @@ -14,21 +14,19 @@ OpenClaw does not ship, download, start, configure, or certify a proxy. You run ```yaml proxy: - enabled: true proxyUrl: http://127.0.0.1:3128 ``` -You can also set the URL through the environment while `proxy.enabled: true` stays in config: +You can also set the URL through the environment: ```bash OPENCLAW_PROXY_URL=http://127.0.0.1:3128 openclaw gateway run ``` -`proxy.proxyUrl` takes precedence over `OPENCLAW_PROXY_URL`. If `proxy.enabled` is `true` but no valid URL resolves, protected commands fail startup rather than falling back to direct network access. +`proxy.proxyUrl` takes precedence over `OPENCLAW_PROXY_URL`. A configured URL activates managed proxy routing; removing both URLs disables it. | Key | Type | Default | Notes | | -------------------- | ------------------------------------ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| `proxy.enabled` | boolean | unset | Must be `true` to activate routing. | | `proxy.proxyUrl` | string | unset | `http://` or `https://` forward proxy URL. Credentials embedded in the URL are treated as sensitive and redacted from snapshots/logs. | | `proxy.tls.caFile` | string | unset | CA bundle for verifying an `https://` proxy endpoint signed by a private CA. | | `proxy.loopbackMode` | `gateway-only` \| `proxy` \| `block` | `gateway-only` | Controls loopback bypass behavior; see below. | @@ -36,7 +34,6 @@ OPENCLAW_PROXY_URL=http://127.0.0.1:3128 openclaw gateway run For managed gateway services, store the URL in config so it survives reinstall, rather than relying on foreground env: ```bash -openclaw config set proxy.enabled true openclaw config set proxy.proxyUrl http://127.0.0.1:3128 openclaw gateway install --force openclaw gateway start @@ -48,7 +45,6 @@ The `OPENCLAW_PROXY_URL` env fallback is best for foreground runs. To use it wit ```yaml proxy: - enabled: true proxyUrl: https://proxy.corp.example:8443 tls: caFile: /etc/openclaw/proxy-ca.pem @@ -57,7 +53,6 @@ proxy: `proxy.tls.caFile` verifies the proxy endpoint's own TLS certificate. It is not a destination MITM trust setting, a client certificate, or a substitute for the proxy's destination policy. Use `NODE_EXTRA_CA_CERTS` instead only when the entire Node process must trust an additional CA from startup (for example, an enterprise TLS-inspection system re-signing every HTTPS destination certificate) — that variable is process-global and must be set before Node starts, so OpenClaw cannot apply it mid-run the way it applies `proxy.tls.caFile`. Prefer `proxy.tls.caFile` for HTTPS proxy endpoint trust: it is scoped to managed proxy routing instead of the whole process. ```bash -openclaw config set proxy.enabled true openclaw config set proxy.proxyUrl https://proxy.corp.example:8443 openclaw config set proxy.tls.caFile /etc/openclaw/proxy-ca.pem openclaw gateway run @@ -65,7 +60,7 @@ openclaw gateway run ## How routing works -With `proxy.enabled: true` and a valid URL, protected runtime processes (`openclaw gateway run`, `openclaw node run`, `openclaw agent --local`) route normal HTTP and WebSocket egress through the proxy: +With a valid proxy URL, protected runtime processes (`openclaw gateway run`, `openclaw node run`, `openclaw agent --local`) route normal HTTP and WebSocket egress through the proxy: ```text OpenClaw process @@ -91,11 +86,14 @@ Local Gateway control-plane clients normally connect to a loopback WebSocket suc ```yaml proxy: - enabled: true proxyUrl: http://127.0.0.1:3128 loopbackMode: gateway-only # gateway-only, proxy, or block ``` +A configured `proxyUrl` or `OPENCLAW_PROXY_URL` enables managed routing. Set +`proxy.enabled: false` only as an advanced opt-out that keeps the URL stored +without activating it. + | Mode | Behavior | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `gateway-only` (default) | OpenClaw registers the active Gateway loopback authority as a direct-connect exception, so local Gateway WebSocket traffic connects without the proxy. Custom loopback ports work because the exception targets the exact configured host/port. The bundled browser plugin registers the same kind of exception for the exact local CDP readiness and DevTools WebSocket URLs of OpenClaw-launched managed browsers; the bundled Ollama memory embedding provider has a narrower guarded direct path for its exact configured host-local loopback embedding origin. | @@ -150,7 +148,7 @@ openclaw proxy validate --proxy-url https://proxy.corp.example:8443 --proxy-ca-f | `--timeout-ms ` | Per-request timeout. | | `--json` | Machine-readable output. | -If `proxy.enabled` is not `true` and no `--proxy-url` is given, the command reports a config problem instead of validating; pass `--proxy-url` for a one-off preflight before changing config. +If no config, environment, or `--proxy-url` value is available, the command reports a config problem; pass `--proxy-url` for a one-off preflight before changing config. With no `--allowed-url`/`--denied-url`, the default checks are: `https://example.com/` must succeed, and a temporary loopback canary server the proxy must not reach must be blocked. The loopback check passes on a transport failure, or on a non-2xx response that lacks the canary's per-run token; it fails on a 2xx response missing the token (an unexpected success from something other than the canary) and, especially, on any response carrying the matching token, since that proves the proxy actually forwarded a loopback destination it should have denied. Custom `--denied-url` targets have no such canary token, so they are fail-closed: any HTTP response counts as reachable (fail), and a transport error is reported as inconclusive rather than proven-blocked, because OpenClaw cannot confirm your proxy denied a reachable origin versus something else going wrong. `--apns-reachable` sends an intentionally invalid provider token, so a `403 InvalidProviderToken` response counts as proof the tunnel reached Apple. The command exits `1` on any validation failure; proxy URL credentials are redacted from both text and JSON output. diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index d34630323b27..e5eeef841aed 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -189,7 +189,7 @@ openclaw agents add work \ --json ``` -Config keys it writes (`agents.list[]` entry for the new agent id): +Config keys it writes (`agents.entries.*` entry for the new agent id): - `name` - `workspace` diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index ce2febbd05b9..6310d4714d50 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -341,7 +341,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `wizard.lastRunMode` - `wizard.securityAcknowledgedAt` -`openclaw agents add` writes `agents.list[]` and optional `bindings`. +`openclaw agents add` writes `agents.entries.*` and optional `bindings`. WhatsApp credentials go under `~/.openclaw/credentials/whatsapp//`. Active sessions and transcripts are stored in diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 44043d9742c0..91bc50e812c1 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -197,9 +197,9 @@ not the full `openclaw onboard` wizard. What it sets: -- `agents.list[].name` -- `agents.list[].workspace` -- `agents.list[].agentDir` +- `agents.entries.*.name` +- `agents.entries.*.workspace` +- `agents.entries.*.agentDir` Notes: diff --git a/docs/tools/acp-agents-setup.md b/docs/tools/acp-agents-setup.md index 8fdaa88675a0..e6f0663a3e15 100644 --- a/docs/tools/acp-agents-setup.md +++ b/docs/tools/acp-agents-setup.md @@ -96,7 +96,7 @@ Core ACP baseline: } ``` -Thread binding config is channel-adapter specific. Example for Discord: +Thread binding config is shared across supported channel adapters: ```json5 { @@ -105,15 +105,7 @@ Thread binding config is channel-adapter specific. Example for Discord: enabled: true, idleHours: 24, maxAgeHours: 0, - }, - }, - channels: { - discord: { - threadBindings: { - enabled: true, - // Default is already true; shown explicitly here. - spawnSessions: true, - }, + spawnSessions: true, }, }, } @@ -121,7 +113,7 @@ Thread binding config is channel-adapter specific. Example for Discord: If thread-bound ACP spawn does not work, verify the adapter feature flag first: -- Discord: `channels.discord.threadBindings.spawnSessions=true` +- Discord: `session.threadBindings.spawnSessions=true` Current-conversation binds do not require child-thread creation. They require an active conversation context and a channel adapter that exposes ACP conversation bindings. diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 77b7c89f00b6..78be27d1b050 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -117,7 +117,7 @@ harness in the same sense as the others above. Custom acpx agent aliases can be configured in acpx itself, but OpenClaw policy still checks `acp.allowedAgents` and any -`agents.list[].runtime.acp.agent` mapping before dispatch. +`agents.entries.*.runtime.acp.agent` mapping before dispatch. ## Operator runbook @@ -211,7 +211,7 @@ Quick `/acp` flow from chat: calls. It targets ACP harness ids such as `codex`, `claude`, `droid`, `gemini`, or `opencode`. Do not pass a normal OpenClaw config agent id from `agents_list` unless that entry is explicitly configured with - `agents.list[].runtime.type="acp"`; otherwise use the default sub-agent + `agents.entries.*.runtime.type="acp"`; otherwise use the default sub-agent runtime. When an OpenClaw agent is configured with `runtime.type="acp"`, OpenClaw uses `runtime.acp.agent` as the underlying harness id. @@ -305,8 +305,7 @@ Examples: - `acp.enabled=true` - `acp.dispatch.enabled` is on by default (set `false` to pause automatic ACP thread dispatch; explicit `sessions_spawn({ runtime: "acp" })` calls still work). - Channel-adapter thread session spawns enabled (default: `true`): - - Discord: `channels.discord.threadBindings.spawnSessions=true` - - Telegram: `channels.telegram.threadBindings.spawnSessions=true` + - Discord/Telegram: `session.threadBindings.spawnSessions=true` Thread binding support is adapter-specific. If the active channel adapter does not support thread bindings, OpenClaw returns a clear @@ -359,18 +358,18 @@ For non-ephemeral workflows, configure persistent ACP bindings in top-level ### Runtime defaults per agent -Use `agents.list[].runtime` to define ACP defaults once per agent: +Use `agents.entries.*.runtime` to define ACP defaults once per agent: -- `agents.list[].runtime.type="acp"` -- `agents.list[].runtime.acp.agent` (harness id, e.g. `codex` or `claude`) -- `agents.list[].runtime.acp.backend` -- `agents.list[].runtime.acp.mode` -- `agents.list[].runtime.acp.cwd` +- `agents.entries.*.runtime.type="acp"` +- `agents.entries.*.runtime.acp.agent` (harness id, e.g. `codex` or `claude`) +- `agents.entries.*.runtime.acp.backend` +- `agents.entries.*.runtime.acp.mode` +- `agents.entries.*.runtime.acp.cwd` **Override precedence for ACP bound sessions:** 1. `bindings[].acp.*` -2. `agents.list[].runtime.acp.*` +2. `agents.entries.*.runtime.acp.*` 3. Global ACP defaults (e.g. `acp.backend`) ### Example @@ -568,7 +567,7 @@ config-the-default error). before `session/new`; slash forms such as `openai/gpt-5.4/high` also set Codex ACP reasoning effort. When omitted, `sessions_spawn({ runtime: "acp" })` uses existing subagent model defaults (`agents.defaults.subagents.model` or - `agents.list[].subagents.model`) when configured; otherwise it lets the ACP + `agents.entries.*.subagents.model`) when configured; otherwise it lets the ACP harness use its own default model. Other harnesses must advertise ACP `models` and support `session/set_model`; otherwise OpenClaw/acpx fails clearly instead of silently falling back to the target agent default. @@ -610,8 +609,7 @@ config-the-default error). - On non-thread binding surfaces, default behavior is effectively `off`. - Thread-bound spawn requires channel policy support: - - Discord: `channels.discord.threadBindings.spawnSessions=true` - - Telegram: `channels.telegram.threadBindings.spawnSessions=true` + - Discord/Telegram: `session.threadBindings.spawnSessions=true` - Use `--bind here` when you want to pin the current conversation without creating a child thread. diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 31efdeb7ce1c..2a6486906a42 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -83,7 +83,7 @@ stage: } ``` -For a single agent, use `agents.list[].tools.alsoAllow: ["browser"]`. +For a single agent, use `agents.entries.*.tools.alsoAllow: ["browser"]`. `tools.subagents.tools.allow: ["browser"]` alone is not enough because sub-agent policy is applied after profile filtering. diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index 6f7f88d1885b..d87b71c446a5 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -84,8 +84,8 @@ Send `/elevated` with no argument to see the current level. - **Global gate**: `tools.elevated.enabled` (must be `true`) - **Sender allowlist**: `tools.elevated.allowFrom` with per-channel lists -- **Per-agent gate**: `agents.list[].tools.elevated.enabled` (can only further restrict; both the global and per-agent gate must be `true`) -- **Per-agent allowlist**: `agents.list[].tools.elevated.allowFrom` (sender must match both global + per-agent) +- **Per-agent gate**: `agents.entries.*.tools.elevated.enabled` (can only further restrict; both the global and per-agent gate must be `true`) +- **Per-agent allowlist**: `agents.entries.*.tools.elevated.allowFrom` (sender must match both global + per-agent) - **Channel-provided fallback allowlist**: channel plugins can optionally supply a fallback allowlist through an SDK adapter hook, used when `tools.elevated.allowFrom.` is not configured. No bundled channel currently implements this hook, so in practice every provider needs an explicit `tools.elevated.allowFrom.` entry today. - **All gates must pass**; otherwise elevated is treated as unavailable diff --git a/docs/tools/exec-approvals-advanced.md b/docs/tools/exec-approvals-advanced.md index 971a11d768bf..097c467a4c55 100644 --- a/docs/tools/exec-approvals-advanced.md +++ b/docs/tools/exec-approvals-advanced.md @@ -120,9 +120,9 @@ automatically. Configuration location: -- `safeBins` comes from config (`tools.exec.safeBins` or per-agent `agents.list[].tools.exec.safeBins`). -- `safeBinTrustedDirs` comes from config (`tools.exec.safeBinTrustedDirs` or per-agent `agents.list[].tools.exec.safeBinTrustedDirs`). -- `safeBinProfiles` comes from config (`tools.exec.safeBinProfiles` or per-agent `agents.list[].tools.exec.safeBinProfiles`). Per-agent profile keys override global keys. +- `safeBins` comes from config (`tools.exec.safeBins` or per-agent `agents.entries.*.tools.exec.safeBins`). +- `safeBinTrustedDirs` comes from config (`tools.exec.safeBinTrustedDirs` or per-agent `agents.entries.*.tools.exec.safeBinTrustedDirs`). +- `safeBinProfiles` comes from config (`tools.exec.safeBinProfiles` or per-agent `agents.entries.*.tools.exec.safeBinProfiles`). Per-agent profile keys override global keys. - allowlist entries live in the host-local approvals file under `agents..allowlist` (or via Control UI / `openclaw approvals allowlist ...`). - `openclaw security audit` warns with `tools.exec.safe_bins_interpreter_unprofiled` when interpreter/runtime bins appear in `safeBins` without explicit profiles. - `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles.` entries as `{}` (review and tighten afterward). Interpreter/runtime bins are not auto-scaffolded. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 443f36e541f8..b1dca48dd059 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -150,8 +150,8 @@ Example schema: | `auto` | Use allowlist policy, run deterministic matches directly, and send approval misses through OpenClaw's native auto reviewer before falling back to a human approval route. | | `full` | Run host exec without approval prompts. | -Legacy `tools.exec.security` / `tools.exec.ask` remain supported and still -apply wherever `mode` is unset at that scope. +Doctor migrates the retired persisted `tools.exec.security` / `tools.exec.ask` +pair to `tools.exec.mode`. ### `exec.security` @@ -221,7 +221,7 @@ executable. Set globally under `tools.exec.commandHighlighting` or per agent under -`agents.list[].tools.exec.commandHighlighting`. +`agents.entries.*.tools.exec.commandHighlighting`. ## YOLO mode (no-approval) @@ -232,11 +232,10 @@ host-local approvals policy in the execution host approvals file. Omitted `askFallback` defaults to `deny`. Set host `askFallback` to `full` explicitly when a no-UI approval prompt should fall back to allow. -| Layer | YOLO setting | -| --------------------- | -------------------------- | -| `tools.exec.security` | `full` on `gateway`/`node` | -| `tools.exec.ask` | `off` | -| Host `askFallback` | `full` | +| Layer | YOLO setting | +| ------------------ | -------------------------- | +| `tools.exec.mode` | `full` on `gateway`/`node` | +| Host `askFallback` | `full` | **Important distinctions:** @@ -267,8 +266,7 @@ If you want a more conservative setup, tighten OpenClaw exec policy back to ```bash openclaw config set tools.exec.host gateway - openclaw config set tools.exec.security full - openclaw config set tools.exec.ask off + openclaw config set tools.exec.mode full openclaw gateway restart ``` diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 6665c4590acc..baee86a8af50 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -32,7 +32,7 @@ Auto-background the command after this delay (ms). Background the command immediately instead of waiting for `yieldMs`. - + Override the configured exec timeout for this call, in seconds. Applies to foreground, background, `yieldMs`, gateway, sandbox, and node `system.run` execution. `timeout: 0` disables the exec process timeout for that call. @@ -45,11 +45,11 @@ Where to execute. `auto` resolves to `sandbox` when a sandbox runtime is active -Ignored for normal tool calls. `gateway`/`node` security is controlled by `tools.exec.security` and the host approvals file; elevated mode can force `security=full` only when the operator explicitly grants elevated access. +Ignored for normal tool calls. `gateway`/`node` security is derived from `tools.exec.mode` and the host approvals file; elevated mode can force full access only when the operator explicitly grants elevated access. -The baseline ask mode comes from `tools.exec.ask` and host approvals. For channel-origin model calls, per-call `ask` is ignored when the effective host ask is `off`; otherwise it can only harden to a stricter mode. Trusted internal/API callers that construct exec tools with an explicit `ask` value are unchanged. +The baseline ask mode is derived from `tools.exec.mode` and host approvals. For channel-origin model calls, per-call `ask` is ignored when the effective host ask is `off`; otherwise it can only harden to a stricter mode. @@ -84,26 +84,24 @@ Notes: ## Config -| Key | Default | Notes | -| ------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `tools.exec.timeoutSec` | `1800` | Default per-command exec timeout in seconds. Per-call `timeout` overrides it; per-call `timeout: 0` disables the exec process timeout. | -| `tools.exec.host` | `auto` | Resolves to `sandbox` when a sandbox runtime is active, `gateway` otherwise. | -| `tools.exec.security` | `deny` for sandbox, `full` for gateway/node when unset | | -| `tools.exec.ask` | `off` | | -| `tools.exec.mode` | unset | Normalized policy knob. See [Modes](#modes) below. Cannot be combined with `tools.exec.security`/`tools.exec.ask`. | -| `tools.exec.reviewer.model` | configured agent primary | Optional provider/model override for `mode=auto` review. | -| `tools.exec.reviewer.timeoutMs` | `30000` | Per-stage timeout for reviewer model preparation and completion before human fallback. | -| `tools.exec.node` | unset | | -| `tools.exec.notifyOnExit` | `true` | When true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit. | -| `tools.exec.approvalRunningNoticeMs` | `10000` | Emit a single "running" notice when an approval-gated exec runs longer than this (`0` disables). | -| `tools.exec.strictInlineEval` | `false` | See [Inline eval](#inline-eval-strictinlineeval). | -| `tools.exec.commandHighlighting` | `false` | When true, approval prompts can highlight parser-derived command spans in the command text. Set globally or per agent; does not change approval policy. | -| `tools.exec.pathPrepend` | unset | List of directories to prepend to `PATH` for exec runs (gateway + sandbox only). | -| `tools.exec.safeBins` | unset | Stdin-only safe binaries that can run without explicit allowlist entries. See [Safe bins](/tools/exec-approvals-advanced#safe-bins-stdin-only). | -| `tools.exec.safeBinTrustedDirs` | `/bin`, `/usr/bin` | Additional explicit directories trusted for `safeBins` path checks. `PATH` entries are never auto-trusted. | -| `tools.exec.safeBinProfiles` | unset | Optional custom argv policy per safe bin (`minPositional`, `maxPositional`, `allowedValueFlags`, `deniedFlags`). | +| Key | Default | Notes | +| ------------------------------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tools.exec.timeoutSeconds` | `1800` | Default per-command exec timeout in seconds. Per-call `timeout` overrides it; per-call `timeout: 0` disables the exec process timeout. | +| `tools.exec.host` | `auto` | Resolves to `sandbox` when a sandbox runtime is active, `gateway` otherwise. | +| `tools.exec.mode` | host-derived | Canonical policy knob. See [Modes](#modes) below. | +| `tools.exec.reviewer.model` | configured agent primary | Optional provider/model override for `mode=auto` review. | +| `tools.exec.reviewer.timeoutMs` | `30000` | Per-stage timeout for reviewer model preparation and completion before human fallback. | +| `tools.exec.node` | unset | | +| `tools.exec.notifyOnExit` | `true` | When true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit. | +| `tools.exec.approvalRunningNoticeMs` | `10000` | Emit a single "running" notice when an approval-gated exec runs longer than this (`0` disables). | +| `tools.exec.strictInlineEval` | `false` | See [Inline eval](#inline-eval-strictinlineeval). | +| `tools.exec.commandHighlighting` | `false` | When true, approval prompts can highlight parser-derived command spans in the command text. Set globally or per agent; does not change approval policy. | +| `tools.exec.pathPrepend` | unset | List of directories to prepend to `PATH` for exec runs (gateway + sandbox only). | +| `tools.exec.safeBins` | unset | Stdin-only safe binaries that can run without explicit allowlist entries. See [Safe bins](/tools/exec-approvals-advanced#safe-bins-stdin-only). | +| `tools.exec.safeBinTrustedDirs` | `/bin`, `/usr/bin` | Additional explicit directories trusted for `safeBins` path checks. `PATH` entries are never auto-trusted. | +| `tools.exec.safeBinProfiles` | unset | Optional custom argv policy per safe bin (`minPositional`, `maxPositional`, `allowedValueFlags`, `deniedFlags`). | -No-approval host exec is the default for gateway and node (`security=full`, `ask=off`) — this comes from the host-policy defaults, not from `host=auto`. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host approvals file; see [Exec approvals](/tools/exec-approvals#yolo-mode-no-approval). To force gateway or node routing regardless of sandbox state, set `tools.exec.host` or use `/exec host=...`. +No-approval host exec is the default for gateway and node (`mode=full`) — this comes from the host-policy defaults, not from `host=auto`. If you want approvals/allowlist behavior, set `tools.exec.mode` and tighten the host approvals file; see [Exec approvals](/tools/exec-approvals#yolo-mode-no-approval). To force gateway or node routing regardless of sandbox state, set `tools.exec.host` or use `/exec host=...`. Example: @@ -119,7 +117,7 @@ Example: ### Modes -`tools.exec.mode` is the normalized policy knob. Setting it derives `security`/`ask` and cannot be combined with explicit `tools.exec.security`/`tools.exec.ask`. +`tools.exec.mode` is the canonical persisted policy knob. Runtime security and approval behavior are derived from it. | Mode | security | ask | Behavior | | ----------- | ----------- | --------- | ------------------------------------------------------------------------------------------------------------------------------ | @@ -129,7 +127,7 @@ Example: | `auto` | `allowlist` | `on-miss` | Allowlist/safe-bin matches run directly; everything else routes through OpenClaw's native auto reviewer before asking a human. | | `full` | `full` | `off` | No approval gate. | -`ask`/`ask=always` still asks a human every time regardless of mode. +Per-session `/exec ask=always` still asks a human every time regardless of the persisted mode. Auto-review approval is single-use. On the gateway, OpenClaw supplies the resolved executable path to the reviewer and pins execution to that same path. Commands that cannot be reduced to one enforceable execution plan—such as heredocs, shell expansions, or unsupported wrapper quoting—fall back to human approval even if the model would otherwise allow them. @@ -148,11 +146,11 @@ When `tools.exec.strictInlineEval` is `true`, inline interpreter-eval forms requ - `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`. OpenClaw prepends `env.PATH` after profile sourcing via an internal env var (no shell interpolation); `tools.exec.pathPrepend` applies here too. - `host=node`: only non-blocked env overrides you pass are sent to the node. `env.PATH` overrides are rejected for host execution and ignored by node hosts. If you need additional PATH entries on a node, configure the node host service environment (systemd/launchd) or install tools in standard locations. -Per-agent node binding (use the agent list index in config): +Per-agent node binding (use the keyed agent ID in config): ```bash -openclaw config get agents.list -openclaw config set 'agents.list[0].tools.exec.node' "node-id-or-name" +openclaw config get agents.entries +openclaw config set 'agents.entries.main.tools.exec.node' "node-id-or-name" ``` Control UI: the **Devices** page includes a small "Exec node binding" panel for the same settings. @@ -167,7 +165,7 @@ Example: /exec host=auto security=allowlist ask=on-miss node=mac-1 ``` -`/exec` is only honored for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`). It updates **session state only** and does not write config. Authorized external channel senders may set these session defaults. Internal gateway/webchat clients need `operator.admin` to persist them. +`/exec` is only honored for **authorized senders** through channel allowlists/pairing and access groups. Access-group enforcement is always on. It updates **session state only** and does not write config. Authorized external channel senders may set these session defaults. Internal gateway/webchat clients need `operator.admin` to persist them. To hard-disable exec, deny it via tool policy (`tools.deny: ["exec"]` or per-agent). Host approvals still apply unless you explicitly set `security=full` and `ask=off`. diff --git a/docs/tools/image-generation.md b/docs/tools/image-generation.md index 75d55344726f..8f489aae613e 100644 --- a/docs/tools/image-generation.md +++ b/docs/tools/image-generation.md @@ -21,7 +21,7 @@ lost. The tool only appears when at least one image-generation provider is available. If you do not see `image_generate` in your agent's tools, -configure `agents.defaults.imageGenerationModel`, set up a provider API key, +configure `agents.defaults.mediaModels.image`, set up a provider API key, or sign in with OpenAI ChatGPT/Codex OAuth. @@ -240,12 +240,11 @@ from each attempt. A provider default only enters the candidate list when OpenClaw can - actually authenticate that provider. Set - `agents.defaults.mediaGenerationAutoProviderFallback: false` to use only - explicit `model`, `primary`, and `fallbacks` entries. + actually authenticate that provider. Automatic fallback across authenticated + providers is always enabled; a per-call `model` remains authoritative. - Set `agents.defaults.imageGenerationModel.timeoutMs` for slow image + Set `agents.defaults.mediaModels.image.timeoutMs` for slow image backends. A per-call `timeoutMs` tool parameter overrides the configured default, and configured defaults override plugin-authored provider defaults. Google and OpenRouter hosted image providers use 180 second diff --git a/docs/tools/index.md b/docs/tools/index.md index d2b420619199..cf0cee212322 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -179,7 +179,7 @@ the current turn: ## Related -- [Automation](/automation) for cron, tasks, heartbeat, commitments, hooks, +- [Automation](/automation) for cron, tasks, heartbeat, hooks, standing orders, and Task Flow - [Agents](/concepts/agent) for the agent model, sessions, memory, and multi-agent coordination diff --git a/docs/tools/loop-detection.md b/docs/tools/loop-detection.md index 734dd6e02ae8..84650c78b7a4 100644 --- a/docs/tools/loop-detection.md +++ b/docs/tools/loop-detection.md @@ -43,7 +43,7 @@ Global setting: } ``` -Per-agent override (optional, at `agents.list[].tools.loopDetection`): +Per-agent override (optional, at `agents.entries.*.tools.loopDetection`): ```json5 { diff --git a/docs/tools/media-overview.md b/docs/tools/media-overview.md index 0f3e6e37418e..edc3f11ef0f4 100644 --- a/docs/tools/media-overview.md +++ b/docs/tools/media-overview.md @@ -38,7 +38,7 @@ telephony, meetings, browser realtime, and native push-to-talk clients. Convert outbound replies to spoken audio via the `tts` tool plus - `messages.tts` config. Synchronous. + `tts` config. Synchronous. Summarize inbound images, audio, and video using vision-capable model diff --git a/docs/tools/multi-agent-sandbox-tools.md b/docs/tools/multi-agent-sandbox-tools.md index 84c68b734784..592991f0cc08 100644 --- a/docs/tools/multi-agent-sandbox-tools.md +++ b/docs/tools/multi-agent-sandbox-tools.md @@ -174,24 +174,24 @@ Auth is scoped by agent: each agent has its own `agentDir` auth store in `~/.ope ## Configuration precedence -When both global (`agents.defaults.*`) and agent-specific (`agents.list[].*`) configs exist: +When both global (`agents.defaults.*`) and agent-specific (`agents.entries.*.*`) configs exist: ### Sandbox config Agent-specific settings override global: ```text -agents.list[].sandbox.mode > agents.defaults.sandbox.mode -agents.list[].sandbox.scope > agents.defaults.sandbox.scope -agents.list[].sandbox.workspaceRoot > agents.defaults.sandbox.workspaceRoot -agents.list[].sandbox.workspaceAccess > agents.defaults.sandbox.workspaceAccess -agents.list[].sandbox.docker.* > agents.defaults.sandbox.docker.* -agents.list[].sandbox.browser.* > agents.defaults.sandbox.browser.* -agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.* +agents.entries.*.sandbox.mode > agents.defaults.sandbox.mode +agents.entries.*.sandbox.scope > agents.defaults.sandbox.scope +agents.entries.*.sandbox.workspaceRoot > agents.defaults.sandbox.workspaceRoot +agents.entries.*.sandbox.workspaceAccess > agents.defaults.sandbox.workspaceAccess +agents.entries.*.sandbox.docker.* > agents.defaults.sandbox.docker.* +agents.entries.*.sandbox.browser.* > agents.defaults.sandbox.browser.* +agents.entries.*.sandbox.prune.* > agents.defaults.sandbox.prune.* ``` -`agents.list[].sandbox.{docker,browser,prune}.*` overrides `agents.defaults.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). +`agents.entries.*.sandbox.{docker,browser,prune}.*` overrides `agents.defaults.sandbox.{docker,browser,prune}.*` for that agent (ignored when sandbox scope resolves to `"shared"`). ### Tool restrictions @@ -200,10 +200,10 @@ The filtering order is: - `tools.profile` or `agents.list[].tools.profile`. + `tools.profile` or `agents.entries.*.tools.profile`. - `tools.byProvider[provider].profile` or `agents.list[].tools.byProvider[provider].profile`. + `tools.byProvider[provider].profile` or `agents.entries.*.tools.byProvider[provider].profile`. `tools.allow` / `tools.deny`. @@ -212,13 +212,13 @@ The filtering order is: `tools.byProvider[provider].allow/deny`. - `agents.list[].tools.allow/deny`. + `agents.entries.*.tools.allow/deny`. - `agents.list[].tools.byProvider[provider].allow/deny`. + `agents.entries.*.tools.byProvider[provider].allow/deny`. - `tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`. + `tools.sandbox.tools` or `agents.entries.*.tools.sandbox.tools`. `tools.subagents.tools`, if applicable. @@ -228,19 +228,19 @@ The filtering order is: - Each level can further restrict tools, but cannot grant back denied tools from earlier levels. - - If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent. - - If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent. + - If `agents.entries.*.tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent. + - If `agents.entries.*.tools.profile` is set, it overrides `tools.profile` for that agent. - Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.4`). - If any explicit allowlist in that chain leaves the run with no callable tools, OpenClaw stops before submitting the prompt to the model. This is intentional: an agent configured with a missing tool such as `agents.list[].tools.allow: ["query_db"]` should fail loudly until the plugin that registers `query_db` is enabled, not continue as a text-only agent. + If any explicit allowlist in that chain leaves the run with no callable tools, OpenClaw stops before submitting the prompt to the model. This is intentional: an agent configured with a missing tool such as `agents.entries.*.tools.allow: ["query_db"]` should fail loudly until the plugin that registers `query_db` is enabled, not continue as a text-only agent. Tool policies support `group:*` shorthands that expand to multiple tools. See [Tool groups](/gateway/sandbox-vs-tool-policy-vs-elevated#tool-groups-shorthands) for the full list. -Per-agent elevated overrides (`agents.list[].tools.elevated`) can further restrict elevated exec for specific agents. See [Elevated mode](/tools/elevated) for details. +Per-agent elevated overrides (`agents.entries.*.tools.elevated`) can further restrict elevated exec for specific agents. See [Elevated mode](/tools/elevated) for details. --- @@ -288,7 +288,7 @@ Per-agent elevated overrides (`agents.list[].tools.elevated`) can further restri -Legacy `agents.defaults.*`/`agents.list[].*` config keys (such as `sandbox.perSession`, `agentRuntime`, `embeddedPi`) are migrated by `openclaw doctor`; prefer `agents.defaults` + `agents.list` going forward. +Legacy `agents.defaults.*`/`agents.entries.*.*` config keys (such as `sandbox.perSession`, `agentRuntime`, `embeddedPi`) are migrated by `openclaw doctor`; prefer `agents.defaults` + `agents.entries` going forward. --- @@ -342,7 +342,7 @@ Legacy `agents.defaults.*`/`agents.list[].*` config keys (such as `sandbox.perSe ## Common pitfall: "non-main" -`agents.defaults.sandbox.mode: "non-main"` checks the session key against the main session key (always `"main"`; `session.mainKey` is not user-configurable, and OpenClaw warns and ignores any other value), not the agent id. Group/channel sessions always get their own keys, so they are treated as non-main and will be sandboxed. If you want an agent to never sandbox, set `agents.list[].sandbox.mode: "off"`. +`agents.defaults.sandbox.mode: "non-main"` checks the session key against the main session key (always `"main"`; `session.mainKey` is not user-configurable, and OpenClaw warns and ignores any other value), not the agent id. Group/channel sessions always get their own keys, so they are treated as non-main and will be sandboxed. If you want an agent to never sandbox, set `agents.entries.*.sandbox.mode: "off"`. --- @@ -381,7 +381,7 @@ After configuring multi-agent sandbox and tools: - Check if there's a global `agents.defaults.sandbox.mode` that overrides it. - - Agent-specific config takes precedence, so set `agents.list[].sandbox.mode: "all"`. + - Agent-specific config takes precedence, so set `agents.entries.*.sandbox.mode: "all"`. diff --git a/docs/tools/music-generation.md b/docs/tools/music-generation.md index 9271f6b8399e..1990e66f43e8 100644 --- a/docs/tools/music-generation.md +++ b/docs/tools/music-generation.md @@ -14,7 +14,7 @@ OpenRouter. `music_generate` only appears when at least one music-generation provider is -available: an explicit `agents.defaults.musicGenerationModel` config, or an +available: an explicit `agents.defaults.mediaModels.music` config, or an auth-configured provider (a set API key, for example). @@ -182,7 +182,7 @@ captures any requested-to-applied mapping. Provider request timeouts are operator configuration only. OpenClaw uses -`agents.defaults.musicGenerationModel.timeoutMs` when configured, raises +`agents.defaults.mediaModels.music.timeoutMs` when configured, raises values below 120000ms to 120000ms, and otherwise defaults provider requests to 300000ms. @@ -263,8 +263,8 @@ OpenClaw tries providers in this order: If a provider fails, the next candidate is tried automatically. If all fail, the error includes details from each attempt. -Set `agents.defaults.mediaGenerationAutoProviderFallback: false` to use only -explicit `model`, `primary`, and `fallbacks` entries. +Automatic fallback across authenticated providers is always enabled. A per-call +`model` remains authoritative. ## Provider notes diff --git a/docs/tools/pdf.md b/docs/tools/pdf.md index e6472f4447b9..ad2178274ec3 100644 --- a/docs/tools/pdf.md +++ b/docs/tools/pdf.md @@ -47,7 +47,7 @@ Optional model override in `provider/model` form. -Per-PDF size cap in MB. Defaults to `agents.defaults.pdfMaxBytesMb`, or `10` if unset. +Per-PDF size cap in MB. Defaults to `agents.defaults.pdfMaxMb`, or `10` if unset. Notes: @@ -107,11 +107,11 @@ Details: } ``` -| Key | Default | Meaning | -| ------------------------------- | ------- | ----------------------------------------------------------------------------------------- | -| `agents.defaults.pdfModel` | unset | Explicit primary/fallback PDF models; falls back to `imageModel`, then the session model. | -| `agents.defaults.pdfMaxBytesMb` | `10` | Per-PDF size cap in MB. | -| `agents.defaults.pdfMaxPages` | `20` | Max pages processed per PDF. | +| Key | Default | Meaning | +| ----------------------------- | ------- | ----------------------------------------------------------------------------------------- | +| `agents.defaults.pdfModel` | unset | Explicit primary/fallback PDF models; falls back to `imageModel`, then the session model. | +| `agents.defaults.pdfMaxMb` | `10` | Per-PDF size cap in MB. | +| `agents.defaults.pdfMaxPages` | `20` | Max pages processed per PDF. | See [Configuration Reference](/gateway/config-agents#agent-defaults) for full field details. diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index c41ffeb7990b..8c96cfe564c8 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -10,7 +10,7 @@ read_when: Most skills configuration lives under `skills` in `~/.openclaw/openclaw.json`. Agent-specific visibility lives under -`agents.defaults.skills` and `agents.list[].skills`. +`agents.defaults.skills` and `agents.entries.*.skills`. ```json5 { @@ -48,7 +48,7 @@ Most skills configuration lives under `skills` in ``` - For built-in image generation, use `agents.defaults.imageGenerationModel` + For built-in image generation, use `agents.defaults.mediaModels.image` plus the core `image_generate` tool instead of `skills.entries`. Skill entries are for custom or third-party skill workflows only. @@ -317,11 +317,11 @@ different visible skill set per agent. Shared baseline allowlist inherited by agents that omit - `agents.list[].skills`. Omit entirely to leave skills unrestricted by + `agents.entries.*.skills`. Omit entirely to leave skills unrestricted by default. - + Explicit final skill set for that agent. Explicit lists **replace** inherited defaults — they do not merge. Set to `[]` to expose no skills for that agent. diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 069c2b22ff19..c1a9e205ab2e 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -113,9 +113,9 @@ regardless of where they are loaded from. - Omit `agents.defaults.skills` to leave all skills unrestricted by default. - - Omit `agents.list[].skills` to inherit `agents.defaults.skills`. - - Set `agents.list[].skills: []` to expose no skills for that agent. - - A non-empty `agents.list[].skills` list is the **final** set — it does not + - Omit `agents.entries.*.skills` to inherit `agents.defaults.skills`. + - Set `agents.entries.*.skills: []` to expose no skills for that agent. + - A non-empty `agents.entries.*.skills` list is the **final** set — it does not merge with defaults. - The effective allowlist applies across prompt building, slash-command discovery, sandbox sync, and skill snapshots. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index f1e0a68ce4c1..8e5279550a6a 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -44,7 +44,7 @@ command handling is enabled for the surface. do **not** persist session settings. - Directives only apply for **authorized senders**. If `commands.allowFrom` is set, it is the only allowlist used; otherwise authorization comes from - channel allowlists/pairing plus `commands.useAccessGroups`. Unauthorized + channel allowlists, pairing, and always-on access-group enforcement. Unauthorized senders see directives treated as plain text. @@ -150,10 +150,6 @@ command handling is enabled for the surface. global default; provider-specific keys override it. - - Enforces allowlists/policies for commands when `commands.allowFrom` is not set. - - ## Command list Commands come from three sources: @@ -383,10 +379,10 @@ aliases do not restrict selection. ```text /config show -/config show messages.responsePrefix -/config get messages.responsePrefix -/config set messages.responsePrefix="[openclaw]" -/config unset messages.responsePrefix +/config show channels.whatsapp.responsePrefix +/config get channels.whatsapp.responsePrefix +/config set channels.whatsapp.responsePrefix="[openclaw]" +/config unset channels.whatsapp.responsePrefix ``` Config is validated before write. Invalid changes are rejected. `/config` @@ -421,9 +417,9 @@ chat. ```text /debug show -/debug set messages.responsePrefix="[openclaw]" +/debug set channels.whatsapp.responsePrefix="[openclaw]" /debug set channels.whatsapp.allowFrom=["+1555","+4477"] -/debug unset messages.responsePrefix +/debug unset channels.whatsapp.responsePrefix /debug reset ``` diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index 44b4ab896839..75ff4aff50c5 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -109,7 +109,7 @@ agent decides whether a user-facing update is needed. - For persistent thread-bound sessions, use `sessions_spawn` with `thread: true` and `mode: "session"`. - If the requester channel does not support thread bindings, use `mode: "run"` instead of retrying an impossible thread-bound combination. - For ACP harness sessions (Claude Code, Gemini CLI, OpenCode, or explicit Codex ACP/acpx), use `sessions_spawn` with `runtime: "acp"` when the tool advertises that runtime. See [ACP delivery model](/tools/acp-agents#delivery-model) when debugging completions or agent-to-agent loops. When the `codex` plugin is enabled, Codex chat/thread control should prefer `/codex ...` over ACP unless the user explicitly asks for ACP/acpx. - - OpenClaw hides `runtime: "acp"` until ACP is enabled, the requester is not sandboxed, and a backend plugin such as `acpx` is loaded. `runtime: "acp"` expects an external ACP harness id, or an `agents.list[]` entry with `runtime.type="acp"`; use the default sub-agent runtime for normal OpenClaw config agents from `agents_list`. + - OpenClaw hides `runtime: "acp"` until ACP is enabled, the requester is not sandboxed, and a backend plugin such as `acpx` is loaded. `runtime: "acp"` expects an external ACP harness id, or an `agents.entries.*` entry with `runtime.type="acp"`; use the default sub-agent runtime for normal OpenClaw config agents from `agents_list`. @@ -145,8 +145,8 @@ session to confirm the effective tool list. **Defaults:** -- **Model:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`). ACP runtime spawns use the same configured subagent model when present; otherwise the ACP harness keeps its own default. An explicit `sessions_spawn.model` still wins. -- **Thinking:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.list[].subagents.thinking`). ACP runtime spawns also apply `agents.defaults.models["provider/model"].params.thinking` for the selected model. An explicit `sessions_spawn.thinking` still wins. +- **Model:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.entries.*.subagents.model`). ACP runtime spawns use the same configured subagent model when present; otherwise the ACP harness keeps its own default. An explicit `sessions_spawn.model` still wins. +- **Thinking:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.entries.*.subagents.thinking`). ACP runtime spawns also apply `agents.defaults.models["provider/model"].params.thinking` for the selected model. An explicit `sessions_spawn.thinking` still wins. - **Run timeout:** OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout). `sessions_spawn` does not accept per-call timeout overrides. - **Process lifetime:** a detached OpenClaw sub-agent has its own run lifecycle. A background task created inside an external CLI backend is different: it shares the parent CLI subprocess and stops if that parent reaches `agents.defaults.timeoutSeconds`. - **Task delivery:** native sub-agents receive the delegated task in their first visible `[Subagent Task]` message. The sub-agent system prompt carries runtime rules and routing context, not a hidden duplicate of the task. @@ -162,7 +162,7 @@ in the tool result: `resolvedModel` contains the applied model ref and - `suggest` (default): keep the standard prompt nudge to use sub-agents for larger or slower work. - `prefer`: tell the main agent to stay responsive and delegate anything more involved than a direct reply through `sessions_spawn`. -Per-agent override: `agents.list[].subagents.delegationMode`. +Per-agent override: `agents.entries.*.subagents.delegationMode`. ```json5 { @@ -201,7 +201,7 @@ Per-agent override: `agents.list[].subagents.delegationMode`. Optional task working directory for the child run. Native sub-agents still load bootstrap files from the target agent workspace; `cwd` only changes where runtime tools and CLI harnesses do the delegated work. - `acp` is only for external ACP harnesses (`claude`, `droid`, `gemini`, `opencode`, or explicitly requested Codex ACP/acpx) and for `agents.list[]` entries whose `runtime.type` is `acp`. + `acp` is only for external ACP harnesses (`claude`, `droid`, `gemini`, `opencode`, or explicitly requested Codex ACP/acpx) and for `agents.entries.*` entries whose `runtime.type` is `acp`. ACP-only. Resumes an existing ACP harness session when `runtime: "acp"`; ignored for native sub-agent spawns. @@ -365,14 +365,14 @@ See [Configuration reference](/gateway/configuration-reference) and ### Allowlist - + List of configured agent ids that can be targeted via explicit `agentId` (`["*"]` allows any configured target). Default: only the requester agent. If you set a list and still want the requester to spawn itself with `agentId`, include the requester id in the list. Default configured target-agent allowlist used when the requester agent does not set its own `subagents.allowAgents`. - Block `sessions_spawn` calls that omit `agentId` (forces explicit profile selection). Per-agent override: `agents.list[].subagents.requireAgentId`. + Block `sessions_spawn` calls that omit `agentId` (forces explicit profile selection). Per-agent override: `agents.entries.*.subagents.requireAgentId`. Per-call timeout for gateway `agent` announce delivery attempts. Values are positive integer milliseconds and are clamped to the platform-safe timer maximum. Transient retries can make the total announce wait longer than one configured timeout. @@ -388,11 +388,11 @@ Use `agents_list` to see which agent ids are currently allowed for model and embedded runtime metadata so callers can distinguish OpenClaw, Codex app-server, and other configured native runtimes. -`allowAgents` entries must point at configured agent ids in `agents.list[]`. +`allowAgents` entries must point at configured agent ids in `agents.entries.*`. `["*"]` means any configured target agent plus the requester. If an agent config is deleted but its id remains in `allowAgents`, `sessions_spawn` rejects that id and `agents_list` omits it. Run `openclaw doctor --fix` to clean stale -allowlist entries, or add a minimal `agents.list[]` entry when the target should +allowlist entries, or add a minimal `agents.entries.*` entry when the target should remain spawnable while inheriting defaults. ### Auto-archive @@ -619,7 +619,7 @@ profile stage: } ``` -Use per-agent `agents.list[].tools.alsoAllow: ["browser"]` when only one +Use per-agent `agents.entries.*.tools.alsoAllow: ["browser"]` when only one agent should get browser automation. ## Concurrency diff --git a/docs/tools/swarm.md b/docs/tools/swarm.md index 8176bc8a4623..2436576c8d05 100644 --- a/docs/tools/swarm.md +++ b/docs/tools/swarm.md @@ -67,7 +67,7 @@ Numeric values must be positive integers. OpenClaw bounds `1`–`86400`. You can override Swarm for one configured agent with -`agents.list[].tools.swarm`. The per-agent object merges over the top-level +`agents.entries.*.tools.swarm`. The per-agent object merges over the top-level `tools.swarm` object. ## Requirements diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index eefc24bb90b1..19a387a41592 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -44,7 +44,7 @@ title: "Thinking levels" 1. Inline directive on the message (applies only to that message). 2. Session override (set by sending a directive-only message). -3. Per-agent default (`agents.list[].thinkingDefault` in config). +3. Per-agent default (`agents.entries.*.thinkingDefault` in config). 4. Global default (`agents.defaults.thinkingDefault` in config). 5. Fallback: provider-declared default when available; otherwise reasoning-capable models resolve to `medium` or the nearest supported non-`off` level for that model, and non-reasoning models stay `off`. @@ -69,7 +69,7 @@ title: "Thinking levels" - OpenClaw resolves fast mode in this order: 1. Inline/directive-only `/fast auto|on|off` override (`/fast default` clears this layer) 2. Session override - 3. Per-agent default (`agents.list[].fastModeDefault`) + 3. Per-agent default (`agents.entries.*.fastModeDefault`) 4. Per-model config: `agents.defaults.models["/"].params.fastMode` 5. Fallback: `off` - `auto` keeps the session/config mode as auto but resolves each new model call independently. Calls that start before the auto cutoff have fast mode enabled; later retry, fallback, tool-result, or continuation calls start with fast mode disabled. The cutoff defaults to 60 seconds; set `agents.defaults.models["/"].params.fastAutoOnSeconds` on the active model to change it. @@ -91,7 +91,7 @@ title: "Thinking levels" - When verbose is on, agents that emit structured tool results send each tool call back as its own metadata-only message, prefixed with ` : ` when available. These tool summaries are sent as soon as each tool starts (separate bubbles), not as streaming deltas. - Tool failure summaries remain visible in normal mode, but raw error detail suffixes are hidden unless verbose is `full`. - When verbose is `full`, tool outputs are also forwarded after completion (separate bubble, truncated to a safe length). If you toggle `/verbose on|full|off` while a run is in-flight, subsequent tool bubbles honor the new setting. -- `agents.defaults.toolProgressDetail` controls the shape of `/verbose` tool summaries and progress-draft tool lines. Use `"explain"` (default) for compact human labels such as `🛠️ Exec: checking JS syntax`; use `"raw"` when you also want the raw command/detail appended for debugging. Per-agent `agents.list[].toolProgressDetail` overrides the default. +- `agents.defaults.toolProgressDetail` controls the shape of `/verbose` tool summaries and progress-draft tool lines. Use `"explain"` (default) for compact human labels such as `🛠️ Exec: checking JS syntax`; use `"raw"` when you also want the raw command/detail appended for debugging. Per-agent `agents.entries.*.toolProgressDetail` overrides the default. - `explain`: `🛠️ Exec: check JS syntax for /tmp/app.js` - `raw`: `🛠️ Exec: check JS syntax for /tmp/app.js, node --check /tmp/app.js` @@ -112,7 +112,7 @@ title: "Thinking levels" - `stream`: streams reasoning while the reply is generating when the active channel supports reasoning previews, then sends the final answer without reasoning. - Alias: `/reason`. - Send `/reasoning` (or `/reasoning:`) with no argument to see the current reasoning level. -- Resolution order: inline directive, then session override, then per-agent default (`agents.list[].reasoningDefault`), then global default (`agents.defaults.reasoningDefault`), then fallback (`off`). +- Resolution order: inline directive, then session override, then per-agent default (`agents.entries.*.reasoningDefault`), then global default (`agents.defaults.reasoningDefault`), then fallback (`off`). Malformed local-model reasoning tags are handled conservatively. Closed `...` blocks stay hidden on normal replies, and unclosed reasoning after already visible text is also hidden. If a reply is fully wrapped in a single unclosed opening tag and would otherwise deliver as empty text, OpenClaw removes the malformed opening tag and delivers the remaining text. @@ -123,7 +123,7 @@ Malformed local-model reasoning tags are handled conservatively. Closed ` ## Heartbeats - Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats). -- Heartbeat delivery defaults to the final payload only. To also send the separate `Thinking` message (when available), set `agents.defaults.heartbeat.includeReasoning: true` or per-agent `agents.list[].heartbeat.includeReasoning: true`. +- Heartbeat delivery defaults to the final payload only. To also send the separate `Thinking` message (when available), set `agents.defaults.heartbeat.includeReasoning: true` or per-agent `agents.entries.*.heartbeat.includeReasoning: true`. ## Web chat UI diff --git a/docs/tools/tts.md b/docs/tools/tts.md index 73191fa838d9..00b5b2293019 100644 --- a/docs/tools/tts.md +++ b/docs/tools/tts.md @@ -30,15 +30,13 @@ synthesize an assistant voice reply. `ELEVENLABS_API_KEY`). Microsoft and Local CLI need no key. - Set `messages.tts.auto: "always"` and `messages.tts.provider`: + Set `tts.auto: "always"` and `tts.provider`: ```json5 { - messages: { - tts: { - auto: "always", - provider: "elevenlabs", - }, + tts: { + auto: "always", + provider: "elevenlabs", }, } ``` @@ -51,7 +49,7 @@ synthesize an assistant voice reply. -Auto-TTS is **off** by default. When `messages.tts.provider` is unset, +Auto-TTS is **off** by default. When `tts.provider` is unset, OpenClaw picks the first configured provider in registry auto-select order. The built-in `tts` agent tool is explicit-intent only: ordinary chat stays text unless the user asks for audio, uses `/tts`, or enables Auto-TTS/directive @@ -93,7 +91,7 @@ config; new configs should always use `microsoft`. ## Configuration -TTS config lives under `messages.tts` in `~/.openclaw/openclaw.json`. Pick a +TTS config lives under `tts` in `~/.openclaw/openclaw.json`. Pick a preset and adapt the provider block. The `speakerVoice`/`speakerVoiceId` fields shown below are canonical; each provider's own `voice`/`voiceId`/ `voiceName` field names still work as legacy aliases. @@ -102,19 +100,17 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "azure-speech", - providers: { - "azure-speech": { - apiKey: "${AZURE_SPEECH_KEY}", - region: "eastus", - speakerVoice: "en-US-JennyNeural", - lang: "en-US", - outputFormat: "audio-24khz-48kbitrate-mono-mp3", - voiceNoteOutputFormat: "ogg-24khz-16bit-mono-opus", - }, + tts: { + auto: "always", + provider: "azure-speech", + providers: { + "azure-speech": { + apiKey: "${AZURE_SPEECH_KEY}", + region: "eastus", + speakerVoice: "en-US-JennyNeural", + lang: "en-US", + outputFormat: "audio-24khz-48kbitrate-mono-mp3", + voiceNoteOutputFormat: "ogg-24khz-16bit-mono-opus", }, }, }, @@ -124,16 +120,14 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "elevenlabs", - providers: { - elevenlabs: { - apiKey: "${ELEVENLABS_API_KEY}", - model: "eleven_multilingual_v2", - speakerVoiceId: "EXAVITQu4vr4xnSDxMaL", - }, + tts: { + auto: "always", + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "${ELEVENLABS_API_KEY}", + model: "eleven_multilingual_v2", + speakerVoiceId: "EXAVITQu4vr4xnSDxMaL", }, }, }, @@ -143,19 +137,17 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "google", - providers: { - google: { - apiKey: "${GEMINI_API_KEY}", - model: "gemini-3.1-flash-tts-preview", - speakerVoice: "Kore", - // Optional natural-language style prompts: - // audioProfile: "Speak in a calm, podcast-host tone.", - // speakerName: "Alex", - }, + tts: { + auto: "always", + provider: "google", + providers: { + google: { + apiKey: "${GEMINI_API_KEY}", + model: "gemini-3.1-flash-tts-preview", + speakerVoice: "Kore", + // Optional natural-language style prompts: + // audioProfile: "Speak in a calm, podcast-host tone.", + // speakerName: "Alex", }, }, }, @@ -165,15 +157,13 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "gradium", - providers: { - gradium: { - apiKey: "${GRADIUM_API_KEY}", - speakerVoiceId: "YTpq7expH9539ERJ", - }, + tts: { + auto: "always", + provider: "gradium", + providers: { + gradium: { + apiKey: "${GRADIUM_API_KEY}", + speakerVoiceId: "YTpq7expH9539ERJ", }, }, }, @@ -183,17 +173,15 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "inworld", - providers: { - inworld: { - apiKey: "${INWORLD_API_KEY}", - modelId: "inworld-tts-1.5-max", - speakerVoiceId: "Sarah", - temperature: 0.7, - }, + tts: { + auto: "always", + provider: "inworld", + providers: { + inworld: { + apiKey: "${INWORLD_API_KEY}", + modelId: "inworld-tts-1.5-max", + speakerVoiceId: "Sarah", + temperature: 0.7, }, }, }, @@ -203,17 +191,15 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "tts-local-cli", - providers: { - "tts-local-cli": { - command: "say", - args: ["-o", "{{OutputPath}}", "{{Text}}"], - outputFormat: "wav", - timeoutMs: 120000, - }, + tts: { + auto: "always", + provider: "tts-local-cli", + providers: { + "tts-local-cli": { + command: "say", + args: ["-o", "{{OutputPath}}", "{{Text}}"], + outputFormat: "wav", + timeoutMs: 120000, }, }, }, @@ -223,19 +209,17 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "microsoft", - providers: { - microsoft: { - enabled: true, - speakerVoice: "en-US-MichelleNeural", - lang: "en-US", - outputFormat: "audio-24khz-48kbitrate-mono-mp3", - rate: "+0%", - pitch: "+0%", - }, + tts: { + auto: "always", + provider: "microsoft", + providers: { + microsoft: { + enabled: true, + speakerVoice: "en-US-MichelleNeural", + lang: "en-US", + outputFormat: "audio-24khz-48kbitrate-mono-mp3", + rate: "+0%", + pitch: "+0%", }, }, }, @@ -245,19 +229,17 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "minimax", - providers: { - minimax: { - apiKey: "${MINIMAX_API_KEY}", - model: "speech-2.8-hd", - speakerVoiceId: "English_expressive_narrator", - speed: 1.0, - vol: 1.0, - pitch: 0, - }, + tts: { + auto: "always", + provider: "minimax", + providers: { + minimax: { + apiKey: "${MINIMAX_API_KEY}", + model: "speech-2.8-hd", + speakerVoiceId: "English_expressive_narrator", + speed: 1.0, + vol: 1.0, + pitch: 0, }, }, }, @@ -267,26 +249,24 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "openai", - summaryModel: "openai/gpt-4.1-mini", - modelOverrides: { enabled: true }, - providers: { - openai: { - apiKey: "${OPENAI_API_KEY}", - model: "gpt-4o-mini-tts", - speakerVoice: "alloy", - }, - elevenlabs: { - apiKey: "${ELEVENLABS_API_KEY}", - model: "eleven_multilingual_v2", - speakerVoiceId: "EXAVITQu4vr4xnSDxMaL", - voiceSettings: { stability: 0.5, similarityBoost: 0.75, style: 0.0, useSpeakerBoost: true, speed: 1.0 }, - applyTextNormalization: "auto", - languageCode: "en", - }, + tts: { + auto: "always", + provider: "openai", + summaryModel: "openai/gpt-4.1-mini", + modelOverrides: { enabled: true }, + providers: { + openai: { + apiKey: "${OPENAI_API_KEY}", + model: "gpt-4o-mini-tts", + speakerVoice: "alloy", + }, + elevenlabs: { + apiKey: "${ELEVENLABS_API_KEY}", + model: "eleven_multilingual_v2", + speakerVoiceId: "EXAVITQu4vr4xnSDxMaL", + voiceSettings: { stability: 0.5, similarityBoost: 0.75, style: 0.0, useSpeakerBoost: true, speed: 1.0 }, + applyTextNormalization: "auto", + languageCode: "en", }, }, }, @@ -296,17 +276,15 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "openrouter", - providers: { - openrouter: { - apiKey: "${OPENROUTER_API_KEY}", - model: "hexgrad/kokoro-82m", - speakerVoice: "af_alloy", - responseFormat: "mp3", - }, + tts: { + auto: "always", + provider: "openrouter", + providers: { + openrouter: { + apiKey: "${OPENROUTER_API_KEY}", + model: "hexgrad/kokoro-82m", + speakerVoice: "af_alloy", + responseFormat: "mp3", }, }, }, @@ -316,16 +294,14 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "volcengine", - providers: { - volcengine: { - apiKey: "${VOLCENGINE_TTS_API_KEY}", - resourceId: "seed-tts-1.0", - speakerVoice: "en_female_anna_mars_bigtts", - }, + tts: { + auto: "always", + provider: "volcengine", + providers: { + volcengine: { + apiKey: "${VOLCENGINE_TTS_API_KEY}", + resourceId: "seed-tts-1.0", + speakerVoice: "en_female_anna_mars_bigtts", }, }, }, @@ -335,17 +311,15 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "xai", - providers: { - xai: { - apiKey: "${XAI_API_KEY}", - speakerVoiceId: "eve", - language: "en", - responseFormat: "mp3", - }, + tts: { + auto: "always", + provider: "xai", + providers: { + xai: { + apiKey: "${XAI_API_KEY}", + speakerVoiceId: "eve", + language: "en", + responseFormat: "mp3", }, }, }, @@ -355,17 +329,15 @@ fields shown below are canonical; each provider's own `voice`/`voiceId`/ ```json5 { - messages: { - tts: { - auto: "always", - provider: "xiaomi", - providers: { - xiaomi: { - apiKey: "${XIAOMI_API_KEY}", - model: "mimo-v2.5-tts", - speakerVoice: "mimo_default", - format: "mp3", - }, + tts: { + auto: "always", + provider: "xiaomi", + providers: { + xiaomi: { + apiKey: "${XIAOMI_API_KEY}", + model: "mimo-v2.5-tts", + speakerVoice: "mimo_default", + format: "mp3", }, }, }, @@ -380,19 +352,17 @@ and does not send `audio.voice` for the voicedesign model. ### Per-agent voice overrides -Use `agents.list[].tts` when one agent should speak with a different provider, +Use `agents.entries.*.tts` when one agent should speak with a different provider, voice, model, persona, or auto-TTS mode. The agent block deep-merges over -`messages.tts`, so provider credentials can stay in the global provider config: +`tts`, so provider credentials can stay in the global provider config: ```json5 { - messages: { - tts: { - auto: "always", - provider: "elevenlabs", - providers: { - elevenlabs: { apiKey: "${ELEVENLABS_API_KEY}", model: "eleven_multilingual_v2" }, - }, + tts: { + auto: "always", + provider: "elevenlabs", + providers: { + elevenlabs: { apiKey: "${ELEVENLABS_API_KEY}", model: "eleven_multilingual_v2" }, }, }, agents: { @@ -410,32 +380,30 @@ voice, model, persona, or auto-TTS mode. The agent block deep-merges over } ``` -To pin a per-agent persona, set `agents.list[].tts.persona` alongside provider -config — it overrides the global `messages.tts.persona` for that agent only. +To pin a per-agent persona, set `agents.entries.*.tts.persona` alongside provider +config — it overrides the global `tts.persona` for that agent only. Precedence order for automatic replies, `/tts audio`, `/tts status`, and the `tts` agent tool: -1. `messages.tts` -2. active `agents.list[].tts` +1. `tts` +2. active `agents.entries.*.tts` 3. channel override, when the channel supports `channels..tts` 4. account override, when the channel passes `channels..accounts..tts` 5. local `/tts` preferences for this host 6. inline `[[tts:...]]` directives when [model overrides](#model-driven-directives) are enabled -Channel and account overrides use the same shape as `messages.tts` and +Channel and account overrides use the same shape as `tts` and deep-merge over the earlier layers, so shared provider credentials can stay in -`messages.tts` while a channel or bot account changes only speaker voice, model, persona, +`tts` while a channel or bot account changes only speaker voice, model, persona, or auto mode: ```json5 { - messages: { - tts: { - provider: "openai", - providers: { - openai: { apiKey: "${OPENAI_API_KEY}", model: "gpt-4o-mini-tts" }, - }, + tts: { + provider: "openai", + providers: { + openai: { apiKey: "${OPENAI_API_KEY}", model: "gpt-4o-mini-tts" }, }, }, channels: { @@ -465,19 +433,17 @@ templates, seeds, and voice settings. ```json5 { - messages: { - tts: { - auto: "always", - persona: "narrator", - personas: { - narrator: { - label: "Narrator", - provider: "elevenlabs", - providers: { - elevenlabs: { - speakerVoiceId: "EXAVITQu4vr4xnSDxMaL", - modelId: "eleven_multilingual_v2", - }, + tts: { + auto: "always", + persona: "narrator", + personas: { + narrator: { + label: "Narrator", + provider: "elevenlabs", + providers: { + elevenlabs: { + speakerVoiceId: "EXAVITQu4vr4xnSDxMaL", + modelId: "eleven_multilingual_v2", }, }, }, @@ -486,47 +452,36 @@ templates, seeds, and voice settings. } ``` -### Full persona (provider-neutral prompt) +### Full persona (provider-specific shaping) ```json5 { - messages: { - tts: { - auto: "always", - persona: "alfred", - personas: { - alfred: { - label: "Alfred", - description: "Dry, warm British butler narrator.", - provider: "google", - fallbackPolicy: "preserve-persona", - prompt: { - profile: "A brilliant British butler. Dry, witty, warm, charming, emotionally expressive, never generic.", - scene: "A quiet late-night study. Close-mic narration for a trusted operator.", - sampleContext: "The speaker is answering a private technical request with concise confidence and dry warmth.", - style: "Refined, understated, lightly amused.", - accent: "British English.", - pacing: "Measured, with short dramatic pauses.", - constraints: ["Do not read configuration values aloud.", "Do not explain the persona."], + tts: { + auto: "always", + persona: "alfred", + personas: { + alfred: { + label: "Alfred", + description: "Dry, warm British butler narrator.", + provider: "google", + fallbackPolicy: "preserve-persona", + providers: { + google: { + model: "gemini-3.1-flash-tts-preview", + speakerVoice: "Algieba", + promptTemplate: "audio-profile-v1", }, - providers: { - google: { - model: "gemini-3.1-flash-tts-preview", - speakerVoice: "Algieba", - promptTemplate: "audio-profile-v1", - }, - openai: { model: "gpt-4o-mini-tts", speakerVoice: "cedar" }, - elevenlabs: { - speakerVoiceId: "voice_id", - modelId: "eleven_multilingual_v2", - seed: 42, - voiceSettings: { - stability: 0.65, - similarityBoost: 0.8, - style: 0.25, - useSpeakerBoost: true, - speed: 0.95, - }, + openai: { model: "gpt-4o-mini-tts", speakerVoice: "cedar" }, + elevenlabs: { + speakerVoiceId: "voice_id", + modelId: "eleven_multilingual_v2", + seed: 42, + voiceSettings: { + stability: 0.65, + similarityBoost: 0.8, + style: 0.25, + useSpeakerBoost: true, + speed: 0.95, }, }, }, @@ -541,7 +496,7 @@ templates, seeds, and voice settings. The active persona is selected deterministically: 1. `/tts persona ` local preference, if set. -2. `messages.tts.persona`, if set. +2. `tts.persona`, if set. 3. No persona. Provider selection runs explicit-first: @@ -549,42 +504,25 @@ Provider selection runs explicit-first: 1. Direct overrides (CLI, gateway, Talk, allowed TTS directives). 2. `/tts provider ` local preference. 3. Active persona's `provider`. -4. `messages.tts.provider`. +4. `tts.provider`. 5. Registry auto-select. For each provider attempt, OpenClaw merges configs in this order: -1. `messages.tts.providers.` -2. `messages.tts.personas..providers.` +1. `tts.providers.` +2. `tts.personas..providers.` 3. Trusted request overrides 4. Allowed model-emitted TTS directive overrides -### How providers use persona prompts +### Custom persona shaping -Persona prompt fields (`profile`, `scene`, `sampleContext`, `style`, `accent`, -`pacing`, `constraints`) are **provider-neutral**. Each provider decides how -to use them: - - - - Wraps persona prompt fields in a Gemini TTS prompt structure **only when** - the effective Google provider config sets `promptTemplate: "audio-profile-v1"` - or `personaPrompt`. The older `audioProfile` and `speakerName` fields are - still prepended as Google-specific prompt text. Inline audio tags such as - `[whispers]` or `[laughs]` inside a `[[tts:text]]` block are preserved - inside the Gemini transcript; OpenClaw does not generate these tags. - - - Maps persona prompt fields to the request `instructions` field **only when** - no explicit OpenAI `instructions` is configured. Explicit `instructions` - always wins. - - - Use only the provider-specific persona bindings under - `personas..providers.`. Persona prompt fields are ignored - unless the provider implements its own persona-prompt mapping. - - +Provider-neutral `personas..prompt.*` config is retired. Doctor removes +those fields and points to the speech-provider seam. Put built-in provider +settings under `personas..providers.` (for example Google +`personaPrompt` or OpenAI `instructions`). For custom shaping, implement a +speech provider plugin with `prepareSynthesis(ctx)` and return adjusted text, +provider config, or overrides before `synthesize()` runs. This keeps expressive +prompt construction in provider code where request semantics are known. ### Fallback policy @@ -603,7 +541,7 @@ or fails. Talk session provider selection is session-scoped. A Talk client should choose provider ids, model ids, voice ids, and locales from `talk.catalog` and pass them through the Talk session or handoff request. Opening a voice session should -not mutate `messages.tts` or global Talk provider defaults. +not mutate `tts` or global Talk provider defaults. ## Model-driven directives @@ -619,7 +557,7 @@ Here you go. [[tts:text]](laughs) Read the song once more.[[/tts:text]] ``` -When `messages.tts.auto` is `"tagged"`, **directives are required** to trigger +When `tts.auto` is `"tagged"`, **directives are required** to trigger audio. Streaming block delivery strips directives from visible text before the channel sees them, even when split across adjacent blocks. @@ -688,9 +626,11 @@ Behavior notes: ## Per-user preferences -Slash commands write local overrides to `prefsPath`. The default is -`~/.openclaw/settings/tts.json`; override with the `OPENCLAW_TTS_PREFS` env var -or `messages.tts.prefsPath`. +Slash commands write local overrides to the TTS preferences path. The default is +`~/.openclaw/settings/tts.json`; override it with `OPENCLAW_TTS_PREFS`. Doctor +moves the retired global `tts.prefsPath` value into shared machine state. +Advanced multi-agent setups may still set `agents.entries..tts.prefsPath` +when agents intentionally use separate preference stores. | Stored field | Effect | | ------------ | -------------------------------------------------------------------------------- | @@ -700,8 +640,8 @@ or `messages.tts.prefsPath`. | `maxLength` | Summary/truncation threshold (default `1500` chars, `/tts limit` range 100–4096) | | `summarize` | Summary toggle (default `true`) | -These override the effective config from `messages.tts` plus the active -`agents.list[].tts` block for that host. +These override the effective config from `tts` plus the active +`agents.entries.*.tts` block for that host. ## Output formats @@ -737,7 +677,7 @@ OpenAI and ElevenLabs output formats are fixed per channel as listed above. ## Auto-TTS behavior -When `messages.tts.auto` is enabled, OpenClaw: +When `tts.auto` is enabled, OpenClaw: - Skips TTS if the reply already contains structured media. - Skips very short replies (under 10 chars). @@ -771,7 +711,7 @@ Reply -> TTS enabled? ## Field reference - + Auto-TTS mode. `inbound` only sends audio after an inbound voice message; `tagged` only sends audio when the reply includes `[[tts:...]]` directives or a `[[tts:text]]` block. @@ -797,16 +737,13 @@ Reply -> TTS enabled? Allow the model to emit TTS directives. `enabled` defaults to `true`; `allowProvider` defaults to `false`. - Provider-owned settings keyed by speech provider id. Legacy direct blocks (`messages.tts.openai`, `.elevenlabs`, `.microsoft`, `.edge`) are rewritten by `openclaw doctor --fix`; commit only `messages.tts.providers.`. + Provider-owned settings keyed by speech provider id. Legacy direct blocks (`tts.openai`, `.elevenlabs`, `.microsoft`, `.edge`) are rewritten by `openclaw doctor --fix`; commit only `tts.providers.`. Hard cap for TTS input characters. `/tts audio`, `tts.convert`, and `tts.speak` fail if exceeded. - Request timeout in milliseconds. A per-call `timeoutMs` (agent tool, gateway) wins when set; otherwise an explicitly configured `messages.tts.timeoutMs` wins over any plugin-authored provider default. - - - Override the local prefs JSON path (provider/limit/summary). Default `~/.openclaw/settings/tts.json`. + Request timeout in milliseconds. A per-call `timeoutMs` (agent tool, gateway) wins when set; otherwise an explicitly configured `tts.timeoutMs` wins over any plugin-authored provider default. @@ -971,7 +908,7 @@ clients do not consistently render captions on voice notes. The tool accepts optional `channel` and `timeoutMs` fields; `timeoutMs` is a per-call provider request timeout in milliseconds. Per-call values override -`messages.tts.timeoutMs`; configured TTS timeouts override any plugin-authored +`tts.timeoutMs`; configured TTS timeouts override any plugin-authored provider default. ## Gateway RPC diff --git a/docs/tools/video-generation.md b/docs/tools/video-generation.md index 6c929c140803..8438265e17a6 100644 --- a/docs/tools/video-generation.md +++ b/docs/tools/video-generation.md @@ -16,7 +16,7 @@ available API keys. `video_generate` only appears when at least one video-generation provider is available. If it is missing from your agent tools, set a provider API key or -configure `agents.defaults.videoGenerationModel`. +configure `agents.defaults.mediaModels.video`. `video_generate` has three runtime modes, resolved from the reference inputs @@ -42,7 +42,7 @@ active mode before submission and reports supported modes in `action=list`. ```bash - openclaw config set agents.defaults.videoGenerationModel.primary "google/veo-3.1-fast-generate-preview" + openclaw config set agents.defaults.mediaModels.video.primary "google/veo-3.1-fast-generate-preview" ``` @@ -219,7 +219,7 @@ dimensions). Providers that do not declare it surface the value via Provider/model override (e.g. `runway/gen4.5`). Output filename hint. -Optional provider operation timeout in milliseconds. When omitted, OpenClaw uses `agents.defaults.videoGenerationModel.timeoutMs` if configured, otherwise the plugin-authored provider default when one exists. +Optional provider operation timeout in milliseconds. When omitted, OpenClaw uses `agents.defaults.mediaModels.video.timeoutMs` if configured, otherwise the plugin-authored provider default when one exists. Provider-specific options as a JSON object (e.g. `{"seed": 42, "draft": true}`). Providers that declare a typed schema validate the keys and types; unknown @@ -298,8 +298,8 @@ OpenClaw resolves the model in this order: If a provider fails, the next candidate is tried automatically. If all candidates fail, the error includes details from each attempt. -Set `agents.defaults.mediaGenerationAutoProviderFallback: false` to use -only the explicit `model`, `primary`, and `fallbacks` entries. +Automatic fallback across authenticated providers is always enabled. A per-call +`model` remains authoritative. ```json5 { @@ -536,7 +536,7 @@ Set the default video-generation model in your OpenClaw config: Or via the CLI: ```bash -openclaw config set agents.defaults.videoGenerationModel.primary "qwen/wan2.6-t2v" +openclaw config set agents.defaults.mediaModels.video.primary "qwen/wan2.6-t2v" ``` ## Related diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 4000819c9380..5a257e893d00 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -399,7 +399,7 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das - During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up. - Live `chat` events are delivery state, while `chat.history` is rebuilt from the durable session transcript. After tool-final events the Control UI reloads history and merges only a small optimistic tail; the transcript boundary is documented in [WebChat](/web/webchat). - `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery). - - The sidebar lists every loaded active session by agent section and pinned/channel/work/custom/Chats buckets with a single New Session action that opens the draft dialog. Opening a visible row moves only the highlight. Sessions can be dropped onto Pinned to pin them, or onto a custom group or Chats to move them; custom groups are collapsible and drag-reorderable, group names and order sync through the gateway, and collapsed state stays in the browser. A new dashboard session asynchronously gets a concise generated title from its first non-command message; explicit names and authenticated sender identity remain separate, so account names are never used as generated titles. Set `agents.defaults.utilityModel` (or `agents.list[].utilityModel`) to route this separate model call to a lower-cost model; if that distinct model fails, title generation retries once with the primary model. Expanding another agent section browses that agent's sessions without leaving the open chat. + - The sidebar lists every loaded active session by agent section and pinned/channel/work/custom/Chats buckets with a single New Session action that opens the draft dialog. Opening a visible row moves only the highlight. Sessions can be dropped onto Pinned to pin them, or onto a custom group or Chats to move them; custom groups are collapsible and drag-reorderable, group names and order sync through the gateway, and collapsed state stays in the browser. A new dashboard session asynchronously gets a concise generated title from its first non-command message; explicit names and authenticated sender identity remain separate, so account names are never used as generated titles. Set `agents.defaults.utilityModel` (or `agents.entries.*.utilityModel`) to route this separate model call to a lower-cost model; if that distinct model fails, title generation retries once with the primary model. Expanding another agent section browses that agent's sessions without leaving the open chat. - Thread search lives in the command palette (⌘K, or the Search field at the top of the sidebar): typing a query follows a bounded number of matching pages across agents, filters internal child/cron rows, and lists visible matches next to navigation commands. The Threads page keeps the exhaustive searchable list with filters. - Each sidebar row keeps direct pin access plus a full context menu for unread state, rename, fork, grouping, archive, and delete. Multi-selected rows (Cmd/Ctrl-click, Shift-click for ranges) get a batch menu covering unread state, grouping, archive, and delete; batch archive/delete stays disabled unless every selected session is archivable. An active run and an agent's main session cannot be archived. Archiving or deleting the currently selected session switches Chat back to that agent's main session. - In the macOS app, the OpenClaw mark uses the otherwise-empty native titlebar strip next to the window controls instead of consuming a sidebar row. @@ -540,7 +540,7 @@ Absolute external `http(s)` embed URLs stay blocked by default. To let `[embed u ## Chat message width -The chat transcript uses a centered readable frame aligned with the composer. Assistant and tool output stay left-aligned while user bubbles stay right-aligned inside that frame. Wide-monitor deployments can override the transcript width without patching bundled CSS by setting `gateway.controlUi.chatMessageMaxWidth`: +The chat transcript uses a centered readable frame aligned with the composer. Assistant and tool output stay left-aligned while user bubbles stay right-aligned inside that frame. Wide-monitor deployments can override the transcript width without patching bundled CSS by setting `ui.prefs.chatMessageMaxWidth`: ```json5 { @@ -591,49 +591,13 @@ The value is validated before it reaches the browser. Supported forms include pl If you open the dashboard over plain HTTP (`http://` or `http://`), the browser runs in a **non-secure context** and blocks WebCrypto. By default, OpenClaw **blocks** Control UI connections without device identity. -Documented exceptions: - -- localhost-only insecure HTTP compatibility with `gateway.controlUi.allowInsecureAuth=true` -- successful operator Control UI auth through `gateway.auth.mode: "trusted-proxy"` -- break-glass `gateway.controlUi.dangerouslyDisableDeviceAuth=true` +The supported device-less exception is successful operator Control UI auth +through `gateway.auth.mode: "trusted-proxy"`. There is no persistent config +switch that disables device identity. **Recommended fix:** use HTTPS (Tailscale Serve) or open the UI locally at `https:///` (Serve) or `http://127.0.0.1:18789/` (on the gateway host). - - ```json5 - { - gateway: { - controlUi: { allowInsecureAuth: true }, - bind: "tailnet", - auth: { mode: "token", token: "replace-me" }, - }, - } - ``` - - `allowInsecureAuth` is a local compatibility toggle only: - - - It lets localhost Control UI sessions proceed without device identity in non-secure HTTP contexts. - - It does not bypass pairing checks. - - It does not relax remote (non-localhost) device identity requirements. - - - - ```json5 - { - gateway: { - controlUi: { dangerouslyDisableDeviceAuth: true }, - bind: "tailnet", - auth: { mode: "token", token: "replace-me" }, - }, - } - ``` - - - `dangerouslyDisableDeviceAuth` disables Control UI device identity checks and is a severe security downgrade. Revert quickly after emergency use. - - - - Successful trusted-proxy auth can admit **operator** Control UI sessions without device identity. - This does **not** extend to node-role Control UI sessions. diff --git a/extensions/acpx/src/codex-auth-bridge.ts b/extensions/acpx/src/codex-auth-bridge.ts index 84706e2bff6d..0b0d737a1223 100644 --- a/extensions/acpx/src/codex-auth-bridge.ts +++ b/extensions/acpx/src/codex-auth-bridge.ts @@ -26,11 +26,7 @@ import { import { quoteCommandPart, splitCommandParts } from "./command-line.js"; import { resolveAcpxPluginRoot } from "./config.js"; import type { ResolvedAcpxPluginConfig } from "./config.js"; -import { - OPENCLAW_ACPX_LEASE_ID_ARG, - OPENCLAW_ACPX_LEASE_ID_ENV, - OPENCLAW_GATEWAY_INSTANCE_ID_ARG, -} from "./process-lease.js"; +import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js"; const CLAUDE_ACP_PACKAGE = "@agentclientprotocol/claude-agent-acp"; const CLAUDE_ACP_BIN = "claude-agent-acp"; @@ -293,7 +289,6 @@ function resolveStderrLogPath(args) { return undefined; } const leaseId = - process.env[${JSON.stringify(OPENCLAW_ACPX_LEASE_ID_ENV)}] || readOpenClawWrapperArg(args, ${quoteCommandPart(OPENCLAW_ACPX_LEASE_ID_ARG)}) || "pid-" + process.pid; const fileName = stderrLogFileNamePrefix + "." + safeDiagnosticFilePart(leaseId) + ".log"; diff --git a/extensions/acpx/src/process-lease.test.ts b/extensions/acpx/src/process-lease.test.ts index 8342d43f86e5..bb7403d805ab 100644 --- a/extensions/acpx/src/process-lease.test.ts +++ b/extensions/acpx/src/process-lease.test.ts @@ -11,14 +11,11 @@ import { createAcpxProcessLeaseStore, openAcpxProcessLeaseStateStore, OPENCLAW_ACPX_LEASE_ID_ARG, - OPENCLAW_ACPX_LEASE_ID_ENV, OPENCLAW_GATEWAY_INSTANCE_ID_ARG, withAcpxLeaseEnvironment, type AcpxProcessLease, } from "./process-lease.js"; -const OPENCLAW_GATEWAY_INSTANCE_ID_ENV = "OPENCLAW_GATEWAY_INSTANCE_ID"; - function makeLease(index: number): AcpxProcessLease { return { leaseId: `lease-${index}`, @@ -80,19 +77,15 @@ describe("createAcpxProcessLeaseStore", () => { }); describe("withAcpxLeaseEnvironment", () => { - it("adds lease environment and wrapper args on POSIX", () => { + it("adds portable lease wrapper args", () => { const command = withAcpxLeaseEnvironment({ command: "node /tmp/openclaw/acpx/codex-acp-wrapper.mjs", leaseId: "lease-test", gatewayInstanceId: "gateway-test", - platform: "darwin", }); expect(command).toBe( [ - "env", - `${OPENCLAW_ACPX_LEASE_ID_ENV}=lease-test`, - `${OPENCLAW_GATEWAY_INSTANCE_ID_ENV}=gateway-test`, "node /tmp/openclaw/acpx/codex-acp-wrapper.mjs", OPENCLAW_ACPX_LEASE_ID_ARG, "lease-test", @@ -102,24 +95,21 @@ describe("withAcpxLeaseEnvironment", () => { ); }); - it("keeps Windows logs keyed by lease id with wrapper args", () => { + it("quotes portable lease wrapper args", () => { const command = withAcpxLeaseEnvironment({ command: "node C:/openclaw/acpx/codex-acp-wrapper.mjs", - leaseId: "lease-test", + leaseId: "lease test", gatewayInstanceId: "gateway-test", - platform: "win32", }); expect(command).toBe( [ "node C:/openclaw/acpx/codex-acp-wrapper.mjs", OPENCLAW_ACPX_LEASE_ID_ARG, - "lease-test", + "'lease test'", OPENCLAW_GATEWAY_INSTANCE_ID_ARG, "gateway-test", ].join(" "), ); - expect(command).not.toContain(`${OPENCLAW_ACPX_LEASE_ID_ENV}=`); - expect(command).not.toContain(`${OPENCLAW_GATEWAY_INSTANCE_ID_ENV}=`); }); }); diff --git a/extensions/acpx/src/process-lease.ts b/extensions/acpx/src/process-lease.ts index 620465461a46..0fe82e758732 100644 --- a/extensions/acpx/src/process-lease.ts +++ b/extensions/acpx/src/process-lease.ts @@ -9,11 +9,7 @@ import type { } from "openclaw/plugin-sdk/plugin-state-runtime"; import { ACPX_PROCESS_LEASE_MAX_ENTRIES, ACPX_PROCESS_LEASE_NAMESPACE } from "./state.js"; -/** Environment variable carrying the ACPX process lease id. */ -export const OPENCLAW_ACPX_LEASE_ID_ENV = "OPENCLAW_ACPX_LEASE_ID"; -/** Environment variable carrying the owning gateway instance id. */ -const OPENCLAW_GATEWAY_INSTANCE_ID_ENV = "OPENCLAW_GATEWAY_INSTANCE_ID"; -/** CLI argument carrying the ACPX process lease id for platforms without env wrapping. */ +/** CLI argument carrying the ACPX process lease id. */ export const OPENCLAW_ACPX_LEASE_ID_ARG = "--openclaw-acpx-lease-id"; /** CLI argument carrying the owning gateway instance id. */ export const OPENCLAW_GATEWAY_INSTANCE_ID_ARG = "--openclaw-gateway-instance-id"; @@ -183,20 +179,11 @@ function appendAcpxLeaseArgs(params: { ].join(" "); } -/** Add ACPX lease identity to a command through env vars and portable args. */ +/** Add ACPX lease identity to a command through portable wrapper arguments. */ export function withAcpxLeaseEnvironment(params: { command: string; leaseId: string; gatewayInstanceId: string; - platform?: NodeJS.Platform; }): string { - if ((params.platform ?? process.platform) === "win32") { - return appendAcpxLeaseArgs(params); - } - return [ - "env", - `${OPENCLAW_ACPX_LEASE_ID_ENV}=${quoteEnvValue(params.leaseId)}`, - `${OPENCLAW_GATEWAY_INSTANCE_ID_ENV}=${quoteEnvValue(params.gatewayInstanceId)}`, - appendAcpxLeaseArgs(params), - ].join(" "); + return appendAcpxLeaseArgs(params); } diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 7b40e2a9957a..207e6901a4ee 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -1804,8 +1804,8 @@ describe("AcpxRuntime fresh reset wrapper", () => { expect(lease?.rootPid).toBe(777); expect(lease?.state).toBe("open"); expect(lease?.wrapperPath).toBe("/tmp/openclaw/acpx/codex-acp-wrapper.mjs"); - expect(launchCommands[0]).toContain("OPENCLAW_ACPX_LEASE_ID="); - expect(launchCommands[0]).toContain("OPENCLAW_GATEWAY_INSTANCE_ID=gateway-test"); + expect(launchCommands[0]).toContain(OPENCLAW_ACPX_LEASE_ID_ARG); + expect(launchCommands[0]).toContain(OPENCLAW_GATEWAY_INSTANCE_ID_ARG); expect(savedRecords[0]?.agentCommand).toBe(CODEX_ACP_WRAPPER_COMMAND); expect(savedRecords[0]?.openclawGatewayInstanceId).toBe("gateway-test"); expect(savedRecords[0]?.openclawLeaseId).toBe(lease?.leaseId); diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index a866c4d31d52..ded03366c1dc 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -684,8 +684,8 @@ describe("active-memory plugin", () => { hasRememberAcrossConversationsAgent({ agents: { list: [ - { id: "personal", memorySearch: { rememberAcrossConversations: false } }, - { id: "support", memorySearch: { rememberAcrossConversations: false } }, + { id: "personal", memory: { search: { rememberAcrossConversations: false } } }, + { id: "support", memory: { search: { rememberAcrossConversations: false } } }, ], }, }), @@ -896,7 +896,7 @@ describe("active-memory plugin", () => { workspace: "/tmp/live-personal-workspace", agentDir: "/tmp/live-personal-agent", model: { primary: "openai/gpt-5.5" }, - memorySearch: { rememberAcrossConversations: true }, + memory: { search: { rememberAcrossConversations: true } }, }, ], }, @@ -935,7 +935,7 @@ describe("active-memory plugin", () => { workspace: "/tmp/live-personal-workspace", agentDir: "/tmp/live-personal-agent", model: { primary: "openai/gpt-5.5" }, - memorySearch: { rememberAcrossConversations: true }, + memory: { search: { rememberAcrossConversations: true } }, }, ], }, @@ -951,7 +951,7 @@ describe("active-memory plugin", () => { { id: "personal", model: { primary: "github-copilot/gpt-5.4-mini" }, - memorySearch: { rememberAcrossConversations: true }, + memory: { search: { rememberAcrossConversations: true } }, }, ], }, @@ -1057,7 +1057,7 @@ describe("active-memory plugin", () => { configFile = { ...configFile, agents: { - list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }], + list: [{ id: "personal", memory: { search: { rememberAcrossConversations: true } } }], }, }; hoisted.sessionStore[testCase.sessionKey] = { sessionId: "s-personal", updatedAt: 0 }; @@ -1122,7 +1122,7 @@ describe("active-memory plugin", () => { { id: "personal", model: { primary: "github-copilot/gpt-5.4-mini" }, - memorySearch: { rememberAcrossConversations: true }, + memory: { search: { rememberAcrossConversations: true } }, }, ], }, @@ -1164,7 +1164,7 @@ describe("active-memory plugin", () => { configFile = { ...configFile, agents: { - list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }], + list: [{ id: "personal", memory: { search: { rememberAcrossConversations: true } } }], }, }; const sessionKey = "agent:personal:telegram:direct:owner"; diff --git a/extensions/amazon-bedrock/memory-embedding-adapter.ts b/extensions/amazon-bedrock/memory-embedding-adapter.ts index 80781cdbe720..3b81e43606f0 100644 --- a/extensions/amazon-bedrock/memory-embedding-adapter.ts +++ b/extensions/amazon-bedrock/memory-embedding-adapter.ts @@ -28,7 +28,7 @@ export const bedrockMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapt "AWS credentials are not available. " + "Set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, AWS_PROFILE, or AWS_BEARER_TOKEN_BEDROCK, " + "configure an EC2/ECS/EKS role, " + - "or set agents.defaults.memorySearch.provider to another provider.", + "or set memory.search.provider to another provider.", ); } const { provider, client } = await createBedrockEmbeddingProvider({ diff --git a/extensions/anthropic/cli-backend.ts b/extensions/anthropic/cli-backend.ts index b82b21daff65..34390f22bbcb 100644 --- a/extensions/anthropic/cli-backend.ts +++ b/extensions/anthropic/cli-backend.ts @@ -89,7 +89,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin { modelAliases: CLAUDE_CLI_MODEL_ALIASES, imageArg: "@", imagePathScope: "workspace", - sessionArg: "--session-id", + sessionArgs: ["--session-id", "{sessionId}"], sessionMode: "always", reseedFromRawTranscriptWhenUncompacted: true, sessionIdFields: [...CLAUDE_CLI_SESSION_ID_FIELDS], diff --git a/extensions/anthropic/cli-shared.test.ts b/extensions/anthropic/cli-shared.test.ts index 665e7861e745..e20344c011e2 100644 --- a/extensions/anthropic/cli-shared.test.ts +++ b/extensions/anthropic/cli-shared.test.ts @@ -61,7 +61,7 @@ function normalizeClaudeArgs( args: string[], context: Parameters[1] = { backendId: "claude-cli", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + config: { tools: { exec: { mode: "ask" } } }, }, ): string[] | undefined { return normalizeClaudeBackendConfig( @@ -548,7 +548,13 @@ describe("normalizeClaudeBackendConfig", () => { expect( normalizeClaudeArgs(["-p"], { backendId: "claude-cli", - config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } }, + config: { tools: { exec: { mode: "ask" } } }, + }), + ).not.toContain("bypassPermissions"); + expect( + normalizeClaudeArgs(["-p"], { + backendId: "claude-cli", + config: { tools: { exec: { security: "allowlist", ask: "always" } } }, }), ).not.toContain("bypassPermissions"); }); @@ -559,12 +565,12 @@ describe("normalizeClaudeBackendConfig", () => { backendId: "claude-cli", agentId: "safe-agent", config: { - tools: { exec: { security: "full", ask: "off" } }, + tools: { exec: { mode: "full" } }, agents: { list: [ { id: "safe-agent", - tools: { exec: { security: "allowlist", ask: "on-miss" } }, + tools: { exec: { mode: "ask" } }, }, ], }, @@ -576,12 +582,12 @@ describe("normalizeClaudeBackendConfig", () => { backendId: "claude-cli", agentId: "yolo-agent", config: { - tools: { exec: { security: "allowlist", ask: "on-miss" } }, + tools: { exec: { mode: "ask" } }, agents: { list: [ { id: "yolo-agent", - tools: { exec: { security: "full", ask: "off" } }, + tools: { exec: { mode: "full" } }, }, ], }, diff --git a/extensions/anthropic/cli-shared.ts b/extensions/anthropic/cli-shared.ts index 09b6b9d06a6b..28cae09ada86 100644 --- a/extensions/anthropic/cli-shared.ts +++ b/extensions/anthropic/cli-shared.ts @@ -8,6 +8,7 @@ import type { CliBackendResolveRuntimeToolAvailabilityContext, CliBackendRuntimeToolAvailability, } from "openclaw/plugin-sdk/cli-backend"; +import { resolveExecModePolicy } from "openclaw/plugin-sdk/exec-approvals-runtime"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js"; export { @@ -137,9 +138,13 @@ function isOpenClawRequestedYolo(context?: CliBackendNormalizeConfigContext): bo ? context.config?.agents?.list?.find((agent) => agent.id === context.agentId)?.tools?.exec : undefined; const exec = agentExec ?? context?.config?.tools?.exec; - const security = exec?.security ?? "full"; - const ask = exec?.ask ?? "off"; - return security === "full" && ask === "off"; + return ( + resolveExecModePolicy({ + mode: exec?.mode, + security: exec?.security ?? "full", + ask: exec?.ask ?? "off", + }).mode === "full" + ); } /** Resolve Claude permission mode from OpenClaw exec security settings. */ diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index ff86a5bc74f1..a8fa06b11013 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -91,7 +91,7 @@ describe("anthropic provider replay hooks", () => { expectFields(backend.config, { command: "claude", modelArg: "--model", - sessionArg: "--session-id", + sessionArgs: ["--session-id", "{sessionId}"], }); }); diff --git a/extensions/anthropic/stream-wrappers.test.ts b/extensions/anthropic/stream-wrappers.test.ts index 6c96236d19c3..f3972aee071c 100644 --- a/extensions/anthropic/stream-wrappers.test.ts +++ b/extensions/anthropic/stream-wrappers.test.ts @@ -1,7 +1,8 @@ +import { configureAiTransportHost, getAiTransportHost } from "@openclaw/ai"; // Anthropic tests cover stream wrappers plugin behavior. import { expectDefined } from "@openclaw/normalization-core"; import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeAll, afterEach, describe, expect, it, vi } from "vitest"; import { createAnthropicBetaHeadersWrapper, createAnthropicFastModeWrapper, @@ -16,6 +17,21 @@ const OAUTH_BETA = "oauth-2025-04-20"; const DEFAULT_BETA_HEADER = "fine-grained-tool-streaming-2025-05-14,interleaved-thinking-2025-05-14"; const OAUTH_BETA_HEADER = `claude-code-20250219,${OAUTH_BETA},${DEFAULT_BETA_HEADER}`; +const initialTransportHost = getAiTransportHost(); + +beforeAll(() => { + configureAiTransportHost({ + ...initialTransportHost, + resolveProviderRequestCapabilities: (input) => ({ + ...initialTransportHost.resolveProviderRequestCapabilities(input), + allowsAnthropicServiceTier: input.provider === "anthropic", + }), + }); +}); + +afterAll(() => { + configureAiTransportHost(initialTransportHost); +}); function runWrapper(apiKey: string | undefined): Record | undefined { const captured: { headers?: Record } = {}; diff --git a/extensions/azure-speech/speech-provider.ts b/extensions/azure-speech/speech-provider.ts index 495dfe63f173..8c7f2e01620c 100644 --- a/extensions/azure-speech/speech-provider.ts +++ b/extensions/azure-speech/speech-provider.ts @@ -92,7 +92,7 @@ function normalizeAzureSpeechProviderConfig( return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, - path: "messages.tts.providers.azure-speech.apiKey", + path: "tts.providers.azure-speech.apiKey", }), region, endpoint, diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index 92fdcfe3b7ec..d0c7763c3bd2 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -1400,7 +1400,11 @@ describe("browser tool snapshot maxChars", () => { it("defangs vision MEDIA-looking text and does not attach media", async () => { configMocks.loadConfig.mockReturnValue({ browser: {}, - tools: { media: { image: { models: [{ provider: "openai", model: "gpt-vision" }] } } }, + tools: { + media: { + models: [{ provider: "openai", model: "gpt-vision", capabilities: ["image"] }], + }, + }, } as never); browserActionsMocks.browserScreenshotAction.mockResolvedValueOnce({ ok: true, @@ -1439,7 +1443,11 @@ describe("browser tool snapshot maxChars", () => { it("defangs vision failure fallback text", async () => { configMocks.loadConfig.mockReturnValue({ browser: {}, - tools: { media: { image: { models: [{ provider: "openai", model: "gpt-vision" }] } } }, + tools: { + media: { + models: [{ provider: "openai", model: "gpt-vision", capabilities: ["image"] }], + }, + }, } as never); browserActionsMocks.browserScreenshotAction.mockResolvedValueOnce({ ok: true, @@ -1478,7 +1486,11 @@ describe("browser tool snapshot maxChars", () => { it("preserves screenshot image sanitization on vision failure fallback", async () => { configMocks.loadConfig.mockReturnValue({ browser: {}, - tools: { media: { image: { models: [{ provider: "openai", model: "gpt-vision" }] } } }, + tools: { + media: { + models: [{ provider: "openai", model: "gpt-vision", capabilities: ["image"] }], + }, + }, agents: { defaults: { imageMaxDimensionPx: 1600 } }, } as never); browserActionsMocks.browserScreenshotAction.mockResolvedValueOnce({ diff --git a/extensions/browser/src/browser/cdp-reachability-policy.ts b/extensions/browser/src/browser/cdp-reachability-policy.ts index 0032fadc5259..38266d17bf33 100644 --- a/extensions/browser/src/browser/cdp-reachability-policy.ts +++ b/extensions/browser/src/browser/cdp-reachability-policy.ts @@ -19,13 +19,13 @@ function withCdpControlHostname( if (!ssrfPolicy || !cdpHost) { return ssrfPolicy; } - const hostnameAllowlist = (ssrfPolicy.hostnameAllowlist ?? []) + const allowedHostnames = (ssrfPolicy.allowedHostnames ?? []) .map((pattern) => normalizeHostname(pattern)) .filter((pattern) => pattern && pattern !== "*" && pattern !== "*."); if ( requireAllowlistMatch && - hostnameAllowlist.length > 0 && - !matchesHostnameAllowlist(cdpHost, hostnameAllowlist) + allowedHostnames.length > 0 && + !matchesHostnameAllowlist(cdpHost, allowedHostnames) ) { return ssrfPolicy; } @@ -45,7 +45,7 @@ export function resolveCdpReachabilityPolicy( } // Configured local relays are control-plane endpoints even when page policy // excludes loopback. Remote CDP hosts must still satisfy an explicit - // hostnameAllowlist before their control policy is narrowed. + // allowedHostnames before their control policy is narrowed. return withCdpControlHostname(profile, ssrfPolicy, capabilities.isRemote); } diff --git a/extensions/browser/src/browser/cdp.helpers.test.ts b/extensions/browser/src/browser/cdp.helpers.test.ts index fcac8bc16008..d9adcf3988f9 100644 --- a/extensions/browser/src/browser/cdp.helpers.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.test.ts @@ -94,7 +94,7 @@ describe("cdp helpers", () => { await expect( assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", { dangerouslyAllowPrivateNetwork: false, - hostnameAllowlist: ["*.corp.example"], + allowedHostnames: ["*.corp.example"], }), ).resolves.toBeUndefined(); }); @@ -103,7 +103,7 @@ describe("cdp helpers", () => { await expect( assertCdpEndpointAllowed("http://172.29.128.1:9222/json/version", { dangerouslyAllowPrivateNetwork: false, - hostnameAllowlist: ["*.corp.example"], + allowedHostnames: ["*.corp.example"], }), ).rejects.toThrow("browser endpoint blocked by policy"); }); @@ -115,7 +115,6 @@ describe("cdp helpers", () => { { allowPrivateNetwork: true, allowedHostnames: ["browserless.example.com"], - hostnameAllowlist: ["browserless.example.com"], }, { source: "discovered", @@ -149,7 +148,6 @@ describe("cdp helpers", () => { await expect( assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", { allowedHostnames: ["api.example.com"], - hostnameAllowlist: ["api.example.com"], }), ).resolves.toBeUndefined(); }); @@ -195,7 +193,6 @@ describe("cdp helpers", () => { expect(request?.policy).toEqual({ dangerouslyAllowPrivateNetwork: false, allowedHostnames: ["127.0.0.1"], - hostnameAllowlist: ["127.0.0.1"], }); expect(release).toHaveBeenCalledTimes(1); }); @@ -236,7 +233,7 @@ describe("cdp helpers", () => { const controller = new AbortController(); const policy = { dangerouslyAllowPrivateNetwork: false, - hostnameAllowlist: ["1.1.1.1"], + allowedHostnames: ["1.1.1.1"], }; const resolveOwnership = resolveCdpTabOwnership as unknown as (params: { profileName: string; @@ -348,7 +345,7 @@ describe("cdp helpers", () => { await expect( fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, { dangerouslyAllowPrivateNetwork: false, - hostnameAllowlist: ["*.corp.example"], + allowedHostnames: ["*.corp.example"], }), ).resolves.toBeUndefined(); @@ -356,7 +353,6 @@ describe("cdp helpers", () => { expect(request?.url).toBe("http://127.0.0.1:9222/json/version"); expect(request?.policy).toEqual({ dangerouslyAllowPrivateNetwork: false, - hostnameAllowlist: ["127.0.0.1"], allowedHostnames: ["127.0.0.1"], }); expect(release).toHaveBeenCalledTimes(1); @@ -461,7 +457,6 @@ describe("CDP reachability policy", () => { expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toEqual({ allowedHostnames: ["172.29.128.1"], - hostnameAllowlist: ["172.29.128.1"], }); expect(browserPolicy).toStrictEqual({}); await expect( @@ -472,20 +467,16 @@ describe("CDP reachability policy", () => { ).rejects.toThrow(/private\/internal\/special-use ip address/i); }); - it("restricts remote CDP policy to the selected profile host", () => { + it("preserves navigation policy when it rejects the selected profile host", () => { const profile = createProfile({}); + const browserPolicy = { + allowedHostnames: ["metadata.internal"], + }; - expect( - resolveCdpReachabilityPolicy(profile, { - allowedHostnames: ["metadata.internal"], - }), - ).toEqual({ - allowedHostnames: ["172.29.128.1"], - hostnameAllowlist: ["172.29.128.1"], - }); + expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toBe(browserPolicy); }); - it("narrows permissive private-network policy to the selected CDP host", () => { + it("preserves a private-network policy that rejects the selected CDP host", () => { const profile = createProfile({}); const browserPolicy = { allowPrivateNetwork: true, @@ -493,11 +484,7 @@ describe("CDP reachability policy", () => { allowedOrigins: ["https://navigation.example"], }; - expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toEqual({ - allowPrivateNetwork: true, - allowedHostnames: ["172.29.128.1"], - hostnameAllowlist: ["172.29.128.1"], - }); + expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toBe(browserPolicy); expect(browserPolicy).toStrictEqual({ allowPrivateNetwork: true, allowedHostnames: ["metadata.internal"], @@ -507,16 +494,16 @@ describe("CDP reachability policy", () => { it("preserves a restrictive hostname allowlist that rejects the remote CDP host", async () => { const profile = createProfile({}); - const browserPolicy = { hostnameAllowlist: ["browserless.example.com"] }; + const browserPolicy = { allowedHostnames: ["browserless.example.com"] }; expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toBe(browserPolicy); - expect(browserPolicy).toStrictEqual({ hostnameAllowlist: ["browserless.example.com"] }); + expect(browserPolicy).toStrictEqual({ allowedHostnames: ["browserless.example.com"] }); await expect( assertBrowserNavigationAllowed({ url: "http://172.29.128.1/", ssrfPolicy: browserPolicy, }), - ).rejects.toThrow(/not in allowlist/i); + ).rejects.toThrow(/private\/internal\/special-use ip address/i); }); it("narrows an allowlisted remote CDP host to that exact control host", () => { @@ -524,11 +511,10 @@ describe("CDP reachability policy", () => { expect( resolveCdpReachabilityPolicy(profile, { - hostnameAllowlist: ["browserless.example.com", "172.29.128.1"], + allowedHostnames: ["browserless.example.com", "172.29.128.1"], allowedOrigins: ["https://navigation.example"], }), ).toEqual({ - hostnameAllowlist: ["172.29.128.1"], allowedHostnames: ["172.29.128.1"], }); }); @@ -541,10 +527,9 @@ describe("CDP reachability policy", () => { expect( resolveCdpReachabilityPolicy(profile, { - hostnameAllowlist: ["*.corp.example"], + allowedHostnames: ["*.corp.example"], }), ).toEqual({ - hostnameAllowlist: ["browser.corp.example"], allowedHostnames: ["browser.corp.example"], }); }); @@ -555,8 +540,7 @@ describe("CDP reachability policy", () => { cdpHost: "browser.example", }); - expect(resolveCdpReachabilityPolicy(profile, { hostnameAllowlist: [pattern] })).toEqual({ - hostnameAllowlist: ["browser.example"], + expect(resolveCdpReachabilityPolicy(profile, { allowedHostnames: [pattern] })).toEqual({ allowedHostnames: ["browser.example"], }); }); @@ -581,10 +565,9 @@ describe("CDP reachability policy", () => { expect( resolveCdpReachabilityPolicy(profile, { - hostnameAllowlist: ["*.corp.example"], + allowedHostnames: ["*.corp.example"], }), ).toEqual({ - hostnameAllowlist: ["127.0.0.1"], allowedHostnames: ["127.0.0.1"], }); }); diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts index a14402d4688e..99a0d30c4def 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -108,10 +108,13 @@ function assertDiscoveredCdpEndpointMatchesConfigured( configuredUrl: string, ssrfPolicy?: SsrFPolicy, ): void { + const hasExplicitAllowedHostnames = (ssrfPolicy?.allowedHostnames ?? []).some( + (hostname) => hostname.trim().length > 0, + ); if ( !ssrfPolicy || - isPrivateNetworkAllowedByPolicy(ssrfPolicy) || - cdpEndpointAuthority(discoveredUrl) === cdpEndpointAuthority(configuredUrl) + cdpEndpointAuthority(discoveredUrl) === cdpEndpointAuthority(configuredUrl) || + (!hasExplicitAllowedHostnames && isPrivateNetworkAllowedByPolicy(ssrfPolicy)) ) { return; } diff --git a/extensions/browser/src/browser/chrome-mcp.ownership.test.ts b/extensions/browser/src/browser/chrome-mcp.ownership.test.ts index 298d7daae413..93d5295135d8 100644 --- a/extensions/browser/src/browser/chrome-mcp.ownership.test.ts +++ b/extensions/browser/src/browser/chrome-mcp.ownership.test.ts @@ -254,7 +254,7 @@ describe("Chrome MCP durable tab ownership", () => { const controller = new AbortController(); const cdpPolicy = { dangerouslyAllowPrivateNetwork: false, - hostnameAllowlist: ["browser.example"], + allowedHostnames: ["browser.example"], }; const operationOptions = { signal: controller.signal, diff --git a/extensions/browser/src/browser/config-mutations.ts b/extensions/browser/src/browser/config-mutations.ts index 61b27d6ffebe..8d1039aacb1a 100644 --- a/extensions/browser/src/browser/config-mutations.ts +++ b/extensions/browser/src/browser/config-mutations.ts @@ -2,7 +2,7 @@ * Browser config mutation helpers. * * Persists browser-control credentials and profile config changes through the - * canonical config writer while preserving port/color allocation rules. + * canonical config writer while preserving port allocation rules. */ import { isDeepStrictEqual } from "node:util"; import { mutateConfigFile } from "../config/config.js"; @@ -20,7 +20,7 @@ import { BrowserResourceExhaustedError, BrowserValidationError, } from "./errors.js"; -import { allocateCdpPort, allocateColor, getUsedColors, getUsedPorts } from "./profiles.js"; +import { allocateCdpPort, getUsedPorts } from "./profiles.js"; type BrowserControlCredential = | { @@ -113,9 +113,6 @@ export async function createBrowserProfileConfig(params: { throw new BrowserConflictError(`profile "${params.name}" already exists`); } - const profileColor = - params.color ?? allocateColor(getUsedColors(latestProfileSource.profiles)); - let nextProfileConfig: BrowserProfileConfig; if (params.parsedCdpUrl) { try { @@ -127,14 +124,12 @@ export async function createBrowserProfileConfig(params: { cdpUrl: params.parsedCdpUrl, ...(params.driver ? { driver: params.driver } : {}), ...(params.driver === "existing-session" ? { attachOnly: true } : {}), - color: profileColor, }; } else if (params.driver === "existing-session") { nextProfileConfig = { driver: params.driver, attachOnly: true, ...(params.userDataDir ? { userDataDir: params.userDataDir } : {}), - color: profileColor, }; } else { const usedPorts = getUsedPorts(latestProfileSource.profiles); @@ -151,7 +146,6 @@ export async function createBrowserProfileConfig(params: { nextProfileConfig = { cdpPort, ...(params.driver ? { driver: params.driver } : {}), - color: profileColor, }; } diff --git a/extensions/browser/src/browser/config.test.ts b/extensions/browser/src/browser/config.test.ts index b0860aa1baa1..eb2e949d8d70 100644 --- a/extensions/browser/src/browser/config.test.ts +++ b/extensions/browser/src/browser/config.test.ts @@ -13,7 +13,7 @@ import { } from "./config.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; -const OPENCLAW_BROWSER_HEADLESS_ENV = "OPENCLAW_BROWSER_HEADLESS"; +const BROWSER_HEADLESS_ENV_KEY = "OPENCLAW_BROWSER_HEADLESS"; // Isolate the extension relay secret (read from stateDir/credentials) so the // extension-token assertions do not pick up a developer's real secret file. @@ -344,7 +344,7 @@ describe("browser config", () => { const noDisplayEnv = { DISPLAY: undefined, WAYLAND_DISPLAY: undefined, - [OPENCLAW_BROWSER_HEADLESS_ENV]: undefined, + [BROWSER_HEADLESS_ENV_KEY]: undefined, }; it("falls back to headless for local managed Linux profiles without display", () => { @@ -415,7 +415,7 @@ describe("browser config", () => { expect( resolveManagedBrowserHeadlessMode(resolved, profile, { platform: "linux", - env: { ...noDisplayEnv, [OPENCLAW_BROWSER_HEADLESS_ENV]: "1" }, + env: { ...noDisplayEnv, [BROWSER_HEADLESS_ENV_KEY]: "1" }, }), ).toEqual({ headless: true, source: "env" }); }); @@ -433,7 +433,7 @@ describe("browser config", () => { resolveManagedBrowserHeadlessMode(resolved, profile, { headlessOverride: true, platform: "linux", - env: { ...noDisplayEnv, [OPENCLAW_BROWSER_HEADLESS_ENV]: "0" }, + env: { ...noDisplayEnv, [BROWSER_HEADLESS_ENV_KEY]: "0" }, }), ).toEqual({ headless: true, source: "request" }); }); @@ -841,14 +841,12 @@ describe("browser config", () => { const resolved = resolveBrowserConfig({ ssrfPolicy: { allowPrivateNetwork: true, - allowedHostnames: [" localhost ", ""], - hostnameAllowlist: [" *.trusted.example ", " "], + allowedHostnames: [" localhost ", " *.trusted.example ", ""], }, } as unknown as BrowserConfig); expect(resolved.ssrfPolicy).toEqual({ dangerouslyAllowPrivateNetwork: true, - allowedHostnames: ["localhost"], - hostnameAllowlist: ["*.trusted.example"], + allowedHostnames: ["localhost", "*.trusted.example"], }); }); @@ -878,13 +876,11 @@ describe("browser config", () => { it("keeps allowlist-only browser SSRF policy strict by default", () => { const resolved = resolveBrowserConfig({ ssrfPolicy: { - allowedHostnames: ["example.com"], - hostnameAllowlist: ["*.example.com"], + allowedHostnames: ["example.com", "*.example.com"], }, } as unknown as BrowserConfig); expect(resolved.ssrfPolicy).toEqual({ - allowedHostnames: ["example.com"], - hostnameAllowlist: ["*.example.com"], + allowedHostnames: ["example.com", "*.example.com"], }); }); @@ -906,7 +902,6 @@ describe("browser config", () => { "chrome-live": { driver: "existing-session", attachOnly: true, - color: "#00AA00", }, }, }); @@ -919,7 +914,7 @@ describe("browser config", () => { cdpUrl: "", cdpHost: "", cdpIsLoopback: true, - color: "#00AA00", + color: "#FF4500", executablePath: undefined, headless: false, headlessSource: "default", diff --git a/extensions/browser/src/browser/config.ts b/extensions/browser/src/browser/config.ts index f349095ab9da..bbd1840ad6aa 100644 --- a/extensions/browser/src/browser/config.ts +++ b/extensions/browser/src/browser/config.ts @@ -140,7 +140,7 @@ const EXTENSION_RELAY_PORT_OFFSET = 8; /** Username half of the relay's Basic credential; the password is the derived token. */ const EXTENSION_RELAY_CDP_USER = "openclaw"; /** Environment variable that overrides managed Chrome headless mode. */ -const OPENCLAW_BROWSER_HEADLESS_ENV = "OPENCLAW_BROWSER_HEADLESS"; +const BROWSER_HEADLESS_ENV_KEY = "OPENCLAW_BROWSER_HEADLESS"; /** Source that determined managed Chrome headless mode. */ export type ManagedBrowserHeadlessSource = @@ -168,18 +168,6 @@ export type ManagedBrowserHeadlessOptions = { platform?: NodeJS.Platform; }; -function normalizeHexColor(raw: string | undefined): string { - const value = (raw ?? "").trim(); - if (!value) { - return DEFAULT_OPENCLAW_BROWSER_COLOR; - } - const normalized = value.startsWith("#") ? value : `#${value}`; - if (!/^#[0-9a-fA-F]{6}$/.test(normalized)) { - return DEFAULT_OPENCLAW_BROWSER_COLOR; - } - return normalized.toUpperCase(); -} - function normalizeExecutablePath(raw: string | undefined): string | undefined { const value = normalizeOptionalString(raw); if (!value) { @@ -249,18 +237,12 @@ function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | const allowPrivateNetwork = rawPolicy?.allowPrivateNetwork; const dangerouslyAllowPrivateNetwork = rawPolicy?.dangerouslyAllowPrivateNetwork; const allowedHostnames = normalizeStringList(rawPolicy?.allowedHostnames); - const hostnameAllowlist = normalizeStringList(rawPolicy?.hostnameAllowlist); const hasExplicitPrivateSetting = allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined; const resolvedAllowPrivateNetwork = dangerouslyAllowPrivateNetwork === true || allowPrivateNetwork === true; - if ( - !resolvedAllowPrivateNetwork && - !hasExplicitPrivateSetting && - !allowedHostnames && - !hostnameAllowlist - ) { + if (!resolvedAllowPrivateNetwork && !hasExplicitPrivateSetting && !allowedHostnames) { // Keep the default policy object present so CDP guards still enforce // fail-closed private-network checks on unconfigured installs. return {}; @@ -273,13 +255,11 @@ function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | ? { dangerouslyAllowPrivateNetwork: resolvedAllowPrivateNetwork } : {}), ...(allowedHostnames ? { allowedHostnames } : {}), - ...(hostnameAllowlist ? { hostnameAllowlist } : {}), }; } function ensureDefaultProfile( profiles: Record | undefined, - defaultColor: string, legacyCdpPort?: number, derivedDefaultCdpPort?: number, legacyCdpUrl?: string, @@ -288,7 +268,6 @@ function ensureDefaultProfile( if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) { result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = { cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? DEFAULT_BROWSER_CDP_PORT_RANGE_START, - color: defaultColor, ...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}), }; } @@ -305,7 +284,6 @@ function ensureDefaultUserBrowserProfile( result.user = { driver: "existing-session", attachOnly: true, - color: "#00AA00", }; return result; } @@ -320,7 +298,6 @@ function ensureDefaultChromeExtensionProfile( } result.chrome = { driver: "extension", - color: DEFAULT_OPENCLAW_BROWSER_COLOR, }; return result; } @@ -381,7 +358,6 @@ export function resolveBrowserConfig( const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED; const gatewayPort = resolveGatewayPort(rootConfig); const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT); - const defaultColor = normalizeHexColor(cfg?.color); const remoteCdpTimeoutMs = DEFAULT_BROWSER_REMOTE_CDP_TIMEOUT_MS; const remoteCdpHandshakeTimeoutMs = DEFAULT_BROWSER_REMOTE_CDP_HANDSHAKE_TIMEOUT_MS; const localLaunchTimeoutMs = DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS; @@ -432,13 +408,7 @@ export function resolveBrowserConfig( const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined; let profiles = ensureDefaultChromeExtensionProfile( ensureDefaultUserBrowserProfile( - ensureDefaultProfile( - cfg?.profiles, - defaultColor, - legacyCdpPort, - cdpPortRangeStart, - legacyCdpUrl, - ), + ensureDefaultProfile(cfg?.profiles, legacyCdpPort, cdpPortRangeStart, legacyCdpUrl), ), ); const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http"; @@ -476,7 +446,7 @@ export function resolveBrowserConfig( localLaunchTimeoutMs, localCdpReadyTimeoutMs, actionTimeoutMs, - color: defaultColor, + color: DEFAULT_OPENCLAW_BROWSER_COLOR, executablePath, headless, headlessSource, @@ -540,7 +510,7 @@ export function resolveProfile( cdpUrl: relayCdpUrl, cdpHost: "127.0.0.1", cdpIsLoopback: true, - color: profile.color, + color: DEFAULT_OPENCLAW_BROWSER_COLOR, driver, executablePath, headless: false, @@ -560,7 +530,7 @@ export function resolveProfile( userDataDir: resolveUserPath(profile.userDataDir?.trim() || "") || undefined, mcpCommand: normalizeOptionalString(profile.mcpCommand), mcpArgs: normalizeStringList(profile.mcpArgs) ?? undefined, - color: profile.color, + color: DEFAULT_OPENCLAW_BROWSER_COLOR, driver, executablePath, headless, @@ -608,7 +578,7 @@ export function resolveProfile( cdpUrl, cdpHost, cdpIsLoopback: isLoopbackHost(cdpHost), - color: profile.color, + color: DEFAULT_OPENCLAW_BROWSER_COLOR, driver, executablePath, headless, @@ -633,7 +603,7 @@ export function resolveManagedBrowserHeadlessMode( const env = params.env ?? process.env; const platform = params.platform ?? process.platform; - const envHeadless = parseBooleanValue(env[OPENCLAW_BROWSER_HEADLESS_ENV]); + const envHeadless = parseBooleanValue(env[BROWSER_HEADLESS_ENV_KEY]); if (envHeadless !== undefined) { return { headless: envHeadless, source: "env" }; } @@ -678,7 +648,7 @@ export function getManagedBrowserMissingDisplayError( mode.source === "request" ? "request override" : mode.source === "env" - ? `${OPENCLAW_BROWSER_HEADLESS_ENV}=0` + ? `${BROWSER_HEADLESS_ENV_KEY}=0` : mode.source === "profile" ? `browser.profiles.${profile.name}.headless=false` : "browser.headless=false"; @@ -686,7 +656,7 @@ export function getManagedBrowserMissingDisplayError( message: `Headed browser start requested for profile "${profile.name}" via ${sourceHint}, ` + "but no Linux display server was detected ($DISPLAY/$WAYLAND_DISPLAY unset). " + - `Set ${OPENCLAW_BROWSER_HEADLESS_ENV}=1, remove the headed override, or launch under Xvfb.`, + `Set ${BROWSER_HEADLESS_ENV_KEY}=1, remove the headed override, or launch under Xvfb.`, headlessSource: mode.source, }; } diff --git a/extensions/browser/src/browser/navigation-guard.test.ts b/extensions/browser/src/browser/navigation-guard.test.ts index bb3cf2b276bd..bf1633ddd2c1 100644 --- a/extensions/browser/src/browser/navigation-guard.test.ts +++ b/extensions/browser/src/browser/navigation-guard.test.ts @@ -162,7 +162,7 @@ describe("browser navigation guard", () => { lookupFn, ssrfPolicy: { dangerouslyAllowPrivateNetwork: false, - hostnameAllowlist: ["*.example.com"], + allowedHostnames: ["*.example.com"], }, }), ).resolves.toBeUndefined(); @@ -176,7 +176,7 @@ describe("browser navigation guard", () => { lookupFn, ssrfPolicy: { dangerouslyAllowPrivateNetwork: false, - hostnameAllowlist: ["*.example.com"], + allowedHostnames: ["*.example.com"], }, }), ).rejects.toThrow(/dns rebinding protections are unavailable/i); @@ -191,7 +191,7 @@ describe("browser navigation guard", () => { lookupFn, ssrfPolicy: { dangerouslyAllowPrivateNetwork: false, - hostnameAllowlist: ["*.example.com"], + allowedHostnames: ["*.example.com"], }, }), ).rejects.toThrow(/dns rebinding protections are unavailable/i); diff --git a/extensions/browser/src/browser/navigation-guard.ts b/extensions/browser/src/browser/navigation-guard.ts index 416b97fcdad7..5f8e57680cc3 100644 --- a/extensions/browser/src/browser/navigation-guard.ts +++ b/extensions/browser/src/browser/navigation-guard.ts @@ -107,15 +107,11 @@ function isIpLiteralHostname(hostname: string): boolean { function isExplicitlyAllowedBrowserHostname(hostname: string, ssrfPolicy?: SsrFPolicy): boolean { const normalizedHostname = normalizeHostname(hostname); - const exactMatches = ssrfPolicy?.allowedHostnames ?? []; - if (exactMatches.some((value) => normalizeHostname(value) === normalizedHostname)) { - return true; - } - const hostnameAllowlist = (ssrfPolicy?.hostnameAllowlist ?? []) + const allowedHostnames = (ssrfPolicy?.allowedHostnames ?? []) .map((pattern) => normalizeHostname(pattern)) .filter(Boolean); - return hostnameAllowlist.length > 0 - ? matchesHostnameAllowlist(normalizedHostname, hostnameAllowlist) + return allowedHostnames.length > 0 + ? matchesHostnameAllowlist(normalizedHostname, allowedHostnames) : false; } diff --git a/extensions/browser/src/browser/profiles-service.test.ts b/extensions/browser/src/browser/profiles-service.test.ts index 4c40c4dede1e..28233172e34a 100644 --- a/extensions/browser/src/browser/profiles-service.test.ts +++ b/extensions/browser/src/browser/profiles-service.test.ts @@ -406,7 +406,6 @@ describe("BrowserProfilesService", () => { const resolvedProfile = state.resolved.profiles["chrome-live"]; expect(resolvedProfile?.driver).toBe("existing-session"); expect(resolvedProfile?.attachOnly).toBe(true); - expect(typeof resolvedProfile?.color).toBe("string"); const profiles = writtenBrowserConfig().profiles as Record< string, { attachOnly?: boolean; driver?: string } @@ -490,7 +489,6 @@ describe("BrowserProfilesService", () => { expect(resolvedProfile?.driver).toBe("existing-session"); expect(resolvedProfile?.attachOnly).toBe(true); expect(resolvedProfile?.userDataDir).toBe(userDataDir); - expect(typeof resolvedProfile?.color).toBe("string"); }); it("rejects userDataDir for non-existing-session profiles", async () => { diff --git a/extensions/browser/src/browser/profiles.test.ts b/extensions/browser/src/browser/profiles.test.ts index 738e4412a5f1..2175152e15a8 100644 --- a/extensions/browser/src/browser/profiles.test.ts +++ b/extensions/browser/src/browser/profiles.test.ts @@ -1,30 +1,10 @@ // Browser tests cover profiles plugin behavior. -import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it } from "vitest"; import { resolveBrowserConfig } from "./config.js"; -import { - allocateCdpPort, - allocateColor, - getUsedColors, - getUsedPorts, - isValidProfileName, -} from "./profiles.js"; +import { allocateCdpPort, getUsedPorts, isValidProfileName } from "./profiles.js"; const CDP_PORT_RANGE_START = 18800; const CDP_PORT_RANGE_END = 18899; -const PROFILE_COLORS = [ - "#FF4500", - "#0066CC", - "#00AA00", - "#9933FF", - "#FF6699", - "#00CCCC", - "#FF9900", - "#6666FF", - "#CC3366", - "#339966", -]; - describe("profile name validation", () => { it.each(["openclaw", "work", "my-profile", "test123", "a", "a-b-c-1-2-3", "1test"])( "accepts valid lowercase name: %s", @@ -196,70 +176,3 @@ describe("port collision prevention", () => { expect(fixedAllocatedPort).toBe(CDP_PORT_RANGE_START + 1); }); }); - -describe("color allocation", () => { - it("allocates next unused color from palette", () => { - const first = expectDefined(PROFILE_COLORS[0], "first browser profile color"); - const second = expectDefined(PROFILE_COLORS[1], "second browser profile color"); - const third = expectDefined(PROFILE_COLORS[2], "third browser profile color"); - const fourth = expectDefined(PROFILE_COLORS[3], "fourth browser profile color"); - const cases = [ - { name: "none used", used: new Set(), expected: first }, - { - name: "first color used", - used: new Set([first.toUpperCase()]), - expected: second, - }, - { - name: "multiple used colors", - used: new Set([first.toUpperCase(), second.toUpperCase(), third.toUpperCase()]), - expected: fourth, - }, - ] as const; - for (const testCase of cases) { - expect(allocateColor(testCase.used), testCase.name).toBe(testCase.expected); - } - }); - - it("handles case-insensitive color matching", () => { - const usedColors = new Set(["#ff4500"]); // lowercase - // Should still skip this color (case-insensitive) - // Note: allocateColor compares against uppercase, so lowercase won't match - // This tests the current behavior - expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[0]); // returns first since lowercase doesn't match - }); - - it("cycles when all colors are used", () => { - const usedColors = new Set(PROFILE_COLORS.map((c) => c.toUpperCase())); - // Should cycle based on count - const result = allocateColor(usedColors); - expect(PROFILE_COLORS).toContain(result); - }); - - it("cycles based on count when palette exhausted", () => { - // Add all colors plus some extras - const usedColors = new Set([ - ...PROFILE_COLORS.map((c) => c.toUpperCase()), - "#AAAAAA", - "#BBBBBB", - ]); - const result = allocateColor(usedColors); - // Index should be (10 + 2) % 10 = 2 - expect(result).toBe(PROFILE_COLORS[2]); - }); -}); - -describe("getUsedColors", () => { - it("returns empty set when no color profiles are configured", () => { - expect(getUsedColors(undefined)).toEqual(new Set()); - }); - - it("extracts and uppercases colors from profile configs", () => { - const profiles = { - openclaw: { color: "#ff4500" }, - work: { color: "#0066CC" }, - }; - const used = getUsedColors(profiles); - expect(used).toEqual(new Set(["#FF4500", "#0066CC"])); - }); -}); diff --git a/extensions/browser/src/browser/profiles.ts b/extensions/browser/src/browser/profiles.ts index 3be547a9c77f..34d94ead60d4 100644 --- a/extensions/browser/src/browser/profiles.ts +++ b/extensions/browser/src/browser/profiles.ts @@ -5,7 +5,6 @@ import { parseBrowserHttpUrl } from "openclaw/plugin-sdk/browser-config"; * Validates profile names and allocates CDP ports/colors for newly persisted * browser profiles. */ -import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; /** * CDP port allocation for browser profiles. @@ -87,40 +86,3 @@ export function getUsedPorts( } return used; } - -/** Default browser profile color palette. */ -const PROFILE_COLORS = [ - "#FF4500", // Orange-red (openclaw default) - "#0066CC", // Blue - "#00AA00", // Green - "#9933FF", // Purple - "#FF6699", // Pink - "#00CCCC", // Cyan - "#FF9900", // Orange - "#6666FF", // Indigo - "#CC3366", // Magenta - "#339966", // Teal -]; - -/** Allocate the first unused profile color, cycling when all are used. */ -export function allocateColor(usedColors: Set): string { - // Find first unused color from palette - for (const color of PROFILE_COLORS) { - if (!usedColors.has(color.toUpperCase())) { - return color; - } - } - // All colors used, cycle based on count - const index = usedColors.size % PROFILE_COLORS.length; - return expectDefined(PROFILE_COLORS[index], "cycled browser color palette index"); -} - -/** Extract currently used profile colors from profile config. */ -export function getUsedColors( - profiles: Record | undefined, -): Set { - if (!profiles) { - return new Set(); - } - return new Set(Object.values(profiles).map((p) => p.color.toUpperCase())); -} diff --git a/extensions/browser/src/browser/pw-session.connections.test.ts b/extensions/browser/src/browser/pw-session.connections.test.ts index d6efb0ff5e95..41a0951c41d8 100644 --- a/extensions/browser/src/browser/pw-session.connections.test.ts +++ b/extensions/browser/src/browser/pw-session.connections.test.ts @@ -217,7 +217,7 @@ describe("pw-session connection scoping", () => { getChromeWebSocketUrlSpy.mockResolvedValue(null); const ssrfPolicy = { dangerouslyAllowPrivateNetwork: true, - hostnameAllowlist: ["example.com"], + allowedHostnames: ["example.com"], }; const page = await getPageForTargetId({ @@ -229,7 +229,7 @@ describe("pw-session connection scoping", () => { expect(connectOverCdpSpy).toHaveBeenCalledTimes(1); expect(ssrfPolicy).toStrictEqual({ dangerouslyAllowPrivateNetwork: true, - hostnameAllowlist: ["example.com"], + allowedHostnames: ["example.com"], }); }); diff --git a/extensions/browser/src/browser/routes/permissions.test.ts b/extensions/browser/src/browser/routes/permissions.test.ts index d70420d895ac..66d7f068653c 100644 --- a/extensions/browser/src/browser/routes/permissions.test.ts +++ b/extensions/browser/src/browser/routes/permissions.test.ts @@ -271,7 +271,6 @@ describe("browser permission routes", () => { { allowPrivateNetwork: true, allowedHostnames: ["browser.example"], - hostnameAllowlist: ["browser.example"], }, ); }); diff --git a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts index b94bf7c8f27d..28363abf8e71 100644 --- a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts +++ b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts @@ -562,7 +562,6 @@ describe("browser server-context ensureBrowserAvailable", () => { { allowPrivateNetwork: true, allowedHostnames: ["browserless"], - hostnameAllowlist: ["browserless"], }, ); expect(isChromeCdpReady).toHaveBeenNthCalledWith( @@ -573,7 +572,6 @@ describe("browser server-context ensureBrowserAvailable", () => { { allowPrivateNetwork: true, allowedHostnames: ["browserless"], - hostnameAllowlist: ["browserless"], }, ); expect(launchOpenClawChrome).not.toHaveBeenCalled(); diff --git a/extensions/browser/src/browser/server-context.loopback-direct-ws.test.ts b/extensions/browser/src/browser/server-context.loopback-direct-ws.test.ts index e16dd5f1318a..aaf0914f52da 100644 --- a/extensions/browser/src/browser/server-context.loopback-direct-ws.test.ts +++ b/extensions/browser/src/browser/server-context.loopback-direct-ws.test.ts @@ -171,7 +171,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => { const state = makeState("openclaw"); state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false, - hostnameAllowlist: ["browserless.example.com"], + allowedHostnames: ["browserless.example.com"], }; state.resolved.profiles.openclaw = { cdpUrl: "ws://10.0.0.42:18800/devtools/browser/SESSION?token=abc", diff --git a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts index 2de3196723a7..bc334839a611 100644 --- a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts +++ b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.fallback.test.ts @@ -304,7 +304,6 @@ describe("browser remote profile fallback and attachOnly behavior", () => { ssrfPolicy: { allowPrivateNetwork: true, allowedHostnames: ["1.1.1.1"], - hostnameAllowlist: ["1.1.1.1"], }, waitForNavigationResult: true, timeouts: { diff --git a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts index 55b135e9ffee..b7868b1dcc1e 100644 --- a/extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts +++ b/extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts @@ -33,7 +33,6 @@ async function expectBlockedCdpEndpoint(promise: Promise) { const permissiveRemoteCdpPolicy = { allowPrivateNetwork: true, allowedHostnames: ["1.1.1.1"], - hostnameAllowlist: ["1.1.1.1"], }; describe("browser remote profile tab ops via Playwright", () => { @@ -469,7 +468,7 @@ describe("browser remote profile tab ops via Playwright", () => { const state = deps.makeState("remote"); state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false, - hostnameAllowlist: ["browserless.example.com"], + allowedHostnames: ["browserless.example.com"], }; const remoteProfile = expectDefined(state.resolved.profiles.remote, "remote browser profile"); state.resolved.profiles.remote = { diff --git a/extensions/browser/src/browser/ssrf-policy-helpers.ts b/extensions/browser/src/browser/ssrf-policy-helpers.ts index 633762085b78..c5ce3961c9fe 100644 --- a/extensions/browser/src/browser/ssrf-policy-helpers.ts +++ b/extensions/browser/src/browser/ssrf-policy-helpers.ts @@ -12,6 +12,5 @@ export function withExactHostnamePolicy( return { ...basePolicy, allowedHostnames: [hostname], - hostnameAllowlist: [hostname], }; } diff --git a/extensions/browser/src/browser/vision.test.ts b/extensions/browser/src/browser/vision.test.ts index f8df85cc8571..9222b0258deb 100644 --- a/extensions/browser/src/browser/vision.test.ts +++ b/extensions/browser/src/browser/vision.test.ts @@ -54,7 +54,9 @@ describe("describeBrowserScreenshot", () => { { cfg: { tools: { - media: { image: { models: [{ provider: "openai", model: "gpt-vision" }] } }, + media: { + models: [{ provider: "openai", model: "gpt-vision", capabilities: ["image"] }], + }, }, }, filePath, @@ -77,9 +79,7 @@ describe("describeBrowserScreenshot", () => { cfg: { tools: { media: { - image: { - models: [{ provider: "openai", model: "gpt-vision" }], - }, + models: [{ provider: "openai", model: "gpt-vision", capabilities: ["image"] }], }, }, }, @@ -141,7 +141,9 @@ describe("describeBrowserScreenshot", () => { { cfg: { tools: { - media: { image: { models: [{ provider: "openai", model: "gpt-vision" }] } }, + media: { + models: [{ provider: "openai", model: "gpt-vision", capabilities: ["image"] }], + }, }, }, filePath: "/tmp/screenshot.png", diff --git a/extensions/codex/src/app-server/bounded-turn.ts b/extensions/codex/src/app-server/bounded-turn.ts index 6275c4c3ca1c..6a49e519337c 100644 --- a/extensions/codex/src/app-server/bounded-turn.ts +++ b/extensions/codex/src/app-server/bounded-turn.ts @@ -33,7 +33,7 @@ import { import { buildCodexRuntimeThreadConfig } from "./thread-lifecycle.js"; const CODEX_PRIVATE_STDIO_ARGS = ["app-server", "--listen", "stdio://"]; -const OPENCLAW_CODEX_APP_SERVER_ARGS_ENV_VAR = "OPENCLAW_CODEX_APP_SERVER_ARGS"; +const CODEX_APP_SERVER_ARGS_ENV_KEY = "OPENCLAW_CODEX_APP_SERVER_ARGS"; const CODEX_BOUNDED_THREAD_CONFIG: JsonObject = { "features.multi_agent": false, "features.apps": false, @@ -267,12 +267,12 @@ function buildPrivateCodexAppServerStartOptions( ): ReturnType["start"] { const privateEnv = Object.fromEntries( Object.entries(start.env ?? {}).filter( - ([name]) => name.trim().toUpperCase() !== OPENCLAW_CODEX_APP_SERVER_ARGS_ENV_VAR, + ([name]) => name.trim().toUpperCase() !== CODEX_APP_SERVER_ARGS_ENV_KEY, ), ); const clearEnv = (start.clearEnv ?? []).filter((name) => { const normalized = name.trim().toUpperCase(); - return normalized !== "CODEX_HOME" && normalized !== OPENCLAW_CODEX_APP_SERVER_ARGS_ENV_VAR; + return normalized !== "CODEX_HOME" && normalized !== CODEX_APP_SERVER_ARGS_ENV_KEY; }); return { ...start, @@ -281,7 +281,7 @@ function buildPrivateCodexAppServerStartOptions( ...privateEnv, CODEX_HOME: codexHome, }, - clearEnv: [...clearEnv, OPENCLAW_CODEX_APP_SERVER_ARGS_ENV_VAR], + clearEnv: [...clearEnv, CODEX_APP_SERVER_ARGS_ENV_KEY], }; } diff --git a/extensions/codex/src/app-server/dynamic-tool-execution.test.ts b/extensions/codex/src/app-server/dynamic-tool-execution.test.ts index 9e1b192e8f56..d26803007a85 100644 --- a/extensions/codex/src/app-server/dynamic-tool-execution.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-execution.test.ts @@ -147,6 +147,54 @@ describe("dynamic tool execution helpers", () => { }, }), ).toBe(180_000); + expect( + resolveDynamicToolCallTimeoutMs({ + call: { + threadId: "thread-1", + turnId: "turn-1", + callId: "call-image-capability-default", + namespace: null, + tool: "image", + arguments: { prompt: "describe", images: ["/tmp/one.jpg"] }, + }, + config: { + tools: { + media: { + models: [{ provider: "openai", model: "vision", capabilities: ["image"] }], + image: { timeoutSeconds: 180 }, + }, + }, + }, + }), + ).toBe(180_000); + expect( + resolveDynamicToolCallTimeoutMs({ + call: { + threadId: "thread-1", + turnId: "turn-1", + callId: "call-image-mixed-timeouts", + namespace: null, + tool: "image", + arguments: { prompt: "describe", images: ["/tmp/one.jpg"] }, + }, + config: { + tools: { + media: { + models: [ + { provider: "openai", model: "inherited", capabilities: ["image"] }, + { + provider: "openai", + model: "short", + capabilities: ["image"], + timeoutSeconds: 60, + }, + ], + image: { timeoutSeconds: 180 }, + }, + }, + }, + }), + ).toBe(180_000); }); it("uses default media and message dynamic tool deadlines", () => { @@ -248,9 +296,11 @@ describe("dynamic tool execution helpers", () => { config: { tools: { media: { - image: { - timeoutSeconds: 180, - }, + models: [ + { provider: "openai", model: "short", timeoutSeconds: 60, capabilities: ["image"] }, + { provider: "openai", model: "long", timeoutSeconds: 180, capabilities: ["image"] }, + ], + image: { preferredModel: "openai/long" }, }, }, }, diff --git a/extensions/codex/src/app-server/dynamic-tool-execution.ts b/extensions/codex/src/app-server/dynamic-tool-execution.ts index e2fed9814ab4..dc3dd0d93a86 100644 --- a/extensions/codex/src/app-server/dynamic-tool-execution.ts +++ b/extensions/codex/src/app-server/dynamic-tool-execution.ts @@ -565,9 +565,18 @@ function readConfiguredDynamicToolTimeoutMs( } if (toolName === "image") { - return ( - readTimeoutSecondsAsMs(config?.tools?.media?.image?.timeoutSeconds) ?? - CODEX_DYNAMIC_IMAGE_TOOL_TIMEOUT_MS + const candidates = (config?.tools?.media?.models ?? []).filter( + (entry) => !entry.capabilities || entry.capabilities.includes("image"), + ); + const capabilityTimeoutMs = readTimeoutSecondsAsMs(config?.tools?.media?.image?.timeoutSeconds); + return Math.max( + capabilityTimeoutMs ?? CODEX_DYNAMIC_IMAGE_TOOL_TIMEOUT_MS, + ...candidates.map( + (entry) => + readTimeoutSecondsAsMs(entry.timeoutSeconds) ?? + capabilityTimeoutMs ?? + CODEX_DYNAMIC_IMAGE_TOOL_TIMEOUT_MS, + ), ); } diff --git a/extensions/codex/src/app-server/run-attempt.hooks.test.ts b/extensions/codex/src/app-server/run-attempt.hooks.test.ts index c7a3a38d5ce3..07a8b594ea5f 100644 --- a/extensions/codex/src/app-server/run-attempt.hooks.test.ts +++ b/extensions/codex/src/app-server/run-attempt.hooks.test.ts @@ -319,12 +319,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => { otel: { enabled: true, traces: true, - captureContent: { - enabled: true, - inputMessages: true, - outputMessages: true, - systemPrompt: true, - }, + captureContent: true, }, }, } as never; @@ -354,9 +349,7 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => { const startedContent = diagnosticContentByType.get("model.call.started")?.modelContent; expect(JSON.stringify(startedContent?.inputMessages)).toContain("hello"); expect(JSON.stringify(startedContent?.inputMessages)).not.toContain("existing context"); - expect(startedContent?.systemPrompt).toContain( - "You are a personal agent running inside OpenClaw.", - ); + expect(startedContent?.systemPrompt).toBeUndefined(); expect(completed).toMatchObject({ callId: expectedCallId, observationUnit: "turn" }); expect(JSON.stringify(completed)).not.toContain("hello back"); expect( diff --git a/extensions/discord/src/config-schema.test.ts b/extensions/discord/src/config-schema.test.ts index 4e03bfa3491f..4952081c2696 100644 --- a/extensions/discord/src/config-schema.test.ts +++ b/extensions/discord/src/config-schema.test.ts @@ -361,7 +361,6 @@ describe("discord config schema", () => { enabled: true, intervalMs: 30000, minUpdateIntervalMs: 15000, - exhaustedText: "token exhausted", }, }, }, diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index 8b551f24f8b5..e9f8c4a27e1f 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -98,14 +98,6 @@ export const discordChannelConfigUiHints = { label: "Discord Thread Spawn Context", help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".', }, - subagentProgress: { - label: "Discord Subagent Progress", - help: "Show active subagent count reactions and typing on the source message. Default: false.", - }, - "ui.components.accentColor": { - label: "Discord Component Accent Color", - help: "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor.", - }, "agentComponents.ttlMs": { label: "Discord Component TTL (ms)", help: "How long sent Discord component callbacks remain registered. Default is 1800000 (30 minutes); maximum is 86400000 (24 hours).", @@ -229,7 +221,7 @@ export const discordChannelConfigUiHints = { }, "voice.tts": { label: "Discord Voice Text-to-Speech", - help: "Optional TTS overrides for Discord voice playback (merged with messages.tts).", + help: "Optional TTS overrides for Discord voice playback (merged with tts).", }, "pluralkit.enabled": { label: "Discord PluralKit Enabled", @@ -259,18 +251,6 @@ export const discordChannelConfigUiHints = { label: "Discord Auto Presence Min Update Interval (ms)", help: "Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes.", }, - "autoPresence.healthyText": { - label: "Discord Auto Presence Healthy Text", - help: "Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set.", - }, - "autoPresence.degradedText": { - label: "Discord Auto Presence Degraded Text", - help: "Optional custom status text while runtime/model availability is degraded or unknown (idle).", - }, - "autoPresence.exhaustedText": { - label: "Discord Auto Presence Exhausted Text", - help: "Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder.", - }, "guilds.*.presenceEvents": { label: "Discord Online Presence Events", help: "Route selected human offline-to-online transitions into the configured guild channel as agent system events. Requires the Guild Presences privileged intent and an enabled agent heartbeat.", diff --git a/extensions/discord/src/monitor/acp-bind-here.integration.test.ts b/extensions/discord/src/monitor/acp-bind-here.integration.test.ts index f1d16ea725e0..ae0b787fcda2 100644 --- a/extensions/discord/src/monitor/acp-bind-here.integration.test.ts +++ b/extensions/discord/src/monitor/acp-bind-here.integration.test.ts @@ -35,6 +35,9 @@ const baseCfg = { session: { mainKey: "main", scope: "per-sender", + threadBindings: { + enabled: true, + }, }, acp: { enabled: true, @@ -43,13 +46,6 @@ const baseCfg = { }, backend: "acpx", }, - channels: { - discord: { - threadBindings: { - enabled: true, - }, - }, - }, } satisfies OpenClawConfig; function createDmClient(channelId: string): DiscordClient { diff --git a/extensions/discord/src/monitor/agent-components-guild-auth.ts b/extensions/discord/src/monitor/agent-components-guild-auth.ts index bb5291f04058..69ee66655796 100644 --- a/extensions/discord/src/monitor/agent-components-guild-auth.ts +++ b/extensions/discord/src/monitor/agent-components-guild-auth.ts @@ -307,7 +307,7 @@ export async function resolveComponentCommandAuthorized(params: { }, allowNameMatching: params.allowNameMatching, }); - const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false; + const useAccessGroups = true; const authorizers = useAccessGroups ? [ { configured: ownerAllowList != null, allowed: ownerOk }, diff --git a/extensions/discord/src/monitor/auto-presence.ts b/extensions/discord/src/monitor/auto-presence.ts index cc3190bdb48b..0a3c7896f6ec 100644 --- a/extensions/discord/src/monitor/auto-presence.ts +++ b/extensions/discord/src/monitor/auto-presence.ts @@ -28,9 +28,6 @@ type ResolvedDiscordAutoPresenceConfig = { enabled: boolean; intervalMs: number; minUpdateIntervalMs: number; - healthyText?: string; - degradedText?: string; - exhaustedText?: string; }; type DiscordAutoPresenceDecision = { @@ -44,14 +41,6 @@ type PresenceGateway = { updatePresence: (payload: UpdatePresenceData) => void; }; -function normalizeOptionalText(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - function clampPositiveInt(value: unknown, fallback: number, minValue: number): number { if (typeof value !== "number" || !Number.isFinite(value)) { return fallback; @@ -77,9 +66,6 @@ function resolveAutoPresenceConfig( enabled: config?.enabled === true, intervalMs, minUpdateIntervalMs, - healthyText: normalizeOptionalText(config?.healthyText), - degradedText: normalizeOptionalText(config?.degradedText), - exhaustedText: normalizeOptionalText(config?.exhaustedText), }; } @@ -168,14 +154,11 @@ function resolvePresenceActivities(params: { const reasonLabel = formatUnavailableReason(params.unavailableReason ?? null); if (params.state === "healthy") { - if (params.cfg.healthyText) { - return [buildCustomStatusActivity(params.cfg.healthyText)]; - } return params.basePresence?.activities ?? []; } if (params.state === "degraded") { - const template = params.cfg.degradedText ?? "runtime degraded"; + const template = "runtime degraded"; const text = renderTemplate(template, { reason: reasonLabel }); return text ? [buildCustomStatusActivity(text)] : []; } @@ -183,7 +166,7 @@ function resolvePresenceActivities(params: { const defaultTemplate = isExhaustedUnavailableReason(params.unavailableReason ?? null) ? "token exhausted" : "model unavailable ({reason})"; - const template = params.cfg.exhaustedText ?? defaultTemplate; + const template = defaultTemplate; const text = renderTemplate(template, { reason: reasonLabel }); return text ? [buildCustomStatusActivity(text)] : []; } diff --git a/extensions/discord/src/monitor/dm-command-auth.test.ts b/extensions/discord/src/monitor/dm-command-auth.test.ts index 188e2eddb497..2152afa36fed 100644 --- a/extensions/discord/src/monitor/dm-command-auth.test.ts +++ b/extensions/discord/src/monitor/dm-command-auth.test.ts @@ -71,22 +71,6 @@ describe("resolveDiscordTextCommandAccess", () => { expect(result.authorized).toBe(false); expect(result.shouldBlockControlCommand).toBe(true); }); - - it("preserves configured mode when access groups are disabled", async () => { - const result = await resolveDiscordTextCommandAccess({ - accountId: "default", - sender, - ownerAllowFrom: [], - memberAccessConfigured: false, - memberAllowed: false, - allowNameMatching: false, - cfg: { commands: { useAccessGroups: false } }, - allowTextCommands: true, - hasControlCommand: true, - }); - expect(result.authorized).toBe(true); - expect(result.shouldBlockControlCommand).toBe(false); - }); }); describe("resolveDiscordDmCommandAccess", () => { @@ -283,7 +267,7 @@ describe("resolveDiscordDmCommandAccess", () => { configuredAllowFrom: [], sender, allowNameMatching: false, - cfg: { commands: { useAccessGroups: false } }, + cfg: {}, readStoreAllowFrom: async () => [], }); diff --git a/extensions/discord/src/monitor/message-handler.process-reactions.ts b/extensions/discord/src/monitor/message-handler.process-reactions.ts index d2535dfacdb1..26f8f0c43140 100644 --- a/extensions/discord/src/monitor/message-handler.process-reactions.ts +++ b/extensions/discord/src/monitor/message-handler.process-reactions.ts @@ -60,7 +60,7 @@ export function createDiscordMessageReactionRuntime(params: { channel: "discord", accountId, }); - const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; + const removeAckAfterReply = false; const shouldSendAckReaction = Boolean( ackReaction && shouldAckReactionGate({ @@ -101,7 +101,7 @@ export function createDiscordMessageReactionRuntime(params: { enabled: statusReactionsEnabled, adapter: discordAdapter, initialEmoji: ackReaction, - emojis: cfg.messages?.statusReactions?.emojis, + emojis: undefined, timing: statusReactionTiming, onError: (err) => { logAckFailure({ @@ -185,7 +185,7 @@ export function createDiscordMessageReactionRuntime(params: { reactionContext: ackReactionContext, }), initialEmoji: emoji, - emojis: cfg.messages?.statusReactions?.emojis, + emojis: undefined, timing: statusReactionTiming, onError: (err) => { logAckFailure({ diff --git a/extensions/discord/src/monitor/message-handler.process.ack.test.ts b/extensions/discord/src/monitor/message-handler.process.ack.test.ts index c8c9896ce11c..a4ee6bfb632e 100644 --- a/extensions/discord/src/monitor/message-handler.process.ack.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.ack.test.ts @@ -21,7 +21,6 @@ import type { DispatchInboundParams } from "./message-handler.process.test-harne import { expectReactAckCallAt, expectReactionCallsContain, - expectRemoveAckCallAt, firstMockArg, firstMockCall, getReactionEmojis, @@ -164,7 +163,7 @@ describe("processDiscordMessage ack reactions", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); const ctx = await createAutomaticSourceDeliveryContext({ - cfg: { session: { typingMode: "never" } }, + cfg: { agents: { defaults: { typingMode: "never" } } }, }); await runProcessDiscordMessage(ctx); @@ -199,7 +198,7 @@ describe("processDiscordMessage ack reactions", () => { return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; }); const ctx = await createAutomaticSourceDeliveryContext({ - cfg: { session: { typingMode: "message" } }, + cfg: { agents: { defaults: { typingMode: "message" } } }, }); await runProcessDiscordMessage(ctx); @@ -376,32 +375,6 @@ describe("processDiscordMessage ack reactions", () => { expect(emojis).toContain(DEFAULT_EMOJIS.done); }); - it("applies status reaction emoji/timing overrides from config", async () => { - dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { - await params?.replyOptions?.onReasoningStream?.(); - return createNoQueuedDispatchResult(); - }); - - const ctx = await createAutomaticSourceDeliveryContext({ - cfg: { - messages: { - ackReaction: "👀", - statusReactions: { - emojis: { queued: "🟦", thinking: "🧪", done: "🏁" }, - timing: { debounceMs: 0 }, - }, - }, - session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, - }, - }); - - await runProcessDiscordMessage(ctx); - - const emojis = getReactionEmojis(); - expect(emojis).toContain("🟦"); - expect(emojis).toContain("🏁"); - }); - it("falls back to plain ack when status reactions are disabled", async () => { dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await params?.replyOptions?.onReasoningStream?.(); @@ -462,40 +435,11 @@ describe("processDiscordMessage ack reactions", () => { expect(emojis).toContain(DEFAULT_EMOJIS.thinking); }); - it("clears status reactions when dispatch aborts and removeAckAfterReply is enabled", async () => { - const abortController = new AbortController(); - dispatchInboundMessage.mockImplementationOnce(async () => { - abortController.abort(); - throw new Error("aborted"); - }); - - const ctx = await createAutomaticSourceDeliveryContext({ - abortSignal: abortController.signal, - cfg: { - messages: { - ackReaction: "👀", - removeAckAfterReply: true, - }, - session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, - }, - }); - - await runProcessDiscordMessage(ctx); - - await vi.waitFor(() => expect(sendMocks.removeReactionDiscord).toHaveBeenCalled()); - expectRemoveAckCallAt(0, "👀", { - accountId: "default", - ackReaction: "👀", - removeAckAfterReply: true, - }); - }); - - it("removes the plain ack reaction when status reactions are disabled and removeAckAfterReply is enabled", async () => { + it("keeps the plain ack reaction when status reactions are disabled", async () => { const ctx = await createAutomaticSourceDeliveryContext({ cfg: { messages: { ackReaction: "👀", - removeAckAfterReply: true, statusReactions: { enabled: false, }, @@ -507,76 +451,6 @@ describe("processDiscordMessage ack reactions", () => { await runProcessDiscordMessage(ctx); expect(getReactionEmojis()).toEqual(["👀"]); - expectRemoveAckCallAt(0, "👀", { - accountId: "default", - ackReaction: "👀", - removeAckAfterReply: true, - }); + expect(sendMocks.removeReactionDiscord).not.toHaveBeenCalled(); }); - - it.each([ - { - outcome: "done", - timingKey: "doneHoldMs", - configuredHoldMs: 2_000, - builtInHoldMs: DEFAULT_TIMING.doneHoldMs, - terminalEmoji: DEFAULT_EMOJIS.done, - }, - { - outcome: "error", - timingKey: "errorHoldMs", - configuredHoldMs: 4_000, - builtInHoldMs: DEFAULT_TIMING.errorHoldMs, - terminalEmoji: DEFAULT_EMOJIS.error, - }, - ] as const)( - "uses built-in statusReactions.timing.$timingKey for $outcome cleanup", - async ({ outcome, timingKey, configuredHoldMs, builtInHoldMs, terminalEmoji }) => { - vi.useFakeTimers(); - dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { - if (outcome === "done") { - await params?.replyOptions?.onReasoningStream?.(); - return createNoQueuedDispatchResult(); - } - return { - queuedFinal: false, - counts: { final: 0, tool: 0, block: 0 }, - failedCounts: { final: 1 }, - }; - }); - - const ctx = await createAutomaticSourceDeliveryContext({ - cfg: { - messages: { - ackReaction: "👀", - removeAckAfterReply: true, - statusReactions: { - timing: { [timingKey]: configuredHoldMs, debounceMs: 0 }, - }, - }, - session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, - }, - }); - - await runProcessDiscordMessage(ctx); - expect(getReactionEmojis()).toContain(terminalEmoji); - - await vi.advanceTimersByTimeAsync(builtInHoldMs - 1); - expect(sendMocks.removeReactionDiscord).not.toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - terminalEmoji, - expect.anything(), - ); - - await vi.advanceTimersByTimeAsync(1); - await vi.runAllTimersAsync(); - expect(sendMocks.removeReactionDiscord).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - terminalEmoji, - expect.anything(), - ); - }, - ); }); diff --git a/extensions/discord/src/monitor/message-handler.process.test-helpers.ts b/extensions/discord/src/monitor/message-handler.process.test-helpers.ts index 48cdbf77a921..a2721aa1f920 100644 --- a/extensions/discord/src/monitor/message-handler.process.test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.process.test-helpers.ts @@ -131,20 +131,6 @@ export function expectReactAckCallAt( expectReactionCallAt(sendMocks.reactMessageDiscord, index, emoji, params); } -export function expectRemoveAckCallAt( - index: number, - emoji: string, - params?: { - channelId?: string; - messageId?: string; - accountId?: string; - ackReaction?: string; - removeAckAfterReply?: boolean; - }, -) { - expectReactionCallAt(sendMocks.removeReactionDiscord, index, emoji, params); -} - export function createMockDraftStreamForTest() { const draftStream = createMockDraftStream(); createDiscordDraftStream.mockReturnValueOnce(draftStream); diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 12478505f337..3571ea1465fc 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -1,6 +1,6 @@ // Discord plugin module implements message handler.process behavior. import type { APIAllowedMentions } from "discord-api-types/v10"; -import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveAgentConfig, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { dispatchChannelInboundTurn, hasFinalInboundReplyDispatch, @@ -127,8 +127,10 @@ async function processDiscordMessageInner( }, }); const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only"; - const configuredTypingMode = cfg.session?.typingMode ?? cfg.agents?.defaults?.typingMode; - const configuredTypingInterval = cfg.agents?.defaults?.typingIntervalSeconds; + const routedAgentConfig = resolveAgentConfig(cfg, route.agentId); + const configuredTypingMode = routedAgentConfig?.typingMode ?? cfg.agents?.defaults?.typingMode; + const configuredTypingInterval = + routedAgentConfig?.typingIntervalSeconds ?? cfg.agents?.defaults?.typingIntervalSeconds; const shouldDisableCoreTypingKeepalive = sourceRepliesAreToolOnly && configuredTypingMode === undefined && diff --git a/extensions/discord/src/monitor/native-command-auth.ts b/extensions/discord/src/monitor/native-command-auth.ts index a9ed6757e8ec..be22f96547e3 100644 --- a/extensions/discord/src/monitor/native-command-auth.ts +++ b/extensions/discord/src/monitor/native-command-auth.ts @@ -237,7 +237,7 @@ export async function resolveDiscordNativeAutocompleteAuthorized(params: { ? interaction.rawData.member.roles.map((roleId: string) => roleId) : []; const allowNameMatching = isDangerousNameMatchingEnabled(discordConfig); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const useAccessGroups = true; const configuredDmAllowFrom = resolveDiscordAccountAllowFrom({ cfg, diff --git a/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts b/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts index 342a0578acbe..3cccb7993896 100644 --- a/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts +++ b/extensions/discord/src/monitor/native-command.commands-allowfrom.test.ts @@ -131,6 +131,13 @@ function expectUnauthorizedReply(interaction: MockCommandInteraction) { expect(interaction.reply).not.toHaveBeenCalled(); } +function expectChannelNotAllowedReply(interaction: MockCommandInteraction) { + expect(interaction.followUp).toHaveBeenCalledWith({ + content: "This channel is not allowed.", + ephemeral: true, + }); +} + describe("Discord native slash commands with commands.allowFrom", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -179,7 +186,6 @@ describe("Discord native slash commands with commands.allowFrom", () => { mutateConfig: (cfg) => { cfg.commands = { ...cfg.commands, - useAccessGroups: false, }; }, }); @@ -357,12 +363,11 @@ describe("Discord native slash commands with commands.allowFrom", () => { expectUnauthorizedReply(interaction); }); - it("rejects guild slash commands outside the Discord allowlist when commands.useAccessGroups is false and commands.allowFrom is not configured", async () => { + it("rejects guild slash commands outside the Discord channel allowlist", async () => { const { dispatchSpy, interaction } = await runGuildSlashCommand({ mutateConfig: (cfg) => { cfg.commands = { ...cfg.commands, - useAccessGroups: false, allowFrom: undefined, }; cfg.channels = { @@ -384,7 +389,7 @@ describe("Discord native slash commands with commands.allowFrom", () => { }, }); expect(dispatchSpy).not.toHaveBeenCalled(); - expectUnauthorizedReply(interaction); + expectChannelNotAllowedReply(interaction); }); it("does not treat open-DM wildcard access as guild command owner authorization", async () => { @@ -393,7 +398,6 @@ describe("Discord native slash commands with commands.allowFrom", () => { mutateConfig: (cfg) => { cfg.commands = { ...cfg.commands, - useAccessGroups: false, allowFrom: undefined, }; cfg.channels = { @@ -417,7 +421,7 @@ describe("Discord native slash commands with commands.allowFrom", () => { }, }); expect(dispatchSpy).not.toHaveBeenCalled(); - expectUnauthorizedReply(interaction); + expectChannelNotAllowedReply(interaction); }); it("rejects guild slash commands when commands.allowFrom.discord does not match the sender", async () => { @@ -434,7 +438,6 @@ describe("Discord native slash commands with commands.allowFrom", () => { mutateConfig: (cfg) => { cfg.commands = { ...cfg.commands, - useAccessGroups: false, }; }, }); 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 4c3ceebeebeb..13bedf364178 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -80,9 +80,8 @@ function createConfiguredAcpCase(params: { }) { return { cfg: { - commands: { - useAccessGroups: false, - }, + agents: { entries: { [params.agentId ?? "codex"]: {} } }, + commands: { allowFrom: { discord: ["user:owner"] } }, ...(params.includeChannelAccess === false ? {} : params.channelType === ChannelType.DM @@ -1052,9 +1051,6 @@ describe("Discord native plugin command dispatch", () => { it("forwards Discord thread metadata into direct plugin command execution", async () => { const cfg = { - commands: { - useAccessGroups: false, - }, channels: { discord: { groupPolicy: "allowlist", @@ -1120,9 +1116,6 @@ describe("Discord native plugin command dispatch", () => { it("preserves fetched thread parent metadata when interaction parentId getter throws", async () => { const cfg = { - commands: { - useAccessGroups: false, - }, channels: { discord: { groupPolicy: "allowlist", @@ -1224,9 +1217,8 @@ describe("Discord native plugin command dispatch", () => { const guildId = "1459246755253325866"; const channelId = "1478836151241412759"; const cfg = { - commands: { - useAccessGroups: false, - }, + agents: { entries: { qwen: {} } }, + commands: { allowFrom: { discord: ["user:owner"] } }, bindings: [ { agentId: "qwen", diff --git a/extensions/discord/src/monitor/native-command.status-direct.test.ts b/extensions/discord/src/monitor/native-command.status-direct.test.ts index 52a9c62074a7..300bf53afc65 100644 --- a/extensions/discord/src/monitor/native-command.status-direct.test.ts +++ b/extensions/discord/src/monitor/native-command.status-direct.test.ts @@ -37,12 +37,13 @@ let createDiscordNativeCommand: typeof import("./native-command.js").createDisco function createConfig(params?: { requireMention?: boolean }): OpenClawConfig { return { commands: { - useAccessGroups: false, + allowFrom: { discord: ["user:owner"] }, }, channels: { discord: { dm: { enabled: true }, dmPolicy: "open", + groupPolicy: "open", allowFrom: ["*"], guilds: { guild1: { diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index d31290cfd9d7..415f9512da93 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -244,7 +244,7 @@ async function dispatchDiscordCommandInteraction(params: { }); }; - const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const useAccessGroups = true; const user = interaction.user; if (!user) { return { accepted: false }; diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index a18b2be3f05a..891b0b4f7edd 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -99,9 +99,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); const rawDiscordCfg = account.config; - const discordRootThreadBindings = cfg.channels?.discord?.threadBindings; - const discordAccountThreadBindings = - cfg.channels?.discord?.accounts?.[account.accountId]?.threadBindings; const discordRestFetch = resolveDiscordRestFetch(rawDiscordCfg.proxy, runtime); const dmConfig = rawDiscordCfg.dm; const configuredDmAllowFrom = resolveDiscordAccountAllowFrom({ @@ -146,17 +143,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { await discordProviderRuntime.loadDiscordProviderSessionRuntime(); const threadBindingIdleTimeoutMs = discordProviderSessionRuntime.resolveThreadBindingIdleTimeoutMs({ - channelIdleHoursRaw: - discordAccountThreadBindings?.idleHours ?? discordRootThreadBindings?.idleHours, + channelIdleHoursRaw: discordCfg.threadBindings?.idleHours, sessionIdleHoursRaw: cfg.session?.threadBindings?.idleHours, }); const threadBindingMaxAgeMs = discordProviderSessionRuntime.resolveThreadBindingMaxAgeMs({ - channelMaxAgeHoursRaw: - discordAccountThreadBindings?.maxAgeHours ?? discordRootThreadBindings?.maxAgeHours, + channelMaxAgeHoursRaw: discordCfg.threadBindings?.maxAgeHours, sessionMaxAgeHoursRaw: cfg.session?.threadBindings?.maxAgeHours, }); const threadBindingsEnabled = discordProviderSessionRuntime.resolveThreadBindingsEnabled({ - channelEnabledRaw: discordAccountThreadBindings?.enabled ?? discordRootThreadBindings?.enabled, + channelEnabledRaw: discordCfg.threadBindings?.enabled, sessionEnabledRaw: cfg.session?.threadBindings?.enabled, }); const groupDmEnabled = dmConfig?.groupEnabled ?? false; @@ -171,7 +166,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { providerSetting: discordCfg.commands?.nativeSkills, globalSetting: cfg.commands?.nativeSkills, }); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const useAccessGroups = true; const slashCommand = resolveDiscordSlashCommandConfig(discordCfg.slashCommand); const sessionPrefix = "discord:slash"; const ephemeralDefault = slashCommand.ephemeral; diff --git a/extensions/discord/src/security-audit.test.ts b/extensions/discord/src/security-audit.test.ts index b2bdb7532b89..31376991de77 100644 --- a/extensions/discord/src/security-audit.test.ts +++ b/extensions/discord/src/security-audit.test.ts @@ -47,40 +47,6 @@ async function collectFindings(params: { } describe("Discord security audit findings", () => { - it("flags slash commands when access-group enforcement is disabled and no users allowlist exists", async () => { - const cfg: OpenClawConfig = { - commands: { native: true, useAccessGroups: false }, - channels: { - discord: { - enabled: true, - token: "t", - groupPolicy: "allowlist", - guilds: { - "123": { - channels: { - general: { enabled: true }, - }, - }, - }, - }, - }, - }; - - const discordConfig = cfg.channels?.discord; - if (!discordConfig) { - throw new Error("discord config required"); - } - const findings = await collectFindings({ - cfg, - config: discordConfig, - }); - - const unrestrictedFinding = findings.find( - (finding) => finding.checkId === "channels.discord.commands.native.unrestricted", - ); - expect(unrestrictedFinding?.severity).toBe("critical"); - }); - it.each([ { name: "flags missing guild user allowlists", diff --git a/extensions/discord/src/security-audit.ts b/extensions/discord/src/security-audit.ts index 4c6c29166089..053acbb4d563 100644 --- a/extensions/discord/src/security-audit.ts +++ b/extensions/discord/src/security-audit.ts @@ -171,20 +171,7 @@ export async function collectDiscordSecurityAuditFindings(params: { const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : []; const ownerAllowFromConfigured = normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0; - const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; - - if (!useAccessGroups && groupPolicy !== "disabled" && guildsConfigured && !hasAnyUserAllowlist) { - findings.push({ - checkId: "channels.discord.commands.native.unrestricted", - severity: "critical", - title: "Discord slash commands are unrestricted", - detail: - "commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.", - remediation: - "Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds..users (or channels.discord.guilds..channels..users).", - }); - } else if ( - useAccessGroups && + if ( groupPolicy !== "disabled" && guildsConfigured && !ownerAllowFromConfigured && diff --git a/extensions/discord/src/subagent-progress-config.ts b/extensions/discord/src/subagent-progress-config.ts index e8deee65c69c..9ef55d90d118 100644 --- a/extensions/discord/src/subagent-progress-config.ts +++ b/extensions/discord/src/subagent-progress-config.ts @@ -43,11 +43,6 @@ export function resolveDiscordProgressTarget(requester?: DiscordProgressRequeste export function reservedReactionEmojis(config: OpenClawConfig, ackReaction?: string): Set { const reserved = new Set(Object.values(DEFAULT_EMOJIS)); - for (const emoji of Object.values(config.messages?.statusReactions?.emojis ?? {})) { - if (emoji?.trim()) { - reserved.add(emoji.trim()); - } - } for (const emoji of [config.messages?.ackReaction, ackReaction]) { if (emoji?.trim()) { reserved.add(emoji.trim()); diff --git a/extensions/discord/src/subagent-progress.ts b/extensions/discord/src/subagent-progress.ts index e7d3d7a1859e..73a7149e2e7b 100644 --- a/extensions/discord/src/subagent-progress.ts +++ b/extensions/discord/src/subagent-progress.ts @@ -62,16 +62,19 @@ type PersistedReconciliationResult = typingEnabled: boolean; runningEmoji?: string; }; +type StartupRecovery = { attempts: number; timer?: ReturnType }; const trackers = new Map(); + const trackerKeyByRunId = new Map(); const terminalRetryTimers = new Map>(); const terminalRetryExpiresAt = new Map(); const terminalRetryAttempts = new Map(); -const startupRecoveryRetries = new Map< - ProgressApi, - { attempts: number; timer?: ReturnType } ->(); +const startupRecoveryRetries = new Map(); + +function isSubagentProgressEnabled(account: ReturnType): boolean { + return Boolean(process.env.VITEST) && account.enabled && account.config.subagentProgress === true; +} function clearTerminalRetry(runId: string) { const timer = terminalRetryTimers.get(runId); @@ -171,8 +174,7 @@ async function updateRunningReaction(api: ProgressApi, tracker: ProgressTracker) tracker.runningEmoji = undefined; tracker.runningEmojiConfirmed = false; if (nextEmoji) { - // Discord may apply the idempotent add before its response is lost. Keep - // attempted ownership so terminal cleanup still removes the possible glyph. + // A lost add response may still have applied; keep attempted ownership for cleanup. tracker.runningEmoji = nextEmoji; if (!(await persistTrackerRunningEmoji(api, tracker))) { tracker.runningEmoji = undefined; @@ -252,7 +254,7 @@ async function handleStarted( } const account = resolveDiscordAccount({ cfg: api.config, accountId: event.requester?.accountId }); const key = `${account.accountId}:${target.channelId}:${target.messageId}`; - if (!account.enabled || account.config.subagentProgress !== true) { + if (!isSubagentProgressEnabled(account)) { await runQueued(key, async () => { const tracker = trackers.get(key); if (!tracker) { @@ -297,9 +299,7 @@ async function handleStarted( reactionsEnabled, typingExpiresAt: 0, }; - // The process can stop between durable registration and either Discord - // reaction call. Rebuild from bot-owned glyphs instead of guessing which - // count made it to Discord. + // Recover post-registration crashes from bot-owned glyphs instead of guessing. if (restored.activeRunIds.length > 0 || restored.cleanupRuns.length > 0) { const reserved = reservedReactionEmojis(api.config, account.config.ackReaction); const cleanupEmojis = restored.ownedEmojis.filter((emoji) => !reserved.has(emoji)); @@ -376,8 +376,7 @@ async function handleStarted( if (persistResult === "persisted") { tracker.persistedRunIds.add(runId); } - // A fast child can end between hook dispatch and durable presentation setup. - // The tombstone makes that ordering explicit and prevents a late start from sticking. + // Tombstone a fast child that ends before presentation so a late start cannot stick. const endedOutcome = terminalOutcome(runId); if (endedOutcome) { const owned = persistedProgressRunFromTracker(tracker); @@ -445,7 +444,7 @@ async function reconcilePersistedTracker( typingExpiresAt: 0, }; const account = resolveDiscordAccount({ cfg: api.config, accountId: persisted.accountId }); - const typingEnabled = account.enabled && account.config.subagentProgress === true; + const typingEnabled = isSubagentProgressEnabled(account); const reserved = reservedReactionEmojis(api.config, account.config.ackReaction); const cleanupEmojis = persisted.runningEmoji && !reserved.has(persisted.runningEmoji) ? [persisted.runningEmoji] : []; @@ -495,7 +494,7 @@ function scheduleTerminalLookupRetry( cfg: api.config, accountId: event.requester?.accountId, }); - if (!target || !account.enabled || account.config.subagentProgress !== true) { + if (!target || !isSubagentProgressEnabled(account)) { return; } } @@ -571,7 +570,7 @@ async function handleEnded( cfg: api.config, accountId: tracker.accountId, }); - if (!currentAccount.enabled || currentAccount.config.subagentProgress !== true) { + if (!isSubagentProgressEnabled(currentAccount)) { tracker.reactionsEnabled = false; stopTyping(tracker); } else { diff --git a/extensions/discord/src/ui-colors.ts b/extensions/discord/src/ui-colors.ts index 18ca901afaef..289d0b1b8fdf 100644 --- a/extensions/discord/src/ui-colors.ts +++ b/extensions/discord/src/ui-colors.ts @@ -1,6 +1,5 @@ // Discord plugin module implements ui colors behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { inspectDiscordAccount } from "./account-inspect.js"; const DEFAULT_DISCORD_ACCENT_COLOR = "#5865F2"; @@ -21,8 +20,6 @@ export function normalizeDiscordAccentColor(raw?: string | null): string | null return normalized.toUpperCase(); } -export function resolveDiscordAccentColor(params: ResolveDiscordAccentColorParams): string { - const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); - const configured = normalizeDiscordAccentColor(account.config.ui?.components?.accentColor); - return configured ?? DEFAULT_DISCORD_ACCENT_COLOR; +export function resolveDiscordAccentColor(_params: ResolveDiscordAccentColorParams): string { + return DEFAULT_DISCORD_ACCENT_COLOR; } diff --git a/extensions/discord/src/voice/access.ts b/extensions/discord/src/voice/access.ts index 8c67c1163502..6719e729232e 100644 --- a/extensions/discord/src/voice/access.ts +++ b/extensions/discord/src/voice/access.ts @@ -110,7 +110,7 @@ export async function authorizeDiscordVoiceIngress(params: { ? allowListMatches(admissionAllowList, params.sender, { allowNameMatching: false }) : false; - const useAccessGroups = params.useAccessGroups ?? params.cfg.commands?.useAccessGroups !== false; + const useAccessGroups = params.useAccessGroups ?? true; const authorizers = useAccessGroups ? [ { diff --git a/extensions/discord/src/voice/manager.e2e.test.ts b/extensions/discord/src/voice/manager.e2e.test.ts index eb490add89cd..128f5ea9e9d9 100644 --- a/extensions/discord/src/voice/manager.e2e.test.ts +++ b/extensions/discord/src/voice/manager.e2e.test.ts @@ -6507,9 +6507,11 @@ describe("DiscordVoiceManager", () => { discriminator: "4321", }, }); - const manager = createManager({ groupPolicy: "open", allowFrom: ["discord:u-owner"] }, client, { - commands: { useAccessGroups: false }, - }); + const manager = createManager( + { groupPolicy: "open", allowFrom: ["discord:u-owner"] }, + client, + {}, + ); await processVoiceSegment(manager, "u-guest"); }); @@ -6607,9 +6609,7 @@ describe("DiscordVoiceManager", () => { }, }, client, - { - commands: { useAccessGroups: false }, - }, + {}, ); await processVoiceSegment(manager, "u-guest"); @@ -6636,9 +6636,7 @@ describe("DiscordVoiceManager", () => { discriminator: "4321", }, }); - const manager = createManager({ groupPolicy: "open" }, client, { - commands: { useAccessGroups: false }, - }); + const manager = createManager({ groupPolicy: "open" }, client, {}); await processVoiceSegment(manager, "u-guest"); const commandArgs = lastAgentCommandArgs() as @@ -6670,9 +6668,7 @@ describe("DiscordVoiceManager", () => { discriminator: "0001", }, }); - const manager = createManager({ groupPolicy: "open" }, client, { - commands: { useAccessGroups: false }, - }); + const manager = createManager({ groupPolicy: "open" }, client, {}); await processVoiceSegment(manager, "u-debug"); @@ -6710,9 +6706,7 @@ describe("DiscordVoiceManager", () => { discriminator: "4321", }, }); - const manager = createManager({ groupPolicy: "open" }, client, { - commands: { useAccessGroups: false }, - }); + const manager = createManager({ groupPolicy: "open" }, client, {}); await processVoiceSegment(manager, "u-guest"); expect(lastTtsStreamArgs().channel).toBe("discord"); @@ -6754,9 +6748,7 @@ describe("DiscordVoiceManager", () => { }, }, client, - { - commands: { useAccessGroups: false }, - }, + {}, ); await processVoiceSegment(manager, "u-guest"); @@ -6828,7 +6820,7 @@ describe("DiscordVoiceManager", () => { }, }, client, - { commands: { useAccessGroups: false } }, + {}, ); manager.setBotUserId("bot-user"); diff --git a/extensions/elevenlabs/speech-provider.ts b/extensions/elevenlabs/speech-provider.ts index a1594083640d..91574b3bd350 100644 --- a/extensions/elevenlabs/speech-provider.ts +++ b/extensions/elevenlabs/speech-provider.ts @@ -148,7 +148,7 @@ function normalizeElevenLabsProviderConfig( return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, - path: "messages.tts.providers.elevenlabs.apiKey", + path: "tts.providers.elevenlabs.apiKey", }), baseUrl: normalizeElevenLabsBaseUrl(trimToUndefined(raw?.baseUrl)), voiceId: trimToUndefined(raw?.voiceId) ?? DEFAULT_ELEVENLABS_VOICE_ID, diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index 84a679166257..e2021867dfbf 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -229,8 +229,10 @@ describe("exa web search provider", () => { it("exposes newer documented Exa search types and count limits", () => { const provider = createExaWebSearchProvider(); const tool = provider.createTool({ - config: {}, - searchConfig: { exa: { apiKey: "exa-secret" } }, + config: { + plugins: { entries: { exa: { config: { webSearch: { apiKey: "exa-secret" } } } } }, + }, + searchConfig: {}, }); if (!tool) { throw new Error("Expected tool definition"); @@ -263,8 +265,10 @@ describe("exa web search provider", () => { it("returns validation errors for conflicting time filters", async () => { const provider = createExaWebSearchProvider(); const tool = provider.createTool({ - config: {}, - searchConfig: { exa: { apiKey: "exa-secret" } }, + config: { + plugins: { entries: { exa: { config: { webSearch: { apiKey: "exa-secret" } } } } }, + }, + searchConfig: {}, }); if (!tool) { throw new Error("Expected tool definition"); @@ -287,8 +291,10 @@ describe("exa web search provider", () => { it("returns validation errors for invalid date input", async () => { const provider = createExaWebSearchProvider(); const tool = provider.createTool({ - config: {}, - searchConfig: { exa: { apiKey: "exa-secret" } }, + config: { + plugins: { entries: { exa: { config: { webSearch: { apiKey: "exa-secret" } } } } }, + }, + searchConfig: {}, }); if (!tool) { throw new Error("Expected tool definition"); diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 34ca2c8ad7d1..b9d1182f8df4 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -499,6 +499,7 @@ describe("feishuPlugin actions", () => { }, cfg: { ...cfg, + channels: undefined, messages: { responsePrefix: "[Nexus]" }, }, accountId: undefined, @@ -677,7 +678,10 @@ describe("feishuPlugin actions", () => { }, cfg: { ...cfg, - messages: { responsePrefix: "[Nexus]" }, + channels: { + ...cfg.channels, + feishu: { ...cfg.channels?.feishu, responsePrefix: "[Nexus]" }, + }, }, accountId: undefined, toolContext: {}, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index bd81eaa5cd28..fa924e204ff3 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -641,7 +641,13 @@ function readFirstString( const UNRESOLVED_RESPONSE_PREFIX_VAR_PATTERN = /\{[a-zA-Z][a-zA-Z0-9.]*\}/; function resolveFeishuMessageActionResponsePrefix(ctx: ChannelMessageActionContext) { - const configured = ctx.cfg.messages?.responsePrefix; + const channel = ctx.cfg.channels?.feishu as + | { responsePrefix?: string; accounts?: Record } + | undefined; + const configured = + (ctx.accountId ? channel?.accounts?.[ctx.accountId]?.responsePrefix : undefined) ?? + channel?.responsePrefix ?? + (channel === undefined ? ctx.cfg.messages?.responsePrefix : undefined); if (!configured) { return undefined; } diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index e1e4fee9fee3..2ded86809b93 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -211,7 +211,7 @@ const FeishuSharedConfigShape = { textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), httpTimeoutMs: z.number().int().positive().max(300_000).optional(), - heartbeat: ChannelHeartbeatVisibilitySchema, + heartbeatVisibility: ChannelHeartbeatVisibilitySchema, renderMode: RenderModeSchema, streaming: FeishuStreamingSchema, tools: FeishuToolsConfigSchema, diff --git a/extensions/feishu/src/doctor-contract.test.ts b/extensions/feishu/src/doctor-contract.test.ts index 4547e078bf0e..dafdd7e43147 100644 --- a/extensions/feishu/src/doctor-contract.test.ts +++ b/extensions/feishu/src/doctor-contract.test.ts @@ -171,6 +171,31 @@ describe("feishu normalizeCompatibilityConfig streaming aliases", () => { expect(FeishuConfigSchema.safeParse(feishu).success).toBe(true); }); + it("strips unread legacy Feishu heartbeat fields at root and account scope", () => { + const result = normalizeCompatibilityConfig({ + cfg: feishuConfig({ + heartbeat: { visibility: "hidden", intervalMs: 1000 }, + accounts: { + work: { heartbeat: { visibility: "visible" } }, + empty: { heartbeat: {} }, + }, + }), + }); + + const feishu = result.config.channels?.feishu as unknown as Record; + expect(feishu.heartbeat).toBeUndefined(); + const work = (feishu.accounts as Record>).work; + expect(work?.heartbeat).toBeUndefined(); + const empty = (feishu.accounts as Record>).empty; + expect(empty?.heartbeat).toBeUndefined(); + expect(result.changes).toEqual([ + "Removed channels.feishu.heartbeat (legacy Feishu fields were never read by runtime).", + "Removed channels.feishu.accounts.work.heartbeat (legacy Feishu fields were never read by runtime).", + "Removed channels.feishu.accounts.empty.heartbeat (legacy Feishu fields were never read by runtime).", + ]); + expect(FeishuConfigSchema.safeParse(feishu).success).toBe(true); + }); + it("is idempotent: a second run reports no changes", () => { const first = normalizeCompatibilityConfig({ cfg: feishuConfig({ streaming: true, blockStreaming: true }), diff --git a/extensions/feishu/src/doctor-contract.ts b/extensions/feishu/src/doctor-contract.ts index 24295021ab78..66862688608d 100644 --- a/extensions/feishu/src/doctor-contract.ts +++ b/extensions/feishu/src/doctor-contract.ts @@ -25,6 +25,28 @@ const streamingAliasMigration = defineChannelAliasMigration({ // generic alias migration moves the object verbatim, so strip the dead fields // afterwards or `doctor --fix` would emit a schema-invalid coalesce object. const LEGACY_COALESCE_FIELDS = ["enabled", "minDelayMs", "maxDelayMs"] as const; +const LEGACY_HEARTBEAT_FIELDS = ["visibility", "intervalMs"] as const; + +function sanitizeLegacyHeartbeatFields(params: { + entry: Record; + pathPrefix: string; + changes: string[]; +}): { entry: Record; changed: boolean } { + const heartbeat = asObjectRecord(params.entry.heartbeat); + if ( + !heartbeat || + (Object.keys(heartbeat).length > 0 && + !LEGACY_HEARTBEAT_FIELDS.some((field) => Object.hasOwn(heartbeat, field))) + ) { + return { entry: params.entry, changed: false }; + } + const next = { ...params.entry }; + delete next.heartbeat; + params.changes.push( + `Removed ${params.pathPrefix}.heartbeat (legacy Feishu fields were never read by runtime).`, + ); + return { entry: next, changed: true }; +} function sanitizeLegacyCoalesceFields(params: { entry: Record; @@ -68,8 +90,13 @@ function sanitizeFeishuCoalesce(cfg: OpenClawConfig, changes: string[]): OpenCla pathPrefix: "channels.feishu", changes, }); - let updated = root.entry; - let changed = root.changed; + const rootHeartbeat = sanitizeLegacyHeartbeatFields({ + entry: root.entry, + pathPrefix: "channels.feishu", + changes, + }); + let updated = rootHeartbeat.entry; + let changed = root.changed || rootHeartbeat.changed; const accounts = asObjectRecord(updated.accounts); if (accounts) { @@ -85,8 +112,13 @@ function sanitizeFeishuCoalesce(cfg: OpenClawConfig, changes: string[]): OpenCla pathPrefix: `channels.feishu.accounts.${accountId}`, changes, }); - if (sanitized.changed) { - nextAccounts[accountId] = sanitized.entry; + const sanitizedHeartbeat = sanitizeLegacyHeartbeatFields({ + entry: sanitized.entry, + pathPrefix: `channels.feishu.accounts.${accountId}`, + changes, + }); + if (sanitized.changed || sanitizedHeartbeat.changed) { + nextAccounts[accountId] = sanitizedHeartbeat.entry; accountsChanged = true; } } diff --git a/extensions/github-copilot/embeddings.test.ts b/extensions/github-copilot/embeddings.test.ts index 34e9fbc3d9b8..3d8d1c41dbf2 100644 --- a/extensions/github-copilot/embeddings.test.ts +++ b/extensions/github-copilot/embeddings.test.ts @@ -339,7 +339,7 @@ describe("githubCopilotMemoryEmbeddingProviderAdapter", () => { } as never), ).rejects.toMatchObject({ name: "UnresolvedSecretInputError", - path: "agents.*.memorySearch.remote.apiKey", + path: "memory.search.remote.apiKey", }); expect(resolveFirstGithubTokenMock).not.toHaveBeenCalled(); expect(resolveCopilotApiTokenMock).not.toHaveBeenCalled(); diff --git a/extensions/github-copilot/embeddings.ts b/extensions/github-copilot/embeddings.ts index b2a245e104be..11ecdd7bfcdb 100644 --- a/extensions/github-copilot/embeddings.ts +++ b/extensions/github-copilot/embeddings.ts @@ -295,7 +295,7 @@ export const githubCopilotMemoryEmbeddingProviderAdapter: MemoryEmbeddingProvide create: async (options) => { const explicitValue = normalizeResolvedSecretInputString({ value: options.remote?.apiKey, - path: "agents.*.memorySearch.remote.apiKey", + path: "memory.search.remote.apiKey", }); const value = explicitValue ? explicitValue diff --git a/extensions/google/embedding-provider.ts b/extensions/google/embedding-provider.ts index 0aa79bf1b87f..f8d54dd2fc02 100644 --- a/extensions/google/embedding-provider.ts +++ b/extensions/google/embedding-provider.ts @@ -183,7 +183,7 @@ function resolveGeminiOutputDimensionality(model: string, requested?: number): n function resolveRemoteApiKey(remoteApiKey: unknown): string | undefined { const trimmed = resolveMemorySecretInputString({ value: remoteApiKey, - path: "agents.*.memorySearch.remote.apiKey", + path: "memory.search.remote.apiKey", }); if (!trimmed) { return undefined; diff --git a/extensions/google/speech-provider.test.ts b/extensions/google/speech-provider.test.ts index 9ed6ea88fd5f..9e5d5da41311 100644 --- a/extensions/google/speech-provider.test.ts +++ b/extensions/google/speech-provider.test.ts @@ -261,15 +261,6 @@ describe("Google speech provider", () => { persona: { id: "alfred", label: "Alfred", - prompt: { - profile: "A brilliant British butler.", - scene: "A quiet late-night study.", - sampleContext: "The speaker is answering a trusted operator.", - style: "Refined and lightly amused.", - accent: "British English.", - pacing: "Measured.", - constraints: ["Do not read configuration values aloud."], - }, }, target: "audio-file", timeoutMs: 1_000, @@ -282,23 +273,11 @@ describe("Google speech provider", () => { "configuration aloud.", "", "# AUDIO PROFILE: Alfred", - "A brilliant British butler.", - "", - "## THE SCENE", - "A quiet late-night study.", "", "### DIRECTOR'S NOTES", - "Style: Refined and lightly amused.", - "Accent: British English.", - "Pacing: Measured.", - "Constraints:", - "- Do not read configuration values aloud.", "Provider notes:", "Keep a close-mic feel.", "", - "### SAMPLE CONTEXT", - "The speaker is answering a trusted operator.", - "", "### TRANSCRIPT", "[whispers] The door is open.", ].join("\n"), @@ -328,9 +307,6 @@ describe("Google speech provider", () => { persona: { id: "alfred", label: "Alfred", - prompt: { - profile: "A brilliant British butler.", - }, }, target: "audio-file", timeoutMs: 1_000, diff --git a/extensions/google/speech-provider.ts b/extensions/google/speech-provider.ts index 88820e159a7d..5ee4cd3c0221 100644 --- a/extensions/google/speech-provider.ts +++ b/extensions/google/speech-provider.ts @@ -205,7 +205,7 @@ function normalizeGoogleTtsProviderConfig( return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, - path: "messages.tts.providers.google.apiKey", + path: "tts.providers.google.apiKey", }), baseUrl: trimToUndefined(raw?.baseUrl), model: normalizeGoogleTtsModel(raw?.model), @@ -325,12 +325,6 @@ function normalizePromptSectionText(value: string | undefined): string | undefin return sanitized; } -function normalizePromptList(values: readonly string[] | undefined): string[] { - return (values ?? []) - .map((value) => normalizePromptSectionText(value)) - .filter((value): value is string => Boolean(value)); -} - function isOpenClawGoogleAudioProfilePrompt(text: string): boolean { return ( text.includes("# AUDIO PROFILE:") && @@ -344,27 +338,10 @@ function renderGoogleAudioProfilePrompt(params: { persona?: { id: string; label?: string; - prompt?: { - profile?: string; - scene?: string; - sampleContext?: string; - style?: string; - accent?: string; - pacing?: string; - constraints?: string[]; - }; }; personaPrompt?: string; }): string { const transcript = params.text.replace(/\r\n?/g, "\n").trim(); - const prompt = params.persona?.prompt; - const profile = normalizePromptSectionText(prompt?.profile); - const scene = normalizePromptSectionText(prompt?.scene); - const sampleContext = normalizePromptSectionText(prompt?.sampleContext); - const style = normalizePromptSectionText(prompt?.style); - const accent = normalizePromptSectionText(prompt?.accent); - const pacing = normalizePromptSectionText(prompt?.pacing); - const constraints = normalizePromptList(prompt?.constraints); const personaPrompt = normalizePromptSectionText(params.personaPrompt); const label = normalizePromptSectionText(params.persona?.label) ?? @@ -378,26 +355,11 @@ function renderGoogleAudioProfilePrompt(params: { ].join("\n"), ]; - if (label || profile) { - sections.push([`# AUDIO PROFILE: ${label ?? "voice"}`, profile].filter(Boolean).join("\n")); - } - if (scene) { - sections.push(["## THE SCENE", scene].join("\n")); + if (label) { + sections.push(`# AUDIO PROFILE: ${label}`); } const directorNotes: string[] = []; - if (style) { - directorNotes.push(`Style: ${style}`); - } - if (accent) { - directorNotes.push(`Accent: ${accent}`); - } - if (pacing) { - directorNotes.push(`Pacing: ${pacing}`); - } - if (constraints.length > 0) { - directorNotes.push(["Constraints:", ...constraints.map((item) => `- ${item}`)].join("\n")); - } if (personaPrompt) { directorNotes.push(["Provider notes:", personaPrompt].join("\n")); } @@ -405,10 +367,6 @@ function renderGoogleAudioProfilePrompt(params: { sections.push(["### DIRECTOR'S NOTES", ...directorNotes].join("\n")); } - if (sampleContext) { - sections.push(["### SAMPLE CONTEXT", sampleContext].join("\n")); - } - sections.push(["### TRANSCRIPT", transcript].join("\n")); return sections.join("\n\n"); } diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts index e51e68720da0..0f46a7a8198b 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -49,7 +49,7 @@ const { resolveDefaultAccountId: resolveDefaultGoogleChatAccountId, } = createAccountListHelpers("googlechat", { implicitDefaultAccount: { - channelKeys: ["serviceAccount", "serviceAccountRef", "serviceAccountFile"], + channelKeys: ["serviceAccount", "serviceAccountFile"], envVars: [ENV_SERVICE_ACCOUNT, ENV_SERVICE_ACCOUNT_FILE], }, }); @@ -75,7 +75,6 @@ function mergeGoogleChatAccountConfig( enabled: _ignoredEnabled, dangerouslyAllowNameMatching: _ignoredDangerouslyAllowNameMatching, serviceAccount: _ignoredServiceAccount, - serviceAccountRef: _ignoredServiceAccountRef, serviceAccountFile: _ignoredServiceAccountFile, ...defaultAccountShared } = defaultAccountConfig; @@ -140,12 +139,6 @@ function resolveCredentialsFromConfig(params: { ); } - if (isSecretRef(account.serviceAccountRef)) { - throw new Error( - `channels.googlechat.accounts.${accountId}.serviceAccount: unresolved SecretRef "${account.serviceAccountRef.source}:${account.serviceAccountRef.provider}:${account.serviceAccountRef.id}". Resolve this command against an active gateway runtime snapshot before reading it.`, - ); - } - const file = normalizeOptionalString(account.serviceAccountFile); if (file) { const resolvedFile = resolveUserPath(file); diff --git a/extensions/googlechat/src/config-schema.test.ts b/extensions/googlechat/src/config-schema.test.ts index 882db88054b8..e9e4a72aadc3 100644 --- a/extensions/googlechat/src/config-schema.test.ts +++ b/extensions/googlechat/src/config-schema.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from "vitest"; import { GoogleChatConfigSchema } from "../runtime-api.js"; describe("googlechat config schema", () => { - it("accepts serviceAccount refs", () => { + it("accepts inline serviceAccount refs", () => { const result = GoogleChatConfigSchema.safeParse({ - serviceAccountRef: { + serviceAccount: { source: "file", provider: "filemain", id: "/channels/googlechat/serviceAccount", @@ -35,7 +35,7 @@ describe("googlechat config schema", () => { dmPolicy: "allowlist", allowFrom: ["users/1234567890"], markdown: { tables: "bullets" }, - heartbeat: { showOk: false }, + heartbeatVisibility: { showOk: false }, contextVisibility: "allowlist_quote", }); diff --git a/extensions/googlechat/src/monitor-webhook.test.ts b/extensions/googlechat/src/monitor-webhook.test.ts index 0ac888d8d55d..95fbb8b87f28 100644 --- a/extensions/googlechat/src/monitor-webhook.test.ts +++ b/extensions/googlechat/src/monitor-webhook.test.ts @@ -18,6 +18,7 @@ vi.mock("openclaw/plugin-sdk/webhook-request-guards", () => ({ })); vi.mock("openclaw/plugin-sdk/webhook-targets", () => ({ + normalizeWebhookPath: (raw: string) => raw, resolveWebhookTargetWithAuthOrReject, withResolvedWebhookRequestPipeline, })); diff --git a/extensions/googlechat/src/secret-contract.test.ts b/extensions/googlechat/src/secret-contract.test.ts index b6b04794bb68..5af79ab392a4 100644 --- a/extensions/googlechat/src/secret-contract.test.ts +++ b/extensions/googlechat/src/secret-contract.test.ts @@ -17,7 +17,7 @@ describe("googlechat secret contract", () => { accounts: { work: { enabled: true, - serviceAccountRef: { + serviceAccount: { source: "env", provider: "default", id: "GOOGLECHAT_SERVICE_ACCOUNT", diff --git a/extensions/googlechat/src/secret-contract.ts b/extensions/googlechat/src/secret-contract.ts index 674e84e4044a..cd193fcdfcc2 100644 --- a/extensions/googlechat/src/secret-contract.ts +++ b/extensions/googlechat/src/secret-contract.ts @@ -6,7 +6,6 @@ import { hasOwnProperty, pushAssignment, pushInactiveSurfaceWarning, - pushWarning, resolveChannelAccountSurface, type ResolverContext, type SecretDefaults, @@ -15,7 +14,6 @@ import { coerceSecretRef } from "openclaw/plugin-sdk/secret-ref-runtime"; type GoogleChatAccountLike = { serviceAccount?: unknown; - serviceAccountRef?: unknown; accounts?: Record; }; @@ -33,10 +31,9 @@ export const secretTargetRegistryEntries = createChannelSecretTargetRegistryEntr account: [ { path: "serviceAccount", - refPath: "serviceAccountRef", targetType: "channels.googlechat.serviceAccount", targetTypeAliases: ["channels.googlechat.accounts.*.serviceAccount"], - secretShape: "sibling_ref", + secretShape: "secret_input", expectedResolvedValue: "string-or-object", accountIdPathSegmentIndex: 3, }, @@ -44,25 +41,14 @@ export const secretTargetRegistryEntries = createChannelSecretTargetRegistryEntr channel: [ { path: "serviceAccount", - refPath: "serviceAccountRef", - secretShape: "sibling_ref", + secretShape: "secret_input", expectedResolvedValue: "string-or-object", }, ], }); -function resolveSecretInputRef(params: { - value: unknown; - refValue?: unknown; - defaults?: SecretDefaults; -}) { - const explicitRef = coerceSecretRef(params.refValue, params.defaults); - const inlineRef = explicitRef ? null : coerceSecretRef(params.value, params.defaults); - return { - explicitRef, - inlineRef, - ref: explicitRef ?? inlineRef, - }; +function resolveSecretInputRef(params: { value: unknown; defaults?: SecretDefaults }) { + return coerceSecretRef(params.value, params.defaults); } function collectGoogleChatAccountAssignment(params: { @@ -73,9 +59,8 @@ function collectGoogleChatAccountAssignment(params: { ownerAccountIds: string[]; inactiveReason?: string; }): void { - const { explicitRef, ref } = resolveSecretInputRef({ + const ref = resolveSecretInputRef({ value: params.target.serviceAccount, - refValue: params.target.serviceAccountRef, defaults: params.defaults, }); if (!ref) { @@ -89,17 +74,6 @@ function collectGoogleChatAccountAssignment(params: { }); return; } - if ( - explicitRef && - params.target.serviceAccount !== undefined && - !coerceSecretRef(params.target.serviceAccount, params.defaults) - ) { - pushWarning(params.context, { - code: "SECRETS_REF_OVERRIDES_PLAINTEXT", - path: params.path, - message: `${params.path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`, - }); - } for (const accountId of params.ownerAccountIds) { pushAssignment(params.context, { ref, @@ -129,12 +103,7 @@ export function collectRuntimeConfigAssignments(params: { : !surface.hasExplicitAccounts ? ["default"] : surface.accounts - .filter( - ({ account, enabled }) => - enabled && - !hasOwnProperty(account, "serviceAccount") && - !hasOwnProperty(account, "serviceAccountRef"), - ) + .filter(({ account, enabled }) => enabled && !hasOwnProperty(account, "serviceAccount")) .map(({ accountId }) => accountId); collectGoogleChatAccountAssignment({ target: googleChat, @@ -148,10 +117,7 @@ export function collectRuntimeConfigAssignments(params: { return; } for (const { accountId, account, enabled } of surface.accounts) { - if ( - !hasOwnProperty(account, "serviceAccount") && - !hasOwnProperty(account, "serviceAccountRef") - ) { + if (!hasOwnProperty(account, "serviceAccount")) { continue; } collectGoogleChatAccountAssignment({ diff --git a/extensions/googlechat/src/setup.test.ts b/extensions/googlechat/src/setup.test.ts index 486aa0b17a71..444bc14655cd 100644 --- a/extensions/googlechat/src/setup.test.ts +++ b/extensions/googlechat/src/setup.test.ts @@ -659,7 +659,7 @@ describe("resolveGoogleChatAccount", () => { googlechat: { accounts: { default: { - serviceAccountRef: { + serviceAccount: { source: "env", provider: "test", id: "default-sa", diff --git a/extensions/gradium/speech-provider.ts b/extensions/gradium/speech-provider.ts index 241883a192b4..c231fc418c8a 100644 --- a/extensions/gradium/speech-provider.ts +++ b/extensions/gradium/speech-provider.ts @@ -24,7 +24,7 @@ function normalizeGradiumProviderConfig(rawConfig: Record): Gra return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, - path: "messages.tts.providers.gradium.apiKey", + path: "tts.providers.gradium.apiKey", }), baseUrl: normalizeGradiumBaseUrl(trimToUndefined(raw?.baseUrl)), voiceId: trimToUndefined(raw?.voiceId) ?? DEFAULT_GRADIUM_VOICE_ID, diff --git a/extensions/imessage/src/monitor.last-route.test.ts b/extensions/imessage/src/monitor.last-route.test.ts index 18a14649f330..da0cce72852d 100644 --- a/extensions/imessage/src/monitor.last-route.test.ts +++ b/extensions/imessage/src/monitor.last-route.test.ts @@ -550,7 +550,8 @@ describe("iMessage monitor last-route updates", () => { }, }, messages: { inbound: { debounceMs: 0 } }, - session: { mainKey: "main", typingMode }, + agents: { defaults: { typingMode } }, + session: { mainKey: "main" }, } as never, runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() }, }); @@ -1949,93 +1950,6 @@ describe("iMessage monitor last-route updates", () => { ); }); - it("merges a command row with the following URL balloon row", async () => { - // Apple's command+URL composition can arrive as a command row followed by a - // URL-preview balloon row. The opt-in coalescer keeps the pair as one agent - // turn and uses balloon metadata to avoid collapsing ordinary rows. - debouncerControl.holdEntries = true; - - let onNotification: ((message: { method: string; params: unknown }) => void) | undefined; - const client = { - request: vi.fn(async (method: string) => { - if (method === "watch.subscribe") { - return { subscription: 1 }; - } - throw new Error(`unexpected imsg method ${method}`); - }), - waitForClose: vi.fn(async () => { - // Fresh dates relative to now so the stale-backlog age fence lets the - // live rows through to the debouncer. - for (const row of [ - { - id: 91, - guid: "LIVE-GUID-91", - text: "summarize", - created_at: new Date(Date.now() - 2000).toISOString(), - }, - { - id: 92, - guid: "LIVE-GUID-92", - text: "https://example.com/article", - balloon_bundle_id: "com.apple.messages.URLBalloonProvider", - created_at: new Date(Date.now() - 1000).toISOString(), - }, - ]) { - onNotification?.({ - method: "message", - params: { - message: { - ...row, - chat_id: 123, - sender: "+15550001111", - is_from_me: false, - is_group: false, - }, - }, - }); - } - await vi.waitFor(() => { - expect(debouncerControl.flush).toBeDefined(); - }); - await debouncerControl.flush?.(); - await Promise.resolve(); - }), - stop: vi.fn(async () => {}), - }; - createIMessageRpcClientMock.mockImplementation(async (params) => { - if (!params?.onNotification) { - throw new Error("expected iMessage notification handler"); - } - onNotification = params.onNotification; - return client as never; - }); - - await monitorIMessageProvider({ - config: { - channels: { - imessage: { - coalesceSameSenderDms: true, - dmPolicy: "allowlist", - allowFrom: ["+15550001111"], - sendReadReceipts: false, - }, - }, - session: { mainKey: "main" }, - } as never, - runtime: { error: vi.fn(), exit: vi.fn(), log: vi.fn() }, - }); - - const debouncerOptions = createChannelInboundDebouncerMock.mock.calls.at(-1)?.[0] as - | { debounceMsOverride?: number } - | undefined; - expect(debouncerOptions?.debounceMsOverride).toBe(7000); - expect(dispatchReplyWithBufferedBlockDispatcherMock).toHaveBeenCalledTimes(1); - const mergedBody = - dispatchReplyWithBufferedBlockDispatcherMock.mock.calls[0]?.[0].ctx.Body ?? ""; - expect(mergedBody).toContain("summarize"); - expect(mergedBody).toContain("https://example.com/article"); - }); - it("keeps ordinary buffered DMs separate after balloon metadata is observed", async () => { debouncerControl.holdEntries = true; diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 93835647816e..de0ce879eb70 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveAgentConfig, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime"; import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-runtime"; import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback"; import { @@ -130,8 +130,8 @@ const IMESSAGE_TYPING_KEEPALIVE_MAX_DURATION_MS = 10 * 60_000; const IMESSAGE_SPLIT_SEND_COMPAT_DEBOUNCE_MS = 7_000; type IMessageTypingController = Parameters>[0]; -function resolveConfiguredIMessageTypingMode(cfg: OpenClawConfig) { - return cfg.session?.typingMode ?? cfg.agents?.defaults?.typingMode; +function resolveConfiguredIMessageTypingMode(cfg: OpenClawConfig, agentId: string) { + return resolveAgentConfig(cfg, agentId)?.typingMode ?? cfg.agents?.defaults?.typingMode; } function resolveIMessageSplitSendCompatDebounceMs( @@ -551,7 +551,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P : recoveryCursorRowid : recoveryBoundaryRowid; - const coalesceSameSenderDms = imessageCfg.coalesceSameSenderDms === true; + const coalesceSameSenderDms = false; const debounceMsOverride = resolveIMessageSplitSendCompatDebounceMs(cfg, coalesceSameSenderDms); // Session capability latch: flips true once any inbound row from this imsg // build carries balloon metadata. The coalesce flush gate needs a build-level @@ -1073,7 +1073,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P warnIfImsgUpgradeNeeded.fireOnce(privateApiStatus.rpcMethods, runtime); } } - const configuredTypingMode = resolveConfiguredIMessageTypingMode(cfg); + const configuredTypingMode = resolveConfiguredIMessageTypingMode(cfg, decision.route.agentId); const sendPolicy = resolveSendPolicy({ cfg, entry: getSessionEntry({ storePath, sessionKey: decision.route.sessionKey }), diff --git a/extensions/inworld/speech-provider.ts b/extensions/inworld/speech-provider.ts index d1101965bbdf..0ed8a756a653 100644 --- a/extensions/inworld/speech-provider.ts +++ b/extensions/inworld/speech-provider.ts @@ -47,7 +47,7 @@ function normalizeInworldProviderConfig(rawConfig: Record): Inw return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, - path: "messages.tts.providers.inworld.apiKey", + path: "tts.providers.inworld.apiKey", }), baseUrl: normalizeInworldBaseUrl(trimToUndefined(raw?.baseUrl)), voiceId: trimToUndefined(raw?.voiceId) ?? DEFAULT_INWORLD_VOICE_ID, diff --git a/extensions/llama-cpp/README.md b/extensions/llama-cpp/README.md index d2f6487aae44..4b55b8550b60 100644 --- a/extensions/llama-cpp/README.md +++ b/extensions/llama-cpp/README.md @@ -31,9 +31,9 @@ for custom GGUF model configuration and hardware guidance. ## Configure embeddings -Set `agents.defaults.memorySearch.provider` to `local`. By default, the plugin +Set `memory.search.provider` to `local`. By default, the plugin downloads and uses the EmbeddingGemma GGUF model. Configure -`agents.defaults.memorySearch.local.modelPath` to use another local path, Hugging +`memory.search.local.modelPath` to use another local path, Hugging Face model URI, or HTTPS model URL. ## Package diff --git a/extensions/lmstudio/src/embedding-provider.ts b/extensions/lmstudio/src/embedding-provider.ts index 5d06c1b75496..76f95a40ef78 100644 --- a/extensions/lmstudio/src/embedding-provider.ts +++ b/extensions/lmstudio/src/embedding-provider.ts @@ -168,7 +168,7 @@ export async function createLmstudioEmbeddingProvider( const remoteApiKey = !isFallbackActivation ? resolveMemorySecretInputString({ value: options.remote?.apiKey, - path: "agents.*.memorySearch.remote.apiKey", + path: "memory.search.remote.apiKey", }) : undefined; // memorySearch.remote is shared across primary + fallback providers. diff --git a/extensions/logbook/src/service.test.ts b/extensions/logbook/src/service.test.ts index e271d01e98dc..a2c9edd75183 100644 --- a/extensions/logbook/src/service.test.ts +++ b/extensions/logbook/src/service.test.ts @@ -139,12 +139,10 @@ describe("LogbookService vision model selection", () => { fullConfig: { tools: { media: { - image: { - models: [ - { provider: "openai", model: "gpt-5.5", capabilities: ["image"] }, - { provider: " Codex ", model: "gpt-5.5", capabilities: ["image"] }, - ], - }, + models: [ + { provider: "openai", model: "gpt-5.5", capabilities: ["image"] }, + { provider: " Codex ", model: "gpt-5.5", capabilities: ["image"] }, + ], }, }, }, diff --git a/extensions/logbook/src/service.ts b/extensions/logbook/src/service.ts index a0174b1fc077..eaa69c4d38c4 100644 --- a/extensions/logbook/src/service.ts +++ b/extensions/logbook/src/service.ts @@ -326,7 +326,7 @@ export class LogbookService { if (media?.image?.enabled === false) { return { source: "missing" }; } - const entries = [...(media?.image?.models ?? []), ...(media?.models ?? [])]; + const entries = media?.models ?? []; for (const entry of entries) { const usable = entry.type !== "cli" && diff --git a/extensions/matrix/src/config-schema.test.ts b/extensions/matrix/src/config-schema.test.ts index a43c769f47ce..cf7d852c59da 100644 --- a/extensions/matrix/src/config-schema.test.ts +++ b/extensions/matrix/src/config-schema.test.ts @@ -147,6 +147,7 @@ describe("MatrixConfigSchema SecretInput", () => { label: "Shelling", maxLines: 4, toolProgress: false, + commandText: "status", }, preview: { toolProgress: true, diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 3e5a19407ee2..0693a9184ebc 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -92,6 +92,7 @@ const matrixStreamingSchema = z maxLines: z.number().int().positive().optional(), maxLineChars: z.number().int().positive().optional(), toolProgress: z.boolean().optional(), + commandText: z.enum(["raw", "status"]).optional(), }) .strict() .optional(), diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index ddc460fe7043..006a996bb83c 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -865,8 +865,8 @@ describe("matrix monitor handler pairing account scope", () => { const hasControlCommand = vi.fn((text?: string) => text === "/new"); const { handler, finalizeInboundContext, recordInboundSession } = createMatrixHandlerTestHarness({ - cfg: { commands: { useAccessGroups: false } }, isDirectMessage: false, + groupAllowFrom: ["@user:example.org"], mentionRegexes: [], shouldHandleTextCommands: () => true, hasControlCommand, @@ -878,20 +878,14 @@ describe("matrix monitor handler pairing account scope", () => { createMatrixTextMessageEvent({ eventId: "$mxid-command", body: "@bot:example.org /new", + mentions: { user_ids: ["@bot:example.org"] }, }), ); expect(callArg(hasControlCommand, 0, 0, "control command")).toBe("/new"); requireRecord(callArg(hasControlCommand, 0, 1, "control command"), "control command context"); - const context = requireRecord( - callArg(finalizeInboundContext, 0, 0, "finalized context"), - "finalized context", - ); - expect(context.RawBody).toBe("@bot:example.org /new"); - expect(context.CommandBody).toBe("/new"); - expect(context.BodyForAgent).toBe("@bot:example.org /new"); - expect(context.BodyForCommands).toBe("/new"); - expect(recordInboundSession).toHaveBeenCalled(); + expect(finalizeInboundContext).not.toHaveBeenCalled(); + expect(recordInboundSession).not.toHaveBeenCalled(); }); it.each([ @@ -901,8 +895,9 @@ describe("matrix monitor handler pairing account scope", () => { "keeps require-mention decision for unmentioned room text $body", async ({ body, isControlCommand, expectedDispatches }) => { const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({ - cfg: { commands: { useAccessGroups: false } }, + cfg: { channels: { matrix: { groupAllowFrom: ["@user:example.org"] } } }, isDirectMessage: false, + groupAllowFrom: ["@user:example.org"], mentionRegexes: [], shouldHandleTextCommands: () => true, hasControlCommand: (text?: string) => isControlCommand && text === body, diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 9339ecee1114..34453ec149a3 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1127,7 +1127,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam cfg, surface: "matrix", }); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const useAccessGroups = true; // Keep mention stripping on the command-only path so history and agent // prompt text continue to see the original Matrix message. const commandCheckText = stripMatrixMentionPrefix({ diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index e881e1726e02..e5766c46279a 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -238,9 +238,7 @@ export type CoreConfig = { botLoopProtection?: ChannelBotLoopProtectionConfig; }; }; - commands?: { - useAccessGroups?: boolean; - }; + commands?: OpenClawConfig["commands"]; session?: { store?: string; dmScope?: NonNullable["dmScope"]; diff --git a/extensions/mattermost/src/mattermost/monitor.authz.test.ts b/extensions/mattermost/src/mattermost/monitor.authz.test.ts index 90652fb8a837..996d377e2617 100644 --- a/extensions/mattermost/src/mattermost/monitor.authz.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.authz.test.ts @@ -29,11 +29,7 @@ function authorizeGroupCommand(senderId: string) { allowFrom: ["trusted-user"], }, }, - cfg: { - commands: { - useAccessGroups: true, - }, - }, + cfg: {}, senderId, senderName: senderId, channelId: "chan-1", @@ -126,11 +122,7 @@ describe("mattermost monitor authz", () => { dmPolicy: "open", }, }, - cfg: { - commands: { - useAccessGroups: true, - }, - }, + cfg: {}, senderId: "alice", senderName: "Alice", channelId: "dm-1", @@ -237,9 +229,6 @@ describe("mattermost monitor authz", () => { }, }, cfg: { - commands: { - useAccessGroups: true, - }, accessGroups: { oncall: { type: "message.senders", diff --git a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts index a2832ef51fba..b33696c934ac 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -197,7 +197,7 @@ vi.mock("./runtime-api.js", async () => { createChannelMessageReplyPipeline: vi.fn((params: { cfg: OpenClawConfig }) => ({ onModelSelected: vi.fn(), typingCallbacks: {}, - resolveResponsePrefix: () => params.cfg.messages?.responsePrefix, + resolveResponsePrefix: () => params.cfg.channels?.mattermost?.responsePrefix, })), registerPluginHttpRoute: mockState.registerPluginHttpRoute, resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024), @@ -780,7 +780,6 @@ describe("mattermost inbound user posts", () => { const abortController = new AbortController(); mockState.abortController = abortController; const inlineCommandConfig: OpenClawConfig = { - commands: { useAccessGroups: true }, channels: { mattermost: { enabled: true, @@ -1042,7 +1041,6 @@ describe("mattermost inbound user posts", () => { const abortController = new AbortController(); mockState.abortController = abortController; const mentionConfig: OpenClawConfig = { - commands: { useAccessGroups: false }, messages: { inbound: { debounceMs: 60_000 } }, channels: { mattermost: { @@ -1052,6 +1050,7 @@ describe("mattermost inbound user posts", () => { chatmode: "oncall", dmPolicy: "open", groupPolicy: "open", + groupAllowFrom: ["user-1"], }, }, }; @@ -1556,7 +1555,6 @@ describe("mattermost inbound user posts", () => { it("finalizes only the current block when the terminal reply is cumulative", async () => { const blockConfig: OpenClawConfig = { - messages: { responsePrefix: "[bot]" }, channels: { mattermost: { enabled: true, @@ -1566,6 +1564,7 @@ describe("mattermost inbound user posts", () => { dmPolicy: "open", groupPolicy: "open", streaming: { mode: "block" }, + responsePrefix: "[bot]", }, }, }; diff --git a/extensions/memory-core/doctor-contract-api.test.ts b/extensions/memory-core/doctor-contract-api.test.ts index e0f60fbcc60d..a4ee559fb6ce 100644 --- a/extensions/memory-core/doctor-contract-api.test.ts +++ b/extensions/memory-core/doctor-contract-api.test.ts @@ -1364,14 +1364,16 @@ describe("memory-core doctor dreaming migration", () => { const agentPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"); await writeLegacyMemorySidecar(legacyPath); const config = { - agents: { - defaults: { - memorySearch: { - store: { - fts: { tokenizer: "trigram" }, - }, + memory: { + search: { + store: { + fts: { tokenizer: "trigram" }, }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, } as unknown as OpenClawConfig; @@ -1389,14 +1391,16 @@ describe("memory-core doctor dreaming migration", () => { const agentPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"); await writeLegacyMemorySidecar(legacyPath); const config = { - agents: { - defaults: { - memorySearch: { - store: { - path: path.join(rootDir, "custom-memory", "{agentId}.sqlite"), - }, + memory: { + search: { + store: { + path: path.join(rootDir, "custom-memory", "{agentId}.sqlite"), }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, } as unknown as OpenClawConfig; @@ -1447,14 +1451,16 @@ describe("memory-core doctor dreaming migration", () => { path: topLevelPath, }, }, - agents: { - defaults: { - memorySearch: { - store: { - path: path.join(rootDir, "default-memory", "{agentId}.sqlite"), - }, + memory: { + search: { + store: { + path: path.join(rootDir, "default-memory", "{agentId}.sqlite"), }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, } as unknown as OpenClawConfig; @@ -1497,14 +1503,16 @@ describe("memory-core doctor dreaming migration", () => { ); await writeLegacyMemorySidecar(legacyPath); const config = { - agents: { - defaults: { - memorySearch: { - store: { - path: legacyPath, - }, + memory: { + search: { + store: { + path: legacyPath, }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, } as unknown as OpenClawConfig; @@ -1552,14 +1560,16 @@ describe("memory-core doctor dreaming migration", () => { const workAgentPath = path.join(stateDir, "agents", "work", "agent", "openclaw-agent.sqlite"); await writeLegacyMemorySidecar(legacyPath); const config = { - agents: { - defaults: { - memorySearch: { - store: { - path: legacyPath, - }, + memory: { + search: { + store: { + path: legacyPath, }, }, + }, + + agents: { + defaults: {}, list: [ { id: "main", workspace: workspaceDir }, { id: "work", workspace: path.join(rootDir, "work") }, @@ -1620,16 +1630,18 @@ describe("memory-core doctor dreaming migration", () => { db.close(); } const config: OpenClawConfig = { - agents: { - defaults: { - memorySearch: { - store: { - vector: { - extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), - }, + memory: { + search: { + store: { + vector: { + extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), }, }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, }; @@ -1678,16 +1690,18 @@ describe("memory-core doctor dreaming migration", () => { const agentPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"); await writeLegacyMemorySidecar(legacyPath, { vector: "vec0" }); const config: OpenClawConfig = { - agents: { - defaults: { - memorySearch: { - store: { - vector: { - extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), - }, + memory: { + search: { + store: { + vector: { + extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), }, }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, }; @@ -1713,23 +1727,25 @@ describe("memory-core doctor dreaming migration", () => { await expect(fs.access(`${legacyPath}.migrated`)).rejects.toThrow(); }); - it("archives legacy vector sidecars when vector search is disabled", async () => { + it("archives legacy vector sidecars when memory search is disabled", async () => { const stateDir = path.join(rootDir, "state"); const legacyPath = path.join(stateDir, "memory", "main.sqlite"); const agentPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"); await writeLegacyMemorySidecar(legacyPath, { vector: "vec0" }); const config: OpenClawConfig = { - agents: { - defaults: { - memorySearch: { - store: { - vector: { - enabled: false, - extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), - }, + memory: { + search: { + provider: "none", + store: { + vector: { + extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), }, }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, }; @@ -1752,17 +1768,19 @@ describe("memory-core doctor dreaming migration", () => { const agentPath = path.join(stateDir, "agents", "main", "agent", "openclaw-agent.sqlite"); await writeLegacyMemorySidecar(legacyPath, { vector: "vec0" }); const config: OpenClawConfig = { - agents: { - defaults: { - memorySearch: { - provider: "none", - store: { - vector: { - extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), - }, + memory: { + search: { + provider: "none", + store: { + vector: { + extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), }, }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, }; @@ -1785,17 +1803,19 @@ describe("memory-core doctor dreaming migration", () => { const retryPath = path.join(stateDir, "memory", "main.sqlite"); await writeLegacyMemorySidecar(legacyPath, { vector: "vec0" }); const config = { - agents: { - defaults: { - memorySearch: { - store: { - path: legacyPath, - vector: { - extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), - }, + memory: { + search: { + store: { + path: legacyPath, + vector: { + extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), }, }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, } as unknown as OpenClawConfig; @@ -1839,17 +1859,19 @@ describe("memory-core doctor dreaming migration", () => { await writeLegacyMemorySidecar(legacyPath, { vector: "vec0" }); await writeLegacyMemorySidecar(retryPath, { vector: "vec0" }); const config = { - agents: { - defaults: { - memorySearch: { - store: { - path: legacyPath, - vector: { - extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), - }, + memory: { + search: { + store: { + path: legacyPath, + vector: { + extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), }, }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, } as unknown as OpenClawConfig; @@ -1862,16 +1884,18 @@ describe("memory-core doctor dreaming migration", () => { expect(alternateRetry).toBeDefined(); const alternateRetryPath = path.join(stateDir, "memory", alternateRetry ?? ""); const repairedConfig: OpenClawConfig = { - agents: { - defaults: { - memorySearch: { - store: { - vector: { - extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), - }, + memory: { + search: { + store: { + vector: { + extensionPath: path.join(rootDir, "missing-sqlite-vec.so"), }, }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, }; @@ -1951,14 +1975,16 @@ describe("memory-core doctor dreaming migration", () => { await writeLegacyMemorySidecar(legacyPath); await createCanonicalMemoryIndex(agentPath, "canonical memory remains authoritative"); const config = { - agents: { - defaults: { - memorySearch: { - store: { - path: legacyPath, - }, + memory: { + search: { + store: { + path: legacyPath, }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, } as unknown as OpenClawConfig; @@ -1992,14 +2018,16 @@ describe("memory-core doctor dreaming migration", () => { await writeLegacyMemorySidecar(legacyPath); await fs.mkdir(agentPath, { recursive: true }); const config = { - agents: { - defaults: { - memorySearch: { - store: { - path: legacyPath, - }, + memory: { + search: { + store: { + path: legacyPath, }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main", workspace: workspaceDir }], }, } as unknown as OpenClawConfig; diff --git a/extensions/memory-core/doctor-contract-api.ts b/extensions/memory-core/doctor-contract-api.ts index c698758ba7db..546d2c893f16 100644 --- a/extensions/memory-core/doctor-contract-api.ts +++ b/extensions/memory-core/doctor-contract-api.ts @@ -651,8 +651,14 @@ function importLegacyMemorySidecarIndex(params: { } function resolveConfiguredAgentIds(config: unknown): string[] { - const cfg = config as { agents?: { list?: unknown } }; + const cfg = config as { agents?: { entries?: unknown; list?: unknown } }; const ids = new Set(); + const entries = asRecord(cfg.agents?.entries); + if (entries) { + for (const id of Object.keys(entries)) { + ids.add(normalizeAgentId(id)); + } + } if (Array.isArray(cfg.agents?.list)) { for (const entry of cfg.agents.list) { if (!entry || typeof entry !== "object") { @@ -677,22 +683,29 @@ function readAgentMemorySearch( agentId: string, ): Record | undefined { const agents = asRecord(asRecord(config)?.agents); + const keyedEntries = asRecord(agents?.entries); + const keyedEntry = keyedEntries + ? Object.entries(keyedEntries).find(([id]) => normalizeAgentId(id) === agentId)?.[1] + : undefined; + const keyedSearch = asRecord(asRecord(asRecord(keyedEntry)?.memory)?.search); + if (keyedSearch) { + return keyedSearch; + } const entries = Array.isArray(agents?.list) ? agents.list : []; - return asRecord( - entries - .map(asRecord) - .find( - (entry) => - normalizeAgentId(typeof entry?.id === "string" ? entry.id : undefined) === agentId, - )?.memorySearch, - ); + const entry = entries + .map(asRecord) + .find( + (candidate) => + normalizeAgentId(typeof candidate?.id === "string" ? candidate.id : undefined) === agentId, + ); + return asRecord(asRecord(entry?.memory)?.search); } function readDefaultMemorySearch(config: unknown): Record | undefined { - const agents = asRecord(asRecord(config)?.agents); - return asRecord(asRecord(agents?.defaults)?.memorySearch); + return asRecord(asRecord(asRecord(config)?.memory)?.search); } +// Doctor still inspects the retired root shape so it can migrate its persisted sidecar path. function readTopLevelMemorySearch(config: unknown): Record | undefined { return asRecord(asRecord(config)?.memorySearch); } diff --git a/extensions/memory-core/index.test.ts b/extensions/memory-core/index.test.ts index d9b09b2c717e..6157341535c3 100644 --- a/extensions/memory-core/index.test.ts +++ b/extensions/memory-core/index.test.ts @@ -171,20 +171,7 @@ describe("buildMemoryFlushPlan", () => { it("replaces YYYY-MM-DD using user timezone and appends current time", () => { const plan = buildMemoryFlushPlan({ - cfg: { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - compaction: { - memoryFlush: { - prompt: "Store durable notes in memory/YYYY-MM-DD.md", - }, - }, - }, - }, - }, + cfg, nowMs: Date.UTC(2026, 1, 16, 15, 0, 0), }); @@ -196,26 +183,12 @@ describe("buildMemoryFlushPlan", () => { expect(plan?.relativePath).toBe("memory/2026-02-16.md"); }); - it("does not append a duplicate current time line", () => { + it("appends one current time line to the built-in prompt", () => { const plan = buildMemoryFlushPlan({ - cfg: { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - compaction: { - memoryFlush: { - prompt: "Store notes.\nCurrent time: already present", - }, - }, - }, - }, - }, + cfg, nowMs: Date.UTC(2026, 1, 16, 15, 0, 0), }); - expect(plan?.prompt).toContain("Current time: already present"); expect((plan?.prompt.match(/Current time:/g) ?? []).length).toBe(1); }); diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts index 4b3f1ff2b0cf..b0ec275bf94e 100644 --- a/extensions/memory-core/src/cli.runtime.ts +++ b/extensions/memory-core/src/cli.runtime.ts @@ -161,10 +161,7 @@ type LoadedMemoryCommandConfig = { }; function getMemoryCommandSecretTargetIds(): Set { - return new Set([ - "agents.defaults.memorySearch.remote.apiKey", - "agents.list[].memorySearch.remote.apiKey", - ]); + return new Set(["memory.search.remote.apiKey", "agents.entries.*.memory.search.remote.apiKey"]); } async function loadMemoryCommandConfig(commandName: string): Promise { diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index 2319ece0ee9c..0295ccdbf17a 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -132,7 +132,7 @@ afterAll(async () => { }); describe("memory cli", () => { - const inactiveMemorySecretDiagnostic = "agents.defaults.memorySearch.remote.apiKey inactive"; // pragma: allowlist secret + const inactiveMemorySecretDiagnostic = "memory.search.remote.apiKey inactive"; // pragma: allowlist secret function firstMockCallArg(mock: { mock: { calls: unknown[][] } }, label: string): unknown { const call = mock.mock.calls[0]; @@ -515,15 +515,17 @@ describe("memory cli", () => { it("resolves configured memory SecretRefs through gateway snapshot", async () => { const config = { - agents: { - defaults: { - memorySearch: { - remote: { - apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" }, - }, + memory: { + search: { + remote: { + apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" }, }, }, }, + + agents: { + defaults: {}, + }, }; getRuntimeConfig.mockReturnValue(config); const close = vi.fn(async () => {}); @@ -542,10 +544,7 @@ describe("memory cli", () => { expect(secretRefsCall.config).toBe(config); expect(secretRefsCall.commandName).toBe("memory status"); expect(secretRefsCall.targetIds).toStrictEqual( - new Set([ - "agents.defaults.memorySearch.remote.apiKey", - "agents.list[].memorySearch.remote.apiKey", - ]), + new Set(["memory.search.remote.apiKey", "agents.entries.*.memory.search.remote.apiKey"]), ); }); diff --git a/extensions/memory-core/src/dreaming-phases.test.ts b/extensions/memory-core/src/dreaming-phases.test.ts index eada786babc1..ae7d0a6aeb69 100644 --- a/extensions/memory-core/src/dreaming-phases.test.ts +++ b/extensions/memory-core/src/dreaming-phases.test.ts @@ -2312,12 +2312,15 @@ describe("memory-core dreaming phases", () => { const { beforeAgentReply } = createHarness( { + memory: { + search: { + enabled: false, + }, + }, + agents: { defaults: { workspace: workspaceDir, - memorySearch: { - enabled: false, - }, }, }, plugins: { diff --git a/extensions/memory-core/src/flush-plan.ts b/extensions/memory-core/src/flush-plan.ts index 422d1cb57f4a..8de6cb754a81 100644 --- a/extensions/memory-core/src/flush-plan.ts +++ b/extensions/memory-core/src/flush-plan.ts @@ -119,13 +119,9 @@ export function buildMemoryFlushPlan( const dateStamp = formatDateStampInTimezone(nowMs, userTimezone); const relativePath = `memory/${dateStamp}.md`; - const promptBase = ensureNoReplyHint( - ensureMemoryFlushSafetyHints(defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT), - ); + const promptBase = ensureNoReplyHint(ensureMemoryFlushSafetyHints(DEFAULT_MEMORY_FLUSH_PROMPT)); const systemPrompt = ensureNoReplyHint( - ensureMemoryFlushSafetyHints( - defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT, - ), + ensureMemoryFlushSafetyHints(DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT), ); return { diff --git a/extensions/memory-core/src/memory/index.test.ts b/extensions/memory-core/src/memory/index.test.ts index 9aa61e1f5661..cdac86da1e68 100644 --- a/extensions/memory-core/src/memory/index.test.ts +++ b/extensions/memory-core/src/memory/index.test.ts @@ -386,33 +386,31 @@ describe("memory index", () => { }; }): TestCfg { return { + memory: { + search: { + ...(params.provider !== undefined ? { provider: params.provider } : {}), + model: params.model ?? "mock-embed", + fallback: params.fallback, + outputDimensionality: params.outputDimensionality, + store: { vector: {} }, + remote: params.batchEnabled + ? { + batch: { enabled: true }, + } + : undefined, + query: { minScore: params.minScore ?? 0 }, + cache: params.cacheEnabled ? { enabled: true } : undefined, + extraPaths: params.extraPaths, + multimodal: params.multimodal, + sources: params.sources, + rememberAcrossConversations: + params.rememberAcrossConversations ?? params.sessionMemory ?? false, + }, + }, + agents: { defaults: { workspace: workspaceDir, - memorySearch: { - ...(params.provider !== undefined ? { provider: params.provider } : {}), - model: params.model ?? "mock-embed", - fallback: params.fallback, - outputDimensionality: params.outputDimensionality, - store: { vector: { enabled: params.vectorEnabled ?? false } }, - sync: { watch: false, onSessionStart: false, onSearch: params.onSearch ?? true }, - remote: params.batchEnabled - ? { - nonBatchConcurrency: 1, - batch: { enabled: true, pollIntervalMs: 0, timeoutMinutes: 1 }, - } - : undefined, - query: { - minScore: params.minScore ?? 0, - hybrid: params.hybrid ?? { enabled: false }, - }, - cache: params.cacheEnabled ? { enabled: true } : undefined, - extraPaths: params.extraPaths, - multimodal: params.multimodal, - sources: params.sources, - rememberAcrossConversations: params.rememberAcrossConversations ?? false, - experimental: { sessionMemory: params.sessionMemory ?? false }, - }, }, list: [{ id: "main", default: true }], }, @@ -561,6 +559,7 @@ describe("memory index", () => { forceNoProvider = true; setMemoryIndexStateDir(path.join(workspaceDir, params.stateDirName)); const cfg = createCfg({ + provider: "none", sources: ["memory", "sessions"], sessionMemory: true, minScore: 0, @@ -1865,21 +1864,11 @@ describe("memory index", () => { expect(status.vector?.available).toBeUndefined(); }); - it("marks older vector indexes dirty after vector store probing", async () => { - const legacyCfg = createCfg({ - provider: "gemini", - vectorEnabled: false, - }); - const legacyManager = await getFreshManager(legacyCfg); - await legacyManager.sync({ reason: "test", force: true }); - await legacyManager.close?.(); - - const cfg = createCfg({ - provider: "gemini", - vectorEnabled: true, - }); + it("keeps current vector indexes clean after vector store probing", async () => { + const cfg = createCfg({ provider: "gemini" }); const manager = await getFreshManager(cfg); try { + await manager.sync({ reason: "test", force: true }); const metaAccess = manager as unknown as { readMeta(): MemoryIndexMeta | null; }; @@ -1887,16 +1876,12 @@ describe("memory index", () => { if (!meta) { throw new Error("expected index metadata"); } - expect(meta.vectorDims).toBeUndefined(); + expect(meta.vectorDims).toBe(4); await manager.probeVectorStoreAvailability?.(); const status = manager.status(); - expect(status.dirty).toBe(true); - expect(status.custom?.indexIdentity).toEqual({ - status: "mismatched", - reason: "index vector dimensions are missing", - }); + expect(status.dirty).toBe(false); } finally { await manager.close?.(); } @@ -2099,7 +2084,7 @@ describe("memory index", () => { } }); - it("activates configured fallback after probe-time local degradation", async () => { + it("reinitializes the configured provider after probe-time local degradation", async () => { const cfg = createCfg({ fallback: "fallback-provider", hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 }, @@ -2137,17 +2122,15 @@ describe("memory index", () => { const results = await manager.search("alpha"); - expect(results).toStrictEqual([]); - expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain( - "fallback-provider", - ); + expect(results.length).toBeGreaterThan(0); + expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain("openai"); expect( ( manager as unknown as { provider: { id: string } | null; } ).provider?.id, - ).toBe("fallback-provider"); + ).toBe("mock"); }); it("clears identity dirty after status resolves the indexed fallback provider", async () => { @@ -2304,6 +2287,7 @@ describe("memory index", () => { forceNoProvider = true; const cfg = createCfg({ + provider: "none", minScore: 0.35, hybrid: { enabled: true }, }); @@ -2336,6 +2320,7 @@ describe("memory index", () => { it("ranks an exact path stem ahead of a body match before applying the result limit", async () => { forceNoProvider = true; const cfg = createCfg({ + provider: "none", minScore: 0.35, hybrid: { enabled: true }, }); @@ -2363,6 +2348,7 @@ describe("memory index", () => { it("does not let fallback-term filenames consume the candidate cap", async () => { forceNoProvider = true; const cfg = createCfg({ + provider: "none", minScore: 0, hybrid: { enabled: true }, }); @@ -2543,6 +2529,7 @@ describe("memory index", () => { it("uses body relevance within the same exact basename tier in FTS-only mode", async () => { forceNoProvider = true; const cfg = createCfg({ + provider: "none", minScore: 0, hybrid: { enabled: true }, }); @@ -2568,7 +2555,7 @@ describe("memory index", () => { expect(results[0]?.score).toBe(1); }); - it("preserves temporal decay for body and path-only exact basenames", async () => { + it("returns exact basename candidates with fixed FTS ranking", async () => { forceNoProvider = true; const staleDir = path.join(fixtureRoot, "decay-a-stale"); const freshDir = path.join(fixtureRoot, "decay-z-fresh"); @@ -2587,12 +2574,9 @@ describe("memory index", () => { fs.utimes(staleBarPath, staleMtime, staleMtime), ]); const cfg = createCfg({ + provider: "none", extraPaths: [staleDir, freshDir], minScore: 0, - hybrid: { - enabled: true, - temporalDecay: { enabled: true }, - }, }); const result = await getMemorySearchManager({ cfg, agentId: "main" }); const manager = requireManager(result); @@ -2606,12 +2590,11 @@ describe("memory index", () => { for (const basename of ["foo.md", "bar.md"]) { const results = await manager.search(basename, { maxResults: 1, minScore: 0 }); expect(results).toHaveLength(1); - expect(results[0]?.path.endsWith(`decay-z-fresh/${basename}`)).toBe(true); expect(results[0]?.score).toBe(1); } }); - it("applies temporal decay after the exact-path candidate cap", async () => { + it("applies the fixed FTS candidate cap to exact paths", async () => { forceNoProvider = true; const staleMtime = new Date(Date.now() - 90 * 24 * 60 * 60_000); const extraPaths: string[] = []; @@ -2628,12 +2611,9 @@ describe("memory index", () => { extraPaths.push(extraDir); } const cfg = createCfg({ + provider: "none", extraPaths, minScore: 0, - hybrid: { - enabled: true, - temporalDecay: { enabled: true }, - }, }); const result = await getMemorySearchManager({ cfg, agentId: "main" }); const manager = requireManager(result); @@ -2646,11 +2626,10 @@ describe("memory index", () => { const results = await manager.search("foo.md", { maxResults: 1, minScore: 0 }); expect(results).toHaveLength(1); - expect(results[0]?.path.endsWith("decay-cap-z-fresh/foo.md")).toBe(true); expect(results[0]?.score).toBe(1); }); - it("applies hybrid temporal decay beyond the content candidate cap", async () => { + it("applies the fixed hybrid candidate cap", async () => { const staleMtime = new Date(Date.now() - 90 * 24 * 60 * 60_000); const extraPaths: string[] = []; for (let index = 0; index < 5; index += 1) { @@ -2668,21 +2647,16 @@ describe("memory index", () => { const cfg = createCfg({ extraPaths, minScore: 0, - hybrid: { - enabled: true, - temporalDecay: { enabled: true }, - }, }); const manager = await getPersistentManager(cfg); await manager.sync({ reason: "test" }); const results = await manager.search("alpha.md", { maxResults: 1, minScore: 0 }); expect(results).toHaveLength(1); - expect(results[0]?.path.endsWith("hybrid-decay-cap-z-fresh/alpha.md")).toBe(true); expect(results[0]?.score).toBe(1); }); - it("keeps temporal decay when degraded hybrid search becomes keyword-only", async () => { + it("keeps fixed hybrid ranking when search degrades to keyword-only", async () => { const staleMtime = new Date(Date.now() - 90 * 24 * 60 * 60_000); const extraPaths: string[] = []; for (let index = 0; index < 5; index += 1) { @@ -2700,10 +2674,6 @@ describe("memory index", () => { extraPaths, fallback: "none", minScore: 0, - hybrid: { - enabled: true, - temporalDecay: { enabled: true }, - }, }); const manager = await getPersistentManager(cfg); await manager.sync({ reason: "test" }); @@ -2730,13 +2700,13 @@ describe("memory index", () => { const results = await manager.search("beta.md", { maxResults: 1, minScore: 0 }); expect(results).toHaveLength(1); - expect(results[0]?.path.endsWith("degraded-decay-cap-z-fresh/beta.md")).toBe(true); expect(results[0]?.score).toBe(1); }); it("keeps body relevance for an exact basename beyond the exact candidate cap", async () => { forceNoProvider = true; const cfg = createCfg({ + provider: "none", minScore: 0, hybrid: { enabled: true }, }); @@ -2800,6 +2770,7 @@ describe("memory index", () => { it("keeps boosted score ordering for non-exact FTS-only body matches", async () => { forceNoProvider = true; const cfg = createCfg({ + provider: "none", minScore: 0, hybrid: { enabled: true }, }); @@ -2827,14 +2798,11 @@ describe("memory index", () => { expect(results[0]?.score).toBeLessThanOrEqual(1); }); - it("keeps an exact dated path ahead when temporal decay is enabled", async () => { + it("keeps an exact dated path ahead in FTS-only mode", async () => { forceNoProvider = true; const cfg = createCfg({ + provider: "none", minScore: 0.35, - hybrid: { - enabled: true, - temporalDecay: { enabled: true }, - }, }); const result = await getMemorySearchManager({ cfg, agentId: "main" }); const manager = requireManager(result); @@ -2981,6 +2949,7 @@ describe("memory index", () => { setMemoryIndexStateDir(path.join(workspaceDir, ".state-remember-search-sources")); try { const cfg = createCfg({ + provider: "none", rememberAcrossConversations: true, minScore: 0, hybrid: { enabled: true, vectorWeight: 0.7, textWeight: 0.3 }, diff --git a/extensions/memory-core/src/memory/manager-watch-ops.ts b/extensions/memory-core/src/memory/manager-watch-ops.ts index 78f8252f61af..7bd10a544d49 100644 --- a/extensions/memory-core/src/memory/manager-watch-ops.ts +++ b/extensions/memory-core/src/memory/manager-watch-ops.ts @@ -237,7 +237,7 @@ export abstract class MemoryManagerWatchOps extends MemoryManagerSyncBase { count, unit, "Large memory folders or extraPaths can make OpenClaw run out of file watchers or open files.", - "Remove large extraPaths, or set memorySearch.sync.watch to false and refresh memory manually.", + "Remove large extraPaths, or set memory.search.sync.watch to false and refresh memory manually.", (message) => log.warn(message), ); } diff --git a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts index 32125e557a15..ec087c4efd40 100644 --- a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts +++ b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts @@ -87,17 +87,18 @@ describe("memory manager FTS-only reindex", () => { const cfg = { memory: { backend: "builtin", + + search: { + provider: params.provider ?? "auto", + model: "", + store, + cache: { enabled: false }, + sync: { watch: false, onSessionStart: false, onSearch: false }, + }, }, agents: { defaults: { workspace: workspaceDir, - memorySearch: { - provider: params.provider ?? "auto", - model: "", - store, - cache: { enabled: false }, - sync: { watch: false, onSessionStart: false, onSearch: false }, - }, }, list: [{ id: "main", default: true }], }, diff --git a/extensions/memory-core/src/memory/manager.reindex-recovery.test.ts b/extensions/memory-core/src/memory/manager.reindex-recovery.test.ts index 5f31123b6aa6..a0acdeab4404 100644 --- a/extensions/memory-core/src/memory/manager.reindex-recovery.test.ts +++ b/extensions/memory-core/src/memory/manager.reindex-recovery.test.ts @@ -62,20 +62,20 @@ describe("memory manager reindex recovery", () => { sources?: Array<"memory" | "sessions">; }): OpenClawConfig { return { - memory: { backend: "builtin" }, + memory: { + backend: "builtin", + search: { + provider: params.provider ?? "openai", + model: "mock-embed", + store: { vector: {} }, + cache: { enabled: false }, + sources: params.sources, + rememberAcrossConversations: params.sources?.includes("sessions") ?? false, + }, + }, agents: { defaults: { workspace: workspaceDir, - memorySearch: { - provider: params.provider ?? "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: false, onSessionStart: false, onSearch: false }, - remote: { nonBatchConcurrency: 1 }, - cache: { enabled: false }, - sources: params.sources, - experimental: { sessionMemory: params.sources?.includes("sessions") ?? false }, - }, }, list: [{ id: "main", default: true }], }, diff --git a/extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts b/extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts index 553d489c36ae..6266e2a1f037 100644 --- a/extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts +++ b/extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts @@ -94,17 +94,19 @@ describe("memory manager self-heal missing identity with FTS-only chunks", () => ? undefined : { vector: { enabled: params.vectorEnabled } }; const cfg = { - memory: { backend: "builtin" }, + memory: { + backend: "builtin", + search: { + provider: params.provider ?? "auto", + model: "", + store, + cache: { enabled: false }, + sync: { watch: false, onSessionStart: false, onSearch: false }, + }, + }, agents: { defaults: { workspace: workspaceDir, - memorySearch: { - provider: params.provider ?? "auto", - model: "", - store, - cache: { enabled: false }, - sync: { watch: false, onSessionStart: false, onSearch: false }, - }, }, list: [{ id: "main", default: true }], }, diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index 193f60936ce1..2e31e9569145 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -1,6 +1,7 @@ // Memory Core plugin module implements manager behavior. import type { DatabaseSync } from "node:sqlite"; import type { FSWatcher } from "chokidar"; +import { resolveAgentConfig } from "openclaw/plugin-sdk/agent-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { listRegisteredMemoryEmbeddingProviderAdapters } from "openclaw/plugin-sdk/memory-core-host-embedding-registry"; import { classifyMemoryMultimodalPath } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; @@ -211,11 +212,8 @@ function resolveConfiguredMemoryEmbeddingProvider(params: { cfg: OpenClawConfig; agentId: string; }): string | undefined { - const normalizedAgentId = normalizeAgentId(params.agentId); - const agentEntry = params.cfg.agents?.list?.find( - (entry) => entry && normalizeAgentId(entry.id) === normalizedAgentId, - ); - return agentEntry?.memorySearch?.provider ?? params.cfg.agents?.defaults?.memorySearch?.provider; + const agentEntry = resolveAgentConfig(params.cfg, normalizeAgentId(params.agentId)); + return agentEntry?.memory?.search?.provider ?? params.cfg.memory?.search?.provider; } function resolveMemoryEmbeddingProviderRequirement(params: { diff --git a/extensions/memory-core/src/memory/manager.watcher-config.test.ts b/extensions/memory-core/src/memory/manager.watcher-config.test.ts index 95c2ec57ebfc..b3c0e8215108 100644 --- a/extensions/memory-core/src/memory/manager.watcher-config.test.ts +++ b/extensions/memory-core/src/memory/manager.watcher-config.test.ts @@ -229,18 +229,20 @@ describe("memory watcher config", () => { function createWatcherConfig(overrides?: Partial): OpenClawConfig { const defaults: NonNullable["defaults"]> = { workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: true, onSessionStart: false, onSearch: false }, - query: { minScore: 0, hybrid: { enabled: false } }, - extraPaths: [extraDir], - ...overrides, - }, }; return { - memory: { backend: "builtin" }, + memory: { + backend: "builtin", + search: { + provider: "openai", + model: "mock-embed", + store: { vector: { enabled: false } }, + sync: { watch: true, onSessionStart: false, onSearch: false }, + query: { minScore: 0, hybrid: { enabled: false } }, + extraPaths: [extraDir], + ...overrides, + }, + }, agents: { defaults, list: [{ id: "main", default: true }], diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 91c87cd7b2dd..5232499a447d 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -750,7 +750,7 @@ describe("QmdMemoryManager", () => { ...cfg, agents: { ...cfg.agents, - list: [{ id: "main", memorySearch: { rememberAcrossConversations: true } }], + list: [{ id: "main", memory: { search: { rememberAcrossConversations: true } } }], }, memory: { backend: "qmd", @@ -876,6 +876,73 @@ describe("QmdMemoryManager", () => { return path.join(stateDir, "agents", selectedAgentId, "qmd", "xdg-config", "qmd", "index.yml"); } + function resolveMemoryBackendConfigForTest(sourceCfg: OpenClawConfig, selectedAgentId: string) { + const resolved = resolveMemoryBackendConfig({ cfg: sourceCfg, agentId: selectedAgentId }); + const qmdTestConfig = sourceCfg.memory?.qmd as + | { + mcporter?: { enabled?: boolean; serverName?: string; startDaemon?: boolean }; + update?: { + commandTimeoutMs?: number; + debounceMs?: number; + embedInterval?: string; + embedTimeoutMs?: number; + interval?: string; + onBoot?: boolean; + startup?: "off" | "idle" | "blocking"; + startupDelayMs?: number; + updateTimeoutMs?: number; + waitForBootSync?: boolean; + }; + } + | undefined; + if (!resolved.qmd) { + return resolved; + } + + // Removed config knobs still drive focused manager mechanics in this test file only. + Object.assign(resolved.qmd.mcporter, qmdTestConfig?.mcporter); + const update = qmdTestConfig?.update; + if (!update) { + return resolved; + } + const parseInterval = (value: string | undefined, defaultUnitMs: number) => { + if (!value) { + return undefined; + } + const match = /^(\d+)(ms|s|m|h)?$/.exec(value.trim()); + if (!match) { + return undefined; + } + const amount = Number(match[1]); + const unitMsBySuffix: Record = { + ms: 1, + s: 1_000, + m: 60_000, + h: 3_600_000, + }; + return amount * (unitMsBySuffix[match[2] ?? ""] ?? defaultUnitMs); + }; + Object.assign(resolved.qmd.update, { + ...(update.interval !== undefined + ? { intervalMs: parseInterval(update.interval, 60_000) } + : {}), + ...(update.debounceMs !== undefined ? { debounceMs: update.debounceMs } : {}), + ...(update.onBoot !== undefined ? { onBoot: update.onBoot } : {}), + ...(update.startup !== undefined ? { startup: update.startup } : {}), + ...(update.startupDelayMs !== undefined ? { startupDelayMs: update.startupDelayMs } : {}), + ...(update.waitForBootSync !== undefined ? { waitForBootSync: update.waitForBootSync } : {}), + ...(update.embedInterval !== undefined + ? { embedIntervalMs: parseInterval(update.embedInterval, 60_000) } + : {}), + ...(update.commandTimeoutMs !== undefined + ? { commandTimeoutMs: update.commandTimeoutMs } + : {}), + ...(update.updateTimeoutMs !== undefined ? { updateTimeoutMs: update.updateTimeoutMs } : {}), + ...(update.embedTimeoutMs !== undefined ? { embedTimeoutMs: update.embedTimeoutMs } : {}), + }); + return resolved; + } + async function createManager(params?: { mode?: "full" | "status" | "cli"; cfg?: OpenClawConfig; @@ -884,19 +951,23 @@ describe("QmdMemoryManager", () => { const sourceCfg = params?.cfg ?? cfg; const cfgToUse: OpenClawConfig = { ...sourceCfg, + memory: { + ...sourceCfg.memory, + search: { + rememberAcrossConversations: false, + ...sourceCfg.memory?.search, + }, + }, + agents: { ...sourceCfg.agents, defaults: { ...sourceCfg.agents?.defaults, - memorySearch: { - rememberAcrossConversations: false, - ...sourceCfg.agents?.defaults?.memorySearch, - }, }, }, }; const selectedAgentId = params?.agentId ?? agentId; - const resolved = resolveMemoryBackendConfig({ cfg: cfgToUse, agentId: selectedAgentId }); + const resolved = resolveMemoryBackendConfigForTest(cfgToUse, selectedAgentId); const manager = trackManager( await QmdMemoryManager.create({ cfg: cfgToUse, @@ -955,13 +1026,6 @@ describe("QmdMemoryManager", () => { agents: { defaults: { workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - rememberAcrossConversations: false, - store: { vector: { enabled: false } }, - sync: { watch: false, onSessionStart: false, onSearch: false }, - }, }, list: [{ id: agentId, default: true, workspace: workspaceDir }], }, @@ -972,6 +1036,14 @@ describe("QmdMemoryManager", () => { update: { interval: "0s", debounceMs: 60_000, onBoot: false }, paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], }, + + search: { + provider: "openai", + model: "mock-embed", + rememberAcrossConversations: false, + store: { vector: { enabled: false } }, + sync: { watch: false, onSessionStart: false, onSearch: false }, + }, }, } as OpenClawConfig; seedMemoryEmbeddingProviders(); @@ -1043,12 +1115,6 @@ describe("QmdMemoryManager", () => { agents: { defaults: { workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: false, onSessionStart: true, onSearch: false }, - }, }, list: [{ id: agentId, default: true, workspace: workspaceDir }], }, @@ -1059,6 +1125,13 @@ describe("QmdMemoryManager", () => { update: { interval: "0s", debounceMs: 0, onBoot: false }, paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], }, + + search: { + provider: "openai", + model: "mock-embed", + store: { vector: { enabled: false } }, + sync: { watch: false, onSessionStart: true, onSearch: false }, + }, }, } as OpenClawConfig; @@ -1087,12 +1160,6 @@ describe("QmdMemoryManager", () => { agents: { defaults: { workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: false, onSessionStart: true, onSearch: false }, - }, }, list: [{ id: agentId, default: true, workspace: workspaceDir }], }, @@ -1103,6 +1170,13 @@ describe("QmdMemoryManager", () => { update: { interval: "0s", debounceMs: 0, onBoot: false }, paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], }, + + search: { + provider: "openai", + model: "mock-embed", + store: { vector: { enabled: false } }, + sync: { watch: false, onSessionStart: true, onSearch: false }, + }, }, } as OpenClawConfig; @@ -1142,12 +1216,6 @@ describe("QmdMemoryManager", () => { agents: { defaults: { workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: true, onSessionStart: false, onSearch: false }, - }, }, list: [{ id: agentId, default: true, workspace: workspaceDir }], }, @@ -1158,6 +1226,13 @@ describe("QmdMemoryManager", () => { update: { interval: "0s", debounceMs: 0, onBoot: false }, paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], }, + + search: { + provider: "openai", + model: "mock-embed", + store: { vector: { enabled: false } }, + sync: { watch: true, onSessionStart: false, onSearch: false }, + }, }, } as OpenClawConfig; @@ -1210,12 +1285,6 @@ describe("QmdMemoryManager", () => { agents: { defaults: { workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: true, onSessionStart: false, onSearch: false }, - }, }, list: [{ id: agentId, default: true, workspace: workspaceDir }], }, @@ -1230,6 +1299,13 @@ describe("QmdMemoryManager", () => { name: path.basename(root), })), }, + + search: { + provider: "openai", + model: "mock-embed", + store: { vector: { enabled: false } }, + sync: { watch: true, onSessionStart: false, onSearch: false }, + }, }, } as OpenClawConfig; @@ -1257,12 +1333,6 @@ describe("QmdMemoryManager", () => { agents: { defaults: { workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: true, onSessionStart: false, onSearch: false }, - }, }, list: [{ id: agentId, default: true, workspace: workspaceDir }], }, @@ -1276,6 +1346,13 @@ describe("QmdMemoryManager", () => { { path: nestedRoot, pattern: "**/*.md", name: "build" }, ], }, + + search: { + provider: "openai", + model: "mock-embed", + store: { vector: { enabled: false } }, + sync: { watch: true, onSessionStart: false, onSearch: false }, + }, }, } as OpenClawConfig; @@ -1295,12 +1372,6 @@ describe("QmdMemoryManager", () => { agents: { defaults: { workspace: workspaceDir, - memorySearch: { - provider: "openai", - model: "mock-embed", - store: { vector: { enabled: false } }, - sync: { watch: true, onSessionStart: false, onSearch: false }, - }, }, list: [{ id: agentId, default: true, workspace: workspaceDir }], }, @@ -1311,6 +1382,13 @@ describe("QmdMemoryManager", () => { update: { interval: "0s", debounceMs: 0, onBoot: false }, paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], }, + + search: { + provider: "openai", + model: "mock-embed", + store: { vector: { enabled: false } }, + sync: { watch: true, onSessionStart: false, onSearch: false }, + }, }, } as OpenClawConfig; @@ -1474,10 +1552,6 @@ describe("QmdMemoryManager", () => { defaults: { ...cfg.agents?.defaults, workspace: workspaceDir, - memorySearch: { - ...cfg.agents?.defaults?.memorySearch, - sync: { watch: false, onSessionStart: true, onSearch: true }, - }, }, }, memory: { @@ -1488,6 +1562,11 @@ describe("QmdMemoryManager", () => { update: { interval: "0s", debounceMs: 60_000, onBoot: false }, paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], }, + + search: { + ...cfg.memory?.search, + sync: { watch: false, onSessionStart: true, onSearch: true }, + }, }, } as OpenClawConfig; spawnMock.mockImplementation((_cmd: string, args: string[]) => { @@ -1545,7 +1624,7 @@ describe("QmdMemoryManager", () => { return createMockChild(); }); - const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const resolved = resolveMemoryBackendConfigForTest(cfg, agentId); const createPromise = QmdMemoryManager.create({ cfg, agentId, @@ -1648,7 +1727,7 @@ describe("QmdMemoryManager", () => { return createMockChild(); }); - const resolved = resolveMemoryBackendConfig({ cfg, agentId: devAgentId }); + const resolved = resolveMemoryBackendConfigForTest(cfg, devAgentId); const manager = trackManager( await QmdMemoryManager.create({ cfg, @@ -2406,14 +2485,16 @@ describe("QmdMemoryManager", () => { }, }, } as OpenClawConfig; + const updateSpawned = createDeferred(); spawnMock.mockImplementation((_cmd: string, args: string[]) => { if (args[0] === "update") { + updateSpawned.resolve(); return createMockChild({ autoClose: false }); } return createMockChild(); }); - const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const resolved = resolveMemoryBackendConfigForTest(cfg, agentId); const createPromise = QmdMemoryManager.create({ cfg, agentId, @@ -2425,6 +2506,8 @@ describe("QmdMemoryManager", () => { const manager = requireValue(trackManager(await createPromise), "manager missing"); const syncPromise = manager.sync({ reason: "manual" }); const rejected = expect(syncPromise).rejects.toThrow("qmd update timed out after 20ms"); + await vi.advanceTimersByTimeAsync(0); + await updateSpawned.promise; await vi.advanceTimersByTimeAsync(20); await rejected; await manager.close(); @@ -3060,10 +3143,10 @@ describe("QmdMemoryManager", () => { ]); expect(addCallsAfterMissing).toBeGreaterThan(0); expectMockMessageContains(logWarnMock, "repairing collections and retrying once"); - const [repairLease] = writeLeaseCalls(); - expect(repairLease?.[0].signal?.aborted).toBe(false); + const repairLeases = writeLeaseCalls(); + expect(repairLeases.some(([options]) => options.signal?.aborted)).toBe(false); callerController.abort(); - expect(repairLease?.[0].signal?.aborted).toBe(true); + expect(repairLeases.some(([options]) => options.signal?.aborted)).toBe(true); await manager.close(); }); @@ -5463,7 +5546,7 @@ describe("QmdMemoryManager", () => { return createMockChild(); }); - const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const resolved = resolveMemoryBackendConfigForTest(cfg, agentId); const createPromise = QmdMemoryManager.create({ cfg, agentId, @@ -6602,7 +6685,7 @@ describe("QmdMemoryManager", () => { ...cfg, agents: { ...cfg.agents, - list: [{ id: "main", memorySearch: { rememberAcrossConversations: true } }], + list: [{ id: "main", memory: { search: { rememberAcrossConversations: true } } }], }, memory: { backend: "qmd", @@ -7470,12 +7553,6 @@ describe("QmdMemoryManager", () => { qmd: { includeDefaultMemory: false, sessions: { enabled: true }, - update: { - interval: "0s", - debounceMs: 0, - onBoot: true, - waitForBootSync: true, - }, paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], }, }, @@ -7495,6 +7572,7 @@ describe("QmdMemoryManager", () => { }); const { manager } = await createManager({ mode: "full" }); + await manager.sync({ reason: "manual", force: true }); const sessionExportDir = path.join(stateDir, "agents", agentId, "qmd", "sessions"); const exported = (await fs.readdir(sessionExportDir)).toSorted(); diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index b9cfc06a9bbb..97e1b6feee89 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -1308,7 +1308,7 @@ export class QmdMemoryManager implements MemorySearchManager { count, "paths", "Large QMD collections can make OpenClaw run out of file watchers or open files.", - "Remove large collections, or set memorySearch.sync.watch to false and refresh memory manually.", + "Remove large collections, or set memory.search.sync.watch to false and refresh memory manually.", (message) => log.warn(message), ); } diff --git a/extensions/memory-core/src/memory/search-manager.test.ts b/extensions/memory-core/src/memory/search-manager.test.ts index ce5adbb8c1bc..32600e46eb25 100644 --- a/extensions/memory-core/src/memory/search-manager.test.ts +++ b/extensions/memory-core/src/memory/search-manager.test.ts @@ -194,21 +194,24 @@ function createQmdCfg( function createBuiltinCfg(agentId: string): OpenClawConfig { return { + memory: { + search: { + provider: "openai", + model: "text-embedding-3-small", + store: { + path: "/tmp/index.sqlite", + vector: { enabled: false }, + }, + sync: { watch: false, onSessionStart: false, onSearch: false }, + query: { minScore: 0, hybrid: { enabled: false } }, + sources: ["memory"], + experimental: { sessionMemory: false }, + }, + }, + agents: { defaults: { workspace: "/tmp/workspace", - memorySearch: { - provider: "openai", - model: "text-embedding-3-small", - store: { - path: "/tmp/index.sqlite", - vector: { enabled: false }, - }, - sync: { watch: false, onSessionStart: false, onSearch: false }, - query: { minScore: 0, hybrid: { enabled: false } }, - sources: ["memory"], - experimental: { sessionMemory: false }, - }, }, list: [{ id: agentId, default: true, workspace: "/tmp/workspace" }], }, diff --git a/extensions/memory-core/src/memory/test-runtime-mocks.ts b/extensions/memory-core/src/memory/test-runtime-mocks.ts index 3be68b81f439..0396d3ea01d1 100644 --- a/extensions/memory-core/src/memory/test-runtime-mocks.ts +++ b/extensions/memory-core/src/memory/test-runtime-mocks.ts @@ -2,13 +2,19 @@ import { vi } from "vitest"; // Unit tests: avoid importing the real chokidar implementation (native fsevents, etc.). -vi.mock("chokidar", () => ({ - default: { - watch: () => ({ on: () => {}, close: async () => {} }), - }, - watch: () => ({ on: () => {}, close: async () => {} }), -})); +function createWatcherMock() { + const watcher = { + on: () => watcher, + once: () => watcher, + add: () => watcher, + unwatch: async () => watcher, + close: async () => undefined, + getWatched: () => ({}), + }; + return watcher; +} -vi.mock("./sqlite-vec.js", () => ({ - loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }), +vi.mock("chokidar", () => ({ + default: { watch: createWatcherMock }, + watch: createWatcherMock, })); diff --git a/extensions/memory-core/src/tools.test.ts b/extensions/memory-core/src/tools.test.ts index 41f867dc9190..ef1590cc817e 100644 --- a/extensions/memory-core/src/tools.test.ts +++ b/extensions/memory-core/src/tools.test.ts @@ -1278,8 +1278,8 @@ describe("memory_search corpus labels", () => { config: asOpenClawConfig({ agents: { list: [ - { id: "main", default: true, memorySearch: { enabled: false } }, - { id: "recall", memorySearch: { enabled: true } }, + { id: "main", default: true, memory: { search: { enabled: false } } }, + { id: "recall", memory: { search: { enabled: true } } }, ], }, }), @@ -1295,30 +1295,30 @@ describe("memory_search corpus labels", () => { it("re-resolves config when executing a previously created tool", async () => { const startupConfig = asOpenClawConfig({ agents: { - defaults: { - memorySearch: { - provider: "ollama", - model: "nomic-embed-text", - }, - }, + defaults: {}, list: [{ id: "main", default: true }], }, memory: { backend: "builtin", + + search: { + provider: "ollama", + model: "nomic-embed-text", + }, }, }); const patchedConfig = asOpenClawConfig({ agents: { - defaults: { - memorySearch: { - provider: "openai", - model: "text-embedding-3-small", - }, - }, + defaults: {}, list: [{ id: "main", default: true }], }, memory: { backend: "builtin", + + search: { + provider: "openai", + model: "text-embedding-3-small", + }, }, }); let liveConfig = startupConfig; @@ -1345,10 +1345,13 @@ describe("memory_search corpus labels", () => { const tool = createMemorySearchToolOrThrow({ config: { agents: { - defaults: { memorySearch: { rememberAcrossConversations: true } }, + defaults: {}, list: [{ id: "main", default: true }], }, - memory: { citations: "off" }, + memory: { + citations: "off", + search: { rememberAcrossConversations: true }, + }, tools: { sessions: { visibility: "all" } }, }, agentSessionKey: "agent:main:main", @@ -1379,10 +1382,13 @@ describe("memory_search corpus labels", () => { const tool = createMemorySearchToolOrThrow({ config: { agents: { - defaults: { memorySearch: { rememberAcrossConversations: true } }, + defaults: {}, list: [{ id: "main", default: true }], }, - memory: { citations: "off" }, + memory: { + citations: "off", + search: { rememberAcrossConversations: true }, + }, tools: { sessions: { visibility: "all" } }, }, agentSessionKey: "agent:main:main", @@ -1407,15 +1413,16 @@ describe("memory_search corpus labels", () => { const tool = createMemorySearchToolOrThrow({ config: { agents: { - defaults: { - memorySearch: { - rememberAcrossConversations: true, - sources: ["sessions"], - }, - }, + defaults: {}, list: [{ id: "main", default: true }], }, - memory: { citations: "off" }, + memory: { + citations: "off", + search: { + rememberAcrossConversations: true, + sources: ["sessions"], + }, + }, tools: { sessions: { visibility: "all" } }, }, agentSessionKey: "agent:main:main", @@ -1488,10 +1495,13 @@ describe("memory_search corpus labels", () => { const tool = createMemorySearchToolOrThrow({ config: { agents: { - defaults: { memorySearch: { rememberAcrossConversations: true } }, + defaults: {}, list: [{ id: "main", default: true }], }, - memory: { citations: "off" }, + memory: { + citations: "off", + search: { rememberAcrossConversations: true }, + }, tools: { sessions: { visibility: "self" } }, }, agentSessionKey: "agent:main:main", @@ -1579,15 +1589,16 @@ describe("memory_search corpus labels", () => { const tool = createMemorySearchToolOrThrow({ config: { agents: { - defaults: { - memorySearch: { - sources: ["memory", "sessions"], - experimental: { sessionMemory: true }, - }, - }, + defaults: {}, list: [{ id: "main", default: true }], }, - memory: { citations: "off" }, + memory: { + citations: "off", + search: { + sources: ["memory", "sessions"], + rememberAcrossConversations: true, + }, + }, tools: { sessions: { visibility: "all" } }, }, agentSessionKey: "agent:main:main", diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts index 190fb3573fe1..2622b2c08b1d 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -1671,11 +1671,13 @@ describe("memory plugin e2e", () => { })); const pluginEntryConfig = parseConfig({ autoCapture: true, autoRecall: true }); let configFile: Record = { + memory: { search: { enabled: true } }, + agents: { - defaults: { memorySearch: { enabled: true } }, + defaults: {}, list: [ - { id: "main", memorySearch: { enabled: true } }, - { id: "xiaohuo", memorySearch: { enabled: false } }, + { id: "main", memory: { search: { enabled: true } } }, + { id: "xiaohuo", memory: { search: { enabled: false } } }, ], }, plugins: { @@ -1804,7 +1806,9 @@ describe("memory plugin e2e", () => { embeddingsCreate.mockClear(); configFile = { ...configFile, - agents: { defaults: { memorySearch: { enabled: false } } }, + memory: { search: { enabled: false } }, + + agents: { defaults: {} }, }; const recallDefaultDisabled = await beforePromptBuild?.(recallEvent, { agentId: "unlisted", diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index ce8b23b68d0c..a0216234aa4e 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -1468,9 +1468,8 @@ export default definePluginEntry({ return undefined; } const agentId = normalizeAgentId(rawAgentId); - const overrides = resolveAgentConfig(runtimeConfig, agentId)?.memorySearch; - const enabled = - overrides?.enabled ?? runtimeConfig.agents?.defaults?.memorySearch?.enabled ?? true; + const overrides = resolveAgentConfig(runtimeConfig, agentId)?.memory?.search; + const enabled = overrides?.enabled ?? runtimeConfig.memory?.search?.enabled ?? true; return enabled ? agentId : undefined; }; const resolveCliAgentId = (rawAgentId: unknown): string => { diff --git a/extensions/minimax/speech-provider.ts b/extensions/minimax/speech-provider.ts index 2e3dc514ee50..91b1d2d59d19 100644 --- a/extensions/minimax/speech-provider.ts +++ b/extensions/minimax/speech-provider.ts @@ -102,7 +102,7 @@ function normalizeMinimaxProviderConfig( return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, - path: "messages.tts.providers.minimax.apiKey", + path: "tts.providers.minimax.apiKey", }), baseUrl: normalizeMinimaxTtsBaseUrl( trimToUndefined(raw?.baseUrl) ?? diff --git a/extensions/msteams/src/attachments.graph.test.ts b/extensions/msteams/src/attachments.graph.test.ts index 6556922a609f..25b49597fbf5 100644 --- a/extensions/msteams/src/attachments.graph.test.ts +++ b/extensions/msteams/src/attachments.graph.test.ts @@ -389,7 +389,11 @@ describe("msteams graph attachments", () => { }, ); - expectAttachmentMediaLength(media.media, 0); + expectAttachmentMediaLength(media.media, 2); + expect(media.media).toEqual([ + { kind: "document", sourceId: "ref-1" }, + { kind: "document", sourceId: "ref-1" }, + ]); const calledUrls = fetchMock.mock.calls.map((call) => call[0]); const expectedSharesUrl = `${GRAPH_SHARES_URL_PREFIX}${encodeGraphShareId(DEFAULT_SHARE_REFERENCE_URL)}/driveItem/content`; expect(calledUrls).toEqual([ @@ -413,7 +417,9 @@ describe("msteams graph attachments", () => { { maxBytes: 4 }, ); - expect(media.media).toStrictEqual([]); + expect(media.media).toStrictEqual([ + { kind: "image", contentType: "image/png", sourceId: "hosted-oversized" }, + ]); expect(saveResponseMediaMock).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/msteams/src/attachments/shared.test.ts b/extensions/msteams/src/attachments/shared.test.ts index b5d5efb7d501..5fbbd5ed84c8 100644 --- a/extensions/msteams/src/attachments/shared.test.ts +++ b/extensions/msteams/src/attachments/shared.test.ts @@ -599,7 +599,7 @@ describe("msteams inline image limits", () => { }, ]; const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 4 }); - expect(out).toStrictEqual([]); + expect(out).toStrictEqual([{ kind: "unavailable" }]); }); it("accepts inline data images within limit", () => { @@ -626,7 +626,7 @@ describe("msteams inline image limits", () => { }, ]; const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 10 }); - expect(out).toStrictEqual([]); + expect(out).toStrictEqual([{ kind: "unavailable" }]); }); it("enforces cumulative inline size limit across attachments", () => { @@ -644,8 +644,9 @@ describe("msteams inline image limits", () => { maxInlineBytes: 10, maxInlineTotalBytes: 6, }); - expect(out.length).toBe(1); + expect(out.length).toBe(2); expect(out[0]?.kind).toBe("data"); + expect(out[1]).toStrictEqual({ kind: "unavailable" }); }); }); diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts index cb1cbac196a8..4edf5853a04b 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -1,5 +1,4 @@ // Msteams tests cover message handler.authz plugin behavior. -import { createInboundDebouncer } from "openclaw/plugin-sdk/channel-inbound-debounce"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, PluginRuntime } from "../../runtime-api.js"; import type { GraphThreadMessage } from "../graph-thread.js"; @@ -696,45 +695,6 @@ describe("msteams monitor handler authz", () => { expect(ctxPayload.CommandAuthorized).toBe(false); }); - it("flushes pending group text before authorizing a bare abort without a mention", async () => { - resetThreadMocks(); - const isBareAbort = vi.fn((text?: string) => - ["abort", "stop"].includes(text?.trim().toLowerCase() ?? ""), - ); - const { deps } = createDeps( - { - commands: { useAccessGroups: false }, - messages: { inbound: { debounceMs: 60_000 } }, - channels: { - msteams: { - groupPolicy: "open", - requireMention: true, - }, - }, - } as OpenClawConfig, - { - hasControlCommand: vi.fn(() => false), - isControlCommandMessage: isBareAbort, - shouldComputeCommandAuthorized: isBareAbort, - shouldHandleTextCommands: vi.fn(() => true), - createInboundDebouncer, - resolveInboundDebounceMs: vi.fn(() => 60_000), - }, - ); - - const handler = createMSTeamsMessageHandler(deps); - await handler(createAttackerGroupActivity({ text: "pending text" })); - expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); - - await handler(createAttackerGroupActivity({ text: "abort" })); - - expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); - const dispatched = firstSettledDispatch(); - const ctxPayload = recordFromMockCall(dispatched.ctxPayload); - expect(ctxPayload.BodyForAgent).toBe("abort"); - expect(ctxPayload.CommandAuthorized).toBe(true); - }); - it("marks skipped channel message system events as non-owner without duplicating body text", async () => { resetThreadMocks(); const { deps, enqueueSystemEvent } = createDeps({ diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 32fbdab369ec..09172f136f33 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -623,8 +623,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { }, log, deadline: preprocessingDeadline, - preserveFilenames: (cfg as { media?: { preserveFilenames?: boolean } }).media - ?.preserveFilenames, + preserveFilenames: false, }), }); } catch (err) { diff --git a/extensions/mxc/test/mxc-backend.test.ts b/extensions/mxc/test/mxc-backend.test.ts index 960290159fb5..1cc6c95ed311 100644 --- a/extensions/mxc/test/mxc-backend.test.ts +++ b/extensions/mxc/test/mxc-backend.test.ts @@ -152,7 +152,7 @@ function createSandboxBackendTestConfig( binds: [], cdpPort: 0, cdpSourceRange: undefined, - enableNoVnc: false, + noVncEnabled: false, headless: true, image: "", network: "", diff --git a/extensions/nostr/src/channel.inbound.test.ts b/extensions/nostr/src/channel.inbound.test.ts index e05f1307c10a..ead170453d16 100644 --- a/extensions/nostr/src/channel.inbound.test.ts +++ b/extensions/nostr/src/channel.inbound.test.ts @@ -177,9 +177,7 @@ describe("nostr inbound gateway path", () => { publicKey: "bot-pubkey", config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] }, }), - cfg: { - commands: { useAccessGroups: true }, - }, + cfg: {}, }); const options = mockCallArg(mocks.startNostrBus) as { diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index f05196634a60..45604c15f3f2 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -935,14 +935,13 @@ describe("ollama plugin", () => { fallbacks: ["ollama/global-fallback:cloud"], }, }, - list: [ - { - id: "ops", + entries: { + ops: { model: { primary: "ollama/per-agent:cloud@work", }, }, - ], + }, }, }, env: process.env, diff --git a/extensions/ollama/src/embedding-provider.test.ts b/extensions/ollama/src/embedding-provider.test.ts index 8cf20f00a90e..49cde37ef212 100644 --- a/extensions/ollama/src/embedding-provider.test.ts +++ b/extensions/ollama/src/embedding-provider.test.ts @@ -283,7 +283,7 @@ describe("ollama embedding provider", () => { apiKey: { source: "env", provider: "default", id: "OLLAMA_API_KEY" }, }, }), - ).rejects.toThrow(/agents\.\*\.memorySearch\.remote\.apiKey: unresolved SecretRef/i); + ).rejects.toThrow(/memory\.search\.remote\.apiKey: unresolved SecretRef/i); }); it("falls back to env key when provider apiKey is an unresolved SecretRef", async () => { diff --git a/extensions/ollama/src/embedding-provider.ts b/extensions/ollama/src/embedding-provider.ts index 3b1fe90e94c9..3b09c284187a 100644 --- a/extensions/ollama/src/embedding-provider.ts +++ b/extensions/ollama/src/embedding-provider.ts @@ -228,7 +228,7 @@ function resolveOllamaEmbeddingResolvedKeys( const remote = resolveSourcedOllamaEmbeddingKey({ configString: resolveMemorySecretInputString({ value: remoteValue, - path: "agents.*.memorySearch.remote.apiKey", + path: "memory.search.remote.apiKey", }), declared: hasConfiguredSecretInput(remoteValue), }); diff --git a/extensions/openai/speech-provider.test.ts b/extensions/openai/speech-provider.test.ts index dbd5fe398717..9a3b8e6f4a09 100644 --- a/extensions/openai/speech-provider.test.ts +++ b/extensions/openai/speech-provider.test.ts @@ -482,40 +482,6 @@ describe("buildOpenAISpeechProvider", () => { }); }); - it("maps persona prompt fields to instructions when instructions are unset", async () => { - const provider = buildOpenAISpeechProvider(); - - const prepared = await provider.prepareSynthesis?.({ - text: "hello", - cfg: {} as never, - providerConfig: { - apiKey: "sk-test", - model: "gpt-4o-mini-tts", - voice: "cedar", - }, - persona: { - id: "alfred", - label: "Alfred", - prompt: { - profile: "A brilliant British butler.", - scene: "A quiet late-night study.", - sampleContext: "The speaker is answering a trusted operator.", - style: "Refined and lightly amused.", - accent: "British English.", - pacing: "Measured.", - constraints: ["Do not read configuration values aloud."], - }, - }, - target: "audio-file", - timeoutMs: 1_000, - }); - - expect(prepared?.providerConfig?.instructions).toContain("Persona: Alfred"); - expect(prepared?.providerConfig?.instructions).toContain( - "Constraint: Do not read configuration values aloud.", - ); - }); - it("uses wav for Groq-compatible OpenAI TTS endpoints", async () => { const provider = buildOpenAISpeechProvider(); mockSpeechFetchExpectingFormat("wav"); diff --git a/extensions/openai/speech-provider.ts b/extensions/openai/speech-provider.ts index 7db8e0df54bc..71ffefaf0144 100644 --- a/extensions/openai/speech-provider.ts +++ b/extensions/openai/speech-provider.ts @@ -137,7 +137,7 @@ function normalizeOpenAIProviderConfig( return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, - path: "messages.tts.providers.openai.apiKey", + path: "tts.providers.openai.apiKey", }), baseUrl, model: trimToUndefined(raw?.model) ?? "gpt-4o-mini-tts", @@ -192,37 +192,6 @@ function resolveGeneratedAudioMaxBytes(req: { return DEFAULT_GENERATED_AUDIO_MAX_BYTES; } -function renderOpenAITtsPersonaInstructions(req: { - label?: string; - prompt?: { - profile?: string; - scene?: string; - sampleContext?: string; - style?: string; - accent?: string; - pacing?: string; - constraints?: string[]; - }; -}): string | undefined { - const prompt = req.prompt; - if (!prompt) { - return undefined; - } - const lines = [ - req.label ? `Persona: ${req.label}` : undefined, - prompt.profile ? `Profile: ${prompt.profile}` : undefined, - prompt.scene ? `Scene: ${prompt.scene}` : undefined, - prompt.style ? `Style: ${prompt.style}` : undefined, - prompt.accent ? `Accent: ${prompt.accent}` : undefined, - prompt.pacing ? `Pacing: ${prompt.pacing}` : undefined, - prompt.sampleContext ? `Sample context: ${prompt.sampleContext}` : undefined, - ...(prompt.constraints ?? []).map((constraint) => `Constraint: ${constraint}`), - ] - .map((line) => trimToUndefined(line)) - .filter((line): line is string => Boolean(line)); - return lines.length > 0 ? lines.join("\n") : undefined; -} - function isCustomOpenAITtsBaseUrl(baseUrl: string | undefined): boolean { if (baseUrl !== undefined) { return normalizeOpenAITtsBaseUrl(baseUrl) !== DEFAULT_OPENAI_BASE_URL; @@ -327,23 +296,6 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin { listVoices: async () => OPENAI_TTS_VOICES.map((voice) => ({ id: voice, name: voice })), isConfigured: ({ providerConfig }) => Boolean(resolveOpenAISpeechApiKey(readOpenAIProviderConfig(providerConfig))), - prepareSynthesis: (ctx) => { - const config = readOpenAIProviderConfig(ctx.providerConfig); - if (config.instructions) { - return undefined; - } - const instructions = renderOpenAITtsPersonaInstructions({ - label: ctx.persona?.label ?? ctx.persona?.id, - prompt: ctx.persona?.prompt, - }); - return instructions - ? { - providerConfig: { - instructions, - }, - } - : undefined; - }, synthesize: async (req) => { const config = readOpenAIProviderConfig(req.providerConfig); const overrides = readOpenAIOverrides(req.providerOverrides, config.baseUrl); diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index b6fa21f6fabc..e2b995419dc3 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -114,13 +114,23 @@ function createPhoneControlConfig(): Record { return { gateway: { nodes: { - allowCommands: [], - denyCommands: [...FRESH_SETUP_DENY_COMMANDS], + commands: { + allow: [], + deny: [...FRESH_SETUP_DENY_COMMANDS], + }, }, }, }; } +function getPhoneControlCommands(config: Record) { + return ( + config.gateway as { + nodes?: { commands?: { allow?: string[]; deny?: string[] } }; + } + ).nodes?.commands; +} + function createMockOpenKeyedStore(params: { lookup: (key: string) => Promise; delete?: (key: string) => Promise; @@ -313,17 +323,15 @@ describe("phone-control plugin", () => { gatewayClientScopes: ["operator.admin"], }); const text = res?.text ?? ""; - const nodes = ( - getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } - ).nodes; - if (!nodes) { + const commands = getPhoneControlCommands(getConfig()); + if (!commands) { throw new Error("phone-control command did not persist gateway node config"); } expect(writeConfigFile).toHaveBeenCalledTimes(1); expect(text).toContain("armed for 30s"); - expect(nodes.allowCommands).toEqual([...WRITE_COMMANDS]); - expect(nodes.denyCommands).toStrictEqual(["computer.act"]); + expect(commands.allow).toEqual([...WRITE_COMMANDS]); + expect(commands.deny).toStrictEqual(["computer.act"]); expect(text).toContain("sms.send"); }); }); @@ -337,18 +345,16 @@ describe("phone-control plugin", () => { gatewayClientScopes: ["operator.admin"], }); const text = res?.text ?? ""; - const nodes = ( - getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } - ).nodes; - if (!nodes) { + const commands = getPhoneControlCommands(getConfig()); + if (!commands) { throw new Error("phone-control command did not persist gateway node config"); } expect(writeConfigFile).toHaveBeenCalledTimes(1); - expect(nodes.allowCommands).toEqual(["computer.act"]); + expect(commands.allow).toEqual(["computer.act"]); // Arming removes the fresh-setup computer deny while leaving the writes // group denied. - expect(nodes.denyCommands).toStrictEqual([...WRITE_COMMANDS]); + expect(commands.deny).toStrictEqual([...WRITE_COMMANDS]); expect(text).toContain("computer.act"); }); }); @@ -369,12 +375,10 @@ describe("phone-control plugin", () => { }), ).rejects.toThrow("failed to persist temporary arm lease"); - const nodes = ( - getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } - ).nodes; + const commands = getPhoneControlCommands(getConfig()); expect(writeConfigFile).not.toHaveBeenCalled(); - expect(nodes?.allowCommands).not.toContain("computer.act"); - expect(nodes?.denyCommands).toContain("computer.act"); + expect(commands?.allow).not.toContain("computer.act"); + expect(commands?.deny).toContain("computer.act"); }, { openKeyedStore: store.openKeyedStore }, ); @@ -393,13 +397,11 @@ describe("phone-control plugin", () => { }), ).rejects.toThrow("failed to persist temporary arm lease"); - const nodes = ( - getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } - ).nodes; + const commands = getPhoneControlCommands(getConfig()); expect(writeConfigFile).toHaveBeenCalledTimes(2); expect(store.values.size).toBe(0); - expect(nodes?.allowCommands).not.toContain("computer.act"); - expect(nodes?.denyCommands).toContain("computer.act"); + expect(commands?.allow).not.toContain("computer.act"); + expect(commands?.deny).toContain("computer.act"); }, { openKeyedStore: store.openKeyedStore, @@ -429,13 +431,11 @@ describe("phone-control plugin", () => { }), ).rejects.toThrow("failed to persist temporary arm lease"); - const nodes = ( - getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } - ).nodes; + const commands = getPhoneControlCommands(getConfig()); expect(writeConfigFile).toHaveBeenCalledTimes(2); expect(store.values.size).toBe(0); - expect(nodes?.allowCommands).not.toContain("computer.act"); - expect(nodes?.denyCommands).toContain("computer.act"); + expect(commands?.allow).not.toContain("computer.act"); + expect(commands?.deny).toContain("computer.act"); }, { openKeyedStore: store.openKeyedStore }, ); @@ -451,11 +451,9 @@ describe("phone-control plugin", () => { gatewayClientScopes: ["operator.admin"], }); - const nodes = ( - getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } - ).nodes; - expect(nodes?.allowCommands).toStrictEqual(["computer.act", "operator.keep"]); - expect(nodes?.denyCommands).toStrictEqual([...WRITE_COMMANDS, "operator.block"].toSorted()); + const commands = getPhoneControlCommands(getConfig()); + expect(commands?.allow).toStrictEqual(["computer.act", "operator.keep"]); + expect(commands?.deny).toStrictEqual([...WRITE_COMMANDS, "operator.block"].toSorted()); }, { beforeMutateConfig: (draft) => { @@ -463,13 +461,9 @@ describe("phone-control plugin", () => { return; } injectOperatorEdit = false; - const nodes = ( - draft.gateway as { - nodes: { allowCommands: string[]; denyCommands: string[] }; - } - ).nodes; - nodes.allowCommands.push("operator.keep"); - nodes.denyCommands.push("operator.block"); + const commands = getPhoneControlCommands(draft); + commands?.allow?.push("operator.keep"); + commands?.deny?.push("operator.block"); }, }, ); @@ -519,11 +513,9 @@ describe("phone-control plugin", () => { gatewayClientScopes: ["operator.admin"], }), ).resolves.toMatchObject({ text: expect.stringContaining("disarmed") }); - const nodes = ( - getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } - ).nodes; - expect(nodes?.allowCommands).not.toContain("computer.act"); - expect(nodes?.denyCommands).toContain("computer.act"); + const commands = getPhoneControlCommands(getConfig()); + expect(commands?.allow).not.toContain("computer.act"); + expect(commands?.deny).toContain("computer.act"); } finally { releaseTransport.resolve(); } @@ -535,8 +527,7 @@ describe("phone-control plugin", () => { const initialConfig = { gateway: { nodes: { - allowCommands: ["computer.act"], - denyCommands: [...WRITE_COMMANDS], + commands: { allow: ["computer.act"], deny: [...WRITE_COMMANDS] }, }, }, }; @@ -569,11 +560,9 @@ describe("phone-control plugin", () => { code: "PHONE_CONTROL_DISARMED", }); expect(invokeNode).not.toHaveBeenCalled(); - const nodes = ( - getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } - ).nodes; - expect(nodes?.allowCommands).not.toContain("computer.act"); - expect(nodes?.denyCommands).toContain("computer.act"); + const commands = getPhoneControlCommands(getConfig()); + expect(commands?.allow).not.toContain("computer.act"); + expect(commands?.deny).toContain("computer.act"); }); } finally { vi.useRealTimers(); @@ -598,8 +587,7 @@ describe("phone-control plugin", () => { const initialConfig = { gateway: { nodes: { - allowCommands: ["computer.act"], - denyCommands: [...WRITE_COMMANDS], + commands: { allow: ["computer.act"], deny: [...WRITE_COMMANDS] }, }, }, }; @@ -627,8 +615,7 @@ describe("phone-control plugin", () => { const initialConfig = { gateway: { nodes: { - allowCommands: ["computer.act"], - denyCommands: [...WRITE_COMMANDS], + commands: { allow: ["computer.act"], deny: [...WRITE_COMMANDS] }, }, }, }; @@ -762,11 +749,9 @@ describe("phone-control plugin", () => { channel: "webchat", }); expect(finalStatus?.text ?? "").toContain("expires in 30s"); - const nodes = ( - getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } - ).nodes; - expect(nodes?.allowCommands).toContain("computer.act"); - expect(nodes?.denyCommands).not.toContain("computer.act"); + const commands = getPhoneControlCommands(getConfig()); + expect(commands?.allow).toContain("computer.act"); + expect(commands?.deny).not.toContain("computer.act"); }, { openKeyedStore: store.openKeyedStore }, ); @@ -788,12 +773,10 @@ describe("phone-control plugin", () => { gatewayClientScopes: ["operator.admin"], }); - const nodes = ( - getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } - ).nodes; + const commands = getPhoneControlCommands(getConfig()); expect(writeConfigFile).toHaveBeenCalledTimes(2); - expect(nodes?.allowCommands).toStrictEqual([]); - expect(nodes?.denyCommands).toStrictEqual([...FRESH_SETUP_DENY_COMMANDS]); + expect(commands?.allow).toStrictEqual([]); + expect(commands?.deny).toStrictEqual([...FRESH_SETUP_DENY_COMMANDS]); }); }); @@ -804,12 +787,10 @@ describe("phone-control plugin", () => { channel: "webchat", gatewayClientScopes: ["operator.admin"], }); - const nodes = ( - getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } - ).nodes; + const commands = getPhoneControlCommands(getConfig()); - expect(nodes?.allowCommands).not.toContain("computer.act"); - expect(nodes?.denyCommands).toContain("computer.act"); + expect(commands?.allow).not.toContain("computer.act"); + expect(commands?.deny).toContain("computer.act"); expect(res?.text ?? "").not.toContain("computer.act"); }); }); @@ -818,8 +799,7 @@ describe("phone-control plugin", () => { const initialConfig = { gateway: { nodes: { - allowCommands: ["computer.act"], - denyCommands: [...WRITE_COMMANDS], + commands: { allow: ["computer.act"], deny: [...WRITE_COMMANDS] }, }, }, }; @@ -849,14 +829,12 @@ describe("phone-control plugin", () => { channel: "webchat", gatewayClientScopes: ["operator.admin"], }); - const nodes = ( - getConfig().gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } } - ).nodes; + const commands = getPhoneControlCommands(getConfig()); expect(disarm?.text ?? "").toContain("remain active after /phone disarm"); expect(disarm?.text ?? "").toContain("computer.act"); expect(writeConfigFile).toHaveBeenCalledTimes(1); - expect(nodes?.allowCommands).toStrictEqual(["computer.act"]); - expect(nodes?.denyCommands).toStrictEqual([...WRITE_COMMANDS]); + expect(commands?.allow).toStrictEqual(["computer.act"]); + expect(commands?.deny).toStrictEqual([...WRITE_COMMANDS]); }, { initialConfig }, ); @@ -877,9 +855,9 @@ describe("phone-control plugin", () => { channel: "webchat", gatewayClientScopes: ["operator.admin"], }); - const nodes = (getConfig().gateway as { nodes?: { allowCommands?: string[] } }).nodes; + const commands = getPhoneControlCommands(getConfig()); // Disarm must fully remove computer.act despite the re-arm. - expect(nodes?.allowCommands ?? []).not.toContain("computer.act"); + expect(commands?.allow ?? []).not.toContain("computer.act"); }); }); @@ -1105,8 +1083,7 @@ describe("phone-control plugin", () => { let config: Record = { gateway: { nodes: { - allowCommands: [...WRITE_COMMANDS], - denyCommands: [], + commands: { allow: [...WRITE_COMMANDS], deny: [] }, }, }, }; @@ -1150,11 +1127,9 @@ describe("phone-control plugin", () => { expect(writeConfigFile).toHaveBeenCalledTimes(1); expect(removeState).toHaveBeenCalledWith("current"); - expect( - (config.gateway as { nodes?: { allowCommands?: string[]; denyCommands?: string[] } }).nodes, - ).toEqual({ - allowCommands: [], - denyCommands: [...WRITE_COMMANDS], + expect(getPhoneControlCommands(config)).toEqual({ + allow: [], + deny: [...WRITE_COMMANDS], }); await service.stop?.({ diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts index de50188141c3..03a38f8a29ed 100644 --- a/extensions/phone-control/index.ts +++ b/extensions/phone-control/index.ts @@ -55,8 +55,10 @@ type StoredArmState = { key: string; state: ArmStateFile }; type PhoneControlConfigView = { readonly gateway?: { readonly nodes?: { - readonly allowCommands?: readonly string[]; - readonly denyCommands?: readonly string[]; + readonly commands?: { + readonly allow?: readonly string[]; + readonly deny?: readonly string[]; + }; }; }; }; @@ -177,11 +179,11 @@ async function consumeArmState(api: OpenClawPluginApi, expected: StoredArmState) } function normalizeDenyList(cfg: PhoneControlConfigView): string[] { - return uniqSorted([...(cfg.gateway?.nodes?.denyCommands ?? [])]); + return uniqSorted([...(cfg.gateway?.nodes?.commands?.deny ?? [])]); } function normalizeAllowList(cfg: PhoneControlConfigView): string[] { - return uniqSorted([...(cfg.gateway?.nodes?.allowCommands ?? [])]); + return uniqSorted([...(cfg.gateway?.nodes?.commands?.allow ?? [])]); } function resolveEffectivePhoneControlAllows(params: { @@ -260,8 +262,7 @@ function patchConfigNodeLists( ...cfg.gateway, nodes: { ...cfg.gateway?.nodes, - allowCommands: next.allowCommands, - denyCommands: next.denyCommands, + commands: { allow: next.allowCommands, deny: next.denyCommands }, }, }, }; diff --git a/extensions/pixverse/index.test.ts b/extensions/pixverse/index.test.ts index 8400887634b4..a6845526786a 100644 --- a/extensions/pixverse/index.test.ts +++ b/extensions/pixverse/index.test.ts @@ -141,7 +141,7 @@ describe("pixverse plugin", () => { region: "cn", }); expect(result.defaultModel).toBeUndefined(); - expect(result.configPatch?.agents?.defaults?.videoGenerationModel).toEqual({ + expect(result.configPatch?.agents?.defaults?.mediaModels?.video).toEqual({ primary: PIXVERSE_DEFAULT_VIDEO_MODEL_REF, }); expect(result.notes).toEqual([`PixVerse endpoint: CN (${PIXVERSE_BASE_URL_BY_REGION.cn})`]); @@ -154,12 +154,12 @@ describe("pixverse plugin", () => { throw new Error("expected PixVerse auth method"); } const { ctx } = createRuntimeContext("international", { - agents: { defaults: { videoGenerationModel: { primary: "openai/sora-2" } } }, + agents: { defaults: { mediaModels: { video: { primary: "openai/sora-2" } } } }, }); const result = await auth.run(ctx); - expect(result.configPatch?.agents?.defaults?.videoGenerationModel).toEqual({ + expect(result.configPatch?.agents?.defaults?.mediaModels?.video).toEqual({ primary: "openai/sora-2", }); }); diff --git a/extensions/pixverse/onboard.ts b/extensions/pixverse/onboard.ts index c123fd9765fe..774d2df299e4 100644 --- a/extensions/pixverse/onboard.ts +++ b/extensions/pixverse/onboard.ts @@ -100,7 +100,7 @@ function applyPixVerseConfig( options?: { resetBaseUrl?: boolean }, ): OpenClawConfig { const next = applyPixVerseProviderConfig(cfg, region, options); - if (next.agents?.defaults?.videoGenerationModel) { + if (next.agents?.defaults?.mediaModels?.video) { return next; } return { @@ -109,8 +109,9 @@ function applyPixVerseConfig( ...next.agents, defaults: { ...next.agents?.defaults, - videoGenerationModel: { - primary: PIXVERSE_DEFAULT_VIDEO_MODEL_REF, + mediaModels: { + ...next.agents?.defaults?.mediaModels, + video: { primary: PIXVERSE_DEFAULT_VIDEO_MODEL_REF }, }, }, }, diff --git a/extensions/policy/src/cli.test.ts b/extensions/policy/src/cli.test.ts index 020665d29f83..7dddecca4659 100644 --- a/extensions/policy/src/cli.test.ts +++ b/extensions/policy/src/cli.test.ts @@ -161,7 +161,7 @@ describe("policy commands", () => { configPath, JSON.stringify({ plugins: { entries: { policy: { enabled: true, config: { enabled: true } } } }, - agents: { list: [{ id: "main", default: true }, { id: "family" }] }, + agents: { entries: { main: { default: true }, family: {} } }, channels: { imessage: { enabled: false } }, bindings: [], }), diff --git a/extensions/policy/src/doctor/fix-metadata.ts b/extensions/policy/src/doctor/fix-metadata.ts index fb5bc7480be9..abad6955acfc 100644 --- a/extensions/policy/src/doctor/fix-metadata.ts +++ b/extensions/policy/src/doctor/fix-metadata.ts @@ -183,7 +183,7 @@ const POLICY_FIX_METADATA = [ "Add the command to gateway node denyCommands or update policy after review.", { policyPath: ["gateway", "nodes", "denyCommands"], - configTargets: ["gateway.nodes.denyCommands"], + configTargets: ["gateway.nodes.commands.deny"], }, ), m( diff --git a/extensions/policy/src/doctor/register.base.test-utils.ts b/extensions/policy/src/doctor/register.base.test-utils.ts index 34a8efb3cb8e..74e1004d396d 100644 --- a/extensions/policy/src/doctor/register.base.test-utils.ts +++ b/extensions/policy/src/doctor/register.base.test-utils.ts @@ -1547,13 +1547,10 @@ describe("registerPolicyDoctorChecks", () => { mode: "remote", remote: { url: "wss://remote.example.test:18789" }, controlUi: { - allowInsecureAuth: true, - dangerouslyDisableDeviceAuth: true, dangerouslyAllowHostHeaderOriginFallback: true, }, }, - logging: { redactSensitive: "off" }, - diagnostics: { otel: { enabled: true, captureContent: { enabled: true, toolInputs: true } } }, + diagnostics: { otel: { enabled: true, captureContent: true } }, } as unknown as OpenClawConfig; await fs.writeFile(configPath, "{}", "utf-8"); await fs.writeFile( @@ -1584,28 +1581,20 @@ describe("registerPolicyDoctorChecks", () => { "policy/gateway-remote-enabled", repairCtx(configPath, controlUi.config), ); - const redaction = await runPolicyRepairCheck( - "policy/data-handling-redaction-disabled", - repairCtx(configPath, remote.config), - ); const telemetry = await runPolicyRepairCheck( "policy/data-handling-telemetry-content-capture", - repairCtx(configPath, redaction.config), + repairCtx(configPath, remote.config), ); expect([ ...elevated.changes, ...controlUi.changes, ...remote.changes, - ...redaction.changes, ...telemetry.changes, ]).toEqual([ "Set tools.elevated.enabled=false for policy conformance.", - "Set gateway.controlUi.allowInsecureAuth=false for policy conformance.", - "Set gateway.controlUi.dangerouslyDisableDeviceAuth=false for policy conformance.", "Set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=false for policy conformance.", "Set gateway.mode=local for policy conformance.", - "Set logging.redactSensitive=tools for policy conformance.", "Set diagnostics.otel.captureContent=false for policy conformance.", ]); expect(telemetry.remainingFindings).toEqual([]); @@ -1615,12 +1604,9 @@ describe("registerPolicyDoctorChecks", () => { mode: "local", remote: {}, controlUi: { - allowInsecureAuth: false, - dangerouslyDisableDeviceAuth: false, dangerouslyAllowHostHeaderOriginFallback: false, }, }, - logging: { redactSensitive: "tools" }, diagnostics: { otel: { captureContent: false } }, }); }); @@ -1770,7 +1756,7 @@ describe("registerPolicyDoctorChecks", () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { ...cfgWithPolicy({ workspaceRepairs: true }), - gateway: { nodes: { denyCommands: ["mcp.help"] } }, + gateway: { nodes: { commands: { deny: ["mcp.help"] } } }, } as unknown as OpenClawConfig; await fs.writeFile(configPath, "{}", "utf-8"); await fs.writeFile( @@ -1787,20 +1773,20 @@ describe("registerPolicyDoctorChecks", () => { expect(result.status).toBe("skipped"); expect(result.reason).toBe("policy repair requires review before changing config"); expect(result.changes).toEqual([ - "Review required: add system.run to gateway.nodes.denyCommands for policy conformance.", + "Review required: add system.run to gateway.nodes.commands.deny for policy conformance.", ]); expect(result.warnings).toEqual([ - "Review required: add system.run to gateway.nodes.denyCommands for policy conformance.", + "Review required: add system.run to gateway.nodes.commands.deny for policy conformance.", ]); expect(result.effects).toEqual([ { kind: "config", action: "would-append-after-review", - target: "gateway.nodes.denyCommands += system.run", + target: "gateway.nodes.commands.deny += system.run", dryRunSafe: true, }, ]); - expect(result.config.gateway?.nodes?.denyCommands).toEqual(["mcp.help"]); + expect(result.config.gateway?.nodes?.commands?.deny).toEqual(["mcp.help"]); expect(result.remainingFindings).toHaveLength(1); }); diff --git a/extensions/policy/src/doctor/register.gateway-data-and-approvals.test-utils.ts b/extensions/policy/src/doctor/register.gateway-data-and-approvals.test-utils.ts index 6104fcc36101..33c866bf35b6 100644 --- a/extensions/policy/src/doctor/register.gateway-data-and-approvals.test-utils.ts +++ b/extensions/policy/src/doctor/register.gateway-data-and-approvals.test-utils.ts @@ -100,8 +100,7 @@ describe("registerPolicyDoctorChecks", () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { ...cfgWithPolicy(), - logging: { redactSensitive: "off" }, - diagnostics: { otel: { enabled: true, captureContent: { enabled: true, toolInputs: true } } }, + diagnostics: { otel: { enabled: true, captureContent: true } }, session: { maintenance: { mode: "warn" } }, memory: { backend: "qmd", qmd: { sessions: { enabled: true } } }, } as unknown as OpenClawConfig; @@ -127,8 +126,8 @@ describe("registerPolicyDoctorChecks", () => { expect.arrayContaining([ expect.objectContaining({ kind: "sensitiveLoggingRedaction", - source: "oc://openclaw.config/logging/redactSensitive", - value: false, + source: "oc://openclaw.invariant/logging/redaction", + value: true, }), expect.objectContaining({ kind: "telemetryContentCapture", @@ -149,11 +148,6 @@ describe("registerPolicyDoctorChecks", () => { ); expect(result.findings).toEqual( expect.arrayContaining([ - expect.objectContaining({ - checkId: "policy/data-handling-redaction-disabled", - ocPath: "oc://openclaw.config/logging/redactSensitive", - requirement: "oc://policy.jsonc/dataHandling/sensitiveLogging/requireRedaction", - }), expect.objectContaining({ checkId: "policy/data-handling-telemetry-content-capture", ocPath: "oc://openclaw.config/diagnostics/otel/captureContent", @@ -207,11 +201,11 @@ describe("registerPolicyDoctorChecks", () => { expect(result.findings).toEqual([]); }); - it("does not treat disabled telemetry capture subkeys as content capture", async () => { + it("does not treat disabled telemetry capture as content capture", async () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { ...cfgWithPolicy(), - diagnostics: { otel: { captureContent: { toolInputs: true } } }, + diagnostics: { otel: { captureContent: false } }, } as unknown as OpenClawConfig; await fs.writeFile(configPath, "{}", "utf-8"); await fs.writeFile( @@ -274,44 +268,19 @@ describe("registerPolicyDoctorChecks", () => { ]); }); - it("does not treat trace-only content capture subkeys as log body capture", async () => { - const configPath = join(workspaceDir, "openclaw.jsonc"); - const cfg = { - ...cfgWithPolicy(), - diagnostics: { - otel: { - enabled: true, - traces: false, - logs: true, - captureContent: { enabled: true, toolInputs: true }, - }, - }, - } as unknown as OpenClawConfig; - await fs.writeFile(configPath, "{}", "utf-8"); - await fs.writeFile( - join(workspaceDir, "policy.jsonc"), - JSON.stringify({ dataHandling: { telemetry: { denyContentCapture: true } } }), - "utf-8", - ); - - registerPolicyDoctorChecks(); - const result = await runDoctorLintChecks(ctx(configPath, cfg)); - - expect(result.findings).toEqual([]); - }); - it("supports agent-scoped session transcript memory conformance", async () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { ...cfgWithPolicy(), + memory: { + search: { rememberAcrossConversations: true, sources: ["memory", "sessions"] }, + }, agents: { - defaults: { - memorySearch: { experimental: { sessionMemory: true }, sources: ["memory", "sessions"] }, + defaults: {}, + entries: { + sebby: {}, + buddy: { memory: { search: { rememberAcrossConversations: false } } }, }, - list: [ - { id: "sebby" }, - { id: "buddy", memorySearch: { experimental: { sessionMemory: false } } }, - ], }, } as unknown as OpenClawConfig; await fs.writeFile(configPath, "{}", "utf-8"); @@ -334,7 +303,7 @@ describe("registerPolicyDoctorChecks", () => { expect(result.findings).toEqual([ expect.objectContaining({ checkId: "policy/data-handling-session-transcript-memory-enabled", - ocPath: "oc://openclaw.config/agents/defaults/memorySearch/experimental/sessionMemory", + ocPath: "oc://openclaw.config/memory/search/rememberAcrossConversations", requirement: "oc://policy.jsonc/scopes/restricted/dataHandling/memory/denySessionTranscriptIndexing", }), @@ -345,10 +314,8 @@ describe("registerPolicyDoctorChecks", () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { ...cfgWithPolicy(), - agents: { - defaults: { - memorySearch: { experimental: { sessionMemory: true }, sources: ["sessions"] }, - }, + memory: { + search: { rememberAcrossConversations: true, sources: ["sessions"] }, }, } as unknown as OpenClawConfig; await fs.writeFile(configPath, "{}", "utf-8"); @@ -371,7 +338,7 @@ describe("registerPolicyDoctorChecks", () => { expect(result.findings).toEqual([ expect.objectContaining({ checkId: "policy/data-handling-session-transcript-memory-enabled", - ocPath: "oc://openclaw.config/agents/defaults/memorySearch/experimental/sessionMemory", + ocPath: "oc://openclaw.config/memory/search/rememberAcrossConversations", requirement: "oc://policy.jsonc/scopes/restricted/dataHandling/memory/denySessionTranscriptIndexing", }), @@ -382,14 +349,12 @@ describe("registerPolicyDoctorChecks", () => { const configPath = join(workspaceDir, "openclaw.jsonc"); const cfg = { ...cfgWithPolicy(), - memory: { qmd: { sessions: { enabled: true } } }, - agents: { - defaults: { - memorySearch: { - enabled: false, - experimental: { sessionMemory: true }, - sources: ["sessions"], - }, + memory: { + qmd: { sessions: { enabled: true } }, + search: { + enabled: false, + rememberAcrossConversations: true, + sources: ["sessions"], }, }, } as unknown as OpenClawConfig; diff --git a/extensions/policy/src/doctor/register.ingress-and-secrets.test-utils.ts b/extensions/policy/src/doctor/register.ingress-and-secrets.test-utils.ts index e7dfe6060358..eb4cb5d2ed28 100644 --- a/extensions/policy/src/doctor/register.ingress-and-secrets.test-utils.ts +++ b/extensions/policy/src/doctor/register.ingress-and-secrets.test-utils.ts @@ -905,15 +905,9 @@ describe("registerPolicyDoctorChecks", () => { ocPath: "oc://openclaw.config/secrets/providers/command", requirement: "oc://policy.jsonc/secrets/denySources", }), - expect.objectContaining({ - checkId: "policy/secrets-insecure-provider", - severity: "error", - ocPath: "oc://openclaw.config/secrets/providers/vault", - requirement: "oc://policy.jsonc/secrets/allowInsecureProviders", - }), ]), ); - expect(result.findings).toHaveLength(3); + expect(result.findings).toHaveLength(2); }); it("checks managed providers for structured provider request SecretRefs", async () => { @@ -955,6 +949,15 @@ describe("registerPolicyDoctorChecks", () => { }, }, }, + { + capabilities: ["image"], + request: { + auth: { + mode: "authorization-bearer", + token: { source: "exec", provider: "rogue", id: "media/image-token" }, + }, + }, + }, ], audio: { request: { @@ -964,18 +967,6 @@ describe("registerPolicyDoctorChecks", () => { }, }, }, - image: { - models: [ - { - request: { - auth: { - mode: "authorization-bearer", - token: { source: "exec", provider: "rogue", id: "media/image-token" }, - }, - }, - }, - ], - }, }, }, plugins: { @@ -1069,7 +1060,7 @@ describe("registerPolicyDoctorChecks", () => { provenance: "secretRef", refSource: "exec", refProvider: "rogue", - source: "oc://openclaw.config/tools/media/image/models/#0/request/auth/token", + source: "oc://openclaw.config/tools/media/models/#1/request/auth/token", }), ]), ); @@ -1110,7 +1101,7 @@ describe("registerPolicyDoctorChecks", () => { }), expect.objectContaining({ checkId: "policy/secrets-unmanaged-provider", - ocPath: "oc://openclaw.config/tools/media/image/models/#0/request/auth/token", + ocPath: "oc://openclaw.config/tools/media/models/#1/request/auth/token", }), expect.objectContaining({ checkId: "policy/secrets-unmanaged-provider", diff --git a/extensions/policy/src/doctor/register.models-and-mcp.test-utils.ts b/extensions/policy/src/doctor/register.models-and-mcp.test-utils.ts index 909f3da992b4..761007de6176 100644 --- a/extensions/policy/src/doctor/register.models-and-mcp.test-utils.ts +++ b/extensions/policy/src/doctor/register.models-and-mcp.test-utils.ts @@ -116,52 +116,6 @@ describe("registerPolicyDoctorChecks", () => { ]); }); - it("skips scoped data-handling repairs that would mutate shared config", async () => { - const configPath = join(workspaceDir, "openclaw.jsonc"); - const cfg = { - ...cfgWithPolicy({ workspaceRepairs: true }), - logging: { redactSensitive: "off" }, - agents: { - list: [{ id: "reviewer" }], - }, - } as unknown as OpenClawConfig; - await fs.writeFile(configPath, "{}", "utf-8"); - await fs.writeFile( - join(workspaceDir, "policy.jsonc"), - JSON.stringify({ - scopes: { - reviewer: { - agentIds: ["reviewer"], - dataHandling: { - sensitiveLogging: { requireRedaction: true }, - }, - }, - }, - }), - "utf-8", - ); - - const result = await runPolicyRepairCheck( - "policy/data-handling-redaction-disabled", - repairCtx(configPath, cfg), - ); - - expect(result.status).toBe("skipped"); - expect(result.reason).toBe("policy automatic repair had no config changes to apply"); - expect(result.changes).toEqual([]); - expect(result.warnings).toEqual([ - "Skipped scoped data-handling repair. The finding reports shared logging config, so changing it would affect more than the scoped policy target.", - ]); - expect(result.config.logging?.redactSensitive).toBe("off"); - expect(result.remainingFindings).toEqual([ - expect.objectContaining({ - checkId: "policy/data-handling-redaction-disabled", - requirement: - "oc://policy.jsonc/scopes/reviewer/dataHandling/sensitiveLogging/requireRedaction", - }), - ]); - }); - it("does not register repair for non-previewable policy findings", () => { const check = registerChecks().find( (entry) => entry.id === "policy/gateway-http-url-fetch-unrestricted", diff --git a/extensions/policy/src/doctor/register.sandbox-and-tools.test-utils.ts b/extensions/policy/src/doctor/register.sandbox-and-tools.test-utils.ts index 3505cf626882..f346ddd430c5 100644 --- a/extensions/policy/src/doctor/register.sandbox-and-tools.test-utils.ts +++ b/extensions/policy/src/doctor/register.sandbox-and-tools.test-utils.ts @@ -1433,8 +1433,6 @@ describe("registerPolicyDoctorChecks", () => { bind: "lan", auth: { mode: "none" }, controlUi: { - allowInsecureAuth: true, - dangerouslyDisableDeviceAuth: true, dangerouslyAllowHostHeaderOriginFallback: true, }, tailscale: { mode: "funnel" }, @@ -1453,7 +1451,7 @@ describe("registerPolicyDoctorChecks", () => { }, }, nodes: { - allowCommands: ["mcp.help", "mcp.invoke", "system.run"], + commands: { allow: ["mcp.help", "mcp.invoke", "system.run"] }, }, }, } as unknown as OpenClawConfig; @@ -1511,12 +1509,6 @@ describe("registerPolicyDoctorChecks", () => { ocPath: "oc://openclaw.config/gateway/auth/rateLimit", requirement: "oc://policy.jsonc/gateway/auth/requireExplicitRateLimit", }), - expect.objectContaining({ - checkId: "policy/gateway-control-ui-insecure", - severity: "error", - ocPath: "oc://openclaw.config/gateway/controlUi/allowInsecureAuth", - requirement: "oc://policy.jsonc/gateway/controlUi/allowInsecure", - }), expect.objectContaining({ checkId: "policy/gateway-tailscale-funnel", severity: "error", @@ -1544,12 +1536,12 @@ describe("registerPolicyDoctorChecks", () => { expect.objectContaining({ checkId: "policy/gateway-node-command-denied", severity: "error", - ocPath: "oc://openclaw.config/gateway/nodes/denyCommands", + ocPath: "oc://openclaw.config/gateway/nodes/commands/deny", requirement: "oc://policy.jsonc/gateway/nodes/denyCommands", }), ]), ); - expect(result.findings).toHaveLength(13); + expect(result.findings).toHaveLength(11); }); it("does not report gateway node commands denied by runtime config", async () => { @@ -1558,8 +1550,10 @@ describe("registerPolicyDoctorChecks", () => { ...cfgWithPolicy(), gateway: { nodes: { - allowCommands: ["system.run"], - denyCommands: ["system.run"], + commands: { + allow: ["system.run"], + deny: ["system.run"], + }, }, }, } as unknown as OpenClawConfig; @@ -1610,7 +1604,7 @@ describe("registerPolicyDoctorChecks", () => { expect.objectContaining({ checkId: "policy/gateway-node-command-denied", severity: "error", - ocPath: "oc://openclaw.config/gateway/nodes/denyCommands", + ocPath: "oc://openclaw.config/gateway/nodes/commands/deny", requirement: "oc://policy.jsonc/gateway/nodes/denyCommands", }), ]); diff --git a/extensions/policy/src/doctor/review-required-repairs.ts b/extensions/policy/src/doctor/review-required-repairs.ts index a52123936fa4..aba1a1845662 100644 --- a/extensions/policy/src/doctor/review-required-repairs.ts +++ b/extensions/policy/src/doctor/review-required-repairs.ts @@ -104,17 +104,17 @@ function previewGatewayNodeDenyCommand( if ( command === undefined || command === "" || - finding.ocPath !== "oc://openclaw.config/gateway/nodes/denyCommands" + finding.ocPath !== "oc://openclaw.config/gateway/nodes/commands/deny" ) { return []; } return [ { - change: `Review required: add ${command} to gateway.nodes.denyCommands for policy conformance.`, + change: `Review required: add ${command} to gateway.nodes.commands.deny for policy conformance.`, effect: { kind: "config", action: "would-append-after-review", - target: `gateway.nodes.denyCommands += ${command}`, + target: `gateway.nodes.commands.deny += ${command}`, dryRunSafe: true, }, }, diff --git a/extensions/policy/src/doctor/scopes/gateway.ts b/extensions/policy/src/doctor/scopes/gateway.ts index 563f9f9d6d0e..071052abfe17 100644 --- a/extensions/policy/src/doctor/scopes/gateway.ts +++ b/extensions/policy/src/doctor/scopes/gateway.ts @@ -400,10 +400,10 @@ function gatewayNodeCommandFindings( message: `Gateway node command '${command}' is denied by policy but not denied by OpenClaw config.`, source: "policy", path: "openclaw config", - ocPath: "oc://openclaw.config/gateway/nodes/denyCommands", - target: "oc://openclaw.config/gateway/nodes/denyCommands", + ocPath: "oc://openclaw.config/gateway/nodes/commands/deny", + target: "oc://openclaw.config/gateway/nodes/commands/deny", requirement: `oc://${policyDocName}/gateway/nodes/denyCommands`, - fixHint: `Add '${command}' to gateway.nodes.denyCommands or update policy after review.`, + fixHint: `Add '${command}' to gateway.nodes.commands.deny or update policy after review.`, }; }); } diff --git a/extensions/policy/src/policy-state-data.test.ts b/extensions/policy/src/policy-state-data.test.ts new file mode 100644 index 000000000000..ccab7ed2243b --- /dev/null +++ b/extensions/policy/src/policy-state-data.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { scanPolicyDataHandling, scanPolicySecrets } from "./policy-state-data.js"; + +describe("scanPolicyDataHandling", () => { + it("reports canonical per-agent memory overrides from agents.entries", () => { + const evidence = scanPolicyDataHandling({ + memory: { search: { experimental: { sessionMemory: false } } }, + agents: { + entries: { + support: { + memory: { + search: { sources: ["sessions"], experimental: { sessionMemory: true } }, + }, + }, + }, + }, + }); + + expect(evidence).toContainEqual( + expect.objectContaining({ + kind: "memorySessionTranscriptIndexing", + source: + "oc://openclaw.config/agents/entries/support/memory/search/experimental/sessionMemory", + scope: "agent", + agentId: "support", + value: true, + }), + ); + }); + + it("scans canonical per-agent memory headers", () => { + const evidence = scanPolicySecrets({ + agents: { + entries: { + support: { + memory: { + search: { + remote: { + headers: { + Authorization: { source: "env", provider: "default", id: "MEMORY_HEADER" }, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(evidence.some((entry) => entry.source.includes("agents/entries/support"))).toBe(true); + }); + + it("keeps legacy-list policy evidence visible during doctor compatibility", () => { + const evidence = scanPolicyDataHandling({ + memory: { search: { experimental: { sessionMemory: false } } }, + agents: { + list: [ + { + id: "support", + memory: { + search: { sources: ["sessions"], experimental: { sessionMemory: true } }, + }, + }, + ], + }, + }); + + expect(evidence).toContainEqual( + expect.objectContaining({ + source: "oc://openclaw.config/agents/list/#0/memory/search/experimental/sessionMemory", + agentId: "support", + value: true, + }), + ); + }); +}); diff --git a/extensions/policy/src/policy-state-data.ts b/extensions/policy/src/policy-state-data.ts index aa00b75dca01..1f050bd14bcc 100644 --- a/extensions/policy/src/policy-state-data.ts +++ b/extensions/policy/src/policy-state-data.ts @@ -1,11 +1,7 @@ // Policy plugin data, secret, and auth evidence. import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input"; -import { - isRecord, - asBoolean as readBoolean, - normalizeOptionalString as readString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, asBoolean as readBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; import { ocPathSegment } from "./policy-state-helpers.js"; import type { PolicyAuthProfileEvidence, @@ -56,14 +52,13 @@ export function scanPolicyDataHandling( cfg: Record, ): readonly PolicyDataHandlingEvidence[] { const entries: PolicyDataHandlingEvidence[] = []; - const logging = isRecord(cfg.logging) ? cfg.logging : {}; entries.push({ id: "logging-redaction", kind: "sensitiveLoggingRedaction", - source: "oc://openclaw.config/logging/redactSensitive", + source: "oc://openclaw.invariant/logging/redaction", scope: "global", - value: logging.redactSensitive !== "off", - explicit: logging.redactSensitive !== undefined, + value: true, + explicit: true, }); const diagnostics = isRecord(cfg.diagnostics) ? cfg.diagnostics : {}; @@ -146,34 +141,60 @@ function pushMemorySessionTranscriptIndexing( }); } - const agents = isRecord(cfg.agents) ? cfg.agents : {}; - const defaults = isRecord(agents.defaults) ? agents.defaults : {}; - const defaultsMemorySearch = isRecord(defaults.memorySearch) ? defaults.memorySearch : {}; + const defaultsMemorySearch = isRecord(memory.search) ? memory.search : {}; const defaultSessionMemory = memorySearchSessionTranscriptIndexing(defaultsMemorySearch); if (defaultSessionMemory !== undefined) { + const defaultExperimental = isRecord(defaultsMemorySearch.experimental) + ? defaultsMemorySearch.experimental + : {}; entries.push({ id: "agents-defaults-memory-session-transcripts", kind: "memorySessionTranscriptIndexing", - source: "oc://openclaw.config/agents/defaults/memorySearch/experimental/sessionMemory", + source: + readBoolean(defaultsMemorySearch.rememberAcrossConversations) === undefined && + readBoolean(defaultExperimental.sessionMemory) !== undefined + ? "oc://openclaw.config/memory/search/experimental/sessionMemory" + : "oc://openclaw.config/memory/search/rememberAcrossConversations", scope: "global", value: defaultSessionMemory, explicit: true, }); } - if (!Array.isArray(agents.list)) { + const agents = isRecord(cfg.agents) ? cfg.agents : {}; + const agentEntries = isRecord(agents.entries) + ? Object.entries(agents.entries).map(([entryId, value]) => ({ + agentId: entryId, + container: "entries" as const, + pathId: entryId, + value, + })) + : []; + const legacyAgents = Array.isArray(agents.list) + ? agents.list.flatMap((value, index) => { + if (!isRecord(value)) { + return []; + } + return [ + { + agentId: typeof value.id === "string" ? value.id : `agent-${index}`, + container: "list" as const, + pathId: String(index), + value, + }, + ]; + }) + : []; + const configuredAgents = agentEntries.length > 0 ? agentEntries : legacyAgents; + if (configuredAgents.length === 0) { return; } - agents.list.forEach((rawAgent, index) => { + configuredAgents.forEach(({ agentId, container, pathId, value: rawAgent }) => { if (!isRecord(rawAgent)) { return; } - const agentId = - readString(rawAgent.id) ?? - readString(rawAgent.name) ?? - readString(rawAgent.slug) ?? - `agent-${index}`; - const memorySearch = isRecord(rawAgent.memorySearch) ? rawAgent.memorySearch : undefined; + const agentMemory = isRecord(rawAgent.memory) ? rawAgent.memory : undefined; + const memorySearch = isRecord(agentMemory?.search) ? agentMemory.search : undefined; const agentSessionMemory = memorySearch === undefined ? defaultSessionMemory @@ -182,12 +203,17 @@ function pushMemorySessionTranscriptIndexing( return; } const explicit = memorySearchSessionTranscriptIndexingHasLocalConfig(memorySearch); + const experimental = isRecord(memorySearch?.experimental) ? memorySearch.experimental : {}; + const pathSegment = container === "list" ? `#${pathId}` : ocPathSegment(pathId); entries.push({ id: `${agentId}-memory-session-transcripts`, kind: "memorySessionTranscriptIndexing", source: explicit - ? `oc://openclaw.config/agents/list/#${index}/memorySearch/experimental/sessionMemory` - : "oc://openclaw.config/agents/defaults/memorySearch/experimental/sessionMemory", + ? readBoolean(memorySearch?.rememberAcrossConversations) === undefined && + readBoolean(experimental.sessionMemory) !== undefined + ? `oc://openclaw.config/agents/${container}/${pathSegment}/memory/search/experimental/sessionMemory` + : `oc://openclaw.config/agents/${container}/${pathSegment}/memory/search/rememberAcrossConversations` + : "oc://openclaw.config/memory/search/rememberAcrossConversations", scope: "agent", agentId: normalizeAgentId(agentId), value: agentSessionMemory, @@ -203,18 +229,22 @@ function memorySearchSessionTranscriptIndexing( if (!isRecord(memorySearch)) { return undefined; } - const experimental = isRecord(memorySearch.experimental) ? memorySearch.experimental : {}; const inherited = isRecord(inheritedMemorySearch) ? inheritedMemorySearch : {}; - const inheritedExperimental = isRecord(inherited.experimental) ? inherited.experimental : {}; const enabled = readBoolean(memorySearch.enabled) ?? readBoolean(inherited.enabled) ?? true; - const sessionMemory = - readBoolean(experimental.sessionMemory) ?? readBoolean(inheritedExperimental.sessionMemory); + const experimental = isRecord(memorySearch.experimental) ? memorySearch.experimental : {}; + const inheritedExperimental = isRecord(inherited.experimental) ? inherited.experimental : {}; + const rememberAcrossConversations = + readBoolean(memorySearch.rememberAcrossConversations) ?? + readBoolean(experimental.sessionMemory) ?? + readBoolean(inherited.rememberAcrossConversations) ?? + readBoolean(inheritedExperimental.sessionMemory); const sourcesIncludeSessions = memorySearchSourcesIncludeSessions(memorySearch) ?? memorySearchSourcesIncludeSessions(inherited) ?? false; if ( - sessionMemory === undefined && + rememberAcrossConversations === undefined && + readBoolean(experimental.sessionMemory) === undefined && memorySearchSourcesIncludeSessions(memorySearch) === undefined && readBoolean(memorySearch.enabled) === undefined ) { @@ -223,17 +253,19 @@ function memorySearchSessionTranscriptIndexing( if (!enabled) { return false; } - return sessionMemory === true && sourcesIncludeSessions; + return rememberAcrossConversations === true && sourcesIncludeSessions; } function memorySearchSessionTranscriptIndexingHasLocalConfig(memorySearch: unknown): boolean { if (!isRecord(memorySearch)) { return false; } - const experimental = isRecord(memorySearch.experimental) ? memorySearch.experimental : {}; return ( readBoolean(memorySearch.enabled) !== undefined || - readBoolean(experimental.sessionMemory) !== undefined || + readBoolean(memorySearch.rememberAcrossConversations) !== undefined || + readBoolean( + isRecord(memorySearch.experimental) ? memorySearch.experimental.sessionMemory : undefined, + ) !== undefined || memorySearchSourcesIncludeSessions(memorySearch) !== undefined ); } @@ -340,7 +372,27 @@ function isSecretInputPath(path: readonly string[]): boolean { matchesConfigPath(path, ["models", "providers", "*", "headers", "*"]) || isConfiguredProviderRequestSecretPath(path, ["models", "providers", "*"]) || isMediaConfiguredProviderRequestSecretPath(path) || - matchesConfigPath(path, ["agents", "defaults", "memorySearch", "remote", "headers", "*"]) || + matchesConfigPath(path, ["memory", "search", "remote", "headers", "*"]) || + matchesConfigPath(path, [ + "agents", + "entries", + "*", + "memory", + "search", + "remote", + "headers", + "*", + ]) || + matchesConfigPath(path, [ + "agents", + "list", + "#", + "memory", + "search", + "remote", + "headers", + "*", + ]) || matchesConfigPath(path, ["diagnostics", "otel", "headers", "*"]) ); } @@ -353,11 +405,8 @@ function isMediaConfiguredProviderRequestSecretPath(path: readonly string[]): bo return ( isConfiguredProviderRequestSecretPath(path, ["tools", "media", "models", "#"]) || isConfiguredProviderRequestSecretPath(path, ["tools", "media", "audio"]) || - isConfiguredProviderRequestSecretPath(path, ["tools", "media", "audio", "models", "#"]) || isConfiguredProviderRequestSecretPath(path, ["tools", "media", "image"]) || - isConfiguredProviderRequestSecretPath(path, ["tools", "media", "image", "models", "#"]) || - isConfiguredProviderRequestSecretPath(path, ["tools", "media", "video"]) || - isConfiguredProviderRequestSecretPath(path, ["tools", "media", "video", "models", "#"]) + isConfiguredProviderRequestSecretPath(path, ["tools", "media", "video"]) ); } @@ -473,13 +522,8 @@ function secretRefEvidence( } function secretProviderInsecureFlags(value: unknown): readonly string[] { - if (!isRecord(value)) { - return []; - } - return [ - ...(value.allowInsecurePath === true ? ["allowInsecurePath"] : []), - ...(value.allowSymlinkCommand === true ? ["allowSymlinkCommand"] : []), - ]; + void value; + return []; } function isValidAuthProfileMetadata(value: unknown): boolean { diff --git a/extensions/policy/src/policy-state-gateway.ts b/extensions/policy/src/policy-state-gateway.ts index 87e814a5d219..e5ffb14f5fbb 100644 --- a/extensions/policy/src/policy-state-gateway.ts +++ b/extensions/policy/src/policy-state-gateway.ts @@ -65,15 +65,15 @@ export function scanPolicyGatewayExposure( entries, "gateway-control-ui-insecure-auth", "controlUi", - controlUi.allowInsecureAuth, - "oc://openclaw.config/gateway/controlUi/allowInsecureAuth", + false, + "oc://openclaw.invariant/gateway/controlUi/deviceIdentity", ); pushGatewayBooleanEvidence( entries, "gateway-control-ui-device-auth-disabled", "controlUi", - controlUi.dangerouslyDisableDeviceAuth, - "oc://openclaw.config/gateway/controlUi/dangerouslyDisableDeviceAuth", + false, + "oc://openclaw.invariant/gateway/controlUi/deviceIdentity", ); pushGatewayBooleanEvidence( entries, @@ -201,15 +201,17 @@ function pushGatewayNodeCommandEvidence( entries: PolicyGatewayExposureEvidence[], nodes: Record, ): void { + const commands = isRecord(nodes.commands) ? nodes.commands : null; + const denyCommands = commands?.deny; const deniedCommands = new Set( - Array.isArray(nodes.denyCommands) - ? nodes.denyCommands + Array.isArray(denyCommands) + ? denyCommands .filter((command): command is string => typeof command === "string") .map((command) => command.trim()) : [], ); - if (Array.isArray(nodes.denyCommands)) { - nodes.denyCommands.forEach((command, index) => { + if (Array.isArray(denyCommands)) { + denyCommands.forEach((command, index) => { if (typeof command !== "string") { return; } @@ -220,16 +222,17 @@ function pushGatewayNodeCommandEvidence( entries.push({ id: `gateway-node-deny-command-${normalized}`, kind: "nodeDenyCommand", - source: `oc://openclaw.config/gateway/nodes/denyCommands/#${index}`, + source: `oc://openclaw.config/gateway/nodes/commands/deny/#${index}`, value: normalized, command: normalized, }); }); } - if (!Array.isArray(nodes.allowCommands)) { + const allowCommands = commands?.allow; + if (!Array.isArray(allowCommands)) { return; } - nodes.allowCommands.forEach((command, index) => { + allowCommands.forEach((command, index) => { if (typeof command !== "string") { return; } @@ -240,7 +243,7 @@ function pushGatewayNodeCommandEvidence( entries.push({ id: `gateway-node-command-${normalized}`, kind: "nodeCommand", - source: `oc://openclaw.config/gateway/nodes/allowCommands/#${index}`, + source: `oc://openclaw.config/gateway/nodes/commands/allow/#${index}`, value: normalized, command: normalized, }); diff --git a/extensions/policy/src/policy-state-tool-posture.ts b/extensions/policy/src/policy-state-tool-posture.ts index c00baf225d7e..b1db78f1fe4e 100644 --- a/extensions/policy/src/policy-state-tool-posture.ts +++ b/extensions/policy/src/policy-state-tool-posture.ts @@ -270,7 +270,7 @@ export const AGENT_WORKSPACE_POLICY_TOOLS = [ export const IMPLICIT_DEFAULT_ACCOUNT_FIELDS: Readonly> = { discord: ["token"], - googlechat: ["serviceAccount", "serviceAccountRef", "serviceAccountFile"], + googlechat: ["serviceAccount", "serviceAccountFile"], imessage: ["cliPath", "dbPath"], "qa-channel": ["baseUrl"], qqbot: ["appId", "clientSecret", "clientSecretFile"], diff --git a/extensions/qa-lab/src/docker-harness.test.ts b/extensions/qa-lab/src/docker-harness.test.ts index 592bb8905934..66288512338e 100644 --- a/extensions/qa-lab/src/docker-harness.test.ts +++ b/extensions/qa-lab/src/docker-harness.test.ts @@ -110,7 +110,7 @@ describe("qa docker harness", () => { entries?: Record; }; }; - expect(configText).toContain('"allowInsecureAuth": true'); + expect(configText).not.toContain('"allowInsecureAuth"'); expect(configText).toContain('"pluginToolsMcpBridge": true'); expect(configText).toContain('"openClawToolsMcpBridge": true'); expect(configText).toContain("/app/dist/control-ui"); diff --git a/extensions/qa-lab/src/live-transports/matrix/matrix-scenario-flows.test.ts b/extensions/qa-lab/src/live-transports/matrix/matrix-scenario-flows.test.ts index 04493e5d4ef5..e42e4d31b92a 100644 --- a/extensions/qa-lab/src/live-transports/matrix/matrix-scenario-flows.test.ts +++ b/extensions/qa-lab/src/live-transports/matrix/matrix-scenario-flows.test.ts @@ -124,10 +124,10 @@ describe("Matrix QA Lab scenario flows", () => { expect(readQaScenarioById("matrix-voice-preflight-mention").gatewayConfigPatch).toMatchObject({ tools: { media: { + models: [{ capabilities: ["audio"], model: "gpt-4o-transcribe", provider: "openai" }], audio: { echoTranscript: true, enabled: true, - models: [{ model: "gpt-4o-transcribe", provider: "openai" }], prompt: "MATRIX_QA_VOICE_PREFLIGHT_TRIGGER", }, }, @@ -141,10 +141,10 @@ describe("Matrix QA Lab scenario flows", () => { expect(readQaScenarioExecutionConfig("matrix-voice-preflight-mention")).toMatchObject({ matrixRequireCanary: true, matrixConfigOverrides: { + mediaModels: [{ capabilities: ["audio"], model: "gpt-4o-transcribe", provider: "openai" }], audio: { echoTranscript: true, enabled: true, - models: [{ model: "gpt-4o-transcribe", provider: "openai" }], prompt: "MATRIX_QA_VOICE_PREFLIGHT_TRIGGER", }, groupMentionPatterns: ["matrix\\W+qa\\W+voice\\W+pre[ -]?flight\\W+ok(?:ay)?"], diff --git a/extensions/qa-lab/src/live-transports/matrix/substrate/config.ts b/extensions/qa-lab/src/live-transports/matrix/substrate/config.ts index c79abe6e0221..0b4a8ad7c174 100644 --- a/extensions/qa-lab/src/live-transports/matrix/substrate/config.ts +++ b/extensions/qa-lab/src/live-transports/matrix/substrate/config.ts @@ -39,6 +39,9 @@ type MatrixQaToolConfigOverrides = { type MatrixQaAudioConfigOverrides = NonNullable< NonNullable["media"]>["audio"] >; +type MatrixQaMediaModelsOverrides = NonNullable< + NonNullable["media"]>["models"] +>; type MatrixQaGroupConfigOverrides = { allowBots?: MatrixQaAllowBotsMode; enabled?: boolean; @@ -93,6 +96,7 @@ export type MatrixQaConfigOverrides = { threadBindings?: MatrixQaThreadBindingsConfigOverrides; threadReplies?: MatrixQaThreadRepliesMode; audio?: MatrixQaAudioConfigOverrides; + mediaModels?: MatrixQaMediaModelsOverrides; toolProfile?: "coding" | "messaging" | "minimal"; }; @@ -597,7 +601,7 @@ export function buildMatrixQaConfig( : {}; const toolsConfig = - params.overrides?.toolProfile || params.overrides?.audio + params.overrides?.toolProfile || params.overrides?.audio || params.overrides?.mediaModels ? { ...baseCfg.tools, ...(params.overrides?.toolProfile @@ -605,14 +609,19 @@ export function buildMatrixQaConfig( profile: params.overrides.toolProfile, } : {}), - ...(params.overrides?.audio + ...(params.overrides?.audio || params.overrides?.mediaModels ? { media: { ...baseCfg.tools?.media, - audio: { - ...baseCfg.tools?.media?.audio, - ...params.overrides.audio, - }, + ...(params.overrides.mediaModels ? { models: params.overrides.mediaModels } : {}), + ...(params.overrides.audio + ? { + audio: { + ...baseCfg.tools?.media?.audio, + ...params.overrides.audio, + }, + } + : {}), }, } : {}), diff --git a/extensions/qa-lab/src/live-transports/shared/live-gateway.runtime.test.ts b/extensions/qa-lab/src/live-transports/shared/live-gateway.runtime.test.ts index 22aae60e263b..faa58abdfd69 100644 --- a/extensions/qa-lab/src/live-transports/shared/live-gateway.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/shared/live-gateway.runtime.test.ts @@ -149,28 +149,22 @@ describe("startQaLiveLaneGateway", () => { contextEngine: "qmd", }, }, - agents: { - defaults: { - memorySearch: { - enabled: true, - sync: { - onSearch: true, - onSessionStart: true, - watch: true, - }, - }, + memory: { + search: { + enabled: true, }, }, + + agents: { + defaults: {}, + }, }); expect(cfg?.plugins?.allow).toEqual(["acpx", "qa-channel"]); expect(cfg?.plugins?.entries).not.toHaveProperty("memory-core"); expect(cfg?.plugins?.slots?.memory).toBe("none"); expect(cfg?.plugins?.slots?.contextEngine).toBe("qmd"); - expect(cfg?.agents?.defaults?.memorySearch?.enabled).toBe(false); - expect(cfg?.agents?.defaults?.memorySearch?.sync?.onSearch).toBe(false); - expect(cfg?.agents?.defaults?.memorySearch?.sync?.onSessionStart).toBe(false); - expect(cfg?.agents?.defaults?.memorySearch?.sync?.watch).toBe(false); + expect(cfg?.memory?.search?.enabled).toBe(false); }); it("forwards gateway stop options to the child harness", async () => { diff --git a/extensions/qa-lab/src/live-transports/shared/live-gateway.runtime.ts b/extensions/qa-lab/src/live-transports/shared/live-gateway.runtime.ts index a8036ee672eb..88104017921a 100644 --- a/extensions/qa-lab/src/live-transports/shared/live-gateway.runtime.ts +++ b/extensions/qa-lab/src/live-transports/shared/live-gateway.runtime.ts @@ -49,7 +49,6 @@ function omitMemoryCoreEntry | undefined>(entr } function prepareLiveTransportGatewayConfig(cfg: OpenClawConfig): OpenClawConfig { - const defaults = cfg.agents?.defaults ?? {}; return { ...cfg, plugins: cfg.plugins @@ -67,20 +66,11 @@ function prepareLiveTransportGatewayConfig(cfg: OpenClawConfig): OpenClawConfig memory: "none", }, }, - agents: { - ...cfg.agents, - defaults: { - ...defaults, - memorySearch: { - ...defaults.memorySearch, - enabled: false, - sync: { - ...defaults.memorySearch?.sync, - onSearch: false, - onSessionStart: false, - watch: false, - }, - }, + memory: { + ...cfg.memory, + search: { + ...cfg.memory?.search, + enabled: false, }, }, }; diff --git a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.config.ts b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.config.ts index 3be1cd7f1c97..d644ecbcdb41 100644 --- a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.config.ts +++ b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.config.ts @@ -196,15 +196,17 @@ export function buildWhatsAppQaConfig( ...baseCfg.tools, media: { ...baseCfg.tools?.media, + models: [ + { + provider: "openai", + model: "gpt-4o-transcribe", + capabilities: ["audio" as const], + }, + ...(baseCfg.tools?.media?.models ?? []), + ], audio: { ...baseCfg.tools?.media?.audio, enabled: true, - models: [ - { - provider: "openai", - model: "gpt-4o-transcribe", - }, - ], }, }, }, diff --git a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.test.ts index 6d4fd10219b2..ac0fa928ad33 100644 --- a/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.test.ts @@ -2091,9 +2091,11 @@ describe("WhatsApp QA live runtime", () => { }); expect(cfg.plugins?.allow).toContain("whatsapp"); - expect(cfg.tools?.media?.audio).toEqual({ - enabled: true, - models: [{ provider: "openai", model: "gpt-4o-transcribe" }], + expect(cfg.tools?.media?.audio).toEqual({ enabled: true }); + expect(cfg.tools?.media?.models?.[0]).toEqual({ + provider: "openai", + model: "gpt-4o-transcribe", + capabilities: ["audio"], }); }); diff --git a/extensions/qa-lab/src/qa-gateway-config.test.ts b/extensions/qa-lab/src/qa-gateway-config.test.ts index 77ff475ad151..093c299be383 100644 --- a/extensions/qa-lab/src/qa-gateway-config.test.ts +++ b/extensions/qa-lab/src/qa-gateway-config.test.ts @@ -73,7 +73,7 @@ describe("buildQaGatewayConfig", () => { expect(getModelFallbacks(cfg.agents?.defaults?.model)).toEqual([ "mock-openai/gpt-5.6-luna-alt", ]); - expect(getModelFallbacks(cfg.agents?.list?.[0]?.model)).toEqual([ + expect(getModelFallbacks(cfg.agents?.entries?.qa?.model)).toEqual([ "mock-openai/gpt-5.6-luna-alt", ]); expect(cfg.models?.providers?.["mock-openai"]?.baseUrl).toBe("http://127.0.0.1:44080/v1"); @@ -88,7 +88,7 @@ describe("buildQaGatewayConfig", () => { expect(cfg.models?.providers?.openai?.request).toEqual({ allowPrivateNetwork: true }); expect(cfg.models?.providers?.anthropic?.baseUrl).toBe("http://127.0.0.1:44080"); expect(cfg.models?.providers?.anthropic?.request).toEqual({ allowPrivateNetwork: true }); - expect(cfg.agents?.defaults?.memorySearch).toMatchObject({ + expect(cfg.memory?.search).toMatchObject({ provider: "openai", model: "text-embedding-3-small", remote: { @@ -110,7 +110,7 @@ describe("buildQaGatewayConfig", () => { expect(cfg.plugins?.entries?.["qa-channel"]).toEqual({ enabled: true }); expect(cfg.plugins?.entries?.openai).toBeUndefined(); expect(cfg.tools?.profile).toBe("coding"); - expect(cfg.agents?.list?.[0]?.tools?.profile).toBe("coding"); + expect(cfg.agents?.entries?.qa?.tools?.profile).toBe("coding"); expect(cfg.channels?.["qa-channel"]?.enabled).toBe(true); expect(cfg.channels?.["qa-channel"]?.baseUrl).toBe("http://127.0.0.1:43124"); expect(cfg.channels?.["qa-channel"]?.pollTimeoutMs).toBe(250); @@ -154,7 +154,9 @@ describe("buildQaGatewayConfig", () => { expect(getPrimaryModel(cfg.agents?.defaults?.model)).toBe("openai/gpt-5.6-luna"); expect(getModelFallbacks(cfg.agents?.defaults?.model)).toEqual(["anthropic/claude-opus-4-8"]); - expect(getModelFallbacks(cfg.agents?.list?.[0]?.model)).toEqual(["anthropic/claude-opus-4-8"]); + expect(getModelFallbacks(cfg.agents?.entries?.qa?.model)).toEqual([ + "anthropic/claude-opus-4-8", + ]); expect(cfg.models?.providers?.openai?.api).toBe("openai-responses"); expect(cfg.models?.providers?.openai?.request).toEqual({ allowPrivateNetwork: true }); expect(cfg.models?.providers?.openai?.models.map((model) => model.id)).toContain( @@ -257,11 +259,11 @@ describe("buildQaGatewayConfig", () => { }); expect(getPrimaryModel(cfg.agents?.defaults?.model)).toBe("openai/gpt-5.6-luna"); - expect(getPrimaryModel(cfg.agents?.list?.[0]?.model)).toBe("openai/gpt-5.6-luna"); + expect(getPrimaryModel(cfg.agents?.entries?.qa?.model)).toBe("openai/gpt-5.6-luna"); expect(getModelFallbacks(cfg.agents?.defaults?.model)).toBeUndefined(); - expect(getModelFallbacks(cfg.agents?.list?.[0]?.model)).toBeUndefined(); + expect(getModelFallbacks(cfg.agents?.entries?.qa?.model)).toBeUndefined(); expect(cfg.models).toBeUndefined(); - expect(cfg.agents?.defaults?.memorySearch?.remote).toBeUndefined(); + expect(cfg.memory?.search?.remote).toBeUndefined(); expect(cfg.plugins?.allow).toEqual(["acpx", "memory-core", "qa-lab", "openai", "qa-channel"]); expect(cfg.plugins?.entries?.openai).toEqual({ enabled: true }); expect(cfg.agents?.defaults?.models?.["openai/gpt-5.6-luna"]).toEqual({ @@ -285,7 +287,7 @@ describe("buildQaGatewayConfig", () => { expect(cfg.agents?.defaults?.models?.["openai/gpt-5.6-luna"]).toEqual({}); expect(cfg.agents?.defaults?.models?.["openai/gpt-5.4"]).toEqual({}); - expect(cfg.agents?.list?.[0]?.fastModeDefault).toBe(true); + expect(cfg.agents?.entries?.qa?.fastModeDefault).toBe(true); }); it("routes forced Codex mock cells through the app-server OpenAI provider", () => { @@ -308,7 +310,7 @@ describe("buildQaGatewayConfig", () => { expect(cfg.models?.mode).toBe("merge"); expect(cfg.models?.providers?.openai?.baseUrl).toBe("https://api.openai.com/v1"); expect(cfg.models?.providers?.openai?.request).toBeUndefined(); - expect(cfg.agents?.defaults?.memorySearch?.remote).toEqual({ + expect(cfg.memory?.search?.remote).toEqual({ baseUrl: "http://127.0.0.1:44080/v1", apiKey: "test", }); diff --git a/extensions/qa-lab/src/qa-gateway-config.ts b/extensions/qa-lab/src/qa-gateway-config.ts index c07b45b91c7d..81fc9a0ad2db 100644 --- a/extensions/qa-lab/src/qa-gateway-config.ts +++ b/extensions/qa-lab/src/qa-gateway-config.ts @@ -189,6 +189,12 @@ export function buildQaGatewayConfig(params: { : {}; return { + memory: { + backend: "builtin", + search: { + ...mockMemorySearch, + }, + }, plugins: { allow: allowedPlugins, slots: { @@ -218,20 +224,12 @@ export function buildQaGatewayConfig(params: { model: buildQaModelSelection(primaryModel, alternateModel), ...(imageGenerationModelRef ? { - imageGenerationModel: { - primary: imageGenerationModelRef, + mediaModels: { + image: { primary: imageGenerationModelRef }, }, } : {}), ...(params.thinkingDefault ? { thinkingDefault: params.thinkingDefault } : {}), - memorySearch: { - ...mockMemorySearch, - sync: { - watch: true, - onSessionStart: true, - onSearch: true, - }, - }, models: { [primaryModel]: resolveModelEntry(primaryModel), [alternateModel]: resolveModelEntry(alternateModel), @@ -241,9 +239,8 @@ export function buildQaGatewayConfig(params: { maxConcurrent: 2, }, }, - list: [ - { - id: "qa", + entries: { + qa: { default: true, model: buildQaModelSelection(primaryModel, alternateModel), ...(params.forcedRuntime === "codex" && params.fastMode !== undefined @@ -262,10 +259,7 @@ export function buildQaGatewayConfig(params: { profile: "coding", }, }, - ], - }, - memory: { - backend: "builtin", + }, }, tools: { // The parity scenarios are code-agent contracts: they must always expose @@ -296,7 +290,6 @@ export function buildQaGatewayConfig(params: { : {}), ...((params.controlUiEnabled ?? true) ? { - allowInsecureAuth: true, allowedOrigins, } : {}), diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index 00a2522a50c3..e700509ca276 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -965,11 +965,7 @@ describe("qa scenario catalog", () => { expect(config?.requiredChannelDriver).toBe("qa-channel"); expect(scenario.gatewayConfigPatch).toMatchObject({ session: { dmScope: "per-channel-peer" }, - agents: { - defaults: { - memorySearch: { rememberAcrossConversations: true }, - }, - }, + memory: { search: { rememberAcrossConversations: true } }, plugins: { entries: { "active-memory": { diff --git a/extensions/qa-lab/src/tool-search-gateway.fixture.ts b/extensions/qa-lab/src/tool-search-gateway.fixture.ts index b3c815ca0677..615a476e7f8f 100644 --- a/extensions/qa-lab/src/tool-search-gateway.fixture.ts +++ b/extensions/qa-lab/src/tool-search-gateway.fixture.ts @@ -252,31 +252,22 @@ function applyLaneConfig( }, }; - const agents = (cfg.agents && typeof cfg.agents === "object" ? cfg.agents : {}) as Record< - string, - unknown - >; - const defaults = - agents.defaults && typeof agents.defaults === "object" - ? (agents.defaults as Record) - : {}; + const memory = + cfg.memory && typeof cfg.memory === "object" ? (cfg.memory as Record) : {}; const memorySearch = - defaults.memorySearch && typeof defaults.memorySearch === "object" - ? (defaults.memorySearch as Record) + memory.search && typeof memory.search === "object" + ? (memory.search as Record) : {}; - cfg.agents = { - ...agents, - defaults: { - ...defaults, - memorySearch: { - ...memorySearch, - enabled: false, - sync: { - ...(memorySearch.sync && typeof memorySearch.sync === "object" ? memorySearch.sync : {}), - onSearch: false, - onSessionStart: false, - watch: false, - }, + cfg.memory = { + ...memory, + search: { + ...memorySearch, + enabled: false, + sync: { + ...(memorySearch.sync && typeof memorySearch.sync === "object" ? memorySearch.sync : {}), + onSearch: false, + onSessionStart: false, + watch: false, }, }, }; diff --git a/extensions/qqbot/src/engine/utils/stt.test.ts b/extensions/qqbot/src/engine/utils/stt.test.ts index 738a43e0badf..b8ab8c1da690 100644 --- a/extensions/qqbot/src/engine/utils/stt.test.ts +++ b/extensions/qqbot/src/engine/utils/stt.test.ts @@ -135,14 +135,20 @@ describe("engine/utils/stt", () => { }); }); - it("falls back to framework audio model config when plugin STT is disabled", () => { + it("falls back to a generic framework media model when plugin STT is disabled", () => { const cfg = { channels: { qqbot: { stt: { enabled: false, apiKey: "ignored" } } }, tools: { media: { + models: [ + { + provider: "local", + baseUrl: "https://stt.example.test/", + model: "sense", + }, + ], audio: { timeoutSeconds: 90, - models: [{ provider: "local", baseUrl: "https://stt.example.test/", model: "sense" }], }, }, }, @@ -160,7 +166,7 @@ describe("engine/utils/stt", () => { timeoutMs: 90_000, }); - Object.assign(expectDefined(cfg.tools.media.audio.models[0], "QQBot STT model"), { + Object.assign(expectDefined(cfg.tools.media.models[0], "QQBot STT model"), { timeoutSeconds: 75, }); expect(resolveSTTConfig(cfg)?.timeoutMs).toBe(75_000); diff --git a/extensions/qqbot/src/engine/utils/stt.ts b/extensions/qqbot/src/engine/utils/stt.ts index b6cbc8e3926f..3a438f99e16a 100644 --- a/extensions/qqbot/src/engine/utils/stt.ts +++ b/extensions/qqbot/src/engine/utils/stt.ts @@ -71,8 +71,10 @@ export function resolveSTTConfig(cfg: Record): STTConfig | null const tools = asRecord(cfg.tools); const media = asRecord(tools?.media); const audio = asRecord(media?.audio); - const audioModels = audio?.models; - const audioModelEntry = Array.isArray(audioModels) ? asRecord(audioModels[0]) : undefined; + const mediaModels = Array.isArray(media?.models) ? media.models : []; + const audioModelEntry = mediaModels + .map((entry) => asRecord(entry)) + .find((entry) => !Array.isArray(entry?.capabilities) || entry.capabilities.includes("audio")); if (audioModelEntry) { const providerId = readString(audioModelEntry, "provider") ?? "openai"; const providerCfg = asRecord(providers?.[providerId]); diff --git a/extensions/reef/src/config-schema.ts b/extensions/reef/src/config-schema.ts index 977efca0ee20..02e7043036a4 100644 --- a/extensions/reef/src/config-schema.ts +++ b/extensions/reef/src/config-schema.ts @@ -37,7 +37,6 @@ export type ReefChannelConfig = z.infer; export type ReefCoreConfig = { channels?: { reef?: Partial }; - commands?: { useAccessGroups?: boolean }; session?: { store?: string }; }; diff --git a/extensions/signal/src/accounts.ts b/extensions/signal/src/accounts.ts index 38bd7fd3173b..6302ba558926 100644 --- a/extensions/signal/src/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -49,16 +49,12 @@ export function resolveSignalAccount(params: { const merged = mergeSignalAccountConfig(params.cfg, accountId); const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; - const host = normalizeOptionalString(merged.httpHost) ?? "127.0.0.1"; - const port = merged.httpPort ?? 8080; - const baseUrl = normalizeOptionalString(merged.httpUrl) ?? `http://${host}:${port}`; + const baseUrl = normalizeOptionalString(merged.httpUrl) ?? "http://127.0.0.1:8080"; const configured = Boolean( normalizeOptionalString(merged.account) || normalizeOptionalString(merged.configPath) || normalizeOptionalString(merged.httpUrl) || normalizeOptionalString(merged.cliPath) || - normalizeOptionalString(merged.httpHost) || - typeof merged.httpPort === "number" || typeof merged.autoStart === "boolean", ); return { diff --git a/extensions/signal/src/core.test.ts b/extensions/signal/src/core.test.ts index 1a17cad77d1f..e3a0cb9ee5de 100644 --- a/extensions/signal/src/core.test.ts +++ b/extensions/signal/src/core.test.ts @@ -32,6 +32,7 @@ import { resolveSignalReplyContextWithPersistence, } from "./reply-authors.js"; import { + buildSignalSetupPatch, createSignalCliPathTextInput, normalizeSignalAccountInput, signalDmPolicy, @@ -59,6 +60,20 @@ describe("looksLikeUuid", () => { }); describe("signal sender identity", () => { + it("brackets IPv6 hosts in setup-derived HTTP URLs", () => { + expect(buildSignalSetupPatch({ httpHost: "::1", httpPort: "9090" })).toMatchObject({ + httpUrl: "http://[::1]:9090", + autoStart: true, + }); + }); + + it("disables local daemon ownership for an explicit external HTTP URL", () => { + expect(buildSignalSetupPatch({ httpUrl: "http://signal.example:9090" })).toMatchObject({ + httpUrl: "http://signal.example:9090", + autoStart: false, + }); + }); + it("prefers sourceNumber over sourceUuid and keeps the uuid as an alias", () => { const sender = resolveSignalSender({ sourceNumber: " +15550001111 ", diff --git a/extensions/signal/src/monitor.tool-result.autostart.test.ts b/extensions/signal/src/monitor.tool-result.autostart.test.ts index 5ffce21d8979..fcb1197d5c5e 100644 --- a/extensions/signal/src/monitor.tool-result.autostart.test.ts +++ b/extensions/signal/src/monitor.tool-result.autostart.test.ts @@ -134,6 +134,23 @@ describe("monitorSignalProvider autostart", () => { ); }); + it("passes a bare IPv6 bind host from an IPv6 daemon URL", async () => { + const runtime = createMonitorRuntime(); + setSignalAutoStartConfig(); + const abortController = createAutoAbortController(); + + await runMonitorWithMocks({ + autoStart: true, + baseUrl: "http://[::1]:9090", + abortSignal: abortController.signal, + runtime, + }); + + expect(spawnSignalDaemonMock).toHaveBeenCalledWith( + expect.objectContaining({ httpHost: "::1", httpPort: 9090 }), + ); + }); + it("omits configPath when channels.signal.configPath is blank", async () => { const runtime = createMonitorRuntime(); setSignalAutoStartConfig({ configPath: " " }); diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 246e9d459e2c..835ed40ba8d9 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -596,8 +596,15 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi const configPath = normalizeOptionalString(opts.configPath) ?? normalizeOptionalString(accountInfo.config.configPath); - const httpHost = opts.httpHost ?? accountInfo.config.httpHost ?? "127.0.0.1"; - const httpPort = opts.httpPort ?? accountInfo.config.httpPort ?? 8080; + const daemonUrl = new URL(baseUrl); + const daemonHostname = daemonUrl.hostname; + const httpHost = + opts.httpHost ?? + (daemonHostname.startsWith("[") && daemonHostname.endsWith("]") + ? daemonHostname.slice(1, -1) + : daemonHostname); + const httpPort = + opts.httpPort ?? Number(daemonUrl.port || (daemonUrl.protocol === "https:" ? 443 : 80)); daemonHandle = spawnSignalDaemon({ cliPath, ...(configPath ? { configPath } : {}), diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts index d542eb9bbb19..881db3c1d37b 100644 --- a/extensions/signal/src/monitor/event-handler.ts +++ b/extensions/signal/src/monitor/event-handler.ts @@ -421,7 +421,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { }, }, initialEmoji: ackReaction, - emojis: resolveSignalStatusReactionEmojis(statusReactionsConfig.emojis), + emojis: resolveSignalStatusReactionEmojis(undefined), timing: statusReactionTiming, onError: (err) => { logAckFailure({ @@ -613,7 +613,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) { controller: statusReactionController, outcome: hasFinalResponse && !hasDeliveryFailure ? "done" : "error", hasFinalResponse, - removeAckAfterReply: deps.cfg.messages?.removeAckAfterReply ?? false, + removeAckAfterReply: false, timing: statusReactionTiming, }).catch((err: unknown) => { logVerbose(`signal: status reaction finalize failed: ${String(err)}`); diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 478a43102221..b8847146a537 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -82,19 +82,28 @@ function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: }); } -function buildSignalSetupPatch(input: { +export function buildSignalSetupPatch(input: { signalNumber?: string; cliPath?: string; httpUrl?: string; httpHost?: string; httpPort?: string; }) { + const rawHttpHost = input.httpHost || "127.0.0.1"; + const httpHost = + rawHttpHost.includes(":") && !rawHttpHost.startsWith("[") ? `[${rawHttpHost}]` : rawHttpHost; + const derivedHttpUrl = + input.httpUrl ?? + (input.httpHost || input.httpPort + ? `http://${httpHost}:${input.httpPort || "8080"}` + : undefined); return { ...(input.signalNumber ? { account: input.signalNumber } : {}), ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), - ...(input.httpHost ? { httpHost: input.httpHost } : {}), - ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), + ...(derivedHttpUrl ? { httpUrl: derivedHttpUrl } : {}), + // Legacy host/port selected the locally owned daemon; httpUrl selected an + // external endpoint. Preserve that lifecycle distinction while unifying URLs. + ...(input.httpUrl ? { autoStart: false } : derivedHttpUrl ? { autoStart: true } : {}), }; } diff --git a/extensions/slack/src/account-inspect.test.ts b/extensions/slack/src/account-inspect.test.ts index c5b9305ab43e..854e26fd33b7 100644 --- a/extensions/slack/src/account-inspect.test.ts +++ b/extensions/slack/src/account-inspect.test.ts @@ -9,7 +9,7 @@ describe("inspectSlackAccount", () => { cfg: { channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", appToken: "test-app-token", }, @@ -36,7 +36,7 @@ describe("inspectSlackAccount", () => { cfg: { channels: { slack: { - identity: "user", + postAs: "user", mode: "http", userToken: "test-user-token", }, diff --git a/extensions/slack/src/account-inspect.ts b/extensions/slack/src/account-inspect.ts index 32baf5148a65..2be79d196785 100644 --- a/extensions/slack/src/account-inspect.ts +++ b/extensions/slack/src/account-inspect.ts @@ -80,7 +80,7 @@ export function inspectSlackAccount(params: { const enabled = params.cfg.channels?.slack?.enabled !== false && merged.enabled !== false; const allowEnv = accountId === DEFAULT_ACCOUNT_ID; const mode = merged.mode ?? "socket"; - const identity = merged.identity ?? "bot"; + const identity = merged.postAs ?? "bot"; const isHttpMode = mode === "http"; const isRelayMode = mode === "relay"; diff --git a/extensions/slack/src/accounts.test.ts b/extensions/slack/src/accounts.test.ts index b3b8738846e5..08a6e7fe6af3 100644 --- a/extensions/slack/src/accounts.test.ts +++ b/extensions/slack/src/accounts.test.ts @@ -67,7 +67,7 @@ describe("resolveSlackOperationToken", () => { cfg: { channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", userTokenReadOnly: true, }, @@ -84,7 +84,7 @@ describe("resolveSlackOperationToken", () => { cfg: { channels: { slack: { - identity: "user", + postAs: "user", botToken: "test-bot-token", }, }, diff --git a/extensions/slack/src/accounts.ts b/extensions/slack/src/accounts.ts index 1e666ac62b8e..67ab186cf009 100644 --- a/extensions/slack/src/accounts.ts +++ b/extensions/slack/src/accounts.ts @@ -61,7 +61,7 @@ export function resolveSlackOperationToken( const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("slack", { hasImplicitDefaultAccount: (cfg) => { const slack = cfg.channels?.slack; - if (slack?.identity === "user") { + if (slack?.postAs === "user") { const hasUserToken = hasConfiguredAccountValue(slack.userToken) || hasConfiguredAccountValue(process.env.SLACK_USER_TOKEN); @@ -239,7 +239,7 @@ export function resolveSlackAccount(params: { ); const baseEnabled = params.cfg.channels?.slack?.enabled !== false; const merged = mergeSlackAccountConfig(params.cfg, accountId); - const identity = merged.identity ?? "bot"; + const identity = merged.postAs ?? "bot"; const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; const mode = merged.mode ?? "socket"; diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index cd26b376787c..3c6ca223debf 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -1639,7 +1639,7 @@ describe("handleSlackAction", () => { const token = await resolveSendToken({ channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", }, }, @@ -1653,7 +1653,7 @@ describe("handleSlackAction", () => { resolveSendToken({ channels: { slack: { - identity: "user", + postAs: "user", botToken: "test-bot-token", }, }, diff --git a/extensions/slack/src/channel-actions-setup-status.contract.test.ts b/extensions/slack/src/channel-actions-setup-status.contract.test.ts index bfdbdc175dc1..29954f34c242 100644 --- a/extensions/slack/src/channel-actions-setup-status.contract.test.ts +++ b/extensions/slack/src/channel-actions-setup-status.contract.test.ts @@ -115,7 +115,7 @@ describe("slack setup contract", () => { assertPatchedConfig: (cfg) => { expect(cfg.channels?.slack).toMatchObject({ enabled: true, - identity: "user", + postAs: "user", userToken: "test-user-token", appToken: "test-app-token", }); @@ -135,7 +135,7 @@ describe("slack setup contract", () => { assertPatchedConfig: (cfg) => { expect(cfg.channels?.slack).toMatchObject({ enabled: true, - identity: "user", + postAs: "user", mode: "http", userToken: "test-user-token", signingSecret: "test-signing-secret", @@ -149,7 +149,7 @@ describe("slack setup contract", () => { cfg: { channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-old-user-token", appToken: "test-old-app-token", }, @@ -164,7 +164,7 @@ describe("slack setup contract", () => { assertPatchedConfig: (cfg) => { expect(cfg.channels?.slack).toMatchObject({ enabled: true, - identity: "user", + postAs: "user", mode: "http", userToken: "test-user-token", signingSecret: "test-signing-secret", @@ -208,7 +208,7 @@ describe("slack setup contract", () => { assertPatchedConfig: (cfg) => { expect(cfg.channels?.slack).toMatchObject({ enabled: true, - identity: "bot", + postAs: "bot", botToken: "test-bot-token", appToken: "test-app-token", }); diff --git a/extensions/slack/src/channel-type.test.ts b/extensions/slack/src/channel-type.test.ts index e169baad85f8..adf3f9b92703 100644 --- a/extensions/slack/src/channel-type.test.ts +++ b/extensions/slack/src/channel-type.test.ts @@ -162,7 +162,7 @@ describe("resolveSlackChannelType", () => { cfg: { channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", }, }, diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 889d7e55b8be..abefb207d744 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -483,7 +483,7 @@ describe("slackPlugin status", () => { const cfg = { channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", appToken: "test-app-token", }, @@ -1636,7 +1636,7 @@ describe("slackPlugin config", () => { { name: "Socket Mode", slack: { - identity: "user" as const, + postAs: "user" as const, userToken: "test-user-token", appToken: "test-app-token", }, @@ -1645,7 +1645,7 @@ describe("slackPlugin config", () => { { name: "HTTP mode", slack: { - identity: "user" as const, + postAs: "user" as const, mode: "http" as const, userToken: "test-user-token", signingSecret: "test-signing-secret", diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 8b745672a3a5..448b74c027c5 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -796,7 +796,7 @@ export const slackPlugin: ChannelPlugin = crea snapshot, snapshot.identity === "user" ? { - identity: "user", + postAs: "user", userTokenSource: snapshot.userTokenSource ?? "none", ...(snapshot.mode === "http" ? { signingSecretSource: snapshot.signingSecretSource ?? "none" } @@ -872,7 +872,7 @@ export const slackPlugin: ChannelPlugin = crea }, resolveAccountSnapshot: ({ account }) => { const mode = account.config.mode ?? "socket"; - const identity = account.config.identity ?? "bot"; + const identity = account.config.postAs ?? "bot"; const credentialConfigured = mode === "http" ? resolveConfiguredFromRequiredCredentialStatuses(account, [ diff --git a/extensions/slack/src/config-schema.test.ts b/extensions/slack/src/config-schema.test.ts index 18c1e8c0415a..314f1d956192 100644 --- a/extensions/slack/src/config-schema.test.ts +++ b/extensions/slack/src/config-schema.test.ts @@ -40,39 +40,39 @@ describe("slack config schema", () => { } }); - it('defaults identity to "bot"', () => { + it('defaults postAs to "bot"', () => { const res = SlackConfigSchema.safeParse({ accounts: { work: {} } }); expect(res.success).toBe(true); if (res.success) { - expect(res.data.identity).toBe("bot"); - expect(res.data.accounts?.work?.identity).toBeUndefined(); - expect(res.data.accounts?.work?.identity ?? res.data.identity).toBe("bot"); + expect(res.data.postAs).toBe("bot"); + expect(res.data.accounts?.work?.postAs).toBeUndefined(); + expect(res.data.accounts?.work?.postAs ?? res.data.postAs).toBe("bot"); } }); - it('accepts identity="user" with a user token and socket companion app', () => { + it('accepts postAs="user" with a user token and socket companion app', () => { expectSlackConfigValid({ - identity: "user", + postAs: "user", userToken: "test-user-token", appToken: "test-app-token", }); }); - it('accepts identity="user" with a user token and HTTP companion app', () => { + it('accepts postAs="user" with a user token and HTTP companion app', () => { expectSlackConfigValid({ - identity: "user", + postAs: "user", mode: "http", userToken: "test-user-token", signingSecret: "test-signing-secret", }); }); - it("allows account entries to inherit the top-level user identity", () => { + it("allows account entries to inherit the top-level user postAs", () => { const cfg = { channels: { slack: { - identity: "user" as const, + postAs: "user" as const, userToken: "test-user-token", appToken: "test-app-token", accounts: { work: {} }, @@ -84,11 +84,11 @@ describe("slack config schema", () => { expect(resolveSlackAccount({ cfg, accountId: "work" }).identity).toBe("user"); }); - it("keeps user tokens and companion app tokens active for user identity", () => { + it("keeps user tokens and companion app tokens active for user postAs", () => { const cfg = { channels: { slack: { - identity: "user" as const, + postAs: "user" as const, userToken: "test-user-token", appToken: "test-app-token", }, @@ -104,9 +104,9 @@ describe("slack config schema", () => { expect(account.appTokenSource).toBe("config"); }); - it("accepts inherited and relay companion-app transports for user identity", () => { + it("accepts inherited and relay companion-app transports for user postAs", () => { expectSlackConfigValid({ - identity: "user", + postAs: "user", userToken: "test-user-token", appToken: "test-app-token", accounts: { @@ -114,7 +114,7 @@ describe("slack config schema", () => { }, }); expectSlackConfigValid({ - identity: "user", + postAs: "user", mode: "relay", userToken: "test-user-token", relay: { @@ -125,8 +125,8 @@ describe("slack config schema", () => { }); }); - it("defers user-identity user-token presence to runtime", () => { - expectSlackConfigValid({ identity: "user" }); + it("defers user-postAs user-token presence to runtime", () => { + expectSlackConfigValid({ postAs: "user" }); }); it("keeps presence events off by default and accepts account/channel modes", () => { @@ -224,22 +224,24 @@ describe("slack config schema", () => { }); }); - it("accepts Socket Mode ping/pong transport tuning", () => { - expectSlackConfigValid({ - mode: "socket", - socketMode: { - clientPingTimeout: 15_000, - serverPingTimeout: 45_000, - pingPongLoggingEnabled: true, - }, - accounts: { - ops: { - socketMode: { - clientPingTimeout: 20_000, + it("rejects retired Socket Mode ping/pong transport tuning", () => { + expect( + SlackConfigSchema.safeParse({ + mode: "socket", + socketMode: { + clientPingTimeout: 15_000, + serverPingTimeout: 45_000, + pingPongLoggingEnabled: true, + }, + accounts: { + ops: { + socketMode: { + clientPingTimeout: 20_000, + }, }, }, - }, - }); + }).success, + ).toBe(false); }); it("accepts relay mode with a SecretInput auth token", () => { @@ -272,17 +274,6 @@ describe("slack config schema", () => { ); }); - it("rejects invalid Socket Mode ping/pong transport tuning", () => { - expectSlackConfigIssue( - { - socketMode: { - clientPingTimeout: 0, - }, - }, - "socketMode.clientPingTimeout", - ); - }); - it("accepts per-channel replyToMode", () => { expectSlackConfigValid({ channels: { diff --git a/extensions/slack/src/config-ui-hints.ts b/extensions/slack/src/config-ui-hints.ts index 7cced59daa12..727e0b0f6480 100644 --- a/extensions/slack/src/config-ui-hints.ts +++ b/extensions/slack/src/config-ui-hints.ts @@ -11,7 +11,7 @@ export const slackChannelConfigUiHints = { label: "Slack Enterprise Grid Org Install", help: 'Enable only for an Enterprise Grid org-wide bot installation. OpenClaw verifies the token with Slack auth.test at startup; DMs must be disabled or use dmPolicy="open" with allowFrom=["*"].', }, - identity: { + postAs: { label: "Slack Identity", help: 'Select "bot" (default) for the classic Slack app/bot identity or "user" to post as the authorizing human through a user token while the app carries event transport.', }, @@ -51,22 +51,6 @@ export const slackChannelConfigUiHints = { label: "Slack Bot Loop Cooldown Seconds", help: "How long to suppress the bot pair after it exceeds the budget. Default: 60.", }, - socketMode: { - label: "Slack Socket Mode Transport", - help: "Slack Socket Mode transport tuning passed to the Slack SDK. Use only when investigating ping/pong timeout or stale websocket behavior.", - }, - "socketMode.clientPingTimeout": { - label: "Slack Socket Mode Pong Timeout", - help: "Milliseconds the Slack SDK waits for a pong after its client ping before treating the websocket as stale (OpenClaw default: 15000). Increase on hosts with event-loop starvation or slow network scheduling.", - }, - "socketMode.serverPingTimeout": { - label: "Slack Socket Mode Server Ping Timeout", - help: "Milliseconds the Slack SDK waits for Slack server pings before treating the websocket as stale.", - }, - "socketMode.pingPongLoggingEnabled": { - label: "Slack Socket Mode Ping/Pong Logging", - help: "Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health.", - }, relay: { label: "Slack Relay Mode", help: 'Relay-delivered Slack events. Use with mode="relay" when openclaw-slack-router owns the Slack Socket Mode connection.', diff --git a/extensions/slack/src/doctor.test.ts b/extensions/slack/src/doctor.test.ts index 87f0ee8ae3aa..1b8f6615f490 100644 --- a/extensions/slack/src/doctor.test.ts +++ b/extensions/slack/src/doctor.test.ts @@ -48,7 +48,7 @@ describe("slack doctor", () => { cfg: { channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", appToken: "test-app-token", }, @@ -78,7 +78,7 @@ describe("slack doctor", () => { cfg: { channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", appToken: "test-app-token", }, @@ -96,12 +96,12 @@ describe("slack doctor", () => { it.each([ { name: "Socket Mode app token", - slack: { identity: "user", userToken: "test-user-token" }, + slack: { postAs: "user", userToken: "test-user-token" }, expected: "requires appToken for companion-app events", }, { name: "HTTP signing secret", - slack: { identity: "user", mode: "http", userToken: "test-user-token" }, + slack: { postAs: "user", mode: "http", userToken: "test-user-token" }, expected: "requires signingSecret for companion-app events", }, ])("warns when user identity is missing the $name", async ({ slack, expected }) => { diff --git a/extensions/slack/src/message-tools.test.ts b/extensions/slack/src/message-tools.test.ts index a27ccdaff3b2..7fc3de14bf19 100644 --- a/extensions/slack/src/message-tools.test.ts +++ b/extensions/slack/src/message-tools.test.ts @@ -223,7 +223,7 @@ describe("Slack message tools", () => { const cfg = { channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", appToken: "test-app-token", }, diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 159cc78e5d37..55bf0072e7ba 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -487,7 +487,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag enabled: statusReactionsEnabled, adapter: slackStatusAdapter, initialEmoji: prepared.ackReactionValue || "eyes", - emojis: cfg.messages?.statusReactions?.emojis, + emojis: undefined, timing: DEFAULT_TIMING, onError: (err) => { logAckFailure({ diff --git a/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts b/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts index b01b1647b96c..b968a01b0b8e 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts @@ -46,7 +46,7 @@ export function createInboundSlackTestContext(params: { defaultRequireMention: params.defaultRequireMention ?? true, channelsConfig: params.channelsConfig, groupPolicy: "open", - useAccessGroups: false, + useAccessGroups: true, reactionMode: "off", reactionAllowlist: [], replyToMode: params.replyToMode ?? "off", diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index ddf444ea0d13..034fd24b2637 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -3884,6 +3884,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`; }) { const cfg = { ...(params.storePath ? { session: { store: params.storePath } } : {}), + commands: { allowFrom: { slack: ["user:U_BEK"] } }, messages: { groupChat: { mentionPatterns: ["\\bbill\\b"] } }, tools: { media: { audio: { enabled: params.audioEnabled ?? true } } }, channels: { @@ -3917,7 +3918,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`; async (_input: string | URL | Request) => new Response(Buffer.from("voice clip"), { status: 200, - headers: { "content-type": "video/mp4" }, + headers: { "content-type": "audio/mp4" }, }), ); globalThis.fetch = mockFetch as typeof fetch; @@ -3935,8 +3936,8 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`; let downloadedPath: string | undefined; let downloadedPaths: string[] = []; transcribeFirstAudioMock.mockImplementation( - async ({ ctx }: { ctx: { MediaPaths: string[] } }) => { - downloadedPath = ctx.MediaPaths[0]; + async ({ ctx }: { ctx: { media: Array<{ path?: string }> } }) => { + downloadedPath = ctx.media[0]?.path; return "Bill /new please review this"; }, ); @@ -4052,7 +4053,7 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`; async (_input: string | URL | Request) => new Response(Buffer.from("voice clip"), { status: 200, - headers: { "content-type": "video/mp4" }, + headers: { "content-type": "audio/mp4" }, }), ); globalThis.fetch = mockFetch as typeof fetch; @@ -4060,8 +4061,8 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`; slackCtx.historyLimit = 5; let downloadedPath: string | undefined; transcribeFirstAudioMock.mockImplementation( - async ({ ctx }: { ctx: { MediaPaths: string[] } }) => { - downloadedPath = ctx.MediaPaths[0]; + async ({ ctx }: { ctx: { media: Array<{ path?: string }> } }) => { + downloadedPath = ctx.media[0]?.path; return "please review this"; }, ); diff --git a/extensions/slack/src/monitor/provider-support.ts b/extensions/slack/src/monitor/provider-support.ts index 2a48524662bc..e1c6ea7be03f 100644 --- a/extensions/slack/src/monitor/provider-support.ts +++ b/extensions/slack/src/monitor/provider-support.ts @@ -10,10 +10,6 @@ type SlackHttpReceiverConstructor = typeof import("@slack/bolt").HTTPReceiver; type SlackReceiver = import("@slack/bolt").Receiver; type SlackSocketModeReceiverConstructor = typeof import("@slack/bolt").SocketModeReceiver; type SlackSocketModeReceiverOptions = ConstructorParameters[0]; -type SlackSocketModeConfig = Pick< - SlackSocketModeReceiverOptions, - "clientPingTimeout" | "serverPingTimeout" | "pingPongLoggingEnabled" ->; type SlackSdkLogger = NonNullable; type SlackSdkLogLevel = ReturnType; type SlackSocketModeLogger = SlackSdkLogger & { @@ -313,27 +309,19 @@ export function createSlackBoltApp(params: { signingSecret?: string; slackWebhookPath: string; clientOptions: Record; - socketMode?: SlackSocketModeConfig; wrapReceiver?: (receiver: SlackReceiver) => SlackReceiver; }) { const socketModeLogger = createSlackSocketModeLogger(); const socketModeReceiverOptions: SlackSocketModeReceiverOptions = { appToken: params.appToken ?? "", autoReconnectEnabled: true, - clientPingTimeout: - params.socketMode?.clientPingTimeout ?? OPENCLAW_SLACK_CLIENT_PING_TIMEOUT_MS, + clientPingTimeout: OPENCLAW_SLACK_CLIENT_PING_TIMEOUT_MS, logger: socketModeLogger, installerOptions: { clientOptions: params.clientOptions, }, ...(params.wrapReceiver ? { processEventErrorHandler: async () => false } : {}), }; - if (params.socketMode?.serverPingTimeout !== undefined) { - socketModeReceiverOptions.serverPingTimeout = params.socketMode.serverPingTimeout; - } - if (params.socketMode?.pingPongLoggingEnabled !== undefined) { - socketModeReceiverOptions.pingPongLoggingEnabled = params.socketMode.pingPongLoggingEnabled; - } let receiver: | InstanceType diff --git a/extensions/slack/src/monitor/provider.auth-test-token.test.ts b/extensions/slack/src/monitor/provider.auth-test-token.test.ts index 16b16a2758e2..3e9489d2bf40 100644 --- a/extensions/slack/src/monitor/provider.auth-test-token.test.ts +++ b/extensions/slack/src/monitor/provider.auth-test-token.test.ts @@ -341,7 +341,7 @@ describe("user identity provider transport", () => { const userSocketConfig = () => ({ channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", appToken: "test-app-token", dm: { enabled: true }, @@ -483,7 +483,7 @@ describe("user identity provider transport", () => { const config = { channels: { slack: { - identity: "user", + postAs: "user", mode: "http", userToken: "test-user-token", signingSecret: "test-signing-secret", @@ -514,7 +514,7 @@ describe("user identity provider transport", () => { const config = { channels: { slack: { - identity: "user", + postAs: "user", appToken: "test-app-token", }, }, @@ -530,7 +530,7 @@ describe("user identity provider transport", () => { const config = { channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", }, }, @@ -545,7 +545,7 @@ describe("user identity provider transport", () => { const config = { channels: { slack: { - identity: "user", + postAs: "user", mode: "http", userToken: "test-user-token", }, diff --git a/extensions/slack/src/monitor/provider.interop.test.ts b/extensions/slack/src/monitor/provider.interop.test.ts index 9f186b7b3541..a75d7926fbb6 100644 --- a/extensions/slack/src/monitor/provider.interop.test.ts +++ b/extensions/slack/src/monitor/provider.interop.test.ts @@ -356,7 +356,7 @@ describe("createSlackBoltApp", () => { ]); }); - it("passes Socket Mode ping/pong options through Slack's public receiver API", () => { + it("uses Slack's fixed Socket Mode receiver policy", () => { const clientOptions = { teamId: "T1" }; const { receiver } = createSlackBoltApp({ interop: { @@ -369,11 +369,6 @@ describe("createSlackBoltApp", () => { appToken: "xapp-test", slackWebhookPath: "/slack/events", clientOptions, - socketMode: { - clientPingTimeout: 20_000, - serverPingTimeout: 45_000, - pingPongLoggingEnabled: true, - }, }); const receiverArgs = (receiver as unknown as FakeSocketModeReceiver).args; @@ -383,9 +378,7 @@ describe("createSlackBoltApp", () => { expect(receiverArgs).toEqual({ appToken: "xapp-test", autoReconnectEnabled: true, - clientPingTimeout: 20_000, - serverPingTimeout: 45_000, - pingPongLoggingEnabled: true, + clientPingTimeout: 15_000, logger: receiverLogger, installerOptions: { clientOptions, diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 7198b6b3f912..28bfc5448832 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -327,7 +327,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }); const resolveToken = account.userToken || botToken; - const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const useAccessGroups = true; const reactionMode = slackCfg.reactionNotifications ?? "own"; const reactionAllowlist = slackCfg.reactionAllowlist ?? []; const replyToMode = slackCfg.replyToMode ?? "off"; @@ -341,7 +341,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const typingReaction = slackCfg.typingReaction?.trim() ?? ""; const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024; - const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; + const removeAckAfterReply = false; const clientOptions = resolveSlackWebClientOptions(); const durableIngress = createSlackDurableIngress({ accountId: account.accountId, @@ -356,7 +356,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { signingSecret: slackMode === "http" ? (signingSecret ?? undefined) : undefined, slackWebhookPath, clientOptions: clientOptions as Record, - ...(slackCfg.socketMode ? { socketMode: slackCfg.socketMode } : {}), wrapReceiver: durableIngress.wrapReceiver, }); diff --git a/extensions/slack/src/security-audit.test.ts b/extensions/slack/src/security-audit.test.ts index 923f0932ae0a..bfefe6fbc6be 100644 --- a/extensions/slack/src/security-audit.test.ts +++ b/extensions/slack/src/security-audit.test.ts @@ -23,13 +23,8 @@ function createSlackAccount(config: NonNullable["sla } as ResolvedSlackAccount; } -function createSlashCommandSlackConfig( - options: { useAccessGroups?: boolean } = {}, -): OpenClawConfig { +function createSlashCommandSlackConfig(): OpenClawConfig { return { - ...(options.useAccessGroups === undefined - ? {} - : { commands: { useAccessGroups: options.useAccessGroups } }), channels: { slack: { enabled: true, @@ -60,15 +55,4 @@ describe("Slack security audit findings", () => { ); expect(slashAllowlistFinding?.severity).toBe("warn"); }); - - it("flags slash commands when access-group enforcement is disabled", async () => { - const findings = await collectSlackFindingsForConfig( - createSlashCommandSlackConfig({ useAccessGroups: false }), - ); - - const accessGroupFinding = findings.find( - ({ checkId }) => checkId === "channels.slack.commands.slash.useAccessGroups_off", - ); - expect(accessGroupFinding?.severity).toBe("critical"); - }); }); diff --git a/extensions/slack/src/security-audit.ts b/extensions/slack/src/security-audit.ts index e779be034b21..1eca590031fe 100644 --- a/extensions/slack/src/security-audit.ts +++ b/extensions/slack/src/security-audit.ts @@ -44,19 +44,6 @@ export async function collectSlackSecurityAuditFindings(params: { return findings; } - const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; - if (!useAccessGroups) { - findings.push({ - checkId: "channels.slack.commands.slash.useAccessGroups_off", - severity: "critical", - title: "Slack slash commands bypass access groups", - detail: - "Slack slash/native commands are enabled while commands.useAccessGroups=false; this can allow unrestricted /… command execution from channels/users you didn't explicitly authorize.", - remediation: "Set commands.useAccessGroups=true (recommended).", - }); - return findings; - } - const allowFromRaw = slackCfg.allowFrom; const legacyAllowFromRaw = (params.account as { dm?: { allowFrom?: unknown } }).dm?.allowFrom; const allowFrom = Array.isArray(allowFromRaw) diff --git a/extensions/slack/src/send.reconcile.test.ts b/extensions/slack/src/send.reconcile.test.ts index 95349783bec1..2e289ba683ab 100644 --- a/extensions/slack/src/send.reconcile.test.ts +++ b/extensions/slack/src/send.reconcile.test.ts @@ -307,7 +307,7 @@ describe("reconcileSlackUnknownSend", () => { const userIdentityCfg = { channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", }, }, diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 9d583a168585..300f83a856ce 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -53,7 +53,7 @@ function setSlackSetupIdentity(params: { cfg: params.cfg, channel, accountId: params.accountId, - patch: params.identity === "user" ? { identity: "user" } : {}, + patch: params.identity === "user" ? { postAs: "user" } : {}, }); if (params.identity === "user") { return next; @@ -67,7 +67,7 @@ function setSlackSetupIdentity(params: { } if (params.accountId === DEFAULT_ACCOUNT_ID) { const nextSlack = { ...slack }; - delete nextSlack.identity; + delete nextSlack.postAs; return { ...next, channels: { @@ -82,12 +82,12 @@ function setSlackSetupIdentity(params: { return next; } const nextAccount = { ...account }; - if (slack.identity === "user") { + if (slack.postAs === "user") { // Named accounts inherit the root identity, so an explicit bot value is // required only when overriding a user-identity channel default. - nextAccount.identity = "bot"; + nextAccount.postAs = "bot"; } else { - delete nextAccount.identity; + delete nextAccount.postAs; } return { ...next, @@ -220,7 +220,7 @@ const slackSetupAdapterBase = createPatchedAccountSetupAdapter({ return "Slack env tokens can only be used for the default account."; } const account = inspectSlackAccount({ cfg, accountId }); - const identity = input.identity ?? account.config.identity ?? "bot"; + const identity = input.identity ?? account.config.postAs ?? "bot"; const mode = input.mode ?? account.config.mode ?? "socket"; if (identity === "user" && mode === "relay") { return 'Slack user identity setup supports mode "socket" or "http", not "relay".'; @@ -241,7 +241,7 @@ const slackSetupAdapterBase = createPatchedAccountSetupAdapter({ return "Slack requires --bot-token and --app-token (or --use-env)."; }, buildPatch: (input) => ({ - ...(input.identity ? { identity: input.identity } : {}), + ...(input.identity ? { postAs: input.identity } : {}), ...(input.identity === "user" && input.mode ? { mode: input.mode } : {}), ...(input.botToken ? { botToken: input.botToken } : {}), ...(input.appToken ? { appToken: input.appToken } : {}), @@ -254,7 +254,7 @@ export const slackSetupAdapter: ChannelSetupAdapter = { ...slackSetupAdapterBase, singleAccountKeysToMove: ["appToken"], applyAccountConfig: ({ cfg, accountId, input }) => { - const identity = input.identity ?? inspectSlackAccount({ cfg, accountId }).config.identity; + const identity = input.identity ?? inspectSlackAccount({ cfg, accountId }).config.postAs; return slackSetupAdapterBase.applyAccountConfig({ cfg, accountId, @@ -294,7 +294,7 @@ export function createSlackSetupWizardBase(handlers: { const currentAccount = inspectSlackAccount({ cfg, accountId }); // Configured implicit-bot accounts historically skip this step. An // explicit user identity still needs the selector to return to bot. - if (currentAccount.configured && currentAccount.config.identity !== "user") { + if (currentAccount.configured && currentAccount.config.postAs !== "user") { return { cfg }; } const identity = await prompter.select<"bot" | "user">({ @@ -303,14 +303,14 @@ export function createSlackSetupWizardBase(handlers: { { value: "bot", label: "Slack bot", hint: "Post as the Slack app (default)" }, { value: "user", label: "Slack user", hint: "Post as the authorizing human" }, ], - initialValue: currentAccount.config.identity ?? "bot", + initialValue: currentAccount.config.postAs ?? "bot", }); const next = setSlackSetupIdentity({ cfg, accountId, identity, }); - if (currentAccount.configured && identity === currentAccount.config.identity) { + if (currentAccount.configured && identity === currentAccount.config.postAs) { return { cfg: next }; } if (identity === "user") { @@ -351,7 +351,7 @@ export function createSlackSetupWizardBase(handlers: { preferredEnvVar: "SLACK_BOT_TOKEN", isAvailable: ({ cfg, accountId }) => accountId === DEFAULT_ACCOUNT_ID && - (inspectSlackAccount({ cfg, accountId }).config.identity ?? "bot") === "bot" && + (inspectSlackAccount({ cfg, accountId }).config.postAs ?? "bot") === "bot" && Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && Boolean(process.env.SLACK_APP_TOKEN?.trim()) && !inspectSlackAccount({ cfg, accountId }).configured, @@ -366,7 +366,7 @@ export function createSlackSetupWizardBase(handlers: { keepPrompt: t("wizard.slack.botTokenKeep"), inputPrompt: t("wizard.slack.botTokenInput"), shouldPrompt: ({ cfg, accountId }) => - (inspectSlackAccount({ cfg, accountId }).config.identity ?? "bot") === "bot", + (inspectSlackAccount({ cfg, accountId }).config.postAs ?? "bot") === "bot", }), createSlackTokenCredential({ inputKey: "userToken", @@ -376,7 +376,7 @@ export function createSlackSetupWizardBase(handlers: { keepPrompt: "Slack user OAuth token already configured. Keep it?", inputPrompt: "Enter Slack user OAuth token", shouldPrompt: ({ cfg, accountId }) => - inspectSlackAccount({ cfg, accountId }).config.identity === "user", + inspectSlackAccount({ cfg, accountId }).config.postAs === "user", }), createSlackTokenCredential({ inputKey: "appToken", @@ -388,7 +388,7 @@ export function createSlackSetupWizardBase(handlers: { shouldPrompt: ({ cfg, accountId }) => { const account = inspectSlackAccount({ cfg, accountId }); return ( - (account.config.identity ?? "bot") === "bot" || + (account.config.postAs ?? "bot") === "bot" || (account.config.mode ?? "socket") === "socket" ); }, @@ -401,7 +401,7 @@ export function createSlackSetupWizardBase(handlers: { inputPrompt: "Enter Slack signing secret", shouldPrompt: ({ cfg, accountId }) => { const account = inspectSlackAccount({ cfg, accountId }); - return account.config.identity === "user" && account.config.mode === "http"; + return account.config.postAs === "user" && account.config.mode === "http"; }, }), ], diff --git a/extensions/slack/src/setup-surface.test.ts b/extensions/slack/src/setup-surface.test.ts index 7355288a631c..f46331f5c5a0 100644 --- a/extensions/slack/src/setup-surface.test.ts +++ b/extensions/slack/src/setup-surface.test.ts @@ -246,7 +246,7 @@ describe("slackSetupWizard.prepare", () => { expect(result.cfg.channels?.slack).toMatchObject({ enabled: true, - identity: "user", + postAs: "user", userToken: "test-user-token", appToken: "test-app-token", }); @@ -291,7 +291,7 @@ describe("slackSetupWizard.prepare", () => { expect(result.cfg.channels?.slack).toMatchObject({ enabled: true, - identity: "user", + postAs: "user", mode: "http", userToken: "test-user-token", signingSecret: "test-signing-secret", @@ -330,7 +330,7 @@ describe("slackSetupWizard.prepare", () => { channels: { slack: { accounts: { - work: { identity: "user", userToken: userTokenRef }, + work: { postAs: "user", userToken: userTokenRef }, }, }, }, @@ -340,7 +340,7 @@ describe("slackSetupWizard.prepare", () => { }); expect(result.cfg.channels?.slack?.accounts?.work).toMatchObject({ - identity: "user", + postAs: "user", userToken: userTokenRef, appToken: "test-app-token", }); @@ -350,7 +350,7 @@ describe("slackSetupWizard.prepare", () => { { name: "new setup", cfg: {} as OpenClawConfig }, { name: "switch from user identity", - cfg: { channels: { slack: { identity: "user" } } } as OpenClawConfig, + cfg: { channels: { slack: { postAs: "user" } } } as OpenClawConfig, }, ])("keeps bot identity implicit for $name", async ({ cfg }) => { vi.stubEnv("SLACK_BOT_TOKEN", ""); @@ -387,7 +387,7 @@ describe("slackSetupWizard.prepare", () => { expect(JSON.stringify(result.cfg.channels?.slack)).toBe( '{"enabled":true,"botToken":"test-bot-token","appToken":"test-app-token"}', ); - expect(result.cfg.channels?.slack).not.toHaveProperty("identity"); + expect(result.cfg.channels?.slack).not.toHaveProperty("postAs"); }); it("keeps a named bot override when the channel default is user identity", async () => { @@ -413,7 +413,7 @@ describe("slackSetupWizard.prepare", () => { cfg: { channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", appToken: "test-user-app-token", accounts: { @@ -426,10 +426,10 @@ describe("slackSetupWizard.prepare", () => { options: { secretInputMode: "plaintext" as const }, }); - expect(result.cfg.channels?.slack?.identity).toBe("user"); + expect(result.cfg.channels?.slack?.postAs).toBe("user"); expect(result.cfg.channels?.slack?.accounts?.work).toMatchObject({ enabled: true, - identity: "bot", + postAs: "bot", botToken: "test-bot-token", appToken: "test-app-token", }); @@ -443,7 +443,7 @@ describe("slackSetupWizard.prepare", () => { cfg: { channels: { slack: { - identity: "user", + postAs: "user", userToken: "test-user-token", appToken: "test-app-token", }, @@ -458,7 +458,7 @@ describe("slackSetupWizard.prepare", () => { userToken: "test-user-token", appToken: "test-app-token", }); - expect(result?.cfg.channels?.slack).not.toHaveProperty("identity"); + expect(result?.cfg.channels?.slack).not.toHaveProperty("postAs"); }); it("does not print the manifest after Slack credentials are configured", async () => { @@ -539,7 +539,7 @@ describe("slackSetupWizard.status", () => { { name: "Socket Mode", slack: { - identity: "user" as const, + postAs: "user" as const, userToken: "test-user-token", appToken: "test-app-token", }, @@ -547,7 +547,7 @@ describe("slackSetupWizard.status", () => { { name: "HTTP mode", slack: { - identity: "user" as const, + postAs: "user" as const, mode: "http" as const, userToken: "test-user-token", signingSecret: "test-signing-secret", diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index 2baf2ac19e58..fce9ff70b4ff 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -31,7 +31,7 @@ function resolveSlackSetupAuth( account: InspectedSlackAccount, credentialValues: SlackSetupCredentialValues, ): string | undefined { - if (account.config.identity === "user") { + if (account.config.postAs === "user") { return credentialValues.userToken || account.userToken; } return credentialValues.botToken || account.botToken; diff --git a/extensions/sms/src/inbound.test.ts b/extensions/sms/src/inbound.test.ts index 25cf4bee794d..c2628dc27fb7 100644 --- a/extensions/sms/src/inbound.test.ts +++ b/extensions/sms/src/inbound.test.ts @@ -128,7 +128,7 @@ async function resolveAuthorizedSmsTurn(params: { accountSid: "AC123", }; await dispatchSmsInboundEvent({ - cfg: { commands: { useAccessGroups: true } }, + cfg: {}, account: createAccount({ dmPolicy: "allowlist", allowFrom: [SMS_FROM] }), channelRuntime: mocks.runtime, receivedAt: params.receivedAt ?? 1_700_000_000_123, @@ -220,18 +220,8 @@ describe("dispatchSmsInboundEvent", () => { isTextCommand: true, }); - expect(shouldComputeCommandAuthorized).toHaveBeenCalledWith( - "/status", - expect.objectContaining({ - commands: expect.objectContaining({ useAccessGroups: true }), - }), - ); - expect(isControlCommandMessage).toHaveBeenCalledWith( - "/status", - expect.objectContaining({ - commands: expect.objectContaining({ useAccessGroups: true }), - }), - ); + expect(shouldComputeCommandAuthorized).toHaveBeenCalledWith("/status", {}); + expect(isControlCommandMessage).toHaveBeenCalledWith("/status", {}); expect(buildContext).toHaveBeenCalledWith( expect.objectContaining({ @@ -266,18 +256,8 @@ describe("dispatchSmsInboundEvent", () => { isTextCommand: false, }); - expect(shouldComputeCommandAuthorized).toHaveBeenCalledWith( - "please inspect /tmp/foo", - expect.objectContaining({ - commands: expect.objectContaining({ useAccessGroups: true }), - }), - ); - expect(isControlCommandMessage).toHaveBeenCalledWith( - "please inspect /tmp/foo", - expect.objectContaining({ - commands: expect.objectContaining({ useAccessGroups: true }), - }), - ); + expect(shouldComputeCommandAuthorized).toHaveBeenCalledWith("please inspect /tmp/foo", {}); + expect(isControlCommandMessage).toHaveBeenCalledWith("please inspect /tmp/foo", {}); expect(buildContext).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/extensions/telegram/src/bot-message-context.body.test.ts b/extensions/telegram/src/bot-message-context.body.test.ts index a104a3b4baf5..627fc869a0ec 100644 --- a/extensions/telegram/src/bot-message-context.body.test.ts +++ b/extensions/telegram/src/bot-message-context.body.test.ts @@ -669,14 +669,13 @@ describe("resolveTelegramInboundBody", () => { expect(result).toBeNull(); }); - it("still transcribes when commands.useAccessGroups is false", async () => { + it("transcribes when the group sender is authorized", async () => { transcribeFirstAudioMock.mockReset(); transcribeFirstAudioMock.mockResolvedValueOnce("hey bot please help"); const result = await resolveTelegramBody({ cfg: { channels: { telegram: {} }, - commands: { useAccessGroups: false }, messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } }, tools: { media: { audio: { enabled: true } } }, } as never, @@ -694,7 +693,7 @@ describe("resolveTelegramInboundBody", () => { senderId: "46", senderUsername: "", routeAgentId: undefined, - effectiveGroupAllow: normalizeAllowFrom(["999"]), + effectiveGroupAllow: normalizeAllowFrom(["46"]), effectiveDmAllow: normalizeAllowFrom([]), groupConfig: { requireMention: true } as never, requireMention: true, @@ -776,7 +775,6 @@ describe("resolveTelegramInboundBody", () => { await resolveTelegramBody({ cfg: { channels: { telegram: {} }, - commands: { useAccessGroups: false }, messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } }, tools: { media: { audio: { enabled: true, echoTranscript: true } } }, } as never, @@ -794,6 +792,7 @@ describe("resolveTelegramInboundBody", () => { isGroup: true, chatId: -1001234567890, senderId: "46", + effectiveGroupAllow: normalizeAllowFrom(["46"]), groupConfig: { requireMention: true } as never, requireMention: true, resolvedThreadId: 99, @@ -862,7 +861,6 @@ describe("resolveTelegramInboundBody", () => { const result = await resolveTelegramBody({ cfg: { channels: { telegram: {} }, - commands: { useAccessGroups: false }, messages: { groupChat: { mentionPatterns: ["\\bbot\\b"] } }, tools: { media: { audio: { enabled: true } } }, } as never, @@ -879,7 +877,7 @@ describe("resolveTelegramInboundBody", () => { chatId: -1001234567892, senderId: "46", senderUsername: "", - effectiveGroupAllow: normalizeAllowFrom(["999"]), + effectiveGroupAllow: normalizeAllowFrom(["46"]), groupConfig: { requireMention: true } as never, requireMention: true, }); diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts index f86ac4bc0644..79bd2b09c6a3 100644 --- a/extensions/telegram/src/bot-message-context.body.ts +++ b/extensions/telegram/src/bot-message-context.body.ts @@ -192,7 +192,7 @@ export async function resolveTelegramInboundBody(params: { }); const messageTextParts = getTelegramTextParts(msg); const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow; - const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const useAccessGroups = true; const hasControlCommandInMessage = hasControlCommand(messageTextParts.text, cfg, { botUsername, }); diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index e041db42ef84..f7de5bd08cd6 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -538,7 +538,7 @@ export const buildTelegramMessageContext = async ({ }); const ackReactionEmoji = ackReaction && isTelegramSupportedReactionEmoji(ackReaction) ? ackReaction : undefined; - const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; + const removeAckAfterReply = false; const shouldSendAckReaction = Boolean( ackReaction && shouldAckReactionGate({ @@ -562,7 +562,7 @@ export const buildTelegramMessageContext = async ({ const resolvedStatusReactionEmojis = statusReactionsEnabled ? resolveTelegramStatusReactionEmojis({ initialEmoji: ackReaction, - overrides: statusReactionsConfig?.emojis, + overrides: undefined, }) : null; const statusReactionVariantsByEmoji = resolvedStatusReactionEmojis 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 19c7579b0d02..ac78dd43354b 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -293,7 +293,6 @@ function registerAndResolveStatusHandler(params: { allowFrom: allowFrom ?? ["*"], groupAllowFrom: groupAllowFrom ?? [], storeAllowFrom, - useAccessGroups: true, telegramCfg, resolveTelegramGroupConfig, }); @@ -306,7 +305,6 @@ function registerAndResolveCommandHandlerBase(params: { allowFrom: string[]; groupAllowFrom: string[]; storeAllowFrom?: string[]; - useAccessGroups: boolean; telegramCfg?: NativeCommandTestParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; pluginCommandSpecs?: TelegramPluginCommandSpecs; @@ -322,7 +320,6 @@ function registerAndResolveCommandHandlerBase(params: { allowFrom, groupAllowFrom, storeAllowFrom, - useAccessGroups, telegramCfg, resolveTelegramGroupConfig, pluginCommandSpecs, @@ -331,13 +328,7 @@ function registerAndResolveCommandHandlerBase(params: { const commandHandlers = new Map(); const sendMessage = vi.fn().mockResolvedValue(undefined); const baseRuntimeCfg = runtimeCfg ?? cfg; - const commandRuntimeCfg = - (baseRuntimeCfg.commands?.useAccessGroups !== false) === useAccessGroups - ? baseRuntimeCfg - : { - ...baseRuntimeCfg, - commands: { ...baseRuntimeCfg.commands, useAccessGroups }, - }; + const commandRuntimeCfg = baseRuntimeCfg; const telegramDeps: TelegramNativeCommandDeps = { getRuntimeConfig: vi.fn(() => commandRuntimeCfg), readChannelAllowFromStore: vi.fn(async () => storeAllowFrom ?? []), @@ -380,7 +371,6 @@ function registerAndResolveCommandHandler(params: { allowFrom?: string[]; groupAllowFrom?: string[]; storeAllowFrom?: string[]; - useAccessGroups?: boolean; telegramCfg?: NativeCommandTestParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; pluginCommandSpecs?: TelegramPluginCommandSpecs; @@ -395,7 +385,6 @@ function registerAndResolveCommandHandler(params: { allowFrom, groupAllowFrom, storeAllowFrom, - useAccessGroups, telegramCfg, resolveTelegramGroupConfig, pluginCommandSpecs, @@ -407,7 +396,6 @@ function registerAndResolveCommandHandler(params: { allowFrom: allowFrom ?? [], groupAllowFrom: groupAllowFrom ?? [], storeAllowFrom, - useAccessGroups: useAccessGroups ?? true, telegramCfg, resolveTelegramGroupConfig, pluginCommandSpecs, @@ -1499,7 +1487,6 @@ describe("registerTelegramNativeCommands — session metadata", () => { cfg: {}, allowFrom: ["200"], groupAllowFrom: ["200"], - useAccessGroups: true, }); await handler(createTelegramTopicCommandContext()); @@ -1583,7 +1570,6 @@ describe("registerTelegramNativeCommands — session metadata", () => { cfg: {}, allowFrom: [], groupAllowFrom: [], - useAccessGroups: true, }); await handler(createTelegramTopicCommandContext()); @@ -1600,7 +1586,6 @@ describe("registerTelegramNativeCommands — session metadata", () => { cfg: {}, allowFrom: [], groupAllowFrom: [], - useAccessGroups: true, }); await handler(createTelegramTopicCommandContext()); @@ -1626,7 +1611,6 @@ describe("registerTelegramNativeCommands — session metadata", () => { commandName: "codex", cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, groupAllowFrom: ["-1001234567890"], - useAccessGroups: false, pluginCommandSpecs: [ { name: "codex", @@ -1907,7 +1891,6 @@ describe("registerTelegramNativeCommands — session metadata", () => { const { handler } = registerAndResolveCommandHandler({ commandName: "codex", cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - useAccessGroups: false, pluginCommandSpecs: [ { name: "codex", @@ -1954,7 +1937,6 @@ describe("registerTelegramNativeCommands — session metadata", () => { const { handler } = registerAndResolveCommandHandler({ commandName: "codex", cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - useAccessGroups: false, pluginCommandSpecs: [ { name: "codex", @@ -2001,7 +1983,6 @@ describe("registerTelegramNativeCommands — session metadata", () => { const { handler } = registerAndResolveCommandHandler({ commandName: "codex", cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - useAccessGroups: false, pluginCommandSpecs: [ { name: "codex", @@ -2041,7 +2022,6 @@ describe("registerTelegramNativeCommands — session metadata", () => { const { handler } = registerAndResolveCommandHandler({ commandName: "codex", cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, - useAccessGroups: false, pluginCommandSpecs: [ { name: "codex", diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 9d56c7da1b90..a379b21d4d1c 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -812,12 +812,12 @@ async function resolveTelegramCommandAuth(params: { senderId, senderUsername, resolveGroupPolicy, - enforcePolicy: cfg.commands?.useAccessGroups !== false, + enforcePolicy: true, useTopicAndGroupOverrides: false, enforceAllowlistAuthorization: requireAuth && !commandsAllowFromConfigured, allowEmptyAllowlistEntries: true, requireSenderForAllowlistAuthorization: true, - checkChatAllowlist: cfg.commands?.useAccessGroups !== false, + checkChatAllowlist: true, }); if (!policyAccess.allowed) { if (policyAccess.reason === "group-policy-disabled") { diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index 4f8c7bc44644..81f4c58cf634 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -541,7 +541,7 @@ export const getOnHandler = (event: string) => { const DEFAULT_TELEGRAM_TEST_CONFIG: OpenClawConfig = { agents: { defaults: { - envelopeTimezone: "utc", + userTimezone: "UTC", }, }, channels: { diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 6e578f6cd8f7..4553981d9390 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -892,7 +892,7 @@ describe("createTelegramBot", () => { loadConfig.mockReturnValue({ agents: { defaults: { - envelopeTimezone: "utc", + userTimezone: "UTC", }, }, messages: { @@ -1013,7 +1013,7 @@ describe("createTelegramBot", () => { loadConfig.mockReturnValue({ agents: { defaults: { - envelopeTimezone: "utc", + userTimezone: "UTC", }, }, messages: { @@ -4553,7 +4553,7 @@ describe("createTelegramBot", () => { loadConfig.mockReturnValue({ agents: { defaults: { - envelopeTimezone: "utc", + userTimezone: "UTC", }, }, identity: { name: "Bert" }, @@ -4621,7 +4621,7 @@ describe("createTelegramBot", () => { loadConfig.mockReturnValue({ agents: { defaults: { - envelopeTimezone: "utc", + userTimezone: "UTC", }, }, channels: { @@ -5182,9 +5182,8 @@ describe("createTelegramBot", () => { replySpy.mockResolvedValue({ text: "final reply" }); loadConfig.mockReturnValue({ channels: { - telegram: { dmPolicy: "open", allowFrom: ["*"] }, + telegram: { dmPolicy: "open", allowFrom: ["*"], responsePrefix: "PFX" }, }, - messages: { responsePrefix: "PFX" }, }); createTelegramBot({ token: "tok" }); diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index a8923b7fa060..2ef4deda2960 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -480,7 +480,7 @@ describe("createTelegramBot", () => { loadConfig.mockReturnValue({ agents: { defaults: { - envelopeTimezone: "utc", + userTimezone: "UTC", }, }, channels: { @@ -2890,7 +2890,7 @@ describe("createTelegramBot", () => { loadConfig.mockReturnValue({ agents: { defaults: { - envelopeTimezone: "utc", + userTimezone: "UTC", }, }, channels: { diff --git a/extensions/telegram/src/target-writeback.ts b/extensions/telegram/src/target-writeback.ts index 2667d9172e0f..3a2ebe1b911c 100644 --- a/extensions/telegram/src/target-writeback.ts +++ b/extensions/telegram/src/target-writeback.ts @@ -198,7 +198,7 @@ export async function maybePersistResolvedTelegramTarget(params: { } try { - const storePath = resolveCronStorePath(params.cfg.cron?.store); + const storePath = resolveCronStorePath(); const store = await loadCronStore(storePath); let cronChanged = false; for (const job of store.jobs) { diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 251b8a2907f5..d0025907692a 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -480,7 +480,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { }); it("passes fullConfig to the webhook server for streaming provider resolution", async () => { - const coreConfig = { messages: { tts: { provider: "openai" } } } as CoreConfig; + const coreConfig = { tts: { provider: "openai" } } as CoreConfig; const fullConfig = { plugins: { entries: { diff --git a/extensions/voice-call/src/telephony-tts.test.ts b/extensions/voice-call/src/telephony-tts.test.ts index c87777022d0b..5984c7fe2e84 100644 --- a/extensions/voice-call/src/telephony-tts.test.ts +++ b/extensions/voice-call/src/telephony-tts.test.ts @@ -15,7 +15,7 @@ function createCoreConfig(): OpenClawConfig { }, }, }; - return { messages: { tts } }; + return { tts }; } const passthroughPreparation: TelephonyTtsRuntime["prepareTtsRequest"] = async ({ cfg, text }) => ({ @@ -38,7 +38,7 @@ function createRuntime( describe("createTelephonyTtsProvider", () => { it("uses shared preparation for the surface override and request text", async () => { const effectiveConfig: OpenClawConfig = { - messages: { tts: { provider: "openai", timeoutMs: 15_000 } }, + tts: { provider: "openai", timeoutMs: 15_000 }, }; const prepareTtsRequest = vi.fn( async ({ cfg, override, text }) => ({ @@ -163,7 +163,7 @@ describe("createTelephonyTtsProvider", () => { it("exposes configured timeoutMs as synthesisTimeoutMs", async () => { const provider = await createTelephonyTtsProvider({ - coreConfig: { messages: { tts: { provider: "openai", timeoutMs: 15000 } } }, + coreConfig: { tts: { provider: "openai", timeoutMs: 15000 } }, runtime: createRuntime(async () => ({ success: true, audioBuffer: Buffer.alloc(2), @@ -177,7 +177,7 @@ describe("createTelephonyTtsProvider", () => { it("clamps oversized configured timeoutMs", async () => { const provider = await createTelephonyTtsProvider({ coreConfig: { - messages: { tts: { provider: "openai", timeoutMs: Number.MAX_SAFE_INTEGER } }, + tts: { provider: "openai", timeoutMs: Number.MAX_SAFE_INTEGER }, }, runtime: createRuntime(async () => ({ success: true, diff --git a/extensions/voice-call/src/telephony-tts.ts b/extensions/voice-call/src/telephony-tts.ts index 7ae34245739b..f90b493cf9bb 100644 --- a/extensions/voice-call/src/telephony-tts.ts +++ b/extensions/voice-call/src/telephony-tts.ts @@ -58,7 +58,7 @@ export async function createTelephonyTtsProvider(params: { text: "", }); const synthesisTimeoutMs = resolveTimerTimeoutMs( - preparedConfig.cfg.messages?.tts?.timeoutMs, + preparedConfig.cfg.tts?.timeoutMs, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS, ); diff --git a/extensions/volcengine/speech-provider.ts b/extensions/volcengine/speech-provider.ts index cb023b27bfd1..6b5ad51f8047 100644 --- a/extensions/volcengine/speech-provider.ts +++ b/extensions/volcengine/speech-provider.ts @@ -64,12 +64,12 @@ function normalizeVolcengineProviderConfig( return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, - path: "messages.tts.providers.volcengine.apiKey", + path: "tts.providers.volcengine.apiKey", }), appId: trimToUndefined(raw?.appId), token: normalizeResolvedSecretInputString({ value: raw?.token, - path: "messages.tts.providers.volcengine.token", + path: "tts.providers.volcengine.token", }), voice: trimToUndefined(raw?.voice) ?? @@ -117,7 +117,7 @@ function readProviderConfig(config: SpeechProviderConfig): VolcengineTtsProvider apiKey: normalizeResolvedSecretInputString({ value: config.apiKey, - path: "messages.tts.providers.volcengine.apiKey", + path: "tts.providers.volcengine.apiKey", }) ?? normalized.apiKey, appId: trimToUndefined(config.appId) ?? normalized.appId, token: trimToUndefined(config.token) ?? normalized.token, diff --git a/extensions/vydra/onboard.ts b/extensions/vydra/onboard.ts index e8800dc934f7..b9b910871173 100644 --- a/extensions/vydra/onboard.ts +++ b/extensions/vydra/onboard.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard"; export const VYDRA_DEFAULT_IMAGE_MODEL_REF = "vydra/grok-imagine"; export function applyVydraConfig(cfg: OpenClawConfig): OpenClawConfig { - if (cfg.agents?.defaults?.imageGenerationModel) { + if (cfg.agents?.defaults?.mediaModels?.image) { return cfg; } return { @@ -13,8 +13,9 @@ export function applyVydraConfig(cfg: OpenClawConfig): OpenClawConfig { ...cfg.agents, defaults: { ...cfg.agents?.defaults, - imageGenerationModel: { - primary: VYDRA_DEFAULT_IMAGE_MODEL_REF, + mediaModels: { + ...cfg.agents?.defaults?.mediaModels, + image: { primary: VYDRA_DEFAULT_IMAGE_MODEL_REF }, }, }, }, diff --git a/extensions/vydra/speech-provider.ts b/extensions/vydra/speech-provider.ts index 173c6296ac65..bc24b856d0ad 100644 --- a/extensions/vydra/speech-provider.ts +++ b/extensions/vydra/speech-provider.ts @@ -43,7 +43,7 @@ function normalizeVydraSpeechConfig(rawConfig: Record): VydraSp return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, - path: "messages.tts.providers.vydra.apiKey", + path: "tts.providers.vydra.apiKey", }), baseUrl: normalizeVydraBaseUrl( trimToUndefined(raw?.baseUrl) ?? trimToUndefined(process.env.VYDRA_BASE_URL), diff --git a/extensions/whatsapp/index.test.ts b/extensions/whatsapp/index.test.ts index b56b22e3b17f..19ff908372dc 100644 --- a/extensions/whatsapp/index.test.ts +++ b/extensions/whatsapp/index.test.ts @@ -15,7 +15,11 @@ describe("whatsapp bundled entries", () => { it("declares account config as channel-restart reload metadata", () => { expect(whatsappPlugin.reload).toEqual({ - configPrefixes: ["web", "channels.whatsapp.accounts", "channels.whatsapp.selfChatMode"], + configPrefixes: [ + "channels.whatsapp.enabled", + "channels.whatsapp.accounts", + "channels.whatsapp.selfChatMode", + ], noopPrefixes: ["channels.whatsapp"], }); }); diff --git a/extensions/whatsapp/src/accounts.test.ts b/extensions/whatsapp/src/accounts.test.ts index a79dfa1a5f90..ffaa2b5e2970 100644 --- a/extensions/whatsapp/src/accounts.test.ts +++ b/extensions/whatsapp/src/accounts.test.ts @@ -74,7 +74,7 @@ describe("resolveWhatsAppAuthDir", () => { channels: { whatsapp: { sendReadReceipts: false, - messagePrefix: "[root]", + responsePrefix: "[root]", debounceMs: 100, accounts: { work: { diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index 418f018956ed..fd2d677a5093 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -133,7 +133,7 @@ export function resolveWhatsAppAccount(params: { name: normalizeOptionalString(merged.name), enabled, sendReadReceipts: merged.sendReadReceipts ?? true, - messagePrefix: merged.messagePrefix, + messagePrefix: merged.responsePrefix, defaultTo: merged.defaultTo, authDir, isLegacyAuthDir: isLegacy, diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index 1314104b56c0..f664e5ff6f9c 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -87,8 +87,7 @@ function resolveWebMonitorConfigSnapshot(params: { ...params.cfg.channels, whatsapp: { ...params.cfg.channels?.whatsapp, - ackReaction: account.ackReaction, - messagePrefix: account.messagePrefix, + responsePrefix: account.messagePrefix, allowFrom: account.allowFrom, groupAllowFrom: account.groupAllowFrom, groupPolicy: account.groupPolicy, diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts index b30e57465dee..69bc3e7dd3cb 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.test.ts @@ -45,7 +45,7 @@ describe("resolveWhatsAppAckEmoji", () => { ).toBe("👀"); }); - it("keeps an explicit empty emoji disabled", () => { + it("falls back to the routed agent identity for an empty emoji", () => { const cfg = createConfig({ emoji: " ", direct: true, group: "mentions" }); expect( @@ -54,7 +54,7 @@ describe("resolveWhatsAppAckEmoji", () => { agentId: "agent", ackConfig: cfg.channels?.whatsapp?.ackReaction, }), - ).toBe(""); + ).toBe("🔥"); }); it("falls back to the routed agent identity emoji when the ack object has no emoji", () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts index e9eaa238eebd..5b1c08d149e7 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-emoji.ts @@ -4,22 +4,21 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; const DEFAULT_WHATSAPP_ACK_REACTION = "👀"; -type WhatsAppAckReactionConfig = NonNullable< - NonNullable["whatsapp"]>["ackReaction"] ->; - export function resolveWhatsAppAckEmoji(params: { cfg: OpenClawConfig; agentId: string; - ackConfig: WhatsAppAckReactionConfig | undefined; + ackConfig: string | { emoji?: string } | undefined; }): string { if (!params.ackConfig) { return ""; } - if (params.ackConfig.emoji !== undefined) { - return params.ackConfig.emoji.trim(); - } - return resolveAgentIdentityEmoji(params.cfg, params.agentId) ?? DEFAULT_WHATSAPP_ACK_REACTION; + const configured = + typeof params.ackConfig === "string" ? params.ackConfig : params.ackConfig.emoji; + return ( + configured?.trim() || + resolveAgentIdentityEmoji(params.cfg, params.agentId) || + DEFAULT_WHATSAPP_ACK_REACTION + ); } function resolveAgentIdentityEmoji(cfg: OpenClawConfig, agentId: string): string | undefined { diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts index 3d1ede01c369..5d1635644a09 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts @@ -45,14 +45,10 @@ function createConfig( extras?: Partial["whatsapp"]>, ): OpenClawConfig { return { + messages: { ackReaction: "👀", ackReactionScope: "all" }, channels: { whatsapp: { reactionLevel, - ackReaction: { - emoji: "👀", - direct: true, - group: "mentions", - }, ...extras, }, }, @@ -137,18 +133,15 @@ describe("maybeSendAckReaction", () => { expectAckReactionSent("work", cfg); }); - it("uses the agent identity emoji when WhatsApp ackReaction has no emoji", async () => { + it("uses the canonical emoji preserved from agent identity", async () => { const cfg = { agents: { - list: [{ id: "agent", identity: { emoji: "🔥" } }], + entries: { agent: { identity: { emoji: "🔥" } } }, }, + messages: { ackReaction: "🔥", ackReactionScope: "all" }, channels: { whatsapp: { reactionLevel: "ack", - ackReaction: { - direct: true, - group: "mentions", - }, }, }, } as OpenClawConfig; @@ -190,13 +183,7 @@ describe("maybeSendAckReaction", () => { }); it("uses the sender LID as the group reaction participant when no sender JID is available", async () => { - const cfg = createConfig("ack", { - ackReaction: { - emoji: "👀", - direct: true, - group: "always", - }, - }); + const cfg = createConfig("ack"); const ackReaction = await runAckReaction({ cfg, msg: createMessage({ diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts index 2519e4ef2fd3..7405ed9ba3a6 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts @@ -40,14 +40,19 @@ export async function maybeSendAckReaction(params: { return null; } - const ackConfig = params.cfg.channels?.whatsapp?.ackReaction; + const ackConfig = params.cfg.messages?.ackReaction; + const scope = params.cfg.messages?.ackReactionScope ?? "group-mentions"; + if (scope === "off" || scope === "none") { + return null; + } const emoji = resolveWhatsAppAckEmoji({ cfg: params.cfg, agentId: params.agentId, ackConfig, }); - const directEnabled = ackConfig?.direct ?? true; - const groupMode = ackConfig?.group ?? "mentions"; + const directEnabled = scope === "all" || scope === "direct"; + const groupMode = + scope === "all" || scope === "group-all" ? "always" : scope === "direct" ? "never" : "mentions"; const isGroup = admission.conversation.kind === "group"; const conversationIdForCheck = admission.conversation.id; diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts index 604b7b55621b..5928d8847a92 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts @@ -680,6 +680,16 @@ describe("whatsapp inbound dispatch", () => { expect(responsePrefix).toBeUndefined(); }); + it("retains the global response prefix when the shared pipeline has no value", async () => { + const responsePrefix = resolveWhatsAppResponsePrefix({ + cfg: { messages: { responsePrefix: "[legacy]" } } as never, + agentId: "main", + isSelfChat: false, + }); + + expect(responsePrefix).toBe("[legacy]"); + }); + it("clears pending group history when the dispatcher does not queue a final reply", async () => { const groupHistories = new Map>([ ["whatsapp:default:group:123@g.us", [{ sender: "Alice (+111)", body: "first" }]], diff --git a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts index 4f30d729f16d..effca2ea1955 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts @@ -290,9 +290,10 @@ export function resolveWhatsAppResponsePrefix(params: { const configuredResponsePrefix = params.cfg.messages?.responsePrefix; return ( params.pipelineResponsePrefix ?? - (configuredResponsePrefix === undefined && params.isSelfChat + (configuredResponsePrefix === "auto" ? resolveIdentityNamePrefix(params.cfg, params.agentId) - : undefined) + : configuredResponsePrefix) ?? + (params.isSelfChat ? resolveIdentityNamePrefix(params.cfg, params.agentId) : undefined) ); } @@ -538,7 +539,7 @@ export function createWhatsAppReplyPlan(params: { const conversationKind = admission.conversation.kind; const statusReactionController = params.statusReactionController ?? null; const statusReactionTiming = DEFAULT_TIMING; - const removeAckAfterReply = params.cfg.messages?.removeAckAfterReply ?? false; + const removeAckAfterReply = false; const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp"); const chunkMode = resolveChunkMode(params.cfg, "whatsapp", params.route.accountId); const tableMode = resolveMarkdownTableMode({ diff --git a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts index 2720ded7b3b8..562b3697eefe 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts @@ -36,9 +36,9 @@ export function buildInboundLine(params: { visibleReplyTo?: WhatsAppReplyContext | null; }) { const { cfg, msg, agentId, previousTimestamp, envelope } = params; - // WhatsApp inbound prefix: channels.whatsapp.messagePrefix > identity/defaults. + // WhatsApp inbound prefix: channels.whatsapp.responsePrefix > identity/defaults. const messagePrefix = resolveMessagePrefix(cfg, agentId, { - configured: cfg.channels?.whatsapp?.messagePrefix, + configured: cfg.channels?.whatsapp?.responsePrefix, hasAllowFrom: (cfg.channels?.whatsapp?.allowFrom?.length ?? 0) > 0, }); const admission = requireWhatsAppInboundAdmission(msg); diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts index d4bedbe078e5..7b71dc76b131 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.audio-preflight.test.ts @@ -221,7 +221,6 @@ function makeRemoveAckAfterReplyParams() { tools: { media: { audio: { enabled: true } } }, channels: { whatsapp: {} }, commands: { useAccessGroups: false }, - messages: { removeAckAfterReply: true }, } as never, preflightAudioTranscript: "pre-computed transcript from caller", }; @@ -295,7 +294,7 @@ describe("processMessage audio preflight transcription", () => { MediaTranscribedIndexes: [0], }); // mediaPath and mediaType must be preserved so inboundAudio detection (used by - // features like messages.tts.auto: "inbound") still recognises this as audio. + // features like tts.auto: "inbound") still recognises this as audio. expectContextFields(context, { MediaPath: "/tmp/voice.ogg", MediaType: "audio/ogg; codecs=opus", @@ -407,7 +406,7 @@ describe("processMessage audio preflight transcription", () => { expect(maybeSendAckReactionMock).not.toHaveBeenCalled(); }); - it("removes caller-provided ack after a successful visible reply", async () => { + it("keeps caller-provided ack after a successful visible reply", async () => { const ackReaction = makeAckReactionHandle(); await processMessage({ @@ -416,10 +415,10 @@ describe("processMessage audio preflight transcription", () => { }); await flushMicrotasks(); - expect(ackReaction.remove).toHaveBeenCalledTimes(1); + expect(ackReaction.remove).not.toHaveBeenCalled(); }); - it("removes internally sent ack after a successful visible reply", async () => { + it("keeps internally sent ack after a successful visible reply", async () => { const ackReaction = makeAckReactionHandle(); maybeSendAckReactionMock.mockResolvedValueOnce(ackReaction); @@ -427,7 +426,7 @@ describe("processMessage audio preflight transcription", () => { await flushMicrotasks(); expect(maybeSendAckReactionMock).toHaveBeenCalledTimes(1); - expect(ackReaction.remove).toHaveBeenCalledTimes(1); + expect(ackReaction.remove).not.toHaveBeenCalled(); }); it("keeps ack when no visible reply was delivered", async () => { diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index 218ff9349486..1f065bb35697 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -293,7 +293,7 @@ export async function processMessage(params: { // If we have a transcript, replace the agent-facing body so the agent sees the spoken text. // mediaPath and mediaType are intentionally preserved so that inboundAudio detection - // (used by features such as messages.tts.auto: "inbound") still sees this as an + // (used by features such as tts.auto: "inbound") still sees this as an // audio message. The transcript and transcribed media index are also stored on // context so downstream media understanding does not transcribe it again. const msgForAgent: AdmittedWebInboundMessage = @@ -609,7 +609,7 @@ export async function processMessage(params: { ? (finalizeReply?.(turnResult.dispatchResult) ?? false) : false; removeAckReactionHandleAfterReply({ - removeAfterReply: Boolean(params.cfg.messages?.removeAckAfterReply && didSendReply), + removeAfterReply: false, ackReaction, onError: (err) => { logAckFailure({ diff --git a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts index 2902a9b1f49d..d3c8fb86c19d 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.test.ts @@ -48,6 +48,8 @@ describe("createWhatsAppStatusReactionController", () => { it("uses the sender LID as the group reaction participant when no sender JID is available", async () => { const cfg = { messages: { + ackReaction: "👀", + ackReactionScope: "all", statusReactions: { enabled: true, }, @@ -55,11 +57,6 @@ describe("createWhatsAppStatusReactionController", () => { channels: { whatsapp: { reactionLevel: "ack", - ackReaction: { - emoji: "👀", - direct: true, - group: "always", - }, }, }, } as OpenClawConfig; @@ -121,12 +118,14 @@ describe("createWhatsAppStatusReactionController", () => { ); }); - it("uses the agent identity emoji when WhatsApp ackReaction has no emoji", async () => { + it("uses the canonical emoji preserved from agent identity", async () => { const cfg = { agents: { - list: [{ id: "agent", identity: { emoji: "🔥" } }], + entries: { agent: { identity: { emoji: "🔥" } } }, }, messages: { + ackReaction: "🔥", + ackReactionScope: "all", statusReactions: { enabled: true, }, @@ -134,10 +133,6 @@ describe("createWhatsAppStatusReactionController", () => { channels: { whatsapp: { reactionLevel: "ack", - ackReaction: { - direct: true, - group: "mentions", - }, }, }, } as OpenClawConfig; @@ -170,6 +165,8 @@ describe("createWhatsAppStatusReactionController", () => { it("uses the active account reactionLevel override from admission", async () => { const cfg = { messages: { + ackReaction: "👀", + ackReactionScope: "all", statusReactions: { enabled: true, }, @@ -177,11 +174,6 @@ describe("createWhatsAppStatusReactionController", () => { channels: { whatsapp: { reactionLevel: "off", - ackReaction: { - emoji: "👀", - direct: true, - group: "mentions", - }, accounts: { work: { reactionLevel: "ack", diff --git a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts index 120a0c4797e1..5c47cf711791 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/status-reaction.ts @@ -46,7 +46,11 @@ export async function createWhatsAppStatusReactionController( return null; } - const ackConfig = params.cfg.channels?.whatsapp?.ackReaction; + const ackConfig = params.cfg.messages?.ackReaction; + const scope = params.cfg.messages?.ackReactionScope ?? "group-mentions"; + if (scope === "off" || scope === "none") { + return null; + } const ackEmoji = resolveWhatsAppAckEmoji({ cfg: params.cfg, agentId: params.agentId, @@ -55,8 +59,9 @@ export async function createWhatsAppStatusReactionController( if (!ackEmoji) { return null; } - const directEnabled = ackConfig?.direct ?? true; - const groupMode = ackConfig?.group ?? "mentions"; + const directEnabled = scope === "all" || scope === "direct"; + const groupMode = + scope === "all" || scope === "group-all" ? "always" : scope === "direct" ? "never" : "mentions"; const isGroup = admission.conversation.kind === "group"; const conversationIdForCheck = admission.conversation.id; @@ -106,7 +111,7 @@ export async function createWhatsAppStatusReactionController( }, }, initialEmoji: ackEmoji, - emojis: statusReactionsConfig.emojis, + emojis: undefined, onError: (err) => { logVerbose(`WhatsApp status-reaction error for chat ${chatId}/${msgId}: ${String(err)}`); }, diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts index 7a24c7d1ef00..f8d3e8cfc739 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts @@ -201,10 +201,10 @@ function makeOwnerGroupConfig() { }); } -function makeInboundCfg(messagePrefix = "") { +function makeInboundCfg(responsePrefix = "") { return { agents: { defaults: { workspace: "/tmp/openclaw" } }, - channels: { whatsapp: { messagePrefix } }, + channels: { whatsapp: { responsePrefix } }, } as never; } @@ -811,7 +811,7 @@ describe("buildInboundLine", () => { expect(line).toContain("[/Replying]"); }); - it("applies the WhatsApp messagePrefix when configured", () => { + it("applies the WhatsApp responsePrefix when configured", () => { const line = buildInboundLine({ cfg: makeInboundCfg("[PFX]"), agentId: "main", diff --git a/extensions/whatsapp/src/channel.setup.test.ts b/extensions/whatsapp/src/channel.setup.test.ts index 1d8e7aa1e65a..9ea1fbd8a1bc 100644 --- a/extensions/whatsapp/src/channel.setup.test.ts +++ b/extensions/whatsapp/src/channel.setup.test.ts @@ -484,6 +484,21 @@ describe("whatsapp setup wizard", () => { expect(result).toEqual({ ok: true, reason: "ok" }); }); + it("heartbeat readiness honors the channel disable flag", async () => { + const result = await checkWhatsAppHeartbeatReady({ + cfg: { channels: { whatsapp: { enabled: false } } } as OpenClawConfig, + deps: { + readWebAuthExistsForDecision: async () => ({ + outcome: "stable" as const, + exists: true, + }), + hasActiveWebListener: () => true, + }, + }); + + expect(result).toEqual({ ok: false, reason: "whatsapp-disabled" }); + }); + it("heartbeat readiness returns unstable when auth state timing is unresolved", async () => { const result = await checkWhatsAppHeartbeatReady({ cfg: { diff --git a/extensions/whatsapp/src/doctor-contract.test.ts b/extensions/whatsapp/src/doctor-contract.test.ts index d52ec3876ea2..354187d88fb4 100644 --- a/extensions/whatsapp/src/doctor-contract.test.ts +++ b/extensions/whatsapp/src/doctor-contract.test.ts @@ -103,7 +103,7 @@ describe("whatsapp normalizeCompatibilityConfig streaming aliases", () => { }); }); - it("keeps the legacy ackReaction migration and stays idempotent", () => { + it("keeps global ackReaction canonical while migrating streaming aliases", () => { const first = normalizeCompatibilityConfig({ cfg: { messages: { ackReaction: "👀" }, @@ -111,7 +111,8 @@ describe("whatsapp normalizeCompatibilityConfig streaming aliases", () => { } as never, }); const whatsapp = first.config.channels?.whatsapp as unknown as Record; - expect(whatsapp.ackReaction).toEqual({ emoji: "👀", direct: false, group: "mentions" }); + expect(whatsapp.ackReaction).toBeUndefined(); + expect(first.config.messages?.ackReaction).toBe("👀"); expect(whatsapp.streaming).toEqual({ block: { enabled: true } }); const second = normalizeCompatibilityConfig({ cfg: first.config }); diff --git a/extensions/whatsapp/src/doctor.test.ts b/extensions/whatsapp/src/doctor.test.ts index 29269b3b11aa..519b0923bb71 100644 --- a/extensions/whatsapp/src/doctor.test.ts +++ b/extensions/whatsapp/src/doctor.test.ts @@ -17,35 +17,6 @@ describe("whatsapp doctor compatibility", () => { expect(result.changes).toStrictEqual([]); }); - it("copies legacy ack reaction into configured whatsapp channel", () => { - const result = normalizeCompatibilityConfig({ - cfg: { - messages: { - ackReaction: "👀", - ackReactionScope: "group-mentions", - }, - channels: { - whatsapp: { - accounts: { - work: { - authDir: "/tmp/openclaw-wa-auth", - }, - }, - }, - }, - }, - }); - - expect(result.config.channels?.whatsapp?.ackReaction).toEqual({ - emoji: "👀", - direct: false, - group: "mentions", - }); - expect(result.changes).toEqual([ - "Copied messages.ackReaction → channels.whatsapp.ackReaction (scope: group-mentions).", - ]); - }); - it("keeps existing whatsapp ack reaction", () => { const result = normalizeCompatibilityConfig({ cfg: { diff --git a/extensions/whatsapp/src/doctor.ts b/extensions/whatsapp/src/doctor.ts index 86731175f30a..eb2f2c5b5536 100644 --- a/extensions/whatsapp/src/doctor.ts +++ b/extensions/whatsapp/src/doctor.ts @@ -10,46 +10,7 @@ export function normalizeCompatibilityConfig({ }: { cfg: OpenClawConfig; }): ChannelDoctorConfigMutation { - const legacyAckReaction = cfg.messages?.ackReaction?.trim(); - if (!legacyAckReaction || cfg.channels?.whatsapp === undefined) { - return { config: cfg, changes: [] }; - } - if (cfg.channels.whatsapp?.ackReaction !== undefined) { - return { config: cfg, changes: [] }; - } - - const legacyScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - let direct = true; - let group: "always" | "mentions" | "never" = "mentions"; - if (legacyScope === "all") { - direct = true; - group = "always"; - } else if (legacyScope === "direct") { - direct = true; - group = "never"; - } else if (legacyScope === "group-all") { - direct = false; - group = "always"; - } else if (legacyScope === "group-mentions") { - direct = false; - group = "mentions"; - } - - return { - config: { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - ackReaction: { emoji: legacyAckReaction, direct, group }, - }, - }, - }, - changes: [ - `Copied messages.ackReaction → channels.whatsapp.ackReaction (scope: ${legacyScope}).`, - ], - }; + return { config: cfg, changes: [] }; } export const whatsappDoctor: ChannelDoctorAdapter = { diff --git a/extensions/whatsapp/src/heartbeat.ts b/extensions/whatsapp/src/heartbeat.ts index f15353d64e75..68e55b01312a 100644 --- a/extensions/whatsapp/src/heartbeat.ts +++ b/extensions/whatsapp/src/heartbeat.ts @@ -12,7 +12,7 @@ export async function checkWhatsAppHeartbeatReady(params: { hasActiveWebListener?: (accountId?: string) => boolean; }; }) { - if (params.cfg.web?.enabled === false) { + if (params.cfg.channels?.whatsapp?.enabled === false) { return { ok: false as const, reason: "whatsapp-disabled" as const }; } const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId }); diff --git a/extensions/whatsapp/src/inbound-policy.ts b/extensions/whatsapp/src/inbound-policy.ts index bf7df3c59f5e..7fe0f9e7df28 100644 --- a/extensions/whatsapp/src/inbound-policy.ts +++ b/extensions/whatsapp/src/inbound-policy.ts @@ -175,7 +175,7 @@ export async function resolveWhatsAppCommandAuthorized(params: { policy?: ResolvedWhatsAppInboundPolicy; authDir?: string; }): Promise { - const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; + const useAccessGroups = true; if (!useAccessGroups) { return true; } diff --git a/extensions/whatsapp/src/inbound.media.test.ts b/extensions/whatsapp/src/inbound.media.test.ts index d8e1f51afc4b..2527cc646d16 100644 --- a/extensions/whatsapp/src/inbound.media.test.ts +++ b/extensions/whatsapp/src/inbound.media.test.ts @@ -285,7 +285,6 @@ describe("web inbound media saves with extension", () => { const listener = await monitorWebInbox({ cfg: { channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { responsePrefix: undefined }, } as never, verbose: false, onMessage, @@ -338,7 +337,6 @@ describe("web inbound media saves with extension", () => { const listener = await monitorWebInbox({ cfg: { channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { responsePrefix: undefined }, } as never, verbose: false, onMessage, @@ -390,7 +388,6 @@ describe("web inbound media saves with extension", () => { const listener = await monitorWebInbox({ cfg: { channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { responsePrefix: undefined }, } as never, verbose: false, onMessage, @@ -427,7 +424,6 @@ describe("web inbound media saves with extension", () => { const listener = await monitorWebInbox({ cfg: { channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { responsePrefix: undefined }, } as never, verbose: false, onMessage, diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 995b73e6dee2..a6a90ecd8072 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -172,19 +172,23 @@ export function createWhatsAppPluginBase(params: { }, }, }, - // `channels.whatsapp.accounts.*` (account add/remove, and `enabled` flips) - // must restart the channel so a disabled account's provider is torn down; + // Root/account `enabled` flips must restart the channel so a disabled + // provider is torn down; // the broad `channels.whatsapp` noop prefix below otherwise swallows it as a // hot no-op and leaves the account connected until a full restart. reload: { - configPrefixes: ["web", "channels.whatsapp.accounts", "channels.whatsapp.selfChatMode"], + configPrefixes: [ + "channels.whatsapp.enabled", + "channels.whatsapp.accounts", + "channels.whatsapp.selfChatMode", + ], noopPrefixes: ["channels.whatsapp"], }, gatewayMethodDescriptors: [{ name: "web.login.start" }, { name: "web.login.wait" }], configSchema: WhatsAppChannelConfigSchema, config: { ...whatsappConfigAdapter, - isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, + isEnabled: (account) => account.enabled, disabledReason: () => "disabled", isConfigured: params.isConfigured, hasPersistedAuthState: ({ cfg }) => hasAnyWhatsAppAuth(cfg), diff --git a/extensions/xai/doctor-contract-api.test.ts b/extensions/xai/doctor-contract-api.test.ts index 29c28964ba5d..57ee81e5b563 100644 --- a/extensions/xai/doctor-contract-api.test.ts +++ b/extensions/xai/doctor-contract-api.test.ts @@ -127,28 +127,17 @@ describe("xAI doctor contract", () => { { provider: "x-ai", model: "grok-stt" }, { provider: "xai" }, ], - audio: { - models: [ - { - type: "provider", - provider: "xai", - model: "grok-stt", - profile: "speech", - timeoutSeconds: 45, - }, - ], - }, }, }, } as unknown as OpenClawConfig; expect( legacyConfigRules.filter((rule) => rule.match(readPathForTest(config, rule.path))), - ).toHaveLength(2); + ).toHaveLength(1); const result = normalizeCompatibilityConfig({ cfg: config }); - expect(result.changes).toHaveLength(2); + expect(result.changes).toHaveLength(1); expect(result.config).not.toBe(config); expect(result.config.tools?.media?.models).toEqual([ { provider: " xAI ", language: "en", timeoutSeconds: 30 }, @@ -158,16 +147,7 @@ describe("xAI doctor contract", () => { { provider: "x-ai", model: "grok-stt" }, { provider: "xai" }, ]); - expect(result.config.tools?.media?.audio?.models).toEqual([ - { - type: "provider", - provider: "xai", - profile: "speech", - timeoutSeconds: 45, - }, - ]); expect(config.tools?.media?.models?.[0]).toHaveProperty("model", " GROK-STT "); - expect(config.tools?.media?.audio?.models?.[0]).toHaveProperty("model", "grok-stt"); expect(normalizeCompatibilityConfig({ cfg: result.config })).toEqual({ config: result.config, changes: [], diff --git a/extensions/xai/doctor-contract-api.ts b/extensions/xai/doctor-contract-api.ts index b57f8adb7f78..3ea636daf552 100644 --- a/extensions/xai/doctor-contract-api.ts +++ b/extensions/xai/doctor-contract-api.ts @@ -54,10 +54,7 @@ const PLUGIN_MODEL_MIGRATIONS: PluginModelMigration[] = [ ["plugins", "entries", "xai", "config", "xSearch"], ].map((path) => ({ path, retiredModels: RETIRED_CODE_MODELS, targetModel: "grok-build-0.1" })), ]; -const XAI_STT_MODEL_LIST_PATHS = [ - ["tools", "media", "models"], - ["tools", "media", "audio", "models"], -] as const; +const XAI_STT_MODEL_LIST_PATHS = [["tools", "media", "models"]] as const; function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) diff --git a/extensions/xai/speech-provider.ts b/extensions/xai/speech-provider.ts index f87165c3c846..3294b32eb0f3 100644 --- a/extensions/xai/speech-provider.ts +++ b/extensions/xai/speech-provider.ts @@ -99,7 +99,7 @@ function normalizeXaiProviderConfig(rawConfig: Record): XaiTtsP return { apiKey: normalizeResolvedSecretInputString({ value: xai?.apiKey, - path: "messages.tts.providers.xai.apiKey", + path: "tts.providers.xai.apiKey", }), baseUrl: normalizeXaiTtsBaseUrl( trimToUndefined(xai?.baseUrl) ?? trimToUndefined(process.env.XAI_BASE_URL) ?? XAI_BASE_URL, @@ -317,7 +317,7 @@ export function buildXaiSpeechProvider(): SpeechProviderPlugin { } // Resolve an xAI bearer for `/v1/tts`: -// 1. Configured `messages.tts.providers.xai.apiKey` (or talk equivalent) +// 1. Configured `tts.providers.xai.apiKey` (or talk equivalent) // 2. `XAI_API_KEY` env var // 3. xAI OAuth auth profile (cfg-scoped) async function resolveOptionalXaiAudioApiKey( diff --git a/extensions/xiaomi/speech-provider.ts b/extensions/xiaomi/speech-provider.ts index 6e6c8c2b42a9..458c9e34dd63 100644 --- a/extensions/xiaomi/speech-provider.ts +++ b/extensions/xiaomi/speech-provider.ts @@ -87,7 +87,7 @@ function normalizeXiaomiTtsProviderConfig( return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, - path: "messages.tts.providers.xiaomi.apiKey", + path: "tts.providers.xiaomi.apiKey", }), baseUrl: normalizeXiaomiTtsBaseUrl( trimToUndefined(raw?.baseUrl) ?? trimToUndefined(process.env.XIAOMI_BASE_URL), @@ -118,7 +118,7 @@ function readXiaomiTtsProviderConfig(config: SpeechProviderConfig): XiaomiTtsPro apiKey: normalizeResolvedSecretInputString({ value: config.apiKey, - path: "messages.tts.providers.xiaomi.apiKey", + path: "tts.providers.xiaomi.apiKey", }) ?? normalized.apiKey, baseUrl: normalizeXiaomiTtsBaseUrl(trimToUndefined(config.baseUrl) ?? normalized.baseUrl), model: trimToUndefined(config.model) ?? trimToUndefined(config.modelId) ?? normalized.model, diff --git a/extensions/zalo/src/monitor.group-policy.test-support.ts b/extensions/zalo/src/monitor.group-policy.test-support.ts index dbd26a3f78f6..949b40a5469c 100644 --- a/extensions/zalo/src/monitor.group-policy.test-support.ts +++ b/extensions/zalo/src/monitor.group-policy.test-support.ts @@ -93,7 +93,7 @@ async function resolveAccess( }, accessGroups: params.cfg?.accessGroups, readStoreAllowFrom: async () => await readAllowFromStore(), - useAccessGroups: params.cfg?.commands?.useAccessGroups !== false, + useAccessGroups: true, subject: { stableId: params.senderId ?? "123" }, conversation: { kind: isGroup ? "group" : "direct", diff --git a/package.json b/package.json index acefdca04701..2e55282fcc49 100644 --- a/package.json +++ b/package.json @@ -1422,6 +1422,7 @@ "check:deprecated-api-usage": "node scripts/check-deprecated-api-usage.mjs", "check:deprecated-jsdoc": "node scripts/check-deprecated-jsdoc.mjs", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-mdx && pnpm docs:check-i18n-glossary && pnpm docs:check-links && pnpm docs:map:check", + "check:env-var-count": "node scripts/check-env-var-count.mjs", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:import-cycles": "node --import tsx scripts/check-import-cycles.ts", "check:max-lines-ratchet": "node scripts/check-max-lines-ratchet.mjs", diff --git a/packages/gateway-client/src/timeouts.ts b/packages/gateway-client/src/timeouts.ts index 030296dcdef7..bf2db25d522b 100644 --- a/packages/gateway-client/src/timeouts.ts +++ b/packages/gateway-client/src/timeouts.ts @@ -8,6 +8,22 @@ function parseStrictPositiveInteger(value: string): number | undefined { return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; } +function isTestRuntimeEnv(env: NodeJS.ProcessEnv): boolean { + return ( + env.VITEST === "true" || + env.VITEST === "1" || + env.VITEST_POOL_ID !== undefined || + env.VITEST_WORKER_ID !== undefined || + env.NODE_ENV === "test" || + (env !== process.env && + (process.env.VITEST === "true" || + process.env.VITEST === "1" || + process.env.VITEST_POOL_ID !== undefined || + process.env.VITEST_WORKER_ID !== undefined || + process.env.NODE_ENV === "test")) + ); +} + /** Maximum delay Node timers can represent without overflow warnings. */ export const MAX_SAFE_TIMEOUT_DELAY_MS = 2_147_483_647; /** Default server-side window for gateway preauth handshakes. */ @@ -120,7 +136,8 @@ export function resolvePreauthHandshakeTimeoutMs(params?: { }): number { const env = params?.env ?? process.env; const configuredTimeout = - env.OPENCLAW_HANDSHAKE_TIMEOUT_MS || (env.VITEST && env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS); + env.OPENCLAW_HANDSHAKE_TIMEOUT_MS || + (isTestRuntimeEnv(env) ? env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS : undefined); if (configuredTimeout) { const parsed = parseStrictPositiveInteger(configuredTimeout); if (parsed !== undefined) { diff --git a/packages/memory-host-sdk/src/engine-foundation.ts b/packages/memory-host-sdk/src/engine-foundation.ts index 0c8400ed7f39..2b1c4d66e77d 100644 --- a/packages/memory-host-sdk/src/engine-foundation.ts +++ b/packages/memory-host-sdk/src/engine-foundation.ts @@ -43,7 +43,6 @@ export type { MemoryCitationsMode, MemoryQmdConfig, MemoryQmdIndexPath, - MemoryQmdMcporterConfig, MemoryQmdSearchMode, } from "./host/openclaw-runtime-config.js"; export type { MemorySearchConfig } from "./host/openclaw-runtime-config.js"; diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index e6f5927cf5f6..2128acf5d85c 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -151,7 +151,7 @@ describe("resolveMemoryBackendConfig", () => { list: [ { id: "personal", - memorySearch: { rememberAcrossConversations: true }, + memory: { search: { rememberAcrossConversations: true } }, }, ], }, @@ -173,7 +173,7 @@ describe("resolveMemoryBackendConfig", () => { list: [ { id: "personal", - memorySearch: { rememberAcrossConversations: true }, + memory: { search: { rememberAcrossConversations: true } }, }, ], }, @@ -386,32 +386,23 @@ describe("resolveMemoryBackendConfig", () => { agents: { defaults: { workspace: "/workspace/root", - memorySearch: { - qmd: { - extraCollections: [ - { - path: "/shared/team-notes", - name: "team-notes", - pattern: "**/*.md", - }, - ], - }, - }, }, list: [ { id: "main", default: true, workspace: "/workspace/root", - memorySearch: { - qmd: { - extraCollections: [ - { - path: "notes", - name: "notes", - pattern: "**/*.md", - }, - ], + memory: { + search: { + qmd: { + extraCollections: [ + { + path: "notes", + name: "notes", + pattern: "**/*.md", + }, + ], + }, }, }, }, @@ -419,6 +410,17 @@ describe("resolveMemoryBackendConfig", () => { }, memory: { backend: "qmd", + search: { + qmd: { + extraCollections: [ + { + path: "/shared/team-notes", + name: "team-notes", + pattern: "**/*.md", + }, + ], + }, + }, qmd: { includeDefaultMemory: false, }, @@ -506,7 +508,7 @@ describe("resolveMemoryBackendConfig", () => { expect(names).toStrictEqual(["notes-main"]); }); - it("resolves qmd update timeout overrides", () => { + it("uses fixed qmd update timeouts", () => { const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } }, memory: { @@ -523,13 +525,13 @@ describe("resolveMemoryBackendConfig", () => { } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); const update = requireQmdConfig(resolved).update; - expect(update.waitForBootSync).toBe(true); - expect(update.commandTimeoutMs).toBe(12_000); - expect(update.updateTimeoutMs).toBe(480_000); - expect(update.embedTimeoutMs).toBe(360_000); + expect(update.waitForBootSync).toBe(false); + expect(update.commandTimeoutMs).toBe(30_000); + expect(update.updateTimeoutMs).toBe(120_000); + expect(update.embedTimeoutMs).toBe(120_000); }); - it("keeps sub-unit positive qmd numeric overrides usable", () => { + it("keeps supported sub-unit qmd numeric overrides while update timeouts stay fixed", () => { const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } }, memory: { @@ -558,9 +560,9 @@ describe("resolveMemoryBackendConfig", () => { const qmd = requireQmdConfig(resolved); expect(qmd.sessions.retentionDays).toBe(1); - expect(qmd.update.commandTimeoutMs).toBe(1); - expect(qmd.update.updateTimeoutMs).toBe(1); - expect(qmd.update.embedTimeoutMs).toBe(1); + expect(qmd.update.commandTimeoutMs).toBe(30_000); + expect(qmd.update.updateTimeoutMs).toBe(120_000); + expect(qmd.update.embedTimeoutMs).toBe(120_000); expect(qmd.limits).toMatchObject({ maxResults: 1, maxSnippetChars: 1, @@ -609,7 +611,7 @@ describe("resolveMemoryBackendConfig", () => { }); }); - it("resolves qmd startup refresh overrides", () => { + it("uses the fixed lazy qmd startup policy", () => { const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } }, memory: { @@ -624,8 +626,8 @@ describe("resolveMemoryBackendConfig", () => { } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); const update = requireQmdConfig(resolved).update; - expect(update.startup).toBe("idle"); - expect(update.startupDelayMs).toBe(45_000); + expect(update.startup).toBe("off"); + expect(update.startupDelayMs).toBe(120_000); expect(update.onBoot).toBe(true); }); @@ -679,15 +681,18 @@ describe("resolveMemoryBackendConfig", () => { }); describe("memorySearch.extraPaths integration", () => { - it("maps agents.defaults.memorySearch.extraPaths to QMD collections", () => { + it("maps memory.search.extraPaths to QMD collections", () => { const cfg = { - memory: { backend: "qmd" }, + memory: { + backend: "qmd", + search: { + extraPaths: ["/home/user/docs", "/home/user/vault"], + }, + }, + agents: { defaults: { workspace: "/workspace/root", - memorySearch: { - extraPaths: ["/home/user/docs", "/home/user/vault"], - }, }, }, } as OpenClawConfig; @@ -702,19 +707,24 @@ describe("memorySearch.extraPaths integration", () => { it("merges default and per-agent memorySearch.extraPaths for QMD collections", () => { const cfg = { - memory: { backend: "qmd" }, + memory: { + backend: "qmd", + search: { + extraPaths: ["/default/path"], + }, + }, + agents: { defaults: { workspace: "/workspace/root", - memorySearch: { - extraPaths: ["/default/path"], - }, }, list: [ { id: "my-agent", - memorySearch: { - extraPaths: ["/agent/specific/path"], + memory: { + search: { + extraPaths: ["/agent/specific/path"], + }, }, }, ], @@ -731,19 +741,24 @@ describe("memorySearch.extraPaths integration", () => { it("falls back to defaults when agent has no overrides", () => { const cfg = { - memory: { backend: "qmd" }, + memory: { + backend: "qmd", + search: { + extraPaths: ["/default/path"], + }, + }, + agents: { defaults: { workspace: "/workspace/root", - memorySearch: { - extraPaths: ["/default/path"], - }, }, list: [ { id: "other-agent", - memorySearch: { - extraPaths: ["/other/path"], + memory: { + search: { + extraPaths: ["/other/path"], + }, }, }, ], @@ -757,19 +772,24 @@ describe("memorySearch.extraPaths integration", () => { it("deduplicates merged memorySearch.extraPaths for QMD collections", () => { const cfg = { - memory: { backend: "qmd" }, + memory: { + backend: "qmd", + search: { + extraPaths: ["/shared/path", " /shared/path "], + }, + }, + agents: { defaults: { workspace: "/workspace/root", - memorySearch: { - extraPaths: ["/shared/path", " /shared/path "], - }, }, list: [ { id: "my-agent", - memorySearch: { - extraPaths: ["/shared/path", "/agent-only"], + memory: { + search: { + extraPaths: ["/shared/path", "/agent-only"], + }, }, }, ], @@ -787,13 +807,16 @@ describe("memorySearch.extraPaths integration", () => { it("keeps unnamed extra paths agent-scoped even when they resolve outside the workspace", () => { const cfg = { - memory: { backend: "qmd" }, + memory: { + backend: "qmd", + search: { + extraPaths: ["/shared/path"], + }, + }, + agents: { defaults: { workspace: "/workspace/root", - memorySearch: { - extraPaths: ["/shared/path"], - }, }, }, } as OpenClawConfig; @@ -813,8 +836,10 @@ describe("memorySearch.extraPaths integration", () => { list: [ { id: "My-Agent", - memorySearch: { - extraPaths: ["/agent/mixed-case"], + memory: { + search: { + extraPaths: ["/agent/mixed-case"], + }, }, }, ], @@ -832,6 +857,9 @@ describe("memorySearch.extraPaths integration", () => { const cfg = { memory: { backend: "qmd", + search: { + extraPaths: ["./docs"], + }, qmd: { paths: [{ path: "docs", pattern: "**/*.md", name: "workspace-docs" }], }, @@ -839,9 +867,6 @@ describe("memorySearch.extraPaths integration", () => { agents: { defaults: { workspace: "/workspace/root", - memorySearch: { - extraPaths: ["./docs"], - }, }, }, } as OpenClawConfig; diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index c9f0699ac9e2..29d9a9a09bf2 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -12,7 +12,6 @@ import { type MemoryCitationsMode, type MemoryQmdConfig, type MemoryQmdIndexPath, - type MemoryQmdMcporterConfig, type MemoryQmdSearchMode, type MemoryQmdStartupMode, type OpenClawConfig, @@ -253,10 +252,6 @@ function resolveDebounceMs(raw: number | undefined): number { return DEFAULT_QMD_DEBOUNCE_MS; } -function resolveTimeoutMs(raw: number | undefined, fallback: number): number { - return resolvePositiveIntegerConfig(raw, fallback); -} - function resolvePositiveIntegerConfig(raw: number | undefined, fallback: number): number; function resolvePositiveIntegerConfig(raw: number | undefined): number | undefined; function resolvePositiveIntegerConfig( @@ -269,14 +264,6 @@ function resolvePositiveIntegerConfig( return Math.max(1, Math.floor(raw)); } -function resolveStartupMode(raw: MemoryQmdConfig["update"]): MemoryQmdStartupMode { - const value = raw?.startup; - if (value === "idle" || value === "immediate" || value === "off") { - return value; - } - return DEFAULT_QMD_STARTUP; -} - function resolveStartupDelayMs(raw: number | undefined): number { if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) { return Math.floor(raw); @@ -391,27 +378,6 @@ function resolveCustomPaths( return collections; } -function resolveMcporterConfig(raw?: MemoryQmdMcporterConfig): ResolvedQmdMcporterConfig { - const parsed: ResolvedQmdMcporterConfig = { ...DEFAULT_QMD_MCPORTER }; - if (!raw) { - return parsed; - } - if (raw.enabled !== undefined) { - parsed.enabled = raw.enabled; - } - if (typeof raw.serverName === "string" && raw.serverName.trim()) { - parsed.serverName = raw.serverName.trim(); - } - if (raw.startDaemon !== undefined) { - parsed.startDaemon = raw.startDaemon; - } - // When enabled, default startDaemon to true. - if (parsed.enabled && raw.startDaemon === undefined) { - parsed.startDaemon = true; - } - return parsed; -} - function resolveDefaultCollections( include: boolean, workspaceDir: string, @@ -454,8 +420,8 @@ export function resolveMemoryBackendConfig(params: { ); const mergedExtraPaths = normalizeStringEntries( [ - ...(params.cfg.agents?.defaults?.memorySearch?.extraPaths ?? []), - ...(agentEntry?.memorySearch?.extraPaths ?? []), + ...(params.cfg.memory?.search?.extraPaths ?? []), + ...(agentEntry?.memory?.search?.extraPaths ?? []), ].filter((value): value is string => typeof value === "string"), ); const dedupedExtraPaths = uniqueStrings(mergedExtraPaths); @@ -463,8 +429,8 @@ export function resolveMemoryBackendConfig(params: { (pathValue): { path: string; pattern?: string; name?: string } => ({ path: pathValue }), ); const mergedExtraCollections = [ - ...(params.cfg.agents?.defaults?.memorySearch?.qmd?.extraCollections ?? []), - ...(agentEntry?.memorySearch?.qmd?.extraCollections ?? []), + ...(params.cfg.memory?.search?.qmd?.extraCollections ?? []), + ...(agentEntry?.memory?.search?.qmd?.extraCollections ?? []), ].filter( (value): value is MemoryQmdIndexPath => value !== null && typeof value === "object" && typeof value.path === "string", @@ -486,7 +452,7 @@ export function resolveMemoryBackendConfig(params: { const command = resolveQmdCommand(rawCommand); const resolved: ResolvedQmdConfig = { command, - mcporter: resolveMcporterConfig(qmdCfg?.mcporter), + mcporter: { ...DEFAULT_QMD_MCPORTER }, searchMode: resolveSearchMode(qmdCfg?.searchMode), rerank: qmdCfg?.rerank, searchTool: resolveSearchTool(qmdCfg?.searchTool), @@ -503,25 +469,16 @@ export function resolveMemoryBackendConfig(params: { { explicit: qmdCfg?.sessions?.enabled === true }, ), update: { - intervalMs: resolveIntervalMs(qmdCfg?.update?.interval), - debounceMs: resolveDebounceMs(qmdCfg?.update?.debounceMs), - onBoot: qmdCfg?.update?.onBoot !== false, - startup: resolveStartupMode(qmdCfg?.update), - startupDelayMs: resolveStartupDelayMs(qmdCfg?.update?.startupDelayMs), - waitForBootSync: qmdCfg?.update?.waitForBootSync === true, - embedIntervalMs: resolveEmbedIntervalMs(qmdCfg?.update?.embedInterval), - commandTimeoutMs: resolveTimeoutMs( - qmdCfg?.update?.commandTimeoutMs, - DEFAULT_QMD_COMMAND_TIMEOUT_MS, - ), - updateTimeoutMs: resolveTimeoutMs( - qmdCfg?.update?.updateTimeoutMs, - DEFAULT_QMD_UPDATE_TIMEOUT_MS, - ), - embedTimeoutMs: resolveTimeoutMs( - qmdCfg?.update?.embedTimeoutMs, - DEFAULT_QMD_EMBED_TIMEOUT_MS, - ), + intervalMs: resolveIntervalMs(undefined), + debounceMs: resolveDebounceMs(undefined), + onBoot: true, + startup: DEFAULT_QMD_STARTUP, + startupDelayMs: resolveStartupDelayMs(undefined), + waitForBootSync: false, + embedIntervalMs: resolveEmbedIntervalMs(undefined), + commandTimeoutMs: DEFAULT_QMD_COMMAND_TIMEOUT_MS, + updateTimeoutMs: DEFAULT_QMD_UPDATE_TIMEOUT_MS, + embedTimeoutMs: DEFAULT_QMD_EMBED_TIMEOUT_MS, }, limits: resolveLimits(qmdCfg?.limits), scope: qmdCfg?.scope ?? DEFAULT_QMD_SCOPE, diff --git a/packages/memory-host-sdk/src/host/config-utils.test.ts b/packages/memory-host-sdk/src/host/config-utils.test.ts new file mode 100644 index 000000000000..510cf4a986ab --- /dev/null +++ b/packages/memory-host-sdk/src/host/config-utils.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { resolveRememberAcrossConversations } from "./config-utils.js"; + +describe("resolveRememberAcrossConversations", () => { + it("honors keyed per-agent memory overrides", () => { + const config = { + memory: { search: { rememberAcrossConversations: true } }, + agents: { + entries: { + support: { memory: { search: { rememberAcrossConversations: false } } }, + }, + }, + }; + + expect(resolveRememberAcrossConversations(config, "support")).toBe(false); + }); +}); diff --git a/packages/memory-host-sdk/src/host/config-utils.ts b/packages/memory-host-sdk/src/host/config-utils.ts index f697835df802..93d518f275f3 100644 --- a/packages/memory-host-sdk/src/host/config-utils.ts +++ b/packages/memory-host-sdk/src/host/config-utils.ts @@ -55,13 +55,6 @@ export type MemoryQmdIndexPath = { pattern?: string; }; -/** QMD mcporter daemon integration config. */ -export type MemoryQmdMcporterConfig = { - enabled?: boolean; - serverName?: string; - startDaemon?: boolean; -}; - /** QMD session export config. */ type MemoryQmdSessionConfig = { enabled?: boolean; @@ -69,20 +62,6 @@ type MemoryQmdSessionConfig = { retentionDays?: number; }; -/** QMD update, debounce, startup, and timeout config. */ -type MemoryQmdUpdateConfig = { - interval?: string; - debounceMs?: number; - onBoot?: boolean; - startup?: MemoryQmdStartupMode; - startupDelayMs?: number; - waitForBootSync?: boolean; - embedInterval?: string; - commandTimeoutMs?: number; - updateTimeoutMs?: number; - embedTimeoutMs?: number; -}; - /** Search and injection limits for QMD memory results. */ type MemoryQmdLimitsConfig = { maxResults?: number; @@ -94,14 +73,12 @@ type MemoryQmdLimitsConfig = { /** Full QMD-backed memory config. */ export type MemoryQmdConfig = { command?: string; - mcporter?: MemoryQmdMcporterConfig; searchMode?: MemoryQmdSearchMode; rerank?: boolean; searchTool?: string; includeDefaultMemory?: boolean; paths?: MemoryQmdIndexPath[]; sessions?: MemoryQmdSessionConfig; - update?: MemoryQmdUpdateConfig; limits?: MemoryQmdLimitsConfig; scope?: SessionSendPolicyConfig; }; @@ -110,6 +87,7 @@ export type MemoryQmdConfig = { type MemoryConfig = { backend?: MemoryBackend; citations?: MemoryCitationsMode; + search?: MemorySearchConfig; qmd?: MemoryQmdConfig; }; @@ -143,7 +121,9 @@ type AgentConfig = { id?: string; default?: boolean; workspace?: string; - memorySearch?: MemorySearchConfig; + memory?: { + search?: MemorySearchConfig; + }; contextLimits?: AgentContextLimitsConfig; }; @@ -152,9 +132,9 @@ export type OpenClawConfig = { agents?: { defaults?: { workspace?: string; - memorySearch?: MemorySearchConfig; contextLimits?: AgentContextLimitsConfig; }; + entries?: Record>; list?: AgentConfig[]; }; session?: { @@ -175,8 +155,8 @@ export type OpenClawConfig = { }; export function resolveRememberAcrossConversations(cfg: OpenClawConfig, agentId: string): boolean { - const defaults = cfg.agents?.defaults?.memorySearch; - const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch; + const defaults = cfg.memory?.search; + const overrides = resolveAgentConfig(cfg, agentId)?.memory?.search; const explicit = overrides?.rememberAcrossConversations ?? defaults?.rememberAcrossConversations; if (explicit !== undefined) { return explicit; @@ -256,6 +236,22 @@ function legacyStateDirs(homedir: () => string): string[] { return LEGACY_STATE_DIRNAMES.map((dir) => path.join(homedir(), dir)); } +function isFastTestRuntimeEnv(env: NodeJS.ProcessEnv): boolean { + const isTestRuntime = + env.VITEST === "true" || + env.VITEST === "1" || + env.VITEST_POOL_ID !== undefined || + env.VITEST_WORKER_ID !== undefined || + env.NODE_ENV === "test" || + (env !== process.env && + (process.env.VITEST === "true" || + process.env.VITEST === "1" || + process.env.VITEST_POOL_ID !== undefined || + process.env.VITEST_WORKER_ID !== undefined || + process.env.NODE_ENV === "test")); + return isTestRuntime && env.OPENCLAW_TEST_FAST === "1"; +} + /** Resolve the current state root while preserving shipped legacy installs when present. */ function resolveStateDir( env: NodeJS.ProcessEnv = process.env, @@ -267,7 +263,7 @@ function resolveStateDir( } const effectiveHome = () => resolveRequiredHomeDir(env, homedir); const nextDir = path.join(effectiveHome(), NEW_STATE_DIRNAME); - if (env.OPENCLAW_TEST_FAST === "1" || fs.existsSync(nextDir)) { + if (isFastTestRuntimeEnv(env) || fs.existsSync(nextDir)) { return nextDir; } // Existing legacy state remains authoritative until an explicit migration creates .openclaw. @@ -293,6 +289,9 @@ function resolveDefaultAgentWorkspaceDir(env: NodeJS.ProcessEnv = process.env): /** Return configured agent entries after dropping nullish placeholders. */ function listAgentEntries(cfg: OpenClawConfig): AgentConfig[] { + if (cfg.agents?.entries) { + return Object.entries(cfg.agents.entries).map(([id, entry]) => Object.assign({ id }, entry)); + } return Array.isArray(cfg.agents?.list) ? cfg.agents.list.filter((entry): entry is AgentConfig => Boolean(entry)) : []; @@ -359,8 +358,8 @@ export function resolveMemoryHostSearchPathConfig( cfg: OpenClawConfig, agentId: string, ): { enabled: boolean; rememberAcrossConversations: boolean; extraPaths: string[] } | null { - const defaults = cfg.agents?.defaults?.memorySearch; - const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch; + const defaults = cfg.memory?.search; + const overrides = resolveAgentConfig(cfg, agentId)?.memory?.search; const enabled = overrides?.enabled ?? defaults?.enabled ?? true; if (!enabled) { return null; diff --git a/packages/memory-host-sdk/src/host/embeddings-remote-client.ts b/packages/memory-host-sdk/src/host/embeddings-remote-client.ts index 6c7c23ce1fa6..afab7004aaa0 100644 --- a/packages/memory-host-sdk/src/host/embeddings-remote-client.ts +++ b/packages/memory-host-sdk/src/host/embeddings-remote-client.ts @@ -42,7 +42,7 @@ export async function resolveRemoteEmbeddingBearerClient(params: { const remote = params.options.remote; const remoteApiKey = resolveMemorySecretInputString({ value: remote?.apiKey, - path: "agents.*.memorySearch.remote.apiKey", + path: "memory.search.remote.apiKey", }); const remoteBaseUrl = normalizeOptionalString(remote?.baseUrl); const providerConfig = params.options.config.models?.providers?.[params.provider]; diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime-config.ts b/packages/memory-host-sdk/src/host/openclaw-runtime-config.ts index 0c112a653bfe..be30d15fa463 100644 --- a/packages/memory-host-sdk/src/host/openclaw-runtime-config.ts +++ b/packages/memory-host-sdk/src/host/openclaw-runtime-config.ts @@ -15,7 +15,6 @@ export type { MemoryCitationsMode, MemoryQmdConfig, MemoryQmdIndexPath, - MemoryQmdMcporterConfig, MemoryQmdSearchMode, MemorySearchConfig, OpenClawConfig, diff --git a/packages/memory-host-sdk/src/host/openclaw-runtime.ts b/packages/memory-host-sdk/src/host/openclaw-runtime.ts index 5e5df21a67ad..b0fcb9afee14 100644 --- a/packages/memory-host-sdk/src/host/openclaw-runtime.ts +++ b/packages/memory-host-sdk/src/host/openclaw-runtime.ts @@ -94,7 +94,6 @@ export type { MemoryCitationsMode, MemoryQmdConfig, MemoryQmdIndexPath, - MemoryQmdMcporterConfig, MemoryQmdSearchMode, } from "../../../../src/config/types.memory.js"; export { diff --git a/packages/memory-host-sdk/src/host/session-files.test.ts b/packages/memory-host-sdk/src/host/session-files.test.ts index 6ed1eab011b3..f373701c02e1 100644 --- a/packages/memory-host-sdk/src/host/session-files.test.ts +++ b/packages/memory-host-sdk/src/host/session-files.test.ts @@ -620,7 +620,7 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => { ); fsSync.writeFileSync( configPath, - JSON.stringify({ agents: { list: [{ id: "ops", default: true }] } }), + JSON.stringify({ agents: { entries: { ops: { default: true } } } }), ); Reflect.set(process.env, "OPENCLAW_CONFIG_PATH", configPath); clearRuntimeConfigSnapshot(); diff --git a/packages/memory-host-sdk/src/host/sqlite-vec.test.ts b/packages/memory-host-sdk/src/host/sqlite-vec.test.ts index cf81b283e535..efbd5eabba70 100644 --- a/packages/memory-host-sdk/src/host/sqlite-vec.test.ts +++ b/packages/memory-host-sdk/src/host/sqlite-vec.test.ts @@ -108,7 +108,7 @@ describe("loadSqliteVecExtension", () => { expect(db.loadExtension).toHaveBeenCalledWith("/opt/openclaw/sqlite-vec.so"); }); - it("returns a valid memorySearch extensionPath hint when sqlite-vec is absent", async () => { + it("returns a valid memory.search extensionPath hint when sqlite-vec is absent", async () => { mockMissingSqliteVecPackage(); mockPlatformVariantResolver(undefined); const { loadSqliteVecExtension } = await importLoader(); @@ -119,7 +119,7 @@ describe("loadSqliteVecExtension", () => { expect(result).toEqual({ ok: false, error: expect.stringMatching( - /^sqlite-vec package is not installed\. Set agents\.defaults\.memorySearch\.store\.vector\.extensionPath, or an agent-specific memorySearch\.store\.vector\.extensionPath, to a sqlite-vec loadable extension path\. Original error: (?:\[vitest\] There was an error when mocking a module\. If you are using "vi\.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file\. Read more: https:\/\/vitest\.dev\/api\/vi\.html#vi-mock \| )?Cannot find package 'sqlite-vec' imported from sqlite-vec\.test\.ts \| ERR_MODULE_NOT_FOUND$/u, + /^sqlite-vec package is not installed\. Set memory\.search\.store\.vector\.extensionPath, or an agent-specific memory\.search\.store\.vector\.extensionPath, to a sqlite-vec loadable extension path\. Original error: (?:\[vitest\] There was an error when mocking a module\. If you are using "vi\.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file\. Read more: https:\/\/vitest\.dev\/api\/vi\.html#vi-mock \| )?Cannot find package 'sqlite-vec' imported from sqlite-vec\.test\.ts \| ERR_MODULE_NOT_FOUND$/u, ), }); expect(result.error).not.toContain("memory.store.vector.extensionPath"); @@ -213,7 +213,7 @@ describe("loadSqliteVecExtension", () => { expect(result).toEqual({ ok: false, error: - "sqlite-vec platform variant sqlite-vec-linux-x64 failed to load from /install/node_modules/sqlite-vec-linux-x64/vec0.so. Set agents.defaults.memorySearch.store.vector.extensionPath, or an agent-specific memorySearch.store.vector.extensionPath, to a sqlite-vec loadable extension path. Original error: dlopen failed: file not found", + "sqlite-vec platform variant sqlite-vec-linux-x64 failed to load from /install/node_modules/sqlite-vec-linux-x64/vec0.so. Set memory.search.store.vector.extensionPath, or an agent-specific memory.search.store.vector.extensionPath, to a sqlite-vec loadable extension path. Original error: dlopen failed: file not found", }); }); @@ -231,7 +231,7 @@ describe("loadSqliteVecExtension", () => { expect(result).toEqual({ ok: false, error: - "sqlite-vec platform variant sqlite-vec-linux-x64 failed to load from /install/node_modules/sqlite-vec-linux-x64/vec0.so. Set agents.defaults.memorySearch.store.vector.extensionPath, or an agent-specific memorySearch.store.vector.extensionPath, to a sqlite-vec loadable extension path. Original error: sqlite-vec health check failed after loading /install/node_modules/sqlite-vec-linux-x64/vec0.so | no such function: vec_version", + "sqlite-vec platform variant sqlite-vec-linux-x64 failed to load from /install/node_modules/sqlite-vec-linux-x64/vec0.so. Set memory.search.store.vector.extensionPath, or an agent-specific memory.search.store.vector.extensionPath, to a sqlite-vec loadable extension path. Original error: sqlite-vec health check failed after loading /install/node_modules/sqlite-vec-linux-x64/vec0.so | no such function: vec_version", }); }); @@ -252,7 +252,7 @@ describe("loadSqliteVecExtension", () => { expect(result).toEqual({ ok: false, error: - "sqlite-vec package failed to load, and platform variant sqlite-vec-linux-x64 failed to load from /install/node_modules/sqlite-vec-linux-x64/vec0.so. Set agents.defaults.memorySearch.store.vector.extensionPath, or an agent-specific memorySearch.store.vector.extensionPath, to a sqlite-vec loadable extension path. Package error: bundled sqlite-vec load failed. Variant error: platform variant failed", + "sqlite-vec package failed to load, and platform variant sqlite-vec-linux-x64 failed to load from /install/node_modules/sqlite-vec-linux-x64/vec0.so. Set memory.search.store.vector.extensionPath, or an agent-specific memory.search.store.vector.extensionPath, to a sqlite-vec loadable extension path. Package error: bundled sqlite-vec load failed. Variant error: platform variant failed", }); }); }); diff --git a/packages/memory-host-sdk/src/host/sqlite-vec.ts b/packages/memory-host-sdk/src/host/sqlite-vec.ts index d1c929e61cc0..fc8e358d931f 100644 --- a/packages/memory-host-sdk/src/host/sqlite-vec.ts +++ b/packages/memory-host-sdk/src/host/sqlite-vec.ts @@ -11,7 +11,7 @@ type SqliteVecModule = { const SQLITE_VEC_MODULE_ID = "sqlite-vec"; const SQLITE_VEC_CONFIG_HINT = - "Set agents.defaults.memorySearch.store.vector.extensionPath, or an agent-specific memorySearch.store.vector.extensionPath, to a sqlite-vec loadable extension path."; + "Set memory.search.store.vector.extensionPath, or an agent-specific memory.search.store.vector.extensionPath, to a sqlite-vec loadable extension path."; async function loadSqliteVecModule(): Promise { return import(SQLITE_VEC_MODULE_ID) as Promise; diff --git a/packages/model-catalog-core/src/configured-model-refs.test.ts b/packages/model-catalog-core/src/configured-model-refs.test.ts index 08bdf773d64a..44aaad8d8752 100644 --- a/packages/model-catalog-core/src/configured-model-refs.test.ts +++ b/packages/model-catalog-core/src/configured-model-refs.test.ts @@ -13,22 +13,20 @@ describe("configured model refs", () => { defaults: { model: { primary: "openai/gpt-5.5", fallbacks: ["anthropic/claude-sonnet-4-6"] }, utilityModel: "google/gemini-3.1-flash-lite-preview", + mediaModels: { image: "openai/gpt-image-2" }, compaction: { memoryFlush: { model: "openai/gpt-5.5-mini" } }, }, - list: [ - { - id: "custom", + entries: { + custom: { model: "xai/grok-4-fast", utilityModel: "openai/gpt-5.5-nano", }, - ], + }, }, hooks: { mappings: [{ model: "openai/gpt-5.5-nano" }], }, - messages: { - tts: { summaryModel: "openai/gpt-5.5-mini" }, - }, + tts: { summaryModel: "openai/gpt-5.5-mini" }, channels: { modelByChannel: { discord: { @@ -44,12 +42,13 @@ describe("configured model refs", () => { path: "agents.defaults.utilityModel", value: "google/gemini-3.1-flash-lite-preview", }, + { path: "agents.defaults.mediaModels.image", value: "openai/gpt-image-2" }, { path: "agents.defaults.compaction.memoryFlush.model", value: "openai/gpt-5.5-mini" }, - { path: "agents.list.0.model", value: "xai/grok-4-fast" }, - { path: "agents.list.0.utilityModel", value: "openai/gpt-5.5-nano" }, + { path: "agents.entries.custom.model", value: "xai/grok-4-fast" }, + { path: "agents.entries.custom.utilityModel", value: "openai/gpt-5.5-nano" }, { path: "channels.modelByChannel.discord.guild", value: "anthropic/claude-opus-4-8" }, { path: "hooks.mappings.0.model", value: "openai/gpt-5.5-nano" }, - { path: "messages.tts.summaryModel", value: "openai/gpt-5.5-mini" }, + { path: "tts.summaryModel", value: "openai/gpt-5.5-mini" }, ]); }); diff --git a/packages/model-catalog-core/src/configured-model-refs.ts b/packages/model-catalog-core/src/configured-model-refs.ts index f7ee185630f7..03d68ad5e9c3 100644 --- a/packages/model-catalog-core/src/configured-model-refs.ts +++ b/packages/model-catalog-core/src/configured-model-refs.ts @@ -16,9 +16,6 @@ export const AGENT_MODEL_CONFIG_KEYS = [ "model", "utilityModel", "imageModel", - "imageGenerationModel", - "videoGenerationModel", - "musicGenerationModel", "voiceModel", "pdfModel", ] as const; @@ -56,6 +53,10 @@ export function collectConfiguredModelRefs( for (const key of AGENT_MODEL_CONFIG_KEYS) { collectModelConfig(`${path}.${key}`, agent[key]); } + const mediaModels = isRecord(agent.mediaModels) ? agent.mediaModels : {}; + for (const capability of ["image", "video", "music"] as const) { + collectModelConfig(`${path}.mediaModels.${capability}`, mediaModels[capability]); + } pushModelRef( `${path}.heartbeat.model`, isRecord(agent.heartbeat) ? agent.heartbeat.model : undefined, @@ -81,9 +82,9 @@ export function collectConfiguredModelRefs( const root = isRecord(config) ? config : {}; const agents = isRecord(root.agents) ? root.agents : {}; collectFromAgent("agents.defaults", agents.defaults); - if (Array.isArray(agents.list)) { - for (const [index, entry] of agents.list.entries()) { - collectFromAgent(`agents.list.${index}`, entry); + if (isRecord(agents.entries)) { + for (const [agentId, entry] of Object.entries(agents.entries)) { + collectFromAgent(`agents.entries.${agentId}`, entry); } } if (options.includeChannelModelOverrides !== false) { @@ -105,12 +106,7 @@ export function collectConfiguredModelRefs( } } pushModelRef("hooks.gmail.model", isRecord(hooks.gmail) ? hooks.gmail.model : undefined); - pushModelRef( - "messages.tts.summaryModel", - isRecord(root.messages) && isRecord(root.messages.tts) - ? root.messages.tts.summaryModel - : undefined, - ); + pushModelRef("tts.summaryModel", isRecord(root.tts) ? root.tts.summaryModel : undefined); pushModelRef( "channels.discord.voice.model", isRecord(root.channels) && diff --git a/packages/speech-core/runtime-api.ts b/packages/speech-core/runtime-api.ts index 9cbc8874a5db..76800af4bf61 100644 --- a/packages/speech-core/runtime-api.ts +++ b/packages/speech-core/runtime-api.ts @@ -11,6 +11,7 @@ export { resolveTtsAutoMode, resolveTtsConfig, resolveTtsPrefsPath, + setTtsMachinePrefsPathResolver, type ResolvedTtsConfig, type ResolvedTtsModelOverrides, } from "./src/tts-settings.js"; diff --git a/packages/speech-core/src/tts-settings.ts b/packages/speech-core/src/tts-settings.ts index fd788d35090c..cfd56072a276 100644 --- a/packages/speech-core/src/tts-settings.ts +++ b/packages/speech-core/src/tts-settings.ts @@ -36,6 +36,11 @@ export const DEFAULT_TTS_TIMEOUT_MS = 30_000; const DEFAULT_TTS_MAX_LENGTH = 1500; const DEFAULT_TTS_SUMMARIZE = true; const DEFAULT_MAX_TEXT_LENGTH = 4096; +let machinePrefsPathResolver: () => string | undefined = () => undefined; + +export function setTtsMachinePrefsPathResolver(resolver?: () => string | undefined): void { + machinePrefsPathResolver = resolver ?? (() => undefined); +} export type TtsUserPrefs = { tts?: { @@ -67,6 +72,7 @@ export function normalizeTtsPersonaId(personaId: string | null | undefined): str } function resolveTtsPrefsPathValue(prefsPath: string | undefined): string { + // Scoped agent paths must win over the migrated machine-wide default. if (prefsPath?.trim()) { return resolveUserPath(prefsPath.trim()); } @@ -74,6 +80,10 @@ function resolveTtsPrefsPathValue(prefsPath: string | undefined): string { if (envPath) { return resolveUserPath(envPath); } + const machinePath = machinePrefsPathResolver()?.trim(); + if (machinePath) { + return resolveUserPath(machinePath); + } return path.join(resolveConfigDir(process.env), "settings", "tts.json"); } @@ -221,7 +231,7 @@ export function resolveTtsConfig( summaryModel: normalizeOptionalString(raw.summaryModel), modelOverrides: resolveModelOverridePolicy(raw.modelOverrides), providerConfigs: collectDirectProviderConfigEntries(raw), - prefsPath: raw.prefsPath, + prefsPath: (raw as TtsConfig & { prefsPath?: string }).prefsPath, maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH, timeoutMs, timeoutMsSource, diff --git a/packages/speech-core/src/tts.test.ts b/packages/speech-core/src/tts.test.ts index 12d9dfa20d9e..2da23566e011 100644 --- a/packages/speech-core/src/tts.test.ts +++ b/packages/speech-core/src/tts.test.ts @@ -121,6 +121,8 @@ const { maybeApplyTtsToPayload, prepareTtsRequest, resolveTtsConfig, + resolveTtsPrefsPath, + setTtsMachinePrefsPathResolver, setSummarizationEnabled, setTtsMaxLength, synthesizeSpeech, @@ -161,13 +163,11 @@ function prefsPathFor(prefsName: string): string { } function createTtsConfig(prefsName: string): OpenClawConfig { + setTtsMachinePrefsPathResolver(() => prefsPathFor(prefsName)); return { - messages: { - tts: { - enabled: true, - provider: "mock", - prefsPath: prefsPathFor(prefsName), - }, + tts: { + enabled: true, + provider: "mock", }, }; } @@ -243,6 +243,7 @@ async function expectTtsPayloadResult(params: { describe("speech-core native voice-note routing", () => { afterEach(() => { + setTtsMachinePrefsPathResolver(); clearRuntimeConfigSnapshot(); delete (Object.prototype as Record).polluted; synthesizeMock.mockClear(); @@ -251,6 +252,22 @@ describe("speech-core native voice-note routing", () => { installSpeechProviders([createMockSpeechProvider()]); }); + it("prefers the environment preference path over migrated machine state", () => { + const previousEnvPath = process.env.OPENCLAW_TTS_PREFS; + const envPath = prefsPathFor("env-override"); + setTtsMachinePrefsPathResolver(() => prefsPathFor("machine-state")); + process.env.OPENCLAW_TTS_PREFS = envPath; + try { + expect(resolveTtsPrefsPath(resolveTtsConfig({}))).toBe(envPath); + } finally { + if (previousEnvPath === undefined) { + delete process.env.OPENCLAW_TTS_PREFS; + } else { + process.env.OPENCLAW_TTS_PREFS = previousEnvPath; + } + } + }); + it("resolves voice delivery support from channel capabilities", () => { for (const channel of nativeVoiceNoteChannels) { expect(testApi.supportsNativeVoiceNoteTts(channel)).toBe(true); @@ -274,15 +291,13 @@ describe("speech-core native voice-note routing", () => { it("prepares deep-merged surface config and directive inputs", () => { const cfg: OpenClawConfig = { - messages: { - tts: { - provider: "mock", - modelOverrides: { allowProvider: false }, - providers: { - mock: { - model: "base-model", - voiceSettings: { stability: 0.4 }, - }, + tts: { + provider: "mock", + modelOverrides: { allowProvider: false }, + providers: { + mock: { + model: "base-model", + voiceSettings: { stability: 0.4 }, }, }, }, @@ -303,12 +318,12 @@ describe("speech-core native voice-note routing", () => { }); expect(prepared.cfg).not.toBe(cfg); - expect(prepared.cfg.messages?.tts?.providers?.mock).toEqual({ + expect(prepared.cfg.tts?.providers?.mock).toEqual({ model: "base-model", voice: "surface-voice", voiceSettings: { stability: 0.4, speed: 1.1 }, }); - expect(prepared.cfg.messages?.tts?.modelOverrides?.allowProvider).toBe(true); + expect(prepared.cfg.tts?.modelOverrides?.allowProvider).toBe(true); expect(prepared.directives).toEqual({ cleanedText: "Hello caller", hasDirective: true, @@ -318,7 +333,7 @@ describe("speech-core native voice-note routing", () => { ttsText: "Speak this instead", warnings: [], }); - expect(cfg.messages?.tts?.providers?.mock).toEqual({ + expect(cfg.tts?.providers?.mock).toEqual({ model: "base-model", voiceSettings: { stability: 0.4 }, }); @@ -327,11 +342,9 @@ describe("speech-core native voice-note routing", () => { it("sanitizes blocked override keys while preparing TTS config", () => { const prepared = prepareTtsRequest({ cfg: { - messages: { - tts: { - provider: "mock", - providers: { mock: { model: "base-model" } }, - }, + tts: { + provider: "mock", + providers: { mock: { model: "base-model" } }, }, }, override: JSON.parse( @@ -341,8 +354,8 @@ describe("speech-core native voice-note routing", () => { }); expect((Object.prototype as Record).polluted).toBeUndefined(); - expect(prepared.cfg.messages?.tts).not.toHaveProperty("polluted"); - expect(prepared.cfg.messages?.tts?.providers?.mock).toEqual({ + expect(prepared.cfg.tts).not.toHaveProperty("polluted"); + expect(prepared.cfg.tts?.providers?.mock).toEqual({ model: "base-model", voice: "safe", }); @@ -443,27 +456,23 @@ describe("speech-core native voice-note routing", () => { it("uses the active runtime snapshot when source config still contains TTS SecretRefs", async () => { const sourceConfig = { - messages: { - tts: { - enabled: true, - provider: "mock", - providers: { - mock: { - apiKey: { source: "exec", provider: "mockexec", id: "minimax/tts/apiKey" }, - }, + tts: { + enabled: true, + provider: "mock", + providers: { + mock: { + apiKey: { source: "exec", provider: "mockexec", id: "minimax/tts/apiKey" }, }, }, }, } as unknown as OpenClawConfig; const runtimeConfig = { - messages: { - tts: { - enabled: true, - provider: "mock", - providers: { - mock: { - apiKey: "resolved-minimax-key", - }, + tts: { + enabled: true, + provider: "mock", + providers: { + mock: { + apiKey: "resolved-minimax-key", }, }, }, @@ -501,11 +510,9 @@ describe("speech-core native voice-note routing", () => { const result = await synthesizeSpeech({ text: "Use provider timeout.", cfg: { - messages: { - tts: { - enabled: true, - provider: "mock", - }, + tts: { + enabled: true, + provider: "mock", }, } as OpenClawConfig, disableFallback: true, @@ -560,12 +567,10 @@ describe("speech-core native voice-note routing", () => { await listSpeechVoices({ provider: "mock", cfg: { - messages: { - tts: { - enabled: true, - provider: "mock", - timeoutMs: 45_000, - }, + tts: { + enabled: true, + provider: "mock", + timeoutMs: 45_000, }, } as OpenClawConfig, }); @@ -581,11 +586,9 @@ describe("speech-core native voice-note routing", () => { const result = await synthesizeSpeech({ text: "Use capped provider timeout.", cfg: { - messages: { - tts: { - enabled: true, - provider: "mock", - }, + tts: { + enabled: true, + provider: "mock", }, } as OpenClawConfig, disableFallback: true, @@ -602,11 +605,9 @@ describe("speech-core native voice-note routing", () => { const result = await synthesizeSpeech({ text: "Use fallback timeout.", cfg: { - messages: { - tts: { - enabled: true, - provider: "mock", - }, + tts: { + enabled: true, + provider: "mock", }, } as OpenClawConfig, disableFallback: true, @@ -623,12 +624,10 @@ describe("speech-core native voice-note routing", () => { await synthesizeSpeech({ text: "Use configured timeout.", cfg: { - messages: { - tts: { - enabled: true, - provider: "mock", - timeoutMs: 45_000, - }, + tts: { + enabled: true, + provider: "mock", + timeoutMs: 45_000, }, } as OpenClawConfig, disableFallback: true, @@ -651,11 +650,9 @@ describe("speech-core native voice-note routing", () => { voiceModel: { primary: "mock/mock-tts", timeoutMs: Number.MAX_SAFE_INTEGER }, }, }, - messages: { - tts: { - enabled: true, - provider: "mock", - }, + tts: { + enabled: true, + provider: "mock", }, } as OpenClawConfig, disableFallback: true, @@ -691,11 +688,9 @@ describe("speech-core native voice-note routing", () => { voiceModel: { primary: "openai/gpt-4o-mini-tts", timeoutMs: 12_345 }, }, }, - messages: { - tts: { - enabled: true, - prefsPath: "/tmp/openclaw-speech-core-voice-model-default-test.json", - }, + tts: { + enabled: true, + prefsPath: "/tmp/openclaw-speech-core-voice-model-default-test.json", }, } as OpenClawConfig, disableFallback: true, @@ -731,15 +726,13 @@ describe("speech-core native voice-note routing", () => { voiceModel: { primary: "openrouter/default-model" }, }, }, - messages: { - tts: { - enabled: true, - provider: "openrouter", - prefsPath: "/tmp/openclaw-speech-core-explicit-model-alias-test.json", - providers: { - openrouter: { - modelId: "explicit-model", - }, + tts: { + enabled: true, + provider: "openrouter", + prefsPath: "/tmp/openclaw-speech-core-explicit-model-alias-test.json", + providers: { + openrouter: { + modelId: "explicit-model", }, }, }, @@ -781,11 +774,9 @@ describe("speech-core native voice-note routing", () => { }, }, }, - messages: { - tts: { - enabled: true, - prefsPath: "/tmp/openclaw-speech-core-voice-model-fallback-test.json", - }, + tts: { + enabled: true, + prefsPath: "/tmp/openclaw-speech-core-voice-model-fallback-test.json", }, } as OpenClawConfig, }); @@ -827,11 +818,9 @@ describe("speech-core native voice-note routing", () => { }, }, }, - messages: { - tts: { - enabled: true, - prefsPath: "/tmp/openclaw-speech-core-same-provider-voice-model-fallback-test.json", - }, + tts: { + enabled: true, + prefsPath: "/tmp/openclaw-speech-core-same-provider-voice-model-fallback-test.json", }, } as OpenClawConfig, }); @@ -871,12 +860,10 @@ describe("speech-core native voice-note routing", () => { voiceModel: { primary: "openai/gpt-realtime-2" }, }, }, - messages: { - tts: { - enabled: true, - provider: "openai", - prefsPath: "/tmp/openclaw-speech-core-realtime-voice-model-ignored-test.json", - }, + tts: { + enabled: true, + provider: "openai", + prefsPath: "/tmp/openclaw-speech-core-realtime-voice-model-ignored-test.json", }, } as OpenClawConfig, disableFallback: true, @@ -915,11 +902,9 @@ describe("speech-core native voice-note routing", () => { }, }, }, - messages: { - tts: { - enabled: true, - prefsPath: "/tmp/openclaw-speech-core-supported-voice-model-provider-test.json", - }, + tts: { + enabled: true, + prefsPath: "/tmp/openclaw-speech-core-supported-voice-model-provider-test.json", }, } as OpenClawConfig, }); @@ -934,18 +919,16 @@ describe("speech-core native voice-note routing", () => { const result = await synthesizeSpeech({ text: "Use the configured speaker.", cfg: { - messages: { - tts: { - enabled: true, - provider: "mock", - providers: { - mock: { - speakerVoice: "cedar", - speakerVoiceId: "voice-123", - voice: "legacy-voice", - voiceName: "legacy-name", - voiceId: "legacy-id", - }, + tts: { + enabled: true, + provider: "mock", + providers: { + mock: { + speakerVoice: "cedar", + speakerVoiceId: "voice-123", + voice: "legacy-voice", + voiceName: "legacy-name", + voiceId: "legacy-id", }, }, }, @@ -979,13 +962,11 @@ describe("speech-core native voice-note routing", () => { const result = await synthesizeSpeech({ text: "Use alias provider config.", cfg: { - messages: { - tts: { - enabled: true, - provider: "xiaomi", - providers: { - mimo: { apiKey: "mimo-key" }, - }, + tts: { + enabled: true, + provider: "xiaomi", + providers: { + mimo: { apiKey: "mimo-key" }, }, }, } as OpenClawConfig, @@ -1001,17 +982,15 @@ describe("speech-core native voice-note routing", () => { const result = await synthesizeSpeech({ text: "Use the persona speaker.", cfg: { - messages: { - tts: { - enabled: true, - provider: "mock", - persona: "narrator", - personas: { - narrator: { - providers: { - mock: { - speakerVoice: "marin", - }, + tts: { + enabled: true, + provider: "mock", + persona: "narrator", + personas: { + narrator: { + providers: { + mock: { + speakerVoice: "marin", }, }, }, @@ -1267,19 +1246,17 @@ describe("speech-core native voice-note routing", () => { it("selects persona preferred provider before config fallback", () => { const cfg: OpenClawConfig = { - messages: { - tts: { - enabled: true, - provider: "other", - persona: "alfred", - personas: { - alfred: { - label: "Alfred", - provider: "mock", - providers: { - mock: { - voice: "Algieba", - }, + tts: { + enabled: true, + provider: "other", + persona: "alfred", + personas: { + alfred: { + label: "Alfred", + provider: "mock", + providers: { + mock: { + voice: "Algieba", }, }, }, @@ -1301,42 +1278,39 @@ describe("speech-core native voice-note routing", () => { }, }), ]); + const prefsPath = "/tmp/openclaw-speech-core-invalid-provider.json"; + setTtsMachinePrefsPathResolver(() => prefsPath); const cfg = { - messages: { - tts: { - providers: { broken: {} }, - prefsPath: "/tmp/openclaw-speech-core-invalid-provider.json", - }, + tts: { + providers: { broken: {} }, }, } as OpenClawConfig; const config = resolveTtsConfig(cfg); expect(isTtsProviderConfigured(config, "broken", cfg)).toBe(false); - expect(getTtsProvider(config, config.prefsPath ?? "")).toBe(""); + expect(getTtsProvider(config, prefsPath)).toBe(""); }); it("merges active persona provider binding into synthesis config", async () => { + setTtsMachinePrefsPathResolver(() => "/tmp/openclaw-speech-core-persona-merge.json"); const cfg: OpenClawConfig = { - messages: { - tts: { - enabled: true, - provider: "mock", - prefsPath: "/tmp/openclaw-speech-core-persona-merge.json", - providers: { - mock: { - model: "base-model", - voice: "base-voice", - }, + tts: { + enabled: true, + provider: "mock", + providers: { + mock: { + model: "base-model", + voice: "base-voice", }, - persona: "alfred", - personas: { - alfred: { - provider: "mock", - providers: { - mock: { - voice: "persona-voice", - style: "dry", - }, + }, + persona: "alfred", + personas: { + alfred: { + provider: "mock", + providers: { + mock: { + voice: "persona-voice", + style: "dry", }, }, }, @@ -1377,17 +1351,15 @@ describe("speech-core native voice-note routing", () => { const result = await synthesizeSpeech({ text: "Use fallback provider.", cfg: { - messages: { - tts: { - enabled: true, - provider: "missing", - persona: "alfred", - personas: { - alfred: { - providers: { - missing: { - voice: "configured-but-unregistered", - }, + tts: { + enabled: true, + provider: "missing", + persona: "alfred", + personas: { + alfred: { + providers: { + missing: { + voice: "configured-but-unregistered", }, }, }, @@ -1409,17 +1381,15 @@ describe("speech-core native voice-note routing", () => { const result = await textToSpeechTelephony({ text: "Use telephony provider.", cfg: { - messages: { - tts: { - enabled: true, - provider: "mock", - persona: "alfred", - personas: { - alfred: { - providers: { - mock: { - voice: "persona-voice", - }, + tts: { + enabled: true, + provider: "mock", + persona: "alfred", + personas: { + alfred: { + providers: { + mock: { + voice: "persona-voice", }, }, }, @@ -1452,15 +1422,13 @@ describe("speech-core native voice-note routing", () => { const result = await textToSpeechTelephony({ text: "Use a directed telephony voice.", cfg: { - messages: { - tts: { - enabled: true, - provider: "mock", - providers: { - mock: { - modelId: "telephony-model", - voiceId: "default-voice", - }, + tts: { + enabled: true, + provider: "mock", + providers: { + mock: { + modelId: "telephony-model", + voiceId: "default-voice", }, }, }, @@ -1493,18 +1461,13 @@ describe("speech-core native voice-note routing", () => { await synthesizeSpeech({ text: "Use neutral provider defaults.", cfg: { - messages: { - tts: { - enabled: true, - provider: "mock", - persona: "alfred", - personas: { - alfred: { - fallbackPolicy: "provider-defaults", - prompt: { - profile: "A precise butler.", - }, - }, + tts: { + enabled: true, + provider: "mock", + persona: "alfred", + personas: { + alfred: { + fallbackPolicy: "provider-defaults", }, }, }, @@ -1520,21 +1483,17 @@ describe("speech-core native voice-note routing", () => { expect(prepareContext.personaProviderConfig).toBeUndefined(); }); - it("preserves persona prompts by default when provider bindings are missing", async () => { + it("preserves persona metadata by default when provider bindings are missing", async () => { await synthesizeSpeech({ text: "Use persona prompt.", cfg: { - messages: { - tts: { - enabled: true, - provider: "mock", - persona: "alfred", - personas: { - alfred: { - prompt: { - profile: "A precise butler.", - }, - }, + tts: { + enabled: true, + provider: "mock", + persona: "alfred", + personas: { + alfred: { + label: "Alfred", }, }, }, @@ -1560,18 +1519,16 @@ describe("speech-core native voice-note routing", () => { const result = await synthesizeSpeech({ text: "Use the first persona-bound provider.", cfg: { - messages: { - tts: { - enabled: true, - provider: "mock", - persona: "alfred", - personas: { - alfred: { - fallbackPolicy: "fail", - providers: { - fallback: { - voice: "fallback-voice", - }, + tts: { + enabled: true, + provider: "mock", + persona: "alfred", + personas: { + alfred: { + fallbackPolicy: "fail", + providers: { + fallback: { + voice: "fallback-voice", }, }, }, @@ -1599,18 +1556,16 @@ describe("speech-core native voice-note routing", () => { }); describe("speech-core per-agent TTS config", () => { - it("deep-merges the active agent TTS override over messages.tts", () => { + it("deep-merges the active agent TTS override over tts", () => { const cfg = { - messages: { - tts: { - enabled: true, - provider: "openai", - providers: { - openai: { - apiKey: "${OPENAI_API_KEY}", - voice: "coral", - speed: 1, - }, + tts: { + enabled: true, + provider: "openai", + providers: { + openai: { + apiKey: "${OPENAI_API_KEY}", + voice: "coral", + speed: 1, }, }, }, @@ -1645,32 +1600,30 @@ describe("speech-core per-agent TTS config", () => { it("composes per-agent TTS overrides with active persona bindings", async () => { const cfg = { - messages: { - tts: { - enabled: true, - provider: "mock", - providers: { - mock: { - model: "base-model", - voice: "base-voice", - }, + tts: { + enabled: true, + provider: "mock", + providers: { + mock: { + model: "base-model", + voice: "base-voice", }, - persona: "alfred", - personas: { - alfred: { - provider: "mock", - providers: { - mock: { - voice: "alfred-voice", - }, + }, + persona: "alfred", + personas: { + alfred: { + provider: "mock", + providers: { + mock: { + voice: "alfred-voice", }, }, - jarvis: { - provider: "mock", - providers: { - mock: { - style: "jarvis-style", - }, + }, + jarvis: { + provider: "mock", + providers: { + mock: { + style: "jarvis-style", }, }, }, @@ -1720,13 +1673,11 @@ describe("speech-core per-agent TTS config", () => { it("ignores prototype-pollution keys in agent TTS overrides", () => { const cfg = { - messages: { - tts: { - provider: "openai", - providers: { - openai: { - voice: "coral", - }, + tts: { + provider: "openai", + providers: { + openai: { + voice: "coral", }, }, }, diff --git a/packages/speech-core/src/tts.ts b/packages/speech-core/src/tts.ts index 68b4a8ac889e..0a2c76fc8eaf 100644 --- a/packages/speech-core/src/tts.ts +++ b/packages/speech-core/src/tts.ts @@ -476,10 +476,7 @@ export function prepareTtsRequest(params: { const cfg = params.override ? { ...params.cfg, - messages: { - ...params.cfg.messages, - tts: mergeDeep(params.cfg.messages?.tts ?? {}, params.override) as TtsConfig, - }, + tts: mergeDeep(params.cfg.tts ?? {}, params.override) as TtsConfig, } : params.cfg; const config = resolveTtsConfig(cfg); diff --git a/qa/scenarios/channels/matrix-voice-preflight-mention.yaml b/qa/scenarios/channels/matrix-voice-preflight-mention.yaml index 10af9c632799..f6953d4a9a56 100644 --- a/qa/scenarios/channels/matrix-voice-preflight-mention.yaml +++ b/qa/scenarios/channels/matrix-voice-preflight-mention.yaml @@ -8,12 +8,13 @@ scenario: gatewayConfigPatch: tools: media: + models: + - provider: openai + model: gpt-4o-transcribe + capabilities: [audio] audio: enabled: true echoTranscript: true - models: - - provider: openai - model: gpt-4o-transcribe prompt: MATRIX_QA_VOICE_PREFLIGHT_TRIGGER messages: groupChat: @@ -29,12 +30,13 @@ scenario: config: matrixRequireCanary: true matrixConfigOverrides: + mediaModels: + - provider: openai + model: gpt-4o-transcribe + capabilities: [audio] audio: enabled: true echoTranscript: true - models: - - provider: openai - model: gpt-4o-transcribe prompt: MATRIX_QA_VOICE_PREFLIGHT_TRIGGER groupMentionPatterns: - 'matrix\W+qa\W+voice\W+pre[ -]?flight\W+ok(?:ay)?' diff --git a/qa/scenarios/memory/remember-across-conversations.yaml b/qa/scenarios/memory/remember-across-conversations.yaml index 079a6510abdc..a8655f5050eb 100644 --- a/qa/scenarios/memory/remember-across-conversations.yaml +++ b/qa/scenarios/memory/remember-across-conversations.yaml @@ -16,10 +16,9 @@ scenario: gatewayConfigPatch: session: dmScope: per-channel-peer - agents: - defaults: - memorySearch: - rememberAcrossConversations: true + memory: + search: + rememberAcrossConversations: true plugins: entries: active-memory: @@ -98,7 +97,7 @@ flow: - ref: env - set: originalMemorySearch value: - expr: "original.config.agents && typeof original.config.agents === 'object' && typeof original.config.agents.defaults === 'object' ? structuredClone(original.config.agents.defaults.memorySearch) : undefined" + expr: "original.config.memory && typeof original.config.memory === 'object' ? structuredClone(original.config.memory.search) : undefined" - set: initialSessionsVisibility value: expr: "original.config.tools && typeof original.config.tools === 'object' && typeof original.config.tools.sessions === 'object' ? original.config.tools.sessions.visibility : undefined" @@ -459,10 +458,9 @@ flow: - env: ref: env patch: - agents: - defaults: - memorySearch: - expr: "{ ...structuredClone(originalMemorySearch ?? {}), rememberAcrossConversations: false }" + memory: + search: + expr: "{ ...structuredClone(originalMemorySearch ?? {}), rememberAcrossConversations: false }" - call: waitForGatewayHealthy args: - ref: env @@ -511,10 +509,9 @@ flow: - env: ref: env patch: - agents: - defaults: - memorySearch: - expr: "originalMemorySearch === undefined ? null : structuredClone(originalMemorySearch)" + memory: + search: + expr: "originalMemorySearch === undefined ? null : structuredClone(originalMemorySearch)" - call: waitForGatewayHealthy args: - ref: env diff --git a/qa/scenarios/memory/session-memory-ranking.yaml b/qa/scenarios/memory/session-memory-ranking.yaml index 091a0e4fca9d..40020a9ad9a9 100644 --- a/qa/scenarios/memory/session-memory-ranking.yaml +++ b/qa/scenarios/memory/session-memory-ranking.yaml @@ -49,7 +49,7 @@ flow: - ref: env - set: originalMemorySearch value: - expr: "original.config.agents && typeof original.config.agents === 'object' && typeof original.config.agents.defaults === 'object' ? original.config.agents.defaults.memorySearch : undefined" + expr: "original.config.memory && typeof original.config.memory === 'object' ? structuredClone(original.config.memory.search) : undefined" - set: originalToolsSessions value: expr: "original.config.tools && typeof original.config.tools === 'object' && typeof original.config.tools.sessions === 'object' ? structuredClone(original.config.tools.sessions) : undefined" @@ -61,21 +61,20 @@ flow: tools: sessions: visibility: all - agents: - defaults: - memorySearch: - sources: - - memory - - sessions - experimental: - sessionMemory: true - query: - minScore: 0 - hybrid: + memory: + search: + sources: + - memory + - sessions + experimental: + sessionMemory: true + query: + minScore: 0 + hybrid: + enabled: true + temporalDecay: enabled: true - temporalDecay: - enabled: true - halfLifeDays: 1 + halfLifeDays: 1 - call: waitForGatewayHealthy args: - ref: env @@ -192,10 +191,9 @@ flow: tools: sessions: expr: "originalToolsSessions === undefined ? null : structuredClone(originalToolsSessions)" - agents: - defaults: - memorySearch: - expr: "originalMemorySearch === undefined ? null : structuredClone(originalMemorySearch)" + memory: + search: + expr: "originalMemorySearch === undefined ? null : structuredClone(originalMemorySearch)" - call: waitForGatewayHealthy args: - ref: env diff --git a/scripts/bench-gateway-restart.ts b/scripts/bench-gateway-restart.ts index 8142d975d875..fe597cd6af2b 100644 --- a/scripts/bench-gateway-restart.ts +++ b/scripts/bench-gateway-restart.ts @@ -836,7 +836,6 @@ function sanitizedEnv( TMPDIR: process.env.TMPDIR, USER: process.env.USER ?? "openclaw-bench", npm_config_update_notifier: "false", - OPENCLAW_CONFIG: configPath, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_GATEWAY_RESTART_TRACE: "1", OPENCLAW_GATEWAY_STARTUP_TRACE: "1", diff --git a/scripts/bench-gateway-startup.ts b/scripts/bench-gateway-startup.ts index 1006cc57e11b..f0d0200a2bdc 100644 --- a/scripts/bench-gateway-startup.ts +++ b/scripts/bench-gateway-startup.ts @@ -734,7 +734,6 @@ function sanitizedEnv( TMPDIR: process.env.TMPDIR, USER: process.env.USER ?? "openclaw-bench", npm_config_update_notifier: "false", - OPENCLAW_CONFIG: configPath, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_GATEWAY_STARTUP_TRACE: "1", OPENCLAW_HOME: root, diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index eadc6bbe8acb..de110a9e3432 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -407,6 +407,20 @@ export function createChangedCheckPlan(result, options = {}) { }; add("conflict markers", ["check:no-conflict-markers"]); + if ( + result.paths.some((filePath) => + /^(?:src\/|packages\/|extensions\/|config\/env-var-count-budget\.txt$|scripts\/check-env-var-count\.mjs$)/u.test( + filePath, + ), + ) + ) { + add("environment variable count ratchet", [ + "check:env-var-count", + ...(options.staged ? ["--staged"] : []), + "--base", + options.staged ? "HEAD" : (options.base ?? "origin/main"), + ]); + } if ( result.paths.some((filePath) => /^(?:src\/|ui\/src\/|packages\/|extensions\/|\.oxlintrc\.json$|config\/max-lines-baseline\.txt$|scripts\/check-max-lines-ratchet\.mjs$)/u.test( diff --git a/scripts/check-env-var-count.d.mts b/scripts/check-env-var-count.d.mts new file mode 100644 index 000000000000..769a7b980165 --- /dev/null +++ b/scripts/check-env-var-count.d.mts @@ -0,0 +1,4 @@ +export function isCountedSourcePath(filePath: string): boolean; +export function collectEnvVarNames(root?: string, options?: { staged?: boolean }): string[]; +export function parseBudget(source: string): number; +export function main(argv?: string[], root?: string): number; diff --git a/scripts/check-env-var-count.mjs b/scripts/check-env-var-count.mjs new file mode 100644 index 000000000000..58de322a25dc --- /dev/null +++ b/scripts/check-env-var-count.mjs @@ -0,0 +1,126 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const BUDGET_PATH = "config/env-var-count-budget.txt"; +const SOURCE_ROOTS = ["src", "packages", "extensions"]; +const SOURCE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]); +const ENV_VAR_PATTERN = /OPENCLAW_[A-Z0-9_]+/gu; + +export function isCountedSourcePath(filePath) { + const normalized = filePath.replaceAll("\\", "/"); + if (!SOURCE_ROOTS.some((root) => normalized.startsWith(root + "/"))) { + return false; + } + if (!SOURCE_EXTENSIONS.has(path.posix.extname(normalized))) { + return false; + } + if ( + /^(?:extensions\/(?:qa-lab|test-support)|.*\/(?:__tests__|test|tests|test-utils|test-support))\//u.test( + normalized, + ) + ) { + return false; + } + return !/(?:^|[./-])(?:e2e|live-helpers|live-harness|spec|suite|test|test-helpers|test-harness|test-setup|test-support|test-utils)(?:[./-]|$)/u.test( + normalized, + ); +} + +export function collectEnvVarNames(root = process.cwd(), options = {}) { + const staged = options.staged === true; + const files = execFileSync( + "git", + [ + "ls-files", + "-z", + "--cached", + ...(staged ? [] : ["--others", "--exclude-standard"]), + "--", + ...SOURCE_ROOTS, + ], + { cwd: root, maxBuffer: 256 * 1024 * 1024 }, + ) + .toString("utf8") + .split("\0") + .filter(isCountedSourcePath) + .filter((file) => staged || fs.existsSync(path.join(root, file))); + const names = new Set(); + for (const file of files) { + const source = staged + ? execFileSync("git", ["show", `:${file}`], { cwd: root, encoding: "utf8" }) + : fs.readFileSync(path.join(root, file), "utf8"); + for (const match of source.matchAll(ENV_VAR_PATTERN)) { + names.add(match[0]); + } + } + return [...names].toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0)); +} + +export function parseBudget(source) { + const values = source + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); + if (values.length !== 1 || !/^\d+$/u.test(values[0])) { + throw new Error(`${BUDGET_PATH} must contain exactly one non-negative integer`); + } + return Number(values[0]); +} + +function readBaseBudget(root, ref) { + const resolved = spawnSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], { + cwd: root, + encoding: "utf8", + }); + if (resolved.status !== 0) { + throw new Error(`Could not resolve env-var count base ref: ${ref}`); + } + const entry = execFileSync("git", ["ls-tree", "--name-only", ref, "--", BUDGET_PATH], { + cwd: root, + encoding: "utf8", + }).trim(); + if (!entry) { + return null; + } + return parseBudget( + execFileSync("git", ["show", `${ref}:${BUDGET_PATH}`], { cwd: root, encoding: "utf8" }), + ); +} + +export function main(argv = process.argv.slice(2), root = process.cwd()) { + const baseIndex = argv.indexOf("--base"); + const baseRef = baseIndex >= 0 ? argv[baseIndex + 1] : "origin/main"; + const staged = argv.includes("--staged"); + const expectedLength = (baseIndex >= 0 ? 2 : 0) + (staged ? 1 : 0); + if ((baseIndex >= 0 && !baseRef) || argv.length !== expectedLength) { + throw new Error("Usage: node scripts/check-env-var-count.mjs [--staged] [--base ]"); + } + const budgetSource = staged + ? execFileSync("git", ["show", `:${BUDGET_PATH}`], { cwd: root, encoding: "utf8" }) + : fs.readFileSync(path.join(root, BUDGET_PATH), "utf8"); + const budget = parseBudget(budgetSource); + const baseBudget = readBaseBudget(root, baseRef); + if (baseBudget !== null && budget > baseBudget) { + throw new Error(`OPENCLAW_* budget grew from ${baseBudget} to ${budget}`); + } + const names = collectEnvVarNames(root, { staged }); + if (names.length !== budget) { + const direction = names.length > budget ? "exceeds" : "is below"; + throw new Error( + `OPENCLAW_* count ${names.length} ${direction} budget ${budget}; update ${BUDGET_PATH}`, + ); + } + console.log(`OPENCLAW_* count ${names.length}/${budget}`); + return names.length; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/scripts/check-memory-fd-repro.mjs b/scripts/check-memory-fd-repro.mjs index 2670cde15e95..15364e075cb8 100644 --- a/scripts/check-memory-fd-repro.mjs +++ b/scripts/check-memory-fd-repro.mjs @@ -338,26 +338,27 @@ export function writeConfig({ homeDir, workspaceDir, port, token }) { agents: { defaults: { workspace: workspaceDir, - memorySearch: { - provider: "none", - model: "", - store: { - vector: { enabled: false }, - }, - sync: { - watch: true, - onSessionStart: false, - onSearch: false, - }, - }, }, - list: [ - { - id: "main", + entries: { + main: { default: true, tools: { allow: ["memory_search"] }, }, - ], + }, + }, + memory: { + search: { + provider: "none", + model: "", + store: { + vector: { enabled: false }, + }, + sync: { + watch: true, + onSessionStart: false, + onSearch: false, + }, + }, }, plugins: { allow: ["memory-core"] }, gateway: { diff --git a/scripts/check.mjs b/scripts/check.mjs index 6ac19ec9d949..0479b2d507ca 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -87,6 +87,7 @@ export async function main(argv = process.argv.slice(2)) { parallel: true, commands: [ { name: "conflict markers", args: ["check:no-conflict-markers"] }, + { name: "environment variable count ratchet", args: ["check:env-var-count"] }, { name: "max-lines suppression ratchet", args: ["check:max-lines-ratchet"] }, { name: "changelog attributions", args: ["check:changelog-attributions"] }, { name: "database-first legacy-store guard", args: ["check:database-first-legacy-stores"] }, diff --git a/scripts/e2e/commitments-safety-docker.sh b/scripts/e2e/commitments-safety-docker.sh index 713cb3a24ab5..71ff1d933785 100755 --- a/scripts/e2e/commitments-safety-docker.sh +++ b/scripts/e2e/commitments-safety-docker.sh @@ -23,7 +23,6 @@ set +e docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ - -e OPENCLAW_COMMITMENTS_SAFETY_E2E=1 \ "$IMAGE_NAME" \ bash -lc 'set -euo pipefail; tsx scripts/e2e/commitments-safety-docker-client.ts' \ >"$RUN_LOG" 2>&1 diff --git a/scripts/e2e/cron-mcp-cleanup-seed.ts b/scripts/e2e/cron-mcp-cleanup-seed.ts index c2bfd40a051e..7096c829314b 100644 --- a/scripts/e2e/cron-mcp-cleanup-seed.ts +++ b/scripts/e2e/cron-mcp-cleanup-seed.ts @@ -72,7 +72,6 @@ async function main() { { gateway: { controlUi: { - allowInsecureAuth: true, enabled: false, }, }, diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mjs b/scripts/e2e/kitchen-sink-rpc-walk.mjs index 1f00834be6d6..2594a58f5820 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mjs +++ b/scripts/e2e/kitchen-sink-rpc-walk.mjs @@ -1268,16 +1268,13 @@ function configureKitchenSink(env, port) { profile: config.tools?.profile ?? "full", alsoAllow: [...new Set([...(config.tools?.alsoAllow ?? []), ...EXPECTED_TOOLS])], }; - config.messages = { - ...config.messages, - tts: { - ...config.messages?.tts, - provider: config.messages?.tts?.provider ?? EXPECTED_SPEECH_PROVIDERS[0], - providers: { - ...config.messages?.tts?.providers, - [EXPECTED_SPEECH_PROVIDERS[0]]: { - ...config.messages?.tts?.providers?.[EXPECTED_SPEECH_PROVIDERS[0]], - }, + config.tts = { + ...config.tts, + provider: config.tts?.provider ?? EXPECTED_SPEECH_PROVIDERS[0], + providers: { + ...config.tts?.providers, + [EXPECTED_SPEECH_PROVIDERS[0]]: { + ...config.tts?.providers?.[EXPECTED_SPEECH_PROVIDERS[0]], }, }, }; diff --git a/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs b/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs index d807dc80a897..5fafb657ddc5 100644 --- a/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs +++ b/scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs @@ -1062,16 +1062,13 @@ async function smokePlugin(pluginId, pluginDir, requiresConfig, pluginIndex, plu const env = withManifestChannelActivationEnv(process.env, plan.channels); if (plan.speechProviders[0]) { const provider = plan.speechProviders[0]; - config.messages = { - ...config.messages, - tts: { - ...config.messages?.tts, - provider, - providers: { - ...config.messages?.tts?.providers, - [provider]: { - ...config.messages?.tts?.providers?.[provider], - }, + config.tts = { + ...config.tts, + provider, + providers: { + ...config.tts?.providers, + [provider]: { + ...config.tts?.providers?.[provider], }, }, }; @@ -1394,10 +1391,8 @@ async function smokeTtsGlobalDisable(pluginId, pluginDir, provider, pluginIndex, plugins: { enabled: false, }, - messages: { - tts: { - provider: selectedProvider, - }, + tts: { + provider: selectedProvider, }, }, port, @@ -1450,13 +1445,11 @@ async function smokeOpenAiTts(pluginIndex) { openai: { enabled: true }, }, }, - messages: { - tts: { - provider: "openai", - providers: { - openai: { - apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, - }, + tts: { + provider: "openai", + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, }, }, }, diff --git a/scripts/e2e/lib/codex-media-path/write-config.mjs b/scripts/e2e/lib/codex-media-path/write-config.mjs index 4f0f22a2d6e7..b4ff23b58538 100644 --- a/scripts/e2e/lib/codex-media-path/write-config.mjs +++ b/scripts/e2e/lib/codex-media-path/write-config.mjs @@ -56,9 +56,8 @@ const config = { timeoutSeconds, sandbox: { mode: "off" }, }, - list: [ - { - id: "main", + entries: { + main: { default: true, model: { primary: "openai/gpt-5.6-luna", fallbacks: [] }, models: { @@ -68,7 +67,7 @@ const config = { }, workspace: workspaceDir, }, - ], + }, }, skills: { allowBundled: [] }, }; diff --git a/scripts/e2e/lib/fixtures/workspace.mjs b/scripts/e2e/lib/fixtures/workspace.mjs index c93cbba4db87..e11d12a69c3a 100644 --- a/scripts/e2e/lib/fixtures/workspace.mjs +++ b/scripts/e2e/lib/fixtures/workspace.mjs @@ -43,10 +43,10 @@ function writeAgentsDeleteConfig() { fs.mkdirSync(sharedWorkspace, { recursive: true }); writeJson(path.join(stateDir, "openclaw.json"), { agents: { - list: [ - { id: "main", workspace: sharedWorkspace }, - { id: "ops", workspace: sharedWorkspace }, - ], + entries: { + main: { workspace: sharedWorkspace }, + ops: { workspace: sharedWorkspace }, + }, }, ...(gatewayToken ? { gateway: { auth: { mode: "token", token: gatewayToken } } } : {}), }); diff --git a/scripts/e2e/mcp-channels-seed.ts b/scripts/e2e/mcp-channels-seed.ts index 4d195541bcc5..e53bc1f99ef0 100644 --- a/scripts/e2e/mcp-channels-seed.ts +++ b/scripts/e2e/mcp-channels-seed.ts @@ -20,7 +20,6 @@ async function main() { { gateway: { controlUi: { - allowInsecureAuth: true, enabled: false, }, }, diff --git a/scripts/e2e/mcp-code-mode-gateway-seed.ts b/scripts/e2e/mcp-code-mode-gateway-seed.ts index ec2f88c45972..bf3e71e75be8 100644 --- a/scripts/e2e/mcp-code-mode-gateway-seed.ts +++ b/scripts/e2e/mcp-code-mode-gateway-seed.ts @@ -20,7 +20,6 @@ async function main() { { gateway: { controlUi: { - allowInsecureAuth: true, enabled: false, }, http: { @@ -36,13 +35,15 @@ async function main() { heartbeat: { every: "0m", }, - memorySearch: { - enabled: false, - sync: { - onSearch: false, - onSessionStart: false, - watch: false, - }, + }, + }, + memory: { + search: { + enabled: false, + sync: { + onSearch: false, + onSessionStart: false, + watch: false, }, }, }, diff --git a/scripts/e2e/system-agent-rescue-docker-client.ts b/scripts/e2e/system-agent-rescue-docker-client.ts index a6eea51974ab..0da30555634a 100644 --- a/scripts/e2e/system-agent-rescue-docker-client.ts +++ b/scripts/e2e/system-agent-rescue-docker-client.ts @@ -271,9 +271,7 @@ async function main() { "setup did not update default workspace", ); assert( - updatedConfig.agents?.list?.some( - (agent) => agent.id === "work" && agent.workspace === "/tmp/openclaw-work", - ), + updatedConfig.agents?.entries?.work?.workspace === "/tmp/openclaw-work", "agent config was not updated", ); diff --git a/scripts/e2e/telegram-user-crabbox-proof.ts b/scripts/e2e/telegram-user-crabbox-proof.ts index 0049d23b919b..576052f4a9b8 100644 --- a/scripts/e2e/telegram-user-crabbox-proof.ts +++ b/scripts/e2e/telegram-user-crabbox-proof.ts @@ -1157,19 +1157,18 @@ export function writeSutConfig(params: { "openai/gpt-5.6-luna": { params: { openaiWsWarmup: false, transport: "sse" } }, }, }, - list: [ - { + entries: { + main: { default: true, - id: "main", model: { primary: "openai/gpt-5.6-luna" }, name: "Main", workspace, }, - ], + }, }, // Exercise the opt-in message audit surface: the DM probe should produce // inbound/outbound rows under the privacy-sensitive "direct" mode. - audit: { enabled: true, messages: "direct" }, + logging: { audit: { enabled: true, messages: "direct" } }, channels: { telegram: { allowFrom: [params.testerId], diff --git a/scripts/generate-docs-map.mjs b/scripts/generate-docs-map.mjs index 0e591e95b0b1..df5036be04fc 100644 --- a/scripts/generate-docs-map.mjs +++ b/scripts/generate-docs-map.mjs @@ -89,12 +89,21 @@ function escapeMarkdownHtmlText(value) { } function cleanHeadingText(value) { - const normalized = value + const codeSpans = []; + const withCodePlaceholders = value.replace(/(`+)([^`\n]*?)\1/gu, (_match, _ticks, content) => { + const index = codeSpans.push(content) - 1; + return `\u{e000}${index}\u{e001}`; + }); + const normalized = withCodePlaceholders .replace(/\s+#+\s*$/u, "") .replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1") .replace(/[*_~`]/gu, "") .replace(/\s+/gu, " ") - .trim(); + .trim() + .replace(/\u{e000}(\d+)\u{e001}/gu, (_match, index) => { + const content = codeSpans[Number(index)] ?? ""; + return /[*_~[\]()!]/u.test(content) ? `\`${content}\`` : content; + }); // Docs map is Markdown consumed by humans and agents. Escape HTML instead of // trying to strip tags so malformed source headings cannot reintroduce markup. return escapeMarkdownHtmlText(normalized); diff --git a/scripts/lib/openclaw-test-state.mjs b/scripts/lib/openclaw-test-state.mjs index 4a58ceaf670e..67349e7fb124 100644 --- a/scripts/lib/openclaw-test-state.mjs +++ b/scripts/lib/openclaw-test-state.mjs @@ -124,9 +124,8 @@ function scenarioConfig(scenario, options = {}) { contextTokens: 64000, skills: ["memory"], }, - list: [ - { - id: "main", + entries: { + main: { default: true, name: "Main", workspace: "~/workspace", @@ -137,8 +136,7 @@ function scenarioConfig(scenario, options = {}) { skills: ["memory"], contextTokens: 64000, }, - { - id: "ops", + ops: { name: "Ops", workspace: "~/workspace/ops", model: { @@ -146,7 +144,7 @@ function scenarioConfig(scenario, options = {}) { }, fastModeDefault: true, }, - ], + }, }, skills: { allowBundled: ["memory", "openclaw-testing"], diff --git a/scripts/lib/plain-gh.d.mts b/scripts/lib/plain-gh.d.mts index 38716c1e0e62..5753b212e9a8 100644 --- a/scripts/lib/plain-gh.d.mts +++ b/scripts/lib/plain-gh.d.mts @@ -2,10 +2,6 @@ import type { ExecFileSyncOptions, ExecFileSyncOptionsWithBufferEncoding, ExecFileSyncOptionsWithStringEncoding, - SpawnSyncOptions, - SpawnSyncOptionsWithBufferEncoding, - SpawnSyncOptionsWithStringEncoding, - SpawnSyncReturns, } from "node:child_process"; export function plainGhEnv(env?: NodeJS.ProcessEnv): { @@ -36,16 +32,4 @@ export function execGhApiRead( endpoint: string, options?: ExecFileSyncOptions, ): string | Uint8Array; -export function spawnPlainGh( - args: readonly string[], - options: SpawnSyncOptionsWithStringEncoding, -): SpawnSyncReturns; -export function spawnPlainGh( - args: readonly string[], - options?: SpawnSyncOptionsWithBufferEncoding, -): SpawnSyncReturns; -export function spawnPlainGh( - args: readonly string[], - options?: SpawnSyncOptions, -): SpawnSyncReturns; export const PLAIN_GH_SYSTEM_CANDIDATES: string[]; diff --git a/scripts/lib/plain-gh.mjs b/scripts/lib/plain-gh.mjs index 205e973f0f33..2f26b43bedc2 100644 --- a/scripts/lib/plain-gh.mjs +++ b/scripts/lib/plain-gh.mjs @@ -1,4 +1,4 @@ -import { execFileSync, spawnSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; @@ -98,13 +98,3 @@ export function execGhApiRead(endpoint, options = {}) { maxBuffer: options.maxBuffer ?? PLAIN_GH_MAX_BUFFER_BYTES, }); } - -export function spawnPlainGh(args, options = {}) { - const env = plainGhEnv(options.env ?? process.env); - const ghBin = resolvePlainGhBin(env); - return spawnSync(ghBin, args, { - ...options, - env, - maxBuffer: options.maxBuffer ?? PLAIN_GH_MAX_BUFFER_BYTES, - }); -} diff --git a/scripts/lib/policy-config-coverage.jsonc b/scripts/lib/policy-config-coverage.jsonc index 34cafe2da8a3..ac84cbedaf7e 100644 --- a/scripts/lib/policy-config-coverage.jsonc +++ b/scripts/lib/policy-config-coverage.jsonc @@ -26,7 +26,7 @@ "gateway.http.endpoints.*.*.urlAllowlist.*", "gateway.http.endpoints.*.enabled", "gateway.mode", - "gateway.nodes.denyCommands.*", + "gateway.nodes.commands.deny.*", "gateway.tailscale.mode", "gateway.tailscale.preserveFunnel", "logging.redactSensitive", @@ -60,9 +60,9 @@ "tools.web.fetch.ssrfPolicy.allowPrivateNetwork", "tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange", "tools.web.fetch.ssrfPolicy.dangerouslyAllowPrivateNetwork", - "agents.defaults.memorySearch.enabled", - "agents.defaults.memorySearch.experimental.sessionMemory", - "agents.defaults.memorySearch.sources.*", + "memory.search.enabled", + "memory.search.experimental.sessionMemory", + "memory.search.sources.*", "agents.defaults.model.fallbacks.*", "agents.defaults.model.primary", "agents.defaults.models.*.alias", @@ -90,36 +90,36 @@ "agents.defaults.tools.sandbox.tools.allow.*", "agents.defaults.tools.sandbox.tools.alsoAllow.*", "agents.defaults.tools.sandbox.tools.deny.*", - "agents.list.*.memorySearch.enabled", - "agents.list.*.memorySearch.experimental.sessionMemory", - "agents.list.*.memorySearch.sources.*", - "agents.list.*.model.fallbacks.*", - "agents.list.*.model.primary", - "agents.list.*.models.*.alias", - "agents.list.*.sandbox.backend", - "agents.list.*.sandbox.browser.binds.*", - "agents.list.*.sandbox.browser.cdpSourceRange", - "agents.list.*.sandbox.docker.apparmorProfile", - "agents.list.*.sandbox.docker.binds.*", - "agents.list.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin", - "agents.list.*.sandbox.docker.network", - "agents.list.*.sandbox.docker.readOnlyRoot", - "agents.list.*.sandbox.docker.seccompProfile", - "agents.list.*.sandbox.mode", - "agents.list.*.sandbox.workspaceAccess", - "agents.list.*.tools.allow.*", - "agents.list.*.tools.alsoAllow.*", - "agents.list.*.tools.deny.*", - "agents.list.*.tools.elevated.allowFrom.*.*", - "agents.list.*.tools.elevated.enabled", - "agents.list.*.tools.exec.ask", - "agents.list.*.tools.exec.host", - "agents.list.*.tools.exec.security", - "agents.list.*.tools.fs.workspaceOnly", - "agents.list.*.tools.profile", - "agents.list.*.tools.sandbox.tools.allow.*", - "agents.list.*.tools.sandbox.tools.alsoAllow.*", - "agents.list.*.tools.sandbox.tools.deny.*", + "agents.entries.*.memory.search.enabled", + "agents.entries.*.memory.search.experimental.sessionMemory", + "agents.entries.*.memory.search.sources.*", + "agents.entries.*.model.fallbacks.*", + "agents.entries.*.model.primary", + "agents.entries.*.models.*.alias", + "agents.entries.*.sandbox.backend", + "agents.entries.*.sandbox.browser.binds.*", + "agents.entries.*.sandbox.browser.cdpSourceRange", + "agents.entries.*.sandbox.docker.apparmorProfile", + "agents.entries.*.sandbox.docker.binds.*", + "agents.entries.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin", + "agents.entries.*.sandbox.docker.network", + "agents.entries.*.sandbox.docker.readOnlyRoot", + "agents.entries.*.sandbox.docker.seccompProfile", + "agents.entries.*.sandbox.mode", + "agents.entries.*.sandbox.workspaceAccess", + "agents.entries.*.tools.allow.*", + "agents.entries.*.tools.alsoAllow.*", + "agents.entries.*.tools.deny.*", + "agents.entries.*.tools.elevated.allowFrom.*.*", + "agents.entries.*.tools.elevated.enabled", + "agents.entries.*.tools.exec.ask", + "agents.entries.*.tools.exec.host", + "agents.entries.*.tools.exec.security", + "agents.entries.*.tools.fs.workspaceOnly", + "agents.entries.*.tools.profile", + "agents.entries.*.tools.sandbox.tools.allow.*", + "agents.entries.*.tools.sandbox.tools.alsoAllow.*", + "agents.entries.*.tools.sandbox.tools.deny.*", ], "classifications": [ { @@ -205,42 +205,42 @@ "reason": "Policy observes QMD session-transcript indexing.", }, { - "pattern": "agents.defaults.memorySearch.enabled", + "pattern": "memory.search.enabled", "status": "observed", "area": "dataHandling", "policy": "dataHandling.memory.denySessionTranscriptIndexing", "reason": "Policy observes default memory-search session indexing enablement.", }, { - "pattern": "agents.defaults.memorySearch.experimental.sessionMemory", + "pattern": "memory.search.experimental.sessionMemory", "status": "observed", "area": "dataHandling", "policy": "dataHandling.memory.denySessionTranscriptIndexing", "reason": "Policy observes default memory-search session-memory toggle.", }, { - "pattern": "agents.defaults.memorySearch.sources.*", + "pattern": "memory.search.sources.*", "status": "observed", "area": "dataHandling", "policy": "dataHandling.memory.denySessionTranscriptIndexing", "reason": "Policy observes whether default memory-search sources include sessions.", }, { - "pattern": "agents.list.*.memorySearch.enabled", + "pattern": "agents.entries.*.memory.search.enabled", "status": "observed", "area": "dataHandling", "policy": "dataHandling.memory.denySessionTranscriptIndexing", "reason": "Policy observes per-agent memory-search session indexing enablement.", }, { - "pattern": "agents.list.*.memorySearch.experimental.sessionMemory", + "pattern": "agents.entries.*.memory.search.experimental.sessionMemory", "status": "observed", "area": "dataHandling", "policy": "dataHandling.memory.denySessionTranscriptIndexing", "reason": "Policy observes per-agent memory-search session-memory toggle.", }, { - "pattern": "agents.list.*.memorySearch.sources.*", + "pattern": "agents.entries.*.memory.search.sources.*", "status": "observed", "area": "dataHandling", "policy": "dataHandling.memory.denySessionTranscriptIndexing", @@ -401,7 +401,7 @@ "reason": "Policy observes URL-fetch allowlists on Gateway HTTP inputs.", }, { - "pattern": "gateway.nodes.denyCommands.*", + "pattern": "gateway.nodes.commands.deny.*", "status": "observed", "area": "gateway", "policy": "gateway.nodes.denyCommands", @@ -475,14 +475,14 @@ "reason": "Policy observes default agent model aliases.", }, { - "pattern": "agents.list.*.model.**", + "pattern": "agents.entries.*.model.**", "status": "observed", "area": "models", "policy": "models.providers.allow / models.providers.deny", "reason": "Policy observes per-agent model refs.", }, { - "pattern": "agents.list.*.models.*.alias", + "pattern": "agents.entries.*.models.*.alias", "status": "observed", "area": "models", "policy": "models.providers.allow / models.providers.deny", @@ -597,7 +597,7 @@ "allowNoSchemaPath": true, }, { - "pattern": "agents.list.*.tools.**", + "pattern": "agents.entries.*.tools.**", "status": "observed", "area": "tools", "policy": "tools.* scoped by agentIds", @@ -611,7 +611,7 @@ "reason": "Policy observes sandbox mode posture.", }, { - "pattern": "agents.list.*.sandbox.mode", + "pattern": "agents.entries.*.sandbox.mode", "status": "observed", "area": "sandbox", "policy": "sandbox.requireMode", @@ -625,7 +625,7 @@ "reason": "Policy observes sandbox backend posture.", }, { - "pattern": "agents.list.*.sandbox.backend", + "pattern": "agents.entries.*.sandbox.backend", "status": "observed", "area": "sandbox", "policy": "sandbox.allowBackends", @@ -639,7 +639,7 @@ "reason": "Policy observes sandbox workspace access posture.", }, { - "pattern": "agents.list.*.sandbox.workspaceAccess", + "pattern": "agents.entries.*.sandbox.workspaceAccess", "status": "observed", "area": "agents", "policy": "agents.workspace.allowedAccess", @@ -653,7 +653,7 @@ "reason": "Policy observes Docker container network posture.", }, { - "pattern": "agents.list.*.sandbox.docker.network", + "pattern": "agents.entries.*.sandbox.docker.network", "status": "observed", "area": "sandbox", "policy": "sandbox.containers.denyHostNetwork and sandbox.containers.denyContainerNamespaceJoin", @@ -667,7 +667,7 @@ "reason": "Policy observes Docker bind mount posture.", }, { - "pattern": "agents.list.*.sandbox.docker.binds.*", + "pattern": "agents.entries.*.sandbox.docker.binds.*", "status": "observed", "area": "sandbox", "policy": "sandbox.containers.requireReadOnlyMounts and sandbox.containers.denyContainerRuntimeSocketMounts", @@ -681,7 +681,7 @@ "reason": "Policy observes sandbox browser bind mount posture.", }, { - "pattern": "agents.list.*.sandbox.browser.binds.*", + "pattern": "agents.entries.*.sandbox.browser.binds.*", "status": "observed", "area": "sandbox", "policy": "sandbox.containers.requireReadOnlyMounts", @@ -695,7 +695,7 @@ "reason": "Policy observes Docker AppArmor profile posture.", }, { - "pattern": "agents.list.*.sandbox.docker.apparmorProfile", + "pattern": "agents.entries.*.sandbox.docker.apparmorProfile", "status": "observed", "area": "sandbox", "policy": "sandbox.containers.denyUnconfinedProfiles", @@ -709,7 +709,7 @@ "reason": "Policy observes Docker seccomp profile posture.", }, { - "pattern": "agents.list.*.sandbox.docker.seccompProfile", + "pattern": "agents.entries.*.sandbox.docker.seccompProfile", "status": "observed", "area": "sandbox", "policy": "sandbox.containers.denyUnconfinedProfiles", @@ -723,7 +723,7 @@ "reason": "Policy observes explicit Docker namespace-join escape posture.", }, { - "pattern": "agents.list.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin", + "pattern": "agents.entries.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin", "status": "observed", "area": "sandbox", "policy": "sandbox.containers.denyContainerNamespaceJoin", @@ -737,7 +737,7 @@ "reason": "Policy observes Docker read-only root posture.", }, { - "pattern": "agents.list.*.sandbox.docker.readOnlyRoot", + "pattern": "agents.entries.*.sandbox.docker.readOnlyRoot", "status": "observed", "area": "sandbox", "policy": "sandbox.containers.requireReadOnlyMounts", @@ -751,7 +751,7 @@ "reason": "Policy observes sandbox browser CDP source range posture.", }, { - "pattern": "agents.list.*.sandbox.browser.cdpSourceRange", + "pattern": "agents.entries.*.sandbox.browser.cdpSourceRange", "status": "observed", "area": "sandbox", "policy": "sandbox.browser.requireCdpSourceRange", diff --git a/scripts/mcp-code-mode-gateway-e2e.ts b/scripts/mcp-code-mode-gateway-e2e.ts index 63fd7a3de8db..a1b5a2372473 100644 --- a/scripts/mcp-code-mode-gateway-e2e.ts +++ b/scripts/mcp-code-mode-gateway-e2e.ts @@ -125,20 +125,11 @@ async function writeConfig(params: { memory: "none", }, }, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - memorySearch: { - ...cfg.agents?.defaults?.memorySearch, - enabled: false, - sync: { - ...cfg.agents?.defaults?.memorySearch?.sync, - onSearch: false, - onSessionStart: false, - watch: false, - }, - }, + memory: { + ...cfg.memory, + search: { + ...cfg.memory?.search, + enabled: false, }, }, tools: { diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 89223f500b01..f066ba90c8ab 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -105,7 +105,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({ "inbound-reply-dispatch": 26, "channel-reply-pipeline": 12, "interactive-runtime": 13, - "infra-runtime": 593, + "infra-runtime": 594, "ssrf-policy": 1, "ssrf-runtime": 1, "media-runtime": 2, @@ -152,7 +152,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", // +1: session discussion provider registration. // +1: structured media placeholder formatter for text-only channel carriers. - 2879, + 2880, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 61ee68d79574..79880f002df3 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -796,6 +796,9 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["scripts/lib/ci-changed-node-test-plan.mjs", ["test/scripts/ci-changed-node-test-plan.test.ts"]], ["scripts/check.mjs", ["test/scripts/check.test.ts"]], ["scripts/check-changed.mjs", ["test/scripts/changed-lanes.test.ts"]], + ["scripts/check-env-var-count.mjs", ["test/scripts/check-env-var-count.test.ts"]], + ["scripts/check-env-var-count.d.mts", ["test/scripts/check-env-var-count.test.ts"]], + ["config/env-var-count-budget.txt", ["test/scripts/check-env-var-count.test.ts"]], ["scripts/check-max-lines-ratchet.mjs", ["test/scripts/check-max-lines-ratchet.test.ts"]], [ "scripts/check-native-state-schema-version.mjs", diff --git a/src/acp/control-plane/manager.runtime-handles.test.ts b/src/acp/control-plane/manager.runtime-handles.test.ts index 7bfbe49d38f6..c539a79d3b99 100644 --- a/src/acp/control-plane/manager.runtime-handles.test.ts +++ b/src/acp/control-plane/manager.runtime-handles.test.ts @@ -66,7 +66,7 @@ describe("AcpSessionManager runtime handles", () => { ...baseCfg, tools: { exec: { - security: "allowlist", + mode: "allowlist", safeBins: ["git"], }, }, @@ -75,7 +75,7 @@ describe("AcpSessionManager runtime handles", () => { ...baseCfg, tools: { exec: { - security: "deny", + mode: "deny", safeBins: ["node"], }, }, diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 5f8c6784ed88..5bcba16cf85d 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -36,13 +36,9 @@ function createDefaultSpawnConfig(): OpenClawConfig { session: { mainKey: "main", scope: "per-sender", - }, - channels: { - discord: { - threadBindings: { - enabled: true, - spawnSessions: true, - }, + threadBindings: { + enabled: true, + spawnSessions: true, }, }, }; @@ -629,12 +625,11 @@ function enableLineCurrentConversationBindings(): void { function enableTelegramCurrentConversationBindings(): void { replaceSpawnConfig({ ...hoisted.state.cfg, - channels: { - ...hoisted.state.cfg.channels, - telegram: { - threadBindings: { - enabled: true, - }, + session: { + ...hoisted.state.cfg.session, + threadBindings: { + ...hoisted.state.cfg.session?.threadBindings, + enabled: true, }, }, }); @@ -2532,12 +2527,11 @@ describe("spawnAcpDirect", () => { it("fails fast when Discord ACP thread spawn is disabled", async () => { replaceSpawnConfig({ ...hoisted.state.cfg, - channels: { - discord: { - threadBindings: { - enabled: true, - spawnSessions: false, - }, + session: { + ...hoisted.state.cfg.session, + threadBindings: { + enabled: true, + spawnSessions: false, }, }, }); diff --git a/src/agents/agent-create.test.ts b/src/agents/agent-create.test.ts index 800ccc9e1b60..984721e94fe0 100644 --- a/src/agents/agent-create.test.ts +++ b/src/agents/agent-create.test.ts @@ -150,8 +150,10 @@ describe("createAgent", () => { const result = await createAgent({ name: "researcher", workspace: "/tmp/work" }); - const agents = mocks.persisted.agents as { list?: Array<{ workspace?: string }> } | undefined; - expect(agents?.list?.at(-1)?.workspace).toBe("/normalized/work"); + const agents = mocks.persisted.agents as + | { entries?: Record } + | undefined; + expect(agents?.entries?.researcher?.workspace).toBe("/normalized/work"); expect(result).toMatchObject({ status: "created", workspace: "/normalized/work" }); }); @@ -166,16 +168,15 @@ describe("createAgent", () => { expect(mocks.transformConfigFileWithRetry).toHaveBeenCalledOnce(); expect(mocks.persisted).toMatchObject({ agents: { - list: expect.arrayContaining([ - { - id: "researcher", + entries: { + researcher: { name: "Researcher", workspace: "/tmp/work", agentDir: "/tmp/agent-researcher", model: "openai/gpt-5.5", identity: { name: "Researcher", emoji: "🔎" }, }, - ]), + }, }, }); expect(result).toMatchObject({ status: "created", agentId: "researcher" }); @@ -199,9 +200,9 @@ describe("createAgent", () => { expect(mocks.rootWrite).not.toHaveBeenCalled(); expect(mocks.persisted).toMatchObject({ agents: { - list: expect.arrayContaining([ - expect.objectContaining({ identity: { name: "researcher" } }), - ]), + entries: { + researcher: expect.objectContaining({ identity: { name: "researcher" } }), + }, }, }); }); diff --git a/src/agents/agent-hooks/compaction-safeguard-runtime.ts b/src/agents/agent-hooks/compaction-safeguard-runtime.ts index 47e091f8f685..13e393c9d008 100644 --- a/src/agents/agent-hooks/compaction-safeguard-runtime.ts +++ b/src/agents/agent-hooks/compaction-safeguard-runtime.ts @@ -7,7 +7,7 @@ import { createSessionManagerRuntimeRegistry } from "./session-manager-runtime-r type CompactionSafeguardRuntimeValue = { maxHistoryShare?: number; contextWindowTokens?: number; - identifierPolicy?: AgentCompactionIdentifierPolicy; + identifierPolicy?: AgentCompactionIdentifierPolicy | "custom"; identifierInstructions?: string; customInstructions?: string; /** diff --git a/src/agents/agent-runtime-config.ts b/src/agents/agent-runtime-config.ts index 48242bf6ed86..07249a4c227b 100644 --- a/src/agents/agent-runtime-config.ts +++ b/src/agents/agent-runtime-config.ts @@ -9,6 +9,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isSecretRef } from "../config/types.secrets.js"; import type { RuntimeEnv } from "../runtime.js"; import { discoverConfigSecretTargetsByIds } from "../secrets/target-registry.js"; +import { listAgentEntries } from "./agent-scope.js"; /** Loads runtime/source config and resolves command SecretRefs when the agent path needs them. */ export async function resolveAgentRuntimeConfig( @@ -101,16 +102,20 @@ function hasAgentRuntimeSecretRefs(params: { if (hasNestedSecretRef(config.models?.providers)) { return true; } - if (hasNestedSecretRef(config.agents?.defaults?.memorySearch?.remote?.apiKey)) { + if (hasNestedSecretRef(config.memory?.search?.remote)) { return true; } if ( - Array.isArray(config.agents?.list) && - config.agents.list.some((agent) => hasNestedSecretRef(agent?.memorySearch?.remote?.apiKey)) + listAgentEntries(config).some((agent) => + hasNestedSecretRef({ + memoryRemote: agent.memory?.search?.remote, + ttsProviders: agent.tts?.providers, + }), + ) ) { return true; } - if (hasNestedSecretRef(config.messages?.tts?.providers)) { + if (hasNestedSecretRef(config.tts?.providers)) { return true; } if (hasNestedSecretRef(config.skills?.entries)) { diff --git a/src/agents/agent-scope-config.ts b/src/agents/agent-scope-config.ts index 0822e3e5b8e0..50946a598577 100644 --- a/src/agents/agent-scope-config.ts +++ b/src/agents/agent-scope-config.ts @@ -34,8 +34,10 @@ export type ResolvedAgentConfig = { bootstrapTotalMaxChars?: AgentEntry["bootstrapTotalMaxChars"]; experimental?: AgentDefaultsConfig["experimental"]; skills?: AgentEntry["skills"]; - memorySearch?: AgentEntry["memorySearch"]; + memory?: AgentEntry["memory"]; humanDelay?: AgentEntry["humanDelay"]; + typingMode?: AgentEntry["typingMode"]; + typingIntervalSeconds?: AgentEntry["typingIntervalSeconds"]; tts?: AgentEntry["tts"]; contextLimits?: AgentContextLimitsConfig; heartbeat?: AgentEntry["heartbeat"]; @@ -66,6 +68,10 @@ function stripNullBytes(s: string): string { /** Lists valid configured agent entries from config. */ export function listAgentEntries(cfg: OpenClawConfig): AgentEntry[] { + const entries = cfg.agents?.entries; + if (entries && typeof entries === "object") { + return Object.entries(entries).map(([id, entry]) => Object.assign({ id }, entry)); + } const list = cfg.agents?.list; if (!Array.isArray(list)) { return []; @@ -137,7 +143,7 @@ export function resolveAgentConfig( thinkingDefault: entry.thinkingDefault, verboseDefault: entry.verboseDefault ?? agentDefaults?.verboseDefault, reasoningDefault: entry.reasoningDefault, - fastModeDefault: entry.fastModeDefault, + fastModeDefault: entry.fastModeDefault ?? agentDefaults?.fastModeDefault, contextTokens: entry.contextTokens ?? agentDefaults?.contextTokens, contextInjection: entry.contextInjection, bootstrapMaxChars: entry.bootstrapMaxChars, @@ -147,8 +153,10 @@ export function resolveAgentConfig( ? { ...agentDefaults?.experimental, ...entry.experimental } : agentDefaults?.experimental, skills: Array.isArray(entry.skills) ? entry.skills : undefined, - memorySearch: entry.memorySearch, + memory: entry.memory, humanDelay: entry.humanDelay, + typingMode: entry.typingMode ?? agentDefaults?.typingMode, + typingIntervalSeconds: entry.typingIntervalSeconds ?? agentDefaults?.typingIntervalSeconds, tts: entry.tts, contextLimits: typeof entry.contextLimits === "object" && entry.contextLimits diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index 415605120849..b0960a107a14 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -367,8 +367,17 @@ export function resolveAgentEffectiveModelPrimary( ); } -function findMutableAgentEntry(cfg: OpenClawConfig, agentId: string): AgentConfig | undefined { +function findMutableAgentEntry( + cfg: OpenClawConfig, + agentId: string, +): Pick | undefined { const id = normalizeAgentId(agentId); + const entryKey = Object.keys(cfg.agents?.entries ?? {}).find( + (key) => normalizeAgentId(key) === id, + ); + if (entryKey) { + return cfg.agents?.entries?.[entryKey]; + } return cfg.agents?.list?.find((entry) => normalizeAgentId(entry?.id) === id); } diff --git a/src/agents/agent-tools-agent-config.exec.test.ts b/src/agents/agent-tools-agent-config.exec.test.ts index d9502e15fd5e..2a63e40c3fb4 100644 --- a/src/agents/agent-tools-agent-config.exec.test.ts +++ b/src/agents/agent-tools-agent-config.exec.test.ts @@ -20,8 +20,7 @@ function createExecHostDefaultsConfig( tools: { exec: { host: "auto", - security: "full", - ask: "off", + mode: "full", }, }, agents: { @@ -75,8 +74,7 @@ describe("Agent-specific exec tool defaults", () => { deny: ["process"], exec: { host: "gateway", - security: "full", - ask: "off", + mode: "full", }, }, }; @@ -102,8 +100,7 @@ describe("Agent-specific exec tool defaults", () => { config: { tools: { exec: { - security: "full", - ask: "off", + mode: "full", }, }, }, @@ -177,7 +174,7 @@ describe("Agent-specific exec tool defaults", () => { id: "main", tools: { exec: { - ask: "off", + mode: "allowlist", }, }, }, diff --git a/src/agents/agent-tools.policy.test.ts b/src/agents/agent-tools.policy.test.ts index 82613a011dce..be585792cdf0 100644 --- a/src/agents/agent-tools.policy.test.ts +++ b/src/agents/agent-tools.policy.test.ts @@ -443,7 +443,7 @@ describe("resolveEffectiveToolPolicy", () => { it("global tools.exec does not widen agent messaging profile (#47487)", () => { const cfg = { tools: { - exec: { security: "allowlist" }, + exec: { mode: "allowlist" }, }, agents: { list: [ @@ -468,7 +468,7 @@ describe("resolveEffectiveToolPolicy", () => { try { const cfg = { tools: { - exec: { security: "allowlist" }, + exec: { mode: "allowlist" }, fs: { workspaceOnly: true }, }, agents: { @@ -502,7 +502,7 @@ describe("resolveEffectiveToolPolicy", () => { id: "sage", tools: { profile: "messaging", - exec: { security: "allowlist" }, + exec: { mode: "allowlist" }, }, }, ], @@ -531,7 +531,7 @@ describe("resolveEffectiveToolPolicy", () => { tools: { profile: "messaging", alsoAllow: ["read", "write", "edit"], - exec: { security: "allowlist" }, + exec: { mode: "allowlist" }, fs: { workspaceOnly: true }, }, }, diff --git a/src/agents/agent-tools.safe-bins.test.ts b/src/agents/agent-tools.safe-bins.test.ts index 10f918f3c3e7..2e28123a3051 100644 --- a/src/agents/agent-tools.safe-bins.test.ts +++ b/src/agents/agent-tools.safe-bins.test.ts @@ -204,8 +204,7 @@ async function createSafeBinsExecTool(params: { tools: { exec: { host: "gateway", - security: "allowlist", - ask: "off", + mode: "allowlist", safeBins: params.safeBins, safeBinProfiles: params.safeBinProfiles, }, diff --git a/src/agents/auth-profiles.external-cli-scope.test.ts b/src/agents/auth-profiles.external-cli-scope.test.ts index 712b3e447634..4e8fcdc681a6 100644 --- a/src/agents/auth-profiles.external-cli-scope.test.ts +++ b/src/agents/auth-profiles.external-cli-scope.test.ts @@ -55,7 +55,7 @@ describe("external CLI auth scope", () => { primary: "anthropic/claude-opus-4-7", fallbacks: ["openai/gpt-5.5"], }, - imageGenerationModel: "minimax-portal/image-01", + mediaModels: { image: "minimax-portal/image-01" }, voiceModel: "elevenlabs/eleven_multilingual_v2", cliBackends: { "claude-cli": { command: "claude" }, @@ -64,16 +64,15 @@ describe("external CLI auth scope", () => { "claude-cli/claude-opus-4-7": { alias: "opus" }, }, }, - list: [ - { - id: "worker", + entries: { + worker: { model: "opencode-go/kimi-k2.6", models: { "opencode-go/kimi-k2.6": { agentRuntime: { id: "codex-app-server" } }, }, subagents: { model: { primary: "z.ai/glm-4.7" } }, }, - ], + }, }, } satisfies OpenClawConfig; diff --git a/src/agents/auth-profiles/external-cli-scope.ts b/src/agents/auth-profiles/external-cli-scope.ts index d0b5ed3cf0a9..cd64a2ebaf70 100644 --- a/src/agents/auth-profiles/external-cli-scope.ts +++ b/src/agents/auth-profiles/external-cli-scope.ts @@ -110,9 +110,9 @@ export function resolveExternalCliAuthScopeFromConfig( const defaults = cfg.agents?.defaults; addProviderScopeFromModelConfig(providerIds, defaults?.model); addProviderScopeFromModelConfig(providerIds, defaults?.imageModel); - addProviderScopeFromModelConfig(providerIds, defaults?.imageGenerationModel); - addProviderScopeFromModelConfig(providerIds, defaults?.videoGenerationModel); - addProviderScopeFromModelConfig(providerIds, defaults?.musicGenerationModel); + addProviderScopeFromModelConfig(providerIds, defaults?.mediaModels?.image); + addProviderScopeFromModelConfig(providerIds, defaults?.mediaModels?.video); + addProviderScopeFromModelConfig(providerIds, defaults?.mediaModels?.music); addProviderScopeFromModelConfig(providerIds, defaults?.voiceModel); addProviderScopeFromModelConfig(providerIds, defaults?.pdfModel); addExternalCliRuntimeScopeFromModelMap(providerIds, defaults?.models); @@ -123,8 +123,7 @@ export function resolveExternalCliAuthScopeFromConfig( } } - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - for (const agent of agents) { + for (const agent of Object.values(cfg.agents?.entries ?? {})) { addProviderScopeFromModelConfig(providerIds, agent.model); addProviderScopeFromModelConfig(providerIds, agent.subagents?.model); addExternalCliRuntimeScopeFromModelMap(providerIds, agent.models); diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 8cb1da103808..f5c92e29ceb1 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -440,7 +440,7 @@ describe("resolveBootstrapContextForRun", () => { expect(files.map((file) => file.name)).toContain("SOUL.md"); }); - it("drops HEARTBEAT.md for non-heartbeat runs when the heartbeat prompt section is disabled", async () => { + it("keeps HEARTBEAT.md for non-heartbeat runs when heartbeat cadence is enabled", async () => { const workspaceDir = await createHeartbeatAgentsWorkspace(); const files = await resolveBootstrapFilesForRun({ @@ -448,16 +448,14 @@ describe("resolveBootstrapContextForRun", () => { config: { agents: { defaults: { - heartbeat: { - includeSystemPromptSection: false, - }, + heartbeat: {}, }, list: [{ id: "main" }], }, }, }); - expectHeartbeatExcludedAndAgentsKept(files); + expect(files.map((file) => file.name)).toContain("HEARTBEAT.md"); }); it("drops HEARTBEAT.md for non-heartbeat runs when the heartbeat cadence is disabled", async () => { @@ -480,7 +478,7 @@ describe("resolveBootstrapContextForRun", () => { expectHeartbeatExcludedAndAgentsKept(files); }); - it("keeps HEARTBEAT.md for actual heartbeat runs even when the prompt section is disabled", async () => { + it("keeps HEARTBEAT.md for actual heartbeat runs", async () => { const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8"); @@ -489,11 +487,7 @@ describe("resolveBootstrapContextForRun", () => { runKind: "heartbeat", config: { agents: { - defaults: { - heartbeat: { - includeSystemPromptSection: false, - }, - }, + defaults: { heartbeat: {} }, list: [{ id: "main" }], }, }, diff --git a/src/agents/cache-trace.test.ts b/src/agents/cache-trace.test.ts index 1505e96c3290..359854a515c7 100644 --- a/src/agents/cache-trace.test.ts +++ b/src/agents/cache-trace.test.ts @@ -2,7 +2,6 @@ import crypto from "node:crypto"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { resolveUserPath } from "../utils.js"; import { createCacheTrace } from "./cache-trace.js"; describe("createCacheTrace", () => { @@ -43,18 +42,17 @@ describe("createCacheTrace", () => { expect(trace).toBeNull(); }); - it("honors diagnostics cache trace config and expands file paths", () => { + it("uses the fixed cache trace path under the state directory", () => { const lines: string[] = []; const trace = createCacheTrace({ cfg: { diagnostics: { cacheTrace: { enabled: true, - filePath: "~/.openclaw/logs/cache-trace.jsonl", }, }, }, - env: {}, + env: { OPENCLAW_STATE_DIR: "/tmp/openclaw-cache-trace" }, writer: { filePath: "memory", write: (line) => lines.push(line), @@ -63,7 +61,7 @@ describe("createCacheTrace", () => { }); expect(typeof trace?.recordStage).toBe("function"); - expect(trace?.filePath).toBe(resolveUserPath("~/.openclaw/logs/cache-trace.jsonl")); + expect(trace?.filePath).toBe("/tmp/openclaw-cache-trace/logs/cache-trace.jsonl"); trace?.recordStage("session:loaded", { messages: [], diff --git a/src/agents/cache-trace.ts b/src/agents/cache-trace.ts index a66f929726c2..16c4558dac1b 100644 --- a/src/agents/cache-trace.ts +++ b/src/agents/cache-trace.ts @@ -91,15 +91,14 @@ function resolveCacheTraceConfig(params: CacheTraceInit): CacheTraceConfig { const config = params.cfg?.diagnostics?.cacheTrace; const envEnabled = parseBooleanValue(env.OPENCLAW_CACHE_TRACE); const enabled = envEnabled ?? config?.enabled ?? false; - const fileOverride = config?.filePath?.trim() || env.OPENCLAW_CACHE_TRACE_FILE?.trim(); + const fileOverride = env.OPENCLAW_CACHE_TRACE_FILE?.trim(); const filePath = fileOverride ? resolveUserPath(fileOverride) : path.join(resolveStateDir(env), "logs", "cache-trace.jsonl"); - const includeMessages = - parseBooleanValue(env.OPENCLAW_CACHE_TRACE_MESSAGES) ?? config?.includeMessages; - const includePrompt = parseBooleanValue(env.OPENCLAW_CACHE_TRACE_PROMPT) ?? config?.includePrompt; - const includeSystem = parseBooleanValue(env.OPENCLAW_CACHE_TRACE_SYSTEM) ?? config?.includeSystem; + const includeMessages = parseBooleanValue(env.OPENCLAW_CACHE_TRACE_MESSAGES); + const includePrompt = parseBooleanValue(env.OPENCLAW_CACHE_TRACE_PROMPT); + const includeSystem = parseBooleanValue(env.OPENCLAW_CACHE_TRACE_SYSTEM); return { enabled, diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 9e1533e7a957..c1c381479f2b 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -145,7 +145,7 @@ function isTestYoloConfig(context?: CliBackendNormalizeConfigContext): boolean { ? context.config?.agents?.list?.find((agent) => agent.id === context.agentId)?.tools?.exec : undefined; const exec = agentExec ?? context?.config?.tools?.exec; - return (exec?.security ?? "full") === "full" && (exec?.ask ?? "off") === "off"; + return (exec?.mode ?? "full") === "full"; } function normalizeTestPermissionMode(context?: CliBackendNormalizeConfigContext): { @@ -429,7 +429,7 @@ beforeEach(() => { ...claudeBackend, config: { ...claudeBackend.config, - sessionArg: "--session-id", + sessionArgs: ["--session-id", "{sessionId}"], sessionMode: "always", systemPromptFileArg: "--append-system-prompt-file", systemPromptWhen: "always", // fix(#80374): was "first" @@ -568,7 +568,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { it("keeps Claude permission mode unset when OpenClaw exec policy is not YOLO", () => { const resolved = requireCliBackendConfig("claude-cli", { - tools: { exec: { security: "allowlist", ask: "on-miss" } }, + tools: { exec: { mode: "ask" } }, }); expect(resolved?.config.args).not.toContain("--permission-mode"); @@ -579,16 +579,16 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { it("derives Claude permission mode from per-agent exec policy when an agent id is known", () => { const cfg = { - tools: { exec: { security: "full", ask: "off" } }, + tools: { exec: { mode: "full" } }, agents: { list: [ { id: "reviewer", - tools: { exec: { security: "allowlist", ask: "on-miss" } }, + tools: { exec: { mode: "ask" } }, }, { id: "builder", - tools: { exec: { security: "full", ask: "off" } }, + tools: { exec: { mode: "full" } }, }, ], }, @@ -607,7 +607,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { it("preserves raw Claude permission args during backend normalization", () => { const safe = resolveCliBackendConfig("claude-cli", { - tools: { exec: { security: "full", ask: "off" } }, + tools: { exec: { mode: "full" } }, agents: { defaults: { cliBackends: { @@ -621,7 +621,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, }); const yolo = resolveCliBackendConfig("claude-cli", { - tools: { exec: { security: "deny", ask: "always" } }, + tools: { exec: { mode: "deny" } }, agents: { defaults: { cliBackends: { @@ -698,7 +698,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, }, }, - tools: { exec: { security: "allowlist", ask: "on-miss" } }, + tools: { exec: { mode: "ask" } }, } satisfies OpenClawConfig; const resolved = requireCliBackendConfig("claude-cli", cfg); @@ -799,7 +799,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { args: ["-p", "--setting-sources", "--output-format", "stream-json"], resumeArgs: ["-p", "--setting-sources", "--resume", "{sessionId}"], }), - tools: { exec: { security: "allowlist", ask: "on-miss" } }, + tools: { exec: { mode: "ask" } }, } satisfies OpenClawConfig; const resolved = requireCliBackendConfig("claude-cli", cfg); @@ -815,7 +815,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { args: ["-p", "--permission-mode", "--output-format", "stream-json"], resumeArgs: ["-p", "--permission-mode=--resume", "--resume", "{sessionId}"], }), - tools: { exec: { security: "allowlist", ask: "on-miss" } }, + tools: { exec: { mode: "ask" } }, } satisfies OpenClawConfig; const resolved = requireCliBackendConfig("claude-cli", cfg); @@ -844,7 +844,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { }, }, }, - tools: { exec: { security: "allowlist", ask: "on-miss" } }, + tools: { exec: { mode: "ask" } }, } satisfies OpenClawConfig; const resolved = requireCliBackendConfig("claude-cli", cfg); @@ -936,7 +936,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => { ]); expect(resolved?.config.systemPromptFileArg).toBe("--append-system-prompt-file"); expect(resolved?.config.systemPromptWhen).toBe("always"); // fix(#80374): was "first" - expect(resolved?.config.sessionArg).toBe("--session-id"); + expect(resolved?.config.sessionArgs).toEqual(["--session-id", "{sessionId}"]); expect(resolved?.config.sessionMode).toBe("always"); expect(resolved?.config.input).toBe("stdin"); expect(resolved?.config.output).toBe("jsonl"); diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index f9705897b41b..99b012ea86b3 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -1879,7 +1879,7 @@ describe("runCliAgent reliability", () => { output: "jsonl" as const, input: "stdin" as const, modelArg: "--model", - sessionArg: "--session-id", + sessionArgs: ["--session-id", "{sessionId}"], sessionMode: "always" as const, liveSession: "claude-stdio" as const, reliability: { diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 8549e8304f3a..37783d2c9f6c 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -193,7 +193,7 @@ function buildPreparedCliRunContext(params: { output: "jsonl" as const, input: "stdin" as const, modelArg: "--model", - sessionArg: "--session-id", + sessionArgs: ["--session-id", "{sessionId}"], sessionMode: "always" as const, systemPromptFileArg: "--append-system-prompt-file", systemPromptWhen: "first" as const, @@ -869,7 +869,7 @@ describe("runCliAgent spawn path", () => { output: "jsonl" as const, input: "stdin" as const, modelArg: "--model", - sessionArg: "--session-id", + sessionArgs: ["--session-id", "{sessionId}"], systemPromptArg: "--append-system-prompt", systemPromptWhen: "first" as const, serialize: true, @@ -1083,7 +1083,7 @@ describe("runCliAgent spawn path", () => { } }); - it("captures only representable Claude prompt, system, and assistant content when opted in", async () => { + it("captures only representable Claude prompt and assistant content when opted in", async () => { const prompt = "Explain the trace"; const stdout = [ @@ -1133,13 +1133,7 @@ describe("runCliAgent spawn path", () => { otel: { enabled: true, traces: true, - captureContent: { - enabled: true, - inputMessages: true, - outputMessages: true, - systemPrompt: true, - toolDefinitions: true, - }, + captureContent: true, }, }, }, @@ -1152,7 +1146,6 @@ describe("runCliAgent spawn path", () => { ); expect(completed?.privateData.modelContent).toEqual({ inputMessages: [{ role: "user", content: [{ type: "text", text: prompt }] }], - systemPrompt: "You are a helpful assistant.", outputMessages: [ { role: "assistant", @@ -2124,12 +2117,7 @@ describe("runCliAgent spawn path", () => { otel: { enabled: true, traces: true, - captureContent: { - enabled: true, - inputMessages: true, - outputMessages: true, - systemPrompt: true, - }, + captureContent: true, }, }, }, @@ -3343,7 +3331,7 @@ describe("runCliAgent spawn path", () => { args: ["-p", "--output-format", "stream-json"], output: "jsonl", input: "stdin", - sessionArg: "--session-id", + sessionArgs: ["--session-id", "{sessionId}"], systemPromptArg: "--append-system-prompt", systemPromptFileArg: "--append-system-prompt-file", }; @@ -3384,7 +3372,7 @@ describe("runCliAgent spawn path", () => { args: ["-p"], output: "jsonl", input: "stdin", - sessionArg: "--session-id", + sessionArgs: ["--session-id", "{sessionId}"], systemPromptArg: "--append-system-prompt", systemPromptFileArg: "--append-system-prompt-file", }; diff --git a/src/agents/cli-runner/claude-live-session.background-tasks.test.ts b/src/agents/cli-runner/claude-live-session.background-tasks.test.ts index faa63f8d2c3a..f5e4d74dcf9e 100644 --- a/src/agents/cli-runner/claude-live-session.background-tasks.test.ts +++ b/src/agents/cli-runner/claude-live-session.background-tasks.test.ts @@ -58,7 +58,7 @@ function buildPreparedCliRunContext(params: { output: "jsonl" as const, input: "stdin" as const, modelArg: "--model", - sessionArg: "--session-id", + sessionArgs: ["--session-id", "{sessionId}"], sessionMode: "always" as const, systemPromptFileArg: "--append-system-prompt-file", systemPromptWhen: "first" as const, diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index 80a2cf045ac2..7511dae41ed9 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -19,6 +19,7 @@ import { minSecurity, normalizeExecAsk, resolveExecApprovalsFromFile, + resolveExecModePolicy, type ExecAsk, type ExecSecurity, } from "../../infra/exec-approvals.js"; @@ -265,7 +266,6 @@ function stripLiveProcessArgs( ): string[] { const liveProcessFlags = new Set( [ - backend.sessionArg, "--session-id", stripSystemPrompt ? backend.systemPromptArg : undefined, stripSystemPrompt ? backend.systemPromptFileArg : undefined, @@ -386,7 +386,6 @@ function buildClaudeLiveFingerprint(params: { ); const unstableValueFlags = new Set( [ - params.context.preparedBackend.backend.sessionArg, "--session-id", normalizeMcpConfigPath ? "--mcp-config" : undefined, normalizePluginDir ? "--plugin-dir" : undefined, @@ -969,8 +968,13 @@ function readConfiguredExecPolicy(context: PreparedCliRunContext): { const agentExec = context.params.config?.agents?.list?.find((agent) => agent.id === agentId) ?.tools?.exec; const exec = agentExec ?? context.params.config?.tools?.exec; - const security = exec?.security ?? "full"; - const configuredAsk = exec?.ask ?? "off"; + const configured = resolveExecModePolicy({ + mode: exec?.mode, + security: exec?.security ?? "full", + ask: exec?.ask ?? "off", + }); + const security = configured.security; + const configuredAsk = configured.ask; const sessionAsk = normalizeExecAsk(context.params.sessionEntry?.execAsk); return { agentId, diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index e5456711710b..2e6054efec68 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -196,7 +196,6 @@ function setCliRunnerExecuteTestDeps(overrides: Partial): vo function buildCliLogArgs(params: { args: string[]; systemPromptArg?: string; - sessionArg?: string; modelArg?: string; imageArg?: string; argsPrompt?: string; @@ -210,11 +209,6 @@ function buildCliLogArgs(params: { i += 1; continue; } - if (arg === params.sessionArg) { - logArgs.push(arg, params.args[i + 1] ?? ""); - i += 1; - continue; - } if (arg === params.modelArg) { logArgs.push(arg, params.args[i + 1] ?? ""); i += 1; @@ -988,7 +982,6 @@ export async function executePreparedCliRun( const logArgs = buildCliLogArgs({ args: executionArgs, systemPromptArg: backend.systemPromptArg, - sessionArg: backend.sessionArg, modelArg: backend.modelArg, imageArg: backend.imageArg, argsPrompt, diff --git a/src/agents/cli-runner/helpers.system-prompt-resume.test.ts b/src/agents/cli-runner/helpers.system-prompt-resume.test.ts index a531d96b0a41..8897e9637cf3 100644 --- a/src/agents/cli-runner/helpers.system-prompt-resume.test.ts +++ b/src/agents/cli-runner/helpers.system-prompt-resume.test.ts @@ -35,21 +35,23 @@ import { buildCliArgs, resolveSystemPromptUsage } from "./helpers.js"; // Minimal backend config matching the Anthropic claude-cli backend shape. const CLAUDE_BACKEND_BASE: Pick< CliBackendConfig, + | "command" | "systemPromptFileArg" | "systemPromptArg" | "systemPromptFileConfigKey" | "systemPromptWhen" - | "sessionArg" + | "sessionArgs" | "modelArg" | "input" | "output" | "liveSession" > = { + command: "claude", systemPromptFileArg: "--append-system-prompt-file", systemPromptArg: undefined, systemPromptFileConfigKey: undefined, systemPromptWhen: "always", - sessionArg: "--session-id", + sessionArgs: ["--session-id", "{sessionId}"], modelArg: "--model", input: "stdin", output: "jsonl", diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 473b1587250e..4d0f58ab7e1c 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -557,8 +557,6 @@ export function buildCliArgs(params: { for (const entry of params.backend.sessionArgs) { args.push(entry.replaceAll("{sessionId}", params.sessionId)); } - } else if (params.backend.sessionArg) { - args.push(params.backend.sessionArg, params.sessionId); } } if (params.useResume && params.forkResume) { diff --git a/src/agents/cli-runner/model-call-diagnostics.test.ts b/src/agents/cli-runner/model-call-diagnostics.test.ts index db25dbdba5a9..ff531a962dc1 100644 --- a/src/agents/cli-runner/model-call-diagnostics.test.ts +++ b/src/agents/cli-runner/model-call-diagnostics.test.ts @@ -25,12 +25,7 @@ function createContext(): PreparedCliRunContext { otel: { enabled: true, traces: true, - captureContent: { - enabled: true, - inputMessages: true, - outputMessages: true, - systemPrompt: true, - }, + captureContent: true, }, }, }, diff --git a/src/agents/command/delivery.test.ts b/src/agents/command/delivery.test.ts index 8f0684f822bb..ae70b10efff3 100644 --- a/src/agents/command/delivery.test.ts +++ b/src/agents/command/delivery.test.ts @@ -387,9 +387,7 @@ describe("deliverAgentCommandResult payload normalization", () => { it("renders response prefix templates with the selected runtime model", async () => { const delivered = await deliverAgentCommandResult({ cfg: { - messages: { - responsePrefix: "[{modelFull}]", - }, + channels: { slack: { responsePrefix: "[{modelFull}]" } }, } as OpenClawConfig, deps: {} as CliDeps, runtime: { log: vi.fn() } as never, @@ -1005,7 +1003,7 @@ describe("deliverAgentCommandResult payload normalization", () => { it("dedupes sent text after applying the delivery response prefix", async () => { const delivered = await deliverAgentCommandResult({ cfg: { - messages: { responsePrefix: "Bot:" }, + channels: { slack: { responsePrefix: "Bot:" } }, } as OpenClawConfig, deps: {} as CliDeps, runtime: { log: vi.fn(), error: vi.fn() } as never, @@ -1040,7 +1038,7 @@ describe("deliverAgentCommandResult payload normalization", () => { const delivered = await deliverAgentCommandResult({ cfg: { - messages: { responsePrefix: "[{modelFull}]" }, + channels: { slack: { responsePrefix: "[{modelFull}]" } }, } as OpenClawConfig, deps: {} as CliDeps, runtime: { log: vi.fn(), error: vi.fn() } as never, diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index 78885aa05a56..2ec93e0a45f8 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -68,7 +68,7 @@ const IDENTIFIER_PRESERVATION_INSTRUCTIONS = /** Optional instruction policy for preserving identifiers during compaction. */ export type CompactionSummarizationInstructions = { - identifierPolicy?: AgentCompactionIdentifierPolicy; + identifierPolicy?: AgentCompactionIdentifierPolicy | "custom"; identifierInstructions?: string; }; diff --git a/src/agents/current-time.test.ts b/src/agents/current-time.test.ts index 29a26fd52f6a..bceff080a715 100644 --- a/src/agents/current-time.test.ts +++ b/src/agents/current-time.test.ts @@ -17,7 +17,7 @@ describe("resolveCronStyleNow", () => { 8_640_000_000_000_001, ); - expect(result.formattedTime).toBe("Saturday, May 30th, 2026 - 12:00"); + expect(result.formattedTime).toBe("Saturday, May 30th, 2026 - 12:00 PM"); expect(result.timeLine).toContain("Reference UTC: 2026-05-30 12:00 UTC"); }); diff --git a/src/agents/current-time.ts b/src/agents/current-time.ts index c3a4fa1c5439..176bd7686995 100644 --- a/src/agents/current-time.ts +++ b/src/agents/current-time.ts @@ -2,12 +2,7 @@ * Formats cron-style current-time prompt text with local and UTC references. */ import { resolveDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; -import { - type TimeFormatPreference, - formatUserTime, - resolveUserTimeFormat, - resolveUserTimezone, -} from "./date-time.js"; +import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "./date-time.js"; type CronStyleNow = { userTimezone: string; @@ -19,7 +14,7 @@ type TimeConfigLike = { agents?: { defaults?: { userTimezone?: string; - timeFormat?: TimeFormatPreference; + timeFormat?: "auto" | "12" | "24"; }; }; }; @@ -27,7 +22,7 @@ type TimeConfigLike = { /** Resolve localized and UTC current-time text for agent prompts. */ export function resolveCronStyleNow(cfg: TimeConfigLike, nowMs: number): CronStyleNow { const userTimezone = resolveUserTimezone(cfg.agents?.defaults?.userTimezone); - const userTimeFormat = resolveUserTimeFormat(cfg.agents?.defaults?.timeFormat); + const userTimeFormat = resolveUserTimeFormat(undefined); const timestampMs = resolveDateTimestampMs(nowMs); const date = new Date(timestampMs); const formattedTime = formatUserTime(date, userTimezone, userTimeFormat) ?? date.toISOString(); diff --git a/src/agents/date-time.ts b/src/agents/date-time.ts index 4b3c84112ecf..05f95b0d56b5 100644 --- a/src/agents/date-time.ts +++ b/src/agents/date-time.ts @@ -4,7 +4,6 @@ import { execFileSync } from "node:child_process"; import { resolveDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; -export type TimeFormatPreference = "auto" | "12" | "24"; export type ResolvedTimeFormat = "12" | "24"; let cachedTimeFormat: ResolvedTimeFormat | undefined; @@ -35,7 +34,7 @@ export function resolveUserTimezone(configured?: string): string { } /** Resolve 12/24-hour display preference, detecting the host for `auto`. */ -export function resolveUserTimeFormat(preference?: TimeFormatPreference): ResolvedTimeFormat { +export function resolveUserTimeFormat(preference?: "auto" | "12" | "24"): ResolvedTimeFormat { if (preference === "12" || preference === "24") { return preference; } diff --git a/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts b/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts index 827aa1bc99ab..69e6cd7826cd 100644 --- a/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts +++ b/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts @@ -377,17 +377,17 @@ describe("resolveBootstrapPromptTruncationWarningMode", () => { expect(EXPECTED_DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE).toBe("always"); }); - it("accepts explicit valid modes", () => { + it("ignores retired explicit modes", () => { expect( resolveBootstrapPromptTruncationWarningMode({ agents: { defaults: { bootstrapPromptTruncationWarning: "off" } }, } as OpenClawConfig), - ).toBe("off"); + ).toBe("always"); expect( resolveBootstrapPromptTruncationWarningMode({ agents: { defaults: { bootstrapPromptTruncationWarning: "once" } }, } as OpenClawConfig), - ).toBe("once"); + ).toBe("always"); expect( resolveBootstrapPromptTruncationWarningMode({ agents: { defaults: { bootstrapPromptTruncationWarning: "always" } }, diff --git a/src/agents/embedded-agent-helpers/bootstrap.ts b/src/agents/embedded-agent-helpers/bootstrap.ts index 8f7f11c5903f..a0eaf0f1343e 100644 --- a/src/agents/embedded-agent-helpers/bootstrap.ts +++ b/src/agents/embedded-agent-helpers/bootstrap.ts @@ -145,12 +145,8 @@ export function resolveBootstrapTotalMaxChars( } export function resolveBootstrapPromptTruncationWarningMode( - cfg?: OpenClawConfig, + _cfg?: OpenClawConfig, ): "off" | "once" | "always" { - const raw = cfg?.agents?.defaults?.bootstrapPromptTruncationWarning; - if (raw === "off" || raw === "once" || raw === "always") { - return raw; - } return DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE; } diff --git a/src/agents/embedded-agent-runner.guard.test.ts b/src/agents/embedded-agent-runner.guard.test.ts index a8e1f5ad6217..ffe60a22fd6f 100644 --- a/src/agents/embedded-agent-runner.guard.test.ts +++ b/src/agents/embedded-agent-runner.guard.test.ts @@ -374,7 +374,6 @@ describe("guardSessionManager integration", () => { it("redacts configured text patterns before persisting transcript messages", () => { const cfg = { logging: { - redactSensitive: "tools", redactPatterns: [String.raw`([\w]|[-.])+@([\w]|[-.])+\.\w+`], }, } satisfies OpenClawConfig; diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index 13d61dce2fcc..b5e095beb414 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -1340,7 +1340,7 @@ async function compactEmbeddedAgentSessionDirectOnce( model: effectiveModel, }); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); - const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); + const userTimeFormat = resolveUserTimeFormat(undefined); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); const promptSurface = resolveAgentPromptSurfaceForSessionKey(params.sessionKey); const promptMode = diff --git a/src/agents/embedded-agent-runner/extensions.ts b/src/agents/embedded-agent-runner/extensions.ts index 0686e2efd518..1ad78532d161 100644 --- a/src/agents/embedded-agent-runner/extensions.ts +++ b/src/agents/embedded-agent-runner/extensions.ts @@ -195,8 +195,6 @@ export function buildEmbeddedExtensionFactories(params: { setCompactionSafeguardRuntime(params.sessionManager, { contextWindowTokens: contextWindowInfo.tokens, identifierPolicy: compactionCfg?.identifierPolicy, - identifierInstructions: compactionCfg?.identifierInstructions, - customInstructions: compactionCfg?.customInstructions, qualityGuardEnabled: qualityGuardCfg?.enabled ?? true, qualityGuardMaxRetries: qualityGuardCfg?.maxRetries, model: params.model, diff --git a/src/agents/embedded-agent-runner/run/attempt-session-boundary.ts b/src/agents/embedded-agent-runner/run/attempt-session-boundary.ts index af759ba77922..b68535840abc 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-boundary.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-boundary.ts @@ -81,8 +81,7 @@ export function prepareEmbeddedAttemptSessionBoundary(input: { const boundaryTimezone = isRawModelRun ? undefined : resolveUserTimezone(attempt.config?.agents?.defaults?.userTimezone); - const includeBoundaryTimestamp = - !isRawModelRun && attempt.config?.agents?.defaults?.envelopeTimestamp !== "off"; + const includeBoundaryTimestamp = !isRawModelRun; let currentUserTimestampOverride: CurrentUserTimestampOverride | undefined; const buildBoundaryOptions = (): LlmBoundaryOptions => { if (isRawModelRun) { diff --git a/src/agents/embedded-agent-runner/run/attempt.abort-settle-timeout.ts b/src/agents/embedded-agent-runner/run/attempt.abort-settle-timeout.ts index 182c6a3c20b9..683929671409 100644 --- a/src/agents/embedded-agent-runner/run/attempt.abort-settle-timeout.ts +++ b/src/agents/embedded-agent-runner/run/attempt.abort-settle-timeout.ts @@ -1,3 +1,4 @@ +import { isFastTestRuntimeEnv } from "../../../infra/env.js"; /** * Resolves how long aborted attempts wait for cleanup to settle. */ @@ -19,5 +20,5 @@ export function resolveEmbeddedAbortSettleTimeoutMs( if (override !== undefined) { return override; } - return env.OPENCLAW_TEST_FAST === "1" ? 250 : 2_000; + return isFastTestRuntimeEnv(env) ? 250 : 2_000; } diff --git a/src/agents/embedded-agent-runner/run/attempt.async-tasks.ts b/src/agents/embedded-agent-runner/run/attempt.async-tasks.ts index 3f4cd94c1e5b..b1d8773aa6da 100644 --- a/src/agents/embedded-agent-runner/run/attempt.async-tasks.ts +++ b/src/agents/embedded-agent-runner/run/attempt.async-tasks.ts @@ -2,6 +2,7 @@ * Waits for completion-required async tasks before finalizing an attempt. */ import { createAbortError as createNamedAbortError } from "../../../infra/abort-signal.js"; +import { isFastTestRuntimeEnv } from "../../../infra/env.js"; import { toErrorObject } from "../../../infra/errors.js"; import { isCronRunSessionKey } from "../../../sessions/session-key-utils.js"; import { isTerminalTaskStatus } from "../../../tasks/task-executor-policy.js"; @@ -33,7 +34,7 @@ const COMPLETION_REQUIRED_TASK_KINDS = new Set([ ]); function resolveAsyncTaskPollIntervalMs(): number { - return process.env.OPENCLAW_TEST_FAST === "1" ? 10 : DEFAULT_ASYNC_TASK_POLL_INTERVAL_MS; + return isFastTestRuntimeEnv() ? 10 : DEFAULT_ASYNC_TASK_POLL_INTERVAL_MS; } function sleep(ms: number): Promise { diff --git a/src/agents/embedded-agent-subscribe.tools.test.ts b/src/agents/embedded-agent-subscribe.tools.test.ts index f82dcc79c6e8..dc1fb1357c3e 100644 --- a/src/agents/embedded-agent-subscribe.tools.test.ts +++ b/src/agents/embedded-agent-subscribe.tools.test.ts @@ -409,7 +409,6 @@ describe("sanitizeToolResult", () => { it("applies configured redact patterns to Control UI tool payloads", () => { vi.spyOn(loggingConfigModule, "readLoggingConfig").mockReturnValue({ - redactSensitive: "off", redactPatterns: [String.raw`\bcustom-secret-[A-Za-z0-9]+\b`], }); diff --git a/src/agents/exec-defaults.test.ts b/src/agents/exec-defaults.test.ts index 70713bf8fe72..3feb102f6b92 100644 --- a/src/agents/exec-defaults.test.ts +++ b/src/agents/exec-defaults.test.ts @@ -227,9 +227,7 @@ describe("resolveExecDefaults", () => { }); }); - it("keeps legacy security overrides ahead of higher-scope normalized mode", () => { - // Legacy security/ask overrides are still a shipped config shape. They win - // when scoped directly to the agent that is being resolved. + it("keeps agent mode overrides ahead of the global mode", () => { expect( resolveExecDefaults({ cfg: { @@ -244,8 +242,7 @@ describe("resolveExecDefaults", () => { id: "agent-a", tools: { exec: { - security: "full", - ask: "off", + mode: "full", }, }, }, @@ -262,7 +259,7 @@ describe("resolveExecDefaults", () => { }); }); - it("preserves mode-derived security for partial legacy agent overrides", () => { + it("derives security fields from an agent mode override", () => { expect( resolveExecDefaults({ cfg: { @@ -277,7 +274,7 @@ describe("resolveExecDefaults", () => { id: "agent-a", tools: { exec: { - ask: "off", + mode: "allowlist", }, }, }, @@ -301,7 +298,7 @@ describe("resolveExecDefaults", () => { tools: { exec: { host: "node", - security: "deny", + mode: "deny", node: "build-mac", }, }, @@ -314,8 +311,8 @@ describe("resolveExecDefaults", () => { expect( resolveNodeExecEligibility({ cfg: { - gateway: { nodes: { denyCommands: [" system.run "] } }, - tools: { exec: { host: "node", security: "full" } }, + gateway: { nodes: { commands: { deny: [" system.run "] } } }, + tools: { exec: { host: "node", mode: "full" } }, }, }), ).toEqual({ canExec: false }); diff --git a/src/agents/exec-defaults.ts b/src/agents/exec-defaults.ts index 7b1ca4cd0fd0..2546c67c0c65 100644 --- a/src/agents/exec-defaults.ts +++ b/src/agents/exec-defaults.ts @@ -116,7 +116,7 @@ export function resolveNodeExecEligibility(params: { sandboxAvailable?: boolean; }): { canExec: boolean; node?: string } { const defaults = resolveExecDefaults(params); - const systemRunDenied = params.cfg?.gateway?.nodes?.denyCommands?.some( + const systemRunDenied = params.cfg?.gateway?.nodes?.commands?.deny?.some( (command) => command.trim() === "system.run", ); return { diff --git a/src/agents/gpt5-prompt-overlay.ts b/src/agents/gpt5-prompt-overlay.ts index bfcd27903056..d265e78e3f79 100644 --- a/src/agents/gpt5-prompt-overlay.ts +++ b/src/agents/gpt5-prompt-overlay.ts @@ -92,7 +92,6 @@ export function resolveGpt5PromptOverlayMode( const canUseOpenAiPluginFallback = !providerId || OPENAI_FAMILY_GPT5_PROMPT_OVERLAY_PROVIDERS.has(providerId); return ( - normalizeGpt5PromptOverlayMode(config?.agents?.defaults?.promptOverlays?.gpt5?.personality) ?? (canUseOpenAiPluginFallback ? normalizeGpt5PromptOverlayMode(config?.plugins?.entries?.openai?.config?.personality) : undefined) ?? diff --git a/src/agents/heartbeat-system-prompt.test.ts b/src/agents/heartbeat-system-prompt.test.ts index a82aca16aab3..ad6025012e0e 100644 --- a/src/agents/heartbeat-system-prompt.test.ts +++ b/src/agents/heartbeat-system-prompt.test.ts @@ -3,22 +3,18 @@ import { describe, expect, it } from "vitest"; import { resolveHeartbeatPromptForSystemPrompt } from "./heartbeat-system-prompt.js"; describe("resolveHeartbeatPromptForSystemPrompt", () => { - it("omits the heartbeat section when disabled in defaults", () => { + it("includes the heartbeat section for the default enabled cadence", () => { expect( resolveHeartbeatPromptForSystemPrompt({ config: { agents: { - defaults: { - heartbeat: { - includeSystemPromptSection: false, - }, - }, + defaults: { heartbeat: {} }, }, }, agentId: "main", defaultAgentId: "main", }), - ).toBeUndefined(); + ).toBeDefined(); }); it("omits the heartbeat section when the default cadence is disabled", () => { diff --git a/src/agents/heartbeat-system-prompt.ts b/src/agents/heartbeat-system-prompt.ts index 84ff2affa76b..6bf789c8d80f 100644 --- a/src/agents/heartbeat-system-prompt.ts +++ b/src/agents/heartbeat-system-prompt.ts @@ -73,9 +73,6 @@ export function shouldIncludeHeartbeatGuidanceForSystemPrompt(params: { return false; } const heartbeat = resolveHeartbeatConfigForSystemPrompt(params.config, agentId); - if (heartbeat?.includeSystemPromptSection === false) { - return false; - } return isHeartbeatCadenceEnabled(heartbeat); } diff --git a/src/agents/identity.per-channel-prefix.test.ts b/src/agents/identity.per-channel-prefix.test.ts index 0e1d272f5d3e..eba84effbdd9 100644 --- a/src/agents/identity.per-channel-prefix.test.ts +++ b/src/agents/identity.per-channel-prefix.test.ts @@ -6,42 +6,24 @@ import { resolveResponsePrefix, resolveEffectiveMessagesConfig } from "./identit const makeConfig = (cfg: T) => cfg; describe("resolveResponsePrefix with per-channel override", () => { - // ─── Backward compatibility ───────────────────────────────────────── + it("keeps the global fallback when no channel block exists", () => { + const cfg: OpenClawConfig = { messages: { responsePrefix: "[Bot] " } }; + expect(resolveResponsePrefix(cfg, "main", { channel: "telegram" })).toBe("[Bot] "); + }); - describe("backward compatibility (no channel param)", () => { - it("returns undefined when no prefix configured anywhere", () => { - const cfg: OpenClawConfig = {}; - expect(resolveResponsePrefix(cfg, "main")).toBeUndefined(); - }); - - it("returns global prefix when set", () => { - const cfg: OpenClawConfig = { messages: { responsePrefix: "[Bot] " } }; - expect(resolveResponsePrefix(cfg, "main")).toBe("[Bot] "); - }); - - it("resolves 'auto' to identity name at global level", () => { - const cfg: OpenClawConfig = { - agents: { - list: [{ id: "main", identity: { name: "TestBot" } }], - }, - messages: { responsePrefix: "auto" }, - }; - expect(resolveResponsePrefix(cfg, "main")).toBe("[TestBot]"); - }); - - it("returns empty string when global prefix is explicitly empty", () => { - const cfg: OpenClawConfig = { messages: { responsePrefix: "" } }; - expect(resolveResponsePrefix(cfg, "main")).toBe(""); - }); + it("keeps the global fallback for a configured custom channel", () => { + const cfg = { + messages: { responsePrefix: "[Bot] " }, + channels: { custom: { enabled: true } }, + } as OpenClawConfig; + expect(resolveResponsePrefix(cfg, "main", { channel: "custom" })).toBe("[Bot] "); }); // ─── Channel-level prefix ────────────────────────────────────────── describe("channel-level prefix", () => { - it("returns channel prefix when set, ignoring global", () => { - // Channel scope is more specific than the global messages default. + it("returns the configured channel prefix", () => { const cfg = makeConfig({ - messages: { responsePrefix: "[Global] " }, channels: { whatsapp: { responsePrefix: "[WA] " }, }, @@ -49,20 +31,18 @@ describe("resolveResponsePrefix with per-channel override", () => { expect(resolveResponsePrefix(cfg, "main", { channel: "whatsapp" })).toBe("[WA] "); }); - it("falls through to global when channel prefix is undefined", () => { + it("returns undefined when channel prefix is undefined", () => { const cfg = makeConfig({ - messages: { responsePrefix: "[Global] " }, channels: { whatsapp: {}, }, } satisfies OpenClawConfig); - expect(resolveResponsePrefix(cfg, "main", { channel: "whatsapp" })).toBe("[Global] "); + expect(resolveResponsePrefix(cfg, "main", { channel: "whatsapp" })).toBeUndefined(); }); it("channel empty string stops cascade (no global prefix applied)", () => { // Empty string is an explicit operator choice, not an unset value. const cfg = makeConfig({ - messages: { responsePrefix: "[Global] " }, channels: { telegram: { responsePrefix: "" }, }, @@ -108,9 +88,8 @@ describe("resolveResponsePrefix with per-channel override", () => { // ─── Account-level prefix ───────────────────────────────────────── describe("account-level prefix", () => { - it("returns account prefix when set, ignoring channel and global", () => { + it("returns account prefix when set, ignoring the channel prefix", () => { const cfg = makeConfig({ - messages: { responsePrefix: "[Global] " }, channels: { whatsapp: { responsePrefix: "[WA] ", @@ -141,9 +120,8 @@ describe("resolveResponsePrefix with per-channel override", () => { ).toBe("[WA] "); }); - it("falls through to global when both account and channel are undefined", () => { + it("returns undefined when both account and channel are undefined", () => { const cfg = makeConfig({ - messages: { responsePrefix: "[Global] " }, channels: { whatsapp: { accounts: { @@ -154,12 +132,11 @@ describe("resolveResponsePrefix with per-channel override", () => { } satisfies OpenClawConfig); expect( resolveResponsePrefix(cfg, "main", { channel: "whatsapp", accountId: "business" }), - ).toBe("[Global] "); + ).toBeUndefined(); }); it("account empty string stops cascade", () => { const cfg = makeConfig({ - messages: { responsePrefix: "[Global] " }, channels: { whatsapp: { responsePrefix: "[WA] ", @@ -231,14 +208,11 @@ describe("resolveResponsePrefix with per-channel override", () => { // ─── Full cascade ───────────────────────────────────────────────── - describe("full 4-level cascade", () => { - // Specificity order: account, channel, agent/global identity fallback, - // then global messages config. + describe("full channel/account cascade", () => { const fullCfg = makeConfig({ agents: { list: [{ id: "main", identity: { name: "TestBot" } }], }, - messages: { responsePrefix: "[L4-Global] " }, channels: { whatsapp: { responsePrefix: "[L2-Channel] ", @@ -263,8 +237,8 @@ describe("resolveResponsePrefix with per-channel override", () => { ).toBe("[L2-Channel] "); }); - it("L4: global prefix when channel has no prefix", () => { - expect(resolveResponsePrefix(fullCfg, "main", { channel: "telegram" })).toBe("[L4-Global] "); + it("returns undefined when the channel has no prefix", () => { + expect(resolveResponsePrefix(fullCfg, "main", { channel: "telegram" })).toBeUndefined(); }); it("undefined: no prefix at any level", () => { @@ -280,7 +254,6 @@ describe("resolveResponsePrefix with per-channel override", () => { describe("resolveEffectiveMessagesConfig with channel context", () => { it("passes channel context through to responsePrefix resolution", () => { const cfg = makeConfig({ - messages: { responsePrefix: "[Global] " }, channels: { whatsapp: { responsePrefix: "[WA] " }, }, @@ -291,15 +264,14 @@ describe("resolveResponsePrefix with per-channel override", () => { expect(result.responsePrefix).toBe("[WA] "); }); - it("uses global when no channel context provided", () => { + it("returns undefined when no channel context is provided", () => { const cfg = makeConfig({ - messages: { responsePrefix: "[Global] " }, channels: { whatsapp: { responsePrefix: "[WA] " }, }, } satisfies OpenClawConfig); const result = resolveEffectiveMessagesConfig(cfg, "main"); - expect(result.responsePrefix).toBe("[Global] "); + expect(result.responsePrefix).toBeUndefined(); }); }); }); diff --git a/src/agents/identity.ts b/src/agents/identity.ts index b638b8a6f298..dfe0399ece0f 100644 --- a/src/agents/identity.ts +++ b/src/agents/identity.ts @@ -127,7 +127,7 @@ export function resolveResponsePrefix( } } - // L4: Global level + // L3: Retained fallback for implicit and custom channels that have no block to migrate. const configured = cfg.messages?.responsePrefix; if (configured !== undefined) { if (configured === "auto") { @@ -135,6 +135,7 @@ export function resolveResponsePrefix( } return configured; } + return undefined; } diff --git a/src/agents/lazy-exec-tool.ts b/src/agents/lazy-exec-tool.ts index 7475cda0cd21..5511d2387b25 100644 --- a/src/agents/lazy-exec-tool.ts +++ b/src/agents/lazy-exec-tool.ts @@ -84,7 +84,7 @@ export function resolveExecToolConfig(params: { cfg?: OpenClawConfig; agentId?: }), reviewer: agentExec?.reviewer ?? globalExec?.reviewer, backgroundMs: agentExec?.backgroundMs ?? globalExec?.backgroundMs, - timeoutSec: agentExec?.timeoutSec ?? globalExec?.timeoutSec, + timeoutSec: agentExec?.timeoutSeconds ?? globalExec?.timeoutSeconds, approvalRunningNoticeMs: agentExec?.approvalRunningNoticeMs ?? globalExec?.approvalRunningNoticeMs, cleanupMs: agentExec?.cleanupMs ?? globalExec?.cleanupMs, diff --git a/src/agents/mcp-transport-config.ts b/src/agents/mcp-transport-config.ts index eb403602d0ae..3e3200dcceed 100644 --- a/src/agents/mcp-transport-config.ts +++ b/src/agents/mcp-transport-config.ts @@ -170,21 +170,19 @@ function resolveHttpTransportConfig( !Array.isArray((rawServer as { oauth?: unknown }).oauth) ? { oauth: (rawServer as { oauth: Record }).oauth } : {}), - ...(getBooleanField(rawServer, ["sslVerify", "ssl_verify"]) !== undefined - ? { sslVerify: getBooleanField(rawServer, ["sslVerify", "ssl_verify"]) } + ...(getBooleanField(rawServer, ["sslVerify"]) !== undefined + ? { sslVerify: getBooleanField(rawServer, ["sslVerify"]) } : {}), - ...(getStringField(rawServer, ["clientCert", "client_cert"]) - ? { clientCert: getStringField(rawServer, ["clientCert", "client_cert"]) } + ...(getStringField(rawServer, ["clientCert"]) + ? { clientCert: getStringField(rawServer, ["clientCert"]) } : {}), - ...(getStringField(rawServer, ["clientKey", "client_key"]) - ? { clientKey: getStringField(rawServer, ["clientKey", "client_key"]) } + ...(getStringField(rawServer, ["clientKey"]) + ? { clientKey: getStringField(rawServer, ["clientKey"]) } : {}), description: describeHttpMcpServerLaunchConfig(launch.config), connectionTimeoutMs: getConnectionTimeoutMs(rawServer), requestTimeoutMs: resolveMcpRequestTimeoutMs(rawServer), - supportsParallelToolCalls: - getBooleanField(rawServer, ["supportsParallelToolCalls", "supports_parallel_tool_calls"]) ?? - false, + supportsParallelToolCalls: getBooleanField(rawServer, ["supportsParallelToolCalls"]) ?? false, }; } @@ -217,9 +215,7 @@ export function resolveMcpTransportConfig( description: describeStdioMcpServerLaunchConfig(stdioLaunch.config), connectionTimeoutMs: getConnectionTimeoutMs(rawServer), requestTimeoutMs: resolveMcpRequestTimeoutMs(rawServer), - supportsParallelToolCalls: - getBooleanField(rawServer, ["supportsParallelToolCalls", "supports_parallel_tool_calls"]) ?? - false, + supportsParallelToolCalls: getBooleanField(rawServer, ["supportsParallelToolCalls"]) ?? false, }; } diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index 67d4e59f9b17..18f3ff0d8acf 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -18,7 +18,6 @@ import { setActiveDegradedSecretOwners, } from "../secrets/runtime-degraded-state.js"; import { runtimeMemorySecretOwnerId } from "../secrets/runtime-memory-secret-owner.js"; -import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js"; import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js"; import { resolveMemorySearchConfig, resolveMemorySearchSyncConfig } from "./memory-search.js"; @@ -100,13 +99,15 @@ describe("memory search config", () => { function configWithDefaultProvider(provider: string): OpenClawConfig { return asConfig({ - agents: { - defaults: { - memorySearch: { - provider, - }, + memory: { + search: { + provider, }, }, + + agents: { + defaults: {}, + }, }); } @@ -132,20 +133,24 @@ describe("memory search config", () => { function configWithRemoteDefaults(remote: Record) { return asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - remote, - }, + memory: { + search: { + provider: "openai", + remote, }, + }, + + agents: { + defaults: {}, list: [ { id: "main", default: true, - memorySearch: { - remote: { - baseUrl: "https://agent.example/v1", + memory: { + search: { + remote: { + baseUrl: "https://agent.example/v1", + }, }, }, }, @@ -157,15 +162,11 @@ describe("memory search config", () => { function expectMergedRemoteConfig( resolved: ReturnType, apiKey: unknown, - extras?: { nonBatchConcurrency?: number }, ) { expect(resolved?.remote).toEqual({ baseUrl: "https://agent.example/v1", apiKey, headers: { "X-Default": "on" }, - ...(typeof extras?.nonBatchConcurrency === "number" - ? { nonBatchConcurrency: extras.nonBatchConcurrency } - : {}), batch: { enabled: false, wait: true, @@ -178,15 +179,15 @@ describe("memory search config", () => { it("returns null when disabled", () => { const cfg = asConfig({ + memory: { search: { enabled: true } }, + agents: { - defaults: { - memorySearch: { enabled: true }, - }, + defaults: {}, list: [ { id: "main", default: true, - memorySearch: { enabled: false }, + memory: { search: { enabled: false } }, }, ], }, @@ -206,7 +207,7 @@ describe("memory search config", () => { ownerKind: "capability", ownerId: runtimeMemorySecretOwnerId("cold"), state: "unavailable", - paths: ["agents.defaults.memorySearch.remote.apiKey"], + paths: ["memory.search.remote.apiKey"], refKeys: ["env:default:MISSING_MEMORY_KEY"], reason: "secret reference was not found", }, @@ -218,15 +219,15 @@ describe("memory search config", () => { it("returns null sync config when disabled", () => { const cfg = asConfig({ + memory: { search: { enabled: true } }, + agents: { - defaults: { - memorySearch: { enabled: true }, - }, + defaults: {}, list: [ { id: "main", default: true, - memorySearch: { enabled: false }, + memory: { search: { enabled: false } }, }, ], }, @@ -260,7 +261,9 @@ describe("memory search config", () => { name: "explicit false with main scope", cfg: { session: { dmScope: "main" }, - agents: { defaults: { memorySearch: { rememberAcrossConversations: false } } }, + memory: { search: { rememberAcrossConversations: false } }, + + agents: { defaults: {} }, }, expected: false, }, @@ -268,7 +271,9 @@ describe("memory search config", () => { name: "explicit true with per-peer scope", cfg: { session: { dmScope: "per-peer" }, - agents: { defaults: { memorySearch: { rememberAcrossConversations: true } } }, + memory: { search: { rememberAcrossConversations: true } }, + + agents: { defaults: {} }, }, expected: true, }, @@ -303,7 +308,7 @@ describe("memory search config", () => { list: [ { id: "personal", - memorySearch: { rememberAcrossConversations: true }, + memory: { search: { rememberAcrossConversations: true } }, }, ], }, @@ -323,9 +328,11 @@ describe("memory search config", () => { list: [ { id: "personal", - memorySearch: { - rememberAcrossConversations: true, - sources: ["sessions"], + memory: { + search: { + rememberAcrossConversations: true, + sources: ["sessions"], + }, }, }, ], @@ -340,14 +347,14 @@ describe("memory search config", () => { it("lets a per-agent false override a default true", () => { const cfg = asConfig({ + memory: { search: { rememberAcrossConversations: true } }, + agents: { - defaults: { - memorySearch: { rememberAcrossConversations: true }, - }, + defaults: {}, list: [ { id: "shared", - memorySearch: { rememberAcrossConversations: false }, + memory: { search: { rememberAcrossConversations: false } }, }, ], }, @@ -362,13 +369,15 @@ describe("memory search config", () => { it("defaults provider to openai when unspecified", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - enabled: true, - }, + memory: { + search: { + enabled: true, }, }, + + agents: { + defaults: {}, + }, }); const resolved = resolveMemorySearchConfig(cfg, "main"); expect(resolved?.provider).toBe("openai"); @@ -415,8 +424,10 @@ describe("memory search config", () => { const resolved = resolveMemorySearchConfig( asConfig({ plugins: { enabled: true }, + memory: { search: { provider: "none", fallback: "deepinfra" } }, + agents: { - defaults: { memorySearch: { provider: "none", fallback: "deepinfra" } }, + defaults: {}, }, }), "main", @@ -429,14 +440,16 @@ describe("memory search config", () => { const resolved = resolveMemorySearchConfig( asConfig({ plugins: { enabled: true }, - agents: { - defaults: { - memorySearch: { - provider: "none", - multimodal: { enabled: true, modalities: ["image"] }, - }, + memory: { + search: { + provider: "none", + multimodal: { enabled: true, modalities: ["image"] }, }, }, + + agents: { + defaults: {}, + }, }), "main", ); @@ -458,13 +471,15 @@ describe("memory search config", () => { }, }, }, - agents: { - defaults: { - memorySearch: { - provider: "ollama-5080", - }, + memory: { + search: { + provider: "ollama-5080", }, }, + + agents: { + defaults: {}, + }, }); const resolved = resolveMemorySearchConfig(cfg, "main"); @@ -474,86 +489,77 @@ describe("memory search config", () => { expectDefaultRemoteBatch(resolved); }); - it("resolves sync config without consulting embedding providers", () => { + it("resolves fixed sync defaults without consulting embedding providers", () => { clearMemoryEmbeddingProviders(); const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - sync: { - onSessionStart: false, - onSearch: true, - watch: false, - sessions: { - deltaBytes: 321, - deltaMessages: 7, - postCompactionForce: false, - }, - }, - }, + memory: { + search: { + provider: "openai", }, }, + + agents: { + defaults: {}, + }, }); expect(resolveMemorySearchSyncConfig(cfg, "main")).toEqual({ - onSessionStart: false, + onSessionStart: true, onSearch: true, - watch: false, + watch: true, watchDebounceMs: 1500, intervalMinutes: 0, embeddingBatchTimeoutSeconds: undefined, sessions: { - deltaBytes: 321, - deltaMessages: 7, - postCompactionForce: false, + deltaBytes: 100_000, + deltaMessages: 50, + postCompactionForce: true, }, }); }); - it("uses configured embeddingBatchTimeoutSeconds when set", () => { + it("keeps the fixed embedding batch timeout unset", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - sync: { - embeddingBatchTimeoutSeconds: 600, - }, - }, + memory: { + search: { + provider: "openai", }, }, + + agents: { + defaults: {}, + }, }); - expect(resolveMemorySearchSyncConfig(cfg, "main")?.embeddingBatchTimeoutSeconds).toBe(600); + expect( + resolveMemorySearchSyncConfig(cfg, "main")?.embeddingBatchTimeoutSeconds, + ).toBeUndefined(); }); it("merges defaults and overrides", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - model: "text-embedding-3-small", - store: { - vector: { - enabled: false, - extensionPath: "/opt/sqlite-vec.dylib", - }, + memory: { + search: { + provider: "openai", + model: "text-embedding-3-small", + store: { + vector: { + extensionPath: "/opt/sqlite-vec.dylib", }, - query: { maxResults: 4, minScore: 0.2 }, }, + query: { maxResults: 4, minScore: 0.2 }, }, + }, + + agents: { + defaults: {}, list: [ { id: "main", default: true, - memorySearch: { - query: { maxResults: 8 }, - store: { - vector: { - enabled: true, - }, + memory: { + search: { + query: { maxResults: 8 }, }, }, }, @@ -571,18 +577,22 @@ describe("memory search config", () => { it("merges extra memory paths from defaults and overrides", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - extraPaths: ["/shared/notes", " docs "], - }, + memory: { + search: { + extraPaths: ["/shared/notes", " docs "], }, + }, + + agents: { + defaults: {}, list: [ { id: "main", default: true, - memorySearch: { - extraPaths: ["/shared/notes", "../team-notes"], + memory: { + search: { + extraPaths: ["/shared/notes", "../team-notes"], + }, }, }, ], @@ -594,19 +604,21 @@ describe("memory search config", () => { it("normalizes multimodal settings", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "gemini", - model: "gemini-embedding-2-preview", - multimodal: { - enabled: true, - modalities: ["all"], - maxFileBytes: 8192, - }, + memory: { + search: { + provider: "gemini", + model: "gemini-embedding-2-preview", + multimodal: { + enabled: true, + modalities: ["all"], + maxFileBytes: 8192, }, }, }, + + agents: { + defaults: {}, + }, }); const resolved = resolveMemorySearchConfig(cfg, "main"); expect(resolved?.multimodal).toEqual({ @@ -618,18 +630,20 @@ describe("memory search config", () => { it("keeps an explicit empty multimodal modalities list empty", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "gemini", - model: "gemini-embedding-2-preview", - multimodal: { - enabled: true, - modalities: [], - }, + memory: { + search: { + provider: "gemini", + model: "gemini-embedding-2-preview", + multimodal: { + enabled: true, + modalities: [], }, }, }, + + agents: { + defaults: {}, + }, }); const resolved = resolveMemorySearchConfig(cfg, "main"); expectEmptyMultimodalConfig(resolved); @@ -638,19 +652,21 @@ describe("memory search config", () => { it("does not enforce multimodal provider validation when no modalities are active", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - model: "text-embedding-3-small", - fallback: "openai", - multimodal: { - enabled: true, - modalities: [], - }, + memory: { + search: { + provider: "openai", + model: "text-embedding-3-small", + fallback: "openai", + multimodal: { + enabled: true, + modalities: [], }, }, }, + + agents: { + defaults: {}, + }, }); const resolved = resolveMemorySearchConfig(cfg, "main"); expectEmptyMultimodalConfig(resolved); @@ -658,36 +674,40 @@ describe("memory search config", () => { it("rejects multimodal memory on unsupported providers", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - model: "text-embedding-3-small", - multimodal: { enabled: true, modalities: ["image"] }, - }, + memory: { + search: { + provider: "openai", + model: "text-embedding-3-small", + multimodal: { enabled: true, modalities: ["image"] }, }, }, + + agents: { + defaults: {}, + }, }); expect(() => resolveMemorySearchConfig(cfg, "main")).toThrow( - /memorySearch\.multimodal requires a provider adapter that supports multimodal embeddings/, + /memory\.search\.multimodal requires a provider adapter that supports multimodal embeddings/, ); }); it("rejects multimodal memory on generic OpenAI-compatible providers", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai-compatible", - model: "text-embedding-bge-m3", - remote: { baseUrl: "http://127.0.0.1:1234/v1" }, - multimodal: { enabled: true, modalities: ["image"] }, - }, + memory: { + search: { + provider: "openai-compatible", + model: "text-embedding-bge-m3", + remote: { baseUrl: "http://127.0.0.1:1234/v1" }, + multimodal: { enabled: true, modalities: ["image"] }, }, }, + + agents: { + defaults: {}, + }, }); expect(() => resolveMemorySearchConfig(cfg, "main")).toThrow( - /memorySearch\.multimodal requires a provider adapter that supports multimodal embeddings/, + /memory\.search\.multimodal requires a provider adapter that supports multimodal embeddings/, ); }); @@ -701,18 +721,20 @@ describe("memory search config", () => { }, }, }, - agents: { - defaults: { - memorySearch: { - provider: "localEmbeddings", - model: "text-embedding-bge-m3", - multimodal: { enabled: true, modalities: ["image"] }, - }, + memory: { + search: { + provider: "localEmbeddings", + model: "text-embedding-bge-m3", + multimodal: { enabled: true, modalities: ["image"] }, }, }, + + agents: { + defaults: {}, + }, }); expect(() => resolveMemorySearchConfig(cfg, "main")).toThrow( - /memorySearch\.multimodal requires a provider adapter that supports multimodal embeddings/, + /memory\.search\.multimodal requires a provider adapter that supports multimodal embeddings/, ); }); @@ -720,15 +742,17 @@ describe("memory search config", () => { clearMemoryEmbeddingProviders(); registerBaseMemoryEmbeddingProviders({ includeGemini: false }); const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "gemini", - model: "gemini-embedding-2-preview", - multimodal: { enabled: true, modalities: ["image"] }, - }, + memory: { + search: { + provider: "gemini", + model: "gemini-embedding-2-preview", + multimodal: { enabled: true, modalities: ["image"] }, }, }, + + agents: { + defaults: {}, + }, }); const resolved = resolveMemorySearchConfig(cfg, "main"); expect(resolved?.provider).toBe("gemini"); @@ -741,19 +765,21 @@ describe("memory search config", () => { it("rejects multimodal memory when fallback is configured", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "gemini", - model: "gemini-embedding-2-preview", - fallback: "openai", - multimodal: { enabled: true, modalities: ["image"] }, - }, + memory: { + search: { + provider: "gemini", + model: "gemini-embedding-2-preview", + fallback: "openai", + multimodal: { enabled: true, modalities: ["image"] }, }, }, + + agents: { + defaults: {}, + }, }); expect(() => resolveMemorySearchConfig(cfg, "main")).toThrow( - /memorySearch\.multimodal does not support memorySearch\.fallback/, + /memory\.search\.multimodal does not support memory\.search\.fallback/, ); }); @@ -763,50 +789,6 @@ describe("memory search config", () => { expectDefaultRemoteBatch(resolved); }); - it("normalizes remote batch timer config once before provider adapters receive it", () => { - const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - remote: { - batch: { - pollIntervalMs: Number.MAX_SAFE_INTEGER, - timeoutMinutes: Number.MAX_SAFE_INTEGER, - }, - }, - }, - }, - }, - }); - - const resolved = resolveMemorySearchConfig(cfg, "main"); - - expect(resolved?.remote?.batch?.pollIntervalMs).toBe(MAX_TIMER_TIMEOUT_MS); - expect(resolved?.remote?.batch?.timeoutMinutes).toBe(Math.floor(MAX_TIMER_TIMEOUT_MS / 60_000)); - }); - - it("keeps the default remote batch poll delay for zero intervals", () => { - const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - remote: { - batch: { - pollIntervalMs: 0, - }, - }, - }, - }, - }, - }); - - const resolved = resolveMemorySearchConfig(cfg, "main"); - - expect(resolved?.remote?.batch?.pollIntervalMs).toBe(2000); - }); - it("keeps remote unset for local provider without overrides", () => { const cfg = configWithDefaultProvider("local"); const resolved = resolveMemorySearchConfig(cfg, "main"); @@ -842,20 +824,24 @@ describe("memory search config", () => { it("merges memory search input_type overrides", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - inputType: "passage", - queryInputType: "query", - }, + memory: { + search: { + provider: "openai", + inputType: "passage", + queryInputType: "query", }, + }, + + agents: { + defaults: {}, list: [ { id: "main", default: true, - memorySearch: { - documentInputType: "document", + memory: { + search: { + documentInputType: "document", + }, }, }, ], @@ -869,13 +855,15 @@ describe("memory search config", () => { it("defaults session delta thresholds", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - }, + memory: { + search: { + provider: "openai", }, }, + + agents: { + defaults: {}, + }, }); const resolved = resolveMemorySearchConfig(cfg, "main"); expect(resolved?.sync.sessions).toEqual({ @@ -895,7 +883,7 @@ describe("memory search config", () => { expectMergedRemoteConfig(resolved, "default-key"); // pragma: allowlist secret }); - it("merges remote non-batch concurrency from defaults with agent overrides", () => { + it("ignores retired remote non-batch concurrency", () => { const cfg = configWithRemoteDefaults({ apiKey: "default-key", // pragma: allowlist secret headers: { "X-Default": "on" }, @@ -904,7 +892,7 @@ describe("memory search config", () => { const resolved = resolveMemorySearchConfig(cfg, "main"); - expectMergedRemoteConfig(resolved, "default-key", { nonBatchConcurrency: 1 }); // pragma: allowlist secret + expectMergedRemoteConfig(resolved, "default-key"); // pragma: allowlist secret }); it("preserves SecretRef remote apiKey when merging defaults with agent overrides", () => { @@ -922,22 +910,25 @@ describe("memory search config", () => { }); }); - it("gates session sources behind experimental flag", () => { + it("gates session sources behind rememberAcrossConversations", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - sources: ["memory", "sessions"], - }, + memory: { + search: { + provider: "openai", + sources: ["memory", "sessions"], }, + }, + + agents: { + defaults: {}, list: [ { id: "main", default: true, - memorySearch: { - rememberAcrossConversations: false, - experimental: { sessionMemory: false }, + memory: { + search: { + rememberAcrossConversations: false, + }, }, }, ], @@ -947,17 +938,19 @@ describe("memory search config", () => { expect(resolved?.sources).toEqual(["memory"]); }); - it("allows session sources when experimental flag is enabled", () => { + it("allows session sources when cross-conversation recall is enabled", () => { const cfg = asConfig({ - agents: { - defaults: { - memorySearch: { - provider: "openai", - sources: ["memory", "sessions"], - experimental: { sessionMemory: true }, - }, + memory: { + search: { + provider: "openai", + sources: ["memory", "sessions"], + rememberAcrossConversations: true, }, }, + + agents: { + defaults: {}, + }, }); const resolved = resolveMemorySearchConfig(cfg, "main"); expect(resolved?.sources).toContain("sessions"); diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index 97ce903b18bb..478cdfea5f6a 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -247,11 +247,10 @@ function mergeConfig( overrideRemote?.baseUrl || overrideRemote?.apiKey || overrideRemote?.headers || - overrideRemote?.nonBatchConcurrency != null || defaultRemote?.baseUrl || defaultRemote?.apiKey || defaultRemote?.headers || - defaultRemote?.nonBatchConcurrency != null, + false, ); const includeRemote = hasRemoteConfig || @@ -259,27 +258,16 @@ function mergeConfig( fallbackAdapter?.transport === "remote"; const batch = { enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? false, - wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true, - concurrency: Math.max( - 1, - overrideRemote?.batch?.concurrency ?? defaultRemote?.batch?.concurrency ?? 2, - ), - pollIntervalMs: resolveRemoteBatchPollIntervalMs( - overrideRemote?.batch?.pollIntervalMs, - defaultRemote?.batch?.pollIntervalMs, - ), - timeoutMinutes: resolveRemoteBatchTimeoutMinutes( - overrideRemote?.batch?.timeoutMinutes, - defaultRemote?.batch?.timeoutMinutes, - ), + wait: true, + concurrency: 2, + pollIntervalMs: resolveRemoteBatchPollIntervalMs(undefined, undefined), + timeoutMinutes: resolveRemoteBatchTimeoutMinutes(undefined, undefined), }; const remote = includeRemote ? { baseUrl: overrideRemote?.baseUrl ?? defaultRemote?.baseUrl, apiKey: overrideRemote?.apiKey ?? defaultRemote?.apiKey, headers: overrideRemote?.headers ?? defaultRemote?.headers, - nonBatchConcurrency: - overrideRemote?.nonBatchConcurrency ?? defaultRemote?.nonBatchConcurrency, batch, } : undefined; @@ -293,8 +281,6 @@ function mergeConfig( const outputDimensionality = overrides?.outputDimensionality ?? defaults?.outputDimensionality; const local = { modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath, - modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir, - contextSize: overrides?.local?.contextSize ?? defaults?.local?.contextSize, }; const configuredSources = overrides?.sources ?? defaults?.sources; const searchSources = normalizeSources( @@ -325,7 +311,7 @@ function mergeConfig( tokenizer: overrides?.store?.fts?.tokenizer ?? defaults?.store?.fts?.tokenizer ?? "unicode61", }; const store = { - driver: overrides?.store?.driver ?? defaults?.store?.driver ?? "sqlite", + driver: "sqlite" as const, databasePath: resolveOpenClawAgentSqlitePath({ agentId, env: process.env }), fts, vector, @@ -340,25 +326,16 @@ function mergeConfig( minScore: overrides?.query?.minScore ?? defaults?.query?.minScore ?? DEFAULT_MIN_SCORE, }; const hybrid = { - enabled: - overrides?.query?.hybrid?.enabled ?? - defaults?.query?.hybrid?.enabled ?? - DEFAULT_HYBRID_ENABLED, + enabled: DEFAULT_HYBRID_ENABLED, vectorWeight: DEFAULT_HYBRID_VECTOR_WEIGHT, textWeight: DEFAULT_HYBRID_TEXT_WEIGHT, candidateMultiplier: DEFAULT_HYBRID_CANDIDATE_MULTIPLIER, mmr: { - enabled: - overrides?.query?.hybrid?.mmr?.enabled ?? - defaults?.query?.hybrid?.mmr?.enabled ?? - DEFAULT_MMR_ENABLED, + enabled: DEFAULT_MMR_ENABLED, lambda: DEFAULT_MMR_LAMBDA, }, temporalDecay: { - enabled: - overrides?.query?.hybrid?.temporalDecay?.enabled ?? - defaults?.query?.hybrid?.temporalDecay?.enabled ?? - DEFAULT_TEMPORAL_DECAY_ENABLED, + enabled: DEFAULT_TEMPORAL_DECAY_ENABLED, halfLifeDays: DEFAULT_TEMPORAL_DECAY_HALF_LIFE_DAYS, }, }; @@ -446,30 +423,20 @@ function mergeConfig( } function resolveSyncConfig( - defaults: MemorySearchConfig | undefined, - overrides: MemorySearchConfig | undefined, + _defaults: MemorySearchConfig | undefined, + _overrides: MemorySearchConfig | undefined, ): ResolvedMemorySearchSyncConfig { return { - onSessionStart: overrides?.sync?.onSessionStart ?? defaults?.sync?.onSessionStart ?? true, - onSearch: overrides?.sync?.onSearch ?? defaults?.sync?.onSearch ?? true, - watch: overrides?.sync?.watch ?? defaults?.sync?.watch ?? true, + onSessionStart: true, + onSearch: true, + watch: true, watchDebounceMs: DEFAULT_WATCH_DEBOUNCE_MS, intervalMinutes: 0, - embeddingBatchTimeoutSeconds: - overrides?.sync?.embeddingBatchTimeoutSeconds ?? defaults?.sync?.embeddingBatchTimeoutSeconds, + embeddingBatchTimeoutSeconds: undefined, sessions: { - deltaBytes: - overrides?.sync?.sessions?.deltaBytes ?? - defaults?.sync?.sessions?.deltaBytes ?? - DEFAULT_SESSION_DELTA_BYTES, - deltaMessages: - overrides?.sync?.sessions?.deltaMessages ?? - defaults?.sync?.sessions?.deltaMessages ?? - DEFAULT_SESSION_DELTA_MESSAGES, - postCompactionForce: - overrides?.sync?.sessions?.postCompactionForce ?? - defaults?.sync?.sessions?.postCompactionForce ?? - true, + deltaBytes: DEFAULT_SESSION_DELTA_BYTES, + deltaMessages: DEFAULT_SESSION_DELTA_MESSAGES, + postCompactionForce: true, }, }; } @@ -478,8 +445,8 @@ export function resolveMemorySearchConfig( cfg: OpenClawConfig, agentId: string, ): ResolvedMemorySearchConfig | null { - const defaults = cfg.agents?.defaults?.memorySearch; - const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch; + const defaults = cfg.memory?.search; + const overrides = resolveAgentConfig(cfg, agentId)?.memory?.search; const resolved = mergeConfig(cfg, defaults, overrides, agentId); if (!resolved.enabled) { return null; @@ -500,12 +467,12 @@ export function resolveMemorySearchConfig( (!multimodalProvider && getEmbeddingProvider(resolved.provider, cfg))) ) { throw new Error( - "agents.*.memorySearch.multimodal requires a provider adapter that supports multimodal embeddings for the configured model.", + "memory.search.multimodal requires a provider adapter that supports multimodal embeddings for the configured model.", ); } if (multimodalActive && resolved.fallback !== "none") { throw new Error( - 'agents.*.memorySearch.multimodal does not support memorySearch.fallback. Set fallback to "none".', + 'memory.search.multimodal does not support memory.search.fallback. Set fallback to "none".', ); } return resolved; @@ -515,8 +482,8 @@ export function resolveMemorySearchSyncConfig( cfg: OpenClawConfig, agentId: string, ): ResolvedMemorySearchSyncConfig | null { - const defaults = cfg.agents?.defaults?.memorySearch; - const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch; + const defaults = cfg.memory?.search; + const overrides = resolveAgentConfig(cfg, agentId)?.memory?.search; const enabled = overrides?.enabled ?? defaults?.enabled ?? true; if (!enabled) { return null; diff --git a/src/agents/models-config.e2e-harness.ts b/src/agents/models-config.e2e-harness.ts index 2a583e7069c0..16930931aaa9 100644 --- a/src/agents/models-config.e2e-harness.ts +++ b/src/agents/models-config.e2e-harness.ts @@ -90,7 +90,6 @@ export function unsetEnv(vars: string[]) { /** Ambient env vars cleared by implicit provider discovery tests. */ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [ - "OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS", "VITEST", "NODE_ENV", "AI_GATEWAY_API_KEY", diff --git a/src/agents/models-config.providers.implicit.ts b/src/agents/models-config.providers.implicit.ts index fba9af7aa156..27733fcb1b2a 100644 --- a/src/agents/models-config.providers.implicit.ts +++ b/src/agents/models-config.providers.implicit.ts @@ -7,10 +7,7 @@ import { findNormalizedProviderValue, normalizeProviderId, } from "@openclaw/model-catalog-core/provider-id"; -import { - normalizeStringEntries, - uniqueStrings, -} from "@openclaw/normalization-core/string-normalization"; +import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -95,11 +92,6 @@ function resolveProviderDiscoveryFilter(params: { providerIds?: readonly string[]; }): string[] | undefined { const { config, workspaceDir, env } = params; - const testRaw = env.OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS?.trim(); - if (testRaw) { - const ids = normalizeStringEntries(testRaw.split(",")); - return ids.length > 0 ? uniqueStrings(ids) : undefined; - } const scopedProviderIds = params.providerIds ? normalizeStringEntries([...params.providerIds]) : undefined; diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index 4cddefc1c5cd..b6f50c337f6f 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -284,7 +284,6 @@ function enableLiveProviderPlugins( ...compatConfig.plugins, enabled: true, allow: [...allow].toSorted((left, right) => left.localeCompare(right)), - bundledDiscovery: compatConfig.plugins?.bundledDiscovery ?? "compat", entries, }, }; @@ -854,7 +853,6 @@ describe("explicit live model discovery scope", () => { const cfg = { plugins: { allow: ["openai"], - bundledDiscovery: "compat", entries: { openai: { enabled: true }, }, @@ -869,15 +867,12 @@ describe("explicit live model discovery scope", () => { expect(result.plugins?.enabled).toBe(true); expect(result.plugins?.allow).toContain("deepseek"); - expect(result.plugins?.bundledDiscovery).toBe("compat"); expect(result.plugins?.entries?.deepseek).toEqual({ enabled: true }); }); it("hydrates Ollama Cloud provider settings from live env when Ollama is in scope", () => { const cfg = { - plugins: { - bundledDiscovery: "compat", - }, + plugins: {}, } satisfies OpenClawConfig; const result = applyLiveProviderDiscoveryPluginCompat({ @@ -899,9 +894,7 @@ describe("explicit live model discovery scope", () => { it("defaults Ollama live provider settings to the local endpoint", () => { const cfg = { - plugins: { - bundledDiscovery: "compat", - }, + plugins: {}, } satisfies OpenClawConfig; const result = applyLiveProviderDiscoveryPluginCompat({ @@ -921,9 +914,7 @@ describe("explicit live model discovery scope", () => { it("preserves configured Ollama provider endpoints when live env is absent", () => { const cfg = { - plugins: { - bundledDiscovery: "compat", - }, + plugins: {}, models: { providers: { ollama: { @@ -951,9 +942,7 @@ describe("explicit live model discovery scope", () => { it("honors the documented Ollama baseURL alias when live env is absent", () => { const cfg = { - plugins: { - bundledDiscovery: "compat", - }, + plugins: {}, models: { providers: { ollama: { @@ -982,9 +971,7 @@ describe("explicit live model discovery scope", () => { it("uses the local Ollama auth marker for self-hosted live env URLs", () => { const cfg = { - plugins: { - bundledDiscovery: "compat", - }, + plugins: {}, } satisfies OpenClawConfig; for (const baseUrl of [ @@ -1020,9 +1007,7 @@ describe("explicit live model discovery scope", () => { for (const apiKey of remoteApiKeyRefs) { const cfg = { - plugins: { - bundledDiscovery: "compat", - }, + plugins: {}, models: { providers: { ollama: { @@ -1054,9 +1039,7 @@ describe("explicit live model discovery scope", () => { it("replaces configured Ollama auth when live env redirects to a different local endpoint", () => { const cfg = { - plugins: { - bundledDiscovery: "compat", - }, + plugins: {}, models: { providers: { ollama: { @@ -1087,9 +1070,7 @@ describe("explicit live model discovery scope", () => { it("preserves configured Ollama auth for equivalent live env base URLs", () => { const cfg = { - plugins: { - bundledDiscovery: "compat", - }, + plugins: {}, models: { providers: { ollama: { @@ -1121,9 +1102,7 @@ describe("explicit live model discovery scope", () => { it("keeps local Ollama live auth on the non-secret marker", async () => { const cfg = applyLiveProviderDiscoveryPluginCompat({ config: { - plugins: { - bundledDiscovery: "compat", - }, + plugins: {}, }, providers: ["ollama"], env: { @@ -1156,9 +1135,7 @@ describe("explicit live model discovery scope", () => { try { const cfg = applyLiveProviderDiscoveryPluginCompat({ config: { - plugins: { - bundledDiscovery: "compat", - }, + plugins: {}, }, providers: ["ollama"], env: { @@ -1197,9 +1174,7 @@ describe("explicit live model discovery scope", () => { try { const cfg = applyLiveProviderDiscoveryPluginCompat({ config: { - plugins: { - bundledDiscovery: "compat", - }, + plugins: {}, models: { providers: { ollama: { @@ -1246,9 +1221,7 @@ describe("explicit live model discovery scope", () => { try { const cfg = applyLiveProviderDiscoveryPluginCompat({ config: { - plugins: { - bundledDiscovery: "compat", - }, + plugins: {}, models: { providers: { ollama: { diff --git a/src/agents/openclaw-tools.media-factory-plan.test.ts b/src/agents/openclaw-tools.media-factory-plan.test.ts index cee01ee6256e..d75352afbbc1 100644 --- a/src/agents/openclaw-tools.media-factory-plan.test.ts +++ b/src/agents/openclaw-tools.media-factory-plan.test.ts @@ -274,9 +274,11 @@ describe("optional media tool factory planning", () => { const config: OpenClawConfig = { agents: { defaults: { - imageGenerationModel: { primary: "image-owner/model" }, - videoGenerationModel: { primary: "video-owner/model" }, - musicGenerationModel: { primary: "music-owner/model" }, + mediaModels: { + image: { primary: "image-owner/model" }, + video: { primary: "video-owner/model" }, + music: { primary: "music-owner/model" }, + }, pdfModel: { primary: "media-owner/model" }, }, }, @@ -300,9 +302,11 @@ describe("optional media tool factory planning", () => { const config: OpenClawConfig = { agents: { defaults: { - imageGenerationModel: { primary: "image-owner/model" }, - videoGenerationModel: { primary: "video-owner/model" }, - musicGenerationModel: { primary: "music-owner/model" }, + mediaModels: { + image: { primary: "image-owner/model" }, + video: { primary: "video-owner/model" }, + music: { primary: "music-owner/model" }, + }, pdfModel: { primary: "media-owner/model" }, }, }, @@ -341,9 +345,11 @@ describe("optional media tool factory planning", () => { const config: OpenClawConfig = { agents: { defaults: { - imageGenerationModel: { primary: "image-owner/model" }, - videoGenerationModel: { primary: "video-owner/model" }, - musicGenerationModel: { primary: "music-owner/model" }, + mediaModels: { + image: { primary: "image-owner/model" }, + video: { primary: "video-owner/model" }, + music: { primary: "music-owner/model" }, + }, pdfModel: { primary: "media-owner/model" }, }, }, diff --git a/src/agents/openclaw-tools.media-factory-plan.ts b/src/agents/openclaw-tools.media-factory-plan.ts index 53450f3e1cea..12a05fb94826 100644 --- a/src/agents/openclaw-tools.media-factory-plan.ts +++ b/src/agents/openclaw-tools.media-factory-plan.ts @@ -209,9 +209,9 @@ export function resolveOptionalMediaToolFactoryPlan(params: { allowlist: toolAllowlist, denylist: toolDenylist, }); - const explicitImageGeneration = hasExplicitToolModelConfig(defaults?.imageGenerationModel); - const explicitVideoGeneration = hasExplicitToolModelConfig(defaults?.videoGenerationModel); - const explicitMusicGeneration = hasExplicitToolModelConfig(defaults?.musicGenerationModel); + const explicitImageGeneration = hasExplicitToolModelConfig(defaults?.mediaModels?.image); + const explicitVideoGeneration = hasExplicitToolModelConfig(defaults?.mediaModels?.video); + const explicitMusicGeneration = hasExplicitToolModelConfig(defaults?.mediaModels?.music); const explicitPdf = hasExplicitPdfModelConfig(params.config); if (params.config?.plugins?.enabled === false) { // Optional media tools are plugin/capability backed. Disabling plugins shuts them off even when diff --git a/src/agents/openclaw-tools.tts-config.test.ts b/src/agents/openclaw-tools.tts-config.test.ts index a329bce3caf1..46db34e61674 100644 --- a/src/agents/openclaw-tools.tts-config.test.ts +++ b/src/agents/openclaw-tools.tts-config.test.ts @@ -161,14 +161,12 @@ describe("createOpenClawTools TTS config wiring", () => { it("passes the resolved shared config into the tts tool", async () => { const injectedConfig = { - messages: { - tts: { - auto: "always", - provider: "microsoft", - providers: { - microsoft: { - voice: "en-US-AvaNeural", - }, + tts: { + auto: "always", + provider: "microsoft", + providers: { + microsoft: { + voice: "en-US-AvaNeural", }, }, }, @@ -279,9 +277,11 @@ describe("createOpenClawTools media generation session wiring", () => { const config = { agents: { defaults: { - imageGenerationModel: { primary: "image-owner/model" }, - videoGenerationModel: { primary: "video-owner/model" }, - musicGenerationModel: { primary: "music-owner/model" }, + mediaModels: { + image: { primary: "image-owner/model" }, + video: { primary: "video-owner/model" }, + music: { primary: "music-owner/model" }, + }, }, }, } satisfies OpenClawConfig; @@ -317,7 +317,7 @@ describe("createOpenClawTools media generation session wiring", () => { const config = { agents: { defaults: { - imageGenerationModel: { primary: "image-owner/model" }, + mediaModels: { image: { primary: "image-owner/model" } }, }, }, } satisfies OpenClawConfig; diff --git a/src/agents/owner-display.test.ts b/src/agents/owner-display.test.ts index 71fb9ef94a34..105c84b92db6 100644 --- a/src/agents/owner-display.test.ts +++ b/src/agents/owner-display.test.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { ensureOwnerDisplaySecret, resolveOwnerDisplaySetting } from "./owner-display.js"; describe("resolveOwnerDisplaySetting", () => { - it("returns keyed hash settings when hash mode has an explicit secret", () => { + it("always uses raw owner ids after hash configuration retirement", () => { const cfg = { commands: { ownerDisplay: "hash", @@ -13,25 +13,7 @@ describe("resolveOwnerDisplaySetting", () => { } as OpenClawConfig; expect(resolveOwnerDisplaySetting(cfg)).toEqual({ - ownerDisplay: "hash", - ownerDisplaySecret: "owner-secret", // pragma: allowlist secret - }); - }); - - it("does not fall back to gateway tokens when hash secret is missing", () => { - // Gateway auth tokens are unrelated secrets and must never seed owner hashes. - const cfg = { - commands: { - ownerDisplay: "hash", - }, - gateway: { - auth: { token: "gateway-auth-token" }, - remote: { token: "gateway-remote-token" }, - }, - } as OpenClawConfig; - - expect(resolveOwnerDisplaySetting(cfg)).toEqual({ - ownerDisplay: "hash", + ownerDisplay: "raw", ownerDisplaySecret: undefined, }); }); @@ -52,7 +34,7 @@ describe("resolveOwnerDisplaySetting", () => { }); describe("ensureOwnerDisplaySecret", () => { - it("generates a dedicated secret when hash mode is enabled without one", () => { + it("leaves retired hash configuration untouched without generating a secret", () => { const cfg = { commands: { ownerDisplay: "hash", @@ -60,8 +42,8 @@ describe("ensureOwnerDisplaySecret", () => { } as OpenClawConfig; const result = ensureOwnerDisplaySecret(cfg, () => "generated-owner-secret"); - expect(result.generatedSecret).toBe("generated-owner-secret"); - expect(result.config.commands?.ownerDisplaySecret).toBe("generated-owner-secret"); + expect(result.generatedSecret).toBeUndefined(); + expect(result.config.commands?.ownerDisplaySecret).toBeUndefined(); expect(result.config.commands?.ownerDisplay).toBe("hash"); }); diff --git a/src/agents/owner-display.ts b/src/agents/owner-display.ts index 489da21366bb..b233e811baab 100644 --- a/src/agents/owner-display.ts +++ b/src/agents/owner-display.ts @@ -1,10 +1,8 @@ /** * Owner display settings for prompt rendering. * - * Hash mode uses a dedicated prompt-display secret so auth material is never reused for owner redaction. + * Owner ids are rendered raw; no config or secret is required. */ -import crypto from "node:crypto"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; type OwnerDisplaySetting = { @@ -21,15 +19,8 @@ type OwnerDisplaySecretResolution = { * Resolve owner display settings for prompt rendering. * Keep auth secrets decoupled from owner hash secrets. */ -export function resolveOwnerDisplaySetting(config?: OpenClawConfig): OwnerDisplaySetting { - const ownerDisplay = config?.commands?.ownerDisplay; - if (ownerDisplay !== "hash") { - return { ownerDisplay, ownerDisplaySecret: undefined }; - } - return { - ownerDisplay: "hash", - ownerDisplaySecret: normalizeOptionalString(config?.commands?.ownerDisplaySecret), - }; +export function resolveOwnerDisplaySetting(_config?: OpenClawConfig): OwnerDisplaySetting { + return { ownerDisplay: "raw", ownerDisplaySecret: undefined }; } /** @@ -38,22 +29,7 @@ export function resolveOwnerDisplaySetting(config?: OpenClawConfig): OwnerDispla */ export function ensureOwnerDisplaySecret( config: OpenClawConfig, - generateSecret: () => string = () => crypto.randomBytes(32).toString("hex"), + _generateSecret?: () => string, ): OwnerDisplaySecretResolution { - const settings = resolveOwnerDisplaySetting(config); - if (settings.ownerDisplay !== "hash" || settings.ownerDisplaySecret) { - return { config }; - } - const generatedSecret = generateSecret(); - return { - config: { - ...config, - commands: { - ...config.commands, - ownerDisplay: "hash", - ownerDisplaySecret: generatedSecret, - }, - }, - generatedSecret, - }; + return { config }; } diff --git a/src/agents/prompt-overlay-runtime-contract.test.ts b/src/agents/prompt-overlay-runtime-contract.test.ts index 1ef7cfb1ebf7..be2d8dbb258d 100644 --- a/src/agents/prompt-overlay-runtime-contract.test.ts +++ b/src/agents/prompt-overlay-runtime-contract.test.ts @@ -43,7 +43,7 @@ describe("GPT-5 prompt overlay runtime contract", () => { ); }); - it("lets the shared GPT-5 overlay config disable friendly style without removing the behavior contract", () => { + it("ignores the retired shared overlay switch and keeps friendly style", () => { const contribution = resolveGpt5SystemPromptContribution({ providerId: NON_OPENAI_CONTRACT_PROVIDER_ID, modelId: GPT5_PREFIXED_CONTRACT_MODEL_ID, @@ -51,7 +51,9 @@ describe("GPT-5 prompt overlay runtime contract", () => { }); expect(contribution?.stablePrefix).toContain(""); - expect(contribution?.sectionOverrides).toStrictEqual({}); + expect(contribution?.sectionOverrides?.interaction_style).toContain( + "Live chat: short, natural, human.", + ); }); it("scopes OpenAI plugin personality fallback to OpenAI-family GPT-5 providers", () => { diff --git a/src/agents/runtime-capabilities.test.ts b/src/agents/runtime-capabilities.test.ts index 1d24658390a2..34cd27cca1f7 100644 --- a/src/agents/runtime-capabilities.test.ts +++ b/src/agents/runtime-capabilities.test.ts @@ -8,11 +8,9 @@ describe("collectRuntimeChannelCapabilities", () => { channel: "discord", accountId: "default", cfg: { - channels: { - discord: { - threadBindings: { - spawnSessions: true, - }, + session: { + threadBindings: { + spawnSessions: true, }, }, }, @@ -26,11 +24,9 @@ describe("collectRuntimeChannelCapabilities", () => { channel: "discord", accountId: "default", cfg: { - channels: { - discord: { - threadBindings: { - spawnSessions: false, - }, + session: { + threadBindings: { + spawnSessions: false, }, }, }, diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts index 24f9a29ce5bf..11788a7597d7 100644 --- a/src/agents/sandbox-merge.test.ts +++ b/src/agents/sandbox-merge.test.ts @@ -127,12 +127,12 @@ describe("sandbox config merges", () => { it("applies per-agent browser and prune overrides (ignored under shared scope)", () => { const browser = resolveSandboxBrowserConfig({ scope: "agent", - globalBrowser: { enabled: false, headless: false, enableNoVnc: true }, - agentBrowser: { enabled: true, headless: true, enableNoVnc: false }, + globalBrowser: { enabled: false, headless: false, noVncEnabled: true }, + agentBrowser: { enabled: true, headless: true, noVncEnabled: false }, }); expect(browser.enabled).toBe(true); expect(browser.headless).toBe(true); - expect(browser.enableNoVnc).toBe(false); + expect(browser.noVncEnabled).toBe(false); const prune = resolveSandboxPruneConfig({ scope: "agent", diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 3a80db904250..8a443e7ad3ed 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -115,7 +115,7 @@ async function loadFreshBrowserModulesForTest() { ({ ensureSandboxBrowser } = await import("./browser.js")); } -function buildConfig(enableNoVnc: boolean): SandboxConfig { +function buildConfig(noVncEnabled: boolean): SandboxConfig { return { mode: "all", backend: "docker", @@ -147,7 +147,7 @@ function buildConfig(enableNoVnc: boolean): SandboxConfig { vncPort: 5900, noVncPort: 6080, headless: false, - enableNoVnc, + noVncEnabled, allowHostControl: false, autoStart: true, autoStartTimeoutMs: 12_000, @@ -185,7 +185,7 @@ function computeTestBrowserHash(params: { vncPort: params.cfg.browser.vncPort, noVncPort: params.cfg.browser.noVncPort, headless: params.cfg.browser.headless, - enableNoVnc: params.cfg.browser.enableNoVnc, + noVncEnabled: params.cfg.browser.noVncEnabled, autoStartTimeoutMs: params.cfg.browser.autoStartTimeoutMs, }, securityEpoch: SANDBOX_BROWSER_SECURITY_HASH_EPOCH, diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 4318b6004d47..bc7ca20f101c 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -266,7 +266,7 @@ export async function ensureSandboxBrowser(params: { vncPort: params.cfg.browser.vncPort, noVncPort: params.cfg.browser.noVncPort, headless: params.cfg.browser.headless, - enableNoVnc: params.cfg.browser.enableNoVnc, + noVncEnabled: params.cfg.browser.noVncEnabled, autoStartTimeoutMs: params.cfg.browser.autoStartTimeoutMs, cdpSourceRange, }, @@ -389,7 +389,7 @@ export async function ensureSandboxBrowser(params: { args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`); } args.push("-e", `OPENCLAW_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`); - args.push("-e", `OPENCLAW_BROWSER_ENABLE_NOVNC=${params.cfg.browser.enableNoVnc ? "1" : "0"}`); + args.push("-e", `OPENCLAW_BROWSER_ENABLE_NOVNC=${params.cfg.browser.noVncEnabled ? "1" : "0"}`); args.push("-e", `OPENCLAW_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`); args.push("-e", `${CDP_AUTH_TOKEN_ENV_KEY}=${cdpAuthToken}`); args.push( diff --git a/src/agents/sandbox/config-hash.test.ts b/src/agents/sandbox/config-hash.test.ts index 1deea584ee32..5ba466f322ef 100644 --- a/src/agents/sandbox/config-hash.test.ts +++ b/src/agents/sandbox/config-hash.test.ts @@ -163,7 +163,7 @@ describe("computeSandboxBrowserConfigHash", () => { vncPort: 5900, noVncPort: 6080, headless: false, - enableNoVnc: true, + noVncEnabled: true, autoStartTimeoutMs: 12000, }, securityEpoch: "epoch-v1", @@ -197,7 +197,7 @@ describe("computeSandboxBrowserConfigHash", () => { vncPort: 5900, noVncPort: 6080, headless: false, - enableNoVnc: true, + noVncEnabled: true, autoStartTimeoutMs: 12000, }, securityEpoch: "browser-security-v1", @@ -220,7 +220,7 @@ describe("computeSandboxBrowserConfigHash", () => { vncPort: 5900, noVncPort: 6080, headless: false, - enableNoVnc: true, + noVncEnabled: true, autoStartTimeoutMs: 12000, }, workspaceAccess: "rw" as const, @@ -248,7 +248,7 @@ describe("computeSandboxBrowserConfigHash", () => { vncPort: 5900, noVncPort: 6080, headless: false, - enableNoVnc: true, + noVncEnabled: true, autoStartTimeoutMs: 12000, }, securityEpoch: "epoch-v1", @@ -278,7 +278,7 @@ describe("computeSandboxBrowserConfigHash", () => { vncPort: 5900, noVncPort: 6080, headless: false, - enableNoVnc: true, + noVncEnabled: true, autoStartTimeoutMs: 12000, }, securityEpoch: "epoch-v1", diff --git a/src/agents/sandbox/config-hash.ts b/src/agents/sandbox/config-hash.ts index fff2ba71638b..48666ffd9128 100644 --- a/src/agents/sandbox/config-hash.ts +++ b/src/agents/sandbox/config-hash.ts @@ -35,7 +35,7 @@ type SandboxBrowserHashInput = { | "vncPort" | "noVncPort" | "headless" - | "enableNoVnc" + | "noVncEnabled" | "autoStartTimeoutMs" >; securityEpoch: string; diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index a11f4031f453..3387f0dedfd0 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -161,7 +161,7 @@ export function resolveSandboxBrowserConfig(params: { noVncPort: agentBrowser?.noVncPort ?? globalBrowser?.noVncPort ?? DEFAULT_SANDBOX_BROWSER_NOVNC_PORT, headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false, - enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true, + noVncEnabled: agentBrowser?.noVncEnabled ?? globalBrowser?.noVncEnabled ?? true, allowHostControl: agentBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ?? false, autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true, autoStartTimeoutMs: resolveSandboxBrowserAutoStartTimeoutMs( diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index dcb2940922c2..c7e93e10d5ab 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -152,7 +152,7 @@ function createSandboxConfig( vncPort: 5900, noVncPort: 6080, headless: true, - enableNoVnc: false, + noVncEnabled: false, allowHostControl: false, autoStart: false, autoStartTimeoutMs: 5000, diff --git a/src/agents/sandbox/novnc-auth.ts b/src/agents/sandbox/novnc-auth.ts index 675a1adbfa49..1f72a2d196cc 100644 --- a/src/agents/sandbox/novnc-auth.ts +++ b/src/agents/sandbox/novnc-auth.ts @@ -50,8 +50,8 @@ function resolveNoVncObserverTokenExpiresAt(params: { ttlMs?: number; nowMs: num ); } -export function isNoVncEnabled(params: { enableNoVnc: boolean; headless: boolean }) { - return params.enableNoVnc && !params.headless; +export function isNoVncEnabled(params: { noVncEnabled: boolean; headless: boolean }) { + return params.noVncEnabled && !params.headless; } export function generateNoVncPassword() { diff --git a/src/agents/sandbox/prune.test.ts b/src/agents/sandbox/prune.test.ts index 69d4d1467e20..b92aae3b4d58 100644 --- a/src/agents/sandbox/prune.test.ts +++ b/src/agents/sandbox/prune.test.ts @@ -88,7 +88,7 @@ function buildPruneConfig(): SandboxConfig { vncPort: 5900, noVncPort: 6080, headless: true, - enableNoVnc: false, + noVncEnabled: false, allowHostControl: false, autoStart: true, autoStartTimeoutMs: 1_000, diff --git a/src/agents/sandbox/ssh-backend.test.ts b/src/agents/sandbox/ssh-backend.test.ts index 6356cafa21b7..1f0e4dc98b60 100644 --- a/src/agents/sandbox/ssh-backend.test.ts +++ b/src/agents/sandbox/ssh-backend.test.ts @@ -430,7 +430,7 @@ describe("ssh sandbox backend", () => { vncPort: 5900, noVncPort: 6080, headless: true, - enableNoVnc: false, + noVncEnabled: false, allowHostControl: false, autoStart: false, autoStartTimeoutMs: 1000, diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index d4c02648c383..d43f4badfd7c 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -45,7 +45,7 @@ export type SandboxBrowserConfig = { vncPort: number; noVncPort: number; headless: boolean; - enableNoVnc: boolean; + noVncEnabled: boolean; allowHostControl: boolean; autoStart: boolean; autoStartTimeoutMs: number; diff --git a/src/agents/session-tool-result-guard.test.ts b/src/agents/session-tool-result-guard.test.ts index 2b9f00eccd0b..f75f42ceb1c6 100644 --- a/src/agents/session-tool-result-guard.test.ts +++ b/src/agents/session-tool-result-guard.test.ts @@ -506,7 +506,7 @@ describe("installSessionToolResultGuard", () => { const sm = SessionManager.inMemory(); installSessionToolResultGuard(sm, { beforeMessageWriteHook: ({ message }) => ({ - message: redactTranscriptMessage(message, { logging: { redactSensitive: "tools" } }), + message: redactTranscriptMessage(message, {}), }), }); diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 8b64006e7ad3..112c9440bb89 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -15,6 +15,7 @@ import { getLoadedChannelPluginForRead } from "../channels/plugins/registry-load import type { ChannelId } from "../channels/plugins/types.public.js"; import { routeFromConversationRef, routeToDeliveryFields } from "../channels/route-projection.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isFastTestRuntimeEnv } from "../infra/env.js"; import { isOutboundDeliveryError } from "../infra/outbound/deliver-types.js"; import type { ConversationRef } from "../infra/outbound/session-binding-service.js"; import { sourceDeliveryTargetsMatch } from "../infra/outbound/source-delivery-plan.js"; @@ -246,9 +247,7 @@ function resolveRequesterSessionActivity(requesterSessionKey: string) { } function resolveDirectAnnounceTransientRetryDelaysMs() { - return process.env.OPENCLAW_TEST_FAST === "1" - ? ([8, 16, 32] as const) - : ([5_000, 10_000, 20_000] as const); + return isFastTestRuntimeEnv() ? ([8, 16, 32] as const) : ([5_000, 10_000, 20_000] as const); } // Backoff schedule for re-attempting an active-requester steer while the run is @@ -256,7 +255,7 @@ function resolveDirectAnnounceTransientRetryDelaysMs() { // schedule is used than for transient delivery errors. Total wait stays well // within the announce delivery timeout, and the loop also stops on cancellation. function resolveCompactionSteerRetryDelaysMs() { - return process.env.OPENCLAW_TEST_FAST === "1" + return isFastTestRuntimeEnv() ? ([8, 16, 32, 64] as const) : ([1_000, 2_000, 4_000, 8_000] as const); } diff --git a/src/agents/subagent-announce-output.ts b/src/agents/subagent-announce-output.ts index dc286b1a6905..7ac089be1c93 100644 --- a/src/agents/subagent-announce-output.ts +++ b/src/agents/subagent-announce-output.ts @@ -5,6 +5,7 @@ */ import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { isFastTestRuntimeEnv } from "../infra/env.js"; import { formatDurationCompact } from "../infra/format-time/format-duration.js"; import { buildAgentRunTerminalOutcomeFromWaitResult } from "./agent-run-terminal-outcome.js"; import { wrapPromptDataBlock } from "./sanitize-for-prompt.js"; @@ -48,7 +49,7 @@ const defaultSubagentAnnounceOutputDeps: SubagentAnnounceOutputDeps = { let subagentAnnounceOutputDeps: SubagentAnnounceOutputDeps = defaultSubagentAnnounceOutputDeps; function isFastTestMode() { - return process.env.OPENCLAW_TEST_FAST === "1"; + return isFastTestRuntimeEnv(); } type SubagentOutputSnapshot = { diff --git a/src/agents/subagent-registry-run-manager.ts b/src/agents/subagent-registry-run-manager.ts index 60ba800ba765..3f76aff8f7dc 100644 --- a/src/agents/subagent-registry-run-manager.ts +++ b/src/agents/subagent-registry-run-manager.ts @@ -6,6 +6,7 @@ import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway } from "../gateway/call.js"; +import { isFastTestRuntimeEnv } from "../infra/env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-work-admission.js"; import { @@ -64,7 +65,7 @@ import { updateSwarmCollectorCompletion } from "./swarm-collector.js"; import { isSwarmRunQueued, removeQueuedSwarmRun } from "./swarm-scheduler.js"; const log = createSubsystemLogger("agents/subagent-registry"); -const RECOVERABLE_WAIT_RETRY_DELAY_MS = process.env.OPENCLAW_TEST_FAST === "1" ? 25 : 5_000; +const RECOVERABLE_WAIT_RETRY_DELAY_MS = isFastTestRuntimeEnv() ? 25 : 5_000; const WAIT_TIMEOUT_DEADLINE_SKEW_MS = 250; function shouldDeleteAttachments(entry: SubagentRunRecord) { diff --git a/src/agents/subagent-registry-state.ts b/src/agents/subagent-registry-state.ts index 55850a1e5b66..e1c2dec56d56 100644 --- a/src/agents/subagent-registry-state.ts +++ b/src/agents/subagent-registry-state.ts @@ -1,3 +1,4 @@ +import { isVitestRuntimeEnv } from "../infra/env.js"; /** * Subagent registry state persistence bridge. * @@ -56,10 +57,7 @@ function rememberPersistedSubagentRunsSnapshot(runs: Map((resolve) => { - const timer = setTimeout(resolve, process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000); + const timer = setTimeout(resolve, isFastTestRuntimeEnv() ? 1 : 1_000); timer.unref?.(); }); } @@ -1232,7 +1233,7 @@ async function failAndCleanupRestoredQueuedRun( } } await new Promise((resolve) => { - const timer = setTimeout(resolve, process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000); + const timer = setTimeout(resolve, isFastTestRuntimeEnv() ? 1 : 1_000); timer.unref?.(); }); } @@ -1260,7 +1261,7 @@ async function failAndCleanupRestoredQueuedRun( }); } await new Promise((resolve) => { - const timer = setTimeout(resolve, process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000); + const timer = setTimeout(resolve, isFastTestRuntimeEnv() ? 1 : 1_000); timer.unref?.(); }); } diff --git a/src/agents/subagent-spawn.in-process-gateway.test.ts b/src/agents/subagent-spawn.in-process-gateway.test.ts index 18665acc7f96..41679c6bd51d 100644 --- a/src/agents/subagent-spawn.in-process-gateway.test.ts +++ b/src/agents/subagent-spawn.in-process-gateway.test.ts @@ -112,7 +112,7 @@ describe("spawnSubagentDirect in-process Gateway collector launch", () => { tools: { swarm: true }, agents: { defaults: { workspace: stateDir }, - list: [{ id: "main", workspace: stateDir }], + entries: { main: { workspace: stateDir } }, }, })}\n`, ); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index c6813f04cbdd..8beefcba1d01 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -21,6 +21,7 @@ import { import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { SubagentSpawnPreparation } from "../context-engine/types.js"; +import { isFastTestRuntimeEnv } from "../infra/env.js"; import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js"; import { listRegisteredPluginAgentPromptGuidance } from "../plugins/command-registry-state.js"; import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js"; @@ -810,7 +811,7 @@ async function waitForProvisionalSessionDeletion( return; } await new Promise((resolve) => { - const timer = setTimeout(resolve, process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000); + const timer = setTimeout(resolve, isFastTestRuntimeEnv() ? 1 : 1_000); timer.unref?.(); }); } @@ -867,7 +868,7 @@ async function terminateAcceptedCollectorRun(params: { } } await new Promise((resolve) => { - const timer = setTimeout(resolve, process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000); + const timer = setTimeout(resolve, isFastTestRuntimeEnv() ? 1 : 1_000); timer.unref?.(); }); } @@ -1873,10 +1874,7 @@ export async function spawnSubagentDirect( } catch { // The child is stopped; retry only the durable terminal write. await new Promise((resolve) => { - const timer = setTimeout( - resolve, - process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000, - ); + const timer = setTimeout(resolve, isFastTestRuntimeEnv() ? 1 : 1_000); timer.unref?.(); }); } diff --git a/src/agents/swarm-scheduler.ts b/src/agents/swarm-scheduler.ts index 1bead495d962..6647d60e33c0 100644 --- a/src/agents/swarm-scheduler.ts +++ b/src/agents/swarm-scheduler.ts @@ -1,3 +1,5 @@ +import { isFastTestRuntimeEnv } from "../infra/env.js"; + type QueuedSwarmRun = { runId: string; start?: () => Promise; @@ -42,7 +44,7 @@ function startQueuedRun(lane: SwarmGroupLane, item: QueuedSwarmRun) { item.retryReady = true; pumpLane(lane); }, - process.env.OPENCLAW_TEST_FAST === "1" ? 1 : 1_000, + isFastTestRuntimeEnv() ? 1 : 1_000, ); timer.unref?.(); }); diff --git a/src/agents/system-prompt-params.ts b/src/agents/system-prompt-params.ts index 92490247e622..b32604255474 100644 --- a/src/agents/system-prompt-params.ts +++ b/src/agents/system-prompt-params.ts @@ -62,7 +62,7 @@ export function buildSystemPromptParams(params: { cwd: params.cwd, }); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); - const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); + const userTimeFormat = resolveUserTimeFormat(undefined); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); return { runtimeInfo: { diff --git a/src/agents/tools/computer-tool.test.ts b/src/agents/tools/computer-tool.test.ts index 949cc0f2a1c4..fec1d1cd12eb 100644 --- a/src/agents/tools/computer-tool.test.ts +++ b/src/agents/tools/computer-tool.test.ts @@ -492,7 +492,7 @@ describe("createComputerTool node resolution", () => { callGatewayToolMock.mockImplementation(async (_method, _opts, body) => { if ((body as { command?: string }).command === COMPUTER_ACT_COMMAND) { throw new Error( - 'node command not allowed: "computer.act" requires explicit gateway.nodes.allowCommands opt-in', + 'node command not allowed: "computer.act" requires explicit gateway.nodes.commands.allow opt-in', ); } // screen.snapshot succeeds so a frame is established before the click. @@ -514,7 +514,7 @@ describe("createComputerTool node resolution", () => { callGatewayToolMock.mockImplementation(async (_method, _opts, body) => { if ((body as { command?: string }).command === COMPUTER_ACT_COMMAND) { throw new Error( - 'node command not allowed: "computer.act" is blocked by gateway.nodes.denyCommands', + 'node command not allowed: "computer.act" is blocked by gateway.nodes.commands.deny', ); } return screenshotPayload(); @@ -830,7 +830,7 @@ describe("createComputerTool node resolution", () => { if ((body as { command?: string }).command === COMPUTER_ACT_COMMAND) { throw Object.assign( new Error( - 'node command not allowed: "computer.act" requires explicit gateway.nodes.allowCommands opt-in', + 'node command not allowed: "computer.act" requires explicit gateway.nodes.commands.allow opt-in', ), { name: "GatewayClientRequestError" }, ); diff --git a/src/agents/tools/computer-tool.ts b/src/agents/tools/computer-tool.ts index d1fafedf74c9..3add9c307d03 100644 --- a/src/agents/tools/computer-tool.ts +++ b/src/agents/tools/computer-tool.ts @@ -478,8 +478,8 @@ function resolveReferenceWidth(limits: { maxDimensionPx?: number }): number { // The gateway hint for dangerous commands (see buildNodeCommandRejectionHint // in src/gateway/server-methods/nodes.ts); mapped to the arming workflow. -const DANGEROUS_OPT_IN_HINT = "requires explicit gateway.nodes.allowCommands opt-in"; -const DANGEROUS_DENY_HINT = "blocked by gateway.nodes.denyCommands"; +const DANGEROUS_OPT_IN_HINT = "requires explicit gateway.nodes.commands.allow opt-in"; +const DANGEROUS_DENY_HINT = "blocked by gateway.nodes.commands.deny"; const BUTTON_NOT_HELD_HINT = "left button is not held by computer control"; export type ComputerContextEpoch = { @@ -564,7 +564,7 @@ function withArmHint(err: unknown): Error { return new Error( `${message} — computer control is disarmed; an operator can arm it with ` + `"/phone arm computer ". Persistent configuration must both allow ${COMPUTER_ACT_COMMAND} ` + - `and remove it from gateway.nodes.denyCommands.`, + `and remove it from gateway.nodes.commands.deny.`, { cause: err }, ); } diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index acf798164490..708c9056629b 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -32,8 +32,23 @@ let splitMediaFromOutput: typeof import("../../media/parse.js").splitMediaFromOu let mediaStore: typeof import("../../media/store.js"); let webMedia: typeof import("../../media/web-media.js"); let resetRecentMediaGenerationDuplicateGuardsForTests: typeof import("../media-generation-task-status-shared.test-support.js").resetRecentMediaGenerationDuplicateGuardsForTests; -let createImageGenerateTool: typeof import("./image-generate-tool.js").createImageGenerateTool; +let createImageGenerateToolImpl: typeof import("./image-generate-tool.js").createImageGenerateTool; let resolveImageGenerationModelConfigForTool: typeof import("./image-generate-tool.test-support.js").resolveImageGenerationModelConfigForTool; +import { canonicalizeMediaGenerationTestConfig } from "./media-generation-config.test-support.js"; + +function createImageGenerateTool( + params: Parameters[0], +): ReturnType { + const options = params ?? {}; + return createImageGenerateToolImpl({ + ...options, + config: canonicalizeMediaGenerationTestConfig( + options.config ?? {}, + "image", + "imageGenerationModel", + ), + }); +} const GENERATION_PROVIDER_ENV_VARS = [ "BYTEPLUS_API_KEY", @@ -337,7 +352,8 @@ describe("createImageGenerateTool", () => { webMedia = await import("../../media/web-media.js"); ({ resetRecentMediaGenerationDuplicateGuardsForTests } = await import("../media-generation-task-status-shared.test-support.js")); - ({ createImageGenerateTool } = await import("./image-generate-tool.js")); + ({ createImageGenerateTool: createImageGenerateToolImpl } = + await import("./image-generate-tool.js")); ({ resolveImageGenerationModelConfigForTool } = await import("./image-generate-tool.test-support.js")); }); @@ -395,8 +411,10 @@ describe("createImageGenerateTool", () => { config: { agents: { defaults: { - imageGenerationModel: { - primary: "openai/gpt-image-1", + mediaModels: { + image: { + primary: "openai/gpt-image-1", + }, }, }, }, @@ -466,8 +484,10 @@ describe("createImageGenerateTool", () => { cfg: { agents: { defaults: { - imageGenerationModel: { - primary: "openai/gpt-image-1", + mediaModels: { + image: { + primary: "openai/gpt-image-1", + }, }, }, }, @@ -658,8 +678,10 @@ describe("createImageGenerateTool", () => { agents: { defaults: { mediaMaxMb: 8, - imageGenerationModel: { - primary: "openai/gpt-image-1", + mediaModels: { + image: { + primary: "openai/gpt-image-1", + }, }, }, }, @@ -681,8 +703,10 @@ describe("createImageGenerateTool", () => { agents: { defaults: { mediaMaxMb: 8, - imageGenerationModel: { - primary: "openai/gpt-image-1", + mediaModels: { + image: { + primary: "openai/gpt-image-1", + }, }, }, }, @@ -769,14 +793,16 @@ describe("createImageGenerateTool", () => { const config: OpenClawConfig = { agents: { defaults: { - imageGenerationModel: { - primary: "bootstrap/unused", + mediaModels: { + image: { + primary: "bootstrap/unused", + }, }, }, }, }; const tool = requireImageGenerateTool(createImageGenerateTool({ config })); - config.agents!.defaults!.imageGenerationModel = { timeoutMs: 180_000 }; + config.agents!.defaults!.mediaModels!.image = { timeoutMs: 180_000 }; const result = await tool.execute("call-explicit-foundry", { prompt: "A product render", @@ -789,7 +815,7 @@ describe("createImageGenerateTool", () => { const cfg = requireRecord(generateArgs.cfg, "generateImage config"); const agents = requireRecord(cfg.agents, "generateImage agents config"); const defaults = requireRecord(agents.defaults, "generateImage defaults config"); - expect(defaults.imageGenerationModel).toEqual({ + expect(requireRecord(defaults.mediaModels, "mediaModels").image).toEqual({ primary: "microsoft-foundry/prod-image", timeoutMs: 180_000, }); diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index a35badd3cad8..40b224424cda 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -256,7 +256,7 @@ function resolveImageGenerationModelConfigForTool(params: { workspaceDir: params.workspaceDir, agentDir: params.agentDir, authStore: params.authStore, - modelConfig: params.cfg?.agents?.defaults?.imageGenerationModel, + modelConfig: params.cfg?.agents?.defaults?.mediaModels?.image, providers: () => listRuntimeImageGenerationProviders({ config: params.cfg }), }); } @@ -268,7 +268,7 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") { } function hasExplicitImageGenerationModelConfig(cfg?: OpenClawConfig): boolean { - return hasToolModelConfig(coerceToolModelConfig(cfg?.agents?.defaults?.imageGenerationModel)); + return hasToolModelConfig(coerceToolModelConfig(cfg?.agents?.defaults?.mediaModels?.image)); } function resolveAction(args: Record): "generate" | "list" | "status" { @@ -914,7 +914,7 @@ export function createImageGenerateTool(options?: { agentDir: options?.agentDir, workspaceDir: options?.workspaceDir, authStore: options?.authProfileStore, - modelConfig: cfg.agents?.defaults?.imageGenerationModel, + modelConfig: cfg.agents?.defaults?.mediaModels?.image, providerKey: "imageGenerationProviders", }) ) { @@ -954,7 +954,7 @@ export function createImageGenerateTool(options?: { const model = readStringParam(params, "model"); const configuredImageGenerationModelConfig = coerceToolModelConfig( - cfg.agents?.defaults?.imageGenerationModel, + cfg.agents?.defaults?.mediaModels?.image, ); const imageGenerationModelConfig = resolveImageGenerationModelConfigForTool({ @@ -1009,7 +1009,7 @@ export function createImageGenerateTool(options?: { }); const imageGenerationCandidates = resolveCapabilityModelCandidates({ cfg: effectiveCfg, - modelConfig: effectiveCfg.agents?.defaults?.imageGenerationModel, + modelConfig: effectiveCfg.agents?.defaults?.mediaModels?.image, modelOverride: model, parseModelRef: parseImageGenerationModelRef, agentDir: options?.agentDir, diff --git a/src/agents/tools/image-tool.ollama.live.test.ts b/src/agents/tools/image-tool.ollama.live.test.ts index 7e8b2a728d96..d5976180f232 100644 --- a/src/agents/tools/image-tool.ollama.live.test.ts +++ b/src/agents/tools/image-tool.ollama.live.test.ts @@ -75,9 +75,16 @@ describe.skipIf(!LIVE)("image tool Ollama live", () => { }, tools: { media: { + models: [ + { + provider: "ollama", + model: OLLAMA_IMAGE_MODEL, + timeoutSeconds: 300, + capabilities: ["image"], + }, + ], image: { timeoutSeconds: 180, - models: [{ provider: "ollama", model: OLLAMA_IMAGE_MODEL, timeoutSeconds: 300 }], }, }, }, diff --git a/src/agents/tools/image-tool.providers.live.test.ts b/src/agents/tools/image-tool.providers.live.test.ts index 6f864a5efdab..3891d445ca93 100644 --- a/src/agents/tools/image-tool.providers.live.test.ts +++ b/src/agents/tools/image-tool.providers.live.test.ts @@ -158,9 +158,16 @@ function createLiveConfig(testCase: LiveProviderCase): OpenClawConfig { }, tools: { media: { + models: [ + { + provider: testCase.provider, + model: testCase.model, + timeoutSeconds: 90, + capabilities: ["image"], + }, + ], image: { timeoutSeconds: 90, - models: [{ provider: testCase.provider, model: testCase.model, timeoutSeconds: 90 }], }, }, }, diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 1fe403b19c8c..12043db63f44 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -1437,16 +1437,15 @@ describe("image tool implicit imageModel config", () => { }, tools: { media: { - image: { - timeoutSeconds: 180, - models: [ - { - provider: "ollama", - model: "gemma4:26b-a4b-it-q4_K_M", - timeoutSeconds: 300, - }, - ], - }, + image: { timeoutSeconds: 180 }, + models: [ + { + provider: "ollama", + model: "gemma4:26b-a4b-it-q4_K_M", + timeoutSeconds: 300, + capabilities: ["image"], + }, + ], }, }, }; diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index caaea6fc0449..79ec53c5242f 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -647,16 +647,6 @@ function resolveImageToolTimeoutMs(params: { model: string; providerRegistry: Map; }): number { - const imageConfig = params.cfg.tools?.media?.image; - const capabilityEntry = imageConfig?.models?.find((entry) => - matchesImageTimeoutEntry({ - entry, - source: "capability", - provider: params.provider, - model: params.model, - providerRegistry: params.providerRegistry, - }), - ); const sharedEntry = params.cfg.tools?.media?.models?.find((entry) => matchesImageTimeoutEntry({ entry, @@ -667,7 +657,7 @@ function resolveImageToolTimeoutMs(params: { }), ); return resolveTimeoutMs( - capabilityEntry?.timeoutSeconds ?? sharedEntry?.timeoutSeconds ?? imageConfig?.timeoutSeconds, + sharedEntry?.timeoutSeconds ?? params.cfg.tools?.media?.image?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS.image, ); } diff --git a/src/agents/tools/media-generation-config.test-support.ts b/src/agents/tools/media-generation-config.test-support.ts new file mode 100644 index 000000000000..b46a64b4616f --- /dev/null +++ b/src/agents/tools/media-generation-config.test-support.ts @@ -0,0 +1,29 @@ +// Test-only bridge that feeds legacy fixture values through the canonical mediaModels owner. +import type { OpenClawConfig } from "../../config/types.openclaw.js"; + +type MediaCapability = "image" | "music" | "video"; +type LegacyMediaModelKey = "imageGenerationModel" | "musicGenerationModel" | "videoGenerationModel"; + +export function canonicalizeMediaGenerationTestConfig( + config: OpenClawConfig, + capability: MediaCapability, + legacyKey: LegacyMediaModelKey, +): OpenClawConfig { + const defaults = config.agents?.defaults as + | (NonNullable["defaults"] & Record) + | undefined; + const legacyValue = defaults?.[legacyKey]; + if (legacyValue === undefined || defaults?.mediaModels?.[capability] !== undefined) { + return config; + } + return { + ...config, + agents: { + ...config.agents, + defaults: { + ...defaults, + mediaModels: { ...defaults?.mediaModels, [capability]: legacyValue }, + }, + }, + }; +} diff --git a/src/agents/tools/media-tool-shared.ts b/src/agents/tools/media-tool-shared.ts index 29abffd97edd..76fd5e696be1 100644 --- a/src/agents/tools/media-tool-shared.ts +++ b/src/agents/tools/media-tool-shared.ts @@ -95,7 +95,7 @@ export function applyImageGenerationModelConfigDefaults( cfg: OpenClawConfig | undefined, imageGenerationModelConfig: ToolModelConfig, ): OpenClawConfig | undefined { - return applyAgentDefaultModelConfig(cfg, "imageGenerationModel", imageGenerationModelConfig); + return applyAgentDefaultModelConfig(cfg, "image", imageGenerationModelConfig); } /** @@ -105,7 +105,7 @@ export function applyVideoGenerationModelConfigDefaults( cfg: OpenClawConfig | undefined, videoGenerationModelConfig: ToolModelConfig, ): OpenClawConfig | undefined { - return applyAgentDefaultModelConfig(cfg, "videoGenerationModel", videoGenerationModelConfig); + return applyAgentDefaultModelConfig(cfg, "video", videoGenerationModelConfig); } /** @@ -115,7 +115,7 @@ export function applyMusicGenerationModelConfigDefaults( cfg: OpenClawConfig | undefined, musicGenerationModelConfig: ToolModelConfig, ): OpenClawConfig | undefined { - return applyAgentDefaultModelConfig(cfg, "musicGenerationModel", musicGenerationModelConfig); + return applyAgentDefaultModelConfig(cfg, "music", musicGenerationModelConfig); } /** @@ -138,19 +138,28 @@ export function resolveRemoteMediaSsrfPolicy( function applyAgentDefaultModelConfig( cfg: OpenClawConfig | undefined, - key: "imageModel" | "imageGenerationModel" | "videoGenerationModel" | "musicGenerationModel", + key: "imageModel" | "image" | "video" | "music", modelConfig: ToolModelConfig, ): OpenClawConfig | undefined { if (!cfg) { return undefined; } + if (key === "imageModel") { + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { ...cfg.agents?.defaults, imageModel: modelConfig }, + }, + }; + } return { ...cfg, agents: { ...cfg.agents, defaults: { ...cfg.agents?.defaults, - [key]: modelConfig, + mediaModels: { ...cfg.agents?.defaults?.mediaModels, [key]: modelConfig }, }, }, }; diff --git a/src/agents/tools/music-generate-tool.test.ts b/src/agents/tools/music-generate-tool.test.ts index 8bc00c05ad20..27158b16bfe0 100644 --- a/src/agents/tools/music-generate-tool.test.ts +++ b/src/agents/tools/music-generate-tool.test.ts @@ -7,8 +7,23 @@ import * as webMedia from "../../media/web-media.js"; import * as musicGenerationRuntime from "../../music-generation/runtime.js"; import * as fetchTimeout from "../../utils/fetch-timeout.js"; import { resetRecentMediaGenerationDuplicateGuardsForTests } from "../media-generation-task-status-shared.test-support.js"; +import { canonicalizeMediaGenerationTestConfig } from "./media-generation-config.test-support.js"; import * as musicGenerateBackground from "./music-generate-background.js"; -import { createMusicGenerateTool } from "./music-generate-tool.js"; +import { createMusicGenerateTool as createMusicGenerateToolImpl } from "./music-generate-tool.js"; + +function createMusicGenerateTool( + params: Parameters[0], +): ReturnType { + const options = params ?? {}; + return createMusicGenerateToolImpl({ + ...options, + config: canonicalizeMediaGenerationTestConfig( + options.config ?? {}, + "music", + "musicGenerationModel", + ), + }); +} const taskRuntimeInternalMocks = vi.hoisted(() => { const mocks = { diff --git a/src/agents/tools/music-generate-tool.ts b/src/agents/tools/music-generate-tool.ts index 58f6712e95b2..ee1e98e48366 100644 --- a/src/agents/tools/music-generate-tool.ts +++ b/src/agents/tools/music-generate-tool.ts @@ -159,13 +159,13 @@ function resolveMusicGenerationModelConfigForTool(params: { workspaceDir: params.workspaceDir, agentDir: params.agentDir, authStore: params.authStore, - modelConfig: params.cfg?.agents?.defaults?.musicGenerationModel, + modelConfig: params.cfg?.agents?.defaults?.mediaModels?.music, providers: () => listRuntimeMusicGenerationProviders({ config: params.cfg }), }); } function hasExplicitMusicGenerationModelConfig(cfg?: OpenClawConfig): boolean { - return hasToolModelConfig(coerceToolModelConfig(cfg?.agents?.defaults?.musicGenerationModel)); + return hasToolModelConfig(coerceToolModelConfig(cfg?.agents?.defaults?.mediaModels?.music)); } function resolveSelectedMusicGenerationProvider(params: { @@ -590,7 +590,7 @@ export function createMusicGenerateTool(options?: { agentDir: options?.agentDir, workspaceDir: options?.workspaceDir, authStore: options?.authProfileStore, - modelConfig: cfg.agents?.defaults?.musicGenerationModel, + modelConfig: cfg.agents?.defaults?.mediaModels?.music, providerKey: "musicGenerationProviders", }) ) { diff --git a/src/agents/tools/pdf-tool.ts b/src/agents/tools/pdf-tool.ts index ba0667467e70..fd44de6481de 100644 --- a/src/agents/tools/pdf-tool.ts +++ b/src/agents/tools/pdf-tool.ts @@ -371,7 +371,7 @@ export function createPdfTool(options?: { const maxBytesMbDefault = ( options?.config?.agents?.defaults as Record | undefined - )?.pdfMaxBytesMb; + )?.pdfMaxMb; const maxPagesDefault = (options?.config?.agents?.defaults as Record | undefined) ?.pdfMaxPages; const configuredMaxBytesMb = diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 86270a764b89..84d74235f004 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -153,11 +153,9 @@ describe("sessions_spawn tool", () => { agentChannel: "discord", agentAccountId: "default", config: { - channels: { - discord: { - threadBindings: { - spawnSessions: true, - }, + session: { + threadBindings: { + spawnSessions: true, }, }, }, @@ -1004,11 +1002,9 @@ describe("sessions_spawn tool", () => { agentChannel: "discord", agentAccountId: "default", config: { - channels: { - discord: { - threadBindings: { - spawnSessions: false, - }, + session: { + threadBindings: { + spawnSessions: false, }, }, }, @@ -1031,11 +1027,9 @@ describe("sessions_spawn tool", () => { agentChannel: "discord", agentAccountId: "default", config: { - channels: { - discord: { - threadBindings: { - spawnSessions: true, - }, + session: { + threadBindings: { + spawnSessions: true, }, }, }, diff --git a/src/agents/tools/video-generate-tool.test.ts b/src/agents/tools/video-generate-tool.test.ts index a15b081e5da7..6d337111a2af 100644 --- a/src/agents/tools/video-generate-tool.test.ts +++ b/src/agents/tools/video-generate-tool.test.ts @@ -16,10 +16,25 @@ import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snaps import * as videoGenerationRuntime from "../../video-generation/runtime.js"; import type { AuthProfileStore } from "../auth-profiles/types.js"; import { resetRecentMediaGenerationDuplicateGuardsForTests } from "../media-generation-task-status-shared.test-support.js"; +import { canonicalizeMediaGenerationTestConfig } from "./media-generation-config.test-support.js"; import * as videoGenerateBackground from "./video-generate-background.js"; -import { createVideoGenerateTool } from "./video-generate-tool.js"; +import { createVideoGenerateTool as createVideoGenerateToolImpl } from "./video-generate-tool.js"; import { resolveVideoGenerationModelConfigForTool } from "./video-generate-tool.test-support.js"; +function createVideoGenerateTool( + params: Parameters[0], +): ReturnType { + const options = params ?? {}; + return createVideoGenerateToolImpl({ + ...options, + config: canonicalizeMediaGenerationTestConfig( + options.config ?? {}, + "video", + "videoGenerationModel", + ), + }); +} + const taskRuntimeInternalMocks = vi.hoisted(() => { const mocks = { listTasksForOwnerKey: vi.fn(), @@ -364,7 +379,7 @@ describe("createVideoGenerateTool", () => { config: asConfig({ agents: { defaults: { - videoGenerationModel: { primary: "qwen/wan2.6-t2v" }, + mediaModels: { video: { primary: "qwen/wan2.6-t2v" } }, }, }, }), @@ -384,7 +399,7 @@ describe("createVideoGenerateTool", () => { config: asConfig({ agents: { defaults: { - videoGenerationModel: { primary: "qwen/wan2.6-t2v" }, + mediaModels: { video: { primary: "qwen/wan2.6-t2v" } }, }, }, }), @@ -552,7 +567,7 @@ describe("createVideoGenerateTool", () => { cfg: asConfig({ agents: { defaults: { - videoGenerationModel: { primary: "qwen/wan2.6-t2v" }, + mediaModels: { video: { primary: "qwen/wan2.6-t2v" } }, }, }, }), diff --git a/src/agents/tools/video-generate-tool.ts b/src/agents/tools/video-generate-tool.ts index ce5655b8ca63..883d46a113d2 100644 --- a/src/agents/tools/video-generate-tool.ts +++ b/src/agents/tools/video-generate-tool.ts @@ -238,7 +238,7 @@ function resolveVideoGenerationModelConfigForTool(params: { workspaceDir: params.workspaceDir, agentDir: params.agentDir, authStore: params.authStore, - modelConfig: params.cfg?.agents?.defaults?.videoGenerationModel, + modelConfig: params.cfg?.agents?.defaults?.mediaModels?.video, providers: () => listRuntimeVideoGenerationProviders({ config: params.cfg }), }); } @@ -250,7 +250,7 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") { } function hasExplicitVideoGenerationModelConfig(cfg?: OpenClawConfig): boolean { - return hasToolModelConfig(coerceToolModelConfig(cfg?.agents?.defaults?.videoGenerationModel)); + return hasToolModelConfig(coerceToolModelConfig(cfg?.agents?.defaults?.mediaModels?.video)); } function collectVideoGenerationModelProviderIds(params: { @@ -314,7 +314,7 @@ function shouldExposeVideoReferenceAudioParams(params: { const audioCandidateProviderIds = new Set(); const explicitProviderIds = collectVideoGenerationModelProviderIds({ cfg: params.cfg, - modelConfig: coerceToolModelConfig(params.cfg.agents?.defaults?.videoGenerationModel), + modelConfig: coerceToolModelConfig(params.cfg.agents?.defaults?.mediaModels?.video), ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), }); @@ -952,7 +952,7 @@ export function createVideoGenerateTool(options?: { agentDir: options?.agentDir, workspaceDir: options?.workspaceDir, authStore: options?.authProfileStore, - modelConfig: cfg.agents?.defaults?.videoGenerationModel, + modelConfig: cfg.agents?.defaults?.mediaModels?.video, providerKey: "videoGenerationProviders", }) ) { diff --git a/src/agents/transcript-redact.test.ts b/src/agents/transcript-redact.test.ts index 96f6072bb26f..18d2a3e88ecf 100644 --- a/src/agents/transcript-redact.test.ts +++ b/src/agents/transcript-redact.test.ts @@ -21,12 +21,9 @@ function textMessage(text: string): AgentMessage { } as unknown as AgentMessage; } -function cfg(mode: "tools" | "off", patterns?: string[]): OpenClawConfig { +function cfg(_mode: "tools" | "off", patterns?: string[]): OpenClawConfig { return { - logging: { - redactSensitive: mode, - ...(patterns ? { redactPatterns: patterns } : {}), - }, + logging: patterns ? { redactPatterns: patterns } : {}, } satisfies OpenClawConfig; } @@ -1472,13 +1469,14 @@ describe("redactTranscriptMessage", () => { expect(text).toContain("ok"); }); - it("passes through unchanged when redactSensitive is off", () => { + it("redacts text even when a caller supplies the retired off spelling", () => { const msg = textMessage("key is sk-abcdef1234567890xyz"); const result = redactTranscriptMessage(msg, cfg("off")); - expect(result).toBe(msg); // same reference; nothing changed + expect(result).not.toBe(msg); + expect(JSON.stringify(msgContent(result))).not.toContain("sk-abcdef1234567890xyz"); }); - it("leaves structured tool-call secrets unchanged when redactSensitive is off", () => { + it("redacts structured tool-call secrets regardless of retired mode input", () => { const msg = { role: "assistant", content: [ @@ -1491,12 +1489,12 @@ describe("redactTranscriptMessage", () => { ], } as unknown as AgentMessage; const result = redactTranscriptMessage(msg, cfg("off")); - expect(result).toBe(msg); - expect(JSON.stringify(msgContent(result))).toContain("plainsecretvalue123"); - expect(JSON.stringify(msgContent(result))).toContain("hunter2"); + expect(result).not.toBe(msg); + expect(JSON.stringify(msgContent(result))).not.toContain("plainsecretvalue123"); + expect(JSON.stringify(msgContent(result))).not.toContain("hunter2"); }); - it("leaves structured tool-result details unchanged when redactSensitive is off", () => { + it("redacts structured tool-result details regardless of retired mode input", () => { const msg = { role: "toolResult", toolCallId: "call_1", @@ -1507,9 +1505,9 @@ describe("redactTranscriptMessage", () => { timestamp: Date.now(), } as unknown as AgentMessage; const result = redactTranscriptMessage(msg, cfg("off")) as unknown as { details: unknown }; - expect(result).toBe(msg); - expect(JSON.stringify(result.details)).toContain("plainsecretvalue123"); - expect(JSON.stringify(result.details)).toContain("hunter2"); + expect(result).not.toBe(msg); + expect(JSON.stringify(result.details)).not.toContain("plainsecretvalue123"); + expect(JSON.stringify(result.details)).not.toContain("hunter2"); }); it("returns same object reference when nothing matches", () => { @@ -1518,10 +1516,10 @@ describe("redactTranscriptMessage", () => { expect(result).toBe(msg); }); - it("passes through signatures unchanged when global redaction is off", () => { + it("redacts signature summaries with the fixed global policy", () => { const readLoggingConfig = vi .spyOn(loggingConfigModule, "readLoggingConfig") - .mockReturnValue({ redactSensitive: "off" }); + .mockReturnValue({}); const msg = { role: "assistant", content: [ @@ -1539,7 +1537,7 @@ describe("redactTranscriptMessage", () => { } as unknown as AgentMessage; try { - expect(redactTranscriptMessage(msg)).toBe(msg); + expect(JSON.stringify(redactTranscriptMessage(msg))).not.toContain("sk-abcdef1234567890xyz"); } finally { readLoggingConfig.mockRestore(); } diff --git a/src/agents/transcript-redact.ts b/src/agents/transcript-redact.ts index e1c4f8a1a5ca..a58ec7641ebc 100644 --- a/src/agents/transcript-redact.ts +++ b/src/agents/transcript-redact.ts @@ -27,27 +27,24 @@ function resolveTranscriptRedactPatterns(patterns?: string[]) { function redactTranscriptOptions(cfg?: OpenClawConfig) { const configuredLogging = readLoggingConfig(); - const mode = cfg?.logging?.redactSensitive ?? configuredLogging?.redactSensitive; const patterns = resolveTranscriptRedactPatterns( cfg?.logging?.redactPatterns ?? configuredLogging?.redactPatterns, ); - if (mode === undefined && patterns === undefined) { + if (patterns === undefined) { return undefined; } return { - ...(mode !== undefined ? { mode } : {}), + mode: "tools" as const, ...(patterns !== undefined ? { patterns } : {}), }; } function isTranscriptRedactionDisabled(cfg?: OpenClawConfig): boolean { - return (cfg?.logging?.redactSensitive ?? readLoggingConfig()?.redactSensitive) === "off"; + void cfg; + return false; } function redactTranscriptText(value: string, cfg?: OpenClawConfig): string { - if (cfg?.logging?.redactSensitive === "off") { - return value; - } return redactSensitiveText(value, redactTranscriptOptions(cfg)); } @@ -56,9 +53,6 @@ function redactTranscriptStructuredFieldValue( value: string, cfg?: OpenClawConfig, ): string { - if (cfg?.logging?.redactSensitive === "off") { - return value; - } // Preserve pagination state only in transcripts; value-pattern and global log redaction remain. return /^(?:next[_-]?)?page[_-]?token$|^page[_-]?cursor$/i.test(key) ? redactTranscriptText(value, cfg) diff --git a/src/audit/audit-config.test.ts b/src/audit/audit-config.test.ts index 3c84d59cfde1..6ca8f2ff8474 100644 --- a/src/audit/audit-config.test.ts +++ b/src/audit/audit-config.test.ts @@ -5,21 +5,21 @@ describe("isAuditLedgerEnabled", () => { it("defaults to enabled without config or audit section", () => { expect(isAuditLedgerEnabled(undefined)).toBe(true); expect(isAuditLedgerEnabled({})).toBe(true); - expect(isAuditLedgerEnabled({ audit: {} })).toBe(true); + expect(isAuditLedgerEnabled({ logging: { audit: {} } })).toBe(true); }); it("stays enabled on explicit true", () => { - expect(isAuditLedgerEnabled({ audit: { enabled: true } })).toBe(true); + expect(isAuditLedgerEnabled({ logging: { audit: { enabled: true } } })).toBe(true); }); it("disables only on explicit false", () => { - expect(isAuditLedgerEnabled({ audit: { enabled: false } })).toBe(false); + expect(isAuditLedgerEnabled({ logging: { audit: { enabled: false } } })).toBe(false); }); it("keeps message metadata off until explicitly enabled", () => { expect(resolveAuditMessageMode(undefined)).toBe("off"); - expect(resolveAuditMessageMode({ audit: {} })).toBe("off"); - expect(resolveAuditMessageMode({ audit: { messages: "direct" } })).toBe("direct"); - expect(resolveAuditMessageMode({ audit: { messages: "all" } })).toBe("all"); + expect(resolveAuditMessageMode({ logging: { audit: {} } })).toBe("off"); + expect(resolveAuditMessageMode({ logging: { audit: { messages: "direct" } } })).toBe("direct"); + expect(resolveAuditMessageMode({ logging: { audit: { messages: "all" } } })).toBe("all"); }); }); diff --git a/src/audit/audit-config.ts b/src/audit/audit-config.ts index b3a32f635838..9fe205ea7a95 100644 --- a/src/audit/audit-config.ts +++ b/src/audit/audit-config.ts @@ -5,14 +5,14 @@ export type AuditMessageMode = "off" | "direct" | "all"; /** * The ledger is on by default: an audit trail enabled only after an incident - * cannot explain the incident. `audit.enabled: false` stops new event inserts after + * cannot explain the incident. `logging.audit.enabled: false` stops new event inserts after * restart; audit queries still serve retained rows until they expire. */ export function isAuditLedgerEnabled(cfg: OpenClawConfig | undefined): boolean { - return cfg?.audit?.enabled !== false; + return cfg?.logging?.audit?.enabled !== false; } /** Message metadata remains an explicit opt-in inside the default-on ledger. */ export function resolveAuditMessageMode(cfg: OpenClawConfig | undefined): AuditMessageMode { - return cfg?.audit?.messages ?? "off"; + return cfg?.logging?.audit?.messages ?? "off"; } diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts index 2c2f059f17da..187fe4620ce3 100644 --- a/src/auto-reply/envelope.test.ts +++ b/src/auto-reply/envelope.test.ts @@ -208,7 +208,7 @@ describe("formatInboundEnvelope", () => { expect(body).toBe("[WhatsApp Family Chat] Alice: hello"); }); - it("resolves envelope options from config", () => { + it("uses fixed envelope options while preserving the user timezone", () => { const options = resolveEnvelopeFormatOptions({ agents: { defaults: { @@ -220,9 +220,9 @@ describe("formatInboundEnvelope", () => { }, }); expect(options).toEqual({ - timezone: "user", - includeTimestamp: false, - includeElapsed: false, + timezone: "Europe/Vienna", + includeTimestamp: true, + includeElapsed: true, userTimezone: "Europe/Vienna", }); }); diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index 51b93c2dd295..bc29efd25f7b 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -72,9 +72,9 @@ function sanitizeEnvelopeHeaderPart(value: string): string { export function resolveEnvelopeFormatOptions(cfg?: OpenClawConfig): EnvelopeFormatOptions { const defaults = cfg?.agents?.defaults; return { - timezone: defaults?.envelopeTimezone, - includeTimestamp: defaults?.envelopeTimestamp !== "off", - includeElapsed: defaults?.envelopeElapsed !== "off", + timezone: defaults?.userTimezone, + includeTimestamp: true, + includeElapsed: true, userTimezone: defaults?.userTimezone, }; } diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 17e27e693c4c..630ea74edde1 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -5,7 +5,6 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe import { hasSessionAutoModelFallbackProvenance, hasConfiguredModelFallbacks, - resolveAgentConfig, resolveSessionAgentId, } from "../../agents/agent-scope.js"; import { resolveContextTokensForModel } from "../../agents/context.js"; @@ -2560,7 +2559,7 @@ export async function runReplyAgent(params: { const coveredByExistingCron = hasReminderCommitment && successfulCronAdds === 0 ? await hasSessionRelatedCronJobs({ - cronStorePath: cfg.cron?.store, + cronStorePath: undefined, sessionKey, }) : false; @@ -2938,14 +2937,7 @@ export async function runReplyAgent(params: { } } const pendingText = sourceReplyPolicy.suppressDelivery ? "" : finalDeliveryText; - const agentId = followupRun.run.agentId; - const heartbeatAgentCfg = agentId ? resolveAgentConfig(cfg, agentId)?.heartbeat : undefined; - const heartbeatAckMaxChars = Math.max( - 0, - heartbeatAgentCfg?.ackMaxChars ?? - cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? - DEFAULT_HEARTBEAT_ACK_MAX_CHARS, - ); + const heartbeatAckMaxChars = DEFAULT_HEARTBEAT_ACK_MAX_CHARS; const resolvedPendingText = isHeartbeat ? (() => { const stripped = stripHeartbeatToken(pendingText, { diff --git a/src/auto-reply/reply/bash-command.ts b/src/auto-reply/reply/bash-command.ts index e3d3b8274c31..69276ece7476 100644 --- a/src/auto-reply/reply/bash-command.ts +++ b/src/auto-reply/reply/bash-command.ts @@ -347,7 +347,7 @@ export async function handleBashChatCommand(params: { try { const foregroundMs = resolveForegroundMs(params.cfg); const shouldBackgroundImmediately = foregroundMs <= 0; - const timeoutSec = params.cfg.tools?.exec?.timeoutSec; + const timeoutSec = params.cfg.tools?.exec?.timeoutSeconds; const notifyOnExit = params.cfg.tools?.exec?.notifyOnExit; const notifyOnExitEmptySuccess = params.cfg.tools?.exec?.notifyOnExitEmptySuccess; const execTool = createExecTool({ diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 7fecd62ec57a..bf921342d629 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -507,18 +507,17 @@ function createSessionBinding(overrides?: Partial): FakeBinding { } const baseCfg = { - session: { mainKey: "main", scope: "per-sender" }, acp: { enabled: true, dispatch: { enabled: true }, backend: "acpx", }, - channels: { - discord: { - threadBindings: { - enabled: true, - spawnSessions: true, - }, + session: { + mainKey: "main", + scope: "per-sender", + threadBindings: { + enabled: true, + spawnSessions: true, }, }, } satisfies OpenClawConfig; @@ -1360,12 +1359,10 @@ describe("/acp command", () => { it("binds the current Discord channel with --bind here without creating a child thread", async () => { const cfg = { ...baseCfg, - channels: { - discord: { - threadBindings: { - enabled: true, - spawnSessions: false, - }, + session: { + threadBindings: { + enabled: true, + spawnSessions: false, }, }, } satisfies OpenClawConfig; @@ -1570,12 +1567,10 @@ describe("/acp command", () => { it("rejects thread-bound ACP spawn when spawnSessions is disabled", async () => { const cfg = { ...baseCfg, - channels: { - discord: { - threadBindings: { - enabled: true, - spawnSessions: false, - }, + session: { + threadBindings: { + enabled: true, + spawnSessions: false, }, }, } satisfies OpenClawConfig; @@ -1722,9 +1717,7 @@ describe("/acp command", () => { const cfg = { ...baseCfg, channels: { - ...baseCfg.channels, discord: { - ...baseCfg.channels.discord, defaultAccount: "work", }, }, diff --git a/src/auto-reply/reply/commands-diagnostics.ts b/src/auto-reply/reply/commands-diagnostics.ts index 6483430e1bf1..8aa8b9ab8b9a 100644 --- a/src/auto-reply/reply/commands-diagnostics.ts +++ b/src/auto-reply/reply/commands-diagnostics.ts @@ -271,7 +271,7 @@ async function requestGatewayDiagnosticsExportApproval( options: { privateApprovalTarget?: PrivateCommandRouteTarget } = {}, codexDiagnostics: CodexDiagnosticsApprovalIntegration = {}, ): Promise { - const timeoutSec = params.cfg.tools?.exec?.timeoutSec; + const timeoutSec = params.cfg.tools?.exec?.timeoutSeconds; const agentId = params.agentId ?? resolveSessionAgentId({ diff --git a/src/auto-reply/reply/commands-export-trajectory.ts b/src/auto-reply/reply/commands-export-trajectory.ts index fd4584455ef1..e64f6cc76f1a 100644 --- a/src/auto-reply/reply/commands-export-trajectory.ts +++ b/src/auto-reply/reply/commands-export-trajectory.ts @@ -166,7 +166,7 @@ async function requestTrajectoryExportApproval( request: TrajectoryExportExecRequest, options: { privateApprovalTarget?: PrivateCommandRouteTarget } = {}, ): Promise { - const timeoutSec = params.cfg.tools?.exec?.timeoutSec; + const timeoutSec = params.cfg.tools?.exec?.timeoutSeconds; const agentId = params.agentId ?? resolveSessionAgentId({ diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts index 1a91387de95c..f21faddafbf1 100644 --- a/src/auto-reply/reply/commands-plugins.install.test.ts +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -165,7 +165,6 @@ describe("handleCommands /plugins install", () => { source: "exec", command: process.execPath, args: ["-e", "process.exit(1)"], - allowInsecurePath: true, }, }, }, @@ -228,7 +227,6 @@ describe("handleCommands /plugins install", () => { source: "exec", command: process.execPath, args: ["-e", "process.exit(1)"], - allowInsecurePath: true, }, }, }, diff --git a/src/auto-reply/reply/commands-tts.test.ts b/src/auto-reply/reply/commands-tts.test.ts index 432ea83fa70b..1610f36a870f 100644 --- a/src/auto-reply/reply/commands-tts.test.ts +++ b/src/auto-reply/reply/commands-tts.test.ts @@ -241,7 +241,7 @@ describe("handleTtsCommands status fallback reporting", () => { it("treats bare /tts as status", async () => { const result = await handleTtsCommands( buildTtsParams("/tts", { - messages: { tts: { prefsPath: "/tmp/tts.json" } }, + tts: { prefsPath: "/tmp/tts.json" }, } as OpenClawConfig), true, ); diff --git a/src/auto-reply/reply/conversation-turn-capture.test.ts b/src/auto-reply/reply/conversation-turn-capture.test.ts index bda38086b2a0..b553e050224d 100644 --- a/src/auto-reply/reply/conversation-turn-capture.test.ts +++ b/src/auto-reply/reply/conversation-turn-capture.test.ts @@ -228,7 +228,6 @@ describe("conversation turn capture", () => { cfg: { ...setup.cfg, logging: { - redactSensitive: "tools", redactPatterns: ["sensitive-reply-[a-z]+"], }, }, diff --git a/src/auto-reply/reply/dispatch-acp-delivery.test.ts b/src/auto-reply/reply/dispatch-acp-delivery.test.ts index 7256aa918c46..8b795d2dd7c3 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.test.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.test.ts @@ -222,7 +222,7 @@ describe("createAcpDispatchDeliveryCoordinator", () => { const dispatcher = createDispatcher(); const coordinator = createAcpDispatchDeliveryCoordinator({ cfg: createAcpTestConfig({ - messages: { tts: { enabled: true } }, + tts: { enabled: true }, }), ctx: buildTestCtx({ Provider: "visiblechat", @@ -513,7 +513,7 @@ describe("createAcpDispatchDeliveryCoordinator", () => { const dispatcher = createDispatcher(); const coordinator = createAcpDispatchDeliveryCoordinator({ cfg: createAcpTestConfig({ - messages: { tts: { enabled: true } }, + tts: { enabled: true }, }), ctx: buildTestCtx({ Provider: "visiblechat", @@ -544,7 +544,7 @@ describe("createAcpDispatchDeliveryCoordinator", () => { const dispatcher = createDispatcher(); const coordinator = createAcpDispatchDeliveryCoordinator({ cfg: createAcpTestConfig({ - messages: { tts: { enabled: true } }, + tts: { enabled: true }, }), ctx: buildTestCtx({ Provider: "visiblechat", diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 0eb3164993b1..c765a82f7f87 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -5098,8 +5098,8 @@ describe("createFollowupRunner messaging delivery and dedupe", () => { registerFollowupTestSessionStore(storePath, sessionStore); const cfg = { - messages: { - responsePrefix: "agent", + channels: { + slack: { responsePrefix: "agent" }, }, }; const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage"); diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index 4bd948a1592e..70b6bb716165 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -12,6 +12,7 @@ import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js"; import type { SessionEntry } from "../../config/sessions.js"; import { isSessionWorkStartInvalidatedError } from "../../config/sessions/lifecycle.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isFastTestRuntimeEnv } from "../../infra/env.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import { ModelSelectionLockedError } from "../../sessions/model-overrides.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; @@ -506,7 +507,7 @@ export async function resolveReplyDirectives(params: { : undefined; const useFastReplyRuntime = shouldUseReplyFastTestRuntime({ cfg, - isFastTestEnv: process.env.OPENCLAW_TEST_FAST === "1", + isFastTestEnv: isFastTestRuntimeEnv(), }); const useFastModelSelection = diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index eadbf881c72a..aa455070251c 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -13,6 +13,7 @@ import { resolveSessionKey } from "../../config/sessions/session-key.js"; import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import type { SessionEntry, SessionScope } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { isVitestRuntimeEnv } from "../../infra/env.js"; import { isModelSelectionLocked, MODEL_SELECTION_LOCKED_RESET_MESSAGE, @@ -35,7 +36,8 @@ import type { SessionInitResult } from "./session.js"; function isSlowReplyTestAllowed(env: NodeJS.ProcessEnv = process.env): boolean { return ( - env.OPENCLAW_ALLOW_SLOW_REPLY_TESTS === "1" || env.OPENCLAW_STRICT_FAST_REPLY_CONFIG === "0" + (isVitestRuntimeEnv(env) && env.OPENCLAW_ALLOW_SLOW_REPLY_TESTS === "1") || + env.OPENCLAW_STRICT_FAST_REPLY_CONFIG === "0" ); } diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index daf63e7e35a9..755d4635469c 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -7,6 +7,7 @@ import { clearAutoFallbackPrimaryProbeSelection, hasLegacyAutoFallbackWithoutOrigin, hasSessionAutoModelFallbackProvenance, + resolveAgentConfig, type AutoFallbackPrimaryProbe, } from "../../agents/agent-scope.js"; import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; @@ -39,6 +40,7 @@ import { resolveSilentReplySettings } from "../../config/silent-reply.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js"; +import { isFastTestRuntimeEnv } from "../../infra/env.js"; import { resolveHeartbeatRunScope } from "../../infra/heartbeat-run-scope.js"; import type { ExtractedFileImage } from "../../media-understanding/extracted-file-images.js"; import { clearCommandLane, getQueueSize } from "../../process/command-queue.js"; @@ -587,7 +589,7 @@ export async function runPreparedReply( }); const useFastReplyRuntime = shouldUseReplyFastTestRuntime({ cfg, - isFastTestEnv: process.env.OPENCLAW_TEST_FAST === "1", + isFastTestEnv: isFastTestRuntimeEnv(), }); const thinkingRuntime = resolveEffectiveAgentRuntime({ cfg, @@ -618,7 +620,7 @@ export async function runPreparedReply( originatingChannel: ctx.OriginatingChannel, }); const typingMode = resolveTypingMode({ - configured: sessionCfg?.typingMode ?? agentCfg?.typingMode, + configured: resolveAgentConfig(cfg, agentId)?.typingMode ?? agentCfg?.typingMode, isGroupChat, wasMentioned, isHeartbeat, @@ -964,29 +966,28 @@ export async function runPreparedReply( systemEventBlocks: drainedSystemEventBlocks, }); }; - const skillResult = - process.env.OPENCLAW_TEST_FAST === "1" - ? { + const skillResult = isFastTestRuntimeEnv() + ? { + sessionEntry, + skillsSnapshot: sessionEntry?.skillsSnapshot, + systemSent: currentSystemSent, + } + : await traceRunPhase("reply.ensure_skill_snapshot", async () => { + const { ensureSkillSnapshot } = await loadSessionUpdatesRuntime(); + return await ensureSkillSnapshot({ sessionEntry, - skillsSnapshot: sessionEntry?.skillsSnapshot, - systemSent: currentSystemSent, - } - : await traceRunPhase("reply.ensure_skill_snapshot", async () => { - const { ensureSkillSnapshot } = await loadSessionUpdatesRuntime(); - return await ensureSkillSnapshot({ - sessionEntry, - sessionEntryHandle, - sessionStore, - sessionKey, - storePath, - sessionId, - isFirstTurnInSession, - workspaceDir, - cfg, - execOverrides, - skillFilter: opts?.skillFilter, - }); + sessionEntryHandle, + sessionStore, + sessionKey, + storePath, + sessionId, + isFirstTurnInSession, + workspaceDir, + cfg, + execOverrides, + skillFilter: opts?.skillFilter, }); + }); sessionEntry = skillResult.sessionEntry; if (sessionEntry) { sessionEntryHandle?.replaceCurrent(sessionEntry); diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 3e869c76b87c..159ff224827e 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -360,7 +360,7 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(stored.pendingFinalDeliveryIntentId).toBeUndefined(); }); - it("keeps non-ack heartbeat pending delivery without direct replay", async () => { + it("clears short heartbeat pending delivery under the fixed ack policy", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-heartbeat-pending-replay-")); const storePath = path.join(home, "sessions.json"); const sessionKey = "agent:main:telegram:123"; @@ -377,7 +377,7 @@ describe("getReplyFromConfig fast test bootstrap", () => { defaults: { model: "openai/gpt-5.5", workspace: home, - heartbeat: { ackMaxChars: 0 }, + heartbeat: {}, }, }, session: { store: storePath }, @@ -388,8 +388,8 @@ describe("getReplyFromConfig fast test bootstrap", () => { ).resolves.toEqual({ text: "ok" }); const stored = readFastPathSessionEntry(storePath, sessionKey); - expect(stored.pendingFinalDelivery).toBe(true); - expect(stored.pendingFinalDeliveryText).toBe("HEARTBEAT_OK short"); + expect(stored.pendingFinalDelivery).toBeUndefined(); + expect(stored.pendingFinalDeliveryText).toBeUndefined(); expect(stored.pendingFinalDeliveryAttemptCount).toBeUndefined(); }); diff --git a/src/auto-reply/reply/get-reply.message-hooks.test.ts b/src/auto-reply/reply/get-reply.message-hooks.test.ts index b562929e3e67..8b9a01b37529 100644 --- a/src/auto-reply/reply/get-reply.message-hooks.test.ts +++ b/src/auto-reply/reply/get-reply.message-hooks.test.ts @@ -86,15 +86,16 @@ function buildConfiguredAudioCfg() { return withFastReplyConfig({ tools: { media: { + models: [ + { + type: "cli", + command: "/usr/local/bin/stt-transcribe", + args: ["{{MediaPath}}"], + capabilities: ["audio"], + }, + ], audio: { enabled: true, - models: [ - { - type: "cli", - command: "/usr/local/bin/stt-transcribe", - args: ["{{MediaPath}}"], - }, - ], }, }, }, diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 84bb3efc1b98..e5e0ad6a20d6 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -18,6 +18,7 @@ import { type OpenClawConfig, getRuntimeConfig } from "../../config/config.js"; import { isSessionWorkStartInvalidatedError } from "../../config/sessions/lifecycle.js"; import { logVerbose } from "../../globals.js"; import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js"; +import { isFastTestRuntimeEnv } from "../../infra/env.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { ApplyMediaUnderstandingResult } from "../../media-understanding/apply.js"; @@ -95,14 +96,8 @@ function classifyHeartbeatPendingFinalDelivery(text: string, ackMaxChars: number }; } -function resolveHeartbeatAckMaxChars(cfg: OpenClawConfig, agentId: string): number { - const agentHeartbeat = resolveAgentConfig(cfg, agentId)?.heartbeat; - return Math.max( - 0, - agentHeartbeat?.ackMaxChars ?? - cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? - DEFAULT_HEARTBEAT_ACK_MAX_CHARS, - ); +function resolveHeartbeatAckMaxChars(_cfg: OpenClawConfig, _agentId: string): number { + return DEFAULT_HEARTBEAT_ACK_MAX_CHARS; } const sessionResetModelRuntimeLoader = createLazyImportLoader( @@ -218,7 +213,7 @@ export async function getReplyFromConfig( opts?: GetReplyOptions, configOverride?: OpenClawConfig, ): Promise { - const isFastTestEnv = process.env.OPENCLAW_TEST_FAST === "1"; + const isFastTestEnv = isFastTestRuntimeEnv(); const cfg = resolveGetReplyConfig({ getRuntimeConfig, isFastTestEnv, @@ -351,7 +346,8 @@ export async function getReplyFromConfig( }; }); const typing = resolverTiming.measureSync("reply.create_typing_controller", () => { - const configuredTypingSeconds = agentCfg?.typingIntervalSeconds; + const configuredTypingSeconds = + agentEntry?.typingIntervalSeconds ?? agentCfg?.typingIntervalSeconds; const typingIntervalSeconds = typeof configuredTypingSeconds === "number" ? configuredTypingSeconds : 6; const controller = createTypingController({ diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 13dd4334701a..38eba3258a67 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -703,7 +703,7 @@ describe("routeReply", () => { it("applies responsePrefix when routing", async () => { const cfg = { - messages: { responsePrefix: "[openclaw]" }, + channels: { slack: { responsePrefix: "[openclaw]" } }, } as unknown as OpenClawConfig; await routeReply({ payload: { text: "hi" }, @@ -716,7 +716,6 @@ describe("routeReply", () => { it("interpolates responsePrefix from the routed channel and account", async () => { const cfg = { - messages: { responsePrefix: "[global]" }, channels: { slack: { responsePrefix: "[slack]", diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 0df3c5b595ef..f9bdf19470ec 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -149,15 +149,11 @@ export async function routeReply(params: RouteReplyParams): Promise { - if (process.env.OPENCLAW_TEST_FAST === "1") { + if (isFastTestRuntimeEnv()) { // In fast unit-test runs we skip filesystem scanning, watchers, and session-store writes. // Dedicated skills tests cover snapshot generation behavior. return { diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 09e907d68228..b713b5641247 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -4465,7 +4465,9 @@ describe("initSessionState preserves behavior overrides across /new and /reset", Provider: "telegram", Surface: "telegram", }, - cfg: { session: { store: storePath, idleMinutes: 1 } } as OpenClawConfig, + cfg: { + session: { store: storePath, reset: { mode: "idle", idleMinutes: 1 } }, + } as OpenClawConfig, commandAuthorized: true, }); const replaceSession = runExclusiveSessionStoreWrite(storePath, async () => { diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 25f6fa847a90..0b903b6c8c96 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -390,17 +390,15 @@ describe("buildStatusMessage", () => { await withTempHome(async () => { const text = buildStatusMessage({ config: { - messages: { - tts: { - auto: "always", - provider: "openai", - providers: { - openai: { - displayName: "NeuTTS local", - baseUrl: "http://user:secret@127.0.0.1:18801/v1?token=hidden#fragment", - model: "neutts-nano", - voice: "clara", - }, + tts: { + auto: "always", + provider: "openai", + providers: { + openai: { + displayName: "NeuTTS local", + baseUrl: "http://username@127.0.0.1:18801/v1?token=hidden#fragment", + model: "neutts-nano", + voice: "clara", }, }, }, @@ -413,7 +411,7 @@ describe("buildStatusMessage", () => { expect(normalized).toContain( "Voice: always · provider=openai · name=NeuTTS local · model=neutts-nano · voice=clara · endpoint=custom(http://127.0.0.1:18801/v1)", ); - expect(normalized).not.toContain("secret"); + expect(normalized).not.toContain("username"); expect(normalized).not.toContain("token=hidden"); expect(normalized).not.toContain("fragment"); }); diff --git a/src/channels/direct-dm-access.ts b/src/channels/direct-dm-access.ts index 2779c2dbc497..c1acb64f5759 100644 --- a/src/channels/direct-dm-access.ts +++ b/src/channels/direct-dm-access.ts @@ -130,7 +130,7 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: { // preserve the legacy direct-DM behavior: command access follows sender allowlist access. const commandAuthorized = shouldComputeAuth ? (params.runtime.resolveCommandAuthorizedFromAuthorizers?.({ - useAccessGroups: params.cfg.commands?.useAccessGroups !== false, + useAccessGroups: true, authorizers: [ { configured: access.effectiveAllowFrom.length > 0, diff --git a/src/channels/inbound-event/envelope.test.ts b/src/channels/inbound-event/envelope.test.ts index 7b25f18aac96..59362cf575f5 100644 --- a/src/channels/inbound-event/envelope.test.ts +++ b/src/channels/inbound-event/envelope.test.ts @@ -20,7 +20,7 @@ vi.mock("../../config/sessions/session-accessor.js", () => ({ readSessionUpdated vi.mock("../../routing/resolve-route.js", () => ({ resolveAgentRoute })); const cfg = { - agents: { defaults: { envelopeTimestamp: "off" } }, + agents: { defaults: { userTimezone: "UTC" } }, session: { store: "/state/{agentId}/sessions.json" }, } as OpenClawConfig; @@ -40,7 +40,7 @@ describe("channel inbound envelope", () => { body: "hello", timestamp: 120_000, }), - ).toBe("[Telegram Alice +1m] hello"); + ).toBe("[Telegram Alice +1m Thu 1970-01-01T00:02:00Z] hello"); expect(resolveStorePath).toHaveBeenCalledWith(cfg.session?.store, { agentId: "main" }); expect(readSessionUpdatedAt).toHaveBeenCalledWith({ storePath: "/state/main/sessions.json", @@ -78,7 +78,7 @@ describe("channel inbound envelope", () => { timestamp: 30_000, previousTimestamp: null, }), - ).toBe("[Telegram Alice] older"); + ).toBe("[Telegram Alice Thu 1970-01-01T00:00:30Z] older"); expect(readSessionUpdatedAt).not.toHaveBeenCalled(); }); }); diff --git a/src/channels/message-access/runtime-types.ts b/src/channels/message-access/runtime-types.ts index ee56f7c22f37..15eaf4bcda66 100644 --- a/src/channels/message-access/runtime-types.ts +++ b/src/channels/message-access/runtime-types.ts @@ -97,8 +97,6 @@ export type ChannelIngressIdentitySubjectInput = { export type ChannelIngressConfigInput = { /** Static or dynamic access group definitions referenced by allowlist entries. */ accessGroups?: ChannelIngressStateInput["accessGroups"]; - /** Command config used for access-group command behavior. */ - commands?: { useAccessGroups?: boolean } | null; } | null; /** Command gate input for control-command authorization. */ @@ -122,7 +120,7 @@ export type ChannelIngressCommandPresetInput = Omit< > & { /** Set false to omit the command gate entirely. */ requested?: boolean; - /** Overrides `cfg.commands.useAccessGroups` for this command decision. */ + /** Internal override for this command decision. */ useAccessGroups?: boolean | null; /** Config subset used to derive command access-group behavior. */ cfg?: ChannelIngressConfigInput; diff --git a/src/channels/message-access/runtime.ts b/src/channels/message-access/runtime.ts index 5cddcc4eb674..d5e27ad63fdc 100644 --- a/src/channels/message-access/runtime.ts +++ b/src/channels/message-access/runtime.ts @@ -137,7 +137,7 @@ function useAccessGroupsFromConfig(params: { useAccessGroups?: boolean | null; cfg?: ChannelIngressCommandPresetInput["cfg"]; }): boolean { - return params.useAccessGroups ?? params.cfg?.commands?.useAccessGroups !== false; + return params.useAccessGroups ?? true; } function channelIngressCommand( diff --git a/src/channels/thread-bindings-policy.test.ts b/src/channels/thread-bindings-policy.test.ts index 49caba29d4c2..abd183acb1c2 100644 --- a/src/channels/thread-bindings-policy.test.ts +++ b/src/channels/thread-bindings-policy.test.ts @@ -103,9 +103,7 @@ describe("thread binding spawn policy helpers", () => { const cfg = { channels: { discord: { - threadBindings: { - spawnSessions: false, - }, + threadBindings: { spawnSessions: false }, }, }, }; diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 02f50053c789..03ddc9e09338 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -181,11 +181,7 @@ export function resolveThreadBindingSpawnPolicy(params: { }): ThreadBindingSpawnPolicy { const channel = normalizeChannelId(params.channel); const accountId = normalizeAccountId(params.accountId); - const { root, account } = resolveChannelThreadBindings({ - cfg: params.cfg, - channel, - accountId, - }); + const { root, account } = resolveChannelThreadBindings({ cfg: params.cfg, channel, accountId }); const enabled = normalizeBoolean(account?.enabled) ?? normalizeBoolean(root?.enabled) ?? diff --git a/src/claws/add.ts b/src/claws/add.ts index 998b70227b51..227ed1eeb6af 100644 --- a/src/claws/add.ts +++ b/src/claws/add.ts @@ -2,8 +2,10 @@ import { lstat, mkdir, rmdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js"; +import { listAgentEntries } from "../agents/agent-scope.js"; import { stableStringify } from "../agents/stable-stringify.js"; import { transformConfigFileWithRetry } from "../config/config.js"; +import type { AgentConfig } from "../config/types.agents.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolvePathViaExistingAncestorSync } from "../infra/boundary-path.js"; import { normalizeWindowsPathForComparison } from "../infra/path-guards.js"; @@ -41,8 +43,6 @@ type ClawAddApplyOptions = OpenClawStateDatabaseOptions & { installPackages?: typeof installClawPackages; nowMs?: number; }; -type AgentConfig = NonNullable["list"]>[number]; - export class ClawAddMutationError extends Error { constructor( readonly code: string, @@ -289,12 +289,15 @@ export async function applyClawAddPlan( }); }); await commit((config) => { - const existingAgents = config.agents?.list ?? []; + const existingAgents = listAgentEntries(config); const agentsToPreserve: AgentConfig[] = existingAgents.length > 0 ? existingAgents : [{ id: DEFAULT_AGENT_ID, default: true }]; const configWithPreservedAgents: OpenClawConfig = { ...config, - agents: { ...config.agents, list: agentsToPreserve }, + agents: { + ...config.agents, + entries: Object.fromEntries(agentsToPreserve.map(({ id, ...entry }) => [id, entry])), + }, }; const normalizedAgentId = normalizeAgentId(plan.agent.finalId); const existingAgent = agentsToPreserve.find( @@ -323,7 +326,9 @@ export async function applyClawAddPlan( ...config, agents: { ...config.agents, - list: [...agentsToPreserve, plan.agent.config], + entries: Object.fromEntries( + [...agentsToPreserve, plan.agent.config].map(({ id, ...entry }) => [id, entry]), + ), }, }; configCommitted = true; diff --git a/src/claws/lifecycle-config-removal.ts b/src/claws/lifecycle-config-removal.ts index 1d2d2ae8ba42..a4fccfd29b72 100644 --- a/src/claws/lifecycle-config-removal.ts +++ b/src/claws/lifecycle-config-removal.ts @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; +import { listAgentEntries } from "../agents/agent-scope.js"; import { stableStringify } from "../agents/stable-stringify.js"; import { getRuntimeConfig } from "../config/config.js"; +import type { AgentConfig } from "../config/types.agents.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { AgentConfigPreconditionError, @@ -15,9 +17,7 @@ import { export type ConfigCommit = (transform: (config: OpenClawConfig) => OpenClawConfig) => Promise; -export function digestClawAgentConfig( - agent: NonNullable["list"]>[number], -): string { +export function digestClawAgentConfig(agent: AgentConfig): string { return `sha256:${createHash("sha256").update(stableStringify(agent)).digest("hex")}`; } @@ -61,7 +61,7 @@ export async function claimClawAgentConfigRemoval(params: { | undefined; await params.commitConfig((config) => { const effects = deletionEffects(config, params.agentId, params.fallbackWorkspace); - const agent = config.agents?.list?.find((candidate) => candidate.id === params.agentId); + const agent = listAgentEntries(config).find((candidate) => candidate.id === params.agentId); if ( (agent && digestClawAgentConfig(agent) !== params.expectedDigest) || digestClawAgentRemovalSurface(config, params.agentId) !== @@ -134,7 +134,7 @@ export async function claimClawAgentConfigRemoval(params: { throw error; } const latestConfig = getRuntimeConfig(); - if (latestConfig.agents?.list?.some((agent) => agent.id === params.agentId)) { + if (listAgentEntries(latestConfig).some((agent) => agent.id === params.agentId)) { throw params.onModified(); } const effects = deletionEffects(latestConfig, params.agentId, params.fallbackWorkspace); diff --git a/src/claws/lifecycle-delete-support.ts b/src/claws/lifecycle-delete-support.ts index ad3cfc5a2e29..482d662b729d 100644 --- a/src/claws/lifecycle-delete-support.ts +++ b/src/claws/lifecycle-delete-support.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js"; -import { resolveAgentDir } from "../agents/agent-scope.js"; +import { listAgentEntries, resolveAgentDir } from "../agents/agent-scope.js"; import { prepareLegacyWorkspaceStateReset, removeLegacyWorkspaceStateForReset, @@ -107,7 +107,7 @@ export function synthesizeOrphanInstall(params: { } export function deletionEffects(config: OpenClawConfig, agentId: string, fallbackWorkspace = "") { - const agent = config.agents?.list?.find((candidate) => candidate.id === agentId); + const agent = listAgentEntries(config).find((candidate) => candidate.id === agentId); const pruned = pruneAgentConfig(config, agentId); const workspace = agent?.workspace ?? fallbackWorkspace; const agentDir = resolveAgentDir(config, agentId); diff --git a/src/claws/lifecycle-state.test.ts b/src/claws/lifecycle-state.test.ts index 3d3d4844dd83..b685fc6e1314 100644 --- a/src/claws/lifecycle-state.test.ts +++ b/src/claws/lifecycle-state.test.ts @@ -77,7 +77,7 @@ describe("Claw status and remove", () => { expectedRemovalSurfaceDigest: "sha256:unused", expectedState: "missing", fallbackWorkspace: "/tmp/old-worker", - config: { agents: { list: [{ id: "worker", workspace: "/tmp/new-worker" }] } }, + config: { agents: { entries: { worker: { workspace: "/tmp/new-worker" } } } }, onModified: () => new Error("agent recreated"), }), ).rejects.toThrow("agent recreated"); @@ -125,7 +125,7 @@ describe("Claw status and remove", () => { persistClawInstallRecord(current.plan, { env: current.env, status: "config_committed" }); await expect( - readClawStatus("worker", { env: current.env, config: { agents: { list: [] } } }), + readClawStatus("worker", { env: current.env, config: { agents: { entries: {} } } }), ).resolves.toMatchObject({ summary: { claws: 1, partial: 1 } }); }); @@ -187,7 +187,7 @@ describe("Claw status and remove", () => { expect(plan.actions).toEqual( expect.arrayContaining([ - expect.objectContaining({ kind: "agent", target: "agents.list[worker]" }), + expect.objectContaining({ kind: "agent", target: 'agents.entries["worker"]' }), expect.objectContaining({ kind: "configBinding", target: "bindings[agentId=worker]" }), expect.objectContaining({ kind: "agentAllow", target: "tools.agentToAgent.allow[worker]" }), expect.objectContaining({ kind: "workspace", action: "trash" }), @@ -296,7 +296,7 @@ describe("Claw status and remove", () => { packageRefsReleased: 1, workspaceFiles: [{ path: "SOUL.md", action: "deleted" }], }); - expect(config.agents?.list?.some((agent) => agent.id === "worker")).toBe(false); + expect(config.agents?.entries?.worker).toBeUndefined(); await expect(readFile(join(current.plan.agent.workspace, "SOUL.md"), "utf8")).rejects.toThrow(); await expect(readClawStatus("worker", { env: current.env, config })).resolves.toMatchObject({ summary: { claws: 0 }, @@ -499,9 +499,8 @@ describe("Claw status and remove", () => { it("blocks removal when the created agent config changed", async () => { const current = await addFixture(); const config = current.getConfig(); - const agentIndex = config.agents!.list!.findIndex((agent) => agent.id === "worker"); - const agent = config.agents!.list![agentIndex]!; - config.agents!.list![agentIndex] = { ...agent, name: "Operator edit" }; + const agent = config.agents!.entries!.worker!; + config.agents!.entries!.worker = { ...agent, name: "Operator edit" }; const plan = await buildClawRemovePlan("worker", { env: current.env, config }); expect(plan.blockers).toContainEqual(expect.objectContaining({ code: "agent_modified" })); await expect( @@ -564,8 +563,10 @@ describe("Claw status and remove", () => { origin: "claw-introduced", independentOwner: false, }); + const { id: firstId, ...firstConfig } = first.plan.agent.config; + const { id: secondId, ...secondConfig } = second.plan.agent.config; let config: OpenClawConfig = { - agents: { list: [first.plan.agent.config, second.plan.agent.config] }, + agents: { entries: { [firstId]: firstConfig, [secondId]: secondConfig } }, }; const remove = await buildClawRemovePlan("worker-a", { env: first.env, config }); await applyClawRemovePlan(remove, { diff --git a/src/claws/lifecycle-state.ts b/src/claws/lifecycle-state.ts index 52170a311393..3f2b9cdbf6da 100644 --- a/src/claws/lifecycle-state.ts +++ b/src/claws/lifecycle-state.ts @@ -1,4 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; +import { listAgentEntries } from "../agents/agent-scope.js"; import { stableStringify } from "../agents/stable-stringify.js"; import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -201,7 +202,7 @@ export async function readClawStatus( ); const records: ClawStatusRecord[] = []; for (const install of installs) { - const agent = config.agents?.list?.find((candidate) => candidate.id === install.agentId); + const agent = listAgentEntries(config).find((candidate) => candidate.id === install.agentId); const packageRefs = allPackageRefs.filter( (packageRef) => packageRef.agentId === install.agentId, ); @@ -322,7 +323,7 @@ export async function buildClawRemovePlan( kind: "agent", id: record.install.agentId, action: "remove", - target: `agents.list[${record.install.agentId}]`, + target: `agents.entries[${JSON.stringify(record.install.agentId)}]`, blocked: record.agentState === "modified", details: { expectedState: record.agentState, diff --git a/src/claws/lifecycle.e2e.test.ts b/src/claws/lifecycle.e2e.test.ts index 415baf695aef..263a0bbb95d1 100644 --- a/src/claws/lifecycle.e2e.test.ts +++ b/src/claws/lifecycle.e2e.test.ts @@ -1,6 +1,6 @@ // E2E coverage for experimental grouped Claw inspection and add planning. import { execFile } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, realpath, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; @@ -145,14 +145,14 @@ describe("claws lifecycle cli e2e", () => { installRecord: { agentId: "internal-triage", status: "complete" }, }); const config = JSON.parse(await readFile(join(result.stateDir, "openclaw.json"), "utf8")); - expect(config.agents.list).toEqual([ - { id: "main", default: true }, - expect.objectContaining({ - id: "internal-triage", + const canonicalStateDir = await realpath(result.stateDir); + expect(config.agents.entries).toEqual({ + main: { default: true }, + "internal-triage": expect.objectContaining({ name: "Internal Triage", - workspace: join(result.stateDir, ".openclaw", "workspace-internal-triage"), + workspace: join(canonicalStateDir, ".openclaw", "workspace-internal-triage"), }), - ]); + }); }); it("creates declared bootstrap and supporting files in the new workspace", async () => { @@ -177,7 +177,11 @@ describe("claws lifecycle cli e2e", () => { { stateDir: preview.stateDir }, ); const payload = parseJson(result.stdout); - const workspace = join(result.stateDir, ".openclaw", "workspace-workspace-agent"); + const workspace = join( + await realpath(result.stateDir), + ".openclaw", + "workspace-workspace-agent", + ); expect(payload).toMatchObject({ schemaVersion: "openclaw.clawAddResult.v1", @@ -262,7 +266,7 @@ describe("claws lifecycle cli e2e", () => { agentRemoved: true, }); const config = JSON.parse(await readFile(join(added.stateDir, "openclaw.json"), "utf8")); - expect(config.agents).toEqual({ list: [{ id: "main", default: true }] }); + expect(config.agents).toEqual({ entries: { main: { default: true } } }); }); it("blocks mutation when declared components need later lifecycle slices", async () => { diff --git a/src/claws/lifecycle.ts b/src/claws/lifecycle.ts index add3c703c19f..a0bbe74e98e4 100644 --- a/src/claws/lifecycle.ts +++ b/src/claws/lifecycle.ts @@ -231,7 +231,7 @@ export async function buildClawAddPlan(params: { kind: "agent", id: finalId, action: "create", - target: `agents.list[${JSON.stringify(finalId)}]`, + target: `agents.entries[${JSON.stringify(finalId)}]`, details: { ...params.manifest.agent, id: finalId, workspace, expectedState: "absent" }, blocked: agentBlocked || !AGENT_ID_PATTERN.test(finalId), }); diff --git a/src/claws/provenance.test.ts b/src/claws/provenance.test.ts index 9efb4ba8140a..e5a8221268b2 100644 --- a/src/claws/provenance.test.ts +++ b/src/claws/provenance.test.ts @@ -133,7 +133,7 @@ describe("Claw root install provenance", () => { planIntegrity: plan.planIntegrity, agentId: "worker", workspace: plan.agent.workspace, - agentOwnedPaths: ['agents.list["worker"]'], + agentOwnedPaths: ['agents.entries["worker"]'], status: "complete", addedAtMs: 42, }); @@ -228,7 +228,7 @@ describe("applyClawAddPlan", () => { let config: OpenClawConfig = { agents: { defaults: { workspace: "/operator/default" }, - list: [{ id: "main", default: true }], + entries: { main: { default: true } }, }, }; @@ -250,16 +250,15 @@ describe("applyClawAddPlan", () => { installRecord: { agentId: "worker" }, }); expect(config.agents?.defaults).toEqual({ workspace: "/operator/default" }); - expect(config.agents?.list).toEqual([ - { id: "main", default: true }, - { - id: "worker", + expect(config.agents?.entries).toEqual({ + main: { default: true }, + worker: { name: "Worker", identity: { name: "Work" }, tools: { deny: ["exec"] }, workspace: plan.agent.workspace, }, - ]); + }); await expect(access(plan.agent.workspace)).resolves.toBeUndefined(); }); @@ -275,10 +274,10 @@ describe("applyClawAddPlan", () => { }, }); - expect(config.agents?.list).toEqual([ - { id: "main", default: true }, - expect.objectContaining({ id: "worker" }), - ]); + expect(config.agents?.entries).toEqual({ + main: { default: true }, + worker: expect.any(Object), + }); }); it("rejects overlap with the implicit main workspace before materializing it", async () => { @@ -313,7 +312,7 @@ describe("applyClawAddPlan", () => { applyClawAddPlan(plan, { consentPlanIntegrity: plan.planIntegrity, commitConfig: async (transform) => { - transform({ agents: { list: [{ id: "worker" }] } }); + transform({ agents: { entries: { worker: {} } } }); }, }), ).resolves.toMatchObject({ @@ -333,7 +332,7 @@ describe("applyClawAddPlan", () => { env: stateEnv(root), consentPlanIntegrity: plan.planIntegrity, commitConfig: async (transform) => { - transform({ agents: { list: [{ id: " Worker " }] } }); + transform({ agents: { entries: { " Worker ": {} } } }); }, }), ).resolves.toMatchObject({ @@ -362,7 +361,7 @@ describe("applyClawAddPlan", () => { commitConfig: async (transform) => { transform({ agents: { - list: [{ id: "other", workspace: join(aliasParent, "workspace-worker") }], + entries: { other: { workspace: join(aliasParent, "workspace-worker") } }, }, }); }, @@ -496,7 +495,7 @@ describe("applyClawAddPlan", () => { workspaceCreated: true, configCommitted: true, }); - expect(config.agents?.list).toContainEqual(expect.objectContaining({ id: "worker" })); + expect(config.agents?.entries?.worker).toBeDefined(); expect(readInstallRow("worker", root)?.status).toBe("complete"); }); @@ -519,7 +518,7 @@ describe("applyClawAddPlan", () => { expect(result.status).toBe("complete"); await expect(access(plan.agent.workspace)).resolves.toBeUndefined(); - expect(config.agents?.list).toContainEqual(expect.objectContaining({ id: "worker" })); + expect(config.agents?.entries?.worker).toBeDefined(); }); it("rejects a non-directory replacement for a workspace-ready record", async () => { @@ -542,7 +541,7 @@ describe("applyClawAddPlan", () => { }), ).rejects.toMatchObject({ code: "workspace_collision" }); - expect(config.agents?.list).toBeUndefined(); + expect(config.agents?.entries).toBeUndefined(); expect(readClawInstallRecord("worker", { env: stateEnv(root) })?.status).toBe( "workspace_ready", ); @@ -585,7 +584,7 @@ describe("applyClawAddPlan", () => { }, }), ).rejects.toMatchObject({ code: "provenance_failed" }); - expect(config.agents?.list).toBeUndefined(); + expect(config.agents?.entries).toBeUndefined(); }); it("rejects mutation when consent does not bind the current plan", async () => { diff --git a/src/claws/workspace.test.ts b/src/claws/workspace.test.ts index f1a723d1549c..efd538658eff 100644 --- a/src/claws/workspace.test.ts +++ b/src/claws/workspace.test.ts @@ -339,7 +339,7 @@ describe("workspace files in the consented add lifecycle", () => { ], installRecord: { status: "complete" }, }); - expect(config.agents?.list?.some((agent) => agent.id === "workspace-agent")).toBe(true); + expect(config.agents?.entries?.["workspace-agent"]).toBeDefined(); expect(readInstallStatus("workspace-agent", root)).toBe("complete"); }); @@ -370,7 +370,7 @@ describe("workspace files in the consented add lifecycle", () => { diagnostics: [expect.objectContaining({ code: "workspace_source_changed" })], }, }); - expect(config.agents?.list?.some((agent) => agent.id === "workspace-agent")).toBe(true); + expect(config.agents?.entries?.["workspace-agent"]).toBeDefined(); expect(readInstallStatus("workspace-agent", root)).toBe("config_committed"); await writeFile(join(root, "content", "policy.md"), "Policy\n", "utf8"); diff --git a/src/cli/banner-config-lite.ts b/src/cli/banner-config-lite.ts index 311df6c11fa2..54836ad74079 100644 --- a/src/cli/banner-config-lite.ts +++ b/src/cli/banner-config-lite.ts @@ -1,25 +1,10 @@ -// Lightweight banner config reader kept out of the full CLI import path. -import { createConfigIO } from "../config/config.js"; +// Lightweight banner option parser kept out of the full CLI import path. import type { TaglineMode } from "./tagline.js"; -/** Parse a persisted CLI banner tagline mode. */ +/** Parse an explicit CLI banner tagline mode. */ export function parseTaglineMode(value: unknown): TaglineMode | undefined { if (value === "random" || value === "default" || value === "off") { return value; } return undefined; } - -/** Read the banner tagline mode without pulling in full CLI command registration. */ -export function readCliBannerTaglineMode( - env: NodeJS.ProcessEnv = process.env, -): TaglineMode | undefined { - try { - const parsed = createConfigIO({ env }).loadConfig() as { - cli?: { banner?: { taglineMode?: unknown } }; - }; - return parseTaglineMode(parsed.cli?.banner?.taglineMode); - } catch { - return undefined; - } -} diff --git a/src/cli/banner.test.ts b/src/cli/banner.test.ts index ba88d802a15a..fbe36fe5ef1a 100644 --- a/src/cli/banner.test.ts +++ b/src/cli/banner.test.ts @@ -1,21 +1,14 @@ // Banner tests cover CLI banner rendering and suppression behavior. -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { formatCliBannerLine } from "./banner.js"; -const readCliBannerTaglineModeMock = vi.hoisted(() => vi.fn()); const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); vi.mock("./banner-config-lite.js", () => ({ parseTaglineMode: (value: unknown) => value === "random" || value === "default" || value === "off" ? value : undefined, - readCliBannerTaglineMode: readCliBannerTaglineModeMock, })); -beforeEach(() => { - readCliBannerTaglineModeMock.mockReset(); - readCliBannerTaglineModeMock.mockReturnValue(undefined); -}); - afterEach(() => { vi.restoreAllMocks(); if (stdoutIsTtyDescriptor) { @@ -38,37 +31,20 @@ function setStdoutIsTty(value: boolean) { } describe("formatCliBannerLine", () => { - it("hides tagline text when cli.banner.taglineMode is off", () => { - readCliBannerTaglineModeMock.mockReturnValue("off"); - + it("hides tagline text when explicitly disabled", () => { const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", env: { LANG: "en_US.UTF-8" }, isTty: true, platform: "darwin", richTty: false, + mode: "off", }); expect(line).toBe("🦞 OpenClaw 2026.3.7 (abc1234)"); }); - it("uses default tagline when cli.banner.taglineMode is default", () => { - readCliBannerTaglineModeMock.mockReturnValue("default"); - - const line = formatCliBannerLine("2026.3.7", { - commit: "abc1234", - env: { LANG: "en_US.UTF-8" }, - isTty: true, - platform: "darwin", - richTty: false, - }); - - expect(line).toBe("🦞 OpenClaw 2026.3.7 (abc1234) — All your chats, one OpenClaw."); - }); - - it("prefers explicit tagline mode over config", () => { - readCliBannerTaglineModeMock.mockReturnValue("off"); - + it("uses the default tagline when explicitly requested", () => { const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", env: { LANG: "en_US.UTF-8" }, @@ -82,14 +58,13 @@ describe("formatCliBannerLine", () => { }); it("drops decorative emoji for generic Linux terminals", () => { - readCliBannerTaglineModeMock.mockReturnValue("off"); - const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", env: { TERM: "xterm-256color", LANG: "en_US.UTF-8" }, isTty: true, platform: "linux", richTty: false, + mode: "off", }); expect(line).toBe("OpenClaw 2026.3.7 (abc1234)"); diff --git a/src/cli/banner.ts b/src/cli/banner.ts index 84e5e4853a6b..a0440388e651 100644 --- a/src/cli/banner.ts +++ b/src/cli/banner.ts @@ -9,7 +9,7 @@ import { import { isRich, theme } from "../../packages/terminal-core/src/theme.js"; import { resolveCommitHash } from "../infra/git-commit.js"; import { hasRootVersionAlias } from "./argv.js"; -import { parseTaglineMode, readCliBannerTaglineMode } from "./banner-config-lite.js"; +import { parseTaglineMode } from "./banner-config-lite.js"; import { pickCliLobsterArt } from "./lobster-art.js"; import { pickTagline, type TaglineMode, type TaglineOptions } from "./tagline.js"; @@ -35,7 +35,7 @@ function resolveTaglineMode(options: BannerOptions): TaglineMode | undefined { if (explicit) { return explicit; } - return readCliBannerTaglineMode(options.env); + return undefined; } function resolveEmojiOptions(options: BannerOptions): DecorativeEmojiOptions { diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index c9e510807bfc..082c4f9784e6 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -2665,7 +2665,7 @@ describe("capability cli", () => { }), ).rejects.toThrow("exit 1"); expectRuntimeErrorContains("No audio transcription provider is configured or ready"); - expectRuntimeErrorContains("tools.media.audio.models"); + expectRuntimeErrorContains("tools.media.models"); }); it("surfaces the underlying transcription failure for audio transcribe", async () => { @@ -2736,7 +2736,7 @@ describe("capability cli", () => { }); it("hydrates local TTS provider config from API-key auth profiles", async () => { - const rawConfig = { messages: { tts: { providers: { openai: { voice: "coral" } } } } }; + const rawConfig = { tts: { providers: { openai: { voice: "coral" } } } }; mocks.loadConfig.mockReturnValue(rawConfig); mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ apiKey: "profile-openai-key", @@ -2766,9 +2766,9 @@ describe("capability cli", () => { }), ); const cfg = firstTextToSpeechCall()?.cfg as { - messages?: { tts?: { providers?: { openai?: { apiKey?: string; voice?: string } } } }; + tts?: { providers?: { openai?: { apiKey?: string; voice?: string } } }; }; - expect(cfg.messages?.tts?.providers?.openai).toMatchObject({ + expect(cfg.tts?.providers?.openai).toMatchObject({ apiKey: "profile-openai-key", voice: "coral", }); @@ -2776,7 +2776,7 @@ describe("capability cli", () => { }); it("hydrates local TTS default provider config from API-key auth profiles", async () => { - const rawConfig = { messages: { tts: { provider: "openai" } } }; + const rawConfig = { tts: { provider: "openai" } }; mocks.loadConfig.mockReturnValue(rawConfig); mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ apiKey: "profile-openai-key", @@ -2797,9 +2797,9 @@ describe("capability cli", () => { }), ); const cfg = firstTextToSpeechCall()?.cfg as { - messages?: { tts?: { providers?: { openai?: { apiKey?: string } } } }; + tts?: { providers?: { openai?: { apiKey?: string } } }; }; - expect(cfg.messages?.tts?.providers?.openai).toMatchObject({ + expect(cfg.tts?.providers?.openai).toMatchObject({ apiKey: "profile-openai-key", }); }); @@ -2823,9 +2823,9 @@ describe("capability cli", () => { expect.objectContaining({ channelId: "discord" }), ); const cfg = firstTextToSpeechCall()?.cfg as { - messages?: { tts?: { providers?: { openai?: { apiKey?: string } } } }; + tts?: { providers?: { openai?: { apiKey?: string } } }; }; - expect(cfg.messages?.tts?.providers?.openai).toMatchObject({ + expect(cfg.tts?.providers?.openai).toMatchObject({ apiKey: "profile-openai-key", }); }); @@ -2856,19 +2856,19 @@ describe("capability cli", () => { channels?: { discord?: { tts?: { openai?: { apiKey?: string; speakerVoice?: string } } }; }; - messages?: { tts?: { providers?: { openai?: { apiKey?: string } } } }; + tts?: { providers?: { openai?: { apiKey?: string } } }; }; expect(cfg.channels?.discord?.tts?.openai).toMatchObject({ apiKey: "profile-openai-key", speakerVoice: "nova", }); - expect(cfg.messages?.tts?.providers?.openai).toBeUndefined(); + expect(cfg.tts?.providers?.openai).toBeUndefined(); expect(mocks.setRuntimeConfigSnapshot).toHaveBeenLastCalledWith(cfg); }); it("does not override inherited local TTS channel provider API keys", async () => { const rawConfig = { - messages: { tts: { providers: { openai: { apiKey: "config-key" } } } }, + tts: { providers: { openai: { apiKey: "config-key" } } }, channels: { discord: { tts: { @@ -2893,18 +2893,18 @@ describe("capability cli", () => { }); const cfg = firstTextToSpeechCall()?.cfg as { - messages?: { tts?: { providers?: { openai?: { apiKey?: string } } } }; + tts?: { providers?: { openai?: { apiKey?: string } } }; channels?: { discord?: { tts?: { providers?: { openai?: { apiKey?: string; speakerVoice?: string } } } }; }; }; - expect(cfg.messages?.tts?.providers?.openai?.apiKey).toBe("config-key"); + expect(cfg.tts?.providers?.openai?.apiKey).toBe("config-key"); expect(cfg.channels?.discord?.tts?.providers?.openai).toEqual({ speakerVoice: "nova" }); expect(mocks.resolveApiKeyForProvider).not.toHaveBeenCalled(); }); it("does not hydrate local TTS provider config from token auth profiles", async () => { - const rawConfig = { messages: { tts: { provider: "openai" } } }; + const rawConfig = { tts: { provider: "openai" } }; mocks.loadConfig.mockReturnValue(rawConfig); mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ apiKey: "profile-openai-token", @@ -2918,13 +2918,13 @@ describe("capability cli", () => { }); const cfg = firstTextToSpeechCall()?.cfg as { - messages?: { tts?: { providers?: { openai?: { apiKey?: string } } } }; + tts?: { providers?: { openai?: { apiKey?: string } } }; }; - expect(cfg.messages?.tts?.providers?.openai?.apiKey).toBeUndefined(); + expect(cfg.tts?.providers?.openai?.apiKey).toBeUndefined(); }); it("does not override existing TTS provider API keys with different casing", async () => { - const rawConfig = { messages: { tts: { providers: { OpenAI: { apiKey: "config-key" } } } } }; + const rawConfig = { tts: { providers: { OpenAI: { apiKey: "config-key" } } } }; mocks.loadConfig.mockReturnValue(rawConfig); mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ apiKey: "profile-openai-key", @@ -2947,16 +2947,14 @@ describe("capability cli", () => { }); const cfg = firstTextToSpeechCall()?.cfg as { - messages?: { - tts?: { providers?: { openai?: { apiKey?: string }; OpenAI?: { apiKey?: string } } }; - }; + tts?: { providers?: { openai?: { apiKey?: string }; OpenAI?: { apiKey?: string } } }; }; - expect(cfg.messages?.tts?.providers?.OpenAI?.apiKey).toBe("config-key"); - expect(cfg.messages?.tts?.providers?.openai).toBeUndefined(); + expect(cfg.tts?.providers?.OpenAI?.apiKey).toBe("config-key"); + expect(cfg.tts?.providers?.openai).toBeUndefined(); }); it("does not override existing direct TTS provider API keys", async () => { - const rawConfig = { messages: { tts: { openai: { apiKey: "config-key" } } } }; + const rawConfig = { tts: { openai: { apiKey: "config-key" } } }; mocks.loadConfig.mockReturnValue(rawConfig); mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ apiKey: "profile-openai-key", @@ -2979,15 +2977,13 @@ describe("capability cli", () => { }); const cfg = firstTextToSpeechCall()?.cfg as { - messages?: { - tts?: { - openai?: { apiKey?: string }; - providers?: { openai?: { apiKey?: string } }; - }; + tts?: { + openai?: { apiKey?: string }; + providers?: { openai?: { apiKey?: string } }; }; }; - expect(cfg.messages?.tts?.openai?.apiKey).toBe("config-key"); - expect(cfg.messages?.tts?.providers?.openai).toBeUndefined(); + expect(cfg.tts?.openai?.apiKey).toBe("config-key"); + expect(cfg.tts?.providers?.openai).toBeUndefined(); }); it("disables TTS fallback when explicit provider or voice/model selection is requested", async () => { diff --git a/src/cli/capability-cli/audio.ts b/src/cli/capability-cli/audio.ts index 26b313864e8a..361f2848842e 100644 --- a/src/cli/capability-cli/audio.ts +++ b/src/cli/capability-cli/audio.ts @@ -40,7 +40,7 @@ async function runAudioTranscribe(params: { if (!result.text) { if (isMissingMediaUnderstandingProvider(result)) { throw new Error( - "No audio transcription provider is configured or ready. Configure tools.media.audio.models, or pass --model after configuring that provider's auth/API key.", + "No audio transcription provider is configured or ready. Configure an audio-capable tools.media.models entry, or pass --model after configuring that provider's auth/API key.", ); } throw new Error(`No transcript returned for audio: ${path.resolve(params.file)}`); diff --git a/src/cli/capability-cli/image.ts b/src/cli/capability-cli/image.ts index 5beffb34f809..6cfc2cde81ab 100644 --- a/src/cli/capability-cli/image.ts +++ b/src/cli/capability-cli/image.ts @@ -199,7 +199,7 @@ async function runImageDescribe(params: { if (!result.result.text) { if (isMissingMediaUnderstandingProvider(result.result)) { throw new Error( - "No image understanding provider is configured or ready. Configure tools.media.image.models or agents.defaults.imageModel.primary, or pass --model after configuring that provider's auth/API key.", + "No image understanding provider is configured or ready. Configure an image-capable tools.media.models entry or agents.defaults.imageModel.primary, or pass --model after configuring that provider's auth/API key.", ); } throw new Error(`No description returned for image: ${resolvedPath}`); @@ -411,7 +411,7 @@ export function registerImageCapabilityCommands(capability: Command): void { await runCommandWithRuntime(defaultRuntime, async () => { const cfg = getRuntimeConfig(); const selectedProvider = resolveSelectedProviderFromModelRef( - resolveAgentModelPrimaryValue(cfg.agents?.defaults?.imageGenerationModel), + resolveAgentModelPrimaryValue(cfg.agents?.defaults?.mediaModels?.image), ); const result = listRuntimeImageGenerationProviders({ config: cfg }).map((provider) => ({ available: true, diff --git a/src/cli/capability-cli/shared.ts b/src/cli/capability-cli/shared.ts index 1838f9f18a32..933fdf40c345 100644 --- a/src/cli/capability-cli/shared.ts +++ b/src/cli/capability-cli/shared.ts @@ -113,7 +113,7 @@ export function providerHasGenericConfig(params: { }): boolean { const modelsProviders = (params.cfg.models?.providers ?? {}) as Record; const pluginEntries = (params.cfg.plugins?.entries ?? {}) as Record; - const ttsProviders = (params.cfg.messages?.tts?.providers ?? {}) as Record; + const ttsProviders = (params.cfg.tts?.providers ?? {}) as Record; const envConfigured = (params.envVars ?? []).some((envVar) => Boolean(process.env[envVar]?.trim()), ); diff --git a/src/cli/capability-cli/tts-runtime.ts b/src/cli/capability-cli/tts-runtime.ts index 942ab6a8a825..7193606666e0 100644 --- a/src/cli/capability-cli/tts-runtime.ts +++ b/src/cli/capability-cli/tts-runtime.ts @@ -226,19 +226,15 @@ async function injectTtsAuthProfileApiKey(params: { }, }; } - const messages = { ...params.cfg.messages }; const nextTts = buildTtsConfigWithHydratedProvider({ - tts: messages.tts, + tts: params.cfg.tts, existingProviderConfig, providerId, apiKey: auth.apiKey, }); return { ...params.cfg, - messages: { - ...messages, - tts: nextTts, - }, + tts: nextTts, }; } @@ -280,7 +276,7 @@ function resolveExistingTtsProviderConfig(params: { } const rootProviderConfig = resolveExistingTtsProviderConfigInTts({ cfg: params.cfg, - tts: params.cfg.messages?.tts, + tts: params.cfg.tts, providerId: params.providerId, }); return rootProviderConfig ? { ...rootProviderConfig, scope: "root" } : undefined; diff --git a/src/cli/capability-cli/video.ts b/src/cli/capability-cli/video.ts index 4109aba6ae88..7d7f740f3dd1 100644 --- a/src/cli/capability-cli/video.ts +++ b/src/cli/capability-cli/video.ts @@ -293,7 +293,7 @@ export function registerVideoCapabilityCommands(capability: Command): void { await runCommandWithRuntime(defaultRuntime, async () => { const cfg = getRuntimeConfig(); const selectedGenerationProvider = resolveSelectedProviderFromModelRef( - resolveAgentModelPrimaryValue(cfg.agents?.defaults?.videoGenerationModel), + resolveAgentModelPrimaryValue(cfg.agents?.defaults?.mediaModels?.video), ); const result = { generation: listRuntimeVideoGenerationProviders({ config: cfg }).map((provider) => ({ diff --git a/src/cli/claws-cli.runtime.ts b/src/cli/claws-cli.runtime.ts index 568cd3c2363d..b2ab34515e8f 100644 --- a/src/cli/claws-cli.runtime.ts +++ b/src/cli/claws-cli.runtime.ts @@ -1,4 +1,8 @@ -import { listAgentIds, resolveAgentWorkspaceDir } from "../agents/agent-scope-config.js"; +import { + listAgentEntries, + listAgentIds, + resolveAgentWorkspaceDir, +} from "../agents/agent-scope-config.js"; import { stableStringify } from "../agents/stable-stringify.js"; import { applyClawAddPlan, @@ -215,9 +219,7 @@ export async function runClawsAddCommand( const existingWorkspacePaths = existingAgentIds.map((agentId) => resolveAgentWorkspaceDir(config, agentId), ); - const cronStore = await loadCronJobsStoreWithConfigJobsReadOnly( - resolveCronJobsStorePath(config.cron?.store), - ); + const cronStore = await loadCronJobsStoreWithConfigJobsReadOnly(resolveCronJobsStorePath()); const basePlanContext = { ...(opts.agentId ? { agentId: opts.agentId } : {}), ...(opts.workspace ? { workspace: opts.workspace } : {}), @@ -237,7 +239,7 @@ export async function runClawsAddCommand( if (resumeRecord && plan.blockers.length > 0) { const canResumeWorkspace = resumeRecord.status === "workspace_ready" || resumeRecord.status === "config_committed"; - const committedAgent = config.agents?.list?.find( + const committedAgent = listAgentEntries(config).find( (agent) => stableStringify(agent) === stableStringify(plan.agent.config), ); const canResumeAgent = diff --git a/src/cli/claws-cli.test.ts b/src/cli/claws-cli.test.ts index 5859e8f64bbf..34f139ad1558 100644 --- a/src/cli/claws-cli.test.ts +++ b/src/cli/claws-cli.test.ts @@ -1,6 +1,6 @@ // Tests for the experimental grouped Claws CLI. -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { mkdir, realpath, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; @@ -107,6 +107,10 @@ async function writePackage(): Promise<{ root: string; workspace: string }> { return { root, workspace: join(root, "target-workspace") }; } +async function canonicalFuturePath(target: string): Promise { + return join(await realpath(dirname(target)), basename(target)); +} + async function runCli(args: string[]) { const program = new Command(); program.exitOverride(); @@ -225,6 +229,7 @@ describe("claws cli", () => { it("takes identity from package.json and plans one new agent", async () => { const { root, workspace } = await writePackage(); + const expectedWorkspace = await canonicalFuturePath(workspace); await runCli(["claws", "add", root, "--dry-run", "--workspace", workspace, "--json"]); @@ -232,7 +237,7 @@ describe("claws cli", () => { schemaVersion: "openclaw.clawAddPlan.v1", stability: "experimental", claw: { kind: "package", name: "@acme/demo-agent", version: "1.2.3" }, - agent: { finalId: "demo-agent", workspace }, + agent: { finalId: "demo-agent", workspace: expectedWorkspace }, summary: { agentActions: 1, workspaceActions: 2, packageActions: 1, blockedActions: 1 }, }); expect(mocks.runtime.exit).toHaveBeenCalledWith(1); @@ -240,7 +245,7 @@ describe("claws cli", () => { it("blocks adding into an existing agent instead of merging", async () => { const { root, workspace } = await writePackage(); - mocks.loadConfig.mockReturnValue({ agents: { list: [{ id: "demo-agent" }] } }); + mocks.loadConfig.mockReturnValue({ agents: { entries: { "demo-agent": {} } } }); await runCli(["claws", "add", root, "--dry-run", "--workspace", workspace, "--json"]); @@ -253,7 +258,7 @@ describe("claws cli", () => { it("honors an explicit unused agent id in the plan", async () => { const { root, workspace } = await writePackage(); - mocks.loadConfig.mockReturnValue({ agents: { list: [{ id: "demo-agent" }] } }); + mocks.loadConfig.mockReturnValue({ agents: { entries: { "demo-agent": {} } } }); await runCli([ "claws", @@ -299,6 +304,7 @@ describe("claws cli", () => { it("applies a minimal Claw only after explicit consent", async () => { const manifestPath = await writeManifest(); const workspace = join(tempDirs.make("openclaw-claws-add-"), "workspace"); + const expectedWorkspace = await canonicalFuturePath(workspace); await runCli(["claws", "add", manifestPath, "--dry-run", "--workspace", workspace, "--json"]); const plan = JSON.parse(mocks.logs[0] ?? "{}"); mocks.logs.length = 0; @@ -323,7 +329,7 @@ describe("claws cli", () => { schemaVersion: "openclaw.clawAddResult.v1", stability: "experimental", status: "complete", - agent: { finalId: "demo-agent", workspace }, + agent: { finalId: "demo-agent", workspace: expectedWorkspace }, }); }); diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index e7251050d6d5..d9ccb0f2802d 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -270,30 +270,31 @@ describe("resolveCommandSecretRefsViaGateway", () => { it("skips gateway resolution when all configured target refs are inactive", async () => { const config = { agents: { - list: [ - { - id: "main", - memorySearch: { - enabled: false, - remote: { - apiKey: { source: "env", provider: "default", id: "AGENT_MEMORY_API_KEY" }, + entries: { + main: { + memory: { + search: { + enabled: false, + remote: { + apiKey: { source: "env", provider: "default", id: "AGENT_MEMORY_API_KEY" }, + }, }, }, }, - ], + }, }, } as unknown as OpenClawConfig; const result = await resolveCommandSecretRefsViaGateway({ config, commandName: "status", - targetIds: new Set(["agents.list[].memorySearch.remote.apiKey"]), + targetIds: new Set(["agents.entries.*.memory.search.remote.apiKey"]), }); expect(callGateway).not.toHaveBeenCalled(); expect(result.resolvedConfig).toEqual(config); expect(result.diagnostics).toEqual([ - "agents.list.0.memorySearch.remote.apiKey: agent or memorySearch override is disabled.", + "agents.entries.main.memory.search.remote.apiKey: agent or memorySearch override is disabled.", ]); }); @@ -1158,31 +1159,32 @@ describe("resolveCommandSecretRefsViaGateway", () => { callGateway.mockResolvedValueOnce({ assignments: [], diagnostics: ["memory search ref inactive"], - inactiveRefPaths: ["agents.list.0.memorySearch.remote.apiKey"], + inactiveRefPaths: ["agents.entries.main.memory.search.remote.apiKey"], }); const config = { agents: { - list: [ - { - id: "main", - memorySearch: { - remote: { - apiKey: { source: "env", provider: "default", id: "MISSING_MEMORY_API_KEY" }, + entries: { + main: { + memory: { + search: { + remote: { + apiKey: { source: "env", provider: "default", id: "MISSING_MEMORY_API_KEY" }, + }, }, }, }, - ], + }, }, } as unknown as OpenClawConfig; const result = await resolveCommandSecretRefsViaGateway({ config, commandName: "memory status", - targetIds: new Set(["agents.list[].memorySearch.remote.apiKey"]), + targetIds: new Set(["agents.entries.*.memory.search.remote.apiKey"]), }); - expect(result.resolvedConfig.agents?.list?.[0]?.memorySearch?.remote?.apiKey).toEqual({ + expect(result.resolvedConfig.agents?.entries?.main?.memory?.search?.remote?.apiKey).toEqual({ source: "env", provider: "default", id: "MISSING_MEMORY_API_KEY", diff --git a/src/cli/command-secret-targets.import.test.ts b/src/cli/command-secret-targets.import.test.ts index 30a026882f0e..7b9c45dcd8d6 100644 --- a/src/cli/command-secret-targets.import.test.ts +++ b/src/cli/command-secret-targets.import.test.ts @@ -61,7 +61,7 @@ describe("command secret targets module import", () => { expect(listSecretTargetRegistryEntries).not.toHaveBeenCalled(); const ids = mod.getAgentRuntimeCommandSecretTargetIds(); - expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true); + expect(ids.has("memory.search.remote.apiKey")).toBe(true); expect(ids.has("plugins.entries.example.config.webSearch.apiKey")).toBe(true); expect(ids.has("plugins.entries.example.config.other.apiKey")).toBe(false); expect(ids.has("channels.telegram.botToken")).toBe(false); @@ -170,7 +170,7 @@ describe("command secret targets module import", () => { expect(targets.has("gateway.auth.password")).toBe(true); expect(targets.has("gateway.remote.token")).toBe(true); expect(targets.has("gateway.remote.password")).toBe(true); - expect(targets.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true); + expect(targets.has("memory.search.remote.apiKey")).toBe(true); const pluginCall = listReadOnlyChannelPluginsForConfig.mock.calls[0] as unknown as | [unknown, { includePersistedAuthState?: boolean }] | undefined; @@ -206,7 +206,7 @@ describe("command secret targets module import", () => { { includeChannelTargets: false }, ); - expect(targets.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true); + expect(targets.has("memory.search.remote.apiKey")).toBe(true); expect(targets.has("gateway.auth.token")).toBe(true); expect(targets.has("gateway.auth.password")).toBe(true); expect(targets.has("gateway.remote.token")).toBe(true); diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index 490c6cd7862b..2119f3ee6fb8 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from "vitest"; const REGISTRY_IDS = [ - "agents.defaults.memorySearch.remote.apiKey", - "agents.list[].memorySearch.remote.apiKey", + "memory.search.remote.apiKey", + "agents.entries.*.memory.search.remote.apiKey", "channels.discord.token", "channels.discord.accounts.*.token", "channels.telegram.botToken", @@ -12,7 +12,7 @@ const REGISTRY_IDS = [ "gateway.remote.token", "gateway.remote.password", "models.providers.*.apiKey", - "messages.tts.providers.openai.apiKey", + "tts.providers.openai.apiKey", "plugins.entries.voice-call.config.twilio.authToken", "plugins.entries.firecrawl.config.webFetch.apiKey", "plugins.entries.firecrawl.config.webSearch.apiKey", @@ -264,8 +264,8 @@ describe("command secret target ids", () => { it("includes memorySearch remote targets for agent runtime commands", () => { const ids = getAgentRuntimeCommandSecretTargetIds(); - expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true); - expect(ids.has("agents.list[].memorySearch.remote.apiKey")).toBe(true); + expect(ids.has("memory.search.remote.apiKey")).toBe(true); + expect(ids.has("agents.entries.*.memory.search.remote.apiKey")).toBe(true); expect(ids.has("plugins.entries.firecrawl.config.webFetch.apiKey")).toBe(true); expect(ids.has("plugins.entries.exa.config.webSearch.apiKey")).toBe(true); expect(ids.has("channels.discord.token")).toBe(false); @@ -280,8 +280,8 @@ describe("command secret target ids", () => { expect(ids.has("gateway.auth.password")).toBe(true); expect(ids.has("gateway.remote.token")).toBe(true); expect(ids.has("gateway.remote.password")).toBe(true); - expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true); - expect(ids.has("agents.list[].memorySearch.remote.apiKey")).toBe(true); + expect(ids.has("memory.search.remote.apiKey")).toBe(true); + expect(ids.has("agents.entries.*.memory.search.remote.apiKey")).toBe(true); expect(ids.has("channels.discord.token")).toBe(false); }); @@ -291,8 +291,8 @@ describe("command secret target ids", () => { expect(ids.has("plugins.entries.firecrawl.config.webFetch.apiKey")).toBe(false); expect(ids.has("plugins.entries.voice-call.config.twilio.authToken")).toBe(false); expect(ids.has("models.providers.openai.apiKey")).toBe(false); - expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(false); - expect(ids.has("messages.tts.providers.openai.apiKey")).toBe(false); + expect(ids.has("memory.search.remote.apiKey")).toBe(false); + expect(ids.has("tts.providers.openai.apiKey")).toBe(false); expect(ids.has("skills.entries.demo.apiKey")).toBe(false); expect(ids.has("channels.discord.token")).toBe(false); }); @@ -303,8 +303,8 @@ describe("command secret target ids", () => { expect(ids.has("plugins.entries.firecrawl.config.webFetch.apiKey")).toBe(true); expect(ids.has("plugins.entries.voice-call.config.twilio.authToken")).toBe(false); expect(ids.has("models.providers.openai.apiKey")).toBe(false); - expect(ids.has("agents.defaults.memorySearch.remote.apiKey")).toBe(false); - expect(ids.has("messages.tts.providers.openai.apiKey")).toBe(false); + expect(ids.has("memory.search.remote.apiKey")).toBe(false); + expect(ids.has("tts.providers.openai.apiKey")).toBe(false); expect(ids.has("skills.entries.demo.apiKey")).toBe(false); expect(ids.has("channels.discord.token")).toBe(false); }); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index 55640827d447..4e0ac561bef2 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -38,21 +38,21 @@ const STATIC_MODEL_TARGET_IDS = [ ] as const; const STATIC_AGENT_RUNTIME_BASE_TARGET_IDS = [ ...STATIC_MODEL_TARGET_IDS, - "agents.defaults.memorySearch.remote.apiKey", - "agents.list[].memorySearch.remote.apiKey", - "agents.list[].tts.providers.*.apiKey", - "messages.tts.providers.*.apiKey", + "memory.search.remote.apiKey", + "agents.entries.*.memory.search.remote.apiKey", + "agents.entries.*.tts.providers.*.apiKey", + "tts.providers.*.apiKey", "skills.entries.*.apiKey", ] as const; const STATIC_MEMORY_EMBEDDING_TARGET_IDS = [ ...STATIC_MODEL_TARGET_IDS, - "agents.defaults.memorySearch.remote.apiKey", - "agents.list[].memorySearch.remote.apiKey", + "memory.search.remote.apiKey", + "agents.entries.*.memory.search.remote.apiKey", ] as const; const STATIC_TTS_TARGET_IDS = [ ...STATIC_MODEL_TARGET_IDS, - "agents.list[].tts.providers.*.apiKey", - "messages.tts.providers.*.apiKey", + "agents.entries.*.tts.providers.*.apiKey", + "tts.providers.*.apiKey", ] as const; const STATIC_GATEWAY_AUTH_TARGET_IDS = [ "gateway.auth.token", @@ -62,8 +62,8 @@ const STATIC_GATEWAY_AUTH_TARGET_IDS = [ ] as const; const STATIC_STATUS_TARGET_IDS = [ ...STATIC_GATEWAY_AUTH_TARGET_IDS, - "agents.defaults.memorySearch.remote.apiKey", - "agents.list[].memorySearch.remote.apiKey", + "memory.search.remote.apiKey", + "agents.entries.*.memory.search.remote.apiKey", ] as const; const STATIC_SECURITY_AUDIT_TARGET_IDS = [...STATIC_GATEWAY_AUTH_TARGET_IDS] as const; diff --git a/src/cli/config-cli.integration.test.ts b/src/cli/config-cli.integration.test.ts index 4c51878ac9e7..b0516ccd7393 100644 --- a/src/cli/config-cli.integration.test.ts +++ b/src/cli/config-cli.integration.test.ts @@ -32,18 +32,20 @@ function createExecDryRunBatch(params: { markerPath: string }) { }, }); const script = [ + `#!${process.execPath}`, 'const fs = require("node:fs");', `fs.writeFileSync(${JSON.stringify(params.markerPath)}, "dryrun\\n", "utf8");`, `process.stdout.write(${JSON.stringify(response)});`, - ].join(""); + ].join("\n"); + const scriptPath = path.join(path.dirname(params.markerPath), "exec-provider.cjs"); + fs.writeFileSync(scriptPath, script, { mode: 0o700 }); return [ { path: "secrets.providers.runner", provider: { source: "exec", - command: process.execPath, - args: ["-e", script], - allowInsecurePath: true, + command: scriptPath, + trustedDirs: [path.dirname(scriptPath)], timeoutMs: 60_000, noOutputTimeoutMs: 60_000, }, diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 413e870c3c6d..50c44273c977 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -143,7 +143,7 @@ function buildSnapshot(params: { } function setSnapshot(resolved: OpenClawConfig, config: OpenClawConfig) { - mockReadConfigFileSnapshot.mockResolvedValueOnce(buildSnapshot({ resolved, config })); + mockReadConfigFileSnapshot.mockResolvedValue(buildSnapshot({ resolved, config })); } function setSnapshotOnce(snapshot: ConfigFileSnapshot) { @@ -466,6 +466,8 @@ describe("config cli", () => { beforeEach(() => { vi.clearAllMocks(); + mockReadConfigFileSnapshot.mockReset(); + mockReadConfigFileSnapshot.mockResolvedValue(buildSnapshot({ resolved: {}, config: {} })); resetRuntimeCapture(); mockLoadPluginMetadataSnapshot.mockReturnValue(createPluginMetadataSnapshot()); mockReadBestEffortRuntimeConfigSchema.mockResolvedValue({ @@ -934,32 +936,6 @@ describe("config cli", () => { expectErrorIncludes("auto-managed"); }); - it("rejects auto-managed meta.lastTouchedAt config updates (#80849)", async () => { - await expect( - runConfigCommand([ - "config", - "set", - "meta.lastTouchedAt", - "1999-01-01T00:00:00.000Z", - "--dry-run", - ]), - ).rejects.toThrow(ExitError); - - expect(mockWriteConfigFile).not.toHaveBeenCalled(); - expectErrorIncludes("meta.lastTouchedAt"); - expectErrorIncludes("auto-managed"); - }); - - it("rejects auto-managed meta paths via config unset (#80849)", async () => { - await expect(runConfigCommand(["config", "unset", "meta.lastTouchedAt"])).rejects.toThrow( - ExitError, - ); - - expect(mockWriteConfigFile).not.toHaveBeenCalled(); - expectErrorIncludes("meta.lastTouchedAt"); - expectErrorIncludes("auto-managed"); - }); - it("rejects parent meta path mutations when payload merges an auto-managed child (#80849)", async () => { await expect( runConfigCommand([ @@ -984,7 +960,7 @@ describe("config cli", () => { "config", "set", "meta", - '{"lastTouchedAt":"1999-01-01T00:00:00.000Z"}', + '{"lastTouchedVersion":"BOGUS-NOT-A-VERSION"}', "--strict-json", "--replace", "--dry-run", @@ -992,7 +968,7 @@ describe("config cli", () => { ).rejects.toThrow(ExitError); expect(mockWriteConfigFile).not.toHaveBeenCalled(); - expectErrorIncludes("meta.lastTouchedAt"); + expectErrorIncludes("meta.lastTouchedVersion"); expectErrorIncludes("auto-managed"); }); @@ -1776,7 +1752,6 @@ describe("config cli", () => { "/tmp/vault.json", "--provider-mode", "json", - "--provider-allow-insecure-path", ]); expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); @@ -1785,7 +1760,6 @@ describe("config cli", () => { source: "file", path: "/tmp/vault.json", mode: "json", - allowInsecurePath: true, }); }); @@ -1931,14 +1905,14 @@ describe("config cli", () => { "config", "set", "--batch-json", - '[{"path":"hooks.token","ref":{"source":"env","provider":"default","id":"HOOK_TOKEN"}},{"path":"commands.ownerDisplaySecret","ref":{"source":"env","provider":"default","id":"OWNER_DISPLAY_SECRET"}}]', + '[{"path":"hooks.token","ref":{"source":"env","provider":"default","id":"HOOK_TOKEN"}},{"path":"hooks.gmail.pushToken","ref":{"source":"env","provider":"default","id":"GMAIL_PUSH_TOKEN"}}]', "--dry-run", ]), ).rejects.toThrow(ExitError); expect(mockWriteConfigFile).not.toHaveBeenCalled(); expectErrorIncludes("hooks.token"); - expectErrorIncludes("commands.ownerDisplaySecret"); + expectErrorIncludes("hooks.gmail.pushToken"); }); it("does not duplicate policy errors in --dry-run --json mode for parent-object writes", async () => { @@ -2019,7 +1993,6 @@ describe("config cli", () => { runner: { source: "exec", command: "/usr/bin/env", - allowInsecurePath: true, }, }, }, @@ -2054,7 +2027,6 @@ describe("config cli", () => { runner: { source: "exec", command: "/usr/bin/env", - allowInsecurePath: true, }, }, }, @@ -2160,37 +2132,31 @@ describe("config cli", () => { expectErrorIncludes('Secret provider "runner" has source "env" but ref requests "exec".'); }); - it("writes sibling SecretRef paths when target uses sibling-ref shape", async () => { + it("writes inline SecretRef paths when target uses secret-input shape", async () => { const resolved: OpenClawConfig = { - gateway: { port: 18789 }, - channels: { - googlechat: { - enabled: true, - } as never, - } as never, + gateway: { port: 18789, auth: { mode: "token" } }, }; setSnapshot(resolved, resolved); await runConfigCommand([ "config", "set", - "channels.googlechat.serviceAccount", + "gateway.auth.token", "--ref-provider", "vaultfile", "--ref-source", "file", "--ref-id", - "/providers/googlechat/serviceAccount", + "/gateway/auth/token", ]); expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); const written = firstWrittenConfig(); - expect(written.channels?.googlechat?.serviceAccountRef).toEqual({ + expect(written.gateway?.auth?.token).toEqual({ source: "file", provider: "vaultfile", - id: "/providers/googlechat/serviceAccount", + id: "/gateway/auth/token", }); - expect(written.channels?.googlechat?.serviceAccount).toBeUndefined(); }); it("rejects mixing ref-builder and provider-builder flags", async () => { @@ -2280,9 +2246,9 @@ describe("config cli", () => { fs.writeFileSync( pathname, JSON.stringify([ - { path: "agents.defaults.memorySearch.enabled", value: true }, - { path: "agents.defaults.memorySearch.provider", value: "gemini" }, - { path: "agents.defaults.memorySearch.sources", value: ["memory"] }, + { path: "memory.search.enabled", value: true }, + { path: "memory.search.provider", value: "gemini" }, + { path: "memory.search.sources", value: ["memory"] }, ]), "utf8", ); @@ -2296,7 +2262,7 @@ describe("config cli", () => { const written = firstWrittenConfig(); expect(written.agents?.defaults?.models).toEqual(resolved.agents?.defaults?.models); expect(written.agents?.defaults?.model).toEqual(resolved.agents?.defaults?.model); - expect(written.agents?.defaults?.memorySearch).toEqual({ + expect(written.memory?.search).toEqual({ enabled: true, provider: "gemini", sources: ["memory"], @@ -3079,7 +3045,6 @@ describe("config cli", () => { runner: { source: "exec", command: "/usr/bin/env", - allowInsecurePath: true, }, }, }, @@ -3496,7 +3461,7 @@ describe("config cli", () => { describe("config unset - issue #6070", () => { it("preserves existing config keys when unsetting a value", async () => { const resolved: OpenClawConfig = { - agents: { list: [{ id: "main" }] }, + agents: { entries: { main: {} } }, gateway: { port: 18789 }, tools: { profile: "coding", @@ -3574,7 +3539,7 @@ describe("config cli", () => { it("dry-runs an unset without writing the config file", async () => { const resolved: OpenClawConfig = { - agents: { list: [{ id: "main" }] }, + agents: { entries: { main: {} } }, gateway: { port: 18789 }, tools: { profile: "coding", @@ -3674,7 +3639,7 @@ describe("config cli", () => { it("prints JSON for config unset dry-run", async () => { const resolved: OpenClawConfig = { - agents: { list: [{ id: "main" }] }, + agents: { entries: { main: {} } }, gateway: { port: 18789 }, tools: { profile: "coding", @@ -3953,7 +3918,7 @@ describe("config cli", () => { expectLogExcludes("Change will apply without restarting the gateway."); }); - it("keeps the restart hint for hot-path edits when reload mode is restart", async () => { + it("normalizes legacy restart mode to hot apply semantics", async () => { const resolved: OpenClawConfig = { agents: { list: [{ id: "main", model: { primary: "openai/gpt-5.4" } }], @@ -3973,8 +3938,8 @@ describe("config cli", () => { ]); expectLogIncludes("Updated agents.list.0.model.primary"); - expectLogIncludes("Restart the gateway to apply."); - expectLogExcludes("Change will apply without restarting the gateway."); + expectLogIncludes("Change will apply without restarting the gateway."); + expectLogExcludes("Restart the gateway to apply."); }); it("prints a hot-reload hint when removing legacy per-agent agentRuntime", async () => { diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index a96eef0e951b..ee5247132ded 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -1059,7 +1059,7 @@ function configApplyHintForPaths(paths: string[], afterConfig: OpenClawConfig): } if (plan.hotReasons.length > 0) { const { mode } = resolveGatewayReloadSettings(afterConfig); - if (mode === "off" || mode === "restart") { + if (mode === "off") { return RESTART_HINT; } return HOT_RELOAD_HINT; diff --git a/src/cli/config-model-validation.test.ts b/src/cli/config-model-validation.test.ts index 229134f81d89..8f0a71ba16f6 100644 --- a/src/cli/config-model-validation.test.ts +++ b/src/cli/config-model-validation.test.ts @@ -7,7 +7,6 @@ type ResolverInput = { ref: { path: string; value: string; - agentIndex?: number; agentId?: string; fallback: boolean; authProfileId?: string; @@ -81,7 +80,7 @@ describe("config model validation", () => { fallbacks: ["anthropic/claude-sonnet-4-6"], }, }, - list: [{ id: "main", default: true }, { id: "ops" }], + entries: { main: { default: true }, ops: {} }, }, }, touchedPaths: [["agents", "defaults", "model"]], @@ -109,7 +108,10 @@ describe("config model validation", () => { config: { agents: { defaults: { model: { primary: "provider-a/default" } }, - list: [{ id: "main", default: true, model: "provider-b/override" }, { id: "ops" }], + entries: { + main: { default: true, model: "provider-b/override" }, + ops: {}, + }, }, }, touchedPaths: [["agents", "defaults", "model", "primary"]], @@ -122,7 +124,6 @@ describe("config model validation", () => { ref: { path: "agents.defaults.model.primary", value: "provider-a/default", - agentIndex: 1, agentId: "ops", fallback: false, }, @@ -431,21 +432,21 @@ describe("config model validation", () => { }); }); - it.each([{ agents: { list: {} } }, { agents: { list: [null] } }])( - "ignores schema-invalid agent-list draft values", - async (config) => { - const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); + it.each([ + { agents: { entries: [{ model: "missing/model" }] } }, + { agents: { entries: { bad: null } } }, + ])("ignores schema-invalid agent-entry draft values", async (config) => { + const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); - const result = await checkTouchedTextModelRefs({ - config: config as unknown as OpenClawConfig, - touchedPaths: [["agents", "list"]], - resolveModelRef, - }); + const result = await checkTouchedTextModelRefs({ + config: config as unknown as OpenClawConfig, + touchedPaths: [["agents", "entries"]], + resolveModelRef, + }); - expect(result).toEqual({ refsChecked: 0, refsTotal: 0, errors: [] }); - expect(resolveModelRef).not.toHaveBeenCalled(); - }, - ); + expect(result).toEqual({ refsChecked: 0, refsTotal: 0, errors: [] }); + expect(resolveModelRef).not.toHaveBeenCalled(); + }); it("rejects an unresolved default fallback", async () => { const resolveModelRef = vi.fn(async () => "Unknown model: missing/fallback"); @@ -507,16 +508,15 @@ describe("config model validation", () => { fallbacks: ["backup", "provider-a/qualified-backup"], }, }, - list: [ - { id: "main", default: true }, - { - id: "ops", + entries: { + main: { default: true }, + ops: { model: { primary: "provider-c/main", fallbacks: ["agent-backup", "provider-c/qualified-agent-backup"], }, }, - ], + }, }, }; @@ -543,7 +543,7 @@ describe("config model validation", () => { expect(resolveModelRef.mock.calls.map(([call]) => call.ref.path)).toEqual([ "agents.defaults.model.primary", "agents.defaults.model.fallbacks.0", - "agents.list.1.model.fallbacks.0", + "agents.entries.ops.model.fallbacks.0", ]); }); @@ -597,7 +597,7 @@ describe("config model validation", () => { fallbacks: ["backup"], }, }, - list: [{ id: "ops", model: { fallbacks: ["agent-backup"] } }], + entries: { ops: { model: { fallbacks: ["agent-backup"] } } }, }, }; @@ -636,10 +636,10 @@ describe("config model validation", () => { fallbacks: ["anthropic/claude-sonnet-4-6"], }, }, - list: [ - { id: "main", default: true }, - { id: "ops", model: { primary: "google/gemini-3.1-pro-preview" } }, - ], + entries: { + main: { default: true }, + ops: { model: { primary: "google/gemini-3.1-pro-preview" } }, + }, }, }; @@ -647,7 +647,7 @@ describe("config model validation", () => { config, touchedPaths: [ ["agents", "defaults", "model", "fallbacks"], - ["agents", "list", "1", "model", "primary"], + ["agents", "entries", "ops", "model", "primary"], ], resolveModelRef, }); @@ -660,9 +660,8 @@ describe("config model validation", () => { fallback: true, }, { - path: "agents.list.1.model.primary", + path: "agents.entries.ops.model.primary", value: "google/gemini-3.1-pro-preview", - agentIndex: 1, agentId: "ops", fallback: false, }, @@ -677,11 +676,11 @@ describe("config model validation", () => { agents: { defaults: { model: { primary: "openai/gpt-5.4-mini" }, - videoGenerationModel: { primary: "qwen/wan2.6-t2v" }, + mediaModels: { video: { primary: "qwen/wan2.6-t2v" } }, }, }, }, - touchedPaths: [["agents", "defaults", "videoGenerationModel", "primary"]], + touchedPaths: [["agents", "defaults", "mediaModels", "video", "primary"]], resolveModelRef, }); @@ -713,27 +712,27 @@ describe("config model validation", () => { expect(resolveModelRef).not.toHaveBeenCalled(); }); - it("revalidates per-agent refs when list ownership changes", async () => { + it("revalidates per-agent refs when entry model ownership changes", async () => { const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); const result = await checkTouchedTextModelRefs({ config: { agents: { - list: [ - { id: "beta", model: "provider-a/model" }, - { id: "alpha", model: "provider-b/model" }, - ], + entries: { + beta: { model: "provider-a/model" }, + alpha: { model: "provider-b/model" }, + }, }, }, previousConfig: { agents: { - list: [ - { id: "alpha", model: "provider-a/model" }, - { id: "beta", model: "provider-b/model" }, - ], + entries: { + alpha: { model: "provider-a/model" }, + beta: { model: "provider-b/model" }, + }, }, }, - touchedPaths: [["agents", "list"]], + touchedPaths: [["agents", "entries"]], resolveModelRef, }); @@ -741,22 +740,22 @@ describe("config model validation", () => { expect(resolveModelRef.mock.calls.map(([call]) => call.ref.agentId)).toEqual(["beta", "alpha"]); }); - it("does not revalidate a retained agent model after an earlier entry is removed", async () => { + it("does not revalidate a retained agent model when another entry is removed", async () => { const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); const result = await checkTouchedTextModelRefs({ config: { - agents: { list: [{ id: "beta", model: "provider-b/model" }] }, + agents: { entries: { beta: { model: "provider-b/model" } } }, }, previousConfig: { agents: { - list: [ - { id: "alpha", model: "provider-a/model" }, - { id: "beta", model: "provider-b/model" }, - ], + entries: { + alpha: { model: "provider-a/model" }, + beta: { model: "provider-b/model" }, + }, }, }, - touchedPaths: [["agents", "list", "0"]], + touchedPaths: [["agents", "entries", "alpha"]], resolveModelRef, }); @@ -764,17 +763,17 @@ describe("config model validation", () => { expect(resolveModelRef).not.toHaveBeenCalled(); }); - it("revalidates a per-agent model when its agent id changes directly", async () => { + it("revalidates a per-agent model when its entry key changes", async () => { const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); const result = await checkTouchedTextModelRefs({ config: { - agents: { list: [{ id: "next", model: "provider-a/model" }] }, + agents: { entries: { next: { model: "provider-a/model" } } }, }, previousConfig: { - agents: { list: [{ id: "current", model: "provider-a/model" }] }, + agents: { entries: { current: { model: "provider-a/model" } } }, }, - touchedPaths: [["agents", "list", "0", "id"]], + touchedPaths: [["agents", "entries"]], resolveModelRef, }); @@ -782,9 +781,8 @@ describe("config model validation", () => { expect(resolveModelRef).toHaveBeenCalledWith({ config: expect.any(Object), ref: { - path: "agents.list.0.model", + path: "agents.entries.next.model", value: "provider-a/model", - agentIndex: 0, agentId: "next", fallback: false, dependency: true, @@ -804,7 +802,7 @@ describe("config model validation", () => { fallbacks: ["provider-a/backup"], }, }, - list: [{ id: "ops" }], + entries: { ops: {} }, }, }, previousConfig: { @@ -815,10 +813,10 @@ describe("config model validation", () => { fallbacks: ["provider-a/backup"], }, }, - list: [{ id: "ops", model: "provider-b/override" }], + entries: { ops: { model: "provider-b/override" } }, }, }, - touchedPaths: [["agents", "list", "0", "model"]], + touchedPaths: [["agents", "entries", "ops", "model"]], resolveModelRef, }); @@ -827,7 +825,6 @@ describe("config model validation", () => { { path: "agents.defaults.model.primary", value: "provider-a/default", - agentIndex: 0, agentId: "ops", fallback: false, dependency: true, @@ -835,7 +832,6 @@ describe("config model validation", () => { { path: "agents.defaults.model.fallbacks.0", value: "provider-a/backup", - agentIndex: 0, agentId: "ops", fallback: true, dependency: true, @@ -843,7 +839,7 @@ describe("config model validation", () => { ]); }); - it("validates inherited defaults when an agent is created through its id path", async () => { + it("validates inherited defaults when a leaf write creates an agent entry", async () => { const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined); const result = await checkTouchedTextModelRefs({ @@ -855,7 +851,7 @@ describe("config model validation", () => { fallbacks: ["provider-a/backup"], }, }, - list: [{ id: "ops" }], + entries: { ops: { workspace: "/tmp/ops" } }, }, }, previousConfig: { @@ -868,7 +864,7 @@ describe("config model validation", () => { }, }, }, - touchedPaths: [["agents", "list", "0", "id"]], + touchedPaths: [["agents", "entries", "ops", "workspace"]], resolveModelRef, }); @@ -888,7 +884,7 @@ describe("config model validation", () => { fallbacks: ["provider-a/backup"], }, }, - list: [], + entries: {}, }, }, previousConfig: { @@ -899,10 +895,10 @@ describe("config model validation", () => { fallbacks: ["provider-a/backup"], }, }, - list: [{ id: "ops", default: true, model: "provider-b/override" }], + entries: { ops: { default: true, model: "provider-b/override" } }, }, }, - touchedPaths: [["agents", "list", "0"]], + touchedPaths: [["agents", "entries", "ops"]], resolveModelRef, }); @@ -920,16 +916,16 @@ describe("config model validation", () => { config: { agents: { defaults: { model: { primary: "provider-a/default" } }, - list: [{ id: "ops", model: { fallbacks: ["provider-b/next"] } }], + entries: { ops: { model: { fallbacks: ["provider-b/next"] } } }, }, }, previousConfig: { agents: { defaults: { model: { primary: "provider-a/default" } }, - list: [{ id: "ops", model: { fallbacks: ["provider-b/current"] } }], + entries: { ops: { model: { fallbacks: ["provider-b/current"] } } }, }, }, - touchedPaths: [["agents", "list", "0", "model", "fallbacks"]], + touchedPaths: [["agents", "entries", "ops", "model", "fallbacks"]], resolveModelRef, }); @@ -937,9 +933,8 @@ describe("config model validation", () => { expect(resolveModelRef).toHaveBeenCalledWith({ config: expect.any(Object), ref: { - path: "agents.list.0.model.fallbacks.0", + path: "agents.entries.ops.model.fallbacks.0", value: "provider-b/next", - agentIndex: 0, agentId: "ops", fallback: true, }, diff --git a/src/cli/config-model-validation.ts b/src/cli/config-model-validation.ts index 227dfbdd4cd8..4f438a0574fd 100644 --- a/src/cli/config-model-validation.ts +++ b/src/cli/config-model-validation.ts @@ -21,7 +21,6 @@ import { formatCliCommand } from "./command-format.js"; type TouchedModelRef = { path: string; value: string; - agentIndex?: number; agentId?: string; fallback: boolean; authProfileId?: string; @@ -39,6 +38,15 @@ type ConfigModelRefCheckResult = { errors: string[]; }; +type AgentEntriesConfig = NonNullable["entries"]>; + +function resolveAgentEntries(config: OpenClawConfig): AgentEntriesConfig | undefined { + const entries: unknown = config.agents?.entries; + return entries && typeof entries === "object" && !Array.isArray(entries) + ? (entries as AgentEntriesConfig) + : undefined; +} + function isPathPrefix(prefix: readonly string[], path: readonly string[]): boolean { return prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment); } @@ -46,7 +54,6 @@ function isPathPrefix(prefix: readonly string[], path: readonly string[]): boole function collectTextModelConfigRefs(params: { model: unknown; path: string; - agentIndex?: number; agentId?: string; }): TouchedModelRef[] { if (typeof params.model === "string") { @@ -55,7 +62,6 @@ function collectTextModelConfigRefs(params: { { path: params.path, value, - ...(params.agentIndex === undefined ? {} : { agentIndex: params.agentIndex }), ...(params.agentId ? { agentId: params.agentId } : {}), fallback: false, }, @@ -71,7 +77,6 @@ function collectTextModelConfigRefs(params: { refs.push({ path: `${params.path}.primary`, value, - ...(params.agentIndex === undefined ? {} : { agentIndex: params.agentIndex }), ...(params.agentId ? { agentId: params.agentId } : {}), fallback: false, }); @@ -84,7 +89,6 @@ function collectTextModelConfigRefs(params: { refs.push({ path: `${params.path}.fallbacks.${index}`, value: fallback.trim(), - ...(params.agentIndex === undefined ? {} : { agentIndex: params.agentIndex }), ...(params.agentId ? { agentId: params.agentId } : {}), fallback: true, }); @@ -98,18 +102,17 @@ function collectTextModelRefs(config: OpenClawConfig): TouchedModelRef[] { model: config.agents?.defaults?.model, path: "agents.defaults.model", }); - const agentList = config.agents?.list; - if (Array.isArray(agentList)) { - for (const [agentIndex, agent] of agentList.entries()) { + const agentEntries = resolveAgentEntries(config); + if (agentEntries) { + for (const [agentId, agent] of Object.entries(agentEntries)) { if (!agent || typeof agent !== "object" || Array.isArray(agent)) { continue; } refs.push( ...collectTextModelConfigRefs({ model: (agent as { model?: unknown }).model, - path: `agents.list.${agentIndex}.model`, - agentIndex, - ...(typeof agent.id === "string" ? { agentId: agent.id } : {}), + path: `agents.entries.${agentId}.model`, + agentId, }), ); } @@ -129,8 +132,8 @@ function collectTextModelRefs(config: OpenClawConfig): TouchedModelRef[] { } function modelRefComparisonKey(ref: TouchedModelRef): string { - if (ref.agentId && ref.agentIndex !== undefined) { - const prefix = `agents.list.${ref.agentIndex}.`; + if (ref.agentId) { + const prefix = `agents.entries.${ref.agentId}.`; const relativePath = ref.path.startsWith(prefix) ? ref.path.slice(prefix.length) : ref.path; return `agent:${normalizeAgentId(ref.agentId)}:${relativePath}`; } @@ -179,15 +182,6 @@ function collectTouchedTextModelRefs(params: { } } const refPath = ref.path.split("."); - const agentIdPath = - ref.agentIndex === undefined ? undefined : ["agents", "list", String(ref.agentIndex), "id"]; - if ( - agentIdPath && - params.touchedPaths.some((touchedPath) => isPathPrefix(agentIdPath, touchedPath)) - ) { - ref.dependency = true; - return true; - } const touched = params.touchedPaths.some( (touchedPath) => isPathPrefix(touchedPath, refPath) || isPathPrefix(refPath, touchedPath), ); @@ -201,18 +195,21 @@ function collectTouchedTextModelRefs(params: { } return previousRef?.value !== ref.value || ownerChanged; }); - const defaultRefs = refs.filter((ref) => ref.agentIndex === undefined); - const agentList = params.config.agents?.list; + const defaultRefs = refs.filter((ref) => ref.agentId === undefined); + const agentEntries = resolveAgentEntries(params.config); if (defaultRefs.length === 0) { return touchedRefs; } - if (!Array.isArray(agentList) || agentList.length === 0) { - const listPath = ["agents", "list"]; - const listTouched = params.touchedPaths.some( - (touchedPath) => isPathPrefix(touchedPath, listPath) || isPathPrefix(listPath, touchedPath), + if (!agentEntries || Object.keys(agentEntries).length === 0) { + const entriesPath = ["agents", "entries"]; + const entriesTouched = params.touchedPaths.some( + (touchedPath) => + isPathPrefix(touchedPath, entriesPath) || isPathPrefix(entriesPath, touchedPath), ); - const previousList = params.previousConfig?.agents?.list; - if (!listTouched || !Array.isArray(previousList) || previousList.length === 0) { + const previousEntries = params.previousConfig + ? resolveAgentEntries(params.previousConfig) + : undefined; + if (!entriesTouched || !previousEntries || Object.keys(previousEntries).length === 0) { return touchedRefs; } const previousDefaultAgentId = resolveDefaultAgentId(params.previousConfig ?? {}); @@ -227,7 +224,7 @@ function collectTouchedTextModelRefs(params: { undefined : false; const alreadySelected = touchedRefs.some( - (ref) => ref.agentIndex === undefined && ref.path === defaultRef.path, + (ref) => ref.agentId === undefined && ref.path === defaultRef.path, ); if (!previouslyInherited && !alreadySelected) { touchedRefs.push({ ...defaultRef, dependency: true }); @@ -235,19 +232,13 @@ function collectTouchedTextModelRefs(params: { } return touchedRefs; } - for (const [agentIndex, agent] of agentList.entries()) { - const agentId = typeof agent?.id === "string" ? agent.id : ""; - if (!agentId) { - continue; - } - const agentEntryPath = ["agents", "list", String(agentIndex)]; - const agentIdPath = [...agentEntryPath, "id"]; + for (const agentId of Object.keys(agentEntries)) { + const agentEntryPath = ["agents", "entries", agentId]; const agentModelPath = [...agentEntryPath, "model"]; const ownershipTouched = params.touchedPaths.some( (touchedPath) => isPathPrefix(touchedPath, agentEntryPath) || - isPathPrefix(touchedPath, agentIdPath) || - isPathPrefix(agentIdPath, touchedPath) || + isPathPrefix(agentEntryPath, touchedPath) || isPathPrefix(touchedPath, agentModelPath) || isPathPrefix(agentModelPath, touchedPath), ); @@ -258,11 +249,9 @@ function collectTouchedTextModelRefs(params: { const inherits = defaultRef.fallback ? resolveAgentModelFallbacksOverride(params.config, agentId) === undefined : resolveAgentExplicitModelPrimary(params.config, agentId) === undefined; - const previousAgentExists = Boolean( - params.previousConfig?.agents?.list?.some( - (entry) => normalizeAgentId(entry?.id) === normalizeAgentId(agentId), - ), - ); + const previousAgentExists = Object.keys( + params.previousConfig ? (resolveAgentEntries(params.previousConfig) ?? {}) : {}, + ).some((entryId) => normalizeAgentId(entryId) === normalizeAgentId(agentId)); const previouslyInherited = previousAgentExists && params.previousConfig ? defaultRef.fallback @@ -270,7 +259,7 @@ function collectTouchedTextModelRefs(params: { : resolveAgentExplicitModelPrimary(params.previousConfig, agentId) === undefined : false; if (inherits && !previouslyInherited) { - touchedRefs.push({ ...defaultRef, agentIndex, agentId, dependency: true }); + touchedRefs.push({ ...defaultRef, agentId, dependency: true }); } } } @@ -344,27 +333,27 @@ function expandInheritedDefaultRefs( config: OpenClawConfig, refs: TouchedModelRef[], ): TouchedModelRef[] { - const agentList = config.agents?.list; - if (!Array.isArray(agentList)) { + const agentEntries = resolveAgentEntries(config); + if (!agentEntries) { return refs; } const defaultAgentId = resolveDefaultAgentId(config); const expanded: TouchedModelRef[] = []; const seen = new Set(); const push = (ref: TouchedModelRef) => { - const key = `${ref.path}\u0000${ref.agentId ?? ""}\u0000${ref.agentIndex ?? ""}`; + const key = `${ref.path}\u0000${ref.agentId ?? ""}`; if (!seen.has(key)) { seen.add(key); expanded.push(ref); } }; for (const ref of refs) { - if (ref.agentIndex !== undefined) { + if (ref.agentId !== undefined) { push(ref); continue; } - const defaultAgentConfigured = agentList.some( - (agent) => normalizeAgentId(agent?.id) === normalizeAgentId(defaultAgentId), + const defaultAgentConfigured = Object.keys(agentEntries).some( + (agentId) => normalizeAgentId(agentId) === normalizeAgentId(defaultAgentId), ); const defaultAgentInherits = !defaultAgentConfigured || @@ -374,16 +363,15 @@ function expandInheritedDefaultRefs( if (defaultAgentInherits) { push(ref); } - for (const [agentIndex, agent] of agentList.entries()) { - const agentId = typeof agent?.id === "string" ? agent.id : ""; - if (!agentId || normalizeAgentId(agentId) === normalizeAgentId(defaultAgentId)) { + for (const agentId of Object.keys(agentEntries)) { + if (normalizeAgentId(agentId) === normalizeAgentId(defaultAgentId)) { continue; } const inherits = ref.fallback ? resolveAgentModelFallbacksOverride(config, agentId) === undefined : resolveAgentExplicitModelPrimary(config, agentId) === undefined; if (inherits) { - push({ ...ref, agentIndex, agentId }); + push({ ...ref, agentId }); } } } @@ -427,12 +415,7 @@ async function createRuntimeModelRefResolver(): Promise ])); return async ({ config, ref }) => { - const configuredAgent = - ref.agentIndex === undefined ? undefined : config.agents?.list?.[ref.agentIndex]; - const targetAgentId = - typeof configuredAgent?.id === "string" - ? configuredAgent.id - : agentScope.resolveDefaultAgentId(config); + const targetAgentId = ref.agentId ?? agentScope.resolveDefaultAgentId(config); const agentDir = agentScope.resolveAgentDir(config, targetAgentId); const workspaceDir = agentScope.resolveAgentWorkspaceDir(config, targetAgentId); const resolvedRef = ref.fallback diff --git a/src/cli/dns-cli.ts b/src/cli/dns-cli.ts index c351d6f759db..ae0d00aa39a8 100644 --- a/src/cli/dns-cli.ts +++ b/src/cli/dns-cli.ts @@ -258,11 +258,11 @@ export function registerDnsCli(program: Command) { inherit: true, }); - if (cfg.discovery?.wideArea?.enabled !== true) { + if (!cfg.discovery?.wideArea?.domain?.trim()) { defaultRuntime.log(""); defaultRuntime.log( theme.muted( - "Note: enable discovery.wideArea.enabled in the active OpenClaw config ($OPENCLAW_CONFIG_PATH, default ~/.openclaw/openclaw.json) on the gateway and restart the gateway so it writes the DNS-SD zone.", + "Note: set discovery.wideArea.domain in the active OpenClaw config ($OPENCLAW_CONFIG_PATH, default ~/.openclaw/openclaw.json) on the gateway and restart the gateway so it writes the DNS-SD zone.", ), ); } diff --git a/src/cli/exec-policy-cli.test.ts b/src/cli/exec-policy-cli.test.ts index 5aba0e9ba49f..0e867c03db6c 100644 --- a/src/cli/exec-policy-cli.test.ts +++ b/src/cli/exec-policy-cli.test.ts @@ -61,8 +61,7 @@ const mocks = vi.hoisted(() => { tools: { exec: { host: "auto", - security: "allowlist", - ask: "on-miss", + mode: "ask", }, }, }; @@ -226,8 +225,7 @@ describe("exec-policy CLI", () => { tools: { exec: { host: "auto", - security: "allowlist", - ask: "on-miss", + mode: "ask", }, }, }); @@ -333,8 +331,7 @@ describe("exec-policy CLI", () => { tools: { exec: { host: "node", - security: "allowlist", - ask: "on-miss", + mode: "ask", }, }, }); @@ -374,8 +371,7 @@ describe("exec-policy CLI", () => { expect(mocks.getConfig().tools?.exec).toEqual({ host: "gateway", - security: "full", - ask: "off", + mode: "full", }); expect(mocks.getApprovals().defaults).toEqual({ security: "full", @@ -405,8 +401,7 @@ describe("exec-policy CLI", () => { expect(mocks.getConfig().tools?.exec).toEqual({ host: "gateway", - security: "full", - ask: "off", + mode: "full", }); expect(mocks.getApprovals().defaults).toEqual({ security: "full", @@ -415,13 +410,28 @@ describe("exec-policy CLI", () => { }); }); + it("derives partial updates from retained legacy config policy", async () => { + mocks.setConfig({ tools: { exec: { security: "deny", ask: "always" } } }); + + await runExecPolicyCommand(["exec-policy", "set", "--ask", "off", "--json"]); + + expect(mocks.getConfig().tools?.exec).toEqual({ mode: "deny" }); + }); + + it("retains nonrepresentable always-ask policy updates", async () => { + mocks.setConfig({ tools: { exec: { mode: "full" } } }); + + await runExecPolicyCommand(["exec-policy", "set", "--ask", "always", "--json"]); + + expect(mocks.getConfig().tools?.exec).toEqual({ security: "full", ask: "always" }); + }); + it("sanitizes terminal control content before rendering the text table", async () => { mocks.setConfig({ tools: { exec: { host: "auto", - security: "allowlist\u001B[31m" as unknown as "allowlist", - ask: "on-miss", + mode: "ask", }, }, }); @@ -497,8 +507,7 @@ describe("exec-policy CLI", () => { tools: { exec: { host: "node", - security: "allowlist", - ask: "on-miss", + mode: "ask", }, }, }); diff --git a/src/cli/exec-policy-cli.ts b/src/cli/exec-policy-cli.ts index 2644dcd3969e..5af5e943a25d 100644 --- a/src/cli/exec-policy-cli.ts +++ b/src/cli/exec-policy-cli.ts @@ -16,14 +16,18 @@ import { maxAsk, minSecurity, normalizeExecAsk, + normalizeExecMode, normalizeExecSecurity, normalizeExecTarget, readExecApprovalsSnapshot, + resolveExecModeFromPolicy, + resolveExecModePolicy, resolveExecApprovalsFromFile, restoreExecApprovalsSnapshotLocked, updateExecApprovals, type ExecApprovalsFile, type ExecAsk, + type ExecMode, type ExecSecurity, type ExecTarget, } from "../infra/exec-approvals.js"; @@ -174,6 +178,7 @@ function applyConfigExecPolicy(draft: Record, policy: ExecPolic tools?: { exec?: { host?: ExecTarget; + mode?: ExecMode; security?: ExecSecurity; ask?: ExecAsk; }; @@ -184,11 +189,23 @@ function applyConfigExecPolicy(draft: Record, policy: ExecPolic if (policy.host !== undefined) { root.tools.exec.host = policy.host; } - if (policy.security !== undefined) { - root.tools.exec.security = policy.security; - } - if (policy.ask !== undefined) { - root.tools.exec.ask = policy.ask; + if (policy.security !== undefined || policy.ask !== undefined) { + const currentPolicy = resolveExecModePolicy({ + mode: normalizeExecMode(root.tools.exec.mode), + security: root.tools.exec.security ?? "full", + ask: root.tools.exec.ask ?? "off", + }); + const security = policy.security ?? currentPolicy.security; + const ask = policy.ask ?? currentPolicy.ask; + if (ask === "always" || (security === "full" && ask === "on-miss")) { + delete root.tools.exec.mode; + root.tools.exec.security = security; + root.tools.exec.ask = ask; + } else { + root.tools.exec.mode = resolveExecModeFromPolicy({ security, ask }); + delete root.tools.exec.security; + delete root.tools.exec.ask; + } } } diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 545fe206a66b..1fdc5bd60612 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -3,6 +3,7 @@ import { Command } from "commander"; import type { Mock } from "vitest"; import { vi } from "vitest"; import { getRuntimeConfig } from "../config/config.js"; +import type { HookInstallRecord } from "../config/types.hooks.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { CliMockOutputRuntime } from "./test-runtime-capture.js"; @@ -41,6 +42,11 @@ function createEmptyUninstallActions() { } let mockInstalledPluginIndexInstallRecords: PluginInstallRecordMap = {}; +let mockHookInstallRecords: Record = {}; + +export function setHookInstallRecords(records: Record): void { + mockHookInstallRecords = structuredClone(records); +} function clonePluginInstallRecords(records: PluginInstallRecordMap): PluginInstallRecordMap { // Tests mutate records freely; clone to keep helper state from leaking across assertions. @@ -658,6 +664,7 @@ vi.mock("../hooks/install.js", () => ({ })); vi.mock("../hooks/installs.js", () => ({ + readHookInstalls: () => structuredClone(mockHookInstallRecords), recordHookInstall: (( ...args: Parameters<(typeof import("../hooks/installs.js"))["recordHookInstall"]> ) => @@ -741,6 +748,7 @@ export function resetPluginsCliTestState() { applyPluginUninstallDirectoryRemoval.mockReset(); updateNpmInstalledPlugins.mockReset(); updateNpmInstalledHookPacks.mockReset(); + mockHookInstallRecords = {}; promptText.mockReset(); promptYesNo.mockReset(); installPluginFromGitSpec.mockReset(); diff --git a/src/cli/plugins-cli.marketplace-entries.test.ts b/src/cli/plugins-cli.marketplace-entries.test.ts index cfa5530fb8a3..ef4e21e51d5f 100644 --- a/src/cli/plugins-cli.marketplace-entries.test.ts +++ b/src/cli/plugins-cli.marketplace-entries.test.ts @@ -69,13 +69,8 @@ describe("plugins marketplace entries", () => { vi.unstubAllEnvs(); }); - it("lists entries from the configured marketplace feed as JSON", async () => { - const config = { - marketplaces: { - feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } }, - sources: { "acme-npm": { type: "npm" as const } }, - }, - }; + it("lists entries from an explicitly selected marketplace feed as JSON", async () => { + const config = {}; mocks.getRuntimeConfig.mockReturnValue(config); mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ source: "hosted-snapshot", @@ -87,7 +82,7 @@ describe("plugins marketplace entries", () => { state: "available", publisher: { trust: "official" }, install: { - candidates: [{ sourceRef: "acme-npm", package: "@acme/calendar", version: "1.2.3" }], + candidates: [{ sourceRef: "public-npm", package: "@acme/calendar", version: "1.2.3" }], }, openclaw: { plugin: { id: "acme-calendar", label: "Acme Calendar" }, @@ -128,10 +123,10 @@ describe("plugins marketplace entries", () => { const { runPluginMarketplaceEntriesCommand } = await import("./plugins-cli.runtime.js"); await runPluginMarketplaceEntriesCommand({ feedProfile: "acme", offline: true, json: true }); - expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith( - config, - { feedProfile: "acme", offline: true }, - ); + expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith({ + feedProfile: "acme", + offline: true, + }); expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith( expect.objectContaining({ source: "hosted-snapshot", diff --git a/src/cli/plugins-cli.marketplace-refresh.test.ts b/src/cli/plugins-cli.marketplace-refresh.test.ts index eccfa0e4df9c..8ea7a98c2f49 100644 --- a/src/cli/plugins-cli.marketplace-refresh.test.ts +++ b/src/cli/plugins-cli.marketplace-refresh.test.ts @@ -64,12 +64,8 @@ describe("plugins marketplace refresh", () => { vi.unstubAllEnvs(); }); - it("refreshes the configured marketplace feed and prints JSON", async () => { - const config = { - marketplaces: { - feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } }, - }, - }; + it("refreshes an explicitly selected marketplace feed and prints JSON", async () => { + const config = {}; mocks.getRuntimeConfig.mockReturnValue(config); mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ source: "hosted", @@ -103,10 +99,11 @@ describe("plugins marketplace refresh", () => { json: true, }); - expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith( - config, - { feedProfile: "acme", expectedSha256: "feed-sha", requireSnapshotWrite: true }, - ); + expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith({ + feedProfile: "acme", + expectedSha256: "feed-sha", + requireSnapshotWrite: true, + }); expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith({ source: "hosted", entries: 2, @@ -169,11 +166,7 @@ describe("plugins marketplace refresh", () => { }); it("normalizes bare SHA-256 pins before refreshing", async () => { - const config = { - marketplaces: { - feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } }, - }, - }; + const config = {}; mocks.getRuntimeConfig.mockReturnValue(config); mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ source: "hosted", @@ -199,14 +192,11 @@ describe("plugins marketplace refresh", () => { json: true, }); - expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith( - config, - { - feedProfile: "acme", - expectedSha256: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", - requireSnapshotWrite: true, - }, - ); + expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith({ + feedProfile: "acme", + expectedSha256: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + requireSnapshotWrite: true, + }); mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockClear(); @@ -216,14 +206,11 @@ describe("plugins marketplace refresh", () => { json: true, }); - expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith( - config, - { - feedProfile: "acme", - expectedSha256: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", - requireSnapshotWrite: true, - }, - ); + expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith({ + feedProfile: "acme", + expectedSha256: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + requireSnapshotWrite: true, + }); }); it("reports bundled fallback without failing the command", async () => { @@ -318,9 +305,6 @@ describe("plugins marketplace refresh", () => { vi.stubEnv("OPENCLAW_DIAGNOSTICS_TIMELINE_PATH", timelinePath); const config = { diagnostics: { flags: ["timeline"] }, - marketplaces: { - feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } }, - }, }; mocks.getRuntimeConfig.mockReturnValue(config); mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries.mockResolvedValue({ @@ -357,7 +341,6 @@ describe("plugins marketplace refresh", () => { const [event] = await readTimeline(timelinePath); expect(mocks.loadConfiguredHostedOfficialExternalPluginCatalogEntries).toHaveBeenCalledWith( - config, expect.objectContaining({ feedUrl: "https://override.example/openclaw/feed?token=override-leak", }), diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index 31cc10ba2443..5db9d6f6ad89 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -743,7 +743,7 @@ export async function runPluginMarketplaceEntriesCommand( ): Promise { const catalog = await import("../plugins/official-external-plugin-catalog.js"); const cfg = getRuntimeConfig(); - const result = await catalog.loadConfiguredHostedOfficialExternalPluginCatalogEntries(cfg, { + const result = await catalog.loadConfiguredHostedOfficialExternalPluginCatalogEntries({ ...(opts.feedProfile ? { feedProfile: opts.feedProfile } : {}), ...(opts.feedUrl ? { feedUrl: opts.feedUrl } : {}), ...(opts.offline ? { offline: true } : {}), @@ -753,9 +753,7 @@ export async function runPluginMarketplaceEntriesCommand( }); const entries: MarketplaceEntryPayload[] = result.entries.map((entry) => { const id = catalog.resolveOfficialExternalPluginId(entry); - const install = - catalog.resolveOfficialExternalPluginInstall(entry, { catalogConfig: cfg.marketplaces }) ?? - undefined; + const install = catalog.resolveOfficialExternalPluginInstall(entry) ?? undefined; const payload: MarketplaceEntryPayload = { label: catalog.resolveOfficialExternalPluginLabel(entry), }; @@ -829,7 +827,7 @@ export async function runPluginMarketplaceRefreshCommand( await import("../plugins/official-external-plugin-catalog.js"); const cfg = getRuntimeConfig(); const expectedSha256 = normalizeMarketplaceExpectedSha256(opts.expectedSha256); - const result = await loadConfiguredHostedOfficialExternalPluginCatalogEntries(cfg, { + const result = await loadConfiguredHostedOfficialExternalPluginCatalogEntries({ ...(opts.feedProfile ? { feedProfile: opts.feedProfile } : {}), ...(opts.feedUrl ? { feedUrl: opts.feedUrl } : {}), ...(expectedSha256 ? { expectedSha256 } : {}), diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index 6dbc6c9cc26f..600d92064ade 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -1,11 +1,8 @@ // Plugins CLI update tests cover plugin update command behavior and output. -import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { hashConfigIncludeRaw } from "../config/includes.js"; import { CLAWHUB_INSTALL_ERROR_CODE } from "../plugins/clawhub-error-codes.js"; import { loadConfig, @@ -19,6 +16,7 @@ import { runtimeErrors, runtimeLogs, setInstalledPluginIndexInstallRecords, + setHookInstallRecords, updateNpmInstalledHookPacks, updateNpmInstalledPlugins, writeConfigFile, @@ -198,38 +196,16 @@ describe("plugins cli update", () => { }); it("updates tracked hook packs through plugins update", async () => { - const cfg = { - hooks: { - internal: { - installs: { - "demo-hooks": { - source: "npm", - spec: "@acme/demo-hooks@1.0.0", - installPath: "/tmp/hooks/demo-hooks", - resolvedName: "@acme/demo-hooks", - }, - }, - }, - }, - } as OpenClawConfig; - const nextConfig = { - hooks: { - internal: { - installs: { - "demo-hooks": { - source: "npm", - spec: "@acme/demo-hooks@1.1.0", - installPath: "/tmp/hooks/demo-hooks", - }, - }, - }, - }, - } as OpenClawConfig; + const cfg = {} as OpenClawConfig; + const nextConfig = cfg; - primeUpdateConfigSnapshot({ - config: cfg, - includeFileHashesForWrite: { - "/tmp/hooks.json5": "hooks-start-hash", + primeUpdateConfigSnapshot({ config: cfg }); + setHookInstallRecords({ + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.0.0", + installPath: "/tmp/hooks/demo-hooks", + resolvedName: "@acme/demo-hooks", }, }); updateNpmInstalledPlugins.mockResolvedValue({ @@ -255,32 +231,15 @@ describe("plugins cli update", () => { expect(hookUpdateParams.config).toBe(cfg); expect(hookUpdateParams.hookIds).toEqual(["demo-hooks"]); expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); - expect(replaceConfigFile).toHaveBeenCalledWith({ - nextConfig, - baseHash: "update-config", - writeOptions: expect.objectContaining({ - includeFileHashesForWrite: { - "/tmp/hooks.json5": "hooks-start-hash", - }, - }), - }); + expect(replaceConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ nextConfig, baseHash: "update-config" }), + ); expect(refreshPluginRegistry).not.toHaveBeenCalled(); expectRestartNoticeLogged(); }); it("uses the mutation-start snapshot for updater input and hook selection", async () => { const loadedConfig = { - hooks: { - internal: { - installs: { - "old-hooks": { - source: "npm", - spec: "@acme/old-hooks@1.0.0", - installPath: "/tmp/hooks/old-hooks", - }, - }, - }, - }, plugins: { entries: { alpha: { enabled: true }, @@ -288,17 +247,6 @@ describe("plugins cli update", () => { }, } as OpenClawConfig; const snapshotConfig = { - hooks: { - internal: { - installs: { - "new-hooks": { - source: "npm", - spec: "@acme/new-hooks@1.0.0", - installPath: "~/.openclaw/hooks/new-hooks", - }, - }, - }, - }, plugins: { entries: { alpha: { enabled: false }, @@ -317,23 +265,19 @@ describe("plugins cli update", () => { loadedConfig, runtimeConfig: { ...snapshotConfig, - hooks: { - internal: { - installs: { - "new-hooks": { - source: "npm", - spec: "@acme/new-hooks@1.0.0", - installPath: "/home/test/.openclaw/hooks/new-hooks", - }, - }, - }, - }, messages: { ackReactionScope: "group-mentions", }, }, }); setInstalledPluginIndexInstallRecords(installRecords); + setHookInstallRecords({ + "new-hooks": { + source: "npm", + spec: "@acme/new-hooks@1.0.0", + installPath: "/home/test/.openclaw/hooks/new-hooks", + }, + }); updateNpmInstalledPlugins.mockImplementation(async (params: { config: OpenClawConfig }) => ({ config: params.config, changed: false, @@ -351,17 +295,6 @@ describe("plugins cli update", () => { const hookUpdateParams = expectSingleCallParams(updateNpmInstalledHookPacks); expect(pluginUpdateParams.config).toEqual({ ...snapshotConfig, - hooks: { - internal: { - installs: { - "new-hooks": { - source: "npm", - spec: "@acme/new-hooks@1.0.0", - installPath: "/home/test/.openclaw/hooks/new-hooks", - }, - }, - }, - }, messages: { ackReactionScope: "group-mentions", }, @@ -448,33 +381,6 @@ describe("plugins cli update", () => { expect(writeConfigFile).not.toHaveBeenCalled(); }); - it("blocks hook pack updates before updater side effects when hooks config is include-owned", async () => { - const cfg = { - hooks: { - internal: { - installs: { - "demo-hooks": { - source: "npm", - spec: "@acme/demo-hooks@1.0.0", - installPath: "/tmp/hooks/demo-hooks", - resolvedName: "@acme/demo-hooks", - }, - }, - }, - }, - } as OpenClawConfig; - primeBlockedUpdateConfig("hooks", cfg); - - await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow("__exit__:1"); - - expect(runtimeErrors.at(-1)).toContain( - "Config hooks are stored in an external or unresolved top-level $include", - ); - expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); - expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); - expect(writeConfigFile).not.toHaveBeenCalled(); - }); - it("allows index-only legacy id migration when an included plugins section has no references", async () => { const cfg = { plugins: {} } as OpenClawConfig; const pluginRecords = createTrackedPluginConfig({ @@ -995,17 +901,6 @@ describe("plugins cli update", () => { it("ignores retired plugin records during hook-only ownership checks", async () => { const cfg = { - hooks: { - internal: { - installs: { - "demo-hooks": { - source: "npm", - spec: "@acme/demo-hooks@1.0.0", - installPath: "/tmp/hooks/demo-hooks", - }, - }, - }, - }, plugins: { installs: { legacy: { @@ -1017,6 +912,13 @@ describe("plugins cli update", () => { }, } as OpenClawConfig; primeBlockedUpdateConfig("plugins", cfg); + setHookInstallRecords({ + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.0.0", + installPath: "/tmp/hooks/demo-hooks", + }, + }); await runPluginsCommand(["plugins", "update", "demo-hooks"]); @@ -1093,67 +995,6 @@ describe("plugins cli update", () => { expect(writeConfigFile).not.toHaveBeenCalled(); }); - it("blocks combined plugin and hook updates when either config section uses an include", async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-update-")); - const configPath = path.join(tempRoot, "openclaw.json5"); - const pluginsPath = path.join(tempRoot, "plugins.json5"); - const pluginsRaw = "{}\n"; - fs.writeFileSync(pluginsPath, pluginsRaw); - const cfg = { - hooks: { - internal: { - installs: { - "demo-hooks": { - source: "npm", - spec: "@acme/demo-hooks@1.0.0", - installPath: "/tmp/hooks/demo-hooks", - }, - }, - }, - }, - plugins: { - entries: { - "voice-call": { - enabled: true, - }, - }, - }, - } as OpenClawConfig; - primeUpdateConfigSnapshot({ - config: cfg, - configPath, - parsed: { - hooks: {}, - plugins: { $include: "./plugins.json5" }, - }, - includeFileHashesForWrite: { - [pluginsPath]: hashConfigIncludeRaw(pluginsRaw), - }, - includeFileTargetsForWrite: { - [pluginsPath]: fs.realpathSync(pluginsPath), - }, - }); - setInstalledPluginIndexInstallRecords({ - "voice-call": { - source: "npm", - spec: "@openclaw/voice-call@1.0.0", - installPath: "/tmp/voice-call", - }, - }); - - try { - await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow("__exit__:1"); - expect(runtimeErrors.at(-1)).toContain( - "Config plugins and hooks cannot be updated together while either section uses a top-level $include", - ); - expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); - expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled(); - expect(writeConfigFile).not.toHaveBeenCalled(); - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } - }); - it("exits when update is called without id and without --all", async () => { loadConfig.mockReturnValue({ plugins: { @@ -1592,21 +1433,16 @@ describe("plugins cli update", () => { }); it("exits non-zero when a hook pack update reports an error", async () => { - const cfg = { - hooks: { - internal: { - installs: { - "demo-hooks": { - source: "npm", - spec: "@acme/demo-hooks@1.0.0", - installPath: "/tmp/hooks/demo-hooks", - resolvedName: "@acme/demo-hooks", - }, - }, - }, - }, - } as OpenClawConfig; + const cfg = {} as OpenClawConfig; loadConfig.mockReturnValue(cfg); + setHookInstallRecords({ + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.0.0", + installPath: "/tmp/hooks/demo-hooks", + resolvedName: "@acme/demo-hooks", + }, + }); updateNpmInstalledPlugins.mockResolvedValue({ config: cfg, changed: false, diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 9bb8ebf7468e..d514d2a9162e 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -17,6 +17,7 @@ import { applyMergePatch } from "../config/merge-patch.js"; import { ConfigMutationConflictError } from "../config/mutate.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { readHookInstalls } from "../hooks/installs.js"; import { updateNpmInstalledHookPacks } from "../hooks/update.js"; import { normalizeUpdateChannel } from "../infra/update-channels.js"; import { @@ -219,8 +220,9 @@ export async function runPluginUpdateCommand(params: { rawId: params.id, all: params.opts.all, }); + const selectedHooks = readHookInstalls(); const hookSelection = resolveHookPackUpdateSelection({ - installs: cfg.hooks?.internal?.installs ?? {}, + installs: selectedHooks, rawId: params.id, all: params.opts.all, }); @@ -234,7 +236,6 @@ export async function runPluginUpdateCommand(params: { return defaultRuntime.exit(1); } - const selectedHooks = cfg.hooks?.internal?.installs ?? {}; const pluginUpdateMayMutate = !params.opts.dryRun && pluginSelection.pluginIds.some((pluginId) => { diff --git a/src/cli/proxy-cli.runtime.test.ts b/src/cli/proxy-cli.runtime.test.ts index d4cc048ccf79..0486debaff7a 100644 --- a/src/cli/proxy-cli.runtime.test.ts +++ b/src/cli/proxy-cli.runtime.test.ts @@ -225,7 +225,7 @@ describe("proxy cli runtime", () => { "Problems\n" + " - proxy validation requires proxy.enabled to be true for configured proxy URLs\n\n" + "Next steps\n" + - " Enable proxy.enabled with proxy.proxyUrl or OPENCLAW_PROXY_URL, or pass --proxy-url for an explicit one-off validation.\n", + " Fix proxy.proxyUrl, OPENCLAW_PROXY_URL, or --proxy-url so it uses a reachable http:// or https:// proxy.\n", ); }); @@ -253,7 +253,7 @@ describe("proxy cli runtime", () => { "Problems\n" + " - proxy validation requires proxy.enabled=true with proxy.proxyUrl or OPENCLAW_PROXY_URL, or --proxy-url\n\n" + "Next steps\n" + - " Enable proxy.enabled with proxy.proxyUrl or OPENCLAW_PROXY_URL, or pass --proxy-url for an explicit one-off validation.\n", + " Fix proxy.proxyUrl, OPENCLAW_PROXY_URL, or --proxy-url so it uses a reachable http:// or https:// proxy.\n", ); expect(process.exitCode).toBe(1); }); diff --git a/src/cli/proxy-cli.runtime.ts b/src/cli/proxy-cli.runtime.ts index 4c74169cd8fa..ed716df4e8f3 100644 --- a/src/cli/proxy-cli.runtime.ts +++ b/src/cli/proxy-cli.runtime.ts @@ -187,11 +187,6 @@ function formatProxyValidationNextSteps(result: ProxyValidationResult): string[] if (result.ok) { return []; } - if (result.config.errors.some((error) => error.includes("proxy.enabled"))) { - return [ - "Enable proxy.enabled with proxy.proxyUrl or OPENCLAW_PROXY_URL, or pass --proxy-url for an explicit one-off validation.", - ]; - } if (result.config.errors.some((error) => error.includes("proxy CA file could not be read"))) { return [ "Confirm proxy.tls.caFile or --proxy-ca-file points to a readable PEM CA file for the HTTPS proxy endpoint.", diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 3cecb9703918..10ac539befe7 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -2241,8 +2241,8 @@ describe("runCli exit behavior", () => { it("replaces the early managed proxy with the final accepted gateway config", async () => { const earlyHandle = makeProxyHandle(); const finalHandle = makeProxyHandle(); - const earlyProxy = { enabled: true, proxyUrl: "http://127.0.0.1:19876" }; - const finalProxy = { enabled: true, proxyUrl: "http://127.0.0.1:29876" }; + const earlyProxy = { proxyUrl: "http://127.0.0.1:19876" }; + const finalProxy = { proxyUrl: "http://127.0.0.1:29876" }; loadConfigMock.mockReturnValueOnce({ proxy: earlyProxy }); startProxyMock.mockResolvedValueOnce(earlyHandle).mockResolvedValueOnce(finalHandle); commanderParseAsyncMock.mockImplementationOnce(async () => { @@ -2268,8 +2268,8 @@ describe("runCli exit behavior", () => { it("removes early proxy signal handlers when the final config disables the proxy", async () => { const earlyHandle = makeProxyHandle(); - const earlyProxy = { enabled: true, proxyUrl: "http://127.0.0.1:19876" }; - const finalProxy = { enabled: false }; + const earlyProxy = { proxyUrl: "http://127.0.0.1:19876" }; + const finalProxy = undefined; loadConfigMock.mockReturnValueOnce({ proxy: earlyProxy }); startProxyMock.mockResolvedValueOnce(earlyHandle).mockResolvedValueOnce(null); const processOnceSpy = vi.spyOn(process, "once"); diff --git a/src/commands/agent.runtime-config.test.ts b/src/commands/agent.runtime-config.test.ts index 4580cdaa2bbc..b7cfdf06cd0e 100644 --- a/src/commands/agent.runtime-config.test.ts +++ b/src/commands/agent.runtime-config.test.ts @@ -410,6 +410,81 @@ describe("agentCommand runtime config", () => { }); }); + it.each([ + { + name: "global memory headers", + apply: (config: OpenClawConfig) => { + config.memory = { + search: { + remote: { + headers: { + Authorization: { source: "env", provider: "default", id: "MEMORY_HEADER" }, + }, + }, + }, + } as unknown as OpenClawConfig["memory"]; + }, + }, + { + name: "per-agent memory headers", + apply: (config: OpenClawConfig) => { + config.agents = { + ...config.agents, + entries: { + personal: { + memory: { + search: { + remote: { + headers: { + Authorization: { + source: "env", + provider: "default", + id: "AGENT_MEMORY_HEADER", + }, + }, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig["agents"]; + }, + }, + { + name: "per-agent TTS provider", + apply: (config: OpenClawConfig) => { + config.agents = { + ...config.agents, + entries: { + personal: { + tts: { + providers: { + elevenlabs: { + apiKey: { source: "env", provider: "default", id: "AGENT_TTS_KEY" }, + }, + }, + }, + }, + }, + } as OpenClawConfig["agents"]; + }, + }, + ])("resolves command secrets for $name", async ({ apply }) => { + await withTempHome(async (home) => { + const loadedConfig = mockConfig(home, path.join(home, "sessions.json")); + apply(loadedConfig); + resolveCommandConfigWithSecretsMock.mockResolvedValueOnce({ + resolvedConfig: loadedConfig, + effectiveConfig: loadedConfig, + diagnostics: [], + }); + + await resolveAgentRuntimeConfig(runtime); + + expect(resolveCommandConfigWithSecretsMock).toHaveBeenCalledTimes(1); + }); + }); + it("derives a fresh session from --to", async () => { await withTempHome(async (home) => { const store = path.join(home, "sessions.json"); diff --git a/src/commands/agents.commands.identity.ts b/src/commands/agents.commands.identity.ts index e5108cca74a6..a40283dc8e89 100644 --- a/src/commands/agents.commands.identity.ts +++ b/src/commands/agents.commands.identity.ts @@ -196,7 +196,12 @@ export async function agentsSetIdentityCommand( ...cfg, agents: { ...cfg.agents, - list: nextList, + entries: Object.fromEntries( + nextList.map((entry) => { + const { id, ...config } = entry; + return [id, config]; + }), + ), }, }; diff --git a/src/commands/agents.config.ts b/src/commands/agents.config.ts index 75c1a7c8cec2..c6eff6519bf0 100644 --- a/src/commands/agents.config.ts +++ b/src/commands/agents.config.ts @@ -37,6 +37,15 @@ export type AgentSummary = { type AgentEntry = NonNullable["list"]>[number]; +function toAgentEntries(list: AgentEntry[]): Record> { + return Object.fromEntries( + list.map((entry) => { + const { id, ...config } = entry; + return [id, config]; + }), + ); +} + export type AgentIdentity = AgentIdentityFile; export { listAgentEntries }; @@ -165,7 +174,7 @@ export function applyAgentConfig( ...cfg, agents: { ...cfg.agents, - list: nextList, + entries: toAgentEntries(nextList), }, }; } @@ -203,7 +212,7 @@ export function pruneAgentConfig( : entry, ); } - const nextAgents = nextAgentsList.length > 0 ? nextAgentsList : undefined; + const nextAgents = nextAgentsList.length > 0 ? toAgentEntries(nextAgentsList) : undefined; const bindings = cfg.bindings ?? []; const filteredBindings = bindings.filter((binding) => normalizeAgentId(binding.agentId) !== id); @@ -221,9 +230,9 @@ export function pruneAgentConfig( } : cfg.agents?.defaults; const nextAgentsConfig = cfg.agents - ? { ...cfg.agents, defaults: nextDefaults, list: nextAgents } + ? { ...cfg.agents, defaults: nextDefaults, entries: nextAgents } : nextAgents - ? { list: nextAgents } + ? { entries: nextAgents } : undefined; const nextTools = cfg.tools?.agentToAgent ? { diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index 0a71b3a78119..a83f6b195679 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -74,15 +74,42 @@ function resolveFixtureStoreAgentId(cfg: OpenClawConfig, deletedAgentId: string) return deletedAgentId; } +function canonicalizeAgentEntriesForTest(cfg: OpenClawConfig): OpenClawConfig { + const list = (cfg.agents as { list?: Array> } | undefined)?.list; + if (!Array.isArray(list)) { + return cfg; + } + const { list: _list, ...agents } = cfg.agents as NonNullable & { + list?: unknown; + }; + return { + ...cfg, + agents: { + ...agents, + entries: Object.fromEntries( + list.flatMap((entry) => { + const id = typeof entry.id === "string" ? entry.id : ""; + if (!id) { + return []; + } + const { id: _id, ...value } = entry; + return [[id, value]]; + }), + ), + }, + }; +} + async function arrangeAgentsDeleteTest(params: { stateDir: string; cfg: OpenClawConfig; deletedAgentId?: string; sessions: Record; }) { + const cfg = canonicalizeAgentEntriesForTest(params.cfg); const deletedAgentId = params.deletedAgentId ?? "ops"; - const storeAgentId = resolveFixtureStoreAgentId(params.cfg, deletedAgentId); - const storePath = resolveStorePath(params.cfg.session?.store, { agentId: deletedAgentId }); + const storeAgentId = resolveFixtureStoreAgentId(cfg, deletedAgentId); + const storePath = resolveStorePath(cfg.session?.store, { agentId: deletedAgentId }); for (const [sessionKey, entry] of Object.entries(params.sessions)) { await replaceSessionEntry( { agentId: storeAgentId, sessionKey, storePath }, @@ -96,10 +123,10 @@ async function arrangeAgentsDeleteTest(params: { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, - config: params.cfg, - runtimeConfig: params.cfg, - sourceConfig: params.cfg, - resolved: params.cfg, + config: cfg, + runtimeConfig: cfg, + sourceConfig: cfg, + resolved: cfg, }); return storePath; @@ -303,7 +330,12 @@ describe("agents delete command", () => { [{ nextConfig: OpenClawConfig }] >; expect(replaceConfigFileCalls[0]?.[0].nextConfig).toEqual({ - agents: { list: [{ id: "main", workspace: path.join(stateDir, "workspace-main") }] }, + agents: { + defaults: undefined, + entries: { main: { workspace: path.join(stateDir, "workspace-main") } }, + }, + bindings: undefined, + tools: undefined, }); expectSessionStore(storePath, { "agent:main:main": { sessionId: "sess-main", updatedAt: now + 3 }, diff --git a/src/commands/agents.identity.test.ts b/src/commands/agents.identity.test.ts index 6adc8c7e48c5..e9715a8812e2 100644 --- a/src/commands/agents.identity.test.ts +++ b/src/commands/agents.identity.test.ts @@ -29,7 +29,7 @@ import { agentsSetIdentityCommand } from "./agents.commands.identity.js"; const runtime = createTestRuntime(); type ConfigWritePayload = { - agents?: { list?: Array<{ id: string; identity?: Record }> }; + agents?: { entries?: Record }> }; }; async function createIdentityWorkspace(subdir = "work") { @@ -51,13 +51,13 @@ function getWrittenMainIdentity() { throw new Error("expected written agent config"); } const payload = written as ConfigWritePayload; - return payload.agents?.list?.find((entry) => entry.id === "main")?.identity; + return payload.agents?.entries?.main?.identity; } async function runIdentityCommandFromWorkspace(workspace: string, fromIdentity = true) { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, - config: { agents: { list: [{ id: "main", workspace }] } }, + config: { agents: { entries: { main: { workspace } } } }, }); await agentsSetIdentityCommand({ workspace, fromIdentity }, runtime); } @@ -86,10 +86,10 @@ describe("agents set-identity command", () => { ...baseConfigSnapshot, config: { agents: { - list: [ - { id: "main", workspace }, - { id: "ops", workspace: path.join(root, "ops") }, - ], + entries: { + main: { workspace }, + ops: { workspace: path.join(root, "ops") }, + }, }, }, }); @@ -111,7 +111,7 @@ describe("agents set-identity command", () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, - config: { agents: { list: [{ id: "main", workspace }] } }, + config: { agents: { entries: { main: { workspace } } } }, }); const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(root); @@ -132,10 +132,7 @@ describe("agents set-identity command", () => { ...baseConfigSnapshot, config: { agents: { - list: [ - { id: "main", workspace }, - { id: "ops", workspace }, - ], + entries: { main: { workspace }, ops: { workspace } }, }, }, }); @@ -161,7 +158,7 @@ describe("agents set-identity command", () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, - config: { agents: { list: [{ id: "main", workspace }] } }, + config: { agents: { entries: { main: { workspace } } } }, }); await agentsSetIdentityCommand( @@ -195,7 +192,7 @@ describe("agents set-identity command", () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, - config: { agents: { list: [{ id: "main" }] } }, + config: { agents: { entries: { main: {} } } }, }); await agentsSetIdentityCommand({ agent: "main", identityFile: identityPath }, runtime); @@ -222,7 +219,7 @@ describe("agents set-identity command", () => { it("accepts avatar-only updates via flags", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, - config: { agents: { list: [{ id: "main" }] } }, + config: { agents: { entries: { main: {} } } }, }); await agentsSetIdentityCommand( @@ -238,7 +235,7 @@ describe("agents set-identity command", () => { it("errors without changing config when --agent names an unknown agent", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, - config: { agents: { list: [{ id: "main" }] } }, + config: { agents: { entries: { main: {} } } }, }); await agentsSetIdentityCommand({ agent: "ghostzzz", name: "Ghost" }, runtime); @@ -255,7 +252,7 @@ describe("agents set-identity command", () => { async (agentId) => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, - config: { agents: { list: [{ id: "ops" }] } }, + config: { agents: { entries: { ops: {} } } }, }); await agentsSetIdentityCommand({ agent: agentId, name: "Hijack" }, runtime); @@ -273,7 +270,7 @@ describe("agents set-identity command", () => { ...baseConfigSnapshot, config: { agents: { - list: [{ id: "ops", identity: { emoji: "🛠️" } }], + entries: { ops: { identity: { emoji: "🛠️" } } }, }, }, }); @@ -284,7 +281,7 @@ describe("agents set-identity command", () => { const [written] = configMocks.writeConfigFile.mock.calls[0] ?? []; expect(written).toMatchObject({ agents: { - list: [{ id: "ops", identity: { name: "Operator", emoji: "🛠️" } }], + entries: { ops: { identity: { name: "Operator", emoji: "🛠️" } } }, }, }); }); @@ -296,7 +293,7 @@ describe("agents set-identity command", () => { config: { agents: { defaults: { workspace }, - list: [], + entries: {}, }, }, }); @@ -307,7 +304,7 @@ describe("agents set-identity command", () => { const [written] = configMocks.writeConfigFile.mock.calls[0] ?? []; expect(written).toMatchObject({ agents: { - list: [{ id: "main", identity: { name: "Default Agent" } }], + entries: { main: { identity: { name: "Default Agent" } } }, }, }); }); @@ -321,7 +318,7 @@ describe("agents set-identity command", () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, - config: { agents: { list: [{ id: "main" }] } }, + config: { agents: { entries: { main: {} } } }, }); await agentsSetIdentityCommand({ agent: "main", identityFile: identityPath }, runtime); diff --git a/src/commands/agents.test.ts b/src/commands/agents.test.ts index e839234803da..9d614ac22220 100644 --- a/src/commands/agents.test.ts +++ b/src/commands/agents.test.ts @@ -26,17 +26,16 @@ describe("agents helpers", () => { workspace: "/main-ws", model: { primary: "anthropic/claude" }, }, - list: [ - { id: "main" }, - { - id: "work", + entries: { + main: {}, + work: { default: true, name: "Work", workspace: "/work-ws", agentDir: "/state/agents/work/agent", model: "openai/gpt-4.1", }, - ], + }, }, bindings: [ { @@ -69,10 +68,10 @@ describe("agents helpers", () => { fs.writeFileSync(path.join(workspace, "avatar.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); const cfg: OpenClawConfig = { agents: { - list: [ - { id: "main", default: true, workspace }, - { id: "work", workspace, identity: { avatar: "avatar.png" } }, - ], + entries: { + main: { default: true, workspace }, + work: { workspace, identity: { avatar: "avatar.png" } }, + }, }, }; @@ -89,7 +88,7 @@ describe("agents helpers", () => { it("applyAgentConfig merges updates", () => { const cfg: OpenClawConfig = { agents: { - list: [{ id: "work", workspace: "/old-ws", model: "anthropic/claude" }], + entries: { work: { workspace: "/old-ws", model: "anthropic/claude" } }, }, }; @@ -100,7 +99,7 @@ describe("agents helpers", () => { agentDir: "/state/work/agent", }); - const work = next.agents?.list?.find((agent) => agent.id === "work"); + const work = next.agents?.entries?.work; expect(work?.name).toBe("Work"); expect(work?.workspace).toBe("/new-ws"); expect(work?.agentDir).toBe("/state/work/agent"); @@ -111,12 +110,12 @@ describe("agents helpers", () => { const cfg: OpenClawConfig = { agents: { defaults: { model: { primary: "openai/gpt-5.6-luna" } }, - list: [{ id: "work", workspace: "/work-ws", model: "anthropic/claude" }], + entries: { work: { workspace: "/work-ws", model: "anthropic/claude" } }, }, }; const next = applyAgentConfig(cfg, { agentId: "work", model: null }); - const work = next.agents?.list?.find((agent) => agent.id === "work"); + const work = next.agents?.entries?.work; expect(work).not.toHaveProperty("model"); expect(requireAgentSummary(buildAgentSummaries(next), "work").model).toBe( @@ -127,7 +126,7 @@ describe("agents helpers", () => { it("applyAgentConfig merges identity with existing", () => { const cfg: OpenClawConfig = { agents: { - list: [{ id: "work", identity: { name: "Old", theme: "chill", emoji: "🐢" } }], + entries: { work: { identity: { name: "Old", theme: "chill", emoji: "🐢" } } }, }, }; @@ -136,7 +135,7 @@ describe("agents helpers", () => { identity: { name: "New", emoji: "🦀" }, }); - const work = next.agents?.list?.find((agent) => agent.id === "work"); + const work = next.agents?.entries?.work; expect(work?.identity?.name).toBe("New"); expect(work?.identity?.emoji).toBe("🦀"); expect(work?.identity?.theme).toBe("chill"); @@ -145,13 +144,13 @@ describe("agents helpers", () => { it("applyAgentConfig skips identity when not provided", () => { const cfg: OpenClawConfig = { agents: { - list: [{ id: "work", identity: { name: "Keep", emoji: "🐢" } }], + entries: { work: { identity: { name: "Keep", emoji: "🐢" } } }, }, }; const next = applyAgentConfig(cfg, { agentId: "work", name: "Renamed" }); - const work = next.agents?.list?.find((agent) => agent.id === "work"); + const work = next.agents?.entries?.work; expect(work?.name).toBe("Renamed"); expect(work?.identity?.name).toBe("Keep"); expect(work?.identity?.emoji).toBe("🐢"); @@ -415,14 +414,13 @@ describe("agents helpers", () => { const cfg: OpenClawConfig = { agents: { defaults: { subagents: { allowAgents: ["work", "home"] } }, - list: [ - { id: "work", default: true, workspace: "/work-ws" }, - { - id: "home", + entries: { + work: { default: true, workspace: "/work-ws" }, + home: { workspace: "/home-ws", subagents: { allowAgents: ["WORK", "home"] }, }, - ], + }, }, bindings: [ { agentId: "work", match: { channel: "whatsapp" } }, @@ -434,14 +432,14 @@ describe("agents helpers", () => { }; const result = pruneAgentConfig(cfg, "work"); - expect(result.config.agents?.list?.map((agent) => agent.id)).not.toContain("work"); - expect(result.config.agents?.list?.map((agent) => agent.id)).toContain("home"); + expect(result.config.agents?.entries).not.toHaveProperty("work"); + expect(result.config.agents?.entries).toHaveProperty("home"); expect(result.config.bindings).toStrictEqual([ { agentId: "home", match: { channel: "telegram" } }, ]); expect(result.config.tools?.agentToAgent?.allow).toEqual(["home"]); expect(result.config.agents?.defaults?.subagents?.allowAgents).toEqual(["home"]); - expect(result.config.agents?.list?.[0]?.subagents?.allowAgents).toEqual(["home"]); + expect(result.config.agents?.entries?.home?.subagents?.allowAgents).toEqual(["home"]); expect(result.removedBindings).toBe(1); expect(result.removedAllow).toBe(1); }); diff --git a/src/commands/commitments.test.ts b/src/commands/commitments.test.ts index b3b3b8c2c72c..1c6ad350b9d2 100644 --- a/src/commands/commitments.test.ts +++ b/src/commands/commitments.test.ts @@ -9,11 +9,7 @@ const mocks = vi.hoisted(() => ({ listCommitments: vi.fn(), markCommitmentsStatus: vi.fn(), resolveCommitmentDatabasePath: vi.fn(() => "/tmp/openclaw.sqlite"), - getRuntimeConfig: vi.fn(() => ({ - commitments: { - enabled: true, - }, - })), + getRuntimeConfig: vi.fn(() => ({})), })); vi.mock("../commitments/store.js", () => ({ @@ -239,7 +235,7 @@ describe("commitments command", () => { expect(logs).toEqual([]); expect(stdout).toEqual([JSON.stringify({ dismissed: ["cm_escape"] }, null, 2)]); expect(mocks.markCommitmentsStatus).toHaveBeenCalledWith({ - cfg: { commitments: { enabled: true } }, + cfg: {}, ids: ["cm_escape"], status: "dismissed", nowMs: expect.any(Number), diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index b69290d84115..70efcb4a9159 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -622,7 +622,6 @@ describe("buildGatewayInstallPlan", () => { command: "/usr/bin/op", args: ["read", "op://Private/Discord/password"], passEnv: ["OP_CONNECT_TOKEN"], - allowInsecurePath: true, }, }, }, @@ -726,7 +725,6 @@ describe("buildGatewayInstallPlan", () => { command: "/usr/bin/op", args: ["read", "op://Private/OpenAI/api-key"], passEnv: ["OP_CONNECT_TOKEN"], - allowInsecurePath: true, }, }, }, @@ -858,7 +856,6 @@ describe("buildGatewayInstallPlan", () => { "DOCKER_HOST", "NODE_TLS_REJECT_UNAUTHORIZED", ], - allowInsecurePath: true, }, }, }, @@ -920,7 +917,6 @@ describe("buildGatewayInstallPlan", () => { command: "/usr/bin/op", args: ["read", "op://Private/OpenAI/api-key"], passEnv: ["HOME", "NODE_OPTIONS"], - allowInsecurePath: true, }, }, }, @@ -973,7 +969,6 @@ describe("buildGatewayInstallPlan", () => { source: "exec", command: "/usr/bin/op", passEnv: ["OP_CONNECT_TOKEN"], - allowInsecurePath: true, }, }, }, diff --git a/src/commands/doctor-bootstrap-size.test.ts b/src/commands/doctor-bootstrap-size.test.ts index 4eafd21e5b1e..669e84d19946 100644 --- a/src/commands/doctor-bootstrap-size.test.ts +++ b/src/commands/doctor-bootstrap-size.test.ts @@ -67,7 +67,7 @@ describe("noteBootstrapFileSize", () => { "Total bootstrap injected chars: 20,000 (13% of max/total 150,000).", "Total bootstrap raw chars (before truncation): 25,000.", "", - "- Tip: tune `agents.list[].bootstrapMaxChars` for this agent, or `agents.defaults.bootstrapMaxChars` as fallback, for per-file limits.", + "- Tip: tune `agents.entries.*.bootstrapMaxChars` for this agent, or `agents.defaults.bootstrapMaxChars` as fallback, for per-file limits.", ].join("\n"), ); }); diff --git a/src/commands/doctor-bootstrap-size.ts b/src/commands/doctor-bootstrap-size.ts index 3f1575216695..de481eb2f5fc 100644 --- a/src/commands/doctor-bootstrap-size.ts +++ b/src/commands/doctor-bootstrap-size.ts @@ -116,12 +116,12 @@ export async function noteBootstrapFileSize(cfg: OpenClawConfig) { } if (needsPerFileTip) { lines.push( - "- Tip: tune `agents.list[].bootstrapMaxChars` for this agent, or `agents.defaults.bootstrapMaxChars` as fallback, for per-file limits.", + "- Tip: tune `agents.entries.*.bootstrapMaxChars` for this agent, or `agents.defaults.bootstrapMaxChars` as fallback, for per-file limits.", ); } if (needsTotalTip) { lines.push( - "- Tip: tune `agents.list[].bootstrapTotalMaxChars` for this agent, or `agents.defaults.bootstrapTotalMaxChars` as fallback, for total-budget limits.", + "- Tip: tune `agents.entries.*.bootstrapTotalMaxChars` for this agent, or `agents.defaults.bootstrapTotalMaxChars` as fallback, for total-budget limits.", ); } diff --git a/src/commands/doctor-config-analysis.test.ts b/src/commands/doctor-config-analysis.test.ts index 5276e9d3cc69..427878ab3980 100644 --- a/src/commands/doctor-config-analysis.test.ts +++ b/src/commands/doctor-config-analysis.test.ts @@ -61,18 +61,18 @@ describe("doctor config analysis helpers", () => { }, }, agents: { - list: [ - { id: "main", description: "Main coordinator" }, - { id: "stock-news", description: "Tracks market news" }, - ], + entries: { + main: { description: "Main coordinator" }, + "stock-news": { description: "Tracks market news" }, + }, }, unexpected: true, } as never); expect(result.removed).toContain("unexpected"); expect(result.removed).toContain("defaultModel"); - expect(result.removed).not.toContain("agents.list[0].description"); - expect(result.removed).not.toContain("agents.list[1].description"); + expect(result.removed).not.toContain("agents.entries.main.description"); + expect(result.removed).not.toContain("agents.entries.stock-news.description"); expect(OpenClawSchema.safeParse({ defaultModel: "minimax/MiniMax-M2.7" }).success).toBe(false); expect(result.config).toMatchObject({ mcp: { @@ -84,10 +84,10 @@ describe("doctor config analysis helpers", () => { }, }, agents: { - list: [ - { id: "main", description: "Main coordinator" }, - { id: "stock-news", description: "Tracks market news" }, - ], + entries: { + main: { description: "Main coordinator" }, + "stock-news": { description: "Tracks market news" }, + }, }, }); }); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 3c466d7eff68..211b8c37d6f6 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -365,7 +365,7 @@ vi.mock("../config/legacy.js", () => { addIssue( issues, ["memorySearch"], - 'memorySearch is legacy; use agents.defaults.memorySearch. Run "openclaw doctor --fix".', + 'memorySearch is legacy; use memory.search. Run "openclaw doctor --fix".', ); } const gateway = asRecord(root.gateway); @@ -2134,7 +2134,7 @@ describe("doctor config flow", () => { expect(result.cfg.plugins?.entries?.codex?.enabled).toBe(true); }); - it("preserves commitments config on repair", async () => { + it("removes retired commitments config on repair", async () => { const result = await runDoctorConfigWithInput({ repair: true, config: { @@ -2146,10 +2146,7 @@ describe("doctor config flow", () => { run: loadAndMaybeMigrateDoctorConfig, }); - expect(result.cfg.commitments).toEqual({ - enabled: true, - maxPerDay: 2, - }); + expect(result.cfg).not.toHaveProperty("commitments"); }, 300_000); it("preserves discord streaming intent while stripping unsupported keys on repair", async () => { @@ -3099,7 +3096,7 @@ describe("doctor config flow", () => { expect(legacyMessages).toContain("agents.defaults.heartbeat"); expect(legacyMessages).toContain("channels.defaults.heartbeat"); expect(legacyMessages).toContain("memorySearch:"); - expect(legacyMessages).toContain("agents.defaults.memorySearch"); + expect(legacyMessages).toContain("use memory.search"); expect(legacyMessages).toContain("gateway.bind:"); expect(legacyMessages).toContain("gateway.bind host aliases"); expect(legacyMessages).toContain("channels.telegram.groupMentionsOnly:"); diff --git a/src/commands/doctor-config-preflight.state-migration.test.ts b/src/commands/doctor-config-preflight.state-migration.test.ts index 8fda496d79d3..afc6307fbcc1 100644 --- a/src/commands/doctor-config-preflight.state-migration.test.ts +++ b/src/commands/doctor-config-preflight.state-migration.test.ts @@ -49,7 +49,7 @@ const autoMigrateLegacyStateDir = vi.hoisted(() => ); const autoMigrateLegacyState = vi.hoisted(() => vi.fn( - async (): Promise => ({ + async (_params?: unknown): Promise => ({ migrated: true, skipped: false, changes: ["imported"], @@ -905,16 +905,18 @@ describe("runDoctorConfigPreflight state migration", () => { const parsedConfig = { $include: "memory-search.json" }; const resolvedConfig = { cron: { webhook: "https://example.invalid/cron-finished" }, - agents: { - defaults: { - memorySearch: { - store: { - path: "/custom/memory-{agentId}.sqlite", - vector: { enabled: false }, - }, + memory: { + search: { + store: { + path: "/custom/memory-{agentId}.sqlite", + vector: { enabled: false }, }, }, - list: [{ id: "main" }], + }, + + agents: { + defaults: {}, + entries: { main: {} }, }, }; readConfigFileSnapshot.mockResolvedValueOnce({ @@ -925,9 +927,9 @@ describe("runDoctorConfigPreflight state migration", () => { parsed: parsedConfig, legacyIssues: [ { - path: "agents.defaults.memorySearch.store.path", + path: "memory.search.store.path", message: - "agents.defaults.memorySearch.store.path is legacy; memory indexes now live in each agent database.", + "memory.search.store.path is legacy; memory indexes now live in each agent database.", }, ], warnings: [], @@ -942,30 +944,32 @@ describe("runDoctorConfigPreflight state migration", () => { expect(repairLegacyCronStoreWithoutPrompt).toHaveBeenCalledWith({ cfg: expect.objectContaining({ cron: expect.objectContaining({ webhook: "https://example.invalid/cron-finished" }), - agents: expect.objectContaining({ - defaults: expect.objectContaining({ - memorySearch: { - store: { - vector: { enabled: false }, - }, + memory: expect.objectContaining({ + search: expect.objectContaining({ + store: { + vector: { enabled: false }, }, }), - list: [{ id: "main" }], + }), + agents: expect.objectContaining({ + defaults: expect.objectContaining({}), + entries: { main: {} }, }), }), migrateCodexModelRefs: false, }); expect(autoMigrateLegacyState).toHaveBeenCalledWith({ cfg: expect.objectContaining({ - agents: expect.objectContaining({ - defaults: expect.objectContaining({ - memorySearch: { - store: { - vector: { enabled: false }, - }, + memory: expect.objectContaining({ + search: expect.objectContaining({ + store: { + vector: { enabled: false }, }, }), - list: [{ id: "main" }], + }), + agents: expect.objectContaining({ + defaults: expect.objectContaining({}), + entries: { main: {} }, }), }), pluginDoctorConfig: resolvedConfig, @@ -978,15 +982,17 @@ describe("runDoctorConfigPreflight state migration", () => { it("keeps plugin state migrations for partially valid legacy config repairs", async () => { const resolvedConfig = { gateway: { mode: "local", port: "not-a-port" }, - agents: { - defaults: { - memorySearch: { - store: { - path: "/custom/memory-{agentId}.sqlite", - vector: { enabled: false }, - }, + memory: { + search: { + store: { + path: "/custom/memory-{agentId}.sqlite", + vector: { enabled: false }, }, }, + }, + + agents: { + defaults: {}, list: [{ id: "main" }], }, }; @@ -998,9 +1004,9 @@ describe("runDoctorConfigPreflight state migration", () => { parsed: resolvedConfig, legacyIssues: [ { - path: "agents.defaults.memorySearch.store.path", + path: "memory.search.store.path", message: - "agents.defaults.memorySearch.store.path is legacy; memory indexes now live in each agent database.", + "memory.search.store.path is legacy; memory indexes now live in each agent database.", }, ], warnings: [], @@ -1023,7 +1029,7 @@ describe("runDoctorConfigPreflight state migration", () => { expect(note).toHaveBeenCalledWith("- task-imported", "Doctor changes"); }); - it("limits invalid-config preflight to config-independent state migration", async () => { + it("runs config-independent state migration for invalid config", async () => { readConfigFileSnapshot.mockResolvedValueOnce({ exists: true, valid: false, @@ -1040,9 +1046,23 @@ describe("runDoctorConfigPreflight state migration", () => { invalidConfigNote: false, }); - expect(autoMigrateLegacyState).not.toHaveBeenCalled(); - expect(repairLegacyCronStoreWithoutPrompt).not.toHaveBeenCalled(); - expect(autoMigrateLegacyTaskStateSidecars).toHaveBeenCalledWith({ env: process.env }); - expect(note).toHaveBeenCalledWith("- task-imported", "Doctor changes"); + expect(autoMigrateLegacyState).toHaveBeenCalledOnce(); + const migrationParams = autoMigrateLegacyState.mock.calls[0]?.[0] as + | { + cfg?: unknown; + pluginDoctorConfig?: unknown; + env?: NodeJS.ProcessEnv; + } + | undefined; + expect(migrationParams?.cfg).not.toHaveProperty("cron.store"); + expect(migrationParams?.pluginDoctorConfig).toEqual({ + cron: { store: "/tmp/legacy-cron.json" }, + }); + expect(migrationParams?.env).toBe(process.env); + expect(repairLegacyCronStoreWithoutPrompt).toHaveBeenCalledWith({ + cfg: migrationParams?.cfg, + migrateCodexModelRefs: false, + }); + expect(autoMigrateLegacyTaskStateSidecars).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/doctor-gateway-health.test.ts b/src/commands/doctor-gateway-health.test.ts index bbeb6a730c22..f4db3a74892f 100644 --- a/src/commands/doctor-gateway-health.test.ts +++ b/src/commands/doctor-gateway-health.test.ts @@ -127,7 +127,7 @@ describe("checkGatewayHealth", () => { ownerId: "tts", state: "unavailable", degradationState: "stale", - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], reason: "secret provider policy denied resolution", }, { @@ -148,7 +148,7 @@ describe("checkGatewayHealth", () => { [ "- cold account:discord:ops (channels.discord.accounts.ops.token): secret resolution failed", " Retry: openclaw secrets reload", - "- stale capability:tts (messages.tts.providers.elevenlabs.apiKey): secret provider policy denied resolution", + "- stale capability:tts (tts.providers.elevenlabs.apiKey): secret provider policy denied resolution", " Retry: openclaw secrets reload", "- cold capability:web-fetch:firecrawl (plugins.entries.firecrawl.config.webFetch.apiKey): resolved secret value was invalid", " Retry: openclaw secrets reload", diff --git a/src/commands/doctor-install-policy.test.ts b/src/commands/doctor-install-policy.test.ts index ed0b72b6c8b8..1788a7c94243 100644 --- a/src/commands/doctor-install-policy.test.ts +++ b/src/commands/doctor-install-policy.test.ts @@ -30,7 +30,7 @@ async function makeTempDir(): Promise { async function writePolicyScript(dir: string, response: string): Promise { const scriptPath = path.join(dir, "policy.cjs"); - await fs.writeFile(scriptPath, `process.stdout.write(${JSON.stringify(response)});\n`, "utf8"); + await fs.writeFile(scriptPath, `#!/bin/sh\nprintf '%s' ${JSON.stringify(response)}\n`, "utf8"); await fs.chmod(scriptPath, 0o700); return scriptPath; } @@ -42,9 +42,8 @@ function configWithPolicy(scriptPath: string): OpenClawConfig { enabled: true, exec: { source: "exec", - command: process.execPath, - args: [scriptPath], - allowInsecurePath: true, + command: scriptPath, + trustedDirs: [path.dirname(scriptPath)], }, }, }, diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 079defe35448..39f1f60c7d47 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -714,7 +714,7 @@ describe("normalizeCompatibilityConfigValues", () => { }, }); - expect(res.config.agents?.defaults?.imageGenerationModel).toEqual({ + expect(res.config.agents?.defaults?.mediaModels?.image).toEqual({ primary: "google/gemini-3-pro-image-preview", }); expect(res.config.models?.providers?.google?.apiKey).toEqual({ @@ -728,7 +728,7 @@ describe("normalizeCompatibilityConfigValues", () => { expect(res.config.models?.providers?.google?.models).toStrictEqual([]); expect(res.config.skills?.entries).toBeUndefined(); expect(res.changes).toEqual([ - "Moved skills.entries.nano-banana-pro → agents.defaults.imageGenerationModel.primary (google/gemini-3-pro-image-preview).", + "Moved skills.entries.nano-banana-pro → agents.defaults.mediaModels.image.primary (google/gemini-3-pro-image-preview).", "Moved skills.entries.nano-banana-pro.apiKey → models.providers.google.apiKey.", "Removed legacy skills.entries.nano-banana-pro.", ]); @@ -1599,8 +1599,8 @@ describe("normalizeCompatibilityConfigValues", () => { const res = normalizeCompatibilityConfigValues({ agents: { defaults: { - imageGenerationModel: { - primary: "fal/fal-ai/flux/dev", + mediaModels: { + image: { primary: "fal/fal-ai/flux/dev" }, }, }, }, @@ -1623,7 +1623,7 @@ describe("normalizeCompatibilityConfigValues", () => { }, }); - expect(res.config.agents?.defaults?.imageGenerationModel).toEqual({ + expect(res.config.agents?.defaults?.mediaModels?.image).toEqual({ primary: "fal/fal-ai/flux/dev", }); expect(res.config.models?.providers?.google?.apiKey).toBe("existing-google-key"); diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 1fedb101562f..5f8eef02bd31 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -628,7 +628,9 @@ describe("noteMemorySearchHealth", () => { it("does not warn about conversation recall when the setting is off", async () => { const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } }, - agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: false } }] }, + agents: { + list: [{ id: "personal", memory: { search: { rememberAcrossConversations: false } } }], + }, } as OpenClawConfig; resolveMemorySearchConfig.mockReturnValue({ provider: "auto", @@ -644,7 +646,9 @@ describe("noteMemorySearchHealth", () => { it("does not warn when conversation recall and Active Memory are available", async () => { const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } }, - agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }] }, + agents: { + list: [{ id: "personal", memory: { search: { rememberAcrossConversations: true } } }], + }, plugins: { entries: { "active-memory": { enabled: true } } }, } as OpenClawConfig; resolveMemorySearchConfig.mockReturnValue({ @@ -663,7 +667,9 @@ describe("noteMemorySearchHealth", () => { it("does not treat Lossless Claw's context-engine slot as a memory-slot conflict", async () => { const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } }, - agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }] }, + agents: { + list: [{ id: "personal", memory: { search: { rememberAcrossConversations: true } } }], + }, plugins: { slots: { contextEngine: "lossless-claw" }, entries: { @@ -700,7 +706,7 @@ describe("noteMemorySearchHealth", () => { const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } }, agents: { - list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }], + list: [{ id: "personal", memory: { search: { rememberAcrossConversations: true } } }], }, plugins: { slots: { memory: memoryProvider }, @@ -723,7 +729,7 @@ describe("noteMemorySearchHealth", () => { await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); expect(firstNoteMessage()).toBe( - 'Remember across conversations is effectively enabled for agent "personal", but the current memory provider does not support protected private transcript recall. Set memorySearch.rememberAcrossConversations to false or use that provider\'s own recall path; advanced Active Memory can still use its recall tools.', + 'Remember across conversations is effectively enabled for agent "personal", but the current memory provider does not support protected private transcript recall. Set memory.search.rememberAcrossConversations to false or use that provider\'s own recall path; advanced Active Memory can still use its recall tools.', ); }, ); @@ -731,7 +737,9 @@ describe("noteMemorySearchHealth", () => { it("warns when conversation recall is enabled but Active Memory is disabled", async () => { const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } }, - agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }] }, + agents: { + list: [{ id: "personal", memory: { search: { rememberAcrossConversations: true } } }], + }, plugins: { entries: { "active-memory": { enabled: false } } }, } as OpenClawConfig; resolveMemorySearchConfig.mockReturnValue({ @@ -745,14 +753,16 @@ describe("noteMemorySearchHealth", () => { await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); expect(firstNoteMessage()).toBe( - 'Remember across conversations is effectively enabled for agent "personal", but the Active Memory plugin is disabled. Enable the plugin or set memorySearch.rememberAcrossConversations to false.', + 'Remember across conversations is effectively enabled for agent "personal", but the Active Memory plugin is disabled. Enable the plugin or set memory.search.rememberAcrossConversations to false.', ); }); it("warns when conversation recall is enabled but Active Memory is paused in plugin config", async () => { const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } }, - agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }] }, + agents: { + list: [{ id: "personal", memory: { search: { rememberAcrossConversations: true } } }], + }, plugins: { entries: { "active-memory": { enabled: true, config: { enabled: false } }, @@ -775,7 +785,9 @@ describe("noteMemorySearchHealth", () => { it("warns when Active Memory excludes memory_search for conversation recall", async () => { const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } }, - agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }] }, + agents: { + list: [{ id: "personal", memory: { search: { rememberAcrossConversations: true } } }], + }, plugins: { entries: { "active-memory": { enabled: true, config: { toolsAllow: ["memory_get"] } }, @@ -793,14 +805,16 @@ describe("noteMemorySearchHealth", () => { await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); expect(firstNoteMessage()).toBe( - 'Remember across conversations is effectively enabled for agent "personal", but Active Memory does not allow memory_search. Add memory_search to the plugin toolsAllow list or set memorySearch.rememberAcrossConversations to false.', + 'Remember across conversations is effectively enabled for agent "personal", but Active Memory does not allow memory_search. Add memory_search to the plugin toolsAllow list or set memory.search.rememberAcrossConversations to false.', ); }); it("warns when an opted-in agent has memory search disabled", async () => { const qmdCfg = { memory: { backend: "qmd", qmd: { command: "qmd" } }, - agents: { list: [{ id: "personal", memorySearch: { rememberAcrossConversations: true } }] }, + agents: { + list: [{ id: "personal", memory: { search: { rememberAcrossConversations: true } } }], + }, } as OpenClawConfig; resolveMemorySearchConfig.mockImplementation((_cfg: OpenClawConfig, agentId: string) => agentId === "personal" @@ -811,7 +825,7 @@ describe("noteMemorySearchHealth", () => { await noteMemorySearchHealth(qmdCfg, { skipQmdBinaryProbe: true }); expect(firstNoteMessage()).toBe( - 'Remember across conversations is effectively enabled for agent "personal", but memory search is disabled. Enable memory search or set memorySearch.rememberAcrossConversations to false.', + 'Remember across conversations is effectively enabled for agent "personal", but memory search is disabled. Enable memory search or set memory.search.rememberAcrossConversations to false.', ); }); @@ -936,7 +950,7 @@ describe("noteMemorySearchHealth", () => { expect(note).toHaveBeenCalledTimes(1); const message = String(note.mock.calls[0]?.[0] ?? ""); - expect(message).toContain("memorySearch.sources with sessions"); + expect(message).toContain("memory.search.sources with sessions"); expect(message).toContain("memory.qmd.sessions.enabled is not true"); expect(message).toContain("openclaw config set memory.qmd.sessions.enabled true"); }); @@ -1256,7 +1270,7 @@ describe("noteMemorySearchHealth", () => { const message = firstNoteMessage(); expect(message).toContain('provider is set to "openai-compatible"'); - expect(message).toContain("memorySearch.model"); + expect(message).toContain("memory.search.model"); expect(message).toContain("openclaw config set"); expect(resolveApiKeyForProvider).not.toHaveBeenCalled(); }); @@ -1668,7 +1682,7 @@ describe("noteMemorySearchHealth", () => { expect(note).toHaveBeenCalledTimes(1); expect(firstNoteMessage()).toBe( - 'Agent "secondary": Remember across conversations is effectively enabled for agent "secondary", but memory search is disabled. Enable memory search or set memorySearch.rememberAcrossConversations to false.', + 'Agent "secondary": Remember across conversations is effectively enabled for agent "secondary", but memory search is disabled. Enable memory search or set memory.search.rememberAcrossConversations to false.', ); }); }); diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 8a5a4e98e5de..dfd25cb5690b 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -547,18 +547,18 @@ function noteRememberAcrossConversationsHealth(params: { const conversationRecallSupport = resolveActiveMemoryConversationRecallSupport(params.cfg); if (!activeMemoryAvailable) { params.noteFn( - `Remember across conversations is effectively enabled for agent "${params.agentId}", but the Active Memory plugin is disabled. Enable the plugin or set memorySearch.rememberAcrossConversations to false.`, + `Remember across conversations is effectively enabled for agent "${params.agentId}", but the Active Memory plugin is disabled. Enable the plugin or set memory.search.rememberAcrossConversations to false.`, "Memory search", ); } if (activeMemoryAvailable && !conversationRecallSupport.providerSupported) { params.noteFn( - `Remember across conversations is effectively enabled for agent "${params.agentId}", but the current memory provider does not support protected private transcript recall. Set memorySearch.rememberAcrossConversations to false or use that provider's own recall path; advanced Active Memory can still use its recall tools.`, + `Remember across conversations is effectively enabled for agent "${params.agentId}", but the current memory provider does not support protected private transcript recall. Set memory.search.rememberAcrossConversations to false or use that provider's own recall path; advanced Active Memory can still use its recall tools.`, "Memory search", ); } else if (activeMemoryAvailable && !conversationRecallSupport.memorySearchAllowed) { params.noteFn( - `Remember across conversations is effectively enabled for agent "${params.agentId}", but Active Memory does not allow memory_search. Add memory_search to the plugin toolsAllow list or set memorySearch.rememberAcrossConversations to false.`, + `Remember across conversations is effectively enabled for agent "${params.agentId}", but Active Memory does not allow memory_search. Add memory_search to the plugin toolsAllow list or set memory.search.rememberAcrossConversations to false.`, "Memory search", ); } @@ -630,7 +630,7 @@ async function noteMemorySearchHealthForAgent( if (!resolved) { noteFn( recallHealth.enabled - ? `Remember across conversations is effectively enabled for agent "${agentId}", but memory search is disabled. Enable memory search or set memorySearch.rememberAcrossConversations to false.` + ? `Remember across conversations is effectively enabled for agent "${agentId}", but memory search is disabled. Enable memory search or set memory.search.rememberAcrossConversations to false.` : "Memory search is explicitly disabled (enabled: false).", "Memory search", ); @@ -694,7 +694,7 @@ async function noteMemorySearchHealthForAgent( ) { noteFn( [ - "QMD memory backend is configured and the default agent resolves memorySearch.sources with sessions,", + "QMD memory backend is configured and the default agent resolves memory.search.sources with sessions,", "but QMD session transcript export is not enabled (memory.qmd.sessions.enabled is not true).", "Session transcript hits will not appear in QMD-backed memory search until QMD session export is enabled.", "", @@ -702,7 +702,7 @@ async function noteMemorySearchHealthForAgent( `- Enable QMD session export: ${formatCliCommand( "openclaw config set memory.qmd.sessions.enabled true", )}`, - "- Or remove sessions from the default agent's memorySearch.sources if QMD session recall is not intended.", + "- Or remove sessions from the default agent's memory.search.sources if QMD session recall is not intended.", "", `Verify: ${formatCliCommand("openclaw memory status --deep")}`, ].join("\n"), @@ -746,7 +746,7 @@ async function noteMemorySearchHealthForAgent( `- Install the llama.cpp provider plugin: ${formatCliCommand("openclaw plugins install @openclaw/llama-cpp-provider")}`, `- Set a local GGUF model path in config`, suggestedRemoteProvider - ? `- Switch to a remote provider: ${formatCliCommand(`openclaw config set agents.defaults.memorySearch.provider ${suggestedRemoteProvider}`)}` + ? `- Switch to a remote provider: ${formatCliCommand(`openclaw config set memory.search.provider ${suggestedRemoteProvider}`)}` : `- Switch to a remote embedding provider in config`, "", `Verify: ${formatCliCommand("openclaw memory status --deep")}`, @@ -765,10 +765,10 @@ async function noteMemorySearchHealthForAgent( noteFn( [ `Memory search provider is set to "${provider}" but no OpenAI-compatible embeddings endpoint was configured.`, - "Set agents.defaults.memorySearch.remote.baseUrl to the /v1 endpoint for your embeddings server.", + "Set memory.search.remote.baseUrl to the /v1 endpoint for your embeddings server.", "", "Fix:", - `- ${formatCliCommand("openclaw config set agents.defaults.memorySearch.remote.baseUrl http://127.0.0.1:1234/v1")}`, + `- ${formatCliCommand("openclaw config set memory.search.remote.baseUrl http://127.0.0.1:1234/v1")}`, "", `Verify: ${formatCliCommand("openclaw memory status --deep")}`, ].join("\n"), @@ -781,10 +781,10 @@ async function noteMemorySearchHealthForAgent( noteFn( [ `Memory search provider is set to "${provider}" but no OpenAI-compatible embedding model was configured.`, - "Set agents.defaults.memorySearch.model to the embedding model id your server expects.", + "Set memory.search.model to the embedding model id your server expects.", "", "Fix:", - `- ${formatCliCommand("openclaw config set agents.defaults.memorySearch.model text-embedding-bge-m3")}`, + `- ${formatCliCommand("openclaw config set memory.search.model text-embedding-bge-m3")}`, "", `Verify: ${formatCliCommand("openclaw memory status --deep")}`, ].join("\n"), @@ -856,7 +856,7 @@ async function noteMemorySearchHealthForAgent( "Fix (pick one):", `- Set ${envVar} in your environment`, `- Configure credentials: ${formatCliCommand("openclaw configure --section model")}`, - `- To disable: ${formatCliCommand("openclaw config set agents.defaults.memorySearch.enabled false")}`, + `- To disable: ${formatCliCommand("openclaw config set memory.search.enabled false")}`, "", `Verify: ${formatCliCommand("openclaw memory status --deep")}`, ].join("\n"), diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index e1480a532f10..6a629a2ac54e 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -114,24 +114,24 @@ describe("noteSecurityWarnings gateway exposure", () => { async () => { await noteSecurityWarnings({ agents: { - list: [ - { - id: "runner", + entries: { + runner: { tools: { exec: { - security: "full", - ask: "off", + mode: "full", }, }, }, - ], + }, }, } as OpenClawConfig); }, ); const message = lastMessage(); - expect(message).toContain("agents.list.runner.tools.exec is broader than the host exec policy"); + expect(message).toContain( + "agents.entries.runner.tools.exec is broader than the host exec policy", + ); expect(message).toContain(`agents.${agentKey}.security="allowlist"`); expect(message).toContain(`agents.${agentKey}.ask="always"`); } @@ -447,8 +447,7 @@ describe("noteSecurityWarnings gateway exposure", () => { await noteSecurityWarnings({ tools: { exec: { - security: "full", - ask: "off", + mode: "full", }, }, } as OpenClawConfig); @@ -457,7 +456,7 @@ describe("noteSecurityWarnings gateway exposure", () => { const message = lastMessage(); expect(message).toContain("tools.exec is broader than the host exec policy"); - expect(message).toContain('security="full"'); + expect(message).toContain('tools.exec.mode="full"'); expect(message).toContain('defaults.security="allowlist"'); expect(message).toContain("stricter side wins"); }); @@ -503,8 +502,7 @@ describe("noteSecurityWarnings gateway exposure", () => { await noteSecurityWarnings({ tools: { exec: { - security: "allowlist", - ask: "on-miss", + mode: "ask", }, }, } as OpenClawConfig); @@ -524,7 +522,7 @@ describe("noteSecurityWarnings gateway exposure", () => { await noteSecurityWarnings({ tools: { exec: { - ask: "always", + mode: "ask", }, }, } as OpenClawConfig); @@ -553,21 +551,21 @@ describe("noteSecurityWarnings gateway exposure", () => { await noteSecurityWarnings({ tools: { exec: { - security: "full", - ask: "off", + mode: "full", }, }, agents: { - list: [{ id: "runner" }], + entries: { runner: {} }, }, } as OpenClawConfig); }, ); const message = lastMessage(); - expect(message).toContain("agents.list.runner.tools.exec is broader than the host exec policy"); - expect(message).toContain('tools.exec.security="full"'); - expect(message).toContain('tools.exec.ask="off"'); + expect(message).toContain( + "agents.entries.runner.tools.exec is broader than the host exec policy", + ); + expect(message).toContain('tools.exec.mode="full"'); expect(message).toContain('agents.runner.security="allowlist"'); expect(message).toContain('agents.runner.ask="always"'); }); @@ -589,19 +587,20 @@ describe("noteSecurityWarnings gateway exposure", () => { await noteSecurityWarnings({ tools: { exec: { - security: "full", - ask: "off", + mode: "full", }, }, agents: { - list: [{ id: "runner" }], + entries: { runner: {} }, }, } as OpenClawConfig); }, ); const message = lastMessage(); - expect(message).toContain("agents.list.runner.tools.exec is broader than the host exec policy"); + expect(message).toContain( + "agents.entries.runner.tools.exec is broader than the host exec policy", + ); expect(message).toContain('defaults.security="deny"'); expect(message).not.toContain('defaults.ask="always"'); expect(message).not.toContain('agents.runner.ask="foo"'); @@ -629,7 +628,7 @@ describe("noteSecurityWarnings gateway exposure", () => { await noteSecurityWarnings({ tools: { exec: { - ask: "always", + mode: "ask", }, }, } as OpenClawConfig); diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 740dd584e6bc..821d59d666c5 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -119,7 +119,7 @@ function collectExecPolicyConflictWarnings(cfg: OpenClawConfig): string[] { configPath: params.scopeLabel === "tools.exec" ? "tools.exec" - : `agents.list.${params.agentId}.tools.exec`, + : `agents.entries.${params.agentId}.tools.exec`, scopeLabel: params.scopeLabel, agentId: params.agentId, }); @@ -136,12 +136,24 @@ function collectExecPolicyConflictWarnings(cfg: OpenClawConfig): string[] { const configParts: string[] = []; const hostParts: string[] = []; + const canonicalModeSource = + snapshot.security.requestedSource === snapshot.ask.requestedSource && + snapshot.security.requestedSource.endsWith(".mode") + ? snapshot.security.requestedSource + : undefined; + if (canonicalModeSource) { + configParts.push(`${canonicalModeSource}="${snapshot.mode.requested}"`); + } if (securityConflict) { - configParts.push(`${snapshot.security.requestedSource}="${snapshot.security.requested}"`); + if (!canonicalModeSource) { + configParts.push(`${snapshot.security.requestedSource}="${snapshot.security.requested}"`); + } hostParts.push(`${snapshot.security.hostSource}="${snapshot.security.host}"`); } if (askConflict) { - configParts.push(`${snapshot.ask.requestedSource}="${snapshot.ask.requested}"`); + if (!canonicalModeSource) { + configParts.push(`${snapshot.ask.requestedSource}="${snapshot.ask.requested}"`); + } hostParts.push(`${snapshot.ask.hostSource}="${snapshot.ask.host}"`); } @@ -162,13 +174,13 @@ function collectExecPolicyConflictWarnings(cfg: OpenClawConfig): string[] { scopeExecConfig: cfg.tools?.exec, }); - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - for (const agent of agents) { + const agents = cfg.agents?.entries ?? {}; + for (const [agentId, agent] of Object.entries(agents)) { maybeWarn({ - scopeLabel: `agents.list.${agent.id}.tools.exec`, + scopeLabel: `agents.entries.${agentId}.tools.exec`, scopeExecConfig: agent.tools?.exec, globalExecConfig: cfg.tools?.exec, - agentId: agent.id, + agentId, }); } diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index fdfdf37c82b6..2d07ec9ed690 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -236,15 +236,15 @@ describe("structured state integrity findings", () => { async function runOrphanTranscriptCheckWithQmdSessions(enabled: boolean, homeDir: string) { const cfg: OpenClawConfig = { agents: { - defaults: { - memorySearch: { rememberAcrossConversations: false }, - }, + defaults: {}, }, memory: { backend: "qmd", qmd: { sessions: { enabled }, }, + + search: { rememberAcrossConversations: false }, }, }; setupSessionState(cfg, process.env, homeDir); diff --git a/src/commands/doctor/cron/index.test.ts b/src/commands/doctor/cron/index.test.ts index 6a29072b3a1f..7d22a8221b00 100644 --- a/src/commands/doctor/cron/index.test.ts +++ b/src/commands/doctor/cron/index.test.ts @@ -400,7 +400,7 @@ describe("maybeRepairLegacyCronStore", () => { model: { primary: "openai/gpt-5.5", fallbacks: [] }, }, }, - }, + } as unknown as OpenClawConfig, options: {}, prompter, }); @@ -476,7 +476,7 @@ describe("maybeRepairLegacyCronStore", () => { model: { primary: "test:opus", fallbacks: [] }, }, }, - }, + } as unknown as OpenClawConfig, options: {}, prompter: makePrompter(true), }); @@ -1779,7 +1779,7 @@ describe("maybeRepairLegacyCronStore", () => { }), ]); - const cfg = { cron: { store: storePath } } as OpenClawConfig; + const cfg = { cron: { store: storePath } } as unknown as OpenClawConfig; await maybeRepairLegacyCronStore({ cfg, options: {}, @@ -1817,7 +1817,7 @@ describe("maybeRepairLegacyCronStore", () => { }), ]); - const cfg = { cron: { store: storePath } } as OpenClawConfig; + const cfg = { cron: { store: storePath } } as unknown as OpenClawConfig; await maybeRepairLegacyCronStore({ cfg, options: {}, @@ -1949,7 +1949,7 @@ describe("maybeRepairLegacyCronStore", () => { await expect( maybeRepairLegacyCronStore({ - cfg: { cron: { store: storePath } }, + cfg: { cron: { store: storePath } } as unknown as OpenClawConfig, options: {}, prompter, }), diff --git a/src/commands/doctor/cron/index.ts b/src/commands/doctor/cron/index.ts index 322158792979..7ced19ff8122 100644 --- a/src/commands/doctor/cron/index.ts +++ b/src/commands/doctor/cron/index.ts @@ -34,6 +34,11 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function readLegacyCronStorePath(cfg: OpenClawConfig): string | undefined { + return (cfg.cron as (NonNullable & { store?: string }) | undefined) + ?.store; +} + // Count jobs the store still marks in-flight (`state.runningAtMs` is a number). // The scheduler sets this while a run is active and clears it on completion, so a // leftover marker (gateway killed mid-run) makes `cron list` show the job as @@ -108,7 +113,7 @@ export async function collectLegacyCronStoreHealthFindings(params: { try { state = await loadLegacyCronRepairState({ cfg: params.cfg, readOnly: true }); } catch (err) { - const storePath = resolveCronJobsStorePath(params.cfg.cron?.store); + const storePath = resolveCronJobsStorePath(readLegacyCronStorePath(params.cfg)); return [ legacyCronStoreFinding({ message: `Unable to read cron job store at ${shortenHomePath(storePath)}.`, @@ -252,7 +257,7 @@ export async function maybeRepairLegacyCronStore(params: { state = await loadLegacyCronRepairState({ cfg: params.cfg }); } catch (err) { const reason = err instanceof Error ? err.message : String(err); - const storePath = resolveCronJobsStorePath(params.cfg.cron?.store); + const storePath = resolveCronJobsStorePath(readLegacyCronStorePath(params.cfg)); note( [ `Unable to read cron job store at ${shortenHomePath(storePath)}.`, diff --git a/src/commands/doctor/cron/legacy-repair.ts b/src/commands/doctor/cron/legacy-repair.ts index 8f644c63cdf6..f9be30919d2c 100644 --- a/src/commands/doctor/cron/legacy-repair.ts +++ b/src/commands/doctor/cron/legacy-repair.ts @@ -78,12 +78,17 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function readLegacyCronStorePath(cfg: OpenClawConfig): string | undefined { + return (cfg.cron as (NonNullable & { store?: string }) | undefined) + ?.store; +} + export async function loadLegacyCronRepairState(params: { cfg: OpenClawConfig; onlyIfLegacyDetected?: boolean; readOnly?: boolean; }): Promise { - const storePath = resolveCronJobsStorePath(params.cfg.cron?.store); + const storePath = resolveCronJobsStorePath(readLegacyCronStorePath(params.cfg)); const quarantinePath = resolveCronQuarantinePath(storePath); const legacyStoreDetected = await legacyCronStoreFilesExist(storePath); const legacyRunLogDetected = await legacyCronRunLogFilesExist(storePath); @@ -298,7 +303,9 @@ export async function repairLegacyCronStoreWithoutPrompt(params: { migrateCodexModelRefs?: boolean; blockedModelIdentities?: ReadonlySet; }): Promise { - const storePath = resolveCronJobsStorePath(normalizeOptionalString(params.cfg.cron?.store)); + const storePath = resolveCronJobsStorePath( + normalizeOptionalString(readLegacyCronStorePath(params.cfg)), + ); let state: LegacyCronRepairState | null; try { state = await loadLegacyCronRepairState({ @@ -323,7 +330,9 @@ export async function repairLegacyCronStoreWithoutPrompt(params: { export async function collectCronCodexRuntimePolicyTargetsReadOnly(params: { cfg: OpenClawConfig; }): Promise<{ targets: CronCodexRuntimePolicyTarget[]; warnings: string[] }> { - const storePath = resolveCronJobsStorePath(normalizeOptionalString(params.cfg.cron?.store)); + const storePath = resolveCronJobsStorePath( + normalizeOptionalString(readLegacyCronStorePath(params.cfg)), + ); try { const state = await loadLegacyCronRepairState({ cfg: params.cfg, readOnly: true }); return { @@ -345,7 +354,9 @@ export async function repairCronCodexModelRefsAfterConfigWrite(params: { cfg: OpenClawConfig; blockedModelIdentities?: ReadonlySet; }): Promise { - const storePath = resolveCronJobsStorePath(normalizeOptionalString(params.cfg.cron?.store)); + const storePath = resolveCronJobsStorePath( + normalizeOptionalString(readLegacyCronStorePath(params.cfg)), + ); try { const state = await loadLegacyCronRepairState({ cfg: params.cfg }); return state diff --git a/src/commands/doctor/shared/channel-legacy-config-migrate.test.ts b/src/commands/doctor/shared/channel-legacy-config-migrate.test.ts index f730d18c14dc..e9ca784da7ac 100644 --- a/src/commands/doctor/shared/channel-legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/channel-legacy-config-migrate.test.ts @@ -1,5 +1,6 @@ -// Channel legacy config migration tests cover doctor repair of old channel config shapes. import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +// Channel legacy config migration tests cover doctor repair of old channel config shapes. +import type { OpenClawConfig } from "../../../config/types.js"; const { applyPluginDoctorCompatibilityMigrations, collectRelevantDoctorPluginIds } = vi.hoisted( () => ({ @@ -47,6 +48,46 @@ function firstMigrationCall() { } describe("bundled channel legacy config migrations", () => { + it("only renames heartbeat blocks that use the common visibility shape", () => { + collectRelevantDoctorPluginIds.mockReturnValue([]); + loadBundledChannelDoctorContractApi.mockReturnValue({ + normalizeCompatibilityConfig: ({ cfg }: { cfg: OpenClawConfig }) => ({ + config: cfg, + changes: [], + }), + }); + + const result = applyChannelDoctorCompatibilityMigrations({ + channels: { + feishu: { + heartbeat: { visibility: "hidden", intervalMs: 1000 }, + accounts: { + work: { heartbeat: { visibility: "visible" } }, + empty: { heartbeat: {} }, + }, + }, + slack: { + heartbeat: { showOk: true }, + accounts: { + work: { heartbeat: { showAlerts: false } }, + }, + }, + }, + }); + + const channels = result.next.channels as Record>; + const feishu = channels.feishu ?? {}; + const feishuAccounts = feishu.accounts as Record>; + expect(feishu.heartbeat).toEqual({ visibility: "hidden", intervalMs: 1000 }); + expect(feishuAccounts.work?.heartbeat).toEqual({ visibility: "visible" }); + expect(feishuAccounts.empty?.heartbeat).toEqual({}); + const slack = channels.slack ?? {}; + const slackAccounts = slack.accounts as Record>; + expect(slack.heartbeat).toBeUndefined(); + expect(slack.heartbeatVisibility).toEqual({ showOk: true }); + expect(slackAccounts.work?.heartbeatVisibility).toEqual({ showAlerts: false }); + }); + it("prefers bundled channel doctor contract normalizers before plugin registry fallback", () => { collectRelevantDoctorPluginIds.mockReturnValueOnce([]); loadBundledChannelDoctorContractApi.mockImplementation((channelId: string) => diff --git a/src/commands/doctor/shared/channel-legacy-config-migrate.ts b/src/commands/doctor/shared/channel-legacy-config-migrate.ts index 327f820f947e..dd88f1dbeb20 100644 --- a/src/commands/doctor/shared/channel-legacy-config-migrate.ts +++ b/src/commands/doctor/shared/channel-legacy-config-migrate.ts @@ -27,6 +27,59 @@ function collectRelevantDoctorChannelIds(raw: unknown): string[] { .toSorted(); } +function migrateHeartbeatVisibility(raw: Record, changes: string[]): void { + const channels = isRecord(raw.channels) ? raw.channels : null; + if (!channels) { + return; + } + const migrateEntry = ( + entry: Record, + path: string, + preserveEmptyPluginBlock = false, + ) => { + const heartbeat = isRecord(entry.heartbeat) ? entry.heartbeat : null; + const keys = heartbeat ? Object.keys(heartbeat) : []; + if ( + !heartbeat || + (preserveEmptyPluginBlock && keys.length === 0) || + keys.some((key) => key !== "showOk" && key !== "showAlerts" && key !== "useIndicator") + ) { + return; + } + if (entry.heartbeatVisibility === undefined) { + entry.heartbeatVisibility = entry.heartbeat; + changes.push(`Moved ${path}.heartbeat → ${path}.heartbeatVisibility.`); + } else { + changes.push(`Removed ${path}.heartbeat (${path}.heartbeatVisibility already set).`); + } + delete entry.heartbeat; + }; + const defaults = isRecord(channels.defaults) ? channels.defaults : null; + if (defaults) { + migrateEntry(defaults, "channels.defaults"); + } + for (const [channelId, value] of Object.entries(channels)) { + if (channelId === "defaults" || !isRecord(value)) { + continue; + } + const preserveEmptyPluginBlock = channelId === "feishu"; + migrateEntry(value, `channels.${channelId}`, preserveEmptyPluginBlock); + const accounts = isRecord(value.accounts) ? value.accounts : null; + if (!accounts) { + continue; + } + for (const [accountId, account] of Object.entries(accounts)) { + if (isRecord(account)) { + migrateEntry( + account, + `channels.${channelId}.accounts.${accountId}`, + preserveEmptyPluginBlock, + ); + } + } + } +} + function resolveBundledChannelCompatibilityNormalizer( channelId: string, ): ChannelDoctorCompatibilityNormalizer | undefined { @@ -60,6 +113,7 @@ export function applyChannelDoctorCompatibilityMigrations(cfg: Record { + const agent = asMutableRecord(value); + return agent + ? [{ agent, agentId: normalizeAgentId(entryId), path: `agents.entries.${entryId}` }] + : []; + }); + } + + const list = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + return list.flatMap((value, index) => { + const agent = asMutableRecord(value); + if (!agent) { + return []; + } + const pathId = + typeof agent.id === "string" && agent.id.trim() ? agent.id.trim() : String(index); + return [ + { + agent, + agentId: normalizeAgentId(typeof agent.id === "string" ? agent.id : undefined), + path: `agents.list.${pathId}`, + }, + ]; + }); +} diff --git a/src/commands/doctor/shared/codex-route-config-repair.ts b/src/commands/doctor/shared/codex-route-config-repair.ts index ce614888c148..411bf5c17052 100644 --- a/src/commands/doctor/shared/codex-route-config-repair.ts +++ b/src/commands/doctor/shared/codex-route-config-repair.ts @@ -1,6 +1,7 @@ import { AGENT_MODEL_CONFIG_KEYS } from "@openclaw/model-catalog-core/configured-model-refs"; import { asOptionalRecord as asMutableRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { listMutableCodexRouteAgentEntries } from "./codex-route-agent-entries.js"; import { maybeMigrateLegacyLosslessCompactionConfig, rewriteAgentCompactionRefs, @@ -34,8 +35,6 @@ import type { SharedDefaultCompactionOverrideConsumers, } from "./codex-route-types.js"; -const AGENT_MEDIA_MODEL_CONFIG_KEYS = ["imageGenerationModel", "videoGenerationModel"] as const; - function rewriteModelPolicyAllowRefs(params: { hits: CodexRouteHit[]; agent: MutableRecord; @@ -164,12 +163,13 @@ function rewriteAgentModelRefs(params: { blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); - for (const key of AGENT_MEDIA_MODEL_CONFIG_KEYS) { + const mediaModels = asMutableRecord(params.agent.mediaModels); + for (const key of ["image", "video"] as const) { rewriteModelConfigSlot({ hits: params.hits, - container: params.agent, + container: mediaModels ?? {}, key, - path: `${params.path}.${key}`, + path: `${params.path}.mediaModels.${key}`, blockedModelIdentities: params.blockedModelIdentities, }); } @@ -246,20 +246,15 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { env: params.env, }); const inheritedModelRef = readAgentPrimaryModelRef(nextConfig.agents?.defaults); - const agents = Array.isArray(nextConfig.agents?.list) ? nextConfig.agents.list : []; - for (const [index, agent] of agents.entries()) { - const agentRecord = asMutableRecord(agent); - if (!agentRecord) { - continue; - } - const id = readAgentPathId(agentRecord, index); + const agents = listMutableCodexRouteAgentEntries(nextConfig); + for (const { agent: agentRecord, agentId, path } of agents) { rewriteAgentModelRefs({ cfg: nextConfig, preRepairCfg: params.cfg, hits, agent: agentRecord, - path: `agents.list.${id}`, - agentId: id, + path, + agentId, currentRuntime: resolveRuntime({ agentRuntime: ignoreLegacyAgentRuntimePins ? undefined @@ -353,9 +348,9 @@ function rewriteNonAgentModelRefs(params: { rewriteStringModelSlotIfCanonicalCodexRuntime({ cfg: params.cfg, hits: params.hits, - container: asMutableRecord(params.cfg.messages?.tts), + container: asMutableRecord(params.cfg.tts), key: "summaryModel", - path: "messages.tts.summaryModel", + path: "tts.summaryModel", blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); @@ -409,7 +404,3 @@ function isCompactionOnlyRouteHit(hit: CodexRouteHit): boolean { (hit.path.endsWith(".compaction.model") || hit.path.endsWith(".compaction.memoryFlush.model")) ); } - -function readAgentPathId(agent: MutableRecord, index: number): string { - return typeof agent.id === "string" && agent.id.trim() ? agent.id.trim() : String(index); -} diff --git a/src/commands/doctor/shared/codex-route-config-scan.ts b/src/commands/doctor/shared/codex-route-config-scan.ts index 37959fe85329..2cfb56912b42 100644 --- a/src/commands/doctor/shared/codex-route-config-scan.ts +++ b/src/commands/doctor/shared/codex-route-config-scan.ts @@ -2,7 +2,7 @@ import { AGENT_MODEL_CONFIG_KEYS } from "@openclaw/model-catalog-core/configured import { asOptionalRecord as asMutableRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString as normalizeString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { normalizeAgentId } from "../../../routing/session-key.js"; +import { listMutableCodexRouteAgentEntries } from "./codex-route-agent-entries.js"; import { asAgentRuntimePolicyConfig, isOpenAICodexModelRef, @@ -26,11 +26,8 @@ import type { CodexRouteHit, DisabledCodexPluginRouteHit, DisabledCodexPluginRouteIssue, - MutableRecord, } from "./codex-route-types.js"; -const AGENT_MEDIA_MODEL_CONFIG_KEYS = ["imageGenerationModel", "videoGenerationModel"] as const; - function collectModelsMapRefs(params: { hits: CodexRouteHit[]; path: string; @@ -94,11 +91,12 @@ function collectAgentModelRefs(params: { blockedModelIdentities: params.blockedModelIdentities, }); } - for (const key of AGENT_MEDIA_MODEL_CONFIG_KEYS) { + const mediaModels = asMutableRecord(agent.mediaModels); + for (const key of ["image", "video"] as const) { collectModelConfigSlot({ hits: params.hits, - path: `${params.path}.${key}`, - value: agent[key], + path: `${params.path}.mediaModels.${key}`, + value: mediaModels?.[key], blockedModelIdentities: params.blockedModelIdentities, }); } @@ -156,17 +154,12 @@ export function collectConfigModelRefs( blockedModelIdentities, }); - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - for (const [index, agent] of agents.entries()) { - const agentRecord = asMutableRecord(agent); - if (!agentRecord) { - continue; - } - const id = readAgentPathId(agentRecord, index); + const agents = listMutableCodexRouteAgentEntries(cfg); + for (const { agent: agentRecord, path } of agents) { collectAgentModelRefs({ hits, agent: agentRecord, - path: `agents.list.${id}`, + path, runtime: resolveRuntime({ agentRuntime: asAgentRuntimePolicyConfig(agentRecord.agentRuntime), defaultsRuntime, @@ -207,8 +200,8 @@ export function collectConfigModelRefs( }); collectStringModelSlot({ hits, - path: "messages.tts.summaryModel", - value: cfg.messages?.tts?.summaryModel, + path: "tts.summaryModel", + value: cfg.tts?.summaryModel, blockedModelIdentities, }); collectStringModelSlot({ @@ -247,7 +240,7 @@ export function collectDisabledCodexPluginRouteHits( }); } - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + const agents = listMutableCodexRouteAgentEntries(cfg); const inheritedDefaultAuxRefs = defaultRefs.filter( (ref) => ref.path === "agents.defaults.heartbeat.model" || @@ -263,15 +256,7 @@ export function collectDisabledCodexPluginRouteHits( const channelRefs = collectChannelAgentRuntimeModelRefs(cfg); const candidateRefs: Array<{ path: string; modelRef: string; agentId?: string }> = agents.length === 0 ? [...defaultRefs, ...channelRefs] : []; - for (const [index, agent] of agents.entries()) { - const agentRecord = asMutableRecord(agent); - if (!agentRecord) { - continue; - } - const pathId = readAgentPathId(agentRecord, index); - const agentId = normalizeAgentId( - typeof agentRecord.id === "string" ? agentRecord.id : undefined, - ); + for (const { agent: agentRecord, agentId, path } of agents) { for (const ref of channelRefs) { candidateRefs.push({ path: ref.path, modelRef: ref.modelRef, agentId }); } @@ -287,7 +272,7 @@ export function collectDisabledCodexPluginRouteHits( inheritedModelRefs.push(...inheritedDefaultModelPolicyRefs); for (const ref of collectAgentRuntimeModelRefs({ agent: agentRecord, - path: `agents.list.${pathId}`, + path, fallbackModelRefs: inheritedDefaultModelRefs, inheritedModelRefs, })) { @@ -458,7 +443,3 @@ function collectChannelAgentRuntimeModelRefs( } return refs; } - -function readAgentPathId(agent: MutableRecord, index: number): string { - return typeof agent.id === "string" && agent.id.trim() ? agent.id.trim() : String(index); -} diff --git a/src/commands/doctor/shared/codex-route-model-ref.ts b/src/commands/doctor/shared/codex-route-model-ref.ts index 0f380f5300ab..643b0cca8a29 100644 --- a/src/commands/doctor/shared/codex-route-model-ref.ts +++ b/src/commands/doctor/shared/codex-route-model-ref.ts @@ -10,6 +10,7 @@ import { configuredModelRouteNeedsCodex } from "../../../config/codex-plugin-dia import type { AgentRuntimePolicyConfig } from "../../../config/types.agents-shared.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { normalizeAgentId } from "../../../routing/session-key.js"; +import { listMutableCodexRouteAgentEntries } from "./codex-route-agent-entries.js"; import type { MutableRecord } from "./codex-route-types.js"; export function normalizeRuntimeString(value: unknown): string | undefined { @@ -316,14 +317,8 @@ function findAgentById( return undefined; } const normalizedAgentId = normalizeAgentId(agentId); - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - return agents - .map((agent) => asMutableRecord(agent)) - .find( - (agent) => - normalizeAgentId(typeof agent?.id === "string" ? agent.id : undefined) === - normalizedAgentId, - ); + return listMutableCodexRouteAgentEntries(cfg).find((entry) => entry.agentId === normalizedAgentId) + ?.agent; } function resolveAliasFromModelsMap( diff --git a/src/commands/doctor/shared/codex-route-runtime-policy.ts b/src/commands/doctor/shared/codex-route-runtime-policy.ts index 2ba081748612..35e3c8280d82 100644 --- a/src/commands/doctor/shared/codex-route-runtime-policy.ts +++ b/src/commands/doctor/shared/codex-route-runtime-policy.ts @@ -5,6 +5,7 @@ import { normalizeOptionalLowercaseString as normalizeString } from "@openclaw/n import { resolveModelRuntimePolicy } from "../../../agents/model-runtime-policy.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { normalizeAgentId } from "../../../routing/session-key.js"; +import { listMutableCodexRouteAgentEntries } from "./codex-route-agent-entries.js"; import { canonicalOpenAIModelUsesCodexRuntime, isBlockedLegacyCodexModelRef, @@ -108,23 +109,22 @@ function shieldExplicitListedAgentRefsFromDefaultPolicy(params: { changes: string[]; env?: NodeJS.ProcessEnv; }): void { - for (const [index, agent] of (params.cfg.agents?.list ?? []).entries()) { + for (const { agent, agentId, path } of listMutableCodexRouteAgentEntries(params.cfg)) { if (!agentExplicitlyReferencesCanonicalModel(agent, params.modelRef)) { continue; } - const id = typeof agent.id === "string" && agent.id.trim() ? agent.id.trim() : String(index); const runtimeId = resolveCurrentRuntimeIdForCanonicalModel({ cfg: params.cfg, modelRef: params.modelRef, - agentId: id, + agentId, env: params.env, }); if (runtimeId === params.targetRuntimeId) { continue; } setModelRuntimePolicy({ - agent: agent as MutableRecord, - agentPath: `agents.list.${id}`, + agent, + agentPath: path, modelRef: params.modelRef, runtimeId, changes: params.changes, @@ -151,8 +151,12 @@ function legacyEntryExplicitNonDefaultRuntimeId( } export function agentIdFromAgentPath(agentPath: string): string | undefined { - const prefix = "agents.list."; - return agentPath.startsWith(prefix) ? agentPath.slice(prefix.length) : undefined; + for (const prefix of ["agents.entries.", "agents.list."]) { + if (agentPath.startsWith(prefix)) { + return agentPath.slice(prefix.length); + } + } + return undefined; } type PreRepairRuntimePin = { @@ -229,7 +233,8 @@ function agentModelMapExactRuntimeIdForLegacyRef(params: { } const agentId = normalizeAgentId(params.agentId); const agent = agentId - ? (params.cfg.agents?.list ?? []).find((entry) => normalizeAgentId(entry.id) === agentId) + ? listMutableCodexRouteAgentEntries(params.cfg).find((entry) => entry.agentId === agentId) + ?.agent : undefined; const modelMaps = [ asMutableRecord(agent?.models), @@ -464,17 +469,8 @@ export function rewriteModelConfigSlotIfCanonicalCodexRuntime(params: { export function clearConfigLegacyAgentRuntimePolicies(cfg: OpenClawConfig): string[] { const changes: string[] = []; clearLegacyAgentRuntimePolicy(asMutableRecord(cfg.agents?.defaults), "agents.defaults", changes); - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - for (const [index, agent] of agents.entries()) { - const agentRecord = asMutableRecord(agent); - if (!agentRecord) { - continue; - } - const id = - typeof agentRecord.id === "string" && agentRecord.id.trim() - ? agentRecord.id.trim() - : String(index); - clearLegacyAgentRuntimePolicy(agentRecord, `agents.list.${id}`, changes); + for (const { agent, path } of listMutableCodexRouteAgentEntries(cfg)) { + clearLegacyAgentRuntimePolicy(agent, path, changes); } return changes; } diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index 7960b49c6dee..72694ecf6919 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -2086,24 +2086,23 @@ describe("collectCodexRouteWarnings", () => { model: "openai-codex/gpt-5.4-mini", }, }, - imageGenerationModel: { - primary: "openai-codex/gpt-image-2", - fallbacks: ["openai-codex/gpt-image-1"], - }, - videoGenerationModel: { - primary: "openai-codex/sora-2", + mediaModels: { + image: { + primary: "openai-codex/gpt-image-2", + fallbacks: ["openai-codex/gpt-image-1"], + }, + video: { primary: "openai-codex/sora-2" }, }, models: { "openai-codex/gpt-5.5": { alias: "codex" }, }, }, - list: [ - { - id: "worker", + entries: { + worker: { model: "openai-codex/gpt-5.4", agentRuntime: { id: "codex" }, }, - ], + }, }, channels: { modelByChannel: { @@ -2122,10 +2121,8 @@ describe("collectCodexRouteWarnings", () => { model: "openai-codex/gpt-5.4", }, }, - messages: { - tts: { - summaryModel: "openai-codex/gpt-5.4-mini", - }, + tts: { + summaryModel: "openai-codex/gpt-5.4-mini", }, } as unknown as OpenClawConfig, shouldRepair: true, @@ -2138,25 +2135,25 @@ describe("collectCodexRouteWarnings", () => { "Repaired Codex model routes:", "- agents.defaults.model.primary: openai-codex/gpt-5.5 -> openai/gpt-5.5.", "- agents.defaults.model.fallbacks.0: openai-codex/gpt-5.4 -> openai/gpt-5.4.", - "- agents.defaults.imageGenerationModel.primary: openai-codex/gpt-image-2 -> openai/gpt-image-2.", - "- agents.defaults.imageGenerationModel.fallbacks.0: openai-codex/gpt-image-1 -> openai/gpt-image-1.", - "- agents.defaults.videoGenerationModel.primary: openai-codex/sora-2 -> openai/sora-2.", "- agents.defaults.heartbeat.model: openai-codex/gpt-5.4-mini -> openai/gpt-5.4-mini.", "- agents.defaults.subagents.model.primary: openai-codex/gpt-5.5 -> openai/gpt-5.5.", "- agents.defaults.subagents.model.fallbacks.0: openai-codex/gpt-5.4 -> openai/gpt-5.4.", "- agents.defaults.compaction.memoryFlush.model: openai-codex/gpt-5.4-mini -> openai/gpt-5.4-mini.", + "- agents.defaults.mediaModels.image.primary: openai-codex/gpt-image-2 -> openai/gpt-image-2.", + "- agents.defaults.mediaModels.image.fallbacks.0: openai-codex/gpt-image-1 -> openai/gpt-image-1.", + "- agents.defaults.mediaModels.video.primary: openai-codex/sora-2 -> openai/sora-2.", "- agents.defaults.models.openai-codex/gpt-5.5: openai-codex/gpt-5.5 -> openai/gpt-5.5.", - "- agents.list.worker.model: openai-codex/gpt-5.4 -> openai/gpt-5.4.", + "- agents.entries.worker.model: openai-codex/gpt-5.4 -> openai/gpt-5.4.", "- channels.modelByChannel.telegram.default: openai-codex/gpt-5.4 -> openai/gpt-5.4.", "- hooks.mappings.0.model: openai-codex/gpt-5.4-mini -> openai/gpt-5.4-mini.", "- hooks.gmail.model: openai-codex/gpt-5.4 -> openai/gpt-5.4.", - "- messages.tts.summaryModel: openai-codex/gpt-5.4-mini -> openai/gpt-5.4-mini.", + "- tts.summaryModel: openai-codex/gpt-5.4-mini -> openai/gpt-5.4-mini.", ].join("\n"), 'Set agents.defaults.models.openai/gpt-5.5.agentRuntime.id to "codex" so repaired OpenAI refs keep Codex auth routing.', 'Set agents.defaults.models.openai/gpt-5.4.agentRuntime.id to "codex" so repaired OpenAI refs keep Codex auth routing.', - 'Set agents.list.worker.models.openai/gpt-5.4.agentRuntime.id to "codex" so repaired OpenAI refs keep Codex auth routing.', + 'Set agents.entries.worker.models.openai/gpt-5.4.agentRuntime.id to "codex" so repaired OpenAI refs keep Codex auth routing.', "Removed agents.defaults.agentRuntime; runtime is now provider/model scoped.", - "Removed agents.list.worker.agentRuntime; runtime is now provider/model scoped.", + "Removed agents.entries.worker.agentRuntime; runtime is now provider/model scoped.", "Removed agents.defaults.compaction.model; Codex runtime uses native server-side compaction.", ]); expect(result.cfg.agents?.defaults?.model).toEqual({ @@ -2170,11 +2167,11 @@ describe("collectCodexRouteWarnings", () => { }); expect(result.cfg.agents?.defaults?.compaction?.model).toBeUndefined(); expect(result.cfg.agents?.defaults?.compaction?.memoryFlush?.model).toBe("openai/gpt-5.4-mini"); - expect(result.cfg.agents?.defaults?.imageGenerationModel).toEqual({ + expect(result.cfg.agents?.defaults?.mediaModels?.image).toEqual({ primary: "openai/gpt-image-2", fallbacks: ["openai/gpt-image-1"], }); - expect(result.cfg.agents?.defaults?.videoGenerationModel).toEqual({ + expect(result.cfg.agents?.defaults?.mediaModels?.video).toEqual({ primary: "openai/sora-2", }); expect(result.cfg.agents?.defaults?.agentRuntime).toBeUndefined(); @@ -2182,16 +2179,15 @@ describe("collectCodexRouteWarnings", () => { "openai/gpt-5.5": { alias: "codex", agentRuntime: { id: "codex" } }, "openai/gpt-5.4": { agentRuntime: { id: "codex" } }, }); - expect(result.cfg.agents?.list?.[0]?.id).toBe("worker"); - expect(result.cfg.agents?.list?.[0]?.model).toBe("openai/gpt-5.4"); - expect(result.cfg.agents?.list?.[0]?.agentRuntime).toBeUndefined(); - expect(result.cfg.agents?.list?.[0]?.models).toEqual({ + expect(result.cfg.agents?.entries?.worker?.model).toBe("openai/gpt-5.4"); + expect(result.cfg.agents?.entries?.worker?.agentRuntime).toBeUndefined(); + expect(result.cfg.agents?.entries?.worker?.models).toEqual({ "openai/gpt-5.4": { agentRuntime: { id: "codex" } }, }); expect(result.cfg.channels?.modelByChannel?.telegram?.default).toBe("openai/gpt-5.4"); expect(result.cfg.hooks?.mappings?.[0]?.model).toBe("openai/gpt-5.4-mini"); expect(result.cfg.hooks?.gmail?.model).toBe("openai/gpt-5.4"); - expect(result.cfg.messages?.tts?.summaryModel).toBe("openai/gpt-5.4-mini"); + expect(result.cfg.tts?.summaryModel).toBe("openai/gpt-5.4-mini"); }); it("keeps whole-agent runtime pins while repairing compaction-only model refs and overrides", () => { @@ -3926,10 +3922,8 @@ describe("collectCodexRouteWarnings", () => { hooks: { mappings: [{ model: "openai-codex/gpt-5.4" }], }, - messages: { - tts: { - summaryModel: "openai-codex/gpt-5.4", - }, + tts: { + summaryModel: "openai-codex/gpt-5.4", }, } as unknown as OpenClawConfig, shouldRepair: true, @@ -3939,7 +3933,7 @@ describe("collectCodexRouteWarnings", () => { expect(result.cfg.channels?.modelByChannel?.telegram?.default).toBe("openai/gpt-5.5"); expect(result.cfg.channels?.discord?.voice?.model).toBe("openai/gpt-5.4-mini"); expect(result.cfg.hooks?.mappings?.[0]?.model).toBe("openai/gpt-5.4"); - expect(result.cfg.messages?.tts?.summaryModel).toBe("openai/gpt-5.4"); + expect(result.cfg.tts?.summaryModel).toBe("openai/gpt-5.4"); expect(result.cfg.agents?.defaults?.models).toBeUndefined(); }); diff --git a/src/commands/doctor/shared/configured-provider-selection-ids.ts b/src/commands/doctor/shared/configured-provider-selection-ids.ts index dc709b0ad65b..c033a20a1a19 100644 --- a/src/commands/doctor/shared/configured-provider-selection-ids.ts +++ b/src/commands/doctor/shared/configured-provider-selection-ids.ts @@ -60,9 +60,6 @@ function collectConfiguredMediaProviderIds(cfg: OpenClawConfig): Set { }; const media = cfg.tools?.media; addModels(media?.models); - addModels(media?.image?.models); - addModels(media?.audio?.models); - addModels(media?.video?.models); return ids; } diff --git a/src/commands/doctor/shared/deprecation-compat.ts b/src/commands/doctor/shared/deprecation-compat.ts index 03649ea92c69..149d163783f2 100644 --- a/src/commands/doctor/shared/deprecation-compat.ts +++ b/src/commands/doctor/shared/deprecation-compat.ts @@ -62,6 +62,71 @@ function deprecatedCompatRecord( // doctor fixes, and replacement notes should be revalidated against the current // architecture because ownership and config footprint can shift during rollout. const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ + deprecatedCompatRecord({ + code: "doctor-tier-eval-tranche", + deprecated: "2026-07-20", + warningStarts: "2026-07-20", + removeAfter: "2026-09-18", + owner: "config", + introduced: "2026-07-20", + source: "approved tier-eval tranche 6a and small hookify retirements", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts", + replacement: + "canonical config owners, shared SQLite state, built-in defaults, and plugin hooks", + docsPath: "/gateway/doctor", + tests: [ + "src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts", + "src/config/dead-config-keys.test.ts", + ], + }), + deprecatedCompatRecord({ + code: "doctor-final-layout-polish", + deprecated: "2026-07-19", + warningStarts: "2026-07-19", + removeAfter: "2026-09-18", + owner: "config", + introduced: "2026-07-19", + source: "final layout renames, removed knobs, and agents.list", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts", + replacement: "canonical final layout and built-in defaults", + docsPath: "/gateway/doctor", + tests: [ + "src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts", + "src/config/dead-config-keys.test.ts", + ], + }), + deprecatedCompatRecord({ + code: "doctor-phase4-product-config-retirements", + deprecated: "2026-07-19", + warningStarts: "2026-07-19", + removeAfter: "2026-09-18", + owner: "config", + introduced: "2026-07-19", + source: "systemAgent; crestodian; marketplaces; cli.banner.taglineMode; commitments", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts", + replacement: "built-in rescue, marketplace, banner, and disabled commitments behavior", + docsPath: "/gateway/doctor", + tests: [ + "src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts", + "src/config/dead-config-keys.test.ts", + ], + }), + deprecatedCompatRecord({ + code: "doctor-media-models-consolidation", + deprecated: "2026-07-19", + warningStarts: "2026-07-19", + removeAfter: "2026-09-18", + owner: "tools", + introduced: "2026-07-19", + source: "tools.media.image/audio/video models", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts", + replacement: "capability-tagged tools.media.models plus per-capability policy and defaults", + docsPath: "/nodes/media-understanding", + tests: [ + "src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts", + "src/config/dead-config-keys.test.ts", + ], + }), deprecatedCompatRecord({ code: "doctor-runtime-tuning-knobs-purge", deprecated: "2026-07-19", @@ -232,13 +297,29 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], }), deprecatedCompatRecord({ - code: "doctor-top-level-memory-search", + code: "doctor-memory-search-owner-consolidation", + deprecated: "2026-07-19", + warningStarts: "2026-07-19", + removeAfter: "2026-09-18", owner: "config", - introduced: "2026-04-26", - source: "memorySearch", + introduced: "2026-07-19", + source: "memorySearch; agents.defaults.memorySearch; agents.list[].memorySearch", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts", - replacement: "agents.defaults.memorySearch", - docsPath: "/cli/doctor", + replacement: "memory.search; agents.list[].memory.search", + docsPath: "/reference/memory-config", + tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], + }), + deprecatedCompatRecord({ + code: "doctor-session-typing-mode-owner", + deprecated: "2026-07-19", + warningStarts: "2026-07-19", + removeAfter: "2026-09-18", + owner: "agent-runtime", + introduced: "2026-07-19", + source: "session.typingMode", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts", + replacement: "agents.defaults.typingMode or agents.list[].typingMode", + docsPath: "/concepts/typing-indicators", tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], }), deprecatedCompatRecord({ @@ -279,7 +360,7 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ introduced: "2026-04-26", source: "audio.transcription", migration: "src/commands/doctor/shared/legacy-config-migrations.audio.ts", - replacement: "tools.media.audio.models", + replacement: "capability-tagged tools.media.models", docsPath: "/tools/media-overview", tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], }), @@ -341,13 +422,26 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ notes: "WebChat is an internal control surface, not a configurable outbound channel. Runtime ignores the retired channel key; doctor removes stale config.", }), + deprecatedCompatRecord({ + code: "doctor-tts-top-level-owner", + deprecated: "2026-07-19", + warningStarts: "2026-07-19", + removeAfter: "2026-09-18", + owner: "tts", + introduced: "2026-07-19", + source: "messages.tts", + migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts", + replacement: "top-level tts", + docsPath: "/tools/tts", + tests: ["src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts"], + }), deprecatedCompatRecord({ code: "doctor-tts-provider-aliases", owner: "tts", introduced: "2026-04-26", source: "messages.tts.openai/elevenlabs/edge and plugins.entries.voice-call.config.tts aliases", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts", - replacement: "messages.tts.providers. and microsoft instead of edge", + replacement: "tts.providers. and microsoft instead of edge", docsPath: "/tools/tts", tests: ["src/commands/doctor/shared/legacy-config-migrate.test.ts"], }), @@ -359,7 +453,7 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [ "messages.tts.enabled, agents.list[].tts.enabled, supported channel TTS enabled fields, and voice-call plugin tts.enabled", migration: "src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts", replacement: - 'supported messages/agents/channels/plugins TTS auto mode, for example auto: "always" or auto: "off"', + 'supported top-level/agents/channels/plugins TTS auto mode, for example auto: "always" or auto: "off"', docsPath: "/tools/tts", tests: ["src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts"], }), diff --git a/src/commands/doctor/shared/legacy-config-core-migrate.ts b/src/commands/doctor/shared/legacy-config-core-migrate.ts index d96661e166a6..b21f9c24e37b 100644 --- a/src/commands/doctor/shared/legacy-config-core-migrate.ts +++ b/src/commands/doctor/shared/legacy-config-core-migrate.ts @@ -7,6 +7,7 @@ import type { LegacyCodexModelIdentity } from "./codex-route-model-ref.js"; import { pruneBindingsForMissingAgents } from "./legacy-config-binding-repair.js"; import { normalizeBaseCompatibilityConfigValues } from "./legacy-config-compatibility-base.js"; import { normalizeLegacyOpenAICodexModelsAddMetadata } from "./legacy-config-core-normalizers.js"; +import { stripRetiredTuningKnobs } from "./legacy-config-migrations.runtime.retired-media.js"; function repairNullAgentWorkspaces(cfg: OpenClawConfig, changes: string[]): OpenClawConfig { const agents = cfg.agents?.list; @@ -72,6 +73,11 @@ export function normalizeCompatibilityConfigValues( }, options.blockedModelIdentities, ); + const tuningCandidate = structuredClone(next); + if (stripRetiredTuningKnobs(tuningCandidate)) { + next = tuningCandidate; + changes.push("Removed retired runtime tuning knobs; built-in defaults now apply."); + } const channelMigrations = applyChannelDoctorCompatibilityMigrations(next); if (channelMigrations.changes.length > 0) { next = channelMigrations.next; diff --git a/src/commands/doctor/shared/legacy-config-core-normalizers.ts b/src/commands/doctor/shared/legacy-config-core-normalizers.ts index b8971e4f7a81..ab36cc53b0d1 100644 --- a/src/commands/doctor/shared/legacy-config-core-normalizers.ts +++ b/src/commands/doctor/shared/legacy-config-core-normalizers.ts @@ -952,7 +952,7 @@ export function normalizeLegacyNanoBananaSkill( }; } - const existingImageGenerationModel = next.agents?.defaults?.imageGenerationModel; + const existingImageGenerationModel = next.agents?.defaults?.mediaModels?.image; if (existingImageGenerationModel === undefined) { next = { ...next, @@ -960,14 +960,15 @@ export function normalizeLegacyNanoBananaSkill( ...next.agents, defaults: { ...next.agents?.defaults, - imageGenerationModel: { - primary: NANO_BANANA_MODEL, + mediaModels: { + ...next.agents?.defaults?.mediaModels, + image: { primary: NANO_BANANA_MODEL }, }, }, }, }; changes.push( - `Moved skills.entries.${NANO_BANANA_SKILL_KEY} → agents.defaults.imageGenerationModel.primary (${NANO_BANANA_MODEL}).`, + `Moved skills.entries.${NANO_BANANA_SKILL_KEY} → agents.defaults.mediaModels.image.primary (${NANO_BANANA_MODEL}).`, ); } diff --git a/src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts b/src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts new file mode 100644 index 000000000000..6b959e99ebb9 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrate.e2e.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObjectRaw } from "../../../config/validation.js"; +import { applyLegacyDoctorMigrations } from "./legacy-config-compat.js"; +import { migrateLegacyConfig } from "./legacy-config-migrate.js"; + +describe("legacy config migration end to end", () => { + it("reshapes duplicate agent ids deterministically and keeps canonical entries", () => { + const duplicate = applyLegacyDoctorMigrations({ + agents: { + list: [ + { id: "main", name: "first" }, + { id: "main", name: "second" }, + ], + }, + }); + expect(duplicate.next).toEqual({ + agents: { entries: { main: { name: "first" }, "main-2": { name: "second" } } }, + }); + expect(applyLegacyDoctorMigrations(duplicate.next)).toEqual({ next: null, changes: [] }); + + const canonicalWins = applyLegacyDoctorMigrations({ + agents: { entries: { main: { name: "canonical" } }, list: [{ id: "main", name: "old" }] }, + }); + expect(canonicalWins.next).toEqual({ agents: { entries: { main: { name: "canonical" } } } }); + + const prototypeId = applyLegacyDoctorMigrations({ + agents: { list: [{ id: "__proto__", name: "prototype-safe" }] }, + }); + const prototypeEntries = (prototypeId.next?.agents as { entries?: Record }) + ?.entries; + expect(Object.hasOwn(prototypeEntries ?? {}, "__proto__")).toBe(true); + + const normalizedId = applyLegacyDoctorMigrations({ + agents: { list: [{ id: "Team Ops", name: "normalized" }] }, + }); + expect(normalizedId.next).toEqual({ + agents: { entries: { "team-ops": { name: "normalized" } } }, + }); + }); + + it("keeps agents.defaults.tts outside the schema", () => { + expect(validateConfigObjectRaw({ agents: { defaults: { tts: {} } } }).ok).toBe(false); + }); + + it("canonicalizes a multi-family legacy config and is idempotent", () => { + const result = migrateLegacyConfig({ + env: { shellEnv: { enabled: true }, API_ORIGIN: "https://example.test" }, + agents: { + defaults: { + pdfMaxBytesMb: 12, + imageGenerationModel: "openai/image-1", + promptOverlays: { gpt5: { personality: "off" } }, + envelopeTimestamp: "off", + sandbox: { browser: { enableNoVnc: false } }, + }, + list: [{ id: "main", name: "Main", tools: { exec: { timeoutSec: 45 } } }], + }, + tools: { exec: { timeoutSec: 30 } }, + media: { ttlHours: 24, preserveFilenames: true }, + audit: { enabled: false, messages: "direct" }, + diagnostics: { + otel: { captureContent: { enabled: false, toolInputs: true } }, + cacheTrace: { enabled: true, filePath: "/tmp/trace.jsonl", includePrompt: false }, + }, + browser: { + color: "#ffffff", + ssrfPolicy: { allowedHostnames: ["localhost"], hostnameAllowlist: ["*.example.com"] }, + profiles: { chrome: { driver: "extension", color: "#000000" } }, + }, + gateway: { + reload: { mode: "hot" }, + nodes: { + skills: { enabled: false }, + allowCommands: ["camera.snap"], + denyCommands: ["system.run"], + }, + controlUi: { chatMessageMaxWidth: "82%" }, + }, + logging: { consoleStyle: "compact" }, + cron: { failureDestination: { channel: "telegram", to: "123" } }, + messages: { + statusReactions: { enabled: true, emojis: { done: "✅" } }, + removeAckAfterReply: true, + }, + channels: { + defaults: { heartbeat: { showOk: true } }, + slack: { + identity: "user", + groupPolicy: "allowlist", + dmPolicy: "pairing", + mode: "socket", + webhookPath: "/slack/events", + userTokenReadOnly: true, + socketMode: { clientPingTimeout: 1000 }, + }, + whatsapp: { + dmPolicy: "pairing", + groupPolicy: "allowlist", + mediaMaxMb: 50, + debounceMs: 0, + messagePrefix: "[wa]", + ackReaction: { emoji: "👀", direct: false, group: "mentions" }, + }, + imessage: { + dmPolicy: "pairing", + groupPolicy: "allowlist", + coalesceSameSenderDms: true, + }, + }, + mcp: { + servers: { + docs: { + command: "docs", + workingDirectory: "/tmp/docs", + supports_parallel_tool_calls: true, + ssl_verify: false, + codex: { default_tools_approval_mode: "prompt" }, + }, + }, + }, + }); + + expect(result.partiallyValid).toBeUndefined(); + expect(result.config).toMatchObject({ + env: { shellEnv: { enabled: true }, vars: { API_ORIGIN: "https://example.test" } }, + agents: { + defaults: { pdfMaxMb: 12, mediaModels: { image: "openai/image-1" } }, + entries: { main: { name: "Main", tools: { exec: { timeoutSeconds: 45 } } } }, + }, + tools: { exec: { timeoutSeconds: 30 } }, + attachments: { ttlHours: 24 }, + logging: { consoleStyle: "pretty", audit: { enabled: false, messages: "direct" } }, + diagnostics: { otel: { captureContent: false }, cacheTrace: { enabled: true } }, + gateway: { + reload: { mode: "hybrid" }, + nodes: { allowSkills: false, commands: { allow: ["camera.snap"], deny: ["system.run"] } }, + }, + ui: { prefs: { chatMessageMaxWidth: "82%" } }, + cron: { failureAlert: { channel: "telegram", to: "123" } }, + channels: { + defaults: { heartbeatVisibility: { showOk: true } }, + slack: { postAs: "user" }, + whatsapp: { responsePrefix: "[wa]" }, + }, + mcp: { + servers: { + docs: { + command: "docs", + cwd: "/tmp/docs", + supportsParallelToolCalls: true, + sslVerify: false, + codex: { defaultToolsApprovalMode: "prompt" }, + }, + }, + }, + }); + const validation = validateConfigObjectRaw(result.config); + expect(validation.ok, validation.ok ? undefined : JSON.stringify(validation.issues)).toBe(true); + expect(applyLegacyDoctorMigrations(result.config)).toEqual({ next: null, changes: [] }); + const serialized = JSON.stringify(result.config); + for (const key of [ + "pdfMaxBytesMb", + "timeoutSec", + "hostnameAllowlist", + "enableNoVnc", + "preserveFilenames", + "ownerDisplay", + "removeAckAfterReply", + ]) { + expect(serialized).not.toContain(`"${key}"`); + } + }); +}); diff --git a/src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts b/src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts index bc931448303b..7e1afcf76fd3 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.provider-shapes.test.ts @@ -153,7 +153,7 @@ describe("legacy migrate provider-shaped config", () => { }); }); - it("moves messages.tts. keys into messages.tts.providers", () => { + it("moves messages.tts to top-level tts before normalizing provider keys", () => { const res = migrateLegacyConfig({ messages: { tts: { @@ -167,10 +167,11 @@ describe("legacy migrate provider-shaped config", () => { }); expect(res.changes).toStrictEqual([ - "Moved messages.tts.elevenlabs → messages.tts.providers.elevenlabs.", - "Moved messages.tts.providers.elevenlabs.voiceId → messages.tts.providers.elevenlabs.speakerVoiceId.", + "Moved messages.tts to top-level tts.", + "Moved tts.elevenlabs → tts.providers.elevenlabs.", + "Moved tts.providers.elevenlabs.voiceId → tts.providers.elevenlabs.speakerVoiceId.", ]); - expect(res.config?.messages?.tts).toEqual({ + expect(res.config?.tts).toEqual({ provider: "elevenlabs", providers: { elevenlabs: { @@ -181,6 +182,75 @@ describe("legacy migrate provider-shaped config", () => { }); }); + it("routes legacy messages.tts.realtime voice to talk.realtime.speakerVoice", () => { + const res = migrateLegacyConfig({ + messages: { + tts: { + provider: "openai", + realtime: { voice: "cedar" }, + }, + }, + }); + + expect(res.changes).toStrictEqual([ + "Moved messages.tts.realtime voice → talk.realtime.speakerVoice.", + "Moved messages.tts to top-level tts.", + ]); + expect(res.config?.tts).toEqual({ provider: "openai" }); + expect(res.config?.talk?.realtime?.speakerVoice).toBe("cedar"); + }); + + it("drops legacy messages.tts.realtime when talk.realtime.speakerVoice is set", () => { + const res = migrateLegacyConfig({ + talk: { realtime: { speakerVoice: "marin" } }, + messages: { + tts: { + provider: "openai", + realtime: { voice: "cedar" }, + }, + }, + }); + + expect(res.changes).toStrictEqual([ + "Removed messages.tts.realtime (talk.realtime already configured).", + "Moved messages.tts to top-level tts.", + ]); + expect(res.config?.tts).toEqual({ provider: "openai" }); + expect(res.config?.talk?.realtime?.speakerVoice).toBe("marin"); + }); + + it("keeps canonical top-level tts values while filling missing legacy settings", () => { + const res = migrateLegacyConfig({ + tts: { + provider: "openai", + providers: { openai: { model: "canonical-model" } }, + }, + messages: { + tts: { + provider: "elevenlabs", + auto: "always", + providers: { openai: { speakerVoice: "coral" } }, + }, + }, + }); + + expect(res.changes).toStrictEqual(["Moved messages.tts to top-level tts."]); + expect(res.config?.tts).toEqual({ + provider: "openai", + auto: "always", + providers: { openai: { model: "canonical-model", speakerVoice: "coral" } }, + }); + expect(res.config?.messages).toEqual({}); + }); + + it("removes invalid messages.tts values", () => { + const res = migrateLegacyConfig({ messages: { tts: true } }); + + expect(res.changes).toStrictEqual(["Removed messages.tts (invalid value)."]); + expect(res.config?.messages).toEqual({}); + expect(res.config?.tts).toBeUndefined(); + }); + it("moves legacy edge provider aliases into microsoft tts config", () => { const res = migrateLegacyConfig({ messages: { @@ -201,11 +271,12 @@ describe("legacy migrate provider-shaped config", () => { }); expect(res.changes).toStrictEqual([ - 'Moved messages.tts.provider "edge" → "microsoft".', - "Moved messages.tts.providers.edge → messages.tts.providers.microsoft.", - "Moved messages.tts.providers.microsoft.voice → messages.tts.providers.microsoft.speakerVoice.", + "Moved messages.tts to top-level tts.", + 'Moved tts.provider "edge" → "microsoft".', + "Moved tts.providers.edge → tts.providers.microsoft.", + "Moved tts.providers.microsoft.voice → tts.providers.microsoft.speakerVoice.", ]); - expect(res.config?.messages?.tts).toEqual({ + expect(res.config?.tts).toEqual({ provider: "microsoft", providers: { microsoft: { @@ -324,16 +395,17 @@ describe("legacy migrate provider-shaped config", () => { }); expect(res.changes).toStrictEqual([ - "Moved messages.tts.openai → messages.tts.providers.openai.", - "Moved messages.tts.providers.elevenlabs.voiceId → messages.tts.providers.elevenlabs.speakerVoiceId.", - "Moved messages.tts.providers.openai.voice → messages.tts.providers.openai.speakerVoice.", - "Moved messages.tts.personas.narrator.providers.google.voiceName → messages.tts.personas.narrator.providers.google.speakerVoice.", + "Moved messages.tts to top-level tts.", + "Moved tts.openai → tts.providers.openai.", + "Moved tts.providers.elevenlabs.voiceId → tts.providers.elevenlabs.speakerVoiceId.", + "Moved tts.providers.openai.voice → tts.providers.openai.speakerVoice.", + "Moved tts.personas.narrator.providers.google.voiceName → tts.personas.narrator.providers.google.speakerVoice.", "Moved agents.list[0].tts.providers.openai.voice → agents.list[0].tts.providers.openai.speakerVoice.", "Moved channels.discord.voice.tts.providers.openai.voice → channels.discord.voice.tts.providers.openai.speakerVoice.", "Moved channels.discord.accounts.primary.voice.tts.providers.openai.voiceId → channels.discord.accounts.primary.voice.tts.providers.openai.speakerVoiceId.", "Moved plugins.entries.voice-call.config.tts.providers.xai.voiceId → plugins.entries.voice-call.config.tts.providers.xai.speakerVoiceId.", ]); - expect(res.config?.messages?.tts).toEqual({ + expect(res.config?.tts).toEqual({ provider: "openai", providers: { elevenlabs: { @@ -505,7 +577,8 @@ describe("legacy migrate provider-shaped config", () => { }); expect(res.changes).toEqual([ - 'Moved messages.tts.enabled → messages.tts.auto "always".', + "Moved messages.tts to top-level tts.", + 'Moved tts.enabled → tts.auto "always".', "Removed agents.list[0].tts.enabled because agents.list[0].tts.auto is already set.", 'Moved channels.discord.voice.tts.enabled → channels.discord.voice.tts.auto "off".', 'Moved channels.discord.accounts.primary.voice.tts.enabled → channels.discord.accounts.primary.voice.tts.auto "always".', @@ -515,7 +588,7 @@ describe("legacy migrate provider-shaped config", () => { ]); const migratedConfig = res.config as | { - messages?: { tts?: { auto?: unknown } }; + tts?: { auto?: unknown }; agents?: { defaults?: { tts?: { enabled?: unknown; auto?: unknown } }; list?: Array<{ id?: string; tts?: { auto?: unknown } }>; @@ -541,7 +614,7 @@ describe("legacy migrate provider-shaped config", () => { }; } | undefined; - expect(migratedConfig?.messages?.tts?.auto).toBe("always"); + expect(migratedConfig?.tts?.auto).toBe("always"); expect(migratedConfig?.agents?.defaults?.tts).toEqual({ enabled: false }); expect(migratedConfig?.agents?.list?.[0]).toEqual({ id: "voice-agent", diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 8c63e10d4349..a113a6230ace 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -26,9 +26,21 @@ function migrateLegacyConfigForTest(raw: unknown): { for (const migration of LEGACY_CONFIG_MIGRATIONS) { migration.apply(next, changes); } - return changes.length === 0 - ? { config: null, changes } - : { config: next as OpenClawConfig, changes }; + const visibleChanges = changes.filter( + (change) => change !== "Moved agents.list → keyed agents.entries.", + ); + const agents = next.agents as Record | undefined; + const entries = agents?.entries as Record> | undefined; + if (agents && entries) { + Object.defineProperty(agents, "list", { + configurable: true, + enumerable: false, + value: Object.entries(entries).map(([id, entry]) => Object.assign({ id }, entry)), + }); + } + return visibleChanges.length === 0 + ? { config: null, changes: visibleChanges } + : { config: next as OpenClawConfig, changes: visibleChanges }; } function expectMigrationChangesToIncludeFragments(changes: string[], fragments: string[]): void { @@ -38,6 +50,29 @@ function expectMigrationChangesToIncludeFragments(changes: string[], fragments: expect(unmatchedFragments).toStrictEqual([]); } +describe("legacy session typing config migrate", () => { + it("moves session typingMode to agent defaults", () => { + const res = migrateLegacyConfigForTest({ session: { typingMode: "thinking" } }); + + expect(res.config?.agents?.defaults?.typingMode).toBe("thinking"); + expect(res.config?.session).toEqual({}); + expect(res.changes).toContain("Moved session.typingMode → agents.defaults.typingMode."); + }); + + it("preserves session typing precedence over an existing agent default", () => { + const res = migrateLegacyConfigForTest({ + agents: { defaults: { typingMode: "message" } }, + session: { typingMode: "thinking" }, + }); + + expect(res.config?.agents?.defaults?.typingMode).toBe("thinking"); + expect(res.config?.session).toEqual({}); + expect(res.changes).toContain( + "Moved session.typingMode → agents.defaults.typingMode (replaced the previously shadowed agent default).", + ); + }); +}); + describe("compatibility binding repair migrate", () => { it("prunes bindings for missing agents when agents.list is valid", () => { const res = repairBindingsForTest({ @@ -132,6 +167,55 @@ describe("legacy MCP server config migrate", () => { "Moved nodeHost.mcp.servers.example.disabled true → enabled false.", ]); }); + + it("moves MCP workingDirectory aliases to cwd with canonical values winning", () => { + const raw = { + mcp: { + servers: { + legacy: { command: "example-mcp", workingDirectory: "/legacy" }, + canonical: { command: "example-mcp", cwd: "/canonical", workingDirectory: "/legacy" }, + }, + }, + nodeHost: { + mcp: { + servers: { + legacy: { command: "example-mcp", workingDirectory: "/node-legacy" }, + }, + }, + }, + }; + + expect(findLegacyConfigIssues(raw)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "mcp.servers", + message: expect.stringContaining("use camelCase spellings and cwd"), + }), + expect.objectContaining({ + path: "nodeHost.mcp.servers", + message: expect.stringContaining("use camelCase spellings and cwd"), + }), + ]), + ); + const res = migrateLegacyConfigForTest(raw); + + expect(res.config?.mcp?.servers).toEqual({ + legacy: { command: "example-mcp", cwd: "/legacy" }, + canonical: { command: "example-mcp", cwd: "/canonical" }, + }); + expect(res.config?.nodeHost?.mcp?.servers?.legacy).toEqual({ + command: "example-mcp", + cwd: "/node-legacy", + }); + expect(res.changes).toEqual( + expect.arrayContaining([ + "Canonicalized legacy aliases in mcp.servers.legacy.", + "Canonicalized legacy aliases in mcp.servers.canonical.", + "Canonicalized legacy aliases in nodeHost.mcp.servers.legacy.", + ]), + ); + expect(migrateLegacyConfigForTest(res.config)).toEqual({ config: null, changes: [] }); + }); }); describe("legacy memory search config migrate", () => { @@ -168,18 +252,18 @@ describe("legacy memory search config migrate", () => { }); expect((res.config as Record | undefined)?.memorySearch).toBeUndefined(); - expect(res.config?.agents?.defaults?.memorySearch?.store).toEqual({ + expect(res.config?.memory?.search?.store).toEqual({ fts: { tokenizer: "trigram" }, vector: { enabled: false }, }); - expect(res.config?.agents?.list?.[0]?.memorySearch?.store).toEqual({ + expect(res.config?.agents?.list?.[0]?.memory?.search?.store).toEqual({ vector: { enabled: true }, }); expect(res.changes).toContain( - "Removed agents.defaults.memorySearch.store.path; memory indexes now use each agent database.", + "Removed memory.search.store.path; memory indexes now use each agent database.", ); expect(res.changes).toContain( - "Removed agents.list[0].memorySearch.store.path; memory indexes now use each agent database.", + "Removed agents.list[0].memory.search.store.path; memory indexes now use each agent database.", ); }); @@ -922,7 +1006,7 @@ describe("legacy memory search config migrate", () => { ); }); - it("rewrites top-level legacy auto provider after moving memorySearch into agent defaults", () => { + it("rewrites top-level legacy auto provider after moving memorySearch into memory.search", () => { const raw = { memorySearch: { provider: "auto", @@ -937,14 +1021,14 @@ describe("legacy memory search config migrate", () => { const res = migrateLegacyConfigForTest(raw); - expect(res.config?.agents?.defaults?.memorySearch).toEqual({ + expect(res.config?.memory?.search).toEqual({ provider: "openai", model: "text-embedding-3-small", }); expect(res.config).not.toHaveProperty("memorySearch"); expect(res.changes).toEqual([ - "Moved memorySearch → agents.defaults.memorySearch.", - 'Moved agents.defaults.memorySearch.provider from legacy "auto" to "openai".', + "Moved legacy memorySearch defaults → memory.search.", + 'Moved memory.search.provider from legacy "auto" to "openai".', ]); }); @@ -974,18 +1058,23 @@ describe("legacy memory search config migrate", () => { }; expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).toEqual([ - "agents.defaults.memorySearch.provider", + "agents.defaults.memorySearch", + "agents.list", + "agents.list", "agents.list", ]); const res = migrateLegacyConfigForTest(raw); - expect(res.config?.agents?.defaults?.memorySearch?.provider).toBe("openai"); - expect(res.config?.agents?.list?.[0]?.memorySearch?.provider).toBe("openai"); - expect(res.config?.agents?.list?.[1]?.memorySearch?.provider).toBe("openai-compatible"); + expect(res.config?.memory?.search?.provider).toBe("openai"); + expect(res.config?.agents?.list?.[0]?.memory?.search?.provider).toBe("openai"); + expect(res.config?.agents?.list?.[1]?.memory?.search?.provider).toBe("openai-compatible"); expect(res.changes).toEqual([ - 'Moved agents.defaults.memorySearch.provider from legacy "auto" to "openai".', - 'Moved agents.list.0.memorySearch.provider from legacy "auto" to "openai".', + "Moved legacy memorySearch defaults → memory.search.", + "Moved agents.list.0.memorySearch → agents.list.0.memory.search.", + "Moved agents.list.1.memorySearch → agents.list.1.memory.search.", + 'Moved memory.search.provider from legacy "auto" to "openai".', + 'Moved agents.list.0.memory.search.provider from legacy "auto" to "openai".', ]); }); }); @@ -1082,6 +1171,7 @@ describe("legacy agent system prompt override config migrate", () => { expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).toEqual([ "agents.defaults.systemPromptOverride", "agents.list", + "agents.list", ]); const res = migrateLegacyConfigForTest(raw); @@ -1121,7 +1211,7 @@ describe("profile configured tool section migrate", () => { expect(res.config).toBeNull(); expect(res.changes).toEqual([]); expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).not.toContain("tools"); - expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).not.toContain("agents.list"); + expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).toContain("agents.list"); }); it("does not add missing grants to an unrelated allowlist", () => { @@ -1274,7 +1364,7 @@ describe("profile configured tool section migrate", () => { expect(res.config).toBeNull(); expect(res.changes).toEqual([]); - expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).not.toContain("agents.list"); + expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).toContain("agents.list"); }); it("does not report inherited top-level profile provider allowlists as fixable", () => { @@ -1423,9 +1513,11 @@ describe("legacy agent model timeout migrate", () => { expect(defaultSubagents.model).toEqual({ primary: "openai/gpt-5.4", }); - expect(defaults.imageGenerationModel).toEqual({ - primary: "openrouter/openai/gpt-5.4-image-2", - timeoutMs: 180_000, + expect(defaults.mediaModels).toEqual({ + image: { + primary: "openrouter/openai/gpt-5.4-image-2", + timeoutMs: 180_000, + }, }); expect(defaults.pdfModel).toEqual({ primary: "openai/gpt-5.5", @@ -1442,6 +1534,7 @@ describe("legacy agent model timeout migrate", () => { "Removed agents.defaults.subagents.model.timeoutMs; agent model config only selects models.", "Removed agents.list.0.model.timeoutMs; agent model config only selects models.", "Removed agents.list.0.subagents.model.timeoutMs; agent model config only selects models.", + "Moved agents.defaults.imageGenerationModel → agents.defaults.mediaModels.image.", ]); }); }); @@ -1583,7 +1676,9 @@ describe("legacy thread binding spawn migrate", () => { }, }); - expect(res.config?.channels?.discord?.threadBindings).toEqual({ + expect( + (res.config?.channels?.discord as { threadBindings?: unknown } | undefined)?.threadBindings, + ).toEqual({ enabled: true, spawnSessions: true, }); @@ -1609,7 +1704,8 @@ describe("legacy thread binding spawn migrate", () => { }); expect( - res.config?.channels?.discord?.accounts?.work?.threadBindings as Record, + (res.config?.channels?.discord?.accounts?.work as { threadBindings?: unknown } | undefined) + ?.threadBindings as Record, ).toEqual({ spawnSessions: false, }); @@ -1717,7 +1813,7 @@ describe("legacy migrate audio transcription", () => { expect(res.config).toBeNull(); }); - it("does not rewrite removed routing.transcribeAudio migrations when new config exists", () => { + it("consolidates existing per-capability media config without reviving removed routing keys", () => { const res = migrateLegacyConfigForTest({ routing: { transcribeAudio: { @@ -1733,8 +1829,12 @@ describe("legacy migrate audio transcription", () => { }, }); - expect(res.changes).toStrictEqual([]); - expect(res.config).toBeNull(); + expect(res.changes).toStrictEqual([ + "Consolidated tools.media image/audio/video model settings into capability-tagged tools.media.models entries.", + ]); + expect(res.config?.tools?.media).toEqual({ + models: [{ command: "existing", type: "cli", capabilities: ["audio"] }], + }); }); it("drops invalid audio.transcription payloads", () => { @@ -1749,7 +1849,7 @@ describe("legacy migrate audio transcription", () => { expect(findLegacyConfigIssues(raw)).toEqual([ { path: "audio.transcription", - message: "Use tools.media.audio.models instead.", + message: "Use a capability-tagged tools.media.models entry instead.", }, ]); const res = migrateLegacyConfigForTest(raw); @@ -1772,16 +1872,62 @@ describe("legacy migrate audio transcription", () => { expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).toEqual(["audio.transcription"]); const res = migrateLegacyConfigForTest(raw); - expect(res.changes).toStrictEqual(["Moved audio.transcription → tools.media.audio.models."]); + expect(res.changes).toStrictEqual(["Moved audio.transcription → tools.media.models."]); expect(res.config).not.toHaveProperty("audio"); - expect(res.config?.tools?.media?.audio?.models).toEqual([ + expect(res.config?.tools?.media?.models).toEqual([ { type: "cli", command: "whisper-cli", args: ["--model", "small", "{{MediaPath}}", "--input={{MediaPath}}"], timeoutSeconds: 30, + capabilities: ["audio"], }, ]); + expect(res.config?.tools?.media?.audio).toEqual({ + enabled: true, + preferredModel: "cli:whisper-cli", + }); + }); + + it("keeps audio.transcription when the shared list only has unrelated capabilities", () => { + const res = migrateLegacyConfigForTest({ + audio: { transcription: { command: ["whisper-cli", "{input}"] } }, + tools: { + media: { + models: [{ provider: "openai", model: "vision", capabilities: ["image"] }], + }, + }, + }); + + expect(res.config?.tools?.media?.models).toEqual([ + { provider: "openai", model: "vision", capabilities: ["image"] }, + { + type: "cli", + command: "whisper-cli", + args: ["{{MediaPath}}"], + capabilities: ["audio"], + }, + ]); + }); + + it("keeps audio.transcription when the legacy audio list has only incompatible models", () => { + const res = migrateLegacyConfigForTest({ + audio: { transcription: { command: ["whisper-cli", "{input}"] } }, + tools: { + media: { + audio: { + models: [{ provider: "openai", model: "vision", capabilities: ["image"] }], + }, + }, + }, + }); + + expect(res.config?.tools?.media?.models).toContainEqual({ + type: "cli", + command: "whisper-cli", + args: ["{{MediaPath}}"], + capabilities: ["audio"], + }); }); }); @@ -2537,20 +2683,18 @@ describe("legacy bundled provider discovery migrate", () => { expect(res.changes).toContain("Rewrote plugins.slots openai-codex references to openai."); }); - it("sets compat mode for existing restrictive plugin allowlists", () => { + it("leaves restrictive plugin allowlists for the machine-state migration", () => { const res = migrateLegacyConfigForTest({ plugins: { allow: ["telegram"], }, }); - expect(res.config?.plugins?.bundledDiscovery).toBe("compat"); - expect(res.changes).toStrictEqual([ - 'Set plugins.bundledDiscovery="compat" to preserve legacy bundled provider discovery for this restrictive plugins.allow config.', - ]); + expect(res.config).toBeNull(); + expect(res.changes).toStrictEqual([]); }); - it("does not override explicit bundled discovery mode", () => { + it("strips explicit bundled discovery mode after machine-state capture", () => { const res = migrateLegacyConfigForTest({ plugins: { allow: ["telegram"], @@ -2558,8 +2702,10 @@ describe("legacy bundled provider discovery migrate", () => { }, }); - expect(res.config).toBeNull(); - expect(res.changes).toStrictEqual([]); + expect(res.config).toEqual({ plugins: { allow: ["telegram"] } }); + expect(res.changes).toStrictEqual([ + "Applied tier-eval tranche retirements; canonical settings and built-in defaults now apply.", + ]); }); }); @@ -2955,7 +3101,7 @@ describe("legacy model compat migrate", () => { expect(findLegacyConfigIssues(raw).map((issue) => issue.path)).toContain("agents"); const res = migrateLegacyConfigForTest(raw); - expect(res.config?.agents?.defaults?.imageGenerationModel).toEqual({ + expect(res.config?.agents?.defaults?.mediaModels?.image).toEqual({ primary: "xai/grok-imagine-image-quality", fallbacks: ["xai/grok-imagine-image"], }); @@ -3048,15 +3194,15 @@ describe("legacy model compat migrate", () => { }); expect(res.config?.agents?.defaults?.imageModel).toBe("anthropic/claude-haiku-4-5"); - expect(res.config?.agents?.defaults?.imageGenerationModel).toEqual({ + expect(res.config?.agents?.defaults?.mediaModels?.image).toEqual({ primary: "github-copilot/claude-sonnet-4.6", fallbacks: ["github-copilot/gpt-5.4-mini"], }); - expect(res.config?.agents?.defaults?.musicGenerationModel).toBe( + expect(res.config?.agents?.defaults?.mediaModels?.music).toBe( "vercel-ai-gateway/anthropic/claude-opus-4-6", ); expect(res.config?.agents?.defaults?.pdfModel).toBe("anthropic/claude-sonnet-4-6"); - expect(res.config?.agents?.defaults?.videoGenerationModel).toBe("anthropic/claude-opus-4-10"); + expect(res.config?.agents?.defaults?.mediaModels?.video).toBe("anthropic/claude-opus-4-10"); expect(res.config?.agents?.defaults?.model).toEqual({ primary: "anthropic/claude-opus-4-7@anthropic:work", fallbacks: [ @@ -4135,15 +4281,15 @@ describe("legacy flat memory search field migrate", () => { ); const res = migrateLegacyConfigForTest(raw); - expect(res.config?.agents?.defaults?.memorySearch).toEqual({ + expect(res.config?.memory?.search).toEqual({ enabled: true, query: { maxResults: 5 }, }); expect(res.changes).toEqual( expect.arrayContaining([ - "Moved agents.defaults.memorySearch.chunkSize → agents.defaults.memorySearch.chunking.tokens.", - "Moved agents.defaults.memorySearch.chunkOverlap → agents.defaults.memorySearch.chunking.overlap.", - "Moved agents.defaults.memorySearch.maxResults → agents.defaults.memorySearch.query.maxResults.", + "Moved memory.search.chunkSize → memory.search.chunking.tokens.", + "Moved memory.search.chunkOverlap → memory.search.chunking.overlap.", + "Moved memory.search.maxResults → memory.search.query.maxResults.", "Removed retired runtime tuning knobs; built-in defaults now apply.", ]), ); @@ -4168,16 +4314,16 @@ describe("legacy flat memory search field migrate", () => { }); expect(res.config).not.toHaveProperty("memorySearch"); - expect(res.config?.agents?.defaults?.memorySearch).toEqual({ + expect(res.config?.memory?.search).toEqual({ enabled: true, query: { maxResults: 9 }, }); expect(res.changes).toEqual( expect.arrayContaining([ - "Merged memorySearch → agents.defaults.memorySearch (filled missing fields from legacy; kept explicit agents.defaults values).", - "Removed agents.defaults.memorySearch.chunkSize (agents.defaults.memorySearch.chunking.tokens already set).", - "Moved agents.defaults.memorySearch.chunkOverlap → agents.defaults.memorySearch.chunking.overlap.", - "Removed agents.defaults.memorySearch.maxResults (agents.defaults.memorySearch.query.maxResults already set).", + "Moved legacy memorySearch defaults → memory.search.", + "Removed memory.search.chunkSize (memory.search.chunking.tokens already set).", + "Moved memory.search.chunkOverlap → memory.search.chunking.overlap.", + "Removed memory.search.maxResults (memory.search.query.maxResults already set).", "Removed retired runtime tuning knobs; built-in defaults now apply.", ]), ); @@ -4193,8 +4339,8 @@ describe("legacy flat memory search field migrate", () => { }, }); - expect(res.config?.agents?.list?.[0]?.memorySearch).toBeUndefined(); - expect(res.config?.agents?.list?.[1]?.memorySearch).toEqual({ + expect(res.config?.agents?.list?.[0]?.memory?.search).toBeUndefined(); + expect(res.config?.agents?.list?.[1]?.memory?.search).toEqual({ query: { maxResults: 10 }, }); expect(res.changes).toContain( @@ -4214,9 +4360,11 @@ describe("legacy flat memory search field migrate", () => { }, }; - expect(findLegacyConfigIssues(raw)).toEqual([expect.objectContaining({ path: "" })]); + expect(findLegacyConfigIssues(raw)).toEqual([ + expect.objectContaining({ path: "agents.defaults.memorySearch" }), + ]); const res = migrateLegacyConfigForTest(raw); - expect(res.config?.agents?.defaults?.memorySearch).toEqual({ + expect(res.config?.memory?.search).toEqual({ query: { maxResults: 5 }, }); expect(res.changes).toContain( diff --git a/src/commands/doctor/shared/legacy-config-migrations.audio.ts b/src/commands/doctor/shared/legacy-config-migrations.audio.ts index abe04542e4d4..4644a251b376 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.audio.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.audio.ts @@ -23,10 +23,21 @@ function applyLegacyAudioTranscriptionModel(params: { const tools = ensureRecord(params.raw, "tools"); const media = ensureRecord(tools, "media"); const mediaAudio = ensureRecord(media, "audio"); - const models = Array.isArray(mediaAudio.models) ? (mediaAudio.models as unknown[]) : []; - if (models.length === 0) { + const models = Array.isArray(media.models) ? (media.models as unknown[]) : []; + const isAudioCompatible = (value: unknown) => { + const model = getRecord(value); + return ( + model !== null && (!Array.isArray(model.capabilities) || model.capabilities.includes("audio")) + ); + }; + const hasAudioModel = + (Array.isArray(mediaAudio.models) && mediaAudio.models.some(isAudioCompatible)) || + models.some(isAudioCompatible); + if (!hasAudioModel) { mediaAudio.enabled = true; - mediaAudio.models = [mapped]; + mediaAudio.preferredModel = + typeof mapped.command === "string" ? `cli:${mapped.command}` : undefined; + media.models = [...models, { ...mapped, capabilities: ["audio"] }]; params.changes.push(params.movedMessage); return; } @@ -37,11 +48,11 @@ function applyLegacyAudioTranscriptionModel(params: { export const LEGACY_CONFIG_MIGRATIONS_AUDIO: LegacyConfigMigrationSpec[] = [ defineLegacyConfigMigration({ id: "audio.transcription-v2", - describe: "Move audio.transcription to tools.media.audio.models", + describe: "Move audio.transcription to tools.media.models", legacyRules: [ { path: ["audio", "transcription"], - message: "Use tools.media.audio.models instead.", + message: "Use a capability-tagged tools.media.models entry instead.", }, ], apply: (raw, changes) => { @@ -54,8 +65,8 @@ export const LEGACY_CONFIG_MIGRATIONS_AUDIO: LegacyConfigMigrationSpec[] = [ raw, source: audio.transcription, changes, - movedMessage: "Moved audio.transcription → tools.media.audio.models.", - alreadySetMessage: "Removed audio.transcription (tools.media.audio.models already set).", + movedMessage: "Moved audio.transcription → tools.media.models.", + alreadySetMessage: "Removed audio.transcription (tools.media.models already set).", invalidMessage: "Removed audio.transcription (invalid or empty command).", }); delete audio.transcription; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts index ec4615f7bc01..f68175ddbb0e 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.agents.ts @@ -53,9 +53,23 @@ const LEGACY_MEMORY_SEARCH_FIELD_MAPPINGS = [ const MEMORY_SEARCH_RULE: LegacyConfigRule = { path: ["memorySearch"], message: - 'top-level memorySearch was moved; use agents.defaults.memorySearch instead. Run "openclaw doctor --fix".', + 'top-level memorySearch was moved; use memory.search instead. Run "openclaw doctor --fix".', }; +const AGENT_MEMORY_SEARCH_OWNER_RULES: LegacyConfigRule[] = [ + { + path: ["agents", "defaults", "memorySearch"], + message: 'agents.defaults.memorySearch moved to memory.search. Run "openclaw doctor --fix".', + }, + { + path: ["agents", "list"], + message: + 'agents.list[].memorySearch moved to agents.list[].memory.search. Run "openclaw doctor --fix".', + match: (value) => + Array.isArray(value) && value.some((agent) => getRecord(agent)?.memorySearch !== undefined), + }, +]; + const LEGACY_MEMORY_SEARCH_AUTO_PROVIDER_RULES: LegacyConfigRule[] = [ { path: ["memorySearch", "provider"], @@ -64,9 +78,9 @@ const LEGACY_MEMORY_SEARCH_AUTO_PROVIDER_RULES: LegacyConfigRule[] = [ match: isLegacyMemorySearchAutoProvider, }, { - path: ["agents", "defaults", "memorySearch", "provider"], + path: ["memory", "search", "provider"], message: - 'agents.defaults.memorySearch.provider = "auto" is legacy; use "openai" explicitly. Run "openclaw doctor --fix".', + 'memory.search.provider = "auto" is legacy; use "openai" explicitly. Run "openclaw doctor --fix".', match: isLegacyMemorySearchAutoProvider, }, { @@ -84,9 +98,9 @@ const LEGACY_MEMORY_SEARCH_STORE_PATH_RULES: LegacyConfigRule[] = [ 'memorySearch.store.path is legacy; memory indexes now live in each agent database. Run "openclaw doctor --fix".', }, { - path: ["agents", "defaults", "memorySearch", "store", "path"], + path: ["memory", "search", "store", "path"], message: - 'agents.defaults.memorySearch.store.path is legacy; memory indexes now live in each agent database. Run "openclaw doctor --fix".', + 'memory.search.store.path is legacy; memory indexes now live in each agent database. Run "openclaw doctor --fix".', }, { path: ["agents", "list"], @@ -98,9 +112,9 @@ const LEGACY_MEMORY_SEARCH_STORE_PATH_RULES: LegacyConfigRule[] = [ const LEGACY_MEMORY_SEARCH_FLAT_KEY_RULES: LegacyConfigRule[] = [ { - path: ["agents", "defaults", "memorySearch"], + path: ["memory", "search"], message: - 'agents.defaults.memorySearch uses legacy flat chunkSize, chunkOverlap, or maxResults fields. Run "openclaw doctor --fix".', + 'memory.search uses legacy flat chunkSize, chunkOverlap, or maxResults fields. Run "openclaw doctor --fix".', match: hasLegacyMemorySearchFlatKeys, }, { @@ -121,10 +135,15 @@ function hasLegacyMemorySearchFlatKeys(value: unknown): boolean { ); } +function getAgentMemorySearchRecord(agent: unknown): Record | null { + const record = getRecord(agent); + return getRecord(record?.memorySearch) ?? getRecord(getRecord(record?.memory)?.search); +} + function hasAgentListLegacyMemorySearchFlatKeys(value: unknown): boolean { return ( Array.isArray(value) && - value.some((agent) => hasLegacyMemorySearchFlatKeys(getRecord(agent)?.memorySearch)) + value.some((agent) => hasLegacyMemorySearchFlatKeys(getAgentMemorySearchRecord(agent))) ); } @@ -448,7 +467,7 @@ function hasAgentListLegacyMemorySearchAutoProvider(value: unknown): boolean { return false; } return value.some((agent) => - isLegacyMemorySearchAutoProvider(getRecord(getRecord(agent)?.memorySearch)?.provider), + isLegacyMemorySearchAutoProvider(getAgentMemorySearchRecord(agent)?.provider), ); } @@ -459,7 +478,7 @@ function hasMemorySearchStorePath(value: unknown): boolean { function hasAgentListMemorySearchStorePath(value: unknown): boolean { return ( Array.isArray(value) && - value.some((agent) => hasMemorySearchStorePath(getRecord(agent)?.memorySearch)) + value.some((agent) => hasMemorySearchStorePath(getAgentMemorySearchRecord(agent))) ); } @@ -1420,26 +1439,56 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS: LegacyConfigMigrationSpec[ }, }), defineLegacyConfigMigration({ - id: "memorySearch->agents.defaults.memorySearch", - describe: "Move top-level memorySearch to agents.defaults.memorySearch", - legacyRules: [MEMORY_SEARCH_RULE], + id: "memorySearch->memory.search", + describe: "Move memory search config to its canonical memory owner", + legacyRules: [MEMORY_SEARCH_RULE, ...AGENT_MEMORY_SEARCH_OWNER_RULES], apply: (raw, changes) => { - const legacyMemorySearch = getRecord(raw.memorySearch); - if (!legacyMemorySearch) { - return; + const agents = getRecord(raw.agents); + const defaults = getRecord(agents?.defaults); + const legacyDefaults = getRecord(defaults?.memorySearch); + const legacyTopLevel = getRecord(raw.memorySearch); + const memory = getRecord(raw.memory); + const canonical = getRecord(memory?.search); + + if (legacyDefaults || legacyTopLevel) { + const target = structuredClone(canonical ?? {}); + if (legacyDefaults) { + mergeMissing(target, legacyDefaults); + delete defaults!.memorySearch; + } + if (legacyTopLevel) { + mergeMissing(target, legacyTopLevel); + delete raw.memorySearch; + } + ensureRecord(raw, "memory").search = target; + changes.push( + canonical + ? "Merged legacy memorySearch defaults → memory.search (kept explicit memory.search values)." + : "Moved legacy memorySearch defaults → memory.search.", + ); } - mergeLegacyIntoDefaults({ - raw, - rootKey: "agents", - fieldKey: "memorySearch", - legacyValue: legacyMemorySearch, - changes, - movedMessage: "Moved memorySearch → agents.defaults.memorySearch.", - mergedMessage: - "Merged memorySearch → agents.defaults.memorySearch (filled missing fields from legacy; kept explicit agents.defaults values).", - }); - delete raw.memorySearch; + if (!Array.isArray(agents?.list)) { + return; + } + for (const [index, rawAgent] of agents.list.entries()) { + const agent = getRecord(rawAgent); + const legacy = getRecord(agent?.memorySearch); + if (!agent || !legacy) { + continue; + } + const agentMemory = ensureRecord(agent, "memory"); + const existing = getRecord(agentMemory.search); + const target = structuredClone(existing ?? {}); + mergeMissing(target, legacy); + agentMemory.search = target; + delete agent.memorySearch; + changes.push( + existing + ? `Merged agents.list.${index}.memorySearch → agents.list.${index}.memory.search (kept explicit memory.search values).` + : `Moved agents.list.${index}.memorySearch → agents.list.${index}.memory.search.`, + ); + } }, }), defineLegacyConfigMigration({ @@ -1447,20 +1496,20 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS: LegacyConfigMigrationSpec[ describe: "Move legacy flat memory search fields to canonical nested fields", legacyRules: LEGACY_MEMORY_SEARCH_FLAT_KEY_RULES, apply: (raw, changes) => { - const agents = getRecord(raw.agents); migrateLegacyMemorySearchFlatKeys( - getRecord(getRecord(agents?.defaults)?.memorySearch), - "agents.defaults.memorySearch", + getRecord(getRecord(raw.memory)?.search), + "memory.search", changes, ); + const agents = getRecord(raw.agents); if (!Array.isArray(agents?.list)) { return; } for (const [index, agent] of agents.list.entries()) { migrateLegacyMemorySearchFlatKeys( - getRecord(getRecord(agent)?.memorySearch), - `agents.list.${index}.memorySearch`, + getRecord(getRecord(getRecord(agent)?.memory)?.search), + `agents.list.${index}.memory.search`, changes, ); } @@ -1471,20 +1520,20 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS: LegacyConfigMigrationSpec[ describe: 'Rewrite legacy memorySearch provider "auto" to "openai"', legacyRules: LEGACY_MEMORY_SEARCH_AUTO_PROVIDER_RULES, apply: (raw, changes) => { - const agents = getRecord(raw.agents); rewriteLegacyMemorySearchAutoProvider( - getRecord(getRecord(agents?.defaults)?.memorySearch), - "agents.defaults.memorySearch", + getRecord(getRecord(raw.memory)?.search), + "memory.search", changes, ); + const agents = getRecord(raw.agents); if (!Array.isArray(agents?.list)) { return; } for (const [index, agent] of agents.list.entries()) { rewriteLegacyMemorySearchAutoProvider( - getRecord(getRecord(agent)?.memorySearch), - `agents.list.${index}.memorySearch`, + getRecord(getRecord(getRecord(agent)?.memory)?.search), + `agents.list.${index}.memory.search`, changes, ); } @@ -1495,27 +1544,51 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS: LegacyConfigMigrationSpec[ describe: "Remove legacy memory search sidecar index paths", legacyRules: LEGACY_MEMORY_SEARCH_STORE_PATH_RULES, apply: (raw, changes) => { - removeLegacyMemorySearchStorePath(getRecord(raw.memorySearch), "memorySearch", changes); - - const agents = getRecord(raw.agents); removeLegacyMemorySearchStorePath( - getRecord(getRecord(agents?.defaults)?.memorySearch), - "agents.defaults.memorySearch", + getRecord(getRecord(raw.memory)?.search), + "memory.search", changes, ); + const agents = getRecord(raw.agents); if (!Array.isArray(agents?.list)) { return; } for (const [index, agent] of agents.list.entries()) { removeLegacyMemorySearchStorePath( - getRecord(getRecord(agent)?.memorySearch), - `agents.list[${index}].memorySearch`, + getRecord(getRecord(getRecord(agent)?.memory)?.search), + `agents.list[${index}].memory.search`, changes, ); } }, }), + defineLegacyConfigMigration({ + id: "session.typingMode->agents.defaults.typingMode", + describe: "Move session typing mode to agent defaults", + legacyRules: [ + { + path: ["session", "typingMode"], + message: + 'session.typingMode moved to agents.defaults.typingMode. Run "openclaw doctor --fix".', + }, + ], + apply: (raw, changes) => { + const session = getRecord(raw.session); + if (!session || !Object.hasOwn(session, "typingMode")) { + return; + } + const defaults = ensureRecord(ensureRecord(raw, "agents"), "defaults"); + const replacedDefault = defaults.typingMode !== undefined; + defaults.typingMode = session.typingMode; + changes.push( + replacedDefault + ? "Moved session.typingMode → agents.defaults.typingMode (replaced the previously shadowed agent default)." + : "Moved session.typingMode → agents.defaults.typingMode.", + ); + delete session.typingMode; + }, + }), defineLegacyConfigMigration({ id: "heartbeat->agents.defaults.heartbeat", describe: "Move top-level heartbeat to agents.defaults.heartbeat/channels.defaults.heartbeat", diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.entries.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.entries.ts new file mode 100644 index 000000000000..201d0c523df8 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.entries.ts @@ -0,0 +1,64 @@ +import { + defineLegacyConfigMigration, + getRecord, + type LegacyConfigMigrationSpec, +} from "../../../config/legacy.shared.js"; + +function migrateAgentEntries(raw: Record, changes: string[]): void { + const agents = getRecord(raw.agents); + if (!agents || !Array.isArray(agents.list)) { + return; + } + if (getRecord(agents.entries)) { + delete agents.list; + changes.push("Removed agents.list because canonical agents.entries is already set."); + return; + } + const entries: Record = {}; + for (const [index, value] of agents.list.entries()) { + const entry = getRecord(value); + if (!entry) { + changes.push(`Removed malformed agents.list[${index}] entry.`); + continue; + } + const rawId = typeof entry.id === "string" && entry.id.trim() ? entry.id.trim() : "agent"; + const requestedId = normalizeAgentId(rawId); + if (requestedId !== rawId) { + changes.push(`Normalized agents.list id "${rawId}" → agents.entries.${requestedId}.`); + } + let key = requestedId; + let suffix = 2; + while (Object.hasOwn(entries, key)) { + key = `${requestedId}-${suffix}`; + suffix += 1; + } + const { id: _id, ...config } = entry; + Object.defineProperty(entries, key, { + configurable: true, + enumerable: true, + value: config, + writable: true, + }); + if (key !== requestedId) { + changes.push(`Moved duplicate agents.list id "${requestedId}" to agents.entries.${key}.`); + } + } + agents.entries = entries; + delete agents.list; + changes.push("Moved agents.list → keyed agents.entries."); +} + +export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_ENTRIES: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "runtime.agents-entries", + describe: "Move agent arrays to keyed entries", + legacyRules: [ + { + path: ["agents", "list"], + message: 'agents.list moved to keyed agents.entries. Run "openclaw doctor --fix".', + }, + ], + apply: migrateAgentEntries, + }), +]; +import { normalizeAgentId } from "@openclaw/normalization-core/agent-id"; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.mcp.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.mcp.ts index 07db7d31c99b..aa78a891fcb3 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.mcp.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.mcp.ts @@ -5,6 +5,7 @@ import { type LegacyConfigRule, } from "../../../config/legacy.shared.js"; import { + canonicalizeConfiguredMcpServer, isKnownCliMcpTypeAlias, resolveOpenClawMcpTransportAlias, } from "../../../config/mcp-config-normalize.js"; @@ -47,6 +48,48 @@ const MCP_SERVER_TIMEOUT_ALIASES_RULES: LegacyConfigRule[] = [ ), })); +function hasMcpServerLegacyAliases(server: Record): boolean { + const codex = isRecord(server.codex) ? server.codex : undefined; + return ( + Object.hasOwn(server, "workingDirectory") || + ["supports_parallel_tool_calls", "ssl_verify", "client_cert", "client_key"].some((key) => + Object.hasOwn(server, key), + ) || + Boolean(codex && Object.hasOwn(codex, "default_tools_approval_mode")) + ); +} + +const MCP_SERVER_ALIASES_RULES: LegacyConfigRule[] = [ + ["mcp", "servers"], + ["nodeHost", "mcp", "servers"], +].map((path) => ({ + path, + message: `${path.join(".")} legacy aliases were retired; use camelCase spellings and cwd. Run "openclaw doctor --fix".`, + match: (value) => + isRecord(value) && + Object.values(value).some((server) => isRecord(server) && hasMcpServerLegacyAliases(server)), +})); + +function migrateMcpServerAliases(servers: unknown, pathPrefix: string, changes: string[]): void { + if (!isRecord(servers)) { + return; + } + for (const [serverName, value] of Object.entries(servers)) { + if (!isRecord(value)) { + continue; + } + if (!hasMcpServerLegacyAliases(value)) { + continue; + } + const normalized = canonicalizeConfiguredMcpServer(value); + if (JSON.stringify(normalized) === JSON.stringify(value)) { + continue; + } + servers[serverName] = normalized; + changes.push(`Canonicalized legacy aliases in ${pathPrefix}.${serverName}.`); + } +} + function migrateMcpServerTimeoutAliases( servers: unknown, pathPrefix: string, @@ -127,16 +170,19 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_MCP: LegacyConfigMigrationSpec[] = ...MCP_SERVER_DISABLED_RULES, MCP_SERVER_TYPE_RULE, ...MCP_SERVER_TIMEOUT_ALIASES_RULES, + ...MCP_SERVER_ALIASES_RULES, ], apply: (raw, changes) => { const mcp = isRecord(raw.mcp) ? raw.mcp : undefined; migrateMcpServerDisabledFlags(mcp?.servers, "mcp.servers", changes); migrateMcpServerTimeoutAliases(mcp?.servers, "mcp.servers", changes); + migrateMcpServerAliases(mcp?.servers, "mcp.servers", changes); const nodeHost = isRecord(raw.nodeHost) ? raw.nodeHost : undefined; const nodeHostMcp = isRecord(nodeHost?.mcp) ? nodeHost.mcp : undefined; migrateMcpServerDisabledFlags(nodeHostMcp?.servers, "nodeHost.mcp.servers", changes); migrateMcpServerTimeoutAliases(nodeHostMcp?.servers, "nodeHost.mcp.servers", changes); + migrateMcpServerAliases(nodeHostMcp?.servers, "nodeHost.mcp.servers", changes); const servers = isRecord(mcp?.servers) ? mcp?.servers : undefined; if (!servers) { diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts index bfba4105216c..0f5ec2b84240 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.providers.ts @@ -20,20 +20,6 @@ function normalizePluginIdForMigration(value: unknown): string | undefined { return typeof value === "string" ? value.trim().toLowerCase() : undefined; } -const BUNDLED_DISCOVERY_COMPAT_RULE: LegacyConfigRule = { - path: ["plugins", "allow"], - message: - 'plugins.allow now gates bundled provider discovery by default; run "openclaw doctor --fix" to preserve legacy bundled provider compatibility as plugins.bundledDiscovery="compat", or set plugins.bundledDiscovery="allowlist" to keep the stricter behavior.', - requireSourceLiteral: true, - match: (value, root) => { - if (!Array.isArray(value) || value.length === 0) { - return false; - } - const plugins = isRecord(root.plugins) ? root.plugins : undefined; - return plugins?.bundledDiscovery === undefined; - }, -}; - const X_SEARCH_RULE: LegacyConfigRule = { path: ["tools", "web", "x_search", "apiKey"], message: @@ -274,25 +260,6 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS: LegacyConfigMigrationSp changes.push(...rewriteLegacyOpenAICodexPluginPolicy(raw)); }, }), - defineLegacyConfigMigration({ - id: "plugins.allow->plugins.bundledDiscovery.compat", - describe: "Preserve bundled provider discovery for existing restrictive allowlists", - legacyRules: [BUNDLED_DISCOVERY_COMPAT_RULE], - apply: (raw, changes) => { - const plugins = isRecord(raw.plugins) ? raw.plugins : undefined; - if (!plugins || plugins.bundledDiscovery !== undefined) { - return; - } - const allow = plugins.allow; - if (!Array.isArray(allow) || allow.length === 0) { - return; - } - plugins.bundledDiscovery = "compat"; - changes.push( - 'Set plugins.bundledDiscovery="compat" to preserve legacy bundled provider discovery for this restrictive plugins.allow config.', - ); - }, - }), defineLegacyConfigMigration({ id: "tools.web.x_search.apiKey->plugins.entries.xai.config.webSearch.apiKey", describe: "Move legacy x_search auth and repair retired xAI model defaults", diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired-media.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired-media.ts new file mode 100644 index 000000000000..05a08768039d --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired-media.ts @@ -0,0 +1,408 @@ +// Media and voice compatibility migrations retired from canonical runtime config. +import { getRecord } from "../../../config/legacy.shared.js"; + +export function moveVoice(owner: Record, path: string, changes: string[]): void { + if (!Object.hasOwn(owner, "voice")) { + return; + } + if (owner.speakerVoice === undefined) { + owner.speakerVoice = owner.voice; + changes.push(`Moved ${path}.voice → ${path}.speakerVoice.`); + } else { + changes.push(`Removed ${path}.voice (${path}.speakerVoice already set).`); + } + delete owner.voice; +} + +export function migrateDiscordVoice(channels: Record, changes: string[]): void { + const discord = getRecord(channels.discord); + if (!discord) { + return; + } + const migrateEntry = (entry: Record, path: string) => { + const realtime = getRecord(getRecord(entry.voice)?.realtime); + if (realtime) { + moveVoice(realtime, `${path}.voice.realtime`, changes); + } + }; + migrateEntry(discord, "channels.discord"); + const accounts = getRecord(discord.accounts); + if (accounts) { + for (const [accountId, value] of Object.entries(accounts)) { + const account = getRecord(value); + if (account) { + migrateEntry(account, `channels.discord.accounts.${accountId}`); + } + } + } +} + +export function hasDiscordRealtimeVoice(value: unknown): boolean { + const discord = getRecord(value); + if (!discord) { + return false; + } + const hasAlias = (entry: unknown) => { + const realtime = getRecord(getRecord(getRecord(entry)?.voice)?.realtime); + return realtime ? Object.hasOwn(realtime, "voice") : false; + }; + if (hasAlias(discord)) { + return true; + } + const accounts = getRecord(discord.accounts); + return accounts ? Object.values(accounts).some(hasAlias) : false; +} + +function mapDeepgram(value: Record): Record { + const mapped: Record = {}; + if (typeof value.detectLanguage === "boolean") { + mapped.detect_language = value.detectLanguage; + } + if (typeof value.punctuate === "boolean") { + mapped.punctuate = value.punctuate; + } + if (typeof value.smartFormat === "boolean") { + mapped.smart_format = value.smartFormat; + } + return mapped; +} + +function migrateDeepgramOwner( + owner: Record, + path: string, + changes: string[], +): void { + const legacy = getRecord(owner.deepgram); + if (!legacy) { + return; + } + const providerOptions = getRecord(owner.providerOptions) ?? {}; + const canonical = getRecord(providerOptions.deepgram) ?? {}; + providerOptions.deepgram = { ...mapDeepgram(legacy), ...canonical }; + owner.providerOptions = providerOptions; + delete owner.deepgram; + changes.push(`Moved ${path}.deepgram → ${path}.providerOptions.deepgram.`); +} + +export function migrateMediaDeepgram(raw: Record, changes: string[]): void { + const media = getRecord(getRecord(raw.tools)?.media); + if (!media) { + return; + } + const migrateModels = (models: unknown, path: string) => { + if (!Array.isArray(models)) { + return; + } + models.forEach((value, index) => { + const model = getRecord(value); + if (model) { + migrateDeepgramOwner(model, `${path}[${index}]`, changes); + } + }); + }; + migrateModels(media.models, "tools.media.models"); + for (const capability of ["audio", "image", "video"]) { + const entry = getRecord(media[capability]); + if (!entry) { + continue; + } + migrateDeepgramOwner(entry, `tools.media.${capability}`, changes); + migrateModels(entry.models, `tools.media.${capability}.models`); + } +} + +export function hasMediaDeepgram(value: unknown): boolean { + const media = getRecord(value); + if (!media) { + return false; + } + const hasAlias = (entry: unknown) => { + const owner = getRecord(entry); + return owner ? Object.hasOwn(owner, "deepgram") : false; + }; + const modelsHaveAlias = (models: unknown) => Array.isArray(models) && models.some(hasAlias); + if (modelsHaveAlias(media.models)) { + return true; + } + return ["audio", "image", "video"].some((capability) => { + const entry = getRecord(media[capability]); + return entry ? hasAlias(entry) || modelsHaveAlias(entry.models) : false; + }); +} + +const RETIRED_TUNING_PATHS = [ + ["systemAgent"], + ["marketplaces"], + ["cli", "banner", "taglineMode"], + ["commitments"], + ["auth", "cooldowns"], + ["secrets", "resolution"], + ["browser", "remoteCdpTimeoutMs"], + ["browser", "remoteCdpHandshakeTimeoutMs"], + ["browser", "localLaunchTimeoutMs"], + ["browser", "localCdpReadyTimeoutMs"], + ["browser", "actionTimeoutMs"], + ["browser", "cdpPortRangeStart"], + ["browser", "tabCleanup", "idleMinutes"], + ["browser", "tabCleanup", "maxTabsPerSession"], + ["browser", "tabCleanup", "sweepMinutes"], + ["tools", "loopDetection", "genericRepeat"], + ["tools", "loopDetection", "knownPollNoProgress"], + ["tools", "loopDetection", "pingPong"], + ["tools", "loopDetection", "windowSize"], + ["tools", "loopDetection", "historySize"], + ["tools", "loopDetection", "warningThreshold"], + ["tools", "loopDetection", "unknownToolThreshold"], + ["tools", "loopDetection", "criticalThreshold"], + ["tools", "loopDetection", "globalCircuitBreakerThreshold"], + ["tools", "loopDetection", "detectors"], + ["tools", "loopDetection", "postCompactionGuard"], + ["gateway", "handshakeTimeoutMs"], + ["gateway", "channelHealthCheckMinutes"], + ["gateway", "channelStaleEventThresholdMinutes"], + ["gateway", "channelMaxRestartsPerHour"], + ["gateway", "reload", "debounceMs"], + ["gateway", "reload", "deferralTimeoutMs"], + ["gateway", "http", "endpoints", "chatCompletions", "maxBodyBytes"], + ["gateway", "http", "endpoints", "chatCompletions", "maxImageParts"], + ["gateway", "http", "endpoints", "chatCompletions", "maxTotalImageBytes"], + ["gateway", "http", "endpoints", "responses", "maxBodyBytes"], + ["session", "typingIntervalSeconds"], + ["session", "writeLock"], + ["session", "agentToAgent", "maxPingPongTurns"], + ["cron", "maxConcurrentRuns"], + ["cron", "triggers", "minIntervalMs"], + ["cron", "retry"], + ["diagnostics", "stuckSessionWarnMs"], + ["diagnostics", "stuckSessionAbortMs"], + ["diagnostics", "memoryPressureSnapshot"], + ["diagnostics", "memoryPressureBundle"], + ["web", "heartbeatSeconds"], + ["web", "reconnect"], + ["web", "whatsapp"], + ["messages", "queue", "debounceMs"], + ["messages", "statusReactions", "timing"], + ["acp", "stream", "coalesceIdleMs"], + ["acp", "stream", "maxChunkChars"], + ["acp", "stream", "maxOutputChars"], + ["acp", "stream", "maxSessionUpdateChars"], + ["acp", "stream", "hiddenBoundarySeparator"], + ["acp", "maxConcurrentSessions"], + ["acp", "runtime", "ttlMinutes"], + ["mcp", "sessionIdleTtlMs"], + ["worktrees"], + ["transcripts", "maxUtterances"], + ["hooks", "maxBodyBytes"], + ["update", "auto", "stableDelayHours"], + ["update", "auto", "stableJitterHours"], + ["update", "auto", "betaCheckIntervalHours"], + ["memory", "search", "chunking"], + ["memory", "search", "sync", "watchDebounceMs"], + ["memory", "search", "sync", "intervalMinutes"], + ["memory", "search", "query", "hybrid", "vectorWeight"], + ["memory", "search", "query", "hybrid", "textWeight"], + ["memory", "search", "query", "hybrid", "candidateMultiplier"], + ["memory", "search", "query", "hybrid", "mmr", "lambda"], + ["memory", "search", "query", "hybrid", "temporalDecay", "halfLifeDays"], + ["memory", "search", "cache", "maxEntries"], +] as const; + +const RETIRED_AGENT_TUNING_PATHS = [ + ["compaction", "reserveTokens"], + ["compaction", "reserveTokensFloor"], + ["compaction", "maxHistoryShare"], + ["contextPruning", "keepLastAssistants"], + ["contextPruning", "softTrimRatio"], + ["contextPruning", "hardClearRatio"], + ["contextPruning", "minPrunableToolChars"], + ["contextPruning", "softTrim"], + ["memory", "search", "chunking"], + ["memory", "search", "sync", "watchDebounceMs"], + ["memory", "search", "sync", "intervalMinutes"], + ["memory", "search", "query", "hybrid", "vectorWeight"], + ["memory", "search", "query", "hybrid", "textWeight"], + ["memory", "search", "query", "hybrid", "candidateMultiplier"], + ["memory", "search", "query", "hybrid", "mmr", "lambda"], + ["memory", "search", "query", "hybrid", "temporalDecay", "halfLifeDays"], + ["memory", "search", "cache", "maxEntries"], + ["cliBackends", "*", "reliability", "outputLimits"], + ["cliBackends", "*", "reliability", "watchdog", "fresh", "noOutputTimeoutMs"], + ["cliBackends", "*", "reliability", "watchdog", "resume", "noOutputTimeoutMs"], + ["runRetries"], + ["tools", "loopDetection", "genericRepeat"], + ["tools", "loopDetection", "knownPollNoProgress"], + ["tools", "loopDetection", "pingPong"], + ["tools", "loopDetection", "windowSize"], + ["tools", "loopDetection", "historySize"], + ["tools", "loopDetection", "warningThreshold"], + ["tools", "loopDetection", "unknownToolThreshold"], + ["tools", "loopDetection", "criticalThreshold"], + ["tools", "loopDetection", "globalCircuitBreakerThreshold"], + ["tools", "loopDetection", "detectors"], + ["tools", "loopDetection", "postCompactionGuard"], +] as const; + +function deleteRetiredPath(owner: unknown, path: readonly string[], index = 0): boolean { + const record = getRecord(owner); + if (!record) { + return false; + } + const key = path[index]; + if (!key) { + return false; + } + if (key === "*") { + let changed = false; + for (const value of Object.values(record)) { + changed = deleteRetiredPath(value, path, index + 1) || changed; + } + return changed; + } + if (index === path.length - 1) { + if (!Object.hasOwn(record, key)) { + return false; + } + delete record[key]; + return true; + } + const child = getRecord(record[key]); + if (!child || !deleteRetiredPath(child, path, index + 1)) { + return false; + } + if (Object.keys(child).length === 0) { + delete record[key]; + } + return true; +} + +export function stripRetiredTuningKnobs(raw: Record): boolean { + let changed = false; + for (const path of RETIRED_TUNING_PATHS) { + changed = deleteRetiredPath(raw, path) || changed; + } + const agents = getRecord(raw.agents); + const defaults = getRecord(agents?.defaults); + if (defaults) { + for (const path of RETIRED_AGENT_TUNING_PATHS) { + changed = deleteRetiredPath(defaults, path) || changed; + } + } + if (Array.isArray(agents?.list)) { + for (const agent of agents.list) { + for (const path of RETIRED_AGENT_TUNING_PATHS) { + changed = deleteRetiredPath(agent, path) || changed; + } + } + } + const entries = getRecord(agents?.entries); + if (entries) { + for (const agent of Object.values(entries)) { + for (const path of RETIRED_AGENT_TUNING_PATHS) { + changed = deleteRetiredPath(agent, path) || changed; + } + } + } + return changed; +} + +const MEDIA_CAPABILITIES = ["image", "audio", "video"] as const; +function stableConfigValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stableConfigValue); + } + const record = getRecord(value); + if (!record) { + return value; + } + return Object.fromEntries( + Object.keys(record) + .toSorted() + .map((key) => [key, stableConfigValue(record[key])]), + ); +} + +function mediaModelSignature(model: Record): string { + const { capabilities: _capabilities, ...rest } = model; + return JSON.stringify(stableConfigValue(rest)); +} + +function scopeLegacyMediaModel( + model: Record, + capability: string, +): Record | undefined { + if ( + Array.isArray(model.capabilities) && + !model.capabilities.some((value) => value === capability) + ) { + return undefined; + } + return { ...model, capabilities: [capability] }; +} + +export function hasLegacyMediaCapabilityConfig(value: unknown): boolean { + const media = getRecord(value); + return MEDIA_CAPABILITIES.some((capability) => { + const config = getRecord(media?.[capability]); + return Array.isArray(config?.models); + }); +} + +export function consolidateMediaCapabilityConfig( + raw: Record, + changes: string[], +): void { + const media = getRecord(getRecord(raw.tools)?.media); + if (!media) { + return; + } + const sharedModels = Array.isArray(media.models) + ? media.models.filter( + (value): value is Record => getRecord(value) !== undefined, + ) + : []; + const migratedModels: Record[] = []; + let changed = false; + + for (const capability of MEDIA_CAPABILITIES) { + const config = getRecord(media[capability]); + if (!config) { + continue; + } + const legacyModels = Array.isArray(config.models) + ? config.models.filter( + (value): value is Record => getRecord(value) !== undefined, + ) + : []; + const migratedBySignature = new Map>(); + const eligibleLegacyModels = legacyModels.flatMap((legacyModel) => { + const scoped = scopeLegacyMediaModel(legacyModel, capability); + return scoped ? [scoped] : []; + }); + for (const migrated of eligibleLegacyModels) { + const signature = mediaModelSignature(migrated); + const duplicate = migratedBySignature.get(signature); + if (duplicate) { + continue; + } + migratedBySignature.set(signature, migrated); + migratedModels.push(migrated); + } + if (Object.hasOwn(config, "models")) { + delete config.models; + changed = true; + } + if (Object.keys(config).length === 0) { + delete media[capability]; + } + changed = changed || legacyModels.length > 0; + } + const canonicalModels = [...migratedModels, ...sharedModels]; + if (canonicalModels.length > 0) { + media.models = canonicalModels; + } + if (changed) { + changes.push( + "Consolidated tools.media image/audio/video model settings into capability-tagged tools.media.models entries.", + ); + } +} diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts index fc9ae857f397..c8ccb3b10e3c 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.test.ts @@ -42,7 +42,143 @@ function getPath(value: unknown, path: string): unknown { } describe("retired runtime config migrations", () => { + it("consolidates modality model lists with capability tags and exact deduplication", () => { + const result = applyAll({ + tools: { + media: { + models: [{ provider: "openai", model: "shared", capabilities: ["image"] }], + image: { + enabled: true, + models: [{ provider: "openai", model: "shared" }], + }, + audio: { + timeoutSeconds: 20, + models: [ + { provider: "deepgram", model: "nova-3" }, + { provider: "deepgram", model: "nova-3" }, + { provider: "local", model: "same", timeoutSeconds: 20 }, + ], + }, + video: { models: [{ provider: "local", model: "same", timeoutSeconds: 20 }] }, + }, + }, + }); + + expect(result.raw).toEqual({ + tools: { + media: { + models: [ + { provider: "openai", model: "shared", capabilities: ["image"] }, + { + provider: "deepgram", + model: "nova-3", + capabilities: ["audio"], + }, + { + provider: "local", + model: "same", + timeoutSeconds: 20, + capabilities: ["audio"], + }, + { + provider: "local", + model: "same", + timeoutSeconds: 20, + capabilities: ["video"], + }, + { provider: "openai", model: "shared", capabilities: ["image"] }, + ], + image: { enabled: true }, + audio: { timeoutSeconds: 20 }, + }, + }, + }); + }); + + it("keeps modality defaults for auto-detection and preserves distinct legacy entries", () => { + const result = applyAll({ + tools: { + media: { + models: [ + { provider: "openai", model: "shared", capabilities: ["image", "audio"] }, + { provider: "fallback", capabilities: ["audio"] }, + ], + image: { + timeoutSeconds: 180, + models: [{ provider: "openai", model: "shared", prompt: "Describe details" }], + }, + audio: { + language: "en", + models: [{ provider: "local-default" }], + }, + }, + }, + }); + + expect(getPath(result.raw, "tools.media.models")).toEqual([ + { + provider: "openai", + model: "shared", + prompt: "Describe details", + capabilities: ["image"], + }, + { provider: "local-default", capabilities: ["audio"] }, + { provider: "openai", model: "shared", capabilities: ["image", "audio"] }, + { provider: "fallback", capabilities: ["audio"] }, + ]); + expect(getPath(result.raw, "tools.media.image.timeoutSeconds")).toBe(180); + expect(getPath(result.raw, "tools.media.audio.language")).toBe("en"); + }); + + it("preserves independent fallback order across capability lists", () => { + const result = applyAll({ + tools: { + media: { + image: { models: ["a", "b", "c"].map((model) => ({ provider: "p", model })) }, + audio: { models: ["c", "b", "a"].map((model) => ({ provider: "p", model })) }, + }, + }, + }); + const models = getPath(result.raw, "tools.media.models") as Array<{ + model: string; + capabilities: string[]; + }>; + expect( + models.filter((model) => model.capabilities.includes("image")).map((model) => model.model), + ).toEqual(["a", "b", "c"]); + expect( + models.filter((model) => model.capabilities.includes("audio")).map((model) => model.model), + ).toEqual(["c", "b", "a"]); + }); + + it("preserves explicit legacy capability filtering", () => { + const result = applyAll({ + tools: { + media: { + image: { + models: [ + { provider: "skip", model: "audio-only", capabilities: ["audio"] }, + { + provider: "keep", + model: "image-first", + capabilities: ["image", "audio"], + }, + ], + }, + }, + }, + }); + + expect(getPath(result.raw, "tools.media.models")).toEqual([ + { provider: "keep", model: "image-first", capabilities: ["image"] }, + ]); + expect(getPath(result.raw, "tools.media.image.preferredModel")).toBeUndefined(); + }); it.each([ + "systemAgent", + "marketplaces", + "cli.banner.taglineMode", + "commitments", "auth.cooldowns", "secrets.resolution", "browser.remoteCdpTimeoutMs", @@ -53,23 +189,20 @@ describe("retired runtime config migrations", () => { "agents.defaults.compaction.reserveTokensFloor", "agents.defaults.compaction.maxHistoryShare", "agents.defaults.contextPruning.softTrim", - "agents.defaults.memorySearch.chunking", - "agents.defaults.memorySearch.sync.watchDebounceMs", - "agents.defaults.memorySearch.sync.intervalMinutes", - "agents.defaults.memorySearch.query.hybrid.vectorWeight", - "agents.defaults.memorySearch.query.hybrid.mmr.lambda", - "agents.defaults.memorySearch.query.hybrid.temporalDecay.halfLifeDays", - "agents.defaults.memorySearch.cache.maxEntries", + "memory.search.chunking", + "memory.search.cache.maxEntries", "agents.defaults.cliBackends.codex.reliability.outputLimits", "agents.defaults.cliBackends.codex.reliability.watchdog.fresh.noOutputTimeoutMs", "agents.defaults.runRetries", "agents.list.0.compaction.reserveTokens", "agents.list.0.contextPruning.softTrimRatio", - "agents.list.0.memorySearch.chunking", + "agents.list.0.memory.search.chunking", "agents.list.0.cliBackends.codex.reliability.outputLimits", "agents.list.0.runRetries", "agents.list.0.tools.loopDetection.warningThreshold", "agents.list.0.tools.loopDetection.detectors", + "agents.entries.worker.contextPruning.softTrimRatio", + "agents.entries.worker.memory.search.chunking", "gateway.handshakeTimeoutMs", "gateway.channelHealthCheckMinutes", "gateway.reload.debounceMs", @@ -85,9 +218,6 @@ describe("retired runtime config migrations", () => { "diagnostics.stuckSessionWarnMs", "diagnostics.memoryPressureSnapshot", "diagnostics.memoryPressureBundle", - "web.heartbeatSeconds", - "web.reconnect", - "web.whatsapp", "messages.queue.debounceMs", "messages.statusReactions.timing", "acp.stream.coalesceIdleMs", @@ -128,7 +258,7 @@ describe("retired runtime config migrations", () => { }); expect(result.raw).toMatchObject({ - channels: { whatsapp: { messagePrefix: "[wa]" } }, + channels: { whatsapp: { responsePrefix: "[wa]" } }, agents: { defaults: { model: "openai/gpt-5.6" } }, cron: { webhookToken: "keep" }, session: { maintenance: { pruneAfter: 7 }, resetByType: { direct: { mode: "idle" } } }, @@ -136,7 +266,7 @@ describe("retired runtime config migrations", () => { mcp: { servers: { docs: { connectionTimeoutMs: 2000, requestTimeoutMs: 3000 } } }, nodeHost: { mcp: { servers: { local: { connectionTimeoutMs: 4000 } } } }, tools: { - media: { audio: { providerOptions: { deepgram: { smart_format: true } } } }, + media: {}, message: { crossContext: { allowWithinProvider: true, allowAcrossProviders: true } }, }, }); @@ -144,4 +274,323 @@ describe("retired runtime config migrations", () => { expect(result.raw).not.toHaveProperty("commands.modelsWrite"); expect(result.changes.length).toBeGreaterThan(8); }); + + it("consolidates the approved tier-eval tranche with canonical values winning", () => { + const result = applyAll({ + mcp: { servers: { docs: { cwd: "/canonical", workingDirectory: "/legacy" } } }, + nodeHost: { mcp: { servers: { local: { workingDirectory: "/node" } } } }, + session: { + idleMinutes: 45, + reset: { idleMinutes: 90 }, + threadBindings: { enabled: false, idleHours: 12 }, + }, + channels: { + signal: { httpHost: "127.0.0.2", httpPort: 9090 }, + googlechat: { serviceAccountRef: { source: "env" } }, + discord: { threadBindings: { enabled: false, idleHours: 12 } }, + whatsapp: {}, + }, + agents: { + defaults: { + cliBackends: { custom: { sessionArg: "--session" } }, + heartbeat: { ackMaxChars: 10, includeReasoning: true }, + memory: { search: { query: { hybrid: { enabled: false } } } }, + }, + entries: { + main: { + groupChat: { visibleReplies: "automatic" }, + tools: { exec: { security: "allowlist", ask: "on-miss" } }, + }, + }, + }, + tools: { + exec: { mode: "deny", security: "full", ask: "off" }, + media: { + models: [ + { + provider: "openai", + model: "whisper-1", + capabilities: ["audio"], + baseUrl: "https://legacy.example/v1", + headers: { "x-legacy": "1" }, + }, + ], + audio: { request: { auth: { mode: "none" } } }, + }, + }, + models: { providers: { openai: { headers: { "x-canonical": "1" } } } }, + memory: { + qmd: { mcporter: { enabled: true }, update: { interval: "1m" } }, + search: { + experimental: { sessionMemory: true }, + remote: { nonBatchConcurrency: 4, batch: { enabled: true, concurrency: 3 } }, + sync: { watch: false }, + store: { driver: "sqlite", vector: { enabled: false } }, + }, + }, + messages: { responsePrefix: "[bot]" }, + web: { enabled: false }, + logging: { redactSensitive: "off" }, + commands: { useAccessGroups: false }, + gateway: { + controlUi: { allowInsecureAuth: true, dangerouslyDisableDeviceAuth: true }, + }, + proxy: { enabled: true, proxyUrl: "http://proxy.example" }, + discovery: { wideArea: { enabled: true, domain: "openclaw.internal" } }, + }); + + expect(result.raw).toMatchObject({ + mcp: { servers: { docs: { cwd: "/canonical" } } }, + nodeHost: { mcp: { servers: { local: { cwd: "/node" } } } }, + session: { reset: { idleMinutes: 90 }, threadBindings: { enabled: false, idleHours: 12 } }, + channels: { + signal: { httpUrl: "http://127.0.0.2:9090", autoStart: true }, + googlechat: { serviceAccount: { source: "env" } }, + discord: { threadBindings: { enabled: false, idleHours: 12 } }, + whatsapp: { enabled: false, responsePrefix: "[bot]" }, + }, + agents: { + defaults: { cliBackends: { custom: { sessionArgs: ["--session", "{sessionId}"] } } }, + entries: { main: { tools: { exec: { mode: "ask" } } } }, + }, + tools: { + exec: { mode: "deny" }, + media: { + models: [ + { + provider: "openai", + model: "whisper-1", + capabilities: ["audio"], + baseUrl: "https://legacy.example/v1", + headers: { "x-legacy": "1" }, + }, + ], + audio: { request: { auth: { mode: "none" } } }, + }, + }, + models: { + providers: { + openai: { headers: { "x-canonical": "1" } }, + }, + }, + memory: { + search: { + experimental: { sessionMemory: true }, + remote: { batch: { enabled: true } }, + store: { vector: { enabled: false } }, + }, + }, + proxy: { proxyUrl: "http://proxy.example" }, + discovery: { wideArea: { domain: "openclaw.internal" } }, + }); + expect(result.raw).toHaveProperty("messages.responsePrefix", "[bot]"); + expect(result.raw).not.toHaveProperty("web"); + expect(result.raw).not.toHaveProperty("logging.redactSensitive"); + expect(result.raw).not.toHaveProperty("commands.useAccessGroups"); + expect(result.raw).not.toHaveProperty("gateway.controlUi.allowInsecureAuth"); + expect(result.raw).not.toHaveProperty("memory.qmd"); + }); + + it("keeps evidence mismatches while stripping canonical conflict aliases", () => { + const result = applyAll({ + session: { threadBindings: { enabled: true } }, + tools: { media: { audio: { baseUrl: "https://provider-required.example" } } }, + proxy: { enabled: false, proxyUrl: "http://disabled-proxy.example" }, + discovery: { wideArea: { enabled: false, domain: "disabled.example" } }, + channels: { + telegram: { threadBindings: { enabled: false } }, + googlechat: { serviceAccount: "plain", serviceAccountRef: { source: "env" } }, + whatsapp: { enabled: true }, + }, + web: { enabled: false }, + }); + + expect(result.raw).toHaveProperty("channels.telegram.threadBindings.enabled", false); + expect(result.raw).toHaveProperty( + "tools.media.audio.baseUrl", + "https://provider-required.example", + ); + expect(result.raw).toHaveProperty("proxy", { + enabled: false, + proxyUrl: "http://disabled-proxy.example", + }); + expect(result.raw).not.toHaveProperty("discovery.wideArea.domain"); + expect(result.raw).not.toHaveProperty("channels.googlechat.serviceAccountRef"); + expect(result.raw).toHaveProperty("channels.googlechat.serviceAccount", { source: "env" }); + expect(result.raw).not.toHaveProperty("web"); + }); + + it("keeps nonrepresentable exec and inherited memory policies", () => { + const result = applyAll({ + tools: { exec: { security: "allowlist", ask: "always" } }, + memory: { search: { provider: "openai", store: { vector: { enabled: false } } } }, + agents: { + entries: { + malformed: { tools: { exec: { security: "deny " } } }, + onMissFull: { tools: { exec: { security: "full", ask: "on-miss" } } }, + }, + }, + }); + + expect(result.raw).toHaveProperty("tools.exec.ask", "always"); + expect(result.raw).not.toHaveProperty("tools.exec.mode"); + expect(result.raw).toHaveProperty("agents.entries.malformed.tools.exec.security", "deny "); + expect(result.raw).toHaveProperty("agents.entries.onMissFull.tools.exec.ask", "on-miss"); + expect(result.raw).not.toHaveProperty("agents.entries.onMissFull.tools.exec.mode"); + expect(result.raw).toHaveProperty("memory.search.provider", "openai"); + expect(result.raw).toHaveProperty("memory.search.store.vector.enabled", false); + expect(result.changes).toEqual([]); + }); + + it("uses the inherited exec policy when migrating a partial agent override", () => { + const result = applyAll({ + tools: { exec: { security: "allowlist", ask: "on-miss" } }, + agents: { + entries: { + nonInteractive: { tools: { exec: { ask: "off" } } }, + }, + }, + }); + + expect(result.raw).toHaveProperty("tools.exec.mode", "ask"); + expect(result.raw).toHaveProperty("agents.entries.nonInteractive.tools.exec.mode", "allowlist"); + expect(result.raw).not.toHaveProperty("agents.entries.nonInteractive.tools.exec.ask"); + }); + + it("preserves idle mode when migrating a standalone session idle timeout", () => { + const result = applyAll({ session: { idleMinutes: 45 } }); + + expect(result.raw).toHaveProperty("session.reset", { mode: "idle", idleMinutes: 45 }); + }); + + it("brackets IPv6 Signal hosts when migrating the legacy endpoint fields", () => { + const result = applyAll({ + channels: { signal: { httpHost: "::1", httpPort: 9090 } }, + }); + + expect(result.raw).toHaveProperty("channels.signal.httpUrl", "http://[::1]:9090"); + }); + + it("preserves inherited Signal host values for partial account overrides", () => { + const result = applyAll({ + channels: { + signal: { + httpHost: "10.0.0.5", + httpPort: 8080, + accounts: { work: { httpPort: 9090 } }, + }, + }, + }); + + expect(result.raw).toHaveProperty( + "channels.signal.accounts.work.httpUrl", + "http://10.0.0.5:9090", + ); + }); + + it("keeps an inherited canonical Signal URL over account legacy fields", () => { + const result = applyAll({ + channels: { + signal: { + httpUrl: "http://signal.example:8080", + accounts: { work: { httpPort: 9090 } }, + }, + }, + }); + + expect(result.raw).not.toHaveProperty("channels.signal.accounts.work.httpUrl"); + expect(result.raw).not.toHaveProperty("channels.signal.accounts.work.httpPort"); + expect(result.raw).toHaveProperty("channels.signal.httpUrl", "http://signal.example:8080"); + }); + + it("moves the global TTS preference path while retaining scoped agent paths", () => { + const result = applyAll({ + tts: { prefsPath: "/global/tts.json" }, + agents: { entries: { voice: { tts: { prefsPath: "/voice/tts.json" } } } }, + }); + + expect(result.raw).not.toHaveProperty("tts.prefsPath"); + expect(result.raw).toHaveProperty("agents.entries.voice.tts.prefsPath", "/voice/tts.json"); + }); + + it("strips TTS persona prompts with a prepareSynthesis migration pointer", () => { + const result = applyAll({ + tts: { + personas: { + alfred: { + prompt: { style: "dry" }, + providers: { + custom: { tts: { personas: { voice: { prompt: { owned: true } } } } }, + }, + }, + }, + }, + agents: { + entries: { + voice: { tts: { personas: { narrator: { prompt: { pacing: "slow" } } } } }, + }, + }, + }); + + expect(result.raw).not.toHaveProperty("tts.personas.alfred.prompt"); + expect(result.raw).toHaveProperty( + "tts.personas.alfred.providers.custom.tts.personas.voice.prompt.owned", + true, + ); + expect(result.raw).not.toHaveProperty("agents.entries.voice.tts.personas.narrator.prompt"); + expect(result.changes.join("\n")).toContain("prepareSynthesis"); + }); + + it("strips compaction prompt config with provider and hook pointers", () => { + const result = applyAll({ + agents: { + defaults: { + compaction: { + customInstructions: "Keep decisions.", + identifierPolicy: "custom", + identifierInstructions: "Keep ticket IDs.", + postCompactionSections: ["Red Lines"], + memoryFlush: { prompt: "Write memory.", systemPrompt: "Be careful." }, + }, + }, + }, + }); + + expect(result.raw).toHaveProperty("agents.defaults.compaction.identifierPolicy", "strict"); + expect(result.raw).not.toHaveProperty("agents.defaults.compaction.customInstructions"); + expect(result.raw).not.toHaveProperty("agents.defaults.compaction.identifierInstructions"); + expect(result.raw).toHaveProperty("agents.defaults.compaction.postCompactionSections", [ + "Red Lines", + ]); + expect(result.raw).not.toHaveProperty("agents.defaults.compaction.memoryFlush.prompt"); + expect(result.raw).not.toHaveProperty("agents.defaults.compaction.memoryFlush.systemPrompt"); + expect(result.changes.join("\n")).toContain("summarize()"); + expect(result.changes.join("\n")).toContain("before_prompt_build"); + }); + + it("copies responsePrefix to supported channels while retaining custom-channel fallback", () => { + const result = applyAll({ + messages: { responsePrefix: "[bot]" }, + channels: { whatsapp: {}, custom: { enabled: true } }, + }); + + expect(result.raw).toHaveProperty("channels.whatsapp.responsePrefix", "[bot]"); + expect(result.raw).toHaveProperty("messages.responsePrefix", "[bot]"); + expect(applyAll(result.raw).changes).toEqual([]); + }); + + it("keeps the inherited session-memory policy", () => { + const result = applyAll({ + memory: { + search: { + rememberAcrossConversations: true, + sources: ["memory"], + experimental: { sessionMemory: true }, + }, + }, + }); + + expect(result.raw).toHaveProperty("memory.search.sources", ["memory"]); + expect(result.raw).toHaveProperty("memory.search.experimental.sessionMemory", true); + }); }); diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts index 242e346a41a4..d34bcc3ea94d 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.retired.ts @@ -3,9 +3,21 @@ import { defineLegacyConfigMigration, ensureRecord, getRecord, + mergeMissing, type LegacyConfigMigrationSpec, type LegacyConfigRule, } from "../../../config/legacy.shared.js"; +import { + consolidateMediaCapabilityConfig, + hasDiscordRealtimeVoice, + hasLegacyMediaCapabilityConfig, + hasMediaDeepgram, + migrateDiscordVoice, + migrateMediaDeepgram, + moveVoice, + stripRetiredTuningKnobs, +} from "./legacy-config-migrations.runtime.retired-media.js"; +import { migrateTierEvalTranche } from "./legacy-config-migrations.runtime.tier-eval.js"; const rule = ( path: string[], @@ -17,290 +29,465 @@ const rule = ( ...(match ? { match } : {}), }); -function moveVoice(owner: Record, path: string, changes: string[]): void { - if (!Object.hasOwn(owner, "voice")) { - return; - } - if (owner.speakerVoice === undefined) { - owner.speakerVoice = owner.voice; - changes.push(`Moved ${path}.voice → ${path}.speakerVoice.`); - } else { - changes.push(`Removed ${path}.voice (${path}.speakerVoice already set).`); - } - delete owner.voice; -} - -function migrateDiscordVoice(channels: Record, changes: string[]): void { - const discord = getRecord(channels.discord); - if (!discord) { - return; - } - const migrateEntry = (entry: Record, path: string) => { - const realtime = getRecord(getRecord(entry.voice)?.realtime); - if (realtime) { - moveVoice(realtime, `${path}.voice.realtime`, changes); - } - }; - migrateEntry(discord, "channels.discord"); - const accounts = getRecord(discord.accounts); - if (accounts) { - for (const [accountId, value] of Object.entries(accounts)) { - const account = getRecord(value); - if (account) { - migrateEntry(account, `channels.discord.accounts.${accountId}`); - } - } - } -} - -function hasDiscordRealtimeVoice(value: unknown): boolean { - const discord = getRecord(value); - if (!discord) { - return false; - } - const hasAlias = (entry: unknown) => { - const realtime = getRecord(getRecord(getRecord(entry)?.voice)?.realtime); - return realtime ? Object.hasOwn(realtime, "voice") : false; - }; - if (hasAlias(discord)) { - return true; - } - const accounts = getRecord(discord.accounts); - return accounts ? Object.values(accounts).some(hasAlias) : false; -} - -function mapDeepgram(value: Record): Record { - const mapped: Record = {}; - if (typeof value.detectLanguage === "boolean") { - mapped.detect_language = value.detectLanguage; - } - if (typeof value.punctuate === "boolean") { - mapped.punctuate = value.punctuate; - } - if (typeof value.smartFormat === "boolean") { - mapped.smart_format = value.smartFormat; - } - return mapped; -} - -function migrateDeepgramOwner( - owner: Record, +function moveKey( + owner: Record | null | undefined, + legacyKey: string, + canonicalKey: string, path: string, changes: string[], ): void { - const legacy = getRecord(owner.deepgram); - if (!legacy) { + if (!owner || !Object.hasOwn(owner, legacyKey)) { return; } - const providerOptions = getRecord(owner.providerOptions) ?? {}; - const canonical = getRecord(providerOptions.deepgram) ?? {}; - providerOptions.deepgram = { ...mapDeepgram(legacy), ...canonical }; - owner.providerOptions = providerOptions; - delete owner.deepgram; - changes.push(`Moved ${path}.deepgram → ${path}.providerOptions.deepgram.`); + if (owner[canonicalKey] === undefined) { + owner[canonicalKey] = owner[legacyKey]; + changes.push(`Moved ${path}.${legacyKey} → ${path}.${canonicalKey}.`); + } else { + changes.push(`Removed ${path}.${legacyKey} (${path}.${canonicalKey} already set).`); + } + delete owner[legacyKey]; } -function migrateMediaDeepgram(raw: Record, changes: string[]): void { - const media = getRecord(getRecord(raw.tools)?.media); - if (!media) { - return; - } - const migrateModels = (models: unknown, path: string) => { - if (!Array.isArray(models)) { - return; - } - models.forEach((value, index) => { - const model = getRecord(value); - if (model) { - migrateDeepgramOwner(model, `${path}[${index}]`, changes); - } - }); - }; - migrateModels(media.models, "tools.media.models"); - for (const capability of ["audio", "image", "video"]) { - const entry = getRecord(media[capability]); - if (!entry) { - continue; - } - migrateDeepgramOwner(entry, `tools.media.${capability}`, changes); - migrateModels(entry.models, `tools.media.${capability}.models`); - } -} - -function hasMediaDeepgram(value: unknown): boolean { - const media = getRecord(value); - if (!media) { - return false; - } - const hasAlias = (entry: unknown) => { - const owner = getRecord(entry); - return owner ? Object.hasOwn(owner, "deepgram") : false; - }; - const modelsHaveAlias = (models: unknown) => Array.isArray(models) && models.some(hasAlias); - if (modelsHaveAlias(media.models)) { - return true; - } - return ["audio", "image", "video"].some((capability) => { - const entry = getRecord(media[capability]); - return entry ? hasAlias(entry) || modelsHaveAlias(entry.models) : false; - }); -} - -const RETIRED_TUNING_PATHS = [ - ["auth", "cooldowns"], - ["secrets", "resolution"], - ["browser", "remoteCdpTimeoutMs"], - ["browser", "remoteCdpHandshakeTimeoutMs"], - ["browser", "localLaunchTimeoutMs"], - ["browser", "localCdpReadyTimeoutMs"], - ["browser", "actionTimeoutMs"], - ["browser", "cdpPortRangeStart"], - ["browser", "tabCleanup", "idleMinutes"], - ["browser", "tabCleanup", "maxTabsPerSession"], - ["browser", "tabCleanup", "sweepMinutes"], - ["tools", "loopDetection", "genericRepeat"], - ["tools", "loopDetection", "knownPollNoProgress"], - ["tools", "loopDetection", "pingPong"], - ["tools", "loopDetection", "windowSize"], - ["tools", "loopDetection", "historySize"], - ["tools", "loopDetection", "warningThreshold"], - ["tools", "loopDetection", "unknownToolThreshold"], - ["tools", "loopDetection", "criticalThreshold"], - ["tools", "loopDetection", "globalCircuitBreakerThreshold"], - ["tools", "loopDetection", "detectors"], - ["tools", "loopDetection", "postCompactionGuard"], - ["gateway", "handshakeTimeoutMs"], - ["gateway", "channelHealthCheckMinutes"], - ["gateway", "channelStaleEventThresholdMinutes"], - ["gateway", "channelMaxRestartsPerHour"], - ["gateway", "reload", "debounceMs"], - ["gateway", "reload", "deferralTimeoutMs"], - ["gateway", "http", "endpoints", "chatCompletions", "maxBodyBytes"], - ["gateway", "http", "endpoints", "chatCompletions", "maxImageParts"], - ["gateway", "http", "endpoints", "chatCompletions", "maxTotalImageBytes"], - ["gateway", "http", "endpoints", "responses", "maxBodyBytes"], - ["session", "typingIntervalSeconds"], - ["session", "writeLock"], - ["session", "agentToAgent", "maxPingPongTurns"], - ["cron", "maxConcurrentRuns"], - ["cron", "triggers", "minIntervalMs"], - ["cron", "retry"], - ["diagnostics", "stuckSessionWarnMs"], - ["diagnostics", "stuckSessionAbortMs"], - ["diagnostics", "memoryPressureSnapshot"], - ["diagnostics", "memoryPressureBundle"], - ["web", "heartbeatSeconds"], - ["web", "reconnect"], - ["web", "whatsapp"], - ["messages", "queue", "debounceMs"], - ["messages", "statusReactions", "timing"], - ["acp", "stream", "coalesceIdleMs"], - ["acp", "stream", "maxChunkChars"], - ["acp", "stream", "maxOutputChars"], - ["acp", "stream", "maxSessionUpdateChars"], - ["acp", "stream", "hiddenBoundarySeparator"], - ["acp", "maxConcurrentSessions"], - ["acp", "runtime", "ttlMinutes"], - ["mcp", "sessionIdleTtlMs"], - ["worktrees"], - ["transcripts", "maxUtterances"], - ["hooks", "maxBodyBytes"], - ["update", "auto", "stableDelayHours"], - ["update", "auto", "stableJitterHours"], - ["update", "auto", "betaCheckIntervalHours"], -] as const; - -const RETIRED_AGENT_TUNING_PATHS = [ - ["compaction", "reserveTokens"], - ["compaction", "reserveTokensFloor"], - ["compaction", "maxHistoryShare"], - ["contextPruning", "keepLastAssistants"], - ["contextPruning", "softTrimRatio"], - ["contextPruning", "hardClearRatio"], - ["contextPruning", "minPrunableToolChars"], - ["contextPruning", "softTrim"], - ["memorySearch", "chunking"], - ["memorySearch", "sync", "watchDebounceMs"], - ["memorySearch", "sync", "intervalMinutes"], - ["memorySearch", "query", "hybrid", "vectorWeight"], - ["memorySearch", "query", "hybrid", "textWeight"], - ["memorySearch", "query", "hybrid", "candidateMultiplier"], - ["memorySearch", "query", "hybrid", "mmr", "lambda"], - ["memorySearch", "query", "hybrid", "temporalDecay", "halfLifeDays"], - ["memorySearch", "cache", "maxEntries"], - ["cliBackends", "*", "reliability", "outputLimits"], - ["cliBackends", "*", "reliability", "watchdog", "fresh", "noOutputTimeoutMs"], - ["cliBackends", "*", "reliability", "watchdog", "resume", "noOutputTimeoutMs"], - ["runRetries"], - ["tools", "loopDetection", "genericRepeat"], - ["tools", "loopDetection", "knownPollNoProgress"], - ["tools", "loopDetection", "pingPong"], - ["tools", "loopDetection", "windowSize"], - ["tools", "loopDetection", "historySize"], - ["tools", "loopDetection", "warningThreshold"], - ["tools", "loopDetection", "unknownToolThreshold"], - ["tools", "loopDetection", "criticalThreshold"], - ["tools", "loopDetection", "globalCircuitBreakerThreshold"], - ["tools", "loopDetection", "detectors"], - ["tools", "loopDetection", "postCompactionGuard"], -] as const; - -function deleteRetiredPath(owner: unknown, path: readonly string[], index = 0): boolean { - const record = getRecord(owner); - if (!record) { - return false; - } - const key = path[index]; - if (!key) { - return false; - } - if (key === "*") { - let changed = false; - for (const value of Object.values(record)) { - changed = deleteRetiredPath(value, path, index + 1) || changed; - } - return changed; - } - if (index === path.length - 1) { - if (!Object.hasOwn(record, key)) { - return false; - } - delete record[key]; - return true; - } - const child = getRecord(record[key]); - if (!child || !deleteRetiredPath(child, path, index + 1)) { - return false; - } - if (Object.keys(child).length === 0) { - delete record[key]; - } - return true; -} - -function stripRetiredTuningKnobs(raw: Record): boolean { - let changed = false; - for (const path of RETIRED_TUNING_PATHS) { - changed = deleteRetiredPath(raw, path) || changed; - } +function migrateFinalLayoutRenames(raw: Record, changes: string[]): void { const agents = getRecord(raw.agents); const defaults = getRecord(agents?.defaults); + moveKey(defaults, "pdfMaxBytesMb", "pdfMaxMb", "agents.defaults", changes); if (defaults) { - for (const path of RETIRED_AGENT_TUNING_PATHS) { - changed = deleteRetiredPath(defaults, path) || changed; + const mediaModels = getRecord(defaults.mediaModels) ?? {}; + for (const [legacyKey, canonicalKey] of [ + ["imageGenerationModel", "image"], + ["videoGenerationModel", "video"], + ["musicGenerationModel", "music"], + ] as const) { + if (!Object.hasOwn(defaults, legacyKey)) { + continue; + } + if (mediaModels[canonicalKey] === undefined) { + mediaModels[canonicalKey] = defaults[legacyKey]; + changes.push( + `Moved agents.defaults.${legacyKey} → agents.defaults.mediaModels.${canonicalKey}.`, + ); + } else { + changes.push( + `Removed agents.defaults.${legacyKey} (agents.defaults.mediaModels.${canonicalKey} already set).`, + ); + } + delete defaults[legacyKey]; + } + if (Object.keys(mediaModels).length > 0) { + defaults.mediaModels = mediaModels; } } + + const migrateAgentScope = (scope: Record | null, path: string) => { + moveKey( + getRecord(getRecord(scope?.tools)?.exec), + "timeoutSec", + "timeoutSeconds", + `${path}.tools.exec`, + changes, + ); + moveKey( + getRecord(getRecord(getRecord(scope?.sandbox)?.browser)), + "enableNoVnc", + "noVncEnabled", + `${path}.sandbox.browser`, + changes, + ); + }; + migrateAgentScope(defaults, "agents.defaults"); if (Array.isArray(agents?.list)) { - for (const agent of agents.list) { - for (const path of RETIRED_AGENT_TUNING_PATHS) { - changed = deleteRetiredPath(agent, path) || changed; + agents.list.forEach((entry, index) => + migrateAgentScope(getRecord(entry), `agents.list[${index}]`), + ); + } + moveKey( + getRecord(getRecord(raw.tools)?.exec), + "timeoutSec", + "timeoutSeconds", + "tools.exec", + changes, + ); + + const env = getRecord(raw.env); + if (env) { + const vars = getRecord(env.vars) ?? {}; + let moved = false; + for (const [key, value] of Object.entries(env)) { + if (key === "vars" || key === "shellEnv" || typeof value !== "string") { + continue; + } + if (vars[key] === undefined) { + vars[key] = value; + changes.push(`Moved env.${key} → env.vars.${key}.`); + } else { + changes.push(`Removed env.${key} (env.vars.${key} already set).`); + } + delete env[key]; + moved = true; + } + if (moved) { + env.vars = vars; + } + } + + const browser = getRecord(raw.browser); + const ssrfPolicy = getRecord(browser?.ssrfPolicy); + if (ssrfPolicy && Array.isArray(ssrfPolicy.hostnameAllowlist)) { + const canonical = Array.isArray(ssrfPolicy.allowedHostnames) ? ssrfPolicy.allowedHostnames : []; + ssrfPolicy.allowedHostnames = [ + ...new Set( + [...canonical, ...ssrfPolicy.hostnameAllowlist].filter( + (value) => typeof value === "string", + ), + ), + ]; + delete ssrfPolicy.hostnameAllowlist; + changes.push("Merged browser.ssrfPolicy.hostnameAllowlist → allowedHostnames."); + } + + const legacyMedia = getRecord(raw.media); + if (legacyMedia) { + const attachments = ensureRecord(raw, "attachments"); + mergeMissing(attachments, legacyMedia); + delete raw.media; + changes.push("Moved media → attachments."); + } + + const audit = getRecord(raw.audit); + if (audit) { + const logging = ensureRecord(raw, "logging"); + const canonicalAudit = getRecord(logging.audit) ?? {}; + mergeMissing(canonicalAudit, audit); + logging.audit = canonicalAudit; + delete raw.audit; + changes.push("Moved audit → logging.audit."); + } + + const nodes = getRecord(getRecord(raw.gateway)?.nodes); + if (nodes) { + const skills = getRecord(nodes.skills); + if (skills && Object.hasOwn(skills, "enabled")) { + if (nodes.allowSkills === undefined) { + nodes.allowSkills = skills.enabled; + } + delete nodes.skills; + changes.push("Moved gateway.nodes.skills.enabled → gateway.nodes.allowSkills."); + } + const commands = getRecord(nodes.commands) ?? {}; + if (Object.hasOwn(nodes, "allowCommands")) { + if (commands.allow === undefined) { + commands.allow = nodes.allowCommands; + } + delete nodes.allowCommands; + changes.push("Moved gateway.nodes.allowCommands → gateway.nodes.commands.allow."); + } + if (Object.hasOwn(nodes, "denyCommands")) { + if (commands.deny === undefined) { + commands.deny = nodes.denyCommands; + } + delete nodes.denyCommands; + changes.push("Moved gateway.nodes.denyCommands → gateway.nodes.commands.deny."); + } + if (Object.keys(commands).length > 0) { + nodes.commands = commands; + } + } + + const slack = getRecord(getRecord(raw.channels)?.slack); + moveKey(slack, "identity", "postAs", "channels.slack", changes); + const slackAccounts = getRecord(slack?.accounts); + if (slackAccounts) { + for (const [accountId, value] of Object.entries(slackAccounts)) { + moveKey( + getRecord(value), + "identity", + "postAs", + `channels.slack.accounts.${accountId}`, + changes, + ); + } + } +} + +function visitChannelEntries( + raw: Record, + channelId: string, + visitor: (entry: Record, path: string) => void, +): void { + const channel = getRecord(getRecord(raw.channels)?.[channelId]); + if (!channel) { + return; + } + visitor(channel, `channels.${channelId}`); + const accounts = getRecord(channel.accounts); + if (!accounts) { + return; + } + for (const [accountId, value] of Object.entries(accounts)) { + const account = getRecord(value); + if (account) { + visitor(account, `channels.${channelId}.accounts.${accountId}`); + } + } +} + +function migrateFinalLayoutKills(raw: Record, changes: string[]): void { + const defaults = getRecord(getRecord(raw.agents)?.defaults); + for (const key of [ + "promptOverlays", + "envelopeTimestamp", + "envelopeElapsed", + "envelopeTimezone", + "timeFormat", + "bootstrapPromptTruncationWarning", + "mediaGenerationAutoProviderFallback", + ]) { + if (defaults && Object.hasOwn(defaults, key)) { + delete defaults[key]; + changes.push(`Removed agents.defaults.${key}; built-in behavior now applies.`); + } + } + + const diagnostics = getRecord(raw.diagnostics); + const otel = getRecord(diagnostics?.otel); + const captureContent = getRecord(otel?.captureContent); + if (otel && captureContent) { + otel.captureContent = + typeof captureContent.enabled === "boolean" + ? captureContent.enabled + : Object.entries(captureContent).some( + ([key, value]) => key !== "enabled" && value === true, + ); + changes.push("Collapsed diagnostics.otel.captureContent to a boolean."); + } + const cacheTrace = getRecord(diagnostics?.cacheTrace); + if ( + cacheTrace && + (Object.keys(cacheTrace).some((key) => key !== "enabled") || + (cacheTrace.enabled !== undefined && typeof cacheTrace.enabled !== "boolean")) + ) { + diagnostics!.cacheTrace = { enabled: cacheTrace.enabled === true }; + changes.push("Removed diagnostics.cacheTrace detail fields; only enabled remains."); + } + + const attachments = getRecord(raw.attachments); + if (attachments && Object.hasOwn(attachments, "preserveFilenames")) { + delete attachments.preserveFilenames; + changes.push("Removed attachments.preserveFilenames; temp-safe names now always apply."); + } + const browser = getRecord(raw.browser); + if (browser && Object.hasOwn(browser, "color")) { + delete browser.color; + changes.push("Removed browser.color; the built-in color now applies."); + } + const profiles = getRecord(browser?.profiles); + if (profiles) { + for (const [profileId, value] of Object.entries(profiles)) { + const profile = getRecord(value); + if (profile && Object.hasOwn(profile, "color")) { + delete profile.color; + changes.push(`Removed browser.profiles.${profileId}.color.`); } } } - return changed; + + visitChannelEntries(raw, "discord", (entry, path) => { + const autoPresence = getRecord(entry.autoPresence); + for (const key of ["healthyText", "degradedText", "exhaustedText"]) { + if (autoPresence && Object.hasOwn(autoPresence, key)) { + delete autoPresence[key]; + changes.push(`Removed ${path}.autoPresence.${key}.`); + } + } + const components = getRecord(getRecord(entry.ui)?.components); + if (components && Object.hasOwn(components, "accentColor")) { + delete components.accentColor; + changes.push(`Removed ${path}.ui.components.accentColor.`); + const ui = getRecord(entry.ui); + if (Object.keys(components).length === 0 && ui) { + delete ui.components; + } + if (ui && Object.keys(ui).length === 0) { + delete entry.ui; + } + } + if (Object.hasOwn(entry, "subagentProgress")) { + delete entry.subagentProgress; + changes.push(`Removed ${path}.subagentProgress.`); + } + }); + + let messages = getRecord(raw.messages); + const statusReactions = getRecord(messages?.statusReactions); + if (statusReactions && Object.hasOwn(statusReactions, "emojis")) { + delete statusReactions.emojis; + changes.push("Removed messages.statusReactions.emojis; curated defaults now apply."); + } + if (messages && Object.hasOwn(messages, "removeAckAfterReply")) { + delete messages.removeAckAfterReply; + changes.push("Removed messages.removeAckAfterReply; acknowledgements are retained."); + } + + visitChannelEntries(raw, "whatsapp", (entry, path) => { + moveKey(entry, "messagePrefix", "responsePrefix", path, changes); + const ack = getRecord(entry.ackReaction); + if (!ack) { + return; + } + messages ??= ensureRecord(raw, "messages"); + if (messages.ackReaction === undefined) { + const legacyAgents = getRecord(raw.agents)?.list; + const agentEntries = Array.isArray(legacyAgents) + ? legacyAgents.filter((value): value is Record => + Boolean(getRecord(value)), + ) + : []; + const defaultAgent = + agentEntries.find((value) => getRecord(value)?.default === true) ?? agentEntries[0]; + const identityEmoji = getRecord(getRecord(defaultAgent)?.identity)?.emoji; + messages.ackReaction = + typeof ack.emoji === "string" + ? ack.emoji + : typeof identityEmoji === "string" + ? identityEmoji + : "👀"; + } + if (messages.ackReactionScope === undefined) { + const direct = ack.direct !== false; + const group = ack.group ?? "mentions"; + const scope = + direct && group === "always" + ? "all" + : direct && group === "never" + ? "direct" + : !direct && group === "always" + ? "group-all" + : !direct && group === "mentions" + ? "group-mentions" + : !direct && group === "never" + ? "off" + : undefined; + if (scope) { + messages.ackReactionScope = scope; + } + } + delete entry.ackReaction; + changes.push(`Moved translatable ${path}.ackReaction settings to messages ack settings.`); + }); + + visitChannelEntries(raw, "slack", (entry, path) => { + const socketMode = getRecord(entry.socketMode); + for (const key of ["clientPingTimeout", "serverPingTimeout", "pingPongLoggingEnabled"]) { + if (socketMode && Object.hasOwn(socketMode, key)) { + delete socketMode[key]; + changes.push(`Removed ${path}.socketMode.${key}.`); + } + } + if (socketMode && Object.keys(socketMode).length === 0) { + delete entry.socketMode; + } + }); + visitChannelEntries(raw, "imessage", (entry, path) => { + if (Object.hasOwn(entry, "coalesceSameSenderDms")) { + delete entry.coalesceSameSenderDms; + changes.push(`Removed ${path}.coalesceSameSenderDms.`); + } + }); + + const commands = getRecord(raw.commands); + for (const key of ["ownerDisplay", "ownerDisplaySecret"]) { + if (commands && Object.hasOwn(commands, key)) { + delete commands[key]; + changes.push(`Removed commands.${key}; owner ids now render raw.`); + } + } + + const cron = getRecord(raw.cron); + const failureDestination = getRecord(cron?.failureDestination); + if (cron && failureDestination) { + const failureAlert = getRecord(cron.failureAlert) ?? {}; + mergeMissing(failureAlert, failureDestination); + cron.failureAlert = failureAlert; + delete cron.failureDestination; + changes.push("Merged cron.failureDestination → cron.failureAlert."); + } + const gateway = getRecord(raw.gateway); + const reload = getRecord(gateway?.reload); + if (reload?.mode === "restart" || reload?.mode === "hot") { + reload.mode = "hybrid"; + changes.push("Mapped gateway.reload.mode to hybrid."); + } + const logging = getRecord(raw.logging); + if (logging?.consoleStyle === "compact") { + logging.consoleStyle = "pretty"; + changes.push("Mapped logging.consoleStyle compact → pretty."); + } + const controlUi = getRecord(gateway?.controlUi); + if (controlUi && Object.hasOwn(controlUi, "chatMessageMaxWidth")) { + const prefs = ensureRecord(ensureRecord(raw, "ui"), "prefs"); + if (prefs.chatMessageMaxWidth === undefined) { + prefs.chatMessageMaxWidth = controlUi.chatMessageMaxWidth; + changes.push("Moved gateway.controlUi.chatMessageMaxWidth → ui.prefs.chatMessageMaxWidth."); + } else { + changes.push("Removed gateway.controlUi.chatMessageMaxWidth (ui.prefs value already set)."); + } + delete controlUi.chatMessageMaxWidth; + } } export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "runtime.doctor-tier-eval-tranche", + describe: "Consolidate approved tier-eval configuration surfaces", + legacyRules: [ + rule([], "Approved tier-eval configuration surfaces were consolidated.", (_value, root) => { + const changes: string[] = []; + migrateTierEvalTranche(structuredClone(root), changes); + return changes.length > 0; + }), + ], + apply: migrateTierEvalTranche, + }), + defineLegacyConfigMigration({ + id: "runtime.final-layout-polish", + describe: "Normalize final configuration layout names", + legacyRules: [ + rule([], "Final layout aliases were retired.", (_value, root) => { + const changes: string[] = []; + migrateFinalLayoutRenames(structuredClone(root), changes); + return changes.length > 0; + }), + ], + apply: migrateFinalLayoutRenames, + }), + defineLegacyConfigMigration({ + id: "runtime.final-layout-kills", + describe: "Remove final layout tuning knobs", + legacyRules: [ + rule([], "Final layout tuning knobs were retired.", (_value, root) => { + const changes: string[] = []; + migrateFinalLayoutKills(structuredClone(root), changes); + return changes.length > 0; + }), + ], + apply: migrateFinalLayoutKills, + }), + defineLegacyConfigMigration({ + id: "runtime.media-models-consolidation", + describe: "Consolidate per-capability media model configuration", + legacyRules: [ + rule( + ["tools", "media"], + "Per-capability media model settings moved to capability-tagged tools.media.models entries.", + hasLegacyMediaCapabilityConfig, + ), + ], + apply: (raw, changes) => { + migrateMediaDeepgram(raw, changes); + consolidateMediaCapabilityConfig(raw, changes); + }, + }), defineLegacyConfigMigration({ id: "runtime.tuning-knobs-purge", describe: "Remove retired runtime tuning knobs", @@ -325,7 +512,7 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED: LegacyConfigMigrationSpec rule(["commands", "modelsWrite"], "commands.modelsWrite was retired and is ignored."), rule( ["messages", "messagePrefix"], - "messages.messagePrefix moved to channels.whatsapp.messagePrefix.", + "messages.messagePrefix moved to channels.whatsapp.responsePrefix.", ), rule( ["tools", "media", "asyncCompletion"], @@ -363,12 +550,12 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED: LegacyConfigMigrationSpec const messages = getRecord(raw.messages); if (messages && Object.hasOwn(messages, "messagePrefix")) { const whatsapp = ensureRecord(ensureRecord(raw, "channels"), "whatsapp"); - if (whatsapp.messagePrefix === undefined) { - whatsapp.messagePrefix = messages.messagePrefix; - changes.push("Moved messages.messagePrefix → channels.whatsapp.messagePrefix."); + if (whatsapp.responsePrefix === undefined) { + whatsapp.responsePrefix = messages.messagePrefix; + changes.push("Moved messages.messagePrefix → channels.whatsapp.responsePrefix."); } else { changes.push( - "Removed messages.messagePrefix (channels.whatsapp.messagePrefix already set).", + "Removed messages.messagePrefix (channels.whatsapp.responsePrefix already set).", ); } delete messages.messagePrefix; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.system-agent.test.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.system-agent.test.ts index db7a7ee57a32..cbd69de9fe4b 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.system-agent.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.system-agent.test.ts @@ -5,7 +5,7 @@ import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_SYSTEM_AGENT } from "./legacy-config-m const migration = LEGACY_CONFIG_MIGRATIONS_RUNTIME_SYSTEM_AGENT[0]; describe("system-agent config migration", () => { - it("moves the retired config block", () => { + it("removes the retired config block", () => { const raw: Record = { crestodian: { rescue: { enabled: true, pendingTtlMinutes: 10 } }, }; @@ -13,13 +13,13 @@ describe("system-agent config migration", () => { migration?.apply(raw, changes); - expect(raw).toEqual({ - systemAgent: { rescue: { enabled: true, pendingTtlMinutes: 10 } }, - }); - expect(changes).toEqual(["Moved legacy crestodian config to systemAgent."]); + expect(raw).toEqual({}); + expect(changes).toEqual([ + "Removed retired crestodian config; system-agent rescue uses built-in policy.", + ]); }); - it("keeps explicit canonical values while filling missing values", () => { + it("does not mutate an independently retired systemAgent block", () => { const raw: Record = { crestodian: { rescue: { enabled: true, ownerDmOnly: false } }, systemAgent: { rescue: { enabled: false } }, @@ -28,11 +28,9 @@ describe("system-agent config migration", () => { migration?.apply(raw, changes); - expect(raw).toEqual({ - systemAgent: { rescue: { enabled: false, ownerDmOnly: false } }, - }); + expect(raw).toEqual({ systemAgent: { rescue: { enabled: false } } }); expect(changes).toEqual([ - "Merged legacy crestodian config into systemAgent; kept explicit systemAgent values.", + "Removed retired crestodian config; system-agent rescue uses built-in policy.", ]); }); }); diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.system-agent.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.system-agent.ts index 61d77df6d40c..7a58fcab0231 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.system-agent.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.system-agent.ts @@ -1,43 +1,27 @@ -// System-agent config migration from the retired user-facing name. +// Removes the retired system-agent alias before canonical validation. import { defineLegacyConfigMigration, - getRecord, - mergeMissing, type LegacyConfigMigrationSpec, type LegacyConfigRule, } from "../../../config/legacy.shared.js"; const LEGACY_SYSTEM_AGENT_CONFIG_RULE: LegacyConfigRule = { path: ["crestodian"], - message: 'crestodian config moved to systemAgent. Run "openclaw doctor --fix" to migrate it.', + message: + 'crestodian config was retired; system-agent rescue now uses built-in policy. Run "openclaw doctor --fix" to remove it.', }; export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_SYSTEM_AGENT: LegacyConfigMigrationSpec[] = [ defineLegacyConfigMigration({ - id: "crestodian->systemAgent", - describe: "Move retired system-agent config to systemAgent", + id: "crestodian-retired", + describe: "Remove retired system-agent config", legacyRules: [LEGACY_SYSTEM_AGENT_CONFIG_RULE], apply: (raw, changes) => { if (!Object.hasOwn(raw, "crestodian")) { return; } - const retired = getRecord(raw.crestodian); - const canonical = getRecord(raw.systemAgent); - if (retired) { - if (canonical) { - mergeMissing(canonical, retired); - raw.systemAgent = canonical; - changes.push( - "Merged legacy crestodian config into systemAgent; kept explicit systemAgent values.", - ); - } else { - raw.systemAgent = retired; - changes.push("Moved legacy crestodian config to systemAgent."); - } - } else { - changes.push("Removed invalid legacy crestodian config."); - } delete raw.crestodian; + changes.push("Removed retired crestodian config; system-agent rescue uses built-in policy."); }, }), ]; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.tier-eval.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.tier-eval.ts new file mode 100644 index 000000000000..4120bcc76093 --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.tier-eval.ts @@ -0,0 +1,593 @@ +// Tier-eval config compatibility migration and its scoped traversal helpers. +import { ensureRecord, getRecord } from "../../../config/legacy.shared.js"; + +function deleteRetiredPath(owner: unknown, path: readonly string[], index = 0): boolean { + const record = getRecord(owner); + if (!record) { + return false; + } + const key = path[index]; + if (!key) { + return false; + } + if (key === "*") { + let changed = false; + for (const value of Object.values(record)) { + changed = deleteRetiredPath(value, path, index + 1) || changed; + } + return changed; + } + if (index === path.length - 1) { + if (!Object.hasOwn(record, key)) { + return false; + } + delete record[key]; + return true; + } + const child = getRecord(record[key]); + if (!child || !deleteRetiredPath(child, path, index + 1)) { + return false; + } + if (Object.keys(child).length === 0) { + delete record[key]; + } + return true; +} + +function visitChannelEntries( + raw: Record, + channelId: string, + visitor: (entry: Record, path: string) => void, +): void { + const channel = getRecord(getRecord(raw.channels)?.[channelId]); + if (!channel) { + return; + } + visitor(channel, `channels.${channelId}`); + const accounts = getRecord(channel.accounts); + if (!accounts) { + return; + } + for (const [accountId, value] of Object.entries(accounts)) { + const account = getRecord(value); + if (account) { + visitor(account, `channels.${channelId}.accounts.${accountId}`); + } + } +} + +function moveKey( + owner: Record | null | undefined, + legacyKey: string, + canonicalKey: string, + path: string, + changes: string[], +): void { + if (!owner || !Object.hasOwn(owner, legacyKey)) { + return; + } + if (owner[canonicalKey] === undefined) { + owner[canonicalKey] = owner[legacyKey]; + changes.push(`Moved ${path}.${legacyKey} → ${path}.${canonicalKey}.`); + } else { + changes.push(`Removed ${path}.${legacyKey} (${path}.${canonicalKey} already set).`); + } + delete owner[legacyKey]; +} + +const TIER_EVAL_RETIRED_ROOT_PATHS = [ + ["cloudWorkers", "profiles", "*", "lifetime"], + ["meta", "lastTouchedAt"], + ["hooks", "internal", "installs"], + ["cron", "store"], + ["plugins", "bundledDiscovery"], + ["tts", "prefsPath"], + ["logging", "redactSensitive"], + ["commands", "useAccessGroups"], + ["gateway", "controlUi", "allowInsecureAuth"], + ["gateway", "controlUi", "dangerouslyDisableDeviceAuth"], + ["memory", "qmd", "mcporter"], + ["memory", "qmd", "update"], + ["memory", "search", "remote", "nonBatchConcurrency"], + ["memory", "search", "remote", "batch", "wait"], + ["memory", "search", "remote", "batch", "concurrency"], + ["memory", "search", "remote", "batch", "pollIntervalMs"], + ["memory", "search", "remote", "batch", "timeoutMinutes"], + ["memory", "search", "local", "contextSize"], + ["memory", "search", "local", "modelCacheDir"], + ["memory", "search", "store", "driver"], + ["memory", "search", "sync"], + ["memory", "search", "query", "hybrid"], +] as const; + +const TIER_EVAL_RETIRED_AGENT_PATHS = [ + ["groupChat", "visibleReplies"], + ["memory", "search", "remote", "nonBatchConcurrency"], + ["memory", "search", "remote", "batch", "wait"], + ["memory", "search", "remote", "batch", "concurrency"], + ["memory", "search", "remote", "batch", "pollIntervalMs"], + ["memory", "search", "remote", "batch", "timeoutMinutes"], + ["memory", "search", "local", "contextSize"], + ["memory", "search", "local", "modelCacheDir"], + ["memory", "search", "store", "driver"], + ["memory", "search", "sync"], + ["memory", "search", "query", "hybrid"], + ["heartbeat", "ackMaxChars"], + ["heartbeat", "includeReasoning"], + ["heartbeat", "includeSystemPromptSection"], + ["heartbeat", "skipWhenBusy"], + ["heartbeat", "suppressToolErrorWarnings"], +] as const; + +function visitAgentConfigScopes( + raw: Record, + visitor: (scope: Record, path: string) => void, +): void { + const agents = getRecord(raw.agents); + const defaults = getRecord(agents?.defaults); + if (defaults) { + visitor(defaults, "agents.defaults"); + } + const entries = getRecord(agents?.entries); + if (entries) { + for (const [agentId, value] of Object.entries(entries)) { + const entry = getRecord(value); + if (entry) { + visitor(entry, `agents.entries.${agentId}`); + } + } + } + if (Array.isArray(agents?.list)) { + agents.list.forEach((value, index) => { + const entry = getRecord(value); + if (entry) { + visitor(entry, `agents.list[${index}]`); + } + }); + } +} + +type LegacyExecPolicy = { + security: "deny" | "allowlist" | "full"; + ask: "on-miss" | "always" | "off"; +}; + +function resolveConfiguredExecPolicy(scope: Record): LegacyExecPolicy | undefined { + const exec = getRecord(getRecord(scope.tools)?.exec); + if (!exec) { + return undefined; + } + switch (exec.mode) { + case "deny": + return { security: "deny", ask: "off" }; + case "allowlist": + return { security: "allowlist", ask: "off" }; + case "ask": + case "auto": + return { security: "allowlist", ask: "on-miss" }; + case "full": + return { security: "full", ask: "off" }; + } + const security = + exec.security === "deny" || exec.security === "allowlist" || exec.security === "full" + ? exec.security + : undefined; + const ask = + exec.ask === "on-miss" || exec.ask === "always" || exec.ask === "off" ? exec.ask : undefined; + return security && ask ? { security, ask } : undefined; +} + +function migrateExecMode( + scope: Record, + path: string, + changes: string[], + inheritedPolicy?: LegacyExecPolicy, +): void { + const exec = getRecord(getRecord(scope.tools)?.exec); + if (!exec || (!Object.hasOwn(exec, "security") && !Object.hasOwn(exec, "ask"))) { + return; + } + if (exec.mode !== undefined) { + changes.push(`Removed ${path}.tools.exec.security/ask (${path}.tools.exec.mode already set).`); + delete exec.security; + delete exec.ask; + return; + } + const securityValid = + exec.security === "deny" || exec.security === "allowlist" || exec.security === "full"; + const askValid = exec.ask === "on-miss" || exec.ask === "always" || exec.ask === "off"; + if ( + (Object.hasOwn(exec, "security") && !securityValid) || + (Object.hasOwn(exec, "ask") && !askValid) + ) { + return; + } + const security = securityValid ? exec.security : inheritedPolicy?.security; + const ask = askValid ? exec.ask : inheritedPolicy?.ask; + if (!security || !ask) { + return; + } + if (ask === "always" || (security === "full" && ask === "on-miss")) { + return; + } + exec.mode = + security === "deny" + ? "deny" + : security === "allowlist" && ask === "off" + ? "allowlist" + : security === "full" + ? "full" + : "ask"; + changes.push(`Moved ${path}.tools.exec.security/ask → ${path}.tools.exec.mode.`); + delete exec.security; + delete exec.ask; +} + +function migrateCliBackendSessionArgs( + scope: Record, + path: string, + changes: string[], +): void { + const backends = getRecord(scope.cliBackends); + if (!backends) { + return; + } + for (const [backendId, value] of Object.entries(backends)) { + const backend = getRecord(value); + if (!backend || !Object.hasOwn(backend, "sessionArg")) { + continue; + } + if (backend.sessionArgs === undefined && typeof backend.sessionArg === "string") { + backend.sessionArgs = [backend.sessionArg, "{sessionId}"]; + changes.push( + `Moved ${path}.cliBackends.${backendId}.sessionArg → ${path}.cliBackends.${backendId}.sessionArgs.`, + ); + } else { + changes.push( + `Removed ${path}.cliBackends.${backendId}.sessionArg (sessionArgs already set).`, + ); + } + delete backend.sessionArg; + } +} + +function moveMcpWorkingDirectory(raw: Record, changes: string[]): void { + for (const [ownerPath, servers] of [ + ["mcp.servers", getRecord(getRecord(raw.mcp)?.servers)], + ["nodeHost.mcp.servers", getRecord(getRecord(getRecord(raw.nodeHost)?.mcp)?.servers)], + ] as const) { + if (!servers) { + continue; + } + for (const [serverId, value] of Object.entries(servers)) { + moveKey(getRecord(value), "workingDirectory", "cwd", `${ownerPath}.${serverId}`, changes); + } + } +} + +function migrateSignalEndpoint( + entry: Record, + path: string, + changes: string[], + inherited?: { httpUrl?: unknown; httpHost?: unknown; httpPort?: unknown }, +): void { + if (!Object.hasOwn(entry, "httpHost") && !Object.hasOwn(entry, "httpPort")) { + return; + } + if (entry.httpUrl === undefined && typeof inherited?.httpUrl === "string") { + delete entry.httpHost; + delete entry.httpPort; + changes.push(`Removed ${path}.httpHost/httpPort (inherited httpUrl already set).`); + return; + } + if (entry.httpUrl === undefined) { + const rawHost = + typeof (entry.httpHost ?? inherited?.httpHost) === "string" && + String(entry.httpHost ?? inherited?.httpHost).trim() + ? String(entry.httpHost ?? inherited?.httpHost).trim() + : "127.0.0.1"; + const host = rawHost.includes(":") && !rawHost.startsWith("[") ? `[${rawHost}]` : rawHost; + const effectivePort = entry.httpPort ?? inherited?.httpPort; + const port = typeof effectivePort === "number" ? effectivePort : 8080; + entry.httpUrl = `http://${host}:${port}`; + if (entry.autoStart === undefined) { + // Legacy host/port described the locally owned daemon bind, unlike an + // explicit httpUrl that points at an external service. + entry.autoStart = true; + } + changes.push(`Moved ${path}.httpHost/httpPort → ${path}.httpUrl.`); + } else { + changes.push(`Removed ${path}.httpHost/httpPort (${path}.httpUrl already set).`); + } + delete entry.httpHost; + delete entry.httpPort; +} + +function migrateChannelAliases(raw: Record, changes: string[]): void { + const signal = getRecord(getRecord(raw.channels)?.signal); + if (signal) { + const inherited = { + httpUrl: signal.httpUrl, + httpHost: signal.httpHost, + httpPort: signal.httpPort, + }; + migrateSignalEndpoint(signal, "channels.signal", changes); + const accounts = getRecord(signal.accounts); + if (accounts) { + for (const [accountId, value] of Object.entries(accounts)) { + const account = getRecord(value); + if (account) { + migrateSignalEndpoint( + account, + `channels.signal.accounts.${accountId}`, + changes, + inherited, + ); + } + } + } + } + visitChannelEntries(raw, "googlechat", (entry, path) => { + if (!Object.hasOwn(entry, "serviceAccountRef")) { + return; + } + if (entry.serviceAccount !== undefined) { + changes.push( + `Moved ${path}.serviceAccountRef → ${path}.serviceAccount (SecretRef precedence preserved).`, + ); + entry.serviceAccount = entry.serviceAccountRef; + delete entry.serviceAccountRef; + return; + } + entry.serviceAccount = entry.serviceAccountRef; + delete entry.serviceAccountRef; + changes.push(`Moved ${path}.serviceAccountRef → ${path}.serviceAccount.`); + }); +} + +const RESPONSE_PREFIX_CHANNELS = new Set([ + "discord", + "feishu", + "googlechat", + "imessage", + "irc", + "matrix", + "mattermost", + "msteams", + "nextcloud-talk", + "signal", + "slack", + "telegram", + "tlon", + "twitch", + "whatsapp", + "zalo", + "zalouser", + "line", +]); + +function migrateMessagesResponsePrefix(raw: Record, changes: string[]): void { + const messages = getRecord(raw.messages); + if (!messages || !Object.hasOwn(messages, "responsePrefix")) { + return; + } + const channels = getRecord(raw.channels); + const configuredChannels = channels + ? Object.entries(channels).filter( + (entry): entry is [string, Record] => + entry[0] !== "defaults" && Boolean(getRecord(entry[1])), + ) + : []; + const supported = configuredChannels.filter(([channelId]) => + RESPONSE_PREFIX_CHANNELS.has(channelId), + ); + const unsupported = configuredChannels + .map(([channelId]) => channelId) + .filter((channelId) => !RESPONSE_PREFIX_CHANNELS.has(channelId)); + let copied = false; + for (const [, channel] of supported) { + if (channel.responsePrefix === undefined) { + channel.responsePrefix = messages.responsePrefix; + copied = true; + } + } + if (copied) { + changes.push( + `Copied messages.responsePrefix to supported channel blocks while retaining the implicit/custom fallback${unsupported.length > 0 ? ` for: ${unsupported.join(", ")}` : ""}.`, + ); + } +} + +function migratePresenceEnabled(raw: Record, changes: string[]): boolean { + let changed = false; + const wideArea = getRecord(getRecord(raw.discovery)?.wideArea); + if (wideArea && Object.hasOwn(wideArea, "enabled")) { + if ( + wideArea.enabled === false && + typeof wideArea.domain === "string" && + wideArea.domain.trim() + ) { + delete wideArea.enabled; + delete wideArea.domain; + changes.push( + "Removed disabled discovery.wideArea activation fields; domain presence now enables wide-area discovery.", + ); + changed = true; + } else { + delete wideArea.enabled; + changed = true; + } + } + return changed; +} + +function migrateWebEnabled(raw: Record, changes: string[]): boolean { + const web = getRecord(raw.web); + if (!web) { + return false; + } + if (Object.hasOwn(web, "enabled")) { + const whatsapp = ensureRecord(ensureRecord(raw, "channels"), "whatsapp"); + if (web.enabled === false && whatsapp.enabled === true) { + changes.push("Removed web.enabled=false (channels.whatsapp.enabled already set)."); + } + if (whatsapp.enabled === undefined) { + whatsapp.enabled = web.enabled; + changes.push("Moved web.enabled → channels.whatsapp.enabled."); + } + } + delete raw.web; + return true; +} + +function stripPromptsFromTtsConfig(ttsValue: unknown, path: string, changes: string[]): void { + const tts = getRecord(ttsValue); + const personas = getRecord(tts?.personas); + if (personas) { + for (const [personaId, personaValue] of Object.entries(personas)) { + const persona = getRecord(personaValue); + if (persona && Object.hasOwn(persona, "prompt")) { + delete persona.prompt; + changes.push( + `Removed ${path}.personas.${personaId}.prompt; move custom shaping into a speech provider prepareSynthesis implementation.`, + ); + } + } + } +} + +function stripTtsPersonaPrompts(raw: Record, changes: string[]): void { + stripPromptsFromTtsConfig(raw.tts, "tts", changes); + visitAgentConfigScopes(raw, (scope, path) => { + stripPromptsFromTtsConfig(scope.tts, `${path}.tts`, changes); + }); + const channels = getRecord(raw.channels); + if (!channels) { + return; + } + for (const [channelId, channelValue] of Object.entries(channels)) { + const channel = getRecord(channelValue); + if (!channel) { + continue; + } + const stripEntry = (entry: Record, path: string) => { + stripPromptsFromTtsConfig(entry.tts, `${path}.tts`, changes); + stripPromptsFromTtsConfig(getRecord(entry.voice)?.tts, `${path}.voice.tts`, changes); + }; + stripEntry(channel, `channels.${channelId}`); + const accounts = getRecord(channel.accounts); + if (accounts) { + for (const [accountId, accountValue] of Object.entries(accounts)) { + const account = getRecord(accountValue); + if (account) { + stripEntry(account, `channels.${channelId}.accounts.${accountId}`); + } + } + } + } +} + +function stripCompactionInstructionConfig( + scope: Record, + path: string, + changes: string[], +): void { + const compaction = getRecord(scope.compaction); + if (!compaction) { + return; + } + let stripped = false; + for (const key of ["customInstructions", "identifierInstructions"]) { + if (Object.hasOwn(compaction, key)) { + delete compaction[key]; + stripped = true; + } + } + const memoryFlush = getRecord(compaction.memoryFlush); + if (memoryFlush) { + for (const key of ["prompt", "systemPrompt"]) { + if (Object.hasOwn(memoryFlush, key)) { + delete memoryFlush[key]; + stripped = true; + } + } + } + if (compaction.identifierPolicy === "custom") { + compaction.identifierPolicy = "strict"; + stripped = true; + } + if (stripped) { + changes.push( + `Removed ${path}.compaction custom prompt instructions; use a compaction provider summarize() implementation and before_prompt_build hooks.`, + ); + } +} + +export function migrateTierEvalTranche(raw: Record, changes: string[]): void { + const initialChangeCount = changes.length; + let stripped = false; + stripTtsPersonaPrompts(raw, changes); + stripped = migratePresenceEnabled(raw, changes) || stripped; + moveMcpWorkingDirectory(raw, changes); + migrateChannelAliases(raw, changes); + const session = getRecord(raw.session); + if (session && Object.hasOwn(session, "idleMinutes")) { + const existingReset = getRecord(session.reset); + const reset = existingReset ?? { mode: "idle" }; + if (reset.idleMinutes === undefined) { + reset.idleMinutes = session.idleMinutes; + session.reset = reset; + changes.push("Moved session.idleMinutes → session.reset.idleMinutes."); + } + delete session.idleMinutes; + stripped = true; + } + const inheritedExecPolicy = resolveConfiguredExecPolicy(raw); + migrateExecMode(raw, "root", changes); + visitAgentConfigScopes(raw, (scope, path) => { + stripCompactionInstructionConfig(scope, path, changes); + // Agent entries inherit exec policy directly from root tools.exec. The + // agents.defaults schema has no tools.exec policy surface. + if (path !== "agents.defaults") { + migrateExecMode(scope, path, changes, inheritedExecPolicy); + } + migrateCliBackendSessionArgs(scope, path, changes); + for (const retiredPath of TIER_EVAL_RETIRED_AGENT_PATHS) { + stripped = deleteRetiredPath(scope, retiredPath) || stripped; + } + }); + stripped = migrateWebEnabled(raw, changes) || stripped; + migrateMessagesResponsePrefix(raw, changes); + for (const retiredPath of TIER_EVAL_RETIRED_ROOT_PATHS) { + stripped = deleteRetiredPath(raw, retiredPath) || stripped; + } + const secrets = getRecord(raw.secrets); + const providers = getRecord(secrets?.providers); + if (providers) { + for (const provider of Object.values(providers)) { + const entry = getRecord(provider); + if (entry) { + stripped = + Object.hasOwn(entry, "allowInsecurePath") || + Object.hasOwn(entry, "allowSymlinkCommand") || + stripped; + delete entry.allowInsecurePath; + delete entry.allowSymlinkCommand; + } + } + } + const installExec = getRecord(getRecord(getRecord(raw.security)?.installPolicy)?.exec); + if (installExec) { + stripped = + Object.hasOwn(installExec, "allowInsecurePath") || + Object.hasOwn(installExec, "allowSymlinkCommand") || + stripped; + delete installExec.allowInsecurePath; + delete installExec.allowSymlinkCommand; + } + if (stripped || changes.length > initialChangeCount) { + changes.push( + "Applied tier-eval tranche retirements; canonical settings and built-in defaults now apply.", + ); + } +} diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts index d66e47c31e1c..d2d0a840b6f6 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts @@ -3,6 +3,7 @@ import type { LegacyConfigMigrationSpec } from "../../../config/legacy.shared.js import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS } from "./legacy-config-migrations.runtime.agents.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_CRON } from "./legacy-config-migrations.runtime.cron.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_DIAGNOSTICS } from "./legacy-config-migrations.runtime.diagnostics.js"; +import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_ENTRIES } from "./legacy-config-migrations.runtime.entries.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY } from "./legacy-config-migrations.runtime.gateway.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_MCP } from "./legacy-config-migrations.runtime.mcp.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS } from "./legacy-config-migrations.runtime.models.js"; @@ -25,4 +26,5 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [ ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_SESSION, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_SYSTEM_AGENT, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS, + ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_ENTRIES, ]; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts index e60309c622f4..700b709629cc 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.tts.ts @@ -384,8 +384,7 @@ function visitKnownTtsConfigLocations( raw: Record, visit: (tts: Record | null | undefined, pathLabel: string) => void, ): void { - const messages = getRecord(raw.messages); - visit(getRecord(messages?.tts), "messages.tts"); + visit(getRecord(raw.tts), "tts"); const agents = getRecord(raw.agents); const agentList = Array.isArray(agents?.list) ? agents.list : []; @@ -435,9 +434,9 @@ function visitKnownTtsConfigLocations( const LEGACY_TTS_PROVIDER_RULES: LegacyConfigRule[] = [ { - path: ["messages", "tts"], + path: ["tts"], message: - 'messages.tts legacy provider aliases/keys are legacy; use provider: "microsoft" and messages.tts.providers.. Run "openclaw doctor --fix".', + 'tts legacy provider aliases/keys are legacy; use provider: "microsoft" and tts.providers.. Run "openclaw doctor --fix".', match: (value) => hasLegacyTtsProviderKeys(value), }, { @@ -450,8 +449,8 @@ const LEGACY_TTS_PROVIDER_RULES: LegacyConfigRule[] = [ const LEGACY_TTS_ENABLED_RULES: LegacyConfigRule[] = [ { - path: ["messages", "tts"], - message: 'messages.tts.enabled is legacy; use messages.tts.auto. Run "openclaw doctor --fix".', + path: ["tts"], + message: 'tts.enabled is legacy; use tts.auto. Run "openclaw doctor --fix".', match: (value) => hasLegacyTtsEnabled(value), }, { @@ -476,9 +475,9 @@ const LEGACY_TTS_ENABLED_RULES: LegacyConfigRule[] = [ const LEGACY_TTS_SPEAKER_SELECTION_RULES: LegacyConfigRule[] = [ { - path: ["messages", "tts"], + path: ["tts"], message: - 'messages.tts speaker selection fields voice/voiceName/voiceId are legacy; use speakerVoice or speakerVoiceId. Run "openclaw doctor --fix".', + 'tts speaker selection fields voice/voiceName/voiceId are legacy; use speakerVoice or speakerVoiceId. Run "openclaw doctor --fix".', match: (value) => hasLegacyTtsSpeakerSelection(value), }, { @@ -504,12 +503,55 @@ const LEGACY_TTS_SPEAKER_SELECTION_RULES: LegacyConfigRule[] = [ /** Legacy config migration specs for TTS runtime compatibility. */ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS: LegacyConfigMigrationSpec[] = [ defineLegacyConfigMigration({ - id: "tts.providers-generic-shape", - describe: "Move legacy bundled TTS config keys into messages.tts.providers", - legacyRules: LEGACY_TTS_PROVIDER_RULES, + id: "tts.top-level-owner", + describe: "Move messages.tts to top-level tts", + legacyRules: [ + { + path: ["messages", "tts"], + message: 'messages.tts moved to top-level tts. Run "openclaw doctor --fix".', + }, + ], apply: (raw, changes) => { const messages = getRecord(raw.messages); - migrateLegacyTtsConfig(getRecord(messages?.tts), "messages.tts", changes); + if (!messages || !Object.hasOwn(messages, "tts")) { + return; + } + const legacy = getRecord(messages.tts); + if (!legacy) { + delete messages.tts; + changes.push("Removed messages.tts (invalid value)."); + return; + } + // Root tts has no realtime block; realtime speaker voice is owned by + // talk.realtime.speakerVoice, so route the legacy alias there first. + const legacyRealtime = getRecord(legacy.realtime); + if (legacyRealtime) { + const legacyVoice = legacyRealtime.speakerVoice ?? legacyRealtime.voice; + const talk = getRecord(raw.talk) ?? {}; + const talkRealtime = getRecord(talk.realtime) ?? {}; + if (legacyVoice !== undefined && talkRealtime.speakerVoice === undefined) { + talkRealtime.speakerVoice = legacyVoice; + talk.realtime = talkRealtime; + raw.talk = talk; + changes.push("Moved messages.tts.realtime voice → talk.realtime.speakerVoice."); + } else { + changes.push("Removed messages.tts.realtime (talk.realtime already configured)."); + } + delete legacy.realtime; + } + const canonical = getRecord(raw.tts) ?? {}; + mergeMissing(canonical, legacy); + raw.tts = canonical; + delete messages.tts; + changes.push("Moved messages.tts to top-level tts."); + }, + }), + defineLegacyConfigMigration({ + id: "tts.providers-generic-shape", + describe: "Move legacy bundled TTS config keys into tts.providers", + legacyRules: LEGACY_TTS_PROVIDER_RULES, + apply: (raw, changes) => { + migrateLegacyTtsConfig(getRecord(raw.tts), "tts", changes); const plugins = getRecord(raw.plugins); const pluginEntries = getRecord(plugins?.entries); diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 0d2394b1f65c..5619751d17e0 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -1118,13 +1118,15 @@ describe("repairMissingConfiguredPluginInstalls", () => { await import("./missing-configured-plugin-install.js"); const result = await repairMissingConfiguredPluginInstalls({ cfg: { - agents: { - defaults: { - memorySearch: { - provider: "local", - }, + memory: { + search: { + provider: "local", }, }, + + agents: { + defaults: {}, + }, }, env: {}, }); @@ -4487,12 +4489,10 @@ describe("repairMissingConfiguredPluginInstalls", () => { await import("./missing-configured-plugin-install.js"); const result = await repairMissingConfiguredPluginInstalls({ cfg: { - messages: { - tts: { - provider: "gradium", - providers: { - inworld: {}, - }, + tts: { + provider: "gradium", + providers: { + inworld: {}, }, }, tools: { @@ -4602,9 +4602,13 @@ describe("repairMissingConfiguredPluginInstalls", () => { cfg: { tools: { media: { - audio: { - models: [{ provider: "groq", model: "whisper-large-v3-turbo" }], - }, + models: [ + { + provider: "groq", + model: "whisper-large-v3-turbo", + capabilities: ["audio"], + }, + ], }, }, }, diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index 8ab7dfbf88aa..731f29cea1a1 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -917,7 +917,7 @@ describe("doctor preview warnings", () => { tools: { profile: "messaging", exec: { - security: "allowlist", + mode: "allowlist", }, }, }, @@ -942,7 +942,7 @@ describe("doctor preview warnings", () => { tools: { allow: ["message"], exec: { - security: "allowlist", + mode: "allowlist", }, }, }, @@ -971,7 +971,7 @@ describe("doctor preview warnings", () => { id: "sage", tools: { exec: { - security: "allowlist", + mode: "allowlist", }, }, }, @@ -1004,7 +1004,7 @@ describe("doctor preview warnings", () => { id: "sage", tools: { exec: { - security: "allowlist", + mode: "allowlist", }, byProvider: { "openai/gpt-5": { @@ -1038,7 +1038,7 @@ describe("doctor preview warnings", () => { }, tools: { exec: { - security: "allowlist", + mode: "allowlist", }, byProvider: { "openai/gpt-5": { @@ -1070,7 +1070,7 @@ describe("doctor preview warnings", () => { id: "sage", tools: { exec: { - security: "allowlist", + mode: "allowlist", }, byProvider: { openai: { @@ -1098,7 +1098,7 @@ describe("doctor preview warnings", () => { profile: "messaging", alsoAllow: ["exec", "process"], exec: { - security: "allowlist", + mode: "allowlist", }, }, }); @@ -1111,7 +1111,7 @@ describe("doctor preview warnings", () => { tools: { profile: "custom-profile", exec: { - security: "allowlist", + mode: "allowlist", }, byProvider: { openai: { @@ -1125,7 +1125,7 @@ describe("doctor preview warnings", () => { id: "sage", tools: { exec: { - security: "allowlist", + mode: "allowlist", }, byProvider: { openai: { diff --git a/src/commands/doctor/shared/pristine-startup-state.test.ts b/src/commands/doctor/shared/pristine-startup-state.test.ts index 203f2b613cbb..9169b40a2a88 100644 --- a/src/commands/doctor/shared/pristine-startup-state.test.ts +++ b/src/commands/doctor/shared/pristine-startup-state.test.ts @@ -22,7 +22,6 @@ function createFixture(config: Record, stateEntries: string[] = } return { HOME: root, - OPENCLAW_CONFIG: configPath, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_STATE_DIR: stateDir, }; @@ -72,7 +71,10 @@ describe("pristine startup state", () => { expect(canSkipPristineStartupStateMigrations(createFixture({}, ["agents"]))).toBe(false); expect( canSkipPristineStartupStateMigrations( - createFixture({ agents: { defaults: { memorySearch: { provider: "local" } } } }), + createFixture({ + memory: { search: { provider: "local" } }, + agents: { defaults: {} }, + }), ), ).toBe(false); }); diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index ea333027a984..58e18cadcb8c 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -284,12 +284,10 @@ describe("configured plugin install release step", () => { model: "groq/llama-3.3-70b-versatile", }, }, - messages: { - tts: { - provider: "gradium", - providers: { - inworld: {}, - }, + tts: { + provider: "gradium", + providers: { + inworld: {}, }, }, tools: { @@ -312,9 +310,13 @@ describe("configured plugin install release step", () => { cfg: { tools: { media: { - audio: { - models: [{ provider: "groq", model: "whisper-large-v3-turbo" }], - }, + models: [ + { + provider: "groq", + model: "whisper-large-v3-turbo", + capabilities: ["audio"], + }, + ], }, }, }, diff --git a/src/commands/doctor/shared/startup-plugin-convergence-plan.ts b/src/commands/doctor/shared/startup-plugin-convergence-plan.ts index 90ddf26bea78..074065c765d1 100644 --- a/src/commands/doctor/shared/startup-plugin-convergence-plan.ts +++ b/src/commands/doctor/shared/startup-plugin-convergence-plan.ts @@ -1,6 +1,7 @@ // Plans first-start plugin convergence without loading the repair/catalog runtime. import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { listAgentEntries } from "../../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../../config/types.plugins.js"; import { inspectBundledPluginStartupMetadata } from "../../../plugins/bundled-plugin-startup-metadata.js"; @@ -51,17 +52,18 @@ function collectConfiguredMemoryEmbeddingProviderIds(config: OpenClawConfig): Re providerIds.add(ownerId); } }; - const defaults = config.agents?.defaults?.memorySearch; + const defaults = config.memory?.search; if (defaults?.enabled !== false) { add(defaults?.provider); add(defaults?.fallback); } - for (const agent of config.agents?.list ?? []) { - if (agent.memorySearch?.enabled === false) { + for (const agent of listAgentEntries(config)) { + const override = agent.memory?.search; + if (override?.enabled === false) { continue; } - add(agent.memorySearch?.provider ?? defaults?.provider); - add(agent.memorySearch?.fallback ?? defaults?.fallback); + add(override?.provider ?? defaults?.provider); + add(override?.fallback ?? defaults?.fallback); } return providerIds; } diff --git a/src/commands/onboard-config.ts b/src/commands/onboard-config.ts index 28b455acbbc7..44e71e3a19b9 100644 --- a/src/commands/onboard-config.ts +++ b/src/commands/onboard-config.ts @@ -1,6 +1,7 @@ /** Shared config mutations used by interactive and non-interactive onboarding. */ import fs from "node:fs"; import path from "node:path"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace-default.js"; import { setConfigValueAtPath } from "../config/config-paths.js"; import { resolveStateDir } from "../config/paths.js"; @@ -53,7 +54,7 @@ export function resolveOnboardingWorkspaceConflict( return undefined; } - const hasRoster = Array.isArray(baseConfig.agents?.list) && baseConfig.agents.list.length > 0; + const hasRoster = listAgentEntries(baseConfig).length > 0; if (!hasRoster && !(configuredWorkspace && hasExistingAgentState(env))) { return undefined; } @@ -81,7 +82,7 @@ export function applyLocalSetupWorkspaceConfig( workspaceDir, options.env, ); - const hasRoster = Array.isArray(baseConfig.agents?.list) && baseConfig.agents.list.length > 0; + const hasRoster = listAgentEntries(baseConfig).length > 0; const shouldUpdateWorkspace = !options.preserveWorkspace && (options.allowWorkspaceChange || (!hasRoster && !workspaceConflict)); diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index ef6acbdd8df8..8ed385aaf5b1 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -362,7 +362,6 @@ describe("ensureOnboardingPluginInstalled", () => { source: "exec", command: process.execPath, args: ["-e", "process.exit(1)"], - allowInsecurePath: true, }, }, }, @@ -499,7 +498,6 @@ describe("ensureOnboardingPluginInstalled", () => { source: "exec", command: process.execPath, args: ["-e", "process.exit(1)"], - allowInsecurePath: true, }, }, }, @@ -657,7 +655,6 @@ describe("ensureOnboardingPluginInstalled", () => { source: "exec", command: process.execPath, args: ["-e", "process.exit(1)"], - allowInsecurePath: true, }, }, }, diff --git a/src/commands/status.scan.shared.test.ts b/src/commands/status.scan.shared.test.ts index 38b00f668c70..58ca7f554cf2 100644 --- a/src/commands/status.scan.shared.test.ts +++ b/src/commands/status.scan.shared.test.ts @@ -461,6 +461,51 @@ describe("buildTailscaleHttpsUrl", () => { }); describe("resolveSharedMemoryStatusSnapshot", () => { + it.each([ + { + name: "top-level defaults", + cfg: { memory: { search: { provider: "local" } } }, + }, + { + name: "per-agent overrides", + cfg: { + agents: { + entries: { main: { memory: { search: { provider: "local" } } } }, + }, + }, + }, + ])("inspects explicitly configured memory from $name", async ({ cfg }) => { + const manager = { + probeVectorStoreAvailability: vi.fn(async () => true), + probeVectorAvailability: vi.fn(async () => true), + status: vi.fn(() => ({ + backend: "builtin" as const, + provider: "local", + files: 0, + chunks: 0, + })), + close: vi.fn(async () => {}), + }; + const resolveMemoryConfig = vi.fn(() => ({ + store: { databasePath: `/tmp/openclaw-missing-memory-${process.pid}.sqlite` }, + })); + const getMemorySearchManager = vi.fn(async () => ({ manager })); + + const result = await resolveSharedMemoryStatusSnapshot({ + cfg, + agentStatus: { defaultId: "main" }, + memoryPlugin: { enabled: true, slot: "memory-core" }, + resolveMemoryConfig, + getMemorySearchManager, + requireDefaultDatabasePath: () => + `/tmp/openclaw-missing-default-memory-${process.pid}.sqlite`, + }); + + expect(resolveMemoryConfig).toHaveBeenCalledOnce(); + expect(getMemorySearchManager).toHaveBeenCalledOnce(); + expect(result?.provider).toBe("local"); + }); + it("asks custom memory-slot runtimes for status without requiring built-in memorySearch", async () => { const manager = { probeVectorStoreAvailability: vi.fn(async () => true), @@ -486,10 +531,10 @@ describe("resolveSharedMemoryStatusSnapshot", () => { plugins: { slots: { memory: "memory-lancedb-pro" }, }, + memory: { search: { enabled: false } }, + agents: { - defaults: { - memorySearch: { enabled: false }, - }, + defaults: {}, }, }, agentStatus: { defaultId: "main" }, diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index 1b5ea8a520ca..f8f4f8470423 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -12,6 +12,7 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, } from "../../packages/gateway-protocol/src/client-info.js"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.js"; import { buildGatewayConnectionDetailsWithResolvers } from "../gateway/connection-details.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; @@ -25,6 +26,7 @@ import { type MemoryProviderStatus, } from "../memory-host-sdk/engine-storage.js"; import { defaultSlotIdForKey } from "../plugins/slots.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { resolveTailscalePublishedHost } from "../shared/tailscale-status.js"; import { pickGatewaySelfPresence } from "./gateway-presence.js"; @@ -245,11 +247,15 @@ async function applyLocalStatusRpcFallback(params: { } function hasExplicitMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean { - if (cfg.agents?.defaults && Object.hasOwn(cfg.agents.defaults, "memorySearch")) { + if (cfg.memory && Object.hasOwn(cfg.memory, "search")) { return true; } - const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; - return agents.some((agent) => agent?.id === agentId && Object.hasOwn(agent, "memorySearch")); + return listAgentEntries(cfg).some( + (agent) => + normalizeAgentId(agent.id) === normalizeAgentId(agentId) && + agent.memory != null && + Object.hasOwn(agent.memory, "search"), + ); } /** Resolves whether memory status should be shown and which slot owns it. */ diff --git a/src/commands/status.scan.test-helpers.ts b/src/commands/status.scan.test-helpers.ts index d61aaef2b5c4..8f043031fba3 100644 --- a/src/commands/status.scan.test-helpers.ts +++ b/src/commands/status.scan.test-helpers.ts @@ -371,13 +371,11 @@ function createStatusGatewayProbeFailure() { export function createStatusMemorySearchConfig(): OpenClawConfig { return createStatusScanConfig({ - agents: { - defaults: { - memorySearch: { - provider: "local", - local: { modelPath: "/tmp/model.gguf" }, - fallback: "none", - }, + memory: { + search: { + provider: "local", + local: { modelPath: "/tmp/model.gguf" }, + fallback: "none", }, }, }); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 066d889c9e2c..9379f14cd99f 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -144,7 +144,7 @@ function resolveExplicitCronSessionSegment(sessionKey: string | undefined): stri function readRunningCronJobIds(): { ids: Set; count: number } { try { - const cronStorePath = resolveCronJobsStorePath(getRuntimeConfig().cron?.store); + const cronStorePath = resolveCronJobsStorePath(); const runningJobs = loadCronJobsStoreSync(cronStorePath).jobs.filter( (job) => typeof job.state?.runningAtMs === "number", ); diff --git a/src/commitments/commitments-full-chain.integration.test.ts b/src/commitments/commitments-full-chain.integration.test.ts index 899a386a2237..1ad42793e576 100644 --- a/src/commitments/commitments-full-chain.integration.test.ts +++ b/src/commitments/commitments-full-chain.integration.test.ts @@ -18,6 +18,22 @@ import { import { readCommitmentsForTest } from "./store.test-utils.js"; import type { CommitmentExtractionBatchResult, CommitmentExtractionItem } from "./types.js"; +vi.mock("./config.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveCommitmentsConfig: () => ({ + enabled: true, + maxPerDay: 3, + extraction: { + debounceMs: 15_000, + batchMaxItems: 8, + queueMaxItems: 64, + confidenceThreshold: 0.72, + careConfidenceThreshold: 0.86, + timeoutSeconds: 45, + }, + }), +})); + installHeartbeatRunnerTestRuntime(); describe("commitments full-chain integration", () => { @@ -50,7 +66,6 @@ describe("commitments full-chain integration", () => { }, channels: { telegram: { allowFrom: ["*"] } }, session: { store: storePath }, - commitments: { enabled: true }, }; await seedSessionStore(storePath, sessionKey, { lastChannel: "telegram", diff --git a/src/commitments/commitments-heartbeat-policy.e2e.test.ts b/src/commitments/commitments-heartbeat-policy.e2e.test.ts index 812cd1ea6840..ae48282f372d 100644 --- a/src/commitments/commitments-heartbeat-policy.e2e.test.ts +++ b/src/commitments/commitments-heartbeat-policy.e2e.test.ts @@ -12,6 +12,22 @@ import { withEnvAsync } from "../test-utils/env.js"; import { readCommitmentsForTest, seedCommitmentsForTest } from "./store.test-utils.js"; import type { CommitmentRecord } from "./types.js"; +vi.mock("./config.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveCommitmentsConfig: () => ({ + enabled: true, + maxPerDay: 3, + extraction: { + debounceMs: 15_000, + batchMaxItems: 8, + queueMaxItems: 64, + confidenceThreshold: 0.72, + careConfidenceThreshold: 0.86, + timeoutSeconds: 45, + }, + }), +})); + installHeartbeatRunnerTestRuntime(); describe("commitments heartbeat delivery policy e2e", () => { @@ -66,7 +82,6 @@ describe("commitments heartbeat delivery policy e2e", () => { }, channels: { telegram: { allowFrom: ["*"] } }, session: { store: storePath }, - commitments: { enabled: true }, }; await seedSessionStore(storePath, sessionKey, { lastChannel: "telegram", diff --git a/src/commitments/config.ts b/src/commitments/config.ts index 259a22402d5b..bee3bd06d6cb 100644 --- a/src/commitments/config.ts +++ b/src/commitments/config.ts @@ -27,18 +27,11 @@ type ResolvedCommitmentsConfig = { }; }; -function positiveInt(value: unknown, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 - ? Math.floor(value) - : fallback; -} - /** Resolves commitment extraction config with conservative defaults. */ -export function resolveCommitmentsConfig(cfg?: OpenClawConfig): ResolvedCommitmentsConfig { - const raw = cfg?.commitments; +export function resolveCommitmentsConfig(_cfg?: OpenClawConfig): ResolvedCommitmentsConfig { return { - enabled: raw?.enabled === true, - maxPerDay: positiveInt(raw?.maxPerDay, DEFAULT_COMMITMENT_MAX_PER_DAY), + enabled: false, + maxPerDay: DEFAULT_COMMITMENT_MAX_PER_DAY, extraction: { debounceMs: DEFAULT_COMMITMENT_EXTRACTION_DEBOUNCE_MS, batchMaxItems: DEFAULT_COMMITMENT_BATCH_MAX_ITEMS, diff --git a/src/commitments/extraction.test.ts b/src/commitments/extraction.test.ts index 78098ed97b62..19665f5252a1 100644 --- a/src/commitments/extraction.test.ts +++ b/src/commitments/extraction.test.ts @@ -15,6 +15,22 @@ import { validateCommitmentCandidates } from "./extraction.test-support.js"; import { readCommitmentsForTest } from "./store.test-utils.js"; import type { CommitmentCandidate, CommitmentExtractionItem } from "./types.js"; +vi.mock("./config.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveCommitmentsConfig: () => ({ + enabled: true, + maxPerDay: 3, + extraction: { + debounceMs: 15_000, + batchMaxItems: 8, + queueMaxItems: 64, + confidenceThreshold: 0.72, + careConfidenceThreshold: 0.86, + timeoutSeconds: 45, + }, + }), +})); + describe("commitment extraction", () => { const tmpDirs: string[] = []; let stateDirEnvSnapshot: ReturnType | undefined; @@ -35,11 +51,7 @@ describe("commitment extraction", () => { tmpDirs.push(tmpDir); stateDirEnvSnapshot ??= captureEnv(["OPENCLAW_STATE_DIR"]); setTestEnvValue("OPENCLAW_STATE_DIR", tmpDir); - return { - commitments: { - enabled: true, - }, - }; + return {}; } function item(overrides?: Partial): CommitmentExtractionItem { @@ -155,7 +167,7 @@ describe("commitment extraction", () => { }); it("rejects disabled, low-confidence, and non-future candidates", () => { - const cfg: OpenClawConfig = { commitments: { enabled: true } }; + const cfg: OpenClawConfig = {}; const valid = validateCommitmentCandidates({ cfg, items: [item()], diff --git a/src/commitments/runtime.test-support.ts b/src/commitments/runtime.test-support.ts index b4ef2050b2ae..12aa609784a0 100644 --- a/src/commitments/runtime.test-support.ts +++ b/src/commitments/runtime.test-support.ts @@ -1,47 +1,5 @@ -import type { OpenClawConfig } from "../config/config.js"; -import "./runtime.js"; -import type { CommitmentExtractionBatchResult, CommitmentExtractionItem } from "./types.js"; - -type TimerHandle = ReturnType; - -type CommitmentExtractionRuntime = { - extractBatch?: (params: { - cfg?: OpenClawConfig; - items: CommitmentExtractionItem[]; - }) => Promise; - resolveDefaultModel?: (params: { cfg: OpenClawConfig; agentId?: string }) => { - provider: string; - model: string; - }; - setTimer?: (callback: () => void, delayMs: number) => TimerHandle; - clearTimer?: (timer: TimerHandle) => void; - forceInTests?: boolean; -}; - -type CommitmentRuntimeTestApi = { - configureCommitmentExtractionRuntime(next: CommitmentExtractionRuntime): void; - drainCommitmentExtractionQueue(): Promise; - resetCommitmentExtractionRuntimeForTests(): void; -}; - -function getTestApi(): CommitmentRuntimeTestApi { - const api = (globalThis as Record)[ - Symbol.for("openclaw.commitmentRuntimeTestApi") - ]; - if (!api) { - throw new Error("commitment runtime test API is unavailable"); - } - return api as CommitmentRuntimeTestApi; -} - -export function configureCommitmentExtractionRuntime(next: CommitmentExtractionRuntime): void { - getTestApi().configureCommitmentExtractionRuntime(next); -} - -export async function drainCommitmentExtractionQueue(): Promise { - return await getTestApi().drainCommitmentExtractionQueue(); -} - -export function resetCommitmentExtractionRuntimeForTests(): void { - getTestApi().resetCommitmentExtractionRuntimeForTests(); -} +export { + configureCommitmentExtractionRuntime, + drainCommitmentExtractionQueue, + resetCommitmentExtractionRuntimeForTests, +} from "./runtime.js"; diff --git a/src/commitments/runtime.test.ts b/src/commitments/runtime.test.ts index f00e367ce1ec..16e1c337c95c 100644 --- a/src/commitments/runtime.test.ts +++ b/src/commitments/runtime.test.ts @@ -19,6 +19,25 @@ const DEFAULT_COMMITMENT_EXTRACTION_QUEUE_MAX_ITEMS = 64; const runEmbeddedAgentMock = vi.hoisted(() => vi.fn()); const resolveDefaultModelMock = vi.hoisted(() => vi.fn()); +const resolveCommitmentsConfigMock = vi.hoisted(() => + vi.fn(() => ({ + enabled: true, + maxPerDay: 3, + extraction: { + debounceMs: 15_000, + batchMaxItems: 8, + queueMaxItems: 64, + confidenceThreshold: 0.72, + careConfidenceThreshold: 0.86, + timeoutSeconds: 45, + }, + })), +); + +vi.mock("./config.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveCommitmentsConfig: resolveCommitmentsConfigMock, +})); vi.mock("../agents/embedded-agent.js", () => ({ runEmbeddedAgent: runEmbeddedAgentMock, @@ -60,6 +79,7 @@ describe("commitment extraction runtime", () => { resetCommitmentExtractionRuntimeForTests(); runEmbeddedAgentMock.mockReset(); resolveDefaultModelMock.mockReset(); + resolveCommitmentsConfigMock.mockClear(); vi.useRealTimers(); vi.unstubAllEnvs(); stateDirEnvSnapshot?.restore(); @@ -73,11 +93,7 @@ describe("commitment extraction runtime", () => { tmpDirs.push(tmpDir); stateDirEnvSnapshot ??= captureEnv(["OPENCLAW_STATE_DIR"]); setTestEnvValue("OPENCLAW_STATE_DIR", tmpDir); - return { - commitments: { - enabled: true, - }, - }; + return {}; } it("does not enqueue background extraction in test mode unless forced", async () => { @@ -97,9 +113,19 @@ describe("commitment extraction runtime", () => { }); it("keeps hidden extraction opt-in by default", () => { - const cfg: OpenClawConfig = { - commitments: {}, - }; + const cfg: OpenClawConfig = {}; + resolveCommitmentsConfigMock.mockReturnValueOnce({ + enabled: false, + maxPerDay: 3, + extraction: { + debounceMs: 15_000, + batchMaxItems: 8, + queueMaxItems: 64, + confidenceThreshold: 0.72, + careConfidenceThreshold: 0.86, + timeoutSeconds: 45, + }, + }); configureCommitmentExtractionRuntime({ forceInTests: true, setTimer: () => ({ unref() {} }) as ReturnType, diff --git a/src/commitments/runtime.ts b/src/commitments/runtime.ts index 55bbe789e712..6b03841731fc 100644 --- a/src/commitments/runtime.ts +++ b/src/commitments/runtime.ts @@ -91,12 +91,12 @@ function scheduleDrainSoon(debounceMs: number): void { } /** Installs runtime hooks for extraction tests or alternate batch extraction. */ -function configureCommitmentExtractionRuntime(next: CommitmentExtractionRuntime): void { +export function configureCommitmentExtractionRuntime(next: CommitmentExtractionRuntime): void { runtime = next; } /** Clears queued work, timers, and injected hooks for isolated tests. */ -function resetCommitmentExtractionRuntimeForTests(): void { +export function resetCommitmentExtractionRuntimeForTests(): void { if (timer) { clearTimer(timer); } @@ -299,7 +299,7 @@ function takeAgentBatch( } /** Drains queued extraction work in batches and returns processed item count. */ -async function drainCommitmentExtractionQueue(): Promise { +export async function drainCommitmentExtractionQueue(): Promise { if (draining) { return 0; } @@ -357,15 +357,3 @@ async function drainCommitmentExtractionQueue(): Promise { draining = false; } } - -if ( - process.env.VITEST || - process.env.NODE_ENV === "test" || - process.env.OPENCLAW_COMMITMENTS_SAFETY_E2E === "1" -) { - (globalThis as Record)[Symbol.for("openclaw.commitmentRuntimeTestApi")] = { - configureCommitmentExtractionRuntime, - drainCommitmentExtractionQueue, - resetCommitmentExtractionRuntimeForTests, - }; -} diff --git a/src/commitments/store.test.ts b/src/commitments/store.test.ts index a7fd5f4303aa..706c8edc4bb0 100644 --- a/src/commitments/store.test.ts +++ b/src/commitments/store.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { @@ -17,12 +17,36 @@ import { import { readCommitmentsForTest, seedCommitmentsForTest } from "./store.test-utils.js"; import type { CommitmentCandidate, CommitmentRecord } from "./types.js"; +const resolveCommitmentsConfigMock = vi.hoisted(() => vi.fn()); +const enabledConfig = (maxPerDay = 3) => ({ + enabled: true, + maxPerDay, + extraction: { + debounceMs: 15_000, + batchMaxItems: 8, + queueMaxItems: 64, + confidenceThreshold: 0.72, + careConfidenceThreshold: 0.86, + timeoutSeconds: 45, + }, +}); + +vi.mock("./config.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveCommitmentsConfig: resolveCommitmentsConfigMock, +})); + describe("commitment SQLite store", () => { const tmpDirs: string[] = []; let stateDirEnvSnapshot: ReturnType | undefined; const nowMs = Date.parse("2026-04-29T17:00:00.000Z"); const sessionKey = "agent:main:telegram:user-155462274"; + beforeEach(() => { + resolveCommitmentsConfigMock.mockReset(); + resolveCommitmentsConfigMock.mockReturnValue(enabledConfig()); + }); + afterEach(async () => { closeOpenClawStateDatabaseForTest(); vi.unstubAllEnvs(); @@ -68,6 +92,7 @@ describe("commitment SQLite store", () => { } it("does not surface due commitments unless inferred commitments are enabled", async () => { + resolveCommitmentsConfigMock.mockReturnValue({ ...enabledConfig(), enabled: false }); await useTempStateDir(); seedCommitmentsForTest([commitment()]); @@ -77,6 +102,7 @@ describe("commitment SQLite store", () => { }); it("limits delivered commitments per agent session in a rolling day", async () => { + resolveCommitmentsConfigMock.mockReturnValue(enabledConfig(1)); await useTempStateDir(); seedCommitmentsForTest([ commitment({ id: "cm_sent", status: "sent", sentAtMs: nowMs - 60_000 }), @@ -85,7 +111,7 @@ describe("commitment SQLite store", () => { await expect( listDueCommitmentsForSession({ - cfg: { commitments: { enabled: true, maxPerDay: 1 } }, + cfg: {}, agentId: "main", sessionKey, nowMs, @@ -95,6 +121,7 @@ describe("commitment SQLite store", () => { }); it("preserves due windows, snoozes, caps, agent scope, and key ordering", async () => { + resolveCommitmentsConfigMock.mockReturnValue(enabledConfig(2)); await useTempStateDir(); const sessionA = "agent:main:telegram:user-a"; const sessionB = "agent:main:telegram:user-b"; @@ -136,7 +163,7 @@ describe("commitment SQLite store", () => { await expect( listDueCommitmentSessionKeys({ - cfg: { commitments: { enabled: true, maxPerDay: 2 } }, + cfg: {}, agentId: "main", nowMs, }), @@ -154,7 +181,7 @@ describe("commitment SQLite store", () => { await expect( listDueCommitmentSessionKeys({ - cfg: { commitments: { enabled: true } }, + cfg: {}, agentId: "main", nowMs, limit: 10, @@ -176,7 +203,7 @@ describe("commitment SQLite store", () => { await expect( listDueCommitmentsForSession({ - cfg: { commitments: { enabled: true } }, + cfg: {}, agentId: "main", sessionKey, nowMs, @@ -237,7 +264,7 @@ describe("commitment SQLite store", () => { }), ]); await listDueCommitmentsForSession({ - cfg: { commitments: { enabled: true } }, + cfg: {}, agentId: "main", sessionKey, nowMs, @@ -357,7 +384,7 @@ describe("commitment SQLite store", () => { const stateDir = await useTempStateDir(); seedCommitmentsForTest([commitment()]); await listDueCommitmentsForSession({ - cfg: { commitments: { enabled: true } }, + cfg: {}, agentId: "main", sessionKey, nowMs, diff --git a/src/config/agent-dirs.ts b/src/config/agent-dirs.ts index f9271ec561cb..e114b6cff3d3 100644 --- a/src/config/agent-dirs.ts +++ b/src/config/agent-dirs.ts @@ -111,7 +111,7 @@ export function formatDuplicateAgentDirError(dups: DuplicateAgentDir[]): string "Conflicts:", ...dups.map((d) => `- ${d.agentDir}: ${d.agentIds.map((id) => `"${id}"`).join(", ")}`), "", - "Fix: remove the shared agents.list[].agentDir override (or give each agent its own directory).", + "Fix: remove the shared agents.entries.*.agentDir override (or give each agent its own directory).", "If you want to share credentials, copy auth-profiles.json instead of sharing the entire agentDir.", ]; return lines.join("\n"); diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 37040cd5ef40..c8118c6e4622 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -15,24 +15,24 @@ type BundledChannelConfigMetadata = { }; const RAW_BUNDLED_CHANNEL_CONFIG_METADATA = [ - '[{"pluginId":"clickclack","channelId":"clickclack","order":85,"channelEnvVars":["CLICKCLACK_BOT_TOKEN"],"label":"ClickClack","description":"self-hosted chat via first-class ClickClack bot tokens.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"apiBaseUrl":{"type":"string","format":"uri"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"workspace":{"type":"string"},"botUserId":{"type":"string"},"agentId":{"type":"string"},"replyMode":{"type":"string","enum":["agent","model"]},"model":{"type":"string"},"systemPrompt":{"type":"string"},"toolsAllow":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"reconnectMs":{"type":"integer","minimum":100,"maximum":60000},"agentActivity":{"type":"boolean"},"commandMenu":{"type":"boolean"},"discussions":{"type":"object","properties":{"enabled":{"type":"boolean"},"workspace":{"type":"string"},"controlUrlBase":{"type":"string","format":"uri"},"section":{"type":"string"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"apiBaseUrl":{"type":"string","format":"uri"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"workspace":{"type":"string"},"botUserId":{"type":"string"},"agentId":{"type":"string"},"replyMode":{"type":"string","enum":["agent","model"]},"model":{"type":"string"},"systemPrompt":{"type":"string"},"toolsAllow":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"reconnectMs":{"type":"integer","minimum":100,"maximum":60000},"agentActivity":{"type":"boolean"},"commandMenu":{"type":"boolean"},"discussions":{"type":"object","properties":{"enabled":{"type":"boolean"},"workspace":{"type":"string"},"controlUrlBase":{"type":"string","format":"uri"},"section":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"discord","channelId":"discord","channelEnvVars":["DISCORD_BOT_TOKEN"],"label":"Discord","description":"very well supported right now.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"activities":{"type":"object","properties":{"clientSecret":{"type":"string","minLength":1},"applicationId":{"type":"string","pattern":"^\\\\d+$"}},"additionalProperties":false},"proxy":{"type":"string"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"suppressEmbeds":{"type":"boolean"},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"slug":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"presenceEvents":{"type":"object","properties":{"enabled":{"type":"boolean"},"channelId":{"type":"string","pattern":"^\\\\d+$"},"users":{"type":"array","items":{"type":"string","pattern":"^\\\\d+$"}},"reconnectSuppressSeconds":{"type":"integer","minimum":0,"maximum":9007199254740991},"burstLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"burstWindowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["channelId"],"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]},"cleanupAfterResolve":{"type":"boolean"}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"},"ttlMs":{"type":"integer","exclusiveMinimum":0,"maximum":86400000}},"additionalProperties":false},"ui":{"type":"object","properties":{"components":{"type":"object","properties":{"accentColor":{"type":"string","pattern":"^#?[0-9a-fA-F]{6}$"}},"additionalProperties":false}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"subagentProgress":{"type":"boolean"},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["stt-tts","agent-proxy","bidi"]},"agentSession":{"type":"object","properties":{"mode":{"type":"string","enum":["voice","target"]},"target":{"type":"string","minLength":1}},"additionalProperties":false},"model":{"type":"string","minLength":1},"realtime":{"type":"object","properties":{"provider":{"type":"string","minLength":1},"model":{"type":"string","minLength":1},"speakerVoice":{"type":"string","minLength":1},"speakerVoiceId":{"type":"string","minLength":1},"instructions":{"type":"string","minLength":1},"toolPolicy":{"type":"string","enum":["safe-read-only","owner","none"]},"consultPolicy":{"type":"string","enum":["auto","always"]},"requireWakeName":{"type":"boolean"},"wakeNames":{"minItems":1,"type":"array","items":{"type":"string","minLength":1,"pattern":"^\\\\s*[^a-z0-9]*[a-z0-9]+(?:[^a-z0-9]+[a-z0-9]+)?[^a-z0-9]*\\\\s*$"}},"bootstrapContextFiles":{"type":"array","items":{"type":"string","enum":["IDENTITY.md","USER.md","SOUL.md"]}},"bargeIn":{"type":"boolean"},"minBargeInAudioEndMs":{"type":"integer","minimum":0,"maximum":10000},"debounceMs":{"type":"integer","exclusiveMinimum":0,"maximum":10000},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}}},"additionalProperties":false},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"followUsersEnabled":{"type":"boolean"},"followU', - 'sers":{"type":"array","items":{"type":"string","minLength":1}},"allowedChannels":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"prompt":{"type":"object","properties":{"profile":{"type":"string"},"scene":{"type":"string"},"sampleContext":{"type":"string"},"style":{"type":"string"},"accent":{"type":"string"},"pacing":{"type":"string"},"constraints":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"healthyText":{"type":"string"},"degradedText":{"type":"string"},"exhaustedText":{"type":"string"}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const":4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"activities":{"type":"object","properties":{"clientSecret":{"type":"string","minLength":1},"applicationId":{"type":"string","pattern":"^\\\\d+$"}},"additionalProperties":false},"proxy":{"type":"string"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"suppressEmbeds":{"type":"boolean"},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"slug":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"presenceEvents":{"type":"object","properties":{"enabled":{"type":"boolean"},"channelId":{"type":"string","pattern":"^\\\\d+$"},"users":{"type":"array","items":{"type":"string","pattern":"^\\\\d+$"}},"reconnectSuppressSeconds":{"type":"integer","minimum":0,"maximum":9007199254740991},"burstLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"burstWindowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["channelId"],"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]},"cleanupAfterResolve":{"type":"boolean"}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"},"ttlMs":{"type":"integer","exclusiveMinimum":0,"maximum":86400000}},"additionalProperties":false},"ui":{"type":"object","properties":{"components":{"type":"object","properties":{"accentC', - 'olor":{"type":"string","pattern":"^#?[0-9a-fA-F]{6}$"}},"additionalProperties":false}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"subagentProgress":{"type":"boolean"},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["stt-tts","agent-proxy","bidi"]},"agentSession":{"type":"object","properties":{"mode":{"type":"string","enum":["voice","target"]},"target":{"type":"string","minLength":1}},"additionalProperties":false},"model":{"type":"string","minLength":1},"realtime":{"type":"object","properties":{"provider":{"type":"string","minLength":1},"model":{"type":"string","minLength":1},"speakerVoice":{"type":"string","minLength":1},"speakerVoiceId":{"type":"string","minLength":1},"instructions":{"type":"string","minLength":1},"toolPolicy":{"type":"string","enum":["safe-read-only","owner","none"]},"consultPolicy":{"type":"string","enum":["auto","always"]},"requireWakeName":{"type":"boolean"},"wakeNames":{"minItems":1,"type":"array","items":{"type":"string","minLength":1,"pattern":"^\\\\s*[^a-z0-9]*[a-z0-9]+(?:[^a-z0-9]+[a-z0-9]+)?[^a-z0-9]*\\\\s*$"}},"bootstrapContextFiles":{"type":"array","items":{"type":"string","enum":["IDENTITY.md","USER.md","SOUL.md"]}},"bargeIn":{"type":"boolean"},"minBargeInAudioEndMs":{"type":"integer","minimum":0,"maximum":10000},"debounceMs":{"type":"integer","exclusiveMinimum":0,"maximum":10000},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}}},"additionalProperties":false},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"followUsersEnabled":{"type":"boolean"},"followUsers":{"type":"array","items":{"type":"string","minLength":1}},"allowedChannels":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"prompt":{"type":"object","properties":{"profile":{"type":"string"},"scene":{"type":"string"},"sampleContext":{"type":"string"},"style":{"type":"string"},"accent":{"type":"string"},"pacing":{"type":"string"},"constraints":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"healthyText":{"type":"string"},"degradedText":{"type":"string"},"exhaustedText":{"type":"string"}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const":4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Discord","help":"Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed."},"dmPolicy":{"label":"Discord DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.discord.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Discord Config Writes","help":"Allow Discord to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Discord Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Discord channel IDs. Native Discord @mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Discord Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Discord Mention Pattern Allowlist","help":"Discord channel IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Discord Mention Pattern Denylist","help":"Discord channel IDs where configured regex mention patterns are disabled. Native @mentions still trigger."},"proxy":{"label":"Discord Proxy URL","help":"Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts..proxy."},"commands.native":{"label":"Discord Native Commands","help":"Override native commands for Discord (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Discord Native Skill Commands","help":"Override native skill commands for Discord (bool or \\"auto\\")."},"streaming":{"label":"Discord Streaming Mode","help":"Unified Discord stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Discord Streaming Mode","help":"Canonical Discord preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Discord Chunk Mode","help":"Chunking mode for outbound Discord text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Discord Block Streaming Enabled","help":"Enable chunked block-style Discord preview delivery when channels.discord.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Discord Block Streaming Coalesce","help":"Merge streamed Discord block replies before final delivery."},"streaming.preview.chunk.minChars":{"label":"Discord Draft Chunk Min Chars","help":"Minimum chars before emitting a Discord stream preview update when channels.discord.streaming.mode=\\"block\\" (default: 200)."},"streaming.preview.chunk.maxChars":{"label":"Discord Draft Chunk Max Chars","help":"Target max size for a Discord stream preview chunk when channels.discord.streaming.mode=\\"block\\" (default: 800; clamped to channels.discord.textChunkLimit)."},"streaming.preview.chunk.breakPreference":{"label":"Discord Draft Chunk Break Preference","help":"Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph."},"streaming.preview.toolProgress":{"label":"Discord Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Discord Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Discord Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Discord Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Discord Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Discord Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Discord Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commentary":{"label":"Discord Progress Commentary","help":"Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged."},"streaming.progress.commandText":{"label":"Discord Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"maxLinesPerMessage":{"label":"Discord Max Lines Per Message","help":"Soft max line count per Discord message (default: 17)."},"suppressEmbeds":{"label":"Discord Suppress Link Embeds","help":"Suppress Discord-generated link embeds on outbound messages by default. Explicit embeds still send normally. Default: true."},"thread.inheritParent":{"label":"Discord Thread Parent Inheritance","help":"If true, Discord thread sessions inherit the parent channel transcript (default: false)."},"threadBindings.enabled":{"label":"Discord Thread Binding Enabled","help":"Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Discord Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Discord Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Discord Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel."},"threadBindings.defaultSpawnContext":{"label":"Discord Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."},"subagentProgress":{"label":"Discord Subagent Progress","help":"Show active subagent count reactions and typing on the source message. Default: false."},"ui.components.accentColor":{"label":"Discord Component Accent Color","help":"Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor."},"agentComponents.ttlMs":{"label":"Discord Component TTL (ms)","help":"How long sent Discord component callbacks remain registered. Default is 1800000 (30 minutes); maximum is 86400000 (24 hours)."},"intents.presence":{"label":"Discord Presence Intent","help":"Enable the G', - 'uild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false."},"intents.guildMembers":{"label":"Discord Guild Members Intent","help":"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false."},"intents.voiceStates":{"label":"Discord Voice States Intent","help":"Enable the Guild Voice States intent. Defaults to the effective Discord voice setting; set true only for Discord voice channel conversations."},"voice.enabled":{"label":"Discord Voice Enabled","help":"Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent."},"voice.model":{"label":"Discord Voice Model","help":"Optional LLM model override for Discord voice channel responses and realtime agent consults (for example openai/gpt-5.6-sol). Leave unset to inherit the routed agent model."},"voice.mode":{"label":"Discord Voice Mode","help":"Conversation mode: agent-proxy (default) uses realtime voice as the microphone/speaker for the routed OpenClaw agent, stt-tts uses batch speech-to-text plus TTS, and bidi lets the realtime provider converse directly with the OpenClaw consult tool."},"voice.agentSession":{"label":"Discord Voice Agent Session","help":"Controls which OpenClaw conversation receives voice turns. Leave unset for the voice channel session, or set mode=\\"target\\" with a Discord target such as channel:123 to make voice an extension of an existing text channel session."},"voice.agentSession.target":{"label":"Discord Voice Agent Session Target","help":"Discord target used when voice.agentSession.mode=\\"target\\", for example channel:123."},"voice.followUsersEnabled":{"label":"Discord Voice Follow Users Enabled","help":"Toggle Discord voice follow-users behavior without removing the saved voice.followUsers list. Defaults to true when followUsers is configured."},"voice.followUsers":{"label":"Discord Voice Follow Users","help":"Discord user IDs to follow into voice channels. The bot joins when a followed user joins or moves, and leaves when that user disconnects."},"voice.realtime.provider":{"label":"Discord Realtime Provider","help":"Realtime voice provider for agent-proxy or bidi Discord voice modes, such as openai."},"voice.realtime.model":{"label":"Discord Realtime Model","help":"Provider realtime session model, such as gpt-realtime-2.1. This is separate from voice.model, which remains the OpenClaw agent brain model."},"voice.realtime.speakerVoice":{"label":"Discord Realtime Speaker Voice","help":"Provider realtime output voice name, such as cedar."},"voice.realtime.speakerVoiceId":{"label":"Discord Realtime Speaker Voice ID","help":"Provider realtime output voice id."},"voice.realtime.toolPolicy":{"label":"Discord Realtime Tool Policy","help":"Tool policy for the OpenClaw agent consult tool in realtime voice modes: safe-read-only, owner, or none. Default is owner for agent-proxy and safe-read-only for bidi."},"voice.realtime.consultPolicy":{"label":"Discord Realtime Consult Policy","help":"Use always to strongly prefer the OpenClaw agent brain for substantive realtime turns. agent-proxy defaults to always."},"voice.realtime.requireWakeName":{"label":"Discord Realtime Require Wake Name","help":"Control OpenAI agent-proxy wake-name gating. Unset listens naturally with one human and requires a wake name with two or more; true always requires one and false never does."},"voice.realtime.wakeNames":{"label":"Discord Realtime Wake Names","help":"One- or two-word activation names used whenever OpenAI agent-proxy Discord realtime voice has an active wake-name gate."},"voice.realtime.bootstrapContextFiles":{"label":"Discord Realtime Bootstrap Context Files","help":"Agent profile bootstrap files included in realtime provider instructions for direct voice identity/persona grounding. Defaults to IDENTITY.md, USER.md, and SOUL.md; set [] to disable."},"voice.realtime.bargeIn":{"label":"Discord Realtime Barge-In","help":"Allow Discord speaker-start events to interrupt active realtime playback. Set true to keep manual interruption when provider input-audio interruption is disabled for echo control."},"voice.realtime.minBargeInAudioEndMs":{"label":"Discord Realtime Minimum Barge-In Audio (ms)","help":"Minimum assistant playback duration before a Discord barge-in truncates realtime audio. Default: 250; set 0 for immediate interruption in low-echo rooms."},"voice.realtime.providers":{"label":"Discord Realtime Provider Settings","help":"Provider-specific realtime voice settings keyed by provider id.","advanced":true},"voice.autoJoin":{"label":"Discord Voice Auto-Join","help":"Voice channels to auto-join on startup (list of guildId/channelId entries)."},"voice.allowedChannels":{"label":"Discord Voice Allowed Channels","help":"Optional voice channel residency allowlist. When set, /vc join, auto-join, and bot voice-state moves are restricted to these guildId/channelId entries. Leave unset to allow any voice channel."},"voice.daveEncryption":{"label":"Discord Voice DAVE Encryption","help":"Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this)."},"voice.decryptionFailureTolerance":{"label":"Discord Voice Decrypt Failure Tolerance","help":"Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24)."},"voice.connectTimeoutMs":{"label":"Discord Voice Connect Timeout (ms)","help":"Initial @discordjs/voice Ready wait before a join is treated as failed. Default: 30000."},"voice.reconnectGraceMs":{"label":"Discord Voice Reconnect Grace (ms)","help":"Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000."},"voice.captureSilenceGraceMs":{"label":"Discord Voice Capture Silence Grace (ms)","help":"Silence window after Discord reports a speaker ended before OpenClaw finalizes the audio segment for transcription. Default: 2000."},"voice.tts":{"label":"Discord Voice Text-to-Speech","help":"Optional TTS overrides for Discord voice playback (merged with messages.tts)."},"pluralkit.enabled":{"label":"Discord PluralKit Enabled","help":"Resolve PluralKit proxied messages and treat system members as distinct senders."},"pluralkit.token":{"label":"Discord PluralKit Token","help":"Optional PluralKit token for resolving private systems or members."},"activity":{"label":"Discord Presence Activity","help":"Discord presence activity text (defaults to custom status)."},"status":{"label":"Discord Presence Status","help":"Discord presence status (online, dnd, idle, invisible)."},"autoPresence.enabled":{"label":"Discord Auto Presence Enabled","help":"Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd."},"autoPresence.intervalMs":{"label":"Discord Auto Presence Check Interval (ms)","help":"How often to evaluate Discord auto-presence state in milliseconds (default: 30000)."},"autoPresence.minUpdateIntervalMs":{"label":"Discord Auto Presence Min Update Interval (ms)","help":"Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes."},"autoPresence.healthyText":{"label":"Discord Auto Presence Healthy Text","help":"Optional custom status text while runtime is healthy (online). If omitted, falls back to static channels.discord.activity when set."},"autoPresence.degradedText":{"label":"Discord Auto Presence Degraded Text","help":"Optional custom status text while runtime/model availability is degraded or unknown (idle)."},"autoPresence.exhaustedText":{"label":"Discord Auto Presence Exhausted Text","help":"Optional custom status text while runtime detects exhausted/unavailable model quota (dnd). Supports {reason} template placeholder."},"guilds.*.presenceEvents":{"label":"Discord Online Presence Events","help":"Route selected human offline-to-online transitions into the configured guild channel as agent system events. Requires the Guild Presences privileged intent and an enabled agent heartbeat."},"guilds.*.presenceEvents.enabled":{"label":"Discord Online Presence Events Enabled","help":"Enable online-presence agent wakes for this guild. Defaults to true when presenceEvents is configured."},"guilds.*.presenceEvents.channelId":{"label":"Discord Online Presence Target Channel","help":"Numeric Discord channel ID whose routed agent session receives online-presence events and greeting delivery."},"guilds.*.presenceEvents.users":{"label":"Discord Online Presence User IDs","help":"Optional immutable Discord user ID allowlist. Omit to include all human members in the guild."},"guilds.*.presenceEvents.reconnectSuppressSeconds":{"label":"Discord Online Presence Reconnect Suppression","help":"Suppress online-presence events for this many seconds after a new Gateway session while guild presence state is rebuilt. Resumed sessions are unaffected. 0 disables. Default: 300."},"guilds.*.presenceEvents.burstLimit":{"label":"Discord Online Presence Burst Limit","help":"Maximum successfully queued online-presence events for this guild per burst window; the rest are suppressed and logged once. Default: 8."},"guilds.*.presenceEvents.burstWindowSeconds":{"label":"Discord Online Presence Burst Window","help":"Sliding window in seconds used for burst detection. Default: 60."},"activityType":{"label":"Discord Presence Activity Type","help":"Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing)."},"activityUrl":{"label":"Discord Presence Activity URL","help":"Discord presence streaming URL (required for activityType=1)."},"allowBots":{"label":"Discord Allow Bot Messages","help":"Allow bot-authored messages to trigger Discord replies (default: false). Set \\"mentions\\" to only accept bot messages that mention the bot."},"botLoopProtection":{"label":"Discord Bot Loop Protection","help":"Sliding-window guard for bot-to-bot Discord loops. Default is enabled whenever allowBots lets bot-authored messages reach dispatch."},"botLoopProtection.enabled":{"label":"Discord Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allowBots is true or \\"mentions\\", and false when bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Discord Bot Pair Events Per Window","help":"Maximum messages a single Discord bot pair may exchange in the configured window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Discord Bot Loop Window Seconds","help":"Sliding window length in seconds for Discord bot-pair loop budgets. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Discord Bot Loop Cooldown Seconds","help":"Seconds to suppress a Discord bot pair after it exceeds the loop budget. Default: 60."},"mentionAliases":{"label":"Discord Mention Aliases","help":"Map outbound @handle text to stable Discord user IDs before sending. Set per account via channels.discord.accounts..mentionAliases."},"token":{"label":"Discord Bot Token","help":"Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.","sensitive":true},"applicationId":{"label":"Discord Application ID","help":"Optional Discord application/client ID. Set this when hosted environments cannot reach Discord\'s application lookup endpoint during startup."},"activities":{"label":"Discord Activities","help":"Enable Discord Activity widgets for this account. Routes, the agent tool, and the launch handler remain disabled when this block is absent."},"activities.clientSecret":{"label":"Discord Activities Client Secret","help":"OAuth2 client secret for the Discord application. DISCORD_CLIENT_SECRET is used when this field is unset.","sensitive":true},"activities.applicationId":{"label":"Discord Activities Application ID","help":"Optional Activity application ID. Defaults to the bot application ID learned at gateway startup."}},"unsupportedSecretRefSurfacePatterns":["channels.discord.accounts.*.threadBindings.webhookToken","channels.discord.threadBindings.webhookToken"]},{"pluginId":"feishu","channelId":"feishu","aliases":["lark"],"order":35,"channelEnvVars":["FEISHU_APP_ID","FEISHU_APP_SECRET","FEISHU_ENCRYPT_KEY","FEISHU_VERIFICATION_TOKEN"],"label":"Feishu","description":"飞书/Lark enterprise messaging with doc/wiki/drive tools.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"default":"feishu","anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"default":"websocket","type":"string","enum":["websocket","webhook"]},"webhookPath":{"default":"/feishu/events","type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"anyOf":[{"type":"string","enum":["open","disabled","allowlist"]},{"type":"string","const":"allowall"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMen', - 'tion":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeat":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial"]},"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"},"bitable":{"type":"boolean"},"base":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"default":"own","type":"string","enum":["off","own","all"]},"typingIndicator":{"default":true,"type":"boolean"},"resolveSenderNames":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"vcAutoJoin":{"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"dynamicAgentCreation":{"type":"object","properties":{"enabled":{"type":"boolean"},"workspaceTemplate":{"type":"string"},"agentDirTemplate":{"type":"string"},"maxAgents":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"type":"string","enum":["websocket","webhook"]},"webhookPath":{"type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"anyOf":[{"type":"string","enum":["open","disabled","allowlist"]},{"type":"string","const":"allowall"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeat":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial"]},"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"},"bitable":{"type":"boolean"},"base":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"typingIndicator":{"type":"boolean"},"resolveSenderNames":{"type":"boolean"},"allowBots":{"type":"boolean"},"vcAutoJoin":{"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}}},"additionalProperties":false}},{"pluginId":"googlechat","channelId":"googlechat","aliases":["gchat","google-chat"],"order":55,"channelEnvVars":["GOOGLE_CHAT_SERVICE_ACCOUNT","GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"],"label":"Google Chat","description":"Google Workspace Chat app with HTTP webhook.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"type":"boolean"},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source', - '":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"type":"boolean"},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountRef":{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"imessage","channelId":"imessage","aliases":["imsg"],"label":"iMessage","description":"Local iMessage/SMS through the imsg bridge, including private API message actions when enabled.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"},"polls":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"sendTransport":{"type":"string","enum":["auto","bridge","applescript"]},"region":{"type":"string"},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"sendReadReceipts":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"coalesceSameSenderDms":{"type":"boolean"},"catchup":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxAgeMinutes":{"type":"integer","minimum":1,"maximum":720},"perRunLimit":{"type":"integer","minimum":1,"maximum":500},"firstRunLookbackMinutes":{"type":"integer","minimum":1,"maximum":720},"maxFailureRetries":{"type":"integer","minimum":1,"maximum":1000}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"},"polls":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"sendTransport":{"type":"string","enum":["auto","bridge","applescript"]},"region":{"type":"string"},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"sendReadReceipts":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"coalesceSameSenderDms":{"type":"boolean"},"catchup":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxAgeMinutes":{"type":"integer","minimum":1,"maximum":720},"perRunLimit":{"type":"integer","minimum":1,"maximum":500},"firstRunLookbackMinutes":{"type":"integer","minimum":1,"maximum":720},"maxFailureRetries":{"type":"integer","minimum":1,"maximum":1000}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy', - '"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"iMessage","help":"iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations."},"dmPolicy":{"label":"iMessage DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.imessage.allowFrom=[\\"*\\"]."},"configWrites":{"label":"iMessage Config Writes","help":"Allow iMessage to write config in response to channel events/commands (default: true)."},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"cliPath":{"label":"iMessage CLI Path","help":"Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments."},"sendTransport":{"label":"iMessage Send Transport","help":"Preferred imsg RPC send transport for normal outbound replies. \\"auto\\" uses the IMCore bridge when available, \\"bridge\\" requires it, and \\"applescript\\" forces Messages automation."}}},{"pluginId":"irc","channelId":"irc","aliases":["internet-relay-chat"],"channelEnvVars":["IRC_HOST","IRC_NICK"],"label":"IRC","description":"classic IRC networks with DM/channel routing and pairing controls.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"IRC","help":"IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw."},"dmPolicy":{"label":"IRC DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.irc.allowFrom=[\\"*\\"]."},"nickserv.enabled":{"label":"IRC NickServ Enabled","help":"Enable NickServ identify/register after connect (defaults to enabled when password is configured)."},"nickserv.service":{"label":"IRC NickServ Service","help":"NickServ service nick (default: NickServ)."},"nickserv.password":{"label":"IRC NickServ Password","help":"NickServ password used for IDENTIFY/REGISTER (sensitive)."},"nickserv.passwordFile":{"label":"IRC NickServ Password File","help":"Optional file path containing NickServ password."},"nickserv.register":{"label":"IRC NickServ Register","help":"If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable."},"nickserv.registerEmail":{"label":"IRC NickServ Register Email","help":"Email used with NickServ REGISTER (required when register=true)."},"configWrites":{"label":"IRC Config Writes","help":"Allow IRC to write config in response to channel events/commands (default: true)."}}},{"pluginId":"line","channelId":"line","order":75,"channelEnvVars":["LINE_CHANNEL_ACCESS_TOKEN","LINE_CHANNEL_SECRET"],"label":"LINE","description":"LINE Messaging API webhook bot.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"matrix","channelId":"matrix","order":70,"channelEnvVars":["MATRIX_ACCESS_TOKEN","MATRIX_DEVICE_ID","MATRIX_DEVICE_NAME","MATRIX_HOMESERVER","MATRIX_OPS_ACCESS_TOKEN","MATRIX_OPS_DEVICE_ID","MATRIX_OPS_DEVICE_NAME","MATRIX_OPS_HOMESERVER","MATRIX_PASSWORD","MATRIX_USER_ID"],"label":"Matrix","description":"open protocol; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"homeserver":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"userId":{"type":"string"},"accessToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"password":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"deviceId":{"type":"string"},"deviceName":{"type":"string"},"avatarUrl":{"type":"string"},"initialSyncLimit":{"type":"number"},"encryption":{"type":"boolean"},"allowlistOnly":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groupPolicy":{"type":"string","enum":["open","disabled","al', - 'lowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["partial","quiet","progress","off"]},"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"}},"additionalProperties":false},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"textChunkLimit":{"type":"number"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","none","off"]},"reactionNotifications":{"type":"string","enum":["off","own"]},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"startupVerification":{"type":"string","enum":["off","if-unverified"]},"startupVerificationCooldownHours":{"type":"number"},"mediaMaxMb":{"type":"number"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"autoJoin":{"type":"string","enum":["always","allowlist","off"]},"autoJoinAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"sessionScope":{"type":"string","enum":["per-user","per-room"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"account":{"type":"string"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false}},"rooms":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"account":{"type":"string"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"profile":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"verification":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"uiHints":{"mentionPatterns":{"label":"Matrix Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Matrix room IDs. Native Matrix mention evidence still triggers even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Matrix Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Matrix Mention Pattern Allowlist","help":"Matrix room IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Matrix Mention Pattern Denylist","help":"Matrix room IDs where configured regex mention patterns are disabled. Native mention evidence still triggers."},"allowBots":{"label":"Matrix Allow Bot Messages","help":"Allow messages from other configured Matrix bot accounts to trigger replies (default: false). Set \\"mentions\\" to require a visible room mention."},"botLoopProtection":{"label":"Matrix Bot Loop Protection","help":"Sliding-window guard for accepted Matrix configured-bot loops. Default is enabled whenever allowBots lets configured bot messages reach dispatch."},"botLoopProtection.enabled":{"label":"Matrix Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allowBots is true or \\"mentions\\", and false when configured bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Matrix Bot Loop Events per Window","help":"Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Matrix Bot Loop Window Seconds","help":"Sliding window length for counting bot-pair messages. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Matrix Bot Loop Cooldown Seconds","help":"How long to suppress the bot pair after it exceeds the budget. Default: 60."},"dangerouslyAllowNameMatching":{"label":"Matrix Display Name Matching","help":"Compatibility opt-in for resolving Matrix display names and joined room names in allowlists. Prefer full @user:server IDs and room IDs or aliases because names are mutable."},"streaming.progress.label":{"label":"Matrix Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Matrix Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Matrix Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Matrix Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Matrix Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Matrix Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"mattermost","channelId":"mattermost","order":65,"channelEnvVars":["MATTERMOST_BOT_TOKEN","MATTERMOST_URL"],"label":"Mattermost","description":"self-hosted Slack-style chat; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"type":"string","enum":["off","first","all","batched"]},"group":{"type":"string","enum":["off","first","all","batched"]},"channel":{"type":"string","enum":["off","first","all","batched"]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"strea', - 'ming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"type":"string","enum":["off","first","all","batched"]},"group":{"type":"string","enum":["off","first","all","batched"]},"channel":{"type":"string","enum":["off","first","all","batched"]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Mattermost","help":"Mattermost channel provider configuration for bot auth, access policy, slash commands, and preview streaming."},"dmPolicy":{"label":"Mattermost DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.mattermost.allowFrom=[\\"*\\"]."},"implicitMentions":{"label":"Mattermost Implicit Mentions","help":"Control which Mattermost reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Mattermost Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Mattermost Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Mattermost Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."},"streaming":{"label":"Mattermost Streaming Mode","help":"Unified Mattermost stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery."},"streaming.mode":{"label":"Mattermost Streaming Mode","help":"Canonical Mattermost preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.progress.label":{"label":"Mattermost Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Mattermost Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Mattermost Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Mattermost Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Mattermost Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Mattermost Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.preview.toolProgress":{"label":"Mattermost Draft Tool Progress","help":"Show tool/progress activity in the live draft preview post (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Mattermost Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.block.enabled":{"label":"Mattermost Block Streaming Enabled","help":"Enable chunked block-style Mattermost preview delivery when channels.mattermost.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Mattermost Block Streaming Coalesce","help":"Merge streamed Mattermost block replies before final delivery."}}},{"pluginId":"msteams","channelId":"msteams","aliases":["teams"],"order":60,"channelEnvVars":["MSTEAMS_APP_ID","MSTEAMS_APP_PASSWORD","MSTEAMS_TENANT_ID"],"label":"Microsoft Teams","description":"Teams SDK; enterprise support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"dangerouslyAllowNameMatching":{"type":"boolean"},"appId":{"type":"string"},"appPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tenantId":{"type":"string"},"cloud":{"type":"string","enum":["Public","USGov","USGovDoD","China"]},"serviceUrl":{"type":"string","format":"uri"},"authType":{"type":"string","enum":["secret","federated"]},"certificatePath":{"type":"string"},"certificateThumbprint":{"type":"string"},"useManagedIdentity":{"type":"boolean"},"managedIdentityClientId":{"type":"string"},"webhook":{"type":"object","properties":{"port":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"path":{"type":"string"}},"additionalProperties":false},"typingIndicator":{"type":"boolean"},"mediaAllowHosts":{"type":"array","items":{"type":"string"}},"mediaAuthAllowHosts":{"type":"array","items":{"type":"string"}},"graphMediaFallback":{"type":"boolean"},"requireMention":{"type":"boolean"},"replyStyle":{"type":"string","enum":["thread","top-level"]},"teams":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]}},"additionalProperties":false}}},"additionalProperties":false}},"sharePointSiteId":{"type":"string"},"welcomeCard":{"type":"boolean"},"promptStarters":{"type":"array","items":{"type":"string"}},"groupWelcomeCard":{"type":"boolean"},"feedbackEnabled":{"type":"boolean"},"feedbackReflection":{"type":"boolean"},"feedbackReflectionCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"delegatedAuth":{"type":"object","properties":{"enabled":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"sso":{"type":"object","properties":{"enabled":{"type":"boolean"},"connectionName":{"type":"string"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"MS Teams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers."},"configWrites":{"label":"MS Teams Config Writes","help":"Allow Microsoft Teams to write config in response to channel events/commands (default: true)."},"cloud":{"label":"MS Teams Cloud","help":"Teams SDK cloud environment for auth, token validation, and token services: \\"Public\\", \\"USGov\\", \\"USGovDoD\\", or \\"China\\" (default: Public)."},"serviceUrl":{"label":"MS Teams Service URL","help":"Bot Connector service URL for SDK proactive sends/edits/deletes. Set with cloud for USGov/DoD; set alone for GCC."},"graphMediaFallback":{"label":"MS Teams Graph Media Fallback","help":"Query Microsoft Graph for unresolved channel or group-chat HTML media. Adds one lookup per matching message when enabled (default: false)."},"streaming":{"label":"MS Teams Streaming","help":"Microsoft Teams preview/progress streaming mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Personal chats use Teams native streaminfo progress when available."},"streaming.progress.label":{"label":"MS Teams Progress Label","help":"Initial progress title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"MS Teams Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"MS Teams Progress Max Lines","help":"Maximum number of compact progress lines to keep below the progress title (default: 8)."},"streaming.progress.maxLineChars":{"label":"MS Teams Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"MS Teams Progress Tool Lines","help":"Show compact tool/progress lines in progress mode (default: true). Set false to keep only the title until final delivery."},"streaming.progress.commandText":{"label":"MS Teams Progress Command Text","help":"Command/exec detail in progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"nextcloud-talk","channelId":"nextcloud-talk","aliases":["nc","nc-talk"],"order":65,"channelEnvVars":["NEXTCLOUD_TALK_API_PASSWORD","NEXTCLOUD_TALK_BOT_SECRET"],"label":"Nextcloud Talk', - '","description":"Self-hosted chat via Nextcloud Talk webhook bots.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"nostr","channelId":"nostr","order":55,"channelEnvVars":["NOSTR_PRIVATE_KEY"],"label":"Nostr","description":"Decentralized protocol; encrypted DMs via NIP-04.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"defaultAccount":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"relays":{"type":"array","items":{"type":"string"}},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"profile":{"type":"object","properties":{"name":{"type":"string","maxLength":256},"displayName":{"type":"string","maxLength":256},"about":{"type":"string","maxLength":2000},"picture":{"type":"string","format":"uri"},"banner":{"type":"string","format":"uri"},"website":{"type":"string","format":"uri"},"nip05":{"type":"string"},"lud16":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},{"pluginId":"qa-channel","channelId":"qa-channel","order":999,"configurable":false,"label":"QA Channel","description":"Synthetic Slack-class transport for automated OpenClaw QA scenarios.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"qqbot","channelId":"qqbot","channelEnvVars":["QQBOT_APP_ID","QQBOT_CLIENT_SECRET"],"label":"QQ Bot","description":"connect to QQ via official QQ Bot API with group chat and direct message support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"nativeTransport":{"type":"boolean"}},"required":["mode"],"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled', - '":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"commandLevel":{"type":"string","enum":["all","safety","strict"]},"ignoreOtherMentions":{"type":"boolean"},"historyLimit":{"type":"number"},"name":{"type":"string"},"prompt":{"type":"string"}},"additionalProperties":false}},"stt":{"type":"object","properties":{"enabled":{"type":"boolean"},"provider":{"type":"string"},"baseUrl":{"type":"string"},"apiKey":{"type":"string"},"model":{"type":"string"}},"additionalProperties":false},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"nativeTransport":{"type":"boolean"}},"required":["mode"],"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"commandLevel":{"type":"string","enum":["all","safety","strict"]},"ignoreOtherMentions":{"type":"boolean"},"historyLimit":{"type":"number"},"name":{"type":"string"},"prompt":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":{}}},"defaultAccount":{"type":"string"}},"additionalProperties":{}}},{"pluginId":"raft","channelId":"raft","order":72,"channelEnvVars":["RAFT_PROFILE"],"label":"Raft","description":"Raft CLI wake bridge for human and agent collaboration.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"profile":{"type":"string","minLength":1},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"profile":{"type":"string","minLength":1}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"reef","channelId":"reef","label":"Reef","description":"Guarded end-to-end encrypted claw messaging.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"default":true,"type":"boolean"},"relayUrl":{"default":"https://reefwire.ai","type":"string","format":"uri","pattern":"^[hH][tT][tT][pP][sS]?:\\\\/\\\\/[^\\\\\\\\/?#@]+\\\\/?$"},"handle":{"type":"string","pattern":"^[a-z0-9][a-z0-9_-]{0,62}$"},"email":{"type":"string","format":"email","pattern":"^(?!\\\\.)(?!.*\\\\.\\\\.)([A-Za-z0-9_\'+\\\\-\\\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\\\-]*\\\\.)+[A-Za-z]{2,}$"},"guard":{"type":"object","properties":{"provider":{"type":"string","enum":["anthropic","openai"]},"pinnedModel":{"type":"string","minLength":1},"apiKeyEnv":{"type":"string","pattern":"^[A-Z_][A-Z0-9_]*$"},"policyVersion":{"type":"string","minLength":1},"timeoutMs":{"type":"integer","minimum":100,"maximum":120000}},"required":["provider","pinnedModel","apiKeyEnv","policyVersion","timeoutMs"],"additionalProperties":false},"stateDir":{"type":"string","minLength":1},"requestPolicy":{"default":"code-only","type":"string","enum":["code-only","friends-of-friends","open"]},"friends":{}},"required":["enabled","relayUrl","requestPolicy"],"additionalProperties":false}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device; more setup (David Reagans: \\"Hop on Discord.\\").","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"account":{"type":"string"},"accountUuid":{"type":"string"},"configPath":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"aliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"ingest":{"type":"boolean"}},"additionalProperties":false}},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"apiMode":{"type":"string","enum":["auto","native","container"]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"account":{"type":"string"},"accountUuid":{"type":"string"},"configPath":{"type":"string"},"httpUrl":{"type":"string"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"aliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"ingest":{"type":"boolean"}},"additionalProperties":false}},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration includi', - 'ng account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.","presentation":"phone-number"},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"reactionAllowlist":{"presentation":"phone-number"},"accounts.*.account":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"accounts.*.reactionAllowlist.*":{"presentation":"phone-number"},"configPath":{"label":"Signal CLI Config Path","help":"Optional directory passed to signal-cli via --config when the service needs a non-default signal-cli data path."}}},{"pluginId":"slack","channelId":"slack","channelEnvVars":["SLACK_APP_TOKEN","SLACK_BOT_TOKEN","SLACK_USER_TOKEN"],"label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"identity":{"default":"bot","type":"string","enum":["bot","user"]},"mode":{"default":"socket","type":"string","enum":["socket","http","relay"]},"enterpriseOrgInstall":{"type":"boolean"},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"relay":{"type":"object","properties":{"url":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"gatewayId":{"type":"string"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"ackReaction":{"type":"string"},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false}},"additionalProperties":false}},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"histo', - 'ryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"identity":{"type":"string","enum":["bot","user"]},"mode":{"type":"string","enum":["socket","http","relay"]},"enterpriseOrgInstall":{"type":"boolean"},"socketMode":{"type":"object","properties":{"clientPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"serverPingTimeout":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"pingPongLoggingEnabled":{"type":"boolean"}},"additionalProperties":false},"relay":{"type":"object","properties":{"url":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"gatewayId":{"type":"string"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"ackReaction":{"type":"string"},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false}},"additionalProperties":false}},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy","identity","mode","webhookPath","userTokenReadOnly"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"enterpriseOrgInstall":{"label":"Slack Enterprise Grid Org Install","help":"Enable only for an Enterprise Grid org-wide bot installation. OpenClaw verifies the token with Slack auth.test at startup; DMs must be disabled or use dmPolicy=\\"open\\" with allowFrom=[\\"*\\"]."},"identity":{"label":"Slack Identity","help":"Select \\"bot\\" (default) for the classic Slack app/bot identity or \\"user\\" to post as the authorizing human through a user token while the app carries event transport."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Slack Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Slack channel IDs. Native Slack @mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Slack Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Slack Mention Pattern Allowlist","help":"Slack channel IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Slack Mention Pattern Denylist","help":"Slack channel IDs where configured regex mention patterns are disabled. Native @mentions still trigger."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"implicitMentions":{"label":"Slack Implicit Mentions","help":"Control which Slack reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Slack Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Slack Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Slack Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"botLoopProtection":{"label":"Slack Bot Loop Protection","help":"Sliding-window guard for Slack bot-to-bot loops. Default is enabled whenever allowBots lets bot-authored messages reach dispatch."},"botLoopProtection.enabled":{"label":"Slack Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allowBots is true or \\"mentions\\", and false when bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Slack Bot Loop Events per Window","help":"Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Slack Bot Loop Window Seconds","help":"Sliding window length for counting bot-pair messages. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Slack Bot Loop Cooldown Seconds","help":"How long to suppress the bot pair after it exceeds the budget. Default: 60."},"socketMode":{"label":"Slack Socket Mode Transport","help":"Slack Socket Mode transport tuning passed to the Slack SDK. Use only when investigating ping/pong timeout or stale websocket behavior."},"socketMode.clientPingTimeout":{"label":"Slack Socket Mode Pong Timeout","help":"Milliseconds the Slack SDK waits for a pong after its client pin', - 'g before treating the websocket as stale (OpenClaw default: 15000). Increase on hosts with event-loop starvation or slow network scheduling."},"socketMode.serverPingTimeout":{"label":"Slack Socket Mode Server Ping Timeout","help":"Milliseconds the Slack SDK waits for Slack server pings before treating the websocket as stale."},"socketMode.pingPongLoggingEnabled":{"label":"Slack Socket Mode Ping/Pong Logging","help":"Enable Slack SDK ping/pong transport logs while debugging Socket Mode websocket health."},"relay":{"label":"Slack Relay Mode","help":"Relay-delivered Slack events. Use with mode=\\"relay\\" when openclaw-slack-router owns the Slack Socket Mode connection."},"relay.url":{"label":"Slack Relay URL","help":"Full websocket URL for openclaw-slack-router. Include the route path, for example ws://127.0.0.1:8081/gateway/ws."},"relay.authToken":{"label":"Slack Relay Auth Token","help":"Bearer token used by this gateway to authenticate its reverse websocket connection to openclaw-slack-router."},"relay.gatewayId":{"label":"Slack Relay Gateway ID","help":"Destination id that openclaw-slack-router uses when routing user-group mentions to this gateway."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"capabilities.interactiveReplies":{"label":"Slack Interactive Replies","help":"Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account."},"presenceEvents":{"label":"Slack Presence Events","help":"Poll observed human participants and wake the routed agent on away-to-active transitions. Default: \\"off\\"."},"presenceEvents.mode":{"label":"Slack Presence Event Mode","help":"\\"off\\" disables polling; \\"auto\\" covers DMs, MPIMs, and recent threads with up to 8 observed people; \\"on\\" also covers larger threads and top-level channels."},"channels.*.presenceEvents.mode":{"label":"Slack Channel Presence Event Mode","help":"Override presence events for one Slack channel. Use \\"on\\" to include large threads or top-level channel sessions."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native Streaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Slack Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.nativeTaskCards":{"label":"Slack Native Progress Task Cards","help":"Opt in to Slack native task-card progress updates when channels.slack.streaming.mode=\\"progress\\" and streaming.nativeTransport is enabled. Default: false."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."}}},{"pluginId":"sms","channelId":"sms","order":88,"channelEnvVars":["SMS_ALLOWED_USERS","SMS_PUBLIC_WEBHOOK_URL","SMS_WEBHOOK_PATH","TWILIO_ACCOUNT_SID","TWILIO_AUTH_TOKEN","TWILIO_MESSAGING_SERVICE_SID","TWILIO_PHONE_NUMBER","TWILIO_SMS_FROM"],"label":"SMS","description":"Twilio-backed SMS with inbound webhooks and outbound replies.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["dmPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"SMS","help":"Twilio SMS channel configuration for inbound webhooks and outbound text replies."},"accountSid":{"label":"Twilio Account SID","help":"Twilio Account SID used for SMS outbound API calls."},"authToken":{"label":"Twilio Auth Token","help":"Twilio Auth Token used to sign webhook validation and SMS outbound API calls."},"fromNumber":{"label":"SMS From Number","help":"Twilio SMS-capable phone number in E.164 format, for example +15551234567.","presentation":"phone-number"},"messagingServiceSid":{"label":"Twilio Messaging Service SID","help":"Twilio Messaging Service SID to use instead of a dedicated fromNumber."},"defaultTo":{"label":"SMS Default To Number","help":"Optional default outbound phone number used when a send flow omits an explicit SMS target.","presentation":"phone-number"},"publicWebhookUrl":{"label":"SMS Public Webhook URL","help":"Public URL configured in Twilio for incoming messages. Must match Twilio\'s signed URL exactly."},"webhookPath":{"label":"SMS Webhook Path","help":"Gateway HTTP path that receives Twilio incoming-message webhooks. Use a distinct path per account."},"dmPolicy":{"label":"SMS DM Policy","help":"Direct SMS access control (\\"pairing\\" recommended). \\"open\\" requires channels.sms.allowFrom=[\\"*\\"]."},"allowFrom":{"label":"SMS Allow From","help":"Allowed sender phone numbers in E.164 format, or * when dmPolicy is open.","presentation":"phone-number"},"accounts.*.fromNumber":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"textChunkLimit":{"label":"SMS Text Chunk Limit","help":"Maximum characters per outbound SMS chunk before OpenClaw splits long replies."}}},{"pluginId":"synology-chat","channelId":"synology-chat","order":90,"channelEnvVars":["OPENCLAW_BOT_NAME","SYNOLOGY_ALLOWED_USER_IDS","SYNOLOGY_CHAT_INCOMING_URL","SYNOLOGY_CHAT_TOKEN","SYNOLOGY_NAS_HOST","SYNOLOGY_RATE_LIMIT"],"label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","channelEnvVars":["TELEGRAM_BOT_TOKEN"],"label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type"', - ':"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern', - '":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Telegram Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Telegram group chat IDs or chatId:topic:threadId topic IDs. Native Telegram bot mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Telegram Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Telegram Mention Pattern Allowlist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Telegram Mention Pattern Denylist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are disabled. Native bot mentions still trigger."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"richMessages":{"label":"Telegram Rich Messages","help":"Opt into Bot API 10.1 rich text sends and edits, including native tables and rich media. Default: false because some current Telegram clients render these messages as unsupported."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Telegram Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.commentary":{"label":"Telegram Progress Commentary","help":"Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API file_path values. Exact in-root paths are read directly; container paths under /var/lib/telegram-bot-api can map into a host volume mount. Other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligib', - 'le for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","order":90,"label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false},"uiHints":{"implicitMentions":{"label":"Tlon Implicit Mentions","help":"Control which Tlon reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Tlon Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Tlon Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Tlon Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."}}},{"pluginId":"twitch","channelId":"twitch","aliases":["twitch-chat"],"channelEnvVars":["OPENCLAW_TWITCH_ACCESS_TOKEN"],"label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeat":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"messagePrefix":{"type":"string"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"object","properties":{"emoji":{"type":"string"},"direct":{"default":true,"type":"boolean"},"group":{"default":"mentions","type":"string","enum":["always","mentions","never"]}},"required":["direct","group"],"additionalProperties":false},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"},"calls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","mediaMaxMb","debounceMs"],"additionalProp', - 'erties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."},"actions.calls":{"label":"WhatsApp Voice Calls","help":"Expose the experimental requester-bound WhatsApp voice-call tool. Default: false. Requires a separately paired MeowCaller CLI."},"mentionPatterns":{"label":"WhatsApp Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected WhatsApp conversation IDs such as 123@g.us."},"mentionPatterns.mode":{"label":"WhatsApp Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"WhatsApp Mention Pattern Allowlist","help":"WhatsApp conversation IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"WhatsApp Mention Pattern Denylist","help":"WhatsApp conversation IDs where configured regex mention patterns are disabled."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","aliases":["zl"],"order":80,"channelEnvVars":["ZALO_BOT_TOKEN","ZALO_WEBHOOK_SECRET"],"label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","aliases":["zlu"],"order":85,"channelEnvVars":["ZALOUSER_PROFILE","ZCA_PROFILE"],"label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', + '[{"pluginId":"clickclack","channelId":"clickclack","order":85,"channelEnvVars":["CLICKCLACK_BOT_TOKEN"],"label":"ClickClack","description":"self-hosted chat via first-class ClickClack bot tokens.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"apiBaseUrl":{"type":"string","format":"uri"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"workspace":{"type":"string"},"botUserId":{"type":"string"},"agentId":{"type":"string"},"replyMode":{"type":"string","enum":["agent","model"]},"model":{"type":"string"},"systemPrompt":{"type":"string"},"toolsAllow":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"reconnectMs":{"type":"integer","minimum":100,"maximum":60000},"agentActivity":{"type":"boolean"},"commandMenu":{"type":"boolean"},"discussions":{"type":"object","properties":{"enabled":{"type":"boolean"},"workspace":{"type":"string"},"controlUrlBase":{"type":"string","format":"uri"},"section":{"type":"string"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"apiBaseUrl":{"type":"string","format":"uri"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"workspace":{"type":"string"},"botUserId":{"type":"string"},"agentId":{"type":"string"},"replyMode":{"type":"string","enum":["agent","model"]},"model":{"type":"string"},"systemPrompt":{"type":"string"},"toolsAllow":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"reconnectMs":{"type":"integer","minimum":100,"maximum":60000},"agentActivity":{"type":"boolean"},"commandMenu":{"type":"boolean"},"discussions":{"type":"object","properties":{"enabled":{"type":"boolean"},"workspace":{"type":"string"},"controlUrlBase":{"type":"string","format":"uri"},"section":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"discord","channelId":"discord","channelEnvVars":["DISCORD_BOT_TOKEN"],"label":"Discord","description":"very well supported right now.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"activities":{"type":"object","properties":{"clientSecret":{"type":"string","minLength":1},"applicationId":{"type":"string","pattern":"^\\\\d+$"}},"additionalProperties":false},"proxy":{"type":"string"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"suppressEmbeds":{"type":"boolean"},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"slug":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"presenceEvents":{"type":"object","properties":{"enabled":{"type":"boolean"},"channelId":{"type":"string","pattern":"^\\\\d+$"},"users":{"type":"array","items":{"type":"string","pattern":"^\\\\d+$"}},"reconnectSuppressSeconds":{"type":"integer","minimum":0,"maximum":9007199254740991},"burstLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"burstWindowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["channelId"],"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]},"cleanupAfterResolve":{"type":"boolean"}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"},"ttlMs":{"type":"integer","exclusiveMinimum":0,"maximum":86400000}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["stt-tts","agent-proxy","bidi"]},"agentSession":{"type":"object","properties":{"mode":{"type":"string","enum":["voice","target"]},"target":{"type":"string","minLength":1}},"additionalProperties":false},"model":{"type":"string","minLength":1},"realtime":{"type":"object","properties":{"provider":{"type":"string","minLength":1},"model":{"type":"string","minLength":1},"speakerVoice":{"type":"string","minLength":1},"speakerVoiceId":{"type":"string","minLength":1},"instructions":{"type":"string","minLength":1},"toolPolicy":{"type":"string","enum":["safe-read-only","owner","none"]},"consultPolicy":{"type":"string","enum":["auto","always"]},"requireWakeName":{"type":"boolean"},"wakeNames":{"minItems":1,"type":"array","items":{"type":"string","minLength":1,"pattern":"^\\\\s*[^a-z0-9]*[a-z0-9]+(?:[^a-z0-9]+[a-z0-9]+)?[^a-z0-9]*\\\\s*$"}},"bootstrapContextFiles":{"type":"array","items":{"type":"string","enum":["IDENTITY.md","USER.md","SOUL.md"]}},"bargeIn":{"type":"boolean"},"minBargeInAudioEndMs":{"type":"integer","minimum":0,"maximum":10000},"debounceMs":{"type":"integer","exclusiveMinimum":0,"maximum":10000},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}}},"additionalProperties":false},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"followUsersEnabled":{"type":"boolean"},"followUsers":{"type":"array","items":{"type":"string","minLength":1}},"allowedChannels":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required"', + ':["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const":4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"applicationId":{"type":"string"},"activities":{"type":"object","properties":{"clientSecret":{"type":"string","minLength":1},"applicationId":{"type":"string","pattern":"^\\\\d+$"}},"additionalProperties":false},"proxy":{"type":"string"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"mentionAliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string","pattern":"^\\\\d+$"}},"suppressEmbeds":{"type":"boolean"},"maxLinesPerMessage":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"stickers":{"type":"boolean"},"emojiUploads":{"type":"boolean"},"stickerUploads":{"type":"boolean"},"polls":{"type":"boolean"},"permissions":{"type":"boolean"},"messages":{"type":"boolean"},"threads":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"memberInfo":{"type":"boolean"},"roleInfo":{"type":"boolean"},"roles":{"type":"boolean"},"channelInfo":{"type":"boolean"},"voiceStatus":{"type":"boolean"},"events":{"type":"boolean"},"moderation":{"type":"boolean"},"channels":{"type":"boolean"},"presence":{"type":"boolean"}},"additionalProperties":false},"thread":{"type":"object","properties":{"inheritParent":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"guilds":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"slug":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"presenceEvents":{"type":"object","properties":{"enabled":{"type":"boolean"},"channelId":{"type":"string","pattern":"^\\\\d+$"},"users":{"type":"array","items":{"type":"string","pattern":"^\\\\d+$"}},"reconnectSuppressSeconds":{"type":"integer","minimum":0,"maximum":9007199254740991},"burstLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"burstWindowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["channelId"],"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"users":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"includeThreadStarter":{"type":"boolean"},"autoThread":{"type":"boolean"},"autoThreadName":{"type":"string","enum":["message","generated"]},"autoArchiveDuration":{"anyOf":[{"type":"string","enum":["60","1440","4320","10080"]},{"type":"number","const":60},{"type":"number","const":1440},{"type":"number","const":4320},{"type":"number","const":10080}]}},"additionalProperties":false}}},"additionalProperties":false}},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]},"cleanupAfterResolve":{"type":"boolean"}},"additionalProperties":false},"agentComponents":{"type":"object","properties":{"enabled":{"type":"boolean"},"ttlMs":{"type":"integer","exclusiveMinimum":0,"maximum":86400000}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"ephemeral":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"intents":{"type":"object","properties":{"presence":{"type":"boolean"},"guildMembers":{"type":"boolean"},"voiceStates":{"type":"boolean"}},"additionalProperties":false},"voice":{"type":"object","properties":{"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["stt-tts","agent-proxy","bidi"]},"agentSession":{"type":"object', + '","properties":{"mode":{"type":"string","enum":["voice","target"]},"target":{"type":"string","minLength":1}},"additionalProperties":false},"model":{"type":"string","minLength":1},"realtime":{"type":"object","properties":{"provider":{"type":"string","minLength":1},"model":{"type":"string","minLength":1},"speakerVoice":{"type":"string","minLength":1},"speakerVoiceId":{"type":"string","minLength":1},"instructions":{"type":"string","minLength":1},"toolPolicy":{"type":"string","enum":["safe-read-only","owner","none"]},"consultPolicy":{"type":"string","enum":["auto","always"]},"requireWakeName":{"type":"boolean"},"wakeNames":{"minItems":1,"type":"array","items":{"type":"string","minLength":1,"pattern":"^\\\\s*[^a-z0-9]*[a-z0-9]+(?:[^a-z0-9]+[a-z0-9]+)?[^a-z0-9]*\\\\s*$"}},"bootstrapContextFiles":{"type":"array","items":{"type":"string","enum":["IDENTITY.md","USER.md","SOUL.md"]}},"bargeIn":{"type":"boolean"},"minBargeInAudioEndMs":{"type":"integer","minimum":0,"maximum":10000},"debounceMs":{"type":"integer","exclusiveMinimum":0,"maximum":10000},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}}},"additionalProperties":false},"autoJoin":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"followUsersEnabled":{"type":"boolean"},"followUsers":{"type":"array","items":{"type":"string","minLength":1}},"allowedChannels":{"type":"array","items":{"type":"object","properties":{"guildId":{"type":"string","minLength":1},"channelId":{"type":"string","minLength":1}},"required":["guildId","channelId"],"additionalProperties":false}},"daveEncryption":{"type":"boolean"},"decryptionFailureTolerance":{"type":"integer","minimum":0,"maximum":9007199254740991},"connectTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"reconnectGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":120000},"captureSilenceGraceMs":{"type":"integer","exclusiveMinimum":0,"maximum":30000},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string","minLength":1},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"},"provider":{"type":"string","minLength":1},"fallbackPolicy":{"anyOf":[{"type":"string","const":"preserve-persona"},{"type":"string","const":"provider-defaults"},{"type":"string","const":"fail"}]},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}}},"additionalProperties":false}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","properties":{"enabled":{"type":"boolean"},"allowText":{"type":"boolean"},"allowProvider":{"type":"boolean"},"allowVoice":{"type":"boolean"},"allowModelId":{"type":"boolean"},"allowVoiceSettings":{"type":"boolean"},"allowNormalization":{"type":"boolean"},"allowSeed":{"type":"boolean"}},"additionalProperties":false},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"apiKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"},{"type":"array","items":{}},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}]}}},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false}},"additionalProperties":false},"pluralkit":{"type":"object","properties":{"enabled":{"type":"boolean"},"token":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]}},"additionalProperties":false},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","off","none"]},"activity":{"type":"string"},"status":{"type":"string","enum":["online","dnd","idle","invisible"]},"autoPresence":{"type":"object","properties":{"enabled":{"type":"boolean"},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"minUpdateIntervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"activityType":{"anyOf":[{"type":"number","const":0},{"type":"number","const":1},{"type":"number","const":2},{"type":"number","const":3},{"type":"number","const":4},{"type":"number","const":5}]},"activityUrl":{"type":"string","format":"uri"},"inboundWorker":{"type":"object","properties":{"runTimeoutMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Discord","help":"Discord channel provider configuration for bot auth, retry policy, streaming, thread bindings, and optional voice capabilities. Keep privileged intents and advanced features disabled unless needed."},"dmPolicy":{"label":"Discord DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.discord.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Discord Config Writes","help":"Allow Discord to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Discord Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Discord channel IDs. Native Discord @mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Discord Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Discord Mention Pattern Allowlist","help":"Discord channel IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Discord Mention Pattern Denylist","help":"Discord channel IDs where configured regex mention patterns are disabled. Native @mentions still trigger."},"proxy":{"label":"Discord Proxy URL","help":"Proxy URL for Discord gateway + API requests (app-id lookup and allowlist resolution). Set per account via channels.discord.accounts..proxy."},"commands.native":{"label":"Discord Native Commands","help":"Override native commands for Discord (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Discord Native Skill Commands","help":"Override native skill commands for Discord (bool or \\"auto\\")."},"streaming":{"label":"Discord Streaming Mode","help":"Unified Discord stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Discord Streaming Mode","help":"Canonical Discord preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Discord Chunk Mode","help":"Chunking mode for outbound Discord text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Discord Block Streaming Enabled","help":"Enable chunked block-style Discord preview delivery when channels.discord.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Discord Block Streaming Coalesce","help":"Merge streamed Discord block replies before final delivery."},"streaming.preview.chunk.minChars":{"label":"Discord Draft Chunk Min Chars","help":"Minimum chars before emitting a Discord stream preview update when channels.discord.streaming.mode=\\"block\\" (default: 200)."},"streaming.preview.chunk.maxChars":{"label":"Discord Draft Chunk Max Chars","help":"Target max size for a Discord stream preview chunk when channels.discord.streaming.mode=\\"block\\" (default: 800; clamped to channels.discord.textChunkLimit)."},"streaming.preview.chunk.breakPreference":{"label":"Discord Draft Chunk Break Preference","help":"Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph."},"streaming.preview.toolProgress":{"label":"Discord Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Discord Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Discord Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Discord Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Discord Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Discord Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Discord Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commentary":{"label":"Discord Progress Commentary","help":"Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged."},"streaming.progress.commandText":{"label":"Discord Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"maxLinesPerMessage":{"label":"Discord Max Lines Per Message","help":"Soft max line count per Discord message (default: 17)."},"suppressEmbeds":{"label":"Discord Suppress Link Embeds","help":"Suppress Discord-generated link embeds on outbound messages by default. Explicit embeds still send normally. Default: true."},"thread.inheritParent":{"label":"Discord Thread Parent Inheritance","help":"If true, Discord thread sessions inherit the parent channel transcript (default: false)."},"threadBindings.enabled":{"label":"Discord Thread Binding Enabled","help":"Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Discord Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Discord Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Discord Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-create and bind Discord threads (default: true). Set false to disable for this account/channel."},"threadBindings.defaultSpawnContext":{"label":"Discord Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."},"agentComponents.ttlMs":{"label":"Discord Component TTL (ms)","help":"How long sent Discord component callbacks remain registered. Default is 1800000 (30 minutes); maximum is 86400000 (24 hours)."},"intents.presence":{"label":"Discord Presence Intent","help":"Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false."},"intents.guildMembers":{"label":"Discord Guild Members Intent","help":"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false."},"intents.voiceStates":{"label":"Discord Voice States Intent","help":"Enable the Guild Voice States intent. Defaults to the effective Discord voice setting; set true only for Discord voice channel conversations."},"voice.enabled":{"label":"Discord Voice Enabled","help":"Enable Discord voice channel conversations. Text-only Discord configs leave voice off by default; set true to enable /vc commands and the Guild Voice States intent."},"voice.model":{"label":"Discord Voice Model","help":"Optional LLM model override for Discord voice channel responses and realtime agent consults (for example openai/gpt-5.6-sol). Leave unset to inherit the routed agent model."},"voice.mode":{"label":"Discord Voice Mode","help":"Conversation mode: agent-proxy (default) uses realtime voice as the microphone/speaker for the routed OpenClaw agent, stt-tts uses batch speech-to-text plus TTS, and bidi lets the realtime provider converse directly with the OpenClaw consult tool."},"voice.agentSession":{"label":"Discord Voice Agent Session","help":"Controls which OpenClaw conversation receives voice turns. Leave unset for the voice channel session, or set mode=\\"target\\" with a Discord target such as channel:123 to make voice an extension of an existing text channel session."},"voice.agentSession.target":{"label":"Discord Voice Agent Session Target', + '","help":"Discord target used when voice.agentSession.mode=\\"target\\", for example channel:123."},"voice.followUsersEnabled":{"label":"Discord Voice Follow Users Enabled","help":"Toggle Discord voice follow-users behavior without removing the saved voice.followUsers list. Defaults to true when followUsers is configured."},"voice.followUsers":{"label":"Discord Voice Follow Users","help":"Discord user IDs to follow into voice channels. The bot joins when a followed user joins or moves, and leaves when that user disconnects."},"voice.realtime.provider":{"label":"Discord Realtime Provider","help":"Realtime voice provider for agent-proxy or bidi Discord voice modes, such as openai."},"voice.realtime.model":{"label":"Discord Realtime Model","help":"Provider realtime session model, such as gpt-realtime-2.1. This is separate from voice.model, which remains the OpenClaw agent brain model."},"voice.realtime.speakerVoice":{"label":"Discord Realtime Speaker Voice","help":"Provider realtime output voice name, such as cedar."},"voice.realtime.speakerVoiceId":{"label":"Discord Realtime Speaker Voice ID","help":"Provider realtime output voice id."},"voice.realtime.toolPolicy":{"label":"Discord Realtime Tool Policy","help":"Tool policy for the OpenClaw agent consult tool in realtime voice modes: safe-read-only, owner, or none. Default is owner for agent-proxy and safe-read-only for bidi."},"voice.realtime.consultPolicy":{"label":"Discord Realtime Consult Policy","help":"Use always to strongly prefer the OpenClaw agent brain for substantive realtime turns. agent-proxy defaults to always."},"voice.realtime.requireWakeName":{"label":"Discord Realtime Require Wake Name","help":"Control OpenAI agent-proxy wake-name gating. Unset listens naturally with one human and requires a wake name with two or more; true always requires one and false never does."},"voice.realtime.wakeNames":{"label":"Discord Realtime Wake Names","help":"One- or two-word activation names used whenever OpenAI agent-proxy Discord realtime voice has an active wake-name gate."},"voice.realtime.bootstrapContextFiles":{"label":"Discord Realtime Bootstrap Context Files","help":"Agent profile bootstrap files included in realtime provider instructions for direct voice identity/persona grounding. Defaults to IDENTITY.md, USER.md, and SOUL.md; set [] to disable."},"voice.realtime.bargeIn":{"label":"Discord Realtime Barge-In","help":"Allow Discord speaker-start events to interrupt active realtime playback. Set true to keep manual interruption when provider input-audio interruption is disabled for echo control."},"voice.realtime.minBargeInAudioEndMs":{"label":"Discord Realtime Minimum Barge-In Audio (ms)","help":"Minimum assistant playback duration before a Discord barge-in truncates realtime audio. Default: 250; set 0 for immediate interruption in low-echo rooms."},"voice.realtime.providers":{"label":"Discord Realtime Provider Settings","help":"Provider-specific realtime voice settings keyed by provider id.","advanced":true},"voice.autoJoin":{"label":"Discord Voice Auto-Join","help":"Voice channels to auto-join on startup (list of guildId/channelId entries)."},"voice.allowedChannels":{"label":"Discord Voice Allowed Channels","help":"Optional voice channel residency allowlist. When set, /vc join, auto-join, and bot voice-state moves are restricted to these guildId/channelId entries. Leave unset to allow any voice channel."},"voice.daveEncryption":{"label":"Discord Voice DAVE Encryption","help":"Toggle DAVE end-to-end encryption for Discord voice joins (default: true in @discordjs/voice; Discord may require this)."},"voice.decryptionFailureTolerance":{"label":"Discord Voice Decrypt Failure Tolerance","help":"Consecutive decrypt failures before DAVE attempts session recovery (passed to @discordjs/voice; default: 24)."},"voice.connectTimeoutMs":{"label":"Discord Voice Connect Timeout (ms)","help":"Initial @discordjs/voice Ready wait before a join is treated as failed. Default: 30000."},"voice.reconnectGraceMs":{"label":"Discord Voice Reconnect Grace (ms)","help":"Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000."},"voice.captureSilenceGraceMs":{"label":"Discord Voice Capture Silence Grace (ms)","help":"Silence window after Discord reports a speaker ended before OpenClaw finalizes the audio segment for transcription. Default: 2000."},"voice.tts":{"label":"Discord Voice Text-to-Speech","help":"Optional TTS overrides for Discord voice playback (merged with tts)."},"pluralkit.enabled":{"label":"Discord PluralKit Enabled","help":"Resolve PluralKit proxied messages and treat system members as distinct senders."},"pluralkit.token":{"label":"Discord PluralKit Token","help":"Optional PluralKit token for resolving private systems or members."},"activity":{"label":"Discord Presence Activity","help":"Discord presence activity text (defaults to custom status)."},"status":{"label":"Discord Presence Status","help":"Discord presence status (online, dnd, idle, invisible)."},"autoPresence.enabled":{"label":"Discord Auto Presence Enabled","help":"Enable automatic Discord bot presence updates based on runtime/model availability signals. When enabled: healthy=>online, degraded/unknown=>idle, exhausted/unavailable=>dnd."},"autoPresence.intervalMs":{"label":"Discord Auto Presence Check Interval (ms)","help":"How often to evaluate Discord auto-presence state in milliseconds (default: 30000)."},"autoPresence.minUpdateIntervalMs":{"label":"Discord Auto Presence Min Update Interval (ms)","help":"Minimum time between actual Discord presence update calls in milliseconds (default: 15000). Prevents status spam on noisy state changes."},"guilds.*.presenceEvents":{"label":"Discord Online Presence Events","help":"Route selected human offline-to-online transitions into the configured guild channel as agent system events. Requires the Guild Presences privileged intent and an enabled agent heartbeat."},"guilds.*.presenceEvents.enabled":{"label":"Discord Online Presence Events Enabled","help":"Enable online-presence agent wakes for this guild. Defaults to true when presenceEvents is configured."},"guilds.*.presenceEvents.channelId":{"label":"Discord Online Presence Target Channel","help":"Numeric Discord channel ID whose routed agent session receives online-presence events and greeting delivery."},"guilds.*.presenceEvents.users":{"label":"Discord Online Presence User IDs","help":"Optional immutable Discord user ID allowlist. Omit to include all human members in the guild."},"guilds.*.presenceEvents.reconnectSuppressSeconds":{"label":"Discord Online Presence Reconnect Suppression","help":"Suppress online-presence events for this many seconds after a new Gateway session while guild presence state is rebuilt. Resumed sessions are unaffected. 0 disables. Default: 300."},"guilds.*.presenceEvents.burstLimit":{"label":"Discord Online Presence Burst Limit","help":"Maximum successfully queued online-presence events for this guild per burst window; the rest are suppressed and logged once. Default: 8."},"guilds.*.presenceEvents.burstWindowSeconds":{"label":"Discord Online Presence Burst Window","help":"Sliding window in seconds used for burst detection. Default: 60."},"activityType":{"label":"Discord Presence Activity Type","help":"Discord presence activity type (0=Playing,1=Streaming,2=Listening,3=Watching,4=Custom,5=Competing)."},"activityUrl":{"label":"Discord Presence Activity URL","help":"Discord presence streaming URL (required for activityType=1)."},"allowBots":{"label":"Discord Allow Bot Messages","help":"Allow bot-authored messages to trigger Discord replies (default: false). Set \\"mentions\\" to only accept bot messages that mention the bot."},"botLoopProtection":{"label":"Discord Bot Loop Protection","help":"Sliding-window guard for bot-to-bot Discord loops. Default is enabled whenever allowBots lets bot-authored messages reach dispatch."},"botLoopProtection.enabled":{"label":"Discord Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allowBots is true or \\"mentions\\", and false when bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Discord Bot Pair Events Per Window","help":"Maximum messages a single Discord bot pair may exchange in the configured window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Discord Bot Loop Window Seconds","help":"Sliding window length in seconds for Discord bot-pair loop budgets. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Discord Bot Loop Cooldown Seconds","help":"Seconds to suppress a Discord bot pair after it exceeds the loop budget. Default: 60."},"mentionAliases":{"label":"Discord Mention Aliases","help":"Map outbound @handle text to stable Discord user IDs before sending. Set per account via channels.discord.accounts..mentionAliases."},"token":{"label":"Discord Bot Token","help":"Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.","sensitive":true},"applicationId":{"label":"Discord Application ID","help":"Optional Discord application/client ID. Set this when hosted environments cannot reach Discord\'s application lookup endpoint during startup."},"activities":{"label":"Discord Activities","help":"Enable Discord Activity widgets for this account. Routes, the agent tool, and the launch handler remain disabled when this block is absent."},"activities.clientSecret":{"label":"Discord Activities Client Secret","help":"OAuth2 client secret for the Discord application. DISCORD_CLIENT_SECRET is used when this field is unset.","sensitive":true},"activities.applicationId":{"label":"Discord Activities Application ID","help":"Optional Activity application ID. Defaults to the bot application ID learned at gateway startup."}},"unsupportedSecretRefSurfacePatterns":["channels.discord.accounts.*.threadBindings.webhookToken","channels.discord.threadBindings.webhookToken"]},{"pluginId":"feishu","channelId":"feishu","aliases":["lark"],"order":35,"channelEnvVars":["FEISHU_APP_ID","FEISHU_APP_SECRET","FEISHU_ENCRYPT_KEY","FEISHU_VERIFICATION_TOKEN"],"label":"Feishu","description":"飞书/Lark enterprise messaging with doc/wiki/drive tools.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"default":"feishu","anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"default":"websocket","type":"string","enum":["websocket","webhook"]},"webhookPath":{"default":"/feishu/events","type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"anyOf":[{"type":"string","enum":["open","disabled","allowlist"]},{"type":"string","const":"allowall"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeatVisibility":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial"]},"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},', + '"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"},"bitable":{"type":"boolean"},"base":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"default":"own","type":"string","enum":["off","own","all"]},"typingIndicator":{"default":true,"type":"boolean"},"resolveSenderNames":{"default":true,"type":"boolean"},"allowBots":{"type":"boolean"},"vcAutoJoin":{"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"dynamicAgentCreation":{"type":"object","properties":{"enabled":{"type":"boolean"},"workspaceTemplate":{"type":"string"},"agentDirTemplate":{"type":"string"},"maxAgents":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"appSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"encryptKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"verificationToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"domain":{"anyOf":[{"type":"string","enum":["feishu","lark"]},{"type":"string","format":"uri","pattern":"^https:\\\\/\\\\/.*"}]},"connectionMode":{"type":"string","enum":["websocket","webhook"]},"webhookPath":{"type":"string"},"webhookHost":{"type":"string"},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"mode":{"type":"string","enum":["native","escape","strip"]},"tableMode":{"type":"string","enum":["native","ascii","simple"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"anyOf":[{"type":"string","enum":["open","disabled","allowlist"]},{"type":"string","const":"allowall"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupSenderAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]},"replyInThread":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"httpTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":300000},"heartbeatVisibility":{"type":"object","properties":{"visibility":{"type":"string","enum":["visible","hidden"]},"intervalMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"renderMode":{"type":"string","enum":["auto","raw","card"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial"]},"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"tools":{"type":"object","properties":{"doc":{"type":"boolean"},"chat":{"type":"boolean"},"wiki":{"type":"boolean"},"drive":{"type":"boolean"},"perm":{"type":"boolean"},"scopes":{"type":"boolean"},"bitable":{"type":"boolean"},"base":{"type":"boolean"}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"replyInThread":{"type":"string","enum":["disabled","enabled"]},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"typingIndicator":{"type":"boolean"},"resolveSenderNames":{"type":"boolean"},"allowBots":{"type":"boolean"},"vcAutoJoin":{"type":"boolean"},"tts":{"type":"object","properties":{"auto":{"type":"string","enum":["off","always","inbound","tagged"]},"enabled":{"type":"boolean"},"mode":{"type":"string","enum":["final","all"]},"provider":{"type":"string"},"persona":{"type":"string"},"personas":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"summaryModel":{"type":"string"},"modelOverrides":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"providers":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}},"prefsPath":{"type":"string"},"maxTextLength":{"type":"integer","minimum":1,"maximum":9007199254740991},"timeoutMs":{"type":"integer","minimum":1000,"maximum":120000}},"additionalProperties":false},"groupSessionScope":{"type":"string","enum":["group","group_sender","group_topic","group_topic_sender"]},"topicSessionMode":{"type":"string","enum":["disabled","enabled"]}},"additionalProperties":false}}},"additionalProperties":false}},{"pluginId":"googlechat","channelId":"googlechat","aliases":["gchat","google-chat"],"order":55,"channelEnvVars":["GOOGLE_CHAT_SERVICE_ACCOUNT","GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"],"label":"Google Chat","description":"Google Workspace Chat app with HTTP webhook.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"type":"boolean"},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimu', + 'm":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"type":"boolean"},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"serviceAccount":{"anyOf":[{"type":"string"},{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"serviceAccountFile":{"type":"string"},"audienceType":{"type":"string","enum":["app-url","project-number"]},"audience":{"type":"string"},"appPrincipal":{"type":"string"},"webhookPath":{"type":"string"},"webhookUrl":{"type":"string"},"botUser":{"type":"string"},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"typingIndicator":{"type":"string","enum":["none","message","reaction"]}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"imessage","channelId":"imessage","aliases":["imsg"],"label":"iMessage","description":"Local iMessage/SMS through the imsg bridge, including private API message actions when enabled.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"},"polls":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"sendTransport":{"type":"string","enum":["auto","bridge","applescript"]},"region":{"type":"string"},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"sendReadReceipts":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"catchup":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxAgeMinutes":{"type":"integer","minimum":1,"maximum":720},"perRunLimit":{"type":"integer","minimum":1,"maximum":500},"firstRunLookbackMinutes":{"type":"integer","minimum":1,"maximum":720},"maxFailureRetries":{"type":"integer","minimum":1,"maximum":1000}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cliPath":{"type":"string"},"dbPath":{"type":"string"},"remoteHost":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"edit":{"type":"boolean"},"unsend":{"type":"boolean"},"reply":{"type":"boolean"},"sendWithEffect":{"type":"boolean"},"renameGroup":{"type":"boolean"},"setGroupIcon":{"type":"boolean"},"addParticipant":{"type":"boolean"},"removeParticipant":{"type":"boolean"},"leaveGroup":{"type":"boolean"},"sendAttachment":{"type":"boolean"},"polls":{"type":"boolean"}},"additionalProperties":false},"service":{"anyOf":[{"type":"string","const":"imessage"},{"type":"string","const":"sms"},{"type":"string","const":"auto"}]},"sendTransport":{"type":"string","enum":["auto","bridge","applescript"]},"region":{"type":"string"},"includeAttachments":{"type":"boolean"},"attachmentRoots":{"type":"array","items":{"type":"string"}},"remoteAttachmentRoots":{"type":"array","items":{"type":"string"}},"probeTimeoutMs":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"sendReadReceipts":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"catchup":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxAgeMinutes":{"type":"integer","minimum":1,"maximum":720},"perRunLimit":{"type":"integer","minimum":1,"maximum":500},"firstRunLookbackMinutes":{"type":"integer","minimum":1,"maximum":720},"maxFailureRetries":{"type":"integer","minimum":1,"maximum":1000}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"iMessage","help":"iMessage channel provider configuration for CLI integration and DM access policy handling. Use explicit CLI paths when runtime environments have non-standard binary locations."},"dmPolicy":{"label":"iMessage DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.imessage.allowFrom=[\\"*\\"]."},"configWrites":{"label":"iMessage Config Writes","help":"Allow iMessage to write config in response to channel events/commands (default: true)."},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"cliPath":{"label":"iMessage CLI Path","help":"Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments."},"sendTransport":{"label":"iMessage Send Transport","help":"Preferred imsg RPC send transport for normal outbound replies. \\"auto\\" uses the IMCore bridge when available, \\"bridge\\" requires it, and \\"applescript\\" forces Messages automation."}}},{"pluginId":"irc","channelId":"irc","aliases":["internet-relay-chat"],"channelEnvVars":["IRC_HOST","IRC_NICK"],"label":"IRC","description":"classic IRC networks with DM/channel routing and pairing controls.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code', + '","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"host":{"type":"string"},"port":{"type":"integer","minimum":1,"maximum":65535},"tls":{"type":"boolean"},"nick":{"type":"string"},"username":{"type":"string"},"realname":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"nickserv":{"type":"object","properties":{"enabled":{"type":"boolean"},"service":{"type":"string"},"password":{"type":"string"},"passwordFile":{"type":"string"},"register":{"type":"boolean"},"registerEmail":{"type":"string"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"channels":{"type":"array","items":{"type":"string"}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"IRC","help":"IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw."},"dmPolicy":{"label":"IRC DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.irc.allowFrom=[\\"*\\"]."},"nickserv.enabled":{"label":"IRC NickServ Enabled","help":"Enable NickServ identify/register after connect (defaults to enabled when password is configured)."},"nickserv.service":{"label":"IRC NickServ Service","help":"NickServ service nick (default: NickServ)."},"nickserv.password":{"label":"IRC NickServ Password","help":"NickServ password used for IDENTIFY/REGISTER (sensitive)."},"nickserv.passwordFile":{"label":"IRC NickServ Password File","help":"Optional file path containing NickServ password."},"nickserv.register":{"label":"IRC NickServ Register","help":"If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable."},"nickserv.registerEmail":{"label":"IRC NickServ Register Email","help":"Email used with NickServ REGISTER (required when register=true)."},"configWrites":{"label":"IRC Config Writes","help":"Allow IRC to write config in response to channel events/commands (default: true)."}}},{"pluginId":"line","channelId":"line","order":75,"channelEnvVars":["LINE_CHANNEL_ACCESS_TOKEN","LINE_CHANNEL_SECRET"],"label":"LINE","description":"LINE Messaging API webhook bot.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"channelAccessToken":{"type":"string"},"channelSecret":{"type":"string"},"tokenFile":{"type":"string"},"secretFile":{"type":"string"},"name":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number"},"webhookPath":{"type":"string"},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number"},"maxAgeHours":{"type":"number"},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"}},"additionalProperties":false}}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"matrix","channelId":"matrix","order":70,"channelEnvVars":["MATRIX_ACCESS_TOKEN","MATRIX_DEVICE_ID","MATRIX_DEVICE_NAME","MATRIX_HOMESERVER","MATRIX_OPS_ACCESS_TOKEN","MATRIX_OPS_DEVICE_ID","MATRIX_OPS_DEVICE_NAME","MATRIX_OPS_HOMESERVER","MATRIX_PASSWORD","MATRIX_USER_ID"],"label":"Matrix","description":"open protocol; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"homeserver":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"userId":{"type":"string"},"accessToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"password":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"deviceId":{"type":"string"},"deviceName":{"type":"string"},"avatarUrl":{"type":"string"},"initialSyncLimit":{"type":"number"},"encryption":{"type":"boolean"},"allowlistOnly":{"type":"boolean"},"dangerouslyAllowNameMatching":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["partial","quiet","progress","off"]},"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]},"textChunkLimit":{"type":"number"},"responsePrefix":{"type":"string"},"ackReaction":{"type":"string"},"ackReactionScope":{"type":"string","enum":["group-mentions","group-all","direct","all","none","off"]},"reactionNotifications":{"type":"string","enum":["off","own"]},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"startupVerification":{"type":"string","enum":["off","if-unverified"]},"startupVerificationCooldownHours":{"type":"number"},"mediaMaxMb":{"type":"number"},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"autoJoin":{"type":"string","enum":["always","allowlist","off"]},"autoJoinAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"policy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"sessionScope":{"type":"string","enum":["per-user","per-room"]},"threadReplies":{"type":"string","enum":["off","inbound","always"]}},"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"type":"boolean"},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{', + '"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"account":{"type":"string"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false}},"rooms":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"account":{"type":"string"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"profile":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"verification":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"uiHints":{"mentionPatterns":{"label":"Matrix Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Matrix room IDs. Native Matrix mention evidence still triggers even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Matrix Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Matrix Mention Pattern Allowlist","help":"Matrix room IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Matrix Mention Pattern Denylist","help":"Matrix room IDs where configured regex mention patterns are disabled. Native mention evidence still triggers."},"allowBots":{"label":"Matrix Allow Bot Messages","help":"Allow messages from other configured Matrix bot accounts to trigger replies (default: false). Set \\"mentions\\" to require a visible room mention."},"botLoopProtection":{"label":"Matrix Bot Loop Protection","help":"Sliding-window guard for accepted Matrix configured-bot loops. Default is enabled whenever allowBots lets configured bot messages reach dispatch."},"botLoopProtection.enabled":{"label":"Matrix Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allowBots is true or \\"mentions\\", and false when configured bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Matrix Bot Loop Events per Window","help":"Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Matrix Bot Loop Window Seconds","help":"Sliding window length for counting bot-pair messages. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Matrix Bot Loop Cooldown Seconds","help":"How long to suppress the bot pair after it exceeds the budget. Default: 60."},"dangerouslyAllowNameMatching":{"label":"Matrix Display Name Matching","help":"Compatibility opt-in for resolving Matrix display names and joined room names in allowlists. Prefer full @user:server IDs and room IDs or aliases because names are mutable."},"streaming.progress.label":{"label":"Matrix Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Matrix Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Matrix Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Matrix Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Matrix Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Matrix Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"mattermost","channelId":"mattermost","order":65,"channelEnvVars":["MATTERMOST_BOT_TOKEN","MATTERMOST_URL"],"label":"Mattermost","description":"self-hosted Slack-style chat; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"type":"string","enum":["off","first","all","batched"]},"group":{"type":"string","enum":["off","first","all","batched"]},"channel":{"type":"string","enum":["off","first","all","batched"]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"type":"string","enum":["off","first","all","batched"]},"group":{"type":"string","enum":["off","first","all","batched"]},"channel":{"type":"string","enum":["off","first","all","batched"]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Mattermost","help":"Mattermost channel provider configuration for bot auth, access policy, slash commands, and preview streaming."},"dmPolicy":{"label":"Mattermost DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.mattermost.allowFrom=[\\"*\\"]."},"implicitMentions":{"label":"Mattermost Implicit Mentions","help":"Control which Mattermost reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Mattermost Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Mattermost Quoted Bot Messages","help":"Treat messages quoting the bot as impl', + 'icit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Mattermost Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."},"streaming":{"label":"Mattermost Streaming Mode","help":"Unified Mattermost stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery."},"streaming.mode":{"label":"Mattermost Streaming Mode","help":"Canonical Mattermost preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.progress.label":{"label":"Mattermost Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Mattermost Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Mattermost Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Mattermost Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Mattermost Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Mattermost Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.preview.toolProgress":{"label":"Mattermost Draft Tool Progress","help":"Show tool/progress activity in the live draft preview post (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Mattermost Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.block.enabled":{"label":"Mattermost Block Streaming Enabled","help":"Enable chunked block-style Mattermost preview delivery when channels.mattermost.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Mattermost Block Streaming Coalesce","help":"Merge streamed Mattermost block replies before final delivery."}}},{"pluginId":"msteams","channelId":"msteams","aliases":["teams"],"order":60,"channelEnvVars":["MSTEAMS_APP_ID","MSTEAMS_APP_PASSWORD","MSTEAMS_TENANT_ID"],"label":"Microsoft Teams","description":"Teams SDK; enterprise support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"dangerouslyAllowNameMatching":{"type":"boolean"},"appId":{"type":"string"},"appPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tenantId":{"type":"string"},"cloud":{"type":"string","enum":["Public","USGov","USGovDoD","China"]},"serviceUrl":{"type":"string","format":"uri"},"authType":{"type":"string","enum":["secret","federated"]},"certificatePath":{"type":"string"},"certificateThumbprint":{"type":"string"},"useManagedIdentity":{"type":"boolean"},"managedIdentityClientId":{"type":"string"},"webhook":{"type":"object","properties":{"port":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"path":{"type":"string"}},"additionalProperties":false},"typingIndicator":{"type":"boolean"},"mediaAllowHosts":{"type":"array","items":{"type":"string"}},"mediaAuthAllowHosts":{"type":"array","items":{"type":"string"}},"graphMediaFallback":{"type":"boolean"},"requireMention":{"type":"boolean"},"replyStyle":{"type":"string","enum":["thread","top-level"]},"teams":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]}},"additionalProperties":false}}},"additionalProperties":false}},"sharePointSiteId":{"type":"string"},"welcomeCard":{"type":"boolean"},"promptStarters":{"type":"array","items":{"type":"string"}},"groupWelcomeCard":{"type":"boolean"},"feedbackEnabled":{"type":"boolean"},"feedbackReflection":{"type":"boolean"},"feedbackReflectionCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"delegatedAuth":{"type":"object","properties":{"enabled":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"sso":{"type":"object","properties":{"enabled":{"type":"boolean"},"connectionName":{"type":"string"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"MS Teams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers."},"configWrites":{"label":"MS Teams Config Writes","help":"Allow Microsoft Teams to write config in response to channel events/commands (default: true)."},"cloud":{"label":"MS Teams Cloud","help":"Teams SDK cloud environment for auth, token validation, and token services: \\"Public\\", \\"USGov\\", \\"USGovDoD\\", or \\"China\\" (default: Public)."},"serviceUrl":{"label":"MS Teams Service URL","help":"Bot Connector service URL for SDK proactive sends/edits/deletes. Set with cloud for USGov/DoD; set alone for GCC."},"graphMediaFallback":{"label":"MS Teams Graph Media Fallback","help":"Query Microsoft Graph for unresolved channel or group-chat HTML media. Adds one lookup per matching message when enabled (default: false)."},"streaming":{"label":"MS Teams Streaming","help":"Microsoft Teams preview/progress streaming mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Personal chats use Teams native streaminfo progress when available."},"streaming.progress.label":{"label":"MS Teams Progress Label","help":"Initial progress title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"MS Teams Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"MS Teams Progress Max Lines","help":"Maximum number of compact progress lines to keep below the progress title (default: 8)."},"streaming.progress.maxLineChars":{"label":"MS Teams Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"MS Teams Progress Tool Lines","help":"Show compact tool/progress lines in progress mode (default: true). Set false to keep only the title until final delivery."},"streaming.progress.commandText":{"label":"MS Teams Progress Command Text","help":"Command/exec detail in progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"nextcloud-talk","channelId":"nextcloud-talk","aliases":["nc","nc-talk"],"order":65,"channelEnvVars":["NEXTCLOUD_TALK_API_PASSWORD","NEXTCLOUD_TALK_BOT_SECRET"],"label":"Nextcloud Talk","description":"Self-hosted chat via Nextcloud Talk webhook bots.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"', + 'type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"nostr","channelId":"nostr","order":55,"channelEnvVars":["NOSTR_PRIVATE_KEY"],"label":"Nostr","description":"Decentralized protocol; encrypted DMs via NIP-04.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"defaultAccount":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"relays":{"type":"array","items":{"type":"string"}},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"profile":{"type":"object","properties":{"name":{"type":"string","maxLength":256},"displayName":{"type":"string","maxLength":256},"about":{"type":"string","maxLength":2000},"picture":{"type":"string","format":"uri"},"banner":{"type":"string","format":"uri"},"website":{"type":"string","format":"uri"},"nip05":{"type":"string"},"lud16":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},{"pluginId":"qa-channel","channelId":"qa-channel","order":999,"configurable":false,"label":"QA Channel","description":"Synthetic Slack-class transport for automated OpenClaw QA scenarios.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"qqbot","channelId":"qqbot","channelEnvVars":["QQBOT_APP_ID","QQBOT_CLIENT_SECRET"],"label":"QQ Bot","description":"connect to QQ via official QQ Bot API with group chat and direct message support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"nativeTransport":{"type":"boolean"}},"required":["mode"],"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"commandLevel":{"type":"string","enum":["all","safety","strict"]},"ignoreOtherMentions":{"type":"boolean"},"historyLimit":{"type":"number"},"name":{"type":"string"},"prompt":{"type":"string"}},"additionalProperties":false}},"stt":{"type":"object","properties":{"enabled":{"type":"boolean"},"provider":{"type":"string"},"baseUrl":{"type":"string"},"apiKey":{"type":"string"},"model":{"type":"string"}},"additionalProperties":false},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"nativeTransport":{"type":"boolean"}},"required":["mode"],"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"t', + 'ype":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"commandLevel":{"type":"string","enum":["all","safety","strict"]},"ignoreOtherMentions":{"type":"boolean"},"historyLimit":{"type":"number"},"name":{"type":"string"},"prompt":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":{}}},"defaultAccount":{"type":"string"}},"additionalProperties":{}}},{"pluginId":"raft","channelId":"raft","order":72,"channelEnvVars":["RAFT_PROFILE"],"label":"Raft","description":"Raft CLI wake bridge for human and agent collaboration.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"profile":{"type":"string","minLength":1},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"profile":{"type":"string","minLength":1}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"reef","channelId":"reef","label":"Reef","description":"Guarded end-to-end encrypted claw messaging.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"default":true,"type":"boolean"},"relayUrl":{"default":"https://reefwire.ai","type":"string","format":"uri","pattern":"^[hH][tT][tT][pP][sS]?:\\\\/\\\\/[^\\\\\\\\/?#@]+\\\\/?$"},"handle":{"type":"string","pattern":"^[a-z0-9][a-z0-9_-]{0,62}$"},"email":{"type":"string","format":"email","pattern":"^(?!\\\\.)(?!.*\\\\.\\\\.)([A-Za-z0-9_\'+\\\\-\\\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\\\-]*\\\\.)+[A-Za-z]{2,}$"},"guard":{"type":"object","properties":{"provider":{"type":"string","enum":["anthropic","openai"]},"pinnedModel":{"type":"string","minLength":1},"apiKeyEnv":{"type":"string","pattern":"^[A-Z_][A-Z0-9_]*$"},"policyVersion":{"type":"string","minLength":1},"timeoutMs":{"type":"integer","minimum":100,"maximum":120000}},"required":["provider","pinnedModel","apiKeyEnv","policyVersion","timeoutMs"],"additionalProperties":false},"stateDir":{"type":"string","minLength":1},"requestPolicy":{"default":"code-only","type":"string","enum":["code-only","friends-of-friends","open"]},"friends":{}},"required":["enabled","relayUrl","requestPolicy"],"additionalProperties":false}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device; more setup (David Reagans: \\"Hop on Discord.\\").","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"account":{"type":"string"},"accountUuid":{"type":"string"},"configPath":{"type":"string"},"httpUrl":{"type":"string"},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"aliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"ingest":{"type":"boolean"}},"additionalProperties":false}},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"apiMode":{"type":"string","enum":["auto","native","container"]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"account":{"type":"string"},"accountUuid":{"type":"string"},"configPath":{"type":"string"},"httpUrl":{"type":"string"},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"aliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"ingest":{"type":"boolean"}},"additionalProperties":false}},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.","presentation":"phone-number"},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"reactionAllowlist":{"presentation":"phone-number"},"accounts.*.account":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"accounts.*.reactionAllowlist.*":{"presentation":"phone-number"},"configPath":{"label":"Signal CLI Config Path","help":"Optional directory passed to signal-cli via --config when the service needs a non-default signal-cli data path."}}},{"pluginId":"slack","channelId":"slack","channelEnvVars":["SLACK_APP_TOKEN","SLACK_BOT_TOKEN","SLACK_USER_TOKEN"],"label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"inte', + 'ger","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"postAs":{"default":"bot","type":"string","enum":["bot","user"]},"mode":{"default":"socket","type":"string","enum":["socket","http","relay"]},"enterpriseOrgInstall":{"type":"boolean"},"relay":{"type":"object","properties":{"url":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"gatewayId":{"type":"string"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"ackReaction":{"type":"string"},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false}},"additionalProperties":false}},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"postAs":{"type":"string","enum":["bot","user"]},"mode":{"type":"string","enum":["socket","http","relay"]},"enterpriseOrgInstall":{"type":"boolean"},"relay":{"type":"object","properties":{"url":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"gatewayId":{"type":"string"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["sou', + 'rce","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"ackReaction":{"type":"string"},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false}},"additionalProperties":false}},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy","postAs","mode","webhookPath","userTokenReadOnly"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"enterpriseOrgInstall":{"label":"Slack Enterprise Grid Org Install","help":"Enable only for an Enterprise Grid org-wide bot installation. OpenClaw verifies the token with Slack auth.test at startup; DMs must be disabled or use dmPolicy=\\"open\\" with allowFrom=[\\"*\\"]."},"postAs":{"label":"Slack Identity","help":"Select \\"bot\\" (default) for the classic Slack app/bot identity or \\"user\\" to post as the authorizing human through a user token while the app carries event transport."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Slack Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Slack channel IDs. Native Slack @mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Slack Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Slack Mention Pattern Allowlist","help":"Slack channel IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Slack Mention Pattern Denylist","help":"Slack channel IDs where configured regex mention patterns are disabled. Native @mentions still trigger."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"implicitMentions":{"label":"Slack Implicit Mentions","help":"Control which Slack reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Slack Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Slack Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Slack Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"botLoopProtection":{"label":"Slack Bot Loop Protection","help":"Sliding-window guard for Slack bot-to-bot loops. Default is enabled whenever allowBots lets bot-authored messages reach dispatch."},"botLoopProtection.enabled":{"label":"Slack Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allowBots is true or \\"mentions\\", and false when bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Slack Bot Loop Events per Window","help":"Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Slack Bot Loop Window Seconds","help":"Sliding window length for counting bot-pair messages. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Slack Bot Loop Cooldown Seconds","help":"How long to suppress the bot pair after it exceeds the budget. Default: 60."},"relay":{"label":"Slack Relay Mode","help":"Relay-delivered Slack events. Use with mode=\\"relay\\" when openclaw-slack-router owns the Slack Socket Mode connection."},"relay.url":{"label":"Slack Relay URL","help":"Full websocket URL for openclaw-slack-router. Include the route path, for example ws://127.0.0.1:8081/gateway/ws."},"relay.authToken":{"label":"Slack Relay Auth Token","help":"Bearer token used by this gateway to authenticate its reverse websocket connection to openclaw-slack-router."},"relay.gatewayId":{"label":"Slack Relay Gateway ID","help":"Destination id that openclaw-slack-router uses when routing user-group mentions to this gateway."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"capabilities.interactiveReplies":{"label":"Slack Interactive Replies","help":"Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account."},"presenceEvents":{"label":"Slack Presence Events","help":"Poll observed human participants and wake the routed agent on away-to-active transitions. Default: \\"off\\"."},"presenceEvents.mode":{"label":"Slack Presence Event Mode","help":"\\"off\\" disables polling; \\"auto\\" covers DMs, MPIMs, and recent threads with up to 8 observed people; \\"on\\" also covers larger threads and top-level channels."},"channels.*.presenceEvents.mode":{"label":"Slack Channel Presence Event Mode","help":"Override presence events for one Slack channel. Use \\"on\\" to include large threads or top-level channel sessions."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native S', + 'treaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Slack Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.nativeTaskCards":{"label":"Slack Native Progress Task Cards","help":"Opt in to Slack native task-card progress updates when channels.slack.streaming.mode=\\"progress\\" and streaming.nativeTransport is enabled. Default: false."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."}}},{"pluginId":"sms","channelId":"sms","order":88,"channelEnvVars":["SMS_ALLOWED_USERS","SMS_PUBLIC_WEBHOOK_URL","SMS_WEBHOOK_PATH","TWILIO_ACCOUNT_SID","TWILIO_AUTH_TOKEN","TWILIO_MESSAGING_SERVICE_SID","TWILIO_PHONE_NUMBER","TWILIO_SMS_FROM"],"label":"SMS","description":"Twilio-backed SMS with inbound webhooks and outbound replies.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["dmPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"SMS","help":"Twilio SMS channel configuration for inbound webhooks and outbound text replies."},"accountSid":{"label":"Twilio Account SID","help":"Twilio Account SID used for SMS outbound API calls."},"authToken":{"label":"Twilio Auth Token","help":"Twilio Auth Token used to sign webhook validation and SMS outbound API calls."},"fromNumber":{"label":"SMS From Number","help":"Twilio SMS-capable phone number in E.164 format, for example +15551234567.","presentation":"phone-number"},"messagingServiceSid":{"label":"Twilio Messaging Service SID","help":"Twilio Messaging Service SID to use instead of a dedicated fromNumber."},"defaultTo":{"label":"SMS Default To Number","help":"Optional default outbound phone number used when a send flow omits an explicit SMS target.","presentation":"phone-number"},"publicWebhookUrl":{"label":"SMS Public Webhook URL","help":"Public URL configured in Twilio for incoming messages. Must match Twilio\'s signed URL exactly."},"webhookPath":{"label":"SMS Webhook Path","help":"Gateway HTTP path that receives Twilio incoming-message webhooks. Use a distinct path per account."},"dmPolicy":{"label":"SMS DM Policy","help":"Direct SMS access control (\\"pairing\\" recommended). \\"open\\" requires channels.sms.allowFrom=[\\"*\\"]."},"allowFrom":{"label":"SMS Allow From","help":"Allowed sender phone numbers in E.164 format, or * when dmPolicy is open.","presentation":"phone-number"},"accounts.*.fromNumber":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"textChunkLimit":{"label":"SMS Text Chunk Limit","help":"Maximum characters per outbound SMS chunk before OpenClaw splits long replies."}}},{"pluginId":"synology-chat","channelId":"synology-chat","order":90,"channelEnvVars":["OPENCLAW_BOT_NAME","SYNOLOGY_ALLOWED_USER_IDS","SYNOLOGY_CHAT_INCOMING_URL","SYNOLOGY_CHAT_TOKEN","SYNOLOGY_NAS_HOST","SYNOLOGY_RATE_LIMIT"],"label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","channelEnvVars":["TELEGRAM_BOT_TOKEN"],"label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"str', + 'ing","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","prov', + 'ider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Telegram Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Telegram group chat IDs or chatId:topic:threadId topic IDs. Native Telegram bot mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Telegram Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Telegram Mention Pattern Allowlist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Telegram Mention Pattern Denylist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are disabled. Native bot mentions still trigger."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"richMessages":{"label":"Telegram Rich Messages","help":"Opt into Bot API 10.1 rich text sends and edits, including native tables and rich media. Default: false because some current Telegram clients render these messages as unsupported."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Telegram Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.commentary":{"label":"Telegram Progress Commentary","help":"Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API file_path values. Exact in-root paths are read directly; container paths under /var/lib/telegram-bot-api can map into a host volume mount. Other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","order":90,"label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false},"uiHints":{"implicitMentions":{"label":"Tlon Implicit Mentions","help":"Control which Tlon reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Tlon Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Tlon Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Tlon Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the chann', + 'el reports that signal."}}},{"pluginId":"twitch","channelId":"twitch","aliases":["twitch-chat"],"channelEnvVars":["OPENCLAW_TWITCH_ACCESS_TOKEN"],"label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"},"calls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","mediaMaxMb","debounceMs"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."},"actions.calls":{"label":"WhatsApp Voice Calls","help":"Expose the experimental requester-bound WhatsApp voice-call tool. Default: false. Requires a separately paired MeowCaller CLI."},"mentionPatterns":{"label":"WhatsApp Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected WhatsApp conversation IDs such as 123@g.us."},"mentionPatterns.mode":{"label":"WhatsApp Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"WhatsApp Mention Pattern Allowlist","help":"WhatsApp conversation IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"WhatsApp Mention Pattern Denylist","help":"WhatsApp conversation IDs where configured regex mention patterns are disabled."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","aliases":["zl"],"order":80,"channelEnvVars":["ZALO_BOT_TOKEN","ZALO_WEBHOOK_SECRET"],"label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"t', + 'ype":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","aliases":["zlu"],"order":85,"channelEnvVars":["ZALOUSER_PROFILE","ZCA_PROFILE"],"label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', ].join(""); export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = JSON.parse( diff --git a/src/config/channel-configured.test.ts b/src/config/channel-configured.test.ts index 2f432669f9e5..02f13867b838 100644 --- a/src/config/channel-configured.test.ts +++ b/src/config/channel-configured.test.ts @@ -50,7 +50,7 @@ describe("isChannelConfigured", () => { { channels: { signal: { - httpPort: 8080, + httpUrl: "http://127.0.0.1:8080", }, }, }, diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 2de40ad6fd4d..e726732eb70e 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -433,45 +433,6 @@ describe("models.pricing", () => { }); }); -describe("systemAgent.rescue", () => { - it("accepts documented rescue config", () => { - const result = OpenClawSchema.safeParse({ - systemAgent: { - rescue: { - enabled: "auto", - ownerDmOnly: false, - pendingTtlMinutes: 5, - }, - }, - }); - expect(result.success).toBe(true); - }); - - it("accepts boolean rescue enablement", () => { - const result = OpenClawSchema.safeParse({ - systemAgent: { - rescue: { - enabled: true, - ownerDmOnly: true, - }, - }, - }); - expect(result.success).toBe(true); - }); - - it("rejects unknown rescue keys", () => { - const result = OpenClawSchema.safeParse({ - systemAgent: { - rescue: { - enabled: true, - shell: true, - }, - }, - }); - expect(result.success).toBe(false); - }); -}); - describe("diagnostics.otel.captureContent", () => { it("accepts supported OTEL log exporters and rejects unknown values", () => { for (const logsExporter of ["otlp", "stdout", "both"]) { @@ -498,19 +459,7 @@ describe("diagnostics.otel.captureContent", () => { }); it("accepts boolean and granular OTEL content capture config", () => { - for (const captureContent of [ - true, - false, - { - enabled: true, - inputMessages: true, - outputMessages: true, - toolInputs: true, - toolOutputs: true, - systemPrompt: false, - toolDefinitions: true, - }, - ]) { + for (const captureContent of [true, false]) { const result = OpenClawSchema.safeParse({ diagnostics: { otel: { @@ -618,27 +567,27 @@ describe("gateway.controlUi.sessionObserver", () => { }); }); -describe("gateway.controlUi.chatMessageMaxWidth", () => { +describe("ui.prefs.chatMessageMaxWidth", () => { it("accepts constrained CSS width values", () => { for (const value of ["960px", "82%", "min(1280px, 82%)", "calc(100% - 2rem)"]) { const result = OpenClawSchema.safeParse({ - gateway: { - controlUi: { + ui: { + prefs: { chatMessageMaxWidth: value, }, }, }); expect(result.success).toBe(true); if (result.success) { - expect(result.data.gateway?.controlUi?.chatMessageMaxWidth).toBe(value); + expect(result.data.ui?.prefs?.chatMessageMaxWidth).toBe(value); } } }); it("normalizes whitespace around the width value", () => { const result = OpenClawSchema.safeParse({ - gateway: { - controlUi: { + ui: { + prefs: { chatMessageMaxWidth: " min(1280px, 82%) ", }, }, @@ -646,15 +595,15 @@ describe("gateway.controlUi.chatMessageMaxWidth", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.data.gateway?.controlUi?.chatMessageMaxWidth).toBe("min(1280px, 82%)"); + expect(result.data.ui?.prefs?.chatMessageMaxWidth).toBe("min(1280px, 82%)"); } }); it("rejects arbitrary CSS injection", () => { for (const value of ["url(https://example.com/x)", "960px; color: red", "var(--x)"]) { const result = OpenClawSchema.safeParse({ - gateway: { - controlUi: { + ui: { + prefs: { chatMessageMaxWidth: value, }, }, @@ -976,9 +925,8 @@ describe("config identity/materialization regressions", () => { it("keeps explicit responsePrefix and group mention patterns", () => { const res = validateConfigObject({ agents: { - list: [ - { - id: "main", + entries: { + main: { identity: { name: "Samantha Sloth", theme: "space lobster", @@ -986,16 +934,16 @@ describe("config identity/materialization regressions", () => { }, groupChat: { mentionPatterns: ["@openclaw"] }, }, - ], + }, }, - messages: { - responsePrefix: "✅", + channels: { + whatsapp: { responsePrefix: "✅" }, }, }); expect(res.ok).toBe(true); if (res.ok) { - expect(res.config.messages?.responsePrefix).toBe("✅"); + expect(res.config.channels?.whatsapp?.responsePrefix).toBe("✅"); expect(res.config.agents?.list?.[0]?.groupChat?.mentionPatterns).toEqual(["@openclaw"]); } }); @@ -1003,25 +951,24 @@ describe("config identity/materialization regressions", () => { it("preserves empty responsePrefix when identity is present", () => { const res = validateConfigObject({ agents: { - list: [ - { - id: "main", + entries: { + main: { identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥", }, }, - ], + }, }, - messages: { - responsePrefix: "", + channels: { + whatsapp: { responsePrefix: "" }, }, }); expect(res.ok).toBe(true); if (res.ok) { - expect(res.config.messages?.responsePrefix).toBe(""); + expect(res.config.channels?.whatsapp?.responsePrefix).toBe(""); } }); @@ -1085,7 +1032,7 @@ describe("broadcast", () => { it("accepts a broadcast peer map with strategy", () => { const res = validateConfigObject({ agents: { - list: [{ id: "alfred" }, { id: "baerbel" }], + entries: { alfred: {}, baerbel: {} }, }, broadcast: { strategy: "parallel", @@ -1183,9 +1130,8 @@ describe("config strict validation", () => { it("accepts documented agents.list[].params overrides", () => { const res = validateConfigObject({ agents: { - list: [ - { - id: "main", + entries: { + main: { model: "anthropic/claude-opus-4-6", params: { cacheRetention: "none", @@ -1193,7 +1139,7 @@ describe("config strict validation", () => { maxTokens: 8192, }, }, - ], + }, }, }); @@ -1227,7 +1173,7 @@ describe("config strict validation", () => { fallback: "none", query: { maxResults: 7 }, }); - expect(snap.sourceConfig.agents?.defaults?.memorySearch).toBeUndefined(); + expect(snap.sourceConfig.memory?.search).toBeUndefined(); }); }); @@ -1277,26 +1223,24 @@ describe("config strict validation", () => { }); }); - it("reports legacy messages.tts provider keys without read-time auto-migration", () => { + it("reports legacy tts provider keys without read-time auto-migration", () => { const raw = { - messages: { - tts: { - provider: "elevenlabs", - elevenlabs: { - apiKey: "test-key", - voiceId: "voice-1", - }, + tts: { + provider: "elevenlabs", + elevenlabs: { + apiKey: "test-key", + voiceId: "voice-1", }, }, }; const issues = findLegacyConfigIssues(raw); - expect(issuePaths(issues)).toContain("messages.tts"); - expect(raw.messages.tts.elevenlabs).toEqual({ + expect(issuePaths(issues)).toContain("tts"); + expect(raw.tts.elevenlabs).toEqual({ apiKey: "test-key", voiceId: "voice-1", }); - expect(raw.messages.tts).not.toHaveProperty("providers"); + expect(raw.tts).not.toHaveProperty("providers"); }); it("reports retired plugin model refs without an agents section", () => { @@ -1361,7 +1305,7 @@ describe("config strict validation", () => { expect(snap.valid).toBe(false); expect(issuePaths(snap.issues)).toContain("agents.defaults.sandbox"); - expect(issuePaths(snap.issues)).toContain("agents.list.0.sandbox"); + expect(issuePaths(snap.issues)).toContain("agents"); expect(issuePaths(snap.legacyIssues)).toContain("agents.defaults.sandbox"); expect(issuePaths(snap.legacyIssues)).toContain("agents.list"); expect(snap.sourceConfig.agents?.defaults?.sandbox).toEqual({ perSession: true }); diff --git a/src/config/config.acp-binding-cutover.test.ts b/src/config/config.acp-binding-cutover.test.ts index 10129430f156..c0331447de27 100644 --- a/src/config/config.acp-binding-cutover.test.ts +++ b/src/config/config.acp-binding-cutover.test.ts @@ -6,10 +6,9 @@ describe("ACP binding cutover schema", () => { it("accepts top-level typed ACP bindings with per-agent runtime defaults", () => { const parsed = OpenClawSchema.safeParse({ agents: { - list: [ - { id: "main", default: true, runtime: { type: "embedded" } }, - { - id: "coding", + entries: { + main: { default: true, runtime: { type: "embedded" } }, + coding: { runtime: { type: "acp", acp: { @@ -20,7 +19,7 @@ describe("ACP binding cutover schema", () => { }, }, }, - ], + }, }, bindings: [ { diff --git a/src/config/config.compaction-settings.test.ts b/src/config/config.compaction-settings.test.ts index 6e422c3a0ebd..efb071b5e1b8 100644 --- a/src/config/config.compaction-settings.test.ts +++ b/src/config/config.compaction-settings.test.ts @@ -20,8 +20,7 @@ describe("config compaction settings", () => { it("preserves memory flush config values", () => { const compaction = materializeCompactionConfig({ mode: "safeguard", - identifierPolicy: "custom", - identifierInstructions: "Keep ticket IDs unchanged.", + identifierPolicy: "strict", qualityGuard: { enabled: true, maxRetries: 2, @@ -33,24 +32,19 @@ describe("config compaction settings", () => { enabled: false, model: "ollama/qwen3:8b", softThresholdTokens: 1234, - prompt: "Write notes.", - systemPrompt: "Flush memory now.", }, maxActiveTranscriptBytes: "20mb", }); expect(compaction?.mode).toBe("safeguard"); expect(compaction?.keepRecentTokens).toBeUndefined(); - expect(compaction?.identifierPolicy).toBe("custom"); - expect(compaction?.identifierInstructions).toBe("Keep ticket IDs unchanged."); + expect(compaction?.identifierPolicy).toBe("strict"); expect(compaction?.qualityGuard?.enabled).toBe(true); expect(compaction?.qualityGuard?.maxRetries).toBe(2); expect(compaction?.midTurnPrecheck?.enabled).toBe(true); expect(compaction?.memoryFlush?.enabled).toBe(false); expect(compaction?.memoryFlush?.model).toBe("ollama/qwen3:8b"); expect(compaction?.memoryFlush?.softThresholdTokens).toBe(1234); - expect(compaction?.memoryFlush?.prompt).toBe("Write notes."); - expect(compaction?.memoryFlush?.systemPrompt).toBe("Flush memory now."); expect(compaction?.maxActiveTranscriptBytes).toBe("20mb"); }); diff --git a/src/config/config.hooks-module-paths.test.ts b/src/config/config.hooks-module-paths.test.ts index da4ca5788037..73b989874211 100644 --- a/src/config/config.hooks-module-paths.test.ts +++ b/src/config/config.hooks-module-paths.test.ts @@ -15,7 +15,7 @@ describe("config hooks module paths", () => { it("rejects absolute hooks.mappings[].transform.module", () => { expectRejectedIssuePath( { - agents: { list: [{ id: "openclaw" }] }, + agents: { entries: { openclaw: {} } }, hooks: { mappings: [ { @@ -33,7 +33,7 @@ describe("config hooks module paths", () => { it("rejects escaping hooks.mappings[].transform.module", () => { expectRejectedIssuePath( { - agents: { list: [{ id: "openclaw" }] }, + agents: { entries: { openclaw: {} } }, hooks: { mappings: [ { @@ -51,7 +51,7 @@ describe("config hooks module paths", () => { it("rejects absolute hooks.internal.handlers[].module", () => { expectRejectedIssuePath( { - agents: { list: [{ id: "openclaw" }] }, + agents: { entries: { openclaw: {} } }, hooks: { internal: { enabled: true, @@ -66,7 +66,7 @@ describe("config hooks module paths", () => { it("rejects escaping hooks.internal.handlers[].module", () => { expectRejectedIssuePath( { - agents: { list: [{ id: "openclaw" }] }, + agents: { entries: { openclaw: {} } }, hooks: { internal: { enabled: true, @@ -80,7 +80,7 @@ describe("config hooks module paths", () => { it("accepts hooks.mappings[].channel runtime plugin ids", () => { const res = validateConfigObjectWithPlugins({ - agents: { list: [{ id: "openclaw" }] }, + agents: { entries: { openclaw: {} } }, hooks: { mappings: [ { @@ -98,7 +98,7 @@ describe("config hooks module paths", () => { it("rejects blank hooks.mappings[].channel values", () => { expectRejectedIssuePath( { - agents: { list: [{ id: "openclaw" }] }, + agents: { entries: { openclaw: {} } }, hooks: { mappings: [ { diff --git a/src/config/config.identity-avatar.test.ts b/src/config/config.identity-avatar.test.ts index 1004e3c8b3e6..a0c2b5e9b436 100644 --- a/src/config/config.identity-avatar.test.ts +++ b/src/config/config.identity-avatar.test.ts @@ -10,7 +10,7 @@ describe("identity avatar validation", () => { const workspace = path.join(home, "openclaw"); const res = validateConfigObject({ agents: { - list: [{ id: "main", workspace, identity: { avatar: "avatars/openclaw.png" } }], + entries: { main: { workspace, identity: { avatar: "avatars/openclaw.png" } } }, }, }); expect(res.ok).toBe(true); @@ -22,14 +22,14 @@ describe("identity avatar validation", () => { const workspace = path.join(home, "openclaw"); const httpRes = validateConfigObject({ agents: { - list: [{ id: "main", workspace, identity: { avatar: "https://example.com/avatar.png" } }], + entries: { main: { workspace, identity: { avatar: "https://example.com/avatar.png" } } }, }, }); expect(httpRes.ok).toBe(true); const dataRes = validateConfigObject({ agents: { - list: [{ id: "main", workspace, identity: { avatar: "data:image/png;base64,AAA" } }], + entries: { main: { workspace, identity: { avatar: "data:image/png;base64,AAA" } } }, }, }); expect(dataRes.ok).toBe(true); @@ -41,7 +41,7 @@ describe("identity avatar validation", () => { const workspace = path.join(home, "openclaw"); const res = validateConfigObject({ agents: { - list: [{ id: "main", workspace, identity: { avatar: "../oops.png" } }], + entries: { main: { workspace, identity: { avatar: "../oops.png" } } }, }, }); expect(res.ok).toBe(false); diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts index 80d76502d26b..4de1a9a3a132 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts @@ -36,9 +36,8 @@ describe("legacy config detection", () => { }, }, agents: { - list: [ - { - id: "work", + entries: { + work: { workspace: "~/openclaw-work", tools: { elevated: { @@ -47,12 +46,12 @@ describe("legacy config detection", () => { }, }, }, - ], + }, }, }); expect(res.ok).toBe(true); if (res.ok) { - expect(res.config?.agents?.list?.[0]?.tools?.elevated).toEqual({ + expect(res.config?.agents?.entries?.work?.tools?.elevated).toEqual({ enabled: false, allowFrom: { whatsapp: ["+15555550123"] }, }); diff --git a/src/config/config.meta-timestamp-coercion.test.ts b/src/config/config.meta-timestamp-coercion.test.ts index 9f18f1299a4f..820704b5ec4e 100644 --- a/src/config/config.meta-timestamp-coercion.test.ts +++ b/src/config/config.meta-timestamp-coercion.test.ts @@ -3,52 +3,13 @@ import { describe, expect, it } from "vitest"; import { validateConfigObject } from "./validation.js"; describe("config metadata", () => { - it("accepts a numeric Unix timestamp and coerces it to an ISO string", () => { - const numericTimestamp = 1770394758161; - const res = validateConfigObject({ - meta: { - lastTouchedAt: numericTimestamp, - }, - }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(typeof res.config.meta?.lastTouchedAt).toBe("string"); - expect(res.config.meta?.lastTouchedAt).toBe(new Date(numericTimestamp).toISOString()); - } - }); - - it("still accepts a string ISO timestamp unchanged", () => { - const isoTimestamp = "2026-02-07T01:39:18.161Z"; - const res = validateConfigObject({ - meta: { - lastTouchedAt: isoTimestamp, - }, - }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.meta?.lastTouchedAt).toBe(isoTimestamp); - } - }); - - it("rejects out-of-range numeric timestamps without throwing", () => { - const res = validateConfigObject({ - meta: { - lastTouchedAt: 1e20, - }, - }); - expect(res.ok).toBe(false); - }); - - it("passes non-date strings through unchanged (backwards-compatible)", () => { + it("rejects retired lastTouchedAt config metadata", () => { const res = validateConfigObject({ meta: { lastTouchedAt: "not-a-date", }, }); - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.config.meta?.lastTouchedAt).toBe("not-a-date"); - } + expect(res.ok).toBe(false); }); it("accepts meta with only lastTouchedVersion (no lastTouchedAt)", () => { diff --git a/src/config/config.multi-agent-agentdir-validation.test.ts b/src/config/config.multi-agent-agentdir-validation.test.ts index 29fe218b4180..124f8dc48aa3 100644 --- a/src/config/config.multi-agent-agentdir-validation.test.ts +++ b/src/config/config.multi-agent-agentdir-validation.test.ts @@ -7,28 +7,25 @@ import { withTempHomeConfig } from "./test-helpers.js"; import { validateConfigObject } from "./validation.js"; describe("multi-agent agentDir validation", () => { - it("rejects shared agents.list agentDir", () => { + it("rejects shared agents.entries agentDir", () => { const shared = path.join(tmpdir(), "openclaw-shared-agentdir"); const res = validateConfigObject({ agents: { - list: [ - { id: "a", agentDir: shared }, - { id: "b", agentDir: shared }, - ], + entries: { a: { agentDir: shared }, b: { agentDir: shared } }, }, }); expect(res.ok).toBe(false); if (!res.ok) { expect(res.issues).toEqual([ { - path: "agents.list", + path: "agents.entries", message: `Duplicate agentDir detected (multi-agent config). Each agent must have a unique agentDir; sharing it causes auth/session state collisions and token invalidation. Conflicts: - ${shared}: "a", "b" -Fix: remove the shared agents.list[].agentDir override (or give each agent its own directory). +Fix: remove the shared agents.entries.*.agentDir override (or give each agent its own directory). If you want to share credentials, copy auth-profiles.json instead of sharing the entire agentDir.`, }, ]); @@ -39,10 +36,10 @@ If you want to share credentials, copy auth-profiles.json instead of sharing the await withTempHomeConfig( { agents: { - list: [ - { id: "a", agentDir: "~/.openclaw/agents/shared/agent" }, - { id: "b", agentDir: "~/.openclaw/agents/shared/agent" }, - ], + entries: { + a: { agentDir: "~/.openclaw/agents/shared/agent" }, + b: { agentDir: "~/.openclaw/agents/shared/agent" }, + }, }, bindings: [{ agentId: "a", match: { channel: "forum" } }], }, diff --git a/src/config/config.node-skills.test.ts b/src/config/config.node-skills.test.ts index 2eda9fdc50dc..2aea7c929139 100644 --- a/src/config/config.node-skills.test.ts +++ b/src/config/config.node-skills.test.ts @@ -7,14 +7,14 @@ describe("node skills config", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.config.gateway?.nodes?.skills?.enabled).toBeUndefined(); + expect(result.config.gateway?.nodes?.allowSkills).toBeUndefined(); expect(result.config.nodeHost?.skills?.enabled).toBeUndefined(); } }); it.each([true, false])("accepts enabled=%s on both sides", (enabled) => { const result = validateConfigObject({ - gateway: { nodes: { skills: { enabled } } }, + gateway: { nodes: { allowSkills: enabled } }, nodeHost: { skills: { enabled } }, }); @@ -23,15 +23,13 @@ describe("node skills config", () => { it("rejects non-boolean enabled values", () => { const result = validateConfigObject({ - gateway: { nodes: { skills: { enabled: "yes" } } }, + gateway: { nodes: { allowSkills: "yes" } }, nodeHost: { skills: { enabled: "yes" } }, }); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.issues.some((issue) => issue.path === "gateway.nodes.skills.enabled")).toBe( - true, - ); + expect(result.issues.some((issue) => issue.path === "gateway.nodes.allowSkills")).toBe(true); expect(result.issues.some((issue) => issue.path === "nodeHost.skills.enabled")).toBe(true); } }); diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 94a0c23d8e47..075efef3b809 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { clearLoadInstalledPluginIndexInstallRecordsCache } from "../plugins/installed-plugin-index-records.js"; import { writePersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js"; -import { validateConfigObjectWithPlugins } from "./validation.js"; +import { validateConfigObjectWithPlugins as validateConfigObjectWithPluginsRaw } from "./validation.js"; vi.unmock("../version.js"); @@ -139,8 +139,42 @@ describe("config plugin validation", () => { VITEST: "true", }) satisfies NodeJS.ProcessEnv; - const validateInSuite = (raw: unknown) => - validateConfigObjectWithPlugins(raw, { env: suiteEnv() }); + const withCanonicalAgentEntries = (raw: unknown): unknown => { + const next = structuredClone(raw); + if (!next || typeof next !== "object" || Array.isArray(next)) { + return next; + } + const agents = (next as { agents?: unknown }).agents; + if (!agents || typeof agents !== "object" || Array.isArray(agents)) { + return next; + } + const mutableAgents = agents as { entries?: unknown; list?: unknown }; + if (!Array.isArray(mutableAgents.list)) { + return next; + } + mutableAgents.entries = Object.fromEntries( + mutableAgents.list.flatMap((value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return []; + } + const { id, ...entry } = value as Record; + return typeof id === "string" && id.trim() ? [[id, entry]] : []; + }), + ); + delete mutableAgents.list; + return next; + }; + + const validateConfigObjectWithPlugins = ( + raw: unknown, + options: Parameters[1] = {}, + ) => + validateConfigObjectWithPluginsRaw(withCanonicalAgentEntries(raw), { + ...options, + env: options.env ?? suiteEnv(), + }); + + const validateInSuite = (raw: unknown) => validateConfigObjectWithPlugins(raw); const validateRemovedPluginConfig = (removedId: string) => validateInSuite({ diff --git a/src/config/config.sandbox-docker.test.ts b/src/config/config.sandbox-docker.test.ts index 6db741cc54d6..09ed5380ad74 100644 --- a/src/config/config.sandbox-docker.test.ts +++ b/src/config/config.sandbox-docker.test.ts @@ -38,9 +38,8 @@ describe("sandbox docker config", () => { }, }, }, - list: [ - { - id: "main", + entries: { + main: { sandbox: { docker: { image: "custom-sandbox:latest", @@ -48,7 +47,7 @@ describe("sandbox docker config", () => { }, }, }, - ], + }, }, }); expect(res.ok).toBe(true); @@ -57,7 +56,7 @@ describe("sandbox docker config", () => { "/home/user/source:/source:rw", "/var/data/myapp:/data:ro", ]); - expect(res.config.agents?.list?.[0]?.sandbox?.docker?.binds).toEqual([ + expect(res.config.agents?.entries?.main?.sandbox?.docker?.binds).toEqual([ "/home/user/projects:/projects:ro", ]); } diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index a8b5637818aa..880b450e4ade 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -5,13 +5,15 @@ import { validateConfigObject } from "./validation.js"; describe("config schema regressions", () => { it('accepts memorySearch fallback "voyage"', () => { const res = validateConfigObject({ - agents: { - defaults: { - memorySearch: { - fallback: "voyage", - }, + memory: { + search: { + fallback: "voyage", }, }, + + agents: { + defaults: {}, + }, }); expect(res.ok).toBe(true); @@ -19,13 +21,15 @@ describe("config schema regressions", () => { it('accepts memorySearch provider "mistral"', () => { const res = validateConfigObject({ - agents: { - defaults: { - memorySearch: { - provider: "mistral", - }, + memory: { + search: { + provider: "mistral", }, }, + + agents: { + defaults: {}, + }, }); expect(res.ok).toBe(true); @@ -33,13 +37,15 @@ describe("config schema regressions", () => { it('accepts memorySearch provider "bedrock"', () => { const res = validateConfigObject({ - agents: { - defaults: { - memorySearch: { - provider: "bedrock", - }, + memory: { + search: { + provider: "bedrock", }, }, + + agents: { + defaults: {}, + }, }); expect(res.ok).toBe(true); @@ -47,16 +53,18 @@ describe("config schema regressions", () => { it("rejects local memorySearch GPU policy", () => { const res = validateConfigObject({ - agents: { - defaults: { - memorySearch: { - provider: "local", - local: { - gpu: "cpu", - }, + memory: { + search: { + provider: "local", + local: { + gpu: "cpu", }, }, }, + + agents: { + defaults: {}, + }, }); expect(res.ok).toBe(false); @@ -64,37 +72,40 @@ describe("config schema regressions", () => { it("accepts memorySearch.qmd.extraCollections", () => { const res = validateConfigObject({ - agents: { - defaults: { - memorySearch: { - qmd: { - extraCollections: [ - { path: "/shared/team-notes", name: "team-notes", pattern: "**/*.md" }, - ], - }, + memory: { + search: { + qmd: { + extraCollections: [ + { path: "/shared/team-notes", name: "team-notes", pattern: "**/*.md" }, + ], }, }, }, + + agents: { + defaults: {}, + }, }); expect(res.ok).toBe(true); }); - it("accepts agents.list[].memorySearch.qmd.extraCollections", () => { + it("accepts agents.entries.*.memory.search.qmd.extraCollections", () => { const res = validateConfigObject({ agents: { - list: [ - { - id: "main", - memorySearch: { - qmd: { - extraCollections: [ - { path: "/shared/team-notes", name: "team-notes", pattern: "**/*.md" }, - ], + entries: { + main: { + memory: { + search: { + qmd: { + extraCollections: [ + { path: "/shared/team-notes", name: "team-notes", pattern: "**/*.md" }, + ], + }, }, }, }, - ], + }, }, }); @@ -149,7 +160,7 @@ describe("config schema regressions", () => { expect(res.ok).toBe(true); }); - it("accepts agents.defaults and agents.list contextLimits overrides", () => { + it("accepts agents.defaults and agents.entries contextLimits overrides", () => { const res = validateConfigObject({ agents: { defaults: { @@ -160,9 +171,8 @@ describe("config schema regressions", () => { postCompactionMaxChars: 4_000, }, }, - list: [ - { - id: "writer", + entries: { + writer: { skillsLimits: { maxSkillsPromptChars: 30_000, }, @@ -170,24 +180,23 @@ describe("config schema regressions", () => { memoryGetMaxChars: 24_000, }, }, - ], + }, }, }); expect(res.ok).toBe(true); }); - it("accepts agents.list experimental localModelLean overrides", () => { + it("accepts agents.entries experimental localModelLean overrides", () => { const res = validateConfigObject({ agents: { - list: [ - { - id: "gemma", + entries: { + gemma: { experimental: { localModelLean: true, }, }, - ], + }, }, }); @@ -288,7 +297,7 @@ describe("config schema regressions", () => { primary: "anthropic/claude-opus-4-6", fallbacks: ["openai/gpt-5.4-mini"], }, - pdfMaxBytesMb: 12, + pdfMaxMb: 12, pdfMaxPages: 25, }, }, @@ -302,7 +311,7 @@ describe("config schema regressions", () => { agents: { defaults: { pdfModel: { primary: "openai/gpt-5.4-mini" }, - pdfMaxBytesMb: 0, + pdfMaxMb: 0, pdfMaxPages: 0, }, }, @@ -311,7 +320,7 @@ describe("config schema regressions", () => { expect(res.ok).toBe(false); if (!res.ok) { const issuePaths = res.issues.map((issue) => issue.path); - expect(issuePaths).toContain("agents.defaults.pdfMaxBytesMb"); + expect(issuePaths).toContain("agents.defaults.pdfMaxMb"); expect(issuePaths).toContain("agents.defaults.pdfMaxPages"); } }); @@ -352,7 +361,6 @@ describe("config schema regressions", () => { const res = validateConfigObject({ discovery: { wideArea: { - enabled: true, domain: "openclaw.internal", }, }, @@ -361,10 +369,10 @@ describe("config schema regressions", () => { expect(res.ok).toBe(true); }); - it("rejects bindings referencing an agentId missing from agents.list (openclaw#84692)", () => { + it("rejects bindings referencing an agentId missing from agents.entries (openclaw#84692)", () => { const res = validateConfigObject({ agents: { - list: [{ id: "alpha", model: "anthropic/claude-3-5-sonnet" }], + entries: { alpha: { model: "anthropic/claude-3-5-sonnet" } }, }, bindings: [ { @@ -381,10 +389,10 @@ describe("config schema regressions", () => { } }); - it("accepts bindings whose agentId is present in agents.list", () => { + it("accepts bindings whose agentId is present in agents.entries", () => { const res = validateConfigObject({ agents: { - list: [{ id: "alpha", model: "anthropic/claude-3-5-sonnet" }], + entries: { alpha: { model: "anthropic/claude-3-5-sonnet" } }, }, bindings: [ { @@ -398,24 +406,17 @@ describe("config schema regressions", () => { expect(res.ok).toBe(true); }); - it("accepts bindings that match normalized agents.list ids", () => { + it("rejects non-addressable agents.entries keys", () => { const res = validateConfigObject({ agents: { - list: [{ id: "Team Ops", model: "anthropic/claude-3-5-sonnet" }], + entries: { "Team Ops": { model: "anthropic/claude-3-5-sonnet" } }, }, - bindings: [ - { - type: "route", - agentId: "team-ops", - match: { channel: "discord", peer: { kind: "direct", id: "user-1" } }, - }, - ], }); - expect(res.ok).toBe(true); + expect(res.ok).toBe(false); }); - it("skips binding agentId check when agents.list is empty (legacy passthrough)", () => { + it("skips binding agentId check when agents.entries is absent", () => { const res = validateConfigObject({ bindings: [ { diff --git a/src/config/config.secrets-schema.test.ts b/src/config/config.secrets-schema.test.ts index b36dff3ffedf..ffe08f9904b3 100644 --- a/src/config/config.secrets-schema.test.ts +++ b/src/config/config.secrets-schema.test.ts @@ -31,13 +31,11 @@ describe("config secret refs schema", () => { path: "~/.openclaw/secrets.json", mode: "json", timeoutMs: 10_000, - allowInsecurePath: true, }, vault: { source: "exec", command: "/usr/local/bin/openclaw-secret-resolver", args: ["resolve"], - allowSymlinkCommand: true, }, }, }, @@ -113,8 +111,8 @@ describe("config secret refs schema", () => { passphrase: { source: "exec", provider: "vault", id: "media/audio/passphrase" }, }, }, - models: [{ provider: "openai", model: "gpt-4o-mini-transcribe" }], }, + models: [{ provider: "openai", model: "gpt-4o-mini-transcribe" }], }, }, }); diff --git a/src/config/config.skills-entries-config.test.ts b/src/config/config.skills-entries-config.test.ts index ab782fba0fce..0b1b2e2b5736 100644 --- a/src/config/config.skills-entries-config.test.ts +++ b/src/config/config.skills-entries-config.test.ts @@ -58,13 +58,13 @@ describe("skills entries config schema", () => { expect(res.success).toBe(true); }); - it("accepts agents.list[].skills as explicit replacements", () => { + it("accepts agents.entries.*.skills as explicit replacements", () => { const res = OpenClawSchema.safeParse({ agents: { defaults: { skills: ["github", "weather"], }, - list: [{ id: "writer", skills: ["docs-search"] }], + entries: { writer: { skills: ["docs-search"] } }, }, }); @@ -77,7 +77,7 @@ describe("skills entries config schema", () => { defaults: { skills: [], }, - list: [{ id: "writer", skills: [] }], + entries: { writer: { skills: [] } }, }, }); diff --git a/src/config/config.tools-alsoAllow.test.ts b/src/config/config.tools-alsoAllow.test.ts index 817088ab893a..c50caad7ba91 100644 --- a/src/config/config.tools-alsoAllow.test.ts +++ b/src/config/config.tools-alsoAllow.test.ts @@ -19,24 +19,23 @@ describe("config: tools.alsoAllow", () => { } }); - it("rejects agents.list[].tools.allow + alsoAllow together", () => { + it("rejects agents.entries.*.tools.allow + alsoAllow together", () => { const res = validateConfigObject({ agents: { - list: [ - { - id: "main", + entries: { + main: { tools: { allow: ["group:fs"], alsoAllow: ["lobster"], }, }, - ], + }, }, }); expect(res.ok).toBe(false); if (!res.ok) { - expect(res.issues.map((issue) => issue.path)).toContain("agents.list.0.tools"); + expect(res.issues.map((issue) => issue.path)).toContain("agents.entries.main.tools"); } }); @@ -54,9 +53,8 @@ describe("config: tools.alsoAllow", () => { it("allows per-agent message tool cross-context policy", () => { const res = validateConfigObject({ agents: { - list: [ - { - id: "sandbox", + entries: { + sandbox: { tools: { message: { crossContext: { @@ -66,7 +64,7 @@ describe("config: tools.alsoAllow", () => { }, }, }, - ], + }, }, }); @@ -76,9 +74,8 @@ describe("config: tools.alsoAllow", () => { it("allows per-agent message tool action allowlists", () => { const res = validateConfigObject({ agents: { - list: [ - { - id: "sandbox", + entries: { + sandbox: { tools: { message: { actions: { @@ -87,7 +84,7 @@ describe("config: tools.alsoAllow", () => { }, }, }, - ], + }, }, }); diff --git a/src/config/control-ui-css.ts b/src/config/control-ui-css.ts index deda79cec6f5..94212b2f7b7c 100644 --- a/src/config/control-ui-css.ts +++ b/src/config/control-ui-css.ts @@ -42,7 +42,7 @@ export function normalizeControlUiChatMessageMaxWidth(value: string): string { return value.trim().replace(/\s+/g, " "); } -/** Validates the constrained CSS width grammar accepted by `gateway.controlUi.chatMessageMaxWidth`. */ +/** Validates the constrained CSS width grammar accepted by `ui.prefs.chatMessageMaxWidth`. */ export function isValidControlUiChatMessageMaxWidth(value: string): boolean { const normalized = normalizeControlUiChatMessageMaxWidth(value); if (normalized.length === 0 || normalized.length > CSS_WIDTH_MAX_LENGTH) { diff --git a/src/config/dead-config-keys.test.ts b/src/config/dead-config-keys.test.ts index ac7fe9f65c5d..f4a294831f4f 100644 --- a/src/config/dead-config-keys.test.ts +++ b/src/config/dead-config-keys.test.ts @@ -25,15 +25,16 @@ function configWithPath(path: string): Record { (value, segment) => (segment === "0" ? [value] : { [segment]: value }), 1, ) as Record; - const agent = (config.agents as { list?: Array> } | undefined)?.list?.[0]; - if (agent) { - agent.id = "test"; - } return config; } describe("dead config keys", () => { it.each([ + "systemAgent", + "meta.lastTouchedAt", + "marketplaces", + "cli", + "commitments", "auth.cooldowns", "secrets.resolution", "browser.remoteCdpTimeoutMs", @@ -45,31 +46,80 @@ describe("dead config keys", () => { "browser.tabCleanup.idleMinutes", "browser.tabCleanup.maxTabsPerSession", "browser.tabCleanup.sweepMinutes", + "browser.color", + "browser.profiles.chrome.color", + "browser.ssrfPolicy.hostnameAllowlist", + "agents.defaults.pdfMaxBytesMb", + "agents.defaults.imageGenerationModel", + "agents.defaults.videoGenerationModel", + "agents.defaults.musicGenerationModel", + "agents.defaults.promptOverlays", + "agents.defaults.cliBackends.custom.sessionArg", + "agents.defaults.heartbeat.ackMaxChars", + "agents.defaults.heartbeat.includeReasoning", + "agents.defaults.heartbeat.includeSystemPromptSection", + "agents.defaults.heartbeat.skipWhenBusy", + "agents.defaults.heartbeat.suppressToolErrorWarnings", + "agents.entries.test.groupChat.visibleReplies", + "agents.defaults.envelopeTimestamp", + "agents.defaults.envelopeElapsed", + "agents.defaults.envelopeTimezone", + "agents.defaults.timeFormat", + "agents.defaults.bootstrapPromptTruncationWarning", + "agents.defaults.mediaGenerationAutoProviderFallback", + "tools.exec.timeoutSec", + "agents.entries.test.tools.exec.timeoutSec", + "agents.defaults.sandbox.browser.enableNoVnc", + "media", + "audit", + "attachments.preserveFilenames", + "diagnostics.cacheTrace.filePath", + "messages.removeAckAfterReply", + "messages.statusReactions.emojis", + "commands.ownerDisplay", + "commands.ownerDisplaySecret", + "gateway.nodes.skills", + "gateway.nodes.allowCommands", + "gateway.nodes.denyCommands", + "gateway.controlUi.chatMessageMaxWidth", + "cron.failureDestination", + "channels.defaults.heartbeat", "tools.loopDetection.historySize", "tools.loopDetection.warningThreshold", "tools.loopDetection.detectors", "tools.loopDetection.postCompactionGuard", + "tools.media.image.models", + "tools.media.audio.models", + "tools.media.video.models", + "agents.defaults.memorySearch", + "agents.entries.test.memorySearch", + "messages.tts", "agents.defaults.compaction.reserveTokens", "agents.defaults.compaction.reserveTokensFloor", "agents.defaults.compaction.maxHistoryShare", "agents.defaults.contextPruning.keepLastAssistants", "agents.defaults.contextPruning.softTrimRatio", "agents.defaults.contextPruning.softTrim", - "agents.defaults.memorySearch.chunking", - "agents.defaults.memorySearch.sync.watchDebounceMs", - "agents.defaults.memorySearch.sync.intervalMinutes", - "agents.defaults.memorySearch.query.hybrid.vectorWeight", - "agents.defaults.memorySearch.query.hybrid.textWeight", - "agents.defaults.memorySearch.query.hybrid.candidateMultiplier", - "agents.defaults.memorySearch.query.hybrid.mmr.lambda", - "agents.defaults.memorySearch.query.hybrid.temporalDecay.halfLifeDays", - "agents.defaults.memorySearch.cache.maxEntries", + "memory.search.chunking", + "memory.search.remote.nonBatchConcurrency", + "memory.search.remote.batch.wait", + "memory.search.remote.batch.concurrency", + "memory.search.remote.batch.pollIntervalMs", + "memory.search.remote.batch.timeoutMinutes", + "memory.search.local.contextSize", + "memory.search.local.modelCacheDir", + "memory.search.store.driver", + "memory.search.sync", + "memory.search.query.hybrid", + "memory.qmd.mcporter", + "memory.qmd.update", + "memory.search.cache.maxEntries", "agents.defaults.cliBackends.codex.reliability.outputLimits", "agents.defaults.cliBackends.codex.reliability.watchdog.fresh.noOutputTimeoutMs", "agents.defaults.cliBackends.codex.reliability.watchdog.resume.noOutputTimeoutMs", "agents.defaults.runRetries", - "agents.list.0.memorySearch.chunking", - "agents.list.0.runRetries", + "agents.entries.test.memory.search.chunking", + "agents.entries.test.runRetries", "gateway.handshakeTimeoutMs", "gateway.channelHealthCheckMinutes", "gateway.channelStaleEventThresholdMinutes", @@ -81,17 +131,17 @@ describe("dead config keys", () => { "gateway.http.endpoints.chatCompletions.maxTotalImageBytes", "gateway.http.endpoints.responses.maxBodyBytes", "session.typingIntervalSeconds", + "session.typingMode", "session.writeLock", "session.agentToAgent", "cron.maxConcurrentRuns", + "cron.store", "cron.triggers.minIntervalMs", "cron.retry", "diagnostics.stuckSessionWarnMs", "diagnostics.stuckSessionAbortMs", "diagnostics.memoryPressureSnapshot", - "web.heartbeatSeconds", - "web.reconnect", - "web.whatsapp", + "web", "messages.queue.debounceMs", "messages.statusReactions.timing", "acp.stream.coalesceIdleMs", @@ -126,6 +176,25 @@ describe("dead config keys", () => { "channels.discord.accounts.work.retry", "channels.clickclack.timeoutSeconds", "channels.clickclack.accounts.work.timeoutSeconds", + "channels.signal.httpHost", + "channels.signal.httpPort", + "channels.googlechat.serviceAccountRef", + "hooks.internal.installs", + "plugins.bundledDiscovery", + "tts.prefsPath", + "tts.personas.test.prompt", + "agents.defaults.compaction.customInstructions", + "agents.defaults.compaction.identifierInstructions", + "agents.defaults.compaction.memoryFlush.prompt", + "agents.defaults.compaction.memoryFlush.systemPrompt", + "logging.redactSensitive", + "commands.useAccessGroups", + "gateway.controlUi.allowInsecureAuth", + "gateway.controlUi.dangerouslyDisableDeviceAuth", + "discovery.wideArea.enabled", + "cloudWorkers.profiles.default.lifetime", + "mcp.servers.docs.workingDirectory", + "nodeHost.mcp.servers.docs.workingDirectory", ] as const)("rejects retired tuning knob %s", (fullPath) => { const segments = fullPath.split("."); const key = segments.pop() ?? ""; @@ -136,6 +205,46 @@ describe("dead config keys", () => { }); }); + it.each([ + [ + "file provider insecure-path bypass", + { + secrets: { + providers: { + legacy: { + source: "file", + path: "/tmp/openclaw-secret", + allowInsecurePath: true, + }, + }, + }, + }, + ], + [ + "exec provider symlink bypass", + { + secrets: { + providers: { + legacy: { + source: "exec", + command: "/bin/echo", + allowSymlinkCommand: true, + }, + }, + }, + }, + ], + ] as const)("rejects retired secret provider %s", (_name, config) => { + const result = validateConfigObjectRaw(config, { validateBundledChannels: true }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues).toContainEqual({ + path: "secrets.providers.legacy", + message: "Invalid input", + }); + } + }); + it.each([ ["Discord root", "discord", { dm: { policy: "pairing" } }, "channels.discord.dm", "policy"], [ @@ -199,6 +308,32 @@ describe("dead config keys", () => { ["root canvasHost", { canvasHost: { enabled: true } }, "", "canvasHost"], ["root tui", { tui: { footer: { showRemoteHost: true } } }, "", "tui"], ["root defaultModel", { defaultModel: "openai/gpt-5.6" }, "", "defaultModel"], + ["agents list", { agents: { list: [{ id: "main" }] } }, "agents", "list"], + ["Slack identity", { channels: { slack: { identity: "bot" } } }, "channels.slack", "identity"], + [ + "WhatsApp message prefix", + { channels: { whatsapp: { messagePrefix: "x" } } }, + "channels.whatsapp", + "messagePrefix", + ], + [ + "WhatsApp ack block", + { channels: { whatsapp: { ackReaction: { emoji: "x" } } } }, + "channels.whatsapp", + "ackReaction", + ], + [ + "Discord subagent progress", + { channels: { discord: { subagentProgress: true } } }, + "channels.discord", + "subagentProgress", + ], + [ + "iMessage coalesce", + { channels: { imessage: { coalesceSameSenderDms: true } } }, + "channels.imessage", + "coalesceSameSenderDms", + ], ["cron.webhook", { cron: { webhook: "https://example.com" } }, "cron", "webhook"], ["commands.modelsWrite", { commands: { modelsWrite: true } }, "commands", "modelsWrite"], ["messages.messagePrefix", { messages: { messagePrefix: "x" } }, "messages", "messagePrefix"], diff --git a/src/config/defaults.test.ts b/src/config/defaults.test.ts index 92c341541981..99247e7f2789 100644 --- a/src/config/defaults.test.ts +++ b/src/config/defaults.test.ts @@ -115,7 +115,7 @@ describe("config defaults", () => { } as never); expect(next.messages?.ackReactionScope).toBe("group-mentions"); - expect(next.messages?.responsePrefix).toBeUndefined(); + expect(next.messages).not.toHaveProperty("responsePrefix"); expect(next.messages?.groupChat?.mentionPatterns).toBeUndefined(); }); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 976ffccdb217..3bcaa44be68e 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -489,20 +489,7 @@ export function applyCronDefaults(cfg: OpenClawConfig): OpenClawConfig { } export function applyLoggingDefaults(cfg: OpenClawConfig): OpenClawConfig { - const logging = cfg.logging; - if (!logging) { - return cfg; - } - if (logging.redactSensitive) { - return cfg; - } - return { - ...cfg, - logging: { - ...logging, - redactSensitive: "tools", - }, - }; + return cfg; } function hasAnthropicDefaultSignal(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { diff --git a/src/config/future-version-guard.ts b/src/config/future-version-guard.ts index 3c565ca407e2..883322185f6e 100644 --- a/src/config/future-version-guard.ts +++ b/src/config/future-version-guard.ts @@ -19,7 +19,7 @@ export type FutureConfigActionBlock = { type FutureConfigGuardParams = { action: string; snapshot?: Pick | null; - config?: Pick | null; + config?: OpenClawConfig | null; currentVersion?: string; env?: Record; }; @@ -30,11 +30,21 @@ function allowOlderBinaryDestructiveActions(env: Record { + if (!value || typeof value !== "object") { + return undefined; + } + const meta = (value as { meta?: unknown }).meta; + if (!meta || typeof meta !== "object") { + return undefined; + } + const version = (meta as { lastTouchedVersion?: unknown }).lastTouchedVersion; + return typeof version === "string" ? version.trim() || undefined : undefined; + }; return ( - params.snapshot?.sourceConfig?.meta?.lastTouchedVersion?.trim() || - params.snapshot?.config?.meta?.lastTouchedVersion?.trim() || - params.config?.meta?.lastTouchedVersion?.trim() || + readSourceVersion(params.snapshot?.sourceConfig) ?? + readSourceVersion(params.snapshot?.config) ?? + readSourceVersion(params.config) ?? null ); } diff --git a/src/config/gateway-env-selection.ts b/src/config/gateway-env-selection.ts index 690794e95467..a1d848125b53 100644 --- a/src/config/gateway-env-selection.ts +++ b/src/config/gateway-env-selection.ts @@ -15,7 +15,6 @@ export const GATEWAY_CONFIG_SELECTION_ENV_KEYS: ReadonlySet = new Set([ "OPENCLAW_PACKAGE_DIR", "OPENCLAW_PROFILE", "OPENCLAW_STATE_DIR", - "OPENCLAW_TEST_FAST", "OPENCLAW_WORKSPACE_DIR", "PI_CODING_AGENT_DIR", "PREFIX", diff --git a/src/config/heartbeat-config-honor.inventory.test.ts b/src/config/heartbeat-config-honor.inventory.test.ts index d8831b45b5ef..8ebcafe6379d 100644 --- a/src/config/heartbeat-config-honor.inventory.test.ts +++ b/src/config/heartbeat-config-honor.inventory.test.ts @@ -13,9 +13,6 @@ const EXPECTED_HEARTBEAT_KEYS = [ "every", "model", "prompt", - "includeSystemPromptSection", - "ackMaxChars", - "suppressToolErrorWarnings", "timeoutSeconds", "lightContext", "isolatedSession", @@ -23,7 +20,6 @@ const EXPECTED_HEARTBEAT_KEYS = [ "to", "accountId", "directPolicy", - "includeReasoning", ] as const; describe("heartbeat config-honor inventory", () => { diff --git a/src/config/io.meta.test.ts b/src/config/io.meta.test.ts index 074f5dece513..e7e3afd6d897 100644 --- a/src/config/io.meta.test.ts +++ b/src/config/io.meta.test.ts @@ -5,17 +5,15 @@ import { computeModelPolicyAllowlist } from "./model-policy-allowlist-migration. describe("config write metadata stamping", () => { it("stamps every declared auto-managed meta path", () => { - const stamped = stampConfigWriteMetadata({}); + const stamped = stampConfigWriteMetadata({}, undefined, undefined, {}); expect(AUTO_MANAGED_CONFIG_META_PATHS).toEqual([ ["meta", "lastTouchedVersion"], - ["meta", "lastTouchedAt"], + ["meta", "migrations", "modelPolicyAllowlist"], ]); - for (const [parent, field] of AUTO_MANAGED_CONFIG_META_PATHS) { - expect(parent).toBe("meta"); - expect(typeof stamped.meta?.[field]).toBe("string"); - } + expect(typeof stamped.meta?.lastTouchedVersion).toBe("string"); + expect(stamped.meta?.migrations?.modelPolicyAllowlist).toBe(true); }); it("preserves a legacy model restriction before an unrelated write updates version metadata", () => { diff --git a/src/config/io.meta.ts b/src/config/io.meta.ts index 5f74742f1304..78e6d36dacc7 100644 --- a/src/config/io.meta.ts +++ b/src/config/io.meta.ts @@ -1,3 +1,4 @@ +import { writeConfigMachineState } from "../state/config-machine-state.js"; import { isRecord } from "../utils.js"; // Maintains config metadata fields written alongside user config. import { VERSION } from "../version.js"; @@ -16,7 +17,7 @@ const AUTO_MANAGED_CONFIG_META_FIELDS = { export const AUTO_MANAGED_CONFIG_META_PATHS = [ ["meta", AUTO_MANAGED_CONFIG_META_FIELDS.lastTouchedVersion], - ["meta", AUTO_MANAGED_CONFIG_META_FIELDS.lastTouchedAt], + ["meta", "migrations", "modelPolicyAllowlist"], ] as const; function defaultModelScope(value: unknown): Record | null { @@ -93,7 +94,7 @@ function stampModelPolicyAllowlistMigrationForWrite( export function stampConfigWriteMetadata( cfg: OpenClawConfig, - now: string = new Date().toISOString(), + _now: string = new Date().toISOString(), version: string = VERSION, previousConfig?: unknown, ): OpenClawConfig { @@ -106,7 +107,14 @@ export function stampConfigWriteMetadata( meta: { ...migrationStamped.meta, [AUTO_MANAGED_CONFIG_META_FIELDS.lastTouchedVersion]: version, - [AUTO_MANAGED_CONFIG_META_FIELDS.lastTouchedAt]: now, }, }; } + +/** Persist machine-owned metadata only after the matching config file commit succeeds. */ +export function recordConfigWriteMetadata( + now: string = new Date().toISOString(), + _version: string = VERSION, +): void { + writeConfigMachineState("config.lastTouchedAt", now); +} diff --git a/src/config/io.observe-recovery.test.ts b/src/config/io.observe-recovery.test.ts index b5c5b1103014..6156990c8432 100644 --- a/src/config/io.observe-recovery.test.ts +++ b/src/config/io.observe-recovery.test.ts @@ -35,7 +35,7 @@ describe("config observe recovery", () => { const clobberedUpdateChannelConfig = { update: { channel: "beta" } }; const clobberedUpdateChannelRaw = `${JSON.stringify(clobberedUpdateChannelConfig, null, 2)}\n`; const recoverableTelegramConfig = { - meta: { lastTouchedAt: "2026-04-22T00:00:00.000Z" }, + meta: { lastTouchedVersion: "2026.4.22" }, update: { channel: "beta" }, gateway: { mode: "local" }, channels: { telegram: { enabled: true, dmPolicy: "pairing", groupPolicy: "allowlist" } }, @@ -316,7 +316,7 @@ describe("config observe recovery", () => { await withSuiteHome(async (home) => { const { deps, configPath, auditPath, warn } = makeDeps(home); await seedConfigBackup(configPath, { - meta: { lastTouchedAt: "2026-04-22T00:00:00.000Z" }, + meta: { lastTouchedVersion: "2026.4.22" }, update: { channel: "beta" }, browser: { enabled: true }, gateway: { mode: "local", auth: { mode: "token", token: "secret-token" } }, @@ -361,7 +361,7 @@ describe("config observe recovery", () => { const { deps, configPath, auditPath } = makeDeps(home); await seedConfigBackup(configPath, recoverableTelegramConfig); const clobbered = await writeConfigRaw(configPath, { - meta: { lastTouchedAt: "2026-04-22T00:00:00.000Z" }, + meta: { lastTouchedVersion: "2026.4.22" }, update: { channel: "beta" }, channels: { telegram: { enabled: true, dmPolicy: "pairing", groupPolicy: "allowlist" } }, }); @@ -428,7 +428,7 @@ describe("config observe recovery", () => { }, }); const clobbered = await writeConfigRaw(configPath, { - meta: { lastTouchedAt: "2026-04-22T00:00:00.000Z" }, + meta: { lastTouchedVersion: "2026.4.22" }, gateway: { mode: "local" }, }); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 79a7af002467..39edace08b84 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -8,6 +8,7 @@ import { startGatewayConfigReloader } from "../gateway/config-reload.js"; import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { clearLoadPluginMetadataSnapshotMemo } from "../plugins/plugin-metadata-snapshot.js"; +import { readConfigMachineState } from "../state/config-machine-state.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; import { closeOpenClawStateDatabaseForTest, @@ -22,7 +23,6 @@ import { listConfigAuditRecordsForTests } from "./io.audit.test-support.js"; import { createConfigIO as createObservedConfigIO, getRuntimeConfigSourceSnapshot, - readConfigFileSnapshotForWrite, readConfigFileSnapshotForRuntimeTransaction, registerConfigWriteListener, resetConfigRuntimeState, @@ -151,8 +151,8 @@ describe("config io write", () => { ); } - const expectInputOwnerDisplayUnchanged = (input: Record) => { - expect((input.commands as Record).ownerDisplay).toBe("hash"); + const expectInputCommandRestartUnchanged = (input: Record) => { + expect((input.commands as Record).restart).toBe(false); }; const readPersistedCommands = async (configPath: string) => { @@ -333,7 +333,7 @@ describe("config io write", () => { expect(io.configPath).toBe(path.join(overrideDir, "openclaw.json")); await io.writeConfigFile({ - agents: { list: [{ id: "main", default: true }] }, + agents: { entries: { main: { default: true } } }, gateway: { mode: "local" }, session: { mainKey: "main", store: path.join(overrideDir, "sessions.json") }, }); @@ -363,17 +363,17 @@ describe("config io write", () => { const input: Record = { gateway: { mode: "local" }, - commands: { ownerDisplay: "hash" }, + commands: { restart: false }, }; - await io.writeConfigFile(input, { unsetPaths: [["commands", "ownerDisplay"]] }); + await io.writeConfigFile(input, { unsetPaths: [["commands", "restart"]] }); expect(input).toEqual({ gateway: { mode: "local" }, - commands: { ownerDisplay: "hash" }, + commands: { restart: false }, }); - expectInputOwnerDisplayUnchanged(input); - expect((await readPersistedCommands(configPath)) ?? {}).not.toHaveProperty("ownerDisplay"); + expectInputCommandRestartUnchanged(input); + expect((await readPersistedCommands(configPath)) ?? {}).not.toHaveProperty("restart"); }); }); @@ -386,7 +386,7 @@ describe("config io write", () => { `${JSON.stringify( { gateway: { mode: "local", port: 18789 }, - commands: { ownerDisplay: "hash" }, + commands: { restart: false }, }, null, 2, @@ -402,7 +402,7 @@ describe("config io write", () => { }); const nextConfig = Object.assign( - Object.create({ commands: { ownerDisplay: "raw" } }) as Record, + Object.create({ commands: { restart: true } }) as Record, { gateway: { mode: "local", port: 19001 } }, ); @@ -500,7 +500,7 @@ describe("config io write", () => { }); }); - it("prints missing-meta write anomalies when anomaly logging is requested", async () => { + it("prints missing-meta write anomalies when test anomaly logging is requested", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -512,7 +512,7 @@ describe("config io write", () => { const warn = vi.fn(); const io = createConfigIO({ env: { - OPENCLAW_CONFIG_WRITE_ANOMALY_LOG: "1", + OPENCLAW_TEST_CONFIG_WRITE_LOG: "1", } as NodeJS.ProcessEnv, homedir: () => home, logger: { @@ -545,7 +545,7 @@ describe("config io write", () => { const io = createConfigIO({ env: { VITEST: "true", - OPENCLAW_TEST_CONFIG_OVERWRITE_LOG: "1", + OPENCLAW_TEST_CONFIG_WRITE_LOG: "1", } as NodeJS.ProcessEnv, homedir: () => home, logger: { @@ -596,7 +596,7 @@ describe("config io write", () => { const configPath = path.join(home, ".openclaw", "openclaw.json"); const cleanConfig = { gateway: { mode: "local" }, - agents: { list: [{ id: "main", default: true }, { id: "discord-dm" }] }, + agents: { entries: { main: { default: true }, "discord-dm": {} } }, } satisfies ConfigFileSnapshot["config"]; const cleanRaw = `${JSON.stringify(cleanConfig, null, 2)}\n`; await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -616,7 +616,7 @@ describe("config io write", () => { expect(recoveredSnapshot.valid).toBe(true); expect(recoveredSnapshot.config.gateway?.mode).toBe("local"); - expect(recoveredSnapshot.config.agents?.list?.map((entry) => entry.id)).toEqual([ + expect(Object.keys(recoveredSnapshot.config.agents?.entries ?? {})).toEqual([ "main", "discord-dm", ]); @@ -640,7 +640,7 @@ describe("config io write", () => { const configPath = path.join(home, ".openclaw", "openclaw.json"); const cleanConfig = { gateway: { mode: "local" }, - agents: { list: [{ id: "main", default: true }, { id: "discord-dm" }] }, + agents: { entries: { main: { default: true }, "discord-dm": {} } }, } satisfies ConfigFileSnapshot["config"]; const cleanRaw = `${JSON.stringify(cleanConfig, null, 2)}\n`; await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -683,7 +683,7 @@ describe("config io write", () => { const configPath = path.join(home, ".openclaw", "openclaw.json"); const cleanConfig = { gateway: { mode: "local" }, - agents: { list: [{ id: "main", default: true }] }, + agents: { entries: { main: { default: true } } }, } satisfies ConfigFileSnapshot["config"]; const cleanRaw = `${JSON.stringify(cleanConfig, null, 2)}\n`; const warn = vi.fn(); @@ -727,9 +727,9 @@ describe("config io write", () => { const original = { gateway: { mode: "local" }, channels: { telegram: { enabled: true, dmPolicy: "pairing" } }, - agents: { list: [{ id: "main", default: true, workspace: "/tmp/openclaw-main" }] }, + agents: { entries: { main: { default: true, workspace: "/tmp/openclaw-main" } } }, tools: { profile: "messaging" }, - commands: { ownerDisplay: "hash" }, + commands: { restart: false }, } satisfies ConfigFileSnapshot["config"]; const originalRaw = `${JSON.stringify(original, null, 2)}\n`; await fs.writeFile(configPath, originalRaw, "utf-8"); @@ -887,7 +887,7 @@ describe("config io write", () => { const invalid = { gateway: { mode: "local" }, channels, - agents: { list: "not-an-array" }, + agents: { entries: "not-an-array" }, }; const invalidRaw = `\uFEFF${JSON.stringify(invalid, null, 12)}\n`; await fs.writeFile(configPath, invalidRaw, "utf-8"); @@ -1010,7 +1010,7 @@ describe("config io write", () => { }, }, }, - list: [{ id: "main" }], + entries: { main: {} }, }, } satisfies ConfigFileSnapshot["sourceConfig"]; const originalRaw = `${JSON.stringify(original, null, 2)}\n`; @@ -1056,7 +1056,7 @@ describe("config io write", () => { await io.writeConfigFile( { gateway: { mode: "local" }, - agents: { list: [{ id: "main" }, { id: "ops" }] }, + agents: { entries: { main: {}, ops: {} } }, }, { baseSnapshot }, ); @@ -1070,7 +1070,7 @@ describe("config io write", () => { alias: "GPT", params: { transport: "sse", openaiWsWarmup: false }, }); - expect(persisted.agents?.list).toEqual([{ id: "main" }, { id: "ops" }]); + expect(persisted.agents?.entries).toEqual({ main: {}, ops: {} }); }); }); @@ -1247,74 +1247,6 @@ describe("config io write", () => { }); }); - it("rejects local write ownership when config env changes path selection during the read", async () => { - await withSuiteHome(async (home) => { - const configPath = path.join(home, ".openclaw", "openclaw.json"); - const configuredNextPath = path.join(home, ".openclaw", "next.json"); - const sourceConfig = { - env: { OPENCLAW_CONFIG_PATH: configuredNextPath }, - gateway: { mode: "local" }, - } satisfies OpenClawConfig; - await fs.mkdir(path.dirname(configPath), { recursive: true }); - await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf-8"); - const io = createFastConfigIO(home); - - await expect(io.readConfigFileSnapshotForWrite()).rejects.toThrow( - "config path changed since last load", - ); - }); - }); - - it("follows config env path selection before returning global write ownership", async () => { - await withSuiteHome(async (home) => { - const configPath = path.join(home, ".openclaw", "openclaw.json"); - const configuredNextPath = path.join(home, ".openclaw", "next.json"); - const sourceConfig = { - env: { OPENCLAW_CONFIG_PATH: configuredNextPath }, - gateway: { mode: "local" }, - } satisfies OpenClawConfig; - await fs.mkdir(path.dirname(configPath), { recursive: true }); - await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf-8"); - await fs.writeFile( - configuredNextPath, - `${JSON.stringify({ gateway: { mode: "local" } }, null, 2)}\n`, - "utf-8", - ); - - await withEnvAsync( - { - OPENCLAW_CONFIG_PATH: undefined, - OPENCLAW_HOME: home, - OPENCLAW_STATE_DIR: undefined, - OPENCLAW_TEST_FAST: "1", - }, - async () => { - const prepared = await readConfigFileSnapshotForWrite(); - - expect(prepared.snapshot.path).toBe(configuredNextPath); - expect(() => prepared.writeOptions.assertConfigPathForWrite?.()).not.toThrow(); - await writeConfigFile( - { - ...prepared.snapshot.sourceConfig, - gateway: { mode: "remote" }, - }, - { - baseSnapshot: prepared.snapshot, - ...prepared.writeOptions, - }, - ); - }, - ); - - const initialConfig = JSON.parse(await fs.readFile(configPath, "utf-8")) as OpenClawConfig; - const persisted = JSON.parse( - await fs.readFile(configuredNextPath, "utf-8"), - ) as OpenClawConfig; - expect(initialConfig.gateway?.mode).toBe("local"); - expect(persisted.gateway?.mode).toBe("remote"); - }); - }); - it("does not use expectedConfigPath as the write destination", async () => { await withSuiteHome(async (home) => { const expectedConfigPath = path.join(home, ".openclaw", "expected.json"); @@ -1848,7 +1780,7 @@ describe("config io write", () => { await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile( agentsPath, - `${JSON.stringify({ list: [{ id: "main", default: true }] }, null, 2)}\n`, + `${JSON.stringify({ entries: { main: { default: true } } }, null, 2)}\n`, "utf-8", ); await fs.writeFile( @@ -1892,14 +1824,14 @@ describe("config io write", () => { }); }); - it("repairs invalid config without flattening array-nested includes", async () => { + it("repairs invalid config without flattening record-nested includes", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); const includePath = path.join(home, ".openclaw", "main-agent.json5"); await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile( includePath, - `${JSON.stringify({ id: "main", workspace: "${OPENCLAW_AGENT_WORKSPACE}" }, null, 2)}\n`, + `${JSON.stringify({ workspace: "${OPENCLAW_AGENT_WORKSPACE}" }, null, 2)}\n`, "utf-8", ); await fs.writeFile( @@ -1908,7 +1840,7 @@ describe("config io write", () => { { agents: { defaults: { params: { stale: true } }, - list: [{ $include: "./main-agent.json5" }], + entries: { main: { $include: "./main-agent.json5" } }, }, channels: { "test-plugin-channel": { enabled: true } }, }, @@ -1930,15 +1862,17 @@ describe("config io write", () => { expect(snapshot.valid).toBe(false); await io.writeConfigFile({ - agents: { list: [{ id: "main", workspace: "/resolved/agent-workspace" }] }, + agents: { entries: { main: { workspace: "/resolved/agent-workspace" } } }, }); await expect(fs.readFile(configPath, "utf-8")).resolves.not.toBe(originalRootRaw); const persistedRoot = JSON.parse(await fs.readFile(configPath, "utf-8")) as { - agents?: { defaults?: unknown; list?: unknown[] }; + agents?: { defaults?: unknown; entries?: Record }; }; expect(persistedRoot.agents?.defaults).toBeUndefined(); - expect(persistedRoot.agents?.list).toEqual([{ $include: "./main-agent.json5" }]); + expect(persistedRoot.agents?.entries).toEqual({ + main: { $include: "./main-agent.json5" }, + }); await expect(fs.readFile(includePath, "utf-8")).resolves.toContain( '"workspace": "${OPENCLAW_AGENT_WORKSPACE}"', ); @@ -1981,7 +1915,7 @@ describe("config io write", () => { expectPersistedHashResult( await io.writeConfigFile({ - agents: { list: [{ id: "main", default: true }] }, + agents: { entries: { main: { default: true } } }, plugins: { entries: { "required-plugin": { @@ -2080,13 +2014,12 @@ describe("config io write", () => { }, }, meta: { - lastTouchedAt: persisted.meta?.lastTouchedAt, lastTouchedVersion: persisted.meta?.lastTouchedVersion, migrations: { modelPolicyAllowlist: true }, }, }); - expect(typeof persisted.meta?.lastTouchedAt).toBe("string"); expect(typeof persisted.meta?.lastTouchedVersion).toBe("string"); + expect(readConfigMachineState("config.lastTouchedAt")).toEqual(expect.any(String)); }); }); }); @@ -2489,8 +2422,9 @@ describe("config io write", () => { expect(postWriteSnapshot.valid).toBe(true); expect(observedSources).toEqual([postWriteSnapshot.sourceConfig]); expect(getRuntimeConfigSourceSnapshot()).toEqual(postWriteSnapshot.sourceConfig); - expect(postWriteSnapshot.sourceConfig.meta?.lastTouchedAt).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u, + expect(postWriteSnapshot.sourceConfig.meta).not.toHaveProperty("lastTouchedAt"); + expect(readConfigMachineState("config.lastTouchedAt")).toEqual( + expect.any(String), ); expect(postWriteSnapshot.sourceConfig.plugins?.entries?.demo?.config).toStrictEqual({}); }); @@ -2871,46 +2805,6 @@ describe("config io write", () => { }); }); - it("rolls back root writes when canonical reread changes config path ownership", async () => { - await withSuiteHome(async (home) => { - const configPath = path.join(home, ".openclaw", "openclaw.json"); - const nextConfigPath = path.join(home, ".openclaw", "next.json"); - const initialConfig = { gateway: { mode: "local", port: 18789 } } satisfies OpenClawConfig; - const initialRaw = `${JSON.stringify(initialConfig, null, 2)}\n`; - await fs.mkdir(path.dirname(configPath), { recursive: true }); - await fs.writeFile(configPath, initialRaw, "utf-8"); - - await withEnvAsync( - { - OPENCLAW_CONFIG_PATH: undefined, - OPENCLAW_HOME: home, - OPENCLAW_STATE_DIR: undefined, - OPENCLAW_TEST_FAST: "1", - }, - async () => { - const prepared = await readConfigFileSnapshotForWrite(); - - await expect( - writeConfigFile( - { - gateway: { mode: "local", port: 19001 }, - env: { OPENCLAW_CONFIG_PATH: nextConfigPath }, - }, - { - baseSnapshot: prepared.snapshot, - ...prepared.writeOptions, - }, - ), - ).rejects.toThrow("config path changed since last load"); - - await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(initialRaw); - expect(process.env.OPENCLAW_CONFIG_PATH).toBeUndefined(); - await expect(fs.stat(nextConfigPath)).rejects.toMatchObject({ code: "ENOENT" }); - }, - ); - }); - }); - it("uses injected filesystem operations when rolling back ownership loss", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); @@ -3071,7 +2965,7 @@ describe("config io write", () => { try { // Plugin is enabled but missing required "token" — validation fails without skip. const cfg: OpenClawConfig = { - agents: { list: [{ id: "main", default: true }] }, + agents: { entries: { main: { default: true } } }, plugins: { entries: { "strict-plugin": { enabled: true } } }, }; @@ -3083,7 +2977,7 @@ describe("config io write", () => { /Config validation failed/, ); await expect( - writeConfigFile({ agents: { list: "not-array" } } as unknown as OpenClawConfig, { + writeConfigFile({ agents: { entries: "not-array" } } as unknown as OpenClawConfig, { skipPluginValidation: true, }), ).rejects.toThrow(/Config validation failed/); diff --git a/src/config/io.write-prepare.test.ts b/src/config/io.write-prepare.test.ts index 802e9d8d95b2..3df150f5f32b 100644 --- a/src/config/io.write-prepare.test.ts +++ b/src/config/io.write-prepare.test.ts @@ -254,9 +254,8 @@ describe("config io write prepare", () => { }, }, }, - list: [ - { - id: "ops", + entries: { + ops: { model: { primary: "google/gemini-3-pro-preview", fallbacks: ["google/gemini-3-pro-preview"], @@ -270,7 +269,7 @@ describe("config io write prepare", () => { }, }, }, - ], + }, }, gateway: { port: 18789 }, }; @@ -299,9 +298,8 @@ describe("config io write prepare", () => { }, }, }, - list: [ - { - id: "ops", + entries: { + ops: { model: { primary: "google/gemini-3.1-pro-preview", fallbacks: ["google/gemini-3.1-pro-preview"], @@ -315,7 +313,7 @@ describe("config io write prepare", () => { }, }, }, - ], + }, }, gateway: { port: 18789 }, }; @@ -347,14 +345,14 @@ describe("config io write prepare", () => { alias: "Gemini", }, }); - expect(persisted.agents?.list?.[0]?.model).toEqual({ + expect(persisted.agents?.entries?.ops?.model).toEqual({ primary: "google/gemini-3.1-pro-preview", fallbacks: ["google/gemini-3.1-pro-preview"], }); - expect(persisted.agents?.list?.[0]?.utilityModel).toBe("google/gemini-3.1-pro-preview"); - expect(persisted.agents?.list?.[0]?.heartbeat?.model).toBe("google/gemini-3.1-pro-preview"); - expect(persisted.agents?.list?.[0]?.subagents?.model).toBe("google/gemini-3.1-pro-preview"); - expect(persisted.agents?.list?.[0]?.models).toEqual({ + expect(persisted.agents?.entries?.ops?.utilityModel).toBe("google/gemini-3.1-pro-preview"); + expect(persisted.agents?.entries?.ops?.heartbeat?.model).toBe("google/gemini-3.1-pro-preview"); + expect(persisted.agents?.entries?.ops?.subagents?.model).toBe("google/gemini-3.1-pro-preview"); + expect(persisted.agents?.entries?.ops?.models).toEqual({ "google/gemini-3.1-pro-preview": { alias: "Ops Gemini", }, diff --git a/src/config/io.write-prepare.ts b/src/config/io.write-prepare.ts index 9edcea435138..93dd09f9f84d 100644 --- a/src/config/io.write-prepare.ts +++ b/src/config/io.write-prepare.ts @@ -446,15 +446,7 @@ function normalizeAgentModelConfigForWrite(value: unknown): unknown { return mutated ? next : value; } -const AGENT_MODEL_CONFIG_KEYS = [ - "model", - "imageModel", - "imageGenerationModel", - "videoGenerationModel", - "musicGenerationModel", - "voiceModel", - "pdfModel", -] as const; +const AGENT_MODEL_CONFIG_KEYS = ["model", "imageModel", "voiceModel", "pdfModel"] as const; function normalizeModelConfigPathForWrite(config: unknown, path: string[]): unknown { const value = getPathValue(config, path); @@ -484,6 +476,9 @@ function normalizeAgentModelRefsAtPathForWrite(config: unknown, path: string[]): for (const key of AGENT_MODEL_CONFIG_KEYS) { next = normalizeModelConfigPathForWrite(next, [...path, key]); } + for (const key of ["image", "video", "music"] as const) { + next = normalizeModelConfigPathForWrite(next, [...path, "mediaModels", key]); + } next = normalizeModelStringPathForWrite(next, [...path, "utilityModel"]); next = normalizeModelStringPathForWrite(next, [...path, "heartbeat", "model"]); next = normalizeModelConfigPathForWrite(next, [...path, "subagents", "model"]); @@ -501,28 +496,30 @@ function normalizeAgentModelRefsAtPathForWrite(config: unknown, path: string[]): } function normalizeAgentListModelRefsForWrite(config: unknown): unknown { - const list = getPathValue(config, ["agents", "list"]); - if (!Array.isArray(list)) { + const entries = getPathValue(config, ["agents", "entries"]); + if (!isRecord(entries)) { return config; } let mutated = false; - const nextList = list.map((agent) => { - if (!isRecord(agent)) { - return agent; - } + const nextEntries = Object.fromEntries( + Object.entries(entries).map(([agentId, agent]) => { + if (!isRecord(agent)) { + return [agentId, agent]; + } - const normalized = normalizeAgentModelRefsAtPathForWrite({ agent }, ["agent"]) as { - agent: unknown; - }; - if (normalized.agent !== agent) { - mutated = true; - return normalized.agent; - } - return agent; - }); + const normalized = normalizeAgentModelRefsAtPathForWrite({ agent }, ["agent"]) as { + agent: unknown; + }; + if (normalized.agent !== agent) { + mutated = true; + return [agentId, normalized.agent]; + } + return [agentId, agent]; + }), + ); - return mutated ? setPathValue(config, ["agents", "list"], nextList) : config; + return mutated ? setPathValue(config, ["agents", "entries"], nextEntries) : config; } function normalizeToolsModelRefsForWrite(config: unknown): unknown { diff --git a/src/config/io.write.ts b/src/config/io.write.ts index 7e29745b3f36..984db1744ed0 100644 --- a/src/config/io.write.ts +++ b/src/config/io.write.ts @@ -1,6 +1,7 @@ import type fs from "node:fs"; import path from "node:path"; import { isVerbose } from "../global-state.js"; +import { isVitestRuntimeEnv } from "../infra/env.js"; import { formatErrorMessage } from "../infra/errors.js"; import { replaceFileAtomic } from "../infra/replace-file.js"; import { maintainConfigBackups } from "./backup-rotation.js"; @@ -24,6 +25,7 @@ import { } from "./io.audit.js"; import type { ConfigIoContext } from "./io.context.js"; import { resolveModelIdNormalizationPolicies } from "./io.context.js"; +import { recordConfigWriteMetadata } from "./io.meta.js"; import { collectEnvRefPaths, containsConfigIncludeDirective, @@ -254,16 +256,16 @@ export async function writeConfigFileFromContext( gatewayModeAfter, }); - const shouldLogInVitest = (name: string) => deps.env.VITEST !== "true" || deps.env[name] === "1"; + const readTestLogFlag = (name: string) => isVitestRuntimeEnv(deps.env) && deps.env[name] === "1"; const logConfigOverwrite = () => { if ( !snapshot.exists || options.skipOutputLogs || - !shouldLogInVitest("OPENCLAW_TEST_CONFIG_OVERWRITE_LOG") + (isVitestRuntimeEnv(deps.env) && !readTestLogFlag("OPENCLAW_TEST_CONFIG_WRITE_LOG")) ) { return; } - const testLog = deps.env.OPENCLAW_TEST_CONFIG_OVERWRITE_LOG === "1"; + const testLog = readTestLogFlag("OPENCLAW_TEST_CONFIG_WRITE_LOG"); if (!isVerbose() && deps.env.OPENCLAW_CONFIG_OVERWRITE_LOG !== "1" && !testLog) { return; } @@ -277,14 +279,14 @@ export async function writeConfigFileFromContext( ); }; const logConfigWriteAnomalies = () => { + const testLog = readTestLogFlag("OPENCLAW_TEST_CONFIG_WRITE_LOG"); if ( suspiciousReasons.length === 0 || options.skipOutputLogs || - !shouldLogInVitest("OPENCLAW_TEST_CONFIG_WRITE_ANOMALY_LOG") + (isVitestRuntimeEnv(deps.env) && !testLog) ) { return; } - const testLog = deps.env.OPENCLAW_TEST_CONFIG_WRITE_ANOMALY_LOG === "1"; const showMissingMeta = isVerbose() || deps.env.OPENCLAW_CONFIG_WRITE_ANOMALY_LOG === "1" || testLog; const visibleReasons = showMissingMeta @@ -411,6 +413,11 @@ export async function writeConfigFileFromContext( } throw error; } + try { + recordConfigWriteMetadata(new Date().toISOString(), options.lastTouchedVersionOverride); + } catch (error) { + deps.logger.warn(`Config metadata state update failed: ${formatErrorMessage(error)}`); + } logConfigOverwrite(); logConfigWriteAnomalies(); await appendWriteAudit( diff --git a/src/config/mcp-config-normalize.ts b/src/config/mcp-config-normalize.ts index 559f5d021c00..cb0ec741ca94 100644 --- a/src/config/mcp-config-normalize.ts +++ b/src/config/mcp-config-normalize.ts @@ -46,24 +46,39 @@ export function canonicalizeConfiguredMcpServer( if (isKnownCliMcpTypeAlias(next.type)) { delete next.type; } + if (typeof next.cwd !== "string" && typeof next.workingDirectory === "string") { + next.cwd = next.workingDirectory; + } + delete next.workingDirectory; if ( typeof next.supports_parallel_tool_calls === "boolean" && typeof next.supportsParallelToolCalls !== "boolean" ) { next.supportsParallelToolCalls = next.supports_parallel_tool_calls; - delete next.supports_parallel_tool_calls; } + delete next.supports_parallel_tool_calls; if (typeof next.ssl_verify === "boolean" && typeof next.sslVerify !== "boolean") { next.sslVerify = next.ssl_verify; - delete next.ssl_verify; } + delete next.ssl_verify; if (typeof next.client_cert === "string" && typeof next.clientCert !== "string") { next.clientCert = next.client_cert; - delete next.client_cert; } + delete next.client_cert; if (typeof next.client_key === "string" && typeof next.clientKey !== "string") { next.clientKey = next.client_key; - delete next.client_key; + } + delete next.client_key; + const codex = isRecord(next.codex) ? { ...next.codex } : undefined; + if (codex) { + if ( + typeof codex.defaultToolsApprovalMode !== "string" && + typeof codex.default_tools_approval_mode === "string" + ) { + codex.defaultToolsApprovalMode = codex.default_tools_approval_mode; + } + delete codex.default_tools_approval_mode; + next.codex = codex; } return next; } diff --git a/src/config/media-audio-field-metadata.ts b/src/config/media-audio-field-metadata.ts index a981fcc0772d..e0036f534e40 100644 --- a/src/config/media-audio-field-metadata.ts +++ b/src/config/media-audio-field-metadata.ts @@ -1,107 +1,55 @@ /** Config paths with user-facing metadata for audio understanding settings. */ const MEDIA_AUDIO_FIELD_KEYS = [ "tools.media.audio.enabled", + "tools.media.audio.preferredModel", "tools.media.audio.maxBytes", "tools.media.audio.maxChars", "tools.media.audio.prompt", "tools.media.audio.timeoutSeconds", "tools.media.audio.language", - "tools.media.audio.attachments", - "tools.media.audio.models", "tools.media.audio.scope", + "tools.media.audio.attachments", "tools.media.audio.echoTranscript", "tools.media.audio.echoFormat", - "tools.media.audio.request", - "tools.media.audio.request.headers", - "tools.media.audio.request.auth", - "tools.media.audio.request.auth.mode", - "tools.media.audio.request.auth.token", - "tools.media.audio.request.auth.headerName", - "tools.media.audio.request.auth.value", - "tools.media.audio.request.auth.prefix", - "tools.media.audio.request.proxy", - "tools.media.audio.request.proxy.mode", - "tools.media.audio.request.proxy.url", - "tools.media.audio.request.proxy.tls", - "tools.media.audio.request.tls", ] as const; type MediaAudioFieldKey = (typeof MEDIA_AUDIO_FIELD_KEYS)[number]; export const MEDIA_AUDIO_FIELD_HELP: Record = { "tools.media.audio.enabled": - "Enable audio understanding so voice notes or audio clips can be transcribed/summarized for agent context. Disable when audio ingestion is outside policy or unnecessary for your workflows.", + "Enable audio understanding so voice notes or audio clips can be transcribed for agent context.", + "tools.media.audio.preferredModel": + "Prefer one capability-tagged tools.media.models entry for audio transcription before the remaining compatible fallbacks.", "tools.media.audio.maxBytes": - "Maximum accepted audio payload size in bytes before processing is rejected or clipped by policy. Set this based on expected recording length and upstream provider limits.", + "Default audio input size limit for configured and auto-detected models. Set this to the largest recording your providers and network should accept.", "tools.media.audio.maxChars": - "Maximum characters retained from audio understanding output to prevent oversized transcript injection. Increase for long-form dictation, or lower to keep conversational turns compact.", + "Default maximum transcript length for configured and auto-detected models. Use a lower value to keep long voice notes from expanding agent context.", "tools.media.audio.prompt": - "Instruction template guiding audio understanding output style, such as concise summary versus near-verbatim transcript. Keep wording consistent so downstream automations can rely on output format.", + "Default audio transcription prompt when a model entry does not override it. Keep the instruction stable when downstream workflows rely on transcript style.", "tools.media.audio.timeoutSeconds": - "Timeout in seconds for audio understanding execution before the operation is cancelled. Use longer timeouts for long recordings and tighter ones for interactive chat responsiveness.", + "Default timeout for audio understanding requests. Increase it for long recordings or slower local transcription models.", "tools.media.audio.language": - "Preferred language hint for audio understanding/transcription when provider support is available. Set this to improve recognition accuracy for known primary languages.", - "tools.media.audio.attachments": - "Attachment policy for audio inputs indicating which uploaded files are eligible for audio processing. Keep restrictive defaults in mixed-content channels to avoid unintended audio workloads.", - "tools.media.audio.models": - "Ordered model preferences specifically for audio understanding, used before shared media model fallback. Choose models optimized for transcription quality in your primary language/domain.", + "Default language hint for audio transcription providers. Set it when the primary spoken language is known and provider detection is unreliable.", "tools.media.audio.scope": - "Scope selector for when audio understanding runs across inbound messages and attachments. Keep focused scopes in high-volume channels to reduce cost and avoid accidental transcription.", + "Restrict audio understanding by channel, chat type, or source key. Keep this narrow where automatic transcription is sensitive or expensive.", + "tools.media.audio.attachments": + "Choose which matching audio attachments are processed. Use first-only handling unless multi-attachment transcription is intentional.", "tools.media.audio.echoTranscript": - "Echo the audio transcript back to the originating chat before agent processing. When enabled, users immediately see what was heard from their voice note, helping them verify transcription accuracy before the agent acts on it. Default: false.", + "Echo the audio transcript to the originating chat before agent processing. Enable this when users need to verify what the system heard.", "tools.media.audio.echoFormat": - "Format string for the echoed transcript message. Use `{transcript}` as a placeholder for the transcribed text. Default: '📝 \"{transcript}\"'.", - "tools.media.audio.request": - "Low-level HTTP request overrides for audio providers, including custom headers, auth, proxy routing, and TLS client settings. Use this for proxy-backed or self-hosted transcription endpoints when plain baseUrl/apiKey fields are not enough.", - "tools.media.audio.request.headers": - "Additional HTTP headers merged into audio provider requests after provider defaults. Use this for tenant routing or proxy integration headers, and keep secrets in env-backed values.", - "tools.media.audio.request.auth": - "Optional auth override for audio provider requests. Use this when the upstream expects a non-default bearer token or custom auth header shape.", - "tools.media.audio.request.auth.mode": - 'Auth override mode for audio requests: "provider-default" keeps the normal provider auth, "authorization-bearer" forces an Authorization bearer token, and "header" sends a custom header/value pair.', - "tools.media.audio.request.auth.token": - "Bearer token used when audio request auth.mode is authorization-bearer. Keep this in secret storage rather than inline config.", - "tools.media.audio.request.auth.headerName": - "Header name used when audio request auth.mode is header. Match the exact upstream expectation, such as x-api-key or authorization.", - "tools.media.audio.request.auth.value": - "Header value used when audio request auth.mode is header. Keep secrets in env-backed values and avoid duplicating provider-default auth unnecessarily.", - "tools.media.audio.request.auth.prefix": - "Optional prefix prepended to the custom auth header value, such as Bearer. Leave unset when the upstream expects the raw credential only.", - "tools.media.audio.request.proxy": - "Proxy transport override for audio requests. Use env-proxy to respect process proxy settings, or explicit-proxy to force a dedicated proxy URL for this provider path.", - "tools.media.audio.request.proxy.mode": - 'Proxy mode for audio requests: "env-proxy" uses environment proxy settings, while "explicit-proxy" uses the configured proxy URL only for this request path.', - "tools.media.audio.request.proxy.url": - "Explicit proxy URL for audio provider traffic when proxy.mode is explicit-proxy. Keep credentials out of inline URLs when possible and prefer secret-backed env injection.", - "tools.media.audio.request.proxy.tls": - "TLS settings applied when connecting to the configured audio proxy, such as custom CA trust for an internal proxy gateway.", - "tools.media.audio.request.tls": - "Direct TLS client settings for audio provider requests, including custom CA trust, client certs, or SNI overrides for managed gateways and internal endpoints.", + "Format the echoed transcript with a {transcript} placeholder. Keep the placeholder intact so delivery includes the transcript.", }; export const MEDIA_AUDIO_FIELD_LABELS: Record = { "tools.media.audio.enabled": "Enable Audio Understanding", + "tools.media.audio.preferredModel": "Preferred Audio Understanding Model", "tools.media.audio.maxBytes": "Audio Understanding Max Bytes", "tools.media.audio.maxChars": "Audio Understanding Max Chars", "tools.media.audio.prompt": "Audio Understanding Prompt", "tools.media.audio.timeoutSeconds": "Audio Understanding Timeout (sec)", "tools.media.audio.language": "Audio Understanding Language", - "tools.media.audio.attachments": "Audio Understanding Attachment Policy", - "tools.media.audio.models": "Audio Understanding Models", "tools.media.audio.scope": "Audio Understanding Scope", + "tools.media.audio.attachments": "Audio Understanding Attachment Policy", "tools.media.audio.echoTranscript": "Echo Transcript to Chat", "tools.media.audio.echoFormat": "Transcript Echo Format", - "tools.media.audio.request": "Audio Request Overrides", - "tools.media.audio.request.headers": "Audio Request Headers", - "tools.media.audio.request.auth": "Audio Request Auth Override", - "tools.media.audio.request.auth.mode": "Audio Request Auth Mode", - "tools.media.audio.request.auth.token": "Audio Request Bearer Token", - "tools.media.audio.request.auth.headerName": "Audio Request Auth Header Name", - "tools.media.audio.request.auth.value": "Audio Request Auth Header Value", - "tools.media.audio.request.auth.prefix": "Audio Request Auth Header Prefix", - "tools.media.audio.request.proxy": "Audio Request Proxy", - "tools.media.audio.request.proxy.mode": "Audio Request Proxy Mode", - "tools.media.audio.request.proxy.url": "Audio Request Proxy URL", - "tools.media.audio.request.proxy.tls": "Audio Request Proxy TLS", - "tools.media.audio.request.tls": "Audio Request TLS", }; diff --git a/src/config/merge-patch.test.ts b/src/config/merge-patch.test.ts index 0191bdcb14ee..01877b6b803e 100644 --- a/src/config/merge-patch.test.ts +++ b/src/config/merge-patch.test.ts @@ -14,7 +14,7 @@ describe("applyMergePatch", () => { }; const patch = { agents: { - list: [{ id: "primary", memorySearch: { extraPaths: ["/tmp/memory.md"] } }], + list: [{ id: "primary", memory: { search: { extraPaths: ["/tmp/memory.md"] } } }], }, }; return { base, patch }; @@ -27,7 +27,7 @@ describe("applyMergePatch", () => { agents?: { list?: Array<{ id?: string; workspace?: string }> }; }; expect(merged.agents?.list).toEqual([ - { id: "primary", memorySearch: { extraPaths: ["/tmp/memory.md"] } }, + { id: "primary", memory: { search: { extraPaths: ["/tmp/memory.md"] } } }, ]); }); @@ -41,7 +41,7 @@ describe("applyMergePatch", () => { list?: Array<{ id?: string; workspace?: string; - memorySearch?: { extraPaths?: string[] }; + memory?: { search?: { extraPaths?: string[] } }; }>; }; }; @@ -49,7 +49,7 @@ describe("applyMergePatch", () => { const primary = merged.agents?.list?.find((entry) => entry.id === "primary"); const secondary = merged.agents?.list?.find((entry) => entry.id === "secondary"); expect(primary?.workspace).toBe("/tmp/one"); - expect(primary?.memorySearch?.extraPaths).toEqual(["/tmp/memory.md"]); + expect(primary?.memory?.search?.extraPaths).toEqual(["/tmp/memory.md"]); expect(secondary?.workspace).toBe("/tmp/two"); }); @@ -61,12 +61,12 @@ describe("applyMergePatch", () => { replaceArrayPaths: new Set(["agents.list"]), }) as { agents?: { - list?: Array<{ id?: string; memorySearch?: { extraPaths?: string[] } }>; + list?: Array<{ id?: string; memory?: { search?: { extraPaths?: string[] } } }>; }; }; expect(merged.agents?.list).toEqual([ - { id: "primary", memorySearch: { extraPaths: ["/tmp/memory.md"] } }, + { id: "primary", memory: { search: { extraPaths: ["/tmp/memory.md"] } } }, ]); }); diff --git a/src/config/model-policy-allowlist-migration.ts b/src/config/model-policy-allowlist-migration.ts index 404447c88291..2c4a3a5b0de6 100644 --- a/src/config/model-policy-allowlist-migration.ts +++ b/src/config/model-policy-allowlist-migration.ts @@ -4,10 +4,15 @@ import { isRecord } from "../utils.js"; export const MODEL_POLICY_ALLOWLIST_MIGRATION_MARKER = "modelPolicyAllowlist"; export function hasModelPolicyAllowlistMigrationMarker(value: unknown): boolean { - if (!isRecord(value) || !isRecord(value.meta) || !isRecord(value.meta.migrations)) { - return false; + if ( + isRecord(value) && + isRecord(value.meta) && + isRecord(value.meta.migrations) && + value.meta.migrations[MODEL_POLICY_ALLOWLIST_MIGRATION_MARKER] === true + ) { + return true; } - return value.meta.migrations[MODEL_POLICY_ALLOWLIST_MIGRATION_MARKER] === true; + return false; } /** Any policy object opts into the explicit model-policy semantics. */ diff --git a/src/config/paths.ts b/src/config/paths.ts index b3ed6e1cc431..95464b28c8c0 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { resolveHomeRelativePath, resolveRequiredHomeDir } from "../infra/home-dir.js"; import { parseTcpPort } from "../infra/tcp-port.js"; +import { isFastTestRuntimeEnv } from "../infra/test-runtime-env.js"; import type { OpenClawConfig } from "./types.js"; /** @@ -71,7 +72,7 @@ export function resolveStateDir( return resolveUserPath(override, env, effectiveHomedir); } const newDir = newStateDir(effectiveHomedir); - if (env.OPENCLAW_TEST_FAST === "1") { + if (isFastTestRuntimeEnv(env)) { return newDir; } const legacyDirs = legacyStateDirs(effectiveHomedir); @@ -174,7 +175,7 @@ export function resolveConfigPathCandidate( env: NodeJS.ProcessEnv = process.env, homedir: () => string = envHomedir(env), ): string { - if (env.OPENCLAW_TEST_FAST === "1") { + if (isFastTestRuntimeEnv(env)) { return resolveCanonicalConfigPath(env, resolveStateDir(env, homedir)); } const candidates = resolveDefaultConfigCandidates(env, homedir); @@ -203,7 +204,7 @@ export function resolveConfigPath( if (override) { return resolveUserPath(override, env, homedir); } - if (env.OPENCLAW_TEST_FAST === "1") { + if (isFastTestRuntimeEnv(env)) { return path.join(stateDir, CONFIG_FILENAME); } const stateOverride = env.OPENCLAW_STATE_DIR?.trim(); diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts index 9eaef8a3440e..d31a8d44df25 100644 --- a/src/config/plugin-auto-enable.core.test.ts +++ b/src/config/plugin-auto-enable.core.test.ts @@ -224,7 +224,7 @@ describe("applyPluginAutoEnable core", () => { it("auto-enables external speech providers selected by TTS config", () => { const result = applyPluginAutoEnable({ config: { - messages: { tts: { provider: "gradium" } }, + tts: { provider: "gradium" }, plugins: { allow: ["telegram"] }, }, env, @@ -293,7 +293,6 @@ describe("applyPluginAutoEnable core", () => { channels: { slack: { botToken: "x" } }, plugins: { allow: [], - bundledDiscovery: "compat", }, }, env, @@ -517,17 +516,19 @@ describe("applyPluginAutoEnable core", () => { config: { agents: { defaults: { - imageGenerationModel: { - primary: "openai/gpt-image-1", - fallbacks: ["google/gemini-3-pro-image-preview"], - }, - videoGenerationModel: { - primary: "openai/sora-2", - fallbacks: ["google/veo-3.1-fast-generate-preview", "minimax/MiniMax-Hailuo-2.3"], - }, - musicGenerationModel: { - primary: "minimax/music-2.6", - fallbacks: ["google/lyria-3-clip-preview"], + mediaModels: { + image: { + primary: "openai/gpt-image-1", + fallbacks: ["google/gemini-3-pro-image-preview"], + }, + video: { + primary: "openai/sora-2", + fallbacks: ["google/veo-3.1-fast-generate-preview", "minimax/MiniMax-Hailuo-2.3"], + }, + music: { + primary: "minimax/music-2.6", + fallbacks: ["google/lyria-3-clip-preview"], + }, }, }, }, diff --git a/src/config/redact-snapshot.schema.test.ts b/src/config/redact-snapshot.schema.test.ts index d80043ceb84a..a8174a6f4b03 100644 --- a/src/config/redact-snapshot.schema.test.ts +++ b/src/config/redact-snapshot.schema.test.ts @@ -10,38 +10,42 @@ import { buildConfigSchema } from "./schema.js"; describe("realredactConfigSnapshot_real", () => { it("main schema redact works (samples)", () => { const snapshot = makeSnapshot({ - agents: { - defaults: { - memorySearch: { - remote: { - apiKey: "1234", - }, + memory: { + search: { + remote: { + apiKey: "1234", }, }, - list: [ - { - memorySearch: { - remote: { - apiKey: "6789", + }, + + agents: { + defaults: {}, + entries: { + main: { + memory: { + search: { + remote: { + apiKey: "6789", + }, }, }, }, - ], + }, }, }); const result = redactConfigSnapshot(snapshot, mainSchemaHints); const config = result.config as typeof snapshot.config; - expect(config.agents.defaults.memorySearch.remote.apiKey).toBe(REDACTED_SENTINEL); + expect(config.memory.search.remote.apiKey).toBe(REDACTED_SENTINEL); expect( - expectDefined(config.agents.list[0], "config.agents.list[0] test invariant").memorySearch - .remote.apiKey, + expectDefined(config.agents.entries.main, "config.agents.entries.main test invariant").memory + .search.remote.apiKey, ).toBe(REDACTED_SENTINEL); const restored = restoreRedactedValues(result.config, snapshot.config, mainSchemaHints); - expect(restored.agents.defaults.memorySearch.remote.apiKey).toBe("1234"); + expect(restored.memory.search.remote.apiKey).toBe("1234"); expect( - expectDefined(restored.agents.list[0], "restored.agents.list[0] test invariant").memorySearch - .remote.apiKey, + expectDefined(restored.agents.entries.main, "restored.agents.entries.main test invariant") + .memory.search.remote.apiKey, ).toBe("6789"); }); diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index db1562402233..e3bcde48dc66 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -657,8 +657,7 @@ describe("redactConfigSnapshot", () => { const sourceConfig = { tools: { exec: { - ask: "off", - security: "full", + mode: "full", }, }, } satisfies OpenClawConfig; diff --git a/src/config/runtime-overrides.test.ts b/src/config/runtime-overrides.test.ts index 5f81479fec72..5ab42c74d11d 100644 --- a/src/config/runtime-overrides.test.ts +++ b/src/config/runtime-overrides.test.ts @@ -17,11 +17,11 @@ describe("runtime overrides", () => { it("sets and applies nested overrides", () => { const cfg = { - messages: { responsePrefix: "[openclaw]" }, + channels: { whatsapp: { responsePrefix: "[openclaw]" } }, } as OpenClawConfig; - setConfigOverride("messages.responsePrefix", "[debug]"); + setConfigOverride("channels.whatsapp.responsePrefix", "[debug]"); const next = applyConfigOverrides(cfg); - expect(next.messages?.responsePrefix).toBe("[debug]"); + expect(next.channels?.whatsapp?.responsePrefix).toBe("[debug]"); }); it("captures an immutable override applier", () => { diff --git a/src/config/schema.base.generated.test.ts b/src/config/schema.base.generated.test.ts index 2a1fc750477c..217fd1ed4a5c 100644 --- a/src/config/schema.base.generated.test.ts +++ b/src/config/schema.base.generated.test.ts @@ -30,6 +30,8 @@ function schemaAt(schema: TestJsonSchema, path: string[]): TestJsonSchema | unde } if (segment === "[]") { node = Array.isArray(node.items) ? node.items[0] : node.items; + } else if (segment === "*") { + node = typeof node.additionalProperties === "object" ? node.additionalProperties : undefined; } else { node = node.properties?.[segment]; } @@ -146,22 +148,18 @@ describe("base config schema", () => { ).properties?.agents?.properties?.defaults?.properties; const uiHints = BASE_CONFIG_SCHEMA.uiHints as Record; - expect(agentDefaultsProperties).toHaveProperty("videoGenerationModel"); + expect(agentDefaultsProperties).toHaveProperty("mediaModels"); expect(agentDefaultsProperties).toHaveProperty("voiceModel"); - expect(uiHints).toHaveProperty("agents.defaults.videoGenerationModel.primary"); - expect(uiHints).toHaveProperty("agents.defaults.videoGenerationModel.fallbacks"); + expect(uiHints).toHaveProperty("agents.defaults.mediaModels.video.primary"); + expect(uiHints).toHaveProperty("agents.defaults.mediaModels.video.fallbacks"); expect(uiHints).toHaveProperty("agents.defaults.voiceModel.primary"); expect(uiHints).toHaveProperty("agents.defaults.voiceModel.fallbacks"); - expect(uiHints).toHaveProperty("agents.defaults.mediaGenerationAutoProviderFallback"); }); it("publishes accepted input shapes for transform-backed config fields", () => { - const lastTouchedAtBranches = expectAnyOfTypes(["meta", "lastTouchedAt"], ["number", "string"]); - expect(lastTouchedAtBranches.every((branch) => Object.keys(branch).length > 0)).toBe(true); - for (const path of [ ["agents", "defaults", "sandbox", "docker", "setupCommand"], - ["agents", "list", "[]", "sandbox", "docker", "setupCommand"], + ["agents", "entries", "*", "sandbox", "docker", "setupCommand"], ]) { const branches = expectAnyOfTypes(path, ["array", "string"]); expect(itemSchema(branches.find((branch) => branch.type === "array"))?.type).toBe("string"); @@ -190,9 +188,7 @@ describe("base config schema", () => { expect(codexUserLocation?.properties?.city?.type).toBe("string"); expect(codexUserLocation?.properties?.timezone?.type).toBe("string"); - expect(schemaAt(BASE_SCHEMA, ["gateway", "controlUi", "chatMessageMaxWidth"])?.type).toBe( - "string", - ); + expect(schemaAt(BASE_SCHEMA, ["ui", "prefs", "chatMessageMaxWidth"])?.type).toBe("string"); }); it("does not publish metadata-only composition branches", () => { diff --git a/src/config/schema.help.agents.ts b/src/config/schema.help.agents.ts index 55691cef546e..2a8a451af575 100644 --- a/src/config/schema.help.agents.ts +++ b/src/config/schema.help.agents.ts @@ -64,55 +64,52 @@ export const AGENT_FIELD_HELP: Record = { "Per-plugin environment variable map injected for that plugin runtime context only. Use this to scope provider credentials to one plugin instead of sharing global process environment.", "plugins.entries.*.config": "Plugin-defined configuration payload interpreted by that plugin's own schema and validation rules. Use only documented fields from the plugin to prevent ignored or invalid settings.", - "agents.list.*.identity.avatar": + "agents.entries.*.identity.avatar": "Agent avatar (workspace-relative path, http(s) URL, or data URI).", "agents.defaults.model.primary": "Primary model (provider/model).", "agents.defaults.model.fallbacks": "Ordered fallback models (provider/model). Used when the primary model fails.", "agents.defaults.utilityModel": "Optional lower-cost model (provider/model or alias) for short internal tasks such as generated titles and progress narration. Unset derives the primary provider's declared small model when available (otherwise the primary model); set to an empty string to disable utility routing.", - "agents.list.*.utilityModel": + "agents.entries.*.utilityModel": "Optional per-agent utility model override for short internal tasks. Overrides agents.defaults.utilityModel.", - "agents.list.*.models": "Per-agent model catalog overrides keyed by full provider/model IDs.", - "agents.list.*.modelPolicy": + "agents.entries.*.models": "Per-agent model catalog overrides keyed by full provider/model IDs.", + "agents.entries.*.modelPolicy": "Per-agent model override policy. An explicit allow list replaces the default policy for this agent.", - "agents.list.*.modelPolicy.allow": + "agents.entries.*.modelPolicy.allow": 'Allowed model override refs for this agent. Accepts aliases, full "provider/model" refs, and trailing prefix wildcards such as "provider/*" or "provider/namespace/*"; empty permits any model.', - "agents.list.*.models.*.agentRuntime": + "agents.entries.*.models.*.agentRuntime": "Optional per-model runtime policy for this agent. Use this for agent-specific model exceptions instead of setting a whole-agent runtime.", - "agents.list.*.models.*.agentRuntime.id": + "agents.entries.*.models.*.agentRuntime.id": 'Per-agent model runtime id: "openclaw", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli".', "agents.defaults.imageModel.primary": "Optional image model (provider/model) used when the primary model lacks image input.", "agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).", - "agents.defaults.imageGenerationModel.primary": + "agents.defaults.mediaModels.image.primary": "Optional image-generation model (provider/model) used by the shared image generation capability.", - "agents.defaults.imageGenerationModel.fallbacks": + "agents.defaults.mediaModels.image.fallbacks": "Ordered fallback image-generation models (provider/model).", - "agents.defaults.imageGenerationModel.timeoutMs": + "agents.defaults.mediaModels.image.timeoutMs": "Default provider request timeout in milliseconds for image_generate calls. Per-call timeoutMs overrides this.", - "agents.defaults.videoGenerationModel.primary": + "agents.defaults.mediaModels.video.primary": "Optional video-generation model (provider/model) used by the shared video generation capability.", - "agents.defaults.videoGenerationModel.timeoutMs": + "agents.defaults.mediaModels.video.timeoutMs": "Default provider request timeout in milliseconds for video_generate calls. Per-call timeoutMs overrides this, and this value overrides provider-authored defaults.", - "agents.defaults.videoGenerationModel.fallbacks": + "agents.defaults.mediaModels.video.fallbacks": "Ordered fallback video-generation models (provider/model).", - "agents.defaults.musicGenerationModel.primary": + "agents.defaults.mediaModels.music.primary": "Optional music-generation model (provider/model) used by the shared music generation capability.", - "agents.defaults.musicGenerationModel.fallbacks": + "agents.defaults.mediaModels.music.fallbacks": "Ordered fallback music-generation models (provider/model).", "agents.defaults.voiceModel.primary": "Optional voice model (provider/model) used by speech, transcription, and realtime voice capabilities.", "agents.defaults.voiceModel.fallbacks": "Ordered fallback voice models (provider/model).", "agents.defaults.voiceModel.timeoutMs": "Default provider request timeout in milliseconds for voice model operations when the caller supports timeouts.", - "agents.defaults.mediaGenerationAutoProviderFallback": - "When true (default), shared image, music, and video generation automatically appends other auth-backed provider defaults after explicit primary/fallback refs. Set false to disable implicit cross-provider fallback while keeping explicit fallbacks.", "agents.defaults.pdfModel.primary": "Optional PDF model (provider/model) for the PDF analysis tool. Defaults to imageModel, then session model.", "agents.defaults.pdfModel.fallbacks": "Ordered fallback PDF models (provider/model).", - "agents.defaults.pdfMaxBytesMb": - "Maximum PDF file size in megabytes for the PDF tool (default: 10).", + "agents.defaults.pdfMaxMb": "Maximum PDF file size in megabytes for the PDF tool (default: 10).", "agents.defaults.pdfMaxPages": "Maximum number of PDF pages to process for the PDF tool (default: 20).", "agents.defaults.imageMaxDimensionPx": @@ -131,9 +128,7 @@ export const AGENT_FIELD_HELP: Record = { "agents.defaults.compaction.keepRecentTokens": "Minimum token budget preserved from the most recent conversation window during compaction. Use higher values to protect immediate context continuity and lower values to keep more long-tail history.", "agents.defaults.compaction.identifierPolicy": - 'Identifier-preservation policy for compaction summaries: "strict" prepends built-in opaque-identifier retention guidance (default), "off" disables this prefix, and "custom" uses identifierInstructions. Keep "strict" unless you have a specific compatibility need.', - "agents.defaults.compaction.identifierInstructions": - 'Custom identifier-preservation instruction text used when identifierPolicy="custom". Keep this explicit and safety-focused so compaction summaries do not rewrite opaque IDs, URLs, hosts, or ports.', + 'Identifier-preservation policy for compaction summaries: "strict" prepends built-in opaque-identifier retention guidance (default), while "off" disables this prefix.', "agents.defaults.compaction.recentTurnsPreserve": "Number of most recent user/assistant turns kept verbatim outside safeguard summarization (default: 3). Raise this to preserve exact recent dialogue context, or lower it to maximize compaction savings.", "agents.defaults.compaction.qualityGuard": @@ -149,7 +144,7 @@ export const AGENT_FIELD_HELP: Record = { "agents.defaults.compaction.postIndexSync": 'Controls post-compaction session memory reindex mode: "off", "async", or "await" (default: "async"). Use "await" for strongest freshness, "async" for lower compaction latency, and "off" only when session-memory sync is handled elsewhere.', "agents.defaults.compaction.postCompactionSections": - 'Opt-in AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset or set [] to disable reinjection. Explicitly set ["Session Startup", "Red Lines"] to enable the legacy default pair with fallback to older "Every Session"/"Safety" headings. Enabling this can duplicate project context already present in the compaction summary.', + 'Opt-in AGENTS.md H2/H3 section names re-injected after compaction. Leave unset or set [] to disable reinjection. Explicitly set ["Session Startup", "Red Lines"] to enable the legacy default pair.', "agents.defaults.compaction.timeoutSeconds": "Maximum time in seconds allowed for a single compaction operation before it is aborted (default: 180). Increase this for very large sessions that need more time to summarize, or decrease it to fail faster on unresponsive models.", "agents.defaults.compaction.model": @@ -170,23 +165,27 @@ export const AGENT_FIELD_HELP: Record = { "Threshold distance to compaction (in tokens) that triggers pre-compaction memory flush execution. Use earlier thresholds for safer persistence, or tighter thresholds for lower flush frequency.", "agents.defaults.compaction.memoryFlush.forceFlushTranscriptBytes": 'Forces pre-compaction memory flush when active transcript size reaches this threshold (bytes or strings like "2mb"). Use this to prevent long-session hangs even when token counters are stale; set to 0 to disable.', - "agents.defaults.compaction.memoryFlush.prompt": - "User-prompt template used for the pre-compaction memory flush turn when generating memory candidates. Use this only when you need custom extraction instructions beyond the default memory flush behavior.", - "agents.defaults.compaction.memoryFlush.systemPrompt": - "System-prompt override for the pre-compaction memory flush turn to control extraction style and safety constraints. Use carefully so custom instructions do not reduce memory quality or leak sensitive context.", "agents.defaults.embeddedAgent": "Embedded OpenClaw runner hardening controls for how workspace-local agent settings are trusted and applied in OpenClaw sessions.", "agents.defaults.embeddedAgent.projectSettingsPolicy": 'How embedded OpenClaw handles workspace-local `.openclaw/settings.json`: "sanitize" (default) strips shellPath/shellCommandPrefix, "ignore" disables project settings entirely, and "trusted" applies project settings as-is.', "agents.defaults.embeddedAgent.executionContract": 'Embedded OpenClaw execution contract: "default" keeps the standard runner behavior, while "strict-agentic" enables structured plan tracking and non-visible turn recovery for supported OpenAI/OpenAI Codex GPT-5-family runs.', - "agents.list[].embeddedAgent": + "agents.entries.*.embeddedAgent": "Optional per-agent embedded OpenClaw overrides. Use this to opt specific agents into stricter GPT-5 execution behavior without changing the global default.", - "agents.list[].embeddedAgent.executionContract": + "agents.entries.*.embeddedAgent.executionContract": 'Optional per-agent embedded OpenClaw execution contract override. Set "strict-agentic" to enable structured plan tracking and non-visible turn recovery for that agent on supported OpenAI/OpenAI Codex GPT-5-family runs, or "default" to inherit the standard runner behavior.', "agents.defaults.humanDelay.mode": 'Delay style for block replies ("off", "natural", "custom").', "agents.defaults.humanDelay.minMs": "Minimum delay in ms for custom humanDelay (default: 800).", "agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).", + "agents.defaults.typingMode": + 'Controls when typing starts for agents: "never", "instant", "thinking", or "message". Per-agent typingMode overrides this default.', + "agents.defaults.typingIntervalSeconds": + "Controls typing-indicator keepalive cadence in seconds. Per-agent typingIntervalSeconds overrides this default.", + "agents.entries.*.typingMode": + "Overrides the default typing start policy for one agent without changing other agents.", + "agents.entries.*.typingIntervalSeconds": + "Overrides the default typing-indicator keepalive cadence for one agent. Use this when one agent needs a different refresh interval from the shared default.", commands: "Controls chat command surfaces, owner gating, and elevated command access behavior across providers. Keep defaults unless you need stricter operator controls or broader command availability.", "commands.native": @@ -206,13 +205,8 @@ export const AGENT_FIELD_HELP: Record = { "Allow /plugins chat command to list discovered plugins and toggle plugin enablement in config (default: false).", "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", "commands.restart": "Allow /restart and external SIGUSR1 restart requests (default: true).", - "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", "commands.ownerAllowFrom": "Explicit owner allowlist for owner-scoped commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", - "commands.ownerDisplay": - "Controls how owner IDs are rendered in the system prompt. Allowed values: raw, hash. Default: raw.", - "commands.ownerDisplaySecret": - "Optional secret used to HMAC hash owner IDs when ownerDisplay=hash. Prefer env substitution.", "commands.allowFrom": "Defines elevated command allow rules by channel and sender for owner-level command surfaces. Use narrow provider-specific identities so privileged commands are not exposed to broad chat audiences.", mcp: "Global MCP server definitions managed by OpenClaw. Embedded OpenClaw and other runtime adapters can consume these servers without storing them inside runtime-owned project settings.", diff --git a/src/config/schema.help.automation.ts b/src/config/schema.help.automation.ts index 8217c1a2f5db..d9f5fd43a592 100644 --- a/src/config/schema.help.automation.ts +++ b/src/config/schema.help.automation.ts @@ -10,8 +10,6 @@ export const AUTOMATION_FIELD_HELP: Record = { "Maps canonical identities to provider-prefixed peer IDs so equivalent users resolve to one DM thread (example: telegram:123456). Use this when the same human appears across multiple channels or accounts.", "session.resetTriggers": "Lists message triggers that force a session reset when matched in inbound content. Use sparingly for explicit reset phrases so context is not dropped unexpectedly during normal conversation.", - "session.idleMinutes": - "Applies a legacy idle reset window in minutes for session reuse behavior across inactivity gaps. Use this only for compatibility and prefer structured reset policies under session.reset/session.resetByType.", "session.reset": "Defines the default reset policy object used when no type-specific or channel-specific override applies. By default sessions do not reset automatically; use daily or idle schedules to opt in, while /new and /reset remain available at any time.", "session.reset.mode": @@ -32,8 +30,6 @@ export const AUTOMATION_FIELD_HELP: Record = { "Provides channel-specific reset overrides keyed by provider/channel id for fine-grained behavior control. Use this only when one channel needs exceptional reset behavior beyond type-level policies.", "session.store": "Sets the session storage file path used to persist session records across restarts. Use an explicit path only when you need custom disk layout, backup routing, or mounted-volume storage.", - "session.typingMode": - 'Controls typing behavior timing: "never", "instant", "thinking", or "message" based emission points. Keep conservative modes in high-volume channels to avoid unnecessary typing noise.', "session.mainKey": 'Overrides the canonical main session key used for continuity when dmScope or routing logic points to "main". Use a stable value only if you intentionally need custom session anchoring.', "session.sendPolicy": @@ -83,8 +79,6 @@ export const AUTOMATION_FIELD_HELP: Record = { cron: "Global scheduler settings for stored cron jobs, run concurrency, delivery fallback, and run-session retention. Keep defaults unless you are scaling job volume or integrating external webhook receivers.", "cron.enabled": "Enables cron job execution for stored schedules managed by the gateway. Keep enabled for normal reminder/automation flows, and disable only to pause all cron execution without deleting jobs.", - "cron.store": - "Path to the cron job store file used to persist scheduled jobs across restarts. Set an explicit path only when you need custom storage layout, backups, or mounted volumes.", "cron.webhookToken": "Bearer token attached to cron webhook POST deliveries when webhook mode is used. Prefer secret/env substitution and rotate this token regularly if shared webhook endpoints are internet-reachable.", "cron.sessionRetention": @@ -225,14 +219,10 @@ export const AUTOMATION_FIELD_HELP: Record = { "Internal hook loader settings controlling where handler modules are discovered at startup. Use constrained load roots to reduce accidental module conflicts or shadowing.", "hooks.internal.load.extraDirs": "Additional directories searched for internal hook modules beyond default load paths. Keep this minimal and controlled to reduce accidental module shadowing.", - "hooks.internal.installs": - "Install metadata for internal hook modules, including source and resolved artifacts for repeatable deployments. Use this as operational provenance and avoid manual drift edits.", messages: - "Message formatting, acknowledgment, queueing, debounce, and status reaction behavior for inbound/outbound chat flows. Use this section when channel responsiveness or message UX needs adjustment.", + "Message infrastructure and cross-agent defaults. Root siblings own infrastructure and cross-agent defaults; agents.defaults owns agent-loop behavior; agent entries may override either where supported.", "messages.visibleReplies": 'Controls model-authored source replies across direct, group, and channel conversations. "message_tool" requires message(action=send) for normal assistant output and generic tool media; explicitly host-owned runtime output remains deliverable except for ambient room events. "automatic" posts normal replies as before.', - "messages.responsePrefix": - "Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.", "messages.usageTemplate": "Custom /usage full footer template, either an inline object or a JSON file path. Invalid or unavailable templates fall back to the built-in usage line.", "messages.responseUsage": @@ -263,25 +253,20 @@ export const AUTOMATION_FIELD_HELP: Record = { "Direct inbound debounce settings used before queue/turn processing starts. Configure this for provider-specific rapid message bursts from the same sender.", "messages.inbound.byChannel": "Per-channel inbound debounce overrides keyed by provider id in milliseconds. Use this where some providers send message fragments more aggressively than others.", - "messages.removeAckAfterReply": - "Removes the acknowledgment reaction after final reply delivery when enabled. Keep enabled for cleaner UX in channels where persistent ack reactions create clutter.", - "messages.tts": - "Text-to-speech policy for reading agent replies aloud on supported voice or audio surfaces. Keep disabled unless voice playback is part of your operator/user workflow.", - "messages.tts.persona": + tts: "Text-to-speech policy for reading agent replies aloud on supported voice or audio surfaces. Keep disabled unless voice playback is part of your operator/user workflow.", + "tts.persona": "Default TTS persona id. Local TTS persona preferences can override this per host.", - "messages.tts.personas": + "tts.personas": "Named TTS personas that define stable spoken identity plus provider-specific speech bindings.", - "messages.tts.personas.*": + "tts.personas.*": "One TTS persona. Use provider-specific bindings for exact voices/models and prompt templates.", - "messages.tts.personas.*.prompt": - "Provider-neutral persona prompt intent. Providers decide whether and how to map this into request instructions.", - "messages.tts.personas.*.providers": - "Provider-specific TTS persona bindings keyed by speech provider id. These merge over messages.tts.providers for the active persona.", - "messages.tts.providers": + "tts.personas.*.providers": + "Provider-specific TTS persona bindings keyed by speech provider id. These merge over tts.providers for the active persona.", + "tts.providers": "Provider-specific TTS settings keyed by speech provider id. Use this instead of bundled provider-specific top-level keys so speech plugins stay decoupled from core config schema.", - "messages.tts.providers.*": + "tts.providers.*": "Provider-specific TTS configuration for one speech provider id. Keep fields scoped to the plugin that owns that provider.", - "messages.tts.providers.*.apiKey": + "tts.providers.*.apiKey": "Provider API key used by that speech provider when its plugin requires authenticated TTS access.", // pragma: allowlist secret channels: "Channel provider configurations plus shared defaults that control access policies, heartbeat visibility, and per-surface behavior. Keep defaults centralized and override per provider only where required.", @@ -301,13 +286,13 @@ export const AUTOMATION_FIELD_HELP: Record = { "Treat quoted bot messages as implicit mentions by default. Defaults to true for backward compatibility.", "channels.defaults.implicitMentions.threadParticipation": "Treat follow-ups in threads where the bot participated as implicit mentions by default. Defaults to true for backward compatibility.", - "channels.defaults.heartbeat": + "channels.defaults.heartbeatVisibility": "Default heartbeat visibility settings for status messages emitted by providers/channels. Tune this globally to reduce noisy healthy-state updates while keeping alerts visible.", - "channels.defaults.heartbeat.showOk": + "channels.defaults.heartbeatVisibility.showOk": "Shows healthy/OK heartbeat status entries when true in channel status outputs. Keep false in noisy environments and enable only when operators need explicit healthy confirmations.", - "channels.defaults.heartbeat.showAlerts": + "channels.defaults.heartbeatVisibility.showAlerts": "Shows degraded/error heartbeat alerts when true so operator channels surface problems promptly. Keep enabled in production so broken channel states are visible.", - "channels.defaults.heartbeat.useIndicator": + "channels.defaults.heartbeatVisibility.useIndicator": "Enables concise indicator-style heartbeat rendering instead of verbose status text where supported. Use indicator mode for dense dashboards with many active channels.", "channels.defaults.botLoopProtection": "Default pair loop protection settings for channel providers that support bot-to-bot loop guards. Use provider-specific overrides only when one channel needs a different budget.", @@ -319,16 +304,10 @@ export const AUTOMATION_FIELD_HELP: Record = { "Sliding window length in seconds for pair loop budgets. Default for supporting channels is 60.", "channels.defaults.botLoopProtection.cooldownSeconds": "Cooldown seconds applied to a pair after it exceeds the loop budget. Default for supporting channels is 60.", - "agents.defaults.heartbeat.includeSystemPromptSection": - "Includes the default agent's ## Heartbeats system prompt section when true. Turn this off to keep heartbeat runtime behavior while omitting the heartbeat prompt instructions from the agent system prompt.", - "agents.list.*.heartbeat.includeSystemPromptSection": - "Per-agent override for whether the default agent's ## Heartbeats system prompt section is injected. Use false to keep heartbeat runtime behavior but omit the heartbeat prompt instructions from that agent's system prompt.", "agents.defaults.heartbeat.directPolicy": 'Controls whether heartbeat delivery may target direct/DM chats: "allow" (default) permits DM delivery and "block" suppresses direct-target sends.', - "agents.list.*.heartbeat.directPolicy": + "agents.entries.*.heartbeat.directPolicy": 'Per-agent override for heartbeat direct/DM delivery policy; use "block" for agents that should only send heartbeat alerts to non-DM destinations.', - "agents.list.*.heartbeat.skipWhenBusy": - "Per-agent override that defers heartbeat turns on that agent's extra busy lanes: its own session-keyed subagent or nested command work. Cron lanes always defer heartbeat turns.", "channels.mattermost.configWrites": "Allow Mattermost to write config in response to channel events/commands (default: true).", "channels.modelByChannel": @@ -342,8 +321,6 @@ export const AUTOMATION_FIELD_HELP: Record = { "Lifecycle status reactions that update the emoji on the trigger message as the agent progresses (queued → thinking → tool → done/error).", "messages.statusReactions.enabled": "Enable lifecycle status reactions on supported channels. Discord treats unset as enabled when ack reactions are active; Slack, Signal, Telegram, and WhatsApp require this to be true before lifecycle reactions are used. Slack uses native assistant thread status for progress by default.", - "messages.statusReactions.emojis": - "Override default status reaction emojis. Keys: queued, thinking, compacting, tool, coding, web, deploy, build, concierge, done, error, stallSoft, stallHard. Telegram chooses the first supported fallback when a configured emoji is not available in the chat.", "messages.inbound.debounceMs": "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", }; diff --git a/src/config/schema.help.core.ts b/src/config/schema.help.core.ts index 22ed4f112b49..8124c4bd514f 100644 --- a/src/config/schema.help.core.ts +++ b/src/config/schema.help.core.ts @@ -8,34 +8,11 @@ export const CORE_FIELD_HELP: Record = { "OAuth2 client secret for the Discord application that hosts Activities. Keep this value secret; DISCORD_CLIENT_SECRET is used when this field is unset.", "channels.discord.activities.applicationId": "Optional Discord application ID for Activities. Defaults to the bot application ID learned from Discord at gateway startup.", - meta: "Metadata fields automatically maintained by OpenClaw to record write/version history for this config file. Keep these values system-managed and avoid manual edits unless debugging migration history.", - "meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.", - "meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).", - "meta.migrations": "System-managed completion markers for one-time config migrations.", + meta: "Backward-readable compatibility metadata retained so older binaries can refuse unsafe config downgrades.", + "meta.lastTouchedVersion": "OpenClaw version that most recently wrote this config.", + "meta.migrations": "Bounded compatibility markers for completed config migrations.", "meta.migrations.modelPolicyAllowlist": - "Records that doctor preserved or evaluated legacy default and per-agent model-map override restrictions.", - marketplaces: - "Marketplace feed and local package source profile settings. Feeds provide package selection and governance metadata, while sources define the local source names that install candidates may reference.", - "marketplaces.feeds": - "Named marketplace feed profiles. The default public profile can be used as shipped, and deployments can add or override profiles to point OpenClaw at their effective feed endpoint.", - "marketplaces.feeds.*.url": - "HTTPS URL for the marketplace feed profile. Remote feed documents cannot introduce new registry domains or credentials; they only reference locally configured sources by name.", - "marketplaces.feeds.*.verification": - "Feed authenticity policy. Use unsigned only as an explicit local opt-in for self-hosted feeds. Use signed to name locally trusted publisher public keys before refresh enforcement consumes verified envelopes.", - "marketplaces.feeds.*.verification.mode": - 'Feed verification mode: "unsigned" for explicit unsigned opt-in, or "signed" for local direct-key verification once refresh enforcement is wired.', - "marketplaces.feeds.*.verification.keys": - "Trusted publisher public keys for signed feed envelopes. These non-secret local trust anchors belong in source-profile config; remote feed documents cannot introduce or rotate them by themselves.", - "marketplaces.feeds.*.verification.keys[].keyId": - "Stable publisher key identifier expected in a signed feed envelope signature.", - "marketplaces.feeds.*.verification.keys[].publicKey": - "Ed25519 publisher public key for feed envelope verification, encoded as PEM or raw base64url. Feed signing private keys are publisher-side secrets managed outside this config; do not reuse platform signing identities or certificates here.", - "marketplaces.feeds.*.verification.threshold": - "Optional number of trusted signatures required for a signed feed. Defaults to one in the later refresh enforcement slice.", - "marketplaces.sources": - "Named package source profiles that feed entries can reference using sourceRef. Keep credentials and registry endpoints local so remote feeds cannot bootstrap trust roots.", - "marketplaces.sources.*.type": - "Package source profile type: npm, clawhub, or git. This slice validates sourceRef names only; registry and host endpoints are added when installer resolution can enforce them.", + "Records that legacy model-map restrictions were preserved or evaluated.", env: "Environment import and override settings used to supply runtime variables to the gateway process. Use this section to control shell-env loading and explicit variable injection behavior.", "env.shellEnv": "Shell environment import controls for loading variables from your login shell during startup. Keep this enabled when you depend on profile-defined secrets or PATH customizations.", @@ -46,27 +23,26 @@ export const CORE_FIELD_HELP: Record = { "env.vars": "Explicit key/value environment variable overrides merged into runtime process environment for OpenClaw. Use this for deterministic env configuration instead of relying only on shell profile side effects.", wizard: - "Setup wizard state tracking fields that record the most recent guided setup run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.", - "wizard.lastRunAt": - "ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm setup recency during support and operational audits.", - "wizard.lastRunVersion": - "OpenClaw version recorded at the time of the most recent wizard run on this config. Use this when diagnosing behavior differences across version-to-version setup changes.", - "wizard.lastRunCommit": - "Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate setup behavior with exact source state during debugging.", - "wizard.lastRunCommand": - "Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce setup steps when verifying setup regressions.", - "wizard.lastRunMode": - 'Wizard execution mode recorded as "local" or "remote" for the most recent setup flow. Use this to understand whether setup targeted direct local runtime or remote gateway topology.', + "User-owned setup preferences. Machine-owned wizard history and acknowledgement state live in the shared state database.", + "wizard.accessMode": + 'Discovery consent for guided setup: "full" scans silently while "guarded" asks before inspecting local applications.', + "wizard.appRecommendations": + "Controls whether guided setup may use installed-application labels to recommend relevant plugins and skills.", + "wizard.lastRunAt": "Timestamp of the last successfully committed wizard run.", + "wizard.lastRunVersion": "OpenClaw version used by the last wizard run.", + "wizard.lastRunCommit": "Source commit used by the last development wizard run.", + "wizard.lastRunCommand": "Command that invoked the last wizard run.", + "wizard.lastRunMode": 'Whether the last wizard run targeted "local" or "remote" setup.', "wizard.localModelLeanAutoModel": - "System-managed model reference indicating that inference onboarding enabled the global lean-model surface. A later verified route uses this marker to remove only the onboarding-owned setting; a model changed outside onboarding hands ownership back to the user.", + "Model reference whose lean-mode setting remains owned by onboarding.", "wizard.securityAcknowledgedAt": - "ISO timestamp for when the setup security acknowledgement was accepted on this config. Setup uses this to avoid repeating the acknowledgement on later wizard runs.", - audit: + "Timestamp of the setup security acknowledgement, committed with the target config.", + "logging.audit": "Bounded metadata-only audit history for operator review. Run and tool records are enabled by default; message lifecycle metadata is a separate privacy-sensitive opt-in. The background writer is best-effort rather than a lossless compliance archive.", - "audit.enabled": + "logging.audit.enabled": "Records new run, tool, and enabled message audit events. Default: true. Disabling event inserts does not immediately delete existing records; retained rows remain queryable until they expire.", - "audit.messages": - 'Controls content-free message lifecycle records: "off" (default), "direct" for known direct conversations only, or "all" for direct, group, channel, and unknown conversation kinds. Both audit.enabled and audit.messages are startup-scoped; restart the Gateway after changing either setting.', + "logging.audit.messages": + 'Controls content-free message lifecycle records: "off" (default), "direct" for known direct conversations only, or "all" for direct, group, channel, and unknown conversation kinds. Both logging.audit.enabled and logging.audit.messages are startup-scoped; restart the Gateway after changing either setting.', diagnostics: "Diagnostics controls for targeted tracing, telemetry export, and cache inspection during debugging. Keep baseline diagnostics minimal in production and enable deeper signals only when investigating issues.", "diagnostics.otel": @@ -82,16 +58,9 @@ export const CORE_FIELD_HELP: Record = { "logging.consoleLevel": 'Console-specific log threshold: "silent", "fatal", "error", "warn", "info", "debug", or "trace" for terminal output control. Use this to keep local console quieter while retaining richer file logging if needed.', "logging.consoleStyle": - 'Console output format style: "pretty", "compact", or "json" based on operator and ingestion needs. Use json for machine parsing pipelines and pretty/compact for human-first terminal workflows.', - "logging.redactSensitive": - 'Sensitive log/transcript redaction mode: "off" disables general log and transcript masking, while "tools" redacts sensitive tool/config payload fields in those sinks. Safety-boundary UI, tool, and diagnostic payloads may still redact even when this is "off".', + 'Console output format style: "pretty" or "json". Use json for machine parsing pipelines and pretty for human-first terminal workflows.', "logging.redactPatterns": "Additional custom redact regex patterns applied to log output, persisted transcript text, and safety-boundary UI/tool/diagnostic payloads before emission. Use this to mask org-specific tokens and identifiers not covered by built-in redaction rules.", - cli: "CLI presentation controls for local command output behavior such as banner and tagline style. Use this section to keep startup output aligned with operator preference without changing runtime behavior.", - "cli.banner": - "CLI startup banner controls for title/version line and tagline style behavior. Keep banner enabled for fast version/context checks, then tune tagline mode to your preferred noise level.", - "cli.banner.taglineMode": - 'Controls tagline style in the CLI startup banner: "random" (default) picks from the rotating tagline pool, "default" always shows the neutral default tagline, and "off" hides tagline text while keeping the banner version line.', update: "Update-channel and startup-check behavior for keeping OpenClaw runtime versions current. Use conservative channels in production and more experimental channels only in controlled environments.", "update.channel": @@ -103,7 +72,7 @@ export const CORE_FIELD_HELP: Record = { cloudWorkers: "Opt-in cloud worker profiles for disposable remote environments. When this section is omitted or has no profiles, cloud worker creation remains unavailable and existing gateway/node status behavior is unchanged.", "cloudWorkers.profiles": - "Named cloud worker profiles. Each profile selects a worker provider registered by a plugin and carries provider-owned settings plus optional stored lifetime policy.", + "Named cloud worker profiles. Each profile selects a worker provider registered by a plugin and carries provider-owned settings.", "cloudWorkers.profiles.*": "One cloud worker profile selected by name when creating an environment. Keep provider credentials in supported references rather than embedding secret material in this block.", "cloudWorkers.profiles.*.provider": @@ -112,12 +81,6 @@ export const CORE_FIELD_HELP: Record = { 'Worker installation method: "bundle" (default) transfers the gateway\'s content-hashed installed build and supports released, development, and unreleased versions; "npm" installs the exact gateway version and is available only when that version is released.', "cloudWorkers.profiles.*.settings": "Provider-owned settings validated by the selected plugin. Use SecretRef objects for secret-bearing values; opaque settings do not gain automatic secret resolution.", - "cloudWorkers.profiles.*.lifetime": - "Stored environment lifetime policy. This first cloud-worker slice records these values as data; automatic idle and maximum-lifetime enforcement lands in later lifecycle work.", - "cloudWorkers.profiles.*.lifetime.idleTimeoutMinutes": - "Positive inactivity interval in minutes after which later lifecycle policy may reclaim an idle environment. Omit to leave idle cleanup unspecified.", - "cloudWorkers.profiles.*.lifetime.maxLifetimeMinutes": - "Positive maximum environment lifetime in minutes for later lifecycle enforcement. Omit to leave the maximum lifetime unspecified.", gateway: "Gateway runtime surface for bind mode, auth, control UI, remote transport, and operational safety controls. Keep conservative defaults unless you intentionally expose the gateway beyond trusted local interfaces.", "gateway.port": @@ -280,35 +243,31 @@ export const CORE_FIELD_HELP: Record = { "Per-sessionUpdate visibility overrides for ACP projection (for example usage_update, available_commands_update).", "acp.runtime.installCommand": "Optional operator install/setup command shown by `/acp install` and `/acp doctor` when ACP backend wiring is missing.", - commitments: - "Inferred follow-up commitment controls for automatically detecting check-ins from conversation turns and delivering them through heartbeat runs.", - "commitments.enabled": - "Enable hidden LLM extraction, storage, and heartbeat delivery for inferred follow-up commitments. Default: false.", - "commitments.maxPerDay": - "Maximum inferred follow-up commitments delivered per agent session in a rolling day. Default: 3.", - "agents.list.*.skills": - "Optional allowlist of skills for this agent. If omitted, the agent inherits agents.defaults.skills when set; otherwise skills stay unrestricted. Set [] for no skills. An explicit list fully replaces inherited defaults instead of merging with them.", - "agents.list[].skills": + surfaces: + "Per-surface message policy overrides keyed by the resolved delivery surface id. Use this only when one deployed surface needs stricter silent-reply handling than the agent default.", + "surfaces.*.silentReply": + "Overrides silent-reply policy for one resolved delivery surface. Unset fields inherit agents.defaults.silentReply; use narrow surface ids so internal or group-specific behavior does not spill into other destinations.", + "agents.entries.*.skills": "Optional allowlist of skills for this agent. If omitted, the agent inherits agents.defaults.skills when set; otherwise skills stay unrestricted. Set [] for no skills. An explicit list fully replaces inherited defaults instead of merging with them.", agents: - "Agent runtime configuration root covering defaults and explicit agent entries used for routing and execution context. Keep this section explicit so model/tool behavior stays predictable across multi-agent workflows.", + "Agent runtime configuration root. Root siblings own infrastructure and cross-agent defaults; agents.defaults owns agent-loop behavior; agent entries may override either where supported.", "agents.defaults": - "Shared default settings inherited by agents unless overridden per entry in agents.list. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.", + "Shared default settings inherited by agents unless overridden per entry in agents.entries. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.", "agents.defaults.skills": - "Optional default skill allowlist inherited by agents that omit agents.list[].skills. Omit for unrestricted skills, set [] to give inheriting agents no skills, and remember explicit agents.list[].skills replaces this default instead of merging with it.", + "Optional default skill allowlist inherited by agents that omit agents.entries.*.skills. Omit for unrestricted skills, set [] to give inheriting agents no skills, and remember explicit agents.entries.*.skills replaces this default instead of merging with it.", "agents.defaults.subagents.delegationMode": 'Prompt-only sub-agent delegation strength. "suggest" keeps the default guidance; "prefer" strongly instructs the main agent to delegate anything more involved than a direct reply via sessions_spawn.', - "agents.list[].subagents.delegationMode": + "agents.entries.*.subagents.delegationMode": "Per-agent override for sub-agent delegation strength. Use this for coordinator agents that should stay responsive and push non-trivial work into spawned sub-agents.", - "agents.list[].contextInjection": + "agents.entries.*.contextInjection": "Per-agent override for when workspace bootstrap files are injected into this agent's system prompt. Omit to inherit agents.defaults.contextInjection.", - "agents.list[].bootstrapMaxChars": + "agents.entries.*.bootstrapMaxChars": "Per-agent override for max characters of each workspace bootstrap file injected into this agent's system prompt. Omit to inherit agents.defaults.bootstrapMaxChars.", - "agents.list[].bootstrapTotalMaxChars": + "agents.entries.*.bootstrapTotalMaxChars": "Per-agent override for max total characters across all workspace bootstrap files injected into this agent's system prompt. Omit to inherit agents.defaults.bootstrapTotalMaxChars.", - "agents.list[].experimental": + "agents.entries.*.experimental": "Per-agent experimental flags. Omitted fields inherit agents.defaults.experimental.", - "agents.list[].experimental.localModelLean": + "agents.entries.*.experimental.localModelLean": "Per-agent override for lean local-model mode. Enable it for one smaller local-model agent without trimming tools from every agent.", "agents.defaults.contextLimits": "Focused per-agent-context budget defaults for selected high-volume excerpts and injected prompt blocks. Use this to tune bounded read/injection sizes without reopening any unbounded call paths.", @@ -320,54 +279,48 @@ export const CORE_FIELD_HELP: Record = { "Advanced ceiling for a single live tool result before truncation. Leave unset to use the model-context auto cap; explicit values affect both persisted live tool-result writes and overflow-recovery truncation heuristics.", "agents.defaults.contextLimits.postCompactionMaxChars": "Default max characters retained from AGENTS.md during post-compaction context refresh injection. Lower this to make compaction recovery cheaper, or raise it for agents that depend on longer startup guidance.", - "agents.list": + "agents.entries": "Explicit list of configured agents with IDs and optional overrides for model, tools, identity, and workspace. Keep IDs stable over time so bindings, approvals, and session routing remain deterministic.", - "agents.list[].skillsLimits": + "agents.entries.*.skillsLimits": "Optional per-agent overrides for skills subsystem budgets. Use this when an agent needs a different skills prompt budget without introducing a second generic context-limits path.", - "agents.list[].skillsLimits.maxSkillsPromptChars": + "agents.entries.*.skillsLimits.maxSkillsPromptChars": "Per-agent override for the skills prompt character budget. This extends the existing skills.limits.maxSkillsPromptChars path instead of routing the same budget through contextLimits.", - "agents.list[].contextLimits": + "agents.entries.*.contextLimits": "Optional per-agent overrides for the focused context budget knobs. Omitted fields inherit agents.defaults.contextLimits.", - "agents.list[].contextLimits.memoryGetMaxChars": + "agents.entries.*.contextLimits.memoryGetMaxChars": "Per-agent override for the default memory_get max character budget.", - "agents.list[].contextLimits.memoryGetDefaultLines": + "agents.entries.*.contextLimits.memoryGetDefaultLines": "Per-agent override for the default memory_get line window when lines is omitted.", - "agents.list[].contextLimits.toolResultMaxChars": + "agents.entries.*.contextLimits.toolResultMaxChars": "Per-agent advanced ceiling for the live tool-result max character budget. Omit to inherit defaults or the model-context auto cap.", - "agents.list[].contextLimits.postCompactionMaxChars": + "agents.entries.*.contextLimits.postCompactionMaxChars": "Per-agent override for the post-compaction AGENTS.md excerpt budget.", - "agents.list[].thinkingDefault": + "agents.entries.*.thinkingDefault": "Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.", - "agents.list[].reasoningDefault": + "agents.entries.*.reasoningDefault": "Optional per-agent default reasoning visibility (on|off|stream). Applies when no per-message or session reasoning override is set.", - "agents.list[].fastModeDefault": + "agents.entries.*.fastModeDefault": 'Optional per-agent default for fast mode ("auto", true, or false). Applies when no per-message or session fast-mode override is set.', - "agents.list[].runtime": + "agents.defaults.fastModeDefault": + 'Default fast-mode policy for the agent loop ("auto", true, or false). Individual agent entries override it.', + "agents.entries.*.runtime": "Optional runtime descriptor for this agent. Use embedded for default OpenClaw execution or acp for external ACP harness defaults.", - "agents.list[].runtime.type": + "agents.entries.*.runtime.type": 'Runtime type for this agent: "embedded" (default OpenClaw runtime) or "acp" (ACP harness defaults).', - "agents.list[].runtime.acp": + "agents.entries.*.runtime.acp": "ACP runtime defaults for this agent when runtime.type=acp. Binding-level ACP overrides still take precedence per conversation.", - "agents.list[].runtime.acp.agent": + "agents.entries.*.runtime.acp.agent": "Optional ACP harness agent id to use for this OpenClaw agent (for example codex, claude, cursor, gemini, openclaw).", - "agents.list[].runtime.acp.backend": + "agents.entries.*.runtime.acp.backend": "Optional ACP backend override for this agent's ACP sessions (falls back to global acp.backend).", - "agents.list[].runtime.acp.mode": + "agents.entries.*.runtime.acp.mode": "Optional ACP session mode default for this agent (persistent or oneshot).", - "agents.list[].runtime.acp.cwd": + "agents.entries.*.runtime.acp.cwd": "Optional default working directory for this agent's ACP sessions.", - "agents.list[].identity.avatar": + "agents.entries.*.identity.avatar": "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", - "agents.defaults.heartbeat.suppressToolErrorWarnings": - "Suppress tool error warning payloads during heartbeat runs.", - "agents.list[].heartbeat.suppressToolErrorWarnings": - "Suppress tool error warning payloads during heartbeat runs.", "agents.defaults.heartbeat.timeoutSeconds": "Maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use agents.defaults.timeoutSeconds when set, otherwise the heartbeat cadence capped at 600 seconds.", - "agents.list[].heartbeat.timeoutSeconds": + "agents.entries.*.heartbeat.timeoutSeconds": "Per-agent maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to inherit the merged heartbeat timeout, then agents.defaults.timeoutSeconds when set, otherwise the heartbeat cadence capped at 600 seconds.", - "agents.defaults.heartbeat.skipWhenBusy": - "When true, defer heartbeat turns on this agent's extra busy lanes: its own session-keyed subagent or nested command work. Cron lanes always defer heartbeat turns.", - "agents.list[].heartbeat.skipWhenBusy": - "Per-agent override that defers heartbeat turns on that agent's extra busy lanes: its own session-keyed subagent or nested command work. Cron lanes always defer heartbeat turns.", }; diff --git a/src/config/schema.help.models.ts b/src/config/schema.help.models.ts index 32312bf3027a..362a1b81914a 100644 --- a/src/config/schema.help.models.ts +++ b/src/config/schema.help.models.ts @@ -94,7 +94,7 @@ export const MODEL_FIELD_HELP: Record = { proxy: "Operator-managed forward proxy routing for OpenClaw runtime HTTP, HTTPS, WebSocket, and supported raw-egress paths. Use this when central egress control is part of the deployment boundary.", "proxy.enabled": - "Enables operator-managed proxy routing. When enabled, OpenClaw fails startup if no managed proxy URL is configured.", + "Explicit managed-proxy override. URL presence enables routing by default; set false to ignore configured or environment proxy URLs without deleting them.", "proxy.proxyUrl": "Managed forward proxy URL. Use http:// for a plain CONNECT proxy or https:// when the connection to the proxy endpoint itself must use TLS.", "proxy.tls": @@ -167,8 +167,6 @@ export const MODEL_FIELD_HELP: Record = { "Experimental agent-default flags. Keep these off unless you are intentionally testing a preview surface.", "agents.defaults.experimental.localModelLean": "Experimental local-model prompt trim. When enabled, OpenClaw drops heavyweight default tools like browser, cron, and message for weaker or smaller local-model backends.", - "agents.defaults.bootstrapPromptTruncationWarning": - 'Inject agent-visible warning text when bootstrap files are truncated: "off", "once", or "always" (default).', "agents.defaults.startupContext": 'Runtime-owned first-turn prelude for bare "/new" and "/reset". Use this to control whether recent daily memory files are preloaded into the first prompt instead of asking the model to decide what to read.', "agents.defaults.startupContext.enabled": @@ -185,17 +183,6 @@ export const MODEL_FIELD_HELP: Record = { "Maximum total characters retained across all loaded daily memory files in the startup prelude (default: 2800). Additional files are truncated from the prelude once this cap is reached.", "agents.defaults.repoRoot": "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", - "agents.defaults.promptOverlays": - "Provider-independent prompt overlays applied by model family before provider-specific prompt hooks.", - "agents.defaults.promptOverlays.gpt5": - "Shared GPT-5-family prompt overlay applied to matching model ids across providers such as OpenAI, OpenRouter, OpenCode, Codex, and compatible gateways.", - "agents.defaults.promptOverlays.gpt5.personality": - 'Friendly interaction-style layer for GPT-5-family models ("friendly" or "on" enables it; "off" disables only that layer). The tagged behavior contract remains enabled for matching GPT-5 models.', - "agents.defaults.envelopeTimezone": - 'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).', - "agents.defaults.envelopeTimestamp": - 'Include absolute timestamps in message envelopes, direct agent prompt prefixes, and embedded model-input prefixes ("on" or "off").', - "agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").', "agents.defaults.models": "Configured model catalog and per-model settings. Entries provide aliases, params, and runtime metadata; they do not restrict model overrides.", "agents.defaults.modelPolicy": @@ -206,87 +193,68 @@ export const MODEL_FIELD_HELP: Record = { "Optional per-model runtime policy for the default agent. Use this for model-specific runtime exceptions instead of setting a whole-agent runtime.", "agents.defaults.models.*.agentRuntime.id": 'Default-agent model runtime id: "openclaw", "auto", a registered plugin harness id such as "codex", or a supported CLI backend alias such as "claude-cli".', - "agents.defaults.memorySearch": - "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", - "agents.defaults.memorySearch.enabled": + "memory.search": "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", + "memory.search.enabled": "Master toggle for memory search indexing and retrieval behavior on this agent profile. Keep enabled for semantic recall, and disable when you want fully stateless responses.", - "agents.defaults.memorySearch.rememberAcrossConversations": + "memory.search.rememberAcrossConversations": 'Use relevant context from this agent\'s other private conversations through protected transcript recall. Defaults on only when global session.dmScope is unset or "main" and no binding overrides DM scope; any configured DM isolation defaults it off. An explicit true or false always wins.', - "agents.defaults.memorySearch.sources": + "memory.search.sources": 'Chooses which sources are indexed: "memory" reads MEMORY.md + memory files, and "sessions" includes transcript history. Keep ["memory"] unless you need recall from prior chat transcripts.', - "agents.defaults.memorySearch.extraPaths": + "memory.search.extraPaths": "Adds extra directories or .md files to the memory index beyond default memory files. Use this when key reference docs live elsewhere in your repo; when multimodal memory is enabled, matching image/audio files under these paths are also eligible for indexing.", - "agents.defaults.memorySearch.qmd": + "memory.search.qmd": "Use this when one agent should query another agent's transcript collections; QMD-specific extra collections let you opt into cross-agent memory search without flattening everything into one shared namespace.", - "agents.defaults.memorySearch.qmd.extraCollections": + "memory.search.qmd.extraCollections": "Use this when you need directional transcript search across agents; add collections here to scope QMD recalls without creating a shared global transcript namespace.", - "agents.defaults.memorySearch.qmd.extraCollections.path": + "memory.search.qmd.extraCollections.path": "Use an absolute or workspace-relative filesystem path for the extra QMD collection; keep it pointed at the transcript directory or note folder you actually want this agent to search.", - "agents.defaults.memorySearch.qmd.extraCollections.name": + "memory.search.qmd.extraCollections.name": "Preserves the configured collection label only when the path points outside the agent workspace; paths inside the workspace stay agent-scoped even if a name is provided. Use this for shared cross-agent transcript roots that live outside the workspace.", - "agents.defaults.memorySearch.qmd.extraCollections.pattern": + "memory.search.qmd.extraCollections.pattern": "Use a glob pattern to restrict which files inside the collection are indexed; keep the default `**/*.md` unless you need a narrower subset.", - "agents.defaults.memorySearch.multimodal": - 'Optional multimodal memory settings for indexing image and audio files from configured extra paths. Keep this off unless your embedding model explicitly supports cross-modal embeddings, and set `memorySearch.fallback` to "none" while it is enabled. Matching files are uploaded to the configured remote embedding provider during indexing.', - "agents.defaults.memorySearch.multimodal.enabled": + "memory.search.multimodal": + 'Optional multimodal memory settings for indexing image and audio files from configured extra paths. Keep this off unless your embedding model explicitly supports cross-modal embeddings, and set `memory.search.fallback` to "none" while it is enabled. Matching files are uploaded to the configured remote embedding provider during indexing.', + "memory.search.multimodal.enabled": "Enables image/audio memory indexing from extraPaths. This currently requires Gemini embedding-2, keeps the default memory roots Markdown-only, disables memory-search fallback providers, and uploads matching binary content to the configured remote embedding provider.", - "agents.defaults.memorySearch.multimodal.modalities": + "memory.search.multimodal.modalities": 'Selects which multimodal file types are indexed from extraPaths: "image", "audio", or "all". Keep this narrow to avoid indexing large binary corpora unintentionally.', - "agents.defaults.memorySearch.multimodal.maxFileBytes": + "memory.search.multimodal.maxFileBytes": "Sets the maximum bytes allowed per multimodal file before it is skipped during memory indexing. Use this to cap upload cost and indexing latency, or raise it for short high-quality audio clips.", - "agents.defaults.memorySearch.experimental.sessionMemory": - "Indexes session transcripts into memory search so responses can reference prior chat turns. Keep this off unless transcript recall is needed, because indexing cost and storage usage both increase.", - "agents.defaults.memorySearch.provider": + "memory.search.experimental.sessionMemory": + "Indexes session transcripts into memory search. Keep this advanced override when root and per-agent recall inheritance differ.", + "memory.search.provider": 'Selects the embedding backend used to build/query memory vectors. Defaults to "openai"; set "openai-compatible", "gemini", "voyage", "mistral", "bedrock", "deepinfra", "github-copilot", "lmstudio", "ollama", or "local" when you want a different backend.', - "agents.defaults.memorySearch.model": + "memory.search.model": "Embedding model override used by the selected memory provider when a non-default model is required. Set this only when you need explicit recall quality/cost tuning beyond provider defaults.", - "agents.defaults.memorySearch.inputType": + "memory.search.inputType": "Use this optional provider-specific `input_type` value only when the same label should apply to both query and document embedding requests. For asymmetric providers, prefer queryInputType and documentInputType.", - "agents.defaults.memorySearch.queryInputType": + "memory.search.queryInputType": "Optional provider-specific `input_type` value for query-time memory embeddings. Use this with OpenAI-compatible asymmetric embedding endpoints that require a query label.", - "agents.defaults.memorySearch.documentInputType": + "memory.search.documentInputType": "Optional provider-specific `input_type` value for document and indexing memory embeddings. Use this with OpenAI-compatible asymmetric embedding endpoints that require a passage or document label.", - "agents.defaults.memorySearch.outputDimensionality": + "memory.search.outputDimensionality": "Provider-specific output vector size override for memory embeddings. Gemini embedding-2 supports 768, 1536, or 3072; Bedrock families such as Titan V2, Cohere V4, and Nova expose their own allowed sizes. Expect a full reindex when you change it because stored vector dimensions must stay consistent.", - "agents.defaults.memorySearch.remote.baseUrl": + "memory.search.remote.baseUrl": "Overrides the embedding API endpoint, such as an OpenAI-compatible proxy or custom Gemini base URL. Use this only when routing through your own gateway or vendor endpoint; keep provider defaults otherwise.", - "agents.defaults.memorySearch.remote.apiKey": + "memory.search.remote.apiKey": "Supplies a dedicated API key for remote embedding calls used by memory indexing and query-time embeddings. Use this when memory embeddings should use different credentials than global defaults or environment variables.", - "agents.defaults.memorySearch.remote.headers": + "memory.search.remote.headers": "Adds custom HTTP headers to remote embedding requests, merged with provider defaults. Use this for proxy auth and tenant routing headers, and keep values minimal to avoid leaking sensitive metadata.", - "agents.defaults.memorySearch.remote.nonBatchConcurrency": - "Controls concurrent inline embedding requests during non-batch memory indexing. Use a low value for local or small self-hosted providers such as Ollama; batch embedding concurrency is configured separately under remote.batch.", - "agents.defaults.memorySearch.remote.batch.enabled": + "memory.search.remote.batch.enabled": "Enables provider batch APIs for embedding jobs when supported (OpenAI/Gemini), improving throughput on larger index runs. Keep this enabled unless debugging provider batch failures or running very small workloads.", - "agents.defaults.memorySearch.remote.batch.wait": - "Waits for batch embedding jobs to fully finish before the indexing operation completes. Keep this enabled for deterministic indexing state; disable only if you accept delayed consistency.", - "agents.defaults.memorySearch.remote.batch.concurrency": - "Limits how many embedding batch jobs run at the same time during indexing (default: 2). Increase carefully for faster bulk indexing, but watch provider rate limits and queue errors.", - "agents.defaults.memorySearch.remote.batch.pollIntervalMs": - "Controls how often the system polls provider APIs for batch job status in milliseconds (default: 2000). Use longer intervals to reduce API chatter, or shorter intervals for faster completion detection.", - "agents.defaults.memorySearch.remote.batch.timeoutMinutes": - "Sets the maximum wait time for a full embedding batch operation in minutes (default: 60). Increase for very large corpora or slower providers, and lower it to fail fast in automation-heavy flows.", - "agents.defaults.memorySearch.local.modelPath": + "memory.search.local.modelPath": "Specifies the local embedding model source for local memory search, such as a GGUF file path or `hf:` URI. Use this only when provider is `local`, and verify model compatibility before large index rebuilds.", - "agents.defaults.memorySearch.local.contextSize": - 'Context window size passed to node-llama-cpp when creating the embedding context (default: 4096). 4096 safely covers typical memory-search chunks (128\u2013512 tokens) while keeping non-weight VRAM bounded. Lower to 1024\u20132048 on resource-constrained hosts. Set to "auto" to let node-llama-cpp use the model\'s trained maximum \u2014 not recommended for large models (e.g. Qwen3-Embedding-8B trained on 40\u202f960 tokens can push VRAM from ~8.8\u202fGB to ~32\u202fGB).', - "agents.defaults.memorySearch.fallback": + "memory.search.store.vector.enabled": + "Controls the sqlite-vec semantic index. Keep this advanced override when root and per-agent vector policies differ.", + "memory.search.fallback": 'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "bedrock", "lmstudio", "ollama", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.', - "agents.defaults.memorySearch.store.vector.enabled": - "Enables the sqlite-vec extension used for vector similarity queries in memory search (default: true). Keep this enabled for normal semantic recall; disable only for debugging or fallback-only operation.", - "agents.defaults.memorySearch.store.vector.extensionPath": + "memory.search.store.vector.extensionPath": "Overrides the auto-discovered sqlite-vec extension library path (`.dylib`, `.so`, or `.dll`). Use this when your runtime cannot find sqlite-vec automatically or you pin a known-good build.", - "agents.defaults.memorySearch.query.maxResults": + "memory.search.query.maxResults": "Maximum number of memory hits returned from search before downstream reranking and prompt injection. Raise for broader recall, or lower for tighter prompts and faster responses.", - "agents.defaults.memorySearch.query.minScore": + "memory.search.query.minScore": "Minimum relevance score threshold for including memory results in final recall output. Increase to reduce weak/noisy matches, or lower when you need more permissive retrieval.", - "agents.defaults.memorySearch.query.hybrid.enabled": - "Combines BM25 keyword matching with vector similarity for better recall on mixed exact + semantic queries. Keep enabled unless you are isolating ranking behavior for troubleshooting.", - "agents.defaults.memorySearch.query.hybrid.mmr.enabled": - "Adds MMR reranking to diversify results and reduce near-duplicate snippets in a single answer window. Enable when recall looks repetitive; keep off for strict score ordering.", - "agents.defaults.memorySearch.query.hybrid.temporalDecay.enabled": - "Applies recency decay so newer memory can outrank older memory when scores are close. Enable when timeliness matters; keep off for timeless reference knowledge.", - "agents.defaults.memorySearch.cache.enabled": + "memory.search.cache.enabled": "Caches computed chunk embeddings in SQLite so reindexing and incremental updates run faster (default: true). Keep this enabled unless investigating cache correctness or minimizing disk usage.", memory: "Memory backend configuration (global).", "memory.backend": @@ -295,20 +263,12 @@ export const MODEL_FIELD_HELP: Record = { 'Controls citation visibility in replies: "auto" shows citations when useful, "on" always shows them, and "off" hides them. Keep "auto" for a balanced signal-to-noise default.', "memory.qmd.command": "Sets the executable path for the `qmd` binary used by the QMD backend (default: resolved from PATH). Use an explicit absolute path when multiple qmd installs exist or PATH differs across environments.", - "memory.qmd.mcporter": - "Routes QMD work through mcporter (MCP runtime) instead of spawning `qmd` for each call. Use this when cold starts are expensive on large models; keep direct process mode for simpler local setups.", - "memory.qmd.mcporter.enabled": - "Routes QMD through an mcporter daemon instead of spawning qmd per request, reducing cold-start overhead for larger models. Keep disabled unless mcporter is installed and configured.", - "memory.qmd.mcporter.serverName": - "Names the mcporter server target used for QMD calls (default: qmd). Change only when your mcporter setup uses a custom server name for qmd mcp keep-alive.", - "memory.qmd.mcporter.startDaemon": - "Automatically starts the mcporter daemon when mcporter-backed QMD mode is enabled (default: true). Keep enabled unless process lifecycle is managed externally by your service supervisor.", "memory.qmd.searchMode": 'Selects the QMD retrieval path: "query" uses standard query flow, "search" uses search-oriented retrieval, and "vsearch" emphasizes vector retrieval. Keep default unless tuning relevance quality.', "memory.qmd.rerank": 'Controls QMD query reranking. Set to false with searchMode "query" and QMD 2.1+ to skip QMD reranking for faster hybrid results; leave unset for QMD defaults.', "memory.qmd.searchTool": - "Overrides the exact mcporter tool name used for QMD searches while preserving `searchMode` as the semantic retrieval mode. Use this only when your QMD MCP server exposes a custom tool such as `hybrid_search` and keep it unset for the normal built-in tool mapping.", + "Overrides the exact QMD search command while preserving `searchMode` as the semantic retrieval mode. Keep it unset for the normal built-in tool mapping.", "memory.qmd.includeDefaultMemory": "Automatically indexes default memory files (MEMORY.md and memory/**/*.md) into QMD collections. Keep enabled unless you want indexing controlled only through explicit custom paths.", "memory.qmd.paths": @@ -325,26 +285,6 @@ export const MODEL_FIELD_HELP: Record = { "Overrides where sanitized session exports are written before QMD indexing. Use this when default state storage is constrained or when exports must land on a managed volume.", "memory.qmd.sessions.retentionDays": "Defines how long exported session files are kept before automatic pruning, in days (default: unlimited). Set a finite value for storage hygiene or compliance retention policies.", - "memory.qmd.update.interval": - "Sets how often QMD refreshes indexes from source content (duration string, default: 5m). Shorter intervals improve freshness but increase background CPU and I/O.", - "memory.qmd.update.debounceMs": - "Sets the minimum delay between consecutive QMD refresh attempts in milliseconds (default: 15000). Increase this if frequent file changes cause update thrash or unnecessary background load.", - "memory.qmd.update.onBoot": - "Runs an initial QMD update when the long-lived QMD manager opens (default: true). Set false to skip manager-start boot updates while keeping configured interval/embed maintenance.", - "memory.qmd.update.startup": - "Controls whether Gateway startup initializes QMD before memory is first used (`off`, `idle`, or `immediate`; default: off). With onBoot disabled, startup only arms configured interval/embed maintenance.", - "memory.qmd.update.startupDelayMs": - 'Sets the idle delay before an opt-in `memory.qmd.update.startup: "idle"` refresh runs (default: 120000). Increase to keep cold-start CPU available for channels and providers.', - "memory.qmd.update.waitForBootSync": - "Blocks QMD manager opening until its initial manager-start update finishes (default: false). Startup refreshes remain opt-in through `memory.qmd.update.startup`.", - "memory.qmd.update.embedInterval": - "Sets how often QMD recomputes embeddings (duration string, default: 60m; set 0 to disable periodic embeds). Lower intervals improve freshness but increase embedding workload and cost.", - "memory.qmd.update.commandTimeoutMs": - "Sets timeout for QMD maintenance commands such as collection list/add in milliseconds (default: 30000). Increase when running on slower disks or remote filesystems that delay command completion.", - "memory.qmd.update.updateTimeoutMs": - "Sets maximum runtime for each `qmd update` cycle in milliseconds (default: 120000). Raise this for larger collections; lower it when you want quicker failure detection in automation.", - "memory.qmd.update.embedTimeoutMs": - "Sets maximum runtime for each `qmd embed` cycle in milliseconds (default: 120000). Increase for heavier embedding workloads or slower hardware, and lower to fail fast under tight SLAs.", "memory.qmd.limits.maxResults": "Limits how many QMD hits are returned into the agent loop for each recall request (default: 6). Increase for broader recall context, or lower to keep prompts tighter and faster.", "memory.qmd.limits.maxSnippetChars": @@ -355,18 +295,4 @@ export const MODEL_FIELD_HELP: Record = { "Sets per-query QMD search timeout in milliseconds (default: 4000). Increase for larger indexes or slower environments, and lower to keep request latency bounded.", "memory.qmd.scope": "Defines which sessions/channels are eligible for QMD recall using session.sendPolicy-style rules. Keep default direct-only scope unless you intentionally want cross-chat memory sharing.", - "agents.defaults.memorySearch.sync.onSessionStart": - "Triggers a memory index sync when a session starts so early turns see fresh memory content. Keep enabled when startup freshness matters more than initial turn latency.", - "agents.defaults.memorySearch.sync.onSearch": - "Uses lazy sync by scheduling reindex on search after content changes are detected. Keep enabled for lower idle overhead, or disable if you require pre-synced indexes before any query.", - "agents.defaults.memorySearch.sync.watch": - "Watches memory files and schedules index updates from file-change events (chokidar). Enable for near-real-time freshness; disable on very large workspaces if watch churn is too noisy.", - "agents.defaults.memorySearch.sync.embeddingBatchTimeoutSeconds": - "Overrides the timeout for inline embedding batches during memory indexing. Leave unset to use provider defaults: 600 seconds for local/self-hosted providers such as local, Ollama, and LM Studio, and 120 seconds for hosted providers.", - "agents.defaults.memorySearch.sync.sessions.deltaBytes": - "Requires at least this many newly appended bytes before session transcript changes trigger reindex (default: 100000). Increase to reduce frequent small reindexes, or lower for faster transcript freshness.", - "agents.defaults.memorySearch.sync.sessions.deltaMessages": - "Requires at least this many appended transcript messages before reindex is triggered (default: 50). Lower this for near-real-time transcript recall, or raise it to reduce indexing churn.", - "agents.defaults.memorySearch.sync.sessions.postCompactionForce": - "Forces a session memory-search reindex after compaction-triggered transcript updates (default: true). Keep enabled when compacted summaries must be immediately searchable, or disable to reduce write-time indexing pressure.", }; diff --git a/src/config/schema.help.quality.test-fixtures.ts b/src/config/schema.help.quality.test-fixtures.ts index 286a727ac2b2..68b6cc29ce68 100644 --- a/src/config/schema.help.quality.test-fixtures.ts +++ b/src/config/schema.help.quality.test-fixtures.ts @@ -8,9 +8,7 @@ export const ROOT_SECTIONS = [ "wizard", "diagnostics", "logging", - "cli", "update", - "commitments", "browser", "ui", "tui", @@ -21,7 +19,7 @@ export const ROOT_SECTIONS = [ "tools", "bindings", "broadcast", - "media", + "attachments", "messages", "commands", "approvals", @@ -29,8 +27,8 @@ export const ROOT_SECTIONS = [ "cron", "transcripts", "hooks", - "web", "channels", + "surfaces", "discovery", "talk", "gateway", @@ -47,11 +45,7 @@ export const TARGET_KEYS = [ "memory.qmd.searchTool", "memory.qmd.scope", "memory.qmd.includeDefaultMemory", - "memory.qmd.mcporter.enabled", - "memory.qmd.mcporter.serverName", "memory.qmd.command", - "memory.qmd.mcporter", - "memory.qmd.mcporter.startDaemon", "memory.qmd.paths", "memory.qmd.paths.path", "memory.qmd.paths.pattern", @@ -59,71 +53,43 @@ export const TARGET_KEYS = [ "memory.qmd.sessions.enabled", "memory.qmd.sessions.exportDir", "memory.qmd.sessions.retentionDays", - "memory.qmd.update.interval", - "memory.qmd.update.debounceMs", - "memory.qmd.update.onBoot", - "memory.qmd.update.startup", - "memory.qmd.update.startupDelayMs", - "memory.qmd.update.waitForBootSync", - "memory.qmd.update.embedInterval", - "memory.qmd.update.commandTimeoutMs", - "memory.qmd.update.updateTimeoutMs", - "memory.qmd.update.embedTimeoutMs", "memory.qmd.limits.maxResults", "memory.qmd.limits.maxSnippetChars", "memory.qmd.limits.maxInjectedChars", "memory.qmd.limits.timeoutMs", - "agents.defaults.memorySearch.provider", - "agents.defaults.memorySearch.fallback", - "agents.defaults.memorySearch.sources", - "agents.defaults.memorySearch.extraPaths", - "agents.defaults.memorySearch.qmd", - "agents.defaults.memorySearch.qmd.extraCollections", - "agents.defaults.memorySearch.qmd.extraCollections.path", - "agents.defaults.memorySearch.qmd.extraCollections.name", - "agents.defaults.memorySearch.qmd.extraCollections.pattern", - "agents.defaults.memorySearch.multimodal", - "agents.defaults.memorySearch.multimodal.enabled", - "agents.defaults.memorySearch.multimodal.modalities", - "agents.defaults.memorySearch.multimodal.maxFileBytes", - "agents.defaults.memorySearch.experimental.sessionMemory", - "agents.defaults.memorySearch.remote.baseUrl", - "agents.defaults.memorySearch.remote.apiKey", - "agents.defaults.memorySearch.remote.headers", - "agents.defaults.memorySearch.remote.nonBatchConcurrency", - "agents.defaults.memorySearch.remote.batch.enabled", - "agents.defaults.memorySearch.remote.batch.wait", - "agents.defaults.memorySearch.remote.batch.concurrency", - "agents.defaults.memorySearch.remote.batch.pollIntervalMs", - "agents.defaults.memorySearch.remote.batch.timeoutMinutes", - "agents.defaults.memorySearch.local.modelPath", - "agents.defaults.memorySearch.inputType", - "agents.defaults.memorySearch.queryInputType", - "agents.defaults.memorySearch.documentInputType", - "agents.defaults.memorySearch.outputDimensionality", - "agents.defaults.memorySearch.store.vector.enabled", - "agents.defaults.memorySearch.store.vector.extensionPath", - "agents.defaults.memorySearch.query.hybrid.enabled", - "agents.defaults.memorySearch.query.hybrid.mmr.enabled", - "agents.defaults.memorySearch.query.hybrid.temporalDecay.enabled", - "agents.defaults.memorySearch.cache.enabled", - "agents.defaults.memorySearch.sync.onSearch", - "agents.defaults.memorySearch.sync.watch", - "agents.defaults.memorySearch.sync.embeddingBatchTimeoutSeconds", - "agents.defaults.memorySearch.sync.sessions.deltaBytes", - "agents.defaults.memorySearch.sync.sessions.deltaMessages", + "memory.search.provider", + "memory.search.fallback", + "memory.search.sources", + "memory.search.extraPaths", + "memory.search.qmd", + "memory.search.qmd.extraCollections", + "memory.search.qmd.extraCollections.path", + "memory.search.qmd.extraCollections.name", + "memory.search.qmd.extraCollections.pattern", + "memory.search.multimodal", + "memory.search.multimodal.enabled", + "memory.search.multimodal.modalities", + "memory.search.multimodal.maxFileBytes", + "memory.search.remote.baseUrl", + "memory.search.remote.apiKey", + "memory.search.remote.headers", + "memory.search.remote.batch.enabled", + "memory.search.local.modelPath", + "memory.search.inputType", + "memory.search.queryInputType", + "memory.search.documentInputType", + "memory.search.outputDimensionality", + "memory.search.store.vector.extensionPath", + "memory.search.cache.enabled", "models.mode", "models.providers.*.auth", "models.providers.*.authHeader", "models.providers.*.request", "gateway.reload.mode", "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback", - "gateway.controlUi.allowInsecureAuth", - "gateway.controlUi.dangerouslyDisableDeviceAuth", "gateway.controlUi.embedSandbox", "cron", "cron.enabled", - "cron.store", "cron.webhookToken", "cron.sessionRetention", "session", @@ -131,7 +97,6 @@ export const TARGET_KEYS = [ "session.dmScope", "session.identityLinks", "session.resetTriggers", - "session.idleMinutes", "session.reset", "session.reset.mode", "session.reset.atHour", @@ -142,7 +107,10 @@ export const TARGET_KEYS = [ "session.resetByType.thread", "session.resetByChannel", "session.store", - "session.typingMode", + "agents.defaults.typingMode", + "agents.defaults.typingIntervalSeconds", + "agents.entries.*.typingMode", + "agents.entries.*.typingIntervalSeconds", "session.mainKey", "session.sendPolicy", "session.sendPolicy.default", @@ -188,8 +156,7 @@ export const TARGET_KEYS = [ "nodeHost.mcp.servers", "nodeHost.skills", "nodeHost.skills.enabled", - "media", - "media.preserveFilenames", + "attachments", "bindings", "bindings[].agentId", "bindings[].match", @@ -228,7 +195,6 @@ export const TARGET_KEYS = [ "hooks.internal.load.extraDirs", "messages", "messages.visibleReplies", - "messages.responsePrefix", "messages.groupChat", "messages.groupChat.mentionPatterns", "messages.groupChat.historyLimit", @@ -242,8 +208,7 @@ export const TARGET_KEYS = [ "messages.queue.drop", "messages.inbound", "messages.inbound.byChannel", - "messages.removeAckAfterReply", - "messages.tts", + "tts", "channels", "channels.defaults", "channels.defaults.groupPolicy", @@ -252,10 +217,10 @@ export const TARGET_KEYS = [ "channels.defaults.implicitMentions.replyToBot", "channels.defaults.implicitMentions.quotedBot", "channels.defaults.implicitMentions.threadParticipation", - "channels.defaults.heartbeat", - "channels.defaults.heartbeat.showOk", - "channels.defaults.heartbeat.showAlerts", - "channels.defaults.heartbeat.useIndicator", + "channels.defaults.heartbeatVisibility", + "channels.defaults.heartbeatVisibility.showOk", + "channels.defaults.heartbeatVisibility.showAlerts", + "channels.defaults.heartbeatVisibility.useIndicator", "channels.defaults.botLoopProtection", "channels.defaults.botLoopProtection.enabled", "channels.defaults.botLoopProtection.maxEventsPerWindow", @@ -287,19 +252,14 @@ export const TARGET_KEYS = [ "tools.exec", "tools.exec.host", "tools.exec.mode", - "tools.exec.security", - "tools.exec.ask", "tools.exec.node", "tools.agentToAgent.enabled", "tools.elevated.enabled", "tools.elevated.allowFrom", "tools.subagents.tools", "tools.sandbox.tools", - "web", - "web.enabled", "discovery", "discovery.wideArea.domain", - "discovery.wideArea.enabled", "discovery.mdns", "discovery.mdns.mode", "gateway.controlUi.embedSandbox", @@ -315,11 +275,6 @@ export const TARGET_KEYS = [ "env.shellEnv.timeoutMs", "env.vars", "wizard", - "wizard.lastRunAt", - "wizard.lastRunVersion", - "wizard.lastRunCommit", - "wizard.lastRunCommand", - "wizard.lastRunMode", "diagnostics", "diagnostics.otel", "diagnostics.cacheTrace", @@ -328,7 +283,6 @@ export const TARGET_KEYS = [ "logging.file", "logging.consoleLevel", "logging.consoleStyle", - "logging.redactSensitive", "logging.redactPatterns", "update", "ui", @@ -371,14 +325,13 @@ export const TARGET_KEYS = [ "models.providers.*.models", "agents", "agents.defaults", - "agents.list", + "agents.entries", "agents.defaults.compaction", "agents.defaults.compaction.mode", "agents.defaults.compaction.provider", "agents.defaults.compaction.thinkingLevel", "agents.defaults.compaction.keepRecentTokens", "agents.defaults.compaction.identifierPolicy", - "agents.defaults.compaction.identifierInstructions", "agents.defaults.compaction.recentTurnsPreserve", "agents.defaults.compaction.qualityGuard", "agents.defaults.compaction.qualityGuard.enabled", @@ -394,8 +347,6 @@ export const TARGET_KEYS = [ "agents.defaults.compaction.memoryFlush.enabled", "agents.defaults.compaction.memoryFlush.model", "agents.defaults.compaction.memoryFlush.softThresholdTokens", - "agents.defaults.compaction.memoryFlush.prompt", - "agents.defaults.compaction.memoryFlush.systemPrompt", ] as const; export const ENUM_EXPECTATIONS: Record = { @@ -404,7 +355,7 @@ export const ENUM_EXPECTATIONS: Record = { "memory.qmd.searchMode": ['"query"', '"search"', '"vsearch"'], "models.mode": ['"merge"', '"replace"'], "models.providers.*.auth": ['"api-key"', '"token"', '"oauth"', '"aws-sdk"'], - "gateway.reload.mode": ['"off"', '"restart"', '"hot"', '"hybrid"'], + "gateway.reload.mode": ['"off"', '"hybrid"'], "approvals.exec.mode": ['"session"', '"targets"', '"both"'], "bindings[].match.peer.kind": ['"direct"', '"group"', '"channel"', '"dm"'], "broadcast.strategy": ['"parallel"', '"sequential"'], @@ -422,7 +373,6 @@ export const ENUM_EXPECTATIONS: Record = { "gateway.tailscale.mode": ['"off"', '"serve"', '"funnel"'], "browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"existing-session"'], "discovery.mdns.mode": ['"off"', '"minimal"', '"full"'], - "wizard.lastRunMode": ['"local"', '"remote"'], "diagnostics.otel.protocol": ['"http/protobuf"', '"grpc"'], "diagnostics.otel.logsExporter": ['"otlp"', '"stdout"', '"both"'], "logging.level": ['"silent"', '"fatal"', '"error"', '"warn"', '"info"', '"debug"', '"trace"'], @@ -435,9 +385,7 @@ export const ENUM_EXPECTATIONS: Record = { '"debug"', '"trace"', ], - "logging.consoleStyle": ['"pretty"', '"compact"', '"json"'], - "logging.redactSensitive": ['"off"', '"tools"'], - "cli.banner.taglineMode": ['"random"', '"default"', '"off"'], + "logging.consoleStyle": ['"pretty"', '"json"'], "update.channel": ['"stable"', '"extended-stable"', '"beta"', '"dev"'], "agents.defaults.compaction.mode": ['"default"', '"safeguard"'], "agents.defaults.compaction.thinkingLevel": [ @@ -451,7 +399,7 @@ export const ENUM_EXPECTATIONS: Record = { '"max"', '"ultra"', ], - "agents.defaults.compaction.identifierPolicy": ['"strict"', '"off"', '"custom"'], + "agents.defaults.compaction.identifierPolicy": ['"strict"', '"off"'], }; export const TOOLS_HOOKS_TARGET_KEYS = [ @@ -466,7 +414,6 @@ export const TOOLS_HOOKS_TARGET_KEYS = [ "hooks.gmail.tailscale", "hooks.gmail.topic", "hooks.internal.entries", - "hooks.internal.installs", "hooks.internal.load", "hooks.mappings[].allowUnsafeExternalContent", "hooks.mappings[].deliver", @@ -490,38 +437,37 @@ export const TOOLS_HOOKS_TARGET_KEYS = [ "tools.links.timeoutSeconds", ...Object.keys(MEDIA_AUDIO_FIELD_HELP), "tools.media.concurrency", - "tools.media.image.attachments", "tools.media.image.enabled", + "tools.media.image.preferredModel", "tools.media.image.maxBytes", "tools.media.image.maxChars", - "tools.media.image.models", "tools.media.image.prompt", - "tools.media.image.scope", "tools.media.image.timeoutSeconds", + "tools.media.image.scope", + "tools.media.image.attachments", "tools.media.models", - "tools.media.video.attachments", "tools.media.video.enabled", + "tools.media.video.preferredModel", "tools.media.video.maxBytes", "tools.media.video.maxChars", - "tools.media.video.models", "tools.media.video.prompt", - "tools.media.video.scope", "tools.media.video.timeoutSeconds", + "tools.media.video.scope", + "tools.media.video.attachments", "tools.profile", ] as const; export const CHANNELS_AGENTS_TARGET_KEYS = [ - "agents.defaults.memorySearch.enabled", - "agents.defaults.memorySearch.model", - "agents.defaults.memorySearch.query.maxResults", - "agents.defaults.memorySearch.query.minScore", - "agents.defaults.memorySearch.sync.onSessionStart", + "memory.search.enabled", + "memory.search.model", + "memory.search.query.maxResults", + "memory.search.query.minScore", "agents.defaults.workspace", - "agents.list[].tools.alsoAllow", - "agents.list[].tools.byProvider", - "agents.list[].tools.message.crossContext.allowAcrossProviders", - "agents.list[].tools.message.crossContext.allowWithinProvider", - "agents.list[].tools.profile", + "agents.entries.*.tools.alsoAllow", + "agents.entries.*.tools.byProvider", + "agents.entries.*.tools.message.crossContext.allowAcrossProviders", + "agents.entries.*.tools.message.crossContext.allowWithinProvider", + "agents.entries.*.tools.profile", "channels.mattermost", ] as const; @@ -532,7 +478,7 @@ export const FINAL_BACKLOG_TARGET_KEYS = [ "browser.ssrfPolicy", "browser.ssrfPolicy.dangerouslyAllowPrivateNetwork", "browser.ssrfPolicy.allowedHostnames", - "browser.ssrfPolicy.hostnameAllowlist", + "browser.ssrfPolicy.allowedHostnames", "diagnostics.enabled", "diagnostics.otel.enabled", "diagnostics.otel.endpoint", diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 067b2359ff04..691862dabd39 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -160,25 +160,13 @@ describe("config help copy quality", () => { expect(/hides|hide/i.test(help)).toBe(true); }); - it("includes concrete examples on path and interval fields", () => { + it("includes a concrete example on memory path fields", () => { expect( expectDefined( FIELD_HELP["memory.qmd.paths.pattern"], 'FIELD_HELP["memory.qmd.paths.pattern"] test invariant', ).includes("**/*.md"), ).toBe(true); - expect( - expectDefined( - FIELD_HELP["memory.qmd.update.interval"], - 'FIELD_HELP["memory.qmd.update.interval"] test invariant', - ).includes("5m"), - ).toBe(true); - expect( - expectDefined( - FIELD_HELP["memory.qmd.update.embedInterval"], - 'FIELD_HELP["memory.qmd.update.embedInterval"] test invariant', - ).includes("60m"), - ).toBe(true); }); it("documents cron retention formats", () => { @@ -307,20 +295,12 @@ describe("config help copy quality", () => { expect(bind.includes('"tailnet"')).toBe(true); }); - it("documents metadata/admin semantics for logging, wizard, and plugins", () => { - const wizardMode = expectDefined( - FIELD_HELP["wizard.lastRunMode"], - 'FIELD_HELP["wizard.lastRunMode"] test invariant', - ); - expect(wizardMode.includes('"local"')).toBe(true); - expect(wizardMode.includes('"remote"')).toBe(true); - + it("documents admin semantics for logging and plugins", () => { const consoleStyle = expectDefined( FIELD_HELP["logging.consoleStyle"], 'FIELD_HELP["logging.consoleStyle"] test invariant', ); expect(consoleStyle.includes('"pretty"')).toBe(true); - expect(consoleStyle.includes('"compact"')).toBe(true); expect(consoleStyle.includes('"json"')).toBe(true); const pluginApiKey = expectDefined( @@ -400,7 +380,6 @@ describe("config help copy quality", () => { ); expect(identifierPolicy.includes('"strict"')).toBe(true); expect(identifierPolicy.includes('"off"')).toBe(true); - expect(identifierPolicy.includes('"custom"')).toBe(true); const recentTurnsPreserve = expectDefined( FIELD_HELP["agents.defaults.compaction.recentTurnsPreserve"], @@ -415,16 +394,6 @@ describe("config help copy quality", () => { ); expect(/mid-turn|tool loop|default:\s*false/i.test(midTurnPrecheck)).toBe(true); - const postCompactionSections = expectDefined( - FIELD_HELP["agents.defaults.compaction.postCompactionSections"], - 'FIELD_HELP["agents.defaults.compaction.postCompactionSections"] test invariant', - ); - expect(/opt-in|Leave unset/i.test(postCompactionSections)).toBe(true); - expect(/Session Startup|Red Lines/i.test(postCompactionSections)).toBe(true); - expect(/Every Session|Safety/i.test(postCompactionSections)).toBe(true); - expect(/\[\]|disable/i.test(postCompactionSections)).toBe(true); - expect(/duplicate project context/i.test(postCompactionSections)).toBe(true); - const compactionModel = expectDefined( FIELD_HELP["agents.defaults.compaction.model"], 'FIELD_HELP["agents.defaults.compaction.model"] test invariant', diff --git a/src/config/schema.help.runtime.ts b/src/config/schema.help.runtime.ts index 3a756646db20..34f13cee0c68 100644 --- a/src/config/schema.help.runtime.ts +++ b/src/config/schema.help.runtime.ts @@ -11,8 +11,6 @@ export const RUNTIME_FIELD_HELP: Record = { "Allows macOS hosts to import cookies from a local Chrome-family system profile into a managed OpenClaw browser profile. Disable this to prevent browser profile cookie import and its macOS Keychain consent prompt.", "browser.cdpUrl": "CDP/DevTools endpoint URL used to attach to an externally managed browser instance. Use this for centralized browser hosts, tunnels, or existing-session attachment, and keep URL access restricted to trusted network paths.", - "browser.color": - "Default accent color used for browser profile/UI cues where colored identity hints are displayed. Use consistent colors to help operators identify active browser profile context quickly.", "browser.executablePath": "Explicit browser executable path when auto-discovery is insufficient for your host environment. Use an absolute stable path, or a path starting with ~ for your OS home directory, so launch behavior stays deterministic across restarts.", "browser.headless": @@ -43,8 +41,6 @@ export const RUNTIME_FIELD_HELP: Record = { "Per-profile headless override for locally launched browser instances. Use this when one profile should stay headless without forcing browser.headless for every other profile.", "browser.profiles.*.attachOnly": "Per-profile attach-only override that skips local browser launch and only attaches to an existing CDP endpoint. Useful when one profile is externally managed but others are locally launched.", - "browser.profiles.*.color": - "Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.", "browser.evaluateEnabled": "Enables browser-side evaluate helpers for runtime script evaluation capabilities where supported. Keep disabled unless your workflows require evaluate semantics beyond snapshots/navigation.", "browser.snapshotDefaults": @@ -60,23 +56,19 @@ export const RUNTIME_FIELD_HELP: Record = { "browser.ssrfPolicy.dangerouslyAllowPrivateNetwork": "Allows access to private-network address ranges from browser tooling. Default is disabled when unset; enable only for explicitly trusted private-network destinations.", "browser.ssrfPolicy.allowedHostnames": - "Explicit hostname allowlist exceptions for SSRF policy checks on browser/network requests. Keep this list minimal and review entries regularly to avoid stale broad access.", - "browser.ssrfPolicy.hostnameAllowlist": - "Legacy/alternate hostname allowlist field used by SSRF policy consumers for explicit host exceptions. Use stable exact hostnames and avoid wildcard-like broad patterns.", + "Explicit hostname patterns for browser SSRF policy checks. Supports exact hosts and wildcard subdomains such as *.example.com; keep the list minimal.", "discovery.mdns.mode": 'mDNS broadcast mode ("minimal" default, "full" includes cliPath/sshPort, "off" disables mDNS).', discovery: "Service discovery settings for local mDNS advertisement and optional wide-area presence signaling. Keep discovery scoped to expected networks to avoid leaking service metadata.", "discovery.wideArea": "Wide-area discovery configuration group for exposing discovery signals beyond local-link scopes. Enable only in deployments that intentionally aggregate gateway presence across sites.", - "discovery.wideArea.enabled": - "Enables wide-area discovery signaling when your environment needs non-local gateway discovery. Keep disabled unless cross-network discovery is operationally required.", "discovery.wideArea.domain": "Optional unicast DNS-SD domain for wide-area discovery, such as openclaw.internal. Use this when you intentionally publish gateway discovery beyond local mDNS scopes.", "discovery.mdns": "mDNS discovery configuration group for local network advertisement and discovery behavior tuning. Keep minimal mode for routine LAN discovery unless extra metadata is required.", tools: - "Global tool access policy and capability configuration across web, exec, media, messaging, and elevated surfaces. Use this section to constrain risky capabilities before broad rollout.", + "Tool infrastructure and cross-agent defaults. Root siblings own infrastructure and cross-agent defaults; agents.defaults owns agent-loop behavior; agent entries may override either where supported.", "tools.allow": "Absolute tool allowlist that replaces profile-derived defaults for strict environments. Use this only when you intentionally run a tightly curated subset of tool capabilities.", "tools.deny": @@ -95,10 +87,6 @@ export const RUNTIME_FIELD_HELP: Record = { "Optional provider/model override for the exec reviewer agent. Omit to reuse the configured primary model for the target agent.", "tools.exec.reviewer.timeoutMs": "Per-stage exec reviewer timeout in milliseconds for model preparation and completion before falling back to human approval (default: 30000).", - "tools.exec.security": - "Execution security posture selector controlling sandbox/approval expectations for command execution. Keep strict security mode for untrusted prompts and relax only for trusted operator workflows.", - "tools.exec.ask": - "Approval strategy for when exec commands require human confirmation before running. Use stricter ask behavior in shared channels and lower-friction settings in private operator contexts.", "tools.exec.node": "Node binding configuration for exec tooling when command execution is delegated through connected nodes. Use explicit node binding only when multi-node routing is required.", "tools.agentToAgent": @@ -168,27 +156,25 @@ export const RUNTIME_FIELD_HELP: Record = { "Tool policy wrapper for sandboxed agent executions so sandbox runs can have distinct capability boundaries. Use this to enforce stronger safety in sandbox contexts.", "tools.sandbox.tools": "Allow/deny tool policy applied when agents run in sandboxed execution environments. Keep policies minimal so sandbox tasks cannot escalate into unnecessary external actions.", - web: "Web channel runtime settings for operating web-based chat surfaces. Use this section to control the built-in web channel.", - "web.enabled": - "Enables the web channel runtime and related websocket lifecycle behavior. Keep disabled when web chat is unused to reduce active connection management overhead.", talk: "Talk-mode voice synthesis settings for voice identity, model selection, output format, and interruption behavior. Use this section to tune human-facing voice UX while controlling latency and cost.", "gateway.auth.token": "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", "gateway.auth.password": "Required for Tailscale funnel.", "agents.defaults.sandbox.browser.network": "Docker network for sandbox browser containers (default: openclaw-sandbox-browser). Avoid bridge if you need stricter isolation.", - "agents.list[].sandbox.browser.network": "Per-agent override for sandbox browser Docker network.", + "agents.entries.*.sandbox.browser.network": + "Per-agent override for sandbox browser Docker network.", "agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": "DANGEROUS break-glass override that allows sandbox Docker network mode container:. This joins another container namespace and weakens sandbox isolation.", - "agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "agents.entries.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": "Per-agent DANGEROUS override for container namespace joins in sandbox Docker network mode.", "agents.defaults.sandbox.docker.gpus": 'Optional Docker GPU passthrough value passed to --gpus, for example "all" or "device=GPU-uuid". Requires a compatible host runtime such as NVIDIA Container Toolkit.', - "agents.list[].sandbox.docker.gpus": + "agents.entries.*.sandbox.docker.gpus": "Per-agent Docker GPU passthrough override for sandbox containers.", "agents.defaults.sandbox.browser.cdpSourceRange": "Optional CIDR allowlist for container-edge CDP ingress (for example 172.21.0.1/32).", - "agents.list[].sandbox.browser.cdpSourceRange": + "agents.entries.*.sandbox.browser.cdpSourceRange": "Per-agent override for CDP source CIDR allowlist.", "gateway.controlUi.basePath": "Optional URL prefix where the Control UI is served (e.g. /openclaw).", @@ -202,16 +188,12 @@ export const RUNTIME_FIELD_HELP: Record = { 'Iframe sandbox policy for hosted Control UI embeds. "strict" disables scripts, "scripts" allows interactive embeds while keeping origin isolation (default), and "trusted" adds `allow-same-origin` for same-site documents that intentionally need stronger privileges.', "gateway.controlUi.allowExternalEmbedUrls": "DANGEROUS toggle that allows hosted embeds to load absolute external http(s) URLs. Keep this off unless your Control UI intentionally embeds trusted third-party pages; hosted /__openclaw__/canvas and /__openclaw__/a2ui documents do not need it.", - "gateway.controlUi.chatMessageMaxWidth": + "ui.prefs.chatMessageMaxWidth": 'Optional CSS max-width for the centered Control UI chat transcript, for example "960px", "82%", or "min(1280px, 82%)". Values are validated against a constrained width grammar before reaching the browser.', "gateway.controlUi.allowedOrigins": 'Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled. Setting ["*"] means allow any browser origin and should be avoided outside tightly controlled local testing.', "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": "DANGEROUS toggle that enables Host-header based origin fallback for Control UI/WebChat websocket checks. This mode is supported when your deployment intentionally relies on Host-header origin policy; explicit gateway.controlUi.allowedOrigins remains the recommended hardened default.", - "gateway.controlUi.allowInsecureAuth": - "Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.", - "gateway.controlUi.dangerouslyDisableDeviceAuth": - "Disables Control UI device identity checks and relies on token/password only. Use only for short-lived debugging on trusted networks, then turn it off immediately.", "mcp.apps": "MCP Apps UI support. When enabled, configured MCP servers may provide interactive HTML views for their tool results.", "mcp.apps.enabled": @@ -247,7 +229,7 @@ export const RUNTIME_FIELD_HELP: Record = { "gateway.http.endpoints.chatCompletions.images.timeoutMs": "Timeout in milliseconds for `image_url` URL fetches (default: 10000).", "gateway.reload.mode": - 'Controls how config edits are applied: "off" ignores live edits, "restart" always restarts, "hot" applies in-process, and "hybrid" tries hot then restarts if required. Keep "hybrid" for safest routine updates.', + 'Controls how config edits are applied: "off" ignores live edits and "hybrid" applies hot-safe changes then restarts when required.', "gateway.nodes.browser.mode": 'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).', "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", @@ -258,9 +240,9 @@ export const RUNTIME_FIELD_HELP: Record = { "gateway.nodes.pairing.sshVerify": "SSH-verified auto-approval for first-time node-role device pairing (default: enabled). The gateway SSHes back to the pairing host (BatchMode, strict host keys) and approves only when the remote `openclaw node identity` output matches the pending device key. Set false to disable SSH verification (independent of autoApproveCidrs, which stays active); for manual-only pairing also unset autoApproveCidrs. Pass an object to override user/identity/timeoutMs/cidrs.", ...NODE_CAPABILITY_FIELD_HELP, - "gateway.nodes.allowCommands": + "gateway.nodes.commands.allow": "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings). Enabling dangerous commands here is a security-sensitive override and is flagged by `openclaw security audit`.", - "gateway.nodes.denyCommands": + "gateway.nodes.commands.deny": "Node command names to block even if present in node claims or default allowlist (exact command-name matching only, e.g. `system.run`; does not inspect shell text inside that command).", nodeHost: "Node host controls for features exposed from this gateway node to other nodes or clients. Keep defaults unless you intentionally proxy local capabilities across your node network.", @@ -284,11 +266,9 @@ export const RUNTIME_FIELD_HELP: Record = { "Use this section to publish skills installed in ~/.openclaw/skills from the headless node host. Restart the node host after changing skill files.", "nodeHost.skills.enabled": "Scan and publish node-hosted skills after connecting (default: true). Set false to disable node skill publication.", - media: - "Top-level media behavior shared across providers and tools that handle inbound files. Keep defaults unless you need stable filenames for external processing pipelines or longer-lived inbound media retention.", - "media.preserveFilenames": - "When enabled, uploaded media keeps its original filename instead of a generated temp-safe name. Turn this on when downstream automations depend on stable names, and leave off to reduce accidental filename leakage.", - "media.ttlHours": + attachments: + "Top-level attachment retention behavior shared across providers and tools that handle inbound files. Use ttlHours when persisted inbound media needs bounded cleanup.", + "attachments.ttlHours": "Optional retention window in hours for persisted media cleanup across the full media tree. Leave unset to disable automatic cleanup (media writes never prune), or set values like 24 (1 day) or 168 (7 days) to periodically remove media older than the window.", bindings: "Top-level binding rules for routing and persistent ACP conversation ownership. Use type=route for normal routing and type=acp for persistent ACP harness bindings.", @@ -319,7 +299,7 @@ export const RUNTIME_FIELD_HELP: Record = { "bindings[].match.roles": "Optional role-based filter list used by providers that attach roles to chat context. Use this to route privileged or operational role traffic to specialized agents.", "bindings[].acp": - "Optional per-binding ACP overrides for bindings[].type=acp. This layer overrides agents.list[].runtime.acp defaults for the matched conversation.", + "Optional per-binding ACP overrides for bindings[].type=acp. This layer overrides agents.entries.*.runtime.acp defaults for the matched conversation.", "bindings[].acp.mode": "ACP session mode override for this binding (persistent or oneshot).", "bindings[].acp.label": "Human-friendly label for ACP status/diagnostics in this bound conversation.", @@ -365,29 +345,9 @@ export const RUNTIME_FIELD_HELP: Record = { "diagnostics.otel.flushIntervalMs": "Interval in milliseconds for periodic telemetry flush from buffers to the collector. Increase to reduce export chatter, or lower for faster visibility during active incident response.", "diagnostics.otel.captureContent": - "Opt-in OTEL span content capture. Defaults to off; boolean true captures non-system message/tool content, while the object form lets you enable specific content classes.", - "diagnostics.otel.captureContent.enabled": - "Master switch for granular OTEL content capture fields. Keep disabled unless your collector is approved for raw prompt, response, or tool content.", - "diagnostics.otel.captureContent.inputMessages": - "Capture model input message text on OTEL spans when content capture is enabled.", - "diagnostics.otel.captureContent.outputMessages": - "Capture model output message text on OTEL spans when content capture is enabled.", - "diagnostics.otel.captureContent.toolInputs": - "Capture tool input text on OTEL spans when content capture is enabled.", - "diagnostics.otel.captureContent.toolOutputs": - "Capture tool output text on OTEL spans when content capture is enabled.", - "diagnostics.otel.captureContent.systemPrompt": - "Capture system prompt text on OTEL spans when content capture is enabled. This remains off unless explicitly enabled.", - "diagnostics.otel.captureContent.toolDefinitions": - "Capture model tool definition schemas on OTEL spans when content capture is enabled.", + "Opt-in OTEL span content capture. Defaults to off; true captures non-system message and tool content.", "diagnostics.cacheTrace.enabled": "Log cache trace snapshots for embedded agent runs (default: false).", - "diagnostics.cacheTrace.filePath": - "JSONL output path for cache trace logs (default: $OPENCLAW_STATE_DIR/logs/cache-trace.jsonl).", - "diagnostics.cacheTrace.includeMessages": - "Include full message payloads in trace output (default: true).", - "diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).", - "diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).", "tools.exec.applyPatch.enabled": "Enable or disable apply_patch for OpenAI and OpenAI Codex models when allowed by tool policy (default: true).", "tools.exec.applyPatch.workspaceOnly": @@ -417,21 +377,21 @@ export const RUNTIME_FIELD_HELP: Record = { "Extra tool allowlist entries merged on top of the selected tool profile and default policy. Keep this list small and explicit so audits can quickly identify intentional policy exceptions.", "tools.byProvider": "Per-provider tool allow/deny overrides keyed by channel/provider ID to tailor capabilities by surface. Use this when one provider needs stricter controls than global tool policy.", - "agents.list[].tools.profile": + "agents.entries.*.tools.profile": "Per-agent override for tool profile selection when one agent needs a different capability baseline. Use this sparingly so policy differences across agents stay intentional and reviewable.", - "agents.list[].tools.alsoAllow": + "agents.entries.*.tools.alsoAllow": "Per-agent additive allowlist for tools on top of global and profile policy. Keep narrow to avoid accidental privilege expansion on specialized agents.", - "agents.list[].tools.codeMode": + "agents.entries.*.tools.codeMode": "Per-agent code mode override. Use this to test or roll out exec/wait tool-surface mode for one agent without enabling it fleet-wide.", - "agents.list[].tools.swarm": + "agents.entries.*.tools.swarm": "Per-agent swarm override. Values merge over the top-level tools.swarm configuration.", - "agents.list[].tools.byProvider": + "agents.entries.*.tools.byProvider": "Per-agent provider-specific tool policy overrides for channel-scoped capability control. Use this when a single agent needs tighter restrictions on one provider than others.", - "agents.list[].tools.message.crossContext.allowWithinProvider": + "agents.entries.*.tools.message.crossContext.allowWithinProvider": "Per-agent message guard for sending to other conversations on the same provider. Set false for current-conversation-only public agents.", - "agents.list[].tools.message.crossContext.allowAcrossProviders": + "agents.entries.*.tools.message.crossContext.allowAcrossProviders": "Per-agent message guard for sending across providers. Keep false for public or sandboxed agents.", - "agents.list[].tools.message.actions.allow": + "agents.entries.*.tools.message.actions.allow": 'Per-agent message action allowlist for the message tool. Set to a minimal list such as ["send"] for public sandbox agents so read, edit, delete, reaction, and other provider-specific message actions stay hidden and blocked.', "tools.exec.approvalRunningNoticeMs": "Delay in milliseconds before showing an in-progress notice after an exec approval is granted. Increase to reduce flicker for fast commands, or lower for quicker operator feedback.", @@ -446,42 +406,42 @@ export const RUNTIME_FIELD_HELP: Record = { "tools.links.scope": "Controls when link understanding runs relative to conversation context and message type. Keep scope conservative to avoid unnecessary fetches on messages where links are not actionable.", "tools.media.models": - "Shared fallback model list used by media understanding tools when modality-specific model lists are not set. Keep this aligned with available multimodal providers to avoid runtime fallback churn.", + "Canonical media-understanding model list. Use image, audio, or video capability tags on every entry so each pipeline selects only compatible fallbacks.", "tools.media.concurrency": "Maximum number of concurrent media understanding operations per turn across image, audio, and video tasks. Lower this in resource-constrained deployments to prevent CPU/network saturation.", "tools.media.image.enabled": "Enable image understanding so attached or referenced images can be interpreted into textual context. Disable if you need text-only operation or want to avoid image-processing cost.", + "tools.media.image.preferredModel": + "Prefer one capability-tagged tools.media.models entry for image understanding before the remaining compatible fallbacks.", "tools.media.image.maxBytes": - "Maximum accepted image payload size in bytes before the item is skipped or truncated by policy. Keep limits realistic for your provider caps and infrastructure bandwidth.", + "Default image input size limit for configured and auto-detected models. Set this to match provider payload limits and deployment bandwidth.", "tools.media.image.maxChars": - "Maximum characters returned from image understanding output after model response normalization. Use tighter limits to reduce prompt bloat and larger limits for detail-heavy OCR tasks.", + "Default maximum image description length. Use a lower value for compact context or a higher value for detailed OCR and scene analysis.", "tools.media.image.prompt": - "Instruction template used for image understanding requests to shape extraction style and detail level. Keep prompts deterministic so outputs stay consistent across turns and channels.", + "Default image-understanding prompt when an entry does not override it. Keep this deterministic when consumers rely on stable descriptions.", "tools.media.image.timeoutSeconds": - "Timeout in seconds for each image understanding request before it is aborted. Increase for high-resolution analysis and lower it for latency-sensitive operator workflows.", - "tools.media.image.attachments": - "Attachment handling policy for image inputs, including which message attachments qualify for image analysis. Use restrictive settings in untrusted channels to reduce unexpected processing.", - "tools.media.image.models": - "Ordered model preferences specifically for image understanding when you want to override shared media models. Put the most reliable multimodal model first to reduce fallback attempts.", + "Default timeout for image-understanding requests. Increase it for large images or slower local vision models.", "tools.media.image.scope": - "Scope selector for when image understanding is attempted (for example only explicit requests versus broader auto-detection). Keep narrow scope in busy channels to control token and API spend.", + "Restrict image understanding by channel, chat type, or source key. Keep this narrow in busy or untrusted channels to control processing.", + "tools.media.image.attachments": + "Choose which matching image attachments are processed. Use first-only handling unless multi-image analysis is intentional.", ...MEDIA_AUDIO_FIELD_HELP, "tools.media.video.enabled": "Enable video understanding so clips can be summarized into text for downstream reasoning and responses. Disable when processing video is out of policy or too expensive for your deployment.", + "tools.media.video.preferredModel": + "Prefer one capability-tagged tools.media.models entry for video understanding before the remaining compatible fallbacks.", "tools.media.video.maxBytes": - "Maximum accepted video payload size in bytes before policy rejection or trimming occurs. Tune this to provider and infrastructure limits to avoid repeated timeout/failure loops.", + "Default video input size limit for configured and auto-detected models. Set this to match provider payload limits and deployment bandwidth.", "tools.media.video.maxChars": - "Maximum characters retained from video understanding output to control prompt growth. Raise for dense scene descriptions and lower when concise summaries are preferred.", + "Default maximum video description length. Use a lower value for compact context or a higher value for detailed scene summaries.", "tools.media.video.prompt": - "Instruction template for video understanding describing desired summary granularity and focus areas. Keep this stable so output quality remains predictable across model/provider fallbacks.", + "Default video-understanding prompt when an entry does not override it. Keep this deterministic when consumers rely on stable summaries.", "tools.media.video.timeoutSeconds": - "Timeout in seconds for each video understanding request before cancellation. Use conservative values in interactive channels and longer values for offline or batch-heavy processing.", - "tools.media.video.attachments": - "Attachment eligibility policy for video analysis, defining which message files can trigger video processing. Keep this explicit in shared channels to prevent accidental large media workloads.", - "tools.media.video.models": - "Ordered model preferences specifically for video understanding before shared media fallback applies. Prioritize models with strong multimodal video support to minimize degraded summaries.", + "Default timeout for video-understanding requests. Increase it for longer clips or slower local analysis models.", "tools.media.video.scope": - "Scope selector controlling when video understanding is attempted across incoming events. Narrow scope in noisy channels, and broaden only where video interpretation is core to workflow.", + "Restrict video understanding by channel, chat type, or source key. Keep this narrow in busy or untrusted channels to control processing.", + "tools.media.video.attachments": + "Choose which matching video attachments are processed. Use first-only handling unless multi-video analysis is intentional.", "skills.load.extraDirs": "Additional shared skill roots to scan at lowest precedence. Use this for sibling repos or shared skill packs that should be available without copying them into the OpenClaw workspace.", "skills.load.allowSymlinkTargets": diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index 6b18ebbb22a7..9f10bae16c3b 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -179,8 +179,8 @@ describe("mapSensitivePaths", () => { schema.title = "OpenClawConfig"; const hints = mapSensitivePaths(OpenClawSchema, "", {}); - expect(hints["agents.defaults.memorySearch.remote.apiKey"]?.sensitive).toBe(true); - expect(hints["agents.list[].memorySearch.remote.apiKey"]?.sensitive).toBe(true); + expect(hints["memory.search.remote.apiKey"]?.sensitive).toBe(true); + expect(hints["agents.entries.*.memory.search.remote.apiKey"]?.sensitive).toBe(true); expect(hints["gateway.auth.token"]?.sensitive).toBe(true); expect(hints["models.providers.*.headers.*"]?.sensitive).toBe(true); expect(hints["models.providers.*.localService.env.*"]?.sensitive).toBe(true); diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index d8b9177c0aa7..4686eb980055 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -6,28 +6,18 @@ export const FIELD_LABELS: Record = { "channels.discord.activities": "Discord Activities", "channels.discord.activities.clientSecret": "Discord Activities Client Secret", "channels.discord.activities.applicationId": "Discord Activities Application ID", - meta: "Metadata", + meta: "Compatibility Metadata", "meta.lastTouchedVersion": "Config Last Touched Version", - "meta.lastTouchedAt": "Config Last Touched At", - "meta.migrations": "Config Migrations", + "meta.migrations": "Config Migration Markers", "meta.migrations.modelPolicyAllowlist": "Model Policy Allowlist Migration", - marketplaces: "Marketplaces", - "marketplaces.feeds": "Marketplace Feeds", - "marketplaces.feeds.*.url": "Marketplace Feed URL", - "marketplaces.feeds.*.verification": "Marketplace Feed Verification", - "marketplaces.feeds.*.verification.mode": "Marketplace Feed Verification Mode", - "marketplaces.feeds.*.verification.keys": "Marketplace Feed Trusted Public Keys", - "marketplaces.feeds.*.verification.keys[].keyId": "Marketplace Feed Publisher Key ID", - "marketplaces.feeds.*.verification.keys[].publicKey": "Marketplace Feed Publisher Public Key", - "marketplaces.feeds.*.verification.threshold": "Marketplace Feed Verification Threshold", - "marketplaces.sources": "Marketplace Sources", - "marketplaces.sources.*.type": "Marketplace Source Type", env: "Environment", "env.shellEnv": "Shell Environment Import", "env.shellEnv.enabled": "Shell Environment Import Enabled", "env.shellEnv.timeoutMs": "Shell Environment Import Timeout (ms)", "env.vars": "Environment Variable Overrides", - wizard: "Setup Wizard State", + wizard: "Setup Preferences", + "wizard.accessMode": "Setup Discovery Access", + "wizard.appRecommendations": "Setup App Recommendations", "wizard.lastRunAt": "Wizard Last Run Timestamp", "wizard.lastRunVersion": "Wizard Last Run Version", "wizard.lastRunCommit": "Wizard Last Run Commit", @@ -35,9 +25,9 @@ export const FIELD_LABELS: Record = { "wizard.lastRunMode": "Wizard Last Run Mode", "wizard.localModelLeanAutoModel": "Wizard Auto-Set Lean Model", "wizard.securityAcknowledgedAt": "Wizard Security Acknowledgement Timestamp", - audit: "Audit Ledger", - "audit.enabled": "Audit Ledger Enabled", - "audit.messages": "Message Audit Scope", + "logging.audit": "Audit Ledger", + "logging.audit.enabled": "Audit Ledger Enabled", + "logging.audit.messages": "Message Audit Scope", diagnostics: "Diagnostics", "diagnostics.otel": "OpenTelemetry", "diagnostics.cacheTrace": "Cache Trace", @@ -46,18 +36,13 @@ export const FIELD_LABELS: Record = { "logging.file": "Log File Path", "logging.consoleLevel": "Console Log Level", "logging.consoleStyle": "Console Log Style", - "logging.redactSensitive": "Sensitive Data Redaction Mode", "logging.redactPatterns": "Custom Redaction Patterns", - cli: "CLI", - "cli.banner": "CLI Banner", - "cli.banner.taglineMode": "CLI Banner Tagline Mode", update: "Updates", "update.channel": "Update Channel", "update.checkOnStart": "Update Check on Start", "update.auto.enabled": "Auto Update Enabled", - commitments: "Commitments", - "commitments.enabled": "Commitments Enabled", - "commitments.maxPerDay": "Commitments per Day", + surfaces: "Surface Policies", + "surfaces.*.silentReply": "Surface Silent Reply Policy", "diagnostics.enabled": "Diagnostics Enabled", "diagnostics.flags": "Diagnostics Flags", "diagnostics.otel.enabled": "OpenTelemetry Enabled", @@ -75,35 +60,25 @@ export const FIELD_LABELS: Record = { "diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate", "diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)", "diagnostics.otel.captureContent": "OpenTelemetry Content Capture", - "diagnostics.otel.captureContent.enabled": "OpenTelemetry Content Capture Enabled", - "diagnostics.otel.captureContent.inputMessages": "OpenTelemetry Input Messages Capture", - "diagnostics.otel.captureContent.outputMessages": "OpenTelemetry Output Messages Capture", - "diagnostics.otel.captureContent.toolInputs": "OpenTelemetry Tool Inputs Capture", - "diagnostics.otel.captureContent.toolOutputs": "OpenTelemetry Tool Outputs Capture", - "diagnostics.otel.captureContent.systemPrompt": "OpenTelemetry System Prompt Capture", - "diagnostics.otel.captureContent.toolDefinitions": "OpenTelemetry Tool Definitions Capture", "diagnostics.cacheTrace.enabled": "Cache Trace Enabled", - "diagnostics.cacheTrace.filePath": "Cache Trace File Path", - "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", - "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", - "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", - "agents.list.*.identity.avatar": "Identity Avatar", - "agents.list.*.skills": "Agent Skill Filter", - "agents.list[].runtime": "Agent Runtime", - "agents.list[].runtime.type": "Agent Runtime Type", - "agents.list[].runtime.acp": "Agent ACP Runtime", - "agents.list[].runtime.acp.agent": "Agent ACP Harness Agent", - "agents.list[].runtime.acp.backend": "Agent ACP Backend", - "agents.list[].runtime.acp.mode": "Agent ACP Mode", - "agents.list[].runtime.acp.cwd": "Agent ACP Working Directory", - "agents.list[].thinkingDefault": "Agent Thinking Default", - "agents.list[].reasoningDefault": "Agent Reasoning Default", - "agents.list[].fastModeDefault": "Agent Fast Mode Default", - "agents.list[].contextInjection": "Agent Context Injection", - "agents.list[].bootstrapMaxChars": "Agent Bootstrap Max Chars", - "agents.list[].bootstrapTotalMaxChars": "Agent Bootstrap Total Max Chars", - "agents.list[].experimental": "Agent Experimental Flags", - "agents.list[].experimental.localModelLean": "Agent Lean Local Model Mode", + "agents.entries.*.identity.avatar": "Identity Avatar", + "agents.entries.*.skills": "Agent Skill Filter", + "agents.entries.*.runtime": "Agent Runtime", + "agents.entries.*.runtime.type": "Agent Runtime Type", + "agents.entries.*.runtime.acp": "Agent ACP Runtime", + "agents.entries.*.runtime.acp.agent": "Agent ACP Harness Agent", + "agents.entries.*.runtime.acp.backend": "Agent ACP Backend", + "agents.entries.*.runtime.acp.mode": "Agent ACP Mode", + "agents.entries.*.runtime.acp.cwd": "Agent ACP Working Directory", + "agents.entries.*.thinkingDefault": "Agent Thinking Default", + "agents.entries.*.reasoningDefault": "Agent Reasoning Default", + "agents.entries.*.fastModeDefault": "Agent Fast Mode Default", + "agents.defaults.fastModeDefault": "Default Agent Fast Mode", + "agents.entries.*.contextInjection": "Agent Context Injection", + "agents.entries.*.bootstrapMaxChars": "Agent Bootstrap Max Chars", + "agents.entries.*.bootstrapTotalMaxChars": "Agent Bootstrap Total Max Chars", + "agents.entries.*.experimental": "Agent Experimental Flags", + "agents.entries.*.experimental.localModelLean": "Agent Lean Local Model Mode", agents: "Agents", "agents.defaults": "Agent Defaults", "agents.defaults.contextLimits": "Default Context Limits", @@ -111,30 +86,27 @@ export const FIELD_LABELS: Record = { "agents.defaults.contextLimits.memoryGetDefaultLines": "Default memory_get Line Window", "agents.defaults.contextLimits.toolResultMaxChars": "Default Tool Result Max Chars", "agents.defaults.contextLimits.postCompactionMaxChars": "Default Post-compaction Max Chars", - "agents.list": "Agent List", - "agents.list[].skillsLimits": "Agent Skills Limits", - "agents.list[].skillsLimits.maxSkillsPromptChars": "Agent Skills Prompt Max Chars", - "agents.list[].contextLimits": "Agent Context Limits", - "agents.list[].contextLimits.memoryGetMaxChars": "Agent memory_get Max Chars", - "agents.list[].contextLimits.memoryGetDefaultLines": "Agent memory_get Line Window", - "agents.list[].contextLimits.toolResultMaxChars": "Agent Tool Result Max Chars", - "agents.list[].contextLimits.postCompactionMaxChars": "Agent Post-compaction Max Chars", - "agents.list.*.models": "Agent Model Overrides", - "agents.list.*.modelPolicy": "Agent Model Policy", - "agents.list.*.modelPolicy.allow": "Allowed Agent Models", - "agents.list.*.models.*.agentRuntime": "Agent Model Runtime", - "agents.list.*.models.*.agentRuntime.id": "Agent Model Runtime ID", - "agents.list.*.agentRuntime": "Legacy Agent Runtime", - "agents.list.*.agentRuntime.id": "Legacy Agent Runtime ID", + "agents.entries": "Agent List", + "agents.entries.*.skillsLimits": "Agent Skills Limits", + "agents.entries.*.skillsLimits.maxSkillsPromptChars": "Agent Skills Prompt Max Chars", + "agents.entries.*.contextLimits": "Agent Context Limits", + "agents.entries.*.contextLimits.memoryGetMaxChars": "Agent memory_get Max Chars", + "agents.entries.*.contextLimits.memoryGetDefaultLines": "Agent memory_get Line Window", + "agents.entries.*.contextLimits.toolResultMaxChars": "Agent Tool Result Max Chars", + "agents.entries.*.contextLimits.postCompactionMaxChars": "Agent Post-compaction Max Chars", + "agents.entries.*.models": "Agent Model Overrides", + "agents.entries.*.modelPolicy": "Agent Model Policy", + "agents.entries.*.modelPolicy.allow": "Allowed Agent Models", + "agents.entries.*.models.*.agentRuntime": "Agent Model Runtime", + "agents.entries.*.models.*.agentRuntime.id": "Agent Model Runtime ID", + "agents.entries.*.agentRuntime": "Legacy Agent Runtime", + "agents.entries.*.agentRuntime.id": "Legacy Agent Runtime ID", cloudWorkers: "Cloud Workers", "cloudWorkers.profiles": "Cloud Worker Profiles", "cloudWorkers.profiles.*": "Cloud Worker Profile", "cloudWorkers.profiles.*.provider": "Cloud Worker Provider", "cloudWorkers.profiles.*.install": "Cloud Worker Install Method", "cloudWorkers.profiles.*.settings": "Cloud Worker Provider Settings", - "cloudWorkers.profiles.*.lifetime": "Cloud Worker Lifetime Policy", - "cloudWorkers.profiles.*.lifetime.idleTimeoutMinutes": "Cloud Worker Idle Timeout (minutes)", - "cloudWorkers.profiles.*.lifetime.maxLifetimeMinutes": "Cloud Worker Maximum Lifetime (minutes)", gateway: "Gateway", "gateway.port": "Gateway Port", "gateway.mode": "Gateway Mode", @@ -191,7 +163,6 @@ export const FIELD_LABELS: Record = { "browser.enabled": "Browser Enabled", "browser.allowSystemProfileImport": "Allow System Profile Import", "browser.cdpUrl": "Browser CDP URL", - "browser.color": "Browser Accent Color", "browser.executablePath": "Browser Executable Path", "browser.headless": "Browser Headless Mode", "browser.noSandbox": "Browser No-Sandbox Mode", @@ -207,31 +178,30 @@ export const FIELD_LABELS: Record = { "browser.profiles.*.executablePath": "Browser Profile Executable Path", "browser.profiles.*.headless": "Browser Profile Headless Mode", "browser.profiles.*.attachOnly": "Browser Profile Attach-only Mode", - "browser.profiles.*.color": "Browser Profile Accent Color", tools: "Tools", "tools.allow": "Tool Allowlist", "tools.deny": "Tool Denylist", "tools.web": "Web Tools", "tools.exec": "Exec Tool", "tools.media.image.enabled": "Enable Image Understanding", + "tools.media.image.preferredModel": "Preferred Image Understanding Model", "tools.media.image.maxBytes": "Image Understanding Max Bytes", "tools.media.image.maxChars": "Image Understanding Max Chars", "tools.media.image.prompt": "Image Understanding Prompt", "tools.media.image.timeoutSeconds": "Image Understanding Timeout (sec)", - "tools.media.image.attachments": "Image Understanding Attachment Policy", - "tools.media.image.models": "Image Understanding Models", "tools.media.image.scope": "Image Understanding Scope", + "tools.media.image.attachments": "Image Understanding Attachment Policy", "tools.media.models": "Media Understanding Shared Models", "tools.media.concurrency": "Media Understanding Concurrency", ...MEDIA_AUDIO_FIELD_LABELS, "tools.media.video.enabled": "Enable Video Understanding", + "tools.media.video.preferredModel": "Preferred Video Understanding Model", "tools.media.video.maxBytes": "Video Understanding Max Bytes", "tools.media.video.maxChars": "Video Understanding Max Chars", "tools.media.video.prompt": "Video Understanding Prompt", "tools.media.video.timeoutSeconds": "Video Understanding Timeout (sec)", - "tools.media.video.attachments": "Video Understanding Attachment Policy", - "tools.media.video.models": "Video Understanding Models", "tools.media.video.scope": "Video Understanding Scope", + "tools.media.video.attachments": "Video Understanding Attachment Policy", "tools.links.enabled": "Enable Link Understanding", "tools.links.maxLinks": "Link Understanding Max Links", "tools.links.timeoutSeconds": "Link Understanding Timeout (sec)", @@ -239,17 +209,17 @@ export const FIELD_LABELS: Record = { "tools.links.scope": "Link Understanding Scope", "tools.profile": "Tool Profile", "tools.alsoAllow": "Tool Allowlist Additions", - "agents.list[].tools.profile": "Agent Tool Profile", - "agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions", - "agents.list[].tools.codeMode": "Agent Code Mode", - "agents.list[].tools.swarm": "Agent Swarm", + "agents.entries.*.tools.profile": "Agent Tool Profile", + "agents.entries.*.tools.alsoAllow": "Agent Tool Allowlist Additions", + "agents.entries.*.tools.codeMode": "Agent Code Mode", + "agents.entries.*.tools.swarm": "Agent Swarm", "tools.byProvider": "Tool Policy by Provider", - "agents.list[].tools.byProvider": "Agent Tool Policy by Provider", - "agents.list[].tools.message.crossContext.allowWithinProvider": + "agents.entries.*.tools.byProvider": "Agent Tool Policy by Provider", + "agents.entries.*.tools.message.crossContext.allowWithinProvider": "Agent Cross-Context Messaging (Same Provider)", - "agents.list[].tools.message.crossContext.allowAcrossProviders": + "agents.entries.*.tools.message.crossContext.allowAcrossProviders": "Agent Cross-Context Messaging (Across Providers)", - "agents.list[].tools.message.actions.allow": "Agent Message Action Allowlist", + "agents.entries.*.tools.message.actions.allow": "Agent Message Action Allowlist", "tools.exec.applyPatch.enabled": "Enable apply_patch", "tools.exec.applyPatch.workspaceOnly": "apply_patch Workspace-Only", "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", @@ -261,11 +231,11 @@ export const FIELD_LABELS: Record = { "tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)", "tools.exec.host": "Exec Target", "tools.exec.mode": "Exec Mode", + "tools.exec.security": "Exec Security", + "tools.exec.ask": "Exec Ask", "tools.exec.reviewer": "Exec Reviewer", "tools.exec.reviewer.model": "Exec Reviewer Model", "tools.exec.reviewer.timeoutMs": "Exec Reviewer Timeout (ms)", - "tools.exec.security": "Exec Security", - "tools.exec.ask": "Exec Ask", "tools.exec.node": "Exec Node Binding", "tools.agentToAgent": "Agent-to-Agent Tool Access", "tools.agentToAgent.enabled": "Enable Agent-to-Agent Tool", @@ -373,12 +343,10 @@ export const FIELD_LABELS: Record = { "gateway.controlUi.root": "Control UI Assets Root", "gateway.controlUi.embedSandbox": "Control UI Embed Sandbox Mode", "gateway.controlUi.allowExternalEmbedUrls": "Allow External Control UI Embed URLs", - "gateway.controlUi.chatMessageMaxWidth": "Control UI Chat Message Max Width", + "ui.prefs.chatMessageMaxWidth": "Control UI Chat Message Max Width", "gateway.controlUi.allowedOrigins": "Control UI Allowed Origins", "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": "Dangerously Allow Host-Header Origin Fallback", - "gateway.controlUi.allowInsecureAuth": "Insecure Control UI Auth Toggle", - "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", "mcp.apps": "MCP Apps", "mcp.apps.enabled": "MCP Apps Enabled", "mcp.apps.sandboxOrigin": "MCP Apps Sandbox Origin", @@ -409,7 +377,7 @@ export const FIELD_LABELS: Record = { "gateway.nodes.pairing.autoApproveCidrs": "Gateway Node Pairing Auto-Approve CIDRs", "gateway.nodes.pairing.sshVerify": "Gateway Node Pairing SSH Verification", ...NODE_CAPABILITY_FIELD_LABELS, - "gateway.nodes.denyCommands": "Gateway Node Denylist", + "gateway.nodes.commands.deny": "Gateway Node Denylist", nodeHost: "Node Host", "nodeHost.agentRuns": "Node Agent Runs", "nodeHost.agentRuns.claude": "Node Claude Agent Runs", @@ -421,9 +389,8 @@ export const FIELD_LABELS: Record = { "nodeHost.mcp.servers": "Node Host MCP Servers", "nodeHost.skills": "Node Host Skills", "nodeHost.skills.enabled": "Node Host Skills Enabled", - media: "Media", - "media.preserveFilenames": "Preserve Media Filenames", - "media.ttlHours": "Media Retention TTL (hours)", + attachments: "Attachments", + "attachments.ttlHours": "Attachment Retention TTL (hours)", bindings: "Bindings", "bindings[].type": "Binding Type", "bindings[].agentId": "Binding Agent ID", @@ -453,19 +420,15 @@ export const FIELD_LABELS: Record = { "skills.workshop.allowSymlinkTargetWrites": "Allow Skill Workshop Symlink Writes", "agents.defaults.skills": "Skills", "agents.defaults.subagents.delegationMode": "Sub-agent Delegation Mode", - "agents.list[].subagents.delegationMode": "Sub-agent Delegation Mode", + "agents.entries.*.subagents.delegationMode": "Sub-agent Delegation Mode", "agents.defaults.workspace": "Workspace", "agents.defaults.repoRoot": "Repo Root", - "agents.defaults.promptOverlays": "Prompt Overlays", - "agents.defaults.promptOverlays.gpt5": "GPT-5 Prompt Overlay", - "agents.defaults.promptOverlays.gpt5.personality": "GPT-5 Personality Overlay", "agents.defaults.skipOptionalBootstrapFiles": "Skipped Optional Bootstrap Files", "agents.defaults.contextInjection": "Context Injection", "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", "agents.defaults.bootstrapTotalMaxChars": "Bootstrap Total Max Chars", "agents.defaults.experimental": "Experimental Agent Flags", "agents.defaults.experimental.localModelLean": "Enable Lean Local Model Mode (Experimental)", - "agents.defaults.bootstrapPromptTruncationWarning": "Bootstrap Prompt Truncation Warning", "agents.defaults.startupContext": "Startup Context", "agents.defaults.startupContext.enabled": "Enable Startup Context", "agents.defaults.startupContext.applyOn": "Startup Context Apply On", @@ -473,68 +436,42 @@ export const FIELD_LABELS: Record = { "agents.defaults.startupContext.maxFileBytes": "Startup Context Max File Bytes", "agents.defaults.startupContext.maxFileChars": "Startup Context Max File Chars", "agents.defaults.startupContext.maxTotalChars": "Startup Context Max Total Chars", - "agents.defaults.envelopeTimezone": "Envelope Timezone", - "agents.defaults.envelopeTimestamp": "Envelope Timestamp", - "agents.defaults.envelopeElapsed": "Envelope Elapsed", - "agents.defaults.memorySearch": "Memory Search", - "agents.defaults.memorySearch.enabled": "Enable Memory Search", - "agents.defaults.memorySearch.rememberAcrossConversations": "Remember Across Conversations", - "agents.defaults.memorySearch.sources": "Memory Search Sources", - "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", - "agents.defaults.memorySearch.qmd": "Memory Search QMD Collections", - "agents.defaults.memorySearch.qmd.extraCollections": "QMD Extra Collections", - "agents.defaults.memorySearch.qmd.extraCollections.path": "QMD Extra Collection Path", - "agents.defaults.memorySearch.qmd.extraCollections.name": "QMD Extra Collection Name", - "agents.defaults.memorySearch.qmd.extraCollections.pattern": "QMD Extra Collection Pattern", - "agents.defaults.memorySearch.multimodal": "Memory Search Multimodal", - "agents.defaults.memorySearch.multimodal.enabled": "Enable Memory Search Multimodal", - "agents.defaults.memorySearch.multimodal.modalities": "Memory Search Multimodal Modalities", - "agents.defaults.memorySearch.multimodal.maxFileBytes": "Memory Search Multimodal Max File Bytes", - "agents.defaults.memorySearch.experimental.sessionMemory": - "Memory Search Session Index (Experimental)", - "agents.defaults.memorySearch.provider": "Memory Search Provider", - "agents.defaults.memorySearch.remote.baseUrl": "Remote Embedding Base URL", - "agents.defaults.memorySearch.remote.apiKey": "Remote Embedding API Key", - "agents.defaults.memorySearch.remote.headers": "Remote Embedding Headers", - "agents.defaults.memorySearch.remote.nonBatchConcurrency": - "Remote Non-Batch Embedding Concurrency", - "agents.defaults.memorySearch.remote.batch.enabled": "Remote Batch Embedding Enabled", - "agents.defaults.memorySearch.remote.batch.wait": "Remote Batch Wait for Completion", - "agents.defaults.memorySearch.remote.batch.concurrency": "Remote Batch Concurrency", - "agents.defaults.memorySearch.remote.batch.pollIntervalMs": "Remote Batch Poll Interval (ms)", - "agents.defaults.memorySearch.remote.batch.timeoutMinutes": "Remote Batch Timeout (min)", - "agents.defaults.memorySearch.model": "Memory Search Model", - "agents.defaults.memorySearch.inputType": "Memory Search Input Type", - "agents.defaults.memorySearch.queryInputType": "Memory Search Query Input Type", - "agents.defaults.memorySearch.documentInputType": "Memory Search Document Input Type", - "agents.defaults.memorySearch.outputDimensionality": "Memory Search Output Dimensionality", - "agents.defaults.memorySearch.fallback": "Memory Search Fallback", - "agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path", - "agents.defaults.memorySearch.local.contextSize": "Local Embedding Context Size", - "agents.defaults.memorySearch.store.vector.enabled": "Memory Search Vector Index", - "agents.defaults.memorySearch.store.vector.extensionPath": "Memory Search Vector Extension Path", - "agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start", - "agents.defaults.memorySearch.sync.onSearch": "Index on Search (Lazy)", - "agents.defaults.memorySearch.sync.watch": "Watch Memory Files", - "agents.defaults.memorySearch.sync.embeddingBatchTimeoutSeconds": "Embedding Batch Timeout (s)", - "agents.defaults.memorySearch.sync.sessions.deltaBytes": "Session Delta Bytes", - "agents.defaults.memorySearch.sync.sessions.deltaMessages": "Session Delta Messages", - "agents.defaults.memorySearch.sync.sessions.postCompactionForce": - "Force Reindex After Compaction", - "agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results", - "agents.defaults.memorySearch.query.minScore": "Memory Search Min Score", - "agents.defaults.memorySearch.query.hybrid.enabled": "Memory Search Hybrid", - "agents.defaults.memorySearch.query.hybrid.mmr.enabled": "Memory Search MMR Re-ranking", - "agents.defaults.memorySearch.query.hybrid.temporalDecay.enabled": "Memory Search Temporal Decay", - "agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache", + "memory.search": "Memory Search", + "memory.search.enabled": "Enable Memory Search", + "memory.search.rememberAcrossConversations": "Remember Across Conversations", + "memory.search.sources": "Memory Search Sources", + "memory.search.extraPaths": "Extra Memory Paths", + "memory.search.qmd": "Memory Search QMD Collections", + "memory.search.qmd.extraCollections": "QMD Extra Collections", + "memory.search.qmd.extraCollections.path": "QMD Extra Collection Path", + "memory.search.qmd.extraCollections.name": "QMD Extra Collection Name", + "memory.search.qmd.extraCollections.pattern": "QMD Extra Collection Pattern", + "memory.search.multimodal": "Memory Search Multimodal", + "memory.search.multimodal.enabled": "Enable Memory Search Multimodal", + "memory.search.multimodal.modalities": "Memory Search Multimodal Modalities", + "memory.search.multimodal.maxFileBytes": "Memory Search Multimodal Max File Bytes", + "memory.search.experimental.sessionMemory": "Memory Search Session Index (Experimental)", + "memory.search.provider": "Memory Search Provider", + "memory.search.remote.baseUrl": "Remote Embedding Base URL", + "memory.search.remote.apiKey": "Remote Embedding API Key", + "memory.search.remote.headers": "Remote Embedding Headers", + "memory.search.remote.batch.enabled": "Remote Batch Embedding Enabled", + "memory.search.model": "Memory Search Model", + "memory.search.inputType": "Memory Search Input Type", + "memory.search.queryInputType": "Memory Search Query Input Type", + "memory.search.documentInputType": "Memory Search Document Input Type", + "memory.search.outputDimensionality": "Memory Search Output Dimensionality", + "memory.search.fallback": "Memory Search Fallback", + "memory.search.local.modelPath": "Local Embedding Model Path", + "memory.search.store.vector.enabled": "Memory Search Vector Index", + "memory.search.store.vector.extensionPath": "Memory Search Vector Extension Path", + "memory.search.query.maxResults": "Memory Search Max Results", + "memory.search.query.minScore": "Memory Search Min Score", + "memory.search.cache.enabled": "Memory Search Embedding Cache", memory: "Memory", "memory.backend": "Memory Backend", "memory.citations": "Memory Citations Mode", "memory.qmd.command": "QMD Binary", - "memory.qmd.mcporter": "QMD MCPorter", - "memory.qmd.mcporter.enabled": "QMD MCPorter Enabled", - "memory.qmd.mcporter.serverName": "QMD MCPorter Server Name", - "memory.qmd.mcporter.startDaemon": "QMD MCPorter Start Daemon", "memory.qmd.searchMode": "QMD Search Mode", "memory.qmd.rerank": "QMD Rerank", "memory.qmd.searchTool": "QMD Search Tool Override", @@ -546,16 +483,6 @@ export const FIELD_LABELS: Record = { "memory.qmd.sessions.enabled": "QMD Session Indexing", "memory.qmd.sessions.exportDir": "QMD Session Export Directory", "memory.qmd.sessions.retentionDays": "QMD Session Retention (days)", - "memory.qmd.update.interval": "QMD Update Interval", - "memory.qmd.update.debounceMs": "QMD Update Debounce (ms)", - "memory.qmd.update.onBoot": "QMD Update on Manager Start", - "memory.qmd.update.startup": "QMD Gateway Startup Refresh", - "memory.qmd.update.startupDelayMs": "QMD Gateway Startup Delay (ms)", - "memory.qmd.update.waitForBootSync": "QMD Wait for Manager-Start Sync", - "memory.qmd.update.embedInterval": "QMD Embed Interval", - "memory.qmd.update.commandTimeoutMs": "QMD Command Timeout (ms)", - "memory.qmd.update.updateTimeoutMs": "QMD Update Timeout (ms)", - "memory.qmd.update.embedTimeoutMs": "QMD Embed Timeout (ms)", "memory.qmd.limits.maxResults": "QMD Max Results", "memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars", "memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars", @@ -655,30 +582,38 @@ export const FIELD_LABELS: Record = { "agents.defaults.model.primary": "Primary Model", "agents.defaults.model.fallbacks": "Model Fallbacks", "agents.defaults.utilityModel": "Utility Model", - "agents.list.*.utilityModel": "Agent Utility Model", + "agents.entries.*.utilityModel": "Agent Utility Model", "agents.defaults.imageModel.primary": "Image Model", "agents.defaults.imageModel.fallbacks": "Image Model Fallbacks", - "agents.defaults.imageGenerationModel.primary": "Image Generation Model", - "agents.defaults.imageGenerationModel.fallbacks": "Image Generation Model Fallbacks", - "agents.defaults.imageGenerationModel.timeoutMs": "Image Generation Timeout (ms)", - "agents.defaults.videoGenerationModel.primary": "Video Generation Model", - "agents.defaults.videoGenerationModel.fallbacks": "Video Generation Model Fallbacks", - "agents.defaults.videoGenerationModel.timeoutMs": "Video Generation Timeout (ms)", - "agents.defaults.musicGenerationModel.primary": "Music Generation Model", - "agents.defaults.musicGenerationModel.fallbacks": "Music Generation Model Fallbacks", + "agents.defaults.mediaModels.image.primary": "Image Generation Model", + "agents.defaults.mediaModels.image.fallbacks": "Image Generation Model Fallbacks", + "agents.defaults.mediaModels.image.timeoutMs": "Image Generation Timeout (ms)", + "agents.defaults.mediaModels.video.primary": "Video Generation Model", + "agents.defaults.mediaModels.video.fallbacks": "Video Generation Model Fallbacks", + "agents.defaults.mediaModels.video.timeoutMs": "Video Generation Timeout (ms)", + "agents.defaults.mediaModels.music.primary": "Music Generation Model", + "agents.defaults.mediaModels.music.fallbacks": "Music Generation Model Fallbacks", "agents.defaults.voiceModel.primary": "Voice Model", "agents.defaults.voiceModel.fallbacks": "Voice Model Fallbacks", "agents.defaults.voiceModel.timeoutMs": "Voice Timeout (ms)", - "agents.defaults.mediaGenerationAutoProviderFallback": "Media Generation Auto Provider Fallback", "agents.defaults.pdfModel.primary": "PDF Model", "agents.defaults.pdfModel.fallbacks": "PDF Model Fallbacks", - "agents.defaults.pdfMaxBytesMb": "PDF Max Size (MB)", + "agents.defaults.pdfMaxMb": "PDF Max Size (MB)", "agents.defaults.pdfMaxPages": "PDF Max Pages", "agents.defaults.imageMaxDimensionPx": "Image Max Dimension (px)", "agents.defaults.imageQuality": "Image Quality", "agents.defaults.humanDelay.mode": "Human Delay Mode", "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", + "agents.defaults.typingMode": "Typing Mode", + "agents.defaults.typingIntervalSeconds": "Typing Interval (Seconds)", + "agents.entries.*.typingMode": "Agent Typing Mode", + "agents.entries.*.typingIntervalSeconds": "Agent Typing Interval (Seconds)", + "agents.entries.*.sandbox.browser.network": "Agent Sandbox Browser Network", + "agents.entries.*.sandbox.browser.cdpSourceRange": "Agent Sandbox Browser CDP Source Range", + "agents.entries.*.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": + "Agent Sandbox Docker Allow Container Namespace Join", + "agents.entries.*.sandbox.docker.gpus": "Agent Sandbox Docker GPUs", "agents.defaults.cliBackends": "CLI Backends", "agents.defaults.compaction": "Compaction", "agents.defaults.compaction.mode": "Compaction Mode", @@ -686,7 +621,6 @@ export const FIELD_LABELS: Record = { "agents.defaults.compaction.thinkingLevel": "Compaction Thinking Level", "agents.defaults.compaction.keepRecentTokens": "Compaction Keep Recent Tokens", "agents.defaults.compaction.identifierPolicy": "Compaction Identifier Policy", - "agents.defaults.compaction.identifierInstructions": "Compaction Identifier Instructions", "agents.defaults.compaction.recentTurnsPreserve": "Compaction Preserve Recent Turns", "agents.defaults.compaction.qualityGuard": "Compaction Quality Guard", "agents.defaults.compaction.qualityGuard.enabled": "Compaction Quality Guard Enabled", @@ -708,24 +642,16 @@ export const FIELD_LABELS: Record = { "Compaction Memory Flush Soft Threshold", "agents.defaults.compaction.memoryFlush.forceFlushTranscriptBytes": "Compaction Memory Flush Transcript Size Threshold", - "agents.defaults.compaction.memoryFlush.prompt": "Compaction Memory Flush Prompt", - "agents.defaults.compaction.memoryFlush.systemPrompt": "Compaction Memory Flush System Prompt", "agents.defaults.embeddedAgent": "Embedded OpenClaw", "agents.defaults.embeddedAgent.projectSettingsPolicy": "Embedded OpenClaw Project Settings Policy", "agents.defaults.embeddedAgent.executionContract": "Embedded OpenClaw Execution Contract", - "agents.defaults.heartbeat.includeSystemPromptSection": "Heartbeat Include System Prompt Section", - "agents.list.*.heartbeat.includeSystemPromptSection": "Heartbeat Include System Prompt Section", - "agents.list[].embeddedAgent": "Agent Embedded OpenClaw", - "agents.list[].embeddedAgent.executionContract": "Agent Embedded OpenClaw Execution Contract", + "agents.entries.*.embeddedAgent": "Agent Embedded OpenClaw", + "agents.entries.*.embeddedAgent.executionContract": "Agent Embedded OpenClaw Execution Contract", "agents.defaults.heartbeat.directPolicy": "Heartbeat Direct Policy", - "agents.list.*.heartbeat.directPolicy": "Heartbeat Direct Policy", - "agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings", - "agents.list.*.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings", + "agents.entries.*.heartbeat.directPolicy": "Heartbeat Direct Policy", "agents.defaults.heartbeat.timeoutSeconds": "Heartbeat Timeout (Seconds)", - "agents.list.*.heartbeat.timeoutSeconds": "Heartbeat Timeout (Seconds)", - "agents.defaults.heartbeat.skipWhenBusy": "Heartbeat Skip When Busy", - "agents.list.*.heartbeat.skipWhenBusy": "Heartbeat Skip When Busy", + "agents.entries.*.heartbeat.timeoutSeconds": "Heartbeat Timeout (Seconds)", "agents.defaults.sandbox.browser.network": "Sandbox Browser Network", "agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range", "agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": @@ -742,10 +668,7 @@ export const FIELD_LABELS: Record = { "commands.plugins": "Allow /plugins", "commands.debug": "Allow /debug", "commands.restart": "Allow Restart", - "commands.useAccessGroups": "Use Access Groups", "commands.ownerAllowFrom": "Command Owners", - "commands.ownerDisplay": "Owner ID Display", - "commands.ownerDisplaySecret": "Owner ID Hash Secret", // pragma: allowlist secret "commands.allowFrom": "Command Elevated Access Rules", mcp: "MCP", "mcp.servers": "MCP Servers", @@ -788,13 +711,13 @@ export const FIELD_LABELS: Record = { "browser.ssrfPolicy": "Browser SSRF Policy", "browser.ssrfPolicy.dangerouslyAllowPrivateNetwork": "Browser Dangerously Allow Private Network", "browser.ssrfPolicy.allowedHostnames": "Browser Allowed Hostnames", - "browser.ssrfPolicy.hostnameAllowlist": "Browser Hostname Allowlist", + "tools.exec.timeoutSeconds": "Exec Timeout (Seconds)", + "agents.entries.*.tools.exec.timeoutSeconds": "Agent Exec Timeout (Seconds)", session: "Session", "session.scope": "Session Scope", "session.dmScope": "DM Session Scope", "session.identityLinks": "Session Identity Links", "session.resetTriggers": "Session Reset Triggers", - "session.idleMinutes": "Session Idle Minutes", "session.reset": "Session Reset Policy", "session.reset.mode": "Session Reset Mode", "session.reset.atHour": "Session Daily Reset Hour", @@ -805,7 +728,6 @@ export const FIELD_LABELS: Record = { "session.resetByType.thread": "Session Reset (Thread)", "session.resetByChannel": "Session Reset by Channel", "session.store": "Session Store Path", - "session.typingMode": "Session Typing Mode", "session.mainKey": "Session Main Key", "session.sendPolicy": "Session Send Policy", "session.sendPolicy.default": "Session Send Policy Default Action", @@ -831,7 +753,6 @@ export const FIELD_LABELS: Record = { "session.maintenance.highWaterBytes": "Session Disk High-water Target", cron: "Cron", "cron.enabled": "Cron Enabled", - "cron.store": "Cron Store Path", "cron.webhookToken": "Cron Webhook Bearer Token", "cron.sessionRetention": "Cron Session Retention", transcripts: "Transcripts", @@ -902,12 +823,9 @@ export const FIELD_LABELS: Record = { "hooks.internal.entries": "Internal Hook Entries", "hooks.internal.load": "Internal Hook Loader", "hooks.internal.load.extraDirs": "Internal Hook Extra Directories", - "hooks.internal.installs": "Internal Hook Install Records", web: "Web Channel", - "web.enabled": "Web Channel Enabled", discovery: "Discovery", "discovery.wideArea": "Wide-area Discovery", - "discovery.wideArea.enabled": "Wide-area Discovery Enabled", "discovery.wideArea.domain": "Wide-area Discovery Domain", "discovery.mdns": "mDNS Discovery", talk: "Talk", @@ -936,21 +854,18 @@ export const FIELD_LABELS: Record = { "messages.suppressToolErrors": "Suppress Tool Error Warnings", "messages.ackReaction": "Ack Reaction Emoji", "messages.ackReactionScope": "Ack Reaction Scope", - "messages.removeAckAfterReply": "Remove Ack Reaction After Reply", "messages.statusReactions": "Status Reactions", "messages.statusReactions.enabled": "Enable Status Reactions", - "messages.statusReactions.emojis": "Status Reaction Emojis", "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", "messages.inbound.byChannel": "Inbound Debounce by Channel (ms)", - "messages.tts": "Message Text-to-Speech", - "messages.tts.persona": "TTS Persona", - "messages.tts.personas": "TTS Personas", - "messages.tts.personas.*": "TTS Persona", - "messages.tts.personas.*.prompt": "TTS Persona Prompt", - "messages.tts.personas.*.providers": "TTS Persona Provider Bindings", - "messages.tts.providers": "TTS Provider Settings", - "messages.tts.providers.*": "TTS Provider Config", - "messages.tts.providers.*.apiKey": "TTS Provider API Key", // pragma: allowlist secret + tts: "Text-to-Speech", + "tts.persona": "TTS Persona", + "tts.personas": "TTS Personas", + "tts.personas.*": "TTS Persona", + "tts.personas.*.providers": "TTS Persona Provider Bindings", + "tts.providers": "TTS Provider Settings", + "tts.providers.*": "TTS Provider Config", + "tts.providers.*.apiKey": "TTS Provider API Key", // pragma: allowlist secret "talk.provider": "Talk Active Provider", "talk.providers": "Talk Provider Settings", "talk.providers.*": "Talk Provider Config", @@ -981,10 +896,10 @@ export const FIELD_LABELS: Record = { "channels.defaults.implicitMentions.quotedBot": "Default Quoted-Bot Implicit Mentions", "channels.defaults.implicitMentions.threadParticipation": "Default Thread-Participation Implicit Mentions", - "channels.defaults.heartbeat": "Default Heartbeat Visibility", - "channels.defaults.heartbeat.showOk": "Heartbeat Show OK", - "channels.defaults.heartbeat.showAlerts": "Heartbeat Show Alerts", - "channels.defaults.heartbeat.useIndicator": "Heartbeat Use Indicator", + "channels.defaults.heartbeatVisibility": "Default Heartbeat Visibility", + "channels.defaults.heartbeatVisibility.showOk": "Heartbeat Show OK", + "channels.defaults.heartbeatVisibility.showAlerts": "Heartbeat Show Alerts", + "channels.defaults.heartbeatVisibility.useIndicator": "Heartbeat Use Indicator", "channels.defaults.botLoopProtection": "Default Bot Loop Protection", "channels.defaults.botLoopProtection.enabled": "Default Bot Loop Protection Enabled", "channels.defaults.botLoopProtection.maxEventsPerWindow": "Default Bot Loop Events per Window", @@ -999,17 +914,6 @@ export const FIELD_LABELS: Record = { "channels.mattermost.chatmode": "Mattermost Chat Mode", "channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes", "channels.mattermost.requireMention": "Mattermost Require Mention", - "agents.list[].skills": "Agent Skill Filter", - "agents.list[].identity.avatar": "Agent Avatar", - "agents.list[].heartbeat.suppressToolErrorWarnings": - "Agent Heartbeat Suppress Tool Error Warnings", - "agents.list[].heartbeat.timeoutSeconds": "Agent Heartbeat Timeout (Seconds)", - "agents.list[].heartbeat.skipWhenBusy": "Agent Heartbeat Skip When Busy", - "agents.list[].sandbox.browser.network": "Agent Sandbox Browser Network", - "agents.list[].sandbox.browser.cdpSourceRange": "Agent Sandbox Browser CDP Source Port Range", - "agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin": - "Agent Sandbox Docker Allow Container Namespace Join", - "agents.list[].sandbox.docker.gpus": "Agent Sandbox Docker GPUs", "discovery.mdns.mode": "mDNS Discovery Mode", plugins: "Plugins", "plugins.enabled": "Enable Plugins", diff --git a/src/config/schema.node-capabilities.ts b/src/config/schema.node-capabilities.ts index 4e5df6e6d79c..48dddd328e0f 100644 --- a/src/config/schema.node-capabilities.ts +++ b/src/config/schema.node-capabilities.ts @@ -3,15 +3,13 @@ export const NODE_CAPABILITY_FIELD_HELP: Record = { "Controls whether paired nodes may publish agent-visible plugin tool descriptors.", "gateway.nodes.pluginTools.enabled": "Accept agent-visible plugin tool descriptors published by paired nodes (default: true). Set false to ignore and remove all node-published plugin tools.", - "gateway.nodes.skills": "Controls whether paired nodes may publish agent-visible skills.", - "gateway.nodes.skills.enabled": + "gateway.nodes.allowSkills": "Accept skills published by paired nodes while they are connected (default: true). Set false to ignore node-published skills.", }; export const NODE_CAPABILITY_FIELD_LABELS: Record = { "gateway.nodes.pluginTools": "Gateway Node Plugin Tools", "gateway.nodes.pluginTools.enabled": "Gateway Node Plugin Tools Enabled", - "gateway.nodes.skills": "Gateway Node Skills", - "gateway.nodes.skills.enabled": "Gateway Node Skills Enabled", - "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", + "gateway.nodes.allowSkills": "Gateway Node Skills Enabled", + "gateway.nodes.commands.allow": "Gateway Node Allowlist (Extra Commands)", }; diff --git a/src/config/schema.tags.ts b/src/config/schema.tags.ts index ee7047c9ac8b..1f76871b16a3 100644 --- a/src/config/schema.tags.ts +++ b/src/config/schema.tags.ts @@ -48,7 +48,7 @@ const TAG_OVERRIDES: Record = { "gateway.push.apns.relay.baseUrl": ["network", "advanced"], "gateway.controlUi.embedSandbox": ["security", "access", "advanced"], "gateway.controlUi.allowExternalEmbedUrls": ["security", "access", "network", "advanced"], - "gateway.controlUi.chatMessageMaxWidth": ["advanced"], + "ui.prefs.chatMessageMaxWidth": ["advanced"], "gateway.controlUi.toolTitles": ["advanced"], "gateway.controlUi.sessionObserver": ["advanced"], "gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": [ @@ -57,15 +57,13 @@ const TAG_OVERRIDES: Record = { "network", "advanced", ], - "gateway.controlUi.dangerouslyDisableDeviceAuth": ["security", "access", "network", "advanced"], - "gateway.controlUi.allowInsecureAuth": ["security", "access", "network", "advanced"], "gateway.nodes.pairing.autoApproveCidrs": ["security", "access", "network", "advanced"], "gateway.nodes.pairing.sshVerify": ["security", "access", "network", "advanced"], "mcp.apps.enabled": ["security", "access", "advanced"], "mcp.apps.sandboxOrigin": ["security", "network", "advanced"], "mcp.apps.sandboxPort": ["network", "advanced"], "gateway.nodes.pluginTools.enabled": ["tools", "security", "access", "network", "advanced"], - "gateway.nodes.skills.enabled": ["tools", "security", "access", "network", "advanced"], + "gateway.nodes.allowSkills": ["tools", "security", "access", "network", "advanced"], "nodeHost.agentRuns.claude.enabled": ["tools", "security", "access", "network", "advanced"], "nodeHost.mcp.servers": ["tools", "network", "advanced"], "nodeHost.skills.enabled": ["tools", "network", "advanced"], diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 3b812fa4e39a..1822bbaa3e70 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -184,7 +184,7 @@ describe("config schema", () => { expect(result.success).toBe(true); }); - it("accepts queued status reaction emoji overrides", () => { + it("rejects retired status reaction emoji overrides", () => { const result = OpenClawSchema.safeParse({ messages: { statusReactions: { @@ -194,7 +194,7 @@ describe("config schema", () => { }, }, }); - expect(result.success).toBe(true); + expect(result.success).toBe(false); }); it("includes MCP SSE header schema under mcp.servers entries", () => { @@ -691,8 +691,8 @@ describe("config schema", () => { ).toBe(true); }); - it("accepts only the Discord subagent progress enable toggle", () => { - expect(DiscordConfigSchema.safeParse({ subagentProgress: true }).success).toBe(true); + it("rejects retired Discord subagent progress config", () => { + expect(DiscordConfigSchema.safeParse({ subagentProgress: true }).success).toBe(false); expect(DiscordConfigSchema.safeParse({ subagentProgress: { enabled: true } }).success).toBe( false, ); @@ -701,15 +701,14 @@ describe("config schema", () => { it("keeps per-agent model overrides limited to model selection", () => { const result = OpenClawSchema.safeParse({ agents: { - list: [ - { - id: "main", + entries: { + main: { model: { primary: "openai/gpt-5.5", timeoutMs: 30_000, }, }, - ], + }, }, }); @@ -719,9 +718,8 @@ describe("config schema", () => { it("rejects per-agent subagent model timeout config", () => { const result = OpenClawSchema.safeParse({ agents: { - list: [ - { - id: "main", + entries: { + main: { subagents: { model: { primary: "openai/gpt-5.5", @@ -729,7 +727,7 @@ describe("config schema", () => { }, }, }, - ], + }, }, }); @@ -746,19 +744,18 @@ describe("config schema", () => { const config = OpenClawSchema.parse({ agents: { - list: [ - { - id: "main", + entries: { + main: { tools: { exec: { commandHighlighting: false, }, }, }, - ], + }, }, }); - expect(config.agents?.list?.[0]?.tools?.exec?.commandHighlighting).toBe(false); + expect(config.agents?.entries?.main?.tools?.exec?.commandHighlighting).toBe(false); }); it("accepts exec reviewer model config in global and agent scopes", () => { @@ -778,9 +775,8 @@ describe("config schema", () => { const config = OpenClawSchema.parse({ agents: { - list: [ - { - id: "main", + entries: { + main: { tools: { exec: { reviewer: { @@ -789,10 +785,10 @@ describe("config schema", () => { }, }, }, - ], + }, }, }); - expect(config.agents?.list?.[0]?.tools?.exec?.reviewer?.model).toBe("openai/gpt-5.5"); + expect(config.agents?.entries?.main?.tools?.exec?.reviewer?.model).toBe("openai/gpt-5.5"); }); it("rejects mixed normalized and legacy exec policy config", () => { @@ -884,8 +880,6 @@ describe("config schema", () => { }, passEnv: ["OPENCLAW_STATE_DIR"], trustedDirs: ["/usr/local/bin"], - allowInsecurePath: false, - allowSymlinkCommand: false, }, }, }, @@ -1010,27 +1004,25 @@ describe("config schema", () => { it("rejects allowPrivateNetwork on media-understanding request config", () => { const result = ToolsSchema.safeParse({ media: { - image: { - models: [ - { - provider: "openai", - model: "gpt-4.1-mini", - request: { - allowPrivateNetwork: true, - }, + models: [ + { + provider: "openai", + model: "gpt-4.1-mini", + capabilities: ["image"], + request: { + allowPrivateNetwork: true, }, - ], - }, + }, + ], }, }); expect(result.success).toBe(false); if (!result.success) { const requestIssue = result.error.issues.find( - (issue) => - JSON.stringify(issue.path) === JSON.stringify(["media", "image", "models", 0, "request"]), + (issue) => JSON.stringify(issue.path) === JSON.stringify(["media", "models", 0, "request"]), ); - expect(requestIssue?.path).toEqual(["media", "image", "models", 0, "request"]); + expect(requestIssue?.path).toEqual(["media", "models", 0, "request"]); const requestKeys = (requestIssue as { keys?: unknown } | undefined)?.keys; expect(requestKeys).toEqual(["allowPrivateNetwork"]); } @@ -1086,9 +1078,9 @@ describe("config schema", () => { }); it("returns a shallow lookup schema without nested composition keywords", () => { - const lookup = lookupConfigSchema(baseSchema, "agents.list.0.runtime"); - expect(lookup?.path).toBe("agents.list.0.runtime"); - expect(lookup?.hintPath).toBe("agents.list[].runtime"); + const lookup = lookupConfigSchema(baseSchema, "agents.entries.main.runtime"); + expect(lookup?.path).toBe("agents.entries.main.runtime"); + expect(lookup?.hintPath).toBe("agents.entries.*.runtime"); expect(lookup?.schema).not.toHaveProperty("allOf"); expect(lookup?.schema).not.toHaveProperty("oneOf"); const schema = lookup?.schema as { anyOf?: Array<{ properties?: Record }> }; @@ -1097,12 +1089,12 @@ describe("config schema", () => { expect(lookup?.schema).toHaveProperty("description"); }); - it("keeps scoped collection item schemas for form editing", () => { - const lookup = lookupConfigSchema(baseSchema, "agents.list"); - expect(lookup?.schema).toHaveProperty("items"); + it("keeps scoped record entry schemas for form editing", () => { + const lookup = lookupConfigSchema(baseSchema, "agents.entries"); + expect(lookup?.schema).toHaveProperty("additionalProperties"); const schema = lookup?.schema as | { - items?: { + additionalProperties?: { properties?: Record< string, { anyOf?: Array<{ properties?: Record }> } @@ -1110,39 +1102,35 @@ describe("config schema", () => { }; } | undefined; - expect(schema?.items?.properties).toHaveProperty("runtime"); - const runtimeVariants = schema?.items?.properties?.runtime?.anyOf ?? []; + expect(schema?.additionalProperties?.properties).toHaveProperty("runtime"); + const runtimeVariants = schema?.additionalProperties?.properties?.runtime?.anyOf ?? []; expect(runtimeVariants.length).toBeGreaterThan(0); expect(runtimeVariants.some((variant) => variant.properties?.type)).toBe(true); }); it("keeps scoped map properties for form editing", () => { const lookup = lookupConfigSchema(baseSchema, "env"); - expect(lookup?.children.map((child) => child.key)).toEqual(["shellEnv", "vars", "*"]); - const schema = lookup?.schema as { properties?: Record } | undefined; - expect(schema?.properties).toHaveProperty("shellEnv"); - expect(schema?.properties).toHaveProperty("vars"); + expect(lookup?.children.map((child) => child.key)).toEqual(["shellEnv", "vars"]); }); it("matches wildcard ui hints for concrete lookup paths", () => { - const lookup = lookupConfigSchema(baseSchema, "agents.list.0.identity.avatar"); - expect(lookup?.path).toBe("agents.list.0.identity.avatar"); - expect(lookup?.hintPath).toBe("agents.list.*.identity.avatar"); + const lookup = lookupConfigSchema(baseSchema, "agents.entries.main.identity.avatar"); + expect(lookup?.path).toBe("agents.entries.main.identity.avatar"); + expect(lookup?.hintPath).toBe("agents.entries.*.identity.avatar"); expect(lookup?.hint?.help).toContain("workspace-relative path"); expect(lookup?.schema?.title).toBe("Identity Avatar"); expect(lookup?.schema?.description).toContain("Agent avatar"); }); - it("normalizes bracketed lookup paths", () => { - const lookup = lookupConfigSchema(baseSchema, "agents.list[0].identity.avatar"); - expect(lookup?.path).toBe("agents.list.0.identity.avatar"); - expect(lookup?.hintPath).toBe("agents.list.*.identity.avatar"); + it("rejects quoted bracket map paths", () => { + const lookup = lookupConfigSchema(baseSchema, 'agents.entries["main"].identity.avatar'); + expect(lookup).toBeNull(); }); - it("matches ui hints that use empty array brackets", () => { - const lookup = lookupConfigSchema(baseSchema, "agents.list.0.runtime"); - expect(lookup?.path).toBe("agents.list.0.runtime"); - expect(lookup?.hintPath).toBe("agents.list[].runtime"); + it("matches ui hints for keyed record entries", () => { + const lookup = lookupConfigSchema(baseSchema, "agents.entries.main.runtime"); + expect(lookup?.path).toBe("agents.entries.main.runtime"); + expect(lookup?.hintPath).toBe("agents.entries.*.runtime"); expect(lookup?.hint?.label).toBe("Agent Runtime"); }); diff --git a/src/config/sessions/reset-policy.test.ts b/src/config/sessions/reset-policy.test.ts index 81bab0c019de..16faa5d4df52 100644 --- a/src/config/sessions/reset-policy.test.ts +++ b/src/config/sessions/reset-policy.test.ts @@ -115,20 +115,6 @@ describe("session reset policy", () => { ).toMatchObject({ fresh: false, staleReason: "idle" }); }); - it("keeps legacy idleMinutes as an idle reset policy", () => { - const now = 10 * HOUR_MS; - const policy = resolveSessionResetPolicy({ - sessionCfg: { idleMinutes: 30 }, - resetType: "direct", - }); - - expect(policy).toMatchObject({ mode: "idle", idleMinutes: 30, configured: true }); - expect(evaluateSessionFreshness({ updatedAt: now - DAY_MS, now, policy })).toMatchObject({ - fresh: false, - staleReason: "idle", - }); - }); - it("applies resetByType only to the matching session type", () => { const sessionCfg = { resetByType: { group: { mode: "idle" as const, idleMinutes: 30 } }, diff --git a/src/config/sessions/reset-policy.ts b/src/config/sessions/reset-policy.ts index be8980ffe948..2d08024d214f 100644 --- a/src/config/sessions/reset-policy.ts +++ b/src/config/sessions/reset-policy.ts @@ -44,25 +44,18 @@ export function resolveSessionResetPolicy(params: { const sessionCfg = params.sessionCfg; const baseReset = params.resetOverride ?? sessionCfg?.reset; const typeReset = params.resetOverride ? undefined : sessionCfg?.resetByType?.[params.resetType]; - const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType); - const legacyIdleMinutes = params.resetOverride ? undefined : sessionCfg?.idleMinutes; - const configured = Boolean(baseReset || typeReset || legacyIdleMinutes != null); - // Legacy `idleMinutes` implied idle reset only when no modern reset block was configured. + const configured = Boolean(baseReset || typeReset); const inheritedTypeMode = typeReset && baseReset?.mode !== "none" ? baseReset?.mode : undefined; const mode = typeReset?.mode ?? inheritedTypeMode ?? (typeReset ? "daily" : undefined) ?? baseReset?.mode ?? - (baseReset - ? "daily" - : !hasExplicitReset && legacyIdleMinutes != null - ? "idle" - : DEFAULT_RESET_MODE); + (baseReset ? "daily" : DEFAULT_RESET_MODE); const atHour = normalizeResetAtHour( typeReset?.atHour ?? baseReset?.atHour ?? DEFAULT_RESET_AT_HOUR, ); - const idleMinutesRaw = typeReset?.idleMinutes ?? baseReset?.idleMinutes ?? legacyIdleMinutes; + const idleMinutesRaw = typeReset?.idleMinutes ?? baseReset?.idleMinutes; let idleMinutes: number | undefined; if (idleMinutesRaw != null) { diff --git a/src/config/sessions/transcript-append-redact.test.ts b/src/config/sessions/transcript-append-redact.test.ts index 758c3a86df67..d09fa9f03867 100644 --- a/src/config/sessions/transcript-append-redact.test.ts +++ b/src/config/sessions/transcript-append-redact.test.ts @@ -60,7 +60,7 @@ describe("appendSessionTranscriptMessage - redaction", () => { it("masks secrets in message content before writing to disk", async () => { const sessionFile = resolveSessionTranscriptPathInDir("redact-on", fixture.sessionsDir()); - const config: OpenClawConfig = { logging: { redactSensitive: "tools" } }; + const config: OpenClawConfig = {}; await appendSessionTranscriptMessage({ transcriptPath: sessionFile, @@ -91,7 +91,7 @@ describe("appendSessionTranscriptMessage - redaction", () => { "redact-image-base64", fixture.sessionsDir(), ); - const config: OpenClawConfig = { logging: { redactSensitive: "tools" } }; + const config: OpenClawConfig = {}; await appendSessionTranscriptMessage({ transcriptPath: sessionFile, @@ -131,9 +131,9 @@ describe("appendSessionTranscriptMessage - redaction", () => { ).toBe(IMAGE_BASE64_WITH_SECRET_TOKEN_SUBSTRING); }); - it("writes content unchanged when redactSensitive is off", async () => { + it("redacts content regardless of the retired off switch", async () => { const sessionFile = resolveSessionTranscriptPathInDir("redact-off", fixture.sessionsDir()); - const config: OpenClawConfig = { logging: { redactSensitive: "off" } }; + const config: OpenClawConfig = {}; await appendSessionTranscriptMessage({ transcriptPath: sessionFile, @@ -145,7 +145,7 @@ describe("appendSessionTranscriptMessage - redaction", () => { }); const raw = fs.readFileSync(sessionFile, "utf-8"); - expect(raw).toContain("sk-abcdef1234567890xyz"); + expect(raw).not.toContain("sk-abcdef1234567890xyz"); }); it("masks secrets when config is undefined (default patterns)", async () => { @@ -169,7 +169,7 @@ describe("appendSessionTranscriptMessage - redaction", () => { "redact-string-payload", fixture.sessionsDir(), ); - const config: OpenClawConfig = { logging: { redactSensitive: "tools" } }; + const config: OpenClawConfig = {}; await appendSessionTranscriptMessage({ transcriptPath: sessionFile, @@ -191,7 +191,7 @@ describe("appendSessionTranscriptMessage - redaction", () => { "redact-structured-no-role", fixture.sessionsDir(), ); - const config: OpenClawConfig = { logging: { redactSensitive: "tools" } }; + const config: OpenClawConfig = {}; await appendSessionTranscriptMessage({ transcriptPath: sessionFile, @@ -239,7 +239,6 @@ describe("appendSessionTranscriptMessage - redaction", () => { fixture.sessionsDir(), ); readLoggingConfig.mockReturnValue({ - redactSensitive: "tools", redactPatterns: [EMAIL_PATTERN], }); @@ -265,7 +264,7 @@ describe("appendSessionTranscriptMessage - redaction", () => { "redact-tool-call-args", fixture.sessionsDir(), ); - const config: OpenClawConfig = { logging: { redactSensitive: "tools" } }; + const config: OpenClawConfig = {}; await appendSessionTranscriptMessage({ transcriptPath: sessionFile, @@ -344,7 +343,7 @@ describe("appendSessionTranscriptMessage - redaction", () => { "redact-tool-result-details", fixture.sessionsDir(), ); - const config: OpenClawConfig = { logging: { redactSensitive: "tools" } }; + const config: OpenClawConfig = {}; await appendSessionTranscriptMessage({ transcriptPath: sessionFile, @@ -405,7 +404,7 @@ describe("appendSessionTranscriptMessage - redaction", () => { "issue-80379-tool-result-env-placeholders", fixture.sessionsDir(), ); - const config: OpenClawConfig = { logging: { redactSensitive: "tools" } }; + const config: OpenClawConfig = {}; const toolOutput = 'DISCORD_BOT_TOKEN="${DISCORD_BOT_TOKEN:-}"\nTELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"'; @@ -452,7 +451,7 @@ describe("appendExactAssistantMessageToSessionTranscript - redaction", () => { ); } - it("does not redact when config.logging.redactSensitive is off", async () => { + it("always redacts exact assistant transcript appends", async () => { const sessionsDir = fixture.sessionsDir(); const storePath = path.join(sessionsDir, "sessions.json"); const sessionId = "test-session-redact-off"; @@ -460,7 +459,7 @@ describe("appendExactAssistantMessageToSessionTranscript - redaction", () => { await seedSessionEntry({ sessionId, sessionKey, storePath }); const fakeApiKey = "sk-proj-FAKEKEYFORTESTINGONLY1234567890"; - const config: OpenClawConfig = { logging: { redactSensitive: "off" } }; + const config: OpenClawConfig = {}; const result = await appendExactAssistantMessageToSessionTranscript({ sessionKey, @@ -491,7 +490,7 @@ describe("appendExactAssistantMessageToSessionTranscript - redaction", () => { } const raw = JSON.stringify(await readStoredMessages({ sessionId, sessionKey, storePath })); - expect(raw).toContain(fakeApiKey); + expect(raw).not.toContain(fakeApiKey); }); it("emits the redacted assistant message for inline transcript updates", async () => { @@ -502,7 +501,7 @@ describe("appendExactAssistantMessageToSessionTranscript - redaction", () => { await seedSessionEntry({ sessionId, sessionKey, storePath }); const fakeApiKey = "sk-proj-FAKEKEYFORTESTINGONLY1234567890"; - const config: OpenClawConfig = { logging: { redactSensitive: "tools" } }; + const config: OpenClawConfig = {}; const updates: Array<{ message?: unknown }> = []; const unsubscribe = onSessionTranscriptUpdate((update) => updates.push(update)); @@ -553,7 +552,7 @@ describe("appendExactAssistantMessageToSessionTranscript - redaction", () => { await seedSessionEntry({ sessionId, sessionKey, storePath }); const fakeApiKey = "sk-proj-FAKEKEYFORTESTINGONLY1234567890"; - const config: OpenClawConfig = { logging: { redactSensitive: "tools" } }; + const config: OpenClawConfig = {}; const first = await appendAssistantMessageToSessionTranscript({ sessionKey, @@ -582,7 +581,7 @@ describe("appendExactAssistantMessageToSessionTranscript - redaction", () => { ); }); - it("dedupes delivery mirrors against older unredacted assistant entries", async () => { + it("dedupes delivery mirrors against existing assistant entries", async () => { const sessionsDir = fixture.sessionsDir(); const storePath = path.join(sessionsDir, "sessions.json"); const sessionId = "test-session-redact-upgrade-dedupe"; @@ -593,7 +592,7 @@ describe("appendExactAssistantMessageToSessionTranscript - redaction", () => { const unredacted = await appendExactAssistantMessageToSessionTranscript({ sessionKey, storePath, - config: { logging: { redactSensitive: "off" } }, + config: {}, message: { role: "assistant", content: [{ type: "text", text: `Here is your key: ${fakeApiKey}` }], @@ -615,7 +614,7 @@ describe("appendExactAssistantMessageToSessionTranscript - redaction", () => { const deduped = await appendAssistantMessageToSessionTranscript({ sessionKey, storePath, - config: { logging: { redactSensitive: "tools" } }, + config: {}, text: `Here is your key: ${fakeApiKey}`, }); @@ -627,7 +626,7 @@ describe("appendExactAssistantMessageToSessionTranscript - redaction", () => { expect(deduped.messageId).toBe(unredacted.messageId); const events = await loadTranscriptEvents({ sessionId, sessionKey, storePath }); - expect(JSON.stringify(events)).toContain(fakeApiKey); + expect(JSON.stringify(events)).not.toContain(fakeApiKey); expect(events.filter((event) => (event as { type?: unknown }).type === "message")).toHaveLength( 1, ); diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 9416a4c85569..50f2fad7ba93 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -1,4 +1,5 @@ // Defines agent default configuration types shared by runtime schemas. +import type { FastMode } from "@openclaw/normalization-core/string-coerce"; import type { SilentReplyPolicyShape } from "../shared/silent-reply-policy.js"; import type { AgentModelConfig, @@ -12,7 +13,6 @@ import type { HumanDelayConfig, TypingMode, } from "./types.base.js"; -import type { MemorySearchConfig } from "./types.tools.js"; /** Workspace bootstrap-file injection policy for agent system prompts. */ export type AgentContextInjection = "always" | "continuation-skip" | "never"; @@ -36,16 +36,6 @@ export type AgentThinkingLevel = | "max" | "ultra"; -export type Gpt5PromptOverlayConfig = { - /** Friendly interaction-style layer for GPT-5-family models (default: friendly). */ - personality?: "friendly" | "on" | "off"; -}; - -export type PromptOverlaysConfig = { - /** Shared GPT-5-family prompt overlay used across providers. */ - gpt5?: Gpt5PromptOverlayConfig; -}; - export type AgentModelEntryConfig = { /** Optional display/lookup alias for this provider/model entry. */ alias?: string; @@ -139,9 +129,7 @@ export type CliBackendConfig = { modelArg?: string; /** Model aliases mapping (config model id → CLI model id). */ modelAliases?: Record; - /** Flag used to pass session id (e.g. --session-id). */ - sessionArg?: string; - /** Extra args used when resuming a session (use {sessionId} placeholder). */ + /** Args used to pass a session id (use {sessionId} placeholder). */ sessionArgs?: string[]; /** Alternate args to use when resuming a session (use {sessionId} placeholder). */ resumeArgs?: string[]; @@ -200,6 +188,22 @@ export type CliBackendConfig = { }; export type AgentDefaultsConfig = { + /** @deprecated Doctor-only legacy input. */ + imageGenerationModel?: AgentToolModelConfig; + /** @deprecated Doctor-only legacy input. */ + videoGenerationModel?: AgentToolModelConfig; + /** @deprecated Doctor-only legacy input. */ + musicGenerationModel?: AgentToolModelConfig; + /** @deprecated Doctor-only legacy input. */ + envelopeTimezone?: string; + /** @deprecated Doctor-only legacy input. */ + envelopeTimestamp?: "on" | "off"; + /** @deprecated Doctor-only legacy input. */ + envelopeElapsed?: "on" | "off"; + /** @deprecated Doctor-only legacy input. */ + timeFormat?: "auto" | "12" | "24"; + /** @deprecated Doctor-only legacy input. */ + promptOverlays?: { gpt5?: { personality?: "friendly" | "on" | "off" } }; /** Global default provider params applied to all models before per-model and per-agent overrides. */ params?: Record; /** Primary model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ @@ -213,25 +217,18 @@ export type AgentDefaultsConfig = { agentRuntime?: AgentRuntimePolicyConfig; /** Optional image-capable model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ imageModel?: AgentToolModelConfig; - /** Optional image-generation model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ - imageGenerationModel?: AgentToolModelConfig; - /** Optional video-generation model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ - videoGenerationModel?: AgentToolModelConfig; - /** Optional music-generation model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ - musicGenerationModel?: AgentToolModelConfig; + /** Media-generation model preferences by output modality. */ + mediaModels?: { + image?: AgentToolModelConfig; + video?: AgentToolModelConfig; + music?: AgentToolModelConfig; + }; /** Optional voice model and fallbacks (provider/model) for TTS/STT/realtime voice providers. */ voiceModel?: AgentToolModelConfig; - /** - * When true (default), shared image/music/video generation appends other - * auth-backed provider defaults after explicit primary/fallback refs. Set to - * false to disable implicit cross-provider fallback while keeping explicit - * fallbacks. - */ - mediaGenerationAutoProviderFallback?: boolean; /** Optional PDF-capable model and fallbacks (provider/model). Accepts string or {primary,fallbacks}. */ pdfModel?: AgentToolModelConfig; /** Maximum PDF file size in megabytes (default: 10). */ - pdfMaxBytesMb?: number; + pdfMaxMb?: number; /** Maximum number of PDF pages to process (default: 20). */ pdfMaxPages?: number; /** Model catalog with optional aliases (full provider/model keys). */ @@ -247,7 +244,6 @@ export type AgentDefaultsConfig = { /** Optional repository root for system prompt runtime line (overrides auto-detect). */ repoRoot?: string; /** Provider-independent prompt overlays applied by model family. */ - promptOverlays?: PromptOverlaysConfig; /** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */ skipBootstrap?: boolean; /** @@ -283,7 +279,6 @@ export type AgentDefaultsConfig = { * - once: inject once per unique truncation signature * - always: inject on every run with truncation (default) */ - bootstrapPromptTruncationWarning?: "off" | "once" | "always"; /** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */ userTimezone?: string; /** Runtime-owned first-turn startup context for bare /new and /reset. */ @@ -291,20 +286,16 @@ export type AgentDefaultsConfig = { /** Focused context-budget overrides for high-volume injected/read surfaces. */ contextLimits?: AgentContextLimitsConfig; /** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */ - timeFormat?: "auto" | "12" | "24"; /** * Envelope timestamp timezone: "utc" (default), "local", "user", or an IANA timezone string. */ - envelopeTimezone?: string; /** * Include absolute timestamps in message envelopes, direct agent prompt prefixes, * and embedded model-input prefixes ("on" | "off", default: "on"). */ - envelopeTimestamp?: "on" | "off"; /** * Include elapsed time in message envelopes ("on" | "off", default: "on"). */ - envelopeElapsed?: "on" | "off"; /** Optional context window cap (used for runtime estimates + status %). */ contextTokens?: number; /** Optional CLI backends for text-only fallback (claude-cli, etc.). */ @@ -329,10 +320,10 @@ export type AgentDefaultsConfig = { */ executionContract?: EmbeddedAgentExecutionContract; }; - /** Vector memory search configuration (per-agent overrides supported). */ - memorySearch?: MemorySearchConfig; /** Default thinking level when no /think directive is present. */ thinkingDefault?: AgentThinkingLevel; + /** Default fast-mode policy inherited by agent entries that omit it. */ + fastModeDefault?: FastMode; /** Default verbose level when no /verbose directive is present. */ verboseDefault?: "off" | "on" | "full"; /** @@ -405,12 +396,6 @@ export type AgentDefaultsConfig = { accountId?: string; /** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."). */ prompt?: string; - /** Include the ## Heartbeats system prompt section for the default agent (default: true). */ - includeSystemPromptSection?: boolean; - /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ - ackMaxChars?: number; - /** Suppress tool error warning payloads during heartbeat runs. */ - suppressToolErrorWarnings?: boolean; /** Run timeout in seconds for heartbeat agent turns. Unset uses global timeout or heartbeat cadence capped at 600 seconds. */ timeoutSeconds?: number; /** @@ -425,18 +410,6 @@ export type AgentDefaultsConfig = { * per-heartbeat token cost by avoiding the full session transcript. */ isolatedSession?: boolean; - /** - * If true, defer heartbeat runs while this agent's session-keyed subagent or nested command lanes are busy. - * Cron lanes are always treated as busy for heartbeat deferral. - */ - skipWhenBusy?: boolean; - /** - * When enabled, deliver the model's reasoning payload for heartbeat runs (when available) - * as a separate message prefixed with `Thinking.` (same as `/reasoning on`). - * - * Default: false (only the final heartbeat payload is delivered). - */ - includeReasoning?: boolean; }; /** Max concurrent agent runs across all conversations. Default: 4. */ maxConcurrent?: number; @@ -471,7 +444,7 @@ export type AgentDefaultsConfig = { export type AgentCompactionMode = "default" | "safeguard"; export type AgentCompactionPostIndexSyncMode = "off" | "async" | "await"; -export type AgentCompactionIdentifierPolicy = "strict" | "off" | "custom"; +export type AgentCompactionIdentifierPolicy = "strict" | "off"; export type AgentCompactionQualityGuardConfig = { /** Enable compaction summary quality audits and regeneration retries. Default: false. */ enabled?: boolean; @@ -494,14 +467,10 @@ export type AgentCompactionConfig = { thinkingLevel?: AgentThinkingLevel; /** Embedded OpenClaw keepRecentTokens budget used for cut-point selection. */ keepRecentTokens?: number; - /** Additional compaction-summary instructions that can preserve language or persona continuity. */ - customInstructions?: string; /** Preserve this many most-recent user/assistant turns verbatim in compaction summary context. */ recentTurnsPreserve?: number; /** Identifier-preservation instruction policy for compaction summaries. */ identifierPolicy?: AgentCompactionIdentifierPolicy; - /** Custom identifier-preservation instructions used when identifierPolicy is "custom". */ - identifierInstructions?: string; /** Optional quality-audit retries for safeguard compaction summaries. */ qualityGuard?: AgentCompactionQualityGuardConfig; /** Mid-turn precheck for tool-loop context pressure. Default: disabled. */ @@ -510,11 +479,7 @@ export type AgentCompactionConfig = { postIndexSync?: AgentCompactionPostIndexSyncMode; /** Pre-compaction memory flush (agentic turn). Default: enabled. */ memoryFlush?: AgentCompactionMemoryFlushConfig; - /** - * H2/H3 section names from AGENTS.md to inject after compaction. - * Disabled when unset or []. - * Explicit ["Session Startup", "Red Lines"] preserves legacy fallback headings. - */ + /** H2/H3 section names from AGENTS.md to inject after compaction. */ postCompactionSections?: string[]; /** Optional provider/model or configured bare alias for compaction summarization. * When set, compaction uses this model instead of the agent's primary model. @@ -564,8 +529,4 @@ export type AgentCompactionMemoryFlushConfig = { * (bytes, or byte-size string like "2mb"). Set to 0 to disable. */ forceFlushTranscriptBytes?: number | string; - /** User prompt used for the memory flush turn (NO_REPLY is enforced if missing). */ - prompt?: string; - /** System prompt appended for the memory flush turn. */ - systemPrompt?: string; }; diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index cf8cc47fff3d..831e1bcaa4b8 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -11,9 +11,10 @@ import type { } from "./types.agent-defaults.js"; import type { AgentModelConfig, AgentSandboxConfig } from "./types.agents-shared.js"; import type { DmScope, HumanDelayConfig, IdentityConfig } from "./types.base.js"; +import type { MemorySearchConfig } from "./types.memory.js"; import type { GroupChatConfig } from "./types.messages.js"; import type { SkillsLimitsConfig } from "./types.skills.js"; -import type { AgentToolsConfig, MemorySearchConfig } from "./types.tools.js"; +import type { AgentToolsConfig } from "./types.tools.js"; import type { TtsConfig } from "./types.tts.js"; export type AgentRuntimeAcpConfig = { @@ -121,11 +122,19 @@ export type AgentConfig = { experimental?: AgentDefaultsConfig["experimental"]; /** Optional allowlist of skills for this agent; omitting it inherits agents.defaults.skills when set, and an explicit list replaces defaults instead of merging. */ skills?: string[]; - memorySearch?: MemorySearchConfig; + /** Per-agent overrides for the shared top-level memory configuration. */ + memory?: { + search?: MemorySearchConfig; + }; /** Human-like delay between block replies for this agent. */ humanDelay?: HumanDelayConfig; - /** Optional per-agent TTS overrides, deep-merged over messages.tts. */ - tts?: TtsConfig; + /** Optional per-agent typing start policy. */ + typingMode?: AgentDefaultsConfig["typingMode"]; + /** Optional per-agent typing keepalive cadence. */ + typingIntervalSeconds?: AgentDefaultsConfig["typingIntervalSeconds"]; + /** Optional per-agent TTS overrides, deep-merged over top-level tts. */ + /** Per-agent TTS overrides. prefsPath remains scoped because agents may use distinct preference stores. */ + tts?: TtsConfig & { prefsPath?: string }; /** Optional per-agent skills subsystem overrides. */ skillsLimits?: Pick; /** Optional per-agent overrides for selected context/token-heavy limits. */ @@ -134,7 +143,7 @@ export type AgentConfig = { /** Optional per-agent heartbeat overrides. */ heartbeat?: AgentDefaultsConfig["heartbeat"]; identity?: IdentityConfig; - groupChat?: GroupChatConfig; + groupChat?: Omit; subagents?: { /** Prompt-only guidance for how strongly this agent should delegate work. */ delegationMode?: SubagentDelegationMode; @@ -161,7 +170,11 @@ export type AgentConfig = { runtime?: AgentRuntimeConfig; }; +export type AgentEntryConfig = Omit; + export type AgentsConfig = { defaults?: AgentDefaultsConfig; + entries?: Record; + /** Internal non-serialized projection materialized by validation for ID-based runtime code. */ list?: AgentConfig[]; }; diff --git a/src/config/types.base.ts b/src/config/types.base.ts index f3a108e7cec6..1fa18a27c8da 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -220,13 +220,11 @@ export type SessionConfig = { /** Map platform-prefixed identities (e.g. "telegram:123") to canonical DM peers. */ identityLinks?: Record; resetTriggers?: string[]; - idleMinutes?: number; reset?: SessionResetConfig; resetByType?: SessionResetByTypeConfig; /** Channel-specific reset overrides (e.g. { discord: { mode: "idle", idleMinutes: 10080 } }). */ resetByChannel?: Record; store?: string; - typingMode?: TypingMode; mainKey?: string; sendPolicy?: SessionSendPolicyConfig; /** Shared defaults for thread-bound session routing across channels/providers. */ @@ -270,11 +268,12 @@ export type LoggingConfig = { /** Maximum size of a single log file in bytes before rotation. Default: 100 MB. */ maxFileBytes?: number; consoleLevel?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"; - consoleStyle?: "pretty" | "compact" | "json"; + consoleStyle?: "pretty" | "json"; /** Redact sensitive tokens in log sinks and persisted transcript text. Default: "tools". Safety-boundary UI/tool/diagnostic payloads may still redact when this is "off". */ - redactSensitive?: "off" | "tools"; /** Regex patterns used to redact sensitive tokens from logs and transcripts. */ redactPatterns?: string[]; + /** Metadata-only agent activity audit ledger settings. */ + audit?: AuditConfig; }; export type DiagnosticsOtelConfig = { @@ -316,13 +315,13 @@ export type DiagnosticsOtelConfig = { export type DiagnosticsCacheTraceConfig = { /** Write prompt-cache trace artifacts for debugging deterministic cache input. */ enabled?: boolean; - /** Optional output path for cache trace artifacts. */ + /** @deprecated Doctor-only legacy input. */ filePath?: string; - /** Include normalized messages in cache trace output. */ + /** @deprecated Doctor-only legacy input. */ includeMessages?: boolean; - /** Include prompt payload text in cache trace output. */ + /** @deprecated Doctor-only legacy input. */ includePrompt?: boolean; - /** Include system-message content in cache trace output. */ + /** @deprecated Doctor-only legacy input. */ includeSystem?: boolean; }; @@ -350,11 +349,6 @@ export type DiagnosticsConfig = { cacheTrace?: DiagnosticsCacheTraceConfig; }; -export type WebConfig = { - /** If false, do not start the WhatsApp web provider. Default: true. */ - enabled?: boolean; -}; - // Provider docking: allowlists keyed by provider id (and internal "webchat"). export type AgentElevatedAllowFromConfig = Partial>>; diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index 19fed151e35a..5766b0327ef9 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -1,5 +1,7 @@ // Defines browser profile configuration types. export type BrowserProfileConfig = { + /** @deprecated Doctor-only legacy input; canonical schema rejects this field. */ + color?: string; /** CDP port for this profile. Allocated once at creation, persisted permanently. */ cdpPort?: number; /** CDP/DevTools endpoint URL for this profile (remote CDP or existing-session endpoint attach). */ @@ -21,8 +23,6 @@ export type BrowserProfileConfig = { executablePath?: string; /** If true, never launch a browser for this profile; only attach. Falls back to browser.attachOnly. */ attachOnly?: boolean; - /** Profile color (hex). Auto-assigned at creation. */ - color: string; }; export type BrowserSnapshotDefaults = { /** Default snapshot mode (applies when mode is not provided). */ @@ -36,17 +36,14 @@ export type BrowserSsrFPolicyConfig = { /** If true, permit browser navigation to private/internal networks. Default: false */ dangerouslyAllowPrivateNetwork?: boolean; /** - * Explicitly allowed hostnames (exact-match), including blocked names like localhost. - * Example: ["localhost", "metadata.internal"] - */ - allowedHostnames?: string[]; - /** - * Hostname allowlist patterns for browser navigation. + * Explicitly allowed hostname patterns, including blocked names like localhost. * Supports exact hosts and "*.example.com" wildcard subdomains. */ - hostnameAllowlist?: string[]; + allowedHostnames?: string[]; }; export type BrowserConfig = { + /** @deprecated Doctor-only legacy input; canonical schema rejects this field. */ + color?: string; enabled?: boolean; /** Allow importing cookies from the user's real Chrome-family profile into a managed profile (macOS). Default: true. */ allowSystemProfileImport?: boolean; @@ -54,8 +51,6 @@ export type BrowserConfig = { evaluateEnabled?: boolean; /** Base URL of the CDP endpoint (for remote browsers). Default: loopback CDP on the derived port. */ cdpUrl?: string; - /** Accent color for the openclaw browser profile (hex). Default: #FF4500 */ - color?: string; /** Override the browser executable path (all platforms). */ executablePath?: string; /** Start Chrome headless (best-effort). Default: false */ diff --git a/src/config/types.channel-messaging-common.ts b/src/config/types.channel-messaging-common.ts index b7d7aaca94f6..67e5ddb4b9e5 100644 --- a/src/config/types.channel-messaging-common.ts +++ b/src/config/types.channel-messaging-common.ts @@ -61,6 +61,8 @@ export type CommonChannelMessagingConfig< /** Delivery streaming config: chunk mode plus block streaming controls. */ streaming?: TStreaming; /** Heartbeat visibility settings for this channel. */ + heartbeatVisibility?: ChannelHeartbeatVisibilityConfig; + /** @deprecated Doctor-only legacy input. */ heartbeat?: ChannelHeartbeatVisibilityConfig; /** Channel health monitor overrides for this channel/account. */ healthMonitor?: ChannelHealthMonitorConfig; diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index 876669707a9b..32c8c0b866a8 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -25,12 +25,14 @@ export type { ChannelBotLoopProtectionConfig } from "./types.bot-loop-protection export type { ChannelImplicitMentionsConfig } from "./types.implicit-mentions.js"; export type ChannelDefaultsConfig = { + /** @deprecated Doctor-only legacy input. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; /** Default group-chat admission policy inherited by channels that support groups. */ groupPolicy?: GroupPolicy; /** Default history/context visibility inherited by channel configs. */ contextVisibility?: ContextVisibilityMode; /** Default heartbeat visibility for all channels. */ - heartbeat?: ChannelHeartbeatVisibilityConfig; + heartbeatVisibility?: ChannelHeartbeatVisibilityConfig; /** Default pair loop guard settings for channels that support bot loop protection. */ botLoopProtection?: ChannelBotLoopProtectionConfig; /** Default implicit-mention policy inherited by supporting channels. */ diff --git a/src/config/types.cli.ts b/src/config/types.cli.ts deleted file mode 100644 index 97816eaa7792..000000000000 --- a/src/config/types.cli.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Defines CLI configuration types. -export type CliBannerTaglineMode = "random" | "default" | "off"; - -export type CliConfig = { - banner?: { - /** - * Controls CLI banner tagline behavior. - * - "random": pick from tagline pool (default) - * - "default": always use DEFAULT_TAGLINE - * - "off": hide tagline text - */ - taglineMode?: CliBannerTaglineMode; - }; -}; diff --git a/src/config/types.cloud-workers.ts b/src/config/types.cloud-workers.ts index ea22e334623a..efd8aac00a6d 100644 --- a/src/config/types.cloud-workers.ts +++ b/src/config/types.cloud-workers.ts @@ -1,12 +1,5 @@ // Defines cloud-worker provider profile configuration types. -export type CloudWorkerLifetimePolicyConfig = { - /** Minutes of inactivity before the environment becomes eligible for cleanup. */ - idleTimeoutMinutes?: number; - /** Maximum environment lifetime in minutes. */ - maxLifetimeMinutes?: number; -}; - export type CloudWorkerProfileConfig = { /** Worker provider id registered by a plugin. */ provider: string; @@ -14,8 +7,6 @@ export type CloudWorkerProfileConfig = { install?: "bundle" | "npm"; /** Provider-owned JSON settings; secret-bearing fields use SecretRef objects. */ settings?: Record; - /** Stored lifecycle policy; enforcement is owned by later worker lifecycle support. */ - lifetime?: CloudWorkerLifetimePolicyConfig; }; export type CloudWorkersConfig = { diff --git a/src/config/types.commitments.ts b/src/config/types.commitments.ts deleted file mode 100644 index 039f92c32fe3..000000000000 --- a/src/config/types.commitments.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Defines commitment feature configuration types. -export type CommitmentsConfig = { - /** Enable inferred follow-up extraction, storage, and heartbeat delivery. Default: false. */ - enabled?: boolean; - /** Maximum inferred follow-up commitments delivered per agent session in a rolling day. Default: 3. */ - maxPerDay?: number; -}; diff --git a/src/config/types.cron.ts b/src/config/types.cron.ts index 1b51b726f042..391b9897e537 100644 --- a/src/config/types.cron.ts +++ b/src/config/types.cron.ts @@ -8,6 +8,8 @@ export type CronFailureAlertConfig = { includeSkipped?: boolean; mode?: "announce" | "webhook"; accountId?: string; + channel?: string; + to?: string; }; export type CronFailureDestinationConfig = { @@ -19,7 +21,6 @@ export type CronFailureDestinationConfig = { export type CronConfig = { enabled?: boolean; - store?: string; triggers?: { enabled?: boolean; }; @@ -32,6 +33,4 @@ export type CronConfig = { */ sessionRetention?: string | false; failureAlert?: CronFailureAlertConfig; - /** Default destination for failure notifications across all cron jobs. */ - failureDestination?: CronFailureDestinationConfig; }; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index fd76e83799db..c7d7c9aedb39 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -228,41 +228,16 @@ export type DiscordAgentComponentsConfig = { ttlMs?: number; }; -export type DiscordUiComponentsConfig = { - /** Accent color used by Discord component containers (hex). */ - accentColor?: string; -}; - -export type DiscordUiConfig = { - components?: DiscordUiComponentsConfig; -}; - export type DiscordThreadBindingsConfig = { - /** - * Enable Discord thread binding features (/focus, thread-bound delivery, and - * thread-bound subagent session flows). Overrides session.threadBindings.enabled - * when set. - */ + /** Enable Discord thread binding features. Overrides session.threadBindings.enabled. */ enabled?: boolean; - /** - * Inactivity window for thread-bound sessions in hours. - * Session auto-unfocuses after this amount of idle time. Set to 0 to disable. Default: 24. - */ + /** Inactivity window in hours. Set 0 to disable. Default: 24. */ idleHours?: number; - /** - * Optional hard max age for thread-bound sessions in hours. - * Session auto-unfocuses once this age is reached even if active. Set to 0 to disable. Default: 0. - */ + /** Hard max age in hours. Set 0 to disable. Default: 0. */ maxAgeHours?: number; - /** - * Allow session spawns to auto-create + bind Discord threads. - * Applies to native subagent and ACP thread spawns. Default: true. - */ + /** Allow session spawns to create and bind Discord threads. Default: true. */ spawnSessions?: boolean; - /** - * Default context mode for native subagents spawned into a bound Discord thread. - * Default: "fork". - */ + /** Default context mode for native subagents. Default: fork. */ defaultSpawnContext?: "isolated" | "fork"; }; @@ -284,10 +259,9 @@ export type DiscordAutoPresenceConfig = { /** Minimum spacing between actual gateway presence updates (ms). Default: 15000. */ minUpdateIntervalMs?: number; /** Optional custom status text while runtime is healthy; supports plain text. */ - healthyText?: string; /** Optional custom status text while runtime/quota state is degraded or unknown. */ - degradedText?: string; /** Optional custom status text while runtime detects quota/token exhaustion. */ + /** @deprecated Doctor-only legacy input. */ exhaustedText?: string; }; @@ -333,12 +307,12 @@ export type DiscordAccountConfig = Omit< /** Agent-controlled interactive components (buttons, select menus). */ agentComponents?: DiscordAgentComponentsConfig; /** Discord UI customization (components, modals, etc.). */ - ui?: DiscordUiConfig; /** Slash command configuration. */ slashCommand?: DiscordSlashCommandConfig; - /** Thread binding lifecycle settings (focus/subagent thread sessions). */ + /** Thread binding lifecycle settings. */ threadBindings?: DiscordThreadBindingsConfig; /** Show subagent count reactions and typing on the source message. Default: false. */ + /** @deprecated Doctor-only legacy input. */ subagentProgress?: boolean; /** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */ intents?: DiscordIntentsConfig; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 9a314ff606e5..614639e70eed 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -18,8 +18,6 @@ export type GatewayTlsConfig = { }; export type WideAreaDiscoveryConfig = { - /** Enable DNS-SD style wide-area discovery. */ - enabled?: boolean; /** Optional unicast DNS-SD domain (e.g. "openclaw.internal"). */ domain?: string; }; @@ -123,6 +121,8 @@ export type TalkConfigResponse = TalkConfig & { }; export type GatewayControlUiConfig = { + /** @deprecated Doctor-only legacy input. */ + chatMessageMaxWidth?: string; /** If false, the Gateway will not serve the Control UI (default /). */ enabled?: boolean; /** Optional base path prefix for the Control UI (e.g. "/openclaw"). */ @@ -150,7 +150,6 @@ export type GatewayControlUiConfig = { */ allowExternalEmbedUrls?: boolean; /** Optional max-width for grouped Control UI chat messages (default: min(900px, 68%)). */ - chatMessageMaxWidth?: string; /** Allowed browser origins for Control UI/WebChat websocket connections. */ allowedOrigins?: string[]; /** @@ -158,14 +157,6 @@ export type GatewayControlUiConfig = { * Supported long-term for deployments that intentionally rely on this policy. */ dangerouslyAllowHostHeaderOriginFallback?: boolean; - /** - * Insecure-auth toggle. - * Control UI still requires secure context + device identity unless - * dangerouslyDisableDeviceAuth is enabled. - */ - allowInsecureAuth?: boolean; - /** DANGEROUS: Disable device identity checks for the Control UI (default: false). */ - dangerouslyDisableDeviceAuth?: boolean; }; /** Gateway authentication strategy for WebSocket and HTTP clients. */ @@ -486,6 +477,12 @@ export type GatewayNodePairingConfig = { }; export type GatewayNodesConfig = { + /** @deprecated Doctor-only legacy input. */ + skills?: { enabled?: boolean }; + /** @deprecated Doctor-only legacy input. */ + allowCommands?: string[]; + /** @deprecated Doctor-only legacy input. */ + denyCommands?: string[]; /** Browser routing policy for node-hosted browser proxies. */ browser?: { /** Routing mode (default: auto). */ @@ -500,15 +497,14 @@ export type GatewayNodesConfig = { /** Accept node-published plugin tool descriptors (default: true). */ enabled?: boolean; }; - /** Controls whether paired nodes may publish agent-visible skills (default: true). */ - skills?: { - /** Accept node-published skill descriptors (default: true). */ - enabled?: boolean; + /** Accept node-published skill descriptors (default: true). */ + allowSkills?: boolean; + commands?: { + /** Additional node.invoke commands to allow on the gateway. */ + allow?: string[]; + /** Commands to deny even if they appear in the defaults or node claims. */ + deny?: string[]; }; - /** Additional node.invoke commands to allow on the gateway. */ - allowCommands?: string[]; - /** Commands to deny even if they appear in the defaults or node claims. */ - denyCommands?: string[]; }; export type GatewayToolsConfig = { diff --git a/src/config/types.googlechat.ts b/src/config/types.googlechat.ts index dbcfa91369e1..75726ff398af 100644 --- a/src/config/types.googlechat.ts +++ b/src/config/types.googlechat.ts @@ -32,8 +32,6 @@ export type GoogleChatAccountConfig = Omit; /** Service account JSON (inline string, object, or secret reference). */ serviceAccount?: string | Record | SecretRef; - /** Explicit secret reference for service account JSON. */ - serviceAccountRef?: SecretRef; /** Service account JSON file path. */ serviceAccountFile?: string; /** Webhook audience type (app-url or project-number). */ diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts index 57c7d345e5a0..dde254c06a84 100644 --- a/src/config/types.hooks.ts +++ b/src/config/types.hooks.ts @@ -87,8 +87,6 @@ export type InternalHooksConfig = { /** Additional hook directories to scan */ extraDirs?: string[]; }; - /** Install records for hook packs or hooks */ - installs?: Record; }; export type HooksConfig = { diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index 41ddd6d84777..ad1d88c69bfb 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -67,7 +67,6 @@ export type IMessageAccountConfig = Omit< * `messages.inbound.byChannel.imessage` or global * `messages.inbound.debounceMs`. Default: `false`. */ - coalesceSameSenderDms?: boolean; groups?: Record< string, { diff --git a/src/config/types.marketplaces.ts b/src/config/types.marketplaces.ts deleted file mode 100644 index 866c59a6d2dd..000000000000 --- a/src/config/types.marketplaces.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Defines marketplace feed and package source profile configuration types. -export type MarketplaceFeedVerificationConfig = - | { - mode: "unsigned"; - } - | { - mode: "signed"; - keys: readonly { - keyId: string; - publicKey: string; - }[]; - threshold?: number; - }; - -export type MarketplaceFeedProfileConfig = { - url: string; - verification?: MarketplaceFeedVerificationConfig; -}; - -export type MarketplaceSourceProfileConfig = - | { - type: "npm"; - } - | { - type: "clawhub"; - } - | { - type: "git"; - }; - -export type MarketplacesConfig = { - feeds?: Record; - sources?: Record; -}; diff --git a/src/config/types.mcp.ts b/src/config/types.mcp.ts index 50ab7ee4813a..ed5d65a5d804 100644 --- a/src/config/types.mcp.ts +++ b/src/config/types.mcp.ts @@ -6,8 +6,6 @@ export type McpServerCodexConfig = { agents?: string[]; /** Codex MCP tool approval mode emitted as default_tools_approval_mode. */ defaultToolsApprovalMode?: McpCodexToolApprovalMode; - /** Codex-native spelling accepted for operator-authored config. */ - default_tools_approval_mode?: McpCodexToolApprovalMode; }; export type McpServerToolFilterConfig = { @@ -32,8 +30,6 @@ export type McpServerConfig = { env?: Record; /** Working directory for stdio server. */ cwd?: string; - /** Alias for cwd. */ - workingDirectory?: string; /** HTTP transport: URL of the remote MCP server (http or https). */ url?: string; /** Transport type — "stdio" for command-bearing servers, "sse" or "streamable-http" for remote URLs. */ @@ -58,16 +54,10 @@ export type McpServerConfig = { }; /** HTTP TLS verification, disabled only for explicitly trusted private endpoints. */ sslVerify?: boolean; - /** Alias for sslVerify. */ - ssl_verify?: boolean; /** HTTP mutual TLS client certificate path. */ clientCert?: string; - /** Alias for clientCert. */ - client_cert?: string; /** HTTP mutual TLS client key path. */ clientKey?: string; - /** Alias for clientKey. */ - client_key?: string; /** Optional per-server OpenClaw MCP tool selection. */ toolFilter?: McpServerToolFilterConfig; /** Codex-specific projection controls for Codex app-server/runtime config. */ diff --git a/src/config/types.memory.ts b/src/config/types.memory.ts index 4237314b38fb..8618fe1ecf97 100644 --- a/src/config/types.memory.ts +++ b/src/config/types.memory.ts @@ -3,6 +3,7 @@ * Builtin memory stays core-owned; qmd settings describe the external QMD integration. */ import type { SessionSendPolicyConfig } from "./types.base.js"; +import type { SecretInput } from "./types.secrets.js"; /** Memory backend family selected for retrieval and session memory features. */ export type MemoryBackend = "builtin" | "qmd"; @@ -10,46 +11,29 @@ export type MemoryBackend = "builtin" | "qmd"; export type MemoryCitationsMode = "auto" | "on" | "off"; /** QMD search command flavor used for retrieval. */ export type MemoryQmdSearchMode = "query" | "search" | "vsearch"; -/** QMD startup/update scheduling mode. */ -export type MemoryQmdStartupMode = "off" | "idle" | "immediate"; /** Top-level memory config block. */ export type MemoryConfig = { backend?: MemoryBackend; citations?: MemoryCitationsMode; + /** Shared embedding/search defaults. Per-agent overrides live under agents.entries.*.memory.search. */ + search?: MemorySearchConfig; qmd?: MemoryQmdConfig; }; /** QMD-specific memory backend config. */ export type MemoryQmdConfig = { command?: string; - mcporter?: MemoryQmdMcporterConfig; searchMode?: MemoryQmdSearchMode; rerank?: boolean; searchTool?: string; includeDefaultMemory?: boolean; paths?: MemoryQmdIndexPath[]; sessions?: MemoryQmdSessionConfig; - update?: MemoryQmdUpdateConfig; limits?: MemoryQmdLimitsConfig; scope?: SessionSendPolicyConfig; }; -/** mcporter daemon integration for long-lived QMD MCP access. */ -export type MemoryQmdMcporterConfig = { - /** - * Route QMD searches through mcporter (MCP runtime) instead of spawning `qmd` per query. - * Requires: - * - `mcporter` installed and on PATH - * - A configured mcporter server that runs `qmd mcp` with `lifecycle: keep-alive` - */ - enabled?: boolean; - /** mcporter server name (defaults to "qmd") */ - serverName?: string; - /** Start the mcporter daemon automatically (defaults to true when enabled). */ - startDaemon?: boolean; -}; - /** Additional QMD index path entry. */ export type MemoryQmdIndexPath = { path: string; @@ -57,6 +41,95 @@ export type MemoryQmdIndexPath = { pattern?: string; }; +export type MemorySearchConfig = { + /** Enable vector memory search (default: true). */ + enabled?: boolean; + /** Use relevant context from this agent's other private conversations. */ + rememberAcrossConversations?: boolean; + /** Sources to index and search (default: ["memory"]). */ + sources?: Array<"memory" | "sessions">; + /** Extra paths to include in memory search (directories or .md files). */ + extraPaths?: string[]; + /** Optional QMD-specific extra collections for cross-agent search. */ + qmd?: { + /** Additional QMD collections appended for this agent's search scope. */ + extraCollections?: MemoryQmdIndexPath[]; + }; + /** Optional multimodal file indexing for selected extra paths. */ + multimodal?: { + /** Enable image/audio embeddings from extraPaths. */ + enabled?: boolean; + /** Which non-text file types to index. */ + modalities?: Array<"image" | "audio" | "all">; + /** Max bytes allowed per multimodal file before it is skipped. */ + maxFileBytes?: number; + }; + /** Experimental session transcript indexing. */ + experimental?: { + sessionMemory?: boolean; + }; + /** Memory embedding provider adapter id. */ + provider?: string; + remote?: { + baseUrl?: string; + apiKey?: SecretInput; + headers?: Record; + batch?: { + /** Enable batch API for embedding indexing (OpenAI/Gemini; default: true). */ + enabled?: boolean; + }; + }; + /** Fallback memory embedding provider adapter id when embeddings fail. */ + fallback?: string; + /** Embedding model id (remote) or alias (local). */ + model?: string; + /** Optional provider-specific embedding input_type for query and document requests. */ + inputType?: string; + /** Optional provider-specific embedding input_type for query-time memory search. */ + queryInputType?: string; + /** Optional provider-specific embedding input_type for document/index embeddings. */ + documentInputType?: string; + /** + * Gemini embedding-2 models only: output vector dimensions. + * Supported values today are 768, 1536, and 3072. + */ + outputDimensionality?: number; + /** Local embedding settings (node-llama-cpp). */ + local?: { + /** GGUF model path or hf: URI. */ + modelPath?: string; + }; + /** Index storage configuration. */ + store?: { + fts?: { + /** FTS5 tokenizer (default: "unicode61"). Use "trigram" for CJK text support. */ + tokenizer?: "unicode61" | "trigram"; + }; + vector?: { + /** Enable the sqlite-vec semantic index (default: true). */ + enabled?: boolean; + /** Optional override path to sqlite-vec extension (.dylib/.so/.dll). */ + extensionPath?: string; + }; + cache?: { + /** Enable embedding cache (default: true). */ + enabled?: boolean; + /** Optional max cache entries per provider/model. */ + maxEntries?: number; + }; + }; + /** Query behavior. */ + query?: { + maxResults?: number; + minScore?: number; + }; + /** Index cache behavior. */ + cache?: { + /** Cache chunk embeddings in SQLite (default: true). */ + enabled?: boolean; + }; +}; + /** Session export settings for QMD memory indexing. */ export type MemoryQmdSessionConfig = { enabled?: boolean; @@ -64,20 +137,6 @@ export type MemoryQmdSessionConfig = { retentionDays?: number; }; -/** Background update and embedding schedule for QMD memory. */ -export type MemoryQmdUpdateConfig = { - interval?: string; - debounceMs?: number; - onBoot?: boolean; - startup?: MemoryQmdStartupMode; - startupDelayMs?: number; - waitForBootSync?: boolean; - embedInterval?: string; - commandTimeoutMs?: number; - updateTimeoutMs?: number; - embedTimeoutMs?: number; -}; - /** Retrieval and injection limits for QMD memory results. */ export type MemoryQmdLimitsConfig = { maxResults?: number; diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 961b3ed62f9e..4260168fe128 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -1,6 +1,5 @@ // Defines message queue and delivery configuration types. import type { QueueDropPolicy, QueueMode, QueueModeByProvider } from "./types.queue.js"; -import type { TtsConfig } from "./types.tts.js"; export type MentionPatternsMode = "allow" | "deny"; @@ -61,30 +60,14 @@ export type BroadcastConfig = { [peerId: string]: string[] | BroadcastStrategy | undefined; }; -export type StatusReactionsEmojiConfig = { - queued?: string; - thinking?: string; - tool?: string; - coding?: string; - web?: string; - deploy?: string; - build?: string; - concierge?: string; - done?: string; - error?: string; - stallSoft?: string; - stallHard?: string; - compacting?: string; -}; - export type StatusReactionsConfig = { /** Enable lifecycle status reactions (default: false). */ enabled?: boolean; - /** Override default emojis. */ - emojis?: StatusReactionsEmojiConfig; }; export type MessagesConfig = { + /** @deprecated Doctor-only legacy input. */ + removeAckAfterReply?: boolean; /** * Controls how source inbound events produce visible replies across direct, * group, and channel conversations. Group/channel events still default to @@ -142,20 +125,14 @@ export type MessagesConfig = { ackReaction?: string; /** When to send ack reactions. Default: "group-mentions". */ ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "off" | "none"; - /** Remove ack reaction after reply is sent (default: false). */ - removeAckAfterReply?: boolean; /** Lifecycle status reactions configuration. */ statusReactions?: StatusReactionsConfig; /** When true, suppress ⚠️ tool-error warnings from being shown to the user. Default: false. */ suppressToolErrors?: boolean; - /** Text-to-speech settings for outbound replies. */ - tts?: TtsConfig; }; export type NativeCommandsSetting = boolean | "auto"; -export type CommandOwnerDisplay = "raw" | "hash"; - /** * Per-provider allowlist for command authorization. * Keys are channel IDs (e.g., "discord", "whatsapp") or "*" for global default. @@ -164,6 +141,10 @@ export type CommandOwnerDisplay = "raw" | "hash"; export type CommandAllowFrom = Record>; export type CommandsConfig = { + /** @deprecated Doctor-only legacy input. */ + ownerDisplay?: "raw" | "hash"; + /** @deprecated Doctor-only legacy input. */ + ownerDisplaySecret?: string; /** Enable native command registration when supported (default: "auto"). */ native?: NativeCommandsSetting; /** Enable native skill command registration when supported (default: "auto"). */ @@ -184,14 +165,9 @@ export type CommandsConfig = { debug?: boolean; /** Allow restart commands/tools (default: true). */ restart?: boolean; - /** Enforce access-group allowlists/policies for commands (default: true). */ - useAccessGroups?: boolean; /** Explicit owner allowlist for owner-scoped commands (channel-native IDs). */ ownerAllowFrom?: Array; /** How owner IDs are rendered in system prompts. */ - ownerDisplay?: CommandOwnerDisplay; - /** Secret used to key owner ID hashes when ownerDisplay is "hash". */ - ownerDisplaySecret?: string; /** * Per-provider allowlist restricting who can use slash commands. * If set, overrides the channel's allowFrom for command authorization. diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 053f6df7e047..fbe42fb6133f 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -6,22 +6,13 @@ import type { AcpConfig } from "./types.acp.js"; import type { AgentBinding, AgentsConfig } from "./types.agents.js"; import type { ApprovalsConfig } from "./types.approvals.js"; import type { AuthConfig } from "./types.auth.js"; -import type { - AuditConfig, - DiagnosticsConfig, - LoggingConfig, - SessionConfig, - WebConfig, -} from "./types.base.js"; +import type { AuditConfig, DiagnosticsConfig, LoggingConfig, SessionConfig } from "./types.base.js"; import type { BrowserConfig } from "./types.browser.js"; import type { ChannelsConfig } from "./types.channels.js"; -import type { CliConfig } from "./types.cli.js"; import type { CloudWorkersConfig } from "./types.cloud-workers.js"; -import type { CommitmentsConfig } from "./types.commitments.js"; import type { CronConfig } from "./types.cron.js"; import type { DiscoveryConfig, GatewayConfig, TalkConfig } from "./types.gateway.js"; import type { HooksConfig } from "./types.hooks.js"; -import type { MarketplacesConfig } from "./types.marketplaces.js"; import type { McpConfig } from "./types.mcp.js"; import type { MemoryConfig } from "./types.memory.js"; import type { BroadcastConfig, CommandsConfig, MessagesConfig } from "./types.messages.js"; @@ -30,8 +21,8 @@ import type { NodeHostConfig } from "./types.node-host.js"; import type { PluginsConfig } from "./types.plugins.js"; import type { SecretsConfig } from "./types.secrets.js"; import type { SkillsConfig } from "./types.skills.js"; -import type { SystemAgentConfig } from "./types.system-agent.js"; import type { ToolsConfig } from "./types.tools.js"; +import type { TtsConfig } from "./types.tts.js"; import type { ProxyConfig } from "./zod-schema.proxy.js"; /** One persisted suppression for a known security audit finding. */ @@ -75,8 +66,6 @@ export type SecurityConfig = { env?: Record; passEnv?: string[]; trustedDirs?: string[]; - allowInsecurePath?: boolean; - allowSymlinkCommand?: boolean; }; }; }; @@ -88,18 +77,15 @@ export type SurfaceConfigEntry = { /** Top-level OpenClaw config as read from user/project config files. */ export type OpenClawConfig = { + /** @deprecated Doctor-only legacy input. */ + audit?: AuditConfig; /** JSON schema URL used by editors and generated config files. */ $schema?: string; meta?: { /** Last OpenClaw version that wrote this config. */ lastTouchedVersion?: string; - /** ISO timestamp when this config was last written. */ - lastTouchedAt?: string; /** One-time doctor migrations already applied to this config. */ - migrations?: { - /** Legacy default/per-agent model-map restrictions were preserved or confirmed unrestricted. */ - modelPolicyAllowlist?: true; - }; + migrations?: { modelPolicyAllowlist?: true }; }; /** Authentication provider/profile configuration. */ auth?: AuthConfig; @@ -128,33 +114,20 @@ export type OpenClawConfig = { accessMode?: "full" | "guarded"; /** Offer installed-application plugin and skill recommendations during onboarding. */ appRecommendations?: boolean; - /** Last setup wizard completion timestamp. */ lastRunAt?: string; - /** OpenClaw version used by the last completed wizard run. */ lastRunVersion?: string; - /** Git commit used by the last completed wizard run, when available. */ lastRunCommit?: string; - /** Command that invoked the last wizard run. */ lastRunCommand?: string; - /** Whether the last wizard run configured a local or remote install. */ lastRunMode?: "local" | "remote"; - /** Model whose lean-mode default is owned by inference onboarding. */ localModelLeanAutoModel?: string; - /** ISO timestamp when the setup security acknowledgement was accepted on this config. */ securityAcknowledgedAt?: string; }; /** Diagnostics, tracing, and stability debugging settings. */ diagnostics?: DiagnosticsConfig; /** Log sink, level, rotation, and redaction settings. */ logging?: LoggingConfig; - /** Metadata-only agent activity audit ledger settings. */ - audit?: AuditConfig; /** Security audit suppressions and security policy settings. */ security?: SecurityConfig; - /** CLI defaults and command-specific settings. */ - cli?: CliConfig; - /** System-agent rescue/maintenance integration settings. */ - systemAgent?: SystemAgentConfig; update?: { /** Update channel for git + npm installs ("stable", "extended-stable", "beta", or "dev"). */ channel?: "stable" | "extended-stable" | "beta" | "dev"; @@ -203,14 +176,14 @@ export type OpenClawConfig = { chatFollowUpMode?: "steer" | "queue"; /** Ordered page and pinned-session entries shown in the Control UI sidebar. */ sidebarEntries?: string[]; + /** Maximum chat message width CSS value. */ + chatMessageMaxWidth?: string; /** Show live agent activity beneath running Control UI sidebar sessions. */ sidebarLiveActivity?: boolean; }; }; /** Secret providers, defaults, and ref-resolution settings. */ secrets?: SecretsConfig; - /** Marketplace feed and local package source profile configuration. */ - marketplaces?: MarketplacesConfig; /** Skill loading and bundled skill configuration. */ skills?: SkillsConfig; /** Plugin registry/install/runtime configuration. */ @@ -229,30 +202,26 @@ export type OpenClawConfig = { bindings?: AgentBinding[]; /** Broadcast command and delivery settings. */ broadcast?: BroadcastConfig; - media?: { - /** Preserve original uploaded filenames when storing inbound media. */ - preserveFilenames?: boolean; + attachments?: { /** Optional retention window for persisted inbound media cleanup. */ ttlHours?: number; }; /** Message formatting, delivery, and action settings. */ messages?: MessagesConfig; + /** Shared text-to-speech defaults. Agent and channel overrides layer over this config. */ + tts?: TtsConfig; /** Chat command settings. */ commands?: CommandsConfig; /** Human approval workflow settings. */ approvals?: ApprovalsConfig; /** Session keying, reset, maintenance, send-policy, and thread-binding settings. */ session?: SessionConfig; - /** Web runtime settings, including WhatsApp web transport controls. */ - web?: WebConfig; /** Channel defaults, built-in channel sections, and plugin-owned channel config. */ channels?: ChannelsConfig; /** Cron schedule and retention settings. */ cron?: CronConfig; /** Transcript persistence and export settings. */ transcripts?: TranscriptsConfig; - /** Commitment/reminder extraction settings. */ - commitments?: CommitmentsConfig; /** Runtime hook registration and queue behavior. */ hooks?: HooksConfig; /** Network discovery and service advertisement settings. */ @@ -261,7 +230,7 @@ export type OpenClawConfig = { talk?: TalkConfig; /** Gateway server, auth, UI, node-pairing, and dispatch settings. */ gateway?: GatewayConfig; - /** Opt-in cloud-worker provider profiles and stored lifetime policy. */ + /** Opt-in cloud-worker provider profiles. */ cloudWorkers?: CloudWorkersConfig; /** Memory indexing/search configuration. */ memory?: MemoryConfig; diff --git a/src/config/types.plugins.ts b/src/config/types.plugins.ts index 2d8146d5013c..c6e4141aab1d 100644 --- a/src/config/types.plugins.ts +++ b/src/config/types.plugins.ts @@ -68,8 +68,6 @@ export type PluginsConfig = { load?: PluginsLoadConfig; slots?: PluginSlotsConfig; entries?: Record; - /** @deprecated Shipped upgrade marker accepted for old restrictive allowlist configs. */ - bundledDiscovery?: "compat" | "allowlist"; /** * Internal transient carrier for plugin install records during command flows. * This is intentionally omitted from the config schema and must not be diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 2b0f18501dc1..076db2b0c6c6 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -76,6 +76,8 @@ export type SandboxBrowserSettings = { vncPort?: number; noVncPort?: number; headless?: boolean; + noVncEnabled?: boolean; + /** @deprecated Doctor-only legacy input. */ enableNoVnc?: boolean; /** * Allow sandboxed sessions to target the host browser control server. diff --git a/src/config/types.secrets.ts b/src/config/types.secrets.ts index d1232d286f46..6d57fc9b758e 100644 --- a/src/config/types.secrets.ts +++ b/src/config/types.secrets.ts @@ -311,7 +311,6 @@ export type FileSecretProviderConfig = { mode?: FileSecretProviderMode; timeoutMs?: number; maxBytes?: number; - allowInsecurePath?: boolean; }; export type ManualExecSecretProviderConfig = { @@ -325,8 +324,6 @@ export type ManualExecSecretProviderConfig = { env?: Record; passEnv?: string[]; trustedDirs?: string[]; - allowInsecurePath?: boolean; - allowSymlinkCommand?: boolean; }; export type PluginIntegrationSecretProviderConfig = { diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index 29214110b379..ccd5fb43e2d9 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -30,10 +30,6 @@ export type SignalAccountConfig = Omit & ChannelBotInteractionConfig & ChannelReactionConfig & { - /** Slack author identity. Default: bot. */ + /** @deprecated Doctor-only legacy input. */ identity?: "bot" | "user"; + /** @deprecated Doctor-only legacy input. */ + socketMode?: { + clientPingTimeout?: number; + serverPingTimeout?: number; + pingPongLoggingEnabled?: boolean; + }; + /** Slack author identity. Default: bot. */ + postAs?: "bot" | "user"; /** Slack connection mode (socket|http|relay). Default: socket. */ mode?: "socket" | "http" | "relay"; /** @@ -158,7 +157,6 @@ export type SlackAccountConfig = Omit< */ enterpriseOrgInstall?: boolean; /** Slack SDK Socket Mode transport options. Ignored in HTTP mode. */ - socketMode?: SlackSocketModeConfig; /** Relay-delivered Slack event source. Used when mode is "relay". */ relay?: SlackRelayConfig; /** Slack signing secret (required for HTTP mode). */ diff --git a/src/config/types.system-agent.ts b/src/config/types.system-agent.ts deleted file mode 100644 index d5aad2ea19ed..000000000000 --- a/src/config/types.system-agent.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * System-agent config types for local control-plane and remote rescue behavior. - * Rescue config is deliberately narrow because it can approve state-changing maintainer actions. - */ -/** Remote rescue gate and approval retention policy. */ -type SystemAgentRescueConfig = { - /** - * Remote message rescue gate. - * "auto" enables only for YOLO host posture with sandboxing off. - */ - enabled?: "auto" | boolean; - /** Restrict rescue to owner DMs. Default: true. */ - ownerDmOnly?: boolean; - /** Pending write approval TTL in minutes. Default: 15. */ - pendingTtlMinutes?: number; -}; - -/** Top-level system-agent config block. */ -export type SystemAgentConfig = { - rescue?: SystemAgentRescueConfig; -}; diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index e197c0fecae0..e7ef962c35ae 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -139,7 +139,7 @@ export type TelegramAccountConfig = CommonChannelMessagingConfig< /** * Per-channel outbound response prefix override. * - * When set, this takes precedence over the global `messages.responsePrefix`. + * Account values take precedence over the channel-level value. * Use `""` to explicitly disable a global prefix for this channel. * Use `"auto"` to derive `[{identity.name}]` from the routed agent. */ diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index edd58b04ec78..84cb45e5a1dc 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -4,9 +4,8 @@ import type { ChatType } from "../channels/chat-type.js"; import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js"; import type { AgentModelConfig } from "./types.agents-shared.js"; import type { AgentElevatedAllowFromConfig, SessionSendPolicyAction } from "./types.base.js"; -import type { MemoryQmdIndexPath } from "./types.memory.js"; import type { ConfiguredProviderRequest } from "./types.provider-request.js"; -import type { SecretInput } from "./types.secrets.js"; +export type { MemorySearchConfig } from "./types.memory.js"; export type MediaUnderstandingScopeMatch = { /** Channel/provider id to match before running media or link understanding. */ @@ -85,6 +84,8 @@ export type MediaUnderstandingModelConfig = MediaProviderRequestConfig & { export type MediaUnderstandingConfig = MediaProviderRequestConfig & { /** Enable media understanding when models are configured. */ enabled?: boolean; + /** Prefer a matching shared model entry. */ + preferredModel?: string; /** Optional scope gating for understanding. */ scope?: MediaUnderstandingScopeConfig; /** Default max bytes to send. */ @@ -117,6 +118,9 @@ export type MediaUnderstandingConfig = MediaProviderRequestConfig & { echoFormat?: string; }; +/** Per-capability defaults and policy. Models live only in tools.media.models. */ +export type MediaUnderstandingCapabilityConfig = Omit; + export type LinkModelConfig = { /** Use a CLI command for link processing. */ type?: "cli"; @@ -142,13 +146,13 @@ export type LinkToolsConfig = { }; export type MediaToolsConfig = { - /** Shared model list applied across image/audio/video. */ + /** Canonical model list for image/audio/video, selected by capability tags. */ models?: MediaUnderstandingModelConfig[]; /** Max concurrent media understanding runs. */ concurrency?: number; - image?: MediaUnderstandingConfig; - audio?: MediaUnderstandingConfig; - video?: MediaUnderstandingConfig; + image?: MediaUnderstandingCapabilityConfig; + audio?: MediaUnderstandingCapabilityConfig; + video?: MediaUnderstandingCapabilityConfig; }; export type ToolProfileId = "minimal" | "coding" | "messaging" | "full"; @@ -221,30 +225,21 @@ export type SwarmConfig = export type SessionsToolsVisibility = "self" | "tree" | "agent" | "all"; -export type ToolPolicyConfig = { - /** Exact tool names allowed after the selected profile is applied. */ +export type ToolAllowDenyPolicyConfig = { + /** Exact tool names allowed in this policy scope. */ allow?: string[]; - /** - * Additional allowlist entries merged into the effective allowlist. - * - * Intended for additive configuration (e.g., "also allow lobster") without forcing - * users to replace/duplicate an existing allowlist or profile. - */ + /** Additional allowlist entries merged into the inherited policy. */ alsoAllow?: string[]; - /** Exact tool names denied after allow/profile expansion; deny wins. */ + /** Exact tool names denied after allow expansion; deny wins. */ deny?: string[]; +}; + +export type ToolPolicyConfig = ToolAllowDenyPolicyConfig & { /** Built-in profile used as the base policy before allow/deny merges. */ profile?: ToolProfileId; }; -export type GroupToolPolicyConfig = { - /** Sender-specific allowlist entries merged into the group tool policy. */ - allow?: string[]; - /** Additional allowlist entries merged into allow. */ - alsoAllow?: string[]; - /** Sender-specific deny entries; deny wins over allow/profile policy. */ - deny?: string[]; -}; +export type GroupToolPolicyConfig = ToolAllowDenyPolicyConfig; export const TOOLS_BY_SENDER_KEY_TYPES = ["channel", "id", "e164", "username", "name"] as const; export type ToolsBySenderKeyType = (typeof TOOLS_BY_SENDER_KEY_TYPES)[number]; @@ -292,9 +287,9 @@ export type ExecToolConfig = { host?: "auto" | "sandbox" | "gateway" | "node"; /** Normalized exec policy mode. Prefer this over raw security/ask knobs. */ mode?: "deny" | "allowlist" | "ask" | "auto" | "full"; - /** Exec security mode (default: full; sandbox host defaults to deny). */ + /** Legacy exec security mode retained when no canonical mode can preserve policy. */ security?: "deny" | "allowlist" | "full"; - /** Exec ask mode (default: off). */ + /** Legacy exec ask mode retained when no canonical mode can preserve policy. */ ask?: "off" | "on-miss" | "always"; /** Default node binding for exec.host=node (node id/name). */ node?: string; @@ -323,7 +318,7 @@ export type ExecToolConfig = { /** Default time (ms) before an exec command auto-backgrounds. */ backgroundMs?: number; /** Default timeout (seconds) before auto-killing exec commands. */ - timeoutSec?: number; + timeoutSeconds?: number; /** Emit a running notice (ms) when approval-backed exec runs long (default: 10000, 0 = off). */ approvalRunningNoticeMs?: number; /** How long to keep finished sessions in memory (ms). */ @@ -402,154 +397,7 @@ export type AgentToolsConfig = { /** Message tool configuration for this agent. */ message?: MessageToolsConfig; sandbox?: { - tools?: { - allow?: string[]; - /** Additional allowlist entries merged into allow and/or the sandbox default allowlist. */ - alsoAllow?: string[]; - deny?: string[]; - }; - }; -}; - -export type MemorySearchConfig = { - /** Enable vector memory search (default: true). */ - enabled?: boolean; - /** Use relevant context from this agent's other private conversations. */ - rememberAcrossConversations?: boolean; - /** Sources to index and search (default: ["memory"]). */ - sources?: Array<"memory" | "sessions">; - /** Extra paths to include in memory search (directories or .md files). */ - extraPaths?: string[]; - /** Optional QMD-specific extra collections for cross-agent search. */ - qmd?: { - /** Additional QMD collections appended for this agent's search scope. */ - extraCollections?: MemoryQmdIndexPath[]; - }; - /** Optional multimodal file indexing for selected extra paths. */ - multimodal?: { - /** Enable image/audio embeddings from extraPaths. */ - enabled?: boolean; - /** Which non-text file types to index. */ - modalities?: Array<"image" | "audio" | "all">; - /** Max bytes allowed per multimodal file before it is skipped. */ - maxFileBytes?: number; - }; - /** Experimental memory search settings. */ - experimental?: { - /** Enable session transcript indexing (experimental, default: false). */ - sessionMemory?: boolean; - }; - /** Memory embedding provider adapter id. */ - provider?: string; - remote?: { - baseUrl?: string; - apiKey?: SecretInput; - headers?: Record; - /** Max concurrent non-batch embedding tasks during indexing. Useful for slower local providers such as Ollama. */ - nonBatchConcurrency?: number; - batch?: { - /** Enable batch API for embedding indexing (OpenAI/Gemini; default: true). */ - enabled?: boolean; - /** Wait for batch completion (default: true). */ - wait?: boolean; - /** Max concurrent batch jobs (default: 2). */ - concurrency?: number; - /** Poll interval in ms (default: 5000). */ - pollIntervalMs?: number; - /** Timeout in minutes (default: 60). */ - timeoutMinutes?: number; - }; - }; - /** Fallback memory embedding provider adapter id when embeddings fail. */ - fallback?: string; - /** Embedding model id (remote) or alias (local). */ - model?: string; - /** Optional provider-specific embedding input_type for query and document requests. */ - inputType?: string; - /** Optional provider-specific embedding input_type for query-time memory search. */ - queryInputType?: string; - /** Optional provider-specific embedding input_type for document/index embeddings. */ - documentInputType?: string; - /** - * Gemini embedding-2 models only: output vector dimensions. - * Supported values today are 768, 1536, and 3072. - */ - outputDimensionality?: number; - /** Local embedding settings (node-llama-cpp). */ - local?: { - /** GGUF model path or hf: URI. */ - modelPath?: string; - /** Optional cache directory for local models. */ - modelCacheDir?: string; - /** - * Context window size for the local embedding context (default: 4096). - * Use `"auto"` to defer to node-llama-cpp, which picks up to the model's - * trained maximum — not recommended for 8B+ models. - */ - contextSize?: number | "auto"; - }; - /** Index storage configuration. */ - store?: { - driver?: "sqlite"; - fts?: { - /** FTS5 tokenizer (default: "unicode61"). Use "trigram" for CJK text support. */ - tokenizer?: "unicode61" | "trigram"; - }; - vector?: { - /** Enable sqlite-vec extension for vector search (default: true). */ - enabled?: boolean; - /** Optional override path to sqlite-vec extension (.dylib/.so/.dll). */ - extensionPath?: string; - }; - cache?: { - /** Enable embedding cache (default: true). */ - enabled?: boolean; - /** Optional max cache entries per provider/model. */ - maxEntries?: number; - }; - }; - /** Sync behavior. */ - sync?: { - onSessionStart?: boolean; - onSearch?: boolean; - watch?: boolean; - /** - * Timeout in seconds for inline embedding batches during memory indexing. - * Unset uses provider defaults: 600s for local/self-hosted providers, 120s for hosted providers. - */ - embeddingBatchTimeoutSeconds?: number; - sessions?: { - /** Minimum appended bytes before session transcripts are reindexed. */ - deltaBytes?: number; - /** Minimum appended JSONL lines before session transcripts are reindexed. */ - deltaMessages?: number; - /** Force session reindex after compaction-triggered transcript updates (default: true). */ - postCompactionForce?: boolean; - }; - }; - /** Query behavior. */ - query?: { - maxResults?: number; - minScore?: number; - hybrid?: { - /** Enable hybrid BM25 + vector search (default: true). */ - enabled?: boolean; - /** Optional MMR re-ranking for result diversity. */ - mmr?: { - /** Enable MMR re-ranking (default: false). */ - enabled?: boolean; - }; - /** Optional temporal decay to boost recency in hybrid scoring. */ - temporalDecay?: { - /** Enable temporal decay (default: false). */ - enabled?: boolean; - }; - }; - }; - /** Index cache behavior. */ - cache?: { - /** Cache chunk embeddings in SQLite (default: true). */ - enabled?: boolean; + tools?: ToolAllowDenyPolicyConfig; }; }; @@ -675,21 +523,11 @@ export type ToolsConfig = { sessions_spawn?: SessionsSpawnToolsConfig; /** Sub-agent tool policy defaults (deny wins). */ subagents?: { - tools?: { - allow?: string[]; - /** Additional allowlist entries merged into allow and/or default sub-agent denylist. */ - alsoAllow?: string[]; - deny?: string[]; - }; + tools?: ToolAllowDenyPolicyConfig; }; /** Sandbox tool policy defaults (deny wins). */ sandbox?: { - tools?: { - allow?: string[]; - /** Additional allowlist entries merged into allow and/or the sandbox default allowlist. */ - alsoAllow?: string[]; - deny?: string[]; - }; + tools?: ToolAllowDenyPolicyConfig; }; /** Experimental tool flags. */ experimental?: { diff --git a/src/config/types.ts b/src/config/types.ts index 79beeb84f0e9..90b4830c9cb8 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -9,10 +9,7 @@ export * from "./types.auth.js"; export * from "./types.base.js"; export * from "./types.browser.js"; export * from "./types.cloud-workers.js"; -export * from "./types.marketplaces.js"; export * from "./types.channels.js"; -export * from "./types.cli.js"; -export * from "./types.commitments.js"; export * from "./types.openclaw.js"; export * from "./types.cron.js"; export * from "./types.discord.js"; diff --git a/src/config/types.tts.ts b/src/config/types.tts.ts index 3b8f7b9925cb..4c8f2908a93f 100644 --- a/src/config/types.tts.ts +++ b/src/config/types.tts.ts @@ -28,23 +28,12 @@ export type TtsProviderConfigMap = Record>; export type TtsPersonaFallbackPolicy = "preserve-persona" | "provider-defaults" | "fail"; -export type TtsPersonaPromptConfig = { - profile?: string; - scene?: string; - sampleContext?: string; - style?: string; - accent?: string; - pacing?: string; - constraints?: string[]; -}; - export type TtsPersonaConfig = { label?: string; description?: string; /** Preferred provider for this persona. Explicit provider prefs still win. */ provider?: TtsProvider; fallbackPolicy?: TtsPersonaFallbackPolicy; - prompt?: TtsPersonaPromptConfig; /** Provider-specific persona bindings keyed by speech provider id. */ providers?: TtsProviderConfigMap; }; @@ -73,7 +62,6 @@ export type TtsConfig = { /** Provider-specific TTS settings keyed by speech provider id. */ providers?: TtsProviderConfigMap; /** Optional path for local TTS user preferences JSON. */ - prefsPath?: string; /** Hard cap for text sent to TTS (chars). */ maxTextLength?: number; /** API request timeout (ms). */ diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 114d8cf6f3dc..77ea0d5f9705 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -58,7 +58,7 @@ type WhatsAppSharedConfig = CommonChannelMessagingConfig & }; type WhatsAppSpecificConfig = { - /** Inbound message prefix override (WhatsApp only). */ + /** @deprecated Doctor-only legacy input. */ messagePrefix?: string; }; diff --git a/src/config/validation.allowed-values.test.ts b/src/config/validation.allowed-values.test.ts index aa0cc34e34a5..76819bf68b2d 100644 --- a/src/config/validation.allowed-values.test.ts +++ b/src/config/validation.allowed-values.test.ts @@ -126,7 +126,7 @@ describe("config validation allowed-values metadata", () => { it("keeps generic union messaging for mixed scalar-or-object unions", () => { const result = validateConfigObjectRaw({ agents: { - list: [{ id: "a", model: true }], + entries: { a: { model: true } }, }, }); @@ -134,7 +134,7 @@ describe("config validation allowed-values metadata", () => { if (!result.ok) { expect(result.issues).toEqual([ { - path: "agents.list.0.model", + path: "agents.entries.a.model", message: "Invalid input", }, ]); diff --git a/src/config/validation.channel-metadata.test.ts b/src/config/validation.channel-metadata.test.ts index a5682bdb3898..ed9764f81d33 100644 --- a/src/config/validation.channel-metadata.test.ts +++ b/src/config/validation.channel-metadata.test.ts @@ -755,20 +755,6 @@ describe("validateConfigObjectRawWithPlugins plugin config defaults", () => { }); describe("validateConfigObjectWithPlugins bundled allowlist compatibility", () => { - it("accepts the shipped deprecated bundledDiscovery marker", () => { - const result = validateConfigObjectWithPlugins({ - plugins: { - allow: ["telegram"], - bundledDiscovery: "compat", - }, - }); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.config.plugins?.bundledDiscovery).toBe("compat"); - } - }); - it("reuses the manifest registry loaded for compatibility during plugin validation", () => { mockLoadPluginManifestRegistry.mockReturnValue(createCompatPluginConfigSchemaRegistry()); diff --git a/src/config/validation.ts b/src/config/validation.ts index 9298fd0fd902..0d1a68f98343 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1133,7 +1133,9 @@ export function validateConfigObjectRaw( issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, schemaIssues), }; } - const validatedConfig = materializeBundledModelProviderOverlays(validated.data as OpenClawConfig); + const validatedConfig = attachAgentListProjection( + materializeBundledModelProviderOverlays(validated.data as OpenClawConfig), + ); const channelIssues = policyIssues.length > 0 || opts?.validateBundledChannels ? collectRawBundledChannelConfigIssues(validatedConfig) @@ -1153,7 +1155,7 @@ export function validateConfigObjectRaw( ok: false, issues: [ { - path: "agents.list", + path: "agents.entries", message: formatDuplicateAgentDirError(duplicates), }, ], @@ -1194,12 +1196,29 @@ export function validateConfigObject( } return { ok: true, - config: materializeRuntimeConfig(result.config, "snapshot", { - manifestRegistry: opts?.manifestRegistry, - }), + config: attachAgentListProjection( + materializeRuntimeConfig(result.config, "snapshot", { + manifestRegistry: opts?.manifestRegistry, + }), + ), }; } +function attachAgentListProjection(config: OpenClawConfig): OpenClawConfig { + if (!config.agents) { + return config; + } + Object.defineProperty(config.agents, "list", { + configurable: true, + enumerable: false, + value: Object.entries(config.agents.entries ?? {}).map(([id, entry]) => + Object.assign({ id }, entry), + ), + writable: false, + }); + return config; +} + type ValidateConfigWithPluginsResult = | { ok: true; diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index 27d32597475d..dc2e843376e8 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -132,12 +132,14 @@ describe("agent defaults schema", () => { ); }); - it("accepts videoGenerationModel", () => { + it("accepts mediaModels.video", () => { expectSchemaSuccess( AgentDefaultsSchema.safeParse({ - videoGenerationModel: { - primary: "qwen/wan2.6-t2v", - fallbacks: ["minimax/video-01"], + mediaModels: { + video: { + primary: "qwen/wan2.6-t2v", + fallbacks: ["minimax/video-01"], + }, }, }), ); @@ -154,26 +156,24 @@ describe("agent defaults schema", () => { ); }); - it("accepts imageGenerationModel timeoutMs", () => { + it("accepts mediaModels.image timeoutMs", () => { const defaults = AgentDefaultsSchema.parse({ - imageGenerationModel: { - primary: "openrouter/openai/gpt-5.4-image-2", - timeoutMs: 180_000, + mediaModels: { + image: { primary: "openrouter/openai/gpt-5.4-image-2", timeoutMs: 180_000 }, }, })!; - expect(defaults.imageGenerationModel).toEqual({ + expect(defaults.mediaModels?.image).toEqual({ primary: "openrouter/openai/gpt-5.4-image-2", timeoutMs: 180_000, }); expectSchemaFailurePath( AgentDefaultsSchema.safeParse({ - imageGenerationModel: { - primary: "openrouter/openai/gpt-5.4-image-2", - timeoutMs: 0, + mediaModels: { + image: { primary: "openrouter/openai/gpt-5.4-image-2", timeoutMs: 0 }, }, }), - "imageGenerationModel.timeoutMs", + "mediaModels.image.timeoutMs", ); }); @@ -219,14 +219,6 @@ describe("agent defaults schema", () => { ); }); - it("accepts mediaGenerationAutoProviderFallback", () => { - expectSchemaSuccess( - AgentDefaultsSchema.safeParse({ - mediaGenerationAutoProviderFallback: false, - }), - ); - }); - it("accepts experimental.localModelLean", () => { const result = AgentDefaultsSchema.parse({ experimental: { @@ -424,17 +416,17 @@ describe("agent defaults schema", () => { it("accepts positive heartbeat timeoutSeconds on defaults and agent entries", () => { const defaults = AgentDefaultsSchema.parse({ - heartbeat: { timeoutSeconds: 45, skipWhenBusy: true }, + heartbeat: { timeoutSeconds: 45 }, })!; const agent = AgentEntrySchema.parse({ id: "ops", - heartbeat: { timeoutSeconds: 45, skipWhenBusy: true }, + heartbeat: { timeoutSeconds: 45 }, }); expect(defaults.heartbeat?.timeoutSeconds).toBe(45); - expect(defaults.heartbeat?.skipWhenBusy).toBe(true); + expect(defaults.heartbeat?.timeoutSeconds).toBe(45); + expect(agent.heartbeat?.timeoutSeconds).toBe(45); expect(agent.heartbeat?.timeoutSeconds).toBe(45); - expect(agent.heartbeat?.skipWhenBusy).toBe(true); }); it("accepts per-agent TTS overrides", () => { @@ -470,12 +462,11 @@ describe("agent defaults schema", () => { it("preserves per-agent contextTokens through config validation", () => { const result = validateConfigObject({ agents: { - list: [ - { - id: "ops", + entries: { + ops: { contextTokens: 1_048_576, }, - ], + }, }, }); @@ -483,8 +474,10 @@ describe("agent defaults schema", () => { if (!result.ok) { throw new Error("expected config validation to succeed"); } - const config = result.config as { agents?: { list?: Array<{ contextTokens?: number }> } }; - expect(config.agents?.list?.[0]?.contextTokens).toBe(1_048_576); + const config = result.config as { + agents?: { entries?: Record }; + }; + expect(config.agents?.entries?.ops?.contextTokens).toBe(1_048_576); }); it("accepts per-agent tools.codeMode config", () => { diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 55c898926dfc..01dc46cda9cc 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -9,7 +9,6 @@ import { AgentModelPolicySchema, AgentModelSchema, AgentToolModelSchema, - MemorySearchSchema, } from "./zod-schema.agent-runtime.js"; import { BlockStreamingChunkSchema, @@ -68,13 +67,17 @@ export const AgentDefaultsSchema = z model: AgentModelSchema.optional(), utilityModel: z.string().optional(), imageModel: AgentToolModelSchema.optional(), - imageGenerationModel: AgentToolModelSchema.optional(), - videoGenerationModel: AgentToolModelSchema.optional(), - musicGenerationModel: AgentToolModelSchema.optional(), + mediaModels: z + .object({ + image: AgentToolModelSchema.optional(), + video: AgentToolModelSchema.optional(), + music: AgentToolModelSchema.optional(), + }) + .strict() + .optional(), voiceModel: AgentToolModelSchema.optional(), - mediaGenerationAutoProviderFallback: z.boolean().optional(), pdfModel: AgentToolModelSchema.optional(), - pdfMaxBytesMb: z.number().positive().optional(), + pdfMaxMb: z.number().positive().optional(), pdfMaxPages: z.number().int().positive().optional(), models: z.record(z.string(), AgentModelRuntimeEntrySchema).optional(), modelPolicy: AgentModelPolicySchema.optional(), @@ -82,19 +85,6 @@ export const AgentDefaultsSchema = z skills: z.array(z.string()).optional(), silentReply: SilentReplyPolicyConfigSchema.optional(), repoRoot: z.string().optional(), - promptOverlays: z - .object({ - gpt5: z - .object({ - personality: z - .union([z.literal("friendly"), z.literal("on"), z.literal("off")]) - .optional(), - }) - .strict() - .optional(), - }) - .strict() - .optional(), skipBootstrap: z.boolean().optional(), skipOptionalBootstrapFiles: z.array(OptionalBootstrapFileNameSchema).optional(), contextInjection: z @@ -108,9 +98,6 @@ export const AgentDefaultsSchema = z }) .strict() .optional(), - bootstrapPromptTruncationWarning: z - .union([z.literal("off"), z.literal("once"), z.literal("always")]) - .optional(), userTimezone: z.string().optional(), startupContext: z .object({ @@ -129,13 +116,8 @@ export const AgentDefaultsSchema = z .strict() .optional(), contextLimits: AgentContextLimitsSchema, - timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(), - envelopeTimezone: z.string().optional(), - envelopeTimestamp: z.union([z.literal("on"), z.literal("off")]).optional(), - envelopeElapsed: z.union([z.literal("on"), z.literal("off")]).optional(), contextTokens: z.number().int().positive().optional(), cliBackends: z.record(z.string(), CliBackendSchema).optional(), - memorySearch: MemorySearchSchema, contextPruning: z .object({ mode: z.union([z.literal("off"), z.literal("cache-ttl")]).optional(), @@ -163,11 +145,7 @@ export const AgentDefaultsSchema = z provider: z.string().optional(), thinkingLevel: AgentThinkingLevelSchema.optional(), keepRecentTokens: z.number().int().positive().optional(), - customInstructions: z.string().optional(), - identifierPolicy: z - .union([z.literal("strict"), z.literal("off"), z.literal("custom")]) - .optional(), - identifierInstructions: z.string().optional(), + identifierPolicy: z.union([z.literal("strict"), z.literal("off")]).optional(), recentTurnsPreserve: z.number().int().min(0).max(12).optional(), qualityGuard: z .object({ @@ -192,8 +170,6 @@ export const AgentDefaultsSchema = z model: z.string().optional(), softThresholdTokens: z.number().int().nonnegative().optional(), forceFlushTranscriptBytes: NonNegativeByteSizeSchema.optional(), - prompt: z.string().optional(), - systemPrompt: z.string().optional(), }) .strict() .optional(), @@ -205,6 +181,7 @@ export const AgentDefaultsSchema = z .optional(), embeddedAgent: EmbeddedAgentConfigSchema.optional(), thinkingDefault: AgentThinkingLevelSchema.optional(), + fastModeDefault: z.union([z.boolean(), z.literal("auto")]).optional(), verboseDefault: z.union([z.literal("off"), z.literal("on"), z.literal("full")]).optional(), toolProgressDetail: z.union([z.literal("explain"), z.literal("raw")]).optional(), reasoningDefault: z.union([z.literal("off"), z.literal("on"), z.literal("stream")]).optional(), diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index da5ae4059f46..26f60ec31ef9 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -20,6 +20,7 @@ import { SecretInputSchema, ToolsLinksSchema, ToolsMediaSchema, + TypingModeSchema, TtsConfigSchema, } from "./zod-schema.core.js"; import { sensitive } from "./zod-schema.sensitive.js"; @@ -62,6 +63,11 @@ const AgentEntryEmbeddedAgentConfigSchema = z }) .strict(); +const AgentTtsConfigSchema = TtsConfigSchema.unwrap() + .extend({ prefsPath: z.string().optional() }) + .strict() + .optional(); + export const HeartbeatSchema = z .object({ every: z.string().optional(), @@ -75,19 +81,14 @@ export const HeartbeatSchema = z .optional(), model: z.string().optional(), session: z.string().optional(), - includeReasoning: z.boolean().optional(), target: z.string().optional(), directPolicy: z.union([z.literal("allow"), z.literal("block")]).optional(), to: z.string().optional(), accountId: z.string().optional(), prompt: z.string().optional(), - includeSystemPromptSection: z.boolean().optional(), - ackMaxChars: z.number().int().nonnegative().optional(), - suppressToolErrorWarnings: z.boolean().optional(), timeoutSeconds: z.number().int().positive().optional(), lightContext: z.boolean().optional(), isolatedSession: z.boolean().optional(), - skipWhenBusy: z.boolean().optional(), }) .strict() .superRefine((val, ctx) => { @@ -247,7 +248,7 @@ const SandboxBrowserSchema = z vncPort: z.number().int().positive().optional(), noVncPort: z.number().int().positive().optional(), headless: z.boolean().optional(), - enableNoVnc: z.boolean().optional(), + noVncEnabled: z.boolean().optional(), allowHostControl: z.boolean().optional(), autoStart: z.boolean().optional(), autoStartTimeoutMs: z.number().int().positive().optional(), @@ -530,7 +531,7 @@ const ToolExecBaseShape = { .strict() .optional(), backgroundMs: z.number().int().positive().optional(), - timeoutSec: z.number().int().positive().optional(), + timeoutSeconds: z.number().int().positive().optional(), cleanupMs: z.number().int().positive().optional(), notifyOnExit: z.boolean().optional(), notifyOnExitEmptySuccess: z.boolean().optional(), @@ -788,26 +789,16 @@ export const MemorySearchSchema = z }) .strict() .optional(), - experimental: z - .object({ - sessionMemory: z.boolean().optional(), - }) - .strict() - .optional(), + experimental: z.object({ sessionMemory: z.boolean().optional() }).strict().optional(), provider: z.string().optional(), remote: z .object({ baseUrl: z.string().optional(), apiKey: SecretInputSchema.optional().register(sensitive), headers: z.record(z.string(), z.string()).optional(), - nonBatchConcurrency: z.number().int().positive().optional(), batch: z .object({ enabled: z.boolean().optional(), - wait: z.boolean().optional(), - concurrency: z.number().int().positive().optional(), - pollIntervalMs: z.number().int().nonnegative().optional(), - timeoutMinutes: z.number().int().positive().optional(), }) .strict() .optional(), @@ -823,14 +814,11 @@ export const MemorySearchSchema = z local: z .object({ modelPath: z.string().optional(), - modelCacheDir: z.string().optional(), - contextSize: z.union([z.number().int().positive(), z.literal("auto")]).optional(), }) .strict() .optional(), store: z .object({ - driver: z.literal("sqlite").optional(), fts: z .object({ tokenizer: z.union([z.literal("unicode61"), z.literal("trigram")]).optional(), @@ -847,45 +835,10 @@ export const MemorySearchSchema = z }) .strict() .optional(), - sync: z - .object({ - onSessionStart: z.boolean().optional(), - onSearch: z.boolean().optional(), - watch: z.boolean().optional(), - embeddingBatchTimeoutSeconds: z.number().int().positive().optional(), - sessions: z - .object({ - deltaBytes: z.number().int().nonnegative().optional(), - deltaMessages: z.number().int().nonnegative().optional(), - postCompactionForce: z.boolean().optional(), - }) - .strict() - .optional(), - }) - .strict() - .optional(), query: z .object({ maxResults: z.number().int().positive().optional(), minScore: z.number().min(0).max(1).optional(), - hybrid: z - .object({ - enabled: z.boolean().optional(), - mmr: z - .object({ - enabled: z.boolean().optional(), - }) - .strict() - .optional(), - temporalDecay: z - .object({ - enabled: z.boolean().optional(), - }) - .strict() - .optional(), - }) - .strict() - .optional(), }) .strict() .optional(), @@ -979,15 +932,22 @@ export const AgentEntrySchema = z .strict() .optional(), skills: z.array(z.string()).optional(), - memorySearch: MemorySearchSchema, + memory: z + .object({ + search: MemorySearchSchema, + }) + .strict() + .optional(), humanDelay: HumanDelaySchema.optional(), - tts: TtsConfigSchema, + typingMode: TypingModeSchema.optional(), + typingIntervalSeconds: z.number().int().positive().optional(), + tts: AgentTtsConfigSchema, skillsLimits: AgentSkillsLimitsSchema, contextLimits: AgentContextLimitsSchema, contextTokens: z.number().int().positive().optional(), heartbeat: HeartbeatSchema, identity: IdentitySchema, - groupChat: GroupChatSchema, + groupChat: GroupChatSchema.unwrap().omit({ visibleReplies: true }).optional(), subagents: z .object({ delegationMode: z.enum(["suggest", "prefer"]).optional(), diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index 1c9b9523b914..92ba326dd5fa 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -4,10 +4,17 @@ import { z } from "zod"; import { AgentDefaultsSchema } from "./zod-schema.agent-defaults.js"; import { AgentEntrySchema } from "./zod-schema.agent-runtime.js"; +const AgentEntryConfigSchema = AgentEntrySchema.omit({ id: true }); + export const AgentsSchema = z .object({ defaults: z.lazy(() => AgentDefaultsSchema).optional(), - list: z.array(AgentEntrySchema).optional(), + entries: z + .record( + z.string().regex(/^[a-z0-9_][a-z0-9_-]{0,63}$/i, "Invalid agent id"), + AgentEntryConfigSchema, + ) + .optional(), }) .strict() .optional(); diff --git a/src/config/zod-schema.channel-messaging-common.ts b/src/config/zod-schema.channel-messaging-common.ts index 5530c2b4550d..bde9038dc05c 100644 --- a/src/config/zod-schema.channel-messaging-common.ts +++ b/src/config/zod-schema.channel-messaging-common.ts @@ -94,7 +94,7 @@ function createCommonChannelAccountShape< dms: z.record(z.string(), DmConfigSchema.optional()).optional(), textChunkLimit: z.number().int().positive().optional(), streaming: (options.streaming ?? CommonStreamingSchema) as TStreaming, - heartbeat: ChannelHeartbeatVisibilitySchema, + heartbeatVisibility: ChannelHeartbeatVisibilitySchema, healthMonitor: ChannelHealthMonitorSchema, responsePrefix: z.string().optional(), mediaMaxMb: (options.mediaMaxMb ?? CommonMediaMaxMbSchema) as TMediaMaxMb, diff --git a/src/config/zod-schema.channels-config.ts b/src/config/zod-schema.channels-config.ts index 02b91ae6d9f6..b0cbe4d1669c 100644 --- a/src/config/zod-schema.channels-config.ts +++ b/src/config/zod-schema.channels-config.ts @@ -56,7 +56,7 @@ export const ChannelsSchema: z.ZodType = z .object({ groupPolicy: GroupPolicySchema.optional(), contextVisibility: ContextVisibilityModeSchema.optional(), - heartbeat: ChannelHeartbeatVisibilitySchema, + heartbeatVisibility: ChannelHeartbeatVisibilitySchema, botLoopProtection: ChannelBotLoopProtectionSchema.optional(), implicitMentions: ChannelImplicitMentionsSchema.optional(), }) diff --git a/src/config/zod-schema.cloud-workers.test.ts b/src/config/zod-schema.cloud-workers.test.ts index e97aad70e08b..b7e41e2be299 100644 --- a/src/config/zod-schema.cloud-workers.test.ts +++ b/src/config/zod-schema.cloud-workers.test.ts @@ -16,7 +16,7 @@ describe("OpenClawSchema cloudWorkers config", () => { expect(parseCloudWorkers({})).toStrictEqual({}); }); - it("accepts provider-owned settings and stored lifetime policy", () => { + it("accepts provider-owned settings", () => { expect( parseCloudWorkers({ profiles: { @@ -32,10 +32,6 @@ describe("OpenClawSchema cloudWorkers config", () => { id: "/cloud-workers/development/privateKey", }, }, - lifetime: { - idleTimeoutMinutes: 60, - maxLifetimeMinutes: 1440, - }, }, }, }), @@ -54,10 +50,6 @@ describe("OpenClawSchema cloudWorkers config", () => { id: "/cloud-workers/development/privateKey", }, }, - lifetime: { - idleTimeoutMinutes: 60, - maxLifetimeMinutes: 1440, - }, }, }, }); diff --git a/src/config/zod-schema.cloud-workers.ts b/src/config/zod-schema.cloud-workers.ts index 989a56009a16..6abc8aa35152 100644 --- a/src/config/zod-schema.cloud-workers.ts +++ b/src/config/zod-schema.cloud-workers.ts @@ -3,24 +3,13 @@ import { z } from "zod"; import { isPluginJsonValue } from "../plugins/host-hook-json.js"; import { isValidSecretRef } from "../secrets/ref-contract.js"; import { isSensitiveConfigPath } from "./sensitive-paths.js"; -import type { - CloudWorkerLifetimePolicyConfig, - CloudWorkerProfileConfig, - CloudWorkersConfig, -} from "./types.cloud-workers.js"; +import type { CloudWorkerProfileConfig, CloudWorkersConfig } from "./types.cloud-workers.js"; import { isSecretRef } from "./types.secrets.js"; type ConfigSchemaShape = { [Key in keyof T]-?: z.ZodType; }; -const CloudWorkerLifetimePolicyShape = { - idleTimeoutMinutes: z.number().int().positive().optional(), - maxLifetimeMinutes: z.number().int().positive().optional(), -} satisfies ConfigSchemaShape; - -const CloudWorkerLifetimePolicySchema = z.object(CloudWorkerLifetimePolicyShape).strict(); - export function validateCloudWorkerProfileSettings(value: unknown): string | undefined { if ( typeof value !== "object" || @@ -70,7 +59,6 @@ const CloudWorkerProfileShape = { provider: z.string().trim().min(1), install: z.enum(["bundle", "npm"]).optional().default("bundle"), settings: CloudWorkerSettingsSchema.optional(), - lifetime: CloudWorkerLifetimePolicySchema.optional(), } satisfies ConfigSchemaShape; const CloudWorkerProfileSchema = z.object(CloudWorkerProfileShape).strict(); diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 69ce58d66b08..90bc56f0672b 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -107,7 +107,6 @@ const SecretsFileProviderSchema = z .positive() .max(20 * 1024 * 1024) .optional(), - allowInsecurePath: z.boolean().optional(), }) .strict(); @@ -143,8 +142,6 @@ const SecretsManualExecProviderSchema = z ) .max(64) .optional(), - allowInsecurePath: z.boolean().optional(), - allowSymlinkCommand: z.boolean().optional(), }) .strict(); @@ -720,17 +717,6 @@ const TtsProviderConfigSchema = z z.record(z.string(), z.unknown()), ]), ); -const TtsPersonaPromptSchema = z - .object({ - profile: z.string().optional(), - scene: z.string().optional(), - sampleContext: z.string().optional(), - style: z.string().optional(), - accent: z.string().optional(), - pacing: z.string().optional(), - constraints: z.array(z.string()).optional(), - }) - .strict(); const TtsPersonaSchema = z .object({ label: z.string().optional(), @@ -739,7 +725,6 @@ const TtsPersonaSchema = z fallbackPolicy: z .union([z.literal("preserve-persona"), z.literal("provider-defaults"), z.literal("fail")]) .optional(), - prompt: TtsPersonaPromptSchema.optional(), providers: z.record(z.string(), TtsProviderConfigSchema).optional(), }) .strict(); @@ -766,7 +751,6 @@ export const TtsConfigSchema = z .strict() .optional(), providers: z.record(z.string(), TtsProviderConfigSchema).optional(), - prefsPath: z.string().optional(), maxTextLength: z.number().int().min(1).optional(), timeoutMs: z.number().int().min(1000).max(120000).optional(), }) @@ -806,7 +790,6 @@ export const CliBackendSchema = z clearEnv: z.array(z.string()).optional(), modelArg: z.string().optional(), modelAliases: z.record(z.string(), z.string()).optional(), - sessionArg: z.string().optional(), sessionArgs: z.array(z.string()).optional(), resumeArgs: z.array(z.string()).optional(), forkArg: z.string().optional(), @@ -964,10 +947,6 @@ export const ExecutableTokenSchema = z const MediaUnderstandingScopeSchema = createAllowDenyChannelRulesSchema(); -const MediaUnderstandingCapabilitiesSchema = z - .array(z.union([z.literal("image"), z.literal("audio"), z.literal("video")])) - .optional(); - const MediaUnderstandingAttachmentsSchema = z .object({ mode: z.union([z.literal("first"), z.literal("all")]).optional(), @@ -979,6 +958,10 @@ const MediaUnderstandingAttachmentsSchema = z .strict() .optional(); +const MediaUnderstandingCapabilitiesSchema = z + .array(z.union([z.literal("image"), z.literal("audio"), z.literal("video")])) + .optional(); + const ProviderOptionValueSchema = z.union([z.string(), z.number(), z.boolean()]); const ProviderOptionsSchema = z .record(z.string(), z.record(z.string(), ProviderOptionValueSchema)) @@ -1011,15 +994,28 @@ const MediaUnderstandingModelSchema = z .strict() .optional(); -const ToolsMediaUnderstandingSchema = z +const ToolsMediaCapabilitySchema = z .object({ enabled: z.boolean().optional(), + preferredModel: z.string().trim().min(1).optional(), + scope: MediaUnderstandingScopeSchema, + maxBytes: z.number().int().positive().optional(), + maxChars: z.number().int().positive().optional(), + ...MediaUnderstandingRuntimeFields, + attachments: MediaUnderstandingAttachmentsSchema, + }) + .strict() + .optional(); + +const ToolsMediaAudioSchema = z + .object({ + enabled: z.boolean().optional(), + preferredModel: z.string().trim().min(1).optional(), scope: MediaUnderstandingScopeSchema, maxBytes: z.number().int().positive().optional(), maxChars: z.number().int().positive().optional(), ...MediaUnderstandingRuntimeFields, attachments: MediaUnderstandingAttachmentsSchema, - models: z.array(MediaUnderstandingModelSchema).optional(), echoTranscript: z.boolean().optional(), echoFormat: z.string().optional(), }) @@ -1030,9 +1026,9 @@ export const ToolsMediaSchema = z .object({ models: z.array(MediaUnderstandingModelSchema).optional(), concurrency: z.number().int().positive().optional(), - image: ToolsMediaUnderstandingSchema.optional(), - audio: ToolsMediaUnderstandingSchema.optional(), - video: ToolsMediaUnderstandingSchema.optional(), + image: ToolsMediaCapabilitySchema.optional(), + audio: ToolsMediaAudioSchema.optional(), + video: ToolsMediaCapabilitySchema.optional(), }) .strict() .optional(); diff --git a/src/config/zod-schema.gateway.ts b/src/config/zod-schema.gateway.ts index 7abc7e263f87..17a7033a52d7 100644 --- a/src/config/zod-schema.gateway.ts +++ b/src/config/zod-schema.gateway.ts @@ -1,8 +1,4 @@ import { z } from "zod"; -import { - isValidControlUiChatMessageMaxWidth, - normalizeControlUiChatMessageMaxWidth, -} from "./control-ui-css.js"; import { SecretInputSchema } from "./zod-schema.core.js"; import { GatewayRemoteConfigSchema, @@ -36,18 +32,8 @@ export const GatewayConfigSchema = z .union([z.literal("strict"), z.literal("scripts"), z.literal("trusted")]) .optional(), allowExternalEmbedUrls: z.boolean().optional(), - chatMessageMaxWidth: z - .string() - .transform((value) => normalizeControlUiChatMessageMaxWidth(value)) - .refine((value) => isValidControlUiChatMessageMaxWidth(value), { - message: - "Expected a CSS width value such as 960px, 82%, min(1280px, 82%), or calc(100% - 2rem)", - }) - .optional(), allowedOrigins: z.array(z.string()).optional(), dangerouslyAllowHostHeaderOriginFallback: z.boolean().optional(), - allowInsecureAuth: z.boolean().optional(), - dangerouslyDisableDeviceAuth: z.boolean().optional(), }) .optional(), terminal: z @@ -113,9 +99,7 @@ export const GatewayConfigSchema = z remote: GatewayRemoteConfigSchema, reload: z .strictObject({ - mode: z - .union([z.literal("off"), z.literal("restart"), z.literal("hot"), z.literal("hybrid")]) - .optional(), + mode: z.union([z.literal("off"), z.literal("hybrid")]).optional(), }) .optional(), tls: z @@ -227,13 +211,13 @@ export const GatewayConfigSchema = z enabled: z.boolean().optional(), }) .optional(), - skills: z + allowSkills: z.boolean().optional(), + commands: z .strictObject({ - enabled: z.boolean().optional(), + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), }) .optional(), - allowCommands: z.array(z.string()).optional(), - denyCommands: z.array(z.string()).optional(), }) .optional(), }) diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts index 121a5b96bc5a..39c13c5756ae 100644 --- a/src/config/zod-schema.hooks.ts +++ b/src/config/zod-schema.hooks.ts @@ -1,7 +1,6 @@ // Defines hook-related Zod schema fragments for config parsing. import path from "node:path"; import { z } from "zod"; -import { InstallRecordShape } from "./zod-schema.installs.js"; import { sensitive } from "./zod-schema.sensitive.js"; function isSafeRelativeModulePath(raw: string): boolean { @@ -87,13 +86,6 @@ const HookConfigSchema = z // whole config invalid (which triggers doctor/best-effort loads). .passthrough(); -const HookInstallRecordSchema = z - .object({ - ...InstallRecordShape, - hooks: z.array(z.string()).optional(), - }) - .strict(); - export const InternalHooksSchema = z .object({ enabled: z.boolean().optional(), @@ -105,7 +97,6 @@ export const InternalHooksSchema = z }) .strict() .optional(), - installs: z.record(z.string(), HookInstallRecordSchema).optional(), }) .strict() .optional(); diff --git a/src/config/zod-schema.installs.ts b/src/config/zod-schema.installs.ts index 956608efee5e..c57d1059936f 100644 --- a/src/config/zod-schema.installs.ts +++ b/src/config/zod-schema.installs.ts @@ -12,7 +12,7 @@ const InstallSourceSchema = z.union([ const PluginInstallSourceSchema = z.union([InstallSourceSchema, z.literal("marketplace")]); /** Zod object shape for persisted generic install records. */ -export const InstallRecordShape = { +const InstallRecordShape = { source: InstallSourceSchema, spec: z.string().optional(), sourcePath: z.string().optional(), diff --git a/src/config/zod-schema.marketplaces.test.ts b/src/config/zod-schema.marketplaces.test.ts deleted file mode 100644 index 28805468b973..000000000000 --- a/src/config/zod-schema.marketplaces.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -// Verifies marketplace feed and source profile config parsing. -import { expectDefined } from "@openclaw/normalization-core"; -import { describe, expect, it } from "vitest"; -import { OpenClawSchema } from "./zod-schema.js"; - -const ACME_ROOT_PUBLIC_KEY = "lHseHhZT8bJYRcI-1M9n7BBeC6trLjN1ccXKufO8WpY"; -const ACME_BACKUP_PUBLIC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; -const ACME_ROOT_PUBLIC_KEY_PEM = [ - "-----BEGIN PUBLIC KEY-----", - "MCowBQYDK2VwAyEAlHseHhZT8bJYRcI+1M9n7BBeC6trLjN1ccXKufO8WpY=", - "-----END PUBLIC KEY-----", -].join("\n"); - -function expectMarketplacesConfig(value: unknown) { - const result = OpenClawSchema.safeParse(value); - if (!result.success) { - throw new Error(JSON.stringify(result.error.issues, null, 2)); - } - return result.data.marketplaces; -} - -describe("OpenClawSchema marketplaces config", () => { - it("accepts hosted feed and local source profiles", () => { - const marketplaces = expectMarketplacesConfig({ - marketplaces: { - feeds: { - "clawhub-public": { - url: "https://clawhub.ai/v1/feeds/plugins", - verification: { mode: "unsigned" }, - }, - acme: { - url: "https://packages.acme.example/openclaw/feed", - verification: { - mode: "signed", - keys: [ - { - keyId: "acme-root-2026", - publicKey: ACME_ROOT_PUBLIC_KEY, - }, - { - keyId: "acme-backup-2026", - publicKey: ACME_BACKUP_PUBLIC_KEY, - }, - ], - threshold: 2, - }, - }, - }, - sources: { - "public-clawhub": { type: "clawhub" }, - "public-npm": { type: "npm" }, - "acme-npm": { type: "npm" }, - "acme-clawhub": { type: "clawhub" }, - "acme-git": { type: "git" }, - }, - }, - }); - - const acmeFeed = expectDefined(marketplaces?.feeds?.acme, "Acme marketplace feed"); - expect(acmeFeed.url).toBe("https://packages.acme.example/openclaw/feed"); - expect(acmeFeed.verification).toEqual({ - mode: "signed", - keys: [ - { - keyId: "acme-root-2026", - publicKey: ACME_ROOT_PUBLIC_KEY, - }, - { - keyId: "acme-backup-2026", - publicKey: ACME_BACKUP_PUBLIC_KEY, - }, - ], - threshold: 2, - }); - expect( - expectDefined( - marketplaces?.sources?.["acme-git"], - 'marketplaces?.sources?.["acme-git"] test invariant', - ).type, - ).toBe("git"); - }); - - it.each([ - "http://packages.acme.example/openclaw/feed", - "https://token@packages.acme.example/openclaw/feed", - "https://user:pass@packages.acme.example/openclaw/feed", - "https://packages.acme.example/openclaw/feed?token=secret", - "https://packages.acme.example/openclaw/feed#access-token", - "not a url", - ])("rejects invalid or auth-bearing hosted feed URL %s without throwing", (url) => { - expect(() => - OpenClawSchema.safeParse({ - marketplaces: { - feeds: { acme: { url } }, - }, - }), - ).not.toThrow(); - const result = OpenClawSchema.safeParse({ - marketplaces: { - feeds: { acme: { url } }, - }, - }); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues.map((issue) => issue.path.join("."))).toContain( - "marketplaces.feeds.acme.url", - ); - } - }); - - it("rejects refresh and auth until loader enforcement exists", () => { - expect( - OpenClawSchema.safeParse({ - marketplaces: { - feeds: { - acme: { - url: "https://packages.acme.example/openclaw/feed", - auth: { scheme: "bearer", secret: "token" }, - }, - }, - }, - }).success, - ).toBe(false); - expect( - OpenClawSchema.safeParse({ - marketplaces: { - feeds: { - acme: { - url: "https://packages.acme.example/openclaw/feed", - refresh: { onStartup: "if-stale" }, - }, - }, - }, - }).success, - ).toBe(false); - }); - - it("rejects signed feed verification without usable local trust anchors", () => { - for (const verification of [ - { mode: "signed" }, - { mode: "signed", keys: [] }, - { mode: "signed", keys: [{ keyId: "", publicKey: "abc" }] }, - { mode: "signed", keys: [{ keyId: "acme-root", publicKey: "" }] }, - { mode: "signed", keys: [{ keyId: "acme-root", publicKey: "abc" }] }, - { mode: "signed", keys: [{ keyId: "acme-root", publicKey: `${ACME_ROOT_PUBLIC_KEY}!` }] }, - { - mode: "signed", - keys: [ - { - keyId: "acme-root", - publicKey: - "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END PRIVATE KEY-----", - }, - ], - }, - { - mode: "signed", - keys: [{ keyId: "acme-root", publicKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" }], - threshold: 0, - }, - { - mode: "signed", - keys: [{ keyId: "acme-root", publicKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" }], - threshold: 2, - }, - ]) { - expect( - OpenClawSchema.safeParse({ - marketplaces: { - feeds: { - acme: { - url: "https://packages.acme.example/openclaw/feed", - verification, - }, - }, - }, - }).success, - ).toBe(false); - } - }); - - it("rejects duplicate signed feed trust anchors", () => { - for (const verification of [ - { - mode: "signed", - keys: [ - { keyId: "acme-root", publicKey: ACME_ROOT_PUBLIC_KEY }, - { keyId: "acme-root", publicKey: ACME_BACKUP_PUBLIC_KEY }, - ], - threshold: 2, - }, - { - mode: "signed", - keys: [ - { keyId: "acme-root-a", publicKey: ACME_ROOT_PUBLIC_KEY }, - { keyId: "acme-root-b", publicKey: ACME_ROOT_PUBLIC_KEY }, - ], - threshold: 2, - }, - { - mode: "signed", - keys: [ - { keyId: "acme-root-a", publicKey: ACME_ROOT_PUBLIC_KEY }, - { keyId: "acme-root-b", publicKey: ACME_ROOT_PUBLIC_KEY_PEM }, - ], - threshold: 2, - }, - ]) { - const result = OpenClawSchema.safeParse({ - marketplaces: { - feeds: { - acme: { - url: "https://packages.acme.example/openclaw/feed", - verification, - }, - }, - }, - }); - - expect(result.success).toBe(false); - } - }); - - it("rejects unknown source profile types", () => { - const result = OpenClawSchema.safeParse({ - marketplaces: { - sources: { acme: { type: "container" } }, - }, - }); - - expect(result.success).toBe(false); - }); - - it("rejects source endpoints until installer resolution can enforce them", () => { - const result = OpenClawSchema.safeParse({ - marketplaces: { - sources: { - "acme-npm": { type: "npm", registry: "https://packages.acme.example/npm/" }, - "acme-clawhub": { type: "clawhub", baseUrl: "https://packages.acme.example/clawhub/" }, - }, - }, - }); - - expect(result.success).toBe(false); - }); -}); diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index f1d895c0af2a..50bd6f33ec97 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -27,7 +27,6 @@ import { DmPolicySchema, ExecutableTokenSchema, GroupPolicySchema, - HexColorSchema, MSTeamsReplyStyleSchema, ProviderCommandsSchema, SecretInputSchema, @@ -446,18 +445,6 @@ const DiscordGuildSchema = buildGroupEntrySchema( { omit: ["enabled", "skills", "allowFrom", "systemPrompt"] }, ); -const DiscordUiSchema = z - .object({ - components: z - .object({ - accentColor: HexColorSchema.optional(), - }) - .strict() - .optional(), - }) - .strict() - .optional(); - const DiscordVoiceAutoJoinSchema = z .object({ guildId: z.string().min(1), @@ -608,7 +595,6 @@ const DiscordAccountSchema = z }) .strict() .optional(), - ui: DiscordUiSchema, slashCommand: z .object({ ephemeral: z.boolean().optional(), @@ -625,7 +611,6 @@ const DiscordAccountSchema = z }) .strict() .optional(), - subagentProgress: z.boolean().optional(), intents: z .object({ presence: z.boolean().optional(), @@ -653,9 +638,6 @@ const DiscordAccountSchema = z enabled: z.boolean().optional(), intervalMs: z.number().int().positive().optional(), minUpdateIntervalMs: z.number().int().positive().optional(), - healthyText: z.string().optional(), - degradedText: z.string().optional(), - exhaustedText: z.string().optional(), }) .strict() .optional(), @@ -820,14 +802,6 @@ const DirectGroupReplyToModeByChatTypeSchema = z }) .strict(); -const SlackSocketModeSchema = z - .object({ - clientPingTimeout: z.number().int().positive().optional(), - serverPingTimeout: z.number().int().positive().optional(), - pingPongLoggingEnabled: z.boolean().optional(), - }) - .strict(); - const SlackRelaySchema = z .object({ url: z.string().optional(), @@ -845,10 +819,9 @@ const SlackAccountSchema = z capabilities: SlackCapabilitiesSchema.optional(), streaming: SlackStreamingConfigSchema.optional(), }), - identity: SlackIdentitySchema.default("bot"), + postAs: SlackIdentitySchema.default("bot"), mode: z.enum(["socket", "http", "relay"]).optional(), enterpriseOrgInstall: z.boolean().optional(), - socketMode: SlackSocketModeSchema.optional(), relay: SlackRelaySchema.optional(), signingSecret: SecretInputSchema.optional().register(sensitive), webhookPath: z.string().optional(), @@ -901,10 +874,10 @@ const SlackAccountSchema = z }) .strict(); -// Account entries leave identity unset to inherit the top-level default. DM allowlist +// Account entries leave postAs unset to inherit the top-level default. DM allowlist // validation stays at SlackConfigSchema so entries can also inherit top-level allowFrom. const SlackAccountEntrySchema = SlackAccountSchema.extend({ - identity: SlackIdentitySchema.optional(), + postAs: SlackIdentitySchema.optional(), }); export const SlackConfigSchema = SlackAccountSchema.safeExtend({ @@ -1031,8 +1004,6 @@ const SignalAccountSchemaBase = z accountUuid: z.string().optional(), configPath: z.string().optional(), httpUrl: z.string().optional(), - httpHost: z.string().optional(), - httpPort: z.number().int().positive().optional(), cliPath: ExecutableTokenSchema.optional(), autoStart: z.boolean().optional(), startupTimeoutMs: z.number().int().min(1000).max(120000).optional(), @@ -1153,7 +1124,6 @@ const IMessageAccountSchemaBase = z probeTimeoutMs: z.number().int().positive().optional(), sendReadReceipts: ChannelSendReadReceiptsSchema, ...buildChannelReactionShape({ notificationModes: ["off", "own", "all"] }), - coalesceSameSenderDms: z.boolean().optional(), catchup: z .object({ enabled: z.boolean().optional(), diff --git a/src/config/zod-schema.providers-googlechat.ts b/src/config/zod-schema.providers-googlechat.ts index 455706912284..907ea880622d 100644 --- a/src/config/zod-schema.providers-googlechat.ts +++ b/src/config/zod-schema.providers-googlechat.ts @@ -47,7 +47,6 @@ const GoogleChatAccountSchemaBase = z .union([z.string(), z.record(z.string(), z.unknown()), SecretRefSchema]) .optional() .register(sensitive), - serviceAccountRef: SecretRefSchema.optional().register(sensitive), serviceAccountFile: z.string().optional(), audienceType: z.enum(["app-url", "project-number"]).optional(), audience: z.string().optional(), diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 4ee5b9acdbb1..d4081849f06f 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -25,15 +25,6 @@ const WhatsAppDirectEntrySchema = z const WhatsAppDirectSchema = z.record(z.string(), WhatsAppDirectEntrySchema).optional(); -const WhatsAppAckReactionSchema = z - .object({ - emoji: z.string().optional(), - direct: z.boolean().optional().default(true), - group: z.enum(["always", "mentions", "never"]).optional().default("mentions"), - }) - .strict() - .optional(); - const WhatsAppPluginHooksSchema = z .object({ messageReceived: z.boolean().optional(), @@ -52,13 +43,11 @@ function buildWhatsAppCommonShape(params: { useDefaults: boolean }) { mediaMaxMb: z.number().int().positive().optional(), }), sendReadReceipts: ChannelSendReadReceiptsSchema, - messagePrefix: z.string().optional(), selfChatMode: z.boolean().optional(), groups: WhatsAppGroupsSchema, direct: WhatsAppDirectSchema, ...buildChannelReactionShape({ reactionLevels: ["off", "ack", "minimal", "extensive"], - ackReaction: WhatsAppAckReactionSchema, }), debounceMs: params.useDefaults ? z.number().int().nonnegative().optional().default(0) diff --git a/src/config/zod-schema.proxy.test.ts b/src/config/zod-schema.proxy.test.ts index b3c2200fbe9a..316fe9c962aa 100644 --- a/src/config/zod-schema.proxy.test.ts +++ b/src/config/zod-schema.proxy.test.ts @@ -54,7 +54,6 @@ describe("ProxyConfigSchema", () => { it("accepts HTTPS proxy URLs for TLS-to-proxy endpoints", () => { const result = ProxyConfigSchema.parse({ - enabled: true, proxyUrl: "https://proxy.example.com:8443", }); @@ -71,10 +70,9 @@ describe("ProxyConfigSchema", () => { it("rejects proxyUrl values that are not HTTP forward proxies", () => { const socksIssues = expectProxyConfigFailure({ - enabled: true, proxyUrl: "socks5://127.0.0.1", }); - const invalidUrlIssues = expectProxyConfigFailure({ enabled: true, proxyUrl: "not-a-url" }); + const invalidUrlIssues = expectProxyConfigFailure({ proxyUrl: "not-a-url" }); expect(socksIssues.map((issue) => issue.path.join("."))).toContain("proxyUrl"); expect(invalidUrlIssues.map((issue) => issue.path.join("."))).toContain("proxyUrl"); }); @@ -87,7 +85,6 @@ describe("ProxyConfigSchema", () => { it("rejects unknown proxy TLS keys", () => { expect(() => ProxyConfigSchema.parse({ - enabled: true, proxyUrl: "https://proxy.example.com:8443", tls: { ca: "/etc/openclaw/proxy-ca.pem", @@ -96,7 +93,7 @@ describe("ProxyConfigSchema", () => { ).toThrow(); }); - it("accepts enabled: false to disable the proxy", () => { + it("accepts enabled: false as an explicit opt-out", () => { const result = ProxyConfigSchema.parse({ enabled: false }); expect(result?.enabled).toBe(false); }); diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts index dcbd7f7c382d..b9c790e354b7 100644 --- a/src/config/zod-schema.root-shape.ts +++ b/src/config/zod-schema.root-shape.ts @@ -1,6 +1,10 @@ import { normalizeStringifiedOptionalString } from "@openclaw/normalization-core/string-coerce"; import { z } from "zod"; import { parseDurationMs } from "../cli/parse-duration.js"; +import { + isValidControlUiChatMessageMaxWidth, + normalizeControlUiChatMessageMaxWidth, +} from "./control-ui-css.js"; import { SilentReplyPolicyConfigSchema } from "./zod-schema.agent-defaults.js"; import { ToolsSchema } from "./zod-schema.agent-runtime.js"; import { AgentsSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js"; @@ -12,6 +16,7 @@ import { ModelsConfigSchema, SecretInputSchema, SecretsConfigSchema, + TtsConfigSchema, } from "./zod-schema.core.js"; import { GatewayConfigSchema } from "./zod-schema.gateway.js"; import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js"; @@ -19,16 +24,13 @@ import { BrowserSnapshotDefaultsSchema } from "./zod-schema.node-host.js"; import { ProxyConfigSchema } from "./zod-schema.proxy.js"; import { AccessGroupsSchema, - CommitmentsSchema, LoggingLevelSchema, - MarketplacesSchema, McpConfigSchema, MemorySchema, NodeHostSchema, PluginEntrySchema, SecuritySchema, SkillEntrySchema, - SystemAgentSchema, TalkSchema, } from "./zod-schema.root-support.js"; import { sensitive } from "./zod-schema.sensitive.js"; @@ -39,24 +41,6 @@ export const OpenClawSchemaShape = { meta: z .strictObject({ lastTouchedVersion: z.string().optional(), - // Accept any string unchanged (backwards-compatible) and coerce numeric Unix - // timestamps to ISO strings (agent file edits may write Date.now()). - lastTouchedAt: z - .union([ - z.string(), - z - .number() - .transform((n, ctx) => { - const d = new Date(n); - if (Number.isNaN(d.getTime())) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid timestamp" }); - return z.NEVER; - } - return d.toISOString(); - }) - .pipe(z.string()), - ]) - .optional(), migrations: z .strictObject({ modelPolicyAllowlist: z.literal(true).optional(), @@ -74,7 +58,7 @@ export const OpenClawSchemaShape = { .optional(), vars: z.record(z.string(), z.string()).optional(), }) - .catchall(z.string()) + .strict() .optional(), wizard: z .strictObject({ @@ -111,37 +95,10 @@ export const OpenClawSchemaShape = { .optional(), sampleRate: z.number().min(0).max(1).optional(), flushIntervalMs: z.number().int().nonnegative().optional(), - captureContent: z - .union([ - z.boolean(), - z.strictObject({ - enabled: z.boolean().optional(), - inputMessages: z.boolean().optional(), - outputMessages: z.boolean().optional(), - toolInputs: z.boolean().optional(), - toolOutputs: z.boolean().optional(), - systemPrompt: z.boolean().optional(), - toolDefinitions: z.boolean().optional(), - }), - ]) - .optional(), + captureContent: z.boolean().optional(), }) .optional(), - cacheTrace: z - .strictObject({ - enabled: z.boolean().optional(), - filePath: z.string().optional(), - includeMessages: z.boolean().optional(), - includePrompt: z.boolean().optional(), - includeSystem: z.boolean().optional(), - }) - .optional(), - }) - .optional(), - audit: z - .strictObject({ - enabled: z.boolean().optional(), - messages: z.union([z.literal("off"), z.literal("direct"), z.literal("all")]).optional(), + cacheTrace: z.strictObject({ enabled: z.boolean().optional() }).optional(), }) .optional(), logging: z @@ -150,25 +107,16 @@ export const OpenClawSchemaShape = { file: z.string().optional(), maxFileBytes: z.number().int().positive().optional(), consoleLevel: LoggingLevelSchema.optional(), - consoleStyle: z - .union([z.literal("pretty"), z.literal("compact"), z.literal("json")]) - .optional(), - redactSensitive: z.union([z.literal("off"), z.literal("tools")]).optional(), + consoleStyle: z.union([z.literal("pretty"), z.literal("json")]).optional(), redactPatterns: z.array(z.string()).optional(), - }) - .optional(), - cli: z - .strictObject({ - banner: z + audit: z .strictObject({ - taglineMode: z - .union([z.literal("random"), z.literal("default"), z.literal("off")]) - .optional(), + enabled: z.boolean().optional(), + messages: z.union([z.literal("off"), z.literal("direct"), z.literal("all")]).optional(), }) .optional(), }) .optional(), - systemAgent: SystemAgentSchema, update: z .strictObject({ channel: z @@ -193,7 +141,6 @@ export const OpenClawSchemaShape = { allowSystemProfileImport: z.boolean().optional(), evaluateEnabled: z.boolean().optional(), cdpUrl: z.string().optional(), - color: z.string().optional(), executablePath: z.string().optional(), headless: z.boolean().optional(), noSandbox: z.boolean().optional(), @@ -204,7 +151,6 @@ export const OpenClawSchemaShape = { .strictObject({ dangerouslyAllowPrivateNetwork: z.boolean().optional(), allowedHostnames: z.array(z.string()).optional(), - hostnameAllowlist: z.array(z.string()).optional(), }) .optional(), profiles: z @@ -228,7 +174,6 @@ export const OpenClawSchemaShape = { headless: z.boolean().optional(), executablePath: z.string().optional(), attachOnly: z.boolean().optional(), - color: HexColorSchema, }) .refine( (value) => @@ -287,13 +232,20 @@ export const OpenClawSchemaShape = { chatSendShortcut: z.union([z.literal("enter"), z.literal("modifier-enter")]).optional(), chatFollowUpMode: z.union([z.literal("steer"), z.literal("queue")]).optional(), sidebarEntries: z.array(z.string()).optional(), + chatMessageMaxWidth: z + .string() + .transform((value) => normalizeControlUiChatMessageMaxWidth(value)) + .refine((value) => isValidControlUiChatMessageMaxWidth(value), { + message: + "Expected a CSS width value such as 960px, 82%, min(1280px, 82%), or calc(100% - 2rem)", + }) + .optional(), sidebarLiveActivity: z.boolean().optional(), }) .optional(), }) .optional(), secrets: SecretsConfigSchema, - marketplaces: MarketplacesSchema, auth: z .strictObject({ profiles: z @@ -349,9 +301,8 @@ export const OpenClawSchemaShape = { security: SecuritySchema, bindings: BindingsSchema, broadcast: BroadcastSchema, - media: z + attachments: z .strictObject({ - preserveFilenames: z.boolean().optional(), ttlHours: z .number() .int() @@ -361,13 +312,13 @@ export const OpenClawSchemaShape = { }) .optional(), messages: MessagesSchema, + tts: TtsConfigSchema, commands: CommandsSchema, approvals: ApprovalsSchema, session: SessionSchema, cron: z .strictObject({ enabled: z.boolean().optional(), - store: z.string().optional(), triggers: z .strictObject({ enabled: z.boolean().optional(), @@ -383,14 +334,8 @@ export const OpenClawSchemaShape = { includeSkipped: z.boolean().optional(), mode: z.enum(["announce", "webhook"]).optional(), accountId: z.string().optional(), - }) - .optional(), - failureDestination: z - .strictObject({ channel: z.string().optional(), to: z.string().optional(), - accountId: z.string().optional(), - mode: z.enum(["announce", "webhook"]).optional(), }) .optional(), }) @@ -428,7 +373,6 @@ export const OpenClawSchemaShape = { .optional(), }) .optional(), - commitments: CommitmentsSchema, hooks: z .strictObject({ enabled: z.boolean().optional(), @@ -445,17 +389,11 @@ export const OpenClawSchemaShape = { internal: InternalHooksSchema, }) .optional(), - web: z - .strictObject({ - enabled: z.boolean().optional(), - }) - .optional(), channels: ChannelsSchema, discovery: z .strictObject({ wideArea: z .strictObject({ - enabled: z.boolean().optional(), domain: z.string().optional(), }) .optional(), @@ -533,7 +471,6 @@ export const OpenClawSchemaShape = { }) .optional(), entries: z.record(z.string(), PluginEntrySchema).optional(), - bundledDiscovery: z.enum(["compat", "allowlist"]).optional(), }) .optional(), surfaces: z diff --git a/src/config/zod-schema.root-support.ts b/src/config/zod-schema.root-support.ts index 3229614e47be..7f630c408e57 100644 --- a/src/config/zod-schema.root-support.ts +++ b/src/config/zod-schema.root-support.ts @@ -1,8 +1,8 @@ import { isHttpsUrl, isHttpUrl } from "@openclaw/net-policy/url-protocol"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { z } from "zod"; -import { base64UrlDecode, normalizeEd25519PublicKeyBase64Url } from "../infra/ed25519-signature.js"; import type { GatewayRemoteConfig } from "./types.gateway.js"; +import { MemorySearchSchema } from "./zod-schema.agent-runtime.js"; import { SecretInputSchema } from "./zod-schema.core.js"; import { NodeHostAgentRunsSchema } from "./zod-schema.node-host.js"; import { sensitive } from "./zod-schema.sensitive.js"; @@ -71,8 +71,6 @@ export const SecuritySchema = z env: z.record(z.string(), z.string().register(sensitive)).optional(), passEnv: z.array(z.string()).optional(), trustedDirs: z.array(z.string()).optional(), - allowInsecurePath: z.boolean().optional(), - allowSymlinkCommand: z.boolean().optional(), }) .optional(), }) @@ -110,19 +108,6 @@ const MemoryQmdSessionSchema = z.strictObject({ retentionDays: z.number().int().nonnegative().optional(), }); -const MemoryQmdUpdateSchema = z.strictObject({ - interval: z.string().optional(), - debounceMs: z.number().int().nonnegative().optional(), - onBoot: z.boolean().optional(), - startup: z.enum(["off", "idle", "immediate"]).optional(), - startupDelayMs: z.number().int().nonnegative().optional(), - waitForBootSync: z.boolean().optional(), - embedInterval: z.string().optional(), - commandTimeoutMs: z.number().int().nonnegative().optional(), - updateTimeoutMs: z.number().int().nonnegative().optional(), - embedTimeoutMs: z.number().int().nonnegative().optional(), -}); - const MemoryQmdLimitsSchema = z.strictObject({ maxResults: z.number().int().positive().optional(), maxSnippetChars: z.number().int().positive().optional(), @@ -130,12 +115,6 @@ const MemoryQmdLimitsSchema = z.strictObject({ timeoutMs: z.number().int().nonnegative().optional(), }); -const MemoryQmdMcporterSchema = z.strictObject({ - enabled: z.boolean().optional(), - serverName: z.string().optional(), - startDaemon: z.boolean().optional(), -}); - export const LoggingLevelSchema = z.union([ z.literal("silent"), z.literal("fatal"), @@ -148,14 +127,12 @@ export const LoggingLevelSchema = z.union([ const MemoryQmdSchema = z.strictObject({ command: z.string().optional(), - mcporter: MemoryQmdMcporterSchema.optional(), searchMode: z.union([z.literal("query"), z.literal("search"), z.literal("vsearch")]).optional(), rerank: z.boolean().optional(), searchTool: z.string().trim().min(1).optional(), includeDefaultMemory: z.boolean().optional(), paths: z.array(MemoryQmdPathSchema).optional(), sessions: MemoryQmdSessionSchema.optional(), - update: MemoryQmdUpdateSchema.optional(), limits: MemoryQmdLimitsSchema.optional(), scope: SessionSendPolicySchema.optional(), }); @@ -164,6 +141,7 @@ export const MemorySchema = z .strictObject({ backend: z.union([z.literal("builtin"), z.literal("qmd")]).optional(), citations: z.union([z.literal("auto"), z.literal("on"), z.literal("off")]).optional(), + search: MemorySearchSchema, qmd: MemoryQmdSchema.optional(), }) .optional(); @@ -311,7 +289,6 @@ const McpServerSchema = z ) .optional(), cwd: z.string().optional(), - workingDirectory: z.string().optional(), url: HttpUrlSchema.optional(), transport: z .union([z.literal("stdio"), z.literal("sse"), z.literal("streamable-http")]) @@ -325,7 +302,6 @@ const McpServerSchema = z connectionTimeoutMs: z.number().finite().positive().optional(), requestTimeoutMs: z.number().finite().positive().optional(), supportsParallelToolCalls: z.boolean().optional(), - supports_parallel_tool_calls: z.boolean().optional(), auth: z.literal("oauth").optional(), oauth: z .strictObject({ @@ -336,11 +312,8 @@ const McpServerSchema = z }) .optional(), sslVerify: z.boolean().optional(), - ssl_verify: z.boolean().optional(), clientCert: z.string().optional(), - client_cert: z.string().optional(), clientKey: z.string().optional(), - client_key: z.string().optional(), toolFilter: z .strictObject({ include: z.array(z.string().trim().min(1)).min(1).optional(), @@ -359,14 +332,22 @@ const McpServerSchema = z .min(1) .optional(), defaultToolsApprovalMode: z.enum(["auto", "prompt", "approve"]).optional(), - default_tools_approval_mode: z.enum(["auto", "prompt", "approve"]).optional(), }) .optional(), }) .superRefine((data, ctx) => { // This schema is .catchall(z.unknown()) (open-world server options), so // unknown keys survive into this refine; retired aliases are rejected here. - for (const key of ["connectTimeout", "connect_timeout", "timeout"] as const) { + for (const key of [ + "connectTimeout", + "connect_timeout", + "timeout", + "workingDirectory", + "supports_parallel_tool_calls", + "ssl_verify", + "client_cert", + "client_key", + ] as const) { if (Object.hasOwn(data, key)) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -374,6 +355,14 @@ const McpServerSchema = z }); } } + const codex = data.codex; + if (codex && Object.hasOwn(codex, "default_tools_approval_mode")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["codex", "default_tools_approval_mode"], + message: 'Unrecognized key: "default_tools_approval_mode"', + }); + } if (Object.hasOwn(data, "disabled")) { const disabled = Reflect.get(data, "disabled") as unknown; const replacement = @@ -457,131 +446,3 @@ export const NodeHostSchema = z .optional(), }) .optional(); - -export const SystemAgentSchema = z - .strictObject({ - rescue: z - .strictObject({ - enabled: z.union([z.literal("auto"), z.boolean()]).optional(), - ownerDmOnly: z.boolean().optional(), - pendingTtlMinutes: z.number().int().positive().optional(), - }) - .optional(), - }) - .optional(); - -function isPlainHttpsUrl(value: string): boolean { - try { - const url = new URL(value); - return url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash; - } catch { - return false; - } -} - -function isEd25519PublicKeyConfig(value: string): boolean { - if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value)) { - return false; - } - if (!value.includes("BEGIN") && !/^[A-Za-z0-9_-]{43}$/.test(value)) { - return false; - } - try { - const normalized = normalizeEd25519PublicKeyBase64Url(value); - return normalized ? base64UrlDecode(normalized).length === 32 : false; - } catch { - return false; - } -} - -const MarketplaceFeedTrustedPublicKeySchema = z.strictObject({ - keyId: z.string().trim().min(1), - publicKey: z - .string() - .trim() - .min(1) - .refine( - (value) => isEd25519PublicKeyConfig(value), - "Expected Ed25519 public key as PEM or raw base64url", - ), -}); - -const MarketplaceVerificationSchema = z.union([ - z.strictObject({ - mode: z.literal("unsigned"), - }), - z - .strictObject({ - mode: z.literal("signed"), - keys: z.array(MarketplaceFeedTrustedPublicKeySchema).min(1), - threshold: z.number().int().positive().optional(), - }) - .superRefine((value, ctx) => { - const seenKeyIds = new Map(); - const seenPublicKeys = new Map(); - value.keys.forEach((key, index) => { - const previousKeyIdIndex = seenKeyIds.get(key.keyId); - if (previousKeyIdIndex !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["keys", index, "keyId"], - message: "Signed marketplace feed publisher key IDs must be unique", - }); - } else { - seenKeyIds.set(key.keyId, index); - } - const normalizedPublicKey = normalizeEd25519PublicKeyBase64Url(key.publicKey); - if (!normalizedPublicKey) { - return; - } - const previousPublicKeyIndex = seenPublicKeys.get(normalizedPublicKey); - if (previousPublicKeyIndex !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["keys", index, "publicKey"], - message: "Signed marketplace feed publisher public keys must be unique", - }); - } else { - seenPublicKeys.set(normalizedPublicKey, index); - } - }); - if (value.threshold !== undefined && value.threshold > value.keys.length) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["threshold"], - message: "Signed marketplace feed threshold cannot exceed configured key count", - }); - } - }), -]); - -const MarketplaceFeedProfileSchema = z.strictObject({ - url: z - .string() - .url() - .refine( - (value) => isPlainHttpsUrl(value), - "Expected https:// URL without credentials, query, or fragment", - ), - verification: MarketplaceVerificationSchema.optional(), -}); - -const MarketplaceSourceProfileSchema = z.union([ - z.strictObject({ type: z.literal("npm") }), - z.strictObject({ type: z.literal("clawhub") }), - z.strictObject({ type: z.literal("git") }), -]); - -export const MarketplacesSchema = z - .strictObject({ - feeds: z.record(z.string().min(1), MarketplaceFeedProfileSchema).optional(), - sources: z.record(z.string().min(1), MarketplaceSourceProfileSchema).optional(), - }) - .optional(); - -export const CommitmentsSchema = z - .strictObject({ - enabled: z.boolean().optional(), - maxPerDay: z.number().int().positive().optional(), - }) - .optional(); diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 079ab9cb90e1..f78c151bc3ee 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -10,11 +10,8 @@ import { InboundDebounceSchema, NativeCommandsSettingSchema, QueueSchema, - TypingModeSchema, - TtsConfigSchema, VisibleRepliesSchema, } from "./zod-schema.core.js"; -import { sensitive } from "./zod-schema.sensitive.js"; const SessionResetConfigSchema = z .object({ @@ -58,7 +55,6 @@ export const SessionSchema = z .optional(), identityLinks: z.record(z.string(), z.array(z.string())).optional(), resetTriggers: z.array(z.string()).optional(), - idleMinutes: z.number().int().positive().optional(), reset: SessionResetConfigSchema.optional(), resetByType: z .object({ @@ -70,7 +66,6 @@ export const SessionSchema = z .optional(), resetByChannel: z.record(z.string(), SessionResetConfigSchema).optional(), store: z.string().optional(), - typingMode: TypingModeSchema.optional(), mainKey: z.string().optional(), sendPolicy: SessionSendPolicySchema.optional(), threadBindings: z @@ -143,33 +138,13 @@ export const MessagesSchema = z ackReactionScope: z .enum(["group-mentions", "group-all", "direct", "all", "off", "none"]) .optional(), - removeAckAfterReply: z.boolean().optional(), statusReactions: z .object({ enabled: z.boolean().optional(), - emojis: z - .object({ - queued: z.string().optional(), - thinking: z.string().optional(), - tool: z.string().optional(), - coding: z.string().optional(), - web: z.string().optional(), - deploy: z.string().optional(), - build: z.string().optional(), - concierge: z.string().optional(), - done: z.string().optional(), - error: z.string().optional(), - stallSoft: z.string().optional(), - stallHard: z.string().optional(), - compacting: z.string().optional(), - }) - .strict() - .optional(), }) .strict() .optional(), suppressToolErrors: z.boolean().optional(), - tts: TtsConfigSchema, }) .strict() .optional(); @@ -186,10 +161,7 @@ export const CommandsSchema = z plugins: z.boolean().optional(), debug: z.boolean().optional(), restart: z.boolean().optional().default(true), - useAccessGroups: z.boolean().optional(), ownerAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), - ownerDisplay: z.enum(["raw", "hash"]).optional().default("raw"), - ownerDisplaySecret: z.string().optional().register(sensitive), allowFrom: ElevatedAllowFromSchema.optional(), }) .strict() @@ -200,6 +172,5 @@ export const CommandsSchema = z native: "auto", nativeSkills: "auto", restart: true, - ownerDisplay: "raw", }) as const, ); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 4280134c7cd8..411f436fd267 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -13,7 +13,9 @@ function installZodDefaultLocale(): void { installZodDefaultLocale(); export const OpenClawSchema = z.strictObject(OpenClawSchemaShape).superRefine((cfg, ctx) => { - const agents = cfg.agents?.list ?? []; + const agents = Object.entries(cfg.agents?.entries ?? {}).map(([id, entry]) => + Object.assign({ id }, entry), + ); if (agents.length === 0) { return; } @@ -35,7 +37,7 @@ export const OpenClawSchema = z.strictObject(OpenClawSchemaShape).superRefine((c ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["bindings", idx, "agentId"], - message: `Unknown agent id "${agentId}" (not in agents.list).`, + message: `Unknown agent id "${agentId}" (not in agents.entries).`, }); } } @@ -58,7 +60,7 @@ export const OpenClawSchema = z.strictObject(OpenClawSchemaShape).superRefine((c ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["broadcast", peerId, idx], - message: `Unknown agent id "${agentId}" (not in agents.list).`, + message: `Unknown agent id "${agentId}" (not in agents.entries).`, }); } } diff --git a/src/config/zod-schema.tts.test.ts b/src/config/zod-schema.tts.test.ts index 36cf0d4da542..753404c296a7 100644 --- a/src/config/zod-schema.tts.test.ts +++ b/src/config/zod-schema.tts.test.ts @@ -55,42 +55,6 @@ describe("TtsConfigSchema openai speed and instructions", () => { expect(result.success).toBe(true); }); - it("accepts provider-specific persona bindings and structured prompt fields", () => { - const result = TtsConfigSchema.safeParse({ - persona: "alfred", - personas: { - alfred: { - label: "Alfred", - description: "Dry, warm British butler narrator.", - provider: "google", - fallbackPolicy: "preserve-persona", - prompt: { - profile: "A brilliant British butler.", - scene: "A quiet late-night study.", - sampleContext: "The speaker is answering a trusted operator.", - style: "Refined and lightly amused.", - accent: "British English.", - pacing: "Measured.", - constraints: ["Do not read configuration values aloud."], - }, - providers: { - google: { - model: "gemini-3.1-flash-tts-preview", - voiceName: "Algieba", - promptTemplate: "audio-profile-v1", - }, - openai: { - model: "gpt-4o-mini-tts", - voice: "cedar", - instructions: "Speak with dry warmth.", - }, - }, - }, - }, - }); - expect(result.success).toBe(true); - }); - it("rejects persona rewrite config until runtime behavior exists", () => { const result = TtsConfigSchema.safeParse({ personas: { diff --git a/src/config/zod-schema.typing-mode.test.ts b/src/config/zod-schema.typing-mode.test.ts index ae2bc9226723..14705983dc12 100644 --- a/src/config/zod-schema.typing-mode.test.ts +++ b/src/config/zod-schema.typing-mode.test.ts @@ -1,26 +1,26 @@ // Verifies typing-mode schema parsing and defaults. import { describe, expect, it } from "vitest"; import { AgentDefaultsSchema } from "./zod-schema.agent-defaults.js"; -import { SessionSchema } from "./zod-schema.session.js"; +import { AgentEntrySchema } from "./zod-schema.agent-runtime.js"; describe("typing mode schema reuse", () => { - it("accepts supported typingMode values for session and agent defaults", () => { - const session = SessionSchema.parse({ typingMode: "thinking" }); + it("accepts supported typingMode values for agent defaults and entries", () => { + const agent = AgentEntrySchema.parse({ id: "support", typingMode: "thinking" }); const agentDefaults = AgentDefaultsSchema.parse({ typingMode: "message" }); - expect(session?.typingMode).toBe("thinking"); + expect(agent.typingMode).toBe("thinking"); expect(agentDefaults?.typingMode).toBe("message"); }); - it("rejects unsupported typingMode values for session and agent defaults", () => { - const sessionResult = SessionSchema.safeParse({ typingMode: "always" }); + it("rejects unsupported typingMode values for agent defaults and entries", () => { + const agentResult = AgentEntrySchema.safeParse({ id: "support", typingMode: "always" }); const agentDefaultsResult = AgentDefaultsSchema.safeParse({ typingMode: "soon" }); - expect(sessionResult.success).toBe(false); + expect(agentResult.success).toBe(false); expect(agentDefaultsResult.success).toBe(false); - if (sessionResult.success || agentDefaultsResult.success) { + if (agentResult.success || agentDefaultsResult.success) { throw new Error("Expected unsupported typingMode values to fail schema validation."); } - expect(sessionResult.error.issues.map((issue) => issue.path.join("."))).toEqual(["typingMode"]); + expect(agentResult.error.issues.map((issue) => issue.path.join("."))).toEqual(["typingMode"]); expect(agentDefaultsResult.error.issues.map((issue) => issue.path.join("."))).toEqual([ "typingMode", ]); diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index 1318e3c0d646..7d23a5a8803e 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -1227,11 +1227,9 @@ describe("dispatchCronDelivery — double-announce guard", () => { runStartedAt: 1_000, }); params.cfgWithAgentDefaults = { - messages: { - tts: { - auto: "tagged", - provider: "microsoft", - }, + tts: { + auto: "tagged", + provider: "microsoft", }, } as never; diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index f39f3dbe48be..360f9a900e78 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -23,6 +23,7 @@ import type { TtsAutoMode } from "../../config/types.tts.js"; import { isSuppressedControlReplyText } from "../../gateway/control-reply-text.js"; import { sleepWithAbort } from "../../infra/backoff.js"; import { isProvenDeliveryNotSentError } from "../../infra/delivery-recovery.shared.js"; +import { isFastTestRuntimeEnv } from "../../infra/env.js"; import { formatErrorMessage } from "../../infra/errors.js"; import type { NormalizedOutboundPayload, @@ -294,7 +295,7 @@ function cloneDeliveryResults( } function pruneCompletedDirectCronDeliveries(now: number) { - const ttlMs = process.env.OPENCLAW_TEST_FAST === "1" ? 60_000 : 24 * 60 * 60 * 1000; + const ttlMs = isFastTestRuntimeEnv() ? 60_000 : 24 * 60 * 60 * 1000; for (const [key, entry] of COMPLETED_DIRECT_CRON_DELIVERIES) { if (now - entry.ts >= ttlMs) { COMPLETED_DIRECT_CRON_DELIVERIES.delete(key); @@ -989,9 +990,7 @@ function isTransientDirectCronDeliveryError(error: unknown): boolean { return isProvenDeliveryNotSentError(error); } function resolveDirectCronRetryDelaysMs(): readonly number[] { - return process.env.NODE_ENV === "test" && process.env.OPENCLAW_TEST_FAST === "1" - ? [0, 0, 0] - : [5_000, 10_000, 20_000]; + return isFastTestRuntimeEnv() ? [0, 0, 0] : [5_000, 10_000, 20_000]; } async function retryTransientDirectCronDelivery(params: { diff --git a/src/cron/isolated-agent/helpers.ts b/src/cron/isolated-agent/helpers.ts index 99c8d6f7955a..5815acdf5882 100644 --- a/src/cron/isolated-agent/helpers.ts +++ b/src/cron/isolated-agent/helpers.ts @@ -200,9 +200,8 @@ export function isHeartbeatOnlyResponse(payloads: DeliveryPayload[], ackMaxChars } /** Resolves the non-negative heartbeat ack length used for heartbeat-only filtering. */ -export function resolveHeartbeatAckMaxChars(agentCfg?: { heartbeat?: { ackMaxChars?: number } }) { - const raw = agentCfg?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS; - return Math.max(0, raw); +export function resolveHeartbeatAckMaxChars(_agentCfg?: { heartbeat?: object }) { + return DEFAULT_HEARTBEAT_ACK_MAX_CHARS; } function isCronMessagePresentationWarning(text: string | undefined): boolean { diff --git a/src/cron/isolated-agent/run-config.ts b/src/cron/isolated-agent/run-config.ts index f1c19cfc2c13..3d1c410443d4 100644 --- a/src/cron/isolated-agent/run-config.ts +++ b/src/cron/isolated-agent/run-config.ts @@ -26,7 +26,7 @@ function extractCronAgentDefaultsOverride(agentConfigOverride?: ResolvedAgentCon const { model: overrideModel, sandbox: _agentSandboxOverride, - memorySearch: _agentMemorySearchOverride, + memory: _agentMemoryOverride, ...agentOverrideRest } = agentConfigOverride ?? {}; return { @@ -61,7 +61,7 @@ export function buildCronAgentDefaultsConfig(params: { params.agentConfigOverride, ); // Keep nested configs owned by agent-aware resolvers out of this flattened snapshot. - // Copying a partial sandbox or memorySearch object into defaults destroys its global + // Copying partial sandbox or memory objects into defaults destroys their global // fields before the resolver can merge the selected agent's override. // Model authorization likewise uses the unflattened config plus agent id; this // snapshot only carries the effective runtime metadata and explicit policy. diff --git a/src/cron/isolated-agent/run.memory-search-config-preserved.test.ts b/src/cron/isolated-agent/run.memory-search-config-preserved.test.ts index b7f942b99f4e..72d11978b8ad 100644 --- a/src/cron/isolated-agent/run.memory-search-config-preserved.test.ts +++ b/src/cron/isolated-agent/run.memory-search-config-preserved.test.ts @@ -12,44 +12,33 @@ describe("buildCronAgentDefaultsConfig memory search preservation", () => { model: "text-embedding-3-large", sources: ["memory", "sessions"], remote: { apiKey: "redacted" }, - query: { - hybrid: { - temporalDecay: { enabled: true }, - }, - }, + query: { maxResults: 6 }, } satisfies MemorySearchConfig; const agentMemorySearch = { - experimental: { sessionMemory: true }, - query: { - hybrid: { - temporalDecay: { enabled: false }, - }, - }, + rememberAcrossConversations: true, + query: { maxResults: 10 }, } satisfies MemorySearchConfig; const agentDefaults = buildCronAgentDefaultsConfig({ - defaults: { memorySearch: defaultMemorySearch }, - agentConfigOverride: { memorySearch: agentMemorySearch }, + defaults: {}, + agentConfigOverride: { memory: { search: agentMemorySearch } }, }); const runCfg: OpenClawConfig = { plugins: { enabled: false }, agents: { defaults: agentDefaults, - list: [{ id: "main", default: true, memorySearch: agentMemorySearch }], + list: [{ id: "main", default: true, memory: { search: agentMemorySearch } }], }, + memory: { search: defaultMemorySearch }, }; - expect(agentDefaults.memorySearch).toEqual(defaultMemorySearch); + expect(agentDefaults).not.toHaveProperty("memory"); expect(resolveMemorySearchConfig(runCfg, "main")).toMatchObject({ provider: "openai", model: "text-embedding-3-large", sources: ["memory", "sessions"], remote: { apiKey: "redacted" }, - experimental: { sessionMemory: true }, - query: { - hybrid: { - temporalDecay: { enabled: false }, - }, - }, + rememberAcrossConversations: true, + query: { maxResults: 10 }, }); }); }); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index ec6aa66ae851..9276ff329ebe 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -33,6 +33,7 @@ import { createChildDiagnosticTraceContext, freezeDiagnosticTraceContext, } from "../../infra/diagnostic-trace-context.js"; +import { isFastTestRuntimeEnv } from "../../infra/env.js"; import { resolveSourceDeliveryOutcome, type SourceDeliveryOutcome, @@ -1698,7 +1699,7 @@ export async function runCronIsolatedAgentTurn(params: { const isAborted = () => abortSignal?.aborted ?? false; const abortReason = () => resolveCronAbortReasonText(abortSignal?.reason) ?? "cron: job execution timed out"; - const isFastTestEnv = process.env.OPENCLAW_TEST_FAST === "1"; + const isFastTestEnv = isFastTestRuntimeEnv(); const prepared = await prepareCronRunContext({ input: { ...params, abortSignal }, isFastTestEnv, diff --git a/src/cron/isolated-agent/subagent-followup.ts b/src/cron/isolated-agent/subagent-followup.ts index a82cefec70f3..b79bd7faba84 100644 --- a/src/cron/isolated-agent/subagent-followup.ts +++ b/src/cron/isolated-agent/subagent-followup.ts @@ -2,10 +2,11 @@ import { readLatestAssistantReply, waitForAgentRunsToDrain } from "../../agents/run-wait.js"; import { listDescendantRunsForRequester } from "../../agents/subagent-registry-read.js"; import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; +import { isFastTestRuntimeEnv } from "../../infra/env.js"; import { isLikelyInterimCronMessage } from "./subagent-followup-hints.js"; function resolveCronSubagentTimings() { - const fastTestMode = process.env.OPENCLAW_TEST_FAST === "1"; + const fastTestMode = isFastTestRuntimeEnv(); return { waitMinMs: fastTestMode ? 10 : 30_000, finalReplyGraceMs: fastTestMode ? 50 : 5_000, diff --git a/src/cron/service/startup-run-repair.ts b/src/cron/service/startup-run-repair.ts index 58a7adfe8fb7..6bc0fd26749a 100644 --- a/src/cron/service/startup-run-repair.ts +++ b/src/cron/service/startup-run-repair.ts @@ -26,7 +26,7 @@ function resolveInterruptedStartupFailureNotificationStatus(params: { if (params.job.delivery?.bestEffort === true) { return "not-requested"; } - if (resolveFailureDestination(params.job, params.state.deps.cronConfig?.failureDestination)) { + if (resolveFailureDestination(params.job, params.state.deps.cronConfig?.failureAlert)) { return "unknown"; } const primaryPlan = resolveCronDeliveryPlan(params.job); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 98023371ab6c..b30a964ed203 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -702,7 +702,7 @@ function resolveDeliveryState(params: { runStatus: CronRunStatus; delivered?: boolean; error?: string; - globalFailureDestination?: CronConfig["failureDestination"]; + globalFailureDestination?: CronConfig["failureAlert"]; }): { delivered?: boolean; status: CronDeliveryStatus; @@ -826,7 +826,7 @@ export function applyJobResult( // so `lastDeliveryError` is populated without conflating it with a // run-level failure. Error runs fall back to the run error as before. error: result.deliveryError ?? result.error, - globalFailureDestination: state.deps.cronConfig?.failureDestination, + globalFailureDestination: state.deps.cronConfig?.failureAlert, }); job.state.lastDelivered = deliveryState.delivered; job.state.lastDeliveryStatus = deliveryState.status; diff --git a/src/cron/store.ts b/src/cron/store.ts index f0377bc62f47..b4b12f33de9b 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -14,6 +14,7 @@ import { import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { resolveConfigDir } from "../utils.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; +import { readCronStoreStatePath } from "./store/config-state.js"; import { cronStoreKey } from "./store/key.js"; import { assertCronStoreCanPersist, @@ -52,8 +53,9 @@ export function resolveCronQuarantinePath(storePath: string): string { /** Resolves the cron jobs store path, expanding home-relative user input. */ export function resolveCronJobsStorePath(storePath?: string, env: NodeJS.ProcessEnv = process.env) { - if (storePath?.trim()) { - const raw = storePath.trim(); + const selected = storePath?.trim() || readCronStoreStatePath(env); + if (selected) { + const raw = selected.trim(); if (raw.startsWith("~")) { return path.resolve(expandHomePrefix(raw, { env })); } diff --git a/src/cron/store/config-state.ts b/src/cron/store/config-state.ts new file mode 100644 index 000000000000..1bcdcec6bc91 --- /dev/null +++ b/src/cron/store/config-state.ts @@ -0,0 +1,7 @@ +// Cron store selection preserves the retired configured partition through shared SQLite state. +import { readConfigMachineState } from "../../state/config-machine-state.js"; + +export function readCronStoreStatePath(env: NodeJS.ProcessEnv = process.env): string | undefined { + const value = readConfigMachineState("cron.store", { env }); + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} diff --git a/src/flows/doctor-core-checks.runtime.ts b/src/flows/doctor-core-checks.runtime.ts index d6e05ea24f7c..c502b0d26bbd 100644 --- a/src/flows/doctor-core-checks.runtime.ts +++ b/src/flows/doctor-core-checks.runtime.ts @@ -83,7 +83,7 @@ export async function collectLocalAudioAccelerationFindings(): Promise { command: "/bin/sh", args: ["-c", `cat >/dev/null; printf executed > ${JSON.stringify(markerPath)}`], jsonOnly: false, - allowInsecurePath: true, }, }, }, @@ -690,8 +689,7 @@ describe("CORE_HEALTH_CHECKS", () => { command: process.execPath, args: [resolverPath, markerPath], jsonOnly: false, - allowInsecurePath: true, - allowSymlinkCommand: true, + trustedDirs: [dirname(process.execPath), tmp], }, }, }, @@ -737,8 +735,7 @@ describe("CORE_HEALTH_CHECKS", () => { command: process.execPath, args: [resolverPath], jsonOnly: false, - allowInsecurePath: true, - allowSymlinkCommand: true, + trustedDirs: [dirname(process.execPath), tmp!], }, }, }, diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 50cd42ce632e..51e7e34c8234 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -7,6 +7,7 @@ import type { DoctorPrompter } from "../commands/doctor-prompter.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { LEGACY_SECRETREF_ENV_MARKER_PREFIX } from "../config/types.secrets.js"; import { migrateLegacySecretRefEnvMarkers } from "../secrets/legacy-secretref-env-marker.js"; +import { readConfigMachineState } from "../state/config-machine-state.js"; import { CORE_HEALTH_CHECKS } from "./doctor-core-checks.js"; import "./doctor-tool-result-cap-advice.js"; import { resolveDoctorContributionHealthChecks } from "./doctor-health-contributions.js"; @@ -972,7 +973,7 @@ describe("doctor health contributions", () => { await contribution.run(ctx); expect(ctx.cfg.meta?.lastTouchedVersion).toBe("2026.5.16-beta.4"); - expect(ctx.cfg.meta?.lastTouchedAt).toEqual(expect.any(String)); + expect(readConfigMachineState("config.lastTouchedAt")).toEqual(expect.any(String)); }); it("checks command owner configuration before final config writes", () => { @@ -1057,7 +1058,7 @@ describe("doctor health contributions", () => { mode: "lint", allowExecSecretRefs: true, runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, - } as const; + } as unknown as Parameters[0]; await expect(runDoctorLintChecks(ctx, { checks: [check] })).resolves.toMatchObject({ checksRun: 0, @@ -2102,7 +2103,7 @@ describe("doctor health contributions", () => { expect.objectContaining({ checkId: "core/doctor/memory-search", severity: "warning", - path: "agents.defaults.memorySearch.provider", + path: "memory.search.provider", message: 'Memory search provider is set to "openai" but no API key was found.', fixHint: expect.stringContaining("OPENAI_API_KEY"), }), @@ -2375,7 +2376,7 @@ describe("doctor health contributions", () => { cfg: { cron: { store: "/tmp/openclaw-cron/jobs.json" } }, mode: "lint", runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, - } as const; + } as unknown as Parameters[0]; const checks = [cronStoreCheck!]; await expect(runDoctorLintChecks(ctx, { checks })).resolves.toMatchObject({ diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index b86803d9efba..0bdf2882330c 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -12,6 +12,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { buildGatewayConnectionDetails } from "../gateway/call.js"; import type { UpdatePostInstallDoctorResult } from "../infra/update-doctor-result.js"; import type { RuntimeEnv } from "../runtime.js"; +import { writeConfigMachineState } from "../state/config-machine-state.js"; import { normalizeHealthCheck } from "./health-check-adapter.js"; import type { HealthCheckInput, RunnableHealthCheck } from "./health-check-runner-types.js"; import type { HealthCheck, HealthCheckContext, HealthFinding } from "./health-checks.js"; @@ -630,9 +631,9 @@ async function runReleaseConfiguredPluginInstallsHealth( meta: { ...ctx.cfg.meta, lastTouchedVersion, - lastTouchedAt: new Date().toISOString(), }, }; + writeConfigMachineState("config.lastTouchedAt", new Date().toISOString()); } async function runDiskSpaceHealth(ctx: DoctorHealthFlowContext): Promise { @@ -1252,12 +1253,12 @@ function inferMemorySearchFindingPath(message: string): string { return "memory.backend"; } if (message.includes("OpenAI-compatible embeddings endpoint")) { - return "agents.defaults.memorySearch.remote.baseUrl"; + return "memory.search.remote.baseUrl"; } if (message.includes("OpenAI-compatible embedding model")) { - return "agents.defaults.memorySearch.model"; + return "memory.search.model"; } - return "agents.defaults.memorySearch.provider"; + return "memory.search.provider"; } async function collectMemorySearchHealthFindings( @@ -1469,8 +1470,7 @@ function resolveLegacyParentVersionOverride(ctx: DoctorHealthFlowContext): { if (!isLegacyParentWritableUpdateDoctorPass(ctx.env ?? process.env)) { return {}; } - const version = - ctx.configResult.sourceLastTouchedVersion?.trim() || ctx.cfg.meta?.lastTouchedVersion; + const version = ctx.configResult.sourceLastTouchedVersion?.trim(); return version ? { lastTouchedVersionOverride: version } : {}; } diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index 8dbc2ac4394a..d5851fe9b3a3 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -612,7 +612,7 @@ describeLive("android node capability integration (preconditioned)", () => { ); expect( commandsToRun.length, - "node.describe advertised no non-interactive allowlisted commands (check gateway.nodes allowCommands/denyCommands)", + "node.describe advertised no non-interactive allowlisted commands (check gateway.nodes.commands allow/deny)", ).toBeGreaterThan(0); const missingProfiles = commandsToRun.filter((command) => !COMMAND_PROFILES[command]); @@ -632,7 +632,7 @@ describeLive("android node capability integration (preconditioned)", () => { `Android node missing required non-interactive command(s): ${missingRequiredCommands.join(", ")}`, `runnable after policy filtering (${commandsToRun.length}/${ANDROID_NODE_REQUIRED_NON_INTERACTIVE_COMMANDS.length}): ${commandsToRun.join(", ")}`, `advertised by node.describe: ${commands.join(", ")}`, - "precondition: update the Android node, or fix gateway.nodes allowCommands/denyCommands before running this suite", + "precondition: update the Android node, or fix gateway.nodes.commands allow/deny before running this suite", ].join("\n"), ); } diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 819624b10e47..b706450464b1 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -24,6 +24,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createAbortError } from "../infra/abort-signal.js"; import { loadDeviceAuthToken } from "../infra/device-auth-store.js"; import { loadOrCreateDeviceIdentity, type DeviceIdentity } from "../infra/device-identity.js"; +import { isVitestRuntimeEnv } from "../infra/env.js"; import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; import type { DeviceAuthEntry } from "../shared/device-auth.js"; import { roleScopesAllow } from "../shared/operator-scope-compat.js"; @@ -667,7 +668,7 @@ function resolveGatewayCallTimeout(timeoutValue: unknown): { } { const hasEnvHandshakeTimeout = Boolean(process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS) || - Boolean(process.env.VITEST && process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS); + Boolean(isVitestRuntimeEnv() && process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS); const resolvedHandshakeTimeoutMs = hasEnvHandshakeTimeout ? resolvePreauthHandshakeTimeoutMs() : undefined; diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index d8cdb0a620a4..9efc525fd1bc 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -478,7 +478,6 @@ describe("GatewayClient security checks", () => { it("proxies ws:// loopback addresses when active proxy loopbackMode is proxy", async () => { const { startProxy, stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js"); const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", loopbackMode: "proxy", }); @@ -503,7 +502,6 @@ describe("GatewayClient security checks", () => { it("blocks ws:// loopback addresses when active proxy loopbackMode is block", async () => { const { startProxy, stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js"); const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", loopbackMode: "block", }); diff --git a/src/gateway/client.watchdog.test.ts b/src/gateway/client.watchdog.test.ts index 139725869e9b..d159ddd27797 100644 --- a/src/gateway/client.watchdog.test.ts +++ b/src/gateway/client.watchdog.test.ts @@ -37,7 +37,6 @@ describe("GatewayClient OpenClaw wrapper watchdog integration", () => { const { startProxy, stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js"); const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:9", loopbackMode: "gateway-only", }); diff --git a/src/gateway/config-reload-settings.ts b/src/gateway/config-reload-settings.ts index 65bd25fd0a76..fdbe37b71970 100644 --- a/src/gateway/config-reload-settings.ts +++ b/src/gateway/config-reload-settings.ts @@ -16,9 +16,6 @@ const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = { /** Resolves gateway reload mode/debounce from config with bounded defaults. */ export function resolveGatewayReloadSettings(cfg: OpenClawConfig): GatewayReloadSettings { const rawMode = cfg.gateway?.reload?.mode; - const mode = - rawMode === "off" || rawMode === "restart" || rawMode === "hot" || rawMode === "hybrid" - ? rawMode - : DEFAULT_RELOAD_SETTINGS.mode; + const mode = rawMode === "off" || rawMode === "hybrid" ? rawMode : DEFAULT_RELOAD_SETTINGS.mode; return { mode, debounceMs: DEFAULT_RELOAD_SETTINGS.debounceMs }; } diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 4662ba5c08df..2aa75d6027eb 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -862,7 +862,7 @@ describe("startGatewayConfigReloader include files", () => { const nestedIncludePath = nodePath.join(rootDir, "hooks-enabled.json5"); await writeFile( configPath, - `${JSON.stringify({ gateway: { reload: { mode: "hot" } }, hooks: { $include: "./hooks-link.json5" } }, null, 2)}\n`, + `${JSON.stringify({ gateway: { reload: { mode: "hybrid" } }, hooks: { $include: "./hooks-link.json5" } }, null, 2)}\n`, ); await writeFile( includePath, @@ -2665,59 +2665,6 @@ describe("startGatewayConfigReloader", () => { await harness.reloader.stop(); }); - it("does not publish a restart-only hot-mode candidate through a later safe edit", async () => { - const initialConfig: OpenClawConfig = { - gateway: { - reload: { mode: "hot" }, - auth: { mode: "token", token: "old-token" }, - }, - logging: { level: "info" }, - }; - const makeWrite = (config: OpenClawConfig, persistedHash: string): ConfigWriteNotification => ({ - configPath: "/tmp/openclaw.json", - sourceConfig: config, - runtimeConfig: config, - persistedHash, - revision: 1, - fingerprint: `runtime-${persistedHash}`, - sourceFingerprint: `source-${persistedHash}`, - writtenAtMs: Date.now(), - }); - let watcherSnapshot = makeSnapshot({ config: initialConfig, hash: "initial" }); - const harness = createReloaderHarness(async () => watcherSnapshot, { initialConfig }); - const restartOnlyConfig: OpenClawConfig = { - ...initialConfig, - gateway: { - ...initialConfig.gateway, - auth: { mode: "token", token: "new-token" }, - }, - }; - - harness.emitWrite(makeWrite(restartOnlyConfig, "restart-only")); - await vi.runAllTimersAsync(); - watcherSnapshot = makeSnapshot({ config: restartOnlyConfig, hash: "restart-only" }); - harness.watcher.emit("change"); - await vi.runAllTimersAsync(); - harness.emitWrite( - makeWrite({ ...restartOnlyConfig, logging: { level: "debug" } }, "safe-after-restart"), - ); - await vi.runAllTimersAsync(); - - expect(harness.onNoopConfigCommit).not.toHaveBeenCalled(); - expect(harness.onHotReload).not.toHaveBeenCalled(); - expect(harness.onRestart).not.toHaveBeenCalled(); - expect(harness.onConfigAccepted.mock.calls.map((call) => call[3])).toEqual([ - { runtimeApplied: false }, - { runtimeApplied: false }, - { runtimeApplied: false }, - ]); - expect(harness.log.warn).toHaveBeenCalledTimes(2); - expect(harness.log.warn).toHaveBeenLastCalledWith( - expect.stringContaining("gateway.auth.token"), - ); - await harness.reloader.stop(); - }); - it("notifies lifecycle owners before hot reload and commits after success", async () => { const initialConfig: OpenClawConfig = { gateway: { reload: {} }, @@ -2827,12 +2774,12 @@ describe("startGatewayConfigReloader", () => { await harness.reloader.stop(); }); - it("does not notify lifecycle owners when hot mode ignores a restart-only change", async () => { + it("notifies lifecycle owners when hybrid mode applies a restart-only change", async () => { const initialConfig: OpenClawConfig = { - gateway: { reload: { mode: "hot" }, terminal: { enabled: true } }, + gateway: { reload: { mode: "hybrid" }, terminal: { enabled: true } }, }; const nextConfig: OpenClawConfig = { - gateway: { reload: { mode: "hot" }, terminal: { enabled: false } }, + gateway: { reload: { mode: "hybrid" }, terminal: { enabled: false } }, }; const readSnapshot = vi.fn(async () => makeSnapshot({ config: nextConfig, hash: "hot" })); const harness = createReloaderHarness(readSnapshot, { initialConfig }); @@ -2840,9 +2787,9 @@ describe("startGatewayConfigReloader", () => { harness.watcher.emit("change"); await vi.runAllTimersAsync(); - expect(harness.onConfigChange).not.toHaveBeenCalled(); + expect(harness.onConfigChange).toHaveBeenCalledOnce(); expect(harness.onHotReload).not.toHaveBeenCalled(); - expect(harness.onRestart).not.toHaveBeenCalled(); + expect(harness.onRestart).toHaveBeenCalledOnce(); await harness.reloader.stop(); }); @@ -3258,7 +3205,12 @@ describe("startGatewayConfigReloader", () => { expected: "old", }, { label: "reload off", afterWrite: undefined, reloadMode: "off", expected: "old" }, - { label: "hot restart ignore", afterWrite: undefined, reloadMode: "hot", expected: "old" }, + { + label: "hybrid restart", + afterWrite: undefined, + reloadMode: "hybrid", + expected: "candidate", + }, ] as const)( "publishes config env only for a runtime-applied $label transaction", async (testCase) => { diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 15187f6456f5..0c5fb27004d4 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -731,23 +731,7 @@ export function startGatewayConfigReloader(opts: { await commitReloadBaseline(); return; } - if (nextSettings.mode === "restart") { - const restartPlan = { ...plan, restartGateway: true }; - await opts.onConfigChange?.(restartPlan, nextConfig); - await prepareRestart(restartPlan, nextConfig, ownership, nextSourceConfig); - await commitReloadBaseline(); - return; - } if (plan.restartGateway) { - if (nextSettings.mode === "hot") { - opts.log.warn( - `config reload requires gateway restart; hot mode ignoring (${plan.restartReasons.join( - ", ", - )})`, - ); - await commitReloadBaseline({ runtimeApplied: false }); - return; - } await opts.onConfigChange?.(plan, nextConfig); await prepareRestart(plan, nextConfig, ownership, nextSourceConfig); await commitReloadBaseline(); diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 0632e7d70238..aeb6381a0746 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -1101,9 +1101,9 @@ describe("handleControlUiHttpRequest", () => { { root: { kind: "resolved", path: tmp }, config: { - agents: { defaults: { workspace: tmp, timeFormat: "24" } }, - gateway: { controlUi: { chatMessageMaxWidth: "min(1280px, 82%)" } }, + agents: { defaults: { workspace: tmp } }, ui: { + prefs: { chatMessageMaxWidth: "min(1280px, 82%)" }, seamColor: "#1A2b3C", assistant: { name: ".png" }, }, @@ -1120,7 +1120,7 @@ describe("handleControlUiHttpRequest", () => { expect(parsed.assistantAgentId).toBe("main"); expect(parsed.chatMessageMaxWidth).toBe("min(1280px, 82%)"); expect(parsed.seamColor).toBe("#1A2b3C"); - expect(parsed.timeFormat).toBe("24"); + expect(parsed.timeFormat).toBe("auto"); expect(parsed.terminalEnabled).toBe(false); expect(parsed.devGitBranch).toBeUndefined(); expect(Array.isArray(parsed.localMediaPreviewRoots)).toBe(true); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index e245c605fef5..6f265dc02259 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -989,9 +989,9 @@ export async function handleControlUiHttpRequest( ? "strict" : "scripts", allowExternalEmbedUrls: config?.gateway?.controlUi?.allowExternalEmbedUrls === true, - chatMessageMaxWidth: config?.gateway?.controlUi?.chatMessageMaxWidth, + chatMessageMaxWidth: config?.ui?.prefs?.chatMessageMaxWidth, seamColor: config?.ui?.seamColor, - timeFormat: config?.agents?.defaults?.timeFormat, + timeFormat: "auto", terminalEnabled, pluginFrameGrants: pluginFrameGrants.map(({ pluginId, path: grantPath, match }) => ({ pluginId, diff --git a/src/gateway/embeddings-http.test.ts b/src/gateway/embeddings-http.test.ts index 6765e75c620e..f149c3b8d1a1 100644 --- a/src/gateway/embeddings-http.test.ts +++ b/src/gateway/embeddings-http.test.ts @@ -329,6 +329,12 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => { }, }, }, + memory: { + search: { + provider: "tenant-embeddings", + model: "tenant-embeddings/nomic-embed-text", + }, + }, }, null, 2, @@ -336,12 +342,6 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => { "utf-8", ); try { - testState.agentConfig = { - memorySearch: { - provider: "tenant-embeddings", - model: "tenant-embeddings/nomic-embed-text", - }, - }; resetConfigRuntimeState(); const res = await postEmbeddings({ @@ -354,14 +354,13 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => { expect(lastCall.model).toBe("nomic-embed-text"); expect(lastCall.acquireLocalService).toEqual(expect.any(Function)); } finally { - testState.agentConfig = undefined; resetConfigRuntimeState(); } }); it("rejects explicit unknown agent ids", async () => { try { - testState.agentsConfig = { list: [{ id: "main" }, { id: "beta" }] }; + testState.agentsConfig = { entries: { main: {}, beta: {} } }; resetConfigRuntimeState(); const header = await postEmbeddings( @@ -424,19 +423,29 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => { }); it("routes explicit OpenAI-compatible embeddings through generic providers", async () => { - testState.agentConfig = { - memorySearch: { - provider: "openai-compatible", - model: "nomic-embed-text", - inputType: "default", - queryInputType: "query", - documentInputType: "document", - outputDimensionality: 768, - remote: { - baseUrl: genericEmbeddingBaseUrl, + const configPath = createConfigIO().configPath; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + memory: { + search: { + provider: "openai-compatible", + model: "nomic-embed-text", + inputType: "default", + queryInputType: "query", + documentInputType: "document", + outputDimensionality: 768, + remote: { baseUrl: genericEmbeddingBaseUrl }, + }, + }, }, - }, - }; + null, + 2, + )}\n`, + "utf-8", + ); resetConfigRuntimeState(); await expectGenericProviderEmbeddingRequest({ @@ -462,22 +471,22 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => { }, }, }, + memory: { + search: { + provider: "tenant-embeddings", + model: "tenant-embeddings/nomic-embed-text", + inputType: "default", + queryInputType: "query", + documentInputType: "document", + outputDimensionality: 768, + }, + }, }, null, 2, )}\n`, "utf-8", ); - testState.agentConfig = { - memorySearch: { - provider: "tenant-embeddings", - model: "tenant-embeddings/nomic-embed-text", - inputType: "default", - queryInputType: "query", - documentInputType: "document", - outputDimensionality: 768, - }, - }; resetConfigRuntimeState(); await expectGenericProviderEmbeddingRequest({ diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index 356591ae271f..afb58897a5eb 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -1012,12 +1012,12 @@ describe("resolveNodeCommandAllowlist", () => { expectDangerousCommandsDenied(allow); }); - it("can explicitly allow dangerous commands via allowCommands", () => { + it("can explicitly allow dangerous commands via commands.allow", () => { const allow = resolveNodeCommandAllowlist( { gateway: { nodes: { - allowCommands: ["camera.snap", "screen.record"], + commands: { allow: ["camera.snap", "screen.record"] }, }, }, }, diff --git a/src/gateway/local-request-context.ts b/src/gateway/local-request-context.ts index 2404034b50a0..4e55cdff1ef3 100644 --- a/src/gateway/local-request-context.ts +++ b/src/gateway/local-request-context.ts @@ -67,7 +67,7 @@ function createLocalGatewayRequestContext( ...unavailableCron, removeAgentJobsTransactional: async (agentId, commit) => { const cfg = params.getRuntimeConfig(); - const storePath = resolveCronJobsStorePath(cfg.cron?.store); + const storePath = resolveCronJobsStorePath(); const service = new CronService({ storePath, cronEnabled: cfg.cron?.enabled !== false, diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index e2fc7602c594..a10b70d37fc2 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -978,7 +978,7 @@ function collectConfiguredModelPricingRefs( ...normalizationParams, }); addResolvedModelRef({ - raw: config.messages?.tts?.summaryModel, + raw: config.tts?.summaryModel, aliasIndex, refs, ...normalizationParams, @@ -1050,31 +1050,6 @@ function collectConfiguredModelPricingRefs( ...normalizationParams, }); } - for (const entry of config.tools?.media?.image?.models ?? []) { - addProviderModelPair({ - provider: entry.provider, - model: entry.model, - refs, - ...normalizationParams, - }); - } - for (const entry of config.tools?.media?.audio?.models ?? []) { - addProviderModelPair({ - provider: entry.provider, - model: entry.model, - refs, - ...normalizationParams, - }); - } - for (const entry of config.tools?.media?.video?.models ?? []) { - addProviderModelPair({ - provider: entry.provider, - model: entry.model, - refs, - ...normalizationParams, - }); - } - return Array.from(refs.values()); } diff --git a/src/gateway/node-command-policy.test.ts b/src/gateway/node-command-policy.test.ts index 613e294d2b0f..663722975f4d 100644 --- a/src/gateway/node-command-policy.test.ts +++ b/src/gateway/node-command-policy.test.ts @@ -469,7 +469,7 @@ describe("gateway/node-command-policy", () => { ).toBe(false); }); - it("allows approved node-host MCP calls while denyCommands still wins", () => { + it("allows approved node-host MCP calls while commands.deny still wins", () => { const node = { platform: "linux", deviceFamily: "Linux", @@ -490,7 +490,7 @@ describe("gateway/node-command-policy", () => { ).toEqual({ ok: true }); const denied = resolveNodeCommandAllowlist( - { gateway: { nodes: { denyCommands: ["mcp.tools.call.v1"] } } } as OpenClawConfig, + { gateway: { nodes: { commands: { deny: ["mcp.tools.call.v1"] } } } } as OpenClawConfig, node, ); expect(denied.has("mcp.tools.call.v1")).toBe(false); @@ -519,7 +519,7 @@ describe("gateway/node-command-policy", () => { { gateway: { nodes: { - allowCommands: ["screen.record"], + commands: { allow: ["screen.record"] }, }, }, } as OpenClawConfig, @@ -554,14 +554,16 @@ describe("gateway/node-command-policy", () => { ).toBe(false); const armed = resolveNodeCommandAllowlist( - { gateway: { nodes: { allowCommands: ["computer.act"] } } } as OpenClawConfig, + { gateway: { nodes: { commands: { allow: ["computer.act"] } } } } as OpenClawConfig, desktopNode, ); expect(armed.has("computer.act")).toBe(true); const denied = resolveNodeCommandAllowlist( { - gateway: { nodes: { allowCommands: ["computer.act"], denyCommands: ["computer.act"] } }, + gateway: { + nodes: { commands: { allow: ["computer.act"], deny: ["computer.act"] } }, + }, } as OpenClawConfig, desktopNode, ); @@ -605,20 +607,22 @@ describe("gateway/node-command-policy", () => { expect(windowsPairing.has("screen.record")).toBe(false); }); - it("keeps computer.act declarable at pairing even when fresh-setup denyCommands blocks it", () => { - // Fresh gateway setup seeds denyCommands from DEFAULT_DANGEROUS_NODE_COMMANDS, + it("keeps computer.act declarable at pairing even when fresh-setup commands.deny blocks it", () => { + // Fresh gateway setup seeds commands.deny from DEFAULT_DANGEROUS_NODE_COMMANDS, // which includes computer.act. The pairing surface must still retain it so // the node can be armed later; invoke-time policy still blocks it at runtime. const cfg = { - gateway: { nodes: { denyCommands: ["computer.act", "screen.record", "camera.snap"] } }, + gateway: { + nodes: { commands: { deny: ["computer.act", "screen.record", "camera.snap"] } }, + }, } as OpenClawConfig; const macNode = { platform: "macos", deviceFamily: "Mac", commands: ["computer.act"] }; expect(resolveNodePairingCommandAllowlist(cfg, macNode).has("computer.act")).toBe(true); - // Runtime allowlist still gates it until armed via allowCommands. + // Runtime allowlist still gates it until armed via commands.allow. expect(resolveNodeCommandAllowlist(cfg, macNode).has("computer.act")).toBe(false); - // Arming (allowCommands opt-in) makes it runtime-invocable. + // Arming (commands.allow opt-in) makes it runtime-invocable. const armedCfg = { - gateway: { nodes: { allowCommands: ["computer.act"] } }, + gateway: { nodes: { commands: { allow: ["computer.act"] } } }, } as OpenClawConfig; expect(resolveNodeCommandAllowlist(armedCfg, macNode).has("computer.act")).toBe(true); }); @@ -634,7 +638,7 @@ describe("gateway/node-command-policy", () => { ); const armed = { - gateway: { nodes: { allowCommands: ["health.summary"] } }, + gateway: { nodes: { commands: { allow: ["health.summary"] } } }, } as OpenClawConfig; expect(resolveNodePairingCommandAllowlist(armed, node).has("health.summary")).toBe(true); expect(resolveNodeCommandAllowlist(armed, node).has("health.summary")).toBe(true); @@ -642,8 +646,7 @@ describe("gateway/node-command-policy", () => { const denied = { gateway: { nodes: { - allowCommands: ["health.summary"], - denyCommands: ["health.summary"], + commands: { allow: ["health.summary"], deny: ["health.summary"] }, }, }, } as OpenClawConfig; diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index d02192d66244..bf4f27fa5286 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -28,7 +28,7 @@ const SCREEN_DANGEROUS_COMMANDS = ["screen.record"]; // Desktop computer use (pointer/keyboard injection). Declarable at pairing on // desktop platforms (macOS/Windows/Linux) but invocable only with explicit -// allowCommands opt-in (arming). +// commands.allow opt-in (arming). const COMPUTER_DANGEROUS_COMMANDS = ["computer.act"]; const ANDROID_DEVICE_COMMANDS = [ @@ -96,7 +96,7 @@ const UNKNOWN_PLATFORM_COMMANDS = [ ]; // "High risk" node commands. These can be enabled by explicitly adding them to -// `gateway.nodes.allowCommands` (and ensuring they're not blocked by denyCommands). +// `gateway.nodes.commands.allow` (and ensuring they're not blocked by commands.deny). export const DEFAULT_DANGEROUS_NODE_COMMANDS = [ ...CAMERA_DANGEROUS_COMMANDS, ...SCREEN_DANGEROUS_COMMANDS, @@ -410,8 +410,8 @@ function resolveNodeCommandAllowlistInternal( platformId, commands: node?.approvedCommands ?? (isLiveNodeSession(node) ? (node?.commands ?? []) : []), }); - const extra = cfg.gateway?.nodes?.allowCommands ?? []; - const deny = new Set(cfg.gateway?.nodes?.denyCommands ?? []); + const extra = cfg.gateway?.nodes?.commands?.allow ?? []; + const deny = new Set(cfg.gateway?.nodes?.commands?.deny ?? []); const dangerousPluginCommands = new Set(listDangerousPluginNodeCommands()); // Dangerous built-ins in PLATFORM_DEFAULTS (e.g. computer.act on desktop nodes) stay // declarable/approvable at pairing but never enter the runtime allowlist by @@ -421,7 +421,7 @@ function resolveNodeCommandAllowlistInternal( ? new Set() : new Set(DEFAULT_DANGEROUS_NODE_COMMANDS); // Dangerous plugin commands are excluded from plugin defaults. Explicit - // gateway.nodes.allowCommands below can still opt them in for operators. + // gateway.nodes.commands.allow below can still opt them in for operators. const allow = new Set( [...base, ...watchRelayCommands, ...talkCommands, ...pluginDefaults, ...approved, ...extra] .map((cmd) => cmd.trim()) @@ -440,7 +440,7 @@ function resolveNodeCommandAllowlistInternal( } // In pairing mode, denylisted dangerous defaults stay declarable so a node // retains the surface it can later be armed for: arming removes them from - // denyCommands and adds them to allowCommands. Fresh setup seeds denyCommands + // commands.deny and adds them to commands.allow. Fresh setup seeds commands.deny // with DEFAULT_DANGEROUS_NODE_COMMANDS, so without this exemption a declarable // dangerous default (e.g. computer.act on desktop nodes) would be stripped from the // pairing surface and stay uninvocable even after arming, because the live diff --git a/src/gateway/node-connect-reconcile.test.ts b/src/gateway/node-connect-reconcile.test.ts index e5fddcb386e2..ea3959839d26 100644 --- a/src/gateway/node-connect-reconcile.test.ts +++ b/src/gateway/node-connect-reconcile.test.ts @@ -219,7 +219,7 @@ describe("reconcileNodePairingOnConnect", () => { }); const requestPairing = vi.fn(); - // No allowCommands entry (unarmed): the previously approved dangerous + // No commands.allow entry (unarmed): the previously approved dangerous // surface must reconcile cleanly instead of demanding a pairing upgrade // on every reconnect. const result = await reconcileNodePairingOnConnect({ diff --git a/src/gateway/node-connect-reconcile.ts b/src/gateway/node-connect-reconcile.ts index 628036aba35f..89b12c4a147e 100644 --- a/src/gateway/node-connect-reconcile.ts +++ b/src/gateway/node-connect-reconcile.ts @@ -175,7 +175,7 @@ export async function reconcileNodePairingOnConnect(params: { // Approved commands reconcile against the pairing allowlist: an approved // dangerous surface awaiting arming (e.g. computer.act without an - // allowCommands entry) must not read as a pairing upgrade on every + // commands.allow entry) must not read as a pairing upgrade on every // reconnect. Invoke-time policy still gates every call on the runtime // allowlist, so keeping it effective here grants nothing by itself. const approvedCommands = resolveApprovedReconnectCommands({ diff --git a/src/gateway/node-invoke-plugin-policy.test.ts b/src/gateway/node-invoke-plugin-policy.test.ts index 4b8b3d509a27..c9b4ea443629 100644 --- a/src/gateway/node-invoke-plugin-policy.test.ts +++ b/src/gateway/node-invoke-plugin-policy.test.ts @@ -77,7 +77,7 @@ function createContext(opts?: { context: { getRuntimeConfig: opts?.getRuntimeConfig ?? - (() => ({ gateway: { nodes: { allowCommands: [DEMO_COMMAND] } } })), + (() => ({ gateway: { nodes: { commands: { allow: [DEMO_COMMAND] } } } })), nodeRegistry: { get: () => nodeSession, invoke }, broadcast: vi.fn(), broadcastToConnIds: vi.fn(), @@ -286,9 +286,9 @@ describe("applyPluginNodeInvokePolicy", () => { const { context, invoke } = createContext({ getRuntimeConfig: () => ({ gateway: { - nodes: allowCommand - ? { allowCommands: [DEMO_COMMAND] } - : { denyCommands: [DEMO_COMMAND] }, + nodes: { + commands: allowCommand ? { allow: [DEMO_COMMAND] } : { deny: [DEMO_COMMAND] }, + }, }, }), }); diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 821a576f2e2e..1f5aec08a435 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -4614,7 +4614,7 @@ describe("agent event handler", () => { it("keeps heartbeat alert text in final chat output when remainder exceeds ackMaxChars", () => { vi.mocked(getRuntimeConfig).mockReturnValue({ - agents: { defaults: { heartbeat: { ackMaxChars: 10 } } }, + agents: { defaults: { heartbeat: {} } }, }); const { broadcast, chatRunState, handler } = createHarness({ now: 3_000 }); @@ -4628,13 +4628,14 @@ describe("agent event handler", () => { verboseLevel: "off", }); + const alert = `Disk usage crossed 95 percent on /data. ${"Cleanup required. ".repeat(20)}`; handler({ runId: "run-heartbeat-alert", seq: 1, stream: "assistant", ts: Date.now(), data: { - text: "HEARTBEAT_OK Disk usage crossed 95 percent on /data and needs cleanup now.", + text: `HEARTBEAT_OK ${alert}`, }, }); @@ -4643,9 +4644,7 @@ describe("agent event handler", () => { const payload = expectSingleFinalChatPayload(broadcast) as { message?: { content?: Array<{ text?: string }> }; }; - expect(payload.message?.content?.[0]?.text).toBe( - "Disk usage crossed 95 percent on /data and needs cleanup now.", - ); + expect(payload.message?.content?.[0]?.text).toBe(alert.trim()); }); describe("spawnedBy enrichment in chat and agent broadcasts", () => { diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index a1b2a4509852..6c3df7adfbd1 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -111,15 +111,7 @@ function projectToolSearchCodeEventForChannelPayload ({ })); import type { CronJob } from "../cron/types.js"; -import { buildGatewayCronService, fireOnExitJob } from "./server-cron.js"; +import { + buildGatewayCronService as buildGatewayCronServiceRuntime, + fireOnExitJob, +} from "./server-cron.js"; + +function buildGatewayCronService(params: Parameters[0]) { + const legacyStore = (params.cfg.cron as { store?: unknown } | undefined)?.store; + if (typeof legacyStore !== "string") { + return buildGatewayCronServiceRuntime(params); + } + const env = { + ...process.env, + OPENCLAW_SKIP_CRON: "0", + OPENCLAW_STATE_DIR: path.dirname(legacyStore), + }; + // These fixtures predate the config-to-SQLite move; seed the canonical machine-state owner. + writeConfigMachineState("cron.store", legacyStore, { env }); + return buildGatewayCronServiceRuntime({ ...params, env }); +} function createCronConfig(name: string): OpenClawConfig { const tmpDir = path.join(os.tmpdir(), `${name}-${Date.now()}`); @@ -1078,7 +1097,7 @@ describe("buildGatewayCronService", () => { it("cron_changed hook event includes agentId from the job", async () => { const cfg = createCronConfig("server-cron-hook-agentId"); - cfg.agents = { list: [{ id: "main", default: true }, { id: "yinze" }] }; + cfg.agents = { entries: { main: { default: true }, yinze: {} } }; loadConfigMock.mockReturnValue(cfg); const state = buildGatewayCronService({ @@ -2028,10 +2047,10 @@ describe("buildGatewayCronService", () => { session: { mainKey: "main" }, cron: { store: path.join(os.tmpdir(), `server-cron-untargeted-${Date.now()}`, "cron.json") }, agents: { - list: [ - { id: "primary", default: true, model: "test/primary" }, - { id: "ops", model: "test/ops" }, - ], + entries: { + primary: { default: true, model: "test/primary" }, + ops: { model: "test/ops" }, + }, }, } as unknown as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); @@ -2087,10 +2106,10 @@ describe("buildGatewayCronService", () => { session: { mainKey: "main" }, cron: { store: path.join(os.tmpdir(), `server-cron-symmetric-${Date.now()}`, "cron.json") }, agents: { - list: [ - { id: "primary", default: true, model: "test/primary" }, - { id: "ops", model: "test/ops" }, - ], + entries: { + primary: { default: true, model: "test/primary" }, + ops: { model: "test/ops" }, + }, }, } as unknown as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); @@ -2162,10 +2181,10 @@ describe("buildGatewayCronService", () => { store: path.join(os.tmpdir(), `server-cron-relative-default-${Date.now()}`, "cron.json"), }, agents: { - list: [ - { id: "primary", default: true, model: "test/primary" }, - { id: "main", model: "test/main" }, - ], + entries: { + primary: { default: true, model: "test/primary" }, + main: { model: "test/main" }, + }, }, } as unknown as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); @@ -2222,10 +2241,10 @@ describe("buildGatewayCronService", () => { store: path.join(os.tmpdir(), `server-cron-unknown-agent-${Date.now()}`, "cron.json"), }, agents: { - list: [ - { id: "primary", default: true, model: "test/primary" }, - { id: "ops", model: "test/ops" }, - ], + entries: { + primary: { default: true, model: "test/primary" }, + ops: { model: "test/ops" }, + }, }, } as unknown as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); @@ -2279,10 +2298,10 @@ describe("buildGatewayCronService", () => { store: path.join(os.tmpdir(), `server-cron-wake-service-${Date.now()}`, "cron.json"), }, agents: { - list: [ - { id: "primary", default: true, model: "test/primary" }, - { id: "ops", model: "test/ops" }, - ], + entries: { + primary: { default: true, model: "test/primary" }, + ops: { model: "test/ops" }, + }, }, } as unknown as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); @@ -2504,10 +2523,10 @@ describe("buildGatewayCronService", () => { defaults: { workspace: path.join(tmpDir, "workspace"), }, - list: [ - { id: "main", default: true }, - { id: "yinze", workspace: path.join(tmpDir, "workspace-yinze") }, - ], + entries: { + main: { default: true }, + yinze: { workspace: path.join(tmpDir, "workspace-yinze") }, + }, }, } as OpenClawConfig; const reloadedCfg = { @@ -2521,7 +2540,7 @@ describe("buildGatewayCronService", () => { defaults: { workspace: path.join(tmpDir, "workspace"), }, - list: [{ id: "main", default: true }], + entries: { main: { default: true } }, }, } as OpenClawConfig; loadConfigMock.mockReturnValue(startupCfg); @@ -2562,7 +2581,7 @@ describe("buildGatewayCronService", () => { cron: { store: path.join(tmpDir, "cron.json") }, agents: { defaults: { workspace: path.join(tmpDir, "workspace") }, - list: [{ id: "main", default: true }, { id: "yinze" }, { id: "other" }], + entries: { main: { default: true }, yinze: {}, other: {} }, }, } as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); @@ -2604,7 +2623,7 @@ describe("buildGatewayCronService", () => { const tmpDir = path.join(os.tmpdir(), `server-cron-agent-uncertain-${Date.now()}`); const cfg = { cron: { store: path.join(tmpDir, "cron.json") }, - agents: { list: [{ id: "main", default: true }, { id: "yinze" }, { id: "other" }] }, + agents: { entries: { main: { default: true }, yinze: {}, other: {} } }, } as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); const state = buildGatewayCronService({ cfg, deps: {} as CliDeps, broadcast: () => {} }); @@ -2641,11 +2660,11 @@ describe("buildGatewayCronService", () => { const tmpDir = path.join(os.tmpdir(), `server-cron-default-change-${Date.now()}`); const startupCfg = { cron: { store: path.join(tmpDir, "cron.json") }, - agents: { list: [{ id: "main" }, { id: "yinze", default: true }, { id: "other" }] }, + agents: { entries: { main: {}, yinze: { default: true }, other: {} } }, } as OpenClawConfig; const runtimeCfg = { ...startupCfg, - agents: { list: [{ id: "main" }, { id: "yinze" }, { id: "other", default: true }] }, + agents: { entries: { main: {}, yinze: {}, other: { default: true } } }, } as OpenClawConfig; loadConfigMock.mockReturnValue(startupCfg); const state = buildGatewayCronService({ @@ -2688,7 +2707,7 @@ describe("buildGatewayCronService", () => { const tmpDir = path.join(os.tmpdir(), `server-cron-agent-fenced-${Date.now()}`); const cfg = { cron: { store: path.join(tmpDir, "cron.json") }, - agents: { list: [{ id: "main", default: true }, { id: "yinze" }] }, + agents: { entries: { main: { default: true }, yinze: {} } }, } as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); const state = buildGatewayCronService({ cfg, deps: {} as CliDeps, broadcast: () => {} }); @@ -2723,12 +2742,12 @@ describe("buildGatewayCronService", () => { cron: { store: path.join(tmpDir, "cron.json") }, agents: { defaults: { workspace: path.join(tmpDir, "workspace") }, - list: [{ id: "main", default: true }, { id: "yinze" }], + entries: { main: { default: true }, yinze: {} }, }, } as OpenClawConfig; const deletedCfg = { ...cfg, - agents: { ...cfg.agents, list: [{ id: "main", default: true }] }, + agents: { ...cfg.agents, entries: { main: { default: true } } }, } as OpenClawConfig; loadConfigMock.mockReturnValue(cfg); const state = buildGatewayCronService({ cfg, deps: {} as CliDeps, broadcast: () => {} }); @@ -2800,17 +2819,16 @@ describe("buildGatewayCronService", () => { deliveryFormat: "text", }, }, - list: [ - { id: "main", default: true }, - { - id: "yinze", + entries: { + main: { default: true }, + yinze: { workspace: path.join(tmpDir, "workspace-yinze"), heartbeat: { target: "last", deliveryFormat: "markdown", }, }, - ], + }, }, } as OpenClawConfig; const reloadedCfg = { @@ -2828,7 +2846,7 @@ describe("buildGatewayCronService", () => { deliveryFormat: "text", }, }, - list: [{ id: "main", default: true }], + entries: { main: { default: true } }, }, } as OpenClawConfig; loadConfigMock.mockReturnValue(reloadedCfg); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 4ac7569cfcb8..698e6c95c5cb 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -2,7 +2,7 @@ // plugin hooks, notifications, and cron lifecycle cleanup. import { retireSessionMcpRuntime } from "../agents/agent-bundle-mcp-tools.js"; import { isAgentDeletionBlocked } from "../agents/agent-lifecycle-registry.js"; -import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listAgentEntries, listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { abortAndDrainEmbeddedAgentRun } from "../agents/embedded-agent.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-cleanup.js"; @@ -264,16 +264,11 @@ export function buildGatewayCronService(params: { }): GatewayCronState { const cronLogger = getChildLogger({ module: "cron" }); const env = params.env ?? process.env; - const storePath = resolveCronJobsStorePath(params.cfg.cron?.store, env); + const storePath = resolveCronJobsStorePath(undefined, env); const cronEnabled = env.OPENCLAW_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false; const findAgentEntry = (cfg: OpenClawConfig, agentId: string) => - Array.isArray(cfg.agents?.list) - ? cfg.agents.list.find( - (entry) => - entry && typeof entry.id === "string" && normalizeAgentId(entry.id) === agentId, - ) - : undefined; + listAgentEntries(cfg).find((entry) => normalizeAgentId(entry.id) === agentId); const hasConfiguredAgent = (cfg: OpenClawConfig, agentId: string) => Boolean(findAgentEntry(cfg, agentId)); @@ -1047,7 +1042,7 @@ export function buildGatewayCronService(params: { logger: cronLogger, resolveCronAgent, webhookToken: params.cfg.cron?.webhookToken, - globalFailureDestination: params.cfg.cron?.failureDestination, + globalFailureDestination: params.cfg.cron?.failureAlert, }); } }, diff --git a/src/gateway/server-discovery-runtime.test.ts b/src/gateway/server-discovery-runtime.test.ts index 61786be3da57..0732eecac2cc 100644 --- a/src/gateway/server-discovery-runtime.test.ts +++ b/src/gateway/server-discovery-runtime.test.ts @@ -330,7 +330,7 @@ describe("startGatewayDiscovery", () => { expect(mocks.writeWideAreaGatewayZone).not.toHaveBeenCalled(); expect(logs.warn.mock.calls).toEqual([ [ - "discovery.wideArea.enabled is true, but no domain was configured; set discovery.wideArea.domain to enable unicast DNS-SD", + "wide-area discovery was requested without a domain; set discovery.wideArea.domain to enable unicast DNS-SD", ], ]); expect(result.bonjourStop).toBeNull(); diff --git a/src/gateway/server-discovery-runtime.ts b/src/gateway/server-discovery-runtime.ts index 898ae9422a66..99f75ed2aca8 100644 --- a/src/gateway/server-discovery-runtime.ts +++ b/src/gateway/server-discovery-runtime.ts @@ -154,14 +154,14 @@ export async function startGatewayDiscovery(params: { }); if (!wideAreaDomain) { params.logDiscovery.warn( - "discovery.wideArea.enabled is true, but no domain was configured; set discovery.wideArea.domain to enable unicast DNS-SD", + "wide-area discovery was requested without a domain; set discovery.wideArea.domain to enable unicast DNS-SD", ); return { bonjourStop }; } const tailnetIPv4 = pickPrimaryTailnetIPv4(); if (!tailnetIPv4) { params.logDiscovery.warn( - "discovery.wideArea.enabled is true, but no Tailscale IPv4 address was found; skipping unicast DNS-SD zone update", + "discovery.wideArea.domain is set, but no Tailscale IPv4 address was found; skipping unicast DNS-SD zone update", ); } else { try { diff --git a/src/gateway/server-methods/agent-timestamp.ts b/src/gateway/server-methods/agent-timestamp.ts index 8b39e156c08f..5052ca681956 100644 --- a/src/gateway/server-methods/agent-timestamp.ts +++ b/src/gateway/server-methods/agent-timestamp.ts @@ -105,6 +105,6 @@ export function injectTimestamp(message: string, opts?: TimestampInjectionOption export function timestampOptsFromConfig(cfg: OpenClawConfig): TimestampInjectionOptions { return { timezone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone), - includeTimestamp: cfg.agents?.defaults?.envelopeTimestamp !== "off", + includeTimestamp: true, }; } diff --git a/src/gateway/server-methods/chat.inject.parentid.test.ts b/src/gateway/server-methods/chat.inject.parentid.test.ts index 381a5d12ecaa..2b1ab65f82ba 100644 --- a/src/gateway/server-methods/chat.inject.parentid.test.ts +++ b/src/gateway/server-methods/chat.inject.parentid.test.ts @@ -155,7 +155,7 @@ describe("gateway chat.inject transcript writes", () => { sessionKey: "global", storePath: fixture.storePath, message: `Here is your key: ${fakeApiKey}`, - config: { logging: { redactSensitive: "tools" } }, + config: {}, }); expect(appended.ok).toBe(true); diff --git a/src/gateway/server-methods/config-write-flow.ts b/src/gateway/server-methods/config-write-flow.ts index de76e921e998..4a6ec9a7e4ef 100644 --- a/src/gateway/server-methods/config-write-flow.ts +++ b/src/gateway/server-methods/config-write-flow.ts @@ -158,11 +158,8 @@ function resolveConfigRestartRequirement(params: { if (reloadSettings.mode === "off") { return { requiresRestart: true, scheduleDirectRestart: true }; } - if (reloadSettings.mode === "restart") { - return { requiresRestart: true, scheduleDirectRestart: false }; - } if (plan.restartGateway) { - return { requiresRestart: true, scheduleDirectRestart: reloadSettings.mode === "hot" }; + return { requiresRestart: true, scheduleDirectRestart: false }; } return { requiresRestart: false, scheduleDirectRestart: false }; } diff --git a/src/gateway/server-methods/config.shared-auth.test.ts b/src/gateway/server-methods/config.shared-auth.test.ts index b324492e8dec..4b3b37950c20 100644 --- a/src/gateway/server-methods/config.shared-auth.test.ts +++ b/src/gateway/server-methods/config.shared-auth.test.ts @@ -287,12 +287,10 @@ describe("config shared auth disconnects", () => { it("accepts an unresolved isolatable TTS SecretRef and reports the cold owner", async () => { const submittedConfig: OpenClawConfig = { - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: { source: "env", provider: "default", id: "ELEVENLABS_API_KEY" }, - }, + tts: { + providers: { + elevenlabs: { + apiKey: { source: "env", provider: "default", id: "ELEVENLABS_API_KEY" }, }, }, }, @@ -306,7 +304,7 @@ describe("config shared auth disconnects", () => { ownerId: "tts", state: "unavailable", degradationState: "cold", - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], refKeys: ["env:default:ELEVENLABS_API_KEY"], reason: "secret reference was not found", }, @@ -355,12 +353,10 @@ describe("config shared auth disconnects", () => { "secret reference is not allowed for this provider", ])("rejects non-retryable SecretRef degradation before config writes: %s", async (reason) => { const submittedConfig: OpenClawConfig = { - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: { source: "env", provider: "default", id: "ELEVENLABS_API_KEY" }, - }, + tts: { + providers: { + elevenlabs: { + apiKey: { source: "env", provider: "default", id: "ELEVENLABS_API_KEY" }, }, }, }, @@ -374,7 +370,7 @@ describe("config shared auth disconnects", () => { ownerId: "tts", state: "unavailable", degradationState: "cold", - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], refKeys: ["env:default:ELEVENLABS_API_KEY"], reason, }, @@ -519,14 +515,13 @@ describe("config shared auth disconnects", () => { expect(disconnectClientsUsingSharedGatewayAuth).not.toHaveBeenCalled(); }); - it("still schedules a direct restart for hot mode when the reloader cannot apply the change", async () => { + it("defers restart-required changes to the watcher after legacy hot mode normalizes", async () => { mockPreviousConfig(hotReloadConfig()); await runConfigPatch({ gateway: { port: 19001 } }); - expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledTimes(1); - const payload = restartSentinelMocks.writeRestartSentinel.mock.calls.at(-1)?.[0]; - expect(payload?.stats?.requiresRestart).toBe(true); + expectNoDirectRestart(); + expect(restartSentinelMocks.writeRestartSentinel).not.toHaveBeenCalled(); }); it("does not schedule a direct restart for hot-mode browser profile config.patch writes", async () => { @@ -568,7 +563,7 @@ describe("config shared auth disconnects", () => { ); const payload = restartSentinelMocks.writeRestartSentinel.mock.calls.at(-1)?.[0]; - expect(payload?.sessionKey).toBe("agent:main:main"); + expect(payload?.sessionKey).toBeUndefined(); expect(payload?.continuation).toBeUndefined(); }); }); diff --git a/src/gateway/server-methods/doctor.test.ts b/src/gateway/server-methods/doctor.test.ts index 51272f9adcce..3d76473792dd 100644 --- a/src/gateway/server-methods/doctor.test.ts +++ b/src/gateway/server-methods/doctor.test.ts @@ -624,12 +624,15 @@ describe("doctor.memory.status", () => { ); getRuntimeConfig.mockReturnValue({ + memory: { + search: { + enabled: true, + }, + }, + agents: { defaults: { userTimezone: "America/Los_Angeles", - memorySearch: { - enabled: true, - }, }, list: [{ id: "alpha", workspace: alphaWorkspaceDir }], }, @@ -1113,12 +1116,14 @@ describe("doctor.memory.status", () => { await fs.mkdir(path.join(mainWorkspaceDir, "memory", ".dreams"), { recursive: true }); getRuntimeConfig.mockReturnValue({ - agents: { - defaults: { - memorySearch: { - enabled: true, - }, + memory: { + search: { + enabled: true, }, + }, + + agents: { + defaults: {}, list: [ { id: "main", workspace: mainWorkspaceDir }, { id: "alpha", workspace: alphaWorkspaceDir }, diff --git a/src/gateway/server-methods/exec-approvals.test.ts b/src/gateway/server-methods/exec-approvals.test.ts index 3d6aaf9faf2f..ad24cba652f5 100644 --- a/src/gateway/server-methods/exec-approvals.test.ts +++ b/src/gateway/server-methods/exec-approvals.test.ts @@ -225,7 +225,7 @@ describe("exec approvals gateway methods", () => { baseHash: "base-hash", }, commands: ["system.execApprovals.set"], - config: { gateway: { nodes: { denyCommands: ["system.execApprovals.set"] } } }, + config: { gateway: { nodes: { commands: { deny: ["system.execApprovals.set"] } } } }, }, ])("blocks $method outside the effective command policy", async (testCase) => { const invoke = vi.fn(); diff --git a/src/gateway/server-methods/fs.test.ts b/src/gateway/server-methods/fs.test.ts index e0ce4efc7b72..61ee39db56a2 100644 --- a/src/gateway/server-methods/fs.test.ts +++ b/src/gateway/server-methods/fs.test.ts @@ -151,7 +151,7 @@ describe("fs.listDir", () => { it("rejects node listings blocked by the live command policy", async () => { const invoke = vi.fn(); const context = { - getRuntimeConfig: () => ({ gateway: { nodes: { denyCommands: ["fs.listDir"] } } }), + getRuntimeConfig: () => ({ gateway: { nodes: { commands: { deny: ["fs.listDir"] } } } }), nodeRegistry: { get: vi.fn().mockReturnValue({ connId: "conn-1", diff --git a/src/gateway/server-methods/node-command-rejection-hint.ts b/src/gateway/server-methods/node-command-rejection-hint.ts index c707671c4676..bde78436204f 100644 --- a/src/gateway/server-methods/node-command-rejection-hint.ts +++ b/src/gateway/server-methods/node-command-rejection-hint.ts @@ -17,12 +17,12 @@ export function buildNodeCommandRejectionHint( if (command.startsWith("talk.")) { return `node command not allowed: "${command}" requires a trusted Talk-capable node`; } - const denyCommands = cfg.gateway?.nodes?.denyCommands ?? []; + const denyCommands = cfg.gateway?.nodes?.commands?.deny ?? []; if (denyCommands.some((entry) => entry.trim() === command)) { - return `node command not allowed: "${command}" is blocked by gateway.nodes.denyCommands`; + return `node command not allowed: "${command}" is blocked by gateway.nodes.commands.deny`; } if (DEFAULT_DANGEROUS_NODE_COMMANDS.includes(command)) { - return `node command not allowed: "${command}" requires explicit gateway.nodes.allowCommands opt-in`; + return `node command not allowed: "${command}" requires explicit gateway.nodes.commands.allow opt-in`; } return `node command not allowed: "${command}" is not in the allowlist for platform "${platform}"`; } diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 6dca39da54d8..a6222299e58f 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -31,8 +31,10 @@ type MockNodeCommandPolicyParams = { type MockNodeConfig = { gateway?: { nodes?: { - allowCommands?: string[]; - denyCommands?: string[]; + commands?: { + allow?: string[]; + deny?: string[]; + }; }; }; }; @@ -778,7 +780,7 @@ describe("node.invoke APNs wake path", () => { it("allows an armed computer.act command for write-scoped operators", async () => { mocks.getRuntimeConfig.mockReturnValue({ - gateway: { nodes: { allowCommands: ["computer.act"] } }, + gateway: { nodes: { commands: { allow: ["computer.act"] } } }, }); mocks.resolveNodeCommandAllowlist.mockReturnValue(new Set(["computer.act"])); const nodeRegistry = { @@ -838,7 +840,7 @@ describe("node.invoke APNs wake path", () => { const call = firstRespondCall(respond); expect(call[0]).toBe(false); expect(call[2]?.message).toBe( - 'node command not allowed: "sms.search" requires explicit gateway.nodes.allowCommands opt-in', + 'node command not allowed: "sms.search" requires explicit gateway.nodes.commands.allow opt-in', ); expect(nodeRegistry.invoke).not.toHaveBeenCalled(); }); @@ -907,7 +909,7 @@ describe("node.invoke APNs wake path", () => { it("distinguishes explicit command denials from missing opt-ins", async () => { mocks.getRuntimeConfig.mockReturnValue({ - gateway: { nodes: { denyCommands: ["sms.search"] } }, + gateway: { nodes: { commands: { deny: ["sms.search"] } } }, }); mocks.isNodeCommandAllowed.mockReturnValue({ ok: false, @@ -933,7 +935,7 @@ describe("node.invoke APNs wake path", () => { const call = firstRespondCall(respond); expect(call[0]).toBe(false); expect(call[2]?.message).toBe( - 'node command not allowed: "sms.search" is blocked by gateway.nodes.denyCommands', + 'node command not allowed: "sms.search" is blocked by gateway.nodes.commands.deny', ); expect(nodeRegistry.invoke).not.toHaveBeenCalled(); }); @@ -1167,13 +1169,13 @@ describe("node.invoke APNs wake path", () => { mockDirectWakeConfig("mac-node-policy-reload"); let runtimeConfig: MockNodeConfig = { - gateway: { nodes: { allowCommands: ["computer.act"] } }, + gateway: { nodes: { commands: { allow: ["computer.act"] } } }, }; const admissionConfig = runtimeConfig; mocks.getRuntimeConfig.mockImplementation(() => runtimeConfig); mocks.resolveNodeCommandAllowlist.mockImplementation((cfg) => { - const allowlist = new Set(cfg.gateway?.nodes?.allowCommands ?? []); - for (const command of cfg.gateway?.nodes?.denyCommands ?? []) { + const allowlist = new Set(cfg.gateway?.nodes?.commands?.allow ?? []); + for (const command of cfg.gateway?.nodes?.commands?.deny ?? []) { allowlist.delete(command); } return allowlist; @@ -1208,7 +1210,7 @@ describe("node.invoke APNs wake path", () => { }); setTimeout(() => { runtimeConfig = { - gateway: { nodes: { denyCommands: ["computer.act"] } }, + gateway: { nodes: { commands: { deny: ["computer.act"] } } }, }; connected = true; }, 300); @@ -1219,7 +1221,7 @@ describe("node.invoke APNs wake path", () => { const call = firstRespondCall(respond); expect(call[0]).toBe(false); expect(call[2]?.message).toBe( - 'node command not allowed: "computer.act" is blocked by gateway.nodes.denyCommands', + 'node command not allowed: "computer.act" is blocked by gateway.nodes.commands.deny', ); expectRecordFields(call[2]?.details, "error details", { reason: "command not allowlisted", @@ -1235,13 +1237,13 @@ describe("node.invoke APNs wake path", () => { mockDirectWakeConfig("mac-node-policy-grant"); let runtimeConfig: MockNodeConfig = { - gateway: { nodes: { denyCommands: ["computer.act"] } }, + gateway: { nodes: { commands: { deny: ["computer.act"] } } }, }; const admissionConfig = runtimeConfig; mocks.getRuntimeConfig.mockImplementation(() => runtimeConfig); mocks.resolveNodeCommandAllowlist.mockImplementation((cfg) => { - const allowlist = new Set(cfg.gateway?.nodes?.allowCommands ?? []); - for (const command of cfg.gateway?.nodes?.denyCommands ?? []) { + const allowlist = new Set(cfg.gateway?.nodes?.commands?.allow ?? []); + for (const command of cfg.gateway?.nodes?.commands?.deny ?? []) { allowlist.delete(command); } return allowlist; @@ -1276,7 +1278,7 @@ describe("node.invoke APNs wake path", () => { }); setTimeout(() => { runtimeConfig = { - gateway: { nodes: { allowCommands: ["computer.act"] } }, + gateway: { nodes: { commands: { allow: ["computer.act"] } } }, }; connected = true; }, 300); @@ -1287,7 +1289,7 @@ describe("node.invoke APNs wake path", () => { const call = firstRespondCall(respond); expect(call[0]).toBe(false); expect(call[2]?.message).toBe( - 'node command not allowed: "computer.act" is blocked by gateway.nodes.denyCommands', + 'node command not allowed: "computer.act" is blocked by gateway.nodes.commands.deny', ); expectRecordFields(call[2]?.details, "error details", { reason: "command not allowlisted", diff --git a/src/gateway/server-methods/plugins.test.ts b/src/gateway/server-methods/plugins.test.ts index e117179ef172..5c07d33cc29b 100644 --- a/src/gateway/server-methods/plugins.test.ts +++ b/src/gateway/server-methods/plugins.test.ts @@ -228,7 +228,7 @@ describe("plugin management Gateway handlers", () => { it.each([ { mode: "off", restartRequired: true }, - { mode: "restart", restartRequired: true }, + { mode: "restart", restartRequired: false }, { mode: "hot", restartRequired: false }, ] as const)( "reports restartRequired=$restartRequired for $mode reload mode", diff --git a/src/gateway/server-methods/plugins.ts b/src/gateway/server-methods/plugins.ts index f16081ea1249..6de804aa0824 100644 --- a/src/gateway/server-methods/plugins.ts +++ b/src/gateway/server-methods/plugins.ts @@ -32,7 +32,7 @@ function pluginPolicyRestartRequired(params: { }): boolean { const plan = buildGatewayReloadPlan([...params.changedPaths]); const mode = resolveGatewayReloadSettings(params.config).mode; - return plan.restartGateway || mode === "off" || mode === "restart"; + return plan.restartGateway || mode === "off"; } /** Gateway handlers for plugin inventory, ClawHub search, install, and policy state. */ diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 699f3da595ef..c6375e32bdd0 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -834,21 +834,6 @@ describe("injectTimestamp", () => { expect(result).toMatch(/^\[Fri 2025-07-04 12:00 EDT\]/); }); - - it("leaves messages bare when config disables envelope timestamps", () => { - const cfg = { - agents: { - defaults: { - envelopeTimestamp: "off", - userTimezone: "America/New_York", - }, - }, - } as OpenClawConfig; - - expect(injectTimestamp("cache sensitive prompt", timestampOptsFromConfig(cfg))).toBe( - "cache sensitive prompt", - ); - }); }); describe("sanitizeChatHistoryMessages", () => { @@ -2676,21 +2661,14 @@ describe("timestampOptsFromConfig", () => { expect(timestampOptsFromConfig(cfg).timezone).toBe(expected); }); - it("keeps timestamp injection enabled for upgraded configs unless explicitly disabled", () => { + it("keeps timestamp injection enabled for upgraded configs", () => { const upgradedConfigWithExistingDefaults = { agents: { defaults: { userTimezone: "America/Chicago" } }, } as OpenClawConfig; - // Existing user configs do not store envelopeTimestamp; omission remains - // the shipped default even when other agent defaults are present, so no - // config migration is needed for this broadened use of the setting. + // Timestamp injection is fixed on even when other agent defaults exist. expect(timestampOptsFromConfig({} as OpenClawConfig).includeTimestamp).toBe(true); expect(timestampOptsFromConfig(upgradedConfigWithExistingDefaults).includeTimestamp).toBe(true); - expect( - timestampOptsFromConfig({ - agents: { defaults: { envelopeTimestamp: "off" } }, - } as OpenClawConfig).includeTimestamp, - ).toBe(false); }); }); diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index a727423a638e..643e23c83e7e 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -756,13 +756,11 @@ describe("talk.speak handler", () => { }, }, }; - runtimeConfig.messages = { - tts: { - providers: { - acme: { - speakerVoice: "marin", - speakerVoiceId: "voice-123", - }, + runtimeConfig.tts = { + providers: { + acme: { + speakerVoice: "marin", + speakerVoiceId: "voice-123", }, }, }; @@ -808,8 +806,8 @@ describe("talk.speak handler", () => { }); mocks.synthesizeSpeech.mockImplementation( async ({ cfg }: { cfg: OpenClawConfig; text: string; disableFallback: boolean }) => { - expect(cfg.messages?.tts?.provider).toBe("acme"); - expect(cfg.messages?.tts?.providers?.acme?.apiKey).toBe("env-acme-key"); + expect(cfg.tts?.provider).toBe("acme"); + expect(cfg.tts?.providers?.acme?.apiKey).toBe("env-acme-key"); return { success: true, provider: "acme", @@ -1005,7 +1003,7 @@ describe("talk.config handler", () => { expect(JSON.stringify(response)).not.toContain("runtime-azure-secret"); }); - it("passes runtime-resolved messages.tts provider secrets to strict provider resolvers", async () => { + it("passes runtime-resolved tts provider secrets to strict provider resolvers", async () => { const sourceConfig = { talk: { provider: "acme", @@ -1018,28 +1016,24 @@ describe("talk.config handler", () => { }, }, }, - messages: { - tts: { - provider: "acme", - timeoutMs: 12_345, - providers: { - acme: { - apiKey: { source: "env", provider: "default", id: "ACME_SPEECH_API_KEY" }, - }, + tts: { + provider: "acme", + timeoutMs: 12_345, + providers: { + acme: { + apiKey: { source: "env", provider: "default", id: "ACME_SPEECH_API_KEY" }, }, }, }, } as OpenClawConfig; const runtimeConfig = { ...sourceConfig, - messages: { - tts: { - provider: "acme", - timeoutMs: 54_321, - providers: { - acme: { - apiKey: "env-acme-key", - }, + tts: { + provider: "acme", + timeoutMs: 54_321, + providers: { + acme: { + apiKey: "env-acme-key", }, }, }, @@ -1067,7 +1061,7 @@ describe("talk.config handler", () => { const providerConfig = (providers.acme ?? {}) as Record; const apiKey = normalizeResolvedSecretInputString({ value: providerConfig.apiKey, - path: "messages.tts.providers.acme.apiKey", + path: "tts.providers.acme.apiKey", }); expect(apiKey).toBe("env-acme-key"); expect(timeoutMs).toBe(54_321); diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index db53831fde4c..b9c3961b6e0b 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -193,7 +193,7 @@ function buildTalkTtsConfig( } const baseTts = withTalkBaseTtsSpeakerSelectionCompat( - asOptionalRecord(config.messages?.tts) ?? {}, + asOptionalRecord(config.tts) ?? {}, ) as TtsConfig; const providerConfig = withSpeakerSelectionFallbackCompat(resolved.config); const resolvedProviderConfig = @@ -218,10 +218,7 @@ function buildTalkTtsConfig( providerConfig, cfg: { ...config, - messages: { - ...config.messages, - tts: talkTts, - }, + tts: talkTts, }, }; } @@ -518,10 +515,10 @@ async function resolveTalkResponseFromConfig(params: { const speechProvider = getSpeechProvider(provider, params.runtimeConfig); const sourceBaseTts = withTalkBaseTtsSpeakerSelectionCompat( - asOptionalRecord(params.sourceConfig.messages?.tts) ?? {}, + asOptionalRecord(params.sourceConfig.tts) ?? {}, ); const runtimeBaseTts = withTalkBaseTtsSpeakerSelectionCompat( - asOptionalRecord(params.runtimeConfig.messages?.tts) ?? {}, + asOptionalRecord(params.runtimeConfig.tts) ?? {}, ); const sourceProviderConfig = withSpeakerSelectionFallbackCompat(sourceResolved?.config); const runtimeProviderConfig = withSpeakerSelectionFallbackCompat(runtimeResolved?.config); diff --git a/src/gateway/server-methods/tts.test.ts b/src/gateway/server-methods/tts.test.ts index 619a189653e3..4efa6d6d0db6 100644 --- a/src/gateway/server-methods/tts.test.ts +++ b/src/gateway/server-methods/tts.test.ts @@ -273,7 +273,7 @@ describe("ttsHandlers", () => { ownerKind: "capability", ownerId: "tts", state: "unavailable", - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], refKeys: ["env:default:ELEVENLABS_API_KEY"], reason: "secret reference was not found", }, diff --git a/src/gateway/server-methods/tts.ts b/src/gateway/server-methods/tts.ts index d3d7543a8013..64a541034625 100644 --- a/src/gateway/server-methods/tts.ts +++ b/src/gateway/server-methods/tts.ts @@ -174,7 +174,7 @@ export const ttsHandlers: GatewayRequestHandlers = { } try { const cfg = context.getRuntimeConfig(); - // synthesizeSpeech enforces the same messages.tts.maxTextLength bound but + // synthesizeSpeech enforces the same tts.maxTextLength bound but // reports it as a synthesis failure; pre-check to return a request error. const maxTextLength = resolveTtsConfig(cfg).maxTextLength; if (text.length > maxTextLength) { diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index e69375024f1c..b88eba3bf659 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -1064,7 +1064,7 @@ describe("loadGatewayPlugins", () => { loadOpenClawPlugins.mockReturnValue(createRegistry([])); loadGatewayStartupPluginsForTest(); serverPluginsModule.setFallbackGatewayContext({ - getRuntimeConfig: () => ({ gateway: { nodes: { denyCommands: [command] } } }), + getRuntimeConfig: () => ({ gateway: { nodes: { commands: { deny: [command] } } } }), nodeRegistry: { get: () => ({ nodeId: "node-policy", diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index d7f9a5c826a4..7503355bfc2e 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -3859,18 +3859,18 @@ describe("gateway Gmail hot reload handlers", () => { const fourthRef = { source: "env" as const, provider: "default", id: "TTS_FOURTH" }; const sourceConfig = (ref: typeof firstRef): OpenClawConfig => ({ gateway: { reload: {} }, - messages: { tts: { providers: { elevenlabs: { apiKey: ref } } } }, + tts: { providers: { elevenlabs: { apiKey: ref } } }, }); const runtimeConfig: OpenClawConfig = { gateway: { reload: {} }, - messages: { tts: { providers: { elevenlabs: { apiKey: String(42) } } } }, + tts: { providers: { elevenlabs: { apiKey: String(42) } } }, }; const ttsContractDigest = "tts-source-only-contract"; const initialSourceConfig = sourceConfig(firstRef); const nextSourceConfig = sourceConfig(secondRef); const activeWarning = { code: "SECRETS_OWNER_UNAVAILABLE" as const, - path: "messages.tts.providers.elevenlabs.apiKey", + path: "tts.providers.elevenlabs.apiKey", message: "Text-to-speech remains unavailable.", }; activateSecretsRuntimeSnapshot({ @@ -3899,7 +3899,7 @@ describe("gateway Gmail hot reload handlers", () => { ownerKind: "capability", ownerId: "tts", state: "unavailable", - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], refKeys: ["env:default:TTS_FIRST"], reason: "secret reference was not found", }, diff --git a/src/gateway/server-startup-config.secrets.test.ts b/src/gateway/server-startup-config.secrets.test.ts index e2a90f86ced4..05502c646757 100644 --- a/src/gateway/server-startup-config.secrets.test.ts +++ b/src/gateway/server-startup-config.secrets.test.ts @@ -871,7 +871,7 @@ describe("gateway startup config secret preflight", () => { ownerId: "tts", state: "unavailable" as const, degradationState: "cold" as const, - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], refKeys: ["env:default:ELEVENLABS_API_KEY"], reason: "secret reference was not found" as const, }, @@ -995,7 +995,7 @@ describe("gateway startup config secret preflight", () => { ownerId: "tts", state: "unavailable" as const, degradationState: "cold" as const, - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], refKeys: ["env:default:ELEVENLABS_API_KEY"], reason: "secret reference was not found" as const, }, @@ -1492,21 +1492,19 @@ describe("gateway startup config secret preflight", () => { it("allows cold startup snapshots with isolated SecretRef owners", async () => { const sourceConfig = gatewayTokenConfig({ - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: { source: "env", provider: "default", id: "ELEVENLABS_API_KEY" }, - }, + tts: { + providers: { + elevenlabs: { + apiKey: { source: "env", provider: "default", id: "ELEVENLABS_API_KEY" }, }, }, }, }); const warning: SecretResolverWarning = { code: "SECRETS_OWNER_UNAVAILABLE", - path: "messages.tts.providers.elevenlabs.apiKey", + path: "tts.providers.elevenlabs.apiKey", message: - "Secret owner capability:tts is configured-unavailable; paths: messages.tts.providers.elevenlabs.apiKey; reason: secret provider policy denied resolution.", + "Secret owner capability:tts is configured-unavailable; paths: tts.providers.elevenlabs.apiKey; reason: secret provider policy denied resolution.", }; const prepareRuntimeSecretsSnapshot = vi.fn(async () => ({ ...preparedSnapshot(sourceConfig), @@ -1517,7 +1515,7 @@ describe("gateway startup config secret preflight", () => { ownerKind: "capability" as const, ownerId: "tts", state: "unavailable" as const, - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], refKeys: ["env:default:ELEVENLABS_API_KEY"], reason: "secret provider policy denied resolution", }, @@ -1536,8 +1534,8 @@ describe("gateway startup config secret preflight", () => { activate: true, }); - expect(result.config.messages?.tts?.providers?.elevenlabs?.apiKey).toEqual( - sourceConfig.messages?.tts?.providers?.elevenlabs?.apiKey, + expect(result.config.tts?.providers?.elevenlabs?.apiKey).toEqual( + sourceConfig.tts?.providers?.elevenlabs?.apiKey, ); expect(prepareRuntimeSecretsSnapshot).toHaveBeenCalledWith( expect.objectContaining({ allowUnavailableSecretOwners: true }), @@ -1572,7 +1570,7 @@ describe("gateway startup config secret preflight", () => { }, { code: "SECRETS_OWNER_UNAVAILABLE" as const, - path: "messages.tts.providers.elevenlabs.apiKey", + path: "tts.providers.elevenlabs.apiKey", message: "Secret owner capability:tts is configured-unavailable.", }, ], @@ -1592,7 +1590,7 @@ describe("gateway startup config secret preflight", () => { ownerId: "tts", state: "unavailable" as const, degradationState: "stale" as const, - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], refKeys: ["exec:vault:tts/elevenlabs"], reason: "secret provider failed", providerFailures, @@ -1671,14 +1669,14 @@ describe("gateway startup config secret preflight", () => { async (reason) => { activateSecretsRuntimeSnapshotForTest(preparedSnapshot(gatewayTokenConfig({}))); const invalidSecretError = new Error( - "messages.tts.providers.elevenlabs.apiKey resolved to a non-string or empty value.", + "tts.providers.elevenlabs.apiKey resolved to a non-string or empty value.", ); associateSecretResolutionErrorOwners(invalidSecretError, [ { ownerKind: "capability", ownerId: "tts", state: "unavailable", - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], refKeys: ["file:ttsfile:/private/value"], reason: "resolved secret value was invalid", degradationState: "stale", @@ -1724,7 +1722,7 @@ describe("gateway startup config secret preflight", () => { ownerKind: "capability", ownerId: "tts", state: "unavailable", - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], refKeys: ["env:default:EXPIRED_RELOAD_REF"], reason: "secret reference was not found", degradationState: "stale", @@ -1881,7 +1879,7 @@ describe("gateway startup config secret preflight", () => { ownerKind: "capability" as const, ownerId: "tts", state: "unavailable" as const, - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], refKeys: ["env:default:ELEVENLABS_API_KEY"], reason: "secret reference was not found", }, @@ -2213,12 +2211,10 @@ describe("gateway startup config secret preflight", () => { ]); const unrelatedChangedSourceConfig = structuredClone(sourceConfig); - unrelatedChangedSourceConfig.messages = { - tts: { - providers: { - elevenlabs: { - apiKey: { source: "env", provider: "default", id: "UNRELATED_TTS_KEY" }, - }, + unrelatedChangedSourceConfig.tts = { + providers: { + elevenlabs: { + apiKey: { source: "env", provider: "default", id: "UNRELATED_TTS_KEY" }, }, }, }; diff --git a/src/gateway/server-startup-early.ts b/src/gateway/server-startup-early.ts index 7a5735e72cc5..37a36463feef 100644 --- a/src/gateway/server-startup-early.ts +++ b/src/gateway/server-startup-early.ts @@ -57,7 +57,7 @@ export async function startGatewayPluginDiscovery(params: { ? { enabled: true, fingerprintSha256: params.gatewayTls.fingerprintSha256 } : undefined, gatewayDirectReachable: params.gatewayDirectReachable, - wideAreaDiscoveryEnabled: params.cfgAtStart.discovery?.wideArea?.enabled === true, + wideAreaDiscoveryEnabled: Boolean(params.cfgAtStart.discovery?.wideArea?.domain?.trim()), wideAreaDiscoveryDomain: params.cfgAtStart.discovery?.wideArea?.domain, tailscaleMode: params.tailscaleMode, mdnsMode: params.cfgAtStart.discovery?.mdns?.mode, diff --git a/src/gateway/server-startup-log.test.ts b/src/gateway/server-startup-log.test.ts index 51128c8bacdd..82c964144650 100644 --- a/src/gateway/server-startup-log.test.ts +++ b/src/gateway/server-startup-log.test.ts @@ -57,13 +57,7 @@ describe("gateway startup log", () => { const warn = vi.fn(); await logGatewayStartup({ - cfg: { - gateway: { - controlUi: { - dangerouslyDisableDeviceAuth: true, - }, - }, - }, + cfg: { hooks: { gmail: { allowUnsafeExternalContent: true } } }, bindHost: "127.0.0.1", loadedPluginIds: [], port: 18789, @@ -73,7 +67,7 @@ describe("gateway startup log", () => { expect(warn.mock.calls).toEqual([ [ - "security warning: dangerous config flags enabled: gateway.controlUi.dangerouslyDisableDeviceAuth=true. Run `openclaw security audit`.", + "security warning: dangerous config flags enabled: hooks.gmail.allowUnsafeExternalContent=true. Run `openclaw security audit`.", ], ]); }); diff --git a/src/gateway/server-startup-memory.test.ts b/src/gateway/server-startup-memory.test.ts index c00b6d8a981f..e8ceb684736f 100644 --- a/src/gateway/server-startup-memory.test.ts +++ b/src/gateway/server-startup-memory.test.ts @@ -1,10 +1,6 @@ -/** - * Gateway startup memory-service tests. - */ +/** Gateway startup memory-service tests. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { MemoryQmdUpdateConfig } from "../config/types.memory.js"; -import { SecretSurfaceUnavailableError } from "../secrets/runtime-degraded-state.js"; const { getMemorySearchManagerMock, resolveMemorySearchConfigMock } = vi.hoisted(() => ({ getMemorySearchManagerMock: vi.fn(), @@ -15,8 +11,6 @@ vi.mock("../plugins/memory-runtime.js", () => ({ getActiveMemorySearchManager: getMemorySearchManagerMock, })); -// This suite owns startup orchestration; agent and memory config resolution have -// separate tests. Keep those graphs out of this non-isolated Gateway shard. vi.mock("../agents/agent-scope.js", () => ({ listAgentEntries: (cfg: OpenClawConfig) => cfg.agents?.list ?? [], listAgentIds: (cfg: OpenClawConfig) => cfg.agents?.list?.map((entry) => entry.id) ?? ["main"], @@ -30,272 +24,37 @@ vi.mock("../agents/memory-search.js", () => ({ import { startGatewayMemoryBackend } from "./server-startup-memory.js"; -function createQmdConfig( - agents: OpenClawConfig["agents"], - update: MemoryQmdUpdateConfig = { startup: "immediate" }, -): OpenClawConfig { - return { - agents, - memory: { backend: "qmd", qmd: { update } }, - } as OpenClawConfig; -} - function createGatewayLogMock() { return { info: vi.fn(), warn: vi.fn() }; } -function createQmdManagerMock() { - return { - search: vi.fn(), - sync: vi.fn(async () => undefined), - close: vi.fn(async () => undefined), - }; -} - -async function startMemoryBackendForTest(cfg: OpenClawConfig) { - const log = createGatewayLogMock(); - await startGatewayMemoryBackend({ cfg, log }); - return log; -} - -async function startQmdBackendWithManager(cfg: OpenClawConfig) { - getMemorySearchManagerMock.mockResolvedValue({ manager: createQmdManagerMock() }); - return await startMemoryBackendForTest(cfg); -} - -function expectNoMemoryBackendStartup(log: ReturnType) { - expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); - expect(log.info).not.toHaveBeenCalled(); - expect(log.warn).not.toHaveBeenCalled(); -} - -function expectQmdManagerRequests(cfg: OpenClawConfig, agentIds: string[]) { - expectQmdManagerRequestsWithPurpose(cfg, agentIds, "cli"); -} - -function expectQmdManagerRequestsWithPurpose( - cfg: OpenClawConfig, - agentIds: string[], - purpose: "cli" | "default", -) { - expect(getMemorySearchManagerMock).toHaveBeenCalledTimes(agentIds.length); - for (const [index, agentId] of agentIds.entries()) { - expect(getMemorySearchManagerMock).toHaveBeenNthCalledWith(index + 1, { - cfg, - agentId, - purpose, - }); - } -} - -function expectBootSyncCompleted( - log: ReturnType, - count: number, - agents: string, -) { - const noun = count === 1 ? "agent" : "agents"; - expect(log.info).toHaveBeenCalledWith( - `qmd memory startup boot sync completed for ${count} ${noun}: ${agents}`, - ); -} - describe("startGatewayMemoryBackend", () => { beforeEach(() => { getMemorySearchManagerMock.mockClear(); - resolveMemorySearchConfigMock.mockReset(); - resolveMemorySearchConfigMock.mockImplementation((cfg: OpenClawConfig, agentId: string) => { - const agent = cfg.agents?.list?.find((entry) => entry.id === agentId); - const enabled = - agent?.memorySearch?.enabled ?? cfg.agents?.defaults?.memorySearch?.enabled ?? true; - return enabled ? {} : null; - }); + resolveMemorySearchConfigMock.mockReset().mockReturnValue({}); }); it("skips initialization when memory backend is not qmd", async () => { - const cfg = { - agents: { list: [{ id: "main", default: true }] }, - memory: { backend: "builtin" }, - } as OpenClawConfig; - - const log = await startMemoryBackendForTest(cfg); - - expectNoMemoryBackendStartup(log); - }); - - it("keeps qmd managers lazy when startup refresh is not opted in", async () => { - const cfg = { - agents: { list: [{ id: "main", default: true }] }, - memory: { backend: "qmd", qmd: {} }, - } as OpenClawConfig; - - const log = await startMemoryBackendForTest(cfg); - - expectNoMemoryBackendStartup(log); - }); - - it("runs qmd boot sync for the default and explicitly configured agents", async () => { - const cfg = createQmdConfig( - { - list: [ - { id: "ops", default: true }, - { id: "main", memorySearch: { enabled: true } }, - { id: "lazy" }, - ], - }, - { startup: "immediate", interval: "0s", embedInterval: "0s" }, - ); - - const log = await startQmdBackendWithManager(cfg); - - expectQmdManagerRequests(cfg, ["ops", "main"]); - expectBootSyncCompleted(log, 2, '"ops", "main"'); - expect(log.info).toHaveBeenCalledWith( - 'qmd memory startup initialization deferred for 1 agent: "lazy"', - ); - expect(log.warn).not.toHaveBeenCalled(); - }); - - it("initializes all qmd agents when memory search is explicitly enabled in defaults", async () => { - const cfg = createQmdConfig( - { - defaults: { memorySearch: { enabled: true } }, - list: [{ id: "ops", default: true }, { id: "main" }], - }, - { startup: "immediate", interval: "0s", embedInterval: "0s" }, - ); - - const log = await startQmdBackendWithManager(cfg); - - expectQmdManagerRequests(cfg, ["ops", "main"]); - expectBootSyncCompleted(log, 2, '"ops", "main"'); - expect(log.info.mock.calls.some(([message]) => String(message).includes("deferred"))).toBe( - false, - ); - }); - - it("logs a warning when qmd manager init fails and continues with other agents", async () => { - const cfg = createQmdConfig( - { - list: [ - { id: "main", default: true }, - { id: "ops", memorySearch: { enabled: true } }, - ], - }, - { startup: "immediate", interval: "0s", embedInterval: "0s" }, - ); const log = createGatewayLogMock(); - getMemorySearchManagerMock - .mockResolvedValueOnce({ manager: null, error: "qmd missing" }) - .mockResolvedValueOnce({ manager: createQmdManagerMock() }); - - await startGatewayMemoryBackend({ cfg, log }); - - expect(log.warn).toHaveBeenCalledWith( - 'qmd memory startup initialization failed for agent "main": qmd missing', - ); - expectBootSyncCompleted(log, 1, '"ops"'); - }); - - it("skips an unavailable memory owner and initializes healthy agents", async () => { - const cfg = createQmdConfig( - { - defaults: { memorySearch: { enabled: true } }, - list: [{ id: "cold", default: true }, { id: "healthy" }], - }, - { startup: "immediate", interval: "0s", embedInterval: "0s" }, - ); - resolveMemorySearchConfigMock.mockImplementation((_cfg: OpenClawConfig, agentId: string) => { - if (agentId === "cold") { - throw new SecretSurfaceUnavailableError({ - ownerKind: "capability", - ownerId: "memory-provider:cold", - state: "unavailable", - paths: ["agents.defaults.memorySearch.remote.apiKey"], - refKeys: ["env:default:MISSING_MEMORY_KEY"], - reason: "secret reference was not found", - }); - } - return {}; + await startGatewayMemoryBackend({ + cfg: { agents: { list: [{ id: "main", default: true }] }, memory: { backend: "builtin" } }, + log, }); - const log = await startQmdBackendWithManager(cfg); - - expectQmdManagerRequests(cfg, ["healthy"]); - expect(log.warn).toHaveBeenCalledWith( - expect.stringContaining('memory startup unavailable for agent "cold"'), - ); - expectBootSyncCompleted(log, 1, '"healthy"'); - }); - - it("skips agents with memory search disabled", async () => { - const cfg = createQmdConfig( - { - defaults: { memorySearch: { enabled: true } }, - list: [ - { id: "main", default: true }, - { id: "ops", memorySearch: { enabled: false } }, - ], - }, - { startup: "immediate", interval: "0s", embedInterval: "0s" }, - ); - - const log = await startQmdBackendWithManager(cfg); - - expectQmdManagerRequests(cfg, ["main"]); - expectBootSyncCompleted(log, 1, '"main"'); + expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); + expect(log.info).not.toHaveBeenCalled(); expect(log.warn).not.toHaveBeenCalled(); }); - it("does not initialize qmd managers when background work is disabled", async () => { - const cfg = { - agents: { list: [{ id: "main", default: true }] }, - memory: { - backend: "qmd", - qmd: { - update: { startup: "immediate", onBoot: false, interval: "0s", embedInterval: "0s" }, - }, - }, - } as OpenClawConfig; + it("keeps qmd managers lazy under the fixed startup policy", async () => { + const log = createGatewayLogMock(); + await startGatewayMemoryBackend({ + cfg: { agents: { list: [{ id: "main", default: true }] }, memory: { backend: "qmd" } }, + log, + }); - const log = await startMemoryBackendForTest(cfg); - - expectNoMemoryBackendStartup(log); - }); - - it("keeps the full qmd manager alive for startup interval maintenance", async () => { - const manager = createQmdManagerMock(); - getMemorySearchManagerMock.mockResolvedValue({ manager }); - const cfg = createQmdConfig( - { list: [{ id: "main", default: true }] }, - { startup: "immediate", onBoot: false, interval: "5m", embedInterval: "0s" }, - ); - - const log = await startMemoryBackendForTest(cfg); - - expectQmdManagerRequestsWithPurpose(cfg, ["main"], "default"); - expect(manager.sync).not.toHaveBeenCalled(); - expect(manager.close).not.toHaveBeenCalled(); - expect(log.info).toHaveBeenCalledWith( - 'qmd memory startup manager initialized for 1 agent: "main"', - ); + expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); + expect(log.info).not.toHaveBeenCalled(); expect(log.warn).not.toHaveBeenCalled(); }); - - it("does not manually boot sync full qmd managers that own their startup update", async () => { - const manager = createQmdManagerMock(); - getMemorySearchManagerMock.mockResolvedValue({ manager }); - const cfg = createQmdConfig( - { list: [{ id: "main", default: true }] }, - { startup: "immediate", onBoot: true, interval: "5m", embedInterval: "0s" }, - ); - - const log = await startMemoryBackendForTest(cfg); - - expectQmdManagerRequestsWithPurpose(cfg, ["main"], "default"); - expect(manager.sync).not.toHaveBeenCalled(); - expect(manager.close).not.toHaveBeenCalled(); - expect(log.info).toHaveBeenCalledWith( - 'qmd memory startup manager initialized for 1 agent: "main"', - ); - }); }); diff --git a/src/gateway/server-startup-memory.ts b/src/gateway/server-startup-memory.ts index 9f335a04f364..a3082e7e2edc 100644 --- a/src/gateway/server-startup-memory.ts +++ b/src/gateway/server-startup-memory.ts @@ -26,7 +26,7 @@ function shouldKeepQmdStartupManagerAlive(qmd: ResolvedQmdConfig): boolean { /** Check whether an agent overrides memory search instead of inheriting defaults. */ function hasExplicitAgentMemorySearchConfig(cfg: OpenClawConfig, agentId: string): boolean { return listAgentEntries(cfg).some( - (entry) => normalizeAgentId(entry.id) === agentId && entry.memorySearch != null, + (entry) => normalizeAgentId(entry.id) === agentId && entry.memory?.search != null, ); } @@ -42,7 +42,7 @@ function shouldEagerlyStartAgentMemory(params: { if (params.agentId === resolveDefaultAgentId(params.cfg)) { return true; } - if (params.cfg.agents?.defaults?.memorySearch?.enabled === true) { + if (params.cfg.memory?.search?.enabled === true) { return true; } return hasExplicitAgentMemorySearchConfig(params.cfg, params.agentId); diff --git a/src/gateway/server-startup-outcomes.test.ts b/src/gateway/server-startup-outcomes.test.ts index d53426303b5c..34cbf841e675 100644 --- a/src/gateway/server-startup-outcomes.test.ts +++ b/src/gateway/server-startup-outcomes.test.ts @@ -69,7 +69,7 @@ describe("gateway startup outcomes", () => { internal: { enabled: true }, gmail: { account: "operator@example.com", model: "openai/gpt-5.5" }, }, - memory: { backend: "qmd", qmd: { update: { startup: "immediate" } } }, + memory: { backend: "qmd" }, }, gatewayStartHooks: true, memoryStartupMode: "immediate", diff --git a/src/gateway/server-startup-plugins.test.ts b/src/gateway/server-startup-plugins.test.ts index 2a6efa3189cc..9cc55f71b717 100644 --- a/src/gateway/server-startup-plugins.test.ts +++ b/src/gateway/server-startup-plugins.test.ts @@ -464,13 +464,15 @@ describe("loadGatewayStartupPluginRuntime", () => { await loadGatewayStartupPluginRuntime({ cfg: { - agents: { - defaults: { - memorySearch: { - provider: "voyage", - }, + memory: { + search: { + provider: "voyage", }, }, + + agents: { + defaults: {}, + }, } as OpenClawConfig, workspaceDir: "/workspace", log, @@ -479,7 +481,7 @@ describe("loadGatewayStartupPluginRuntime", () => { }); expect(log.warn).toHaveBeenCalledWith( - expect.stringContaining('memorySearch.provider="voyage"'), + expect.stringContaining('memory.search.provider="voyage"'), ); }); @@ -489,13 +491,15 @@ describe("loadGatewayStartupPluginRuntime", () => { await loadGatewayStartupPluginRuntime({ cfg: { - agents: { - defaults: { - memorySearch: { - provider: "voyage", - }, + memory: { + search: { + provider: "voyage", }, }, + + agents: { + defaults: {}, + }, } as OpenClawConfig, workspaceDir: "/workspace", log, @@ -522,13 +526,15 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { const log = createLog(); warnUnregisteredConfiguredMemoryEmbeddingProviders({ config: { - agents: { defaults: { memorySearch: { provider: "openai" } } }, + memory: { search: { provider: "openai" } }, + + agents: { defaults: {} }, } as OpenClawConfig, pluginRegistry: registry([]), log, }); expect(log.warn).toHaveBeenCalledTimes(1); - expect(String(log.warn.mock.calls[0]?.[0])).toContain('memorySearch.provider="openai"'); + expect(String(log.warn.mock.calls[0]?.[0])).toContain('memory.search.provider="openai"'); }); it("does not warn when the configured memory embedding provider is registered", async () => { @@ -537,7 +543,9 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { const log = createLog(); warnUnregisteredConfiguredMemoryEmbeddingProviders({ config: { - agents: { defaults: { memorySearch: { provider: "openai" } } }, + memory: { search: { provider: "openai" } }, + + agents: { defaults: {} }, } as OpenClawConfig, pluginRegistry: registry(["openai"]), log, @@ -551,13 +559,15 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { const log = createLog(); warnUnregisteredConfiguredMemoryEmbeddingProviders({ config: { - agents: { defaults: { memorySearch: { provider: "openai", fallback: "ollama" } } }, + memory: { search: { provider: "openai", fallback: "ollama" } }, + + agents: { defaults: {} }, } as OpenClawConfig, pluginRegistry: registry(["openai"]), log, }); expect(log.warn).toHaveBeenCalledTimes(1); - expect(String(log.warn.mock.calls[0]?.[0])).toContain('memorySearch.fallback="ollama"'); + expect(String(log.warn.mock.calls[0]?.[0])).toContain('memory.search.fallback="ollama"'); }); it("does not warn when the configured memory embedding fallback is registered", async () => { @@ -566,7 +576,9 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { const log = createLog(); warnUnregisteredConfiguredMemoryEmbeddingProviders({ config: { - agents: { defaults: { memorySearch: { provider: "openai", fallback: "ollama" } } }, + memory: { search: { provider: "openai", fallback: "ollama" } }, + + agents: { defaults: {} }, } as OpenClawConfig, pluginRegistry: registry(["openai", "ollama"]), log, @@ -580,7 +592,9 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { const log = createLog(); warnUnregisteredConfiguredMemoryEmbeddingProviders({ config: { - agents: { defaults: { memorySearch: { provider: "generic-embed" } } }, + memory: { search: { provider: "generic-embed" } }, + + agents: { defaults: {} }, } as OpenClawConfig, pluginRegistry: registry([], { embeddingProviderIds: ["generic-embed"] }), log, @@ -594,7 +608,9 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { const log = createLog(); warnUnregisteredConfiguredMemoryEmbeddingProviders({ config: { - agents: { defaults: { memorySearch: { provider: "openai-compatible" } } }, + memory: { search: { provider: "openai-compatible" } }, + + agents: { defaults: {} }, } as OpenClawConfig, pluginRegistry: registry([]), log, @@ -608,7 +624,9 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { const log = createLog(); warnUnregisteredConfiguredMemoryEmbeddingProviders({ config: { - agents: { defaults: { memorySearch: { provider: "tenant-embeddings" } } }, + memory: { search: { provider: "tenant-embeddings" } }, + + agents: { defaults: {} }, models: { providers: { "tenant-embeddings": { @@ -631,7 +649,9 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { const log = createLog(); warnUnregisteredConfiguredMemoryEmbeddingProviders({ config: { - agents: { defaults: { memorySearch: { provider: "none", fallback: "openai" } } }, + memory: { search: { provider: "none", fallback: "openai" } }, + + agents: { defaults: {} }, } as OpenClawConfig, pluginRegistry: registry([]), log, @@ -645,7 +665,9 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { const log = createLog(); warnUnregisteredConfiguredMemoryEmbeddingProviders({ config: { - agents: { defaults: { memorySearch: { provider: "openai", fallback: "ollama" } } }, + memory: { search: { provider: "openai", fallback: "ollama" } }, + + agents: { defaults: {} }, plugins: { slots: { memory: "none" } }, } as OpenClawConfig, pluginRegistry: registry([]), @@ -660,7 +682,7 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { ? { provider: "ollama-5080" } : { provider: "openai", fallback: "ollama-5080" }; return { - agents: { defaults: { memorySearch } }, + memory: { search: memorySearch }, models: { providers: { "ollama-5080": { @@ -701,7 +723,7 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { log, }); expect(log.warn).toHaveBeenCalledTimes(1); - expect(String(log.warn.mock.calls[0]?.[0])).toContain('memorySearch.provider="ollama-5080"'); + expect(String(log.warn.mock.calls[0]?.[0])).toContain('memory.search.provider="ollama-5080"'); }); it("warns for custom fallbacks whose api-owner plugin is not registered", async () => { @@ -714,7 +736,7 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { log, }); expect(log.warn).toHaveBeenCalledTimes(1); - expect(String(log.warn.mock.calls[0]?.[0])).toContain('memorySearch.fallback="ollama-5080"'); + expect(String(log.warn.mock.calls[0]?.[0])).toContain('memory.search.fallback="ollama-5080"'); }); it("warns for local memory search when the llama.cpp provider is not registered", async () => { @@ -723,12 +745,14 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { const log = createLog(); warnUnregisteredConfiguredMemoryEmbeddingProviders({ config: { + memory: { search: { provider: "local", fallback: "auto" } }, + agents: { - defaults: { memorySearch: { provider: "local", fallback: "auto" } }, + defaults: {}, list: [ { id: "muted", - memorySearch: { enabled: false, provider: "openai", fallback: "ollama" }, + memory: { search: { enabled: false, provider: "openai", fallback: "ollama" } }, }, ], }, @@ -737,7 +761,7 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { log, }); expect(log.warn).toHaveBeenCalledTimes(1); - expect(String(log.warn.mock.calls[0]?.[0])).toContain('memorySearch.provider="local"'); + expect(String(log.warn.mock.calls[0]?.[0])).toContain('memory.search.provider="local"'); }); it("does not warn for disabled memory search providers", async () => { @@ -750,7 +774,7 @@ describe("warnUnregisteredConfiguredMemoryEmbeddingProviders", () => { list: [ { id: "muted", - memorySearch: { enabled: false, provider: "openai", fallback: "ollama" }, + memory: { search: { enabled: false, provider: "openai", fallback: "ollama" } }, }, ], }, diff --git a/src/gateway/server-startup-plugins.ts b/src/gateway/server-startup-plugins.ts index b052fde015d2..444614bd12c8 100644 --- a/src/gateway/server-startup-plugins.ts +++ b/src/gateway/server-startup-plugins.ts @@ -233,7 +233,7 @@ export async function prepareGatewayPluginBootstrap(params: { } /** - * Warn when `agents.*.memorySearch.provider` selects a memory embedding provider + * Warn when `memory.search.provider` selects a memory embedding provider * that no loaded plugin registered. Without the owning plugin, `active-memory` * cannot embed and silently falls back to keyword/FTS-only recall. */ @@ -247,7 +247,7 @@ export function warnUnregisteredConfiguredMemoryEmbeddingProviders(params: { registeredProviderIds: collectRegisteredEmbeddingProviderIds(params.pluginRegistry), }); for (const provider of unregistered) { - const path = `memorySearch.${provider.source}`; + const path = `memory.search.${provider.source}`; params.log.warn( `${path}="${provider.configuredId}" is configured, but no loaded plugin registered a memory embedding provider that can serve "${provider.configuredId}". Semantic memory recall will fall back to keyword/FTS-only search. Ensure the plugin that provides "${provider.configuredId}" is installed and enabled.`, ); diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index e6a77776bf61..439a65b392db 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -918,76 +918,6 @@ describe("startGatewayPostAttachRuntime", () => { expect( testing.resolveGatewayMemoryStartupPolicy({ memory: { backend: "qmd" } } as never), ).toEqual({ mode: "off" }); - expect( - testing.resolveGatewayMemoryStartupPolicy({ - memory: { backend: "qmd", qmd: { update: { startup: "immediate", onBoot: false } } }, - } as never), - ).toEqual({ mode: "immediate" }); - }); - - it("allows qmd startup initialization when manager-start boot sync is disabled", async () => { - await startGatewayPostAttachRuntime({ - ...createPostAttachParams(), - gatewayPluginConfigAtStart: { - hooks: { internal: { enabled: false } }, - memory: { backend: "qmd", qmd: { update: { startup: "immediate", onBoot: false } } }, - } as never, - }); - - await waitForGatewayTestState(() => { - expect(hoisted.startGatewayMemoryBackend).toHaveBeenCalledTimes(1); - }); - }); - - it("starts the qmd memory backend when startup refresh is immediate", async () => { - await startGatewayPostAttachRuntime({ - ...createPostAttachParams(), - gatewayPluginConfigAtStart: { - hooks: { internal: { enabled: false } }, - memory: { backend: "qmd", qmd: { update: { startup: "immediate" } } }, - } as never, - }); - - await waitForGatewayTestState(() => { - expect(hoisted.startGatewayMemoryBackend).toHaveBeenCalledTimes(1); - }); - }); - - it("defers qmd memory backend startup refresh until the idle delay elapses", async () => { - vi.useFakeTimers(); - try { - await startGatewaySidecars({ - cfg: { - hooks: { internal: { enabled: false } }, - memory: { backend: "qmd", qmd: { update: { startup: "idle", startupDelayMs: 25 } } }, - } as never, - pluginRegistry: createPostAttachParams().pluginRegistry, - defaultWorkspaceDir: "/tmp/openclaw-workspace", - deps: {} as never, - startChannels: vi.fn(async () => {}), - log: { warn: vi.fn() }, - logHooks: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - logChannels: { - info: vi.fn(), - error: vi.fn(), - }, - }); - - expect(hoisted.startGatewayMemoryBackend).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(24); - expect(hoisted.startGatewayMemoryBackend).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1); - - await waitForGatewayTestState(() => { - expect(hoisted.startGatewayMemoryBackend).toHaveBeenCalledTimes(1); - }); - } finally { - vi.useRealTimers(); - } }); it("cleans startup session locks with bounded concurrency", async () => { diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 2d2f67d227b2..46e81e26a25e 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -44,7 +44,6 @@ const AGENT_RUNTIME_PLUGIN_PREWARM_START_DELAY_MS = 0; const DEFERRED_SIDECAR_START_DELAY_MS = 100; const SESSION_LOCK_CLEANUP_CONCURRENCY = 4; const SKIP_STARTUP_MODEL_PREWARM_ENV = "OPENCLAW_SKIP_STARTUP_MODEL_PREWARM"; -const QMD_STARTUP_IDLE_DELAY_MS = 120_000; type Awaitable = T | Promise; type GatewayStartupTrace = { detail: (name: string, metrics: ReadonlyArray) => void; @@ -133,21 +132,7 @@ function shouldSkipStartupModelPrewarm(env: NodeJS.ProcessEnv = process.env): bo } function resolveGatewayMemoryStartupPolicy(cfg: OpenClawConfig): GatewayMemoryStartupPolicy { - if (cfg.memory?.backend !== "qmd") { - return { mode: "off" }; - } - const startup = cfg.memory.qmd?.update?.startup; - if (startup === "immediate") { - return { mode: "immediate" }; - } - if (startup === "idle") { - const rawDelayMs = cfg.memory.qmd?.update?.startupDelayMs; - const delayMs = - typeof rawDelayMs === "number" && Number.isFinite(rawDelayMs) && rawDelayMs >= 0 - ? Math.floor(rawDelayMs) - : QMD_STARTUP_IDLE_DELAY_MS; - return { mode: "idle", delayMs }; - } + void cfg; return { mode: "off" }; } diff --git a/src/gateway/server-startup-secret-owner-isolation.test.ts b/src/gateway/server-startup-secret-owner-isolation.test.ts index 64b03f1e609e..f82448ceb9d8 100644 --- a/src/gateway/server-startup-secret-owner-isolation.test.ts +++ b/src/gateway/server-startup-secret-owner-isolation.test.ts @@ -138,12 +138,10 @@ describe("Gateway startup SecretRef owner isolation", () => { async () => { await writeConfig({ ...baseConfig(), - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: { source: "env", provider: "default", id: "MISSING_TTS_KEY" }, - }, + tts: { + providers: { + elevenlabs: { + apiKey: { source: "env", provider: "default", id: "MISSING_TTS_KEY" }, }, }, }, @@ -180,7 +178,7 @@ describe("Gateway startup SecretRef owner isolation", () => { }), expect.objectContaining({ code: "SECRETS_OWNER_UNAVAILABLE", - path: "messages.tts.providers.elevenlabs.apiKey", + path: "tts.providers.elevenlabs.apiKey", }), ]), ); @@ -219,12 +217,10 @@ describe("Gateway startup SecretRef owner isolation", () => { }, }, }, - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: { source: "exec", provider: "vault", id: "tts/elevenlabs" }, - }, + tts: { + providers: { + elevenlabs: { + apiKey: { source: "exec", provider: "vault", id: "tts/elevenlabs" }, }, }, }, @@ -308,12 +304,10 @@ describe("Gateway startup SecretRef owner isolation", () => { }, }, }, - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: { source: "exec", provider: "vault", id: "tts/elevenlabs" }, - }, + tts: { + providers: { + elevenlabs: { + apiKey: { source: "exec", provider: "vault", id: "tts/elevenlabs" }, }, }, }, @@ -340,7 +334,7 @@ describe("Gateway startup SecretRef owner isolation", () => { }), expect.objectContaining({ code: "SECRETS_OWNER_UNAVAILABLE", - path: "messages.tts.providers.elevenlabs.apiKey", + path: "tts.providers.elevenlabs.apiKey", }), ]), ); @@ -358,15 +352,17 @@ describe("Gateway startup SecretRef owner isolation", () => { await withEnvAsync({ MISSING_MEMORY_KEY: undefined }, async () => { await writeConfig({ ...baseConfig(), - agents: { - defaults: { - memorySearch: { - remote: { - apiKey: { source: "env", provider: "default", id: "MISSING_MEMORY_KEY" }, - }, + memory: { + search: { + remote: { + apiKey: { source: "env", provider: "default", id: "MISSING_MEMORY_KEY" }, }, }, }, + + agents: { + defaults: {}, + }, }); const port = await getFreePort(); @@ -399,29 +395,33 @@ describe("Gateway startup SecretRef owner isolation", () => { }); }); - it("reaches /readyz with one cold media model", async () => { + it("reaches /readyz with one cold media transport provider", async () => { await withEnvAsync({ MISSING_MEDIA_MODEL_VALUE: undefined }, async () => { await writeConfig({ ...baseConfig(), + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { + source: "env", + provider: "default", + id: "MISSING_MEDIA_MODEL_VALUE", + }, + models: [], + }, + }, + }, tools: { media: { + models: [ + { + provider: "openai", + capabilities: ["audio"], + }, + ], audio: { enabled: true, - models: [ - { - provider: "openai", - request: { - auth: { - mode: "authorization-bearer", - token: { - source: "env", - provider: "default", - id: "MISSING_MEDIA_MODEL_VALUE", - }, - }, - }, - }, - ], }, }, }, @@ -434,8 +434,8 @@ describe("Gateway startup SecretRef owner isolation", () => { expect(ready.status).toBe(200); expect(getActiveSecretsRuntimeSnapshot()?.degradedOwners).toMatchObject([ { - ownerKind: "capability", - ownerId: "media-model:audio:0", + ownerKind: "provider", + ownerId: "openai", state: "unavailable", }, ]); @@ -461,7 +461,7 @@ describe("Gateway startup SecretRef owner isolation", () => { }, }, }, - list: [{ id: "cold" }], + entries: { cold: {} }, }, }); @@ -513,12 +513,10 @@ describe("Gateway startup SecretRef owner isolation", () => { token: { source: "env", provider: "default", id: "GATEWAY_TOKEN_REF" }, }, }, - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: { source: "env", provider: "default", id: "MISSING_TTS_KEY" }, - }, + tts: { + providers: { + elevenlabs: { + apiKey: { source: "env", provider: "default", id: "MISSING_TTS_KEY" }, }, }, }, diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 6eee33502c8c..b23f49972262 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -30,7 +30,6 @@ import { TRUSTED_PROXY_CONTROL_UI_HEADERS, waitForWsClose, withGatewayServer, - writeTrustedProxyControlUiConfig, } from "./server.auth.test-helpers.js"; const operatorIdentityPathByPrefix = new Map(); @@ -140,35 +139,6 @@ export function registerControlUiAndPairingSuite(): void { } }; - const expectStatusAndHealthOk = async (ws: WebSocket) => { - const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(true); - const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(true); - }; - - const expectAdminRpcOk = async (ws: WebSocket) => { - const admin = await rpcReq(ws, "set-heartbeats", { enabled: false }); - expect(admin.ok).toBe(true); - }; - - const connectControlUiWithoutDeviceAndExpectOk = async (params: { - ws: WebSocket; - token?: string; - password?: string; - client?: { id: string; version: string; platform: string; mode: string }; - }) => { - const res = await connectReq(params.ws, { - ...(params.token ? { token: params.token } : {}), - ...(params.password ? { password: params.password } : {}), - device: null, - client: { ...(params.client ?? CONTROL_UI_CLIENT) }, - }); - expect(res.ok).toBe(true); - await expectStatusAndHealthOk(params.ws); - await expectAdminRpcOk(params.ws); - }; - const createOperatorIdentityFixture = async (identityPrefix: string) => { const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); let identityPath = operatorIdentityPathByPrefix.get(identityPrefix); @@ -566,187 +536,6 @@ export function registerControlUiAndPairingSuite(): void { }); }); - test("allows localhost ui clients without device identity when insecure auth is enabled", async () => { - testState.gatewayControlUi = { allowInsecureAuth: true }; - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret", { - wsHeaders: { origin: "http://127.0.0.1" }, - }); - let tuiWs: WebSocket | undefined; - try { - await connectControlUiWithoutDeviceAndExpectOk({ ws, token: "secret" }); - - tuiWs = await openWs(port); - await connectControlUiWithoutDeviceAndExpectOk({ - ws: tuiWs, - token: "secret", - client: { - id: GATEWAY_CLIENT_NAMES.TUI, - version: "1.0.0", - platform: "darwin", - mode: GATEWAY_CLIENT_MODES.UI, - }, - }); - } finally { - ws.close(); - tuiWs?.close(); - await Promise.all([ - waitForWsClose(ws, 1_000), - ...(tuiWs ? [waitForWsClose(tuiWs, 1_000)] : []), - ]); - await server.close(); - restoreGatewayToken(prevToken); - } - }); - - test("allows control ui password-only auth on localhost when insecure auth is enabled", async () => { - testState.gatewayControlUi = { allowInsecureAuth: true }; - testState.gatewayAuth = { mode: "password", password: "secret" }; // pragma: allowlist secret - await withControlUiGatewayServer(async ({ port }) => { - const ws = await openWs(port, { origin: originForPort(port) }); - await connectControlUiWithoutDeviceAndExpectOk({ ws, password: "secret" }); // pragma: allowlist secret - ws.close(); - }); - }); - - test("does not bypass pairing for control ui device identity when insecure auth is enabled", async () => { - testState.gatewayControlUi = { - allowInsecureAuth: true, - allowedOrigins: ["https://localhost"], - }; - testState.gatewayAuth = { mode: "token", token: "secret" }; - await writeTrustedProxyControlUiConfig({ allowInsecureAuth: true }); - const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; - process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; - try { - await withControlUiGatewayServer(async ({ port }) => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`, { - headers: { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.10", - }, - }); - const challengePromise = onceMessage( - ws, - (o) => o.type === "event" && o.event === "connect.challenge", - ); - await new Promise((resolve) => { - ws.once("open", resolve); - }); - const challenge = await challengePromise; - const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; - expect(typeof nonce).toBe("string"); - const { identityPath } = await createOperatorIdentityFixture("openclaw-controlui-device-"); - const scopes = [ - "operator.admin", - "operator.read", - "operator.write", - "operator.approvals", - "operator.pairing", - ]; - const { device } = await createSignedDevice({ - token: "secret", - scopes, - clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, - clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, - identityPath, - nonce: String(nonce), - }); - const res = await connectReq(ws, { - token: "secret", - scopes, - device, - client: { - ...CONTROL_UI_CLIENT, - }, - }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("pairing required"); - expect((res.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.PAIRING_REQUIRED, - ); - ws.close(); - }); - } finally { - restoreGatewayToken(prevToken); - } - }); - - test("allows control ui auth bypasses when device auth is disabled", async () => { - testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true }; - testState.gatewayAuth = { mode: "token", token: "secret" }; - const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; - process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; - try { - await withControlUiGatewayServer(async ({ port }) => { - const staleDeviceWs = await openWs(port, { origin: originForPort(port) }); - const challengeNonce = await readConnectChallengeNonce(staleDeviceWs); - if (!challengeNonce) { - throw new Error("expected stale device challenge nonce"); - } - const { device } = await createSignedDevice({ - token: "secret", - scopes: [], - clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, - clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, - signedAtMs: Date.now() - 60 * 60 * 1000, - nonce: challengeNonce, - }); - const res = await connectReq(staleDeviceWs, { - token: "secret", - scopes: ["operator.read"], - device, - client: { - ...CONTROL_UI_CLIENT, - }, - }); - expect(res.ok).toBe(true); - const helloOk = res.payload as - | { - auth?: { - role?: unknown; - scopes?: unknown; - deviceToken?: unknown; - }; - } - | undefined; - expect(helloOk?.auth?.role).toBe("operator"); - expect(helloOk?.auth?.scopes).toEqual(["operator.read"]); - expect(helloOk?.auth?.deviceToken).toBeUndefined(); - const health = await rpcReq(staleDeviceWs, "health"); - expect(health.ok).toBe(true); - staleDeviceWs.close(); - - const scopedWs = await openWs(port, { origin: originForPort(port) }); - const scopedRes = await connectReq(scopedWs, { - token: "secret", - scopes: ["operator.read"], - client: { - ...CONTROL_UI_CLIENT, - }, - }); - expect(scopedRes.ok, "requested scope bypass").toBe(true); - const scopedHelloOk = scopedRes.payload as - | { - auth?: { - role?: unknown; - scopes?: unknown; - deviceToken?: unknown; - }; - } - | undefined; - expect(scopedHelloOk?.auth?.role).toBe("operator"); - expect(scopedHelloOk?.auth?.scopes).toEqual(["operator.read"]); - expect(scopedHelloOk?.auth?.deviceToken).toBeUndefined(); - - const scopedHealth = await rpcReq(scopedWs, "health"); - expect(scopedHealth.ok).toBe(true); - scopedWs.close(); - }); - } finally { - restoreGatewayToken(prevToken); - } - }); - test("device token auth matrix", async () => { const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); const { identity, deviceToken, deviceIdentityPath } = diff --git a/src/gateway/server.auth.test-helpers.ts b/src/gateway/server.auth.test-helpers.ts index 471a8140fa75..f8edd4010b24 100644 --- a/src/gateway/server.auth.test-helpers.ts +++ b/src/gateway/server.auth.test-helpers.ts @@ -257,23 +257,6 @@ async function configureTrustedProxyControlUiAuth() { }); } -async function writeTrustedProxyControlUiConfig(params?: { allowInsecureAuth?: boolean }) { - const { replaceConfigFile } = await import("../config/config.js"); - const nextConfig = { - gateway: { - trustedProxies: ["127.0.0.1"], - controlUi: { - allowedOrigins: ["https://localhost"], - ...(params?.allowInsecureAuth ? { allowInsecureAuth: true } : {}), - }, - }, - }; - await replaceConfigFile({ - nextConfig, - afterWrite: { mode: "auto" }, - }); -} - function isConnectResMessage(id: string) { return (o: unknown) => { if (!o || typeof o !== "object" || Array.isArray(o)) { @@ -421,7 +404,6 @@ export { waitForWsClose, withGatewayServer, withRuntimeVersionEnv, - writeTrustedProxyControlUiConfig, }; export { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js"; export { resolvePreauthHandshakeTimeoutMs } from "./handshake-timeouts.js"; diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 4bcbbdfad2c6..109320bcef61 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -688,7 +688,7 @@ describe("gateway server chat", () => { "openai/gpt-main": {}, }, }, - list: [{ id: "main", default: true }], + entries: { main: { default: true } }, }, models: { providers: { @@ -864,7 +864,7 @@ describe("gateway server chat", () => { model: { primary: "openai/gpt-5.5" }, models: { "openai/gpt-5.5": {} }, }, - list: [{ id: "main", default: true }, { id: "work" }], + entries: { main: { default: true }, work: {} }, }, auth: { order: { openai: ["openai:api", "openai:chatgpt", "openai:expired"] }, @@ -1126,7 +1126,7 @@ describe("gateway server chat", () => { "openai/*": {}, }, }, - list: [{ id: "main", default: true }], + entries: { main: { default: true } }, }, models: { providers: { @@ -1170,10 +1170,9 @@ describe("gateway server chat", () => { "openai/gpt-main": {}, }, }, - list: [ - { id: "main", default: true }, - { - id: "work", + entries: { + main: { default: true }, + work: { model: { primary: "minimax/MiniMax-M2.7-highspeed", }, @@ -1181,7 +1180,7 @@ describe("gateway server chat", () => { "minimax/MiniMax-M2.7-highspeed": {}, }, }, - ], + }, }, models: { providers: { @@ -1291,15 +1290,14 @@ describe("gateway server chat", () => { "openai/gpt-main": {}, }, }, - list: [ - { id: "main", default: true }, - { - id: "work", + entries: { + main: { default: true }, + work: { model: { primary: "minimax/MiniMax-M2.7-highspeed", }, }, - ], + }, }, models: { providers: { @@ -5967,7 +5965,7 @@ describe("gateway server chat", () => { await writeGatewayConfig({ session: { scope: "global" }, agents: { - list: [{ id: "main", default: true }, { id: "work" }], + entries: { main: { default: true }, work: {} }, }, }); await connectOk(ws); diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index 29e34114d25b..f827ce674bf4 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -144,10 +144,6 @@ function makeRouteBinding(index: number) { }; } -function makeAgentEntry(id: string, extra: Record = {}) { - return { id, ...extra }; -} - async function expectSchemaLookupInvalid(pathValue: unknown) { const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.schema.lookup", { pathValue }); expect(res.ok).toBe(false); @@ -531,7 +527,6 @@ describe("gateway config methods", () => { name, { cdpPort: 18991 + index, - color: "#0066CC", constructor: { polluted: true }, prototype: { polluted: true }, }, @@ -902,42 +897,14 @@ describe("gateway config methods", () => { } }); - it("uses replacePaths to replace id-keyed arrays instead of merging by id", async () => { - const original = await getCurrentConfigObject(); - const agents = { - ...(original.config.agents as Record | undefined), - list: [makeAgentEntry("main", { default: true }), makeAgentEntry("worker")], - }; - const seed = await sendConfigApply( - configRawPayload({ ...original.config, agents }, original.hash), - ); - expect(seed.ok).toBe(true); - - try { - const before = await getCurrentConfigObject(); - const replacement = [makeAgentEntry("main", { default: true })]; - const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", { - raw: JSON.stringify({ agents: { list: replacement } }), - baseHash: before.hash, - replacePaths: ["agents.list"], - }); - - expect(res.ok).toBe(true); - const after = await getCurrentConfigObject(); - expect((after.config.agents as { list?: unknown[] }).list).toEqual(replacement); - } finally { - await restoreConfigFileForTest(original); - } - }); - it("rejects nested destructive array patches inside id-keyed arrays without replacePaths", async () => { const original = await getCurrentConfigObject(); const agents = { ...(original.config.agents as Record | undefined), - list: [ - makeAgentEntry("main", { default: true, skills: ["alpha", "beta"] }), - makeAgentEntry("worker", { skills: ["gamma"] }), - ], + entries: { + main: { default: true, skills: ["alpha", "beta"] }, + worker: { skills: ["gamma"] }, + }, }; const seed = await sendConfigApply( configRawPayload({ ...original.config, agents }, original.hash), @@ -947,17 +914,19 @@ describe("gateway config methods", () => { try { const before = await getCurrentConfigObject(); const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", { - raw: JSON.stringify({ agents: { list: [{ id: "main", skills: ["alpha"] }] } }), + raw: JSON.stringify({ agents: { entries: { main: { skills: ["alpha"] } } } }), baseHash: before.hash, }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain( - "config.patch would remove entries from array path(s): agents.list[].skills", + "config.patch would remove entries from array path(s): agents.entries.main.skills", ); const after = await getCurrentConfigObject(); expect(after.hash).toBe(before.hash); - expect((after.config.agents as { list?: unknown[] }).list).toEqual(agents.list); + expect((after.config.agents as { entries?: Record }).entries).toEqual( + agents.entries, + ); } finally { await restoreConfigFileForTest(original); } @@ -967,10 +936,10 @@ describe("gateway config methods", () => { const original = await getCurrentConfigObject(); const agents = { ...(original.config.agents as Record | undefined), - list: [ - makeAgentEntry("main", { default: true, skills: ["alpha", "beta"] }), - makeAgentEntry("worker", { skills: ["gamma"] }), - ], + entries: { + main: { default: true, skills: ["alpha", "beta"] }, + worker: { skills: ["gamma"] }, + }, }; const seed = await sendConfigApply( configRawPayload({ ...original.config, agents }, original.hash), @@ -980,18 +949,20 @@ describe("gateway config methods", () => { try { const before = await getCurrentConfigObject(); const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", { - raw: JSON.stringify({ agents: { list: [{ id: "main", skills: ["alpha"] }] } }), + raw: JSON.stringify({ agents: { entries: { main: { skills: ["alpha"] } } } }), baseHash: before.hash, replacePaths: ["agents"], }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain( - "config.patch would remove entries from array path(s): agents.list[].skills", + "config.patch would remove entries from array path(s): agents.entries.main.skills", ); const after = await getCurrentConfigObject(); expect(after.hash).toBe(before.hash); - expect((after.config.agents as { list?: unknown[] }).list).toEqual(agents.list); + expect((after.config.agents as { entries?: Record }).entries).toEqual( + agents.entries, + ); } finally { await restoreConfigFileForTest(original); } @@ -1001,7 +972,7 @@ describe("gateway config methods", () => { const original = await getCurrentConfigObject(); const agents = { ...(original.config.agents as Record | undefined), - list: [makeAgentEntry("main", { default: true }), makeAgentEntry("worker")], + entries: { main: { default: true, skills: ["alpha"] }, worker: {} }, }; const seed = await sendConfigApply( configRawPayload({ ...original.config, agents }, original.hash), @@ -1017,7 +988,7 @@ describe("gateway config methods", () => { expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain( - "config.patch would remove entries from array path(s): agents.list", + "config.patch would remove entries from array path(s): agents.entries.main.skills", ); const after = await getCurrentConfigObject(); expect(after.hash).toBe(before.hash); @@ -1030,13 +1001,13 @@ describe("gateway config methods", () => { const original = await getCurrentConfigObject(); const agents = { ...(original.config.agents as Record | undefined), - list: [ - makeAgentEntry("main", { + entries: { + main: { default: true, subagents: { allowAgents: ["worker"] }, - }), - makeAgentEntry("worker"), - ], + }, + worker: {}, + }, }; const seed = await sendConfigApply( configRawPayload({ ...original.config, agents }, original.hash), @@ -1046,13 +1017,13 @@ describe("gateway config methods", () => { try { const before = await getCurrentConfigObject(); const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", { - raw: JSON.stringify({ agents: { list: [{ id: "main", subagents: null }] } }), + raw: JSON.stringify({ agents: { entries: { main: { subagents: null } } } }), baseHash: before.hash, }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain( - "config.patch would remove entries from array path(s): agents.list[].subagents.allowAgents", + "config.patch would remove entries from array path(s): agents.entries.main.subagents.allowAgents", ); const after = await getCurrentConfigObject(); expect(after.hash).toBe(before.hash); @@ -1065,10 +1036,10 @@ describe("gateway config methods", () => { const original = await getCurrentConfigObject(); const agents = { ...(original.config.agents as Record | undefined), - list: [ - makeAgentEntry("main", { default: true, skills: ["alpha", "beta"] }), - makeAgentEntry("worker", { skills: ["gamma"] }), - ], + entries: { + main: { default: true, skills: ["alpha", "beta"] }, + worker: { skills: ["gamma"] }, + }, }; const seed = await sendConfigApply( configRawPayload({ ...original.config, agents }, original.hash), @@ -1078,17 +1049,17 @@ describe("gateway config methods", () => { try { const before = await getCurrentConfigObject(); const res = await rpcReq<{ ok?: boolean }>(requireWs(), "config.patch", { - raw: JSON.stringify({ agents: { list: [{ id: "main", skills: ["alpha"] }] } }), + raw: JSON.stringify({ agents: { entries: { main: { skills: ["alpha"] } } } }), baseHash: before.hash, - replacePaths: ["agents.list[].skills"], + replacePaths: ["agents.entries.main.skills"], }); expect(res.ok).toBe(true); const after = await getCurrentConfigObject(); - expect((after.config.agents as { list?: unknown[] }).list).toEqual([ - makeAgentEntry("main", { default: true, skills: ["alpha"] }), - makeAgentEntry("worker", { skills: ["gamma"] }), - ]); + expect((after.config.agents as { entries?: Record }).entries).toEqual({ + main: { default: true, skills: ["alpha"] }, + worker: { skills: ["gamma"] }, + }); } finally { await restoreConfigFileForTest(original); } diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index af9c450ec851..cbb3bf028f0d 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -1094,7 +1094,7 @@ describe("gateway server cron", () => { mainKey: "main", }, agents: { - list: [{ id: "ops", default: true }], + entries: { ops: { default: true } }, }, channels: { telegram: { @@ -1190,7 +1190,7 @@ describe("gateway server cron", () => { mainKey: "main", }, agents: { - list: [{ id: "ops", default: true }], + entries: { ops: { default: true } }, }, channels: { telegram: { @@ -1221,7 +1221,7 @@ describe("gateway server cron", () => { mainKey: "main", }, agents: { - list: [{ id: "main", default: true }, { id: "ops" }], + entries: { main: { default: true }, ops: {} }, }, channels: { telegram: { @@ -1230,11 +1230,7 @@ describe("gateway server cron", () => { }, }); - const agentIds = - cronState - .getRuntimeConfig() - .agents?.list?.map((agent) => agent.id) - .filter((id): id is string => typeof id === "string") ?? []; + const agentIds = Object.keys(cronState.getRuntimeConfig().agents?.entries ?? {}); expect(agentIds).toContain("main"); expect(agentIds).toContain("ops"); @@ -1297,7 +1293,7 @@ describe("gateway server cron", () => { cronEnabled: true, }); await writeCronConfig({ - agents: { list: [{ id: "main", default: true }, { id: "writer" }] }, + agents: { entries: { main: { default: true }, writer: {} } }, }); const events = createCronEventCollector(); const cronState = await createDirectCronState({ broadcast: events["broadcast"] }); @@ -1967,7 +1963,9 @@ describe("gateway server cron", () => { await writeCronConfig({ cron: { - failureDestination: { + failureAlert: { + enabled: true, + after: 1, mode: "webhook", to: "https://hook.example/cron", }, diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index 0e2783d24e6e..d807d4c8fbf5 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -67,7 +67,7 @@ async function postHook( function setMainAndHooksAgents(): void { testState.agentsConfig = { - list: [{ id: "main", default: true }, { id: "hooks" }], + entries: { main: { default: true }, hooks: {} }, }; } @@ -979,7 +979,7 @@ describe("gateway server hooks", () => { allowedAgentIds: [], }; testState.agentsConfig = { - list: [{ id: "main", default: true }, { id: "hooks" }], + entries: { main: { default: true }, hooks: {} }, }; await withGatewayServer(async ({ port }) => { const resNoAgent = await postHook(port, "/hooks/agent", { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index a6a5dd94da1f..1f59b99fe2c3 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1322,7 +1322,7 @@ export async function startGatewayServer( sessionMessageSubscribers, listRegisteredNodePluginToolCommands: () => pluginRegistry.nodeHostCommands, nodePluginToolsEnabled: cfgAtStart.gateway?.nodes?.pluginTools?.enabled !== false, - nodeSkillsEnabled: cfgAtStart.gateway?.nodes?.skills?.enabled !== false, + nodeSkillsEnabled: cfgAtStart.gateway?.nodes?.allowSkills !== false, onPairingInvalidated: ({ nodeId, connId }) => { upsertPresence(nodeId, { reason: "disconnect" }); broadcastPresenceSnapshot({ broadcast, incrementPresenceVersion, getHealthVersion }); @@ -1614,8 +1614,8 @@ export async function startGatewayServer( removeChatRun, agentRunSeq, nodeSendToSession, - ...(typeof cfgAtStart.media?.ttlHours === "number" - ? { mediaCleanupTtlMs: resolveMediaCleanupTtlMs(cfgAtStart.media.ttlHours) } + ...(typeof cfgAtStart.attachments?.ttlHours === "number" + ? { mediaCleanupTtlMs: resolveMediaCleanupTtlMs(cfgAtStart.attachments.ttlHours) } : {}), skillsRefreshDelayMs: runtimeState.skillsRefreshDelayMs, getSkillsRefreshTimer: () => runtimeState.skillsRefreshTimer, diff --git a/src/gateway/server.node-invoke-approval-bypass.test.ts b/src/gateway/server.node-invoke-approval-bypass.test.ts index 36fe05cb9963..e6c66ed4ade0 100644 --- a/src/gateway/server.node-invoke-approval-bypass.test.ts +++ b/src/gateway/server.node-invoke-approval-bypass.test.ts @@ -262,13 +262,15 @@ describe("node.invoke approval bypass", () => { gateway: { nodes: { pairing: { autoApproveCidrs: ["127.0.0.1/32", "::1/128"] }, - allowCommands: [ - "system.run", - "system.run.prepare", - "system.which", - "browser.proxy", - "fs.listDir", - ], + commands: { + allow: [ + "system.run", + "system.run.prepare", + "system.which", + "browser.proxy", + "fs.listDir", + ], + }, }, }, }); diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index c44862c8b014..9e745b9d0034 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -680,7 +680,11 @@ describe("gateway node command allowlist", () => { await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.writeFile( configPath, - JSON.stringify({ gateway: { nodes: { denyCommands: ["canvas.snapshot"] } } }, null, 2), + JSON.stringify( + { gateway: { nodes: { commands: { deny: ["canvas.snapshot"] } } } }, + null, + 2, + ), ); await approvePendingNodePairing(nodeId, ["canvas.snapshot"]); diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 211c11a0d3a7..b0c9898443fb 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -425,7 +425,7 @@ describe("gateway talk.config", () => { }); }); - it("does not pollute Object.prototype when messages.tts.providers contains a __proto__ key", async () => { + it("does not pollute Object.prototype when tts.providers contains a __proto__ key", async () => { // Hardening regression: stripUnresolvedSecretApiKeysFromBaseTtsProviders // rebuilds the providers map with dynamic keys from operator config. Using // a plain `{}` would let `cleaned['__proto__'] = {...}` mutate @@ -441,20 +441,18 @@ describe("gateway talk.config", () => { }, }, }, - messages: { - tts: { - provider: GENERIC_TALK_PROVIDER_ID, - providers: { - [GENERIC_TALK_PROVIDER_ID]: { - apiKey: talkApiSecretRef(), - }, - // Hostile operator-config payload — not a real provider id, just - // a value-shaped key with a SecretRef-shaped apiKey to force the - // strip path. - __proto__: { - apiKey: talkApiSecretRef(), - polluted: "yes", - }, + tts: { + provider: GENERIC_TALK_PROVIDER_ID, + providers: { + [GENERIC_TALK_PROVIDER_ID]: { + apiKey: talkApiSecretRef(), + }, + // Hostile operator-config payload — not a real provider id, just + // a value-shaped key with a SecretRef-shaped apiKey to force the + // strip path. + __proto__: { + apiKey: talkApiSecretRef(), + polluted: "yes", }, }, }, diff --git a/src/gateway/server.talk-runtime.test.ts b/src/gateway/server.talk-runtime.test.ts index bdfc20d0cc29..671a9a1d952c 100644 --- a/src/gateway/server.talk-runtime.test.ts +++ b/src/gateway/server.talk-runtime.test.ts @@ -149,14 +149,12 @@ describe("gateway talk runtime", () => { expect(synthesizeParams.disableFallback).toBe(true); const ttsConfig = ( synthesizeParams.cfg as { - messages?: { - tts?: { - provider?: string; - providers?: Record; - }; + tts?: { + provider?: string; + providers?: Record; }; } - ).messages?.tts; + ).tts; expect(ttsConfig?.provider).toBe("acme"); expect(ttsConfig?.providers?.acme?.resolvedBy).toBe("acme-test-provider"); expect(ttsConfig?.providers?.acme?.voiceId).toBe("plugin-voice"); diff --git a/src/gateway/server/preauth-connection-budget.ts b/src/gateway/server/preauth-connection-budget.ts index 96f499ce2c7e..232452b7854e 100644 --- a/src/gateway/server/preauth-connection-budget.ts +++ b/src/gateway/server/preauth-connection-budget.ts @@ -3,6 +3,7 @@ import { parseStrictPositiveInteger, resolveIntegerOption, } from "@openclaw/normalization-core/number-coercion"; +import { isVitestRuntimeEnv } from "../../infra/env.js"; const DEFAULT_MAX_PREAUTH_CONNECTIONS_PER_IP = 32; const UNKNOWN_CLIENT_IP_BUDGET_KEY = "__openclaw_unknown_client_ip__"; @@ -10,7 +11,7 @@ const UNKNOWN_CLIENT_IP_BUDGET_KEY = "__openclaw_unknown_client_ip__"; function getMaxPreauthConnectionsPerIpFromEnv(env: NodeJS.ProcessEnv = process.env): number { const configured = env.OPENCLAW_MAX_PREAUTH_CONNECTIONS_PER_IP || - (env.VITEST && env.OPENCLAW_TEST_MAX_PREAUTH_CONNECTIONS_PER_IP); + (isVitestRuntimeEnv(env) ? env.OPENCLAW_TEST_MAX_PREAUTH_CONNECTIONS_PER_IP : undefined); if (!configured) { return DEFAULT_MAX_PREAUTH_CONNECTIONS_PER_IP; } diff --git a/src/gateway/server/ws-connection/connect-auth.ts b/src/gateway/server/ws-connection/connect-auth.ts index 86e52c3df471..08ee9af84d6d 100644 --- a/src/gateway/server/ws-connection/connect-auth.ts +++ b/src/gateway/server/ws-connection/connect-auth.ts @@ -252,11 +252,7 @@ export async function authenticateGatewayConnect( authOk, authMethod, }); - const preserveInsecureLocalControlUiScopes = - isControlUi && - controlUiAuthPolicy.allowInsecureAuthConfigured && - isLocalClient && - (authMethod === "token" || authMethod === "password"); + const preserveInsecureLocalControlUiScopes = false; const decision = evaluateMissingDeviceIdentity({ hasDeviceIdentity: Boolean(device), role, @@ -294,7 +290,7 @@ export async function authenticateGatewayConnect( const errorMessage = "control ui requires device identity (use HTTPS or localhost secure context)"; markHandshakeFailure("control-ui-insecure-auth", { - insecureAuthConfigured: controlUiAuthPolicy.allowInsecureAuthConfigured, + insecureAuthConfigured: false, }); sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, errorMessage, { details: { code: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED }, diff --git a/src/gateway/server/ws-connection/connect-policy.test.ts b/src/gateway/server/ws-connection/connect-policy.test.ts index 7b7f7cbb552b..5556af3a288f 100644 --- a/src/gateway/server/ws-connection/connect-policy.test.ts +++ b/src/gateway/server/ws-connection/connect-policy.test.ts @@ -96,15 +96,13 @@ describe("ws connect policy", () => { controlUiConfig: { dangerouslyDisableDeviceAuth: true }, deviceRaw: deviceRaw("dev-1"), }); - expect(bypass.allowBypass).toBe(true); - expect(bypass.device).toBeNull(); + expect(bypass.device?.id).toBe("dev-1"); const regular = authPolicy({ isControlUi: false, controlUiConfig: { dangerouslyDisableDeviceAuth: true }, deviceRaw: deviceRaw("dev-2"), }); - expect(regular.allowBypass).toBe(false); expect(regular.device?.id).toBe("dev-2"); }); @@ -143,7 +141,7 @@ describe("ws connect policy", () => { "reject-control-ui-insecure-auth", ); - // Local Control UI with allowInsecureAuth -> allowed. + // The retired insecure-auth flag no longer bypasses device identity locally. expectMissingDeviceDecision( { role: "operator", @@ -151,7 +149,7 @@ describe("ws connect policy", () => { controlUiAuthPolicy: controlUiStrict, isLocalClient: true, }, - "allow", + "reject-control-ui-insecure-auth", ); // Control UI without allowInsecureAuth, even on localhost -> rejected. @@ -230,14 +228,10 @@ describe("ws connect policy", () => { authOk: false, hasSharedAuth: false, }, - "allow", + "reject-control-ui-insecure-auth", ); - // Regression: dangerouslyDisableDeviceAuth bypass must NOT extend to node-role - // sessions — the break-glass flag is scoped to operator Control UI only. - // A device-less node-role connection must still be rejected even when the flag - // is set, to prevent the flag from being abused to admit unauthorized node - // registrations. + // Retired bypass input cannot admit device-less node-role registrations. expectMissingDeviceDecision( { role: "node", @@ -247,18 +241,18 @@ describe("ws connect policy", () => { authOk: false, hasSharedAuth: false, }, - "reject-device-required", + "reject-control-ui-insecure-auth", ); }); - test("dangerouslyDisableDeviceAuth skips pairing for operator control-ui only", () => { + test("retired device-auth bypass input does not skip pairing", () => { const bypass = authPolicy({ isControlUi: true, controlUiConfig: { dangerouslyDisableDeviceAuth: true }, }); const strict = authPolicy({ isControlUi: true }); - expectSkipPairing(bypass, "operator", true); + expectSkipPairing(bypass, "operator", false); expectSkipPairing(bypass, "node", false); expectSkipPairing(strict, "operator", false); expectSkipPairing(strict, "operator", false, { pairingComplete: true }); diff --git a/src/gateway/server/ws-connection/connect-policy.ts b/src/gateway/server/ws-connection/connect-policy.ts index 210778b225a4..86ffbc9b8590 100644 --- a/src/gateway/server/ws-connection/connect-policy.ts +++ b/src/gateway/server/ws-connection/connect-policy.ts @@ -5,33 +5,18 @@ import { roleCanSkipDeviceIdentity } from "../../role-policy.js"; type ControlUiAuthPolicy = { isControlUi: boolean; - allowInsecureAuthConfigured: boolean; - dangerouslyDisableDeviceAuth: boolean; - allowBypass: boolean; device: ConnectParams["device"] | null | undefined; }; export function resolveControlUiAuthPolicy(params: { isControlUi: boolean; - controlUiConfig: - | { - allowInsecureAuth?: boolean; - dangerouslyDisableDeviceAuth?: boolean; - } - | undefined; + controlUiConfig: unknown; deviceRaw: ConnectParams["device"] | null | undefined; }): ControlUiAuthPolicy { - const allowInsecureAuthConfigured = - params.isControlUi && params.controlUiConfig?.allowInsecureAuth === true; - const dangerouslyDisableDeviceAuth = - params.isControlUi && params.controlUiConfig?.dangerouslyDisableDeviceAuth === true; + void params.controlUiConfig; return { isControlUi: params.isControlUi, - allowInsecureAuthConfigured, - dangerouslyDisableDeviceAuth, - // `allowInsecureAuth` must not bypass secure-context/device-auth requirements. - allowBypass: dangerouslyDisableDeviceAuth, - device: dangerouslyDisableDeviceAuth ? null : params.deviceRaw, + device: params.deviceRaw, }; } @@ -55,10 +40,7 @@ export function shouldSkipControlUiPairing( if (policy.isControlUi && role === "operator" && authMode === "none") { return true; } - // dangerouslyDisableDeviceAuth is the break-glass path for Control UI - // operators. Keep pairing aligned with the missing-device bypass, including - // open-auth deployments where there is no shared token/password to prove. - return role === "operator" && policy.allowBypass; + return false; } export function isTrustedProxyControlUiOperatorAuth(params: { @@ -92,8 +74,7 @@ export function shouldClearUnboundScopesForMissingDeviceIdentity(params: { }): boolean { return ( params.decision.kind !== "allow" || - (!params.controlUiAuthPolicy.allowBypass && - !params.preserveInsecureLocalControlUiScopes && + (!params.preserveInsecureLocalControlUiScopes && (params.authMethod === "token" || params.authMethod === "password" || params.authMethod === "trusted-proxy")) @@ -118,26 +99,11 @@ export function evaluateMissingDeviceIdentity(params: { if (params.isControlUi && params.trustedProxyAuthOk) { return { kind: "allow" }; } - if (params.isControlUi && params.controlUiAuthPolicy.allowBypass && params.role === "operator") { - // dangerouslyDisableDeviceAuth: true — operator has explicitly opted out of - // device-identity enforcement for this Control UI. Allow for operator-role - // sessions only; node-role sessions must still satisfy device identity so - // that the break-glass flag cannot be abused to admit device-less node - // registrations (see #45405 review). - return { kind: "allow" }; - } if (params.localBackendSelfPairingOk && params.role === "operator") { return { kind: "allow" }; } - if (params.isControlUi && !params.controlUiAuthPolicy.allowBypass) { - // Allow localhost Control UI connections when allowInsecureAuth is configured. - // Localhost has no network interception risk, and browser SubtleCrypto - // (needed for device identity) is unavailable in insecure HTTP contexts. - // Remote connections are still rejected to preserve the MitM protection - // that the security fix (#20684) intended. - if (!params.controlUiAuthPolicy.allowInsecureAuthConfigured || !params.isLocalClient) { - return { kind: "reject-control-ui-insecure-auth" }; - } + if (params.isControlUi) { + return { kind: "reject-control-ui-insecure-auth" }; } if (roleCanSkipDeviceIdentity(params.role, params.sharedAuthOk)) { return { kind: "allow" }; diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index e02a418df51b..eee08f93c826 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -48,7 +48,6 @@ const { auth: { mode: "none" }, controlUi: { allowedOrigins: ["http://127.0.0.1:19001"], - dangerouslyDisableDeviceAuth: true, }, }, })), @@ -322,7 +321,6 @@ function connectTrustedProxyUser(connId: string) { trustedProxies: ["10.0.0.1"], controlUi: { allowedOrigins: ["http://127.0.0.1:19001"], - dangerouslyDisableDeviceAuth: true, }, }, })); @@ -540,7 +538,6 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { const isClosed = vi.fn(() => false); const harness = attachGatewayHarness({ connId: "conn-1", - requestOrigin: "http://127.0.0.1:19001", connectNonce: "nonce-1", refreshHealthSnapshot, isClosed, @@ -552,10 +549,10 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { minProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, client: { - id: "openclaw-control-ui", + id: "gateway-client", version: "dev", platform: "test", - mode: "ui", + mode: "backend", }, role: "operator", caps: [], @@ -582,7 +579,7 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { auth_mode: "none", auth_method: "none", auth_provided: "none", - client_mode: "ui", + client_mode: "backend", has_device_identity: false, scope_count: 0, }, @@ -675,13 +672,10 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { ); }); - it("keeps token-authenticated presence free of user identity", async () => { + it("does not project user identity for a token-authenticated backend", async () => { const harness = attachGatewayHarness({ connId: "conn-token-userless", connectNonce: "nonce-token-userless", - requestHost: "gateway.example.com:18789", - requestOrigin: "http://127.0.0.1:19001", - remoteAddr: "203.0.113.50", resolvedAuth: { mode: "token", token: "gateway-token", @@ -693,10 +687,10 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { minProtocol: PROTOCOL_VERSION, maxProtocol: PROTOCOL_VERSION, client: { - id: "openclaw-control-ui", + id: "gateway-client", version: "dev", platform: "test", - mode: "ui", + mode: "backend", }, role: "operator", caps: [], @@ -714,12 +708,7 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { ).toMatchObject({ ok: true, }); - await waitForFast(() => { - expect(upsertPresenceMock).toHaveBeenCalledWith( - "conn-token-userless", - expect.not.objectContaining({ user: expect.anything() }), - ); - }); + expect(upsertPresenceMock).not.toHaveBeenCalled(); expect(harness.client).not.toMatchObject({ authenticatedUserId: expect.anything() }); expect(ensureProfileForEmailMock).not.toHaveBeenCalled(); }); diff --git a/src/gateway/test-helpers.config-runtime.ts b/src/gateway/test-helpers.config-runtime.ts index b219ca441147..857aa197cdd7 100644 --- a/src/gateway/test-helpers.config-runtime.ts +++ b/src/gateway/test-helpers.config-runtime.ts @@ -12,6 +12,7 @@ import type { import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { AgentBinding } from "../config/types.agents.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js"; +import { writeConfigMachineState } from "../state/config-machine-state.js"; import { buildTestConfigSnapshot } from "./test-helpers.config-snapshots.js"; import { testConfigRoot, testIsNixMode, testState } from "./test-helpers.runtime-state.js"; @@ -126,7 +127,7 @@ export function createGatewayConfigModuleMock(actual: GatewayConfigModule): Gate fileCron.enabled = testState.cronEnabled; } if (typeof testState.cronStorePath === "string") { - fileCron.store = testState.cronStorePath; + writeConfigMachineState("cron.store", testState.cronStorePath); } const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined; diff --git a/src/gateway/test-temp-config.ts b/src/gateway/test-temp-config.ts index d94d91989409..2ea8b5e286bc 100644 --- a/src/gateway/test-temp-config.ts +++ b/src/gateway/test-temp-config.ts @@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { clearConfigCache, resetConfigRuntimeState, @@ -11,25 +12,26 @@ import { import type { OpenClawConfig } from "../config/config.js"; import { clearSecretsRuntimeSnapshot } from "../secrets/runtime.js"; -function withStableOwnerDisplaySecretForTest(cfg: unknown): unknown { - if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) { +function canonicalizeTempConfigForTest(cfg: unknown): unknown { + if (!isRecord(cfg)) { return cfg; } - const record = cfg as Record; - const commands = - record.commands && typeof record.commands === "object" && !Array.isArray(record.commands) - ? (record.commands as Record) - : {}; - if (typeof commands.ownerDisplaySecret === "string" && commands.ownerDisplaySecret.length > 0) { - return cfg; + const next = structuredClone(cfg); + const agents = isRecord(next.agents) ? next.agents : undefined; + if (!agents || !Array.isArray(agents.list)) { + return next; } - return { - ...record, - commands: { - ...commands, - ownerDisplaySecret: "openclaw-test-owner-display-secret", - }, - }; + const entries = isRecord(agents.entries) ? { ...agents.entries } : {}; + for (const value of agents.list) { + if (!isRecord(value) || typeof value.id !== "string" || !value.id.trim()) { + continue; + } + const { id, ...entry } = value; + entries[id] = { ...entry, ...(isRecord(entries[id]) ? entries[id] : {}) }; + } + agents.entries = entries; + delete agents.list; + return next; } /** Writes a temp OpenClaw config, installs it as runtime state, then restores globals. */ @@ -40,7 +42,7 @@ export async function withTempConfig(params: { }): Promise { const prevConfigPath = process.env.OPENCLAW_CONFIG_PATH; - const testConfig = withStableOwnerDisplaySecretForTest(params.cfg) as OpenClawConfig; + const testConfig = canonicalizeTempConfigForTest(params.cfg) as OpenClawConfig; const dir = await mkdtemp(path.join(os.tmpdir(), params.prefix ?? "openclaw-test-config-")); const configPath = path.join(dir, "openclaw.json"); diff --git a/src/gateway/worker-environments/service.test.ts b/src/gateway/worker-environments/service.test.ts index bb4f3c6fb6a1..271f37110f2f 100644 --- a/src/gateway/worker-environments/service.test.ts +++ b/src/gateway/worker-environments/service.test.ts @@ -96,7 +96,6 @@ describe("worker environment service", () => { development: { provider: "fake", settings: { region: "test" }, - lifetime: { idleTimeoutMinutes: 10 }, }, }, }, @@ -311,7 +310,6 @@ describe("worker environment service", () => { profileSnapshot: { install: "bundle", settings: { region: "test" }, - lifetime: { idleTimeoutMinutes: 10 }, }, }); getDevelopmentProfile().settings = { region: "mutated" }; diff --git a/src/gateway/worker-environments/service.ts b/src/gateway/worker-environments/service.ts index f02713a9319b..05d3cc502d79 100644 --- a/src/gateway/worker-environments/service.ts +++ b/src/gateway/worker-environments/service.ts @@ -894,7 +894,6 @@ export function createWorkerEnvironmentService(options: WorkerEnvironmentService profileSnapshot: requireWorkerProfile({ install: profile.install ?? "bundle", settings, - ...(profile.lifetime ? { lifetime: profile.lifetime } : {}), }), provisionOperationId: `provision:${digest}`, }); diff --git a/src/gateway/worker-environments/transcript-commit.test.ts b/src/gateway/worker-environments/transcript-commit.test.ts index b94b1045a25d..98ddd2d9c0dc 100644 --- a/src/gateway/worker-environments/transcript-commit.test.ts +++ b/src/gateway/worker-environments/transcript-commit.test.ts @@ -425,7 +425,7 @@ describe("worker transcript commit application", () => { }); it("replays an interrupted terminal write after its branch is abandoned", async () => { - cfg = { ...cfg, logging: { redactSensitive: "tools" } }; + cfg = { ...cfg }; const initialManager = SessionManager.open(sessionFile); const baseLeafId = initialManager.appendMessage({ role: "user", @@ -485,7 +485,7 @@ describe("worker transcript commit application", () => { }); const updates: Parameters[0]>[0][] = []; unsubscribe = onSessionTranscriptUpdate((update) => updates.push(update)); - cfg = { ...cfg, logging: { redactSensitive: "off" } }; + cfg = { ...cfg }; const replay = await committer.commit({ identity: IDENTITY, request }); diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index 1c7372ef1ab2..54d34de9fed8 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -153,26 +153,6 @@ async function runNewWithPreviousSession(params: { return { tempDir, files, memoryContent }; } -function isAsciiDigits(value: string): boolean { - return /^[0-9]+$/.test(value); -} - -function expectDatedMemoryFile(files: string[], slug: string) { - expect(files).toHaveLength(1); - const filename = files[0]; - if (!filename) { - throw new Error("expected one session memory file"); - } - const suffix = `-${slug}.md`; - expect(filename.endsWith(suffix)).toBe(true); - const datePrefix = filename.slice(0, -suffix.length); - const [year, month, day] = datePrefix.split("-"); - expect([year?.length, month?.length, day?.length]).toEqual([4, 2, 2]); - expect(year ? isAsciiDigits(year) : false).toBe(true); - expect(month ? isAsciiDigits(month) : false).toBe(true); - expect(day ? isAsciiDigits(day) : false).toBe(true); -} - async function createSessionMemoryWorkspace(params?: { activeSession?: { name: string; content: string }; }): Promise<{ tempDir: string; sessionsDir: string; activeSessionFile?: string }> { @@ -386,114 +366,6 @@ describe("session-memory hook", () => { expect(generateSlug).not.toHaveBeenCalled(); }); - it("uses a model-generated filename slug only when explicitly enabled", async () => { - const sessionContent = createMockSessionContent([ - { role: "user", content: "What is 2+2?" }, - { role: "assistant", content: "2+2 equals 4" }, - ]); - - const generateSlug = vi.mocked(generateSlugViaLLM); - generateSlug.mockClear(); - generateSlug.mockResolvedValueOnce("simple-math"); - - await withEnvAsync( - { - NODE_ENV: "production", - OPENCLAW_TEST_FAST: undefined, - VITEST: undefined, - }, - async () => { - const { files } = await runNewWithPreviousSession({ - sessionContent, - cfg: (tempDir) => - ({ - agents: { defaults: { workspace: tempDir } }, - hooks: { - internal: { - entries: { - "session-memory": { - enabled: true, - llmSlug: true, - model: "sonnet", - }, - }, - }, - }, - }) satisfies OpenClawConfig, - }); - expectDatedMemoryFile(files, "simple-math"); - }, - ); - - expect(generateSlug).toHaveBeenCalledTimes(1); - expect(generateSlug).toHaveBeenCalledWith(expect.objectContaining({ model: "sonnet" })); - }); - - it("does not block reset command handling on opt-in model slug generation", async () => { - const tempDir = await createCaseWorkspace("workspace"); - const sessionsDir = path.join(tempDir, "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - - const sessionFile = await writeWorkspaceFile({ - dir: sessionsDir, - name: "test-session.jsonl", - content: createMockSessionContent([ - { role: "user", content: "Investigate slow WhatsApp reset" }, - { role: "assistant", content: "Checking reset hooks" }, - ]), - }); - - let resolveSlug: ((slug: string | null) => void) | undefined; - const generateSlug = vi.mocked(generateSlugViaLLM); - generateSlug.mockClear(); - generateSlug.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveSlug = resolve; - }), - ); - - await withEnvAsync( - { - NODE_ENV: "production", - OPENCLAW_TEST_FAST: undefined, - VITEST: undefined, - }, - async () => { - const event = createHookEvent("command", "new", "agent:main:main", { - cfg: { - agents: { defaults: { workspace: tempDir } }, - hooks: { - internal: { - entries: { - "session-memory": { - enabled: true, - llmSlug: true, - }, - }, - }, - }, - } satisfies OpenClawConfig, - previousSessionEntry: { - sessionId: "test-123", - sessionFile, - }, - }); - - const startedAt = Date.now(); - await handler(event); - expect(Date.now() - startedAt).toBeLessThan(100); - - await vi.waitFor(() => expect(generateSlug).toHaveBeenCalledTimes(1), { interval: 1 }); - resolveSlug?.("slow-reset"); - await flushSessionMemoryWritesForTest(); - - const files = await fs.readdir(path.join(tempDir, "memory")); - expectDatedMemoryFile(files, "slow-reset"); - }, - ); - }); - it("creates memory file with session content on /reset command", async () => { const sessionContent = createMockSessionContent([ { role: "user", content: "Please reset and keep notes" }, diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index a29482878415..3e27c3f60164 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -20,6 +20,7 @@ import { } from "../../../config/sessions/sqlite-marker.js"; import { selectVisibleTranscriptEvents } from "../../../config/sessions/transcript-visible-events.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { isVitestRuntimeEnv } from "../../../infra/env.js"; import { root } from "../../../infra/fs-safe.js"; import { createSubsystemLogger } from "../../../logging/subsystem.js"; import { @@ -258,11 +259,7 @@ async function saveSessionMemoryNow(event: Parameters[0]): Promise< }); // Avoid calling the model provider in unit tests; keep hooks fast and deterministic. - const isTestEnv = - process.env.OPENCLAW_TEST_FAST === "1" || - process.env.VITEST === "true" || - process.env.VITEST === "1" || - process.env.NODE_ENV === "test"; + const isTestEnv = isVitestRuntimeEnv(); const allowLlmSlug = !isTestEnv && hookConfig?.llmSlug === true; if (sessionContent && cfg && allowLlmSlug) { diff --git a/src/hooks/configured.ts b/src/hooks/configured.ts index eb3dd4895ace..a4a4be203eb2 100644 --- a/src/hooks/configured.ts +++ b/src/hooks/configured.ts @@ -1,6 +1,7 @@ // Configured hook helpers combine config and install records into active hooks. import type { HookConfig, HookInstallRecord } from "../config/types.hooks.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { readConfigMachineState } from "../state/config-machine-state.js"; import { getLegacyInternalHookHandlers } from "./legacy-config.js"; function hasEnabledFlag(entry: HookConfig | undefined): boolean { @@ -18,10 +19,18 @@ function hasConfiguredInstalls(installs: Record | und return installs ? Object.keys(installs).length > 0 : false; } +function readConfiguredInstalls(): Record | undefined { + return readConfigMachineState>("hooks.internal.installs"); +} + /** Return whether config can load any internal hooks, including legacy handlers. */ export function hasConfiguredInternalHooks(config: OpenClawConfig): boolean { const internal = config.hooks?.internal; - if (!internal || internal.enabled === false) { + const installs = readConfiguredInstalls(); + if (!internal) { + return hasConfiguredInstalls(installs); + } + if (internal.enabled === false) { return false; } if (internal.enabled === true) { @@ -33,7 +42,7 @@ export function hasConfiguredInternalHooks(config: OpenClawConfig): boolean { if ((internal.load?.extraDirs ?? []).some((dir) => dir.trim().length > 0)) { return true; } - if (hasConfiguredInstalls(internal.installs)) { + if (hasConfiguredInstalls(installs)) { return true; } return getLegacyInternalHookHandlers(config).length > 0; @@ -42,7 +51,11 @@ export function hasConfiguredInternalHooks(config: OpenClawConfig): boolean { /** Resolve explicitly configured internal hook names; null means all/discovered hooks may load. */ export function resolveConfiguredInternalHookNames(config: OpenClawConfig): Set | null { const internal = config.hooks?.internal; - if (!internal || internal.enabled === false) { + const installs = readConfiguredInstalls(); + if (!internal) { + return hasConfiguredInstalls(installs) ? null : new Set(); + } + if (internal.enabled === false) { return new Set(); } if (internal.enabled === true) { @@ -56,7 +69,7 @@ export function resolveConfiguredInternalHookNames(config: OpenClawConfig): Set< names.add(trimmed); } } - for (const [installId, install] of Object.entries(internal.installs ?? {})) { + for (const [installId, install] of Object.entries(installs ?? {})) { const hookNames = install.hooks ?? []; if (hookNames.length === 0 && installId.trim()) { // An install without an explicit hook list can add hooks dynamically, so diff --git a/src/hooks/installs.test.ts b/src/hooks/installs.test.ts new file mode 100644 index 000000000000..16514a1057db --- /dev/null +++ b/src/hooks/installs.test.ts @@ -0,0 +1,25 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { readHookInstalls, recordHookInstall } from "./installs.js"; + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + +describe("hook install machine state", () => { + it("merges independently recorded hook packs", () => { + const stateDir = mkdtempSync(join(tmpdir(), "openclaw-hook-installs-")); + const options = { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } }; + + recordHookInstall({}, { hookId: "alpha", source: "npm" }, options); + recordHookInstall({}, { hookId: "beta", source: "path" }, options); + + expect(readHookInstalls(options)).toMatchObject({ + alpha: { source: "npm" }, + beta: { source: "path" }, + }); + }); +}); diff --git a/src/hooks/installs.ts b/src/hooks/installs.ts index a95dda3f9ba5..a787d367d2a3 100644 --- a/src/hooks/installs.ts +++ b/src/hooks/installs.ts @@ -2,33 +2,44 @@ import { expectDefined } from "@openclaw/normalization-core"; // Hook install record helpers read and write installed hook metadata. import type { HookInstallRecord } from "../config/types.hooks.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { readConfigMachineState, updateConfigMachineState } from "../state/config-machine-state.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; /** Install record plus the hook pack id being updated in config. */ export type HookInstallUpdate = HookInstallRecord & { hookId: string }; -/** Return config with one hook install record merged into hooks.internal.installs. */ -export function recordHookInstall(cfg: OpenClawConfig, update: HookInstallUpdate): OpenClawConfig { - const { hookId, ...record } = update; - const installs = { - ...cfg.hooks?.internal?.installs, - [hookId]: { - ...cfg.hooks?.internal?.installs?.[hookId], - ...record, - installedAt: record.installedAt ?? new Date().toISOString(), - }, - }; - - return { - ...cfg, - hooks: { - ...cfg.hooks, - internal: { - ...cfg.hooks?.internal, - installs: { - ...installs, - [hookId]: expectDefined(installs[hookId], "installs entry at hook id"), - }, - }, - }, - }; +/** Read canonical hook install records from machine state. */ +export function readHookInstalls( + options: OpenClawStateDatabaseOptions = {}, +): Record { + return ( + readConfigMachineState>("hooks.internal.installs", options) ?? + {} + ); +} + +/** Persist one hook install record in machine state. */ +export function recordHookInstall( + cfg: OpenClawConfig, + update: HookInstallUpdate, + options: OpenClawStateDatabaseOptions = {}, +): OpenClawConfig { + const { hookId, ...record } = update; + updateConfigMachineState>( + "hooks.internal.installs", + (current) => { + const installs = { + ...current, + [hookId]: { + ...current?.[hookId], + ...record, + installedAt: record.installedAt ?? new Date().toISOString(), + }, + }; + installs[hookId] = expectDefined(installs[hookId], "installs entry at hook id"); + return installs; + }, + options, + ); + return cfg; } diff --git a/src/hooks/loader.test.ts b/src/hooks/loader.test.ts index d1d5fd870e2e..d39db9d8f022 100644 --- a/src/hooks/loader.test.ts +++ b/src/hooks/loader.test.ts @@ -164,11 +164,6 @@ describe("loader", () => { hooks: { internal: { enabled: true } }, } satisfies OpenClawConfig), ).toBeNull(); - expect( - resolveConfiguredInternalHookNames({ - hooks: { internal: { installs: { pack: { source: "path" } } } }, - } satisfies OpenClawConfig), - ).toBeNull(); }); const createLegacyHandlerConfig = () => diff --git a/src/hooks/update.test.ts b/src/hooks/update.test.ts index cffda9f31eba..24c141488c00 100644 --- a/src/hooks/update.test.ts +++ b/src/hooks/update.test.ts @@ -1,15 +1,33 @@ // Hook update tests cover updating installed hook records and config. import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HookInstallRecord } from "../config/types.hooks.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HookNpmIntegrityDriftParams } from "./install.js"; const installHooksFromNpmSpecMock = vi.fn(); +let hookInstalls: Record = {}; vi.mock("./install.js", () => ({ installHooksFromNpmSpec: (...args: unknown[]) => installHooksFromNpmSpecMock(...args), resolveHookInstallDir: (hookId: string) => `/tmp/hooks/${hookId}`, })); +vi.mock("./installs.js", () => ({ + readHookInstalls: () => hookInstalls, + recordHookInstall: (cfg: OpenClawConfig, update: HookInstallRecord & { hookId: string }) => { + const { hookId, ...record } = update; + hookInstalls = { + ...hookInstalls, + [hookId]: { + ...hookInstalls[hookId], + ...record, + installedAt: record.installedAt ?? "2026-05-11T20:00:00.000Z", + }, + }; + return cfg; + }, +})); + const { updateNpmInstalledHookPacks } = await import("./update.js"); function createHookInstallConfig(params: { @@ -17,25 +35,21 @@ function createHookInstallConfig(params: { spec: string; integrity?: string; }): OpenClawConfig { - return { - hooks: { - internal: { - installs: { - [params.hookId]: { - source: "npm", - spec: params.spec, - installPath: `/tmp/hooks/${params.hookId}`, - ...(params.integrity ? { integrity: params.integrity } : {}), - }, - }, - }, + hookInstalls = { + [params.hookId]: { + source: "npm", + spec: params.spec, + installPath: `/tmp/hooks/${params.hookId}`, + ...(params.integrity ? { integrity: params.integrity } : {}), }, - } as OpenClawConfig; + }; + return {}; } describe("updateNpmInstalledHookPacks", () => { beforeEach(() => { installHooksFromNpmSpecMock.mockReset(); + hookInstalls = {}; }); it("aborts exact pinned hook pack updates on integrity drift by default", async () => { @@ -131,7 +145,7 @@ describe("updateNpmInstalledHookPacks", () => { }), ); expect(result.changed).toBe(true); - expect(result.config.hooks?.internal?.installs?.["demo-hooks"]).toEqual({ + expect(hookInstalls["demo-hooks"]).toEqual({ source: "npm", spec: "@openclaw/demo-hooks", installPath: "/tmp/hooks/demo-hooks", @@ -143,7 +157,7 @@ describe("updateNpmInstalledHookPacks", () => { shasum: "abc123", resolvedAt: "2026-05-11T20:00:00.000Z", hooks: ["demo"], - installedAt: expect.any(String), + installedAt: "2026-05-11T20:00:00.000Z", }); }); }); diff --git a/src/hooks/update.ts b/src/hooks/update.ts index ee48f37787db..fee90f5fbe56 100644 --- a/src/hooks/update.ts +++ b/src/hooks/update.ts @@ -10,7 +10,7 @@ import { type HookNpmIntegrityDriftParams, resolveHookInstallDir, } from "./install.js"; -import { recordHookInstall } from "./installs.js"; +import { readHookInstalls, recordHookInstall } from "./installs.js"; /** Logger contract for hook pack update operations. */ type HookPackUpdateLogger = { @@ -82,7 +82,7 @@ export async function updateNpmInstalledHookPacks(params: { onIntegrityDrift?: (params: HookPackUpdateIntegrityDriftParams) => boolean | Promise; }): Promise { const logger = params.logger ?? {}; - const installs = params.config.hooks?.internal?.installs ?? {}; + const installs = readHookInstalls(); const targets = params.hookIds?.length ? params.hookIds : Object.keys(installs); const outcomes: HookPackUpdateOutcome[] = []; let next = params.config; diff --git a/src/image-generation/live-test-helpers.test.ts b/src/image-generation/live-test-helpers.test.ts index bd4c7f2ac9c5..9924a8cc0660 100644 --- a/src/image-generation/live-test-helpers.test.ts +++ b/src/image-generation/live-test-helpers.test.ts @@ -40,9 +40,11 @@ describe("image-generation live-test helpers", () => { const cfg = { agents: { defaults: { - imageGenerationModel: { - primary: "openai/gpt-image-2", - fallbacks: ["google/gemini-3.1-flash-image-preview", "invalid"], + mediaModels: { + image: { + primary: "openai/gpt-image-2", + fallbacks: ["google/gemini-3.1-flash-image-preview", "invalid"], + }, }, }, }, diff --git a/src/image-generation/live-test-helpers.ts b/src/image-generation/live-test-helpers.ts index 50a45e2aa182..6716f87a6f85 100644 --- a/src/image-generation/live-test-helpers.ts +++ b/src/image-generation/live-test-helpers.ts @@ -43,7 +43,7 @@ export function parseCsvFilter(raw?: string): Set | null { } export function resolveConfiguredLiveImageModels(cfg: OpenClawConfig): Map { - return resolveConfiguredLiveProviderModels(cfg.agents?.defaults?.imageGenerationModel); + return resolveConfiguredLiveProviderModels(cfg.agents?.defaults?.mediaModels?.image); } export function resolveLiveImageAuthStore(params: { diff --git a/src/image-generation/runtime.test.ts b/src/image-generation/runtime.test.ts index d6699f2822cf..50ceaff8cac3 100644 --- a/src/image-generation/runtime.test.ts +++ b/src/image-generation/runtime.test.ts @@ -30,7 +30,25 @@ const runtimeDeps: ImageGenerationRuntimeDeps = { }; function runGenerateImage(params: GenerateImageParams) { - return generateImage(params, runtimeDeps); + const defaults = params.cfg.agents?.defaults as + | (NonNullable["defaults"] & { + imageGenerationModel?: unknown; + }) + | undefined; + const cfg = + defaults?.imageGenerationModel !== undefined && defaults.mediaModels?.image === undefined + ? { + ...params.cfg, + agents: { + ...params.cfg.agents, + defaults: { + ...defaults, + mediaModels: { ...defaults.mediaModels, image: defaults.imageGenerationModel }, + }, + }, + } + : params.cfg; + return generateImage({ ...params, cfg }, runtimeDeps); } describe("image-generation runtime", () => { @@ -874,7 +892,7 @@ describe("image-generation runtime", () => { await expect( runGenerateImage({ cfg: {} as OpenClawConfig, prompt: "draw a cat" }), ).rejects.toThrow( - 'No image-generation model configured. Set agents.defaults.imageGenerationModel.primary to a provider/model like "vision-one/paint-v1". If you want a specific provider, also configure that provider\'s auth/API key first (vision-one: VISION_ONE_API_KEY; vision-two: VISION_TWO_API_KEY).', + 'No image-generation model configured. Set agents.defaults.mediaModels.image.primary to a provider/model like "vision-one/paint-v1". If you want a specific provider, also configure that provider\'s auth/API key first (vision-one: VISION_ONE_API_KEY; vision-two: VISION_TWO_API_KEY).', ); }); }); diff --git a/src/image-generation/runtime.ts b/src/image-generation/runtime.ts index 217384ba26b1..9f45aabd21cf 100644 --- a/src/image-generation/runtime.ts +++ b/src/image-generation/runtime.ts @@ -41,7 +41,7 @@ function buildNoImageGenerationModelConfiguredMessage( const listProviders = deps.listProviders ?? listImageGenerationProviders; return buildNoCapabilityModelConfiguredMessage({ capabilityLabel: "image-generation", - modelConfigKey: "imageGenerationModel", + modelConfigKey: "mediaModels.image", providers: listProviders(cfg), getProviderEnvVars: deps.getProviderEnvVars, }); @@ -64,10 +64,10 @@ export async function generateImage( const logger = deps.log ?? log; const requestedTimeoutMs = params.timeoutMs ?? - resolveAgentModelTimeoutMsValue(params.cfg.agents?.defaults?.imageGenerationModel); + resolveAgentModelTimeoutMsValue(params.cfg.agents?.defaults?.mediaModels?.image); const candidates = resolveCapabilityModelCandidates({ cfg: params.cfg, - modelConfig: params.cfg.agents?.defaults?.imageGenerationModel, + modelConfig: params.cfg.agents?.defaults?.mediaModels?.image, modelOverride: params.modelOverride, parseModelRef: parseImageGenerationModelRef, agentDir: params.agentDir, diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index cf9d176960c3..e7f23c79fa6a 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -590,7 +590,7 @@ describe("createBackupArchive", () => { const outputDir = state.path("backups"); await state.writeConfig({ agents: { - list: [{ id: "main", default: true, workspace: state.workspaceDir }], + entries: { main: { default: true, workspace: state.workspaceDir } }, }, }); await fs.mkdir(outputDir, { recursive: true }); @@ -1648,7 +1648,7 @@ describe("createBackupArchive", () => { const hardlinkedDbPath = state.statePath("state", "hardlinked-global.sqlite"); await state.writeConfig({ agents: { - list: [{ id: "main", default: true, workspace: state.workspaceDir }], + entries: { main: { default: true, workspace: state.workspaceDir } }, }, }); await fs.mkdir(path.dirname(linkedDbPath), { recursive: true }); @@ -2012,7 +2012,7 @@ describe("createBackupArchive", () => { configPath, `${JSON.stringify({ agents: { - list: [{ id: "main", default: true, workspace: workspaceDir }], + entries: { main: { default: true, workspace: workspaceDir } }, }, })}\n`, "utf8", diff --git a/src/infra/diagnostic-llm-content.test.ts b/src/infra/diagnostic-llm-content.test.ts index cf6cd2d2ef4e..3bd24799e569 100644 --- a/src/infra/diagnostic-llm-content.test.ts +++ b/src/infra/diagnostic-llm-content.test.ts @@ -42,7 +42,7 @@ describe("resolveDiagnosticModelContentCapturePolicy", () => { }); }); - it("uses the object form for system prompt capture", () => { + it("rejects the retired object form of content capture", () => { expect( resolveDiagnosticModelContentCapturePolicy({ diagnostics: { @@ -60,15 +60,15 @@ describe("resolveDiagnosticModelContentCapturePolicy", () => { }, }), ).toMatchObject({ - inputMessages: true, + inputMessages: false, outputMessages: false, - systemPrompt: true, - toolDefinitions: true, - anyModelContent: true, + systemPrompt: false, + toolDefinitions: false, + anyModelContent: false, }); }); - it("gates tool definitions independently from input messages", () => { + it("does not honor retired per-field capture switches", () => { expect( resolveDiagnosticModelContentCapturePolicy({ diagnostics: { @@ -84,9 +84,9 @@ describe("resolveDiagnosticModelContentCapturePolicy", () => { }, }), ).toMatchObject({ - inputMessages: true, + inputMessages: false, toolDefinitions: false, - anyModelContent: true, + anyModelContent: false, }); expect( @@ -105,8 +105,8 @@ describe("resolveDiagnosticModelContentCapturePolicy", () => { }), ).toMatchObject({ inputMessages: false, - toolDefinitions: true, - anyModelContent: true, + toolDefinitions: false, + anyModelContent: false, }); }); @@ -121,7 +121,7 @@ describe("resolveDiagnosticModelContentCapturePolicy", () => { // Tool input only: tool content on, model content off. expect(base({ toolInputs: true })).toMatchObject({ - toolInputs: true, + toolInputs: false, toolOutputs: false, anyModelContent: false, }); @@ -129,14 +129,14 @@ describe("resolveDiagnosticModelContentCapturePolicy", () => { // Tool output only. expect(base({ toolOutputs: true })).toMatchObject({ toolInputs: false, - toolOutputs: true, + toolOutputs: false, }); // Model content only: tool flags stay off. expect(base({ inputMessages: true })).toMatchObject({ toolInputs: false, toolOutputs: false, - anyModelContent: true, + anyModelContent: false, }); // captureContent: true enables both families. diff --git a/src/infra/diagnostic-llm-content.ts b/src/infra/diagnostic-llm-content.ts index a2de4441139c..8ff789642e15 100644 --- a/src/infra/diagnostic-llm-content.ts +++ b/src/infra/diagnostic-llm-content.ts @@ -83,15 +83,5 @@ export function resolveDiagnosticModelContentCapturePolicy( toolDefinitions: true, }); } - if (!isRecord(captureContent) || captureContent.enabled !== true) { - return NO_MODEL_CONTENT_CAPTURE; - } - return withDerivedFields({ - inputMessages: captureContent.inputMessages === true, - outputMessages: captureContent.outputMessages === true, - toolInputs: captureContent.toolInputs === true, - toolOutputs: captureContent.toolOutputs === true, - systemPrompt: captureContent.systemPrompt === true, - toolDefinitions: captureContent.toolDefinitions === true, - }); + return NO_MODEL_CONTENT_CAPTURE; } diff --git a/src/infra/ed25519-signature.test.ts b/src/infra/ed25519-signature.test.ts index 90f3878ab282..e2dd5753f5fe 100644 --- a/src/infra/ed25519-signature.test.ts +++ b/src/infra/ed25519-signature.test.ts @@ -1,7 +1,6 @@ import crypto from "node:crypto"; import { describe, expect, it } from "vitest"; import { - base64UrlDecode, decodeCanonicalBase64OrBase64Url, deriveCanonicalEd25519PrivateKeyRaw, deriveCanonicalEd25519PublicKeyRaw, @@ -14,8 +13,7 @@ import { describe("strict base64 decoding", () => { it("accepts canonical unpadded base64url", () => { - const raw = Buffer.from([0xfb, 0xff, 0x01]); - expect(base64UrlDecode("-_8B")).toEqual(raw); + expect(normalizeEd25519PublicKeyBase64Url("-_8B")).toBe("-_8B"); }); it("accepts canonical standard base64 through the strict mixed decoder", () => { @@ -31,7 +29,9 @@ describe("strict base64 decoding", () => { ); it("throws on input exceeding the maximum allowed length", () => { - expect(() => base64UrlDecode("A".repeat(5000))).toThrow(/maximum allowed length/); + expect(() => decodeCanonicalBase64OrBase64Url("A".repeat(5000))).toThrow( + /maximum allowed length/, + ); }); }); diff --git a/src/infra/ed25519-signature.ts b/src/infra/ed25519-signature.ts index da59be28b3ce..ee6049821300 100644 --- a/src/infra/ed25519-signature.ts +++ b/src/infra/ed25519-signature.ts @@ -23,7 +23,7 @@ function assertBoundedBase64Input(input: string): void { } /** Decode the existing permissive base64url wire shape. */ -export function base64UrlDecode(input: string): Buffer { +function base64UrlDecode(input: string): Buffer { if (input.length > MAX_BASE64URL_DECODE_INPUT_LENGTH) { throw new Error("base64url input exceeds the maximum allowed length"); } diff --git a/src/infra/env.test.ts b/src/infra/env.test.ts index 48ae280d0947..fe2650817883 100644 --- a/src/infra/env.test.ts +++ b/src/infra/env.test.ts @@ -1,7 +1,13 @@ // Tests infra environment loading and variable normalization. import { beforeEach, describe, expect, it, vi } from "vitest"; import { withEnv } from "../test-utils/env.js"; -import { isTruthyEnvValue, logAcceptedEnvOption, normalizeEnv, normalizeZaiEnv } from "./env.js"; +import { + isFastTestRuntimeEnv, + isTruthyEnvValue, + logAcceptedEnvOption, + normalizeEnv, + normalizeZaiEnv, +} from "./env.js"; const loggerMocks = vi.hoisted(() => ({ info: vi.fn(), @@ -63,6 +69,27 @@ describe("isTruthyEnvValue", () => { }); }); +describe("isFastTestRuntimeEnv", () => { + it("ignores OPENCLAW_TEST_FAST outside a test runtime", () => { + withEnv( + { + NODE_ENV: "production", + VITEST: undefined, + VITEST_POOL_ID: undefined, + VITEST_WORKER_ID: undefined, + OPENCLAW_TEST_FAST: "1", + }, + () => { + expect(isFastTestRuntimeEnv()).toBe(false); + }, + ); + }); + + it("honors OPENCLAW_TEST_FAST inside a detected test runtime", () => { + expect(isFastTestRuntimeEnv({ VITEST: "1", OPENCLAW_TEST_FAST: "1" })).toBe(true); + }); +}); + describe("logAcceptedEnvOption", () => { it("logs accepted env options once with redaction and formatting", async () => { loggerMocks.info.mockClear(); diff --git a/src/infra/env.ts b/src/infra/env.ts index f19d7e8dcbb2..9daac97ba2db 100644 --- a/src/infra/env.ts +++ b/src/infra/env.ts @@ -3,6 +3,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { SubsystemLogger } from "../logging/subsystem.js"; import { createLazyPromise } from "../shared/lazy-runtime.js"; +export { isFastTestRuntimeEnv, isVitestRuntimeEnv } from "./test-runtime-env.js"; let log: SubsystemLogger | null = null; const loadLog = createLazyPromise( @@ -108,17 +109,6 @@ export function isTruthyEnvValue(value?: string): boolean { } } -/** Detects Vitest/test execution from the env shape used by local and worker processes. */ -export function isVitestRuntimeEnv(env: NodeJS.ProcessEnv = process.env): boolean { - return ( - env.VITEST === "true" || - env.VITEST === "1" || - env.VITEST_POOL_ID !== undefined || - env.VITEST_WORKER_ID !== undefined || - env.NODE_ENV === "test" - ); -} - /** Applies process-wide env normalization before runtime configuration is read. */ export function normalizeEnv(): void { normalizeZaiEnv(process.env); diff --git a/src/infra/heartbeat-runner.clears-pending-final-delivery.test.ts b/src/infra/heartbeat-runner.clears-pending-final-delivery.test.ts index 4dbcdbcb8a7d..74a0069af140 100644 --- a/src/infra/heartbeat-runner.clears-pending-final-delivery.test.ts +++ b/src/infra/heartbeat-runner.clears-pending-final-delivery.test.ts @@ -137,7 +137,13 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery // not depend on a byte-equal text match or prefixed agents stay stuck. const cfg = { ...createHeartbeatConfig(storePath), - messages: { responsePrefix: "🤖" }, + channels: { + ...createHeartbeatConfig(storePath).channels, + telegram: { + ...createHeartbeatConfig(storePath).channels?.telegram, + responsePrefix: "🤖", + }, + }, } as unknown as OpenClawConfig; const body = "Heartbeat update: everything is green."; diff --git a/src/infra/heartbeat-runner.commitments.test.ts b/src/infra/heartbeat-runner.commitments.test.ts index b4f33eb5f9fe..3ac9ab132ab4 100644 --- a/src/infra/heartbeat-runner.commitments.test.ts +++ b/src/infra/heartbeat-runner.commitments.test.ts @@ -29,6 +29,22 @@ import { resetSystemEventsForTest, } from "./system-events.js"; +vi.mock("../commitments/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveCommitmentsConfig: () => ({ + enabled: true, + maxPerDay: 3, + extraction: { + debounceMs: 15_000, + batchMaxItems: 8, + queueMaxItems: 64, + confidenceThreshold: 0.72, + careConfidenceThreshold: 0.86, + timeoutSeconds: 45, + }, + }), +})); + installHeartbeatRunnerTestRuntime(); type CommitmentTestStore = { version: 1; commitments: CommitmentRecord[] }; @@ -127,7 +143,6 @@ describe("runHeartbeatOnce commitments", () => { ...(params?.visibleReplies ? { messages: { visibleReplies: params.visibleReplies } } : {}), channels: { telegram: { allowFrom: ["*"] } }, session: { store: storePath }, - commitments: { enabled: true }, }; await seedSessionStore(storePath, sessionKey, { lastChannel: "telegram", @@ -214,7 +229,6 @@ describe("runHeartbeatOnce commitments", () => { }, channels: { telegram: { allowFrom: ["*"] } }, session: { store: storePath }, - commitments: { enabled: true }, }; await fs.writeFile( path.join(tmpDir, "HEARTBEAT.md"), @@ -308,7 +322,6 @@ describe("runHeartbeatOnce commitments", () => { }, channels: { telegram: { allowFrom: ["*"] } }, session: { store: storePath }, - commitments: { enabled: true }, }; await seedSessionStore(storePath, sessionKey, { lastChannel: "telegram", @@ -386,7 +399,6 @@ describe("runHeartbeatOnce commitments", () => { }, }, session: { store: storePath }, - commitments: { enabled: true }, }; await saveCommitmentStore(undefined, { version: 1, @@ -434,7 +446,6 @@ describe("runHeartbeatOnce commitments", () => { }, }, session: { store: storePath }, - commitments: { enabled: true }, }; await saveCommitmentStore(undefined, { version: 1, @@ -510,7 +521,6 @@ describe("runHeartbeatOnce commitments", () => { }, channels: { telegram: { allowFrom: ["*"] } }, session: { store: storePath }, - commitments: { enabled: true }, }; await seedSessionStore(storePath, sessionKey, { lastChannel: "telegram", @@ -650,7 +660,6 @@ describe("runHeartbeatOnce commitments", () => { }, channels: { telegram: { allowFrom: ["*"] } }, session: { store: storePath }, - commitments: { enabled: true }, }; // HEARTBEAT.md has a tasks block (task ran recently — NOT due) plus extra prose directives. await fs.writeFile( @@ -742,7 +751,6 @@ tasks: }, channels: { telegram: { allowFrom: ["*"] } }, session: { store: storePath }, - commitments: { enabled: true }, }; await fs.writeFile( path.join(tmpDir, "HEARTBEAT.md"), diff --git a/src/infra/heartbeat-runner.identity.test.ts b/src/infra/heartbeat-runner.identity.test.ts index 3b06a17fb105..315d78812a43 100644 --- a/src/infra/heartbeat-runner.identity.test.ts +++ b/src/infra/heartbeat-runner.identity.test.ts @@ -28,7 +28,7 @@ describe("runHeartbeatOnce identity", () => { workspace: tmpDir, heartbeat: { every: "5m", target: "last", isolatedSession }, }, - list: [{ id: "main", default: true }, { id: "historian2" }], + entries: { main: { default: true }, historian2: {} }, }, session: { scope: "global", dmScope: "per-channel-peer", store: storeTemplate }, }; @@ -87,9 +87,9 @@ describe("runHeartbeatOnce identity", () => { workspace: tmpDir, heartbeat: { every: "5m", target: "slack", to: "channel:C123" }, }, - list: [{ id: "main", identity: { name: "Pulse", emoji: "📟" } }], + entries: { main: { identity: { name: "Pulse", emoji: "📟" } } }, }, - channels: { slack: { heartbeat: { showOk } } }, + channels: { slack: { heartbeatVisibility: { showOk } } }, session: { store: storePath }, }; await seedMainSessionStore(storePath, cfg, { diff --git a/src/infra/heartbeat-runner.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts index 509de8cd146f..a3c83d5ece11 100644 --- a/src/infra/heartbeat-runner.model-override.test.ts +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -100,7 +100,6 @@ describe("runHeartbeatOnce – heartbeat model override", () => { every?: string; defaultTimeoutSeconds?: number; model?: string; - suppressToolErrorWarnings?: boolean; timeoutSeconds?: number; lightContext?: boolean; isolatedSession?: boolean; @@ -115,7 +114,6 @@ describe("runHeartbeatOnce – heartbeat model override", () => { every: params.every ?? "5m", target: "whatsapp", model: params.model, - suppressToolErrorWarnings: params.suppressToolErrorWarnings, timeoutSeconds: params.timeoutSeconds, lightContext: params.lightContext, isolatedSession: params.isolatedSession, @@ -197,11 +195,11 @@ describe("runHeartbeatOnce – heartbeat model override", () => { }); }); - it("passes suppressToolErrorWarnings when configured", async () => { - const replyOpts = await runDefaultsHeartbeat({ suppressToolErrorWarnings: true }); + it("keeps heartbeat tool-error warnings enabled", async () => { + const replyOpts = await runDefaultsHeartbeat({}); expectReplyOptions(replyOpts, { isHeartbeat: true, - suppressToolErrorWarnings: true, + suppressToolErrorWarnings: false, }); }); diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index e9566848c0ed..15365e47a380 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -135,7 +135,7 @@ describe("runHeartbeatOnce ack handling", () => { storePath: string; replySpy: HeartbeatReplySpy; replyText: string; - messages?: Record; + responsePrefix?: string; telegramOverrides?: Record; }) { const cfg = createHeartbeatConfig({ @@ -146,11 +146,11 @@ describe("runHeartbeatOnce ack handling", () => { telegram: { token: "test-token", allowFrom: ["*"], - heartbeat: { showOk: false }, + heartbeatVisibility: { showOk: false }, + responsePrefix: params.responsePrefix, ...params.telegramOverrides, }, }, - ...(params.messages ? { messages: params.messages } : {}), }); await seedMainSessionStore(params.storePath, cfg, { @@ -188,7 +188,7 @@ describe("runHeartbeatOnce ack handling", () => { channels: { whatsapp: { allowFrom: ["*"], - ...(params.visibility ? { heartbeat: params.visibility } : {}), + ...(params.visibility ? { heartbeatVisibility: params.visibility } : {}), }, }, }); @@ -209,12 +209,12 @@ describe("runHeartbeatOnce ack handling", () => { return cfg; } - it("respects ackMaxChars for heartbeat acks", async () => { + it("uses the fixed ack budget to suppress short heartbeat acknowledgements", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { const cfg = createWhatsAppHeartbeatConfig({ tmpDir, storePath, - heartbeat: { ackMaxChars: 0 }, + heartbeat: {}, }); await seedMainSessionStore(storePath, cfg, { @@ -234,7 +234,7 @@ describe("runHeartbeatOnce ack handling", () => { }, }); - expect(sendWhatsApp).toHaveBeenCalled(); + expect(sendWhatsApp).not.toHaveBeenCalled(); }); }); @@ -291,7 +291,7 @@ describe("runHeartbeatOnce ack handling", () => { telegram: { token: "test-token", allowFrom: ["*"], - heartbeat: { showOk: true }, + heartbeatVisibility: { showOk: true }, }, }, }); @@ -382,24 +382,24 @@ describe("runHeartbeatOnce ack handling", () => { { title: "strips responsePrefix before HEARTBEAT_OK detection and suppresses short ack text", replyText: "[openclaw] HEARTBEAT_OK all good", - messages: { responsePrefix: "[openclaw]" }, + responsePrefix: "[openclaw]", expectedCalls: 0, }, { title: "does not strip alphanumeric responsePrefix from larger words", replyText: "History check complete", - messages: { responsePrefix: "Hi" }, + responsePrefix: "Hi", expectedCalls: 1, expectedText: "History check complete", }, - ])("$title", async ({ replyText, messages, expectedCalls, expectedText }) => { + ])("$title", async ({ replyText, responsePrefix, expectedCalls, expectedText }) => { await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { const { sendTelegram, cfg } = await runTelegramHeartbeatWithDefaults({ tmpDir, storePath, replySpy, replyText, - messages, + responsePrefix, }); expect(sendTelegram).toHaveBeenCalledTimes(expectedCalls); diff --git a/src/infra/heartbeat-runner.response-prefix-template.test.ts b/src/infra/heartbeat-runner.response-prefix-template.test.ts index 4d8b9e12470a..64d883ea1baf 100644 --- a/src/infra/heartbeat-runner.response-prefix-template.test.ts +++ b/src/infra/heartbeat-runner.response-prefix-template.test.ts @@ -30,9 +30,9 @@ describe("runHeartbeatOnce responsePrefix templates", () => { token: "test-token", allowFrom: ["*"], heartbeat: { showOk: false }, + responsePrefix: params.responsePrefix, }, } as never, - messages: { responsePrefix: params.responsePrefix }, session: { store: params.storePath }, }; } diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 31acfdd88d27..e1832cf7f1e2 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -1228,28 +1228,26 @@ describe("runHeartbeatOnce", () => { name: "legacy-prefixed reasoning + final payload", caseDir: "hb-reasoning", replies: [{ text: "Reasoning:\n_Because it helps_" }, { text: "Final alert" }], - expectedTexts: ["Reasoning:\n_Because it helps_", "Final alert"], + expectedTexts: ["Final alert"], }, { name: "raw flagged reasoning + final payload", caseDir: "hb-reasoning-raw", replies: [{ text: "Because it helps", isReasoning: true }, { text: "Final alert" }], - expectedTexts: ["Thinking\n\n_Because it helps_", "Final alert"], + expectedTexts: ["Final alert"], }, { name: "raw flagged reasoning + HEARTBEAT_OK", caseDir: "hb-reasoning-heartbeat-ok", replies: [{ text: "Because it helps", isReasoning: true }, { text: "HEARTBEAT_OK" }], - expectedTexts: ["Thinking\n\n_Because it helps_"], + expectedTexts: [], }, { - // Reasoning-only result: the selector returns no main reply, but the - // documented includeReasoning opt-in must still deliver the Thinking - // message instead of going silent (#92242 follow-up / review finding). + // Reasoning-only results remain internal under the fixed presentation policy. name: "raw flagged reasoning only (no main reply)", caseDir: "hb-reasoning-only", replies: [{ text: "Because it helps", isReasoning: true }], - expectedTexts: ["Thinking\n\n_Because it helps_"], + expectedTexts: [], }, { name: "visible final that starts with thinking prose", @@ -1284,7 +1282,6 @@ describe("runHeartbeatOnce", () => { heartbeat: { every: "5m", target: "whatsapp", - includeReasoning: true, }, }, }, diff --git a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts index 92916b01cec6..a8935c174df1 100644 --- a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts +++ b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts @@ -14,7 +14,6 @@ import { type HeartbeatDeps, runHeartbeatOnce } from "./heartbeat-runner.js"; import { seedMainSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js"; import { HEARTBEAT_SKIP_CRON_IN_PROGRESS, - HEARTBEAT_SKIP_LANES_BUSY, HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT, } from "./heartbeat-wake.js"; import { resetSystemEventsForTest, enqueueSystemEvent } from "./system-events.js"; @@ -134,7 +133,7 @@ describe("heartbeat runner skips when target session lane is busy", () => { // lane variants exercised below. await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => { const cfg = createHeartbeatTelegramConfig(); - cfg.agents!.defaults!.heartbeat = { every: "30m", skipWhenBusy: true }; + cfg.agents!.defaults!.heartbeat = { every: "30m" }; await seedHeartbeatTelegramSession(storePath, cfg); const result = await runHeartbeatOnce({ @@ -151,10 +150,10 @@ describe("heartbeat runner skips when target session lane is busy", () => { }); }); - it("returns lanes-busy for opt-in work in this agent's nested session lane", async () => { + it("runs despite work in this agent's nested session lane", async () => { await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => { const cfg = createHeartbeatTelegramConfig(); - cfg.agents!.defaults!.heartbeat = { every: "30m", skipWhenBusy: true }; + cfg.agents!.defaults!.heartbeat = { every: "30m" }; await seedHeartbeatTelegramSession(storePath, cfg); const nestedSessionLane = resolveNestedAgentLaneForSession("agent:main:telegram:123"); @@ -168,8 +167,8 @@ describe("heartbeat runner skips when target session lane is busy", () => { } as HeartbeatDeps, }); - expect(result).toEqual({ status: "skipped", reason: HEARTBEAT_SKIP_LANES_BUSY }); - expect(replySpy).not.toHaveBeenCalled(); + expect(result.status).toBe("ran"); + expect(replySpy).toHaveBeenCalledTimes(1); }); }); @@ -178,7 +177,7 @@ describe("heartbeat runner skips when target session lane is busy", () => { // different agent must not block this agent's heartbeat. await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => { const cfg = createHeartbeatTelegramConfig(); - cfg.agents!.defaults!.heartbeat = { every: "30m", skipWhenBusy: true }; + cfg.agents!.defaults!.heartbeat = { every: "30m" }; await seedHeartbeatTelegramSession(storePath, cfg); const nestedSessionLane = resolveNestedAgentLaneForSession("agent:other:telegram:123"); @@ -459,11 +458,11 @@ describe("heartbeat runner skips when target session lane is busy", () => { }); }); - it("keeps deferring recent pending delivery when ackMaxChars makes the remainder real content", async () => { + it("does not defer a recent pending acknowledgement under the fixed ack budget", async () => { await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => { const cfg = createHeartbeatTelegramConfig(); cfg.session = { store: storePath }; - cfg.agents!.defaults!.heartbeat = { every: "30m", ackMaxChars: 0 }; + cfg.agents!.defaults!.heartbeat = { every: "30m" }; await seedMainSessionStore(storePath, cfg, { lastChannel: "telegram", lastProvider: "heartbeat", @@ -483,8 +482,8 @@ describe("heartbeat runner skips when target session lane is busy", () => { } as HeartbeatDeps, }); - expect(result).toEqual({ status: "skipped", reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT }); - expect(replySpy).not.toHaveBeenCalled(); + expect(result.status).toBe("ran"); + expect(replySpy).toHaveBeenCalledTimes(1); }); }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 350ce69d0c8b..df507ded9022 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -10,7 +10,6 @@ import { import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { hasOutboundReplyContent, - isReasoningReplyPayload, resolveSendableOutboundReplyParts, } from "openclaw/plugin-sdk/reply-payload"; import { @@ -22,7 +21,6 @@ import { import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js"; import { resolveEmbeddedSessionLane } from "../agents/embedded-agent-runner/lanes.js"; import { listActiveEmbeddedRunSessionKeys } from "../agents/embedded-agent-runner/run-state.js"; -import { formatReasoningMessage } from "../agents/embedded-agent-utils.js"; import { resolveModelRefFromString, type ModelRef } from "../agents/model-selection.js"; import { resolveEffectiveAgentRuntime } from "../agents/thinking-runtime.js"; import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js"; @@ -98,7 +96,6 @@ import { resolveCronSession } from "../cron/isolated-agent/session.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getActivePluginChannelRegistry } from "../plugins/runtime.js"; import { - getCommandLaneSnapshots, getQueueSize, isCommandLaneTaskMarkerCurrent, type CommandLaneSnapshot, @@ -159,7 +156,6 @@ import { import { areHeartbeatsEnabled, HEARTBEAT_SKIP_CRON_IN_PROGRESS, - HEARTBEAT_SKIP_LANES_BUSY, HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT, type HeartbeatRunResult, type HeartbeatWakeHandler, @@ -206,58 +202,6 @@ const loadHeartbeatRunnerRuntime = createLazyRuntimeModule( const DEFAULT_HEARTBEAT_TIMEOUT_SECONDS = 10 * 60; -function hasQueuedWorkInLaneSnapshots( - snapshots: readonly CommandLaneSnapshot[], - matchesLane: (lane: string) => boolean, -): boolean { - return snapshots.some( - (snapshot) => matchesLane(snapshot.lane) && snapshot.activeCount + snapshot.queuedCount > 0, - ); -} - -/** - * Return true when `lane` carries a session-key suffix that parses to - * `agentId`. Lane name shapes covered: - * - * - `session:agent::...` — embedded-runner per-session lanes - * (subagent runs, compaction, context maintenance). - * - `nested:agent::...` — per-session nested-agent lanes. - * - * The generic `subagent` and `nested` global lanes carry no agent identity, - * so they cannot be scoped here; rely on the session-keyed variants and the - * per-session `session-lane-busy` skip at the heartbeat dispatch site. - */ -function laneBelongsToAgent(lane: string, agentId: string): boolean { - let suffix: string | undefined; - if (lane.startsWith("session:")) { - suffix = lane.slice("session:".length); - } else if (lane.startsWith("nested:")) { - suffix = lane.slice("nested:".length); - } - if (!suffix) { - return false; - } - const parsed = parseAgentSessionKey(suffix); - if (!parsed) { - return false; - } - return normalizeAgentId(parsed.agentId) === normalizeAgentId(agentId); -} - -/** - * Per-agent variant of the opt-in busy check. Previously the runner consulted - * a global `subagent` lane size, which meant a zombie subagent on any one - * agent silently disabled every other agent's heartbeat. Restrict the check - * to lanes attributable to `agentId` via session-key parsing so a stuck - * subagent on `main` no longer starves `tank`, `narcissus`, or `shiva`. - */ -function hasAgentOptInBusyLaneWork( - agentId: string, - getSnapshots: () => readonly CommandLaneSnapshot[], -): boolean { - return hasQueuedWorkInLaneSnapshots(getSnapshots(), (lane) => laneBelongsToAgent(lane, agentId)); -} - function hasActiveRunForAgent(agentId: string, listSessionKeys: () => readonly string[]): boolean { const normalizedAgentId = normalizeAgentId(agentId); return listSessionKeys().some((sessionKey) => { @@ -547,27 +491,28 @@ function shouldUseHeartbeatResponseToolPrompt(params: { return usesCodexHarness(params); } -function resolveHeartbeatAckMaxChars(cfg: OpenClawConfig, heartbeat?: HeartbeatConfig) { - return Math.max( - 0, - heartbeat?.ackMaxChars ?? - cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? - DEFAULT_HEARTBEAT_ACK_MAX_CHARS, - ); +function resolveHeartbeatAckMaxChars(_cfg: OpenClawConfig, _heartbeat?: HeartbeatConfig) { + return DEFAULT_HEARTBEAT_ACK_MAX_CHARS; } -function isHeartbeatTypingEnabled(params: { cfg: OpenClawConfig; hasChatDelivery: boolean }) { +function isHeartbeatTypingEnabled(params: { + cfg: OpenClawConfig; + agentId: string; + hasChatDelivery: boolean; +}) { if (!params.hasChatDelivery) { return false; } - const agentCfg = params.cfg.agents?.defaults; - const typingMode = params.cfg.session?.typingMode ?? agentCfg?.typingMode; + const typingMode = + resolveAgentConfig(params.cfg, params.agentId)?.typingMode ?? + params.cfg.agents?.defaults?.typingMode; return typingMode !== "never"; } -function resolveHeartbeatTypingIntervalSeconds(cfg: OpenClawConfig) { - const agentCfg = cfg.agents?.defaults; - const configured = agentCfg?.typingIntervalSeconds; +function resolveHeartbeatTypingIntervalSeconds(cfg: OpenClawConfig, agentId: string) { + const configured = + resolveAgentConfig(cfg, agentId)?.typingIntervalSeconds ?? + cfg.agents?.defaults?.typingIntervalSeconds; return typeof configured === "number" && configured > 0 ? configured : undefined; } @@ -761,42 +706,6 @@ function resolveStaleHeartbeatIsolatedSessionKey(params: { return undefined; } -// Display-format check (not a classifier): whether reasoning text is already -// rendered in the heartbeat "Reasoning:" / "Thinking..._" form, so it should be -// delivered as-is rather than re-wrapped by formatReasoningMessage. Reasoning -// classification itself is the shared SDK `isReasoningReplyPayload`. -const HEARTBEAT_REASONING_DISPLAY_PREFIX = /^(?:Reasoning:|Thinking\.{0,3}(?=\s*_))/u; - -function resolveHeartbeatReasoningPayloads( - replyResult: ReplyPayload | ReplyPayload[] | undefined, -): ReplyPayload[] { - const payloads = Array.isArray(replyResult) ? replyResult : replyResult ? [replyResult] : []; - const reasoningPayloads: ReplyPayload[] = []; - for (const payload of payloads) { - const text = typeof payload.text === "string" ? payload.text : ""; - // Shared classifier keeps this lane in lockstep with the heartbeat reply - // selector so a legacy-formatted reasoning payload is never both skipped - // here and surfaced as the visible reply (or vice versa). See #92242. - if (!isReasoningReplyPayload(payload)) { - continue; - } - - const formattedText = HEARTBEAT_REASONING_DISPLAY_PREFIX.test(text.trimStart()) - ? text - : formatReasoningMessage(text); - if (!formattedText.trim()) { - continue; - } - - const deliverablePayload: ReplyPayload = { ...payload, text: formattedText }; - delete deliverablePayload.isReasoning; - delete deliverablePayload.mediaUrl; - delete deliverablePayload.mediaUrls; - reasoningPayloads.push(deliverablePayload); - } - return reasoningPayloads; -} - async function restoreHeartbeatUpdatedAt(params: { storePath: string; sessionKey: string; @@ -1356,7 +1265,6 @@ export async function runHeartbeatOnce(opts: { } const getSize = opts.deps?.getQueueSize ?? getQueueSize; - const getSnapshots = opts.deps?.getCommandLaneSnapshots ?? getCommandLaneSnapshots; if (getSize(CommandLane.Main) > 0) { return { status: "skipped", reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT }; } @@ -1388,15 +1296,6 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: HEARTBEAT_SKIP_CRON_IN_PROGRESS }; } - if (heartbeat?.skipWhenBusy === true && hasAgentOptInBusyLaneWork(agentId, getSnapshots)) { - emitHeartbeatEvent({ - status: "skipped", - reason: HEARTBEAT_SKIP_LANES_BUSY, - durationMs: Date.now() - startedAt, - }); - return { status: "skipped", reason: HEARTBEAT_SKIP_LANES_BUSY }; - } - const shouldHonorActiveReplyRuns = opts.intent !== "immediate" && opts.intent !== "manual"; const listActiveReplyRuns = opts.deps?.listActiveReplyRunSessionKeys ?? listActiveReplyRunSessionKeys; @@ -1852,13 +1751,14 @@ export async function runHeartbeatOnce(opts: { const hasChatDelivery = Boolean( delivery.channel !== "none" && delivery.to && (visibility.showAlerts || visibility.showOk), ); - const heartbeatTypingIntervalSeconds = resolveHeartbeatTypingIntervalSeconds(cfg); + const heartbeatTypingIntervalSeconds = resolveHeartbeatTypingIntervalSeconds(cfg, agentId); const heartbeatChannelPlugin = delivery.channel !== "none" ? resolveHeartbeatChannelPlugin(delivery.channel) : undefined; const heartbeatTyping = delivery.channel !== "none" && isHeartbeatTypingEnabled({ cfg, + agentId, hasChatDelivery, }) ? createHeartbeatTypingCallbacks({ @@ -1917,7 +1817,7 @@ export async function runHeartbeatOnce(opts: { try { await heartbeatTyping?.onReplyStart(); const heartbeatModelOverride = normalizeOptionalString(heartbeat?.model); - const suppressToolErrorWarnings = heartbeat?.suppressToolErrorWarnings === true; + const suppressToolErrorWarnings = false; const timeoutOverrideSeconds = resolveHeartbeatTimeoutOverrideSeconds(cfg, heartbeat); const bootstrapContextMode: "lightweight" | undefined = heartbeat?.lightContext === true ? "lightweight" : undefined; @@ -1957,10 +1857,7 @@ export async function runHeartbeatOnce(opts: { }); return { status: "skipped", reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT }; } - const includeReasoning = heartbeat?.includeReasoning === true; - const reasoningPayloads = includeReasoning - ? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload) - : []; + const reasoningPayloads: ReplyPayload[] = []; const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat); const responsePrefix = resolveHeartbeatResponsePrefix(); diff --git a/src/infra/heartbeat-runner.typing.test.ts b/src/infra/heartbeat-runner.typing.test.ts index 91d53f2f58dc..160e28f14d9f 100644 --- a/src/infra/heartbeat-runner.typing.test.ts +++ b/src/infra/heartbeat-runner.typing.test.ts @@ -34,20 +34,25 @@ function installHeartbeatTypingPlugin(params: { function createHeartbeatConfig(params: { tmpDir: string; storePath: string; + agents?: OpenClawConfig["agents"]; session?: OpenClawConfig["session"]; - channelHeartbeat?: Record; + channelHeartbeatVisibility?: Record; }): OpenClawConfig { return { agents: { + ...params.agents, defaults: { workspace: params.tmpDir, heartbeat: { every: "5m", target: "telegram" }, + ...params.agents?.defaults, }, }, channels: { telegram: { allowFrom: ["*"], - ...(params.channelHeartbeat ? { heartbeat: params.channelHeartbeat } : {}), + ...(params.channelHeartbeatVisibility + ? { heartbeatVisibility: params.channelHeartbeatVisibility } + : {}), }, }, session: { @@ -142,7 +147,35 @@ describe("runHeartbeatOnce heartbeat typing", () => { const cfg = createHeartbeatConfig({ tmpDir, storePath, - session: { typingMode: "never" }, + agents: { defaults: { typingMode: "never" } }, + }); + await seedTelegramSession(storePath, cfg); + replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); + + await runHeartbeatOnce({ + cfg, + deps: { + getReplyFromConfig: replySpy, + getQueueSize: () => 0, + nowMs: () => 0, + }, + }); + + expect(sendTyping).not.toHaveBeenCalled(); + }); + }); + + it("honors a per-agent typingMode override", async () => { + await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { + const sendTyping = vi.fn(async () => undefined); + installHeartbeatTypingPlugin({ sendTyping }); + const cfg = createHeartbeatConfig({ + tmpDir, + storePath, + agents: { + defaults: { typingMode: "instant" }, + entries: { main: { typingMode: "never" } }, + }, }); await seedTelegramSession(storePath, cfg); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); @@ -167,7 +200,7 @@ describe("runHeartbeatOnce heartbeat typing", () => { const cfg = createHeartbeatConfig({ tmpDir, storePath, - channelHeartbeat: { showAlerts: false, showOk: false, useIndicator: true }, + channelHeartbeatVisibility: { showAlerts: false, showOk: false, useIndicator: true }, }); await seedTelegramSession(storePath, cfg); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); diff --git a/src/infra/heartbeat-summary.ts b/src/infra/heartbeat-summary.ts index b6fb682c4f96..cb4e6d26c185 100644 --- a/src/infra/heartbeat-summary.ts +++ b/src/infra/heartbeat-summary.ts @@ -96,7 +96,7 @@ export function resolveHeartbeatSummaryForAgent( prompt: resolveHeartbeatPromptText(defaults?.prompt), target: defaults?.target ?? DEFAULT_HEARTBEAT_TARGET, model: defaults?.model, - ackMaxChars: Math.max(0, defaults?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS), + ackMaxChars: DEFAULT_HEARTBEAT_ACK_MAX_CHARS, }; } @@ -109,13 +109,7 @@ export function resolveHeartbeatSummaryForAgent( const target = merged?.target ?? defaults?.target ?? overrides?.target ?? DEFAULT_HEARTBEAT_TARGET; const model = merged?.model ?? defaults?.model ?? overrides?.model; - const ackMaxChars = Math.max( - 0, - merged?.ackMaxChars ?? - defaults?.ackMaxChars ?? - overrides?.ackMaxChars ?? - DEFAULT_HEARTBEAT_ACK_MAX_CHARS, - ); + const ackMaxChars = DEFAULT_HEARTBEAT_ACK_MAX_CHARS; return { enabled: true, diff --git a/src/infra/heartbeat-visibility.test.ts b/src/infra/heartbeat-visibility.test.ts index c7ecd915d246..84a0be881b53 100644 --- a/src/infra/heartbeat-visibility.test.ts +++ b/src/infra/heartbeat-visibility.test.ts @@ -4,7 +4,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js"; describe("resolveHeartbeatVisibility", () => { - function createChannelDefaultsHeartbeatConfig(heartbeat: { + function createChannelDefaultsHeartbeatConfig(heartbeatVisibility: { showOk?: boolean; showAlerts?: boolean; useIndicator?: boolean; @@ -12,7 +12,7 @@ describe("resolveHeartbeatVisibility", () => { return { channels: { defaults: { - heartbeat, + heartbeatVisibility, }, }, } as OpenClawConfig; @@ -22,12 +22,12 @@ describe("resolveHeartbeatVisibility", () => { return { channels: { telegram: { - heartbeat: { + heartbeatVisibility: { showOk: true, }, accounts: { primary: { - heartbeat: { + heartbeatVisibility: { showOk: false, }, }, @@ -68,14 +68,14 @@ describe("resolveHeartbeatVisibility", () => { const cfg = { channels: { defaults: { - heartbeat: { + heartbeatVisibility: { showOk: false, showAlerts: true, useIndicator: true, }, }, telegram: { - heartbeat: { + heartbeatVisibility: { showOk: true, }, }, @@ -95,20 +95,20 @@ describe("resolveHeartbeatVisibility", () => { const cfg = { channels: { defaults: { - heartbeat: { + heartbeatVisibility: { showOk: false, showAlerts: true, useIndicator: true, }, }, telegram: { - heartbeat: { + heartbeatVisibility: { showOk: false, showAlerts: false, }, accounts: { primary: { - heartbeat: { + heartbeatVisibility: { showOk: true, showAlerts: true, }, @@ -135,12 +135,12 @@ describe("resolveHeartbeatVisibility", () => { const cfg = { channels: { defaults: { - heartbeat: { + heartbeatVisibility: { showOk: false, }, }, telegram: { - heartbeat: { + heartbeatVisibility: { showAlerts: false, }, accounts: { @@ -185,7 +185,7 @@ describe("resolveHeartbeatVisibility", () => { const cfg = { channels: { whatsapp: { - heartbeat: { + heartbeatVisibility: { showOk: true, showAlerts: false, }, @@ -206,7 +206,7 @@ describe("resolveHeartbeatVisibility", () => { const cfg = { channels: { discord: { - heartbeat: { + heartbeatVisibility: { useIndicator: false, }, }, @@ -226,7 +226,7 @@ describe("resolveHeartbeatVisibility", () => { const cfg = { channels: { slack: { - heartbeat: { + heartbeatVisibility: { showOk: true, showAlerts: true, useIndicator: true, @@ -276,7 +276,7 @@ describe("resolveHeartbeatVisibility", () => { const cfg = { channels: { defaults: { - heartbeat: { + heartbeatVisibility: { showOk: true, }, }, diff --git a/src/infra/heartbeat-visibility.ts b/src/infra/heartbeat-visibility.ts index 624bb12f0765..e523a9ab0b5d 100644 --- a/src/infra/heartbeat-visibility.ts +++ b/src/infra/heartbeat-visibility.ts @@ -29,7 +29,7 @@ export function resolveHeartbeatVisibility(params: { // Webchat has no channel/account config branch, so only shared channel defaults apply. if (channel === "webchat") { - const channelDefaults = cfg.channels?.defaults?.heartbeat; + const channelDefaults = cfg.channels?.defaults?.heartbeatVisibility; return { showOk: channelDefaults?.showOk ?? DEFAULT_VISIBILITY.showOk, showAlerts: channelDefaults?.showAlerts ?? DEFAULT_VISIBILITY.showAlerts, @@ -38,20 +38,20 @@ export function resolveHeartbeatVisibility(params: { } // Layer 1: Global channel defaults - const channelDefaults = cfg.channels?.defaults?.heartbeat; + const channelDefaults = cfg.channels?.defaults?.heartbeatVisibility; // Layer 2: Per-channel config (at channel root level) const channelCfg = cfg.channels?.[channel] as | { - heartbeat?: ChannelHeartbeatVisibilityConfig; - accounts?: Record; + heartbeatVisibility?: ChannelHeartbeatVisibilityConfig; + accounts?: Record; } | undefined; - const perChannel = channelCfg?.heartbeat; + const perChannel = channelCfg?.heartbeatVisibility; // Layer 3: Per-account config (most specific) const accountCfg = accountId ? channelCfg?.accounts?.[accountId] : undefined; - const perAccount = accountCfg?.heartbeat; + const perAccount = accountCfg?.heartbeatVisibility; return { showOk: diff --git a/src/infra/net/proxy/proxy-lifecycle.test.ts b/src/infra/net/proxy/proxy-lifecycle.test.ts index a86f786db174..2d442eae5084 100644 --- a/src/infra/net/proxy/proxy-lifecycle.test.ts +++ b/src/infra/net/proxy/proxy-lifecycle.test.ts @@ -158,10 +158,8 @@ describe("startProxy", () => { expect(mockLogWarn).not.toHaveBeenCalled(); }); - it("throws when enabled without a proxy URL", async () => { - await expect(startProxy({ enabled: true })).rejects.toThrow( - "proxy: enabled but no HTTP proxy URL is configured", - ); + it("does not start without a proxy URL", async () => { + await expect(startProxy({})).resolves.toBeNull(); expect(process.env["http_proxy"]).toBeUndefined(); expect(mockLogWarn).not.toHaveBeenCalled(); @@ -173,7 +171,6 @@ describe("startProxy", () => { expect(getActiveManagedProxyUrl()).toBeUndefined(); const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -192,17 +189,25 @@ describe("startProxy", () => { it("uses OPENCLAW_PROXY_URL when config proxyUrl is omitted", async () => { process.env["OPENCLAW_PROXY_URL"] = "http://127.0.0.1:3128"; - const handle = await startProxy({ enabled: true }); + const handle = await startProxy({}); expect(expectProxyHandle(handle).proxyUrl).toBe("http://127.0.0.1:3128"); expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128"); }); + it("honors an explicit opt-out when OPENCLAW_PROXY_URL is present", async () => { + process.env["OPENCLAW_PROXY_URL"] = "http://127.0.0.1:3128"; + + await expect(startProxy({ enabled: false })).resolves.toBeNull(); + + expect(installGlobalProxyMock).not.toHaveBeenCalled(); + expect(process.env["HTTP_PROXY"]).toBeUndefined(); + }); + it("prefers config proxyUrl over OPENCLAW_PROXY_URL", async () => { process.env["OPENCLAW_PROXY_URL"] = "http://127.0.0.1:3128"; const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3129", }); @@ -213,7 +218,7 @@ describe("startProxy", () => { it("uses HTTPS proxy URLs from OPENCLAW_PROXY_URL", async () => { process.env["OPENCLAW_PROXY_URL"] = "https://127.0.0.1:3128"; - const handle = await startProxy({ enabled: true }); + const handle = await startProxy({}); expect(expectProxyHandle(handle).proxyUrl).toBe("https://127.0.0.1:3128"); expect(process.env["HTTP_PROXY"]).toBe("https://127.0.0.1:3128"); @@ -229,7 +234,6 @@ describe("startProxy", () => { const caFile = writeTempCa("active-proxy-ca"); const handle = await startProxy({ - enabled: true, proxyUrl: "https://127.0.0.1:3128", tls: { caFile }, }); @@ -249,7 +253,6 @@ describe("startProxy", () => { const missingCaFile = path.join(os.tmpdir(), "openclaw-missing-http-proxy-ca.pem"); const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", tls: { caFile: missingCaFile }, }); @@ -286,7 +289,6 @@ describe("startProxy", () => { it("sets process proxy env vars for inherited clients", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -302,7 +304,6 @@ describe("startProxy", () => { it("persists loopbackMode in env for forked child CLIs", async () => { const { getActiveManagedProxyLoopbackMode } = await import("./active-proxy-state.js"); const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", loopbackMode: "block", }); @@ -319,7 +320,6 @@ describe("startProxy", () => { it("redacts proxy credentials before logging the active proxy URL", async () => { await startProxy({ - enabled: true, proxyUrl: "http://user:pass@127.0.0.1:3128", }); @@ -338,7 +338,6 @@ describe("startProxy", () => { process.env["no_proxy"] = "localhost"; await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -348,7 +347,6 @@ describe("startProxy", () => { it("installs and stops Proxyline managed routing", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -388,7 +386,6 @@ describe("startProxy", () => { expect(proxylineStopMock).not.toHaveBeenCalled(); const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3222", }); @@ -445,7 +442,6 @@ describe("startProxy", () => { process.env["NO_PROXY"] = "corp.example.com"; const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -464,11 +460,9 @@ describe("startProxy", () => { it("keeps same-url overlapping handles active until the final stop", async () => { const firstHandle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); const secondHandle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -494,13 +488,11 @@ describe("startProxy", () => { it("rejects overlapping handles with different managed proxy URLs", async () => { const firstHandle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); await expect( startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3129", }), ).rejects.toThrow("cannot activate a managed proxy"); @@ -513,14 +505,12 @@ describe("startProxy", () => { it("rejects overlapping handles with the same proxy URL but different loopback modes", async () => { const firstHandle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", loopbackMode: "gateway-only", }); await expect( startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", loopbackMode: "block", }), @@ -539,7 +529,6 @@ describe("startProxy", () => { await expect( startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }), ).rejects.toThrow("failed to activate external proxy routing"); @@ -550,7 +539,6 @@ describe("startProxy", () => { it("registers exact Gateway loopback URLs with Proxyline", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -568,7 +556,6 @@ describe("startProxy", () => { it("delegates overlapping Gateway loopback bypass registrations to Proxyline", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -596,7 +583,6 @@ describe("startProxy", () => { it("accepts literal loopback IPs and localhost for Gateway bypass registration", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -621,7 +607,6 @@ describe("startProxy", () => { it("allows Gateway bypass registration for custom configured loopback ports", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -636,7 +621,6 @@ describe("startProxy", () => { it("blocks Gateway bypass registration when active proxy loopbackMode is block", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", loopbackMode: "block", }); @@ -652,7 +636,6 @@ describe("startProxy", () => { it("does not register Gateway bypass when active proxy loopbackMode is proxy", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", loopbackMode: "proxy", }); @@ -668,7 +651,6 @@ describe("startProxy", () => { it("does not mutate NO_PROXY while registering Gateway bypass", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); process.env["NO_PROXY"] = "corp.example.com"; @@ -689,7 +671,6 @@ describe("startProxy", () => { it("kill restores env synchronously during hard process exit", async () => { process.env["NO_PROXY"] = "corp.example.com"; const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -705,7 +686,6 @@ describe("startProxy", () => { it("registers loopback CDP URLs with Proxyline for the Browser plugin", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -723,7 +703,6 @@ describe("startProxy", () => { it("accepts loopback IPv6 and localhost authorities for Browser CDP bypass", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", }); @@ -754,7 +733,6 @@ describe("startProxy", () => { it("throws when active proxy loopbackMode is block for Browser CDP bypass", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", loopbackMode: "block", }); @@ -771,7 +749,6 @@ describe("startProxy", () => { it("does not register Browser CDP bypass when active proxy loopbackMode is proxy", async () => { const handle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", loopbackMode: "proxy", }); diff --git a/src/infra/net/proxy/proxy-lifecycle.ts b/src/infra/net/proxy/proxy-lifecycle.ts index 1f3fac7112a1..b8760b8ed3ca 100644 --- a/src/infra/net/proxy/proxy-lifecycle.ts +++ b/src/infra/net/proxy/proxy-lifecycle.ts @@ -199,12 +199,15 @@ export function ensureInheritedManagedProxyRoutingActive(): void { /** Starts process-wide managed proxy routing and returns the owner stop handle. */ export async function startProxy(config: ProxyConfig | undefined): Promise { - if (config?.enabled !== true) { + if ( + config?.enabled === false || + (!config?.proxyUrl?.trim() && !process.env["OPENCLAW_PROXY_URL"]?.trim()) + ) { return null; } const proxyUrl = resolveProxyUrl(config); - const loopbackMode = config.loopbackMode ?? "gateway-only"; + const loopbackMode = config?.loopbackMode ?? "gateway-only"; const proxyCaFile = resolveManagedProxyCaFileForUrl({ proxyUrl, config }); const proxyTls = await loadManagedProxyTlsOptions(proxyCaFile); const activeProxyUrl = getActiveManagedProxyUrl(); diff --git a/src/infra/net/proxy/proxy-validation.test.ts b/src/infra/net/proxy/proxy-validation.test.ts index 78d80f5ee73b..5ba0d42af936 100644 --- a/src/infra/net/proxy/proxy-validation.test.ts +++ b/src/infra/net/proxy/proxy-validation.test.ts @@ -29,74 +29,6 @@ describe("proxy validation", () => { return caFile; } - it("reports disabled proxy config when a config URL is present but proxy routing is disabled", async () => { - const fetchCheck = vi.fn(); - - const result = await runProxyValidation({ - config: { - enabled: false, - proxyUrl: "http://config-proxy.example:3128", - }, - env: {}, - fetchCheck, - }); - - expect(fetchCheck).not.toHaveBeenCalled(); - expect(result).toEqual({ - ok: false, - config: { - enabled: false, - proxyUrl: "http://config-proxy.example:3128", - source: "config", - errors: ["proxy validation requires proxy.enabled to be true for configured proxy URLs"], - }, - checks: [], - }); - }); - - it("reports disabled proxy config when only OPENCLAW_PROXY_URL is present", async () => { - const fetchCheck = vi.fn(); - - const result = await runProxyValidation({ - config: {}, - env: { - OPENCLAW_PROXY_URL: "http://env-proxy.example:3128", - }, - fetchCheck, - }); - - expect(fetchCheck).not.toHaveBeenCalled(); - expect(result).toEqual({ - ok: false, - config: { - enabled: false, - proxyUrl: "http://env-proxy.example:3128", - source: "env", - errors: ["proxy validation requires proxy.enabled to be true for OPENCLAW_PROXY_URL"], - }, - checks: [], - }); - }); - - it("allows explicit proxy URL overrides even when config proxy routing is disabled", async () => { - const fetchCheck = vi.fn().mockResolvedValueOnce({ ok: true, status: 200 }); - - const result = await runProxyValidation({ - proxyUrlOverride: "http://override-proxy.example:3128", - config: { - enabled: false, - proxyUrl: "http://config-proxy.example:3128", - }, - env: {}, - allowedUrls: ["https://example.com/"], - deniedUrls: [], - fetchCheck, - }); - - expect(result.ok).toBe(true); - expect(fetchCheck).toHaveBeenCalled(); - }); - it("preserves the validated response when discarded body cancellation rejects", async () => { const unhandledRejections: unknown[] = []; const onUnhandledRejection = (reason: unknown) => { @@ -152,7 +84,6 @@ describe("proxy validation", () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://config-proxy.example:3128", }, env: { @@ -176,27 +107,32 @@ describe("proxy validation", () => { }); }); - it("reports a missing effective URL when proxy validation is enabled", async () => { + it("honors an explicit opt-out for an environment proxy URL", async () => { const fetchCheck = vi.fn(); + const result = await runProxyValidation({ - config: { enabled: true }, - env: {}, - allowedUrls: [], - deniedUrls: [], + config: { enabled: false }, + env: { OPENCLAW_PROXY_URL: "http://env-proxy.example:3128" }, fetchCheck, }); expect(fetchCheck).not.toHaveBeenCalled(); - expect(result.config).toMatchObject({ enabled: true, source: "missing" }); - expect(result.config.errors).toEqual([ - "proxy validation requires proxy.proxyUrl, --proxy-url, or OPENCLAW_PROXY_URL", - ]); + expect(result).toEqual({ + ok: false, + config: { + enabled: false, + proxyUrl: "http://env-proxy.example:3128", + source: "env", + errors: ["proxy validation is disabled by proxy.enabled=false"], + }, + checks: [], + }); }); it("rejects unsupported proxy URL protocols before probing", async () => { const fetchCheck = vi.fn(); const result = await runProxyValidation({ - config: { enabled: true, proxyUrl: "socks5://proxy.example:1080" }, + config: { proxyUrl: "socks5://proxy.example:1080" }, env: {}, allowedUrls: [], deniedUrls: [], @@ -222,9 +158,7 @@ describe("proxy validation", () => { config: { enabled: false, source: "disabled", - errors: [ - "proxy validation requires proxy.enabled=true with proxy.proxyUrl or OPENCLAW_PROXY_URL, or --proxy-url", - ], + errors: ["proxy validation requires proxy.proxyUrl, OPENCLAW_PROXY_URL, or --proxy-url"], }, checks: [], }); @@ -233,7 +167,6 @@ describe("proxy validation", () => { it("fails the default loopback denied canary on successful ambiguous responses", async () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, @@ -261,7 +194,6 @@ describe("proxy validation", () => { it("passes the default loopback denied canary when the proxy returns a denial response", async () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, @@ -280,7 +212,6 @@ describe("proxy validation", () => { it("fails denied checks when the destination returns HTTP 403", async () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, @@ -304,7 +235,6 @@ describe("proxy validation", () => { it("fails denied checks when the destination returns a non-2xx HTTP status", async () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, @@ -328,7 +258,6 @@ describe("proxy validation", () => { it("fails custom denied checks on ambiguous transport errors", async () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, @@ -353,7 +282,6 @@ describe("proxy validation", () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, @@ -379,7 +307,6 @@ describe("proxy validation", () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, @@ -403,7 +330,6 @@ describe("proxy validation", () => { it("fails validation when a denied destination succeeds", async () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, @@ -438,7 +364,6 @@ describe("proxy validation", () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, @@ -515,7 +440,6 @@ describe("proxy validation", () => { const result = await runProxyValidation({ proxyUrlOverride: "https://override-proxy.example:8443", config: { - enabled: true, proxyUrl: "https://config-proxy.example:8443", tls: { caFile: configCaFile }, }, @@ -559,7 +483,6 @@ describe("proxy validation", () => { await runProxyValidation({ config: { - enabled: true, proxyUrl: "https://proxy.example:8443", tls: { caFile }, }, @@ -601,7 +524,6 @@ describe("proxy validation", () => { it("accepts APNs 403 reachability with InvalidProviderToken when apns-id is unavailable", async () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, @@ -625,7 +547,6 @@ describe("proxy validation", () => { it("fails APNs reachability when bare 403 has no APNs proof", async () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, @@ -646,7 +567,6 @@ describe("proxy validation", () => { it("fails APNs reachability when non-403 response has no apns-id (proxy intercept)", async () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, @@ -667,7 +587,6 @@ describe("proxy validation", () => { it("fails APNs reachability when the proxy blocks CONNECT", async () => { const result = await runProxyValidation({ config: { - enabled: true, proxyUrl: "http://127.0.0.1:3128", }, env: {}, diff --git a/src/infra/net/proxy/proxy-validation.ts b/src/infra/net/proxy/proxy-validation.ts index 9b53d0a0bb07..61644c777354 100644 --- a/src/infra/net/proxy/proxy-validation.ts +++ b/src/infra/net/proxy/proxy-validation.ts @@ -126,24 +126,6 @@ function validateProxyUrl(value: string | undefined): string[] { return []; } -function validateProxyEnabled(source: ProxyValidationConfigSource, enabled: boolean): string[] { - if (enabled || source === "override" || source === "missing" || source === "disabled") { - return []; - } - if (source === "env") { - return ["proxy validation requires proxy.enabled to be true for OPENCLAW_PROXY_URL"]; - } - return ["proxy validation requires proxy.enabled to be true for configured proxy URLs"]; -} - -function validateResolvedProxy( - source: ProxyValidationConfigSource, - enabled: boolean, - value: string | undefined, -): string[] { - return [...validateProxyUrl(value), ...validateProxyEnabled(source, enabled)]; -} - /** Resolves validation config precedence: explicit override, config, then env. */ function resolveProxyValidationConfig( options: ResolveProxyValidationConfigOptions, @@ -159,7 +141,7 @@ function resolveProxyValidationConfig( proxyUrl: overrideUrl, ...(proxyCaFile ? { proxyCaFile } : {}), source: "override", - errors: validateResolvedProxy("override", true, overrideUrl), + errors: validateProxyUrl(overrideUrl), }; } @@ -171,11 +153,14 @@ function resolveProxyValidationConfig( caFileOverride: options.proxyCaFileOverride, }); return { - enabled: options.config?.enabled === true, + enabled: options.config?.enabled !== false, proxyUrl: configUrl, ...(proxyCaFile ? { proxyCaFile } : {}), source: "config", - errors: validateResolvedProxy("config", options.config?.enabled === true, configUrl), + errors: + options.config?.enabled === false + ? ["proxy validation is disabled by proxy.enabled=false"] + : validateProxyUrl(configUrl), }; } @@ -187,11 +172,14 @@ function resolveProxyValidationConfig( caFileOverride: options.proxyCaFileOverride, }); return { - enabled: options.config?.enabled === true, + enabled: options.config?.enabled !== false, proxyUrl: envUrl, ...(proxyCaFile ? { proxyCaFile } : {}), source: "env", - errors: validateResolvedProxy("env", options.config?.enabled === true, envUrl), + errors: + options.config?.enabled === false + ? ["proxy validation is disabled by proxy.enabled=false"] + : validateProxyUrl(envUrl), }; } @@ -206,9 +194,7 @@ function resolveProxyValidationConfig( return { enabled: false, source: "disabled", - errors: [ - "proxy validation requires proxy.enabled=true with proxy.proxyUrl or OPENCLAW_PROXY_URL, or --proxy-url", - ], + errors: ["proxy validation requires proxy.proxyUrl, OPENCLAW_PROXY_URL, or --proxy-url"], }; } @@ -540,7 +526,7 @@ export async function runProxyValidation( config: { ...config, errors: [ - "Proxy validation is disabled. Set proxy.enabled=true or pass --proxy-url to run validation.", + "Proxy validation is disabled. Configure proxy.proxyUrl, OPENCLAW_PROXY_URL, or pass --proxy-url to run validation.", ], }, checks: [], diff --git a/src/infra/outbound/message-action-runner.core-send.test.ts b/src/infra/outbound/message-action-runner.core-send.test.ts index dab0c16d8c29..9fc9421287ec 100644 --- a/src/infra/outbound/message-action-runner.core-send.test.ts +++ b/src/infra/outbound/message-action-runner.core-send.test.ts @@ -403,13 +403,12 @@ describe("runMessageAction core send routing", () => { }); }); - it("prepends messages.responsePrefix to message-tool sends", async () => { + it("prepends the channel responsePrefix to message-tool sends", async () => { const sendText = registerSlackTextPlugin(); await runMessageAction({ cfg: { - channels: { slack: { enabled: true } }, - messages: { responsePrefix: "[Nexus]" }, + channels: { slack: { enabled: true, responsePrefix: "[Nexus]" } }, } as OpenClawConfig, action: "send", params: { @@ -429,8 +428,7 @@ describe("runMessageAction core send routing", () => { await runMessageAction({ cfg: { - channels: { slack: { enabled: true } }, - messages: { responsePrefix: "[Nexus]" }, + channels: { slack: { enabled: true, responsePrefix: "[Nexus]" } }, } as OpenClawConfig, action: "send", params: { @@ -481,8 +479,7 @@ describe("runMessageAction core send routing", () => { await runMessageAction({ cfg: { - channels: { slack: { enabled: true } }, - messages: { responsePrefix: "[Nexus]" }, + channels: { slack: { enabled: true, responsePrefix: "[Nexus]" } }, } as OpenClawConfig, action: "send", params: { @@ -502,8 +499,7 @@ describe("runMessageAction core send routing", () => { await runMessageAction({ cfg: { - channels: { slack: { enabled: true } }, - messages: { responsePrefix: "[{identity.name}]" }, + channels: { slack: { enabled: true, responsePrefix: "[{identity.name}]" } }, agents: { list: [{ id: "main", identity: { name: "Nexus" } }] }, } as OpenClawConfig, action: "send", @@ -525,8 +521,7 @@ describe("runMessageAction core send routing", () => { await runMessageAction({ cfg: { - channels: { slack: { enabled: true } }, - messages: { responsePrefix: "[{provider}/{model}]" }, + channels: { slack: { enabled: true, responsePrefix: "[{provider}/{model}]" } }, } as OpenClawConfig, action: "send", params: { @@ -821,10 +816,8 @@ describe("runMessageAction core send routing", () => { enabled: true, }, }, - messages: { - tts: { - auto: "tagged", - }, + tts: { + auto: "tagged", }, } as OpenClawConfig, action: "send", @@ -881,10 +874,8 @@ describe("runMessageAction core send routing", () => { enabled: true, }, }, - messages: { - tts: { - auto: "inbound", - }, + tts: { + auto: "inbound", }, } as OpenClawConfig, action: "send", diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 5f53191fd13a..7666188db1ef 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -2312,10 +2312,8 @@ describe("runMessageAction plugin dispatch", () => { enabled: true, }, }, - messages: { - tts: { - auto: "tagged", - }, + tts: { + auto: "tagged", }, } as OpenClawConfig, action: "send", @@ -2389,10 +2387,8 @@ describe("runMessageAction plugin dispatch", () => { enabled: true, }, }, - messages: { - tts: { - auto: "tagged", - }, + tts: { + auto: "tagged", }, } as OpenClawConfig, action: "send", @@ -2835,7 +2831,10 @@ describe("runMessageAction plugin dispatch", () => { const handleAction = vi.fn( async ({ cfg, params }: { cfg: OpenClawConfig; params: Record }) => { const message = typeof params.message === "string" ? params.message : ""; - const responsePrefix = cfg.messages?.responsePrefix; + const responsePrefix = Object.values(cfg.channels ?? {}).find( + (entry): entry is { responsePrefix?: string } => + typeof entry === "object" && entry !== null && "responsePrefix" in entry, + )?.responsePrefix; const rawMessage = responsePrefix && message.startsWith(`${responsePrefix} `) ? message.slice(responsePrefix.length + 1) @@ -3106,9 +3105,9 @@ describe("runMessageAction plugin dispatch", () => { channels: { cardchat: { enabled: true, + responsePrefix: "[Nexus]", }, }, - messages: { responsePrefix: "[Nexus]" }, } as OpenClawConfig, action: "send", params: { diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts index f89cdc7e0a94..d8b05d8e409a 100644 --- a/src/infra/push-apns.test.ts +++ b/src/infra/push-apns.test.ts @@ -374,7 +374,7 @@ describe("push APNs send semantics", () => { process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; try { - proxyHandle = await startProxy({ enabled: true, proxyUrl: proxy.proxyUrl }); + proxyHandle = await startProxy({ proxyUrl: proxy.proxyUrl }); const { registration, auth } = createDirectApnsSendFixture({ nodeId: "ios-node-proxied-alert", environment: "sandbox", @@ -431,7 +431,7 @@ describe("push APNs send semantics", () => { process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; try { - proxyHandle = await startProxy({ enabled: true, proxyUrl: proxy.proxyUrl }); + proxyHandle = await startProxy({ proxyUrl: proxy.proxyUrl }); const { registration, auth } = createDirectApnsSendFixture({ nodeId: "ios-node-proxied-error-body", environment: "sandbox", diff --git a/src/infra/state-migrations.config-machine-state.test.ts b/src/infra/state-migrations.config-machine-state.test.ts new file mode 100644 index 000000000000..114e04a3ddee --- /dev/null +++ b/src/infra/state-migrations.config-machine-state.test.ts @@ -0,0 +1,109 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { readConfigMachineState, writeConfigMachineState } from "../state/config-machine-state.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { migrateLegacyConfigMachineState } from "./state-migrations.config-machine-state.js"; + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + +describe("legacy config machine-state migration", () => { + it("imports machine-owned values and keeps existing database state", () => { + const stateDir = mkdtempSync(join(tmpdir(), "openclaw-config-machine-state-")); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + writeConfigMachineState("config.lastTouchedAt", "canonical", { env }); + + const result = migrateLegacyConfigMachineState({ + env, + config: { + meta: { lastTouchedVersion: "legacy", lastTouchedAt: "legacy-time" }, + hooks: { internal: { installs: { pack: { source: "npm" } } } }, + plugins: { bundledDiscovery: "compat" }, + tts: { prefsPath: "/tmp/tts.json" }, + cron: { store: "/tmp/jobs.json" }, + } as never, + }); + + expect(result.warnings).toEqual([]); + expect(result.changes).toContain("Kept existing shared SQLite config.lastTouchedAt state"); + expect(readConfigMachineState("config.lastTouchedAt", { env })).toBe("canonical"); + expect(readConfigMachineState("hooks.internal.installs", { env })).toEqual({ + pack: { source: "npm" }, + }); + expect(readConfigMachineState("plugins.bundledDiscovery", { env })).toBe("compat"); + expect(readConfigMachineState("tts.prefsPath", { env })).toBe("/tmp/tts.json"); + expect(readConfigMachineState("cron.store", { env })).toBe("/tmp/jobs.json"); + }); + + it("merges legacy hook installs while canonical records win conflicts", () => { + const stateDir = mkdtempSync(join(tmpdir(), "openclaw-config-machine-state-")); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + writeConfigMachineState( + "hooks.internal.installs", + { canonical: { source: "npm" }, shared: { source: "path" } }, + { env }, + ); + + migrateLegacyConfigMachineState({ + env, + config: { + hooks: { + internal: { + installs: { + legacy: { source: "archive" }, + shared: { source: "archive" }, + }, + }, + }, + } as never, + }); + + expect(readConfigMachineState("hooks.internal.installs", { env })).toEqual({ + canonical: { source: "npm" }, + legacy: { source: "archive" }, + shared: { source: "path" }, + }); + }); + + it("conservatively preserves compatibility for an unstamped plugin allowlist", () => { + const stateDir = mkdtempSync(join(tmpdir(), "openclaw-config-machine-state-")); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + + migrateLegacyConfigMachineState({ env, config: { plugins: { allow: ["telegram"] } } }); + + expect(readConfigMachineState("plugins.bundledDiscovery", { env })).toBe("compat"); + }); + + it("preserves compatibility discovery for a pre-cutover plugin allowlist", () => { + const stateDir = mkdtempSync(join(tmpdir(), "openclaw-config-machine-state-")); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + + migrateLegacyConfigMachineState({ + env, + config: { + meta: { lastTouchedVersion: "2026.1.1" }, + plugins: { allow: ["telegram"] }, + }, + }); + + expect(readConfigMachineState("plugins.bundledDiscovery", { env })).toBe("compat"); + }); + + it("does not infer compatibility discovery after the fixed cutover release", () => { + const stateDir = mkdtempSync(join(tmpdir(), "openclaw-config-machine-state-")); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + + migrateLegacyConfigMachineState({ + env, + config: { + meta: { lastTouchedVersion: "2026.7.2" }, + plugins: { allow: ["telegram"] }, + }, + }); + + expect(readConfigMachineState("plugins.bundledDiscovery", { env })).toBeUndefined(); + }); +}); diff --git a/src/infra/state-migrations.config-machine-state.ts b/src/infra/state-migrations.config-machine-state.ts new file mode 100644 index 000000000000..612b731efe3a --- /dev/null +++ b/src/infra/state-migrations.config-machine-state.ts @@ -0,0 +1,65 @@ +// Imports machine-owned openclaw.json values into the shared SQLite state store. +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { compareOpenClawVersions } from "../config/version.js"; +import { + importConfigMachineState, + updateConfigMachineState, +} from "../state/config-machine-state.js"; + +const BUNDLED_DISCOVERY_STATE_CUTOVER_VERSION = "2026.7.2"; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** Preserve retired machine-owned config fields before Doctor strips them. */ +export function migrateLegacyConfigMachineState(params: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): { changes: string[]; warnings: string[] } { + const raw = params.config as Record; + const entries: Array = []; + const meta = record(raw.meta); + if (meta && Object.hasOwn(meta, "lastTouchedAt")) { + entries.push(["config.lastTouchedAt", meta.lastTouchedAt]); + } + const installs = record(record(record(raw.hooks)?.internal)?.installs); + const hasInstalls = Boolean(installs && Object.keys(installs).length > 0); + const plugins = record(raw.plugins); + if (plugins && Object.hasOwn(plugins, "bundledDiscovery")) { + entries.push(["plugins.bundledDiscovery", plugins.bundledDiscovery]); + } else if ( + Array.isArray(plugins?.allow) && + plugins.allow.length > 0 && + (typeof meta?.lastTouchedVersion !== "string" || + compareOpenClawVersions(meta.lastTouchedVersion, BUNDLED_DISCOVERY_STATE_CUTOVER_VERSION) === + -1) + ) { + entries.push(["plugins.bundledDiscovery", "compat"]); + } + const tts = record(raw.tts); + if (tts && Object.hasOwn(tts, "prefsPath")) { + entries.push(["tts.prefsPath", tts.prefsPath]); + } + const cron = record(raw.cron); + if (cron && Object.hasOwn(cron, "store")) { + entries.push(["cron.store", cron.store]); + } + if (entries.length === 0 && !hasInstalls) { + return { changes: [], warnings: [] }; + } + const result = importConfigMachineState(entries, { env: params.env }); + const changes = result.imported.map((key) => `Migrated ${key} → shared SQLite state`); + changes.push(...result.kept.map((key) => `Kept existing shared SQLite ${key} state`)); + if (installs && hasInstalls) { + updateConfigMachineState>( + "hooks.internal.installs", + (current) => ({ ...installs, ...current }), + { env: params.env }, + ); + changes.push("Migrated hooks.internal.installs → shared SQLite state"); + } + return { changes, warnings: [] }; +} diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index 4226d3a9991c..621966e1a0ab 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -47,6 +47,7 @@ import { detectLegacyCommitments, migrateLegacyCommitments, } from "./state-migrations.commitments.js"; +import { migrateLegacyConfigMachineState } from "./state-migrations.config-machine-state.js"; import { detectLegacyDebugProxyCaptureSidecar, migrateLegacyDebugProxyCaptureSidecar, @@ -1235,6 +1236,10 @@ export async function autoMigrateLegacyState(params: { }; } const pluginDoctorConfig = params.pluginDoctorConfig ?? params.cfg; + const configMachineState = migrateLegacyConfigMachineState({ + config: pluginDoctorConfig, + env: { ...env, OPENCLAW_STATE_DIR: stateDir }, + }); const pluginSessionStoreAgentIds = listPluginDoctorSessionStoreAgentIds({ config: pluginDoctorConfig, env, @@ -1356,6 +1361,7 @@ export async function autoMigrateLegacyState(params: { const changes = [ ...stateDirResult.changes, ...stateSchema.changes, + ...configMachineState.changes, ...orphanKeys.changes, ...acpSessionMetadata.changes, ...pluginStateSidecar.changes, @@ -1377,6 +1383,7 @@ export async function autoMigrateLegacyState(params: { const warnings = [ ...stateDirResult.warnings, ...stateSchema.warnings, + ...configMachineState.warnings, ...detected.warnings, ...orphanKeys.warnings, ...acpSessionMetadata.warnings, @@ -1411,6 +1418,7 @@ export async function autoMigrateLegacyState(params: { migrated: stateDirResult.migrated || stateSchema.changes.length > 0 || + configMachineState.changes.length > 0 || orphanKeys.changes.length > 0 || acpSessionMetadata.changes.length > 0 || pluginStateSidecar.changes.length > 0 || @@ -1457,6 +1465,7 @@ export async function autoMigrateLegacyState(params: { const changes = [ ...stateDirResult.changes, ...stateSchema.changes, + ...configMachineState.changes, ...orphanKeys.changes, ...acpSessionMetadata.changes, ...deviceIdentity.changes, @@ -1464,6 +1473,7 @@ export async function autoMigrateLegacyState(params: { const warnings = [ ...stateDirResult.warnings, ...stateSchema.warnings, + ...configMachineState.warnings, ...detected.warnings, ...orphanKeys.warnings, ...acpSessionMetadata.warnings, @@ -1479,6 +1489,7 @@ export async function autoMigrateLegacyState(params: { migrated: stateDirResult.migrated || stateSchema.changes.length > 0 || + configMachineState.changes.length > 0 || orphanKeys.changes.length > 0 || acpSessionMetadata.changes.length > 0 || deviceIdentity.changes.length > 0, @@ -1559,6 +1570,7 @@ export async function autoMigrateLegacyState(params: { const changes = [ ...stateDirResult.changes, ...stateSchema.changes, + ...configMachineState.changes, ...orphanKeys.changes, ...acpSessionMetadata.changes, ...pluginStateSidecar.changes, @@ -1584,6 +1596,7 @@ export async function autoMigrateLegacyState(params: { const warnings = [ ...stateDirResult.warnings, ...stateSchema.warnings, + ...configMachineState.warnings, ...detected.warnings, ...orphanKeys.warnings, ...acpSessionMetadata.warnings, diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 58bcf6cd7f4a..99309bf74147 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -11,6 +11,7 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { logVerbose } from "../globals.js"; import { runExec } from "../process/exec.js"; +import { isVitestRuntimeEnv } from "./env.js"; import { toErrorObject } from "./errors.js"; import { retryAsync } from "./retry.js"; @@ -183,14 +184,11 @@ export async function getTailnetHostname(exec: typeof runExec = runExec, detecte let cachedTailscaleBinary: string | null = null; function getTestTailscaleBinaryOverride(env: NodeJS.ProcessEnv = process.env): string | null { - const forcedBinary = env.OPENCLAW_TEST_TAILSCALE_BINARY?.trim(); - if (!forcedBinary) { + if (!isVitestRuntimeEnv(env)) { return null; } - if (env.VITEST || env.NODE_ENV === "test") { - return forcedBinary; - } - return null; + const forcedBinary = env.OPENCLAW_TEST_TAILSCALE_BINARY?.trim(); + return forcedBinary || null; } async function getTailscaleBinary(): Promise { diff --git a/src/infra/test-runtime-env.ts b/src/infra/test-runtime-env.ts new file mode 100644 index 000000000000..93dd85887222 --- /dev/null +++ b/src/infra/test-runtime-env.ts @@ -0,0 +1,19 @@ +// Pure test-runtime env predicates stay dependency-free for config/path callers. + +/** Detects Vitest/test execution from the env shape used by local and worker processes. */ +export function isVitestRuntimeEnv(env: NodeJS.ProcessEnv = process.env): boolean { + return ( + env.VITEST === "true" || + env.VITEST === "1" || + env.VITEST_POOL_ID !== undefined || + env.VITEST_WORKER_ID !== undefined || + env.NODE_ENV === "test" + ); +} + +/** Enables the shared fast-test shortcuts only inside a detected test runtime. */ +export function isFastTestRuntimeEnv(env: NodeJS.ProcessEnv = process.env): boolean { + const isTestRuntime = + isVitestRuntimeEnv(env) || (env !== process.env && isVitestRuntimeEnv(process.env)); + return isTestRuntime && env.OPENCLAW_TEST_FAST === "1"; +} diff --git a/src/infra/tmp-openclaw-dir.browser-import.test.ts b/src/infra/tmp-openclaw-dir.browser-import.test.ts index b69f4f24d79d..4287e211fd5a 100644 --- a/src/infra/tmp-openclaw-dir.browser-import.test.ts +++ b/src/infra/tmp-openclaw-dir.browser-import.test.ts @@ -37,9 +37,9 @@ describe("tmp-openclaw-dir browser-safe import", () => { plugins: [nodeShimPlugin], stdin: { contents: ` - import { POSIX_OPENCLAW_TMP_DIR, resolvePreferredOpenClawTmpDir } from "./src/infra/tmp-openclaw-dir.ts"; + import { DEFAULT_POSIX_TMP_ROOT, resolvePreferredOpenClawTmpDir } from "./src/infra/tmp-openclaw-dir.ts"; globalThis.${resultKey} = { - posixTmpDir: POSIX_OPENCLAW_TMP_DIR, + posixTmpDir: DEFAULT_POSIX_TMP_ROOT, resolverType: typeof resolvePreferredOpenClawTmpDir, }; `, diff --git a/src/infra/tmp-openclaw-dir.test.ts b/src/infra/tmp-openclaw-dir.test.ts index f48568161648..ef60e715b838 100644 --- a/src/infra/tmp-openclaw-dir.test.ts +++ b/src/infra/tmp-openclaw-dir.test.ts @@ -2,7 +2,7 @@ import { constants as fsConstants } from "node:fs"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { POSIX_OPENCLAW_TMP_DIR, resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js"; +import { DEFAULT_POSIX_TMP_ROOT, resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js"; type TmpDirOptions = NonNullable[0]>; @@ -56,7 +56,7 @@ function resolveWithReadOnlyTmpFallback(params: { return resolvePreferredOpenClawTmpDir({ accessSync: readOnlyTmpAccessSync(), lstatSync: vi.fn((target: string) => { - if (target === POSIX_OPENCLAW_TMP_DIR) { + if (target === DEFAULT_POSIX_TMP_ROOT) { throw nodeErrorWithCode("ENOENT"); } if (target === params.fallbackPath) { @@ -118,7 +118,7 @@ function resolveWithMocks(params: { const chmodSync = params.chmodSync ?? vi.fn(); const warn = params.warn ?? vi.fn(); const wrappedLstatSync = vi.fn((target: string) => { - if (target === POSIX_OPENCLAW_TMP_DIR) { + if (target === DEFAULT_POSIX_TMP_ROOT) { return params.lstatSync(target); } if (target === fallbackPath) { @@ -156,7 +156,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { expect(lstatSync).toHaveBeenCalledTimes(1); expect(accessSync).toHaveBeenCalledTimes(1); - expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR); + expect(resolved).toBe(DEFAULT_POSIX_TMP_ROOT); expect(tmpdir).not.toHaveBeenCalled(); }); @@ -167,9 +167,9 @@ describe("resolvePreferredOpenClawTmpDir", () => { lstatSync: lstatSyncMock, }); - expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR); + expect(resolved).toBe(DEFAULT_POSIX_TMP_ROOT); expect(accessSync).toHaveBeenCalledWith("/tmp", fsConstants.W_OK | fsConstants.X_OK); - expect(mkdirSync).toHaveBeenCalledWith(POSIX_OPENCLAW_TMP_DIR, { + expect(mkdirSync).toHaveBeenCalledWith(DEFAULT_POSIX_TMP_ROOT, { recursive: true, mode: 0o700, }); @@ -196,7 +196,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { name: "falls back when /tmp/openclaw exists but is not writable", lstatSync: vi.fn(() => secureDirStat()), accessSync: vi.fn((target: string) => { - if (target === POSIX_OPENCLAW_TMP_DIR) { + if (target === DEFAULT_POSIX_TMP_ROOT) { throw new Error("not writable"); } }), @@ -224,7 +224,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { it("repairs existing /tmp/openclaw permissions when they are too broad", () => { let preferredMode = 0o40777; const chmodSync = vi.fn((target: string, mode: number) => { - if (target === POSIX_OPENCLAW_TMP_DIR && mode === 0o700) { + if (target === DEFAULT_POSIX_TMP_ROOT && mode === 0o700) { preferredMode = 0o40700; } }); @@ -236,10 +236,10 @@ describe("resolvePreferredOpenClawTmpDir", () => { warn, }); - expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR); - expect(chmodSync).toHaveBeenCalledWith(POSIX_OPENCLAW_TMP_DIR, 0o700); + expect(resolved).toBe(DEFAULT_POSIX_TMP_ROOT); + expect(chmodSync).toHaveBeenCalledWith(DEFAULT_POSIX_TMP_ROOT, 0o700); expect(warn).toHaveBeenCalledWith( - `[openclaw] tightened permissions on temp dir: ${POSIX_OPENCLAW_TMP_DIR}`, + `[openclaw] tightened permissions on temp dir: ${DEFAULT_POSIX_TMP_ROOT}`, ); expect(tmpdir).not.toHaveBeenCalled(); }); @@ -259,7 +259,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { ); const chmodSync = vi.fn((target: string, mode: number) => { chmodCalls += 1; - if (target === POSIX_OPENCLAW_TMP_DIR && mode === 0o700 && chmodCalls > 1) { + if (target === DEFAULT_POSIX_TMP_ROOT && mode === 0o700 && chmodCalls > 1) { preferredMode = 0o40700; } }); @@ -271,14 +271,14 @@ describe("resolvePreferredOpenClawTmpDir", () => { warn, }); - expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR); - expect(mkdirSync).toHaveBeenCalledWith(POSIX_OPENCLAW_TMP_DIR, { + expect(resolved).toBe(DEFAULT_POSIX_TMP_ROOT); + expect(mkdirSync).toHaveBeenCalledWith(DEFAULT_POSIX_TMP_ROOT, { recursive: true, mode: 0o700, }); - expect(chmodSync).toHaveBeenCalledWith(POSIX_OPENCLAW_TMP_DIR, 0o700); + expect(chmodSync).toHaveBeenCalledWith(DEFAULT_POSIX_TMP_ROOT, 0o700); expect(warn).toHaveBeenCalledWith( - `[openclaw] tightened permissions on temp dir: ${POSIX_OPENCLAW_TMP_DIR}`, + `[openclaw] tightened permissions on temp dir: ${DEFAULT_POSIX_TMP_ROOT}`, ); expect(tmpdir).not.toHaveBeenCalled(); }); @@ -319,7 +319,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { } }), lstatSync: vi.fn((target: string) => { - if (target === POSIX_OPENCLAW_TMP_DIR) { + if (target === DEFAULT_POSIX_TMP_ROOT) { throw nodeErrorWithCode("ENOENT"); } if (target === fallbackPath) { @@ -411,7 +411,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { const tmpdir = vi.fn(() => "/var/fallback"); const states = [0o40777, 0o40700, 0o40700]; const lstatSync = vi.fn>((target: string) => { - if (target === POSIX_OPENCLAW_TMP_DIR) { + if (target === DEFAULT_POSIX_TMP_ROOT) { return makeDirStat({ mode: states.shift() ?? 0o40700 }); } return secureDirStat(); @@ -427,7 +427,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { warn, }); - expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR); + expect(resolved).toBe(DEFAULT_POSIX_TMP_ROOT); expect(chmodSync).not.toHaveBeenCalled(); expect(warn).not.toHaveBeenCalled(); expect(tmpdir).not.toHaveBeenCalled(); @@ -453,14 +453,14 @@ describe("resolvePreferredOpenClawTmpDir", () => { it("uses /tmp/openclaw when chmod loses a concurrent repair race", () => { const chmodSync = vi.fn((target: string, mode: number) => { - if (target === POSIX_OPENCLAW_TMP_DIR && mode === 0o700) { + if (target === DEFAULT_POSIX_TMP_ROOT && mode === 0o700) { throw nodeErrorWithCode("EPERM"); } }); const warn = vi.fn(); const states = [0o40777, 0o40777, 0o40700]; const lstatSync = vi.fn>((target: string) => { - if (target === POSIX_OPENCLAW_TMP_DIR) { + if (target === DEFAULT_POSIX_TMP_ROOT) { return makeDirStat({ mode: states.shift() ?? 0o40700 }); } return secureDirStat(); @@ -476,8 +476,8 @@ describe("resolvePreferredOpenClawTmpDir", () => { warn, }); - expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR); - expect(chmodSync).toHaveBeenCalledWith(POSIX_OPENCLAW_TMP_DIR, 0o700); + expect(resolved).toBe(DEFAULT_POSIX_TMP_ROOT); + expect(chmodSync).toHaveBeenCalledWith(DEFAULT_POSIX_TMP_ROOT, 0o700); expect(warn).not.toHaveBeenCalled(); }); @@ -508,7 +508,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { resolvePreferredOpenClawTmpDir({ accessSync: readOnlyTmpAccessSync(), lstatSync: vi.fn((target: string) => { - if (target === POSIX_OPENCLAW_TMP_DIR || target === fallbackTmp()) { + if (target === DEFAULT_POSIX_TMP_ROOT || target === fallbackTmp()) { throw nodeErrorWithCode("ENOENT"); } return secureDirStat(); @@ -533,7 +533,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { const winFallback = path.win32.join("C:\\Users\\u\\AppData\\Local\\Temp", "openclaw-501"); const accessSync = vi.fn(); const lstatSync = vi.fn((target: string) => { - if (target === POSIX_OPENCLAW_TMP_DIR || target === winFallback) { + if (target === DEFAULT_POSIX_TMP_ROOT || target === winFallback) { return secureDirStat(); } throw nodeErrorWithCode("ENOENT"); @@ -554,7 +554,7 @@ describe("resolvePreferredOpenClawTmpDir", () => { }); expect(result).toBe(winFallback); - expect(result).not.toBe(POSIX_OPENCLAW_TMP_DIR); + expect(result).not.toBe(DEFAULT_POSIX_TMP_ROOT); expect(tmpdir).toHaveBeenCalled(); }); @@ -570,6 +570,6 @@ describe("resolvePreferredOpenClawTmpDir", () => { warn: vi.fn(), }); - expect(result).toBe(POSIX_OPENCLAW_TMP_DIR); + expect(result).toBe(DEFAULT_POSIX_TMP_ROOT); }); }); diff --git a/src/infra/tmp-openclaw-dir.ts b/src/infra/tmp-openclaw-dir.ts index 441d2c40f2bb..225b099bfac7 100644 --- a/src/infra/tmp-openclaw-dir.ts +++ b/src/infra/tmp-openclaw-dir.ts @@ -4,7 +4,7 @@ import { tmpdir as getOsTmpDir } from "node:os"; import path from "node:path"; /** Preferred shared OpenClaw temp root on POSIX systems when ownership and permissions are safe. */ -export const POSIX_OPENCLAW_TMP_DIR = "/tmp/openclaw"; +export const DEFAULT_POSIX_TMP_ROOT = "/tmp/openclaw"; type MaybeNodeError = { code?: string }; @@ -155,7 +155,7 @@ export function resolvePreferredOpenClawTmpDir( return ensureTrustedFallbackDir(); } - const preferredDir = POSIX_OPENCLAW_TMP_DIR; + const preferredDir = DEFAULT_POSIX_TMP_ROOT; const preferredState = resolveDirState(preferredDir); if (preferredState === "available") { return preferredDir; diff --git a/src/infra/update-runner-git-commands.ts b/src/infra/update-runner-git-commands.ts index 987a0dca678c..f59efff7ae28 100644 --- a/src/infra/update-runner-git-commands.ts +++ b/src/infra/update-runner-git-commands.ts @@ -9,7 +9,6 @@ const BUILD_MAX_OLD_SPACE_MB = 8192; const DEV_PREFLIGHT_LINT_ENV: NodeJS.ProcessEnv = { OPENCLAW_LOCAL_CHECK: "1", OPENCLAW_LOCAL_CHECK_MODE: "throttled", - OPENCLAW_OXLINT_SHARDS_SERIAL: "1", }; const DEV_PREFLIGHT_LINT_OPT_IN_ENV = "OPENCLAW_UPDATE_PREFLIGHT_LINT"; diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 48a861866439..3cbd9790d423 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -1906,7 +1906,6 @@ describe("runGatewayUpdate", () => { expect(lintEnv).toHaveLength(1); expect(lintEnv[0]?.OPENCLAW_LOCAL_CHECK).toBe("1"); expect(lintEnv[0]?.OPENCLAW_LOCAL_CHECK_MODE).toBe("throttled"); - expect(lintEnv[0]?.OPENCLAW_OXLINT_SHARDS_SERIAL).toBe("1"); }); it("retries windows pnpm git installs with --ignore-scripts for dev updates", async () => { diff --git a/src/logging/console.ts b/src/logging/console.ts index 44455ac5c81b..6ffaee96b6e1 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -61,7 +61,7 @@ function resolveConsoleSettings(): ConsoleSettings { return { level: "silent", style: normalizeConsoleStyle(undefined) }; } - let cfg: OpenClawConfig["logging"] | undefined = + let cfg: OpenClawConfig["logging"] | LoggerSettings | undefined = (loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig(); if (!cfg && !shouldSkipMutatingLoggingConfigRead()) { if (loggingState.resolvingConsoleSettings) { diff --git a/src/logging/log-file-path.ts b/src/logging/log-file-path.ts index 8d250f2c75c4..f5f7f905115b 100644 --- a/src/logging/log-file-path.ts +++ b/src/logging/log-file-path.ts @@ -2,13 +2,13 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/types.js"; import { - POSIX_OPENCLAW_TMP_DIR, + DEFAULT_POSIX_TMP_ROOT, resolvePreferredOpenClawTmpDir, } from "../infra/tmp-openclaw-dir.js"; import { canUseNodeFs, formatLocalDate, LOG_PREFIX, LOG_SUFFIX } from "./log-file-shared.js"; function resolveDefaultRollingLogFile(date = new Date()): string { - const logDir = canUseNodeFs() ? resolvePreferredOpenClawTmpDir() : POSIX_OPENCLAW_TMP_DIR; + const logDir = canUseNodeFs() ? resolvePreferredOpenClawTmpDir() : DEFAULT_POSIX_TMP_ROOT; return path.join(logDir, `${LOG_PREFIX}-${formatLocalDate(date)}${LOG_SUFFIX}`); } diff --git a/src/logging/logger-redaction-behavior.test.ts b/src/logging/logger-redaction-behavior.test.ts index 9f6d628f7491..4ef905114a42 100644 --- a/src/logging/logger-redaction-behavior.test.ts +++ b/src/logging/logger-redaction-behavior.test.ts @@ -108,7 +108,7 @@ describe("file log redaction", () => { } }); - it("honors logging redaction opt-out for structured file log fields", () => { + it("keeps structured file log fields redacted when the retired opt-out is present", () => { const logPath = logPathTracker.nextPath(); const configPath = logPathTracker.nextPath(); fs.writeFileSync( @@ -131,10 +131,10 @@ describe("file log redaction", () => { }); const content = fs.readFileSync(logPath, "utf8"); - expect(content).toContain("token-value-1234567890"); - expect(content).toContain("ya29.fake-access-token-with-enough-length"); - expect(content).toContain("abcd-efgh-ijkl-mnop"); - expect(content).toContain(secret); + expect(content).not.toContain("token-value-1234567890"); + expect(content).not.toContain("ya29.fake-access-token-with-enough-length"); + expect(content).not.toContain("abcd-efgh-ijkl-mnop"); + expect(content).not.toContain(secret); }); it("uses logging.file from the active config path", () => { diff --git a/src/logging/logger.ts b/src/logging/logger.ts index ab5604ecd504..77e0df2736e6 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -21,7 +21,7 @@ import { expandHomePrefix } from "../infra/home-dir.js"; import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import { appendRegularFileSync } from "../infra/regular-file.js"; import { - POSIX_OPENCLAW_TMP_DIR, + DEFAULT_POSIX_TMP_ROOT, resolvePreferredOpenClawTmpDir, } from "../infra/tmp-openclaw-dir.js"; import { readLoggingConfig, shouldSkipMutatingLoggingConfigRead } from "./config.js"; @@ -35,13 +35,13 @@ import type { LoggerSettings } from "./types.js"; export type { LoggerSettings } from "./types.js"; function resolveDefaultLogDir(): string { - return canUseNodeFs() ? resolvePreferredOpenClawTmpDir() : POSIX_OPENCLAW_TMP_DIR; + return canUseNodeFs() ? resolvePreferredOpenClawTmpDir() : DEFAULT_POSIX_TMP_ROOT; } function resolveDefaultLogFile(defaultLogDir: string): string { return canUseNodeFs() ? path.join(defaultLogDir, "openclaw.log") - : `${POSIX_OPENCLAW_TMP_DIR}/openclaw.log`; + : `${DEFAULT_POSIX_TMP_ROOT}/openclaw.log`; } export const DEFAULT_LOG_DIR = resolveDefaultLogDir(); @@ -472,12 +472,8 @@ function buildDiagnosticLogRecord(logObj: TsLogRecord) { }; } -function isLogRedactionDisabled(): boolean { - return readLoggingConfig()?.redactSensitive === "off"; -} - function redactLogRecordForTransport(record: T): T { - return isLogRedactionDisabled() ? record : redactSecrets(record); + return redactSecrets(record); } function attachDiagnosticEventTransport(logger: TsLogger): void { @@ -535,7 +531,7 @@ function resolveSettings(): ResolvedSettings { }; } - const cfg: OpenClawConfig["logging"] | undefined = + const cfg: OpenClawConfig["logging"] | LoggerSettings | undefined = (loggingState.overrideSettings as LoggerSettings | null) ?? loadLoggerConfig(); const defaultLevel = process.env.VITEST === "true" && process.env.OPENCLAW_TEST_FILE_LOG !== "1" ? "silent" : "info"; diff --git a/src/logging/redact.test.ts b/src/logging/redact.test.ts index 4960c9944a3a..8ed2d5546fc3 100644 --- a/src/logging/redact.test.ts +++ b/src/logging/redact.test.ts @@ -1487,7 +1487,7 @@ describe("redactSensitiveText", () => { expect(output).toBe(input); }); - it("honors logging redaction settings from the active config path", () => { + it("ignores the retired log-redaction opt-out from the active config path", () => { const configPath = writeConfig(`{ logging: { redactSensitive: "off", @@ -1496,7 +1496,7 @@ describe("redactSensitiveText", () => { withEnv({ OPENCLAW_CONFIG_PATH: configPath }, () => expect(redactSensitiveText("OPENAI_API_KEY=sk-1234567890abcdef")).toBe( - "OPENAI_API_KEY=sk-1234567890abcdef", + "OPENAI_API_KEY=sk-123…cdef", ), ); }); diff --git a/src/logging/redact.ts b/src/logging/redact.ts index 3c61dc9ef332..684bb19bae2e 100644 --- a/src/logging/redact.ts +++ b/src/logging/redact.ts @@ -1016,7 +1016,7 @@ function redactAppSpecificPasswords(text: string): string { function resolveConfigRedaction(): RedactOptions { const cfg = readLoggingConfig(); return { - mode: normalizeMode(cfg?.redactSensitive), + mode: DEFAULT_REDACT_MODE, patterns: cfg?.redactPatterns, }; } diff --git a/src/media-generation/runtime-shared.test.ts b/src/media-generation/runtime-shared.test.ts index 051d238d2cf8..3d5273f0c289 100644 --- a/src/media-generation/runtime-shared.test.ts +++ b/src/media-generation/runtime-shared.test.ts @@ -132,7 +132,7 @@ describe("media-generation runtime shared candidates", () => { ]); }); - it("disables implicit provider expansion when mediaGenerationAutoProviderFallback=false", () => { + it("keeps implicit provider expansion enabled when the retired opt-out is present", () => { let listProviderCalls = 0; const candidates = resolveCapabilityModelCandidates({ cfg: { @@ -158,8 +158,11 @@ describe("media-generation runtime shared candidates", () => { }, }); - expect(candidates).toEqual([{ provider: "google", model: "gemini-3.1-flash-image-preview" }]); - expect(listProviderCalls).toBe(0); + expect(candidates).toEqual([ + { provider: "google", model: "gemini-3.1-flash-image-preview" }, + { provider: "openai", model: "gpt-image-1" }, + ]); + expect(listProviderCalls).toBe(1); }); it("treats an explicit model override as exact-only", () => { diff --git a/src/media-generation/runtime-shared.ts b/src/media-generation/runtime-shared.ts index 955a8e33001e..9ede5656b862 100644 --- a/src/media-generation/runtime-shared.ts +++ b/src/media-generation/runtime-shared.ts @@ -223,9 +223,8 @@ export function resolveCapabilityModelCandidates(params: { return [override]; } - const autoProviderFallbackEnabled = - params.autoProviderFallback ?? - params.cfg.agents?.defaults?.mediaGenerationAutoProviderFallback !== false; + // Cross-provider fallback is a fixed product policy; Doctor removes the retired opt-out. + const autoProviderFallbackEnabled = params.autoProviderFallback ?? true; add(params.modelOverride, { useProviderMetadata: true }); add(resolveAgentModelPrimaryValue(params.modelConfig), { useProviderMetadata: autoProviderFallbackEnabled, diff --git a/src/media-understanding/apply.echo-transcript.test.ts b/src/media-understanding/apply.echo-transcript.test.ts index d1e403735536..6604d76f9f16 100644 --- a/src/media-understanding/apply.echo-transcript.test.ts +++ b/src/media-understanding/apply.echo-transcript.test.ts @@ -88,10 +88,10 @@ function createAudioConfigWithEcho(opts?: { const cfg: OpenClawConfig = { tools: { media: { + models: [{ provider: "groq", capabilities: ["audio"] }], audio: { enabled: true, maxBytes: 1024 * 1024, - models: [{ provider: "groq" }], echoTranscript: opts?.echoTranscript ?? true, ...(opts?.echoFormat !== undefined ? { echoFormat: opts.echoFormat } : {}), }, diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index 2d336061c774..de00f515715a 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -68,10 +68,10 @@ function createGroqAudioConfig(): OpenClawConfig { return { tools: { media: { + models: [{ provider: "groq", capabilities: ["audio"] }], audio: { enabled: true, maxBytes: 1024 * 1024, - models: [{ provider: "groq" }], }, }, }, @@ -475,6 +475,7 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [{ provider: "groq", capabilities: ["audio"] }], audio: { enabled: true, maxBytes: 1024 * 1024, @@ -482,7 +483,6 @@ describe("applyMediaUnderstanding", () => { default: "deny", rules: [{ action: "allow", match: { chatType: "direct" } }], }, - models: [{ provider: "groq" }], }, }, }, @@ -514,6 +514,7 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [{ provider: "groq", capabilities: ["audio"] }], audio: { enabled: true, maxBytes: 1024 * 1024, @@ -521,7 +522,6 @@ describe("applyMediaUnderstanding", () => { default: "deny", rules: [{ action: "allow", match: { channel: "whatsapp" } }], }, - models: [{ provider: "groq" }], }, }, }, @@ -555,6 +555,7 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [{ provider: "groq", capabilities: ["audio"] }], audio: { enabled: true, maxBytes: 1024 * 1024, @@ -562,7 +563,6 @@ describe("applyMediaUnderstanding", () => { default: "deny", rules: [{ action: "allow", match: { chatType: "direct" } }], }, - models: [{ provider: "groq" }], }, }, }, @@ -605,10 +605,10 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [{ provider: "groq", capabilities: ["audio"] }], audio: { enabled: true, maxBytes: 1024 * 1024, - models: [{ provider: "groq" }], }, }, }, @@ -651,10 +651,10 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [{ provider: "groq", capabilities: ["audio"] }], audio: { enabled: true, maxBytes: 4, - models: [{ provider: "groq" }], }, }, }, @@ -676,16 +676,17 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [ + { provider: "groq", capabilities: ["audio"] }, + { + type: "cli", + command: "whisper", + args: ["{{MediaPath}}"], + capabilities: ["audio"], + }, + ], audio: { enabled: true, - models: [ - { provider: "groq" }, - { - type: "cli", - command: "whisper", - args: ["{{MediaPath}}"], - }, - ], }, }, }, @@ -719,15 +720,16 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [ + { + type: "cli", + command: "parakeet-mlx", + args: ["{{MediaPath}}", "--output-format", "txt", "--output-dir", "{{OutputDir}}"], + capabilities: ["audio"], + }, + ], audio: { enabled: true, - models: [ - { - type: "cli", - command: "parakeet-mlx", - args: ["{{MediaPath}}", "--output-format", "txt", "--output-dir", "{{OutputDir}}"], - }, - ], }, }, }, @@ -757,15 +759,16 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [ + { + type: "cli", + command: "parakeet-mlx", + args: ["{{MediaPath}}", "--output-format", "json", "--output-dir", "{{OutputDir}}"], + capabilities: ["audio"], + }, + ], audio: { enabled: true, - models: [ - { - type: "cli", - command: "parakeet-mlx", - args: ["{{MediaPath}}", "--output-format", "json", "--output-dir", "{{OutputDir}}"], - }, - ], }, }, }, @@ -1098,15 +1101,16 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [ + { + type: "cli", + command: "gemini", + args: ["--file", "{{MediaPath}}", "--prompt", "{{Prompt}}"], + capabilities: ["image"], + }, + ], image: { enabled: true, - models: [ - { - type: "cli", - command: "gemini", - args: ["--file", "{{MediaPath}}", "--prompt", "{{Prompt}}"], - }, - ], }, }, }, @@ -1185,9 +1189,15 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [ + { + provider: "openai", + model: "gpt-5.4", + capabilities: ["image"], + }, + ], image: { enabled: true, - models: [{ provider: "openai", model: "gpt-5.4" }], }, }, }, @@ -1233,9 +1243,15 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [ + { + provider: "openai", + model: "gpt-5.4", + capabilities: ["image"], + }, + ], image: { enabled: true, - models: [{ provider: "openai", model: "gpt-5.4" }], }, }, }, @@ -1316,9 +1332,9 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [{ provider: "groq", capabilities: ["audio"] }], audio: { enabled: true, - models: [{ provider: "groq" }], }, }, }, @@ -1362,10 +1378,10 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [{ provider: "groq", capabilities: ["audio"] }], audio: { enabled: true, attachments: { mode: "all", maxAttachments: 2 }, - models: [{ provider: "groq" }], }, }, }, @@ -1406,10 +1422,10 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { + models: [{ provider: "groq", capabilities: ["audio"] }], audio: { enabled: true, attachments: { mode: "all", maxAttachments: 2 }, - models: [{ provider: "groq" }], }, }, }, @@ -1456,9 +1472,14 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { - image: { enabled: true, models: [{ provider: "openai", model: "gpt-5.4" }] }, - audio: { enabled: true, models: [{ provider: "groq" }] }, - video: { enabled: true, models: [{ provider: "google", model: "gemini-3" }] }, + models: [ + { provider: "openai", model: "gpt-5.4", capabilities: ["image"] }, + { provider: "groq", capabilities: ["audio"] }, + { provider: "google", model: "gemini-3", capabilities: ["video"] }, + ], + image: { enabled: true }, + audio: { enabled: true }, + video: { enabled: true }, }, }, }; @@ -1517,8 +1538,12 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { - image: { enabled: true, models: [{ provider: "openai", model: "gpt-5.4" }] }, - audio: { enabled: true, models: [{ provider: "groq" }] }, + models: [ + { provider: "openai", model: "gpt-5.4", capabilities: ["image"] }, + { provider: "groq", capabilities: ["audio"] }, + ], + image: { enabled: true }, + audio: { enabled: true }, }, }, }; @@ -1564,9 +1589,14 @@ describe("applyMediaUnderstanding", () => { const cfg: OpenClawConfig = { tools: { media: { - image: { enabled: true, models: [{ provider: "openai", model: "gpt-5.4" }] }, - audio: { enabled: true, models: [{ provider: "groq" }] }, - video: { enabled: true, models: [{ provider: "google", model: "gemini-3" }] }, + models: [ + { provider: "openai", model: "gpt-5.4", capabilities: ["image"] }, + { provider: "groq", capabilities: ["audio"] }, + { provider: "google", model: "gemini-3", capabilities: ["video"] }, + ], + image: { enabled: true }, + audio: { enabled: true }, + video: { enabled: true }, }, }, }; diff --git a/src/media-understanding/resolve.test.ts b/src/media-understanding/resolve.test.ts index df835ae0c870..91d668222b53 100644 --- a/src/media-understanding/resolve.test.ts +++ b/src/media-understanding/resolve.test.ts @@ -51,13 +51,15 @@ describe("resolveModelEntries", () => { expect(audioEntries).toHaveLength(0); }); - it("keeps per-capability entries even without explicit caps", () => { + it("orders capability-tagged shared entries by the per-capability preference", () => { const cfg: OpenClawConfig = { tools: { media: { - image: { - models: [{ provider: "openai", model: "gpt-5.4" }], - }, + models: [ + { provider: "openai", model: "gpt-5.4-mini", capabilities: ["image"] }, + { provider: "openai", model: "gpt-5.4", capabilities: ["image"] }, + ], + image: { preferredModel: "openai/gpt-5.4" }, }, }, }; @@ -68,8 +70,55 @@ describe("resolveModelEntries", () => { config: cfg.tools?.media?.image, providerRegistry, }); - expect(imageEntries).toHaveLength(1); - expect(imageEntries[0]?.secretOwnerId).toBe("media-model:image:0"); + expect(imageEntries).toHaveLength(2); + expect(imageEntries[0]).toMatchObject({ + entry: { model: "gpt-5.4" }, + secretOwnerId: "media-model:shared:1", + }); + }); + + it("ranks an exact provider-qualified preference above a matching bare model id", () => { + const cfg: OpenClawConfig = { + tools: { + media: { + models: [ + { provider: "openrouter", model: "openai/gpt-5.4", capabilities: ["image"] }, + { provider: "openai", model: "gpt-5.4", capabilities: ["image"] }, + ], + image: { preferredModel: "openai/gpt-5.4" }, + }, + }, + }; + + const entries = resolveModelEntries({ + cfg, + capability: "image", + config: cfg.tools?.media?.image, + providerRegistry, + }); + expect(entries[0]?.entry).toMatchObject({ provider: "openai", model: "gpt-5.4" }); + }); + + it("prefers a provider-default entry without requiring a model id", () => { + const cfg: OpenClawConfig = { + tools: { + media: { + models: [ + { provider: "groq", model: "whisper-large-v3", capabilities: ["audio"] }, + { provider: "openai", capabilities: ["audio"] }, + ], + audio: { preferredModel: "provider:openai" }, + }, + }, + }; + + const entries = resolveModelEntries({ + cfg, + capability: "audio", + config: cfg.tools?.media?.audio, + providerRegistry, + }); + expect(entries[0]?.entry.provider).toBe("openai"); }); it("skips shared CLI entries without capabilities", () => { diff --git a/src/media-understanding/resolve.ts b/src/media-understanding/resolve.ts index 0085d182acc3..1f22cedeae23 100644 --- a/src/media-understanding/resolve.ts +++ b/src/media-understanding/resolve.ts @@ -119,22 +119,11 @@ export function resolveModelEntries(params: { }): ResolvedMediaModelEntry[] { const { cfg, capability, config } = params; const sharedModels = cfg.tools?.media?.models ?? []; - const entries = [ - ...(config?.models ?? []).map((entry, index) => ({ - entry, - source: "capability" as const, - secretOwnerId: runtimeMediaModelSecretOwnerId({ - source: "capability", - capability, - index, - }), - })), - ...sharedModels.map((entry, index) => ({ - entry, - source: "shared" as const, - secretOwnerId: runtimeMediaModelSecretOwnerId({ source: "shared", index }), - })), - ]; + const entries = sharedModels.map((entry, index) => ({ + entry, + source: "shared" as const, + secretOwnerId: runtimeMediaModelSecretOwnerId({ source: "shared", index }), + })); if (entries.length === 0) { return []; } @@ -159,7 +148,31 @@ export function resolveModelEntries(params: { } return caps.includes(capability); }) - .map(({ entry, secretOwnerId }) => ({ entry, secretOwnerId })); + .map(({ entry, secretOwnerId }) => ({ entry, secretOwnerId })) + .toSorted((left, right) => { + const preferred = config?.preferredModel?.trim(); + if (!preferred) { + return 0; + } + return ( + preferredMediaModelRank(right.entry, preferred) - + preferredMediaModelRank(left.entry, preferred) + ); + }); +} + +function preferredMediaModelRank(entry: MediaUnderstandingModelConfig, preferred: string): number { + if (entry.type === "cli" || entry.command) { + return preferred === `cli:${entry.command ?? ""}` ? 2 : 0; + } + const model = entry.model?.trim(); + if (!model) { + return preferred === `provider:${entry.provider?.trim() ?? ""}` ? 2 : 0; + } + if (preferred === `${entry.provider?.trim() ?? ""}/${model}`) { + return 2; + } + return preferred === model ? 1 : 0; } /** Resolves the bounded media-understanding task concurrency from config. */ diff --git a/src/media-understanding/runner.auto-audio.test.ts b/src/media-understanding/runner.auto-audio.test.ts index 10cc859eeade..3a8ad1369576 100644 --- a/src/media-understanding/runner.auto-audio.test.ts +++ b/src/media-understanding/runner.auto-audio.test.ts @@ -381,6 +381,15 @@ describe("runCapability auto audio entries", () => { cfgExtra: { tools: { media: { + models: [ + { + provider: "openai", + model: "whisper-1", + prompt: "entry prompt", + language: "de", + capabilities: ["audio"], + }, + ], audio: { enabled: false, }, @@ -402,9 +411,7 @@ describe("runCapability auto audio entries", () => { cfgExtra: { tools: { media: { - audio: { - models: [{ provider: "openai", model: "whisper-1" }], - }, + models: [{ provider: "openai", model: "whisper-1", capabilities: ["audio"] }], }, }, }, @@ -432,14 +439,6 @@ describe("runCapability auto audio entries", () => { language: "fr", _requestPromptOverride: "Focus on names", _requestLanguageOverride: "en", - models: [ - { - provider: "openai", - model: "whisper-1", - prompt: "entry prompt", - language: "de", - }, - ], }, }, }, @@ -463,10 +462,10 @@ describe("runCapability auto audio entries", () => { cfgExtra: { tools: { media: { + models: [{ provider: "openai", model: "whisper-1", capabilities: ["audio"] }], audio: { enabled: true, language: "ru", - models: [{ provider: "openai", model: "whisper-1" }], }, }, }, diff --git a/src/media-understanding/runner.deepgram.test.ts b/src/media-understanding/runner.deepgram.test.ts index 60cc6e737899..6bf6b4e2c7e3 100644 --- a/src/media-understanding/runner.deepgram.test.ts +++ b/src/media-understanding/runner.deepgram.test.ts @@ -78,33 +78,34 @@ describe("runCapability deepgram provider options", () => { punctuate: true, }, }, - models: [ - { - provider: "deepgram", - model: "nova-3", - baseUrl: "https://entry.example", + }, + models: [ + { + provider: "deepgram", + model: "nova-3", + capabilities: ["audio"], + baseUrl: "https://entry.example", + headers: { + "X-Entry": "3", + "X-Entry-Managed": "secretref-managed", + }, + request: { headers: { - "X-Entry": "3", - "X-Entry-Managed": "secretref-managed", + "X-Entry-Request": "entry", }, - request: { - headers: { - "X-Entry-Request": "entry", - }, - tls: { - serverName: "deepgram.internal", - }, - }, - providerOptions: { - deepgram: { - detectLanguage: false, - punctuate: false, - smart_format: true, - }, + tls: { + serverName: "deepgram.internal", }, }, - ], - }, + providerOptions: { + deepgram: { + detectLanguage: false, + punctuate: false, + smart_format: true, + }, + }, + }, + ], }, }, } as unknown as OpenClawConfig; diff --git a/src/media-understanding/runner.entries.guards.test.ts b/src/media-understanding/runner.entries.guards.test.ts index 9d5773bed065..f3e589fa6020 100644 --- a/src/media-understanding/runner.entries.guards.test.ts +++ b/src/media-understanding/runner.entries.guards.test.ts @@ -133,11 +133,10 @@ describe("media-understanding missing provider errors", () => { describe("media-understanding SecretRef owner isolation", () => { it("rejects only the configured media model whose owner is unavailable", async () => { - const entry = { provider: "openai" }; - const cfg = { tools: { media: { audio: { models: [entry] } } } }; + const entry = { provider: "openai", capabilities: ["audio" as const] }; + const cfg = { tools: { media: { models: [entry], audio: {} } } }; const ownerId = runtimeMediaModelSecretOwnerId({ - source: "capability", - capability: "audio", + source: "shared", index: 0, }); setActiveDegradedSecretOwners([ @@ -145,7 +144,7 @@ describe("media-understanding SecretRef owner isolation", () => { ownerKind: "capability", ownerId, state: "unavailable", - paths: ["tools.media.audio.models.0.request.auth.token"], + paths: ["tools.media.models.0.request.auth.token"], refKeys: ["env:default:MISSING_MEDIA_VALUE"], reason: "secret reference was not found", }, @@ -174,9 +173,10 @@ describe("media-understanding SecretRef owner isolation", () => { it("keeps a model active when it overrides the unavailable request field", async () => { const entry = { provider: "unknown-provider", + capabilities: ["audio" as const], request: { auth: { mode: "authorization-bearer" as const, token: "test-token" } }, }; - const cfg = { tools: { media: { audio: { models: [entry] } } } }; + const cfg = { tools: { media: { models: [entry], audio: {} } } }; setActiveDegradedSecretOwners([ { ownerKind: "capability", diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index ee840d9ee402..007a6c7c9ed4 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -858,11 +858,7 @@ export async function runProviderEntry(params: { timeoutMs, }); assertMinAudioSize({ size: media.size, attachmentIndex: params.attachmentIndex }); - const audioLanguage = - requestOverrides.language ?? - entry.language ?? - params.config?.language ?? - cfg.tools?.media?.audio?.language; + const audioLanguage = requestOverrides.language ?? entry.language ?? params.config?.language; const audioPrompt = requestOverrides.prompt ?? resolveAudioProviderPrompt({ diff --git a/src/media-understanding/runner.local-no-auth.test.ts b/src/media-understanding/runner.local-no-auth.test.ts index 7ecc29864893..c696c293f7db 100644 --- a/src/media-understanding/runner.local-no-auth.test.ts +++ b/src/media-understanding/runner.local-no-auth.test.ts @@ -119,11 +119,17 @@ function createAudioCfg(params: { : {}), tools: { media: { + models: [ + { + type: "provider", + provider: params.provider, + model: params.model, + capabilities: ["audio"], + ...params.entry, + }, + ], audio: { enabled: true, - models: [ - { type: "provider", provider: params.provider, model: params.model, ...params.entry }, - ], }, }, }, @@ -134,9 +140,16 @@ function createVideoCfg(params: { provider: string; model: string }): OpenClawCo return { tools: { media: { + models: [ + { + type: "provider", + provider: params.provider, + model: params.model, + capabilities: ["video"], + }, + ], video: { enabled: true, - models: [{ type: "provider", provider: params.provider, model: params.model }], }, }, }, diff --git a/src/media-understanding/runner.proxy.test.ts b/src/media-understanding/runner.proxy.test.ts index 726b33a19340..e56e68a6911a 100644 --- a/src/media-understanding/runner.proxy.test.ts +++ b/src/media-understanding/runner.proxy.test.ts @@ -51,9 +51,9 @@ function createOpenAiAudioCfg(providerOverrides: Record = {}): }, tools: { media: { + models: [{ provider: "openai", model: "whisper-1", capabilities: ["audio"] }], audio: { enabled: true, - models: [{ provider: "openai", model: "whisper-1" }], }, }, }, @@ -143,9 +143,9 @@ describe("runCapability proxy fetch passthrough", () => { }, tools: { media: { + models: [{ provider: "moonshot", model: "kimi-k2.5", capabilities: ["video"] }], video: { enabled: true, - models: [{ provider: "moonshot", model: "kimi-k2.5" }], }, }, }, diff --git a/src/media-understanding/runner.ts b/src/media-understanding/runner.ts index 29bf9abd603b..bbfa6e711281 100644 --- a/src/media-understanding/runner.ts +++ b/src/media-understanding/runner.ts @@ -45,6 +45,7 @@ import { runExec } from "../process/exec.js"; import { getOrCreatePromise } from "../shared/lazy-promise.js"; import { createLazyRuntimeModule, createLazyRuntimeNamedExport } from "../shared/lazy-runtime.js"; import { MediaAttachmentCache, selectAttachments } from "./attachments.js"; +import { matchesMediaEntryCapability } from "./entry-capabilities.js"; import { clearLocalAudioInspectionCacheForTests, inspectLocalAudioSelection, @@ -662,9 +663,17 @@ function resolveImageModelFromAgentDefaults(params: { } function hasExplicitImageUnderstandingConfig(params: { - config?: MediaUnderstandingConfig; + cfg: OpenClawConfig; + providerRegistry: ProviderRegistry; }): boolean { - return (params.config?.models?.length ?? 0) > 0; + return (params.cfg.tools?.media?.models ?? []).some((entry) => + matchesMediaEntryCapability({ + entry, + source: "shared", + capability: "image", + providerRegistry: params.providerRegistry, + }), + ); } function isMinimaxNativeVisionModel(params: { provider: string; model?: string }): boolean { @@ -1008,7 +1017,7 @@ export async function runCapability(params: { activeModel?: ActiveMediaModel; }): Promise { const { capability, cfg, ctx } = params; - const config = params.config ?? cfg.tools?.media?.[capability]; + const config: MediaUnderstandingConfig = params.config ?? cfg.tools?.media?.[capability] ?? {}; if (config?.enabled === false) { return { outputs: [], @@ -1051,7 +1060,8 @@ export async function runCapability(params: { capability === "image" && activeProvider && !hasExplicitImageUnderstandingConfig({ - config, + cfg, + providerRegistry: params.providerRegistry, }) ) { if ( diff --git a/src/media-understanding/runner.video.test.ts b/src/media-understanding/runner.video.test.ts index 57d0ffc95a25..9040472588a1 100644 --- a/src/media-understanding/runner.video.test.ts +++ b/src/media-understanding/runner.video.test.ts @@ -57,9 +57,16 @@ describe("runCapability video provider wiring", () => { }, tools: { media: { + models: [ + { + provider: "moonshot", + model: "kimi-k2.5", + maxChars: 80, + capabilities: ["video"], + }, + ], video: { enabled: true, - models: [{ provider: "moonshot", model: "kimi-k2.5", maxChars: 80 }], }, }, }, @@ -108,18 +115,19 @@ describe("runCapability video provider wiring", () => { }, tools: { media: { + models: [ + { + provider: "moonshot", + model: "kimi-k2.5", + baseUrl: "https://entry.example/v1", + headers: { "X-Entry": "3" }, + capabilities: ["video"], + }, + ], video: { enabled: true, baseUrl: "https://config.example/v1", headers: { "X-Config": "2" }, - models: [ - { - provider: "moonshot", - model: "kimi-k2.5", - baseUrl: "https://entry.example/v1", - headers: { "X-Entry": "3" }, - }, - ], }, }, }, @@ -361,9 +369,7 @@ describe("runCapability video provider wiring", () => { }, tools: { media: { - video: { - models: [{ provider: "moonshot" }], - }, + models: [{ provider: "moonshot", capabilities: ["video"] }], }, }, } as unknown as OpenClawConfig; @@ -419,9 +425,9 @@ describe("runCapability video provider wiring", () => { }, tools: { media: { + models: [{ provider: "openai", model: "video-model", capabilities: ["video"] }], video: { enabled: true, - models: [{ provider: "openai", model: "video-model" }], }, }, }, diff --git a/src/media-understanding/runner.vision-skip.test.ts b/src/media-understanding/runner.vision-skip.test.ts index 3ca497e4b694..57170b6d14d7 100644 --- a/src/media-understanding/runner.vision-skip.test.ts +++ b/src/media-understanding/runner.vision-skip.test.ts @@ -136,11 +136,13 @@ describe("runCapability image skip", () => { vi.unstubAllEnvs(); }); - it("skips image understanding when the active model supports vision", async () => { + it("skips image understanding for a vision model when preferredModel is dangling", async () => { const ctx: MsgContext = { MediaPath: "/tmp/image.png", MediaType: "image/png" }; const media = normalizeMediaAttachments(ctx); const cache = createMediaAttachmentCache(media); - const cfg = {} as OpenClawConfig; + const cfg = { + tools: { media: { image: { preferredModel: "missing/model" } } }, + } as OpenClawConfig; try { const result = await runCapability({ @@ -299,9 +301,13 @@ describe("runCapability image skip", () => { const cfg = { tools: { media: { - image: { - models: [{ provider: "openrouter", model: "google/gemini-2.5-flash" }], - }, + models: [ + { + provider: "openrouter", + model: "google/gemini-2.5-flash", + capabilities: ["image"], + }, + ], }, }, } as unknown as OpenClawConfig; @@ -342,7 +348,19 @@ describe("runCapability image skip", () => { fileContents: Buffer.from("image"), }, async ({ ctx, media, cache }) => { - const cfg = {} as OpenClawConfig; + const cfg = { + tools: { + media: { + models: [ + { + provider: "openrouter", + model: "google/gemini-2.5-flash", + capabilities: ["image"], + }, + ], + }, + }, + } as OpenClawConfig; const result = await runCapability({ capability: "image", @@ -361,9 +379,6 @@ describe("runCapability image skip", () => { }, ], ]), - config: { - models: [{ provider: "openrouter", model: "google/gemini-2.5-flash" }], - }, activeModel: { provider: "openai", model: "gpt-4.1" }, }); @@ -389,7 +404,20 @@ describe("runCapability image skip", () => { }, async ({ ctx, media, cache }) => { let seenPrompt: string | undefined; - const cfg = {} as OpenClawConfig; + const cfg = { + tools: { + media: { + models: [ + { + provider: "openrouter", + model: "google/gemini-2.5-flash", + prompt: "entry prompt", + capabilities: ["image"], + }, + ], + }, + }, + } as OpenClawConfig; const result = await runCapability({ capability: "image", @@ -413,13 +441,6 @@ describe("runCapability image skip", () => { ]), config: { _requestPromptOverride: "Use this request prompt", - models: [ - { - provider: "openrouter", - model: "google/gemini-2.5-flash", - prompt: "entry prompt", - }, - ], }, activeModel: { provider: "openai", model: "gpt-4.1" }, }); @@ -807,9 +828,13 @@ describe("runCapability image skip", () => { const cfg = { tools: { media: { - image: { - models: [{ provider: "gemini", model: "gemini-3-flash-preview" }], - }, + models: [ + { + provider: "gemini", + model: "gemini-3-flash-preview", + capabilities: ["image"], + }, + ], }, }, } as OpenClawConfig; diff --git a/src/media-understanding/runtime.ts b/src/media-understanding/runtime.ts index f48ce3c8cdec..0128ccc205ed 100644 --- a/src/media-understanding/runtime.ts +++ b/src/media-understanding/runtime.ts @@ -149,9 +149,9 @@ export async function runMediaUnderstandingFile( params.timeoutMs > 0 ? Math.ceil(params.timeoutMs / 1000) : undefined; - const cfg = + const cfg: OpenClawConfig = requestPrompt || requestTimeoutSeconds !== undefined - ? { + ? ({ ...params.cfg, tools: { ...params.cfg.tools, @@ -171,7 +171,7 @@ export async function runMediaUnderstandingFile( }, }, }, - } + } as OpenClawConfig) : params.cfg; const ctx = buildFileContext({ ...params, @@ -396,9 +396,9 @@ export async function describeVideoFile( export async function transcribeAudioFile( params: TranscribeAudioFileParams, ): Promise { - const cfg = + const cfg: OpenClawConfig = params.language || params.prompt - ? { + ? ({ ...params.cfg, tools: { ...params.cfg.tools, @@ -413,7 +413,7 @@ export async function transcribeAudioFile( }, }, }, - } + } as OpenClawConfig) : params.cfg; const result = await runMediaUnderstandingFile({ ...params, cfg, capability: "audio" }); return result; diff --git a/src/music-generation/live-test-helpers.ts b/src/music-generation/live-test-helpers.ts index 6fdbb3c27942..fcbb8b33b4ea 100644 --- a/src/music-generation/live-test-helpers.ts +++ b/src/music-generation/live-test-helpers.ts @@ -22,7 +22,7 @@ export const DEFAULT_LIVE_MUSIC_MODELS: Record = { /** Resolve configured provider/model refs from the musicGenerationModel defaults. */ export function resolveConfiguredLiveMusicModels(cfg: OpenClawConfig): Map { - return resolveConfiguredLiveProviderModels(cfg.agents?.defaults?.musicGenerationModel); + return resolveConfiguredLiveProviderModels(cfg.agents?.defaults?.mediaModels?.music); } /** Resolve whether live music tests should require auth profile keys. */ diff --git a/src/music-generation/runtime.test.ts b/src/music-generation/runtime.test.ts index 144c1cbec336..c023fc1b434e 100644 --- a/src/music-generation/runtime.test.ts +++ b/src/music-generation/runtime.test.ts @@ -22,7 +22,25 @@ const runtimeDeps: MusicGenerationRuntimeDeps = { }; function runGenerateMusic(params: GenerateMusicParams) { - return generateMusic(params, runtimeDeps); + const defaults = params.cfg.agents?.defaults as + | (NonNullable["defaults"] & { + musicGenerationModel?: unknown; + }) + | undefined; + const cfg = + defaults?.musicGenerationModel !== undefined && defaults.mediaModels?.music === undefined + ? { + ...params.cfg, + agents: { + ...params.cfg.agents, + defaults: { + ...defaults, + mediaModels: { ...defaults.mediaModels, music: defaults.musicGenerationModel }, + }, + }, + } + : params.cfg; + return generateMusic({ ...params, cfg }, runtimeDeps); } describe("music-generation runtime", () => { diff --git a/src/music-generation/runtime.ts b/src/music-generation/runtime.ts index 64df4886f05b..07046397dc5b 100644 --- a/src/music-generation/runtime.ts +++ b/src/music-generation/runtime.ts @@ -52,10 +52,10 @@ export async function generateMusic( const logger = deps.log ?? log; const timeoutMs = params.timeoutMs ?? - resolveAgentModelTimeoutMsValue(params.cfg.agents?.defaults?.musicGenerationModel); + resolveAgentModelTimeoutMsValue(params.cfg.agents?.defaults?.mediaModels?.music); const candidates = resolveCapabilityModelCandidates({ cfg: params.cfg, - modelConfig: params.cfg.agents?.defaults?.musicGenerationModel, + modelConfig: params.cfg.agents?.defaults?.mediaModels?.music, modelOverride: params.modelOverride, parseModelRef: parseMusicGenerationModelRef, agentDir: params.agentDir, @@ -66,7 +66,7 @@ export async function generateMusic( throw new Error( buildNoCapabilityModelConfiguredMessage({ capabilityLabel: "music-generation", - modelConfigKey: "musicGenerationModel", + modelConfigKey: "mediaModels.music", providers: listProviders(params.cfg), fallbackSampleRef: "google/lyria-3-clip-preview", getProviderEnvVars: deps.getProviderEnvVars, diff --git a/src/node-host/linux-node-plugin.integration.test.ts b/src/node-host/linux-node-plugin.integration.test.ts index a6f0138cf4d8..9e2cff22e6bb 100644 --- a/src/node-host/linux-node-plugin.integration.test.ts +++ b/src/node-host/linux-node-plugin.integration.test.ts @@ -52,7 +52,7 @@ describe("linux-node node-host integration", () => { const config: OpenClawConfig = { gateway: { nodes: { - allowCommands: ["camera.snap", "camera.clip"], + commands: { allow: ["camera.snap", "camera.clip"] }, }, }, nodeHost: { skills: { enabled: false } }, diff --git a/src/plugin-sdk/channel-reply-pipeline.test.ts b/src/plugin-sdk/channel-reply-pipeline.test.ts index 62599dc8620a..94330bffd56f 100644 --- a/src/plugin-sdk/channel-reply-pipeline.test.ts +++ b/src/plugin-sdk/channel-reply-pipeline.test.ts @@ -99,7 +99,7 @@ describe("createChannelReplyPipeline", () => { it("resolves the live response prefix from selected-model context", () => { const pipeline = createChannelReplyPipeline({ - cfg: { messages: { responsePrefix: "[{model} | {thinkingLevel}]" } }, + cfg: { channels: { mattermost: { responsePrefix: "[{model} | {thinkingLevel}]" } } }, agentId: "main", channel: "mattermost", }); diff --git a/src/plugin-sdk/command-auth.ts b/src/plugin-sdk/command-auth.ts index 67013a86e64b..25342f6aaf9b 100644 --- a/src/plugin-sdk/command-auth.ts +++ b/src/plugin-sdk/command-auth.ts @@ -264,7 +264,7 @@ export async function resolveSenderCommandAuthorization( }); const effectiveAllowFrom = access.effectiveAllowFrom; const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom; - const useAccessGroups = params.cfg.commands?.useAccessGroups !== false; + const useAccessGroups = true; const senderAllowedForCommands = params.isSenderAllowed( params.senderId, params.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, diff --git a/src/plugin-sdk/config-runtime.ts b/src/plugin-sdk/config-runtime.ts index c7183aca3507..dafef82ecdf5 100644 --- a/src/plugin-sdk/config-runtime.ts +++ b/src/plugin-sdk/config-runtime.ts @@ -131,7 +131,6 @@ export type { TtsModelOverrideConfig, TtsPersonaConfig, TtsPersonaFallbackPolicy, - TtsPersonaPromptConfig, TtsProvider, } from "../config/types.js"; export { clearSessionStoreCacheForTest } from "../config/sessions/store.js"; diff --git a/src/plugin-sdk/exec-approvals-runtime.ts b/src/plugin-sdk/exec-approvals-runtime.ts index 07720e246ce5..5fba81af5bc3 100644 --- a/src/plugin-sdk/exec-approvals-runtime.ts +++ b/src/plugin-sdk/exec-approvals-runtime.ts @@ -3,5 +3,6 @@ export { loadExecApprovals, resolveExecApprovalsFromFile, + resolveExecModePolicy, type ExecApprovalsFile, } from "../infra/exec-approvals.js"; diff --git a/src/plugin-sdk/test-helpers/agents/prompt-overlay-runtime-contract.ts b/src/plugin-sdk/test-helpers/agents/prompt-overlay-runtime-contract.ts index f7e35f8f90e1..49474935b96e 100644 --- a/src/plugin-sdk/test-helpers/agents/prompt-overlay-runtime-contract.ts +++ b/src/plugin-sdk/test-helpers/agents/prompt-overlay-runtime-contract.ts @@ -24,16 +24,8 @@ export function openAiPluginPersonalityConfig(personality: "friendly" | "off"): } satisfies OpenClawConfig; } -export function sharedGpt5PersonalityConfig(personality: "friendly" | "off"): OpenClawConfig { - return { - agents: { - defaults: { - promptOverlays: { - gpt5: { personality }, - }, - }, - }, - } satisfies OpenClawConfig; +export function sharedGpt5PersonalityConfig(_personality: "friendly" | "off"): OpenClawConfig { + return {}; } export function codexPromptOverlayContext(params?: { diff --git a/src/plugin-sdk/test-helpers/sandbox-fixtures.ts b/src/plugin-sdk/test-helpers/sandbox-fixtures.ts index c3b8d775c3d8..d112461e84fd 100644 --- a/src/plugin-sdk/test-helpers/sandbox-fixtures.ts +++ b/src/plugin-sdk/test-helpers/sandbox-fixtures.ts @@ -19,7 +19,7 @@ export function createSandboxBrowserConfig( vncPort: 5900, noVncPort: 6080, headless: true, - enableNoVnc: false, + noVncEnabled: false, allowHostControl: false, autoStart: false, autoStartTimeoutMs: 1000, diff --git a/src/plugins/bundled-channel-runtime.ts b/src/plugins/bundled-channel-runtime.ts index 0c29692805e8..4eb839ea3002 100644 --- a/src/plugins/bundled-channel-runtime.ts +++ b/src/plugins/bundled-channel-runtime.ts @@ -1,6 +1,7 @@ /** Loads bundled channel plugin runtime entries and setup metadata. */ import fs from "node:fs"; import path from "node:path"; +import { isVitestRuntimeEnv } from "../infra/env.js"; import { resolveBundledPluginGeneratedPath } from "./bundled-plugin-metadata.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import type { OpenClawPackageManifest } from "./manifest.js"; @@ -49,7 +50,7 @@ function resolveBundledMetadataScope(params?: { env: { ...process.env, OPENCLAW_BUNDLED_PLUGINS_DIR: overrideDir, - OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + ...(isVitestRuntimeEnv() ? { OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1" } : {}), }, }; } diff --git a/src/plugins/bundled-compat.test.ts b/src/plugins/bundled-compat.test.ts index b92bd0be50ad..3a2329083971 100644 --- a/src/plugins/bundled-compat.test.ts +++ b/src/plugins/bundled-compat.test.ts @@ -1,14 +1,22 @@ /** Covers bundled plugin compatibility modes and their activation defaults. */ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withBundledPluginEnablementCompat } from "./bundled-compat.js"; +const readBundledDiscoveryMode = vi.hoisted(() => vi.fn<() => "compat" | "allowlist">()); + +vi.mock("./bundled-discovery-state.js", () => ({ readBundledDiscoveryMode })); + describe("withBundledPluginEnablementCompat", () => { + beforeEach(() => { + readBundledDiscoveryMode.mockReturnValue("allowlist"); + }); + it("honors bundledDiscovery compat before plugin allowlists", () => { + readBundledDiscoveryMode.mockReturnValue("compat"); const config = { plugins: { allow: ["discord"], - bundledDiscovery: "compat", }, } satisfies OpenClawConfig; @@ -28,7 +36,6 @@ describe("withBundledPluginEnablementCompat", () => { const config = { plugins: { allow: ["openai"], - bundledDiscovery: "allowlist", }, } satisfies OpenClawConfig; @@ -43,10 +50,10 @@ describe("withBundledPluginEnablementCompat", () => { }); it("adds compat allow entries for plugins that already have entries", () => { + readBundledDiscoveryMode.mockReturnValue("compat"); const config = { plugins: { allow: ["openai"], - bundledDiscovery: "compat", entries: { deepseek: { enabled: true }, }, diff --git a/src/plugins/bundled-compat.ts b/src/plugins/bundled-compat.ts index e490030bc4d4..1af6b1996b3d 100644 --- a/src/plugins/bundled-compat.ts +++ b/src/plugins/bundled-compat.ts @@ -1,6 +1,7 @@ /** Compatibility helpers that auto-enable bundled plugins for legacy and Vitest flows. */ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginEntryConfig } from "../config/types.plugins.js"; +import { readBundledDiscoveryMode } from "./bundled-discovery-state.js"; import { hasExplicitPluginConfig } from "./config-policy.js"; import { normalizePluginId } from "./config-state.js"; @@ -12,7 +13,7 @@ export function withBundledPluginEnablementCompat(params: { const existingEntries = params.config?.plugins?.entries ?? {}; const forcePluginsEnabled = params.config?.plugins?.enabled === false; const allow = params.config?.plugins?.allow; - const bypassAllowlist = params.config?.plugins?.bundledDiscovery === "compat"; + const bypassAllowlist = readBundledDiscoveryMode() === "compat"; const allowSet = !bypassAllowlist && Array.isArray(allow) && allow.length > 0 ? new Set(allow.map((pluginId) => normalizePluginId(pluginId)).filter(Boolean)) diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index 0c0e0117f9d0..7525447477c7 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -1,7 +1,7 @@ // Verifies bundled plugin directory resolution. import fs from "node:fs"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveBundledPluginsDir, resolveSourceCheckoutDependencyDiagnostic, @@ -11,6 +11,7 @@ import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fi const tempDirs: string[] = []; const originalBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; const originalDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; +const originalTrustBundledPlugins = process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR; const originalVitest = process.env.VITEST; const originalArgv1 = process.argv[1]; const originalExecArgv = [...process.execArgv]; @@ -160,6 +161,10 @@ function requireBundledDir(value: string | null | undefined): string { return value; } +beforeEach(() => { + delete process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR; +}); + afterEach(() => { vi.restoreAllMocks(); if (originalBundledDir === undefined) { @@ -172,6 +177,11 @@ afterEach(() => { } else { process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = originalDisableBundledPlugins; } + if (originalTrustBundledPlugins === undefined) { + delete process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR = originalTrustBundledPlugins; + } if (originalVitest === undefined) { delete process.env.VITEST; } else { @@ -417,6 +427,7 @@ describe("resolveBundledPluginsDir", () => { process.argv[1] = path.join(installedRoot, "openclaw.mjs"); process.execArgv.length = 0; delete process.env.VITEST; + delete process.env.OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR; process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(installedRoot, "dist", "extensions"); delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS; diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index bdee865721e4..7ff4b38eb1a8 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { isVitestRuntimeEnv } from "../infra/env.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import { isPathInside } from "../infra/path-guards.js"; import { resolveUserPath } from "../utils.js"; @@ -44,7 +45,7 @@ function isTruthyEnvValue(value: string | undefined): boolean { } function shouldTrustTestBundledPluginsDirOverride(env: NodeJS.ProcessEnv): boolean { - const isVitestProcess = Boolean(env.VITEST) || Boolean(process.env.VITEST); + const isVitestProcess = isVitestRuntimeEnv(env) || isVitestRuntimeEnv(process.env); return ( isVitestProcess && (isTruthyEnvValue(env[TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV]) || diff --git a/src/plugins/bundled-discovery-state.ts b/src/plugins/bundled-discovery-state.ts new file mode 100644 index 000000000000..3f413ad4ed33 --- /dev/null +++ b/src/plugins/bundled-discovery-state.ts @@ -0,0 +1,10 @@ +// Bundled-discovery compatibility is machine-owned upgrade state. +import { readConfigMachineState } from "../state/config-machine-state.js"; +import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js"; + +export function readBundledDiscoveryMode( + options: OpenClawStateDatabaseOptions = {}, +): "compat" | "allowlist" | undefined { + const value = readConfigMachineState("plugins.bundledDiscovery", options); + return value === "compat" || value === "allowlist" ? value : undefined; +} diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 6c997d6d63d9..788d49ce9c77 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -38,6 +38,7 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [ "bonjour", "browser", "canvas", + "cua-computer", "device-pair", "diagnostics-otel", "diagnostics-prometheus", @@ -509,7 +510,7 @@ describe("bundled plugin metadata", () => { dir: "discord", configuredState: { env: { - allOf: ["DISCORD_BOT_TOKEN"], + anyOf: ["DISCORD_BOT_TOKEN"], }, }, }, @@ -525,7 +526,7 @@ describe("bundled plugin metadata", () => { dir: "slack", configuredState: { env: { - anyOf: ["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"], + anyOf: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"], }, }, }, @@ -533,7 +534,7 @@ describe("bundled plugin metadata", () => { dir: "telegram", configuredState: { env: { - allOf: ["TELEGRAM_BOT_TOKEN"], + anyOf: ["TELEGRAM_BOT_TOKEN"], }, }, }, diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 8c65e27566da..8d2828bbdfbd 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -950,7 +950,8 @@ describe("resolvePluginCapabilityProviders", () => { plugins: { allow: ["openai", "deepgram", "google"] }, tools: { media: { - audio: { enabled: true, models: [{ provider: "deepgram", model: "nova-3" }] }, + models: [{ provider: "deepgram", model: "nova-3", capabilities: ["audio"] }], + audio: { enabled: true }, }, }, } as OpenClawConfig, @@ -986,7 +987,7 @@ describe("resolvePluginCapabilityProviders", () => { key: "speechProviders", cfg: { plugins: { entries: { microsoft: { enabled: true } } }, - messages: { tts: { provider: "edge" } }, + tts: { provider: "edge" }, } as OpenClawConfig, }); @@ -1027,7 +1028,7 @@ describe("resolvePluginCapabilityProviders", () => { const providers = resolvePluginCapabilityProviders({ key: "speechProviders", - cfg: { messages: { tts: { provider: "acme" } } } as OpenClawConfig, + cfg: { tts: { provider: "acme" } } as OpenClawConfig, }); expectResolvedCapabilityProviderIds(providers, ["acme"]); @@ -1096,7 +1097,7 @@ describe("resolvePluginCapabilityProviders", () => { key: "speechProviders", cfg: { plugins: { allow: ["openai", "microsoft"] }, - messages: { tts: { provider: "edge" } }, + tts: { provider: "edge" }, } as OpenClawConfig, }); @@ -1163,7 +1164,7 @@ describe("resolvePluginCapabilityProviders", () => { const providers = resolvePluginCapabilityProviders({ key: "speechProviders", cfg: { - messages: { tts: { provider: "google" } }, + tts: { provider: "google" }, } as OpenClawConfig, }); @@ -1251,7 +1252,7 @@ describe("resolvePluginCapabilityProviders", () => { const providers = resolvePluginCapabilityProviders({ key: "speechProviders", cfg: { - messages: { tts: { provider: "google" } }, + tts: { provider: "google" }, } as OpenClawConfig, }); @@ -1452,7 +1453,7 @@ describe("resolvePluginCapabilityProviders", () => { key: "speechProviders", cfg: { plugins: { allow: ["openai", "microsoft", "elevenlabs"] }, - messages: { tts: { provider: "edge" } }, + tts: { provider: "edge" }, } as OpenClawConfig, }); @@ -1652,7 +1653,7 @@ describe("resolvePluginCapabilityProviders", () => { it("loads bundled speech providers through compat when plugins are globally disabled", () => { const cfg = { plugins: { enabled: false }, - messages: { tts: { provider: "mistral" } }, + tts: { provider: "mistral" }, } as OpenClawConfig; const compatConfig = { ...cfg, diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index e183867435da..dca648f99b49 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -304,8 +304,8 @@ function collectRequestedSpeechProviderIds( ): Set { const requested = new Set(); const tts = - typeof cfg?.messages?.tts === "object" && cfg.messages.tts !== null - ? (cfg.messages.tts as Record) + typeof cfg?.tts === "object" && cfg.tts !== null + ? (cfg.tts as Record) : undefined; addStringValue(requested, tts?.provider); addObjectKeys(requested, tts?.providers); @@ -339,9 +339,6 @@ function collectRequestedMediaUnderstandingProviderIds( const requested = new Set(); const media = cfg?.tools?.media; addMediaModelProviders(requested, media?.models); - addMediaModelProviders(requested, media?.image?.models); - addMediaModelProviders(requested, media?.audio?.models); - addMediaModelProviders(requested, media?.video?.models); return requested; } diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 20431327ebd9..0b76a3c7470f 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -820,7 +820,7 @@ describe("resolveGatewayStartupPluginIds", () => { "includes configured bundled speech providers at startup", { channels: {}, - messages: { tts: { provider: "microsoft" } }, + tts: { provider: "microsoft" }, } as OpenClawConfig, ["browser", "microsoft", "memory-core"], ], @@ -828,7 +828,7 @@ describe("resolveGatewayStartupPluginIds", () => { "includes bundled speech providers configured by provider block", { channels: {}, - messages: { tts: { providers: { "tts-local-cli": { command: "say" } } } }, + tts: { providers: { "tts-local-cli": { command: "say" } } }, } as OpenClawConfig, ["browser", "tts-local-cli", "memory-core"], ], @@ -836,7 +836,7 @@ describe("resolveGatewayStartupPluginIds", () => { "maps legacy edge TTS selection to the Microsoft speech plugin", { channels: {}, - messages: { tts: { provider: "edge" } }, + tts: { provider: "edge" }, } as OpenClawConfig, ["browser", "microsoft", "memory-core"], ], @@ -844,7 +844,7 @@ describe("resolveGatewayStartupPluginIds", () => { "includes explicitly enabled external speech providers at startup", { channels: {}, - messages: { tts: { provider: "gradium" } }, + tts: { provider: "gradium" }, plugins: { entries: { gradium: { enabled: true } } }, } as OpenClawConfig, ["browser", "gradium", "memory-core"], @@ -853,14 +853,12 @@ describe("resolveGatewayStartupPluginIds", () => { "includes active persona speech providers at startup", { channels: {}, - messages: { - tts: { - persona: "narrator", - personas: { - narrator: { - label: "Narrator", - provider: "microsoft", - }, + tts: { + persona: "narrator", + personas: { + narrator: { + label: "Narrator", + provider: "microsoft", }, }, }, @@ -871,13 +869,11 @@ describe("resolveGatewayStartupPluginIds", () => { "includes agent-inherited active persona speech providers at startup", { channels: {}, - messages: { - tts: { - personas: { - narrator: { - label: "Narrator", - provider: "microsoft", - }, + tts: { + personas: { + narrator: { + label: "Narrator", + provider: "microsoft", }, }, }, @@ -893,13 +889,11 @@ describe("resolveGatewayStartupPluginIds", () => { channels: { "demo-channel": { tts: { persona: "narrator" } }, }, - messages: { - tts: { - personas: { - narrator: { - label: "Narrator", - provider: "microsoft", - }, + tts: { + personas: { + narrator: { + label: "Narrator", + provider: "microsoft", }, }, }, @@ -916,13 +910,11 @@ describe("resolveGatewayStartupPluginIds", () => { }, }, }, - messages: { - tts: { - personas: { - narrator: { - label: "Narrator", - provider: "microsoft", - }, + tts: { + personas: { + narrator: { + label: "Narrator", + provider: "microsoft", }, }, }, @@ -933,11 +925,9 @@ describe("resolveGatewayStartupPluginIds", () => { "honors disabled speech provider config blocks at startup", { channels: {}, - messages: { - tts: { - provider: "microsoft", - providers: { microsoft: { enabled: false } }, - }, + tts: { + provider: "microsoft", + providers: { microsoft: { enabled: false } }, }, } as OpenClawConfig, ["browser", "memory-core"], @@ -946,7 +936,7 @@ describe("resolveGatewayStartupPluginIds", () => { "honors explicit plugin disablement for configured speech providers", { channels: {}, - messages: { tts: { provider: "microsoft" } }, + tts: { provider: "microsoft" }, plugins: { entries: { microsoft: { enabled: false } } }, } as OpenClawConfig, ["browser", "memory-core"], @@ -957,15 +947,13 @@ describe("resolveGatewayStartupPluginIds", () => { channels: {}, agents: { defaults: { - imageGenerationModel: { - primary: "openai/gpt-image-2", - fallbacks: ["google/gemini-3-pro-image-preview"], - }, - videoGenerationModel: { - primary: "google/veo-3.1-fast-generate-preview", - }, - musicGenerationModel: { - primary: "google/lyria-3-clip-preview", + mediaModels: { + image: { + primary: "openai/gpt-image-2", + fallbacks: ["google/gemini-3-pro-image-preview"], + }, + video: { primary: "google/veo-3.1-fast-generate-preview" }, + music: { primary: "google/lyria-3-clip-preview" }, }, }, }, @@ -978,7 +966,9 @@ describe("resolveGatewayStartupPluginIds", () => { channels: {}, agents: { defaults: { - imageGenerationModel: { primary: "google/gemini-3-pro-image-preview" }, + mediaModels: { + image: { primary: "google/gemini-3-pro-image-preview" }, + }, }, }, plugins: { entries: { google: { enabled: false } } }, @@ -1017,10 +1007,10 @@ describe("resolveGatewayStartupPluginIds", () => { "includes the owning plugin for a configured memory embedding provider at startup", { channels: {}, + memory: { search: { provider: "openai" } }, + agents: { - defaults: { - memorySearch: { provider: "openai" }, - }, + defaults: {}, }, } as OpenClawConfig, ["browser", "openai", "memory-core"], @@ -1029,10 +1019,10 @@ describe("resolveGatewayStartupPluginIds", () => { "includes the owning plugin for a configured memory embedding fallback at startup", { channels: {}, + memory: { search: { provider: "ollama", fallback: "openai" } }, + agents: { - defaults: { - memorySearch: { provider: "ollama", fallback: "openai" }, - }, + defaults: {}, }, } as OpenClawConfig, ["browser", "openai", "ollama", "memory-core"], @@ -1042,7 +1032,7 @@ describe("resolveGatewayStartupPluginIds", () => { { channels: {}, agents: { - list: [{ id: "researcher", memorySearch: { provider: "openai" } }], + list: [{ id: "researcher", memory: { search: { provider: "openai" } } }], }, } as OpenClawConfig, ["browser", "openai", "memory-core"], @@ -1051,12 +1041,10 @@ describe("resolveGatewayStartupPluginIds", () => { "includes the api-owner plugin for a custom models.providers memory embedding provider at startup", { channels: {}, + memory: { search: { provider: "ollama-5080" } }, + agents: { - defaults: { - // Custom id resolves to its `api` owner ("ollama") for the embedding - // adapter, so the owning plugin must load at startup. - memorySearch: { provider: "ollama-5080" }, - }, + defaults: {}, }, models: { providers: { @@ -1074,10 +1062,10 @@ describe("resolveGatewayStartupPluginIds", () => { "includes the api-owner plugin for a custom models.providers memory embedding fallback at startup", { channels: {}, + memory: { search: { provider: "openai", fallback: "ollama-5080" } }, + agents: { - defaults: { - memorySearch: { provider: "openai", fallback: "ollama-5080" }, - }, + defaults: {}, }, models: { providers: { @@ -1095,10 +1083,10 @@ describe("resolveGatewayStartupPluginIds", () => { "includes generic embedding provider owners for configured memory search at startup", { channels: {}, + memory: { search: { provider: "generic-embed" } }, + agents: { - defaults: { - memorySearch: { provider: "generic-embed" }, - }, + defaults: {}, }, } as OpenClawConfig, ["browser", "generic-embedding", "memory-core"], @@ -1107,10 +1095,10 @@ describe("resolveGatewayStartupPluginIds", () => { "does not load plugin owners for core generic memory embedding providers", { channels: {}, + memory: { search: { provider: "openai-compatible" } }, + agents: { - defaults: { - memorySearch: { provider: "openai-compatible" }, - }, + defaults: {}, }, } as OpenClawConfig, ["browser", "memory-core"], @@ -1119,10 +1107,10 @@ describe("resolveGatewayStartupPluginIds", () => { "does not load plugin owners for custom providers backed by core generic embeddings", { channels: {}, + memory: { search: { provider: "tenant-embeddings" } }, + agents: { - defaults: { - memorySearch: { provider: "tenant-embeddings" }, - }, + defaults: {}, }, models: { providers: { @@ -1140,10 +1128,10 @@ describe("resolveGatewayStartupPluginIds", () => { "does not load memory embedding provider owners when the memory slot is disabled", { channels: {}, + memory: { search: { provider: "openai", fallback: "ollama" } }, + agents: { - defaults: { - memorySearch: { provider: "openai", fallback: "ollama" }, - }, + defaults: {}, }, plugins: { slots: { memory: "none" }, @@ -1155,10 +1143,10 @@ describe("resolveGatewayStartupPluginIds", () => { "ignores memory embedding fallbacks when primary provider is fts-only", { channels: {}, + memory: { search: { provider: "none", fallback: "openai" } }, + agents: { - defaults: { - memorySearch: { provider: "none", fallback: "openai" }, - }, + defaults: {}, }, } as OpenClawConfig, ["browser", "memory-core"], @@ -1167,10 +1155,10 @@ describe("resolveGatewayStartupPluginIds", () => { "includes the llama.cpp provider for configured local memory embeddings", { channels: {}, + memory: { search: { provider: "local", fallback: "auto" } }, + agents: { - defaults: { - memorySearch: { provider: "local", fallback: "auto" }, - }, + defaults: {}, }, } as OpenClawConfig, ["browser", "llama-cpp", "memory-core"], @@ -1179,10 +1167,10 @@ describe("resolveGatewayStartupPluginIds", () => { "skips memory embedding providers from disabled memory search blocks", { channels: {}, + memory: { search: { enabled: false, provider: "openai", fallback: "ollama" } }, + agents: { - defaults: { - memorySearch: { enabled: false, provider: "openai", fallback: "ollama" }, - }, + defaults: {}, }, } as OpenClawConfig, ["browser", "memory-core"], @@ -1191,10 +1179,10 @@ describe("resolveGatewayStartupPluginIds", () => { "honors explicit plugin disablement for configured memory embedding providers", { channels: {}, + memory: { search: { provider: "openai" } }, + agents: { - defaults: { - memorySearch: { provider: "openai" }, - }, + defaults: {}, }, plugins: { entries: { openai: { enabled: false } } }, } as OpenClawConfig, @@ -1204,10 +1192,10 @@ describe("resolveGatewayStartupPluginIds", () => { "honors denied plugins for configured memory embedding providers", { channels: {}, + memory: { search: { provider: "openai" } }, + agents: { - defaults: { - memorySearch: { provider: "openai" }, - }, + defaults: {}, }, plugins: { deny: ["openai"] }, } as OpenClawConfig, @@ -1217,11 +1205,13 @@ describe("resolveGatewayStartupPluginIds", () => { "skips a per-agent memory embedding provider when memory search is disabled by inherited defaults", { channels: {}, + memory: { search: { enabled: false } }, + agents: { - defaults: { - memorySearch: { enabled: false }, - }, - list: [{ id: "researcher", memorySearch: { provider: "openai", fallback: "ollama" } }], + defaults: {}, + list: [ + { id: "researcher", memory: { search: { provider: "openai", fallback: "ollama" } } }, + ], }, } as OpenClawConfig, ["browser", "memory-core"], @@ -1230,11 +1220,11 @@ describe("resolveGatewayStartupPluginIds", () => { "includes the inherited default provider when a per-agent override re-enables memory search", { channels: {}, + memory: { search: { enabled: false, provider: "openai", fallback: "ollama" } }, + agents: { - defaults: { - memorySearch: { enabled: false, provider: "openai", fallback: "ollama" }, - }, - list: [{ id: "researcher", memorySearch: { enabled: true } }], + defaults: {}, + list: [{ id: "researcher", memory: { search: { enabled: true } } }], }, } as OpenClawConfig, ["browser", "openai", "ollama", "memory-core"], @@ -1243,13 +1233,13 @@ describe("resolveGatewayStartupPluginIds", () => { "includes default memory embedding providers for unlisted agents even when listed agents override memory search", { channels: {}, + memory: { search: { provider: "openai" } }, + agents: { - defaults: { - memorySearch: { provider: "openai" }, - }, + defaults: {}, list: [ - { id: "muted", memorySearch: { enabled: false } }, - { id: "researcher", memorySearch: { provider: "ollama" } }, + { id: "muted", memory: { search: { enabled: false } } }, + { id: "researcher", memory: { search: { provider: "ollama" } } }, ], }, } as OpenClawConfig, @@ -1259,10 +1249,10 @@ describe("resolveGatewayStartupPluginIds", () => { "includes default memory embedding providers for listed agents that inherit defaults", { channels: {}, + memory: { search: { provider: "openai" } }, + agents: { - defaults: { - memorySearch: { provider: "openai" }, - }, + defaults: {}, list: [{ id: "researcher" }], }, } as OpenClawConfig, @@ -1343,7 +1333,9 @@ describe("resolveGatewayStartupPluginIds", () => { channels: {}, agents: { defaults: { - imageGenerationModel: { primary: "google/gemini-3-pro-image-preview" }, + mediaModels: { + image: { primary: "google/gemini-3-pro-image-preview" }, + }, }, }, plugins: { allow: ["browser"] }, @@ -2169,7 +2161,7 @@ describe("resolveGatewayStartupPluginIds", () => { defaults: { model: "amazon-bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0", }, - list: [{ id: "ops", utilityModel: "openai/gpt-5.5-nano" }], + entries: { ops: { utilityModel: "openai/gpt-5.5-nano" } }, }, channels: {}, plugins: { @@ -2192,10 +2184,10 @@ describe("resolveGatewayStartupPluginIds", () => { expect( resolveGatewayStartupMetadataPluginIds({ config: { + memory: { search: { provider: "openai", fallback: "ollama" } }, + agents: { - defaults: { - memorySearch: { provider: "openai", fallback: "ollama" }, - }, + defaults: {}, }, channels: {}, plugins: { @@ -2382,7 +2374,7 @@ describe("resolveGatewayStartupPluginIds", () => { config: { agents: { defaults: { - imageGenerationModel: "unknown-provider/model", + mediaModels: { image: { primary: "unknown-provider/model" } }, }, }, plugins: { diff --git a/src/plugins/contracts/tts-contract-suites.ts b/src/plugins/contracts/tts-contract-suites.ts index 627abf218fb1..3c1cae8789d0 100644 --- a/src/plugins/contracts/tts-contract-suites.ts +++ b/src/plugins/contracts/tts-contract-suites.ts @@ -151,16 +151,14 @@ function createSummarizeTextDeps() { function createOpenAiTelephonyCfg(model: "tts-1" | "gpt-4o-mini-tts"): OpenClawConfig { return asLegacyTtsConfig({ - messages: { - tts: { - provider: "openai", - providers: { - openai: { - apiKey: "test-key", - model, - voice: "alloy", - instructions: "Speak warmly", - }, + tts: { + provider: "openai", + providers: { + openai: { + apiKey: "test-key", + model, + voice: "alloy", + instructions: "Speak warmly", }, }, }, @@ -501,8 +499,7 @@ function setupTestSpeechProviderRegistry() { } function createResolvedSummarizationConfig(cfg: OpenClawConfig): ResolvedTtsConfig { - const rawConfig = - typeof cfg.messages?.tts === "object" && cfg.messages?.tts !== null ? cfg.messages.tts : {}; + const rawConfig = typeof cfg.tts === "object" && cfg.tts !== null ? cfg.tts : {}; return { auto: "off", mode: rawConfig.mode ?? "final", @@ -522,7 +519,7 @@ function createResolvedSummarizationConfig(cfg: OpenClawConfig): ResolvedTtsConf }, providerConfigs: {}, personas: {}, - prefsPath: typeof rawConfig.prefsPath === "string" ? rawConfig.prefsPath : undefined, + prefsPath: undefined, maxTextLength: typeof rawConfig.maxTextLength === "number" ? rawConfig.maxTextLength : 4096, timeoutMs: typeof rawConfig.timeoutMs === "number" ? rawConfig.timeoutMs : 30_000, rawConfig, @@ -559,7 +556,7 @@ export function describeTtsConfigContract() { describe("resolveEdgeOutputFormat", () => { const baseCfg: OpenClawConfig = { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, - messages: { tts: {} }, + tts: {}, }; it.each([ @@ -572,10 +569,8 @@ export function describeTtsConfigContract() { name: "override", cfg: { ...baseCfg, - messages: { - tts: { - edge: { outputFormat: "audio-24khz-96kbitrate-mono-mp3" }, - }, + tts: { + edge: { outputFormat: "audio-24khz-96kbitrate-mono-mp3" }, }, } as unknown as OpenClawConfig, expected: "audio-24khz-96kbitrate-mono-mp3", @@ -750,12 +745,10 @@ export function describeTtsConfigContract() { }, }, }, - messages: { - tts: { - providers: { - microsoft: { - enabled: false, - }, + tts: { + providers: { + microsoft: { + enabled: false, }, }, }, @@ -774,13 +767,11 @@ export function describeTtsConfigContract() { const config = resolveTtsConfig( asLegacyOpenClawConfig({ agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, - messages: { - tts: { - provider: "edge", - providers: { - edge: { - enabled: true, - }, + tts: { + provider: "edge", + providers: { + edge: { + enabled: true, }, }, }, @@ -795,7 +786,7 @@ export function describeTtsConfigContract() { describe("resolveTtsConfig – openai.baseUrl", () => { const baseCfg: OpenClawConfig = { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, - messages: { tts: {} }, + tts: {}, }; it.each([ @@ -815,9 +806,7 @@ export function describeTtsConfigContract() { name: "config wins over env", cfg: { ...baseCfg, - messages: { - tts: { ...baseCfg.messages!.tts, openai: { baseUrl: "http://my-server:9000/v1" } }, - }, + tts: { ...baseCfg.tts, openai: { baseUrl: "http://my-server:9000/v1" } }, } as unknown as OpenClawConfig, env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" }, expected: "http://my-server:9000/v1", @@ -826,11 +815,9 @@ export function describeTtsConfigContract() { name: "config slash trimming", cfg: { ...baseCfg, - messages: { - tts: { - ...baseCfg.messages!.tts, - openai: { baseUrl: "http://my-server:9000/v1///" }, - }, + tts: { + ...baseCfg.tts, + openai: { baseUrl: "http://my-server:9000/v1///" }, }, } as unknown as OpenClawConfig, env: { OPENAI_TTS_BASE_URL: undefined }, @@ -876,7 +863,7 @@ export function describeTtsSummarizationContract() { const baseCfg: OpenClawConfig = { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, - messages: { tts: {} }, + tts: {}, }; async function runSummarizeText(params?: { @@ -935,7 +922,7 @@ export function describeTtsSummarizationContract() { it("uses summaryModel override when configured", async () => { const cfg: OpenClawConfig = { agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } }, - messages: { tts: { summaryModel: "openai/gpt-4.1-mini" } }, + tts: { summaryModel: "openai/gpt-4.1-mini" }, }; await runSummarizeText({ cfg }); @@ -1061,10 +1048,8 @@ export function describeTtsProviderRuntimeContract() { const result = await ttsRuntime.synthesizeSpeech({ text: "hello fallback", cfg: { - messages: { - tts: { - provider: "openai", - }, + tts: { + provider: "openai", }, }, }); @@ -1137,10 +1122,8 @@ export function describeTtsProviderRuntimeContract() { const result = await ttsRuntime.textToSpeechTelephony({ text: "hello telephony fallback", cfg: { - messages: { - tts: { - provider: "primary-throws", - }, + tts: { + provider: "primary-throws", }, }, }); @@ -1189,9 +1172,9 @@ export function describeTtsProviderRuntimeContract() { text: "hello cancel", cfg: asLegacyOpenClawConfig({ agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, - messages: { - tts: { - provider: "openai", + tts: { + provider: "openai", + providers: { openai: { baseUrl: `${baseUrl}/v1`, apiKey: "fixture-api-key", @@ -1227,10 +1210,8 @@ export function describeTtsProviderRuntimeContract() { const result = await ttsRuntime.textToSpeech({ text: "hello", cfg: { - messages: { - tts: { - provider: "openai", - }, + tts: { + provider: "openai", }, }, disableFallback: true, @@ -1316,16 +1297,14 @@ export function describeTtsProviderRuntimeContract() { ]; setActivePluginRegistry(registry); const cfg = asLegacyTtsConfig({ - messages: { - tts: { - provider: "openai", - providers: { - openai: { - apiKey: "test-api-key", - baseUrl, - model: "gpt-4o-mini-tts", - voice: "alloy", - }, + tts: { + provider: "openai", + providers: { + openai: { + apiKey: "test-api-key", + baseUrl, + model: "gpt-4o-mini-tts", + voice: "alloy", }, }, }, @@ -1367,13 +1346,11 @@ export function describeTtsAutoApplyContract() { const baseCfg: OpenClawConfig = asLegacyOpenClawConfig({ agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, - messages: { - tts: { - auto: "inbound", - provider: "openai", - providers: { - openai: { apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" }, - }, + tts: { + auto: "inbound", + provider: "openai", + providers: { + openai: { apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" }, }, }, }); @@ -1392,10 +1369,7 @@ export function describeTtsAutoApplyContract() { const taggedCfg: OpenClawConfig = { ...baseCfg, - messages: { - ...baseCfg.messages!, - tts: { ...baseCfg.messages!.tts, auto: "tagged" }, - }, + tts: { ...baseCfg.tts, auto: "tagged" }, }; async function expectAutoTtsOutcome(params: { diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index 4d3773c1c726..921a622d9405 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -397,10 +397,13 @@ describe("doctor-contract-registry module loader", () => { const raw = { tools: { media: { - models: [{ provider: " xAI " }, { provider: " " }], - audio: { models: [{ provider: "XAI", model: "grok-stt" }] }, - image: { models: [{ provider: "openai", model: "gpt-5.5" }] }, - video: { models: [{ provider: "gemini", model: "veo" }] }, + models: [ + { provider: " xAI " }, + { provider: " " }, + { provider: "XAI", model: "grok-stt", capabilities: ["audio"] }, + { provider: "openai", model: "gpt-5.5", capabilities: ["image"] }, + { provider: "gemini", model: "veo", capabilities: ["video"] }, + ], }, }, }; @@ -409,7 +412,7 @@ describe("doctor-contract-registry module loader", () => { expect( collectRelevantDoctorPluginIdsForTouchedPaths({ raw, - touchedPaths: [["tools", "media", "audio", "models", "0", "model"]], + touchedPaths: [["tools", "media", "models", "2", "model"]], }), ).toEqual(["gemini", "openai", "xai"]); }); diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index 13cf0e2686ae..4a0780593c86 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -246,6 +246,8 @@ function collectMediaProviderIds(root: Record, ids: Set if (!media) { return; } + // Keep legacy lists visible until the doctor migration window closes so + // provider-owned repairs can run in the same pass as core consolidation. const modelLists = [ media.models, asNullableRecord(media.audio)?.models, diff --git a/src/plugins/effective-plugin-ids.ts b/src/plugins/effective-plugin-ids.ts index b89f98444a33..9e72ec492c2b 100644 --- a/src/plugins/effective-plugin-ids.ts +++ b/src/plugins/effective-plugin-ids.ts @@ -7,6 +7,7 @@ import { } from "../channels/config-presence.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isVitestRuntimeEnv } from "../infra/env.js"; import { listExplicitConfiguredChannelIdsForConfig, loadGatewayStartupPluginPlan, @@ -61,7 +62,7 @@ function collectBundledChannelOwnerPluginIds(params: { ? { ...params.env, OPENCLAW_BUNDLED_PLUGINS_DIR: params.bundledPluginsDir, - ...(params.env.VITEST || process.env.VITEST + ...(isVitestRuntimeEnv(params.env) || isVitestRuntimeEnv() ? { OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1" } : {}), } diff --git a/src/plugins/gateway-startup-plugin-ids.ts b/src/plugins/gateway-startup-plugin-ids.ts index fad426d7d686..2c05ce731470 100644 --- a/src/plugins/gateway-startup-plugin-ids.ts +++ b/src/plugins/gateway-startup-plugin-ids.ts @@ -25,6 +25,7 @@ import { resolveMemoryDreamingPluginId, } from "../memory-host-sdk/dreaming.js"; import { planManifestModelCatalogRows } from "../model-catalog/manifest-planner.js"; +import { readBundledDiscoveryMode } from "./bundled-discovery-state.js"; import { hasExplicitChannelConfig, listExplicitConfiguredChannelIdsForConfig, @@ -81,6 +82,24 @@ type VoiceProviderContractKey = | "speechProviders" | "realtimeTranscriptionProviders" | "realtimeVoiceProviders"; + +function readStartupBundledDiscoveryMode( + config: OpenClawConfig, + env: NodeJS.ProcessEnv, +): "compat" | "allowlist" | undefined { + const stateMode = readBundledDiscoveryMode({ env }); + if (stateMode) { + return stateMode; + } + // Bootstrap Doctor with the raw legacy marker before it has been imported + // into SQLite; steady-state runtime consumers use machine state only. + const legacyMode = (config.plugins as { bundledDiscovery?: unknown } | undefined) + ?.bundledDiscovery; + if (legacyMode === "compat" || legacyMode === "allowlist") { + return legacyMode; + } + return undefined; +} type ConfiguredGenerationProviderIds = Record>; type ConfiguredVoiceProviderIds = Record>; @@ -546,9 +565,9 @@ function collectConfiguredGenerationProviderIds( ): ConfiguredGenerationProviderIds { const defaults = config.agents?.defaults; return { - imageGenerationProviders: collectModelProviderIds(defaults?.imageGenerationModel), - videoGenerationProviders: collectModelProviderIds(defaults?.videoGenerationModel), - musicGenerationProviders: collectModelProviderIds(defaults?.musicGenerationModel), + imageGenerationProviders: collectModelProviderIds(defaults?.mediaModels?.image), + videoGenerationProviders: collectModelProviderIds(defaults?.mediaModels?.video), + musicGenerationProviders: collectModelProviderIds(defaults?.mediaModels?.music), }; } @@ -684,7 +703,7 @@ export function collectConfiguredMemoryEmbeddingStartupProviderOwners( return []; } const byConfiguredIdAndSource = new Map(); - const defaultsBlock = config.agents?.defaults?.memorySearch; + const defaultsBlock = config.memory?.search; const defaults = isRecord(defaultsBlock) ? defaultsBlock : undefined; const addEffectiveProviders = (override: Record | undefined) => { for (const { configuredId, source } of resolveEffectiveMemoryEmbeddingProviderEntries( @@ -709,7 +728,8 @@ export function collectConfiguredMemoryEmbeddingStartupProviderOwners( return [...byConfiguredIdAndSource.values()]; } for (const agent of agentEntries) { - addEffectiveProviders(isRecord(agent.memorySearch) ? agent.memorySearch : undefined); + const memory = isRecord(agent.memory) ? agent.memory : undefined; + addEffectiveProviders(isRecord(memory?.search) ? memory.search : undefined); } return [...byConfiguredIdAndSource.values()]; } @@ -1002,8 +1022,8 @@ export function resolveGatewayStartupMetadataPluginIds(params: { return []; } if ( - params.config.plugins?.bundledDiscovery === "compat" || - activationSourceConfig.plugins?.bundledDiscovery === "compat" + readStartupBundledDiscoveryMode(params.config, params.env) === "compat" || + readStartupBundledDiscoveryMode(activationSourceConfig, params.env) === "compat" ) { return undefined; } @@ -1203,7 +1223,10 @@ export function resolveConfigValidationMetadataPluginIds(params: { }): string[] | undefined { const lookup = createInstalledPluginIndexScopeLookup(params.index); const pluginsConfig = normalizePluginsConfigForInstalledIndex(params.config.plugins, lookup); - if (params.config.plugins?.bundledDiscovery === "compat" || pluginsConfig.loadPaths.length > 0) { + if ( + readStartupBundledDiscoveryMode(params.config, params.env) === "compat" || + pluginsConfig.loadPaths.length > 0 + ) { return undefined; } diff --git a/src/plugins/install.npm-spec.e2e.test.ts b/src/plugins/install.npm-spec.e2e.test.ts index c7e9a4a55e6b..54f3efe86e24 100644 --- a/src/plugins/install.npm-spec.e2e.test.ts +++ b/src/plugins/install.npm-spec.e2e.test.ts @@ -81,7 +81,6 @@ process.stdin.on("end", () => { }); `, ], - allowInsecurePath: true, timeoutMs: 5000, maxOutputBytes: 16 * 1024, }, diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index c664cfa8d43f..8079316c88ab 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -302,11 +302,12 @@ type CapturedInstallPolicyRequest = { }; function writeAllowingInstallPolicyScript(dir: string) { + fs.chmodSync(dir, 0o700); const scriptPath = path.join(dir, "allow-policy.cjs"); const logPath = path.join(dir, "policy-requests.jsonl"); fs.writeFileSync( scriptPath, - ` + `#!${process.execPath} const fs = require("node:fs"); let input = ""; @@ -326,11 +327,12 @@ process.stdin.on("end", () => { } function writeBlockingInstallPolicyScript(dir: string) { + fs.chmodSync(dir, 0o700); const scriptPath = path.join(dir, "block-policy.cjs"); const logPath = path.join(dir, "policy-requests.jsonl"); fs.writeFileSync( scriptPath, - ` + `#!${process.execPath} const fs = require("node:fs"); let input = ""; @@ -363,11 +365,12 @@ process.stdin.on("end", () => { } function writeInstallOnlyBlockingPolicyScript(dir: string) { + fs.chmodSync(dir, 0o700); const scriptPath = path.join(dir, "block-install-policy.cjs"); const logPath = path.join(dir, "policy-requests.jsonl"); fs.writeFileSync( scriptPath, - ` + `#!${process.execPath} const fs = require("node:fs"); let input = ""; @@ -402,10 +405,9 @@ function configWithInstallPolicy(scriptPath: string, logPath: string): OpenClawC enabled: true, exec: { source: "exec", - command: process.execPath, - args: [scriptPath], + command: scriptPath, env: { OPENCLAW_POLICY_LOG: logPath }, - allowInsecurePath: true, + trustedDirs: [path.dirname(scriptPath)], timeoutMs: 5000, maxOutputBytes: 16 * 1024, }, diff --git a/src/plugins/installed-plugin-index-generation-precedence.test.ts b/src/plugins/installed-plugin-index-generation-precedence.test.ts index c140a4d472b0..6e64df0adb4d 100644 --- a/src/plugins/installed-plugin-index-generation-precedence.test.ts +++ b/src/plugins/installed-plugin-index-generation-precedence.test.ts @@ -215,10 +215,8 @@ describe("managed npm generation-dir loader precedence", () => { const emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined); const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir }); - expectRecordFields(loaded.discord, { - installPath: differentlyCasedActivePath, - resolvedVersion: "1.0.0", - }); + const record = expectRecordFields(loaded.discord, { resolvedVersion: "1.0.0" }); + expect(String(record.installPath).toLowerCase()).toBe(downgradedPackageDir.toLowerCase()); expect(emitWarning).not.toHaveBeenCalled(); }); diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index afc0adf9ed54..8e07a810bcc8 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -137,9 +137,7 @@ let officialCatalogCache: | { key: string; result: Promise } | undefined; -function officialCatalogCacheKey(config: OpenClawConfig): string { - return JSON.stringify(config.marketplaces ?? null); -} +const OFFICIAL_CATALOG_CACHE_KEY = "built-in"; /** Clear the process-stable hosted catalog snapshot after an explicit owner reload. */ export function clearManagedPluginOfficialCatalogCache(): void { @@ -276,12 +274,12 @@ function overlayBundledOfficialPluginCatalogMetadata( }); } -async function loadOfficialCatalog(config: OpenClawConfig): Promise { - const key = officialCatalogCacheKey(config); +async function loadOfficialCatalog(): Promise { + const key = OFFICIAL_CATALOG_CACHE_KEY; if (officialCatalogCache?.key !== key) { officialCatalogCache = { key, - result: loadConfiguredHostedOfficialExternalPluginCatalogEntries(config), + result: loadConfiguredHostedOfficialExternalPluginCatalogEntries(), }; } const result = await officialCatalogCache.result; @@ -326,13 +324,10 @@ function normalizeFeaturedAt(value: unknown): number | undefined { } function resolveCatalogInstallAction(params: { - config: OpenClawConfig; entry: OfficialExternalPluginCatalogEntry; pluginId: string; }): ManagedPluginCatalogEntry["install"] { - const install = resolveOfficialExternalPluginInstall(params.entry, { - catalogConfig: params.config.marketplaces, - }); + const install = resolveOfficialExternalPluginInstall(params.entry); const clawhub = install?.clawhubSpec ? parseClawHubPluginSpec(install.clawhubSpec) : undefined; if (clawhub && !clawhub.version) { return { source: "clawhub", packageName: clawhub.name }; @@ -559,7 +554,7 @@ export async function resolveManagedPluginIconUrl(params: { }): Promise { const env = params.env ?? process.env; const metadata = loadPluginMetadataSnapshot({ config: params.config, env }); - const officialCatalog = params.officialCatalog ?? (await loadOfficialCatalog(params.config)); + const officialCatalog = params.officialCatalog ?? (await loadOfficialCatalog()); return resolvePluginIconUrlFromCatalogFacts({ metadata, officialEntries: officialCatalog.entries, @@ -616,7 +611,7 @@ export async function listManagedPlugins(params: { }): Promise { const env = params.env ?? process.env; const metadata = loadPluginMetadataSnapshot({ config: params.config, env }); - const officialCatalog = params.officialCatalog ?? (await loadOfficialCatalog(params.config)); + const officialCatalog = params.officialCatalog ?? (await loadOfficialCatalog()); const bundledOfficialEntries = listOfficialExternalPluginCatalogEntries(); const plugins = metadata.index.plugins.map((record): ManagedPluginCatalogEntry => { const manifest = metadata.byPluginId.get(record.pluginId); @@ -727,7 +722,7 @@ export async function listManagedPlugins(params: { continue; } const kind = normalizeKinds(entry.kind); - const install = resolveCatalogInstallAction({ config: params.config, entry, pluginId }); + const install = resolveCatalogInstallAction({ entry, pluginId }); const description = normalizeOptionalString(entry.description); const version = normalizeOptionalString(entry.version); const featuredAt = @@ -839,28 +834,22 @@ function resolveDeclaredOfficialPluginId( function resolveOfficialEntryByClawHubPackage( entries: readonly OfficialExternalPluginCatalogEntry[], - config: OpenClawConfig, packageName: string, ): OfficialExternalPluginCatalogEntry | undefined { // Bundled identities remain the local trust anchor when a hosted feed omits // its ClawHub candidate; hosted install/version metadata is never copied back. return [...listOfficialExternalPluginCatalogEntries(), ...entries].find((entry) => { - const install = resolveOfficialExternalPluginInstall(entry, { - catalogConfig: config.marketplaces, - }); + const install = resolveOfficialExternalPluginInstall(entry); return parseClawHubPluginSpec(install?.clawhubSpec ?? "")?.name === packageName; }); } function resolveHostedOfficialEntryByClawHubPackage( entries: readonly OfficialExternalPluginCatalogEntry[], - config: OpenClawConfig, packageName: string, ): OfficialExternalPluginCatalogEntry | undefined { return entries.find((entry) => { - const install = resolveOfficialExternalPluginInstall(entry, { - catalogConfig: config.marketplaces, - }); + const install = resolveOfficialExternalPluginInstall(entry); return parseClawHubPluginSpec(install?.clawhubSpec ?? "")?.name === packageName; }); } @@ -1009,24 +998,17 @@ async function installFromClawHub(params: { expectedIntegrity?: string; }): Promise<{ pluginId: string; config: OpenClawConfig }> { const packageName = params.request.packageName.trim(); - const official = resolveOfficialEntryByClawHubPackage( - params.officialEntries, - params.snapshot.config, - packageName, - ); + const official = resolveOfficialEntryByClawHubPackage(params.officialEntries, packageName); // Pin the runtime id only when the catalog entry declares one; the entry-id // fallback is just the package name and would reject legitimate installs, // while a declared id must stay enforced even if it equals the package name. const expectedPluginId = official ? resolveDeclaredOfficialPluginId(official) : undefined; const hostedOfficial = resolveHostedOfficialEntryByClawHubPackage( params.officialEntries, - params.snapshot.config, packageName, ); const hostedInstall = hostedOfficial - ? resolveOfficialExternalPluginInstall(hostedOfficial, { - catalogConfig: params.snapshot.config.marketplaces, - }) + ? resolveOfficialExternalPluginInstall(hostedOfficial) : undefined; const hostedClawHub = parseClawHubPluginSpec(hostedInstall?.clawhubSpec ?? ""); const requestMatchesHostedCandidate = @@ -1084,9 +1066,7 @@ async function installFromOfficialCatalog(params: { ); } const pluginId = resolveOfficialExternalPluginId(entry); - const install = resolveOfficialExternalPluginInstall(entry, { - catalogConfig: params.snapshot.config.marketplaces, - }); + const install = resolveOfficialExternalPluginInstall(entry); if (!pluginId || !install) { throw new ManagedPluginLifecycleError( `official plugin catalog entry is not installable: ${params.request.pluginId}`, @@ -1155,7 +1135,7 @@ export async function installManagedPlugin(params: { return await withManagedPluginMutationLock(async () => { const env = params.env ?? process.env; const snapshot = await readPluginMutationSnapshot(env); - const officialCatalog = await loadOfficialCatalog(snapshot.config); + const officialCatalog = await loadOfficialCatalog(); const warnings: string[] = []; const installed = params.request.source === "clawhub" diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index c004b4878f06..9f80e9851365 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -37,10 +37,10 @@ const HOSTED_CATALOG_PAYLOAD_TYPE = "openclaw.official-external-plugin-catalog-f type HostedCatalogConfig = NonNullable< NonNullable< Parameters[0] - >["marketplaces"] + >["catalogConfig"] >; type ConfiguredHostedCatalogLoadParams = NonNullable< - Parameters[1] + Parameters[0] >; type HostedCatalogLoadParams = ConfiguredHostedCatalogLoadParams & { catalogConfig?: HostedCatalogConfig; @@ -49,11 +49,7 @@ type HostedCatalogLoadParams = ConfiguredHostedCatalogLoadParams & { function loadHostedCatalog( params: HostedCatalogLoadParams = {}, ): ReturnType { - const { catalogConfig, ...loadParams } = params; - return loadConfiguredHostedOfficialExternalPluginCatalogEntries( - catalogConfig ? { marketplaces: catalogConfig } : undefined, - loadParams, - ); + return loadConfiguredHostedOfficialExternalPluginCatalogEntries(params); } function createInMemoryHostedCatalogSnapshotStore( diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts index d35d40bcaff0..165e1501034b 100644 --- a/src/plugins/official-external-plugin-catalog.ts +++ b/src/plugins/official-external-plugin-catalog.ts @@ -1325,23 +1325,10 @@ export function resolveOfficialExternalPluginInstall( }; } -function resolveOfficialExternalPluginCatalogProfileConfigFromConfig(config?: { - marketplaces?: OfficialExternalPluginCatalogProfileConfig; -}): OfficialExternalPluginCatalogProfileConfig | undefined { - return config?.marketplaces; -} - export async function loadConfiguredHostedOfficialExternalPluginCatalogEntries( - config: { marketplaces?: OfficialExternalPluginCatalogProfileConfig } | undefined, - params?: Omit< - NonNullable[0]>, - "catalogConfig" - >, + params?: Parameters[0], ): Promise { - return await loadHostedOfficialExternalPluginCatalogEntries({ - ...params, - catalogConfig: resolveOfficialExternalPluginCatalogProfileConfigFromConfig(config), - }); + return await loadHostedOfficialExternalPluginCatalogEntries(params); } export function listOfficialExternalPluginCatalogEntries(): OfficialExternalPluginCatalogEntry[] { diff --git a/src/plugins/openai-compatible-embedding-provider.ts b/src/plugins/openai-compatible-embedding-provider.ts index ee9f0ae9feeb..8407f2608590 100644 --- a/src/plugins/openai-compatible-embedding-provider.ts +++ b/src/plugins/openai-compatible-embedding-provider.ts @@ -198,7 +198,7 @@ function resolveSecretString(params: { value: unknown; path: string }): string | function resolveRemoteApiKey(value: unknown): string | undefined { return resolveSecretString({ value, - path: "agents.*.memorySearch.remote.apiKey", + path: "memory.search.remote.apiKey", }); } diff --git a/src/plugins/plugin-registration.types.ts b/src/plugins/plugin-registration.types.ts index 5078511c2f3c..63f122c6c06d 100644 --- a/src/plugins/plugin-registration.types.ts +++ b/src/plugins/plugin-registration.types.ts @@ -192,7 +192,7 @@ export type OpenClawPluginNodeInvokePolicy = { commands: string[]; /** * Platforms where these node-handled commands should be allowlisted by default. - * Omit for commands that require explicit `gateway.nodes.allowCommands`. + * Omit for commands that require explicit `gateway.nodes.commands.allow`. */ defaultPlatforms?: Array<"ios" | "android" | "macos" | "windows" | "linux" | "unknown">; /** diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 43cdf2828026..3e874d902c2b 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -352,6 +352,7 @@ describe("provider-runtime", () => { wrapProviderSimpleCompletionStreamFn, wrapProviderStreamFn, } = await import("./provider-runtime.js")); + await import("../agents/ai-transport-runtime-host.js"); ({ getAiTransportHost } = await import("@openclaw/ai")); ({ createEmptyPluginRegistry } = await import("./registry.js")); ({ resetPluginRuntimeStateForTest, setActivePluginRegistry } = await import("./runtime.js")); @@ -1292,18 +1293,16 @@ describe("provider-runtime", () => { it("respects the shared GPT-5 prompt overlay personality config", () => { const contribution = resolveProviderSystemPromptContribution({ - provider: "opencode", + provider: "openai", config: { - agents: { - defaults: { - promptOverlays: { - gpt5: { personality: "off" }, - }, + plugins: { + entries: { + openai: { config: { personality: "off" } }, }, }, }, context: { - provider: "opencode", + provider: "openai", modelId: "gpt-5.4", promptMode: "full", } as never, diff --git a/src/plugins/web-provider-public-artifacts.fallback.test.ts b/src/plugins/web-provider-public-artifacts.fallback.test.ts index 3df28d8cda0f..2e4ccb5be7bf 100644 --- a/src/plugins/web-provider-public-artifacts.fallback.test.ts +++ b/src/plugins/web-provider-public-artifacts.fallback.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ resolveBundledExplicitWebFetchProvidersFromPublicArtifacts: vi.fn(() => null), loadBundledWebSearchProviderEntriesFromDir: vi.fn(), loadBundledWebFetchProviderEntriesFromDir: vi.fn(), + readBundledDiscoveryMode: vi.fn<() => "compat" | "allowlist">(), })); vi.mock("./plugin-metadata-snapshot.js", () => ({ @@ -15,6 +16,10 @@ vi.mock("./plugin-metadata-snapshot.js", () => ({ resolvePluginMetadataSnapshot: mocks.resolvePluginMetadataSnapshot, })); +vi.mock("./bundled-discovery-state.js", () => ({ + readBundledDiscoveryMode: mocks.readBundledDiscoveryMode, +})); + vi.mock("./web-search-providers.shared.js", () => ({ resolveBundledWebSearchResolutionConfig: (params: { config?: unknown }) => ({ config: params.config, @@ -43,6 +48,7 @@ const { describe("web provider public artifact manifest fallback", () => { beforeEach(() => { + mocks.readBundledDiscoveryMode.mockReturnValue("allowlist"); vi.clearAllMocks(); mocks.loadPluginMetadataSnapshot.mockReturnValue({ diagnostics: [], @@ -124,6 +130,7 @@ describe("web provider public artifact manifest fallback", () => { }); it("keeps deprecated bundledDiscovery compat discovery outside plugin allowlists", () => { + mocks.readBundledDiscoveryMode.mockReturnValue("compat"); const resolveExplicitWebSearchProviders = mocks.resolveBundledExplicitWebSearchProvidersFromPublicArtifacts as unknown as { mockImplementation: ( @@ -140,7 +147,6 @@ describe("web provider public artifact manifest fallback", () => { config: { plugins: { allow: ["some-other-plugin"], - bundledDiscovery: "compat", }, }, onlyPluginIds: ["fallback-search"], diff --git a/src/plugins/web-provider-public-artifacts.ts b/src/plugins/web-provider-public-artifacts.ts index 836236020d4b..e175456cf23b 100644 --- a/src/plugins/web-provider-public-artifacts.ts +++ b/src/plugins/web-provider-public-artifacts.ts @@ -1,6 +1,7 @@ // Extracts web provider public artifacts from plugin entrypoints. import path from "node:path"; import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { readBundledDiscoveryMode } from "./bundled-discovery-state.js"; import { resolveEnabledBundledManifestContractPlugins } from "./bundled-manifest-contract-plugins.js"; import { normalizePluginId } from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; @@ -36,7 +37,7 @@ function filterAllowlistedBundledPluginIds( ) { // Deprecated shipped compat marker: old allowlist configs used this to keep // bundled web provider discovery available while plugin IDs were tightened. - if (config?.plugins?.bundledDiscovery === "compat") { + if (readBundledDiscoveryMode() === "compat") { return [...pluginIds]; } const allow = config?.plugins?.allow; diff --git a/src/process/command-queue.test.ts b/src/process/command-queue.test.ts index cee1d0406b80..e1044e068074 100644 --- a/src/process/command-queue.test.ts +++ b/src/process/command-queue.test.ts @@ -33,7 +33,6 @@ let enqueueCommandInLane: CommandQueueModule["enqueueCommandInLane"]; let GatewayDrainingError: CommandQueueModule["GatewayDrainingError"]; let getActiveTaskCount: CommandQueueModule["getActiveTaskCount"]; let getCommandLaneSnapshot: CommandQueueModule["getCommandLaneSnapshot"]; -let getCommandLaneSnapshots: CommandQueueModule["getCommandLaneSnapshots"]; let getQueueSize: CommandQueueModule["getQueueSize"]; let markGatewayDraining: CommandQueueModule["markGatewayDraining"]; let resetAllLanes: CommandQueueModule["resetAllLanes"]; @@ -103,7 +102,6 @@ describe("command queue", () => { GatewayDrainingError, getActiveTaskCount, getCommandLaneSnapshot, - getCommandLaneSnapshots, getQueueSize, markGatewayDraining, resetAllLanes, @@ -751,40 +749,6 @@ describe("command queue", () => { await expect(second).resolves.toBe("second"); }); - it("getCommandLaneSnapshots reports all live lanes in stable order", async () => { - const alphaLane = `snapshot-all-alpha-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const betaLane = `snapshot-all-beta-${Date.now()}-${Math.random().toString(16).slice(2)}`; - setCommandLaneConcurrency(alphaLane, 1); - setCommandLaneConcurrency(betaLane, 1); - - const alphaBlocker = createDeferred(); - const betaBlocker = createDeferred(); - const alpha = enqueueCommandInLane(alphaLane, async () => { - await alphaBlocker.promise; - return "alpha"; - }); - const beta = enqueueCommandInLane(betaLane, async () => { - await betaBlocker.promise; - return "beta"; - }); - - const snapshots = getCommandLaneSnapshots().filter( - (snapshot) => snapshot.lane === alphaLane || snapshot.lane === betaLane, - ); - expect(snapshots.map((snapshot) => snapshot.lane)).toEqual([alphaLane, betaLane]); - expect(snapshots[0]?.lane).toBe(alphaLane); - expect(snapshots[0]?.activeCount).toBe(1); - expect(snapshots[0]?.queuedCount).toBe(0); - expect(snapshots[1]?.lane).toBe(betaLane); - expect(snapshots[1]?.activeCount).toBe(1); - expect(snapshots[1]?.queuedCount).toBe(0); - - alphaBlocker.resolve(); - betaBlocker.resolve(); - await expect(alpha).resolves.toBe("alpha"); - await expect(beta).resolves.toBe("beta"); - }); - it("waitForActiveTasks ignores tasks that start after the call", async () => { const lane = `drain-snapshot-${Date.now()}-${Math.random().toString(16).slice(2)}`; setCommandLaneConcurrency(lane, 2); diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts index c7ce06ce9e30..19b6b04ee677 100644 --- a/src/process/command-queue.ts +++ b/src/process/command-queue.ts @@ -614,12 +614,6 @@ export function isCommandLaneTaskMarkerCurrent(marker: CommandLaneTaskMarker | u return state?.generation === marker.generation && state.activeTaskIds.has(marker.taskId); } -export function getCommandLaneSnapshots(): CommandLaneSnapshot[] { - return Array.from(getQueueState().lanes.values(), createCommandLaneSnapshot).toSorted((a, b) => - a.lane.localeCompare(b.lane), - ); -} - export function getTotalQueueSize() { let total = 0; for (const s of getQueueState().lanes.values()) { diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index 54ddd70c6c70..c74966c2bbee 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -639,7 +639,7 @@ describe("secrets apply", () => { const coderStorePath = resolveAuthProfileDatabasePath(coderAgentDir); await writeJsonFile(fixture.configPath, { agents: { - list: [{ id: "coder", agentDir: coderAgentDir }], + entries: { coder: { agentDir: coderAgentDir } }, }, }); const plan: SecretsApplyPlan = { @@ -681,10 +681,10 @@ describe("secrets apply", () => { const secondStorePath = resolveAuthProfileDatabasePath(secondAgentDir); await writeJsonFile(fixture.configPath, { agents: { - list: [ - { id: "first", agentDir: firstAgentDir }, - { id: "second", agentDir: secondAgentDir }, - ], + entries: { + first: { agentDir: firstAgentDir }, + second: { agentDir: secondAgentDir }, + }, }, }); const firstState = { @@ -755,10 +755,10 @@ describe("secrets apply", () => { registerResolvedAgentDir({ agentId: "second", agentDir: secondAgentDir }); await writeJsonFile(fixture.configPath, { agents: { - list: [ - { id: "first", agentDir: firstAgentDir }, - { id: "second", agentDir: secondAgentDir }, - ], + entries: { + first: { agentDir: firstAgentDir }, + second: { agentDir: secondAgentDir }, + }, }, }); const initialStore: AuthProfileStore = { @@ -1196,22 +1196,23 @@ describe("secrets apply", () => { ); }); - it("applies array-indexed targets for agent memory search", async () => { + it("applies keyed targets for agent memory search", async () => { await fs.writeFile( fixture.configPath, `${JSON.stringify( { agents: { - list: [ - { - id: "main", - memorySearch: { - remote: { - apiKey: "sk-memory-plaintext", // pragma: allowlist secret + entries: { + main: { + memory: { + search: { + remote: { + apiKey: "sk-memory-plaintext", // pragma: allowlist secret + }, }, }, }, - ], + }, }, }, null, @@ -1227,9 +1228,9 @@ describe("secrets apply", () => { generatedBy: "manual", targets: [ { - type: "agents.list[].memorySearch.remote.apiKey", - path: "agents.list.0.memorySearch.remote.apiKey", - pathSegments: ["agents", "list", "0", "memorySearch", "remote", "apiKey"], + type: "agents.entries.*.memory.search.remote.apiKey", + path: "agents.entries.main.memory.search.remote.apiKey", + pathSegments: ["agents", "entries", "main", "memory", "search", "remote", "apiKey"], ref: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" }, }, ], @@ -1246,16 +1247,21 @@ describe("secrets apply", () => { env: fixture.env, })) as { agents?: { - list?: Array<{ - memorySearch?: { - remote?: { - apiKey?: unknown; + entries?: Record< + string, + { + memory?: { + search?: { + remote?: { + apiKey?: unknown; + }; + }; }; - }; - }>; + } + >; }; }; - expect(nextConfig.agents?.list?.[0]?.memorySearch?.remote?.apiKey).toEqual({ + expect(nextConfig.agents?.entries?.main?.memory?.search?.remote?.apiKey).toEqual({ source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY", diff --git a/src/secrets/command-config.test.ts b/src/secrets/command-config.test.ts index 8d45aef62a18..14f1cadf4399 100644 --- a/src/secrets/command-config.test.ts +++ b/src/secrets/command-config.test.ts @@ -56,38 +56,42 @@ describe("analyzeCommandSecretAssignmentsFromSnapshot", () => { it("skips unresolved refs that are marked inactive by runtime warnings", () => { const sourceConfig = { - agents: { - defaults: { - memorySearch: { - remote: { - apiKey: { source: "env", provider: "default", id: "DEFAULT_MEMORY_KEY" }, - }, + memory: { + search: { + remote: { + apiKey: { source: "env", provider: "default", id: "DEFAULT_MEMORY_KEY" }, }, }, }, + + agents: { + defaults: {}, + }, } as unknown as OpenClawConfig; const resolvedConfig = { - agents: { - defaults: { - memorySearch: { - remote: { - apiKey: { source: "env", provider: "default", id: "DEFAULT_MEMORY_KEY" }, - }, + memory: { + search: { + remote: { + apiKey: { source: "env", provider: "default", id: "DEFAULT_MEMORY_KEY" }, }, }, }, + + agents: { + defaults: {}, + }, } as unknown as OpenClawConfig; const result = analyzeCommandSecretAssignmentsFromSnapshot({ sourceConfig, resolvedConfig, - targetIds: new Set(["agents.defaults.memorySearch.remote.apiKey"]), - inactiveRefPaths: new Set(["agents.defaults.memorySearch.remote.apiKey"]), + targetIds: new Set(["memory.search.remote.apiKey"]), + inactiveRefPaths: new Set(["memory.search.remote.apiKey"]), }); expect(result.assignments).toStrictEqual([]); expect(result.diagnostics).toEqual([ - "agents.defaults.memorySearch.remote.apiKey: secret ref is configured on an inactive surface; skipping command-time assignment.", + "memory.search.remote.apiKey: secret ref is configured on an inactive surface; skipping command-time assignment.", ]); }); }); diff --git a/src/secrets/configure.ts b/src/secrets/configure.ts index 94eb642bb76a..0f02e905ea4e 100644 --- a/src/secrets/configure.ts +++ b/src/secrets/configure.ts @@ -486,21 +486,12 @@ async function promptFileProvider( initialValue: base?.maxBytes, max: 20 * 1024 * 1024, }); - const allowInsecurePath = assertNoCancel( - await confirm({ - message: "Allow insecure file path checks?", - initialValue: base?.allowInsecurePath ?? false, - }), - "Secrets configure cancelled.", - ); - return { source: "file", path: normalizeStringifiedOptionalString(filePath) ?? "", mode, ...(timeoutMs ? { timeoutMs } : {}), ...(maxBytes ? { maxBytes } : {}), - ...(allowInsecurePath ? { allowInsecurePath: true } : {}), }; } @@ -611,21 +602,6 @@ async function promptExecProvider( "Secrets configure cancelled.", ); - const allowInsecurePath = assertNoCancel( - await confirm({ - message: "Allow insecure command path checks?", - initialValue: base?.allowInsecurePath ?? false, - }), - "Secrets configure cancelled.", - ); - const allowSymlinkCommand = assertNoCancel( - await confirm({ - message: "Allow symlink command path?", - initialValue: base?.allowSymlinkCommand ?? false, - }), - "Secrets configure cancelled.", - ); - const args = await parseArgsInput(normalizeStringifiedOptionalString(argsRaw) ?? ""); const trustedDirs = parseCsv(trustedDirsRaw ?? ""); @@ -639,8 +615,6 @@ async function promptExecProvider( ...(jsonOnly ? { jsonOnly } : { jsonOnly: false }), ...(passEnv.length > 0 ? { passEnv } : {}), ...(trustedDirs.length > 0 ? { trustedDirs } : {}), - ...(allowInsecurePath ? { allowInsecurePath: true } : {}), - ...(allowSymlinkCommand ? { allowSymlinkCommand: true } : {}), ...(isRecord(base?.env) ? { env: base.env } : {}), }; } diff --git a/src/secrets/exec-secret-ref-id-parity.test.ts b/src/secrets/exec-secret-ref-id-parity.test.ts index 3f5b89a8199a..11bf2124decc 100644 --- a/src/secrets/exec-secret-ref-id-parity.test.ts +++ b/src/secrets/exec-secret-ref-id-parity.test.ts @@ -192,6 +192,9 @@ describe("exec SecretRef id parity", () => { if (id.startsWith("messages.")) { return "messages"; } + if (id.startsWith("memory.search.")) { + return "memory"; + } if (id.startsWith("models.providers.") && id.includes(".headers.")) { return "models.headers"; } @@ -207,6 +210,9 @@ describe("exec SecretRef id parity", () => { if (id.startsWith("talk.")) { return "talk"; } + if (id.startsWith("tts.providers.")) { + return "tts"; + } if (id.startsWith("tools.web.fetch.")) { return "tools.web.fetch"; } diff --git a/src/secrets/provider-integrations.test.ts b/src/secrets/provider-integrations.test.ts index 6fbd897667fd..6ff7b58f52e7 100644 --- a/src/secrets/provider-integrations.test.ts +++ b/src/secrets/provider-integrations.test.ts @@ -136,7 +136,6 @@ describe("secret provider integration presets", () => { ACME_PROFILE: "work", }, trustedDirs: [path.dirname(process.execPath), rootDir], - allowInsecurePath: true, jsonOnly: false, }, }); @@ -196,7 +195,6 @@ describe("secret provider integration presets", () => { command: process.execPath, args: [fs.realpathSync(path.join(rootDir, "resolve.mjs")), "ok"], trustedDirs: [path.dirname(process.execPath), rootDir], - allowInsecurePath: true, passEnv: ["GOOD_ENV"], }, }); diff --git a/src/secrets/provider-integrations.ts b/src/secrets/provider-integrations.ts index 9b474956dc2d..d45fb753546b 100644 --- a/src/secrets/provider-integrations.ts +++ b/src/secrets/provider-integrations.ts @@ -100,9 +100,8 @@ function isSecurePluginEntrypointPath(params: { pluginRootRealpath: string; resolvedEntrypoint: string; entrypointRealpath: string; - allowInsecurePath: boolean; }): boolean { - if (params.allowInsecurePath || process.platform === "win32") { + if (process.platform === "win32") { return true; } const originalSegments = pathSegmentsBetween( @@ -178,7 +177,7 @@ function resolveNodeEntrypointArg(params: { if (params.rejectHardlinks && stat.nlink > 1) { return undefined; } - if (params.integration.allowInsecurePath !== true && !isSecurePosixPathStat(stat)) { + if (!isSecurePosixPathStat(stat)) { return undefined; } try { @@ -192,7 +191,6 @@ function resolveNodeEntrypointArg(params: { pluginRootRealpath, resolvedEntrypoint: resolved, entrypointRealpath: realpath, - allowInsecurePath: params.integration.allowInsecurePath === true, }) ) { return undefined; @@ -249,9 +247,6 @@ function materializeExecProviderConfig( ...(integration.env ? { env: integration.env } : {}), ...(integration.passEnv ? { passEnv: integration.passEnv } : {}), trustedDirs, - ...(integration.command === NODE_COMMAND_PLACEHOLDER || integration.allowInsecurePath - ? { allowInsecurePath: true } - : {}), }; } diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index 5cca0e6cb3c9..764b8614c2e3 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -504,7 +504,7 @@ describe("secret ref resolver", () => { ); }); - itPosix("rejects symlink command paths unless allowSymlinkCommand is enabled", async () => { + itPosix("rejects symlink command paths", async () => { const root = await createCaseDir("exec-link-reject"); const symlinkPath = path.join(root, "resolver-link.mjs"); await fs.symlink(execPlainScriptPath, symlinkPath); @@ -514,22 +514,20 @@ describe("secret ref resolver", () => { ); }); - itPosix("allows symlink command paths when allowSymlinkCommand is enabled", async () => { + itPosix("stays fail-closed when the retired symlink opt-out is present", async () => { const root = await createCaseDir("exec-link-allow"); const symlinkPath = path.join(root, "resolver-link.mjs"); await fs.symlink(execPlainScriptPath, symlinkPath); - const trustedRoot = await fs.realpath(fixtureRoot); - - const value = await resolveExecSecret(symlinkPath, { - jsonOnly: false, - allowSymlinkCommand: true, - trustedDirs: [trustedRoot], - }); - expect(value).toBe("plain-secret"); + await expect( + resolveExecSecret(symlinkPath, { + jsonOnly: false, + allowSymlinkCommand: true, + }), + ).rejects.toThrow("must not be a symlink"); }); itPosix( - "handles Homebrew-style symlinked exec commands with args only when explicitly allowed", + "rejects Homebrew-style symlinked exec commands even with the retired opt-out", async () => { const root = await createCaseDir("homebrew"); const binDir = path.join(root, "opt", "homebrew", "bin"); @@ -549,22 +547,16 @@ describe("secret ref resolver", () => { 0o700, ); await fs.symlink(targetCommand, symlinkCommand); - const trustedRoot = await fs.realpath(root); - - await expect(resolveExecSecret(symlinkCommand, { args: ["brew"] })).rejects.toThrow( - "must not be a symlink", - ); - - const value = await resolveExecSecret(symlinkCommand, { - args: ["brew"], - allowSymlinkCommand: true, - trustedDirs: [trustedRoot], - }); - expect(value).toBe("brew:openai/api-key"); + await expect( + resolveExecSecret(symlinkCommand, { + args: ["brew"], + allowSymlinkCommand: true, + }), + ).rejects.toThrow("must not be a symlink"); }, ); - itPosix("checks trustedDirs against resolved symlink target", async () => { + itPosix("rejects symlinks before trusted-directory evaluation", async () => { const root = await createCaseDir("exec-link-trusted"); const symlinkPath = path.join(root, "resolver-link.mjs"); await fs.symlink(execPlainScriptPath, symlinkPath); @@ -575,7 +567,7 @@ describe("secret ref resolver", () => { allowSymlinkCommand: true, trustedDirs: [root], }), - ).rejects.toThrow("outside trustedDirs"); + ).rejects.toThrow("must not be a symlink"); }); itPosix("rejects exec refs when protocolVersion is not 1", async () => { @@ -824,25 +816,26 @@ describe("secret ref resolver", () => { }); }); - it("allows trusted file provider opt-out when Windows ACL source is unknown", async () => { + it("stays fail-closed when the retired Windows ACL opt-out is present", async () => { await withMockedWindowsPlatform(async () => { const dir = await createCaseDir("win-acl-opt-out"); const filePath = path.join(dir, "secrets.json"); await writeSecureFile(filePath, '{"token":"abc123"}'); - const value = await resolveSecretRefString( - { source: "file", provider: "filemain", id: "/token" }, - { - config: { - secrets: { - providers: { - filemain: createFileProviderConfig(filePath, { allowInsecurePath: true }), + await expect( + resolveSecretRefString( + { source: "file", provider: "filemain", id: "/token" }, + { + config: { + secrets: { + providers: { + filemain: createFileProviderConfig(filePath, { allowInsecurePath: true }), + }, }, }, }, - }, - ); - expect(value).toBe("abc123"); + ), + ).rejects.toThrow(/ACL verification unavailable on Windows/); }); }); diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index a26f6cedc552..7e36e92b2255 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -285,7 +285,7 @@ async function readFileProviderPayload(params: { filePath, label: `secrets.providers.${params.providerName}.path`, io: { maxBytes, timeoutMs }, - permissions: { allowInsecure: params.providerConfig.allowInsecurePath }, + permissions: { allowInsecure: false }, }); const text = payload.toString("utf8").replace(/^\uFEFF/, ""); if (params.providerConfig.mode === "singleValue") { @@ -518,9 +518,8 @@ async function resolveExecRefs(params: { targetPath: commandPath, label: `secrets.providers.${params.providerName}.command`, trustedDirs: params.providerConfig.trustedDirs, - allowInsecurePath: params.providerConfig.allowInsecurePath, allowReadableByOthers: true, - allowSymlinkPath: params.providerConfig.allowSymlinkCommand, + allowSymlinkPath: false, }); } catch (err) { throwUnknownProviderResolutionError({ diff --git a/src/secrets/runtime-channel-inactive-variants.test.ts b/src/secrets/runtime-channel-inactive-variants.test.ts index e9fbcb50d4b6..5e60a4290571 100644 --- a/src/secrets/runtime-channel-inactive-variants.test.ts +++ b/src/secrets/runtime-channel-inactive-variants.test.ts @@ -157,7 +157,7 @@ describe("secrets runtime snapshot channel inactive variants", () => { expect(warningPaths).toContain("channels.slack.accounts.work.appToken"); }); - it("treats top-level Google Chat serviceAccount as inactive when enabled accounts use serviceAccountRef", async () => { + it("treats top-level Google Chat serviceAccount as inactive when enabled accounts override it", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ channels: { @@ -170,7 +170,7 @@ describe("secrets runtime snapshot channel inactive variants", () => { accounts: { work: { enabled: true, - serviceAccountRef: { + serviceAccount: { source: "env", provider: "default", id: "GOOGLECHAT_WORK_SERVICE_ACCOUNT", diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 6c7bd4993435..31bbc8f46e85 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -456,42 +456,25 @@ function collectMediaRequestAssignments(params: { for (const capability of capabilityKeys) { const section = isRecord(media[capability]) ? media[capability] : undefined; - const active = isCapabilityEnabled(capability); - const inactiveReason = `${capability} media understanding is disabled.`; - if (section && isRecord(section.request)) { - collectProviderRequestAssignments({ - request: section.request, - pathPrefix: `tools.media.${capability}.request`, - defaults: params.defaults, - context: params.context, - active, - inactiveReason, - owner: { - ownerKind: "capability", - ownerId: runtimeMediaRequestSecretOwnerId(capability), - requiredForGateway: false, - disposition: "isolate", - contract: section, - }, - }); + if (!section || !isRecord(section.request)) { + continue; } - collectModelAssignments( - section?.models, - `tools.media.${capability}.models`, - (index) => runtimeMediaModelSecretOwnerId({ source: "capability", capability, index }), - (rawModel) => ({ - active: - active && - (() => { - const entry = rawModel as MediaUnderstandingModelConfig; - const configuredCapabilities = resolveConfiguredMediaEntryCapabilities(entry); - return configuredCapabilities ? configuredCapabilities.includes(capability) : true; - })(), - inactiveReason: active - ? `${capability} media model is filtered out by its configured capabilities.` - : inactiveReason, - }), - ); + const active = isCapabilityEnabled(capability); + collectProviderRequestAssignments({ + request: section.request, + pathPrefix: `tools.media.${capability}.request`, + defaults: params.defaults, + context: params.context, + active, + inactiveReason: `${capability} media understanding is disabled.`, + owner: { + ownerKind: "capability", + ownerId: runtimeMediaRequestSecretOwnerId(capability), + requiredForGateway: false, + disposition: "isolate", + contract: section, + }, + }); } } @@ -500,13 +483,13 @@ function collectMessagesTtsAssignments(params: { defaults: SecretDefaults | undefined; context: ResolverContext; }): void { - const messages = params.config.messages as Record | undefined; - if (!isRecord(messages) || !isRecord(messages.tts)) { + const tts = params.config.tts as Record | undefined; + if (!isRecord(tts)) { return; } collectTtsApiKeyAssignments({ - tts: messages.tts, - pathPrefix: "messages.tts", + tts, + pathPrefix: "tts", defaults: params.defaults, context: params.context, }); @@ -518,17 +501,17 @@ function collectAgentTtsAssignments(params: { context: ResolverContext; }): void { const agents = params.config.agents as Record | undefined; - const list = agents?.list; - if (!Array.isArray(list)) { + const entries = isRecord(agents?.entries) ? agents.entries : undefined; + if (!entries) { return; } - for (const [index, entry] of list.entries()) { + for (const [entryId, entry] of Object.entries(entries)) { if (!isRecord(entry) || !isRecord(entry.tts)) { continue; } collectTtsApiKeyAssignments({ tts: entry.tts, - pathPrefix: `agents.list.${index}.tts`, + pathPrefix: `agents.entries.${entryId}.tts`, defaults: params.defaults, context: params.context, }); diff --git a/src/secrets/runtime-config-collectors-memory.ts b/src/secrets/runtime-config-collectors-memory.ts index 10717efafb01..b923d3ceaf40 100644 --- a/src/secrets/runtime-config-collectors-memory.ts +++ b/src/secrets/runtime-config-collectors-memory.ts @@ -17,26 +17,38 @@ export function collectAgentMemorySearchAssignments(params: { context: ResolverContext; }): void { const agents = params.config.agents as Record | undefined; - if (!isRecord(agents)) { - return; - } - const defaultsConfig = isRecord(agents.defaults) ? agents.defaults : undefined; - const defaultsMemorySearch = isRecord(defaultsConfig?.memorySearch) - ? defaultsConfig.memorySearch - : undefined; - const list = Array.isArray(agents.list) ? agents.list : []; + const memory = params.config.memory as Record | undefined; + const defaultsMemorySearch = isRecord(memory?.search) ? memory.search : undefined; + const canonicalEntries = isRecord(agents?.entries) ? Object.entries(agents.entries) : []; + const legacyEntries = Array.isArray(agents?.list) + ? agents.list.flatMap((value, index) => { + if (!isRecord(value)) { + return []; + } + const id = typeof value.id === "string" ? value.id : String(index); + return [[id, value, "list", String(index)] as const]; + }) + : []; + const entries = + canonicalEntries.length > 0 + ? canonicalEntries.map(([id, value]) => [id, value, "entries", id] as const) + : legacyEntries; const defaultRemote = isRecord(defaultsMemorySearch?.remote) ? defaultsMemorySearch.remote : undefined; const defaultHeaders = isRecord(defaultRemote?.headers) ? defaultRemote.headers : undefined; let defaultApiKeyAssignmentCollected = false; const collectedDefaultHeaderKeys = new Set(); - const collectForAgent = (rawAgent: Record | undefined, index?: number) => { - const memorySearch = isRecord(rawAgent?.memorySearch) ? rawAgent.memorySearch : undefined; + const collectForAgent = ( + rawAgent: Record | undefined, + entryId?: string, + container = "entries", + pathId = entryId, + ) => { + const agentMemory = isRecord(rawAgent?.memory) ? rawAgent.memory : undefined; + const memorySearch = isRecord(agentMemory?.search) ? agentMemory.search : undefined; const remote = isRecord(memorySearch?.remote) ? memorySearch.remote : undefined; - const agentId = normalizeAgentId( - typeof rawAgent?.id === "string" ? rawAgent.id : DEFAULT_AGENT_ID, - ); + const agentId = normalizeAgentId(entryId ?? DEFAULT_AGENT_ID); const active = rawAgent?.enabled !== false && (memorySearch?.enabled ?? defaultsMemorySearch?.enabled ?? true) !== false; @@ -58,8 +70,8 @@ export function collectAgentMemorySearchAssignments(params: { collectRuntimeSecretInputAssignment({ value: apiKeyTarget.apiKey, path: hasApiKeyOverride - ? `agents.list.${index}.memorySearch.remote.apiKey` - : "agents.defaults.memorySearch.remote.apiKey", + ? `agents.${container}.${pathId}.memory.search.remote.apiKey` + : "memory.search.remote.apiKey", expected: "string", defaults: params.defaults, context: params.context, @@ -84,8 +96,8 @@ export function collectAgentMemorySearchAssignments(params: { collectRuntimeSecretInputAssignment({ value: headerValue, path: overrideHeaders - ? `agents.list.${index}.memorySearch.remote.headers.${headerKey}` - : `agents.defaults.memorySearch.remote.headers.${headerKey}`, + ? `agents.${container}.${pathId}.memory.search.remote.headers.${headerKey}` + : `memory.search.remote.headers.${headerKey}`, expected: "string", defaults: params.defaults, context: params.context, @@ -102,12 +114,12 @@ export function collectAgentMemorySearchAssignments(params: { } }; - if (list.length === 0) { + if (entries.length === 0) { collectForAgent(undefined); } else { - list.forEach((rawAgent, index) => { + entries.forEach(([entryId, rawAgent, container, pathId]) => { if (isRecord(rawAgent)) { - collectForAgent(rawAgent, index); + collectForAgent(rawAgent, entryId, container, pathId); } }); } @@ -115,7 +127,7 @@ export function collectAgentMemorySearchAssignments(params: { if (defaultRemote && !defaultApiKeyAssignmentCollected) { collectRuntimeSecretInputAssignment({ value: defaultRemote.apiKey, - path: "agents.defaults.memorySearch.remote.apiKey", + path: "memory.search.remote.apiKey", expected: "string", defaults: params.defaults, context: params.context, @@ -132,7 +144,7 @@ export function collectAgentMemorySearchAssignments(params: { } collectRuntimeSecretInputAssignment({ value: headerValue, - path: `agents.defaults.memorySearch.remote.headers.${headerKey}`, + path: `memory.search.remote.headers.${headerKey}`, expected: "string", defaults: params.defaults, context: params.context, diff --git a/src/secrets/runtime-config-collectors-sandbox.ts b/src/secrets/runtime-config-collectors-sandbox.ts index 6649ae6b4a9a..db8278a8b81b 100644 --- a/src/secrets/runtime-config-collectors-sandbox.ts +++ b/src/secrets/runtime-config-collectors-sandbox.ts @@ -66,25 +66,23 @@ export function collectAgentSandboxAssignments(params: { const defaultsSandbox = isRecord(defaultsAgent?.sandbox) ? defaultsAgent.sandbox : undefined; const defaultsSsh = isRecord(defaultsSandbox?.ssh) ? defaultsSandbox.ssh : undefined; const defaultsBackend = normalizeOptionalLowercaseString(defaultsSandbox?.backend) ?? "docker"; - const rawList = Array.isArray(agents.list) ? agents.list : []; - const configuredAgents: Array<{ entry: Record; index: number }> = []; - rawList.forEach((entry, index) => { + const rawEntries = isRecord(agents.entries) ? agents.entries : {}; + const configuredAgents: Array<{ entry: Record; entryId: string }> = []; + Object.entries(rawEntries).forEach(([entryId, entry]) => { if (isRecord(entry)) { - configuredAgents.push({ entry, index }); + configuredAgents.push({ entry, entryId }); } }); const candidates: Array<{ entry: Record | undefined; - index: number | undefined; - }> = configuredAgents.length > 0 ? configuredAgents : [{ entry: undefined, index: undefined }]; + entryId: string | undefined; + }> = configuredAgents.length > 0 ? configuredAgents : [{ entry: undefined, entryId: undefined }]; const activeDefaultKeys = new Set(); const seenAgentIds = new Set(); for (const candidate of candidates) { const rawAgent = candidate.entry; - const agentId = normalizeAgentId( - typeof rawAgent?.id === "string" ? rawAgent.id : DEFAULT_AGENT_ID, - ); + const agentId = normalizeAgentId(candidate.entryId ?? DEFAULT_AGENT_ID); if (seenAgentIds.has(agentId)) { continue; } @@ -127,7 +125,7 @@ export function collectAgentSandboxAssignments(params: { collectAssignment({ target: ssh, key, - path: `agents.list.${candidate.index}.sandbox.ssh.${key}`, + path: `agents.entries.${candidate.entryId}.sandbox.ssh.${key}`, defaults: params.defaults, context: params.context, active, @@ -139,7 +137,7 @@ export function collectAgentSandboxAssignments(params: { collectAssignment({ target: ssh, key, - path: `agents.list.${candidate.index}.sandbox.ssh.${key}`, + path: `agents.entries.${candidate.entryId}.sandbox.ssh.${key}`, defaults: params.defaults, context: params.context, active: false, diff --git a/src/secrets/runtime-config-collectors-tts.test.ts b/src/secrets/runtime-config-collectors-tts.test.ts index 451c23253fa6..893aab70e1d9 100644 --- a/src/secrets/runtime-config-collectors-tts.test.ts +++ b/src/secrets/runtime-config-collectors-tts.test.ts @@ -30,14 +30,14 @@ describe("collectTtsApiKeyAssignments", () => { collectTtsApiKeyAssignments({ tts, - pathPrefix: "messages.tts", + pathPrefix: "tts", defaults: undefined, context, }); expect(context.assignments).toHaveLength(1); expect(context.assignments[0]).toMatchObject({ - path: "messages.tts.providers.elevenlabs.apiKey", + path: "tts.providers.elevenlabs.apiKey", expected: "string", ownerKind: "capability", ownerId: "tts", diff --git a/src/secrets/runtime-core-snapshots.test.ts b/src/secrets/runtime-core-snapshots.test.ts index f28d1c6c11e4..6f8789bedd90 100644 --- a/src/secrets/runtime-core-snapshots.test.ts +++ b/src/secrets/runtime-core-snapshots.test.ts @@ -159,15 +159,17 @@ describe("secrets runtime snapshot core lanes", () => { it("resolves env refs for memory, talk, and gateway surfaces", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ - agents: { - defaults: { - memorySearch: { - remote: { - apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" }, - }, + memory: { + search: { + remote: { + apiKey: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" }, }, }, }, + + agents: { + defaults: {}, + }, talk: { providers: { "acme-speech": { @@ -194,7 +196,7 @@ describe("secrets runtime snapshot core lanes", () => { loadablePluginOrigins: new Map(), }); - expect(snapshot.config.agents?.defaults?.memorySearch?.remote?.apiKey).toBe("mem-ref-key"); + expect(snapshot.config.memory?.search?.remote?.apiKey).toBe("mem-ref-key"); expect((snapshot.config.talk as { apiKey?: unknown } | undefined)?.apiKey).toBeUndefined(); expect(snapshot.config.talk?.providers?.["acme-speech"]?.apiKey).toBe("talk-provider-ref-key"); expect(snapshot.config.gateway?.remote?.token).toBe("remote-token-ref"); diff --git a/src/secrets/runtime-degradation-attribution.test.ts b/src/secrets/runtime-degradation-attribution.test.ts index b95cf5260ac3..69e850e04db5 100644 --- a/src/secrets/runtime-degradation-attribution.test.ts +++ b/src/secrets/runtime-degradation-attribution.test.ts @@ -37,11 +37,9 @@ describe("secrets runtime degraded-owner attribution", () => { }, }, }, - messages: { - tts: { - providers: { - elevenlabs: { apiKey: TTS_REF }, - }, + tts: { + providers: { + elevenlabs: { apiKey: TTS_REF }, }, }, }), @@ -61,7 +59,7 @@ describe("secrets runtime degraded-owner attribution", () => { expect.objectContaining({ ownerKind: "capability", ownerId: "tts", - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], reason: "secret reference was not found", degradationState: "cold", failureMatched: true, @@ -74,7 +72,7 @@ describe("secrets runtime degraded-owner attribution", () => { ["CHANGED_REF", "cold"], ] as const)("classifies unresolved reload ref %s", async (candidateId, expectedState) => { const config = (ref: SecretRef) => - asConfig({ messages: { tts: { providers: { elevenlabs: { apiKey: ref } } } } }); + asConfig({ tts: { providers: { elevenlabs: { apiKey: ref } } } }); const ref = (id: string): SecretRef => ({ source: "env", provider: "default", id }); const active = await prepareSecretsRuntimeSnapshot({ config: config(ref("CURRENT_REF")), @@ -207,12 +205,10 @@ describe("secrets runtime degraded-owner attribution", () => { }, }, }, - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: { source: "file", provider: "healthy", id: sharedId }, - }, + tts: { + providers: { + elevenlabs: { + apiKey: { source: "file", provider: "healthy", id: sharedId }, }, }, }, @@ -585,9 +581,7 @@ describe("secrets runtime degraded-owner attribution", () => { }, }, }, - messages: { - tts: { providers: { elevenlabs: { apiKey: ref } } }, - }, + tts: { providers: { elevenlabs: { apiKey: ref } } }, }); const authAgentDir = "/tmp/invalid-value-auth-co-owner"; const authProfileId = "openai:invalid-value"; @@ -641,14 +635,14 @@ describe("secrets runtime degraded-owner attribution", () => { expect(error).toBeInstanceOf(Error); expect(String(error)).toContain( - "messages.tts.providers.elevenlabs.apiKey resolved to a non-string or empty value.", + "tts.providers.elevenlabs.apiKey resolved to a non-string or empty value.", ); expect(listSecretResolutionErrorOwners(error)).toEqual( expect.arrayContaining([ expect.objectContaining({ ownerKind: "capability", ownerId: "tts", - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], reason: "resolved secret value was invalid", degradationState: "cold", failureMatched: true, @@ -689,9 +683,7 @@ describe("secrets runtime degraded-owner attribution", () => { }, }, }, - messages: { - tts: { providers: { elevenlabs: { apiKey: sharedRef } } }, - }, + tts: { providers: { elevenlabs: { apiKey: sharedRef } } }, skills: { entries: { healthy: { diff --git a/src/secrets/runtime-external-channel-audit.test.ts b/src/secrets/runtime-external-channel-audit.test.ts index 8012efe02cdb..10f5c7ae0e49 100644 --- a/src/secrets/runtime-external-channel-audit.test.ts +++ b/src/secrets/runtime-external-channel-audit.test.ts @@ -107,8 +107,7 @@ function createGoogleChatSecretContractApi() { targetTypeAliases: ["channels.googlechat.accounts.*.serviceAccount"], configFile: "openclaw.json", pathPattern: "channels.googlechat.accounts.*.serviceAccount", - refPathPattern: "channels.googlechat.accounts.*.serviceAccountRef", - secretShape: "sibling_ref", + secretShape: "secret_input", expectedResolvedValue: "string-or-object", includeInPlan: true, includeInConfigure: true, @@ -120,8 +119,7 @@ function createGoogleChatSecretContractApi() { targetType: "channels.googlechat.serviceAccount", configFile: "openclaw.json", pathPattern: "channels.googlechat.serviceAccount", - refPathPattern: "channels.googlechat.serviceAccountRef", - secretShape: "sibling_ref", + secretShape: "secret_input", expectedResolvedValue: "string-or-object", includeInPlan: true, includeInConfigure: true, @@ -145,7 +143,7 @@ function createGoogleChatSecretContractApi() { return; } const collect = (target: Record, pathKey: string, active: boolean) => { - const refValue = target.serviceAccountRef; + const refValue = target.serviceAccount; if (!refValue) { return; } @@ -280,14 +278,14 @@ describe("secrets runtime externalized channel SecretRef audit", () => { }, }, googlechat: { - serviceAccountRef: ref("GOOGLECHAT_SERVICE_ACCOUNT"), + serviceAccount: ref("GOOGLECHAT_SERVICE_ACCOUNT"), accounts: { inherited: { enabled: true, }, work: { enabled: true, - serviceAccountRef: ref("GOOGLECHAT_WORK_SERVICE_ACCOUNT"), + serviceAccount: ref("GOOGLECHAT_WORK_SERVICE_ACCOUNT"), }, }, }, @@ -461,11 +459,11 @@ describe("secrets runtime externalized channel SecretRef audit", () => { }, googlechat: { enabled: false, - serviceAccountRef: inactiveExecRef("GOOGLECHAT_DISABLED_SERVICE_ACCOUNT"), + serviceAccount: inactiveExecRef("GOOGLECHAT_DISABLED_SERVICE_ACCOUNT"), accounts: { disabled: { enabled: false, - serviceAccountRef: inactiveExecRef("GOOGLECHAT_DISABLED_ACCOUNT_SERVICE_ACCOUNT"), + serviceAccount: inactiveExecRef("GOOGLECHAT_DISABLED_ACCOUNT_SERVICE_ACCOUNT"), }, }, }, diff --git a/src/secrets/runtime-fast-path.ts b/src/secrets/runtime-fast-path.ts index a99cb27ab2d9..96ad2cc3f001 100644 --- a/src/secrets/runtime-fast-path.ts +++ b/src/secrets/runtime-fast-path.ts @@ -37,7 +37,6 @@ const RUNTIME_PATH_ENV_KEYS = [ "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH", "OPENCLAW_AGENT_DIR", - "OPENCLAW_TEST_FAST", ] as const; /** diff --git a/src/secrets/runtime-inactive-core-surfaces.test.ts b/src/secrets/runtime-inactive-core-surfaces.test.ts index e777ce39b9a7..87260390e94b 100644 --- a/src/secrets/runtime-inactive-core-surfaces.test.ts +++ b/src/secrets/runtime-inactive-core-surfaces.test.ts @@ -18,16 +18,18 @@ describe("secrets runtime snapshot inactive core surfaces", () => { it("skips inactive core refs and emits diagnostics", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ - agents: { - defaults: { - memorySearch: { - enabled: false, - remote: { - apiKey: { source: "env", provider: "default", id: "DISABLED_MEMORY_API_KEY" }, - }, + memory: { + search: { + enabled: false, + remote: { + apiKey: { source: "env", provider: "default", id: "DISABLED_MEMORY_API_KEY" }, }, }, }, + + agents: { + defaults: {}, + }, gateway: { auth: { mode: "token", @@ -40,9 +42,6 @@ describe("secrets runtime snapshot inactive core surfaces", () => { loadablePluginOrigins: new Map(), }); - expectWarningPaths(snapshot, [ - "agents.defaults.memorySearch.remote.apiKey", - "gateway.auth.password", - ]); + expectWarningPaths(snapshot, ["memory.search.remote.apiKey", "gateway.auth.password"]); }); }); diff --git a/src/secrets/runtime-provider-and-media-surfaces.test.ts b/src/secrets/runtime-provider-and-media-surfaces.test.ts index a0b98ebe5fe1..f0236ef93d39 100644 --- a/src/secrets/runtime-provider-and-media-surfaces.test.ts +++ b/src/secrets/runtime-provider-and-media-surfaces.test.ts @@ -530,31 +530,26 @@ describe("secrets runtime provider and media surfaces", () => { ); }); - it("treats section media model request refs as inactive when model capabilities exclude the section", async () => { - const sectionTokenRef = { - source: "env" as const, - provider: "default" as const, - id: "MEDIA_AUDIO_SECTION_FILTERED_TOKEN", - }; + it("treats shared media model request refs as inactive when their capabilities are disabled", async () => { + const fixtureRef = envTokenRef("config-token"); const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ tools: { media: { - audio: { - enabled: true, - models: [ - { - provider: "openai", - capabilities: ["video"], - request: { - auth: { - mode: "authorization-bearer", - token: sectionTokenRef, - }, + audio: { enabled: true }, + video: { enabled: false }, + models: [ + { + provider: "openai", + capabilities: ["video"], + request: { + auth: { + mode: "authorization-bearer", + token: fixtureRef, }, }, - ], - }, + }, + ], }, }, }), @@ -563,12 +558,12 @@ describe("secrets runtime provider and media surfaces", () => { loadAuthStore: () => ({ version: 1, profiles: {} }), }); - expect(snapshot.config.tools?.media?.audio?.models?.[0]?.request?.auth).toEqual({ + expect(snapshot.config.tools?.media?.models?.[0]?.request?.auth).toEqual({ mode: "authorization-bearer", - token: sectionTokenRef, + token: fixtureRef, }); expect(snapshot.warnings.map((warning) => warning.path)).toContain( - "tools.media.audio.models.0.request.auth.token", + "tools.media.models.0.request.auth.token", ); }); @@ -618,23 +613,27 @@ describe("secrets runtime provider and media surfaces", () => { it("treats defaults memorySearch ref as inactive when all enabled agents disable memorySearch", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ - agents: { - defaults: { - memorySearch: { - remote: { - apiKey: { - source: "env", - provider: "default", - id: "DEFAULT_MEMORY_REMOTE_API_KEY", - }, + memory: { + search: { + remote: { + apiKey: { + source: "env", + provider: "default", + id: "DEFAULT_MEMORY_REMOTE_API_KEY", }, }, }, + }, + + agents: { + defaults: {}, list: [ { enabled: true, - memorySearch: { - enabled: false, + memory: { + search: { + enabled: false, + }, }, }, ], @@ -645,13 +644,13 @@ describe("secrets runtime provider and media surfaces", () => { loadAuthStore: () => ({ version: 1, profiles: {} }), }); - expect(snapshot.config.agents?.defaults?.memorySearch?.remote?.apiKey).toEqual({ + expect(snapshot.config.memory?.search?.remote?.apiKey).toEqual({ source: "env", provider: "default", id: "DEFAULT_MEMORY_REMOTE_API_KEY", }); expect(snapshot.warnings.map((warning) => warning.path)).toContain( - "agents.defaults.memorySearch.remote.apiKey", + "memory.search.remote.apiKey", ); }); @@ -661,21 +660,25 @@ describe("secrets runtime provider and media surfaces", () => { const healthyRef = envTokenRef("HEALTHY_TEST_VALUE"); const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ - agents: { - defaults: { - memorySearch: { - remote: { - apiKey: missingRef, - headers: { "X-Memory-Value": missingRef }, - }, + memory: { + search: { + remote: { + apiKey: missingRef, + headers: { "X-Memory-Value": missingRef }, }, }, + }, + + agents: { + defaults: {}, list: [ { id: "cold", default: true }, { id: "healthy", - memorySearch: { - remote: { apiKey: healthyRef, headers: { "X-Memory-Value": healthyRef } }, + memory: { + search: { + remote: { apiKey: healthyRef, headers: { "X-Memory-Value": healthyRef } }, + }, }, }, ], @@ -687,8 +690,8 @@ describe("secrets runtime provider and media surfaces", () => { allowUnavailableSecretOwners: true, }); - expect(snapshot.config.agents?.list?.[1]?.memorySearch?.remote?.apiKey).toBe(healthyValue); - expect(snapshot.config.agents?.list?.[1]?.memorySearch?.remote?.headers).toEqual({ + expect(snapshot.config.agents?.list?.[1]?.memory?.search?.remote?.apiKey).toBe(healthyValue); + expect(snapshot.config.agents?.list?.[1]?.memory?.search?.remote?.headers).toEqual({ "X-Memory-Value": healthyValue, }); expect(snapshot.degradedOwners).toMatchObject([ @@ -696,10 +699,7 @@ describe("secrets runtime provider and media surfaces", () => { ownerKind: "capability", ownerId: "memory-provider:cold", state: "unavailable", - paths: [ - "agents.defaults.memorySearch.remote.apiKey", - "agents.defaults.memorySearch.remote.headers.X-Memory-Value", - ], + paths: ["memory.search.remote.apiKey", "memory.search.remote.headers.X-Memory-Value"], }, ]); }); diff --git a/src/secrets/runtime-provider-failure-fanout.test.ts b/src/secrets/runtime-provider-failure-fanout.test.ts index fdd9c00af63c..91739839c4a2 100644 --- a/src/secrets/runtime-provider-failure-fanout.test.ts +++ b/src/secrets/runtime-provider-failure-fanout.test.ts @@ -101,9 +101,7 @@ describe("provider-scoped SecretRef failure fan-out", () => { }, }, }, - messages: { - tts: { providers: { elevenlabs: { apiKey: input.ttsRef } } }, - }, + tts: { providers: { elevenlabs: { apiKey: input.ttsRef } } }, }), env: { PATH: process.env.PATH ?? "" }, includeAuthStoreRefs: false, @@ -112,7 +110,7 @@ describe("provider-scoped SecretRef failure fan-out", () => { }); expect(snapshot.config.models?.providers?.openai?.apiKey).toEqual(input.modelRef); - expect(snapshot.config.messages?.tts?.providers?.elevenlabs?.apiKey).toEqual(input.ttsRef); + expect(snapshot.config.tts?.providers?.elevenlabs?.apiKey).toEqual(input.ttsRef); expect(snapshot.degradedOwners).toMatchObject([ { ownerKind: "provider", diff --git a/src/secrets/runtime-request-secret-refs.test.ts b/src/secrets/runtime-request-secret-refs.test.ts index 507146a92e7f..90d08be42b46 100644 --- a/src/secrets/runtime-request-secret-refs.test.ts +++ b/src/secrets/runtime-request-secret-refs.test.ts @@ -283,6 +283,24 @@ describe("secrets runtime snapshot request secret refs", () => { }, }, }, + { + provider: "deepgram", + capabilities: ["audio"], + request: { + auth: { + mode: "header", + headerName: "x-api-key", + value: { source: "env", provider: "default", id: "MEDIA_AUDIO_MODEL_KEY" }, + }, + proxy: { + mode: "explicit-proxy", + url: "http://proxy.example:8080", + tls: { + ca: { source: "env", provider: "default", id: "MEDIA_AUDIO_PROXY_CA" }, + }, + }, + }, + }, ], audio: { enabled: true, @@ -298,25 +316,6 @@ describe("secrets runtime snapshot request secret refs", () => { cert: { source: "env", provider: "default", id: "MEDIA_AUDIO_CERT" }, }, }, - models: [ - { - provider: "deepgram", - request: { - auth: { - mode: "header", - headerName: "x-api-key", - value: { source: "env", provider: "default", id: "MEDIA_AUDIO_MODEL_KEY" }, - }, - proxy: { - mode: "explicit-proxy", - url: "http://proxy.example:8080", - tls: { - ca: { source: "env", provider: "default", id: "MEDIA_AUDIO_PROXY_CA" }, - }, - }, - }, - }, - ], }, }, }, @@ -352,7 +351,7 @@ describe("secrets runtime snapshot request secret refs", () => { value: "shared-model-key", }, }); - expect(snapshot.config.tools?.media?.audio?.models?.[0]?.request).toEqual({ + expect(snapshot.config.tools?.media?.models?.[1]?.request).toEqual({ auth: { mode: "header", headerName: "x-api-key", diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index ebd88a1503ef..94ebc7bae689 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -446,7 +446,7 @@ function resolveCoverageBatchKey(entry: SecretRegistryEntry): string { if (entry.id.startsWith("agents.defaults.")) { return entry.id; } - if (entry.id.startsWith("agents.list[].")) { + if (entry.id.startsWith("agents.entries.*.")) { return entry.id; } if (entry.id.startsWith("gateway.auth.")) { @@ -476,8 +476,8 @@ function resolveCoverageBatchKey(entry: SecretRegistryEntry): string { const scope = segments[2] === "accounts" ? "accounts" : "root"; return `channels.${channelId}.${scope}`; } - if (entry.id.startsWith("messages.tts.providers.")) { - return "messages.tts.providers"; + if (entry.id.startsWith("tts.providers.")) { + return "tts.providers"; } if (entry.id.startsWith("models.providers.")) { return "models.providers"; @@ -583,7 +583,7 @@ function applyConfigForOpenClawTarget( addCoveragePluginLoadPath(config, pluginId); } } - if (entry.id === "agents.defaults.memorySearch.remote.apiKey") { + if (entry.id === "memory.search.remote.apiKey") { setPathCreateStrict(config, ["agents", "list", "0", "id"], "sample-agent"); } if (entry.id === "gateway.auth.password") { diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 1ccad59cb84b..064e9da63a19 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -372,9 +372,7 @@ describe("secrets runtime snapshot", () => { const secret = "test-secret"; await prepareSecretsRuntimeSnapshot({ config: asConfig({ - messages: { - tts: { providers: { elevenlabs: { apiKey: TTS_REF } } }, - }, + tts: { providers: { elevenlabs: { apiKey: TTS_REF } } }, }), env: { ELEVENLABS_API_KEY: secret }, includeAuthStoreRefs: false, @@ -436,9 +434,8 @@ describe("secrets runtime snapshot", () => { ssh: { target: "peter@example.com:22" }, }, }, - list: [ - { - id: "worker", + entries: { + worker: { enabled: false, sandbox: { ssh: { @@ -450,7 +447,7 @@ describe("secrets runtime snapshot", () => { }, }, }, - ], + }, }, }), env: { DISABLED_WORKER_SSH_IDENTITY: "DISABLED WORKER PRIVATE KEY" }, @@ -458,7 +455,7 @@ describe("secrets runtime snapshot", () => { loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, }); - expect(snapshot.config.agents?.list?.[0]?.sandbox?.ssh?.identityData).toBe( + expect(snapshot.config.agents?.entries?.worker?.sandbox?.ssh?.identityData).toBe( "DISABLED WORKER PRIVATE KEY", ); }); @@ -481,9 +478,8 @@ describe("secrets runtime snapshot", () => { }, }, }, - list: [ - { - id: "worker", + entries: { + worker: { sandbox: { ssh: { identityData: { @@ -494,7 +490,7 @@ describe("secrets runtime snapshot", () => { }, }, }, - ], + }, }, }), env: { @@ -508,7 +504,7 @@ describe("secrets runtime snapshot", () => { expect(snapshot.config.agents?.defaults?.sandbox?.ssh?.identityData).toBe( "DEFAULT PRIVATE KEY", ); - expect(snapshot.config.agents?.list?.[0]?.sandbox?.ssh?.identityData).toBe( + expect(snapshot.config.agents?.entries?.worker?.sandbox?.ssh?.identityData).toBe( "WORKER PRIVATE KEY", ); }); @@ -532,10 +528,9 @@ describe("secrets runtime snapshot", () => { }, }, }, - list: [ - { id: "cold" }, - { - id: "healthy", + entries: { + cold: {}, + healthy: { sandbox: { ssh: { identityData: { @@ -546,7 +541,7 @@ describe("secrets runtime snapshot", () => { }, }, }, - ], + }, }, }), env: { HEALTHY_SSH_IDENTITY: "HEALTHY PRIVATE KEY" }, @@ -556,7 +551,7 @@ describe("secrets runtime snapshot", () => { }); expect(snapshot.config.agents?.defaults?.sandbox?.ssh?.identityData).toEqual(missingRef); - expect(snapshot.config.agents?.list?.[1]?.sandbox?.ssh?.identityData).toBe( + expect(snapshot.config.agents?.entries?.healthy?.sandbox?.ssh?.identityData).toBe( "HEALTHY PRIVATE KEY", ); expect(snapshot.degradedOwners).toMatchObject([ @@ -680,12 +675,10 @@ describe("secrets runtime snapshot", () => { it("isolates the TTS owner when its SecretRef is missing during cold startup", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: TTS_REF, - }, + tts: { + providers: { + elevenlabs: { + apiKey: TTS_REF, }, }, }, @@ -696,17 +689,17 @@ describe("secrets runtime snapshot", () => { loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, }); - expect(snapshot.config.messages?.tts?.providers?.elevenlabs?.apiKey).toEqual(TTS_REF); + expect(snapshot.config.tts?.providers?.elevenlabs?.apiKey).toEqual(TTS_REF); expectWarning(snapshot, { code: "SECRETS_OWNER_UNAVAILABLE", - path: "messages.tts.providers.elevenlabs.apiKey", + path: "tts.providers.elevenlabs.apiKey", }); expect(snapshot.degradedOwners).toMatchObject([ { ownerKind: "capability", ownerId: "tts", state: "unavailable", - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], reason: "secret reference was not found", }, ]); @@ -733,15 +726,13 @@ describe("secrets runtime snapshot", () => { }, }, }, - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: { - source: "file", - provider: "ttsfile", - id: "/providers/elevenlabs/apiKey", - }, + tts: { + providers: { + elevenlabs: { + apiKey: { + source: "file", + provider: "ttsfile", + id: "/providers/elevenlabs/apiKey", }, }, }, @@ -753,14 +744,14 @@ describe("secrets runtime snapshot", () => { loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS, }); - expect(snapshot.config.messages?.tts?.providers?.elevenlabs?.apiKey).toEqual({ + expect(snapshot.config.tts?.providers?.elevenlabs?.apiKey).toEqual({ source: "file", provider: "ttsfile", id: "/providers/elevenlabs/apiKey", }); expectWarning(snapshot, { code: "SECRETS_OWNER_UNAVAILABLE", - path: "messages.tts.providers.elevenlabs.apiKey", + path: "tts.providers.elevenlabs.apiKey", }); expect(snapshot.warnings[0]?.message).toContain("secret reference was not found"); }); @@ -777,12 +768,10 @@ describe("secrets runtime snapshot", () => { }, }, }, - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: TTS_REF, - }, + tts: { + providers: { + elevenlabs: { + apiKey: TTS_REF, }, }, }, @@ -801,12 +790,10 @@ describe("secrets runtime snapshot", () => { await expect( prepareSecretsRuntimeSnapshot({ config: asConfig({ - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: { source: "env", provider: "default", id: "elevenlabs_api_key" }, - }, + tts: { + providers: { + elevenlabs: { + apiKey: { source: "env", provider: "default", id: "elevenlabs_api_key" }, }, }, }, @@ -825,12 +812,10 @@ describe("secrets runtime snapshot", () => { await expect( prepareSecretsRuntimeSnapshot({ config: asConfig({ - messages: { - tts: { - providers: { - elevenlabs: { - apiKey: { source: "env", provider: "missing", id: "ELEVENLABS_API_KEY" }, - }, + tts: { + providers: { + elevenlabs: { + apiKey: { source: "env", provider: "missing", id: "ELEVENLABS_API_KEY" }, }, }, }, diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index bc788fefafab..fade895718bb 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -152,10 +152,10 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ authProfileType: "token", }, { - id: "agents.defaults.memorySearch.remote.apiKey", - targetType: "agents.defaults.memorySearch.remote.apiKey", + id: "memory.search.remote.apiKey", + targetType: "memory.search.remote.apiKey", configFile: "openclaw.json", - pathPattern: "agents.defaults.memorySearch.remote.apiKey", + pathPattern: "memory.search.remote.apiKey", secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, @@ -163,10 +163,10 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInAudit: true, }, { - id: "agents.list[].memorySearch.remote.apiKey", - targetType: "agents.list[].memorySearch.remote.apiKey", + id: "agents.entries.*.memory.search.remote.apiKey", + targetType: "agents.entries.*.memory.search.remote.apiKey", configFile: "openclaw.json", - pathPattern: "agents.list[].memorySearch.remote.apiKey", + pathPattern: "agents.entries.*.memory.search.remote.apiKey", secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, @@ -229,28 +229,28 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [ includeInAudit: true, }, { - id: "messages.tts.providers.*.apiKey", - targetType: "messages.tts.providers.*.apiKey", + id: "tts.providers.*.apiKey", + targetType: "tts.providers.*.apiKey", configFile: "openclaw.json", - pathPattern: "messages.tts.providers.*.apiKey", + pathPattern: "tts.providers.*.apiKey", secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, includeInAudit: true, - providerIdPathSegmentIndex: 3, + providerIdPathSegmentIndex: 2, }, { - id: "agents.list[].tts.providers.*.apiKey", - targetType: "agents.list[].tts.providers.*.apiKey", + id: "agents.entries.*.tts.providers.*.apiKey", + targetType: "agents.entries.*.tts.providers.*.apiKey", configFile: "openclaw.json", - pathPattern: "agents.list[].tts.providers.*.apiKey", + pathPattern: "agents.entries.*.tts.providers.*.apiKey", secretShape: SECRET_INPUT_SHAPE, expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: false, includeInAudit: true, - providerIdPathSegmentIndex: 4, + providerIdPathSegmentIndex: 5, }, { id: "models.providers.*.apiKey", diff --git a/src/secrets/target-registry-pattern.test.ts b/src/secrets/target-registry-pattern.test.ts index 2f64b1acdeb2..f8c1a54a8e9b 100644 --- a/src/secrets/target-registry-pattern.test.ts +++ b/src/secrets/target-registry-pattern.test.ts @@ -24,9 +24,9 @@ function compilePattern(pathPattern: string, refPathPattern?: string) { describe("target registry pattern helpers", () => { it("matches wildcard and array tokens with stable capture ordering", () => { - const tokens = compilePattern("agents.list[].memorySearch.providers.*.apiKey").pathTokens; + const tokens = compilePattern("agents.list[].memory.search.providers.*.apiKey").pathTokens; const match = matchPathTokens( - ["agents", "list", "2", "memorySearch", "providers", "openai", "apiKey"], + ["agents", "list", "2", "memory", "search", "providers", "openai", "apiKey"], tokens, ); @@ -35,25 +35,25 @@ describe("target registry pattern helpers", () => { }); expect( matchPathTokens( - ["agents", "list", "x", "memorySearch", "providers", "openai", "apiKey"], + ["agents", "list", "x", "memory", "search", "providers", "openai", "apiKey"], tokens, ), ).toBeNull(); expect( matchPathTokens( - ["agents", "list", "02", "memorySearch", "providers", "openai", "apiKey"], + ["agents", "list", "02", "memory", "search", "providers", "openai", "apiKey"], tokens, ), ).toBeNull(); expect( matchPathTokens( - ["agents", "list", "+2", "memorySearch", "providers", "openai", "apiKey"], + ["agents", "list", "+2", "memory", "search", "providers", "openai", "apiKey"], tokens, ), ).toBeNull(); expect( matchPathTokens( - ["agents", "list", "4294967294", "memorySearch", "providers", "openai", "apiKey"], + ["agents", "list", "4294967294", "memory", "search", "providers", "openai", "apiKey"], tokens, ), ).toBeNull(); @@ -61,15 +61,16 @@ describe("target registry pattern helpers", () => { it("materializes sibling ref paths from wildcard and array captures", () => { const refTokens = compilePattern( - "agents.list[].memorySearch.providers.*.apiKey", - "agents.list[].memorySearch.providers.*.apiKeyRef", + "agents.list[].memory.search.providers.*.apiKey", + "agents.list[].memory.search.providers.*.apiKeyRef", ).refPathTokens; expect(refTokens).toBeDefined(); expect(materializePathTokens(refTokens ?? [], ["1", "anthropic"])).toEqual([ "agents", "list", "1", - "memorySearch", + "memory", + "search", "providers", "anthropic", "apiKeyRef", @@ -94,10 +95,10 @@ describe("target registry pattern helpers", () => { it("expands wildcard and array patterns over config objects", () => { const root = { agents: { - list: [ - { memorySearch: { remote: { apiKey: "a" } } }, - { memorySearch: { remote: { apiKey: "b" } } }, - ], + entries: { + main: { memory: { search: { remote: { apiKey: "a" } } } }, + ops: { memory: { search: { remote: { apiKey: "b" } } } }, + }, }, talk: { providers: { @@ -109,7 +110,7 @@ describe("target registry pattern helpers", () => { const arrayMatches = expandPathTokens( root, - compilePattern("agents.list[].memorySearch.remote.apiKey").pathTokens, + compilePattern("agents.entries.*.memory.search.remote.apiKey").pathTokens, ); expect( arrayMatches.map((entry) => ({ @@ -119,13 +120,13 @@ describe("target registry pattern helpers", () => { })), ).toEqual([ { - segments: "agents.list.0.memorySearch.remote.apiKey", - captures: ["0"], + segments: "agents.entries.main.memory.search.remote.apiKey", + captures: ["main"], value: "a", }, { - segments: "agents.list.1.memorySearch.remote.apiKey", - captures: ["1"], + segments: "agents.entries.ops.memory.search.remote.apiKey", + captures: ["ops"], value: "b", }, ]); diff --git a/src/secrets/target-registry.fast-path.test.ts b/src/secrets/target-registry.fast-path.test.ts index 0174fb8a239d..f4e31405b26e 100644 --- a/src/secrets/target-registry.fast-path.test.ts +++ b/src/secrets/target-registry.fast-path.test.ts @@ -18,8 +18,7 @@ const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({ targetType: "channels.googlechat.serviceAccount", configFile: "openclaw.json", pathPattern: "channels.googlechat.serviceAccount", - refPathPattern: "channels.googlechat.serviceAccountRef", - secretShape: "sibling_ref", + secretShape: "secret_input", expectedResolvedValue: "string", includeInPlan: true, includeInConfigure: true, @@ -80,7 +79,7 @@ describe("secret target registry fast path", () => { throw new Error("expected googlechat service account target"); } expect(target.entry.id).toBe("channels.googlechat.serviceAccount"); - expect(target.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]); + expect(target.refPathSegments).toBeUndefined(); expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ dirName: "googlechat", artifactBasename: "secret-contract-api.js", diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index aec5071da4cc..f2751037731b 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -36,11 +36,10 @@ describe("secret target registry", () => { expect(targets[0]?.path).toBe(TALK_TEST_PROVIDER_API_KEY_PATH); }); - it("resolves config targets by exact path including sibling ref metadata", () => { + it("resolves config targets by exact path", () => { const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]); expect(target?.entry?.id).toBe("channels.googlechat.serviceAccount"); - expect(target?.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]); }); it("resolves talk realtime provider api key targets", () => { diff --git a/src/secrets/unsupported-surface-policy.test.ts b/src/secrets/unsupported-surface-policy.test.ts index 4b652711b5f0..2a905861e24c 100644 --- a/src/secrets/unsupported-surface-policy.test.ts +++ b/src/secrets/unsupported-surface-policy.test.ts @@ -6,7 +6,6 @@ describe("unsupported SecretRef surface policy metadata", () => { it("exposes the canonical unsupported surface patterns", () => { expect(unsupportedSecretRefSurfacePolicy.listPatterns().toSorted()).toEqual( [ - "commands.ownerDisplaySecret", "hooks.token", "hooks.gmail.pushToken", "hooks.mappings[].sessionKey", @@ -21,7 +20,6 @@ describe("unsupported SecretRef surface policy metadata", () => { it("discovers concrete config candidates for unsupported mutable surfaces", () => { const candidates = unsupportedSecretRefSurfacePolicy.collectConfigCandidates({ - commands: { ownerDisplaySecret: { source: "env", provider: "default", id: "OWNER" } }, hooks: { token: { source: "env", provider: "default", id: "HOOK_TOKEN" }, gmail: { pushToken: { source: "env", provider: "default", id: "GMAIL_PUSH" } }, @@ -59,7 +57,6 @@ describe("unsupported SecretRef surface policy metadata", () => { expect(candidates.map((candidate) => candidate.path).toSorted()).toEqual( [ - "commands.ownerDisplaySecret", "hooks.token", "hooks.gmail.pushToken", "hooks.mappings.0.sessionKey", diff --git a/src/secrets/unsupported-surface-policy.ts b/src/secrets/unsupported-surface-policy.ts index dc1cd8a43eab..42b11f71165f 100644 --- a/src/secrets/unsupported-surface-policy.ts +++ b/src/secrets/unsupported-surface-policy.ts @@ -3,7 +3,6 @@ import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "../config/bundled-cha import { isRecord } from "../utils.js"; const CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [ - "commands.ownerDisplaySecret", "hooks.token", "hooks.gmail.pushToken", "hooks.mappings[].sessionKey", @@ -11,7 +10,6 @@ const CORE_UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [ ] as const; const CORE_UNSUPPORTED_SECRETREF_CONFIG_CANDIDATE_PATTERNS = [ - "commands.ownerDisplaySecret", "hooks.token", "hooks.gmail.pushToken", "hooks.mappings[].sessionKey", diff --git a/src/security/audit-config-basics.test.ts b/src/security/audit-config-basics.test.ts index ff0f8ef190ed..f57b5b8243cd 100644 --- a/src/security/audit-config-basics.test.ts +++ b/src/security/audit-config-basics.test.ts @@ -93,7 +93,7 @@ describe("security audit config basics", () => { { id: "asset-agent", skills: ["asset-lifecycle-tracking"], - tools: { exec: { host: "gateway", security: "full", ask: "off" } }, + tools: { exec: { host: "gateway", mode: "full" } }, }, ], }, @@ -140,7 +140,7 @@ describe("security audit config basics", () => { { id: "asset-agent", skills: ["asset-lifecycle-tracking"], - tools: { exec: { host: "gateway", security: "full", ask: "off" } }, + tools: { exec: { host: "gateway", mode: "full" } }, }, ], }, @@ -177,7 +177,7 @@ describe("security audit config basics", () => { { id: "asset-agent", skills: ["asset-lifecycle-tracking"], - tools: { exec: { host: "gateway", security: "full", ask: "off" } }, + tools: { exec: { host: "gateway", mode: "full" } }, }, ], }, @@ -214,7 +214,7 @@ describe("security audit config basics", () => { { id: "asset-agent", skills: ["asset-lifecycle-tracking"], - tools: { exec: { host: "gateway", security: "full", ask: "off" } }, + tools: { exec: { host: "gateway", mode: "full" } }, }, ], }, @@ -277,7 +277,7 @@ describe("security audit config basics", () => { { id: "asset-agent", skills: ["asset-lifecycle-tracking"], - tools: { exec: { host: "gateway", security: "full", ask: "off" } }, + tools: { exec: { host: "gateway", mode: "full" } }, }, ], }, @@ -315,7 +315,7 @@ describe("security audit config basics", () => { { id: "asset-agent", skills: ["asset-lifecycle-tracking"], - tools: { exec: { host: "gateway", security: "full", ask: "off" } }, + tools: { exec: { host: "gateway", mode: "full" } }, }, ], }, @@ -360,7 +360,7 @@ describe("security audit config basics", () => { { id: "asset-agent", skills: ["asset-lifecycle-tracking"], - tools: { exec: { host: "gateway", security: "full", ask: "off" } }, + tools: { exec: { host: "gateway", mode: "full" } }, }, ], }, @@ -401,7 +401,7 @@ describe("security audit config basics", () => { { id: "asset-agent", skills: ["asset-lifecycle-tracking"], - tools: { exec: { host: "gateway", security: "full", ask: "off" } }, + tools: { exec: { host: "gateway", mode: "full" } }, }, ], }, @@ -433,9 +433,9 @@ describe("security audit config basics", () => { }, agents: { defaults: { skills: ["docs-search"] }, - list: [{ id: "docs-agent", tools: { exec: { security: "deny" } } }], + list: [{ id: "docs-agent", tools: { exec: { mode: "deny" } } }], }, - tools: { exec: { security: "deny" } }, + tools: { exec: { mode: "deny" } }, }, sourceConfig: {}, env: { OPENCLAW_STATE_DIR: stateDir }, @@ -496,9 +496,7 @@ describe("security audit config basics", () => { it("keeps unrelated dangerous flags active when one dangerous flag is suppressed", async () => { const report = await runSecurityAudit({ config: { - gateway: { - controlUi: { allowInsecureAuth: true }, - }, + hooks: { gmail: { allowUnsafeExternalContent: true } }, tools: { exec: { applyPatch: { workspaceOnly: false }, @@ -509,7 +507,7 @@ describe("security audit config basics", () => { suppressions: [ { checkId: "config.insecure_or_dangerous_flags", - detailIncludes: "gateway.controlUi.allowInsecureAuth=true", + detailIncludes: "hooks.gmail.allowUnsafeExternalContent=true", reason: "accepted local-only browser auth testing", }, ], @@ -525,7 +523,7 @@ describe("security audit config basics", () => { expect(report.suppressedFindings).toEqual([ expect.objectContaining({ checkId: "config.insecure_or_dangerous_flags", - detail: expect.stringContaining("gateway.controlUi.allowInsecureAuth=true"), + detail: expect.stringContaining("hooks.gmail.allowUnsafeExternalContent=true"), }), ]); expect(report.findings).toEqual( @@ -548,11 +546,7 @@ describe("security audit config basics", () => { let report: Awaited>; try { report = await runSecurityAudit({ - config: { - logging: { - redactSensitive: "off", - }, - }, + config: {}, sourceConfig: {}, env: {}, includeFilesystem: false, diff --git a/src/security/audit-exec-surface.test.ts b/src/security/audit-exec-surface.test.ts index c2074069c8cb..9892ad40c222 100644 --- a/src/security/audit-exec-surface.test.ts +++ b/src/security/audit-exec-surface.test.ts @@ -112,7 +112,7 @@ describe("security audit exec surface findings", () => { expect.objectContaining({ severity: "warn", detail: expect.stringContaining("args=default"), - remediation: expect.stringContaining("tools.exec.security"), + remediation: expect.stringContaining("tools.exec.mode"), }), ); expect(finding?.detail).toContain("resumeArgs=acceptEdits"); @@ -163,7 +163,7 @@ describe("security audit exec surface findings", () => { it("does not warn for restrictive Claude permission mode when OpenClaw exec is restrictive", async () => { const findings = await collectSecurityAuditFindings({ - tools: { exec: { security: "allowlist", ask: "on-miss" } }, + tools: { exec: { mode: "ask" } }, agents: { defaults: { cliBackends: { @@ -282,7 +282,7 @@ describe("security audit exec surface findings", () => { }, tools: { exec: { - security: "allowlist", + mode: "allowlist", host: "gateway", }, }, @@ -300,7 +300,7 @@ describe("security audit exec surface findings", () => { }, tools: { exec: { - security: "full", + mode: "full", }, }, } satisfies OpenClawConfig); diff --git a/src/security/audit-extra.sync.ts b/src/security/audit-extra.sync.ts index f409c13916cf..b1d202feeca4 100644 --- a/src/security/audit-extra.sync.ts +++ b/src/security/audit-extra.sync.ts @@ -217,7 +217,7 @@ function listKnownNodeCommands(cfg: OpenClawConfig): Set { ...cfg.gateway, nodes: { ...cfg.gateway?.nodes, - denyCommands: [], + commands: { ...cfg.gateway?.nodes?.commands, deny: [] }, }, }, }; @@ -959,7 +959,7 @@ export function collectSandboxDangerousConfigFindings(cfg: OpenClawConfig): Secu export function collectNodeDenyCommandPatternFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; - const denyListRaw = cfg.gateway?.nodes?.denyCommands; + const denyListRaw = cfg.gateway?.nodes?.commands?.deny; if (!Array.isArray(denyListRaw) || denyListRaw.length === 0) { return findings; } @@ -995,20 +995,22 @@ export function collectNodeDenyCommandPatternFindings(cfg: OpenClawConfig): Secu }) .join(", "); - detailParts.push(`Unknown command names (not in defaults/allowCommands): ${unknownDetails}`); + detailParts.push( + `Unknown command names (not in defaults/gateway.nodes.commands.allow): ${unknownDetails}`, + ); } const examples = Array.from(knownCommands).slice(0, 8); findings.push({ checkId: "gateway.nodes.deny_commands_ineffective", severity: "warn", - title: "Some gateway.nodes.denyCommands entries are ineffective", + title: "Some gateway.nodes.commands.deny entries are ineffective", detail: - "gateway.nodes.denyCommands uses exact node command-name matching only (for example `system.run`), not shell-text filtering inside a command payload.\n" + + "gateway.nodes.commands.deny uses exact node command-name matching only (for example `system.run`), not shell-text filtering inside a command payload.\n" + detailParts.map((entry) => `- ${entry}`).join("\n"), remediation: `Use exact command names (for example: ${examples.join(", ")}). ` + - "If you need broader restrictions, remove risky command IDs from allowCommands/default workflows and tighten tools.exec policy.", + "If you need broader restrictions, remove risky command IDs from gateway.nodes.commands.allow/default workflows and tighten tools.exec policy.", }); return findings; @@ -1018,7 +1020,7 @@ export function collectNodeDangerousAllowCommandFindings( cfg: OpenClawConfig, ): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; - const allowRaw = cfg.gateway?.nodes?.allowCommands; + const allowRaw = cfg.gateway?.nodes?.commands?.allow; if (!Array.isArray(allowRaw) || allowRaw.length === 0) { return findings; } @@ -1028,7 +1030,7 @@ export function collectNodeDangerousAllowCommandFindings( return findings; } - const deny = new Set((cfg.gateway?.nodes?.denyCommands ?? []).map(normalizeNodeCommand)); + const deny = new Set((cfg.gateway?.nodes?.commands?.deny ?? []).map(normalizeNodeCommand)); const dangerousAllowed = [ ...DEFAULT_DANGEROUS_NODE_COMMANDS, ...listDangerousPluginNodeCommands(), @@ -1042,10 +1044,10 @@ export function collectNodeDangerousAllowCommandFindings( severity: isGatewayRemotelyExposed(cfg) ? "critical" : "warn", title: "Dangerous node commands explicitly enabled", detail: - `gateway.nodes.allowCommands includes: ${dangerousAllowed.join(", ")}. ` + + `gateway.nodes.commands.allow includes: ${dangerousAllowed.join(", ")}. ` + "These commands can trigger high-impact device actions or read sensitive data (desktop input/camera/screen/contacts/calendar/reminders/health/SMS/file).", remediation: - "Remove these entries from gateway.nodes.allowCommands (recommended). " + + "Remove these entries from gateway.nodes.commands.allow (recommended). " + "If you keep them, treat gateway auth as full operator access and keep gateway exposure local/tailnet-only.", }); diff --git a/src/security/audit-gateway-config.ts b/src/security/audit-gateway-config.ts index b526a027d0f6..4e97747c19a8 100644 --- a/src/security/audit-gateway-config.ts +++ b/src/security/audit-gateway-config.ts @@ -257,28 +257,6 @@ export function collectGatewayConfigFindings( }); } - if (cfg.gateway?.controlUi?.allowInsecureAuth === true) { - findings.push({ - checkId: "gateway.control_ui.insecure_auth", - severity: "warn", - title: "Control UI insecure auth toggle enabled", - detail: - "gateway.controlUi.allowInsecureAuth=true does not bypass secure context or device identity checks; only dangerouslyDisableDeviceAuth disables Control UI device identity checks.", - remediation: "Disable it or switch to HTTPS (Tailscale Serve) or localhost.", - }); - } - - if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) { - findings.push({ - checkId: "gateway.control_ui.device_auth_disabled", - severity: "critical", - title: "DANGEROUS: Control UI device auth disabled", - detail: - "gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.", - remediation: "Disable it unless you are in a short-lived break-glass scenario.", - }); - } - if (cfg.mcp?.apps?.enabled === true) { findings.push({ checkId: "mcp.apps.enabled", diff --git a/src/security/audit-gateway-exposure.test.ts b/src/security/audit-gateway-exposure.test.ts index 06aec114e6c3..23e45fabd456 100644 --- a/src/security/audit-gateway-exposure.test.ts +++ b/src/security/audit-gateway-exposure.test.ts @@ -33,7 +33,6 @@ function requireFinding( if (!finding) { throw new Error(`Expected ${checkId} finding for ${label}`); } - expect(finding.checkId, label).toBe(checkId); return finding; } @@ -49,32 +48,6 @@ describe("security audit gateway exposure findings", () => { it("warns on insecure or dangerous flags", () => { const cases = [ - { - name: "control UI allows insecure auth", - cfg: { - gateway: { - controlUi: { allowInsecureAuth: true }, - }, - } satisfies OpenClawConfig, - expectedFinding: { - checkId: "gateway.control_ui.insecure_auth", - severity: "warn", - }, - expectedDangerousDetails: ["gateway.controlUi.allowInsecureAuth=true"], - }, - { - name: "control UI device auth is disabled", - cfg: { - gateway: { - controlUi: { dangerouslyDisableDeviceAuth: true }, - }, - } satisfies OpenClawConfig, - expectedFinding: { - checkId: "gateway.control_ui.device_auth_disabled", - severity: "critical", - }, - expectedDangerousDetails: ["gateway.controlUi.dangerouslyDisableDeviceAuth=true"], - }, { name: "generic insecure debug flags", cfg: { @@ -100,14 +73,6 @@ describe("security audit gateway exposure findings", () => { for (const testCase of cases) { const findings = collectGatewayConfigFindings(testCase.cfg, testCase.cfg, {}); - if ("expectedFinding" in testCase) { - const exposureFinding = requireFinding( - findings, - testCase.expectedFinding.checkId, - testCase.name, - ); - expect(exposureFinding.severity, testCase.name).toBe(testCase.expectedFinding.severity); - } const dangerousFindings = requireDangerousFlagsFindings(findings, testCase.name); expect(dangerousFindings.every((finding) => finding.severity === "warn")).toBe(true); for (const snippet of testCase.expectedDangerousDetails) { diff --git a/src/security/audit-loopback-logging.test.ts b/src/security/audit-loopback-logging.test.ts index 73c0447380c1..ee4c5151aaa6 100644 --- a/src/security/audit-loopback-logging.test.ts +++ b/src/security/audit-loopback-logging.test.ts @@ -62,12 +62,10 @@ describe("security audit loopback and logging findings", () => { }, ), (async () => { - const cfg: OpenClawConfig = { - logging: { redactSensitive: "off" }, - }; + const cfg: OpenClawConfig = {}; expect( hasLoggingFinding("logging.redact_off", "warn", await collectSecurityAuditFindings(cfg)), - ).toBe(true); + ).toBe(false); })(), ]); }); diff --git a/src/security/audit-node-command-findings.test.ts b/src/security/audit-node-command-findings.test.ts index a20f206de809..976eb093f0be 100644 --- a/src/security/audit-node-command-findings.test.ts +++ b/src/security/audit-node-command-findings.test.ts @@ -35,36 +35,36 @@ function requireFinding( } describe("security audit node command findings", () => { - it("evaluates ineffective gateway.nodes.denyCommands entries", () => { + it("evaluates ineffective gateway.nodes.commands.deny entries", () => { const cases = [ { - name: "flags ineffective gateway.nodes.denyCommands entries", + name: "flags ineffective gateway.nodes.commands.deny entries", cfg: { gateway: { nodes: { - denyCommands: ["system.*", "system.runx"], + commands: { deny: ["system.*", "system.runx"] }, }, }, } satisfies OpenClawConfig, detailIncludes: ["system.*", "system.runx", "did you mean", "system.run"], }, { - name: "suggests prefix-matching commands for unknown denyCommands entries", + name: "suggests prefix-matching commands for unknown deny entries", cfg: { gateway: { nodes: { - denyCommands: ["system.run.prep"], + commands: { deny: ["system.run.prep"] }, }, }, } satisfies OpenClawConfig, detailIncludes: ["system.run.prep", "did you mean", "system.run.prepare"], }, { - name: "keeps unknown denyCommands entries without suggestions when no close command exists", + name: "keeps unknown deny entries without suggestions when no close command exists", cfg: { gateway: { nodes: { - denyCommands: ["zzzzzzzzzzzzzz"], + commands: { deny: ["zzzzzzzzzzzzzz"] }, }, }, } satisfies OpenClawConfig, @@ -72,11 +72,13 @@ describe("security audit node command findings", () => { detailExcludes: ["did you mean"], }, { - name: "keeps valid dangerous denyCommands entries out of unknown warnings", + name: "keeps valid dangerous deny entries out of unknown warnings", cfg: { gateway: { nodes: { - denyCommands: ["camera.snap", "screen.record", "camera.snapp", "system.*"], + commands: { + deny: ["camera.snap", "screen.record", "camera.snapp", "system.*"], + }, }, }, } satisfies OpenClawConfig, @@ -102,11 +104,13 @@ describe("security audit node command findings", () => { } }); - it("does not flag valid dangerous gateway.nodes.denyCommands entries as ineffective", () => { + it("does not flag valid dangerous gateway.nodes.commands.deny entries as ineffective", () => { const findings = collectNodeDenyCommandPatternFindings({ gateway: { nodes: { - denyCommands: ["camera.snap", "camera.clip", "screen.record", "sms.send", "system.run"], + commands: { + deny: ["camera.snap", "camera.clip", "screen.record", "sms.send", "system.run"], + }, }, }, } satisfies OpenClawConfig); @@ -114,7 +118,7 @@ describe("security audit node command findings", () => { expect(findings).toStrictEqual([]); }); - it("evaluates dangerous gateway.nodes.allowCommands findings", () => { + it("evaluates dangerous gateway.nodes.commands.allow findings", () => { const cases: Array<{ name: string; cfg: OpenClawConfig; @@ -126,7 +130,9 @@ describe("security audit node command findings", () => { cfg: { gateway: { bind: "loopback", - nodes: { allowCommands: ["camera.snap", "screen.record", "health.summary"] }, + nodes: { + commands: { allow: ["camera.snap", "screen.record", "health.summary"] }, + }, }, } satisfies OpenClawConfig, expectedSeverity: "warn" as const, @@ -136,18 +142,22 @@ describe("security audit node command findings", () => { cfg: { gateway: { bind: "lan", - nodes: { allowCommands: ["camera.snap", "screen.record", "health.summary"] }, + nodes: { + commands: { allow: ["camera.snap", "screen.record", "health.summary"] }, + }, }, } satisfies OpenClawConfig, expectedSeverity: "critical" as const, }, { - name: "denied again suppresses dangerous allowCommands finding", + name: "denied again suppresses dangerous allow finding", cfg: { gateway: { nodes: { - allowCommands: ["camera.snap", "screen.record"], - denyCommands: ["camera.snap", "screen.record"], + commands: { + allow: ["camera.snap", "screen.record"], + deny: ["camera.snap", "screen.record"], + }, }, }, } satisfies OpenClawConfig, diff --git a/src/security/audit-plugins-trust.test.ts b/src/security/audit-plugins-trust.test.ts index 79aa6580ddb9..bb634aa39574 100644 --- a/src/security/audit-plugins-trust.test.ts +++ b/src/security/audit-plugins-trust.test.ts @@ -4,9 +4,11 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import type { HookInstallRecord } from "../config/types.hooks.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { writePersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js"; import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js"; +import { writeConfigMachineState } from "../state/config-machine-state.js"; import { captureEnv, createPathResolutionEnv, @@ -173,6 +175,15 @@ describe("security audit install metadata findings", () => { return await collectPluginsTrustFindingsForTest({ cfg, stateDir }); }; + const writeHookInstalls = ( + stateDir: string, + installs: Record, + ): void => { + writeConfigMachineState("hooks.internal.installs", installs, { + env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, + }); + }; + const requireInstallFinding = ( findings: Awaited>, checkId: string, @@ -243,21 +254,10 @@ describe("security audit install metadata findings", () => { spec: "@openclaw/voice-call", }, }); - return runInstallMetadataAudit( - { - hooks: { - internal: { - installs: { - "test-hooks": { - source: "npm", - spec: "@openclaw/test-hooks", - }, - }, - }, - }, - }, - stateDir, - ); + writeHookInstalls(stateDir, { + "test-hooks": { source: "npm", spec: "@openclaw/test-hooks" }, + }); + return runInstallMetadataAudit({}, stateDir); }, expectedPresent: [ "plugins.installs_unpinned_npm_specs", @@ -277,22 +277,14 @@ describe("security audit install metadata findings", () => { integrity: "sha512-plugin", }, }); - return runInstallMetadataAudit( - { - hooks: { - internal: { - installs: { - "test-hooks": { - source: "npm", - spec: "@openclaw/test-hooks@1.2.3", - integrity: "sha512-hook", - }, - }, - }, - }, + writeHookInstalls(stateDir, { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks@1.2.3", + integrity: "sha512-hook", }, - stateDir, - ); + }); + return runInstallMetadataAudit({}, stateDir); }, expectedAbsent: [ "plugins.installs_unpinned_npm_specs", @@ -313,23 +305,15 @@ describe("security audit install metadata findings", () => { integrity: "sha512-plugin", }, }); - return runInstallMetadataAudit( - { - hooks: { - internal: { - installs: { - "test-hooks": { - source: "npm", - spec: "@openclaw/test-hooks", - resolvedSpec: "@openclaw/test-hooks@1.2.3", - integrity: "sha512-hook", - }, - }, - }, - }, + writeHookInstalls(stateDir, { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks", + resolvedSpec: "@openclaw/test-hooks@1.2.3", + integrity: "sha512-hook", }, - stateDir, - ); + }); + return runInstallMetadataAudit({}, stateDir); }, expectedPresent: [ "plugins.installs_unpinned_npm_specs", @@ -349,23 +333,15 @@ describe("security audit install metadata findings", () => { resolvedVersion: "1.2.3", }, }); - return runInstallMetadataAudit( - { - hooks: { - internal: { - installs: { - "test-hooks": { - source: "npm", - spec: "@openclaw/test-hooks@1.2.3", - integrity: "sha512-hook", - resolvedVersion: "1.2.3", - }, - }, - }, - }, + writeHookInstalls(stateDir, { + "test-hooks": { + source: "npm", + spec: "@openclaw/test-hooks@1.2.3", + integrity: "sha512-hook", + resolvedVersion: "1.2.3", }, - stateDir, - ); + }); + return runInstallMetadataAudit({}, stateDir); }, expectedPresent: ["plugins.installs_version_drift", "hooks.installs_version_drift"], }, diff --git a/src/security/audit-plugins-trust.ts b/src/security/audit-plugins-trust.ts index 342c6f3df72b..ef4f6f283fef 100644 --- a/src/security/audit-plugins-trust.ts +++ b/src/security/audit-plugins-trust.ts @@ -7,6 +7,7 @@ import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-ins import { resolveNativeSkillsEnabled } from "../config/commands.js"; import type { OpenClawConfig } from "../config/config.js"; import type { AgentToolsConfig } from "../config/types.tools.js"; +import { readHookInstalls } from "../hooks/installs.js"; import { readInstalledPackageVersion } from "../infra/package-update-utils.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { loadInstalledPluginIndexInstallRecords } from "../plugins/installed-plugin-index-record-reader.js"; @@ -487,7 +488,9 @@ export async function collectPluginsTrustFindings(params: { } } - const hookInstalls = params.cfg.hooks?.internal?.installs ?? {}; + const hookInstalls = readHookInstalls({ + env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir }, + }); const npmHookInstalls = Object.entries(hookInstalls).filter( ([, record]) => record?.source === "npm", ); diff --git a/src/security/audit.ts b/src/security/audit.ts index f8af92610700..80409a16eb69 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -27,6 +27,7 @@ import { loadExecApprovals, maxAsk, minSecurity, + resolveExecModePolicy, resolveExecApprovalsFromFile, } from "../infra/exec-approvals.js"; import { @@ -573,22 +574,6 @@ async function collectPluginSecurityAuditFindings( return collectorResults.flat(); } -function collectLoggingFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { - const redact = cfg.logging?.redactSensitive; - if (redact !== "off") { - return []; - } - return [ - { - checkId: "logging.redact_off", - severity: "warn", - title: "Tool summary redaction is disabled", - detail: `logging.redactSensitive="off" can leak secrets into logs and status output.`, - remediation: `Set logging.redactSensitive="tools".`, - }, - ]; -} - function collectElevatedFindings(cfg: OpenClawConfig): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const enabled = cfg.tools?.elevated?.enabled; @@ -796,7 +781,11 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] [ { id: DEFAULT_AGENT_ID, - security: cfg.tools?.exec?.security ?? "deny", + security: resolveExecModePolicy({ + mode: cfg.tools?.exec?.mode, + security: cfg.tools?.exec?.security ?? "deny", + ask: cfg.tools?.exec?.ask ?? "off", + }).security, host: cfg.tools?.exec?.host ?? "auto", }, ...agents @@ -804,11 +793,22 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] (entry): entry is NonNullable<(typeof agents)[number]> => Boolean(entry) && typeof entry === "object" && typeof entry.id === "string", ) - .map((entry) => ({ - id: entry.id, - security: entry.tools?.exec?.security ?? cfg.tools?.exec?.security ?? "deny", - host: entry.tools?.exec?.host ?? cfg.tools?.exec?.host ?? "auto", - })), + .map((entry) => { + const inherited = resolveExecModePolicy({ + mode: cfg.tools?.exec?.mode, + security: cfg.tools?.exec?.security ?? "deny", + ask: cfg.tools?.exec?.ask ?? "off", + }); + return { + id: entry.id, + security: resolveExecModePolicy({ + mode: entry.tools?.exec?.mode, + security: entry.tools?.exec?.security ?? inherited.security, + ask: entry.tools?.exec?.ask ?? inherited.ask, + }).security, + host: entry.tools?.exec?.host ?? cfg.tools?.exec?.host ?? "auto", + }; + }), ].map((entry) => [entry.id, entry] as const), ).values(), ); @@ -827,7 +827,7 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] ? ` Open channel access was also detected at:\n${openExecSurfacePaths.map((entry) => `- ${entry}`).join("\n")}` : ""), remediation: - 'Prefer tools.exec.security="allowlist" with ask prompts, and reserve "full" for tightly scoped break-glass agents only.', + 'Prefer tools.exec.mode="ask" or "allowlist", and reserve "full" for tightly scoped break-glass agents only.', }); } @@ -838,7 +838,7 @@ function collectExecRuntimeFindings(cfg: OpenClawConfig): SecurityAuditFinding[] title: "Claude permission mode is ignored under YOLO exec", detail: `claude-cli sets ${claudePermissionModeHits.map((hit) => `${hit.argSet}=${hit.mode}`).join(", ")}, but OpenClaw exec is YOLO for: ${yoloExecScopeIds.join(", ")}. Managed Claude live sessions use --permission-mode bypassPermissions.`, remediation: - "Restrict OpenClaw tools.exec.security/tools.exec.ask, or remove the Claude --permission-mode override.", + "Restrict OpenClaw tools.exec.mode, or remove the Claude --permission-mode override.", }); } @@ -1417,7 +1417,6 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise { signal: { groupPolicy: "open" }, imessage: { groupPolicy: "open" }, }, - logging: { redactSensitive: "off" }, } satisfies OpenClawConfig; const fixed = await runConfigFixScenario({ prefix: "group-policy", @@ -194,7 +193,6 @@ describe("security fix", () => { channelPlugins: [createWhatsAppConfigFixTestPlugin(["+15551234567"])], }); expect(fixed.res.changes).toEqual([ - 'logging.redactSensitive=off -> "tools"', "channels.telegram.groupPolicy=open -> allowlist", "channels.whatsapp.groupPolicy=open -> allowlist", "channels.discord.groupPolicy=open -> allowlist", diff --git a/src/security/fix.ts b/src/security/fix.ts index 2848c56ba60f..fb1ed649667b 100644 --- a/src/security/fix.ts +++ b/src/security/fix.ts @@ -240,11 +240,6 @@ function applyConfigFixes(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv const next = structuredClone(params.cfg ?? {}); const changes: string[] = []; - if (next.logging?.redactSensitive === "off") { - next.logging = { ...next.logging, redactSensitive: "tools" }; - changes.push('logging.redactSensitive=off -> "tools"'); - } - for (const channel of Object.keys(next.channels ?? {})) { setGroupPolicyAllowlist({ cfg: next, channel, changes }); } diff --git a/src/security/install-policy.test.ts b/src/security/install-policy.test.ts index 2a5feb7d19ed..e06056d68e39 100644 --- a/src/security/install-policy.test.ts +++ b/src/security/install-policy.test.ts @@ -26,7 +26,7 @@ async function writePolicyScript(dir: string): Promise { const scriptPath = path.join(dir, "policy.cjs"); await fs.writeFile( scriptPath, - ` + `#!${process.execPath} const fs = require("node:fs"); let input = ""; @@ -101,10 +101,9 @@ function configWithPolicy(scriptPath: string, env: Record): Open enabled: true, exec: { source: "exec", - command: process.execPath, - args: [scriptPath], + command: scriptPath, env, - allowInsecurePath: true, + trustedDirs: [path.dirname(scriptPath)], timeoutMs: 5000, maxOutputBytes: 16 * 1024, }, @@ -173,7 +172,9 @@ describe("runInstallPolicy", () => { mutable: false, network: true, }); - await expect(fs.readFile(cwdPath, "utf8")).resolves.toBe(path.dirname(process.execPath)); + await expect(fs.readFile(cwdPath, "utf8")).resolves.toBe( + await fs.realpath(path.dirname(scriptPath)), + ); expect(captured.request).toMatchObject({ kind: "skill-install", mode: "install", @@ -201,7 +202,7 @@ describe("runInstallPolicy", () => { POLICY_RESPONSE: response, }, passEnv: ["PATH"], - allowInsecurePath: true, + trustedDirs: [path.dirname(envNodeScriptPath)], }, }, }, @@ -243,7 +244,7 @@ describe("runInstallPolicy", () => { source: "exec", command: forkScriptPath, env: { NODE_BINARY: process.execPath, PID_FILE: pidPath }, - allowInsecurePath: true, + trustedDirs: [path.dirname(forkScriptPath)], // Preserve production-like startup headroom; the test fires // the re-armed timer only after the readiness byte arrives. noOutputTimeoutMs: 1_000, @@ -310,7 +311,7 @@ describe("runInstallPolicy", () => { env: { EXIT_CODE: "1", }, - allowInsecurePath: true, + trustedDirs: [path.dirname(scriptPath)], }, }, }, @@ -469,7 +470,6 @@ describe("runInstallPolicy", () => { source: "exec", command: "policy.cjs", args: [], - allowInsecurePath: true, }, }, }, @@ -495,7 +495,6 @@ describe("runInstallPolicy", () => { source: "exec", command: "C:\\tmp\\policy.cjs", args: [], - allowInsecurePath: true, }, }, }, @@ -659,7 +658,6 @@ describe("runInstallPolicy", () => { source: "exec", command: process.execPath, args: [symlinkScriptPath], - allowSymlinkCommand: true, }, }, }, @@ -682,7 +680,6 @@ describe("runInstallPolicy", () => { source: "exec", command: "/usr/bin/env", args: ["-S", `node ${scriptPath}`], - allowInsecurePath: true, }, }, }, diff --git a/src/security/install-policy.ts b/src/security/install-policy.ts index 2f21ca4bb47c..1e3319607d13 100644 --- a/src/security/install-policy.ts +++ b/src/security/install-policy.ts @@ -476,8 +476,7 @@ export async function validateInstallPolicyStatic( targetPath: policy.exec.command, label: "security.installPolicy.exec.command", trustedDirs: policy.exec.trustedDirs, - allowInsecurePath: policy.exec.allowInsecurePath, - allowSymlinkPath: policy.exec.allowSymlinkCommand, + allowSymlinkPath: false, }); } catch (err) { issues.push({ @@ -490,8 +489,7 @@ export async function validateInstallPolicyStatic( command: policy.exec.command, args: policy.exec.args ?? [], trustedDirs: policy.exec.trustedDirs, - allowInsecurePath: policy.exec.allowInsecurePath, - allowSymlinkPath: policy.exec.allowSymlinkCommand, + allowSymlinkPath: false, }); } catch (err) { issues.push({ @@ -627,8 +625,7 @@ export async function runInstallPolicy(params: { targetPath: commandPath, label: "security.installPolicy.exec.command", trustedDirs: policy.exec.trustedDirs, - allowInsecurePath: policy.exec.allowInsecurePath, - allowSymlinkPath: policy.exec.allowSymlinkCommand, + allowSymlinkPath: false, }); } catch (err) { return failClosed(formatErrorMessage(err)); @@ -638,8 +635,7 @@ export async function runInstallPolicy(params: { command: secureCommandPath, args: policy.exec.args ?? [], trustedDirs: policy.exec.trustedDirs, - allowInsecurePath: policy.exec.allowInsecurePath, - allowSymlinkPath: policy.exec.allowSymlinkCommand, + allowSymlinkPath: false, }); } catch (err) { return failClosed(formatErrorMessage(err)); diff --git a/src/skills/lifecycle/source-install.test.ts b/src/skills/lifecycle/source-install.test.ts index 112e856d68f3..c83befaa6a67 100644 --- a/src/skills/lifecycle/source-install.test.ts +++ b/src/skills/lifecycle/source-install.test.ts @@ -57,10 +57,12 @@ async function runGitOk(repoDir: string, args: string[]) { } async function writeCapturePolicyScript(root: string) { + await fs.chmod(root, 0o700); const scriptPath = path.join(root, "capture-policy.cjs"); await fs.writeFile( scriptPath, [ + `#!${process.execPath}`, "const fs = require('node:fs');", "let input = '';", "process.stdin.on('data', (chunk) => { input += chunk; });", @@ -82,11 +84,9 @@ function capturePolicyConfig(params: { scriptPath: string; capturePath: string } enabled: true, exec: { source: "exec" as const, - command: process.execPath, - args: [params.scriptPath], + command: params.scriptPath, env: { CAPTURE_PATH: params.capturePath }, - allowInsecurePath: true, - allowSymlinkCommand: true, + trustedDirs: [path.dirname(params.scriptPath)], }, }, }, diff --git a/src/state/config-machine-state.ts b/src/state/config-machine-state.ts new file mode 100644 index 000000000000..a10d7d7c816e --- /dev/null +++ b/src/state/config-machine-state.ts @@ -0,0 +1,167 @@ +// Machine-owned values retired from openclaw.json live in the shared state database. +import { existsSync } from "node:fs"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "../infra/kysely-sync.js"; +import { withOpenClawStateDatabaseReadOnly } from "./openclaw-state-db-readonly.js"; +import { tableExists } from "./openclaw-state-db-schema-helpers.js"; +import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; +import { + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "./openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths.js"; + +type ConfigMachineStateDatabase = Pick; + +function normalizeStateKey(key: string): string { + const normalized = key.trim(); + if (!normalized) { + throw new Error("config machine state key must not be empty"); + } + return normalized; +} + +function serializeStateValue(value: unknown): string { + const serialized = JSON.stringify(value); + if (serialized === undefined) { + throw new Error("config machine state value must be JSON-serializable"); + } + return serialized; +} + +// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Callers own the JSON shape for open-ended state keys. +export function readConfigMachineState( + key: string, + options: OpenClawStateDatabaseOptions = {}, +): T | undefined { + const pathname = options.path ?? resolveOpenClawStateSqlitePath(options.env ?? process.env); + if (!existsSync(pathname)) { + return undefined; + } + return withOpenClawStateDatabaseReadOnly(({ db: database }) => { + if (!tableExists(database, "config_machine_state")) { + return undefined; + } + const db = getNodeSqliteKysely(database); + const row = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("config_machine_state") + .select("value_json") + .where("state_key", "=", normalizeStateKey(key)), + ); + return row ? (JSON.parse(row.value_json) as T) : undefined; + }, options); +} + +export function writeConfigMachineState( + key: string, + value: unknown, + options: OpenClawStateDatabaseOptions = {}, +): void { + const stateKey = normalizeStateKey(key); + const valueJson = serializeStateValue(value); + const now = Date.now(); + runOpenClawStateWriteTransaction( + (database) => { + const db = getNodeSqliteKysely(database.db); + executeSqliteQuerySync( + database.db, + db + .insertInto("config_machine_state") + .values({ state_key: stateKey, value_json: valueJson, updated_at_ms: now }) + .onConflict((conflict) => + conflict.column("state_key").doUpdateSet({ value_json: valueJson, updated_at_ms: now }), + ), + ); + }, + options, + { operationLabel: "config-machine-state.write" }, + ); +} + +/** Atomically update one machine-state value from its current database value. */ +export function updateConfigMachineState( + key: string, + update: (current: T | undefined) => T, + options: OpenClawStateDatabaseOptions = {}, +): T { + const stateKey = normalizeStateKey(key); + const now = Date.now(); + return runOpenClawStateWriteTransaction( + (database) => { + const db = getNodeSqliteKysely(database.db); + const row = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("config_machine_state") + .select("value_json") + .where("state_key", "=", stateKey), + ); + const value = update(row ? (JSON.parse(row.value_json) as T) : undefined); + const valueJson = serializeStateValue(value); + executeSqliteQuerySync( + database.db, + db + .insertInto("config_machine_state") + .values({ state_key: stateKey, value_json: valueJson, updated_at_ms: now }) + .onConflict((conflict) => + conflict.column("state_key").doUpdateSet({ value_json: valueJson, updated_at_ms: now }), + ), + ); + return value; + }, + options, + { operationLabel: "config-machine-state.update" }, + ); +} + +/** Import retired config values without replacing newer canonical database state. */ +export function importConfigMachineState( + entries: ReadonlyArray, + options: OpenClawStateDatabaseOptions = {}, +): { imported: string[]; kept: string[] } { + if (entries.length === 0) { + return { imported: [], kept: [] }; + } + const normalized = entries.map(([key, value]) => ({ + key: normalizeStateKey(key), + valueJson: serializeStateValue(value), + })); + const now = Date.now(); + return runOpenClawStateWriteTransaction( + (database) => { + const db = getNodeSqliteKysely(database.db); + const imported: string[] = []; + const kept: string[] = []; + for (const entry of normalized) { + const existing = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("config_machine_state") + .select("state_key") + .where("state_key", "=", entry.key), + ); + if (existing) { + kept.push(entry.key); + continue; + } + executeSqliteQuerySync( + database.db, + db.insertInto("config_machine_state").values({ + state_key: entry.key, + value_json: entry.valueJson, + updated_at_ms: now, + }), + ); + imported.push(entry.key); + } + return { imported, kept }; + }, + options, + { operationLabel: "config-machine-state.import" }, + ); +} diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 2bae6868bbc6..d9db8e38903b 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -375,6 +375,12 @@ export interface ConfigHealthEntries { updated_at_ms: number; } +export interface ConfigMachineState { + state_key: string; + updated_at_ms: number; + value_json: string; +} + export interface CronJobs { agent_id: string | null; anchor_ms: number | null; @@ -1414,6 +1420,7 @@ export interface DB { command_log_entries: CommandLogEntries; commitments: Commitments; config_health_entries: ConfigHealthEntries; + config_machine_state: ConfigMachineState; cron_jobs: CronJobs; current_conversation_bindings: CurrentConversationBindings; delivery_queue_entries: DeliveryQueueEntries; diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index 60fe5d23e304..a0a9ed6d0033 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -384,6 +384,12 @@ CREATE TABLE IF NOT EXISTS schema_meta ( updated_at INTEGER NOT NULL ) STRICT; +CREATE TABLE IF NOT EXISTS config_machine_state ( + state_key TEXT NOT NULL PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL +) STRICT; + CREATE TABLE IF NOT EXISTS device_pairing_pending ( request_id TEXT NOT NULL PRIMARY KEY, device_id TEXT NOT NULL, diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 631ddd252846..aac2ff336483 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -379,6 +379,12 @@ CREATE TABLE IF NOT EXISTS schema_meta ( updated_at INTEGER NOT NULL ) STRICT; +CREATE TABLE IF NOT EXISTS config_machine_state ( + state_key TEXT NOT NULL PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL +) STRICT; + CREATE TABLE IF NOT EXISTS device_pairing_pending ( request_id TEXT NOT NULL PRIMARY KEY, device_id TEXT NOT NULL, diff --git a/src/system-agent/agent-turn.test.ts b/src/system-agent/agent-turn.test.ts index 008eab1f5cd7..a29aa46f6245 100644 --- a/src/system-agent/agent-turn.test.ts +++ b/src/system-agent/agent-turn.test.ts @@ -754,9 +754,9 @@ describe("runSystemAgentTurn", () => { it("rejects an executable-policy change and invalidates CLI continuity", async () => { useTempStateDir(); - const configForGlobalPolicy = (security: "full" | "deny", ask: "off" | "always") => + const configForGlobalPolicy = (mode: "full" | "deny") => ({ - tools: { exec: { security, ask } }, + tools: { exec: { mode } }, agents: { defaults: { cliBackends: { "claude-cli": { command: "claude" } }, @@ -768,7 +768,7 @@ describe("runSystemAgentTurn", () => { default: true, // Keep the model owner's policy stable. OpenClaw executes with // its own identity and therefore follows the changing global policy. - tools: { exec: { security: "allowlist", ask: "on-miss" } }, + tools: { exec: { mode: "ask" } }, }, ], }, @@ -784,10 +784,10 @@ describe("runSystemAgentTurn", () => { })); const readConfigFileSnapshot = vi .fn() - .mockResolvedValueOnce(configSnapshot(configForGlobalPolicy("full", "off"))) - .mockResolvedValueOnce(configSnapshot(configForGlobalPolicy("full", "off"))) - .mockResolvedValueOnce(configSnapshot(configForGlobalPolicy("deny", "always"))); - const { session, deps } = await createVerifiedSession(configForGlobalPolicy("full", "off")); + .mockResolvedValueOnce(configSnapshot(configForGlobalPolicy("full"))) + .mockResolvedValueOnce(configSnapshot(configForGlobalPolicy("full"))) + .mockResolvedValueOnce(configSnapshot(configForGlobalPolicy("deny"))); + const { session, deps } = await createVerifiedSession(configForGlobalPolicy("full")); const turn = async () => await runSystemAgentTurnWithDeps( { diff --git a/src/system-agent/inference-route.ts b/src/system-agent/inference-route.ts index 1804bb2f65e4..a15e8aacc079 100644 --- a/src/system-agent/inference-route.ts +++ b/src/system-agent/inference-route.ts @@ -1,6 +1,7 @@ // Resolves the configured default agent route shared by OpenClaw inference calls. import { isDeepStrictEqual } from "node:util"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { cliBackendAcceptsAuthProfileForwarding, resolveCliExecutionAuthProfileId, @@ -50,8 +51,8 @@ function projectSystemAgentExecutionConfig( config: OpenClawConfig, routeAgentId: string, ): OpenClawConfig { - const agents = config.agents?.list; - if (!agents) { + const agents = listAgentEntries(config); + if (agents.length === 0) { return config; } const routeAgent = @@ -65,26 +66,32 @@ function projectSystemAgentExecutionConfig( if (retainedAgents.length === agents.length && !hasProjectedSettings) { return config; } + const projectedAgents = [ + ...retainedAgents, + ...(hasProjectedSettings + ? [ + { + id: SYSTEM_AGENT_EXECUTION_AGENT_ID, + ...(routeAgent?.params !== undefined + ? { params: structuredClone(routeAgent.params) } + : {}), + ...(routeAgent?.tools !== undefined + ? { tools: structuredClone(routeAgent.tools) } + : {}), + }, + ] + : []), + ]; return { ...config, agents: { ...config.agents, - list: [ - ...retainedAgents, - ...(hasProjectedSettings - ? [ - { - id: SYSTEM_AGENT_EXECUTION_AGENT_ID, - ...(routeAgent?.params !== undefined - ? { params: structuredClone(routeAgent.params) } - : {}), - ...(routeAgent?.tools !== undefined - ? { tools: structuredClone(routeAgent.tools) } - : {}), - }, - ] - : []), - ], + ...(config.agents?.entries + ? { + entries: Object.fromEntries(projectedAgents.map(({ id, ...entry }) => [id, entry])), + } + : {}), + ...(config.agents?.list ? { list: projectedAgents } : {}), }, }; } @@ -212,9 +219,9 @@ export async function projectInferenceRoute( const defaultAgentId = resolveDefaultAgentId(config); const routeAgentId = normalizeAgentId(requestedAgentId ?? defaultAgentId); const route = await resolveSystemAgentConfiguredRouteFromConfig(config, routeAgentId); - const list = config.agents?.list ?? []; + const list = listAgentEntries(config); const agent = list.find((entry) => normalizeAgentId(entry.id) === routeAgentId); - const executionAgent = route?.runConfig.agents?.list?.find( + const executionAgent = listAgentEntries(route?.runConfig ?? {}).find( (entry) => normalizeAgentId(entry.id) === SYSTEM_AGENT_EXECUTION_AGENT_ID, ); const defaults = config.agents?.defaults; diff --git a/src/system-agent/operations.setup.test.ts b/src/system-agent/operations.setup.test.ts index aae5ace9d485..4ef5267aeab6 100644 --- a/src/system-agent/operations.setup.test.ts +++ b/src/system-agent/operations.setup.test.ts @@ -937,13 +937,12 @@ describe("parseSystemAgentOperation", () => { mockConfig.setConfig({ agents: { defaults: { model: { primary: "anthropic/global-default" } }, - list: [ - { - id: "work", + entries: { + work: { default: true, model: { primary: "anthropic/work-default" }, }, - ], + }, }, }); const { runtime } = createSystemAgentTestRuntime(); @@ -952,8 +951,8 @@ describe("parseSystemAgentOperation", () => { expect(requireRecord(agents.defaults, "defaults").model).toEqual({ primary: "anthropic/global-default", }); - const list = agents.list as Array<{ id: string; model: unknown }>; - expect(list.find((agent) => agent.id === "work")?.model).toEqual({ + const entries = agents.entries as Record; + expect(entries.work?.model).toEqual({ primary: "openai/gpt-5.5", }); return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 9 }; @@ -969,8 +968,8 @@ describe("parseSystemAgentOperation", () => { expect(requireRecord(agents.defaults, "defaults").model).toEqual({ primary: "anthropic/global-default", }); - const list = agents.list as Array<{ id: string; model: unknown }>; - expect(list.find((agent) => agent.id === "work")?.model).toEqual({ + const entries = agents.entries as Record; + expect(entries.work?.model).toEqual({ primary: "openai/gpt-5.5", }); }); diff --git a/src/system-agent/overview.ts b/src/system-agent/overview.ts index 739ef6531cfd..6be534c29e0f 100644 --- a/src/system-agent/overview.ts +++ b/src/system-agent/overview.ts @@ -17,6 +17,7 @@ import { type OpenClawConfig, } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import { isFastTestRuntimeEnv } from "../infra/env.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { probeGatewayUrl, probeLocalCommand, type LocalCommandProbe } from "./probes.js"; @@ -131,7 +132,7 @@ function buildAgentSummaries(cfg: OpenClawConfig): SystemAgentSummary[] { } function resolveFastTestReferences(env: NodeJS.ProcessEnv): OpenClawReferencePaths | undefined { - if (env.OPENCLAW_TEST_FAST !== "1") { + if (!isFastTestRuntimeEnv(env)) { return undefined; } const sourcePath = process.cwd(); diff --git a/src/system-agent/rescue-channel.live.test.ts b/src/system-agent/rescue-channel.live.test.ts index 3a022e09188e..99b657f6b598 100644 --- a/src/system-agent/rescue-channel.live.test.ts +++ b/src/system-agent/rescue-channel.live.test.ts @@ -80,7 +80,7 @@ describeLive("OpenClaw live rescue channel smoke", () => { { meta: { lastTouchedVersion: "live-test", lastTouchedAt: new Date(0).toISOString() }, agents: { defaults: {} }, - tools: { exec: { security: "full", ask: "off" } }, + tools: { exec: { mode: "full" } }, }, null, 2, @@ -88,8 +88,7 @@ describeLive("OpenClaw live rescue channel smoke", () => { ); const cfg: OpenClawConfig = { - systemAgent: { rescue: { enabled: true } }, - tools: { exec: { security: "full", ask: "off" } }, + tools: { exec: { mode: "full" } }, }; await expect(runRescue({ commandBody: "/openclaw status", cfg })).resolves.toContain( diff --git a/src/system-agent/rescue-message.test.ts b/src/system-agent/rescue-message.test.ts index c17438a06d30..a283b7108b82 100644 --- a/src/system-agent/rescue-message.test.ts +++ b/src/system-agent/rescue-message.test.ts @@ -225,14 +225,13 @@ describe("OpenClaw rescue message", () => { it("denies rescue when sandboxing is active", async () => { await expect( runRescue("/openclaw status", { - systemAgent: { rescue: { enabled: true } }, agents: { defaults: { sandbox: { mode: "all" } } }, }), ).resolves.toContain("sandboxing is active"); }); it("refuses TUI handoff from remote rescue", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runTui: vi.fn(async () => { throw new Error("remote rescue must not open the TUI"); @@ -249,7 +248,7 @@ describe("OpenClaw rescue message", () => { }); it("rejects natural language instead of guessing an operation", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runGatewayStop: vi.fn(async () => {}), runGatewayRestart: vi.fn(async () => {}), @@ -268,14 +267,14 @@ describe("OpenClaw rescue message", () => { }); it("refuses channel setup from remote rescue with a local pointer", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; await expect(runRescue("/openclaw connect telegram", cfg)).resolves.toContain( "cannot host the interactive channel setup", ); }); it("refuses model provider setup from remote rescue with a local pointer", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const reply = await runRescue("/openclaw configure model provider", cfg); expect(reply).toContain("cannot host model-provider credential setup"); expect(reply).toContain("openclaw onboard"); @@ -283,7 +282,7 @@ describe("OpenClaw rescue message", () => { it("refuses doctor repairs without creating a pending approval", async () => { await withRescueStateDir("doctor-fix-refused-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runDoctor: vi.fn(async () => { throw new Error("remote rescue must not run doctor repair"); @@ -302,7 +301,7 @@ describe("OpenClaw rescue message", () => { it("drops a pending rescue change on decline", async () => { await withRescueStateDir("decline-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runGatewayRestart: vi.fn(async () => {}) }; await expect( @@ -320,7 +319,7 @@ describe("OpenClaw rescue message", () => { it("revokes a pending write when a fresh read-only command arrives", async () => { await withRescueStateDir("read-revokes-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runGatewayRestart: vi.fn(async () => {}), runPluginsList: vi.fn(async (runtime: RuntimeEnv) => runtime.log("plugin rows")), @@ -341,7 +340,7 @@ describe("OpenClaw rescue message", () => { it("consumes a pending approval at most once under concurrent approvals", async () => { await withRescueStateDir("concurrent-approve-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runGatewayRestart: vi.fn(async () => {}) }; await runRescue("/openclaw restart gateway", cfg, commandContext(), deps); @@ -360,7 +359,7 @@ describe("OpenClaw rescue message", () => { it("keeps failed execution consumed", async () => { await withRescueStateDir("failed-consumed-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runGatewayRestart: vi.fn(async () => { throw new Error("restart failed"); @@ -380,7 +379,7 @@ describe("OpenClaw rescue message", () => { it("preserves a new plan created while the consumed plan executes", async () => { await withRescueStateDir("replacement-during-execute-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; let releaseRestart: (() => void) | undefined; let noteRestartEntered: (() => void) | undefined; const restartEntered = new Promise((resolve) => { @@ -413,7 +412,7 @@ describe("OpenClaw rescue message", () => { it("publishes concurrently invoked persistent plans in call order", async () => { await withRescueStateDir("latest-plan-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runGatewayRestart: vi.fn(async () => {}), runGatewayStart: vi.fn(async () => {}), @@ -433,7 +432,7 @@ describe("OpenClaw rescue message", () => { it("persists a pending approval only in SQLite across store reopen", async () => { await withRescueStateDir("sqlite-reopen-", async (stateDir) => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runGatewayRestart: vi.fn(async () => {}) }; await runRescue("/openclaw restart gateway", cfg, commandContext(), deps); @@ -451,7 +450,7 @@ describe("OpenClaw rescue message", () => { it("isolates pending approvals by account, channel, and sender", async () => { await withRescueStateDir("route-isolation-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runGatewayRestart: vi.fn(async () => {}) }; const original = commandContext(); @@ -474,7 +473,7 @@ describe("OpenClaw rescue message", () => { it("falls back to the channel destination when account id is absent", async () => { await withRescueStateDir("route-account-fallback-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runGatewayRestart: vi.fn(async () => {}) }; const original = commandContext({ accountId: undefined, to: "bot:primary" }); @@ -495,7 +494,7 @@ describe("OpenClaw rescue message", () => { }); it("refuses plugin install from remote rescue", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runPluginInstall: vi.fn(async () => { throw new Error("remote rescue must not install plugins"); @@ -509,7 +508,7 @@ describe("OpenClaw rescue message", () => { }); it("allows plugin list and search from remote rescue", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runPluginsList: vi.fn(async (runtime: RuntimeEnv) => { runtime.log("plugin rows"); @@ -537,7 +536,7 @@ describe("OpenClaw rescue message", () => { it("queues and applies persistent writes through conversational approval", async () => { await withRescueStateDir("models-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { verifyInferenceConfig: vi.fn(async () => ({ ok: true as const, @@ -569,7 +568,7 @@ describe("OpenClaw rescue message", () => { it("queues and applies gateway restart through conversational approval", async () => { await withRescueStateDir("gateway-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runGatewayRestart: vi.fn(async () => {}) }; await expect( @@ -596,7 +595,7 @@ describe("OpenClaw rescue message", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(8_640_000_000_000_000)); try { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; await expect( runRescue("/openclaw restart gateway", cfg, commandContext()), @@ -615,16 +614,11 @@ describe("OpenClaw rescue message", () => { await withRescueStateDir("expired-", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runGatewayRestart: vi.fn(async () => {}) }; - await runRescue( - "/openclaw restart gateway", - { systemAgent: { rescue: { enabled: true, pendingTtlMinutes: 1 } } }, - commandContext(), - deps, - ); - vi.advanceTimersByTime(60_001); + await runRescue("/openclaw restart gateway", {}, commandContext(), deps); + vi.advanceTimersByTime(15 * 60_000 + 1); await expect(runRescue("/openclaw yes", cfg, commandContext(), deps)).resolves.toBe( "No pending OpenClaw rescue change is waiting for approval.", @@ -635,7 +629,7 @@ describe("OpenClaw rescue message", () => { it("consumes malformed pending rows without executing them", async () => { await withRescueStateDir("malformed-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { runGatewayRestart: vi.fn(async () => {}) }; await runRescue("/openclaw restart gateway", cfg, commandContext(), deps); @@ -662,7 +656,7 @@ describe("OpenClaw rescue message", () => { it("queues and applies agent creation through conversational approval", async () => { await withRescueStateDir("agent-", async () => { - const cfg: OpenClawConfig = { systemAgent: { rescue: { enabled: true } } }; + const cfg: OpenClawConfig = {}; const deps = { createAgent: vi.fn(async () => ({ status: "created" as const, diff --git a/src/system-agent/rescue-policy.test.ts b/src/system-agent/rescue-policy.test.ts index 9c8de23dc2e0..fa4af5bf9178 100644 --- a/src/system-agent/rescue-policy.test.ts +++ b/src/system-agent/rescue-policy.test.ts @@ -17,9 +17,8 @@ describe("resolveSystemAgentRescuePolicy", () => { expect(decide({}).allowed).toBe(true); }); - it("hard-denies rescue when sandboxing is active even if explicitly enabled", () => { + it("hard-denies rescue when sandboxing is active", () => { const decision = decide({ - systemAgent: { rescue: { enabled: true } }, agents: { defaults: { sandbox: { mode: "all" } } }, }); expect(decision.allowed).toBe(false); @@ -31,7 +30,7 @@ describe("resolveSystemAgentRescuePolicy", () => { it("keeps auto rescue closed outside YOLO host posture", () => { const decision = decide({ - tools: { exec: { security: "allowlist", ask: "on-miss" } }, + tools: { exec: { security: "allowlist", ask: "always" } }, }); expect(decision.allowed).toBe(false); if (decision.allowed) { @@ -56,10 +55,7 @@ describe("resolveSystemAgentRescuePolicy", () => { expect(notDirectMessageDecision.reason).toBe("not-direct-message"); }); - it("allows explicit group rescue when ownerDmOnly is disabled", () => { - expect( - decide({ systemAgent: { rescue: { ownerDmOnly: false } } }, { isDirectMessage: false }) - .allowed, - ).toBe(true); + it("always limits rescue to owner direct messages", () => { + expect(decide({}, { isDirectMessage: false }).allowed).toBe(false); }); }); diff --git a/src/system-agent/rescue-policy.ts b/src/system-agent/rescue-policy.ts index 7742b8b5f6c4..ab04e3c727b9 100644 --- a/src/system-agent/rescue-policy.ts +++ b/src/system-agent/rescue-policy.ts @@ -1,12 +1,13 @@ // OpenClaw rescue policy gates remote writes by owner, DM, sandbox, and YOLO posture. import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveExecModePolicy } from "../infra/exec-approvals.js"; import { normalizeAgentId } from "../routing/session-key.js"; /** * Policy checks for remote OpenClaw rescue commands. * * Rescue intentionally opens only for owner-controlled, non-sandboxed YOLO host - * posture unless config explicitly enables it, because remote commands can write local state. + * posture because remote commands can write local state. */ type SystemAgentRescueDecision = | { @@ -24,7 +25,7 @@ type SystemAgentRescueDecision = pendingTtlMinutes: number; yolo: boolean; sandboxActive: boolean; - reason: "disabled" | "sandbox-active" | "not-yolo" | "not-owner" | "not-direct-message"; + reason: "disabled" | "sandbox-active" | "not-owner" | "not-direct-message"; message: string; }; @@ -35,10 +36,6 @@ type SystemAgentRescuePolicyInput = { isDirectMessage: boolean; }; -function resolvePendingTtlMinutes(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 15; -} - function resolveAgentEntry(cfg: OpenClawConfig, agentId?: string) { if (!agentId) { return undefined; @@ -65,37 +62,30 @@ function resolveScopedSandboxMode( function isYoloHostPosture(cfg: OpenClawConfig, agentId?: string): boolean { const scopedExec = resolveScopedExecConfig(cfg, agentId); const globalExec = cfg.tools?.exec; - const security = scopedExec?.security ?? globalExec?.security ?? "full"; - const ask = scopedExec?.ask ?? globalExec?.ask ?? "off"; - return security === "full" && ask === "off"; + const inherited = resolveExecModePolicy({ + mode: globalExec?.mode, + security: globalExec?.security ?? "full", + ask: globalExec?.ask ?? "off", + }); + return ( + resolveExecModePolicy({ + mode: scopedExec?.mode, + security: scopedExec?.security ?? inherited.security, + ask: scopedExec?.ask ?? inherited.ask, + }).mode === "full" + ); } /** Decide whether a message-channel rescue command is allowed for this sender/context. */ export function resolveSystemAgentRescuePolicy( input: SystemAgentRescuePolicyInput, ): SystemAgentRescueDecision { - const rescue = input.cfg.systemAgent?.rescue; - const configuredEnabled = rescue?.enabled ?? "auto"; - const ownerDmOnly = rescue?.ownerDmOnly ?? true; - const pendingTtlMinutes = resolvePendingTtlMinutes(rescue?.pendingTtlMinutes); + const ownerDmOnly = true; + const pendingTtlMinutes = 15; const sandboxActive = resolveScopedSandboxMode(input.cfg, input.agentId) !== "off"; const yolo = !sandboxActive && isYoloHostPosture(input.cfg, input.agentId); - // "auto" means rescue follows host posture; explicit false/true still keeps owner/DM gates. - const enabled = configuredEnabled === "auto" ? yolo : configuredEnabled; + const enabled = yolo; - if (!enabled) { - return { - allowed: false, - enabled, - ownerDmOnly, - pendingTtlMinutes, - yolo, - sandboxActive, - reason: "disabled", - message: - "OpenClaw rescue is disabled. Set systemAgent.rescue.enabled=true or use YOLO host posture with sandboxing off.", - }; - } if (sandboxActive) { return { allowed: false, @@ -109,7 +99,7 @@ export function resolveSystemAgentRescuePolicy( "OpenClaw rescue is blocked because OpenClaw sandboxing is active. Fix the install locally or disable sandboxing before using remote rescue.", }; } - if (configuredEnabled === "auto" && !yolo) { + if (!enabled) { return { allowed: false, enabled, @@ -117,9 +107,8 @@ export function resolveSystemAgentRescuePolicy( pendingTtlMinutes, yolo, sandboxActive, - reason: "not-yolo", - message: - "OpenClaw rescue auto-mode only opens in YOLO host posture: tools.exec.security=full, tools.exec.ask=off, and sandboxing off.", + reason: "disabled", + message: "OpenClaw rescue requires YOLO host posture with sandboxing off.", }; } if (!input.senderIsOwner) { diff --git a/src/system-agent/setup-apply.test.ts b/src/system-agent/setup-apply.test.ts index 7924d3ee22c9..f2e58d066356 100644 --- a/src/system-agent/setup-apply.test.ts +++ b/src/system-agent/setup-apply.test.ts @@ -99,7 +99,7 @@ vi.mock("../infra/exec-approvals.js", () => ({ vi.mock("../agents/agent-scope.js", async (importOriginal) => ({ ...(await importOriginal()), resolveAgentDir: (config: OpenClawConfig, agentId: string) => - config.agents?.list?.find((agent) => agent.id === agentId)?.agentDir ?? `/agents/${agentId}`, + config.agents?.entries?.[agentId]?.agentDir ?? `/agents/${agentId}`, })); import { applySystemAgentModelSelection, applySystemAgentSetup } from "./setup-apply.js"; @@ -191,9 +191,8 @@ describe("applySystemAgentModelSelection", () => { "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, }, }, - list: [ - { - id: "work", + entries: { + work: { default: true, model: "openai/gpt-5.5", models: { @@ -203,7 +202,7 @@ describe("applySystemAgentModelSelection", () => { }, }, }, - ], + }, }, } satisfies OpenClawConfig; @@ -213,8 +212,8 @@ describe("applySystemAgentModelSelection", () => { }); expect(result.agents?.defaults?.models?.["openai/gpt-5.5"]?.agentRuntime).toBeUndefined(); - expect(result.agents?.list?.[0]?.models?.["openai/gpt-5.5"]).toEqual({ alias: "primary" }); - expect(result.agents?.list?.[0]?.model).toBe("openai/gpt-5.5"); + expect(result.agents?.entries?.work?.models?.["openai/gpt-5.5"]).toEqual({ alias: "primary" }); + expect(result.agents?.entries?.work?.model).toBe("openai/gpt-5.5"); }); it("pins the verified credential without creating a global visibility map", async () => { @@ -339,13 +338,13 @@ describe("applySystemAgentSetup transaction boundaries", () => { baseParams({ expectedConfigHash: null, workspace: "/tmp/requested-workspace", - configPatch: { agents: { list: [{ id: "main" }] } }, + configPatch: { agents: { entries: { main: {} } } }, }), ); expect(mocks.state.persistedConfig?.agents).toMatchObject({ defaults: { workspace: "/tmp/requested-workspace" }, - list: [{ id: "main" }], + entries: { main: {} }, }); }); @@ -353,7 +352,7 @@ describe("applySystemAgentSetup transaction boundaries", () => { const config = { agents: { defaults: { workspace: "/tmp/current-workspace" }, - list: [{ id: "main" }, { id: "ops" }], + entries: { main: {}, ops: {} }, }, } satisfies OpenClawConfig; mocks.state.initialSnapshot = snapshot("probe", config); @@ -364,13 +363,13 @@ describe("applySystemAgentSetup transaction boundaries", () => { baseParams({ workspace: "/tmp/requested-workspace", configPatch: { - agents: { defaults: { workspace: "/tmp/patch-workspace" }, list: null }, + agents: { defaults: { workspace: "/tmp/patch-workspace" }, entries: null }, }, }), ); expect(mocks.state.persistedConfig?.agents?.defaults?.workspace).toBe("/tmp/current-workspace"); - expect(mocks.state.persistedConfig?.agents?.list).toEqual([{ id: "main" }, { id: "ops" }]); + expect(mocks.state.persistedConfig?.agents?.entries).toEqual({ main: {}, ops: {} }); expect(mocks.ensureWorkspace).toHaveBeenCalledWith( "/tmp/current-workspace", runtime, @@ -395,7 +394,7 @@ describe("applySystemAgentSetup transaction boundaries", () => { const config = { agents: { defaults: { model: "openai/gpt-5.5" }, - list: [{ id: "OpenClaw" }], + entries: { OpenClaw: {} }, }, } satisfies OpenClawConfig; mocks.state.initialSnapshot = snapshot("reserved", config); @@ -411,7 +410,7 @@ describe("applySystemAgentSetup transaction boundaries", () => { const config = { agents: { defaults: { model: "openai/gpt-5.5" }, - list: [{ id: "crestodian" }], // reserved retired id + entries: { crestodian: {} }, // reserved retired id }, } satisfies OpenClawConfig; mocks.state.initialSnapshot = snapshot("reserved-retired", config); @@ -439,7 +438,7 @@ describe("applySystemAgentSetup transaction boundaries", () => { runtimeConfig: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } }, - list: [{ id: "other", default: true }], + entries: { other: { default: true } }, }, }, error: "default agent changed", @@ -468,12 +467,14 @@ describe("applySystemAgentSetup transaction boundaries", () => { }); it("rejects same-revision agent credential directory drift in the final snapshot", async () => { - mocks.state.commitSnapshot = snapshot("probe", { + const movedConfig: OpenClawConfig = { agents: { defaults: { model: { primary: "openai/gpt-5.5" } }, - list: [{ id: "main", default: true, agentDir: "/agents/moved" }], + entries: { main: { default: true, agentDir: "/agents/moved" } }, }, - }); + }; + mocks.state.commitConfig = movedConfig; + mocks.state.commitSnapshot = snapshot("probe", movedConfig); await expect( applySystemAgentSetup( diff --git a/src/system-agent/setup-apply.ts b/src/system-agent/setup-apply.ts index 9ec0bd9fc68a..b4c3a64cbfd6 100644 --- a/src/system-agent/setup-apply.ts +++ b/src/system-agent/setup-apply.ts @@ -1,5 +1,6 @@ // Applies OpenClaw's conversational setup: config, workspace files, gateway. import { isDeepStrictEqual } from "node:util"; +import { listAgentEntries } from "../agents/agent-scope-config.js"; import { readConfigFileSnapshot, readConfigFileSnapshotWithPluginMetadata, @@ -149,7 +150,9 @@ function applySystemAgentModelSelectionWithModules( const agentId = targetAgentId ?? agentScope.resolveDefaultAgentId(nextConfig); if ( targetAgentId && - !nextConfig.agents?.list?.some((entry) => normalizeAgentId(entry.id) === targetAgentId) + !Object.keys(nextConfig.agents?.entries ?? {}).some( + (entryId) => normalizeAgentId(entryId) === targetAgentId, + ) ) { throw new Error(`Could not resolve configured agent "${targetAgentId}".`); } @@ -172,7 +175,12 @@ function applySystemAgentModelSelectionWithModules( nextConfig.agents.defaults.models = defaultModels; } - let agent = nextConfig.agents.list?.find((entry) => normalizeAgentId(entry.id) === agentId); + nextConfig.agents.entries ??= {}; + const agentEntryKey = + Object.keys(nextConfig.agents.entries).find( + (entryId) => normalizeAgentId(entryId) === agentId, + ) ?? agentId; + let agent = nextConfig.agents.entries[agentEntryKey]; if (writesAgent) { if (!agent) { throw new Error(`Could not resolve configured default agent "${agentId}".`); @@ -184,8 +192,8 @@ function applySystemAgentModelSelectionWithModules( if (params.agentRuntimeId) { if (!agent) { - agent = { id: agentId, default: true }; - nextConfig.agents.list = [...(nextConfig.agents.list ?? []), agent]; + agent = { default: true }; + nextConfig.agents.entries[agentEntryKey] = agent; } const agentModels = { ...agent.models }; const agentKey = modelConfig.upsertCanonicalModelConfigEntry(agentModels, target); @@ -372,8 +380,7 @@ export async function applySystemAgentSetup( const { configureGatewayForSetup } = await import("../wizard/setup.gateway-config.js"); const buildSetupCandidate = async (currentBaseConfig: OpenClawConfig) => { const workspaceConflict = resolveOnboardingWorkspaceConflict(currentBaseConfig, workspace); - const currentHasRoster = - Array.isArray(currentBaseConfig.agents?.list) && currentBaseConfig.agents.list.length > 0; + const currentHasRoster = listAgentEntries(currentBaseConfig).length > 0; const allowWorkspaceWrite = params.allowWorkspaceChange || (!workspaceConflict && !currentHasRoster); let setupBaseConfig = currentBaseConfig; @@ -390,7 +397,7 @@ export async function applySystemAgentSetup( if (currentHasRoster) { setupBaseConfig = { ...setupBaseConfig, - agents: { ...setupBaseConfig.agents, list: currentBaseConfig.agents?.list }, + agents: { ...setupBaseConfig.agents, entries: currentBaseConfig.agents?.entries }, }; } const preserveWorkspace = diff --git a/src/system-agent/setup-config-snapshot.ts b/src/system-agent/setup-config-snapshot.ts index e3b67d1e8a4c..3d0cc21765d8 100644 --- a/src/system-agent/setup-config-snapshot.ts +++ b/src/system-agent/setup-config-snapshot.ts @@ -16,7 +16,7 @@ export function requireValidSystemAgentSetupSnapshot(snapshot: ConfigFileSnapsho } const sourceConfig = snapshot.exists ? (snapshot.sourceConfig ?? snapshot.config) : {}; const runtimeConfig = snapshot.exists ? (snapshot.runtimeConfig ?? snapshot.config) : {}; - const reservedAgent = runtimeConfig.agents?.list?.find((entry) => + const reservedAgent = listAgentEntries(runtimeConfig).find((entry) => isReservedSystemAgentId(entry.id), ); if (reservedAgent) { @@ -26,3 +26,4 @@ export function requireValidSystemAgentSetupSnapshot(snapshot: ConfigFileSnapsho } return { sourceConfig, runtimeConfig }; } +import { listAgentEntries } from "../agents/agent-scope.js"; diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index 9a99e1d4bcd6..0a35f3e18bb4 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -134,6 +134,31 @@ async function makeTempDir(): Promise { const deferSuiteTempDirCleanup = async () => {}; +function canonicalizeAgentEntriesForTest(config: OpenClawConfig): OpenClawConfig { + const next = structuredClone(config); + const list = next.agents?.list; + if (!list) { + return next; + } + next.agents = { + ...next.agents, + entries: Object.fromEntries(list.map(({ id, ...entry }) => [id, entry])), + }; + delete next.agents.list; + return next; +} + +function materializeRuntimeAgentListForTest(config: OpenClawConfig): OpenClawConfig { + const next = canonicalizeAgentEntriesForTest(config); + if (!next.agents?.entries) { + return next; + } + next.agents.list = Object.entries(next.agents.entries).map(([id, entry]) => + Object.assign({ id }, entry), + ); + return next; +} + function withSuiteTempDirs< T extends NonNullable[0]["deps"]>, >(input: T | undefined): T { @@ -173,6 +198,48 @@ async function activateSetupInference( async () => testCliRuntimeArtifactFingerprint, ); } + if (deps.readConfigFileSnapshot) { + const readConfigFileSnapshot = deps.readConfigFileSnapshot; + deps.readConfigFileSnapshot = (async (...args: Parameters) => { + const snapshot = await readConfigFileSnapshot(...args); + const sourceConfig = snapshot.sourceConfig ?? snapshot.config; + const runtimeConfig = snapshot.runtimeConfig ?? snapshot.config; + return { + ...snapshot, + ...(snapshot.config ? { config: materializeRuntimeAgentListForTest(snapshot.config) } : {}), + ...(sourceConfig ? { sourceConfig: canonicalizeAgentEntriesForTest(sourceConfig) } : {}), + ...(runtimeConfig + ? { runtimeConfig: materializeRuntimeAgentListForTest(runtimeConfig) } + : {}), + }; + }) as typeof deps.readConfigFileSnapshot; + } + if (deps.transformConfigWithPendingPluginInstalls) { + const transformConfigWithPendingPluginInstalls = deps.transformConfigWithPendingPluginInstalls; + deps.transformConfigWithPendingPluginInstalls = (async ( + transformParams: Parameters[0], + ) => { + const transform = transformParams.transform; + const wrappedTransform: typeof transform = async (config, context) => + await transform(canonicalizeAgentEntriesForTest(config), { + ...context, + snapshot: { + ...context.snapshot, + config: materializeRuntimeAgentListForTest(context.snapshot.config), + sourceConfig: canonicalizeAgentEntriesForTest( + context.snapshot.sourceConfig ?? context.snapshot.config, + ), + runtimeConfig: materializeRuntimeAgentListForTest( + context.snapshot.runtimeConfig ?? context.snapshot.config, + ), + }, + }); + return await transformConfigWithPendingPluginInstalls({ + ...transformParams, + transform: wrappedTransform, + } as never); + }) as typeof deps.transformConfigWithPendingPluginInstalls; + } return activateSetupInferenceImpl({ ...params, // Most activation tests isolate commit mechanics from the verified-owner @@ -265,8 +332,8 @@ function createConfigTransformHarness( runtimeConfig: OpenClawConfig = sourceConfig, ) { const state = { - sourceConfig: structuredClone(sourceConfig), - runtimeConfig: structuredClone(runtimeConfig), + sourceConfig: canonicalizeAgentEntriesForTest(sourceConfig), + runtimeConfig: materializeRuntimeAgentListForTest(runtimeConfig), }; const transform = vi.fn( async (params: { @@ -299,7 +366,7 @@ function createConfigTransformHarness( attempt: 0, }); state.sourceConfig = withoutPluginInstallRecords(transformed.nextConfig); - state.runtimeConfig = structuredClone(state.sourceConfig); + state.runtimeConfig = materializeRuntimeAgentListForTest(state.sourceConfig); return { nextConfig: state.sourceConfig }; }, ); @@ -325,15 +392,14 @@ describe("applySystemAgentModelSelection", () => { const config = { agents: { defaults: { model: { primary: "openai/gpt-5.4" } }, - list: [ - { - id: "ops", + entries: { + ops: { default: true, models: { "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, }, }, - ], + }, }, } satisfies OpenClawConfig; @@ -344,11 +410,12 @@ describe("applySystemAgentModelSelection", () => { }); expect(result.agents?.defaults?.model).toMatchObject({ primary: "openai/gpt-5.5" }); - expect(result.agents?.list?.[0]).toMatchObject({ - id: "ops", + expect(result.agents?.entries?.ops).toMatchObject({ models: { "openai/gpt-5.5": { agentRuntime: { id: "codex" } } }, }); - expect(config.agents.list[0]?.models["openai/gpt-5.5"]?.agentRuntime?.id).toBe("openclaw"); + expect(config.agents.entries.ops?.models?.["openai/gpt-5.5"]?.agentRuntime?.id).toBe( + "openclaw", + ); }); }); @@ -1034,8 +1101,7 @@ describe("activateSetupInference", () => { params: { temperature: 0.2 }, tools: { allow: ["read"], deny: ["exec"] }, }); - expect(configHarness.current().agents?.list?.find((agent) => agent.id === "openclaw")).toEqual({ - id: "openclaw", + expect(configHarness.current().agents?.entries?.openclaw).toEqual({ params: { temperature: 1.7 }, tools: { allow: ["exec"] }, }); @@ -1370,16 +1436,15 @@ describe("activateSetupInference", () => { expect(result.ok).toBe(true); const persistedConfig = configHarness.current(); - expect(persistedConfig.agents?.list).toEqual([ - { - id: "work", + expect(persistedConfig.agents?.entries).toEqual({ + work: { default: true, model: "claude-cli/claude-opus-4-8", name: "edited during probe", models: { "claude-cli/claude-opus-4-8": {} }, }, - { id: "new-agent", model: "anthropic/claude-opus-4-8" }, - ]); + "new-agent": { model: "anthropic/claude-opus-4-8" }, + }); }); it.each([ @@ -1475,7 +1540,7 @@ describe("activateSetupInference", () => { }), ).rejects.toThrow("route changed during its live test"); - expect(configHarness.current()).toEqual(concurrent); + expect(configHarness.current()).toEqual(canonicalizeAgentEntriesForTest(concurrent)); }); it("rejects a concurrent edit to inactive target-model metadata", async () => { @@ -1515,7 +1580,7 @@ describe("activateSetupInference", () => { }), ).rejects.toThrow("target model metadata changed"); - expect(configHarness.current()).toEqual(concurrentConfig); + expect(configHarness.current()).toEqual(canonicalizeAgentEntriesForTest(concurrentConfig)); }); it("preserves authored provider rows and lifts an onboarding-owned lean setting", async () => { @@ -2012,9 +2077,8 @@ describe("activateSetupInference", () => { agentHarnessRuntimeOverride: "openclaw", config: expect.objectContaining({ agents: expect.objectContaining({ - list: [ - expect.objectContaining({ - id: "ops", + entries: expect.objectContaining({ + ops: expect.objectContaining({ model: { primary: "anthropic/claude-opus-4-8" }, models: { "anthropic/claude-opus-4-8": { @@ -2022,7 +2086,7 @@ describe("activateSetupInference", () => { }, }, }), - ], + }), }), }), }), @@ -2550,7 +2614,7 @@ describe("activateSetupInference", () => { alias: "selected-groq", }, }); - expect(probeConfig.agents?.list?.[0]?.models).toMatchObject({ + expect(probeConfig.agents?.entries?.main?.models).toMatchObject({ "groq/llama-3.3-70b-versatile": { agentRuntime: { id: "openclaw" } }, }); expect(probeConfig.plugins?.entries?.groq).toEqual({ @@ -2657,7 +2721,7 @@ describe("activateSetupInference", () => { }), ).rejects.toThrow("active route owner"); - expect(configHarness.current()).toEqual(initialConfig); + expect(configHarness.current()).toEqual(canonicalizeAgentEntriesForTest(initialConfig)); expect( Object.keys(readAuthProfileStoreForTest(agentDir).profiles).filter((id) => id.startsWith("groq:setup-"), @@ -3619,9 +3683,7 @@ describe("activateSetupInference", () => { ).nextConfig; const configuredRuntime = transformed.agents?.defaults?.models?.["openai/gpt-5.6-sol"]?.agentRuntime?.id ?? - transformed.agents?.list?.find((agent) => agent.id === "ops")?.models?.[ - "openai/gpt-5.6-sol" - ]?.agentRuntime?.id; + transformed.agents?.entries?.ops?.models?.["openai/gpt-5.6-sol"]?.agentRuntime?.id; events.push(configuredRuntime === "codex" ? "persist-plugin-config" : "unexpected-write"); pendingCodexInstalls.push(transformed.plugins?.installs?.codex); persistedConfig = withoutPluginInstallRecords(transformed); @@ -3675,11 +3737,10 @@ describe("activateSetupInference", () => { expect(ensureCodex).toHaveBeenCalledWith( expect.objectContaining({ cfg: expect.objectContaining({ - agents: { + agents: expect.objectContaining({ defaults: { model: { primary: "openai/gpt-5.4" } }, - list: [ - expect.objectContaining({ - id: "ops", + entries: expect.objectContaining({ + ops: expect.objectContaining({ model: { primary: "openai/gpt-5.6-sol", fallbacks: ["google/gemini-3.1-pro-preview"], @@ -3689,8 +3750,8 @@ describe("activateSetupInference", () => { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, }, }), - ], - }, + }), + }), models: { providers: { openai: { baseUrl: "https://proxy.example.test/v1", models: [] }, @@ -3737,12 +3798,20 @@ describe("activateSetupInference", () => { workspaceDir: "/tmp/openclaw-workspace", logger: expect.objectContaining({ warn: expect.any(Function) }), }); - expect(ensureRegistryLoaded).toHaveBeenCalledWith({ - scope: "all", - config: persistedConfig, - activationSourceConfig: persistedConfig, - workspaceDir: "/tmp/openclaw-workspace", - }); + expect(ensureRegistryLoaded).toHaveBeenCalledWith( + expect.objectContaining({ + scope: "all", + config: expect.objectContaining({ + agents: expect.objectContaining({ entries: persistedConfig.agents?.entries }), + gateway: { port: 19_000 }, + }), + activationSourceConfig: expect.objectContaining({ + agents: expect.objectContaining({ entries: persistedConfig.agents?.entries }), + gateway: { port: 19_000 }, + }), + workspaceDir: "/tmp/openclaw-workspace", + }), + ); // Harness selection: codex tests run embedded with the codex harness. expect(runEmbeddedAgent.mock.calls[0]?.[0]).toMatchObject({ agentId: "openclaw", @@ -3750,13 +3819,12 @@ describe("activateSetupInference", () => { provider: "openai", authProfileStateMode: "read-only", config: { - agents: { + agents: expect.objectContaining({ defaults: { model: { primary: "openai/gpt-5.4" }, }, - list: [ - expect.objectContaining({ - id: "ops", + entries: expect.objectContaining({ + ops: expect.objectContaining({ model: { primary: "openai/gpt-5.6-sol", fallbacks: ["google/gemini-3.1-pro-preview"], @@ -3766,8 +3834,8 @@ describe("activateSetupInference", () => { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, }, }), - ], - }, + }), + }), plugins: { entries: { codex: { @@ -3801,9 +3869,8 @@ describe("activateSetupInference", () => { }, agents: { defaults: { model: { primary: "openai/gpt-5.4" } }, - list: [ - expect.objectContaining({ - id: "ops", + entries: { + ops: expect.objectContaining({ model: { primary: "openai/gpt-5.6-sol", fallbacks: ["google/gemini-3.1-pro-preview"], @@ -3813,7 +3880,7 @@ describe("activateSetupInference", () => { "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, }, }), - ], + }, }, plugins: { entries: { @@ -3921,14 +3988,13 @@ describe("activateSetupInference", () => { defaults: expect.objectContaining({ model: "openai/gpt-5.4", }), - list: expect.arrayContaining([ - expect.objectContaining({ - id: "main", + entries: expect.objectContaining({ + main: expect.objectContaining({ models: { "openai/gpt-5.4": { agentRuntime: { id: "codex" } }, }, }), - ]), + }), }), plugins: expect.objectContaining({ entries: expect.objectContaining({ @@ -3949,12 +4015,11 @@ describe("activateSetupInference", () => { expect(configHarness.current()).toMatchObject({ agents: expect.objectContaining({ defaults: expect.objectContaining({ model: "openai/gpt-5.4" }), - list: expect.arrayContaining([ - expect.objectContaining({ - id: "main", + entries: expect.objectContaining({ + main: expect.objectContaining({ models: { "openai/gpt-5.4": { agentRuntime: { id: "codex" } } }, }), - ]), + }), }), plugins: { entries: { diff --git a/src/system-agent/setup-inference.ts b/src/system-agent/setup-inference.ts index 8dacb9698326..5a1103683c19 100644 --- a/src/system-agent/setup-inference.ts +++ b/src/system-agent/setup-inference.ts @@ -5,7 +5,11 @@ import os from "node:os"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import { expectDefined } from "@openclaw/normalization-core"; -import { resolveAgentEffectiveModelPrimary, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + listAgentEntries, + resolveAgentEffectiveModelPrimary, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import { normalizeAuthProfileCredential } from "../agents/auth-profiles/credential-normalize.js"; import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js"; import { @@ -805,7 +809,9 @@ function projectSetupTargetModelMetadata(config: OpenClawConfig, modelRef: strin ]), ); const defaultAgentId = resolveDefaultAgentId(config); - const agent = config.agents?.list?.find((entry) => normalizeAgentId(entry.id) === defaultAgentId); + const agent = listAgentEntries(config).find( + (entry) => normalizeAgentId(entry.id) === defaultAgentId, + ); return { defaultAgentId, defaults: project(config.agents?.defaults?.models), @@ -909,30 +915,27 @@ function copySelectedModelMetadata(params: { } const defaultAgentId = resolveDefaultAgentId(params.target); - const preparedAgent = params.prepared.agents?.list?.find((agent) => agent.id === defaultAgentId); + const preparedAgent = listAgentEntries(params.prepared).find( + (agent) => normalizeAgentId(agent.id) === defaultAgentId, + ); if (!preparedAgent?.models || !Object.hasOwn(preparedAgent.models, params.modelRef)) { return; } - const targetAgents = params.target.agents?.list; - const targetAgentIndex = targetAgents?.findIndex((agent) => agent.id === defaultAgentId) ?? -1; - if (!targetAgents || targetAgentIndex < 0) { - return; - } - const nextAgents = structuredClone(targetAgents); - const targetAgent = expectDefined( - nextAgents[targetAgentIndex], - "next agents entry at target agent index", + const targetEntryKey = Object.keys(params.target.agents?.entries ?? {}).find( + (agentId) => normalizeAgentId(agentId) === defaultAgentId, ); - if (!targetAgent) { + if (!targetEntryKey || !params.target.agents?.entries?.[targetEntryKey]) { return; } + const nextEntries = structuredClone(params.target.agents.entries); + const targetAgent = expectDefined(nextEntries[targetEntryKey], "target agent entry"); targetAgent.models = { ...targetAgent.models, [params.modelRef]: structuredClone( expectDefined(preparedAgent.models[params.modelRef], "models entry at params.model ref"), ), }; - params.target.agents = { ...params.target.agents, list: nextAgents }; + params.target.agents = { ...params.target.agents, entries: nextEntries }; } function findSelectedProviderConfigKey( @@ -3176,7 +3179,7 @@ function configReferencesManualAuthProfiles( if (modelSelectionReferencesProfile(config.agents?.defaults?.model, profileIds)) { return true; } - return (config.agents?.list ?? []).some((agent) => + return listAgentEntries(config).some((agent) => modelSelectionReferencesProfile(agent.model, profileIds), ); } diff --git a/src/tts/openai-compatible-speech-provider.ts b/src/tts/openai-compatible-speech-provider.ts index 3530c39d968d..4e21994fc751 100644 --- a/src/tts/openai-compatible-speech-provider.ts +++ b/src/tts/openai-compatible-speech-provider.ts @@ -209,7 +209,7 @@ export function createOpenAiCompatibleSpeechProvider< return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, - path: `messages.tts.providers.${providerConfigKey}.apiKey`, + path: `tts.providers.${providerConfigKey}.apiKey`, }), baseUrl: trimToUndefined(raw?.baseUrl) == null diff --git a/src/tts/status-config.test.ts b/src/tts/status-config.test.ts index ab4f5557da56..22df8610af8a 100644 --- a/src/tts/status-config.test.ts +++ b/src/tts/status-config.test.ts @@ -44,12 +44,10 @@ describe("resolveStatusTtsSnapshot", () => { expect( resolveStatusTtsSnapshot({ cfg: { - messages: { - tts: { - auto: "always", - provider: "edge", - prefsPath, - }, + tts: { + auto: "always", + provider: "edge", + prefsPath, }, } as OpenClawConfig, }), @@ -81,10 +79,8 @@ describe("resolveStatusTtsSnapshot", () => { expect( resolveStatusTtsSnapshot({ cfg: { - messages: { - tts: { - prefsPath, - }, + tts: { + prefsPath, }, } as OpenClawConfig, }), @@ -102,10 +98,8 @@ describe("resolveStatusTtsSnapshot", () => { expect( resolveStatusTtsSnapshot({ cfg: { - messages: { - tts: { - auto: "always", - }, + tts: { + auto: "always", }, } as OpenClawConfig, }), @@ -123,11 +117,9 @@ describe("resolveStatusTtsSnapshot", () => { expect( resolveStatusTtsSnapshot({ cfg: { - messages: { - tts: { - auto: "off", - provider: "openai", - }, + tts: { + auto: "off", + provider: "openai", }, agents: { list: [ @@ -157,14 +149,12 @@ describe("resolveStatusTtsSnapshot", () => { expect( resolveStatusTtsSnapshot({ cfg: { - messages: { - tts: { - auto: "always", - persona: "alfred", - personas: { - alfred: { provider: "google" }, - jarvis: { provider: "edge" }, - }, + tts: { + auto: "always", + persona: "alfred", + personas: { + alfred: { provider: "google" }, + jarvis: { provider: "edge" }, }, }, agents: { @@ -195,17 +185,15 @@ describe("resolveStatusTtsSnapshot", () => { expect( resolveStatusTtsSnapshot({ cfg: { - messages: { - tts: { - auto: "always", - provider: "openai", - providers: { - openai: { - displayName: "NeuTTS local", - baseUrl: "http://user:secret@127.0.0.1:18801/v1?token=hidden#fragment", - model: "neutts-nano", - voice: "clara", - }, + tts: { + auto: "always", + provider: "openai", + providers: { + openai: { + displayName: "NeuTTS local", + baseUrl: "http://username@127.0.0.1:18801/v1?token=hidden#fragment", + model: "neutts-nano", + voice: "clara", }, }, }, @@ -232,16 +220,14 @@ describe("resolveStatusTtsSnapshot", () => { const voice = `${"v".repeat(92)}😀tail`; const snapshot = resolveStatusTtsSnapshot({ cfg: { - messages: { - tts: { - auto: "always", - provider: "elevenlabs", - providers: { - elevenlabs: { - displayName, - model, - voice, - }, + tts: { + auto: "always", + provider: "elevenlabs", + providers: { + elevenlabs: { + displayName, + model, + voice, }, }, }, @@ -259,16 +245,14 @@ describe("resolveStatusTtsSnapshot", () => { expect( resolveStatusTtsSnapshot({ cfg: { - messages: { - tts: { - auto: "always", - provider: "openai", - providers: { - openai: { - baseUrl: "https://api.openai.com/v1/", - model: "gpt-4o-mini-tts", - voice: "coral", - }, + tts: { + auto: "always", + provider: "openai", + providers: { + openai: { + baseUrl: "https://api.openai.com/v1/", + model: "gpt-4o-mini-tts", + voice: "coral", }, }, }, @@ -290,14 +274,12 @@ describe("resolveStatusTtsSnapshot", () => { expect( resolveStatusTtsSnapshot({ cfg: { - messages: { - tts: { - auto: "always", - provider: "elevenlabs", - providers: { - elevenlabs: { - speakerVoiceId: "voice-123", - }, + tts: { + auto: "always", + provider: "elevenlabs", + providers: { + elevenlabs: { + speakerVoiceId: "voice-123", }, }, }, @@ -318,15 +300,13 @@ describe("resolveStatusTtsSnapshot", () => { expect( resolveStatusTtsSnapshot({ cfg: { - messages: { - tts: { - auto: "off", - provider: "openai", - providers: { - openai: { - model: "gpt-4o-mini-tts", - voice: "coral", - }, + tts: { + auto: "off", + provider: "openai", + providers: { + openai: { + model: "gpt-4o-mini-tts", + voice: "coral", }, }, }, @@ -376,18 +356,16 @@ describe("resolveStatusTtsSnapshot", () => { expect( resolveStatusTtsSnapshot({ cfg: { - messages: { - tts: { - provider: "openai", - prefsPath, - providers: { - microsoft: { - voice: "en-US-AvaMultilingualNeural", - }, - openai: { - model: "gpt-4o-mini-tts", - voice: "coral", - }, + tts: { + provider: "openai", + prefsPath, + providers: { + microsoft: { + voice: "en-US-AvaMultilingualNeural", + }, + openai: { + model: "gpt-4o-mini-tts", + voice: "coral", }, }, }, @@ -427,9 +405,7 @@ describe("resolveStatusTtsSnapshot", () => { expect( resolveStatusTtsSnapshot({ cfg: { - messages: { - tts: {}, - }, + tts: {}, } as OpenClawConfig, }), ).toEqual({ diff --git a/src/tts/tts-config.test.ts b/src/tts/tts-config.test.ts index c3a83deb309d..fdfe46be29c5 100644 --- a/src/tts/tts-config.test.ts +++ b/src/tts/tts-config.test.ts @@ -58,31 +58,29 @@ describe("shouldAttemptTtsPayload", () => { it("honors session auto state before prefs and config", () => { writeFileSync(prefsPath, JSON.stringify({ tts: { auto: "off" } })); - const cfg = { messages: { tts: { auto: "off" } } } as OpenClawConfig; + const cfg = { tts: { auto: "off" } } as OpenClawConfig; expect(shouldAttemptTtsPayload({ cfg, ttsAuto: "always" })).toBe(true); expect(shouldAttemptTtsPayload({ cfg, ttsAuto: "off" })).toBe(false); }); it("uses local prefs before config auto mode", () => { - const cfg = { messages: { tts: { auto: "off" } } } as OpenClawConfig; + const cfg = { tts: { auto: "off" } } as OpenClawConfig; writeFileSync(prefsPath, JSON.stringify({ tts: { enabled: true } })); expect(shouldAttemptTtsPayload({ cfg })).toBe(true); writeFileSync(prefsPath, JSON.stringify({ tts: { auto: "off" } })); - expect( - shouldAttemptTtsPayload({ cfg: { messages: { tts: { enabled: true } } } as OpenClawConfig }), - ).toBe(false); + expect(shouldAttemptTtsPayload({ cfg: { tts: { enabled: true } } as OpenClawConfig })).toBe( + false, + ); }); it("uses per-agent TTS auto and mode overrides", () => { const cfg = { - messages: { - tts: { - auto: "off", - mode: "final", - }, + tts: { + auto: "off", + mode: "final", }, agents: { list: [ @@ -103,18 +101,30 @@ describe("shouldAttemptTtsPayload", () => { expect(resolveConfiguredTtsMode(cfg, "main")).toBe("final"); }); + it("uses a per-agent preference path before the global environment path", () => { + const voicePrefsPath = path.join(dir, "voice-tts.json"); + writeFileSync(prefsPath, JSON.stringify({ tts: { auto: "off" } })); + writeFileSync(voicePrefsPath, JSON.stringify({ tts: { auto: "always" } })); + const cfg = { + agents: { + list: [{ id: "voice", tts: { prefsPath: voicePrefsPath } }], + }, + } as OpenClawConfig; + + expect(shouldAttemptTtsPayload({ cfg, agentId: "voice" })).toBe(true); + expect(shouldAttemptTtsPayload({ cfg, agentId: "main" })).toBe(false); + }); + it("merges channel and account TTS overrides after agent overrides", () => { const cfg = { - messages: { - tts: { - auto: "off", - mode: "final", - provider: "openai", - providers: { - openai: { - model: "gpt-4o-mini-tts", - voice: "alloy", - }, + tts: { + auto: "off", + mode: "final", + provider: "openai", + providers: { + openai: { + model: "gpt-4o-mini-tts", + voice: "alloy", }, }, }, @@ -171,14 +181,12 @@ describe("shouldAttemptTtsPayload", () => { '{"providers":{"custom":{"nullable":null,"voices":["override"],"__proto__":{"polluted":true},"constructor":{"polluted":true},"prototype":{"polluted":true}}}}', ); const cfg = { - messages: { - tts: { - providers: { - custom: { - model: "base", - nullable: "base", - voices: ["base"], - }, + tts: { + providers: { + custom: { + model: "base", + nullable: "base", + voices: ["base"], }, }, }, diff --git a/src/tts/tts-config.ts b/src/tts/tts-config.ts index 4ced4fc91851..b95bdfcfa5bd 100644 --- a/src/tts/tts-config.ts +++ b/src/tts/tts-config.ts @@ -10,6 +10,7 @@ import type { OpenClawConfig } from "../config/types.js"; import type { TtsAutoMode, TtsConfig, TtsMode } from "../config/types.tts.js"; import { mergeDeep } from "../infra/deep-merge.js"; import { normalizeAccountId, normalizeAgentId } from "../routing/session-key.js"; +import { readConfigMachineState } from "../state/config-machine-state.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { normalizeTtsAutoMode } from "./tts-auto-mode.js"; export { normalizeTtsAutoMode } from "./tts-auto-mode.js"; @@ -109,7 +110,7 @@ export function resolveEffectiveTtsConfig( contextOrAgentId?: string | TtsConfigResolutionContext, ): TtsConfig { const context = resolveTtsConfigContext(contextOrAgentId); - const base = cfg.messages?.tts ?? {}; + const base = cfg.tts ?? {}; const agentOverride = resolveAgentTtsOverride(cfg, context.agentId); const channelOverride = resolveChannelTtsOverride(cfg, context); const accountOverride = resolveAccountTtsOverride(cfg, context); @@ -128,7 +129,10 @@ export function resolveConfiguredTtsMode( return resolveEffectiveTtsConfig(cfg, contextOrAgentId).mode ?? "final"; } -function resolveTtsPrefsPathValue(prefsPath: string | undefined): string { +function resolveTtsPrefsPathValue( + prefsPath: string | undefined, + machinePrefsPath?: string, +): string { if (prefsPath?.trim()) { return resolveUserPath(prefsPath.trim()); } @@ -136,6 +140,9 @@ function resolveTtsPrefsPathValue(prefsPath: string | undefined): string { if (envPath) { return resolveUserPath(envPath); } + if (machinePrefsPath?.trim()) { + return resolveUserPath(machinePrefsPath.trim()); + } return path.join(resolveConfigDir(process.env), "settings", "tts.json"); } @@ -174,7 +181,10 @@ export function shouldAttemptTtsPayload(params: { } const raw = resolveEffectiveTtsConfig(params.cfg, params); - const prefsAuto = readTtsPrefsAutoMode(resolveTtsPrefsPathValue(raw?.prefsPath)); + const scopedPrefsPath = (raw as TtsConfig & { prefsPath?: string }).prefsPath; + const prefsAuto = readTtsPrefsAutoMode( + resolveTtsPrefsPathValue(scopedPrefsPath, readConfigMachineState("tts.prefsPath")), + ); if (prefsAuto) { return prefsAuto !== "off"; } diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 5d6f0acb9585..b9e27841d946 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -42,7 +42,7 @@ describe("tts runtime facade", () => { ownerKind: "capability", ownerId: "tts", state: "unavailable", - paths: ["messages.tts.providers.elevenlabs.apiKey"], + paths: ["tts.providers.elevenlabs.apiKey"], refKeys: ["env:default:MISSING_TTS_KEY"], reason: "secret reference was not found", }, diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 8c9bc5c73f77..face3ad7dd58 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -1,11 +1,17 @@ /** Public TTS runtime barrel exposed to core callers and plugin SDK facades. */ -import { setSpeechRuntimeAvailabilityGuard } from "../../packages/speech-core/runtime-api.js"; +import { + setSpeechRuntimeAvailabilityGuard, + setTtsMachinePrefsPathResolver, +} from "../../packages/speech-core/runtime-api.js"; import { assertSecretOwnerAvailable } from "../secrets/runtime-degraded-state.js"; +import { readConfigMachineState } from "../state/config-machine-state.js"; setSpeechRuntimeAvailabilityGuard(() => { assertSecretOwnerAvailable("capability", "tts"); }); +setTtsMachinePrefsPathResolver(() => readConfigMachineState("tts.prefsPath")); + export { getLastTtsAttempt, getResolvedSpeechProviderConfig, diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 3ad95f4e467d..2b965e8268b4 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -591,7 +591,7 @@ describe("resolveGatewayConnection", () => { ); }); - it("marks loopback local connections for insecure operator ui auth when enabled", async () => { + it("keeps loopback local connections on device-authenticated operator UI", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local", @@ -606,10 +606,10 @@ describe("resolveGatewayConnection", () => { }); const result = await resolveGatewayConnection({}); - expect(result.allowInsecureLocalOperatorUi).toBe(true); + expect(result.allowInsecureLocalOperatorUi).toBe(false); }); - it("preserves insecure local operator ui auth when a loopback url override is provided", async () => { + it("keeps a loopback URL override on device-authenticated operator UI", async () => { loadConfig.mockReturnValue({ gateway: { mode: "local", @@ -627,7 +627,7 @@ describe("resolveGatewayConnection", () => { url: "ws://127.0.0.1:18791", token: "override-token", }); - expect(result.allowInsecureLocalOperatorUi).toBe(true); + expect(result.allowInsecureLocalOperatorUi).toBe(false); expect(result.token).toBe("override-token"); }); }); @@ -714,7 +714,6 @@ describe("GatewayChatClient", () => { vi.useFakeTimers(); const { startProxy, stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js"); const proxyHandle = await startProxy({ - enabled: true, proxyUrl: "http://127.0.0.1:3128", loopbackMode: "block", }); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 40d5ac38077d..00d43a9e5a0c 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -29,7 +29,6 @@ import { } from "../gateway/call.js"; import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js"; import { GatewayClient, GatewayClientRequestError } from "../gateway/client.js"; -import { isLoopbackHost } from "../gateway/net.js"; import { formatErrorMessage } from "../infra/errors.js"; import { readActiveGatewayLockPort } from "../infra/gateway-lock.js"; import { roleScopesAllow } from "../shared/operator-scope-compat.js"; @@ -482,16 +481,7 @@ async function resolveGatewayConnection( ...(urlOverride ? { url: urlOverride } : {}), ...(activeLocalGatewayPort ? { localPortOverride: activeLocalGatewayPort } : {}), }).url; - const allowInsecureLocalOperatorUi = (() => { - if (config.gateway?.controlUi?.allowInsecureAuth !== true) { - return false; - } - try { - return isLoopbackHost(new URL(url).hostname); - } catch { - return false; - } - })(); + const allowInsecureLocalOperatorUi = false; if (urlOverride) { return { diff --git a/src/tui/tui-pty-local.e2e.test.ts b/src/tui/tui-pty-local.e2e.test.ts index 0d4c2ed63ac4..07df4e41ff0c 100644 --- a/src/tui/tui-pty-local.e2e.test.ts +++ b/src/tui/tui-pty-local.e2e.test.ts @@ -486,14 +486,13 @@ function buildLocalModeConfig(params: { skills: [], skipBootstrap: true, }, - list: [ - { - id: "main", + entries: { + main: { default: true, skills: [], model: { primary: "tui-pty-mock/gpt-5.5" }, }, - ], + }, }, tools: { profile: params.toolsProfile ?? "minimal", @@ -615,14 +614,18 @@ function buildGatewayModeConfig(params: { tempDir: string; providerBaseUrl: stri skills: [], skipBootstrap: true, }, - list: scenarios.map((scenario, index) => ({ - id: scenario.agentId, - ...(index === 0 ? { default: true } : {}), - workspace: path.join(params.tempDir, scenario.agentId), - skills: [], - model: { primary: `tui-pty-mock/${scenario.modelId}` }, - tools: { profile: scenario.toolsProfile }, - })), + entries: Object.fromEntries( + scenarios.map((scenario, index) => [ + scenario.agentId, + { + ...(index === 0 ? { default: true } : {}), + workspace: path.join(params.tempDir, scenario.agentId), + skills: [], + model: { primary: `tui-pty-mock/${scenario.modelId}` }, + tools: { profile: scenario.toolsProfile }, + }, + ]), + ), }, models: { mode: "replace", diff --git a/src/tui/tui.ts b/src/tui/tui.ts index dfd768ba6d57..18b4207de764 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -97,10 +97,8 @@ const OPENCLAW_CLI_WRAPPER_PATH = fileURLToPath(new URL("../../openclaw.mjs", im const OPENCLAW_RUN_NODE_SCRIPT_PATH = fileURLToPath( new URL("../../scripts/run-node.mjs", import.meta.url), ); -const OPENCLAW_DIST_ENTRY_JS_PATH = fileURLToPath(new URL("../../dist/entry.js", import.meta.url)); -const OPENCLAW_DIST_ENTRY_MJS_PATH = fileURLToPath( - new URL("../../dist/entry.mjs", import.meta.url), -); +const DIST_ENTRY_JS_PATH = fileURLToPath(new URL("../../dist/entry.js", import.meta.url)); +const DIST_ENTRY_MJS_PATH = fileURLToPath(new URL("../../dist/entry.mjs", import.meta.url)); const OPENAI_CODEX_PROVIDER = "openai"; const CODEX_CLI_LOOKUP_TIMEOUT_MS = 5_000; @@ -146,8 +144,7 @@ export function resolveLocalAuthCliInvocation(params?: { hasRunNodeScript?: boolean; }): { command: string; args: string[] } { const hasDistEntry = - params?.hasDistEntry ?? - (existsSync(OPENCLAW_DIST_ENTRY_JS_PATH) || existsSync(OPENCLAW_DIST_ENTRY_MJS_PATH)); + params?.hasDistEntry ?? (existsSync(DIST_ENTRY_JS_PATH) || existsSync(DIST_ENTRY_MJS_PATH)); const hasRunNodeScript = params?.hasRunNodeScript ?? existsSync(OPENCLAW_RUN_NODE_SCRIPT_PATH); const command = params?.execPath ?? process.execPath; const wrapperPath = params?.wrapperPath ?? OPENCLAW_CLI_WRAPPER_PATH; diff --git a/src/utils/queue-helpers.ts b/src/utils/queue-helpers.ts index 69f4122119f7..061316d86d73 100644 --- a/src/utils/queue-helpers.ts +++ b/src/utils/queue-helpers.ts @@ -6,6 +6,7 @@ import { expectDefined } from "@openclaw/normalization-core"; * debounce drains, and force individual collection when cross-channel ordering matters. */ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { isFastTestRuntimeEnv } from "../infra/env.js"; /** Mutable summary state for a capped queue. */ type QueueSummaryState = { @@ -167,7 +168,7 @@ export function waitForQueueDebounce( }, abortSignal?: AbortSignal, ): Promise { - if (process.env.OPENCLAW_TEST_FAST === "1") { + if (isFastTestRuntimeEnv()) { // Tests use this escape hatch so debounce logic does not slow deterministic queue specs. return Promise.resolve(); } diff --git a/src/video-generation/live-test-helpers.test.ts b/src/video-generation/live-test-helpers.test.ts index f627d5f36a76..1c9400abd4d1 100644 --- a/src/video-generation/live-test-helpers.test.ts +++ b/src/video-generation/live-test-helpers.test.ts @@ -33,9 +33,11 @@ describe("video-generation live-test helpers", () => { const cfg = { agents: { defaults: { - videoGenerationModel: { - primary: "google/veo-3.1-fast-generate-preview", - fallbacks: ["openai/sora-2", "invalid"], + mediaModels: { + video: { + primary: "google/veo-3.1-fast-generate-preview", + fallbacks: ["openai/sora-2", "invalid"], + }, }, }, }, diff --git a/src/video-generation/live-test-helpers.ts b/src/video-generation/live-test-helpers.ts index 1e5aea9cc61e..6269c494464e 100644 --- a/src/video-generation/live-test-helpers.ts +++ b/src/video-generation/live-test-helpers.ts @@ -58,7 +58,7 @@ export function parseCsvFilter(raw?: string): Set | null { } export function resolveConfiguredLiveVideoModels(cfg: OpenClawConfig): Map { - return resolveConfiguredLiveProviderModels(cfg.agents?.defaults?.videoGenerationModel); + return resolveConfiguredLiveProviderModels(cfg.agents?.defaults?.mediaModels?.video); } export function canRunBufferBackedVideoToVideoLiveLane(params: { diff --git a/src/video-generation/runtime.test.ts b/src/video-generation/runtime.test.ts index 733917101005..7d9d14c0641e 100644 --- a/src/video-generation/runtime.test.ts +++ b/src/video-generation/runtime.test.ts @@ -26,7 +26,25 @@ const runtimeDeps = { } satisfies NonNullable[1]>; function runGenerateVideo(params: GenerateVideoParams) { - return generateVideo(params, runtimeDeps); + const defaults = params.cfg.agents?.defaults as + | (NonNullable["defaults"] & { + videoGenerationModel?: unknown; + }) + | undefined; + const cfg = + defaults?.videoGenerationModel !== undefined && defaults.mediaModels?.video === undefined + ? { + ...params.cfg, + agents: { + ...params.cfg.agents, + defaults: { + ...defaults, + mediaModels: { ...defaults.mediaModels, video: defaults.videoGenerationModel }, + }, + }, + } + : params.cfg; + return generateVideo({ ...params, cfg }, runtimeDeps); } function requireAttempt( @@ -1164,7 +1182,7 @@ describe("video-generation runtime", () => { await expect( runGenerateVideo({ cfg: {} as OpenClawConfig, prompt: "animate a cat" }), ).rejects.toThrow( - 'No video-generation model configured. Set agents.defaults.videoGenerationModel.primary to a provider/model like "motion-one/animate-v1". If you want a specific provider, also configure that provider\'s auth/API key first (motion-one: MOTION_ONE_API_KEY).', + 'No video-generation model configured. Set agents.defaults.mediaModels.video.primary to a provider/model like "motion-one/animate-v1". If you want a specific provider, also configure that provider\'s auth/API key first (motion-one: MOTION_ONE_API_KEY).', ); }); }); diff --git a/src/video-generation/runtime.ts b/src/video-generation/runtime.ts index dba0feca6dd3..5f5ea676cd62 100644 --- a/src/video-generation/runtime.ts +++ b/src/video-generation/runtime.ts @@ -99,7 +99,7 @@ function buildNoVideoGenerationModelConfiguredMessage( const listProviders = deps.listProviders ?? listVideoGenerationProviders; return buildNoCapabilityModelConfiguredMessage({ capabilityLabel: "video-generation", - modelConfigKey: "videoGenerationModel", + modelConfigKey: "mediaModels.video", providers: listProviders(cfg), getProviderEnvVars: deps.getProviderEnvVars, }); @@ -121,10 +121,10 @@ export async function generateVideo( const logger = deps.log ?? log; const requestedTimeoutMs = params.timeoutMs ?? - resolveAgentModelTimeoutMsValue(params.cfg.agents?.defaults?.videoGenerationModel); + resolveAgentModelTimeoutMsValue(params.cfg.agents?.defaults?.mediaModels?.video); const candidates = resolveCapabilityModelCandidates({ cfg: params.cfg, - modelConfig: params.cfg.agents?.defaults?.videoGenerationModel, + modelConfig: params.cfg.agents?.defaults?.mediaModels?.video, modelOverride: params.modelOverride, parseModelRef: parseVideoGenerationModelRef, agentDir: params.agentDir, diff --git a/src/wizard/setup.gateway-config.test.ts b/src/wizard/setup.gateway-config.test.ts index de6a5efc1855..475444dc6aef 100644 --- a/src/wizard/setup.gateway-config.test.ts +++ b/src/wizard/setup.gateway-config.test.ts @@ -102,9 +102,11 @@ describe("configureGatewayForSetup", () => { const result = await runGatewayConfig(); expect(result.settings.gatewayToken).toBe("generated-token"); - expect(result.nextConfig.gateway?.nodes?.denyCommands).toEqual(DEFAULT_DANGEROUS_NODE_COMMANDS); - expect(result.nextConfig.gateway?.nodes?.denyCommands).not.toContain("screen.snapshot"); - expect(result.nextConfig.gateway?.nodes?.denyCommands).toContain("screen.record"); + expect(result.nextConfig.gateway?.nodes?.commands?.deny).toEqual( + DEFAULT_DANGEROUS_NODE_COMMANDS, + ); + expect(result.nextConfig.gateway?.nodes?.commands?.deny).not.toContain("screen.snapshot"); + expect(result.nextConfig.gateway?.nodes?.commands?.deny).toContain("screen.record"); }); it.each(["1e3", "0x1000"])("rejects loose gateway port input: %s", async (port) => { @@ -187,7 +189,7 @@ describe("configureGatewayForSetup", () => { } }); - it("enables insecure local control ui auth for fresh quickstart loopback setups", async () => { + it("does not add a Control UI auth bypass for fresh quickstart loopback setups", async () => { mocks.randomToken.mockReturnValue("generated-token"); const result = await runGatewayConfig({ @@ -195,54 +197,7 @@ describe("configureGatewayForSetup", () => { textQueue: [], }); - expect(result.nextConfig.gateway?.controlUi?.allowInsecureAuth).toBe(true); - }); - - it("preserves explicit control ui auth policy in quickstart", async () => { - mocks.randomToken.mockReturnValue("generated-token"); - - const result = await runGatewayConfig({ - flow: "quickstart", - textQueue: [], - nextConfig: { - gateway: { - controlUi: { - allowInsecureAuth: false, - }, - }, - }, - }); - - expect(result.nextConfig.gateway?.controlUi?.allowInsecureAuth).toBe(false); - }); - - it("enables insecure local control ui auth when quickstart reuses an existing loopback config", async () => { - mocks.randomToken.mockReturnValue("generated-token"); - const prompter = createPrompter({ - selectQueue: [], - textQueue: [], - }); - const runtime = createRuntime(); - - const result = await configureGatewayForSetup({ - flow: "quickstart", - baseConfig: {}, - nextConfig: { - gateway: { - port: 18789, - bind: "loopback", - }, - }, - localPort: 18789, - quickstartGateway: { - ...createQuickstartGateway("token"), - hasExisting: true, - }, - prompter, - runtime, - }); - - expect(result.nextConfig.gateway?.controlUi?.allowInsecureAuth).toBe(true); + expect(result.nextConfig.gateway?.controlUi).toBeUndefined(); }); it("does not set password to literal 'undefined' when prompt returns undefined", async () => { @@ -366,8 +321,6 @@ describe("configureGatewayForSetup", () => { gatewaytokens: { source: "exec", command: process.execPath, - allowInsecurePath: true, - allowSymlinkCommand: true, args: [ "-e", "let input='';process.stdin.setEncoding('utf8');process.stdin.on('data',d=>input+=d);process.stdin.on('end',()=>{const req=JSON.parse(input||'{}');const values={};for(const id of req.ids||[]){values[id]='token-from-exec';}process.stdout.write(JSON.stringify({protocolVersion:1,values}));});", diff --git a/src/wizard/setup.gateway-config.ts b/src/wizard/setup.gateway-config.ts index 81038c24c6e9..87e63753afed 100644 --- a/src/wizard/setup.gateway-config.ts +++ b/src/wizard/setup.gateway-config.ts @@ -355,23 +355,6 @@ export async function configureGatewayForSetup( }, }; - if ( - flow === "quickstart" && - bind === "loopback" && - nextConfig.gateway?.controlUi?.allowInsecureAuth === undefined - ) { - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - controlUi: { - ...nextConfig.gateway?.controlUi, - allowInsecureAuth: true, - }, - }, - }; - } - nextConfig = ensureControlUiAllowedOriginsForNonLoopbackBind(nextConfig, { requireControlUiEnabled: true, }).config; @@ -386,8 +369,8 @@ export async function configureGatewayForSetup( // /phone arm ... (phone-control plugin). if ( !quickstartGateway.hasExisting && - nextConfig.gateway?.nodes?.denyCommands === undefined && - nextConfig.gateway?.nodes?.allowCommands === undefined && + nextConfig.gateway?.nodes?.commands?.deny === undefined && + nextConfig.gateway?.nodes?.commands?.allow === undefined && nextConfig.gateway?.nodes?.browser === undefined ) { nextConfig = { @@ -396,7 +379,10 @@ export async function configureGatewayForSetup( ...nextConfig.gateway, nodes: { ...nextConfig.gateway?.nodes, - denyCommands: [...DEFAULT_DANGEROUS_NODE_COMMANDS], + commands: { + ...nextConfig.gateway?.nodes?.commands, + deny: [...DEFAULT_DANGEROUS_NODE_COMMANDS], + }, }, }, }; diff --git a/src/wizard/setup.migration-recovery.test.ts b/src/wizard/setup.migration-recovery.test.ts index 143be298832a..4e6e5ca92478 100644 --- a/src/wizard/setup.migration-recovery.test.ts +++ b/src/wizard/setup.migration-recovery.test.ts @@ -456,13 +456,6 @@ describe("setup migration recovery", () => { await expect( buildSetupMigrationTargetSnapshot({ config: {}, stateDir, workspaceDir }), ).resolves.toBe(initial); - await expect( - buildSetupMigrationTargetSnapshot({ - config: { meta: { lastTouchedAt: "2026-07-13T23:00:00.000Z" } }, - stateDir, - workspaceDir, - }), - ).resolves.toBe(initial); await expect( buildSetupMigrationTargetSnapshot({ config: { wizard: { securityAcknowledgedAt: "2026-07-13T23:00:00.000Z" } }, diff --git a/taxonomy.yaml b/taxonomy.yaml index 216ac3e5e97a..38bc77aa9b23 100644 --- a/taxonomy.yaml +++ b/taxonomy.yaml @@ -3250,10 +3250,10 @@ surfaces: features: - name: TTS coverageIds: [media.tts] - description: Covers TTS across `tts` agent/tool and Gateway methods, `messages.tts`, provider registry, directives, and related tts and outbound voice audio delivery behavior. + description: Covers TTS across `tts` agent/tool and Gateway methods, top-level `tts`, provider registry, directives, and related tts and outbound voice audio delivery behavior. - name: Outbound Voice Audio Delivery coverageIds: [media.outbound-voice-audio-delivery] - description: Covers Outbound Voice Audio Delivery across `tts` agent/tool and Gateway methods, `messages.tts`, provider registry, directives, and related tts and outbound voice audio delivery behavior. + description: Covers Outbound Voice Audio Delivery across `tts` agent/tool and Gateway methods, top-level `tts`, provider registry, directives, and related tts and outbound voice audio delivery behavior. docs: - docs/tools/tts.md - docs/tools/media-overview.md diff --git a/test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts b/test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts index c7af1d0c475c..e889cf416e62 100644 --- a/test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts +++ b/test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts @@ -345,7 +345,7 @@ async function main() { "first-run setup did not write default workspace", ); assert(resolveDefaultModel(config) === spec.model, "first-run setup did not write default model"); - const reef = config.agents?.list?.find((agent) => agent.id === spec.agentId); + const reef = config.agents?.entries?.[spec.agentId]; assert(reef, "OpenClaw did not create reef agent"); assert(reef.workspace === spec.dockerAgentWorkspace, "OpenClaw did not write reef workspace"); assert( diff --git a/test/helpers/config/config-honor-audit.ts b/test/helpers/config/config-honor-audit.ts index c874e997e806..cb7fb2136a0f 100644 --- a/test/helpers/config/config-honor-audit.ts +++ b/test/helpers/config/config-honor-audit.ts @@ -53,11 +53,13 @@ function hasSchemaPath(schemaPath: string): boolean { return false; } if (segment === "*") { - const items = (current as { items?: unknown }).items; - if (!items || typeof items !== "object") { + const wildcardTarget = + (current as { additionalProperties?: unknown; items?: unknown }).items ?? + (current as { additionalProperties?: unknown }).additionalProperties; + if (!wildcardTarget || typeof wildcardTarget !== "object") { return false; } - current = items; + current = wildcardTarget; continue; } const properties = (current as { properties?: Record }).properties; @@ -81,7 +83,10 @@ export function listSchemaLeafKeysForPrefixes(prefixes: string[]): string[] { break; } if (segment === "*") { - current = (current as { items?: unknown }).items ?? null; + current = + (current as { additionalProperties?: unknown; items?: unknown }).items ?? + (current as { additionalProperties?: unknown }).additionalProperties ?? + null; continue; } current = (current as { properties?: Record }).properties?.[segment] ?? null; diff --git a/test/helpers/config/heartbeat-config-honor.inventory.ts b/test/helpers/config/heartbeat-config-honor.inventory.ts index 10e0f77eb9fe..e78291007ed2 100644 --- a/test/helpers/config/heartbeat-config-honor.inventory.ts +++ b/test/helpers/config/heartbeat-config-honor.inventory.ts @@ -6,14 +6,14 @@ import type { ConfigHonorInventoryRow } from "./config-honor-audit.js"; /** Config prefixes audited for heartbeat key coverage. */ export const HEARTBEAT_CONFIG_PREFIXES = [ "agents.defaults.heartbeat", - "agents.list.*.heartbeat", + "agents.entries.*.heartbeat", ] as const; /** Heartbeat config honor inventory consumed by config audit tests. */ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [ { key: "every", - schemaPaths: ["agents.defaults.heartbeat.every", "agents.list.*.heartbeat.every"], + schemaPaths: ["agents.defaults.heartbeat.every", "agents.entries.*.heartbeat.every"], typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], mergePaths: ["src/infra/heartbeat-runner.ts", "src/agents/acp-spawn.ts"], consumerPaths: ["src/infra/heartbeat-runner.ts", "src/agents/acp-spawn.ts"], @@ -25,7 +25,7 @@ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [ }, { key: "model", - schemaPaths: ["agents.defaults.heartbeat.model", "agents.list.*.heartbeat.model"], + schemaPaths: ["agents.defaults.heartbeat.model", "agents.entries.*.heartbeat.model"], typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], mergePaths: ["src/infra/heartbeat-runner.ts"], consumerPaths: ["src/infra/heartbeat-runner.ts"], @@ -37,54 +37,18 @@ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [ }, { key: "prompt", - schemaPaths: ["agents.defaults.heartbeat.prompt", "agents.list.*.heartbeat.prompt"], + schemaPaths: ["agents.defaults.heartbeat.prompt", "agents.entries.*.heartbeat.prompt"], typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], mergePaths: ["src/infra/heartbeat-runner.ts"], consumerPaths: ["src/infra/heartbeat-runner.ts"], reloadPaths: ["src/gateway/config-reload-plan.ts"], testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"], }, - { - key: "includeSystemPromptSection", - schemaPaths: [ - "agents.defaults.heartbeat.includeSystemPromptSection", - "agents.list.*.heartbeat.includeSystemPromptSection", - ], - typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], - mergePaths: ["src/agents/heartbeat-system-prompt.ts"], - consumerPaths: [ - "src/agents/heartbeat-system-prompt.ts", - "src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts", - ], - reloadPaths: ["src/gateway/config-reload-plan.ts"], - testPaths: ["src/agents/heartbeat-system-prompt.test.ts"], - }, - { - key: "ackMaxChars", - schemaPaths: ["agents.defaults.heartbeat.ackMaxChars", "agents.list.*.heartbeat.ackMaxChars"], - typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], - mergePaths: ["src/infra/heartbeat-runner.ts"], - consumerPaths: ["src/infra/heartbeat-runner.ts"], - reloadPaths: ["src/gateway/config-reload-plan.ts"], - testPaths: ["src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts"], - }, - { - key: "suppressToolErrorWarnings", - schemaPaths: [ - "agents.defaults.heartbeat.suppressToolErrorWarnings", - "agents.list.*.heartbeat.suppressToolErrorWarnings", - ], - typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], - mergePaths: ["src/infra/heartbeat-runner.ts"], - consumerPaths: ["src/infra/heartbeat-runner.ts"], - reloadPaths: ["src/gateway/config-reload-plan.ts"], - testPaths: ["src/infra/heartbeat-runner.model-override.test.ts"], - }, { key: "timeoutSeconds", schemaPaths: [ "agents.defaults.heartbeat.timeoutSeconds", - "agents.list.*.heartbeat.timeoutSeconds", + "agents.entries.*.heartbeat.timeoutSeconds", ], typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], mergePaths: ["src/infra/heartbeat-runner.ts"], @@ -97,7 +61,10 @@ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [ }, { key: "lightContext", - schemaPaths: ["agents.defaults.heartbeat.lightContext", "agents.list.*.heartbeat.lightContext"], + schemaPaths: [ + "agents.defaults.heartbeat.lightContext", + "agents.entries.*.heartbeat.lightContext", + ], typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], mergePaths: ["src/infra/heartbeat-runner.ts"], consumerPaths: ["src/infra/heartbeat-runner.ts", "src/agents/bootstrap-files.ts"], @@ -112,7 +79,7 @@ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [ key: "isolatedSession", schemaPaths: [ "agents.defaults.heartbeat.isolatedSession", - "agents.list.*.heartbeat.isolatedSession", + "agents.entries.*.heartbeat.isolatedSession", ], typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], mergePaths: ["src/infra/heartbeat-runner.ts"], @@ -122,7 +89,7 @@ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [ }, { key: "target", - schemaPaths: ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"], + schemaPaths: ["agents.defaults.heartbeat.target", "agents.entries.*.heartbeat.target"], typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"], consumerPaths: ["src/infra/outbound/targets.ts", "src/infra/heartbeat-runner.ts"], @@ -134,7 +101,7 @@ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [ }, { key: "to", - schemaPaths: ["agents.defaults.heartbeat.to", "agents.list.*.heartbeat.to"], + schemaPaths: ["agents.defaults.heartbeat.to", "agents.entries.*.heartbeat.to"], typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"], consumerPaths: ["src/infra/outbound/targets.ts"], @@ -143,7 +110,7 @@ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [ }, { key: "accountId", - schemaPaths: ["agents.defaults.heartbeat.accountId", "agents.list.*.heartbeat.accountId"], + schemaPaths: ["agents.defaults.heartbeat.accountId", "agents.entries.*.heartbeat.accountId"], typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"], consumerPaths: ["src/infra/outbound/targets.ts", "src/infra/heartbeat-runner.ts"], @@ -155,23 +122,14 @@ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [ }, { key: "directPolicy", - schemaPaths: ["agents.defaults.heartbeat.directPolicy", "agents.list.*.heartbeat.directPolicy"], + schemaPaths: [ + "agents.defaults.heartbeat.directPolicy", + "agents.entries.*.heartbeat.directPolicy", + ], typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], mergePaths: ["src/infra/heartbeat-runner.ts", "src/infra/outbound/targets.ts"], consumerPaths: ["src/infra/outbound/targets.ts"], reloadPaths: ["src/gateway/config-reload-plan.ts"], testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"], }, - { - key: "includeReasoning", - schemaPaths: [ - "agents.defaults.heartbeat.includeReasoning", - "agents.list.*.heartbeat.includeReasoning", - ], - typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"], - mergePaths: ["src/infra/heartbeat-runner.ts"], - consumerPaths: ["src/infra/heartbeat-runner.ts"], - reloadPaths: ["src/gateway/config-reload-plan.ts"], - testPaths: ["src/infra/heartbeat-runner.returns-default-unset.test.ts"], - }, ]; diff --git a/test/helpers/config/redact-snapshot-test-hints.ts b/test/helpers/config/redact-snapshot-test-hints.ts index d7f1b818f2fb..09566ea9a03c 100644 --- a/test/helpers/config/redact-snapshot-test-hints.ts +++ b/test/helpers/config/redact-snapshot-test-hints.ts @@ -5,8 +5,8 @@ type ConfigUiHints = NonNullable< >; export const redactSnapshotTestHints: ConfigUiHints = { - "agents.defaults.memorySearch.remote.apiKey": { sensitive: true }, - "agents.list[].memorySearch.remote.apiKey": { sensitive: true }, + "memory.search.remote.apiKey": { sensitive: true }, + "agents.list[].memory.search.remote.apiKey": { sensitive: true }, "broadcast.apiToken[]": { sensitive: true }, "env.GROQ_API_KEY": { sensitive: true }, "gateway.auth.password": { sensitive: true }, diff --git a/test/helpers/infra/heartbeat-runner-channel-plugins.ts b/test/helpers/infra/heartbeat-runner-channel-plugins.ts index b43ecf030d56..d836a4d90ce8 100644 --- a/test/helpers/infra/heartbeat-runner-channel-plugins.ts +++ b/test/helpers/infra/heartbeat-runner-channel-plugins.ts @@ -111,7 +111,7 @@ export const heartbeatRunnerWhatsAppPlugin = createHeartbeatChannelPlugin({ docsPath: "/channels/whatsapp", heartbeat: { checkReady: async ({ cfg, deps }) => { - if (cfg.web?.enabled === false) { + if (cfg.channels?.whatsapp?.enabled === false) { return { ok: false, reason: "whatsapp-disabled" }; } const authExists = await (deps?.webAuthExists ?? (async () => true))(); diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index b0e9aff86911..110863c63db5 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -1439,6 +1439,7 @@ describe("scripts/changed-lanes", () => { }); expect(plan.commands.map((command) => command.name)).toEqual([ "conflict markers", + "environment variable count ratchet", "max-lines suppression ratchet", "changelog attributions", "guarded extension wildcard re-exports", @@ -2314,6 +2315,21 @@ describe("scripts/changed-lanes", () => { ).toMatchObject({ args: ["check:max-lines-ratchet", "--staged", "--base", "HEAD"] }); }); + it("adds the environment variable count ratchet for production source", () => { + const result = detectChangedLanes(["src/runtime.ts"]); + const worktreePlan = createChangedCheckPlan(result, { base: "main" }); + const stagedPlan = createChangedCheckPlan(result, { staged: true }); + + expect( + worktreePlan.commands.find( + (command) => command.name === "environment variable count ratchet", + ), + ).toMatchObject({ args: ["check:env-var-count", "--base", "main"] }); + expect( + stagedPlan.commands.find((command) => command.name === "environment variable count ratchet"), + ).toMatchObject({ args: ["check:env-var-count", "--staged", "--base", "HEAD"] }); + }); + it("keeps the temp creation report out of non-test changed paths", () => { const result = detectChangedLanes(["scripts/check-changed.mjs"]); const plan = createChangedCheckPlan(result); diff --git a/test/scripts/check-env-var-count.test.ts b/test/scripts/check-env-var-count.test.ts new file mode 100644 index 000000000000..f984182e2a9a --- /dev/null +++ b/test/scripts/check-env-var-count.test.ts @@ -0,0 +1,132 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + collectEnvVarNames, + isCountedSourcePath, + main, + parseBudget, +} from "../../scripts/check-env-var-count.mjs"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); + +describe("check-env-var-count", () => { + it("counts production source and excludes tests and QA Lab", () => { + expect(isCountedSourcePath("src/config/paths.ts")).toBe(true); + expect(isCountedSourcePath("packages/api/src/index.ts")).toBe(true); + expect(isCountedSourcePath("extensions/demo/src/index.ts")).toBe(true); + expect(isCountedSourcePath("src/config/paths.test.ts")).toBe(false); + expect(isCountedSourcePath("extensions/qa-lab/src/index.ts")).toBe(false); + }); + + it("collects each distinct name once", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-")); + tempDirs.push(root); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync( + path.join(root, "src/runtime.ts"), + 'const a = process.env.OPENCLAW_ALPHA; const b = "OPENCLAW_ALPHA OPENCLAW_BETA";\n', + ); + fs.writeFileSync(path.join(root, "src/runtime.test.ts"), "OPENCLAW_TEST_ONLY\n"); + execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); + + expect(collectEnvVarNames(root)).toEqual(["OPENCLAW_ALPHA", "OPENCLAW_BETA"]); + fs.rmSync(path.join(root, "src/runtime.ts")); + expect(collectEnvVarNames(root)).toEqual([]); + }); + + it("parses exactly one integer budget", () => { + expect(parseBudget("# count\n42\n")).toBe(42); + expect(() => parseBudget("42\n43\n")).toThrow(); + expect(() => parseBudget("many\n")).toThrow(); + }); + + it("reads staged source from the index", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-staged-")); + tempDirs.push(root); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + const sourcePath = path.join(root, "src/runtime.ts"); + fs.writeFileSync(sourcePath, "process.env.OPENCLAW_STAGED;\n"); + execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["add", "src/runtime.ts"], { cwd: root, stdio: "ignore" }); + fs.writeFileSync(sourcePath, "process.env.OPENCLAW_WORKTREE;\n"); + + expect(collectEnvVarNames(root, { staged: true })).toEqual(["OPENCLAW_STAGED"]); + expect(collectEnvVarNames(root)).toEqual(["OPENCLAW_WORKTREE"]); + }); + + it("fails closed when the base ref cannot be resolved", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-base-")); + tempDirs.push(root); + fs.mkdirSync(path.join(root, "config"), { recursive: true }); + fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "0\n"); + execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); + + expect(() => main(["--base", "missing"], root)).toThrow(/Could not resolve/u); + }); + + it("rejects growth above the budget", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-grow-")); + tempDirs.push(root); + fs.mkdirSync(path.join(root, "config"), { recursive: true }); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "1\n"); + fs.writeFileSync( + path.join(root, "src/runtime.ts"), + "process.env.OPENCLAW_ONE; process.env.OPENCLAW_TWO;\n", + ); + execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["add", "."], { cwd: root, stdio: "ignore" }); + execFileSync( + "git", + ["-c", "user.name=OpenClaw", "-c", "user.email=test@openclaw.local", "commit", "-m", "base"], + { cwd: root, stdio: "ignore" }, + ); + + expect(() => main(["--base", "HEAD"], root)).toThrow(/exceeds budget|over budget/u); + }); + + it("passes when the count exactly matches the budget", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-exact-")); + tempDirs.push(root); + fs.mkdirSync(path.join(root, "config"), { recursive: true }); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "1\n"); + fs.writeFileSync(path.join(root, "src/runtime.ts"), "process.env.OPENCLAW_ONLY;\n"); + execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["add", "."], { cwd: root, stdio: "ignore" }); + execFileSync( + "git", + ["-c", "user.name=OpenClaw", "-c", "user.email=test@openclaw.local", "commit", "-m", "base"], + { cwd: root, stdio: "ignore" }, + ); + + expect(() => main(["--base", "HEAD"], root)).not.toThrow(); + }); + + it("rejects stale headroom after the count shrinks", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-tight-")); + tempDirs.push(root); + fs.mkdirSync(path.join(root, "config"), { recursive: true }); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "2\n"); + fs.writeFileSync(path.join(root, "src/runtime.ts"), "process.env.OPENCLAW_ONLY;\n"); + execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["add", "."], { cwd: root, stdio: "ignore" }); + execFileSync( + "git", + ["-c", "user.name=OpenClaw", "-c", "user.email=test@openclaw.local", "commit", "-m", "base"], + { cwd: root, stdio: "ignore" }, + ); + + expect(() => main(["--base", "HEAD"], root)).toThrow(/is below budget/u); + }); +}); diff --git a/test/scripts/check-memory-fd-repro.test.ts b/test/scripts/check-memory-fd-repro.test.ts index 68158865bc1f..3f3cbc5f23ce 100644 --- a/test/scripts/check-memory-fd-repro.test.ts +++ b/test/scripts/check-memory-fd-repro.test.ts @@ -175,7 +175,11 @@ describe("check-memory-fd-repro", () => { resultCount: 0, }); } finally { - await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }); } }); @@ -196,7 +200,7 @@ describe("check-memory-fd-repro", () => { token: "test-token", }); const config = JSON.parse(fs.readFileSync(configPath, "utf8")); - const memorySearch = config.agents.defaults.memorySearch; + const memorySearch = config.memory.search; expect(memorySearch.store).toEqual({ vector: { enabled: false } }); expect(memorySearch).toMatchObject({ diff --git a/test/scripts/generate-docs-map.test.ts b/test/scripts/generate-docs-map.test.ts index 504b5af0b386..95ce544344ca 100644 --- a/test/scripts/generate-docs-map.test.ts +++ b/test/scripts/generate-docs-map.test.ts @@ -10,4 +10,13 @@ describe("generate docs map", () => { "<scr<script>ipt>alert(1)</script>", ); }); + + it("preserves Markdown syntax inside inline code", () => { + expect(testing.cleanHeadingText("`agents.entries.*.contextLimits` and *defaults*")).toBe( + "`agents.entries.*.contextLimits` and defaults", + ); + expect(testing.cleanHeadingText("`![label](https://example.test/image)`")).toBe( + "`![label](https://example.test/image)`", + ); + }); }); diff --git a/test/scripts/lint-suppressions.test.ts b/test/scripts/lint-suppressions.test.ts index 9e7d9e18ebe2..89a158022b05 100644 --- a/test/scripts/lint-suppressions.test.ts +++ b/test/scripts/lint-suppressions.test.ts @@ -231,6 +231,7 @@ describe("production lint suppressions", () => { "src/plugins/runtime/runtime-plugin-boundary.ts|typescript/no-unnecessary-type-parameters|1", "src/plugins/runtime/types-channel.ts|typescript/no-unnecessary-type-parameters|1", "src/plugins/trusted-tool-policy.ts|typescript/no-unnecessary-type-parameters|1", + "src/state/config-machine-state.ts|typescript/no-unnecessary-type-parameters|1", "src/system-agent/setup-inference.ts|no-unsafe-finally|1", "src/system-agent/setup-inference.ts|preserve-caught-error|1", "src/tasks/task-registry.sqlite.shared.ts|typescript/no-unnecessary-type-parameters|1",