From 7f3f108521f45ba14b65b2ffe507b5ee88979671 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 12:16:48 +0100 Subject: [PATCH] refactor(config): migrate plugin config access --- docs/.generated/config-baseline.sha256 | 4 +- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- extensions/active-memory/index.test.ts | 8 +- extensions/active-memory/index.ts | 16 +- extensions/bluebubbles/src/actions.test.ts | 21 +- .../browser/src/browser-tool.actions.ts | 16 +- .../browser/src/browser-tool.runtime.ts | 2 +- extensions/browser/src/browser-tool.test.ts | 7 +- extensions/browser/src/browser-tool.ts | 20 +- .../browser/src/browser/browser-utils.test.ts | 2 +- .../client-fetch.attach-only.e2e.test.ts | 18 +- .../client-fetch.loopback-auth.test.ts | 1 + .../browser/src/browser/client-fetch.ts | 10 +- .../src/browser/config-refresh-source.ts | 4 +- .../browser/control-auth.auto-token.test.ts | 27 +- .../browser/src/browser/control-auth.ts | 18 +- .../control-service.plugin-disabled.test.ts | 1 + .../src/browser/profiles-service.test.ts | 34 +- .../browser/src/browser/profiles-service.ts | 16 +- .../src/browser/resolved-config-refresh.ts | 5 +- ...server-context.hot-reload-profiles.test.ts | 45 +-- .../browser/server.auth-fail-closed.test.ts | 18 +- .../server.control-server.test-harness.ts | 1 + ...te-disabled-does-not-block-storage.test.ts | 22 +- .../src/browser/session-tab-cleanup.ts | 4 +- .../src/cli/browser-cli-inspect.test.ts | 12 +- .../browser/src/cli/browser-cli-inspect.ts | 4 +- extensions/browser/src/config/config.ts | 5 +- extensions/browser/src/control-service.ts | 4 +- extensions/browser/src/core-api.ts | 2 +- .../browser/src/gateway/browser-request.ts | 7 +- .../src/node-host/invoke-browser.test.ts | 1 + .../browser/src/node-host/invoke-browser.ts | 8 +- extensions/browser/src/server.ts | 4 +- extensions/codex/index.ts | 9 +- extensions/diffs/src/browser.test.ts | 6 +- extensions/diffs/src/plugin.ts | 12 +- extensions/discord/src/monitor.test.ts | 2 +- .../monitor/acp-bind-here.integration.test.ts | 2 +- extensions/discord/src/monitor/listeners.ts | 2 +- .../message-handler.preflight.types.ts | 6 +- .../discord/src/monitor/native-command-ui.ts | 18 +- .../monitor/native-command.options.test.ts | 14 +- .../native-command.think-autocomplete.test.ts | 10 +- .../discord/src/monitor/native-command.ts | 12 +- extensions/discord/src/monitor/provider.ts | 4 +- extensions/feishu/src/channel.ts | 7 +- extensions/feishu/src/dynamic-agent.ts | 10 +- .../imessage/src/monitor/monitor-provider.ts | 4 +- extensions/imessage/src/probe.ts | 4 +- extensions/irc/src/monitor.ts | 2 +- extensions/line/src/bot.ts | 4 +- extensions/line/src/channel.logout.test.ts | 20 +- extensions/line/src/gateway.ts | 5 +- extensions/matrix/src/actions.test.ts | 2 +- extensions/matrix/src/cli.test.ts | 35 +- extensions/matrix/src/cli.ts | 25 +- .../src/matrix/actions/messages.test.ts | 2 +- .../src/matrix/actions/verification.test.ts | 2 +- .../matrix/client-resolver.test-helpers.ts | 2 +- .../matrix/src/matrix/client/storage.ts | 3 +- .../matrix/src/matrix/credentials-read.ts | 3 +- .../matrix/src/matrix/draft-stream.test.ts | 4 +- .../matrix/monitor/handler.test-helpers.ts | 2 +- .../matrix/src/matrix/monitor/handler.ts | 2 +- .../matrix/src/matrix/monitor/index.test.ts | 5 +- extensions/matrix/src/matrix/monitor/index.ts | 11 +- .../matrix/src/matrix/monitor/replies.test.ts | 2 +- .../matrix/src/matrix/monitor/startup.test.ts | 6 +- .../matrix/src/matrix/monitor/startup.ts | 8 +- extensions/matrix/src/matrix/send.test.ts | 2 +- extensions/matrix/src/profile-update.ts | 7 +- extensions/matrix/src/test-runtime.ts | 18 +- .../mattermost/src/mattermost/monitor.ts | 2 +- extensions/memory-core/index.ts | 25 +- .../memory-core/src/cli.host.runtime.ts | 2 +- extensions/memory-core/src/cli.runtime.ts | 4 +- extensions/memory-core/src/cli.test.ts | 8 +- .../memory-core/src/dreaming-command.test.ts | 16 +- .../memory-core/src/dreaming-command.ts | 7 +- .../memory-core/src/dreaming-narrative.ts | 4 +- extensions/memory-core/src/dreaming.test.ts | 18 +- extensions/memory-core/src/dreaming.ts | 2 +- extensions/memory-lancedb/index.test.ts | 12 +- extensions/memory-lancedb/index.ts | 9 +- extensions/memory-wiki/cli-metadata.test.ts | 1 + extensions/migrate-claude/apply.ts | 57 ++- extensions/migrate-claude/config.ts | 29 +- .../migrate-claude/test/provider-helpers.ts | 62 ++- extensions/migrate-hermes/apply.ts | 37 +- extensions/migrate-hermes/config.test.ts | 28 +- extensions/migrate-hermes/config.ts | 29 +- .../migrate-hermes/files-and-skills.test.ts | 12 +- extensions/migrate-hermes/model.ts | 39 +- .../migrate-hermes/test/provider-helpers.ts | 45 ++- extensions/msteams/src/messenger.ts | 4 +- extensions/nextcloud-talk/src/gateway.ts | 5 +- .../nextcloud-talk/src/monitor-runtime.ts | 2 +- extensions/nostr/index.ts | 25 +- extensions/openai/openai.live.test.ts | 5 +- extensions/phone-control/index.test.ts | 7 +- extensions/phone-control/index.ts | 15 +- extensions/qqbot/src/bridge/bootstrap.ts | 4 +- extensions/qqbot/src/bridge/gateway.ts | 7 +- extensions/qqbot/src/channel.ts | 16 +- .../engine/commands/slash-commands-impl.ts | 30 +- .../src/engine/config/credential-backup.ts | 2 +- extensions/signal/src/monitor.ts | 4 +- extensions/skill-workshop/index.test.ts | 10 +- extensions/skill-workshop/index.ts | 9 +- .../slack/src/monitor/config.runtime.ts | 2 +- .../slack/src/monitor/events/channels.ts | 9 +- extensions/slack/src/monitor/provider.ts | 4 +- .../src/channel.integration.test.ts | 3 + .../synology-chat/src/channel.test-mocks.ts | 7 +- extensions/synology-chat/src/inbound-turn.ts | 2 +- extensions/talk-voice/index.test.ts | 50 ++- extensions/talk-voice/index.ts | 12 +- extensions/telegram/src/bot-core.ts | 6 +- extensions/telegram/src/bot-deps.ts | 8 +- .../telegram/src/bot-handlers.runtime.ts | 15 +- .../src/bot-message-context.runtime.ts | 2 +- .../bot-message-context.topic-agentid.test.ts | 8 +- .../telegram/src/bot-message-context.ts | 2 +- .../telegram/src/bot-message-context.types.ts | 2 +- .../telegram/src/bot-message-dispatch.test.ts | 2 +- .../src/bot-native-command-deps.runtime.ts | 8 +- .../bot-native-commands.menu-test-support.ts | 2 +- .../bot-native-commands.session-meta.test.ts | 2 +- .../src/bot-native-commands.test-helpers.ts | 2 +- .../telegram/src/bot-native-commands.ts | 2 +- .../bot.create-telegram-bot.test-harness.ts | 45 +-- .../src/bot.create-telegram-bot.test.ts | 2 +- ...dia-file-path-no-file-download.e2e.test.ts | 13 +- .../telegram/src/bot.media.e2e-harness.ts | 4 +- extensions/telegram/src/channel.ts | 5 +- extensions/telegram/src/monitor.test.ts | 10 +- extensions/telegram/src/monitor.ts | 4 +- .../src/target-writeback.test-shared.ts | 6 + extensions/telegram/src/target-writeback.ts | 9 +- extensions/thread-ownership/index.test.ts | 2 +- extensions/thread-ownership/index.ts | 6 +- extensions/tlon/src/monitor/index.ts | 3 +- .../whatsapp/src/auto-reply/config.runtime.ts | 2 +- .../auto-reply/heartbeat-runner.runtime.ts | 2 +- .../src/auto-reply/heartbeat-runner.ts | 10 +- .../whatsapp/src/auto-reply/mentions.ts | 7 +- extensions/whatsapp/src/auto-reply/monitor.ts | 10 +- .../src/auto-reply/monitor/ack-reaction.ts | 4 +- .../src/auto-reply/monitor/broadcast.ts | 6 +- .../auto-reply/monitor/group-activation.ts | 7 +- .../src/auto-reply/monitor/group-gating.ts | 4 +- .../src/auto-reply/monitor/last-route.ts | 5 +- .../monitor/message-line.runtime.ts | 4 +- .../src/auto-reply/monitor/message-line.ts | 4 +- .../on-message.audio-preflight.test.ts | 2 +- .../src/auto-reply/monitor/on-message.ts | 6 +- .../src/auto-reply/monitor/runtime-api.ts | 2 +- .../src/auto-reply/session-snapshot.ts | 5 +- .../auto-reply/web-auto-reply-monitor.test.ts | 4 +- extensions/whatsapp/src/login-qr.ts | 6 +- .../whatsapp/src/login.coverage.test.ts | 2 +- extensions/whatsapp/src/login.ts | 4 +- extensions/whatsapp/src/test-helpers.ts | 1 + extensions/xai/xai.live.test.ts | 4 +- package.json | 3 +- scripts/changed-lanes.mjs | 5 + scripts/check.mjs | 8 +- scripts/dev/test-device-pair-telegram.ts | 4 +- scripts/live-docker-normalize-config.ts | 4 +- src/acp/runtime/session-meta.ts | 6 +- src/acp/server.startup.test.ts | 14 +- src/acp/server.ts | 4 +- src/agents/acp-spawn.test.ts | 2 +- src/agents/acp-spawn.ts | 4 +- .../agent-command.live-model-switch.test.ts | 2 +- src/agents/agent-runtime-config.ts | 4 +- src/agents/anthropic.setup-token.live.test.ts | 4 +- src/agents/auth-profiles/oauth.ts | 4 +- src/agents/cli-runner.bundle-mcp.e2e.test.ts | 3 + src/agents/context.eager-warmup.test.ts | 2 +- src/agents/context.lookup.test.ts | 10 +- src/agents/context.test.ts | 2 +- src/agents/context.ts | 4 +- src/agents/live-cache-test-support.ts | 4 +- src/agents/model-catalog.ts | 4 +- src/agents/models-config.ts | 4 +- src/agents/models.profiles.live.test.ts | 4 +- .../openai-reasoning-compat.live.test.ts | 6 +- src/agents/openclaw-plugin-tools.ts | 16 +- src/agents/openclaw-tools.agents.test.ts | 4 +- ...w-tools.browser-plugin.integration.test.ts | 72 ++++ src/agents/openclaw-tools.plugin-context.ts | 4 +- .../openclaw-tools.session-status.test.ts | 2 +- ...openclaw-tools.sessions-visibility.test.ts | 2 +- src/agents/openclaw-tools.sessions.test.ts | 2 +- ...subagents.sessions-spawn.allowlist.test.ts | 2 +- ...s.subagents.sessions-spawn.test-harness.ts | 12 +- .../openclaw-tools.subagents.test-harness.ts | 6 +- src/agents/sandbox/context.ts | 3 +- src/agents/sandbox/manage.test.ts | 8 +- src/agents/sandbox/manage.ts | 10 +- src/agents/sandbox/prune.ts | 6 +- src/agents/sessions-spawn-hooks.test.ts | 2 +- .../subagent-announce-delivery.runtime.ts | 2 +- src/agents/subagent-announce-delivery.test.ts | 10 +- src/agents/subagent-announce-delivery.ts | 14 +- src/agents/subagent-announce-output.ts | 8 +- .../subagent-announce.format.e2e.test.ts | 6 +- src/agents/subagent-announce.runtime.ts | 2 +- src/agents/subagent-announce.test-support.ts | 4 +- src/agents/subagent-announce.test.ts | 6 +- src/agents/subagent-announce.timeout.test.ts | 6 +- src/agents/subagent-announce.ts | 8 +- src/agents/subagent-orphan-recovery.test.ts | 2 +- src/agents/subagent-orphan-recovery.ts | 4 +- src/agents/subagent-registry-helpers.ts | 8 +- src/agents/subagent-registry-run-manager.ts | 10 +- ...agent-registry.announce-loop-guard.test.ts | 6 +- .../subagent-registry.archive.e2e.test.ts | 2 +- ...registry.lifecycle-retry-grace.e2e.test.ts | 8 +- .../subagent-registry.nested.e2e.test.ts | 2 +- ...agent-registry.persistence.test-support.ts | 2 +- .../subagent-registry.steer-restart.test.ts | 2 +- src/agents/subagent-registry.test.ts | 6 +- src/agents/subagent-registry.ts | 16 +- src/agents/subagent-spawn.attachments.test.ts | 2 +- .../subagent-spawn.depth-limits.test.ts | 2 +- ...ent-spawn.mode-session-diagnostics.test.ts | 4 +- .../subagent-spawn.model-session.test.ts | 2 +- src/agents/subagent-spawn.runtime.ts | 2 +- src/agents/subagent-spawn.test-helpers.ts | 7 +- src/agents/subagent-spawn.test.ts | 2 +- .../subagent-spawn.thread-binding.test.ts | 2 +- src/agents/subagent-spawn.ts | 8 +- src/agents/subagent-spawn.workspace.test.ts | 2 +- src/agents/tool-replay-repair.live.test.ts | 6 +- src/agents/tools/agents-list-tool.test.ts | 2 +- src/agents/tools/agents-list-tool.ts | 6 +- src/agents/tools/cron-tool.ts | 6 +- .../tools/embedded-gateway-stub.runtime.ts | 2 +- .../tools/embedded-gateway-stub.test.ts | 4 +- src/agents/tools/embedded-gateway-stub.ts | 6 +- src/agents/tools/gateway.test.ts | 2 +- src/agents/tools/gateway.ts | 4 +- src/agents/tools/image-generate-tool.ts | 4 +- src/agents/tools/message-tool.test.ts | 10 +- src/agents/tools/message-tool.ts | 6 +- src/agents/tools/music-generate-tool.test.ts | 2 +- src/agents/tools/music-generate-tool.ts | 4 +- src/agents/tools/session-status-tool.ts | 10 +- src/agents/tools/sessions-helpers.ts | 4 +- src/agents/tools/sessions-history-tool.ts | 4 +- src/agents/tools/sessions-list-tool.ts | 4 +- src/agents/tools/sessions-spawn-tool.ts | 4 +- src/agents/tools/sessions.test.ts | 2 +- src/agents/tools/subagents-tool.ts | 4 +- src/agents/tools/tts-tool.ts | 4 +- src/agents/tools/video-generate-tool.ts | 4 +- src/auto-reply/reply.block-streaming.test.ts | 2 +- .../reply/commands-allowlist.test.ts | 22 +- src/auto-reply/reply/commands-allowlist.ts | 7 +- src/auto-reply/reply/commands-config.ts | 12 +- src/auto-reply/reply/commands-gating.test.ts | 14 +- src/auto-reply/reply/commands-plugins.test.ts | 26 +- src/auto-reply/reply/commands-plugins.ts | 7 +- .../reply/commands-subagents.test-mocks.ts | 2 +- ...ispatch-from-config.reply-dispatch.test.ts | 6 + ...ispatch-from-config.shared.test-harness.ts | 39 ++ .../reply/dispatch-from-config.test.ts | 12 +- src/auto-reply/reply/dispatch-from-config.ts | 26 +- .../reply/dispatch-from-config.types.ts | 2 +- src/auto-reply/reply/get-reply-fast-path.ts | 9 +- .../reply/get-reply.config-override.test.ts | 35 +- .../reply/get-reply.fast-path.test.ts | 8 +- src/auto-reply/reply/get-reply.test-mocks.ts | 2 +- src/auto-reply/reply/get-reply.ts | 4 +- .../reply/runtime-plugins.runtime.ts | 1 + src/auto-reply/reply/session-usage.ts | 4 +- src/cli/capability-cli.test.ts | 1 + src/cli/capability-cli.ts | 52 +-- src/cli/channel-auth.test.ts | 1 + src/cli/channel-auth.ts | 6 +- src/cli/daemon-cli/install.test.ts | 18 +- .../lifecycle-core.config-guard.test.ts | 1 + src/cli/daemon-cli/lifecycle-core.test.ts | 1 + src/cli/daemon-cli/lifecycle.test.ts | 1 + src/cli/daemon-cli/status.gather.test.ts | 1 + src/cli/directory-cli.test.ts | 1 + src/cli/directory-cli.ts | 4 +- src/cli/dns-cli.ts | 4 +- src/cli/gateway-cli/dev.ts | 43 ++- src/cli/gateway-cli/run-loop.test.ts | 1 + src/cli/gateway-cli/run-loop.ts | 4 +- src/cli/hooks-cli.ts | 10 +- src/cli/pairing-cli.test.ts | 1 + src/cli/pairing-cli.ts | 4 +- src/cli/plugins-cli-test-helpers.ts | 4 +- src/cli/plugins-cli.ts | 8 +- src/cli/plugins-update-command.ts | 4 +- src/cli/qr-cli.test.ts | 5 +- src/cli/qr-cli.ts | 4 +- src/cli/qr-dashboard.integration.test.ts | 1 + src/cli/security-cli.test.ts | 1 + src/cli/security-cli.ts | 4 +- src/cli/send-runtime/channel-outbound-send.ts | 4 +- src/cli/skills-cli.commands.test.ts | 1 + src/cli/skills-cli.ts | 6 +- src/commands/agent-via-gateway.test.ts | 2 +- src/commands/agent-via-gateway.ts | 4 +- src/commands/agent.acp.test.ts | 5 +- src/commands/agent.runtime-config.test.ts | 1 + src/commands/agent.test.ts | 1 + src/commands/agents.bind.test-support.ts | 2 + src/commands/channels.resolve.test.ts | 1 + src/commands/channels/resolve.ts | 8 +- src/commands/cleanup-plan.ts | 4 +- src/commands/configure.daemon.test.ts | 1 + src/commands/configure.daemon.ts | 4 +- src/commands/doctor-gateway-services.test.ts | 34 +- src/commands/doctor-gateway-services.ts | 7 +- src/commands/flows.test.ts | 1 + src/commands/flows.ts | 4 +- .../gateway-install-token.persist.runtime.ts | 7 +- src/commands/gateway-install-token.test.ts | 37 +- src/commands/gateway-install-token.ts | 11 +- src/commands/health.snapshot.test.ts | 1 + src/commands/health.ts | 4 +- src/commands/message.test.ts | 1 + src/commands/message.ts | 4 +- src/commands/migrate.test.ts | 1 + src/commands/migrate.ts | 14 +- src/commands/migrate/context.ts | 4 +- src/commands/migrate/providers.ts | 4 +- src/commands/sandbox-explain.test.ts | 1 + src/commands/sandbox-explain.ts | 4 +- src/commands/sessions-cleanup.test.ts | 1 + src/commands/sessions-cleanup.ts | 4 +- .../sessions.default-agent-store.test.ts | 1 + src/commands/sessions.test-helpers.ts | 1 + src/commands/sessions.ts | 4 +- src/commands/setup.test.ts | 4 +- src/commands/setup.ts | 23 +- src/commands/status.summary.test.ts | 4 + src/commands/status.summary.ts | 9 +- src/commands/status.test.ts | 1 + src/commands/tasks.ts | 6 +- ...ig.multi-agent-agentdir-validation.test.ts | 6 +- src/config/config.talk-validation.test.ts | 4 +- src/config/config.ts | 2 + src/config/io.ts | 7 + src/config/mutate.test.ts | 119 +++++- src/config/mutate.ts | 107 +++++- src/config/runtime-schema.test.ts | 1 + src/config/runtime-schema.ts | 4 +- src/config/runtime-snapshot.ts | 52 +++ src/config/schema.base.generated.ts | 4 +- src/config/schema.help.ts | 2 +- src/config/sessions/delivery-info.test.ts | 2 +- src/config/sessions/delivery-info.ts | 4 +- src/config/sessions/main-session.runtime.ts | 4 +- .../sessions/store-maintenance-runtime.ts | 4 +- .../store.pruning.integration.test.ts | 6 +- src/config/sessions/store.ts | 2 +- src/flows/doctor-health-contributions.ts | 7 +- .../android-node.capabilities.live.test.ts | 6 +- src/gateway/call.test.ts | 94 ++--- src/gateway/call.ts | 21 +- src/gateway/config-reload.test.ts | 51 +++ src/gateway/config-reload.ts | 37 +- src/gateway/connection-details.ts | 4 +- src/gateway/embeddings-http.ts | 4 +- src/gateway/exec-approval-ios-push.test.ts | 2 +- src/gateway/exec-approval-ios-push.ts | 4 +- src/gateway/gateway-acp-bind.live.test.ts | 8 +- .../gateway-models.profiles.live.test.ts | 6 +- src/gateway/gateway.test.ts | 7 +- src/gateway/http-auth-utils.ts | 6 +- .../http-utils.authorize-request.test.ts | 2 +- src/gateway/http-utils.model-override.test.ts | 2 +- src/gateway/http-utils.ts | 8 +- src/gateway/mcp-http.test.ts | 2 +- src/gateway/mcp-http.ts | 4 +- src/gateway/models-http.ts | 4 +- ...server-channels.approval-bootstrap.test.ts | 2 +- src/gateway/server-channels.test.ts | 12 +- src/gateway/server-channels.ts | 23 +- src/gateway/server-chat.agent-events.test.ts | 8 +- src/gateway/server-chat.ts | 6 +- src/gateway/server-cron.test.ts | 2 +- src/gateway/server-cron.ts | 6 +- src/gateway/server-http.probe.test.ts | 6 +- src/gateway/server-http.ts | 8 +- .../server-methods/agent.create-event.test.ts | 7 +- src/gateway/server-methods/agent.test.ts | 6 +- src/gateway/server-methods/agent.ts | 7 +- .../server-methods/agents-mutate.test.ts | 8 +- src/gateway/server-methods/agents.ts | 55 ++- .../server-methods/channels.start.test.ts | 9 +- .../server-methods/channels.status.test.ts | 7 +- src/gateway/server-methods/channels.ts | 6 +- src/gateway/server-methods/commands.test.ts | 4 +- src/gateway/server-methods/commands.ts | 16 +- .../server-methods/config.shared-auth.test.ts | 2 + src/gateway/server-methods/config.ts | 22 +- src/gateway/server-methods/cron.ts | 7 +- .../server-methods/cron.validation.test.ts | 15 +- src/gateway/server-methods/doctor.test.ts | 31 +- src/gateway/server-methods/doctor.ts | 27 +- .../server-methods/models-auth-status.test.ts | 20 +- .../server-methods/models-auth-status.ts | 6 +- src/gateway/server-methods/models.ts | 3 +- .../server-methods/nodes-pending.test.ts | 2 + src/gateway/server-methods/nodes-pending.ts | 9 +- .../server-methods/nodes.invoke-wake.test.ts | 15 +- src/gateway/server-methods/nodes.ts | 39 +- src/gateway/server-methods/push.test.ts | 12 +- src/gateway/server-methods/push.ts | 8 +- src/gateway/server-methods/send.test.ts | 3 +- src/gateway/server-methods/send.ts | 7 +- .../sessions.send-deleted-agent.test.ts | 1 + .../sessions.send-followup-status.test.ts | 1 + src/gateway/server-methods/sessions.ts | 41 +- src/gateway/server-methods/shared-types.ts | 1 + .../server-methods/skills.clawhub.test.ts | 12 +- .../skills.search-detail.test.ts | 2 +- src/gateway/server-methods/skills.ts | 25 +- .../skills.update.normalizes-api-key.test.ts | 10 +- src/gateway/server-methods/talk.test.ts | 9 +- src/gateway/server-methods/talk.ts | 14 +- .../server-methods/tools-catalog.test.ts | 4 +- src/gateway/server-methods/tools-catalog.ts | 16 +- .../server-methods/tools-effective.runtime.ts | 1 - .../server-methods/tools-effective.test.ts | 6 +- src/gateway/server-methods/tools-effective.ts | 5 +- src/gateway/server-methods/tts.test.ts | 10 +- src/gateway/server-methods/tts.ts | 33 +- src/gateway/server-methods/update.test.ts | 3 +- src/gateway/server-methods/update.ts | 3 +- .../usage.sessions-usage.test.ts | 12 +- src/gateway/server-methods/usage.ts | 19 +- src/gateway/server-node-events.runtime.ts | 2 +- src/gateway/server-node-events.test.ts | 4 +- src/gateway/server-node-events.ts | 8 +- src/gateway/server-request-context.test.ts | 1 + src/gateway/server-request-context.ts | 2 + src/gateway/server-session-key.test.ts | 2 +- src/gateway/server-session-key.ts | 4 +- src/gateway/server-startup-config.ts | 7 +- src/gateway/server-startup-early.test.ts | 2 +- src/gateway/server-startup-early.ts | 4 +- src/gateway/server.auth.modes.suite.ts | 13 +- src/gateway/server.auth.shared.ts | 36 +- src/gateway/server.cron.test.ts | 17 +- src/gateway/server.impl.ts | 17 +- ...sessions.gateway-server-sessions-a.test.ts | 29 +- src/gateway/server/health-state.ts | 4 +- src/gateway/server/hooks.agent-trust.test.ts | 2 +- src/gateway/server/hooks.ts | 4 +- .../server/ws-connection/message-handler.ts | 8 +- src/gateway/session-kill-http.test.ts | 2 +- src/gateway/session-kill-http.ts | 4 +- src/gateway/session-reset-service.ts | 4 +- src/gateway/session-transcript-key.test.ts | 2 +- src/gateway/session-transcript-key.ts | 4 +- src/gateway/session-utils.ts | 4 +- .../sessions-history-http.revocation.test.ts | 2 +- src/gateway/sessions-history-http.ts | 6 +- src/gateway/talk.test-helpers.ts | 3 +- src/gateway/test-helpers.config-runtime.ts | 1 - src/gateway/test-helpers.mocks.ts | 3 +- .../tools-invoke-http.cron-regression.test.ts | 2 +- src/gateway/tools-invoke-http.test.ts | 2 +- src/hooks/gmail-ops.ts | 11 +- src/hooks/loader.ts | 2 +- src/infra/approval-turn-source.test.ts | 2 +- src/infra/approval-turn-source.ts | 4 +- src/infra/channel-summary.ts | 4 +- src/infra/exec-approval-forwarder.ts | 4 +- src/infra/exec-approval-surface.test.ts | 2 +- src/infra/exec-approval-surface.ts | 4 +- src/infra/heartbeat-runner.ts | 6 +- .../outbound/cfg-threading.guard.test.ts | 14 +- src/infra/outbound/message.config.runtime.ts | 2 +- src/infra/outbound/message.ts | 4 +- ...rovider-usage-plugin-runtime.test-mocks.ts | 2 +- src/infra/provider-usage.auth.ts | 6 +- src/infra/provider-usage.load.plugin.test.ts | 2 +- src/infra/provider-usage.load.ts | 4 +- src/logging/redact.test.ts | 18 + src/logging/redact.ts | 3 + ...erver.shutdown-unhandled-rejection.test.ts | 2 +- src/mcp/channel-server.ts | 4 +- src/mcp/plugin-tools-serve.ts | 6 +- src/memory-host-sdk/runtime-core.ts | 6 +- src/node-host/invoke-system-run.test.ts | 2 +- src/node-host/invoke-system-run.ts | 10 +- src/node-host/runner.ts | 6 +- src/plugin-sdk/browser-config-runtime.ts | 20 + src/plugin-sdk/config-runtime.ts | 15 + src/plugin-sdk/memory-core.ts | 6 + src/plugins/cli.test.ts | 1 + src/plugins/cli.ts | 4 +- .../contracts/boundary-invariants.test.ts | 6 +- .../deprecated-internal-config-api.test.ts | 355 ++++++++++++++++++ src/plugins/runtime/load-context.test.ts | 1 + src/plugins/runtime/load-context.ts | 4 +- .../runtime/metadata-registry-loader.test.ts | 1 + src/plugins/runtime/runtime-config.test.ts | 66 ++++ src/plugins/runtime/runtime-config.ts | 27 +- .../runtime/runtime-plugin-boundary.ts | 4 +- src/plugins/runtime/types-core.ts | 62 ++- src/plugins/status.test.ts | 1 + src/plugins/status.ts | 10 +- src/plugins/tool-types.ts | 2 + src/secrets/apply.ts | 17 +- src/secrets/runtime-core-snapshots.test.ts | 8 +- ...runtime-openai-file-fixture.test-helper.ts | 4 +- .../runtime.gateway-auth.integration.test.ts | 8 +- src/security/fix.ts | 14 +- src/tui/embedded-backend.test.ts | 1 + src/tui/embedded-backend.ts | 10 +- src/tui/gateway-chat.test.ts | 1 + src/tui/gateway-chat.ts | 4 +- src/tui/tui.ts | 4 +- .../modelcontextprotocol-sdk-subpaths.d.ts | 20 + src/wizard/setup.migration-import.ts | 4 +- src/wizard/setup.test.ts | 18 +- src/wizard/setup.ts | 15 +- test/gateway.multi.e2e.test.ts | 2 +- test/helpers/plugins/plugin-runtime-mock.ts | 18 + 531 files changed, 3502 insertions(+), 1646 deletions(-) create mode 100644 src/auto-reply/reply/runtime-plugins.runtime.ts create mode 100644 src/plugins/contracts/deprecated-internal-config-api.test.ts create mode 100644 src/plugins/runtime/runtime-config.test.ts create mode 100644 src/types/modelcontextprotocol-sdk-subpaths.d.ts diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index aaa19906bbe..07ff0a2de67 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -3546f416ff22ead14952cd105c7b88e3b7b76d5ddc10269e73f69ed1950f0603 config-baseline.json -b29ade2d1d2415b030b4d5ec36097a93ab4ea943b7d2a52da95829be1c28fc2a config-baseline.core.json +5027142b42acd038bb3cd15e53a0d45293103448a3aee1072500352095e14242 config-baseline.json +ecb702eee54bcb697916944440e13208ac7a640a8e07f44072bb79e9284ca994 config-baseline.core.json 07963db49502132f26db396c56b36e018b110e6c55a68b3cb012d3ec96f43901 config-baseline.channel.json ed65cefbef96f034ce2b73069d9d5bacc341a43489ff9b20a34d40956b877f79 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index e74323d68b1..98e90d0c9a0 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -6eabbe9e1e568fa1bc02539bd21bb6cd463d609f2ad4573d0cbf116ce39a28f9 plugin-sdk-api-baseline.json -c5a5ba7c051ab741b1cdfb36b23f13e6aad9fbe17ba3fa92c4833c0490a35181 plugin-sdk-api-baseline.jsonl +74344f185b3149695443bf8815c9dd784daf9c0b8118ecc54129dc57899e9564 plugin-sdk-api-baseline.json +7b84c2f1e5743dac9c764fdee6d3b23e64553516c409f4a24f009a36c40d64e8 plugin-sdk-api-baseline.jsonl diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index d0245724117..02534281d32 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -90,7 +90,13 @@ describe("active-memory plugin", () => { resolveStateDir: () => stateDir, }, config: { + current: () => configFile, loadConfig: () => configFile, + replaceConfigFile: vi.fn( + async ({ nextConfig }: { nextConfig: Record }) => { + configFile = nextConfig; + }, + ), writeConfigFile: vi.fn(async (nextConfig: Record) => { configFile = nextConfig; }), @@ -275,7 +281,7 @@ describe("active-memory plugin", () => { }); expect(offResult.text).toBe("Active Memory: off globally."); - expect(api.runtime.config.writeConfigFile).toHaveBeenCalledTimes(1); + expect(api.runtime.config.replaceConfigFile).toHaveBeenCalledTimes(1); expect(configFile).toMatchObject({ plugins: { entries: { diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index e1bf9f3b1b0..3b8bb2a35df 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -1932,7 +1932,9 @@ export default definePluginEntry({ warnDeprecatedModelFallbackPolicy(api.pluginConfig); const refreshLiveConfigFromRuntime = () => { const livePluginConfig = resolveLivePluginConfigObject( - api.runtime.config?.loadConfig, + api.runtime.config?.current + ? () => api.runtime.config.current() as OpenClawConfig + : undefined, "active-memory", api.pluginConfig as Record, ); @@ -1953,7 +1955,7 @@ export default definePluginEntry({ return { text: formatActiveMemoryCommandHelp() }; } if (isGlobal) { - const currentConfig = api.runtime.config.loadConfig(); + const currentConfig = api.runtime.config.current() as OpenClawConfig; if (action === "status") { return { text: `Active Memory: ${isActiveMemoryGloballyEnabled(currentConfig) ? "on" : "off"} globally.`, @@ -1961,13 +1963,19 @@ export default definePluginEntry({ } if (action === "on" || action === "enable" || action === "enabled") { const nextConfig = updateActiveMemoryGlobalEnabledInConfig(currentConfig, true); - await api.runtime.config.writeConfigFile(nextConfig); + await api.runtime.config.replaceConfigFile({ + nextConfig, + afterWrite: { mode: "auto" }, + }); refreshLiveConfigFromRuntime(); return { text: "Active Memory: on globally." }; } if (action === "off" || action === "disable" || action === "disabled") { const nextConfig = updateActiveMemoryGlobalEnabledInConfig(currentConfig, false); - await api.runtime.config.writeConfigFile(nextConfig); + await api.runtime.config.replaceConfigFile({ + nextConfig, + afterWrite: { mode: "auto" }, + }); refreshLiveConfigFromRuntime(); return { text: "Active Memory: off globally." }; } diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index 11fb2593db1..ef8b7d6d4e4 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -50,11 +50,24 @@ const { bluebubblesMessageActions } = await importFreshModule(value: T | undefined, name: string): T { + if (value === undefined) { + throw new Error(`${name} is not registered`); + } + return value; +} + describe("bluebubblesMessageActions", () => { - const describeMessageTool = bluebubblesMessageActions.describeMessageTool!; - const supportsAction = bluebubblesMessageActions.supportsAction!; - const extractToolSend = bluebubblesMessageActions.extractToolSend!; - const handleAction = bluebubblesMessageActions.handleAction!; + const describeMessageTool = requireDefined( + bluebubblesMessageActions.describeMessageTool, + "describeMessageTool", + ); + const supportsAction = requireDefined(bluebubblesMessageActions.supportsAction, "supportsAction"); + const extractToolSend = requireDefined( + bluebubblesMessageActions.extractToolSend, + "extractToolSend", + ); + const handleAction = requireDefined(bluebubblesMessageActions.handleAction, "handleAction"); const callHandleAction = (ctx: Omit[0], "channel">) => handleAction({ channel: "bluebubbles", ...ctx }); const blueBubblesConfig = (): OpenClawConfig => ({ diff --git a/extensions/browser/src/browser-tool.actions.ts b/extensions/browser/src/browser-tool.actions.ts index 687f8f11373..1471953b20b 100644 --- a/extensions/browser/src/browser-tool.actions.ts +++ b/extensions/browser/src/browser-tool.actions.ts @@ -6,9 +6,9 @@ import { browserSnapshot, browserTabs, getBrowserProfileCapabilities, + getRuntimeConfig, imageResultFromFile, jsonResult, - loadConfig, normalizeOptionalString, readStringValue, resolveBrowserConfig, @@ -22,8 +22,8 @@ const browserToolActionDeps = { browserConsoleMessages, browserSnapshot, browserTabs, + getRuntimeConfig, imageResultFromFile, - loadConfig, }; const BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS = 5_000; @@ -70,7 +70,7 @@ function existingSessionRejectsActTimeout(request: BrowserActRequest): boolean { } function usesExistingSessionProfile(profileName: string | undefined): boolean { - const cfg = browserToolActionDeps.loadConfig(); + const cfg = browserToolActionDeps.getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const profile = resolveProfile(resolved, profileName ?? resolved.defaultProfile); return profile ? getBrowserProfileCapabilities(profile).usesChromeMcp : false; @@ -91,7 +91,7 @@ function withConfiguredActTimeout( return request; } - const cfg = browserToolActionDeps.loadConfig(); + const cfg = browserToolActionDeps.getRuntimeConfig(); const configuredTimeout = normalizePositiveTimeoutMs(cfg.browser?.actionTimeoutMs) ?? DEFAULT_BROWSER_ACTION_TIMEOUT_MS; return { ...typedRequest, timeoutMs: configuredTimeout } as BrowserActRequest; @@ -122,7 +122,7 @@ export const __testing = { browserSnapshot: typeof browserSnapshot; browserTabs: typeof browserTabs; imageResultFromFile: typeof imageResultFromFile; - loadConfig: typeof loadConfig; + getRuntimeConfig: typeof getRuntimeConfig; }> | null, ) { browserToolActionDeps.browserAct = overrides?.browserAct ?? browserAct; @@ -132,7 +132,7 @@ export const __testing = { browserToolActionDeps.browserTabs = overrides?.browserTabs ?? browserTabs; browserToolActionDeps.imageResultFromFile = overrides?.imageResultFromFile ?? imageResultFromFile; - browserToolActionDeps.loadConfig = overrides?.loadConfig ?? loadConfig; + browserToolActionDeps.getRuntimeConfig = overrides?.getRuntimeConfig ?? getRuntimeConfig; }, }; @@ -250,7 +250,7 @@ function isChromeStaleTargetError(profile: string | undefined, err: unknown): bo const msg = String(err); return msg.includes("404:") && msg.includes("tab not found"); } - const cfg = browserToolActionDeps.loadConfig(); + const cfg = browserToolActionDeps.getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const browserProfile = resolveProfile(resolved, profile); if (!browserProfile || !getBrowserProfileCapabilities(browserProfile).usesChromeMcp) { @@ -326,7 +326,7 @@ export async function executeSnapshotAction(params: { onTabActivity?: (targetId: string | undefined) => void; }): Promise> { const { input, baseUrl, profile, proxyRequest } = params; - const snapshotDefaults = browserToolActionDeps.loadConfig().browser?.snapshotDefaults; + const snapshotDefaults = browserToolActionDeps.getRuntimeConfig().browser?.snapshotDefaults; const format: "ai" | "aria" | undefined = input.snapshotFormat === "ai" ? "ai" : input.snapshotFormat === "aria" ? "aria" : undefined; const formatExplicit = format !== undefined; diff --git a/extensions/browser/src/browser-tool.runtime.ts b/extensions/browser/src/browser-tool.runtime.ts index 1ecf4c30982..4d39ab21d7a 100644 --- a/extensions/browser/src/browser-tool.runtime.ts +++ b/extensions/browser/src/browser-tool.runtime.ts @@ -1,4 +1,4 @@ -export { loadConfig } from "openclaw/plugin-sdk/browser-config-runtime"; +export { getRuntimeConfig } from "openclaw/plugin-sdk/browser-config-runtime"; export { callGatewayTool, imageResultFromFile, diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index 723323d275f..d301493cd47 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -142,7 +142,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async () => { ); return { ...actual, - loadConfig: configMocks.loadConfig, + getRuntimeConfig: configMocks.loadConfig, }; }); @@ -193,6 +193,7 @@ vi.mock("./browser-tool.runtime.js", () => { ...configMocks, ...gatewayMocks, ...sessionTabRegistryMocks, + getRuntimeConfig: configMocks.loadConfig, applyBrowserProxyPaths: vi.fn(), getBrowserProfileCapabilities: (profile: Record) => ({ usesChromeMcp: profile.driver === "existing-session", @@ -269,7 +270,7 @@ function resetBrowserToolMocks() { browserStatus: browserClientMocks.browserStatus as never, browserStop: browserClientMocks.browserStop as never, imageResultFromFile: toolCommonMocks.imageResultFromFile as never, - loadConfig: configMocks.loadConfig as never, + getRuntimeConfig: configMocks.loadConfig as never, listNodes: nodesUtilsMocks.listNodes as never, callGatewayTool: gatewayMocks.callGatewayTool as never, trackSessionBrowserTab: sessionTabRegistryMocks.trackSessionBrowserTab as never, @@ -280,7 +281,7 @@ function resetBrowserToolMocks() { browserConsoleMessages: browserActionsMocks.browserConsoleMessages as never, browserSnapshot: browserClientMocks.browserSnapshot as never, browserTabs: browserClientMocks.browserTabs as never, - loadConfig: configMocks.loadConfig as never, + getRuntimeConfig: configMocks.loadConfig as never, imageResultFromFile: toolCommonMocks.imageResultFromFile as never, }); } diff --git a/extensions/browser/src/browser-tool.ts b/extensions/browser/src/browser-tool.ts index 1cb6f4930b5..f089162db17 100644 --- a/extensions/browser/src/browser-tool.ts +++ b/extensions/browser/src/browser-tool.ts @@ -26,11 +26,11 @@ import { browserStatus, browserStop, callGatewayTool, + getRuntimeConfig, getBrowserProfileCapabilities, imageResultFromFile, jsonResult, listNodes, - loadConfig, normalizeOptionalString, persistBrowserProxyFiles, readStringParam, @@ -61,8 +61,8 @@ const browserToolDeps = { browserStart, browserStatus, browserStop, + getRuntimeConfig, imageResultFromFile, - loadConfig, listNodes, callGatewayTool, touchSessionBrowserTab, @@ -88,7 +88,7 @@ export const __testing = { browserStatus: typeof browserStatus; browserStop: typeof browserStop; imageResultFromFile: typeof imageResultFromFile; - loadConfig: typeof loadConfig; + getRuntimeConfig: typeof getRuntimeConfig; listNodes: typeof listNodes; callGatewayTool: typeof callGatewayTool; touchSessionBrowserTab: typeof touchSessionBrowserTab; @@ -113,7 +113,7 @@ export const __testing = { browserToolDeps.browserStatus = overrides?.browserStatus ?? browserStatus; browserToolDeps.browserStop = overrides?.browserStop ?? browserStop; browserToolDeps.imageResultFromFile = overrides?.imageResultFromFile ?? imageResultFromFile; - browserToolDeps.loadConfig = overrides?.loadConfig ?? loadConfig; + browserToolDeps.getRuntimeConfig = overrides?.getRuntimeConfig ?? getRuntimeConfig; browserToolDeps.listNodes = overrides?.listNodes ?? listNodes; browserToolDeps.callGatewayTool = overrides?.callGatewayTool ?? callGatewayTool; browserToolDeps.touchSessionBrowserTab = @@ -220,7 +220,7 @@ async function resolveBrowserNodeTarget(params: { target?: "sandbox" | "host" | "node"; sandboxBridgeUrl?: string; }): Promise { - const cfg = browserToolDeps.loadConfig(); + const cfg = browserToolDeps.getRuntimeConfig(); const policy = cfg.gateway?.nodes?.browser; const mode = policy?.mode ?? "auto"; if (mode === "off") { @@ -340,7 +340,7 @@ function resolveBrowserBaseUrl(params: { sandboxBridgeUrl?: string; allowHostControl?: boolean; }): string | undefined { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const normalizedSandbox = params.sandboxBridgeUrl?.trim() ?? ""; const target = params.target ?? (normalizedSandbox ? "sandbox" : "host"); @@ -369,7 +369,7 @@ function shouldPreferHostForProfile(profileName: string | undefined) { if (!profileName) { return false; } - const cfg = browserToolDeps.loadConfig(); + const cfg = browserToolDeps.getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const profile = resolveProfile(resolved, profileName); if (!profile) { @@ -395,7 +395,7 @@ function usesExistingSessionManageFlow(params: { action: string; profileName?: s if (!EXISTING_SESSION_MANAGE_ACTIONS.has(params.action)) { return false; } - const cfg = browserToolDeps.loadConfig(); + const cfg = browserToolDeps.getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const profile = resolveProfile(resolved, params.profileName ?? resolved.defaultProfile); if (profile && getBrowserProfileCapabilities(profile).usesChromeMcp) { @@ -448,7 +448,9 @@ export function createBrowserTool(opts?: { const requestedNode = readStringParam(params, "node"); const requestedTimeoutMs = readToolTimeoutMs(params); let target = readStringParam(params, "target") as "sandbox" | "host" | "node" | undefined; - const configuredNode = browserToolDeps.loadConfig().gateway?.nodes?.browser?.node?.trim(); + const configuredNode = browserToolDeps + .getRuntimeConfig() + .gateway?.nodes?.browser?.node?.trim(); if (requestedNode && target && target !== "node") { throw new Error('node is only supported with target="node".'); diff --git a/extensions/browser/src/browser/browser-utils.test.ts b/extensions/browser/src/browser/browser-utils.test.ts index 33e81bd62fb..bf27f306707 100644 --- a/extensions/browser/src/browser/browser-utils.test.ts +++ b/extensions/browser/src/browser/browser-utils.test.ts @@ -217,7 +217,7 @@ describe("fetchBrowserJson loopback auth (bridge auth registry)", () => { candidate === port ? { token: "registry-token" } : undefined, ); const init = __test.withLoopbackBrowserAuth(`http://127.0.0.1:${port}/`, undefined, { - loadConfig: () => ({}), + getRuntimeConfig: () => ({}), resolveBrowserControlAuth: () => ({}), getBridgeAuthForPort, }); diff --git a/extensions/browser/src/browser/client-fetch.attach-only.e2e.test.ts b/extensions/browser/src/browser/client-fetch.attach-only.e2e.test.ts index a82779d75c1..5bee263b3ff 100644 --- a/extensions/browser/src/browser/client-fetch.attach-only.e2e.test.ts +++ b/extensions/browser/src/browser/client-fetch.attach-only.e2e.test.ts @@ -1,9 +1,10 @@ import fs from "node:fs/promises"; import net from "node:net"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { clearConfigCache } from "../../../../src/config/config.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearConfigCache, clearRuntimeConfigSnapshot } from "../../../../src/config/config.js"; import { createTempHomeEnv } from "../../test-support.js"; +import { stopBrowserControlService } from "../control-service.js"; import { fetchBrowserJson } from "./client-fetch.js"; type TempHome = { @@ -14,8 +15,18 @@ type TempHome = { describe("browser client fetch attachOnly diagnostics", () => { let tempHome: TempHome | undefined; - afterEach(async () => { + beforeEach(async () => { + vi.useRealTimers(); + await stopBrowserControlService(); clearConfigCache(); + clearRuntimeConfigSnapshot(); + }); + + afterEach(async () => { + vi.useRealTimers(); + await stopBrowserControlService(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); await tempHome?.restore(); tempHome = undefined; }); @@ -54,6 +65,7 @@ describe("browser client fetch attachOnly diagnostics", () => { ); process.env.OPENCLAW_CONFIG_PATH = configPath; clearConfigCache(); + clearRuntimeConfigSnapshot(); try { const thrown = await fetchBrowserJson("/tabs?profile=hung", { timeoutMs: 200 }).catch( diff --git a/extensions/browser/src/browser/client-fetch.loopback-auth.test.ts b/extensions/browser/src/browser/client-fetch.loopback-auth.test.ts index 18f4093f4cb..da32f857657 100644 --- a/extensions/browser/src/browser/client-fetch.loopback-auth.test.ts +++ b/extensions/browser/src/browser/client-fetch.loopback-auth.test.ts @@ -49,6 +49,7 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, + getRuntimeConfig: mocks.loadConfig, loadConfig: mocks.loadConfig, }; }); diff --git a/extensions/browser/src/browser/client-fetch.ts b/extensions/browser/src/browser/client-fetch.ts index 259896a6320..5dc2e86d81c 100644 --- a/extensions/browser/src/browser/client-fetch.ts +++ b/extensions/browser/src/browser/client-fetch.ts @@ -2,7 +2,7 @@ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { formatCliCommand } from "../cli/command-format.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { isLoopbackHost } from "../gateway/net.js"; import { getBridgeAuthForPort } from "./bridge-auth-registry.js"; import { resolveBrowserConfig, resolveProfile } from "./config.js"; @@ -19,7 +19,7 @@ class BrowserServiceError extends Error { } type LoopbackBrowserAuthDeps = { - loadConfig: typeof loadConfig; + getRuntimeConfig: typeof getRuntimeConfig; resolveBrowserControlAuth: typeof resolveBrowserControlAuth; getBridgeAuthForPort: typeof getBridgeAuthForPort; }; @@ -50,7 +50,7 @@ function withLoopbackBrowserAuthImpl( } try { - const cfg = deps.loadConfig(); + const cfg = deps.getRuntimeConfig(); const auth = deps.resolveBrowserControlAuth(cfg); if (auth.token) { headers.set("Authorization", `Bearer ${auth.token}`); @@ -92,7 +92,7 @@ function withLoopbackBrowserAuth( init: (RequestInit & { timeoutMs?: number }) | undefined, ): RequestInit & { timeoutMs?: number } { return withLoopbackBrowserAuthImpl(url, init, { - loadConfig, + getRuntimeConfig, resolveBrowserControlAuth, getBridgeAuthForPort, }); @@ -113,7 +113,7 @@ function resolveDispatcherBrowserControlOwnership(url: string): BrowserControlOw return "unknown"; } try { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg?.browser, cfg); const parsed = new URL(url, "http://localhost"); const requestedProfile = parsed.searchParams.get("profile")?.trim(); diff --git a/extensions/browser/src/browser/config-refresh-source.ts b/extensions/browser/src/browser/config-refresh-source.ts index 88c1690925d..a9a3c0150b3 100644 --- a/extensions/browser/src/browser/config-refresh-source.ts +++ b/extensions/browser/src/browser/config-refresh-source.ts @@ -1,5 +1,5 @@ -import { createConfigIO, getRuntimeConfigSnapshot, type OpenClawConfig } from "../config/config.js"; +import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; export function loadBrowserConfigForRuntimeRefresh(): OpenClawConfig { - return getRuntimeConfigSnapshot() ?? createConfigIO().loadConfig(); + return getRuntimeConfig(); } diff --git a/extensions/browser/src/browser/control-auth.auto-token.test.ts b/extensions/browser/src/browser/control-auth.auto-token.test.ts index 9b1bf621969..b2da74c3228 100644 --- a/extensions/browser/src/browser/control-auth.auto-token.test.ts +++ b/extensions/browser/src/browser/control-auth.auto-token.test.ts @@ -3,8 +3,11 @@ import { expectGeneratedTokenPersistedToGatewayAuth } from "../../test-support.j import type { OpenClawConfig } from "../config/config.js"; const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn<() => OpenClawConfig>(), + getRuntimeConfig: vi.fn<() => OpenClawConfig>(), writeConfigFile: vi.fn<(cfg: OpenClawConfig) => Promise>(async (_cfg) => {}), + replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: OpenClawConfig }) => { + await mocks.writeConfigFile(nextConfig); + }), resolveGatewayAuth: vi.fn( ({ authConfig, @@ -48,8 +51,8 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../config/config.js", () => ({ - loadConfig: mocks.loadConfig, - writeConfigFile: mocks.writeConfigFile, + getRuntimeConfig: mocks.getRuntimeConfig, + replaceConfigFile: mocks.replaceConfigFile, })); vi.mock("../gateway/startup-auth.js", () => ({ @@ -73,7 +76,7 @@ async function expectGeneratedBrowserAuthPersistence(params: { mode: "none" | "trusted-proxy"; generatedAuthField: "token" | "password"; }) { - mocks.loadConfig.mockReturnValue(params.cfg); + mocks.getRuntimeConfig.mockReturnValue(params.cfg); const result = await ensureBrowserControlAuth({ cfg: params.cfg, env: {} as NodeJS.ProcessEnv }); @@ -88,7 +91,7 @@ async function expectGeneratedBrowserAuthPersistence(params: { } async function expectUnresolvedBrowserSecretRefSkipsPersistence(cfg: OpenClawConfig) { - mocks.loadConfig.mockReturnValue(cfg); + mocks.getRuntimeConfig.mockReturnValue(cfg); const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv }); @@ -113,7 +116,7 @@ describe("ensureBrowserControlAuth", () => { const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv }); expect(result).toEqual({ auth: {} }); - expect(mocks.loadConfig).not.toHaveBeenCalled(); + expect(mocks.getRuntimeConfig).not.toHaveBeenCalled(); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled(); }; @@ -137,7 +140,7 @@ describe("ensureBrowserControlAuth", () => { beforeEach(() => { vi.restoreAllMocks(); - mocks.loadConfig.mockClear(); + mocks.getRuntimeConfig.mockClear(); mocks.writeConfigFile.mockClear(); mocks.resolveGatewayAuth.mockClear(); mocks.ensureGatewayStartupAuth.mockClear(); @@ -155,7 +158,7 @@ describe("ensureBrowserControlAuth", () => { const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv }); expect(result).toEqual({ auth: { token: "already-set" } }); - expect(mocks.loadConfig).not.toHaveBeenCalled(); + expect(mocks.getRuntimeConfig).not.toHaveBeenCalled(); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled(); }); @@ -260,7 +263,7 @@ describe("ensureBrowserControlAuth", () => { enabled: true, }, }; - mocks.loadConfig.mockReturnValue({ + mocks.getRuntimeConfig.mockReturnValue({ browser: { enabled: true, }, @@ -284,7 +287,7 @@ describe("ensureBrowserControlAuth", () => { }); expect(result).toEqual({ auth: {} }); - expect(mocks.loadConfig).not.toHaveBeenCalled(); + expect(mocks.getRuntimeConfig).not.toHaveBeenCalled(); expect(mocks.writeConfigFile).not.toHaveBeenCalled(); expect(mocks.ensureGatewayStartupAuth).not.toHaveBeenCalled(); }); @@ -401,7 +404,7 @@ describe("ensureBrowserControlAuth", () => { enabled: true, }, }; - mocks.loadConfig.mockReturnValue({ + mocks.getRuntimeConfig.mockReturnValue({ gateway: { auth: { token: "latest-token", @@ -436,7 +439,7 @@ describe("ensureBrowserControlAuth", () => { }, }, }; - mocks.loadConfig.mockReturnValue(cfg); + mocks.getRuntimeConfig.mockReturnValue(cfg); mocks.ensureGatewayStartupAuth.mockRejectedValueOnce(new Error("MISSING_GW_TOKEN")); await expect(ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow( diff --git a/extensions/browser/src/browser/control-auth.ts b/extensions/browser/src/browser/control-auth.ts index 1c76b8aa75f..1204c72779f 100644 --- a/extensions/browser/src/browser/control-auth.ts +++ b/extensions/browser/src/browser/control-auth.ts @@ -3,7 +3,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/text-runtime"; -import { loadConfig, writeConfigFile } from "../config/config.js"; +import { getRuntimeConfig, replaceConfigFile } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js"; @@ -87,10 +87,13 @@ async function generateAndPersistBrowserControlToken(params: { }, }, }; - await writeConfigFile(nextCfg); + await replaceConfigFile({ + nextConfig: nextCfg, + afterWrite: { mode: "auto" }, + }); // Re-read to stay consistent with any concurrent config writer. - const persistedAuth = resolveBrowserControlAuth(loadConfig(), params.env); + const persistedAuth = resolveBrowserControlAuth(getRuntimeConfig(), params.env); if (persistedAuth.token || persistedAuth.password) { return { auth: persistedAuth, @@ -119,10 +122,13 @@ async function generateAndPersistBrowserControlPassword(params: { }, }, }; - await writeConfigFile(nextCfg); + await replaceConfigFile({ + nextConfig: nextCfg, + afterWrite: { mode: "auto" }, + }); // Re-read to stay consistent with any concurrent config writer. - const persistedAuth = resolveBrowserControlAuth(loadConfig(), params.env); + const persistedAuth = resolveBrowserControlAuth(getRuntimeConfig(), params.env); if (persistedAuth.token || persistedAuth.password) { return { auth: persistedAuth, @@ -155,7 +161,7 @@ export async function ensureBrowserControlAuth(params: { } // Re-read latest config to avoid racing with concurrent config writers. - const latestCfg = loadConfig(); + const latestCfg = getRuntimeConfig(); const latestAuth = resolveBrowserControlAuth(latestCfg, env); if (latestAuth.token || latestAuth.password) { return { auth: latestAuth }; diff --git a/extensions/browser/src/browser/control-service.plugin-disabled.test.ts b/extensions/browser/src/browser/control-service.plugin-disabled.test.ts index a3cf22f9acd..c1b3092f1ce 100644 --- a/extensions/browser/src/browser/control-service.plugin-disabled.test.ts +++ b/extensions/browser/src/browser/control-service.plugin-disabled.test.ts @@ -21,6 +21,7 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, + getRuntimeConfig: mocks.loadConfig, loadConfig: mocks.loadConfig, }; }); diff --git a/extensions/browser/src/browser/profiles-service.test.ts b/extensions/browser/src/browser/profiles-service.test.ts index 98190933a9b..bf79d7e4b42 100644 --- a/extensions/browser/src/browser/profiles-service.test.ts +++ b/extensions/browser/src/browser/profiles-service.test.ts @@ -1,17 +1,25 @@ import fs from "node:fs"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { loadConfig, writeConfigFile } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/config.js"; import { resolveOpenClawUserDataDir } from "./chrome.js"; import type { BrowserRouteContext, BrowserServerState } from "./server-context.js"; import { movePathToTrash } from "./trash.js"; +const configMocks = vi.hoisted(() => ({ + writeConfigFile: vi.fn<(cfg: OpenClawConfig) => Promise>(async (_cfg) => {}), +})); +const writeConfigFile = configMocks.writeConfigFile; + vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, - loadConfig: vi.fn(), - writeConfigFile: vi.fn(async () => {}), + getRuntimeConfig: vi.fn(), + replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: OpenClawConfig }) => { + await configMocks.writeConfigFile(nextConfig); + }), }; }); @@ -52,7 +60,7 @@ async function createWorkProfileWithConfig(params: { browserConfig: Record; }) { const { ctx, state } = createCtx(params.resolved); - vi.mocked(loadConfig).mockReturnValue({ browser: params.browserConfig }); + vi.mocked(getRuntimeConfig).mockReturnValue({ browser: params.browserConfig }); const service = createBrowserProfilesService(ctx); const result = await service.createProfile({ name: "work" }); return { result, state }; @@ -116,7 +124,7 @@ describe("BrowserProfilesService", () => { }); const { ctx } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + vi.mocked(getRuntimeConfig).mockReturnValue({ browser: { profiles: {} } }); const service = createBrowserProfilesService(ctx); const result = await service.createProfile({ @@ -146,7 +154,7 @@ describe("BrowserProfilesService", () => { }); const { ctx } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ + vi.mocked(getRuntimeConfig).mockReturnValue({ browser: { ssrfPolicy: { dangerouslyAllowPrivateNetwork: false }, profiles: {}, @@ -167,7 +175,7 @@ describe("BrowserProfilesService", () => { it("creates existing-session profiles as attach-only local entries", async () => { const resolved = resolveBrowserConfig({}); const { ctx, state } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + vi.mocked(getRuntimeConfig).mockReturnValue({ browser: { profiles: {} } }); const service = createBrowserProfilesService(ctx); const result = await service.createProfile({ @@ -202,7 +210,7 @@ describe("BrowserProfilesService", () => { it("rejects driver=existing-session when cdpUrl is provided", async () => { const resolved = resolveBrowserConfig({}); const { ctx } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + vi.mocked(getRuntimeConfig).mockReturnValue({ browser: { profiles: {} } }); const service = createBrowserProfilesService(ctx); @@ -218,7 +226,7 @@ describe("BrowserProfilesService", () => { it("creates existing-session profiles with an explicit userDataDir", async () => { const resolved = resolveBrowserConfig({}); const { ctx, state } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + vi.mocked(getRuntimeConfig).mockReturnValue({ browser: { profiles: {} } }); const tempDir = fs.mkdtempSync(path.join("/tmp", "openclaw-profile-")); const userDataDir = path.join(tempDir, "BraveSoftware", "Brave-Browser"); @@ -244,7 +252,7 @@ describe("BrowserProfilesService", () => { it("rejects userDataDir for non-existing-session profiles", async () => { const resolved = resolveBrowserConfig({}); const { ctx } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } }); + vi.mocked(getRuntimeConfig).mockReturnValue({ browser: { profiles: {} } }); const tempDir = fs.mkdtempSync(path.join("/tmp", "openclaw-profile-")); const userDataDir = path.join(tempDir, "BraveSoftware", "Brave-Browser"); @@ -268,7 +276,7 @@ describe("BrowserProfilesService", () => { }); const { ctx } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ + vi.mocked(getRuntimeConfig).mockReturnValue({ browser: { defaultProfile: "openclaw", profiles: { @@ -294,7 +302,7 @@ describe("BrowserProfilesService", () => { }); const { ctx } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ + vi.mocked(getRuntimeConfig).mockReturnValue({ browser: { defaultProfile: "openclaw", profiles: { @@ -329,7 +337,7 @@ describe("BrowserProfilesService", () => { }); const { ctx } = createCtx(resolved); - vi.mocked(loadConfig).mockReturnValue({ + vi.mocked(getRuntimeConfig).mockReturnValue({ browser: { defaultProfile: "openclaw", profiles: { diff --git a/extensions/browser/src/browser/profiles-service.ts b/extensions/browser/src/browser/profiles-service.ts index c9d0df3500f..7242d09f49d 100644 --- a/extensions/browser/src/browser/profiles-service.ts +++ b/extensions/browser/src/browser/profiles-service.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; -import { loadConfig, writeConfigFile } from "../config/config.js"; +import { getRuntimeConfig, replaceConfigFile } from "../config/config.js"; import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; import { formatErrorMessage } from "../infra/errors.js"; import { resolveUserPath } from "../utils.js"; @@ -101,7 +101,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { throw new BrowserConflictError(`profile "${name}" already exists`); } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const rawProfiles = cfg.browser?.profiles ?? {}; if (name in rawProfiles) { throw new BrowserConflictError(`profile "${name}" already exists`); @@ -176,7 +176,10 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { }, }; - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + afterWrite: { mode: "auto" }, + }); state.resolved.profiles[name] = profileConfig; const resolved = resolveProfile(state.resolved, name); @@ -207,7 +210,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { } const state = ctx.state(); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const profiles = cfg.browser?.profiles ?? {}; const defaultProfile = cfg.browser?.defaultProfile ?? state.resolved.defaultProfile; if (name === defaultProfile) { @@ -246,7 +249,10 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { }, }; - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + afterWrite: { mode: "auto" }, + }); delete state.resolved.profiles[name]; state.profiles.delete(name); diff --git a/extensions/browser/src/browser/resolved-config-refresh.ts b/extensions/browser/src/browser/resolved-config-refresh.ts index 07ce8bbc02d..0653a76f59f 100644 --- a/extensions/browser/src/browser/resolved-config-refresh.ts +++ b/extensions/browser/src/browser/resolved-config-refresh.ts @@ -91,9 +91,8 @@ export function refreshResolvedBrowserConfigFromDisk(params: { return; } - // Route-level browser config hot reload should observe on-disk changes immediately. - // The shared loadConfig() helper may return a cached snapshot for the configured TTL, - // which can leave request-time browser guards stale (for example evaluateEnabled). + // Route-level refresh should use the shared runtime config. Config mutations + // refresh that snapshot and decide whether the wider runtime should restart. const cfg = loadBrowserConfigForRuntimeRefresh(); const freshResolved = resolveBrowserConfig(cfg.browser, cfg); applyResolvedConfig(params.current, freshResolved); diff --git a/extensions/browser/src/browser/server-context.hot-reload-profiles.test.ts b/extensions/browser/src/browser/server-context.hot-reload-profiles.test.ts index c7f3f3b3886..780e6f635ae 100644 --- a/extensions/browser/src/browser/server-context.hot-reload-profiles.test.ts +++ b/extensions/browser/src/browser/server-context.hot-reload-profiles.test.ts @@ -46,15 +46,9 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, - createConfigIO: () => ({ - loadConfig: () => { - // Always return fresh config for createConfigIO to simulate fresh disk read - return buildConfig(); - }, - }), getRuntimeConfigSnapshot: () => null, - loadConfig: () => { - // simulate stale loadConfig that doesn't see updates unless cache cleared + getRuntimeConfig: () => { + // simulate stale getRuntimeConfig that doesn't see updates unless cache cleared if (!mockState.cachedConfig) { mockState.cachedConfig = buildConfig(); } @@ -68,7 +62,7 @@ vi.mock("./config-refresh-source.js", () => ({ loadBrowserConfigForRuntimeRefresh: () => buildConfig(), })); -const { loadConfig } = await import("../config/config.js"); +const { getRuntimeConfig } = await import("../config/config.js"); const { resolveBrowserConfig, resolveProfile } = await import("./config.js"); const { refreshResolvedBrowserConfigFromDisk, resolveBrowserProfileWithHotReload } = await import("./resolved-config-refresh.js"); @@ -84,8 +78,8 @@ describe("server-context hot-reload profiles", () => { it("forProfile hot-reloads newly added profiles from config", async () => { // Start with only openclaw profile - // 1. Prime the cache by calling loadConfig() first - const cfg = loadConfig(); + // 1. Prime the cache by calling getRuntimeConfig() first + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); // Verify cache is primed (without desktop) @@ -109,12 +103,11 @@ describe("server-context hot-reload profiles", () => { // 2. Simulate adding a new profile to config (like user editing openclaw.json) mockState.cfgProfiles.desktop = { cdpUrl: "http://127.0.0.1:9222", color: "#0066CC" }; - // 3. Verify without clearConfigCache, loadConfig() still returns stale cached value - const staleCfg = loadConfig(); + // 3. Verify without clearConfigCache, getRuntimeConfig() still returns stale cached value + const staleCfg = getRuntimeConfig(); expect(staleCfg.browser?.profiles?.desktop).toBeUndefined(); // Cache is stale! - // 4. Hot-reload should read fresh config for the lookup (createConfigIO().loadConfig()), - // without flushing the global loadConfig cache. + // 4. Hot-reload uses the refresh source without flushing the global getRuntimeConfig cache. const profile = resolveBrowserProfileWithHotReload({ current: state, refreshConfigFromDisk: true, @@ -126,14 +119,14 @@ describe("server-context hot-reload profiles", () => { // 5. Verify the new profile was merged into the cached state expect(state.resolved.profiles.desktop).toBeDefined(); - // 6. Verify GLOBAL cache was NOT cleared - subsequent simple loadConfig() still sees STALE value + // 6. Verify GLOBAL cache was NOT cleared - subsequent simple getRuntimeConfig() still sees STALE value // This confirms the fix: we read fresh config for the specific profile lookup without flushing the global cache - const stillStaleCfg = loadConfig(); + const stillStaleCfg = getRuntimeConfig(); expect(stillStaleCfg.browser?.profiles?.desktop).toBeUndefined(); }); it("forProfile still throws for profiles that don't exist in fresh config", async () => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const state = { server: null, @@ -152,8 +145,8 @@ describe("server-context hot-reload profiles", () => { ).toBeNull(); }); - it("forProfile refreshes existing profile config after loadConfig cache updates", async () => { - const cfg = loadConfig(); + it("forProfile refreshes existing profile config after getRuntimeConfig cache updates", async () => { + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const state = { server: null, @@ -175,7 +168,7 @@ describe("server-context hot-reload profiles", () => { }); it("listProfiles refreshes config before enumerating profiles", async () => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const state = { server: null, @@ -196,7 +189,7 @@ describe("server-context hot-reload profiles", () => { }); it("marks existing runtime state for reconcile when profile invariants change", async () => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const openclawProfile = resolveProfile(resolved, "openclaw"); expect(openclawProfile).toBeTruthy(); @@ -234,7 +227,7 @@ describe("server-context hot-reload profiles", () => { }); it("marks local managed runtime state for reconcile when profile headless changes", async () => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const openclawProfile = resolveProfile(resolved, "openclaw"); expect(openclawProfile).toBeTruthy(); @@ -283,7 +276,7 @@ describe("server-context hot-reload profiles", () => { executablePath: "/usr/bin/chrome-old", }; mockState.cachedConfig = null; - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const openclawProfile = resolveProfile(resolved, "openclaw"); expect(openclawProfile).toBeTruthy(); @@ -333,7 +326,7 @@ describe("server-context hot-reload profiles", () => { driver: "existing-session", }; - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const remoteProfile = resolveProfile(resolved, "remote"); expect(remoteProfile).toBeTruthy(); @@ -387,7 +380,7 @@ describe("server-context hot-reload profiles", () => { headless: true, }; - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const remoteProfile = resolveProfile(resolved, "remote"); expect(remoteProfile).toBeTruthy(); diff --git a/extensions/browser/src/browser/server.auth-fail-closed.test.ts b/extensions/browser/src/browser/server.auth-fail-closed.test.ts index 4b471b65cae..5eee9ec12c6 100644 --- a/extensions/browser/src/browser/server.auth-fail-closed.test.ts +++ b/extensions/browser/src/browser/server.auth-fail-closed.test.ts @@ -27,16 +27,18 @@ vi.mock("../config/config.js", async () => { const browserConfig = { enabled: true, }; + const loadConfig = () => { + return { + browser: browserConfig, + ...(mocks.gatewayAuthMode || mocks.gatewayAuthToken + ? { gateway: { auth: { mode: mocks.gatewayAuthMode, token: mocks.gatewayAuthToken } } } + : {}), + }; + }; return { ...actual, - loadConfig: () => { - return { - browser: browserConfig, - ...(mocks.gatewayAuthMode || mocks.gatewayAuthToken - ? { gateway: { auth: { mode: mocks.gatewayAuthMode, token: mocks.gatewayAuthToken } } } - : {}), - }; - }, + getRuntimeConfig: loadConfig, + loadConfig, }; }); diff --git a/extensions/browser/src/browser/server.control-server.test-harness.ts b/extensions/browser/src/browser/server.control-server.test-harness.ts index 121c1b32a45..adad8833b6c 100644 --- a/extensions/browser/src/browser/server.control-server.test-harness.ts +++ b/extensions/browser/src/browser/server.control-server.test-harness.ts @@ -426,6 +426,7 @@ vi.mock("../config/config.js", async () => { loadConfig, writeConfigFile, })), + getRuntimeConfig: loadConfig, getRuntimeConfigSnapshot: vi.fn(() => null), loadConfig, writeConfigFile, diff --git a/extensions/browser/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts b/extensions/browser/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts index 9bed2c3b13f..48f5dfef12d 100644 --- a/extensions/browser/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts +++ b/extensions/browser/src/browser/server.evaluate-disabled-does-not-block-storage.test.ts @@ -37,18 +37,20 @@ const routeCtxMocks = vi.hoisted(() => { vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); + const loadConfig = () => ({ + browser: { + enabled: true, + evaluateEnabled: false, + defaultProfile: "openclaw", + profiles: { + openclaw: { cdpPort: testPort + 1, color: "#FF4500" }, + }, + }, + }); return { ...actual, - loadConfig: () => ({ - browser: { - enabled: true, - evaluateEnabled: false, - defaultProfile: "openclaw", - profiles: { - openclaw: { cdpPort: testPort + 1, color: "#FF4500" }, - }, - }, - }), + getRuntimeConfig: loadConfig, + loadConfig, writeConfigFile: vi.fn(async () => {}), }; }); diff --git a/extensions/browser/src/browser/session-tab-cleanup.ts b/extensions/browser/src/browser/session-tab-cleanup.ts index 3912ba2a018..253455b7501 100644 --- a/extensions/browser/src/browser/session-tab-cleanup.ts +++ b/extensions/browser/src/browser/session-tab-cleanup.ts @@ -3,7 +3,7 @@ import { isCronSessionKey, isSubagentSessionKey, } from "openclaw/plugin-sdk/routing"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveBrowserConfig, type ResolvedBrowserTabCleanupConfig } from "./config.js"; import { sweepTrackedBrowserTabs } from "./session-tab-registry.js"; @@ -22,7 +22,7 @@ export function isPrimaryTrackedBrowserSessionKey(sessionKey: string): boolean { } export function resolveBrowserTabCleanupRuntimeConfig(): ResolvedBrowserTabCleanupConfig { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); return resolveBrowserConfig(cfg.browser, cfg).tabCleanup; } diff --git a/extensions/browser/src/cli/browser-cli-inspect.test.ts b/extensions/browser/src/cli/browser-cli-inspect.test.ts index fb41e939629..af076e15717 100644 --- a/extensions/browser/src/cli/browser-cli-inspect.test.ts +++ b/extensions/browser/src/cli/browser-cli-inspect.test.ts @@ -20,9 +20,13 @@ vi.mock("../../../../src/cli/gateway-rpc.js", () => ({ callGatewayFromCli: gatewayMocks.callGatewayFromCli, })); -const configMocks = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({ browser: {} })), -})); +const configMocks = vi.hoisted(() => { + const loadConfig = vi.fn(() => ({ browser: {} })); + return { + getRuntimeConfig: loadConfig, + loadConfig, + }; +}); vi.mock("../config/config.js", () => configMocks); const sharedMocks = vi.hoisted(() => ({ @@ -51,7 +55,7 @@ const sharedMocks = vi.hoisted(() => ({ vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation( sharedMocks.callBrowserRequest, ); -vi.spyOn(cliCoreApiModule, "loadConfig").mockImplementation(configMocks.loadConfig); +vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockImplementation(configMocks.loadConfig); vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(runtime.log); vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(runtime.writeJson); vi.spyOn(cliCoreApiModule.defaultRuntime, "error").mockImplementation(runtime.error); diff --git a/extensions/browser/src/cli/browser-cli-inspect.ts b/extensions/browser/src/cli/browser-cli-inspect.ts index c2b2221b245..9ae535f3aa7 100644 --- a/extensions/browser/src/cli/browser-cli-inspect.ts +++ b/extensions/browser/src/cli/browser-cli-inspect.ts @@ -5,7 +5,7 @@ import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared import { danger, defaultRuntime, - loadConfig, + getRuntimeConfig, shortenHomePath, type SnapshotResult, } from "./core-api.js"; @@ -81,7 +81,7 @@ export function registerBrowserInspectCommands( const configMode = !formatWasExplicit && format === "ai" && - loadConfig().browser?.snapshotDefaults?.mode === "efficient" + getRuntimeConfig().browser?.snapshotDefaults?.mode === "efficient" ? "efficient" : undefined; const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode; diff --git a/extensions/browser/src/config/config.ts b/extensions/browser/src/config/config.ts index 79a70b51d0d..0e1a4df0102 100644 --- a/extensions/browser/src/config/config.ts +++ b/extensions/browser/src/config/config.ts @@ -1,8 +1,7 @@ export { - createConfigIO, + getRuntimeConfig, getRuntimeConfigSnapshot, - loadConfig, - writeConfigFile, + replaceConfigFile, type BrowserConfig, type BrowserProfileConfig, type OpenClawConfig, diff --git a/extensions/browser/src/control-service.ts b/extensions/browser/src/control-service.ts index b65a42ec78d..0b42621415a 100644 --- a/extensions/browser/src/control-service.ts +++ b/extensions/browser/src/control-service.ts @@ -2,7 +2,7 @@ import { resolveBrowserConfig } from "./browser/config.js"; import { ensureBrowserControlAuth } from "./browser/control-auth.js"; import { createBrowserRuntimeState, stopBrowserRuntime } from "./browser/runtime-lifecycle.js"; import { type BrowserServerState, createBrowserRouteContext } from "./browser/server-context.js"; -import { loadConfig } from "./config/config.js"; +import { getRuntimeConfig } from "./config/config.js"; import { createSubsystemLogger } from "./logging/subsystem.js"; import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js"; @@ -26,7 +26,7 @@ export async function startBrowserControlServiceFromConfig(): Promise; + cfg: OpenClawConfig; nodes: NodeSession[]; }): NodeSession | null { const policy = params.cfg.gateway?.nodes?.browser; @@ -171,7 +172,7 @@ export async function handleBrowserGatewayRequest({ return; } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); let nodeTarget: NodeSession | null = null; try { nodeTarget = resolveBrowserNodeTarget({ diff --git a/extensions/browser/src/node-host/invoke-browser.test.ts b/extensions/browser/src/node-host/invoke-browser.test.ts index 71030e9401d..193f5cf28d9 100644 --- a/extensions/browser/src/node-host/invoke-browser.test.ts +++ b/extensions/browser/src/node-host/invoke-browser.test.ts @@ -27,6 +27,7 @@ const browserConfigMocks = vi.hoisted(() => ({ })); vi.mock("openclaw/plugin-sdk/browser-config-runtime", () => ({ + getRuntimeConfig: configMocks.loadConfig, loadConfig: configMocks.loadConfig, })); diff --git a/extensions/browser/src/node-host/invoke-browser.ts b/extensions/browser/src/node-host/invoke-browser.ts index 198ea6d9ef1..4c7edafe9a5 100644 --- a/extensions/browser/src/node-host/invoke-browser.ts +++ b/extensions/browser/src/node-host/invoke-browser.ts @@ -1,5 +1,5 @@ import fsPromises from "node:fs/promises"; -import { loadConfig } from "openclaw/plugin-sdk/browser-config-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/browser-config-runtime"; import { withTimeout } from "openclaw/plugin-sdk/browser-node-runtime"; import { detectMime } from "openclaw/plugin-sdk/browser-setup-tools"; import { redactCdpUrl } from "../browser/cdp.helpers.js"; @@ -44,7 +44,7 @@ function normalizeProfileAllowlist(raw?: string[]): string[] { } function resolveBrowserProxyConfig() { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const proxy = cfg.nodeHost?.browserProxy; const allowProfiles = normalizeProfileAllowlist(proxy?.allowProfiles); const enabled = proxy?.enabled !== false; @@ -64,7 +64,7 @@ async function ensureBrowserControlService(): Promise { return browserControlReady; } browserControlReady = (async () => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); if (!resolved.enabled) { throw new Error("browser control disabled"); @@ -231,7 +231,7 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis } await ensureBrowserControlService(); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolved = resolveBrowserConfig(cfg.browser, cfg); const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET"; const path = normalizeBrowserRequestPath(pathValue); diff --git a/extensions/browser/src/server.ts b/extensions/browser/src/server.ts index 66761d75689..c4752f7edc0 100644 --- a/extensions/browser/src/server.ts +++ b/extensions/browser/src/server.ts @@ -15,7 +15,7 @@ import { installBrowserAuthMiddleware, installBrowserCommonMiddleware, } from "./browser/server-middleware.js"; -import { loadConfig } from "./config/config.js"; +import { getRuntimeConfig } from "./config/config.js"; import { createSubsystemLogger } from "./logging/subsystem.js"; import { isDefaultBrowserPluginEnabled } from "./plugin-enabled.js"; @@ -28,7 +28,7 @@ export async function startBrowserControlServerFromConfig(): Promise resolveLivePluginConfigObject( - api.runtime.config?.loadConfig, + api.runtime.config?.current + ? () => api.runtime.config.current() as OpenClawConfig + : undefined, "codex", api.pluginConfig as Record, ) ?? api.pluginConfig; diff --git a/extensions/diffs/src/browser.test.ts b/extensions/diffs/src/browser.test.ts index d8d85ef678e..8c6d5c20104 100644 --- a/extensions/diffs/src/browser.test.ts +++ b/extensions/diffs/src/browser.test.ts @@ -256,7 +256,7 @@ describe("diffs plugin registration", () => { }, runtime: { config: { - loadConfig: () => configFile, + current: () => configFile, }, } as never, registerTool(tool: Parameters[0]) { @@ -384,7 +384,7 @@ describe("diffs plugin registration", () => { }, runtime: { config: { - loadConfig: () => configFile, + current: () => configFile, }, } as never, registerTool(tool: Parameters[0]) { @@ -521,7 +521,7 @@ describe("diffs plugin registration", () => { }, runtime: { config: { - loadConfig: () => configFile, + current: () => configFile, }, } as never, registerTool(tool: Parameters[0]) { diff --git a/extensions/diffs/src/plugin.ts b/extensions/diffs/src/plugin.ts index 9fee1a50af5..4de9140d4e4 100644 --- a/extensions/diffs/src/plugin.ts +++ b/extensions/diffs/src/plugin.ts @@ -1,6 +1,10 @@ import path from "node:path"; import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/config-runtime"; -import { resolvePreferredOpenClawTmpDir, type OpenClawPluginApi } from "../api.js"; +import { + resolvePreferredOpenClawTmpDir, + type OpenClawConfig, + type OpenClawPluginApi, +} from "../api.js"; import { resolveDiffsPluginDefaults, resolveDiffsPluginSecurity, @@ -18,12 +22,14 @@ export function registerDiffsPlugin(api: OpenClawPluginApi): void { }); const resolveCurrentPluginConfig = () => resolveLivePluginConfigObject( - api.runtime.config?.loadConfig, + api.runtime.config?.current + ? () => api.runtime.config.current() as OpenClawConfig + : undefined, "diffs", api.pluginConfig as Record, ) ?? {}; const resolveCurrentAccessConfig = () => { - const currentConfig = api.runtime.config?.loadConfig?.() ?? api.config; + const currentConfig = (api.runtime.config?.current?.() ?? api.config) as OpenClawConfig; const pluginConfig = resolveCurrentPluginConfig(); return { allowRemoteViewer: resolveDiffsPluginSecurity(pluginConfig).allowRemoteViewer, diff --git a/extensions/discord/src/monitor.test.ts b/extensions/discord/src/monitor.test.ts index 892cc4f91b8..02fdff7950a 100644 --- a/extensions/discord/src/monitor.test.ts +++ b/extensions/discord/src/monitor.test.ts @@ -991,7 +991,7 @@ function makeReactionListenerParams(overrides?: { guildEntries?: Record; }) { return { - cfg: {} as ReturnType, + cfg: {} as import("openclaw/plugin-sdk/config-runtime").OpenClawConfig, accountId: "acc-1", runtime: {} as import("openclaw/plugin-sdk/runtime-env").RuntimeEnv, botUserId: overrides?.botUserId ?? "bot-1", 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 697c0057d70..c4a12047038 100644 --- a/extensions/discord/src/monitor/acp-bind-here.integration.test.ts +++ b/extensions/discord/src/monitor/acp-bind-here.integration.test.ts @@ -9,7 +9,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async () => { ); return { ...actual, - loadConfig: () => loadConfigMock(), + getRuntimeConfig: () => loadConfigMock(), }; }); diff --git a/extensions/discord/src/monitor/listeners.ts b/extensions/discord/src/monitor/listeners.ts index 22417320292..bd3a1a1f2f9 100644 --- a/extensions/discord/src/monitor/listeners.ts +++ b/extensions/discord/src/monitor/listeners.ts @@ -39,7 +39,7 @@ import { resolveFetchedDiscordThreadLikeChannelContext } from "./thread-channel- import { closeDiscordThreadSessions } from "./thread-session-close.js"; import { normalizeDiscordListenerTimeoutMs, runDiscordTaskWithTimeout } from "./timeouts.js"; -type LoadedConfig = ReturnType; +type LoadedConfig = OpenClawConfig; type RuntimeEnv = import("openclaw/plugin-sdk/runtime-env").RuntimeEnv; type Logger = ReturnType; diff --git a/extensions/discord/src/monitor/message-handler.preflight.types.ts b/extensions/discord/src/monitor/message-handler.preflight.types.ts index 1d980ba8e26..56df3db48d2 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.types.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.types.ts @@ -1,5 +1,5 @@ import type { ChannelType, Client, User } from "@buape/carbon"; -import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; @@ -11,9 +11,7 @@ import type { DiscordSenderIdentity } from "./sender-identity.js"; export type { DiscordSenderIdentity } from "./sender-identity.js"; import type { DiscordThreadChannel } from "./threading.js"; -export type LoadedConfig = ReturnType< - typeof import("openclaw/plugin-sdk/config-runtime").loadConfig ->; +export type LoadedConfig = OpenClawConfig; export type RuntimeEnv = import("openclaw/plugin-sdk/runtime-env").RuntimeEnv; export type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index 326318ebd29..e26165d0ebe 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -24,7 +24,7 @@ import { type CommandArgValues, type CommandArgs, } from "openclaw/plugin-sdk/command-auth"; -import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; @@ -64,7 +64,7 @@ type DiscordNativeChoiceInteraction = const DISCORD_COMMAND_ARG_CUSTOM_ID_KEY = "cmdarg"; export type DiscordCommandArgContext = { - cfg: ReturnType; + cfg: OpenClawConfig; discordConfig: DiscordConfig; accountId: string; sessionPrefix: string; @@ -79,7 +79,7 @@ export type DispatchDiscordCommandInteractionParams = { prompt: string; command: ChatCommandDefinition; commandArgs?: CommandArgs; - cfg: ReturnType; + cfg: OpenClawConfig; discordConfig: DiscordConfig; accountId: string; sessionPrefix: string; @@ -242,7 +242,7 @@ async function resolveDiscordModelPickerRouteState(params: { | ButtonInteraction | StringSelectMenuInteraction | AutocompleteInteraction; - cfg: ReturnType; + cfg: OpenClawConfig; accountId: string; threadBindings: ThreadBindingManager; enforceConfiguredBindingReadiness?: boolean; @@ -283,7 +283,7 @@ async function resolveDiscordModelPickerRoute(params: { | ButtonInteraction | StringSelectMenuInteraction | AutocompleteInteraction; - cfg: ReturnType; + cfg: OpenClawConfig; accountId: string; threadBindings: ThreadBindingManager; }) { @@ -293,7 +293,7 @@ async function resolveDiscordModelPickerRoute(params: { export async function resolveDiscordNativeChoiceContext(params: { interaction: DiscordNativeChoiceInteraction; - cfg: ReturnType; + cfg: OpenClawConfig; accountId: string; threadBindings: ThreadBindingManager; }): Promise<{ provider?: string; model?: string } | null> { @@ -340,7 +340,7 @@ export async function resolveDiscordNativeChoiceContext(params: { } function resolveDiscordModelPickerCurrentModel(params: { - cfg: ReturnType; + cfg: OpenClawConfig; route: ResolvedAgentRoute; data: Awaited>; }): string { @@ -374,7 +374,7 @@ function resolveDiscordModelPickerCurrentModel(params: { } function resolveDiscordModelPickerCurrentRuntime(params: { - cfg: ReturnType; + cfg: OpenClawConfig; route: ResolvedAgentRoute; }): string { try { @@ -405,7 +405,7 @@ function resolveDiscordModelPickerCurrentRuntime(params: { export async function replyWithDiscordModelPickerProviders(params: { interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction; - cfg: ReturnType; + cfg: OpenClawConfig; command: DiscordModelPickerCommandContext; userId: string; accountId: string; diff --git a/extensions/discord/src/monitor/native-command.options.test.ts b/extensions/discord/src/monitor/native-command.options.test.ts index 5a035d9b67b..8731e4451f9 100644 --- a/extensions/discord/src/monitor/native-command.options.test.ts +++ b/extensions/discord/src/monitor/native-command.options.test.ts @@ -1,5 +1,5 @@ import { ChannelType } from "discord-api-types/v10"; -import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const { logVerboseMock } = vi.hoisted(() => ({ @@ -37,7 +37,7 @@ let createNoopThreadBindingManager: typeof import("./thread-bindings.js").create function createNativeCommand( name: string, opts?: { - cfg?: ReturnType; + cfg?: OpenClawConfig; discordConfig?: NonNullable["discord"]; }, ): ReturnType { @@ -47,7 +47,7 @@ function createNativeCommand( if (!command) { throw new Error(`missing native command: ${name}`); } - const baseCfg: ReturnType = opts?.cfg ?? {}; + const baseCfg: OpenClawConfig = opts?.cfg ?? {}; const discordConfig: NonNullable["discord"] = opts?.discordConfig ?? baseCfg.channels?.discord ?? {}; const cfg = @@ -211,7 +211,7 @@ describe("createDiscordNativeCommand option wiring", () => { discord: ["user:allowed-user"], }, }, - } as ReturnType, + } as OpenClawConfig, }); const level = requireOption(command, "level"); const autocomplete = requireAutocomplete(level, "think level option did not wire autocomplete"); @@ -250,7 +250,7 @@ describe("createDiscordNativeCommand option wiring", () => { }, }, }, - } as ReturnType, + } as OpenClawConfig, }); const level = requireOption(command, "level"); const autocomplete = requireAutocomplete(level, "think level option did not wire autocomplete"); @@ -284,7 +284,7 @@ describe("createDiscordNativeCommand option wiring", () => { discord: ["user:allowed-user"], }, }, - } as ReturnType, + } as OpenClawConfig, discordConfig, }); const level = requireOption(command, "level"); @@ -304,7 +304,7 @@ describe("createDiscordNativeCommand option wiring", () => { it("truncates Discord command and option descriptions to Discord's limit", () => { const longDescription = "x".repeat(140); - const cfg = {} as ReturnType; + const cfg = {} as OpenClawConfig; const discordConfig = {} as NonNullable["discord"]; const command = createDiscordNativeCommand({ command: { diff --git a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts index cc027d87246..5a5af99d937 100644 --- a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts +++ b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts @@ -2,8 +2,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { ChannelType, type AutocompleteInteraction } from "@buape/carbon"; -import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; -import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/config-runtime"; +import { + clearSessionStoreCacheForTest, + type OpenClawConfig, +} from "openclaw/plugin-sdk/config-runtime"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; @@ -92,7 +94,7 @@ vi.mock("openclaw/plugin-sdk/conversation-binding-runtime", async () => { vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({ normalizeProviderId: (value: string) => value.trim().toLowerCase(), - resolveDefaultModelForAgent: (params: { cfg: ReturnType }) => { + resolveDefaultModelForAgent: (params: { cfg: OpenClawConfig }) => { const configuredModel = params.cfg.agents?.defaults?.model; const primary = typeof configuredModel === "string" @@ -216,7 +218,7 @@ describe("discord native /think autocomplete", () => { session: { store: STORE_PATH, }, - } as ReturnType; + } as OpenClawConfig; } it("uses the session override context for /think choices", async () => { diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index e48919ebec5..9e8d2f59bc7 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -18,7 +18,7 @@ import { resolveNativeCommandSessionTargets, } from "openclaw/plugin-sdk/command-auth-native"; import { resolveDirectStatusReplyForSession } from "openclaw/plugin-sdk/command-status-runtime"; -import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; @@ -199,7 +199,7 @@ function resolveDiscordNativeCommandAllowlistAccess(params: { } function resolveDiscordGuildNativeCommandAuthorized(params: { - cfg: ReturnType; + cfg: OpenClawConfig; discordConfig: DiscordConfig; useAccessGroups: boolean; commandsAllowFromAccess: ReturnType; @@ -261,7 +261,7 @@ function resolveDiscordGuildNativeCommandAuthorized(params: { function buildDiscordCommandOptions(params: { command: ChatCommandDefinition; - cfg: ReturnType; + cfg: OpenClawConfig; authorizeChoiceContext?: (interaction: AutocompleteInteraction) => Promise; resolveChoiceContext?: ( interaction: AutocompleteInteraction, @@ -397,7 +397,7 @@ function resolveDiscordNativeGroupDmAccess(params: { async function resolveDiscordNativeAutocompleteAuthorized(params: { interaction: AutocompleteInteraction; - cfg: ReturnType; + cfg: OpenClawConfig; discordConfig: DiscordConfig; accountId: string; }): Promise { @@ -633,7 +633,7 @@ function createNativeCommandDefinition(command: NativeCommandSpec): ChatCommandD export function createDiscordNativeCommand(params: { command: NativeCommandSpec; - cfg: ReturnType; + cfg: OpenClawConfig; discordConfig: DiscordConfig; accountId: string; sessionPrefix: string; @@ -741,7 +741,7 @@ async function dispatchDiscordCommandInteraction(params: { prompt: string; command: ChatCommandDefinition; commandArgs?: DiscordCommandArgs; - cfg: ReturnType; + cfg: OpenClawConfig; discordConfig: DiscordConfig; accountId: string; sessionPrefix: string; diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 547eecde115..7b228086e22 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -22,7 +22,7 @@ import { resolveNativeSkillsEnabled, } from "openclaw/plugin-sdk/config-runtime"; import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking"; import { @@ -593,7 +593,7 @@ function isDiscordDisallowedIntentsError(err: unknown): boolean { export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const startupStartedAt = Date.now(); - const cfg = opts.config ?? loadConfig(); + const cfg = opts.config ?? getRuntimeConfig(); const account = (resolveDiscordAccountForTesting ?? resolveDiscordAccount)({ cfg, accountId: opts.accountId, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 13965f204e2..34c18e28908 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1140,11 +1140,14 @@ export const feishuPlugin: ChannelPlugin { const { createClackPrompter } = await import("openclaw/plugin-sdk/setup-runtime"); - const { writeConfigFile } = await import("openclaw/plugin-sdk/config-runtime"); + const { replaceConfigFile } = await import("openclaw/plugin-sdk/config-runtime"); const prompter = createClackPrompter(); const nextCfg = await runFeishuLogin({ cfg, prompter }); if (nextCfg !== cfg) { - await writeConfigFile(nextCfg); + await replaceConfigFile({ + nextConfig: nextCfg, + afterWrite: { mode: "auto" }, + }); } }, }, diff --git a/extensions/feishu/src/dynamic-agent.ts b/extensions/feishu/src/dynamic-agent.ts index 7f8e04cd061..b215749a2a4 100644 --- a/extensions/feishu/src/dynamic-agent.ts +++ b/extensions/feishu/src/dynamic-agent.ts @@ -72,7 +72,10 @@ export async function maybeCreateDynamicAgent(params: { ], }; - await runtime.config.writeConfigFile(updatedCfg); + await runtime.config.replaceConfigFile({ + nextConfig: updatedCfg, + afterWrite: { mode: "auto" }, + }); return { created: true, updatedCfg, agentId }; } @@ -115,7 +118,10 @@ export async function maybeCreateDynamicAgent(params: { }; // Write updated config using PluginRuntime API - await runtime.config.writeConfigFile(updatedCfg); + await runtime.config.replaceConfigFile({ + nextConfig: updatedCfg, + afterWrite: { mode: "auto" }, + }); return { created: true, updatedCfg, agentId }; } diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index 0bb3711dd01..61db9180f46 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -6,7 +6,7 @@ import { } from "openclaw/plugin-sdk/channel-inbound"; import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing"; import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, @@ -116,7 +116,7 @@ async function waitForWatchSubscribeRetryDelay(params: { export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise { const runtime = resolveRuntime(opts); - const cfg = opts.config ?? loadConfig(); + const cfg = opts.config ?? getRuntimeConfig(); const accountInfo = resolveIMessageAccount({ cfg, accountId: opts.accountId, diff --git a/extensions/imessage/src/probe.ts b/extensions/imessage/src/probe.ts index 615e28cbdb5..f8713ab2abf 100644 --- a/extensions/imessage/src/probe.ts +++ b/extensions/imessage/src/probe.ts @@ -1,5 +1,5 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { detectBinary } from "openclaw/plugin-sdk/setup"; @@ -69,7 +69,7 @@ export async function probeIMessage( timeoutMs?: number, opts: IMessageProbeOptions = {}, ): Promise { - const cfg = opts.cliPath || opts.dbPath ? undefined : loadConfig(); + const cfg = opts.cliPath || opts.dbPath ? undefined : getRuntimeConfig(); const cliPath = opts.cliPath?.trim() || cfg?.channels?.imessage?.cliPath?.trim() || "imsg"; const dbPath = opts.dbPath?.trim() || cfg?.channels?.imessage?.dbPath?.trim(); // Use explicit timeout if provided, otherwise fall back to config, then default diff --git a/extensions/irc/src/monitor.ts b/extensions/irc/src/monitor.ts index c5da51cfd0b..379f1743865 100644 --- a/extensions/irc/src/monitor.ts +++ b/extensions/irc/src/monitor.ts @@ -35,7 +35,7 @@ export function resolveIrcInboundTarget(params: { target: string; senderNick: st export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ stop: () => void }> { const core = getIrcRuntime(); - const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig); + const cfg = opts.config ?? (core.config.current() as CoreConfig); const account = resolveIrcAccount({ cfg, accountId: opts.accountId, diff --git a/extensions/line/src/bot.ts b/extensions/line/src/bot.ts index 401f84b39bb..d7b8a18c756 100644 --- a/extensions/line/src/bot.ts +++ b/extensions/line/src/bot.ts @@ -1,7 +1,7 @@ import type { webhook } from "@line/bot-sdk"; import type { NextFunction, Request, Response } from "express"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history"; import { createNonExitingRuntime, @@ -32,7 +32,7 @@ export interface LineBot { export function createLineBot(opts: LineBotOptions): LineBot { const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); - const cfg = opts.config ?? loadConfig(); + const cfg = opts.config ?? getRuntimeConfig(); const account = resolveLineAccount({ cfg, accountId: opts.accountId, diff --git a/extensions/line/src/channel.logout.test.ts b/extensions/line/src/channel.logout.test.ts index 44384587e67..ac78351206c 100644 --- a/extensions/line/src/channel.logout.test.ts +++ b/extensions/line/src/channel.logout.test.ts @@ -7,12 +7,12 @@ import { setLineRuntime } from "./runtime.js"; const DEFAULT_ACCOUNT_ID = "default"; type LineRuntimeMocks = { - writeConfigFile: ReturnType; + replaceConfigFile: ReturnType; resolveLineAccount: ReturnType; }; function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } { - const writeConfigFile = vi.fn(async () => {}); + const replaceConfigFile = vi.fn(async () => {}); const resolveLineAccount = vi.fn( ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => { const lineConfig = (cfg.channels?.line ?? {}) as { @@ -34,10 +34,10 @@ function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } { ); const runtime = { - config: { writeConfigFile }, + config: { replaceConfigFile }, } as unknown as PluginRuntime; - return { runtime, mocks: { writeConfigFile, resolveLineAccount } }; + return { runtime, mocks: { replaceConfigFile, resolveLineAccount } }; } function resolveAccount( @@ -89,7 +89,10 @@ describe("linePlugin gateway.logoutAccount", () => { expect(result.cleared).toBe(true); expect(result.loggedOut).toBe(true); - expect(mocks.writeConfigFile).toHaveBeenCalledWith({}); + expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: {}, + afterWrite: { mode: "auto" }, + }); }); it("clears tokenFile/secretFile on account logout", async () => { @@ -112,7 +115,10 @@ describe("linePlugin gateway.logoutAccount", () => { expect(result.cleared).toBe(true); expect(result.loggedOut).toBe(true); - expect(mocks.writeConfigFile).toHaveBeenCalledWith({}); + expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: {}, + afterWrite: { mode: "auto" }, + }); }); it("does not write config when account has no token/secret fields", async () => { @@ -134,6 +140,6 @@ describe("linePlugin gateway.logoutAccount", () => { expect(result.cleared).toBe(false); expect(result.loggedOut).toBe(true); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); }); }); diff --git a/extensions/line/src/gateway.ts b/extensions/line/src/gateway.ts index 35734c96210..91b6e1c1fd7 100644 --- a/extensions/line/src/gateway.ts +++ b/extensions/line/src/gateway.ts @@ -112,7 +112,10 @@ export const lineGatewayAdapter: NonNullable[ delete nextCfg.channels; } } - await getLineRuntime().config.writeConfigFile(nextCfg); + await getLineRuntime().config.replaceConfigFile({ + nextConfig: nextCfg, + afterWrite: { mode: "auto" }, + }); } const resolved = resolveLineAccount({ diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts index 2e4acf44b40..2e8dfdfcd77 100644 --- a/extensions/matrix/src/actions.test.ts +++ b/extensions/matrix/src/actions.test.ts @@ -8,7 +8,7 @@ const profileAction = "set-profile" as const; const runtimeStub = { config: { - loadConfig: () => ({}), + current: () => ({}), }, media: { loadWebMedia: async () => { diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index 435abf69ef1..90108bf213f 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -22,7 +22,7 @@ const resolveMatrixAuthContextMock = vi.fn(); const matrixSetupApplyAccountConfigMock = vi.fn(); const matrixSetupValidateInputMock = vi.fn(); const matrixRuntimeLoadConfigMock = vi.fn(); -const matrixRuntimeWriteConfigFileMock = vi.fn(); +const matrixRuntimeReplaceConfigFileMock = vi.fn(); const resetMatrixRoomKeyBackupMock = vi.fn(); const restoreMatrixRoomKeyBackupMock = vi.fn(); const runMatrixSelfVerificationMock = vi.fn(); @@ -96,8 +96,8 @@ vi.mock("./setup-core.js", () => ({ vi.mock("./runtime.js", () => ({ getMatrixRuntime: () => ({ config: { - loadConfig: (...args: unknown[]) => matrixRuntimeLoadConfigMock(...args), - writeConfigFile: (...args: unknown[]) => matrixRuntimeWriteConfigFileMock(...args), + current: (...args: unknown[]) => matrixRuntimeLoadConfigMock(...args), + replaceConfigFile: (...args: unknown[]) => matrixRuntimeReplaceConfigFileMock(...args), }, }), })); @@ -180,7 +180,7 @@ describe("matrix CLI verification commands", () => { matrixSetupValidateInputMock.mockReturnValue(null); matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg); matrixRuntimeLoadConfigMock.mockReturnValue({}); - matrixRuntimeWriteConfigFileMock.mockResolvedValue(undefined); + matrixRuntimeReplaceConfigFileMock.mockResolvedValue(undefined); resolveMatrixAuthContextMock.mockImplementation( ({ cfg, accountId }: { cfg: unknown; accountId?: string | null }) => ({ cfg, @@ -1018,8 +1018,8 @@ describe("matrix CLI verification commands", () => { }), }), ); - expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith( - expect.objectContaining({ + expect(matrixRuntimeReplaceConfigFileMock).toHaveBeenCalledWith({ + nextConfig: expect.objectContaining({ channels: { matrix: { accounts: { @@ -1030,7 +1030,8 @@ describe("matrix CLI verification commands", () => { }, }, }), - ); + afterWrite: { mode: "auto" }, + }); expect(console.log).toHaveBeenCalledWith("Saved matrix account: ops"); expect(console.log).toHaveBeenCalledWith( "Bind this account to an agent: openclaw agents bind --agent --bind matrix:ops", @@ -1086,8 +1087,8 @@ describe("matrix CLI verification commands", () => { { from: "user" }, ); - expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith( - expect.objectContaining({ + expect(matrixRuntimeReplaceConfigFileMock).toHaveBeenCalledWith({ + nextConfig: expect.objectContaining({ channels: { matrix: { enabled: true, @@ -1099,7 +1100,8 @@ describe("matrix CLI verification commands", () => { }, }, }), - ); + afterWrite: { mode: "auto" }, + }); expect(bootstrapMatrixVerificationMock).toHaveBeenCalledWith({ accountId: "ops", cfg: expect.objectContaining({ @@ -1159,8 +1161,8 @@ describe("matrix CLI verification commands", () => { from: "user", }); - expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith( - expect.objectContaining({ + expect(matrixRuntimeReplaceConfigFileMock).toHaveBeenCalledWith({ + nextConfig: expect.objectContaining({ channels: { matrix: { enabled: true, @@ -1172,7 +1174,8 @@ describe("matrix CLI verification commands", () => { }, }, }), - ); + afterWrite: { mode: "auto" }, + }); expect(bootstrapMatrixVerificationMock).toHaveBeenCalledWith({ accountId: "ops", cfg: expect.objectContaining({ @@ -1378,7 +1381,7 @@ describe("matrix CLI verification commands", () => { { from: "user" }, ); - expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled(); + expect(matrixRuntimeReplaceConfigFileMock).toHaveBeenCalled(); expect(process.exitCode).toBeUndefined(); expect(console.log).toHaveBeenCalledWith("Saved matrix account: ops"); expect(console.error).toHaveBeenCalledWith( @@ -1408,7 +1411,7 @@ describe("matrix CLI verification commands", () => { { from: "user" }, ); - expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled(); + expect(matrixRuntimeReplaceConfigFileMock).toHaveBeenCalled(); expect(process.exitCode).toBeUndefined(); const jsonOutput = stdoutWriteMock.mock.calls.at(-1)?.[0]; expect(typeof jsonOutput).toBe("string"); @@ -1558,7 +1561,7 @@ describe("matrix CLI verification commands", () => { avatarUrl: "mxc://example/avatar", }), ); - expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled(); + expect(matrixRuntimeReplaceConfigFileMock).toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("Account: alerts"); expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix.accounts.alerts"); }); diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index 1ea87b30ece..84343db923e 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -150,7 +150,7 @@ function resolveMatrixCliAccountContext(accountId?: string): { accountId: string; cfg: CoreConfig; } { - const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig; + const cfg = getMatrixRuntime().config.current() as CoreConfig; return { accountId: resolveMatrixAuthContext({ cfg, accountId }).accountId, cfg, @@ -284,7 +284,7 @@ async function addMatrixAccount(params: { enableEncryption?: boolean; }): Promise { const runtime = getMatrixRuntime(); - const cfg = runtime.config.loadConfig() as CoreConfig; + const cfg = runtime.config.current() as CoreConfig; if (!matrixSetupAdapter.applyAccountConfig) { throw new Error("Matrix account setup is unavailable."); } @@ -325,7 +325,10 @@ async function addMatrixAccount(params: { if (params.enableEncryption === true) { updated = updateMatrixAccountConfig(updated, accountId, { encryption: true }); } - await runtime.config.writeConfigFile(updated as never); + await runtime.config.replaceConfigFile({ + nextConfig: updated as never, + afterWrite: { mode: "auto" }, + }); const accountConfig = resolveMatrixAccountConfig({ cfg: updated, accountId }); let verificationBootstrap: MatrixCliAccountAddResult["verificationBootstrap"] = { @@ -362,11 +365,14 @@ async function addMatrixAccount(params: { }); let resolvedAvatarUrl = synced.resolvedAvatarUrl; if (synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl) { - const latestCfg = runtime.config.loadConfig() as CoreConfig; + const latestCfg = runtime.config.current() as CoreConfig; const withAvatar = updateMatrixAccountConfig(latestCfg, accountId, { avatarUrl: synced.resolvedAvatarUrl, }); - await runtime.config.writeConfigFile(withAvatar as never); + await runtime.config.replaceConfigFile({ + nextConfig: withAvatar as never, + afterWrite: { mode: "auto" }, + }); resolvedAvatarUrl = synced.resolvedAvatarUrl; } profile = { @@ -462,7 +468,7 @@ async function inspectMatrixDirectRoom(params: { accountId: string; userId: string; }): Promise { - const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig; + const cfg = getMatrixRuntime().config.current() as CoreConfig; const [{ withResolvedActionClient }, { inspectMatrixDirectRooms }] = await Promise.all([ loadMatrixActionClientModule(), loadMatrixDirectManagementModule(), @@ -492,7 +498,7 @@ async function repairMatrixDirectRoom(params: { accountId: string; userId: string; }): Promise { - const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig; + const cfg = getMatrixRuntime().config.current() as CoreConfig; const account = resolveMatrixAccount({ cfg, accountId: params.accountId }); const [{ withStartedActionClient }, { repairMatrixDirectRooms }] = await Promise.all([ loadMatrixActionClientModule(), @@ -734,7 +740,10 @@ async function setupMatrixEncryption(params: { ? updateMatrixAccountConfig(cfg, accountId, { encryption: true }) : cfg; if (encryptionChanged) { - await runtime.config.writeConfigFile(updated as never); + await runtime.config.replaceConfigFile({ + nextConfig: updated as never, + afterWrite: { mode: "auto" }, + }); } const canUseExistingBootstrap = diff --git a/extensions/matrix/src/matrix/actions/messages.test.ts b/extensions/matrix/src/matrix/actions/messages.test.ts index b6519da6962..efb0b477612 100644 --- a/extensions/matrix/src/matrix/actions/messages.test.ts +++ b/extensions/matrix/src/matrix/actions/messages.test.ts @@ -13,7 +13,7 @@ const MATRIX_ACTION_TEST_CFG = { function installMatrixActionTestRuntime(): void { setMatrixRuntime({ config: { - loadConfig: () => ({}), + current: () => ({}), }, channel: { text: { diff --git a/extensions/matrix/src/matrix/actions/verification.test.ts b/extensions/matrix/src/matrix/actions/verification.test.ts index eb302f6b8ee..bd15c9f43e4 100644 --- a/extensions/matrix/src/matrix/actions/verification.test.ts +++ b/extensions/matrix/src/matrix/actions/verification.test.ts @@ -11,7 +11,7 @@ const loadConfigMock = vi.fn(() => ({ vi.mock("../../runtime.js", () => ({ getMatrixRuntime: () => ({ config: { - loadConfig: loadConfigMock, + current: loadConfigMock, }, }), })); diff --git a/extensions/matrix/src/matrix/client-resolver.test-helpers.ts b/extensions/matrix/src/matrix/client-resolver.test-helpers.ts index f171f76393d..3721f03e52d 100644 --- a/extensions/matrix/src/matrix/client-resolver.test-helpers.ts +++ b/extensions/matrix/src/matrix/client-resolver.test-helpers.ts @@ -81,7 +81,7 @@ export function primeMatrixClientResolverMocks(params?: { loadConfigMock.mockReturnValue(cfg); getMatrixRuntimeMock.mockReturnValue({ config: { - loadConfig: loadConfigMock, + current: loadConfigMock, }, }); getActiveMatrixClientMock.mockReturnValue(null); diff --git a/extensions/matrix/src/matrix/client/storage.ts b/extensions/matrix/src/matrix/client/storage.ts index 8d5c9a40400..ff194aceecb 100644 --- a/extensions/matrix/src/matrix/client/storage.ts +++ b/extensions/matrix/src/matrix/client/storage.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { requiresExplicitMatrixDefaultAccount, resolveMatrixDefaultOrOnlyAccountId, @@ -48,7 +49,7 @@ function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): { } function assertLegacyMigrationAccountSelection(params: { accountKey: string }): void { - const cfg = getMatrixRuntime().config.loadConfig(); + const cfg = getMatrixRuntime().config.current() as OpenClawConfig; if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") { return; } diff --git a/extensions/matrix/src/matrix/credentials-read.ts b/extensions/matrix/src/matrix/credentials-read.ts index 4222f78fa2d..ffaa5a9d914 100644 --- a/extensions/matrix/src/matrix/credentials-read.ts +++ b/extensions/matrix/src/matrix/credentials-read.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { requiresExplicitMatrixDefaultAccount, resolveMatrixDefaultOrOnlyAccountId, @@ -54,7 +55,7 @@ function resolveLegacyMatrixCredentialsPath(env: NodeJS.ProcessEnv): string { function shouldReadLegacyCredentialsForAccount(accountId?: string | null): boolean { const normalizedAccountId = normalizeAccountId(accountId); - const cfg = getMatrixRuntime().config.loadConfig(); + const cfg = getMatrixRuntime().config.current() as OpenClawConfig; if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") { return normalizedAccountId === DEFAULT_ACCOUNT_ID; } diff --git a/extensions/matrix/src/matrix/draft-stream.test.ts b/extensions/matrix/src/matrix/draft-stream.test.ts index 113f3247f99..1350162c996 100644 --- a/extensions/matrix/src/matrix/draft-stream.test.ts +++ b/extensions/matrix/src/matrix/draft-stream.test.ts @@ -130,7 +130,9 @@ vi.mock("./send.js", () => ({ sendSingleTextMessageMatrix: sendModuleMocks.sendSingleTextMessageMatrix, })); const runtimeStub = { - config: { loadConfig: () => loadConfigMock() }, + config: { + current: () => loadConfigMock(), + }, channel: { text: { resolveTextChunkLimit: (cfg: unknown, channel: unknown, accountId?: unknown) => diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts index 5dbcbe2f9c0..4a9b7570955 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -128,7 +128,7 @@ export function createMatrixHandlerTestHarness( } as never, core: { config: { - loadConfig: () => cfgForHandler, + current: () => cfgForHandler, }, channel: { pairing: { diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 175841c4348..0753b332ee5 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -673,7 +673,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }; const storeAllowFrom = isDirectMessage ? await readStoreAllowFrom() : []; const roomUsers = roomConfig?.users ?? []; - const liveCfg = core.config.loadConfig() as CoreConfig; + const liveCfg = core.config.current() as CoreConfig; const liveAccountAllowlists = resolveMatrixAccountAllowlistConfig({ cfg: liveCfg, accountId, diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index 7f1407c2fb2..cf5b2faa88d 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -227,12 +227,13 @@ vi.mock("../../resolve-targets.js", () => ({ vi.mock("../../runtime.js", () => ({ getMatrixRuntime: () => ({ config: { - loadConfig: () => ({ + current: () => ({ channels: { matrix: hoisted.accountConfig, }, }), - writeConfigFile: vi.fn(), + replaceConfigFile: vi.fn(), + mutateConfigFile: vi.fn(), }, logging: { getChildLogger: () => hoisted.logger, diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 9a1edf7fcdf..ed5f5e6e536 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -71,7 +71,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi throw new Error("Matrix provider requires Node (bun runtime not supported)"); } const core = getMatrixRuntime(); - let cfg = core.config.loadConfig() as CoreConfig; + let cfg = core.config.current() as CoreConfig; if (cfg.channels?.["matrix"]?.enabled === false) { return; } @@ -436,8 +436,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi accountConfig, logger, logVerboseMessage, - loadConfig: () => core.config.loadConfig() as CoreConfig, - writeConfigFile: async (nextCfg) => await core.config.writeConfigFile(nextCfg), + getRuntimeConfig: () => core.config.current() as CoreConfig, + replaceConfigFile: async (nextCfg) => { + await core.config.replaceConfigFile({ + nextConfig: nextCfg, + afterWrite: { mode: "auto" }, + }); + }, loadWebMedia: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes), env: process.env, abortSignal: opts.abortSignal, diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 4534d7becc4..ef37aaa94e8 100644 --- a/extensions/matrix/src/matrix/monitor/replies.test.ts +++ b/extensions/matrix/src/matrix/monitor/replies.test.ts @@ -34,7 +34,7 @@ describe("deliverMatrixReplies", () => { const runtimeStub = { config: { - loadConfig: () => loadConfigMock(), + current: () => loadConfigMock(), }, channel: { text: { diff --git a/extensions/matrix/src/matrix/monitor/startup.test.ts b/extensions/matrix/src/matrix/monitor/startup.test.ts index 493d3139b26..143d5de2f39 100644 --- a/extensions/matrix/src/matrix/monitor/startup.test.ts +++ b/extensions/matrix/src/matrix/monitor/startup.test.ts @@ -127,8 +127,8 @@ describe("runMatrixStartupMaintenance", () => { error: vi.fn(), }, logVerboseMessage: vi.fn(), - loadConfig: vi.fn(() => ({ channels: { matrix: {} } })), - writeConfigFile: vi.fn(async () => {}), + getRuntimeConfig: vi.fn(() => ({ channels: { matrix: {} } })), + replaceConfigFile: vi.fn(async () => {}), loadWebMedia: vi.fn(async () => ({ buffer: Buffer.from("avatar"), contentType: "image/png", @@ -166,7 +166,7 @@ describe("runMatrixStartupMaintenance", () => { "ops", { avatarUrl: "mxc://avatar" }, ); - expect(params.writeConfigFile).toHaveBeenCalledWith(updatedCfg as never); + expect(params.replaceConfigFile).toHaveBeenCalledWith(updatedCfg as never); expect(params.logVerboseMessage).toHaveBeenCalledWith( "matrix: persisted converted avatar URL for account ops (mxc://avatar)", ); diff --git a/extensions/matrix/src/matrix/monitor/startup.ts b/extensions/matrix/src/matrix/monitor/startup.ts index 78a1a1e8ecd..5ef6ef75740 100644 --- a/extensions/matrix/src/matrix/monitor/startup.ts +++ b/extensions/matrix/src/matrix/monitor/startup.ts @@ -60,8 +60,8 @@ export async function runMatrixStartupMaintenance( accountConfig: MatrixConfig; logger: RuntimeLogger; logVerboseMessage: (message: string) => void; - loadConfig: () => CoreConfig; - writeConfigFile: (cfg: never) => Promise; + getRuntimeConfig: () => CoreConfig; + replaceConfigFile: (cfg: never) => Promise; loadWebMedia: ( url: string, maxBytes: number, @@ -93,11 +93,11 @@ export async function runMatrixStartupMaintenance( profileSync.resolvedAvatarUrl && params.accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl ) { - const latestCfg = params.loadConfig(); + const latestCfg = params.getRuntimeConfig(); const updatedCfg = runtimeDeps.updateMatrixAccountConfig(latestCfg, params.accountId, { avatarUrl: profileSync.resolvedAvatarUrl, }); - await params.writeConfigFile(updatedCfg as never); + await params.replaceConfigFile(updatedCfg as never); throwIfMatrixStartupAborted(params.abortSignal); params.logVerboseMessage( `matrix: persisted converted avatar URL for account ${params.accountId} (${profileSync.resolvedAvatarUrl})`, diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index ff827517fae..b8f7c64013b 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -53,7 +53,7 @@ vi.mock("./client-bootstrap.js", () => ({ const runtimeStub = { config: { - loadConfig: () => loadConfigMock(), + current: () => loadConfigMock(), }, media: { loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), diff --git a/extensions/matrix/src/profile-update.ts b/extensions/matrix/src/profile-update.ts index 41d018d6104..8188bf919e8 100644 --- a/extensions/matrix/src/profile-update.ts +++ b/extensions/matrix/src/profile-update.ts @@ -27,7 +27,7 @@ export async function applyMatrixProfileUpdate(params: { mediaLocalRoots?: readonly string[]; }): Promise { const runtime = getMatrixRuntime(); - const persistedCfg = runtime.config.loadConfig() as CoreConfig; + const persistedCfg = runtime.config.current() as CoreConfig; const accountId = normalizeAccountId(params.account); const displayName = params.displayName?.trim() || null; const avatarUrl = params.avatarUrl?.trim() || null; @@ -50,7 +50,10 @@ export async function applyMatrixProfileUpdate(params: { name: displayName ?? undefined, avatarUrl: persistedAvatarUrl ?? undefined, }); - await runtime.config.writeConfigFile(updated as never); + await runtime.config.replaceConfigFile({ + nextConfig: updated as never, + afterWrite: { mode: "auto" }, + }); return { accountId, diff --git a/extensions/matrix/src/test-runtime.ts b/extensions/matrix/src/test-runtime.ts index 7c57c159ed7..4cbe73f0357 100644 --- a/extensions/matrix/src/test-runtime.ts +++ b/extensions/matrix/src/test-runtime.ts @@ -13,11 +13,19 @@ type MatrixTestRuntimeOptions = { stateDir?: string; }; +type MatrixRuntimeStub = { + config: Pick; + channel?: PluginRuntime["channel"]; + logging?: PluginRuntime["logging"]; + state: Pick, "resolveStateDir">; +}; + export function installMatrixTestRuntime(options: MatrixTestRuntimeOptions = {}): void { const defaultStateDirResolver: NonNullable["resolveStateDir"] = ( _env, homeDir, ) => options.stateDir ?? (homeDir ?? (() => "/tmp"))(); + const getRuntimeConfig = () => options.cfg ?? {}; const logging: PluginRuntime["logging"] | undefined = options.logging ? ({ shouldLogVerbose: () => false, @@ -30,16 +38,20 @@ export function installMatrixTestRuntime(options: MatrixTestRuntimeOptions = {}) } as PluginRuntime["logging"]) : undefined; - setMatrixRuntime({ + const runtime: MatrixRuntimeStub = { config: { - loadConfig: () => options.cfg ?? {}, + current: getRuntimeConfig, + mutateConfigFile: vi.fn(), + replaceConfigFile: vi.fn(), }, ...(options.channel ? { channel: options.channel as PluginRuntime["channel"] } : {}), ...(logging ? { logging } : {}), state: { resolveStateDir: defaultStateDirResolver, }, - } as PluginRuntime); + }; + + setMatrixRuntime(runtime as unknown as PluginRuntime); } type MatrixMonitorTestRuntimeOptions = Pick & { diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 948dcbaa709..005676b0a84 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -434,7 +434,7 @@ function buildMattermostWsUrl(baseUrl: string): string { export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise { const core = getMattermostRuntime(); const runtime = resolveRuntime(opts); - const cfg = opts.config ?? core.config.loadConfig(); + const cfg = (opts.config ?? core.config.current()) as OpenClawConfig; const account = resolveMattermostAccount({ cfg, accountId: opts.accountId, diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index add1b516846..22acff620f4 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -1,4 +1,3 @@ -import { getRuntimeConfigSnapshot } from "openclaw/plugin-sdk/config-runtime"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { registerMemoryCli } from "./src/cli.js"; import { registerDreamingCommand } from "./src/dreaming-command.js"; @@ -41,23 +40,27 @@ export default definePluginEntry({ }); api.registerTool( - (ctx) => - createMemorySearchTool({ - config: ctx.runtimeConfig ?? ctx.config, - getConfig: () => getRuntimeConfigSnapshot() ?? ctx.runtimeConfig ?? ctx.config, + (ctx) => { + const getConfig = () => ctx.getRuntimeConfig?.() ?? ctx.runtimeConfig ?? ctx.config; + return createMemorySearchTool({ + config: getConfig(), + getConfig, agentSessionKey: ctx.sessionKey, sandboxed: ctx.sandboxed, - }), + }); + }, { names: ["memory_search"] }, ); api.registerTool( - (ctx) => - createMemoryGetTool({ - config: ctx.runtimeConfig ?? ctx.config, - getConfig: () => getRuntimeConfigSnapshot() ?? ctx.runtimeConfig ?? ctx.config, + (ctx) => { + const getConfig = () => ctx.getRuntimeConfig?.() ?? ctx.runtimeConfig ?? ctx.config; + return createMemoryGetTool({ + config: getConfig(), + getConfig, agentSessionKey: ctx.sessionKey, - }), + }); + }, { names: ["memory_get"] }, ); diff --git a/extensions/memory-core/src/cli.host.runtime.ts b/extensions/memory-core/src/cli.host.runtime.ts index 2939098110f..f4b0a4ca217 100644 --- a/extensions/memory-core/src/cli.host.runtime.ts +++ b/extensions/memory-core/src/cli.host.runtime.ts @@ -13,7 +13,7 @@ export { withProgressTotals, } from "openclaw/plugin-sdk/memory-core-host-runtime-cli"; export { - loadConfig, + getRuntimeConfig, resolveDefaultAgentId, resolveSessionTranscriptsDirForAgent, resolveStateDir, diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts index d053e601391..287707babee 100644 --- a/extensions/memory-core/src/cli.runtime.ts +++ b/extensions/memory-core/src/cli.runtime.ts @@ -9,10 +9,10 @@ import { colorize, defaultRuntime, formatErrorMessage, + getRuntimeConfig, getMemorySearchManager, isRich, listMemoryFiles, - loadConfig, normalizeExtraMemoryPaths, resolveCommandSecretRefsViaGateway, resolveDefaultAgentId, @@ -92,7 +92,7 @@ function getMemoryCommandSecretTargetIds(): Set { async function loadMemoryCommandConfig(commandName: string): Promise { const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ - config: loadConfig(), + config: getRuntimeConfig(), commandName, targetIds: getMemoryCommandSecretTargetIds(), }); diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index 3637e5f0284..efeb9a2e71a 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -12,7 +12,7 @@ import { import { readShortTermRecallEntries, recordShortTermRecalls } from "./short-term-promotion.js"; const getMemorySearchManager = vi.hoisted(() => vi.fn()); -const loadConfig = vi.hoisted(() => vi.fn(() => ({}))); +const getRuntimeConfig = vi.hoisted(() => vi.fn(() => ({}))); const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "main")); const resolveCommandSecretRefsViaGateway = vi.hoisted(() => vi.fn(async ({ config }: { config: unknown }) => ({ @@ -34,7 +34,7 @@ vi.mock("./cli.host.runtime.js", async () => { getMemorySearchManager, isRich: runtimeCli.isRich, listMemoryFiles: runtimeFiles.listMemoryFiles, - loadConfig, + getRuntimeConfig, normalizeExtraMemoryPaths: runtimeFiles.normalizeExtraMemoryPaths, resolveCommandSecretRefsViaGateway, resolveDefaultAgentId, @@ -73,7 +73,7 @@ beforeAll(async () => { beforeEach(() => { getMemorySearchManager.mockReset(); - loadConfig.mockReset().mockReturnValue({}); + getRuntimeConfig.mockReset().mockReturnValue({}); resolveDefaultAgentId.mockReset().mockReturnValue("main"); resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({ resolvedConfig: config, @@ -247,7 +247,7 @@ describe("memory cli", () => { }); it("resolves configured memory SecretRefs through gateway snapshot", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ agents: { defaults: { memorySearch: { diff --git a/extensions/memory-core/src/dreaming-command.test.ts b/extensions/memory-core/src/dreaming-command.test.ts index 95c5361ec06..872fd312788 100644 --- a/extensions/memory-core/src/dreaming-command.test.ts +++ b/extensions/memory-core/src/dreaming-command.test.ts @@ -25,7 +25,11 @@ function createHarness(initialConfig: OpenClawConfig = {}) { const runtime = { config: { + current: vi.fn(() => runtimeConfig), loadConfig: vi.fn(() => runtimeConfig), + replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: OpenClawConfig }) => { + runtimeConfig = nextConfig; + }), writeConfigFile: vi.fn(async (nextConfig: OpenClawConfig) => { runtimeConfig = nextConfig; }), @@ -111,7 +115,7 @@ describe("memory-core /dreaming command", () => { const result = await command.handler(createCommandContext("off")); - expect(runtime.config.writeConfigFile).toHaveBeenCalledTimes(1); + expect(runtime.config.replaceConfigFile).toHaveBeenCalledTimes(1); expect(resolveStoredDreaming(getRuntimeConfig())).toMatchObject({ enabled: false, frequency: "0 */6 * * *", @@ -129,7 +133,7 @@ describe("memory-core /dreaming command", () => { ); expect(result.text).toContain("requires operator.admin"); - expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); + expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled(); }); it("blocks write-scoped gateway callers from persisting dreaming config", async () => { @@ -142,7 +146,7 @@ describe("memory-core /dreaming command", () => { ); expect(result.text).toContain("requires operator.admin"); - expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); + expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled(); }); it("allows admin-scoped gateway callers to persist dreaming config", async () => { @@ -154,7 +158,7 @@ describe("memory-core /dreaming command", () => { }), ); - expect(runtime.config.writeConfigFile).toHaveBeenCalledTimes(1); + expect(runtime.config.replaceConfigFile).toHaveBeenCalledTimes(1); expect(resolveStoredDreaming(getRuntimeConfig())).toMatchObject({ enabled: true, }); @@ -187,7 +191,7 @@ describe("memory-core /dreaming command", () => { expect(result.text).toContain("- enabled: off (America/Los_Angeles)"); expect(result.text).toContain("- sweep cadence: 15 */8 * * *"); expect(result.text).toContain("- promotion policy: score>=0.8, recalls>=3, uniqueQueries>=3"); - expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); + expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled(); }); it("shows usage for invalid args and does not mutate config", async () => { @@ -195,6 +199,6 @@ describe("memory-core /dreaming command", () => { const result = await command.handler(createCommandContext("unknown-mode")); expect(result.text).toContain("Usage: /dreaming status"); - expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); + expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled(); }); }); diff --git a/extensions/memory-core/src/dreaming-command.ts b/extensions/memory-core/src/dreaming-command.ts index 2f202b3e10c..165382bceec 100644 --- a/extensions/memory-core/src/dreaming-command.ts +++ b/extensions/memory-core/src/dreaming-command.ts @@ -90,7 +90,7 @@ export function registerDreamingCommand(api: OpenClawPluginApi): void { .split(/\s+/) .filter(Boolean) .map((token) => normalizeLowercaseStringOrEmpty(token)); - const currentConfig = api.runtime.config.loadConfig(); + const currentConfig = api.runtime.config.current() as OpenClawConfig; if ( !firstToken || @@ -111,7 +111,10 @@ export function registerDreamingCommand(api: OpenClawPluginApi): void { } const enabled = firstToken === "on"; const nextConfig = updateDreamingEnabledInConfig(currentConfig, enabled); - await api.runtime.config.writeConfigFile(nextConfig); + await api.runtime.config.replaceConfigFile({ + nextConfig, + afterWrite: { mode: "auto" }, + }); return { text: [ `Dreaming ${enabled ? "enabled" : "disabled"}.`, diff --git a/extensions/memory-core/src/dreaming-narrative.ts b/extensions/memory-core/src/dreaming-narrative.ts index 10b639fc309..a05e5d26b66 100644 --- a/extensions/memory-core/src/dreaming-narrative.ts +++ b/extensions/memory-core/src/dreaming-narrative.ts @@ -3,7 +3,7 @@ import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { - loadConfig, + getRuntimeConfig, loadSessionStore, resolveStorePath, updateSessionStore, @@ -717,7 +717,7 @@ async function normalizeSessionEntryPathForComparison(params: { } async function scrubDreamingNarrativeArtifacts(logger: Logger): Promise { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const agentsDir = path.join(resolveStateDir(), "agents"); let agentEntries: Dirent[] = []; try { diff --git a/extensions/memory-core/src/dreaming.test.ts b/extensions/memory-core/src/dreaming.test.ts index 89ac9452884..125e8a5fcf2 100644 --- a/extensions/memory-core/src/dreaming.test.ts +++ b/extensions/memory-core/src/dreaming.test.ts @@ -1334,7 +1334,7 @@ describe("gateway startup reconciliation", () => { const logger = createLogger(); const harness = createCronHarness(); const onMock = vi.fn(); - const runtimeLoadConfig = vi.fn( + const runtimeCurrentConfig = vi.fn( () => ({ plugins: { @@ -1370,7 +1370,7 @@ describe("gateway startup reconciliation", () => { logger, runtime: { config: { - loadConfig: runtimeLoadConfig, + current: runtimeCurrentConfig, }, }, on: onMock, @@ -1395,7 +1395,7 @@ describe("gateway startup reconciliation", () => { { trigger: "heartbeat", workspaceDir: ".", sessionKey }, ); - expect(runtimeLoadConfig).toHaveBeenCalled(); + expect(runtimeCurrentConfig).toHaveBeenCalled(); expect(result).toEqual({ handled: true, reason: "memory-core: short-term dreaming disabled", @@ -1411,7 +1411,7 @@ describe("gateway startup reconciliation", () => { const harness = createCronHarness(); const onMock = vi.fn(); const workspaceDir = await createTempWorkspace("memory-dreaming-live-config-workspace-"); - const runtimeLoadConfig = vi.fn( + const runtimeCurrentConfig = vi.fn( () => ({ agents: { @@ -1454,7 +1454,7 @@ describe("gateway startup reconciliation", () => { logger, runtime: { config: { - loadConfig: runtimeLoadConfig, + current: runtimeCurrentConfig, }, }, on: onMock, @@ -1483,7 +1483,7 @@ describe("gateway startup reconciliation", () => { handled: true, reason: "memory-core: short-term dreaming processed", }); - expect(runtimeLoadConfig).toHaveBeenCalled(); + expect(runtimeCurrentConfig).toHaveBeenCalled(); expect(logger.warn).not.toHaveBeenCalledWith( "memory-core: dreaming promotion skipped because no memory workspace is available.", ); @@ -1497,7 +1497,7 @@ describe("gateway startup reconciliation", () => { const logger = createLogger(); const harness = createCronHarness(); const onMock = vi.fn(); - const runtimeLoadConfig = vi.fn( + const runtimeCurrentConfig = vi.fn( () => ({ agents: { @@ -1525,7 +1525,7 @@ describe("gateway startup reconciliation", () => { logger, runtime: { config: { - loadConfig: runtimeLoadConfig, + current: runtimeCurrentConfig, }, }, on: onMock, @@ -1550,7 +1550,7 @@ describe("gateway startup reconciliation", () => { { trigger: "heartbeat", workspaceDir: ".", sessionKey }, ); - expect(runtimeLoadConfig).toHaveBeenCalled(); + expect(runtimeCurrentConfig).toHaveBeenCalled(); expect(result).toEqual({ handled: true, reason: "memory-core: short-term dreaming disabled", diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index 89a2f797281..38dfe952ee0 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -681,7 +681,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void let lastRuntimeCronRef: CronServiceLike | null = null; const resolveCurrentConfig = (): OpenClawConfig => - api.runtime.config?.loadConfig?.() ?? api.config; + (api.runtime.config?.current?.() ?? api.config) as OpenClawConfig; const runtimeConfigKey = (config: ShortTermPromotionDreamingConfig): string => [ diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts index 0936cb0610a..a103fbe0222 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -491,7 +491,7 @@ describe("memory plugin e2e", () => { }, runtime: { config: { - loadConfig: () => configFile, + current: () => configFile, }, }, logger, @@ -616,7 +616,7 @@ describe("memory plugin e2e", () => { }, runtime: { config: { - loadConfig: () => configFile, + current: () => configFile, }, }, logger: { @@ -739,7 +739,7 @@ describe("memory plugin e2e", () => { }, runtime: { config: { - loadConfig: () => configFile, + current: () => configFile, }, }, logger: { @@ -964,7 +964,7 @@ describe("memory plugin e2e", () => { }, runtime: { config: { - loadConfig: () => configFile, + current: () => configFile, }, }, logger: { @@ -1100,7 +1100,7 @@ describe("memory plugin e2e", () => { }, runtime: { config: { - loadConfig: () => configFile, + current: () => configFile, }, }, logger: { @@ -1225,7 +1225,7 @@ describe("memory plugin e2e", () => { }, runtime: { config: { - loadConfig: () => configFile, + current: () => configFile, }, }, logger: { diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index 13da9742089..31c61799c38 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -9,7 +9,10 @@ import { randomUUID } from "node:crypto"; import type * as LanceDB from "@lancedb/lancedb"; import OpenAI from "openai"; -import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/config-runtime"; +import { + resolveLivePluginConfigObject, + type OpenClawConfig, +} from "openclaw/plugin-sdk/config-runtime"; import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { Type } from "typebox"; @@ -381,7 +384,9 @@ export default definePluginEntry({ const autoCaptureCursors = new Map(); const resolveCurrentHookConfig = () => { const runtimePluginConfig = resolveLivePluginConfigObject( - api.runtime.config?.loadConfig, + api.runtime.config?.current + ? () => api.runtime.config.current() as OpenClawConfig + : undefined, "memory-lancedb", api.pluginConfig as Record, ); diff --git a/extensions/memory-wiki/cli-metadata.test.ts b/extensions/memory-wiki/cli-metadata.test.ts index 73956d62f35..f45b2a54e57 100644 --- a/extensions/memory-wiki/cli-metadata.test.ts +++ b/extensions/memory-wiki/cli-metadata.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../../src/config/config.js", () => ({ + getRuntimeConfig: mocks.loadConfig, loadConfig: mocks.loadConfig, })); diff --git a/extensions/migrate-claude/apply.ts b/extensions/migrate-claude/apply.ts index 07f1b86a97e..6c520b3cc91 100644 --- a/extensions/migrate-claude/apply.ts +++ b/extensions/migrate-claude/apply.ts @@ -16,6 +16,54 @@ import { appendItem } from "./helpers.js"; import { buildClaudePlan } from "./plan.js"; import { applyGeneratedSkillItem } from "./skills.js"; +function withCachedConfigRuntime( + runtime: MigrationProviderContext["runtime"] | undefined, + fallbackConfig: MigrationProviderContext["config"], +): MigrationProviderContext["runtime"] | undefined { + if (!runtime) { + return undefined; + } + const configApi = runtime.config; + if (!configApi?.current || !configApi.mutateConfigFile) { + return runtime; + } + let cachedConfig: MigrationProviderContext["config"] | undefined; + const current = (): ReturnType => { + cachedConfig ??= structuredClone( + (configApi.current() ?? fallbackConfig) as MigrationProviderContext["config"], + ); + return cachedConfig; + }; + return { + ...runtime, + config: { + ...runtime.config, + current, + mutateConfigFile: async (params) => { + const result = await configApi.mutateConfigFile({ + ...params, + mutate: async (draft, context) => { + const mutationResult = await params.mutate(draft, context); + cachedConfig = structuredClone(draft); + return mutationResult; + }, + }); + cachedConfig = structuredClone(result.nextConfig); + return result; + }, + ...(configApi.replaceConfigFile + ? { + replaceConfigFile: async (params) => { + const result = await configApi.replaceConfigFile(params); + cachedConfig = structuredClone(result.nextConfig); + return result; + }, + } + : {}), + }, + }; +} + export async function applyClaudePlan(params: { ctx: MigrationProviderContext; plan?: MigrationPlan; @@ -23,6 +71,8 @@ export async function applyClaudePlan(params: { }): Promise { const plan = params.plan ?? (await buildClaudePlan(params.ctx)); const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "claude"); + const runtime = withCachedConfigRuntime(params.ctx.runtime ?? params.runtime, params.ctx.config); + const applyCtx = { ...params.ctx, runtime }; const items: MigrationItem[] = []; for (const item of plan.items) { if (item.status !== "planned") { @@ -30,12 +80,7 @@ export async function applyClaudePlan(params: { continue; } if (item.kind === "config") { - items.push( - await applyConfigItem( - { ...params.ctx, runtime: params.ctx.runtime ?? params.runtime }, - item, - ), - ); + items.push(await applyConfigItem(applyCtx, item)); } else if (item.kind === "manual") { items.push(applyManualItem(item)); } else if (item.action === "archive") { diff --git a/extensions/migrate-claude/config.ts b/extensions/migrate-claude/config.ts index c97f3e85a9b..3005c4a97a3 100644 --- a/extensions/migrate-claude/config.ts +++ b/extensions/migrate-claude/config.ts @@ -24,6 +24,13 @@ type MappedMcpSource = { const CONFIG_RUNTIME_UNAVAILABLE = "config runtime unavailable"; const MISSING_CONFIG_PATCH = "missing config patch"; +class ConfigPatchConflictError extends Error { + constructor(readonly reason: string) { + super(reason); + this.name = "ConfigPatchConflictError"; + } +} + function readPath(root: Record, path: readonly string[]): unknown { let current: unknown = root; for (const segment of path) { @@ -304,18 +311,30 @@ export async function applyConfigItem( if (!details) { return markMigrationItemError(item, MISSING_CONFIG_PATCH); } - if (!ctx.runtime?.config.writeConfigFile) { + const configApi = ctx.runtime?.config; + if (!configApi?.current || !configApi.mutateConfigFile) { return markMigrationItemError(item, CONFIG_RUNTIME_UNAVAILABLE); } try { - const nextConfig = structuredClone(ctx.runtime.config.loadConfig?.() ?? ctx.config); - if (!ctx.overwrite && hasPatchConflict(nextConfig, details.path, details.value)) { + const currentConfig = configApi.current() as MigrationProviderContext["config"]; + if (!ctx.overwrite && hasPatchConflict(currentConfig, details.path, details.value)) { return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS); } - writePath(nextConfig as Record, details.path, details.value); - await ctx.runtime.config.writeConfigFile(nextConfig); + await configApi.mutateConfigFile({ + base: "runtime", + afterWrite: { mode: "auto" }, + mutate(draft) { + if (!ctx.overwrite && hasPatchConflict(draft, details.path, details.value)) { + throw new ConfigPatchConflictError(MIGRATION_REASON_TARGET_EXISTS); + } + writePath(draft as Record, details.path, details.value); + }, + }); return { ...item, status: "migrated" }; } catch (err) { + if (err instanceof ConfigPatchConflictError) { + return markMigrationItemConflict(item, err.reason); + } return markMigrationItemError(item, err instanceof Error ? err.message : String(err)); } } diff --git a/extensions/migrate-claude/test/provider-helpers.ts b/extensions/migrate-claude/test/provider-helpers.ts index c13acecd739..4dbe9eb9ed4 100644 --- a/extensions/migrate-claude/test/provider-helpers.ts +++ b/extensions/migrate-claude/test/provider-helpers.ts @@ -37,15 +37,63 @@ export function makeConfigRuntime( config: OpenClawConfig, onWrite?: (next: OpenClawConfig) => void, ): NonNullable { + const commitConfig = (next: OpenClawConfig) => { + for (const key of Object.keys(config) as Array) { + delete config[key]; + } + Object.assign(config, next); + onWrite?.(next); + }; + return { config: { - loadConfig: () => config, - writeConfigFile: async (next: OpenClawConfig) => { - for (const key of Object.keys(config) as Array) { - delete config[key]; - } - Object.assign(config, next); - onWrite?.(next); + current: () => config, + mutateConfigFile: async ({ + afterWrite, + mutate, + }: { + afterWrite?: unknown; + mutate: (draft: OpenClawConfig, context: unknown) => Promise | void; + }) => { + const next = structuredClone(config); + const result = await mutate(next, { + snapshot: { + path: "/tmp/openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + issues: [], + warnings: [], + legacyIssues: [], + config: next, + resolved: next, + runtimeConfig: next, + sourceConfig: next, + }, + previousHash: "test", + }); + commitConfig(next); + return { + nextConfig: next, + afterWrite, + followUp: { mode: "auto", requiresRestart: false }, + result, + }; + }, + replaceConfigFile: async ({ + afterWrite, + nextConfig, + }: { + afterWrite?: unknown; + nextConfig: OpenClawConfig; + }) => { + commitConfig(nextConfig); + return { + nextConfig, + afterWrite, + followUp: { mode: "auto", requiresRestart: false }, + }; }, }, } as NonNullable; diff --git a/extensions/migrate-hermes/apply.ts b/extensions/migrate-hermes/apply.ts index 96b6e50de9e..7421d8b3604 100644 --- a/extensions/migrate-hermes/apply.ts +++ b/extensions/migrate-hermes/apply.ts @@ -24,23 +24,46 @@ function withCachedConfigRuntime( runtime: MigrationProviderContext["runtime"] | undefined, fallbackConfig: MigrationProviderContext["config"], ): MigrationProviderContext["runtime"] | undefined { - if (!runtime?.config.writeConfigFile) { + if (!runtime) { + return undefined; + } + const configApi = runtime.config; + if (!configApi?.current || !configApi.mutateConfigFile) { return runtime; } let cachedConfig: MigrationProviderContext["config"] | undefined; - const loadConfig = () => { - cachedConfig ??= structuredClone(runtime.config.loadConfig?.() ?? fallbackConfig); + const current = (): ReturnType => { + cachedConfig ??= structuredClone( + (configApi.current() ?? fallbackConfig) as MigrationProviderContext["config"], + ); return cachedConfig; }; return { ...runtime, config: { ...runtime.config, - loadConfig, - writeConfigFile: async (next, options) => { - cachedConfig = structuredClone(next); - await runtime.config.writeConfigFile(next, options); + current, + mutateConfigFile: async (params) => { + const result = await configApi.mutateConfigFile({ + ...params, + mutate: async (draft, context) => { + const mutationResult = await params.mutate(draft, context); + cachedConfig = structuredClone(draft); + return mutationResult; + }, + }); + cachedConfig = structuredClone(result.nextConfig); + return result; }, + ...(configApi.replaceConfigFile + ? { + replaceConfigFile: async (params) => { + const result = await configApi.replaceConfigFile(params); + cachedConfig = structuredClone(result.nextConfig); + return result; + }, + } + : {}), }, }; } diff --git a/extensions/migrate-hermes/config.test.ts b/extensions/migrate-hermes/config.test.ts index 4d8de242592..207eb3ca29b 100644 --- a/extensions/migrate-hermes/config.test.ts +++ b/extensions/migrate-hermes/config.test.ts @@ -1,22 +1,14 @@ import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; import { afterEach, describe, expect, it } from "vitest"; import { buildHermesMigrationProvider } from "./provider.js"; -import { cleanupTempRoots, makeContext, makeTempRoot, writeFile } from "./test/provider-helpers.js"; - -function makeConfigRuntime(config: Record) { - return { - config: { - loadConfig: () => config, - writeConfigFile: async (next: Record) => { - Object.keys(config).forEach((key) => { - delete config[key]; - }); - Object.assign(config, next); - return next; - }, - }, - } as never; -} +import { + cleanupTempRoots, + makeConfigRuntime, + makeContext, + makeTempRoot, + writeFile, +} from "./test/provider-helpers.js"; describe("Hermes migration config mapping", () => { afterEach(async () => { @@ -125,9 +117,9 @@ describe("Hermes migration config mapping", () => { const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); - const config: Record = { + const config = { agents: { defaults: { workspace: workspaceDir } }, - }; + } as OpenClawConfig; await writeFile( path.join(source, "config.yaml"), [ diff --git a/extensions/migrate-hermes/config.ts b/extensions/migrate-hermes/config.ts index 82170ee5718..1bca863de43 100644 --- a/extensions/migrate-hermes/config.ts +++ b/extensions/migrate-hermes/config.ts @@ -23,6 +23,13 @@ type ConfigPatchDetails = { const CONFIG_RUNTIME_UNAVAILABLE = "config runtime unavailable"; const MISSING_CONFIG_PATCH = "missing config patch"; +class ConfigPatchConflictError extends Error { + constructor(readonly reason: string) { + super(reason); + this.name = "ConfigPatchConflictError"; + } +} + function envKeyForProvider(providerId: string): string { return `${providerId.toUpperCase().replaceAll(/[^A-Z0-9]/gu, "_")}_API_KEY`; } @@ -413,18 +420,30 @@ export async function applyConfigItem( if (!details) { return markMigrationItemError(item, MISSING_CONFIG_PATCH); } - if (!ctx.runtime?.config.writeConfigFile) { + const configApi = ctx.runtime?.config; + if (!configApi?.current || !configApi.mutateConfigFile) { return markMigrationItemError(item, CONFIG_RUNTIME_UNAVAILABLE); } try { - const nextConfig = structuredClone(ctx.runtime.config.loadConfig?.() ?? ctx.config); - if (!ctx.overwrite && hasPatchConflict(nextConfig, details.path, details.value)) { + const currentConfig = configApi.current() as MigrationProviderContext["config"]; + if (!ctx.overwrite && hasPatchConflict(currentConfig, details.path, details.value)) { return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS); } - writePath(nextConfig as Record, details.path, details.value); - await ctx.runtime.config.writeConfigFile(nextConfig); + await configApi.mutateConfigFile({ + base: "runtime", + afterWrite: { mode: "auto" }, + mutate(draft) { + if (!ctx.overwrite && hasPatchConflict(draft, details.path, details.value)) { + throw new ConfigPatchConflictError(MIGRATION_REASON_TARGET_EXISTS); + } + writePath(draft as Record, details.path, details.value); + }, + }); return { ...item, status: "migrated" }; } catch (err) { + if (err instanceof ConfigPatchConflictError) { + return markMigrationItemConflict(item, err.reason); + } return markMigrationItemError(item, err instanceof Error ? err.message : String(err)); } } diff --git a/extensions/migrate-hermes/files-and-skills.test.ts b/extensions/migrate-hermes/files-and-skills.test.ts index 8f8f0d4bb43..a1b0030609a 100644 --- a/extensions/migrate-hermes/files-and-skills.test.ts +++ b/extensions/migrate-hermes/files-and-skills.test.ts @@ -13,13 +13,19 @@ describe("Hermes migration file and skill items", () => { function configRuntime(config: Record) { return { config: { - loadConfig: () => config, - writeConfigFile: async (next: Record) => { + current: () => config, + mutateConfigFile: async ({ + mutate, + }: { + mutate: (draft: Record) => void | Promise; + }) => { + const next = structuredClone(config); + await mutate(next); Object.keys(config).forEach((key) => { delete config[key]; }); Object.assign(config, next); - return next; + return { nextConfig: next }; }, }, } as never; diff --git a/extensions/migrate-hermes/model.ts b/extensions/migrate-hermes/model.ts index 7dc8356a9dd..77857ec0fd1 100644 --- a/extensions/migrate-hermes/model.ts +++ b/extensions/migrate-hermes/model.ts @@ -58,6 +58,16 @@ export function resolveCurrentModelRef(ctx: MigrationProviderContext): string | return resolveDefaultAgentModelState(ctx.config).effectivePrimary; } +class ModelApplyAbortError extends Error { + constructor( + readonly status: "conflict" | "skipped", + readonly reason: string, + ) { + super(reason); + this.name = "ModelApplyAbortError"; + } +} + export async function applyModelItem( ctx: MigrationProviderContext, item: MigrationItem, @@ -67,21 +77,40 @@ export async function applyModelItem( return item; } try { - if (!ctx.runtime?.config.writeConfigFile) { + const configApi = ctx.runtime?.config; + if (!configApi?.current || !configApi.mutateConfigFile) { return hermesItemError(item, HERMES_REASON_CONFIG_RUNTIME_UNAVAILABLE); } - const nextConfig = structuredClone(ctx.runtime?.config.loadConfig?.() ?? ctx.config); - const currentState = resolveDefaultAgentModelState(nextConfig); + const currentState = resolveDefaultAgentModelState( + configApi.current() as MigrationProviderContext["config"], + ); if (currentState.effectivePrimary === details.model) { return hermesItemSkipped(item, HERMES_REASON_ALREADY_CONFIGURED); } if (currentState.effectivePrimary && !ctx.overwrite) { return hermesItemConflict(item, HERMES_REASON_DEFAULT_MODEL_CONFIGURED); } - setAgentEffectiveModelPrimary(nextConfig, currentState.agentId, details.model); - await ctx.runtime.config.writeConfigFile(nextConfig); + await configApi.mutateConfigFile({ + base: "runtime", + afterWrite: { mode: "auto" }, + mutate(draft) { + const mutationState = resolveDefaultAgentModelState(draft); + if (mutationState.effectivePrimary === details.model) { + throw new ModelApplyAbortError("skipped", HERMES_REASON_ALREADY_CONFIGURED); + } + if (mutationState.effectivePrimary && !ctx.overwrite) { + throw new ModelApplyAbortError("conflict", HERMES_REASON_DEFAULT_MODEL_CONFIGURED); + } + setAgentEffectiveModelPrimary(draft, mutationState.agentId, details.model); + }, + }); return { ...item, status: "migrated" }; } catch (err) { + if (err instanceof ModelApplyAbortError) { + return err.status === "conflict" + ? hermesItemConflict(item, err.reason) + : hermesItemSkipped(item, err.reason); + } return hermesItemError(item, err instanceof Error ? err.message : String(err)); } } diff --git a/extensions/migrate-hermes/test/provider-helpers.ts b/extensions/migrate-hermes/test/provider-helpers.ts index 84a17a39c62..615bfebd391 100644 --- a/extensions/migrate-hermes/test/provider-helpers.ts +++ b/extensions/migrate-hermes/test/provider-helpers.ts @@ -37,15 +37,46 @@ export function makeConfigRuntime( config: OpenClawConfig, onWrite?: (next: OpenClawConfig) => void, ): NonNullable { + const commitConfig = (next: OpenClawConfig) => { + for (const key of Object.keys(config) as Array) { + delete config[key]; + } + Object.assign(config, next); + onWrite?.(next); + }; + return { config: { - loadConfig: () => config, - writeConfigFile: async (next: OpenClawConfig) => { - for (const key of Object.keys(config) as Array) { - delete config[key]; - } - Object.assign(config, next); - onWrite?.(next); + current: () => config, + mutateConfigFile: async ({ + afterWrite, + mutate, + }: { + afterWrite?: unknown; + mutate: (draft: OpenClawConfig, context: unknown) => Promise | void; + }) => { + const next = structuredClone(config); + const result = await mutate(next, { + previousHash: null, + snapshot: { config, raw: "", hash: null }, + }); + commitConfig(next); + return { + afterWrite, + followUp: { mode: "auto", requiresRestart: false }, + nextConfig: next, + result, + }; + }, + replaceConfigFile: async ({ + afterWrite, + nextConfig, + }: { + afterWrite?: unknown; + nextConfig: OpenClawConfig; + }) => { + commitConfig(nextConfig); + return { afterWrite, followUp: { mode: "auto", requiresRestart: false }, nextConfig }; }, }, } as NonNullable; diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index 5b151c27794..ba0a3e8d666 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -9,7 +9,7 @@ import { } from "openclaw/plugin-sdk/reply-payload"; import { normalizeOptionalLowercaseString, sleep } from "openclaw/plugin-sdk/text-runtime"; import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; -import type { MarkdownTableMode, MSTeamsReplyStyle } from "../runtime-api.js"; +import type { MarkdownTableMode, MSTeamsReplyStyle, OpenClawConfig } from "../runtime-api.js"; import type { MSTeamsAccessTokenProvider } from "./attachments/types.js"; import type { StoredConversationReference } from "./conversation-store.js"; import { classifyMSTeamsSendError } from "./errors.js"; @@ -238,7 +238,7 @@ export function renderReplyPayloadsToMessages( const tableMode = options.tableMode ?? getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({ - cfg: getMSTeamsRuntime().config.loadConfig(), + cfg: getMSTeamsRuntime().config.current() as OpenClawConfig, channel: "msteams", }); diff --git a/extensions/nextcloud-talk/src/gateway.ts b/extensions/nextcloud-talk/src/gateway.ts index 39a4b98cd09..2458a93ecd4 100644 --- a/extensions/nextcloud-talk/src/gateway.ts +++ b/extensions/nextcloud-talk/src/gateway.ts @@ -94,7 +94,10 @@ export const nextcloudTalkGatewayAdapter: NonNullable< const loggedOut = resolved.secretSource === "none"; if (changed) { - await getNextcloudTalkRuntime().config.writeConfigFile(nextCfg); + await getNextcloudTalkRuntime().config.replaceConfigFile({ + nextConfig: nextCfg, + afterWrite: { mode: "auto" }, + }); } return { diff --git a/extensions/nextcloud-talk/src/monitor-runtime.ts b/extensions/nextcloud-talk/src/monitor-runtime.ts index 5c962bb3dcc..fa15ae28040 100644 --- a/extensions/nextcloud-talk/src/monitor-runtime.ts +++ b/extensions/nextcloud-talk/src/monitor-runtime.ts @@ -37,7 +37,7 @@ export async function monitorNextcloudTalkProvider( opts: NextcloudTalkMonitorOptions, ): Promise<{ stop: () => void }> { const core = getNextcloudTalkRuntime(); - const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig); + const cfg = opts.config ?? (core.config.current() as CoreConfig); const account = resolveNextcloudTalkAccount({ cfg, accountId: opts.accountId, diff --git a/extensions/nostr/index.ts b/extensions/nostr/index.ts index f451e86db0d..32447f55f1c 100644 --- a/extensions/nostr/index.ts +++ b/extensions/nostr/index.ts @@ -2,7 +2,7 @@ import { defineBundledChannelEntry, loadBundledEntryExportSync, } from "openclaw/plugin-sdk/channel-entry-contract"; -import type { PluginRuntime, ResolvedNostrAccount } from "./api.js"; +import type { OpenClawConfig, PluginRuntime, ResolvedNostrAccount } from "./api.js"; function createNostrProfileHttpHandler() { return loadBundledEntryExportSync< @@ -46,31 +46,34 @@ export default defineBundledChannelEntry({ const httpHandler = createNostrProfileHttpHandler()({ getConfigProfile: (accountId: string) => { const runtime = getNostrRuntime(); - const cfg = runtime.config.loadConfig(); + const cfg = runtime.config.current() as OpenClawConfig; const account = resolveNostrAccount({ cfg, accountId }); return account.profile; }, updateConfigProfile: async (accountId: string, profile: unknown) => { const runtime = getNostrRuntime(); - const cfg = runtime.config.loadConfig(); + const cfg = runtime.config.current() as OpenClawConfig; const channels = (cfg.channels ?? {}) as Record; const nostrConfig = (channels.nostr ?? {}) as Record; - await runtime.config.writeConfigFile({ - ...cfg, - channels: { - ...channels, - nostr: { - ...nostrConfig, - profile, + await runtime.config.replaceConfigFile({ + nextConfig: { + ...cfg, + channels: { + ...channels, + nostr: { + ...nostrConfig, + profile, + }, }, }, + afterWrite: { mode: "auto" }, }); }, getAccountInfo: (accountId: string) => { const runtime = getNostrRuntime(); - const cfg = runtime.config.loadConfig(); + const cfg = runtime.config.current() as OpenClawConfig; const account = resolveNostrAccount({ cfg, accountId }); if (!account.configured || !account.publicKey) { return null; diff --git a/extensions/openai/openai.live.test.ts b/extensions/openai/openai.live.test.ts index c292a190f8a..1649ecd8dae 100644 --- a/extensions/openai/openai.live.test.ts +++ b/extensions/openai/openai.live.test.ts @@ -5,8 +5,7 @@ import { getModel, type Api, type Model } from "@mariozechner/pi-ai"; import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent"; import OpenAI from "openai"; import type { ResolvedTtsConfig } from "openclaw/plugin-sdk/agent-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime"; import { describe, expect, it } from "vitest"; import { @@ -100,7 +99,7 @@ function createReferencePng(): Buffer { } function createLiveConfig(): OpenClawConfig { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); return { ...cfg, models: { diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index 99c1000d6fb..8f46929d72c 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -30,10 +30,11 @@ function createApi(params: { resolveStateDir: () => params.stateDir, }, config: { - loadConfig: () => params.getConfig(), - writeConfigFile: (next: Record) => params.writeConfig(next), + current: () => params.getConfig(), + replaceConfigFile: ({ nextConfig }: { nextConfig: unknown }) => + params.writeConfig(nextConfig as Record), }, - } as OpenClawPluginApi["runtime"], + } as unknown as OpenClawPluginApi["runtime"], registerCommand: params.registerCommand, }); } diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts index d55b618a34c..2343922d85a 100644 --- a/extensions/phone-control/index.ts +++ b/extensions/phone-control/index.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -197,7 +198,7 @@ async function disarmNow(params: { if (!state) { return { changed: false, restored: [], removed: [] }; } - const cfg = api.runtime.config.loadConfig(); + const cfg = api.runtime.config.current() as OpenClawConfig; const allow = new Set(normalizeAllowList(cfg)); const deny = new Set(normalizeDenyList(cfg)); const removed: string[] = []; @@ -229,7 +230,10 @@ async function disarmNow(params: { allowCommands: uniqSorted([...allow]), denyCommands: uniqSorted([...deny]), }); - await api.runtime.config.writeConfigFile(next); + await api.runtime.config.replaceConfigFile({ + nextConfig: next, + afterWrite: { mode: "auto" }, + }); } await writeArmState(statePath, null); api.logger.info(`phone-control: disarmed (${reason}) stateDir=${stateDir}`); @@ -405,7 +409,7 @@ export default definePluginEntry({ const expiresAtMs = Date.now() + durationMs; const commands = resolveCommandsForGroup(group); - const cfg = api.runtime.config.loadConfig(); + const cfg = api.runtime.config.current() as OpenClawConfig; const allowSet = new Set(normalizeAllowList(cfg)); const denySet = new Set(normalizeDenyList(cfg)); @@ -424,7 +428,10 @@ export default definePluginEntry({ allowCommands: uniqSorted([...allowSet]), denyCommands: uniqSorted([...denySet]), }); - await api.runtime.config.writeConfigFile(next); + await api.runtime.config.replaceConfigFile({ + nextConfig: next, + afterWrite: { mode: "auto" }, + }); await writeArmState(statePath, { version: STATE_VERSION, diff --git a/extensions/qqbot/src/bridge/bootstrap.ts b/extensions/qqbot/src/bridge/bootstrap.ts index 1e9e225d2a7..d1b9b3e57c7 100644 --- a/extensions/qqbot/src/bridge/bootstrap.ts +++ b/extensions/qqbot/src/bridge/bootstrap.ts @@ -95,10 +95,10 @@ function createBuiltinAdapter(): PlatformAdapter { async resolveApproval(approvalId: string, decision: string): Promise { try { - const { loadConfig } = await import("openclaw/plugin-sdk/config-runtime"); + const { getRuntimeConfig } = await import("openclaw/plugin-sdk/config-runtime"); const { resolveApprovalOverGateway } = await import("openclaw/plugin-sdk/approval-gateway-runtime"); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); await resolveApprovalOverGateway({ cfg, approvalId, diff --git a/extensions/qqbot/src/bridge/gateway.ts b/extensions/qqbot/src/bridge/gateway.ts index 47302b9e361..58f4b9e5102 100644 --- a/extensions/qqbot/src/bridge/gateway.ts +++ b/extensions/qqbot/src/bridge/gateway.ts @@ -50,8 +50,11 @@ registerApproveRuntimeGetter(() => { const rt = getQQBotRuntime(); return { config: rt.config as { - loadConfig: () => Record; - writeConfigFile: (cfg: unknown) => Promise; + current: () => Record; + replaceConfigFile: (params: { + nextConfig: Record; + afterWrite: { mode: "auto" }; + }) => Promise; }, }; }); diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts index 7bd2fe5e0b2..5ebe03f3a12 100644 --- a/extensions/qqbot/src/channel.ts +++ b/extensions/qqbot/src/channel.ts @@ -164,10 +164,10 @@ export const qqbotPlugin: ChannelPlugin = { clientSecret: backup.clientSecret, }); const runtime = getQQBotRuntime(); - const configApi = runtime.config as { - writeConfigFile: (cfg: OpenClawConfig) => Promise; - }; - await configApi.writeConfigFile(nextCfg); + await runtime.config.replaceConfigFile({ + nextConfig: nextCfg, + afterWrite: { mode: "auto" }, + }); cfg = nextCfg; account = resolveQQBotAccount(nextCfg, account.accountId); log?.info( @@ -239,10 +239,10 @@ export const qqbotPlugin: ChannelPlugin = { if (changed) { const runtime = getQQBotRuntime(); - const configApi = runtime.config as { - writeConfigFile: (cfg: OpenClawConfig) => Promise; - }; - await configApi.writeConfigFile(nextCfg as OpenClawConfig); + await runtime.config.replaceConfigFile({ + nextConfig: nextCfg as OpenClawConfig, + afterWrite: { mode: "auto" }, + }); } const resolved = resolveQQBotAccount((changed ? nextCfg : cfg) as OpenClawConfig, accountId); diff --git a/extensions/qqbot/src/engine/commands/slash-commands-impl.ts b/extensions/qqbot/src/engine/commands/slash-commands-impl.ts index a404f6003bb..2ea16d259fc 100644 --- a/extensions/qqbot/src/engine/commands/slash-commands-impl.ts +++ b/extensions/qqbot/src/engine/commands/slash-commands-impl.ts @@ -717,8 +717,11 @@ registerCommand({ let _runtimeGetter: | (() => { config: { - loadConfig: () => Record; - writeConfigFile: (cfg: unknown) => Promise; + current: () => Record; + replaceConfigFile: (params: { + nextConfig: Record; + afterWrite: { mode: "auto" }; + }) => Promise; }; }) | null = null; @@ -727,8 +730,11 @@ let _runtimeGetter: export function registerApproveRuntimeGetter( getter: () => { config: { - loadConfig: () => Record; - writeConfigFile: (cfg: unknown) => Promise; + current: () => Record; + replaceConfigFile: (params: { + nextConfig: Record; + afterWrite: { mode: "auto" }; + }) => Promise; }; }, ): void { @@ -786,7 +792,7 @@ registerCommand({ const configApi = runtime.config; const loadExecConfig = () => { - const cfg = configApi.loadConfig(); + const cfg = configApi.current(); const tools = (cfg.tools ?? {}) as Record; const exec = (tools.exec ?? {}) as Record; const security = typeof exec.security === "string" ? exec.security : "deny"; @@ -795,14 +801,17 @@ registerCommand({ }; const writeExecConfig = async (security: string, ask: string) => { - const cfg = structuredClone(configApi.loadConfig()); + const cfg = structuredClone(configApi.current()); const tools = (cfg.tools ?? {}) as Record; const exec = (tools.exec ?? {}) as Record; exec.security = security; exec.ask = ask; tools.exec = exec; cfg.tools = tools; - await configApi.writeConfigFile(cfg); + await configApi.replaceConfigFile({ + nextConfig: cfg, + afterWrite: { mode: "auto" }, + }); }; const formatStatus = (security: string, ask: string) => { @@ -906,7 +915,7 @@ registerCommand({ // reset: 删除配置,恢复框架默认值 if (arg === "reset") { try { - const cfg = structuredClone(configApi.loadConfig()); + const cfg = structuredClone(configApi.current()); const tools = (cfg.tools ?? {}) as Record; const exec = (tools.exec ?? {}) as Record; delete exec.security; @@ -921,7 +930,10 @@ registerCommand({ } else { cfg.tools = tools; } - await configApi.writeConfigFile(cfg); + await configApi.replaceConfigFile({ + nextConfig: cfg, + afterWrite: { mode: "auto" }, + }); return [ `✅ 审批配置已重置`, ``, diff --git a/extensions/qqbot/src/engine/config/credential-backup.ts b/extensions/qqbot/src/engine/config/credential-backup.ts index d99d5a24498..b8bab5e723b 100644 --- a/extensions/qqbot/src/engine/config/credential-backup.ts +++ b/extensions/qqbot/src/engine/config/credential-backup.ts @@ -10,7 +10,7 @@ * resolved `appId` / `clientSecret` to a per-account backup file. * - During plugin startup, if the live config has an empty appId or * secret, the gateway consults the backup and restores the values - * via `writeConfigFile`. + * via the config mutation API. * - Backups live under `~/.openclaw/qqbot/data/` so they survive * plugin directory replacement. * diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 8a3badcedc6..1aa9f178415 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -1,6 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, @@ -360,7 +360,7 @@ async function deliverReplies(params: { export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promise { const runtime = resolveRuntime(opts); - const cfg = opts.config ?? loadConfig(); + const cfg = opts.config ?? getRuntimeConfig(); const accountInfo = resolveSignalAccount({ cfg, accountId: opts.accountId, diff --git a/extensions/skill-workshop/index.test.ts b/extensions/skill-workshop/index.test.ts index 3811c51f69d..c322c6c9f6f 100644 --- a/extensions/skill-workshop/index.test.ts +++ b/extensions/skill-workshop/index.test.ts @@ -234,7 +234,7 @@ describe("skill-workshop", () => { pluginConfig: { approvalPolicy: "auto" }, runtime: { config: { - loadConfig: () => configFile, + current: () => configFile, }, } as never, on, @@ -288,7 +288,7 @@ describe("skill-workshop", () => { resolveStateDir: () => stateDir, }, config: { - loadConfig: () => configFile, + current: () => configFile, }, } as never, registerTool(registered) { @@ -346,7 +346,7 @@ describe("skill-workshop", () => { resolveStateDir: () => stateDir, }, config: { - loadConfig: () => configFile, + current: () => configFile, }, } as never, registerTool(registered) { @@ -405,7 +405,7 @@ describe("skill-workshop", () => { resolveStateDir: () => stateDir, }, config: { - loadConfig: () => configFile, + current: () => configFile, }, } as never, on, @@ -492,7 +492,7 @@ describe("skill-workshop", () => { resolveStateDir: () => stateDir, }, config: { - loadConfig: () => configFile, + current: () => configFile, }, } as never, on, diff --git a/extensions/skill-workshop/index.ts b/extensions/skill-workshop/index.ts index 097c323734c..68403bdd132 100644 --- a/extensions/skill-workshop/index.ts +++ b/extensions/skill-workshop/index.ts @@ -1,4 +1,7 @@ -import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/config-runtime"; +import { + resolveLivePluginConfigObject, + type OpenClawConfig, +} from "openclaw/plugin-sdk/config-runtime"; import { definePluginEntry, resolveDefaultAgentId } from "./api.js"; import { resolveConfig } from "./src/config.js"; import { buildWorkshopGuidance } from "./src/prompt.js"; @@ -15,7 +18,9 @@ export default definePluginEntry({ register(api) { const resolveCurrentConfig = () => { const runtimePluginConfig = resolveLivePluginConfigObject( - api.runtime.config?.loadConfig, + api.runtime.config?.current + ? () => api.runtime.config.current() as OpenClawConfig + : undefined, "skill-workshop", api.pluginConfig as Record, ); diff --git a/extensions/slack/src/monitor/config.runtime.ts b/extensions/slack/src/monitor/config.runtime.ts index 42e44ccfbcf..ed06f209326 100644 --- a/extensions/slack/src/monitor/config.runtime.ts +++ b/extensions/slack/src/monitor/config.runtime.ts @@ -1,6 +1,6 @@ export { + getRuntimeConfig, isDangerousNameMatchingEnabled, - loadConfig, readSessionUpdatedAt, recordSessionMetaFromInbound, resolveChannelContextVisibilityMode, diff --git a/extensions/slack/src/monitor/events/channels.ts b/extensions/slack/src/monitor/events/channels.ts index 159adcfc5e1..25b91f08907 100644 --- a/extensions/slack/src/monitor/events/channels.ts +++ b/extensions/slack/src/monitor/events/channels.ts @@ -1,6 +1,6 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes"; -import { loadConfig, writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig, replaceConfigFile } from "openclaw/plugin-sdk/config-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; import { danger, warn } from "openclaw/plugin-sdk/runtime-env"; @@ -129,7 +129,7 @@ export function registerSlackChannelEvents(params: { return; } - const currentConfig = loadConfig(); + const currentConfig = getRuntimeConfig(); const migration = migrateSlackChannelConfig({ cfg: currentConfig, accountId: ctx.accountId, @@ -144,7 +144,10 @@ export function registerSlackChannelEvents(params: { oldChannelId, newChannelId, }); - await writeConfigFile(currentConfig); + await replaceConfigFile({ + nextConfig: currentConfig, + afterWrite: { mode: "auto" }, + }); ctx.runtime.log?.(warn("[slack] Channel config migrated and saved successfully.")); } else if (migration.skippedExisting) { ctx.runtime.log?.( diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index f34a31d34d4..778de6782e9 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -33,8 +33,8 @@ import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js"; import { normalizeAllowList } from "./allow-list.js"; import { resolveSlackSlashCommandConfig } from "./commands.js"; import { + getRuntimeConfig, isDangerousNameMatchingEnabled, - loadConfig, resolveDefaultGroupPolicy, resolveOpenProviderRuntimeGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, @@ -91,7 +91,7 @@ function parseApiAppIdFromAppToken(raw?: string) { } export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { - const cfg = opts.config ?? loadConfig(); + const cfg = opts.config ?? getRuntimeConfig(); const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); let account = resolveSlackAccount({ diff --git a/extensions/synology-chat/src/channel.integration.test.ts b/extensions/synology-chat/src/channel.integration.test.ts index a1d2e2c2e65..1896b79d97c 100644 --- a/extensions/synology-chat/src/channel.integration.test.ts +++ b/extensions/synology-chat/src/channel.integration.test.ts @@ -5,6 +5,7 @@ import { finalizeInboundContextMock, registerPluginHttpRouteMock, resolveAgentRouteMock, + setSynologyRuntimeConfigForTest, } from "./channel.test-mocks.js"; import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js"; @@ -17,6 +18,7 @@ type _RegisteredRoute = { let createSynologyChatPlugin: typeof import("./channel.js").createSynologyChatPlugin; function makeStartContext(cfg: T, accountId: string, abortSignal: AbortSignal) { + setSynologyRuntimeConfigForTest(cfg); return { cfg, accountId, @@ -35,6 +37,7 @@ describe("Synology channel wiring integration", () => { dispatchReplyWithBufferedBlockDispatcher.mockClear(); finalizeInboundContextMock.mockClear(); resolveAgentRouteMock.mockClear(); + setSynologyRuntimeConfigForTest({}); }); it("registers real webhook handler with resolved account config and enforces allowlist", async () => { diff --git a/extensions/synology-chat/src/channel.test-mocks.ts b/extensions/synology-chat/src/channel.test-mocks.ts index 3fe52e1a699..815951227d6 100644 --- a/extensions/synology-chat/src/channel.test-mocks.ts +++ b/extensions/synology-chat/src/channel.test-mocks.ts @@ -29,6 +29,11 @@ export const resolveAgentRouteMock: Mock< accountId, }; }); +let mockRuntimeConfig: unknown = {}; + +export function setSynologyRuntimeConfigForTest(cfg: unknown): void { + mockRuntimeConfig = cfg; +} async function readRequestBodyWithLimitForTest(req: IncomingMessage): Promise { return await new Promise((resolve, reject) => { @@ -81,7 +86,7 @@ vi.mock("./client.js", () => ({ vi.mock("./runtime.js", () => ({ getSynologyRuntime: vi.fn(() => ({ - config: { loadConfig: vi.fn().mockResolvedValue({}) }, + config: { current: vi.fn(() => mockRuntimeConfig) }, channel: { routing: { resolveAgentRoute: resolveAgentRouteMock, diff --git a/extensions/synology-chat/src/inbound-turn.ts b/extensions/synology-chat/src/inbound-turn.ts index e18e1077878..269272b048d 100644 --- a/extensions/synology-chat/src/inbound-turn.ts +++ b/extensions/synology-chat/src/inbound-turn.ts @@ -61,7 +61,7 @@ export async function dispatchSynologyChatInboundTurn(params: { log?: SynologyChannelLog; }): Promise { const rt = getSynologyRuntime(); - const currentCfg = rt.config.loadConfig(); + const currentCfg = rt.config.current() as OpenClawConfig; // The Chat API user_id (for sending) may differ from the webhook // user_id (used for sessions/pairing). Use chatUserId for API calls. diff --git a/extensions/talk-voice/index.test.ts b/extensions/talk-voice/index.test.ts index fccea5bac0e..5b9032e0ebe 100644 --- a/extensions/talk-voice/index.test.ts +++ b/extensions/talk-voice/index.test.ts @@ -7,7 +7,11 @@ function createHarness(config: Record) { let command: OpenClawPluginCommandDefinition | undefined; const runtime = { config: { + current: vi.fn(() => config), loadConfig: vi.fn(() => config), + replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: Record }) => { + config = nextConfig; + }), writeConfigFile: vi.fn().mockResolvedValue(undefined), }, tts: { @@ -183,16 +187,19 @@ describe("talk-voice plugin", () => { createCommandContext("set Claudia", "webchat", ["operator.admin"]), ); - expect(runtime.config.writeConfigFile).toHaveBeenCalledWith({ - talk: { - provider: "elevenlabs", - providers: { - elevenlabs: { - apiKey: "sk-eleven", - voiceId: "voice-a", + expect(runtime.config.replaceConfigFile).toHaveBeenCalledWith({ + afterWrite: { mode: "auto" }, + nextConfig: { + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + voiceId: "voice-a", + }, }, + voiceId: "voice-a", }, - voiceId: "voice-a", }, }); expect(result).toEqual({ @@ -213,12 +220,15 @@ describe("talk-voice plugin", () => { await command.handler(createCommandContext("set Ava", "webchat", ["operator.admin"])); - expect(runtime.config.writeConfigFile).toHaveBeenCalledWith({ - talk: { - provider: "microsoft", - providers: { - microsoft: { - voiceId: "en-US-AvaNeural", + expect(runtime.config.replaceConfigFile).toHaveBeenCalledWith({ + afterWrite: { mode: "auto" }, + nextConfig: { + talk: { + provider: "microsoft", + providers: { + microsoft: { + voiceId: "en-US-AvaNeural", + }, }, }, }, @@ -230,7 +240,7 @@ describe("talk-voice plugin", () => { const result = await run(); expect(result.text).toContain("requires operator.admin"); - expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); + expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled(); }); it("rejects /voice set from non-webchat gateway callers missing operator.admin", async () => { @@ -238,14 +248,14 @@ describe("talk-voice plugin", () => { const result = await run(); expect(result.text).toContain("requires operator.admin"); - expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); + expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled(); }); it("allows /voice set from gateway client with operator.admin scope", async () => { const { runtime, run } = createElevenlabsVoiceSetHarness("webchat", ["operator.admin"]); const result = await run(); - expect(runtime.config.writeConfigFile).toHaveBeenCalled(); + expect(runtime.config.replaceConfigFile).toHaveBeenCalled(); expect(result.text).toContain("voice-a"); }); @@ -254,14 +264,14 @@ describe("talk-voice plugin", () => { const result = await run(); expect(result.text).toContain("requires operator.admin"); - expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); + expect(runtime.config.replaceConfigFile).not.toHaveBeenCalled(); }); it("allows /voice set from non-gateway channels without operator.admin", async () => { const { runtime, run } = createElevenlabsVoiceSetHarness("telegram"); const result = await run(); - expect(runtime.config.writeConfigFile).toHaveBeenCalled(); + expect(runtime.config.replaceConfigFile).toHaveBeenCalled(); expect(result.text).toContain("voice-a"); }); @@ -269,7 +279,7 @@ describe("talk-voice plugin", () => { const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.admin"]); const result = await run(); - expect(runtime.config.writeConfigFile).toHaveBeenCalled(); + expect(runtime.config.replaceConfigFile).toHaveBeenCalled(); expect(result.text).toContain("voice-a"); }); diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index c3d45a1801d..7034db903e4 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -1,4 +1,7 @@ -import { resolveActiveTalkProviderConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + resolveActiveTalkProviderConfig, + type OpenClawConfig, +} from "openclaw/plugin-sdk/config-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; import { @@ -134,7 +137,7 @@ export default definePluginEntry({ const tokens = args.split(/\s+/).filter(Boolean); const action = normalizeLowercaseStringOrEmpty(tokens[0] ?? "status"); - const cfg = api.runtime.config.loadConfig(); + const cfg = api.runtime.config.current() as OpenClawConfig; const active = resolveActiveTalkProviderConfig(cfg.talk); if (!active) { return { @@ -223,7 +226,10 @@ export default definePluginEntry({ ...(providerId === "elevenlabs" ? { voiceId: chosen.id } : {}), }, }; - await api.runtime.config.writeConfigFile(nextConfig); + await api.runtime.config.replaceConfigFile({ + nextConfig, + afterWrite: { mode: "auto" }, + }); const name = (chosen.name ?? "").trim() || "(unnamed)"; return { text: `✅ ${providerLabel} Talk voice set to ${name}\n${chosen.id}` }; diff --git a/extensions/telegram/src/bot-core.ts b/extensions/telegram/src/bot-core.ts index 3b649720e00..433886ce032 100644 --- a/extensions/telegram/src/bot-core.ts +++ b/extensions/telegram/src/bot-core.ts @@ -137,7 +137,7 @@ export function createTelegramBotCore( const botRuntime = telegramBotRuntimeForTest ?? DEFAULT_TELEGRAM_BOT_RUNTIME; const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); const telegramDeps = opts.telegramDeps; - const cfg = opts.config ?? telegramDeps.loadConfig(); + const cfg = opts.config ?? telegramDeps.getRuntimeConfig(); const account = resolveTelegramAccount({ cfg, accountId: opts.accountId, @@ -505,7 +505,7 @@ export function createTelegramBotCore( const loadFreshTelegramAccountConfig = () => { try { return resolveTelegramAccount({ - cfg: telegramDeps.loadConfig(), + cfg: telegramDeps.getRuntimeConfig(), accountId: account.accountId, }).config; } catch (error) { @@ -567,7 +567,7 @@ export function createTelegramBotCore( resolveGroupActivation, resolveGroupRequireMention, resolveTelegramGroupConfig, - loadFreshConfig: () => telegramDeps.loadConfig(), + loadFreshConfig: () => telegramDeps.getRuntimeConfig(), sendChatActionHandler, runtime, replyToMode, diff --git a/extensions/telegram/src/bot-deps.ts b/extensions/telegram/src/bot-deps.ts index 111522c4e93..7ba2d39a269 100644 --- a/extensions/telegram/src/bot-deps.ts +++ b/extensions/telegram/src/bot-deps.ts @@ -1,5 +1,5 @@ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline"; -import { loadConfig, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore } from "openclaw/plugin-sdk/config-runtime"; import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; @@ -16,7 +16,7 @@ import { editMessageTelegram } from "./send.js"; import { wasSentByBot } from "./sent-message-cache.js"; export type TelegramBotDeps = { - loadConfig: typeof loadConfig; + getRuntimeConfig: typeof getRuntimeConfig; resolveStorePath: typeof resolveStorePath; loadSessionStore?: typeof loadSessionStore; readChannelAllowFromStore: typeof readChannelAllowFromStore; @@ -37,8 +37,8 @@ export type TelegramBotDeps = { }; export const defaultTelegramBotDeps: TelegramBotDeps = { - get loadConfig() { - return loadConfig; + get getRuntimeConfig() { + return getRuntimeConfig; }, get resolveStorePath() { return resolveStorePath; diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index ffec33603e3..04b39b7e745 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -11,7 +11,7 @@ import { resolveCommandAuthorizedFromAuthorizers, } from "openclaw/plugin-sdk/command-auth-native"; import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/command-status"; -import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; +import { replaceConfigFile } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveSessionStoreEntry, @@ -311,7 +311,7 @@ export const registerTelegramHandlers = ({ sessionKey: string; model?: string; } => { - const runtimeCfg = telegramDeps.loadConfig(); + const runtimeCfg = telegramDeps.getRuntimeConfig(); const resolvedThreadId = params.resolvedThreadId ?? resolveTelegramForumThreadId({ @@ -966,7 +966,7 @@ export const registerTelegramHandlers = ({ const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); // Fresh config for bindings lookup; other routing inputs are payload-derived. const route = resolveAgentRoute({ - cfg: telegramDeps.loadConfig(), + cfg: telegramDeps.getRuntimeConfig(), channel: "telegram", accountId, peer: { kind: isGroup ? "group" : "direct", id: peerId }, @@ -1428,7 +1428,7 @@ export const registerTelegramHandlers = ({ return; } - const runtimeCfg = telegramDeps.loadConfig(); + const runtimeCfg = telegramDeps.getRuntimeConfig(); if (approvalCallback) { const isPluginApproval = approvalCallback.approvalId.startsWith("plugin:"); const pluginApprovalAuthorizedSender = isTelegramExecApprovalApprover({ @@ -1807,7 +1807,7 @@ export const registerTelegramHandlers = ({ } // Check if old chat ID has config and migrate it - const currentConfig = telegramDeps.loadConfig(); + const currentConfig = telegramDeps.getRuntimeConfig(); const migration = migrateTelegramGroupConfig({ cfg: currentConfig, accountId, @@ -1818,7 +1818,10 @@ export const registerTelegramHandlers = ({ if (migration.migrated) { runtime.log?.(warn(`[telegram] Migrating group config from ${oldChatId} to ${newChatId}`)); migrateTelegramGroupConfig({ cfg, accountId, oldChatId, newChatId }); - await writeConfigFile(currentConfig); + await replaceConfigFile({ + nextConfig: currentConfig, + afterWrite: { mode: "auto" }, + }); runtime.log?.(warn(`[telegram] Group config migrated and saved successfully`)); } else if (migration.skippedExisting) { runtime.log?.( diff --git a/extensions/telegram/src/bot-message-context.runtime.ts b/extensions/telegram/src/bot-message-context.runtime.ts index 264b37b308f..928269119e9 100644 --- a/extensions/telegram/src/bot-message-context.runtime.ts +++ b/extensions/telegram/src/bot-message-context.runtime.ts @@ -1,4 +1,4 @@ export { createStatusReactionController } from "openclaw/plugin-sdk/channel-feedback"; export { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; -export { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +export { getRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; export { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime"; diff --git a/extensions/telegram/src/bot-message-context.topic-agentid.test.ts b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts index 023552b2972..0e3a98e6a1f 100644 --- a/extensions/telegram/src/bot-message-context.topic-agentid.test.ts +++ b/extensions/telegram/src/bot-message-context.topic-agentid.test.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; const { defaultRouteConfig } = vi.hoisted(() => ({ @@ -17,7 +17,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async () => { ); return { ...actual, - loadConfig: vi.fn(() => defaultRouteConfig), + getRuntimeConfig: vi.fn(() => defaultRouteConfig), }; }); @@ -57,7 +57,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { } beforeEach(() => { - vi.mocked(loadConfig).mockReturnValue(defaultRouteConfig as never); + vi.mocked(getRuntimeConfig).mockReturnValue(defaultRouteConfig as never); }); it("uses group-level agent when no topic agentId is set", async () => { @@ -103,7 +103,7 @@ describe("buildTelegramMessageContext per-topic agentId routing", () => { }); it("preserves an unknown topic agentId in the session key", async () => { - vi.mocked(loadConfig).mockReturnValue({ + vi.mocked(getRuntimeConfig).mockReturnValue({ agents: { list: [{ id: "main", default: true }, { id: "zu" }], }, diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts index 99dc9b860d4..de6f6d770c5 100644 --- a/extensions/telegram/src/bot-message-context.ts +++ b/extensions/telegram/src/bot-message-context.ts @@ -222,7 +222,7 @@ export const buildTelegramMessageContext = async ({ // Fresh config for bindings lookup; other routing inputs are payload-derived. const freshCfg = loadFreshConfig?.() ?? - (runtime?.loadConfig ?? (await loadTelegramMessageContextRuntime()).loadConfig)(); + (runtime?.getRuntimeConfig ?? (await loadTelegramMessageContextRuntime()).getRuntimeConfig)(); let { route, configuredBinding, configuredBindingSessionKey } = resolveTelegramConversationRoute({ cfg: freshCfg, accountId: account.accountId, diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts index 3cfc4b769be..30da153b84c 100644 --- a/extensions/telegram/src/bot-message-context.types.ts +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -49,7 +49,7 @@ export type TelegramMessageContextRuntimeOverrides = Partial< typeof import("./bot-message-context.runtime.js"), | "createStatusReactionController" | "ensureConfiguredBindingRouteReady" - | "loadConfig" + | "getRuntimeConfig" | "recordChannelActivity" > >; diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index e0098bb55ca..6eddb5f7207 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -133,7 +133,7 @@ let getTelegramAbortFenceSizeForTests: typeof import("./bot-message-dispatch.js" let resetTelegramAbortFenceForTests: typeof import("./bot-message-dispatch.js").resetTelegramAbortFenceForTests; const telegramDepsForTest: TelegramBotDeps = { - loadConfig: loadConfig as TelegramBotDeps["loadConfig"], + getRuntimeConfig: loadConfig as TelegramBotDeps["getRuntimeConfig"], resolveStorePath: resolveStorePath as TelegramBotDeps["resolveStorePath"], loadSessionStore: loadSessionStore as TelegramBotDeps["loadSessionStore"], readChannelAllowFromStore: diff --git a/extensions/telegram/src/bot-native-command-deps.runtime.ts b/extensions/telegram/src/bot-native-command-deps.runtime.ts index a284d605e11..02a2c2881bb 100644 --- a/extensions/telegram/src/bot-native-command-deps.runtime.ts +++ b/extensions/telegram/src/bot-native-command-deps.runtime.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; import { getPluginCommandSpecs } from "openclaw/plugin-sdk/plugin-runtime"; import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime"; @@ -10,8 +10,8 @@ export type TelegramNativeCommandDeps = Pick< TelegramBotDeps, | "dispatchReplyWithBufferedBlockDispatcher" | "editMessageTelegram" + | "getRuntimeConfig" | "listSkillCommandsForAgents" - | "loadConfig" | "readChannelAllowFromStore" | "syncTelegramMenuCommands" > & { @@ -26,8 +26,8 @@ async function loadTelegramSendRuntime() { } export const defaultTelegramNativeCommandDeps: TelegramNativeCommandDeps = { - get loadConfig() { - return loadConfig; + get getRuntimeConfig() { + return getRuntimeConfig; }, get readChannelAllowFromStore() { return readChannelAllowFromStore; diff --git a/extensions/telegram/src/bot-native-commands.menu-test-support.ts b/extensions/telegram/src/bot-native-commands.menu-test-support.ts index dd1d34b83be..5beb5540c04 100644 --- a/extensions/telegram/src/bot-native-commands.menu-test-support.ts +++ b/extensions/telegram/src/bot-native-commands.menu-test-support.ts @@ -100,7 +100,7 @@ export function createNativeCommandTestParams( counts: { block: 0, final: 0, tool: 0 }, }; const telegramDeps: TelegramNativeCommandDeps = { - loadConfig: vi.fn(() => cfg) as TelegramNativeCommandDeps["loadConfig"], + getRuntimeConfig: vi.fn(() => cfg) as TelegramNativeCommandDeps["getRuntimeConfig"], readChannelAllowFromStore: vi.fn( async () => [], ) as TelegramNativeCommandDeps["readChannelAllowFromStore"], 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 c815633cc1e..449c99d7771 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -235,7 +235,7 @@ function registerAndResolveCommandHandlerBase(params: { const commandHandlers = new Map(); const sendMessage = vi.fn().mockResolvedValue(undefined); const telegramDeps: TelegramNativeCommandDeps = { - loadConfig: vi.fn(() => cfg), + getRuntimeConfig: vi.fn(() => cfg), readChannelAllowFromStore: vi.fn(async () => []), dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher as TelegramNativeCommandDeps["dispatchReplyWithBufferedBlockDispatcher"], diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index dec12fd2e2f..6f43b534b62 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -138,7 +138,7 @@ export function createNativeCommandsHarness(params?: { const setMyCommands: AnyAsyncMock = vi.fn(async () => undefined); const log: AnyMock = vi.fn(); const telegramDeps = { - loadConfig: vi.fn(() => params?.cfg ?? ({} as OpenClawConfig)), + getRuntimeConfig: vi.fn(() => params?.cfg ?? ({} as OpenClawConfig)), readChannelAllowFromStore: vi.fn(async () => []), dispatchReplyWithBufferedBlockDispatcher: replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher, diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 9449e0bc381..991f66d8df7 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -595,7 +595,7 @@ export const registerTelegramNativeCommands = ({ for (const issue of pluginCatalog.issues) { runtime.error?.(danger(issue)); } - const loadFreshRuntimeConfig = (): OpenClawConfig => telegramDeps.loadConfig(); + const loadFreshRuntimeConfig = (): OpenClawConfig => telegramDeps.getRuntimeConfig(); const resolveFreshTelegramConfig = (runtimeCfg: OpenClawConfig): TelegramAccountConfig => { try { return resolveTelegramAccount({ 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 6b7d803bc9c..5b979d4eab7 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -6,7 +6,7 @@ import type { TelegramBotDeps } from "./bot-deps.js"; type AnyMock = ReturnType; type AnyAsyncMock = ReturnType; -type LoadConfigFn = typeof import("openclaw/plugin-sdk/config-runtime").loadConfig; +type GetRuntimeConfigFn = typeof import("openclaw/plugin-sdk/config-runtime").getRuntimeConfig; type LoadSessionStoreFn = typeof import("openclaw/plugin-sdk/config-runtime").loadSessionStore; type ResolveStorePathFn = typeof import("openclaw/plugin-sdk/config-runtime").resolveStorePath; type SessionStore = ReturnType; @@ -42,26 +42,27 @@ vi.mock("openclaw/plugin-sdk/web-media", () => ({ loadWebMedia, })); -const { loadConfig, loadSessionStoreMock, resolveStorePathMock, sessionStoreEntries } = vi.hoisted( - (): { - loadConfig: MockFn; - loadSessionStoreMock: MockFn; - resolveStorePathMock: MockFn; - sessionStoreEntries: { value: SessionStore }; - } => ({ - loadConfig: vi.fn(() => ({})), - loadSessionStoreMock: vi.fn( - (_storePath, _opts) => sessionStoreEntries.value, - ), - resolveStorePathMock: vi.fn( - (storePath?: string) => storePath ?? sessionStorePath, - ), - sessionStoreEntries: { value: {} as SessionStore }, - }), -); +const { getRuntimeConfig, loadSessionStoreMock, resolveStorePathMock, sessionStoreEntries } = + vi.hoisted( + (): { + getRuntimeConfig: MockFn; + loadSessionStoreMock: MockFn; + resolveStorePathMock: MockFn; + sessionStoreEntries: { value: SessionStore }; + } => ({ + getRuntimeConfig: vi.fn(() => ({})), + loadSessionStoreMock: vi.fn( + (_storePath, _opts) => sessionStoreEntries.value, + ), + resolveStorePathMock: vi.fn( + (storePath?: string) => storePath ?? sessionStorePath, + ), + sessionStoreEntries: { value: {} as SessionStore }, + }), + ); export function getLoadConfigMock(): AnyMock { - return loadConfig; + return getRuntimeConfig; } export function getLoadSessionStoreMock(): AnyMock { @@ -362,7 +363,7 @@ export const telegramBotRuntimeForTest: TelegramBotRuntimeForTest = { )()) as unknown as TelegramBotRuntimeForTest["apiThrottler"], }; export const telegramBotDepsForTest: TelegramBotDeps = { - loadConfig, + getRuntimeConfig, loadSessionStore: loadSessionStoreMock as TelegramBotDeps["loadSessionStore"], resolveStorePath: resolveStorePathMock, readChannelAllowFromStore: @@ -454,8 +455,8 @@ export function makeForumGroupMessageCtx(params?: { } beforeEach(() => { - loadConfig.mockReset(); - loadConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG); + getRuntimeConfig.mockReset(); + getRuntimeConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG); sessionStoreEntries.value = {}; loadSessionStoreMock.mockReset(); loadSessionStoreMock.mockImplementation(() => sessionStoreEntries.value); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index cd7fe5a8ddf..9ab45027213 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -121,7 +121,7 @@ function installPerKeySequentializer(): void { } function mockTelegramConfigWrites() { - return vi.spyOn(configRuntime, "writeConfigFile").mockResolvedValue(undefined); + return vi.spyOn(configRuntime, "replaceConfigFile").mockResolvedValue({} as never); } async function withEnvAsync(env: Record, fn: () => Promise) { diff --git a/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts index 16838548096..ab85ba548ba 100644 --- a/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts +++ b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts @@ -237,12 +237,13 @@ describe("telegram media groups", () => { const MEDIA_GROUP_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 20_000; const MEDIA_GROUP_FLUSH_MS = TELEGRAM_TEST_TIMINGS.mediaGroupFlushMs + 40; + const MEDIA_GROUP_WAIT_TIMEOUT_MS = Math.max(2_000, MEDIA_GROUP_FLUSH_MS * 10); it( "uses custom apiRoot for buffered media-group downloads", async () => { - const originalLoadConfig = telegramBotDepsForTest.loadConfig; - telegramBotDepsForTest.loadConfig = (() => ({ + const originalLoadConfig = telegramBotDepsForTest.getRuntimeConfig; + telegramBotDepsForTest.getRuntimeConfig = (() => ({ channels: { telegram: { dmPolicy: "open", @@ -250,7 +251,7 @@ describe("telegram media groups", () => { apiRoot: "http://127.0.0.1:8081/custom-bot-api", }, }, - })) as typeof telegramBotDepsForTest.loadConfig; + })) as typeof telegramBotDepsForTest.getRuntimeConfig; const runtimeError = vi.fn(); const { handler, replySpy } = await createBotHandlerWithOptions({ runtimeError }); @@ -289,7 +290,7 @@ describe("telegram media groups", () => { () => { expect(replySpy).toHaveBeenCalledTimes(1); }, - { timeout: MEDIA_GROUP_FLUSH_MS * 4, interval: 2 }, + { timeout: MEDIA_GROUP_WAIT_TIMEOUT_MS, interval: 2 }, ); expect(runtimeError).not.toHaveBeenCalled(); @@ -306,7 +307,7 @@ describe("telegram media groups", () => { }), ); } finally { - telegramBotDepsForTest.loadConfig = originalLoadConfig; + telegramBotDepsForTest.getRuntimeConfig = originalLoadConfig; fetchSpy.mockRestore(); } }, @@ -396,7 +397,7 @@ describe("telegram media groups", () => { () => { expect(replySpy).toHaveBeenCalledTimes(scenario.expectedReplyCount); }, - { timeout: MEDIA_GROUP_FLUSH_MS * 4, interval: 2 }, + { timeout: MEDIA_GROUP_WAIT_TIMEOUT_MS, interval: 2 }, ); expect(runtimeError).not.toHaveBeenCalled(); diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e-harness.ts index 1cd604ae0cb..f2faacc696d 100644 --- a/extensions/telegram/src/bot.media.e2e-harness.ts +++ b/extensions/telegram/src/bot.media.e2e-harness.ts @@ -145,10 +145,10 @@ const mediaHarnessDispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => ); export const telegramBotDepsForTest: TelegramBotDeps = { - loadConfig: (() => + getRuntimeConfig: (() => ({ channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } }, - }) as OpenClawConfig) as TelegramBotDeps["loadConfig"], + }) as OpenClawConfig) as TelegramBotDeps["getRuntimeConfig"], resolveStorePath: vi.fn( (storePath?: string) => storePath ?? "/tmp/telegram-media-sessions.json", ) as TelegramBotDeps["resolveStorePath"], diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 05489131eb3..bde70177450 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -965,7 +965,10 @@ export const telegramPlugin = createChatChannelPlugin({ }); const loggedOut = resolved.tokenSource === "none"; if (changed) { - await getTelegramRuntime().config.writeConfigFile(nextCfg); + await getTelegramRuntime().config.replaceConfigFile({ + nextConfig: nextCfg, + afterWrite: { mode: "auto" }, + }); } return { cleared, envToken: Boolean(envToken), loggedOut }; }, diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts index 63de1536b20..254c17283f4 100644 --- a/extensions/telegram/src/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -30,14 +30,14 @@ const api = { use: vi.fn(), }, }; -const { initSpy, runSpy, loadConfig } = vi.hoisted(() => ({ +const { initSpy, runSpy, getRuntimeConfigMock } = vi.hoisted(() => ({ initSpy: vi.fn(async () => undefined), runSpy: vi.fn(() => ({ task: () => Promise.resolve(), stop: vi.fn(), isRunning: (): boolean => false, })), - loadConfig: vi.fn(() => ({ + getRuntimeConfigMock: vi.fn(() => ({ agents: { defaults: { maxConcurrent: 2 } }, channels: { telegram: {} }, })), @@ -265,7 +265,7 @@ async function monitorWithAutoAbort(opts: Omit { return { - loadConfig, + getRuntimeConfig: getRuntimeConfigMock, resolveAgentMaxConcurrent: (cfg: { agents?: { defaults?: { maxConcurrent?: number } } }) => cfg.agents?.defaults?.maxConcurrent ?? 1, }; @@ -343,7 +343,7 @@ describe("monitorTelegramProvider (grammY)", () => { beforeEach(() => { resetTelegramPollingLeasesForTests(); - loadConfig.mockReturnValue({ + getRuntimeConfigMock.mockReturnValue({ agents: { defaults: { maxConcurrent: 2 } }, channels: { telegram: {} }, }); @@ -399,7 +399,7 @@ describe("monitorTelegramProvider (grammY)", () => { it("uses agent maxConcurrent for runner concurrency", async () => { runSpy.mockClear(); - loadConfig.mockReturnValue({ + getRuntimeConfigMock.mockReturnValue({ agents: { defaults: { maxConcurrent: 3 } }, channels: { telegram: {} }, }); diff --git a/extensions/telegram/src/monitor.ts b/extensions/telegram/src/monitor.ts index cec22f6910c..8f30c8bd70b 100644 --- a/extensions/telegram/src/monitor.ts +++ b/extensions/telegram/src/monitor.ts @@ -3,7 +3,7 @@ import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plu import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context"; import { resolveAgentMaxConcurrent } from "openclaw/plugin-sdk/config-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env"; import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; @@ -114,7 +114,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { }); try { - const cfg = opts.config ?? loadConfig(); + const cfg = opts.config ?? getRuntimeConfig(); const account = resolveTelegramAccount({ cfg, accountId: opts.accountId, diff --git a/extensions/telegram/src/target-writeback.test-shared.ts b/extensions/telegram/src/target-writeback.test-shared.ts index 33dc9080960..34d33ed6bc6 100644 --- a/extensions/telegram/src/target-writeback.test-shared.ts +++ b/extensions/telegram/src/target-writeback.test-shared.ts @@ -6,6 +6,10 @@ type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise>; export const readConfigFileSnapshotForWrite: AsyncUnknownMock = vi.fn(); export const writeConfigFile: AsyncUnknownMock = vi.fn(); +export const replaceConfigFile: AsyncUnknownMock = vi.fn(async (params: unknown) => { + const record = params as { nextConfig?: unknown; writeOptions?: unknown }; + await writeConfigFile(record.nextConfig, record.writeOptions); +}); export const loadCronStore: AsyncUnknownMock = vi.fn(); export const resolveCronStorePath: UnknownMock = vi.fn(); export const saveCronStore: AsyncUnknownMock = vi.fn(); @@ -17,6 +21,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async () => { return { ...actual, readConfigFileSnapshotForWrite, + replaceConfigFile, writeConfigFile, loadCronStore, resolveCronStorePath, @@ -36,6 +41,7 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: { beforeEach(() => { readConfigFileSnapshotForWrite.mockReset(); + replaceConfigFile.mockClear(); writeConfigFile.mockReset(); loadCronStore.mockReset(); resolveCronStorePath.mockReset(); diff --git a/extensions/telegram/src/target-writeback.ts b/extensions/telegram/src/target-writeback.ts index 521297b3533..ee81d66833d 100644 --- a/extensions/telegram/src/target-writeback.ts +++ b/extensions/telegram/src/target-writeback.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { readConfigFileSnapshotForWrite, - writeConfigFile, + replaceConfigFile, } from "openclaw/plugin-sdk/config-runtime"; import { loadCronStore, @@ -179,7 +179,12 @@ export async function maybePersistResolvedTelegramTarget(params: { resolvedTarget, }); if (configChanged) { - await writeConfigFile(nextConfig, writeOptions); + await replaceConfigFile({ + nextConfig, + snapshot, + writeOptions, + afterWrite: { mode: "auto" }, + }); if (params.verbose) { writebackLogger.warn(`resolved Telegram defaultTo target ${raw} -> ${resolvedTarget}`); } diff --git a/extensions/thread-ownership/index.test.ts b/extensions/thread-ownership/index.test.ts index 5915827aff7..6de53c991b5 100644 --- a/extensions/thread-ownership/index.test.ts +++ b/extensions/thread-ownership/index.test.ts @@ -15,7 +15,7 @@ describe("thread-ownership plugin", () => { }, runtime: { config: { - loadConfig: () => configFile, + current: () => configFile, }, }, id: "thread-ownership", diff --git a/extensions/thread-ownership/index.ts b/extensions/thread-ownership/index.ts index 2173c04e186..ee416a86592 100644 --- a/extensions/thread-ownership/index.ts +++ b/extensions/thread-ownership/index.ts @@ -83,9 +83,11 @@ export default definePluginEntry({ description: "Slack thread claim coordination for multi-agent setups", register(api: OpenClawPluginApi) { const resolveCurrentState = () => { - const currentConfig = api.runtime.config?.loadConfig?.() ?? api.config; + const currentConfig = (api.runtime.config?.current?.() ?? api.config) as OpenClawConfig; const livePluginCfg = resolveLivePluginConfigObject( - api.runtime.config?.loadConfig, + api.runtime.config?.current + ? () => api.runtime.config.current() as OpenClawConfig + : undefined, "thread-ownership", isThreadOwnershipConfig(api.pluginConfig) ? (api.pluginConfig as Record) diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 46d825bc0a5..ff7e1dda8a2 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -1,5 +1,6 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +import type { OpenClawConfig } from "../../runtime-api.js"; import { createLoggerBackedRuntime } from "../../runtime-api.js"; import { getTlonRuntime } from "../runtime.js"; import { createSettingsManager, type TlonSettingsStore } from "../settings.js"; @@ -57,7 +58,7 @@ function readNumber(record: Record | null, key: string): number export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { const core = getTlonRuntime(); - const cfg = core.config.loadConfig(); + const cfg = core.config.current() as OpenClawConfig; if (cfg.channels?.tlon?.enabled === false) { return; } diff --git a/extensions/whatsapp/src/auto-reply/config.runtime.ts b/extensions/whatsapp/src/auto-reply/config.runtime.ts index eec78cd4c7c..a2039033a0e 100644 --- a/extensions/whatsapp/src/auto-reply/config.runtime.ts +++ b/extensions/whatsapp/src/auto-reply/config.runtime.ts @@ -1,7 +1,7 @@ export { evaluateSessionFreshness, + getRuntimeConfig, getRuntimeConfigSourceSnapshot, - loadConfig, loadSessionStore, recordSessionMetaFromInbound, resolveChannelContextVisibilityMode, diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.runtime.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.runtime.ts index 133656b0680..33fe5c217a7 100644 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.runtime.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.runtime.ts @@ -1,7 +1,7 @@ export { appendCronStyleCurrentTimeLine } from "openclaw/plugin-sdk/agent-runtime"; export { canonicalizeMainSessionAlias, - loadConfig, + getRuntimeConfig, loadSessionStore, resolveSessionKey, resolveStorePath, diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts index a51a0978e7d..8e5a33b7ff7 100644 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts @@ -7,10 +7,10 @@ import { canonicalizeMainSessionAlias, emitHeartbeatEvent, formatError, + getRuntimeConfig, getChildLogger, getReplyFromConfig, hasOutboundReplyContent, - loadConfig, loadSessionStore, normalizeMainKey, redactIdentifier, @@ -29,14 +29,14 @@ import { } from "./heartbeat-runner.runtime.js"; import { getSessionSnapshot } from "./session-snapshot.js"; -function resolveDefaultAgentIdFromConfig(cfg: ReturnType): string { +function resolveDefaultAgentIdFromConfig(cfg: ReturnType): string { const agents = cfg.agents?.list ?? []; const chosen = agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? "main"; return normalizeOptionalLowercaseString(chosen) ?? "main"; } export async function runWebHeartbeatOnce(opts: { - cfg?: ReturnType; + cfg?: ReturnType; to: string; verbose?: boolean; replyResolver?: typeof getReplyFromConfig; @@ -56,7 +56,7 @@ export async function runWebHeartbeatOnce(opts: { to: redactedTo, }); - const cfg = cfgOverride ?? loadConfig(); + const cfg = cfgOverride ?? getRuntimeConfig(); // Resolve heartbeat visibility settings for WhatsApp const visibility = resolveHeartbeatVisibility({ cfg, channel: "whatsapp" }); @@ -323,7 +323,7 @@ export async function runWebHeartbeatOnce(opts: { } export function resolveHeartbeatRecipients( - cfg: ReturnType, + cfg: ReturnType, opts: { to?: string; all?: boolean; accountId?: string } = {}, ) { return resolveWhatsAppHeartbeatRecipients(cfg, opts); diff --git a/extensions/whatsapp/src/auto-reply/mentions.ts b/extensions/whatsapp/src/auto-reply/mentions.ts index 864643f18ac..a1756a4f5ee 100644 --- a/extensions/whatsapp/src/auto-reply/mentions.ts +++ b/extensions/whatsapp/src/auto-reply/mentions.ts @@ -2,7 +2,7 @@ import { buildMentionRegexes, normalizeMentionText, } from "openclaw/plugin-sdk/channel-mention-gating"; -import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { getComparableIdentityValues, getMentionIdentities, @@ -24,10 +24,7 @@ export type MentionTargets = { self: WhatsAppIdentity; }; -export function buildMentionConfig( - cfg: ReturnType, - agentId?: string, -): MentionConfig { +export function buildMentionConfig(cfg: OpenClawConfig, agentId?: string): MentionConfig { const mentionRegexes = buildMentionRegexes(cfg, agentId); return { mentionRegexes, allowFrom: cfg.channels?.whatsapp?.allowFrom }; } diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index 9980fdf02b1..aa0e7c59f36 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -28,7 +28,7 @@ import { sleepWithAbort, } from "../reconnect.js"; import { formatError, getWebAuthAgeMs, readWebSelfId } from "../session.js"; -import { getRuntimeConfigSourceSnapshot, loadConfig } from "./config.runtime.js"; +import { getRuntimeConfig, getRuntimeConfigSourceSnapshot } from "./config.runtime.js"; import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js"; import { buildMentionConfig } from "./mentions.js"; import { createWebChannelStatusController } from "./monitor-state.js"; @@ -62,8 +62,8 @@ function isNoListenerReconnectError(lastError?: string): boolean { } function resolveExplicitWhatsAppDebounceOverride(params: { - cfg: ReturnType; - sourceCfg?: ReturnType | null; + cfg: ReturnType; + sourceCfg?: ReturnType | null; accountId: string; }): number | undefined { const channel = params.sourceCfg?.channels?.whatsapp; @@ -114,7 +114,7 @@ export async function monitorWebChannel( const statusController = createWebChannelStatusController(tuning.statusSink); statusController.emit(); - const baseCfg = loadConfig(); + const baseCfg = getRuntimeConfig(); const sourceCfg = getRuntimeConfigSourceSnapshot(); const account = resolveWhatsAppAccount({ cfg: baseCfg, @@ -138,7 +138,7 @@ export async function monitorWebChannel( groups: account.groups, }, }, - } satisfies ReturnType; + } satisfies ReturnType; const maxMediaBytes = resolveWhatsAppMediaMaxBytes(account); const heartbeatSeconds = resolveHeartbeatSeconds(cfg, tuning.heartbeatSeconds); diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts index 6079eb85ac6..1ac353e161d 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts @@ -3,7 +3,7 @@ import { shouldAckReactionForWhatsApp, type AckReactionHandle, } from "openclaw/plugin-sdk/channel-feedback"; -import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { getSenderIdentity } from "../../identity.js"; import { resolveWhatsAppReactionLevel } from "../../reaction-level.js"; @@ -13,7 +13,7 @@ import type { WebInboundMsg } from "../types.js"; import { resolveGroupActivationFor } from "./group-activation.js"; export async function maybeSendAckReaction(params: { - cfg: ReturnType; + cfg: OpenClawConfig; msg: WebInboundMsg; agentId: string; sessionKey: string; diff --git a/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts b/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts index 7ceb697cdc0..b43e40553c7 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts @@ -1,5 +1,5 @@ import type { AckReactionHandle } from "openclaw/plugin-sdk/channel-feedback"; -import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { buildAgentSessionKey, deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing"; import { @@ -14,7 +14,7 @@ import type { WebInboundMsg } from "../types.js"; import type { GroupHistoryEntry } from "./inbound-context.js"; function buildBroadcastRouteKeys(params: { - cfg: ReturnType; + cfg: OpenClawConfig; msg: WebInboundMsg; route: ReturnType; peerId: string; @@ -47,7 +47,7 @@ function buildBroadcastRouteKeys(params: { } export async function maybeBroadcastMessage(params: { - cfg: ReturnType; + cfg: OpenClawConfig; msg: WebInboundMsg; peerId: string; route: ReturnType; diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts b/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts index 4ea8bcd17a5..0c3e934689e 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts @@ -1,3 +1,4 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { updateSessionStore } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { resolveWhatsAppLegacyGroupSessionKey } from "../../group-session-key.js"; @@ -5,9 +6,7 @@ import { resolveWhatsAppInboundPolicy } from "../../inbound-policy.js"; import { loadSessionStore, resolveStorePath } from "../config.runtime.js"; import { normalizeGroupActivation } from "./group-activation.runtime.js"; -type LoadConfigFn = typeof import("../config.runtime.js").loadConfig; - -function hasNamedWhatsAppAccounts(cfg: ReturnType) { +function hasNamedWhatsAppAccounts(cfg: OpenClawConfig) { const accountIds = Object.keys(cfg.channels?.whatsapp?.accounts ?? {}); return accountIds.some((accountId) => normalizeAccountId(accountId) !== DEFAULT_ACCOUNT_ID); } @@ -29,7 +28,7 @@ function isActivationOnlyEntry( } export async function resolveGroupActivationFor(params: { - cfg: ReturnType; + cfg: OpenClawConfig; accountId?: string | null; agentId: string; sessionKey: string; diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts index a7b0ac17cc6..3c6c9768612 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts @@ -1,4 +1,4 @@ -import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { getPrimaryIdentityId, getReplyContext, @@ -31,7 +31,7 @@ export type GroupHistoryEntry = { }; type ApplyGroupGatingParams = { - cfg: ReturnType; + cfg: OpenClawConfig; msg: WebInboundMsg; mentionText?: string; deferMissingMention?: boolean; diff --git a/extensions/whatsapp/src/auto-reply/monitor/last-route.ts b/extensions/whatsapp/src/auto-reply/monitor/last-route.ts index 6edcb2d01de..300fd921fd0 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/last-route.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/last-route.ts @@ -1,9 +1,8 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; import { formatError } from "../../session.js"; import { resolveStorePath, updateLastRoute } from "../config.runtime.js"; -type LoadConfigFn = typeof import("../config.runtime.js").loadConfig; - export function trackBackgroundTask( backgroundTasks: Set>, task: Promise, @@ -16,7 +15,7 @@ export function trackBackgroundTask( } export function updateLastRouteInBackground(params: { - cfg: ReturnType; + cfg: OpenClawConfig; backgroundTasks: Set>; storeAgentId: string; sessionKey: string; diff --git a/extensions/whatsapp/src/auto-reply/monitor/message-line.runtime.ts b/extensions/whatsapp/src/auto-reply/monitor/message-line.runtime.ts index 603ce5bfd94..98fbdc3a817 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/message-line.runtime.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/message-line.runtime.ts @@ -1,11 +1,11 @@ -import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; export { formatInboundEnvelope, type EnvelopeFormatOptions, } from "openclaw/plugin-sdk/channel-envelope"; -type WhatsAppMessagePrefixConfig = ReturnType; +type WhatsAppMessagePrefixConfig = OpenClawConfig; function normalizeAgentId(agentId: string): string { return agentId.trim().toLowerCase() || "main"; diff --git a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts index d94de41c97c..c52be3cee5a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts @@ -1,4 +1,4 @@ -import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { getPrimaryIdentityId, getReplyContext, getSenderIdentity } from "../../identity.js"; import type { WebInboundMsg } from "../types.js"; import { @@ -18,7 +18,7 @@ export function formatReplyContext(msg: WebInboundMsg) { } export function buildInboundLine(params: { - cfg: ReturnType; + cfg: OpenClawConfig; msg: WebInboundMsg; agentId: string; previousTimestamp?: number; diff --git a/extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts b/extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts index 17895e1bf79..7cc181d616b 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts @@ -41,7 +41,7 @@ vi.mock("./peer.js", () => ({ })); vi.mock("../config.runtime.js", () => ({ - loadConfig: () => ({ + getRuntimeConfig: () => ({ channels: { whatsapp: { ackReaction: { enabled: true }, diff --git a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts index e03b9471f09..64eea5bf425 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts @@ -7,7 +7,7 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveWhatsAppGroupSessionRoute } from "../../group-session-key.js"; import { getPrimaryIdentityId, getSenderIdentity } from "../../identity.js"; import { normalizeE164 } from "../../text-runtime.js"; -import { loadConfig } from "../config.runtime.js"; +import { getRuntimeConfig } from "../config.runtime.js"; import type { MentionConfig } from "../mentions.js"; import type { WebInboundMsg } from "../types.js"; import { maybeSendAckReaction } from "./ack-reaction.js"; @@ -20,7 +20,7 @@ import { resolvePeerId } from "./peer.js"; import { processMessage } from "./process-message.js"; export function createWebOnMessageHandler(params: { - cfg: ReturnType; + cfg: ReturnType; verbose: boolean; connectionId: string; maxMediaBytes: number; @@ -87,7 +87,7 @@ export function createWebOnMessageHandler(params: { const peerId = resolvePeerId(msg); // Fresh config for bindings lookup; other routing inputs are payload-derived. const baseRoute = resolveAgentRoute({ - cfg: loadConfig(), + cfg: getRuntimeConfig(), channel: "whatsapp", accountId: msg.accountId, peer: { diff --git a/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts b/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts index d73498d205f..a70fe88474c 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/runtime-api.ts @@ -12,7 +12,7 @@ export { resolveChannelContextVisibilityMode, } from "../config.runtime.js"; export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; -export type LoadConfigFn = typeof import("../config.runtime.js").loadConfig; +export type LoadConfigFn = typeof import("../config.runtime.js").getRuntimeConfig; export { buildHistoryContextFromEntries, type HistoryEntry, diff --git a/extensions/whatsapp/src/auto-reply/session-snapshot.ts b/extensions/whatsapp/src/auto-reply/session-snapshot.ts index 5f4494c3d36..0e575910905 100644 --- a/extensions/whatsapp/src/auto-reply/session-snapshot.ts +++ b/extensions/whatsapp/src/auto-reply/session-snapshot.ts @@ -1,3 +1,4 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { normalizeMainKey } from "openclaw/plugin-sdk/routing"; import { evaluateSessionFreshness, @@ -10,10 +11,8 @@ import { resolveStorePath, } from "./config.runtime.js"; -type LoadConfigFn = typeof import("./config.runtime.js").loadConfig; - export function getSessionSnapshot( - cfg: ReturnType, + cfg: OpenClawConfig, from: string, _isHeartbeat = false, ctx?: { 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 ca6d0c4c969..57e4fadb307 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 @@ -33,10 +33,10 @@ const makeConfig = (overrides: Record) => }, session: { store: sessionStorePath }, ...overrides, - }) as unknown as ReturnType; + }) as unknown as import("openclaw/plugin-sdk/config-runtime").OpenClawConfig; async function runGroupGating(params: { - cfg: ReturnType; + cfg: import("openclaw/plugin-sdk/config-runtime").OpenClawConfig; msg: Record; conversationId?: string; agentId?: string; diff --git a/extensions/whatsapp/src/login-qr.ts b/extensions/whatsapp/src/login-qr.ts index 0c5ee0d9f49..7d7c2e571c0 100644 --- a/extensions/whatsapp/src/login-qr.ts +++ b/extensions/whatsapp/src/login-qr.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { danger, info, success } from "openclaw/plugin-sdk/runtime-env"; import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { logInfo } from "openclaw/plugin-sdk/text-runtime"; @@ -280,7 +280,7 @@ export async function startWebLoginWithQr( } = {}, ): Promise { const runtime = opts.runtime ?? defaultRuntime; - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId }); const authState = await readWebAuthExistsForDecision(account.authDir); if (authState.outcome === "unstable") { @@ -446,7 +446,7 @@ export async function waitForWebLogin( } = {}, ): Promise<{ connected: boolean; message: string; qrDataUrl?: string }> { const runtime = opts.runtime ?? defaultRuntime; - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId }); const activeLogin = activeLogins.get(account.accountId); if (!activeLogin) { diff --git a/extensions/whatsapp/src/login.coverage.test.ts b/extensions/whatsapp/src/login.coverage.test.ts index e05823236ca..0de40f9ffe5 100644 --- a/extensions/whatsapp/src/login.coverage.test.ts +++ b/extensions/whatsapp/src/login.coverage.test.ts @@ -19,7 +19,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async () => { ); return { ...actual, - loadConfig: () => + getRuntimeConfig: () => ({ channels: { whatsapp: { diff --git a/extensions/whatsapp/src/login.ts b/extensions/whatsapp/src/login.ts index fcaf7927b2d..5d5dc81afd0 100644 --- a/extensions/whatsapp/src/login.ts +++ b/extensions/whatsapp/src/login.ts @@ -1,5 +1,5 @@ import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; -import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig } from "openclaw/plugin-sdk/config-runtime"; import { danger, success } from "openclaw/plugin-sdk/runtime-env"; import { defaultRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { logInfo } from "openclaw/plugin-sdk/text-runtime"; @@ -14,7 +14,7 @@ export async function loginWeb( runtime: RuntimeEnv = defaultRuntime, accountId?: string, ) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const account = resolveWhatsAppAccount({ cfg, accountId }); const restoredFromBackup = await restoreCredsFromBackupIfNeeded(account.authDir); let sock = await createWaSocket(true, verbose, { diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index 7adda661a6e..340d16d8654 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -405,6 +405,7 @@ function resolveChannelGroupRequireMentionMock(params: { } vi.mock("./auto-reply/config.runtime.js", () => ({ + getRuntimeConfig: loadConfigMock, getRuntimeConfigSourceSnapshot: loadRuntimeConfigSourceSnapshotMock, loadConfig: loadConfigMock, updateLastRoute: updateLastRouteMock, diff --git a/extensions/xai/xai.live.test.ts b/extensions/xai/xai.live.test.ts index ed282df57a6..700a1e0a5af 100644 --- a/extensions/xai/xai.live.test.ts +++ b/extensions/xai/xai.live.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { loadConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { getRuntimeConfig, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime"; import { describe, expect, it } from "vitest"; import { @@ -19,7 +19,7 @@ const describeLive = liveEnabled ? describe : describe.skip; const EMPTY_AUTH_STORE = { version: 1, profiles: {} } as const; function createLiveConfig(): OpenClawConfig { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); return { ...cfg, models: { diff --git a/package.json b/package.json index 81f59c4c4f3..4a0e9a2b202 100644 --- a/package.json +++ b/package.json @@ -1332,10 +1332,11 @@ "canvas:a2ui:bundle": "node scripts/bundle-a2ui.mjs", "changed:lanes": "node scripts/changed-lanes.mjs", "check": "node scripts/check.mjs", - "check:architecture": "pnpm check:import-cycles && pnpm check:madge-import-cycles", + "check:architecture": "pnpm check:import-cycles && pnpm check:madge-import-cycles && pnpm check:deprecated-internal-config-api", "check:base-config-schema": "node --import tsx scripts/generate-base-config-schema.ts --check", "check:bundled-channel-config-metadata": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check", "check:changed": "node scripts/check-changed.mjs", + "check:deprecated-internal-config-api": "pnpm test src/plugins/contracts/deprecated-internal-config-api.test.ts -- --reporter=verbose", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-mdx && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "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", diff --git a/scripts/changed-lanes.mjs b/scripts/changed-lanes.mjs index 6e47f74d925..9cd0a809d6f 100644 --- a/scripts/changed-lanes.mjs +++ b/scripts/changed-lanes.mjs @@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process"; import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs"; +const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024; + const DOCS_PATH_RE = /^(?:docs\/|README\.md$|AGENTS\.md$|.*\.mdx?$)/u; const APP_PATH_RE = /^(?:apps\/|Swabble\/|appcast\.xml$)/u; const EXTENSION_PATH_RE = /^extensions\/[^/]+(?:\/|$)/u; @@ -248,6 +250,7 @@ function runGitNameOnlyDiff(extraArgs, cwd = process.cwd()) { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", + maxBuffer: GIT_OUTPUT_MAX_BUFFER, }); return output.split("\n").map(normalizeChangedPath).filter(Boolean); } @@ -257,6 +260,7 @@ function runGitLsFiles(extraArgs, cwd = process.cwd()) { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", + maxBuffer: GIT_OUTPUT_MAX_BUFFER, }); return output.split("\n").map(normalizeChangedPath).filter(Boolean); } @@ -265,6 +269,7 @@ export function listStagedChangedPaths() { const output = execFileSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", + maxBuffer: GIT_OUTPUT_MAX_BUFFER, }); return output.split("\n").map(normalizeChangedPath).filter(Boolean); } diff --git a/scripts/check.mjs b/scripts/check.mjs index f8eff5e13f6..473ddc93f4c 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -10,13 +10,19 @@ export async function main(argv = process.argv.slice(2)) { const tailChecks = [ { name: "webhook body guard", args: ["lint:webhook:no-low-level-body-read"] }, { name: "runtime action config guard", args: ["check:no-runtime-action-load-config"] }, + !includeArchitecture + ? { + name: "deprecated internal config API guard", + args: ["check:deprecated-internal-config-api"], + } + : null, { name: "temp path guard", args: ["check:temp-path-guardrails"] }, { name: "pairing store guard", args: ["lint:auth:no-pairing-store-group"] }, { name: "pairing account guard", args: ["lint:auth:pairing-account-scope"] }, includeArchitecture ? { name: "architecture import cycles", args: ["check:architecture"] } : { name: "runtime import cycles", args: ["check:import-cycles"] }, - ]; + ].filter(Boolean); const stages = [ { diff --git a/scripts/dev/test-device-pair-telegram.ts b/scripts/dev/test-device-pair-telegram.ts index c97199bd7b6..730f6e73a8a 100644 --- a/scripts/dev/test-device-pair-telegram.ts +++ b/scripts/dev/test-device-pair-telegram.ts @@ -1,5 +1,5 @@ import { sendMessageTelegram } from "../../extensions/telegram/runtime-api.js"; -import { loadConfig } from "../../src/config/config.js"; +import { getRuntimeConfig } from "../../src/config/config.js"; import { matchPluginCommand, executePluginCommand } from "../../src/plugins/commands.js"; import { loadOpenClawPlugins } from "../../src/plugins/loader.js"; @@ -35,7 +35,7 @@ if (!chatId) { process.exit(1); } -const cfg = loadConfig(); +const cfg = getRuntimeConfig(); loadOpenClawPlugins({ config: cfg }); const match = matchPluginCommand("/pair"); diff --git a/scripts/live-docker-normalize-config.ts b/scripts/live-docker-normalize-config.ts index a21e9ba2a1c..cc6cf2b686c 100644 --- a/scripts/live-docker-normalize-config.ts +++ b/scripts/live-docker-normalize-config.ts @@ -1,5 +1,5 @@ import { loadAndMaybeMigrateDoctorConfig } from "../src/commands/doctor-config-flow.js"; -import { writeConfigFile } from "../src/config/config.js"; +import { replaceConfigFile } from "../src/config/config.js"; const result = await loadAndMaybeMigrateDoctorConfig({ options: { @@ -11,5 +11,5 @@ const result = await loadAndMaybeMigrateDoctorConfig({ }); if (result.shouldWriteConfig) { - await writeConfigFile(result.cfg); + await replaceConfigFile({ nextConfig: result.cfg }); } diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts index 54185a81a13..491e2a80d57 100644 --- a/src/acp/runtime/session-meta.ts +++ b/src/acp/runtime/session-meta.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { resolveStorePath } from "../../config/sessions/paths.js"; import { loadSessionStore } from "../../config/sessions/store-load.js"; import { resolveAllAgentSessionStoreTargets } from "../../config/sessions/targets.js"; @@ -54,7 +54,7 @@ export function resolveSessionStorePathForAcp(params: { sessionKey: string; cfg?: OpenClawConfig; }): { cfg: OpenClawConfig; storePath: string } { - const cfg = params.cfg ?? loadConfig(); + const cfg = params.cfg ?? getRuntimeConfig(); const parsed = parseAgentSessionKey(params.sessionKey); const storePath = resolveStorePath(cfg.session?.store, { agentId: parsed?.agentId, @@ -99,7 +99,7 @@ export async function listAcpSessionEntries(params: { cfg?: OpenClawConfig; env?: NodeJS.ProcessEnv; }): Promise { - const cfg = params.cfg ?? loadConfig(); + const cfg = params.cfg ?? getRuntimeConfig(); const storeTargets = await resolveAllAgentSessionStoreTargets( cfg, params.env ? { env: params.env } : undefined, diff --git a/src/acp/server.startup.test.ts b/src/acp/server.startup.test.ts index 5d80ff4cd0a..8c072948ed6 100644 --- a/src/acp/server.startup.test.ts +++ b/src/acp/server.startup.test.ts @@ -67,14 +67,18 @@ vi.mock("@agentclientprotocol/sdk", () => ({ ndJsonStream: vi.fn(() => ({ type: "mock-stream" })), })); -vi.mock("../config/config.js", () => ({ - loadConfig: () => ({ +vi.mock("../config/config.js", () => { + const loadConfig = () => ({ gateway: { mode: "local", }, - }), - resolveGatewayPort: vi.fn(() => 18_789), -})); + }); + return { + getRuntimeConfig: loadConfig, + loadConfig, + resolveGatewayPort: vi.fn(() => 18_789), + }; +}); vi.mock("../gateway/call.js", () => ({ callGateway: vi.fn(), diff --git a/src/acp/server.ts b/src/acp/server.ts index f922d1ed129..9c6eb22fdf1 100644 --- a/src/acp/server.ts +++ b/src/acp/server.ts @@ -2,7 +2,7 @@ import { Readable, Writable } from "node:stream"; import { fileURLToPath } from "node:url"; import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveGatewayClientBootstrap } from "../gateway/client-bootstrap.js"; import { GatewayClient } from "../gateway/client.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; @@ -15,7 +15,7 @@ import { normalizeAcpProvenanceMode, type AcpServerOptions } from "./types.js"; export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise { routeLogsToStderr(); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const bootstrap = await resolveGatewayClientBootstrap({ config: cfg, gatewayUrl: opts.gatewayUrl, diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index cbbc3dbb7b8..9333c14d131 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -126,7 +126,7 @@ vi.mock("../config/sessions.js", () => ({ })); vi.mock("../config/config.js", () => ({ - loadConfig: () => hoisted.state.cfg, + getRuntimeConfig: () => hoisted.state.cfg, })); vi.mock("../config/sessions/transcript.js", () => ({ diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index a30e96b533f..89fb963caf9 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -32,7 +32,7 @@ import { DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT, DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH, } from "../config/agent-limits.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveStorePath } from "../config/sessions/paths.js"; import { loadSessionStore } from "../config/sessions/store.js"; import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js"; @@ -1063,7 +1063,7 @@ export async function spawnAcpDirect( params: SpawnAcpParams, ctx: SpawnAcpContext, ): Promise { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const requesterInternalKey = resolveRequesterInternalSessionKey({ cfg, requesterSessionKey: ctx.agentSessionKey, diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 03bbef69dfc..a404307e5cd 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -125,7 +125,7 @@ vi.mock("../cli/deps.js", () => ({ })); vi.mock("../config/io.js", () => ({ - loadConfig: () => ({ + getRuntimeConfig: () => ({ agents: { defaults: { models: { diff --git a/src/agents/agent-runtime-config.ts b/src/agents/agent-runtime-config.ts index 4dc7313be0f..7925be6ae08 100644 --- a/src/agents/agent-runtime-config.ts +++ b/src/agents/agent-runtime-config.ts @@ -1,5 +1,5 @@ import { getAgentRuntimeCommandSecretTargetIds } from "../cli/command-secret-targets.js"; -import { loadConfig, readConfigFileSnapshotForWrite } from "../config/io.js"; +import { getRuntimeConfig, readConfigFileSnapshotForWrite } from "../config/io.js"; import { setRuntimeConfigSnapshot } from "../config/runtime-snapshot.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isSecretRef } from "../config/types.secrets.js"; @@ -13,7 +13,7 @@ export async function resolveAgentRuntimeConfig( sourceConfig: OpenClawConfig; cfg: OpenClawConfig; }> { - const loadedRaw = loadConfig(); + const loadedRaw = getRuntimeConfig(); const sourceConfig = await (async () => { try { const { snapshot } = await readConfigFileSnapshotForWrite(); diff --git a/src/agents/anthropic.setup-token.live.test.ts b/src/agents/anthropic.setup-token.live.test.ts index 12a23c72d31..227b776fcfe 100644 --- a/src/agents/anthropic.setup-token.live.test.ts +++ b/src/agents/anthropic.setup-token.live.test.ts @@ -8,7 +8,7 @@ import { ANTHROPIC_SETUP_TOKEN_PREFIX, validateAnthropicSetupToken, } from "../commands/auth-token.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { type AuthProfileCredential, @@ -184,7 +184,7 @@ describeLive("live anthropic setup-token", () => { async () => { const tokenSource = await resolveTokenSource(); try { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); await ensureOpenClawModelsJson(cfg, tokenSource.agentDir); const authStorage = discoverAuthStorage(tokenSource.agentDir); diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index b93b086bb9e..038b5988629 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -4,7 +4,7 @@ import { type OAuthCredentials, type OAuthProvider, } from "@mariozechner/pi-ai/oauth"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { coerceSecretRef } from "../../config/types.secrets.js"; import { formatErrorMessage } from "../../infra/errors.js"; @@ -279,7 +279,7 @@ export async function resolveApiKeyForProfile( } const refResolveCache: SecretRefResolveCache = {}; - const configForRefResolution = cfg ?? loadConfig(); + const configForRefResolution = cfg ?? getRuntimeConfig(); const refDefaults = configForRefResolution.secrets?.defaults; assertNoOAuthSecretRefPolicyViolations({ store, diff --git a/src/agents/cli-runner.bundle-mcp.e2e.test.ts b/src/agents/cli-runner.bundle-mcp.e2e.test.ts index 40202fbc14e..11293ed6c24 100644 --- a/src/agents/cli-runner.bundle-mcp.e2e.test.ts +++ b/src/agents/cli-runner.bundle-mcp.e2e.test.ts @@ -33,9 +33,11 @@ describe("runCliAgent bundle MCP e2e", () => { { timeout: E2E_TIMEOUT_MS }, async () => { const { runCliAgent } = await import("./cli-runner.js"); + const { resetGlobalHookRunner } = await import("../plugins/hook-runner-global.js"); const envSnapshot = captureEnv(["HOME"]); const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-bundle-mcp-")); process.env.HOME = tempHome; + resetGlobalHookRunner(); const workspaceDir = path.join(tempHome, "workspace"); const sessionFile = path.join(tempHome, "session.jsonl"); @@ -84,6 +86,7 @@ describe("runCliAgent bundle MCP e2e", () => { expect(result.payloads?.[0]?.text).toContain("BUNDLE MCP OK FROM-BUNDLE"); expect(result.meta.agentMeta?.sessionId.length ?? 0).toBeGreaterThan(0); } finally { + resetGlobalHookRunner(); await fs.rm(tempHome, { recursive: true, force: true }); envSnapshot.restore(); } diff --git a/src/agents/context.eager-warmup.test.ts b/src/agents/context.eager-warmup.test.ts index 773a93c02fe..49d362edac6 100644 --- a/src/agents/context.eager-warmup.test.ts +++ b/src/agents/context.eager-warmup.test.ts @@ -3,7 +3,7 @@ import { importFreshModule } from "../../test/helpers/import-fresh.js"; const loadConfigMock = vi.hoisted(() => vi.fn()); -vi.mock("../config/config.js", () => ({ loadConfig: loadConfigMock })); +vi.mock("../config/config.js", () => ({ getRuntimeConfig: loadConfigMock })); describe("agents/context eager warmup", () => { const originalArgv = process.argv.slice(); diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index c80b547fb98..ee3fa1f69fa 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -17,7 +17,7 @@ const contextTestState = vi.hoisted(() => { }); vi.mock("../config/config.js", () => ({ - loadConfig: () => contextTestState.loadConfigImpl(), + getRuntimeConfig: () => contextTestState.loadConfigImpl(), })); vi.mock("./models-config.runtime.js", () => ({ @@ -34,17 +34,17 @@ vi.mock("./pi-model-discovery-runtime.js", () => ({ })); function mockContextDeps(params: { - loadConfig: () => unknown; + getRuntimeConfig: () => unknown; discoveredModels?: DiscoveredModel[]; }) { - contextTestState.loadConfigImpl = params.loadConfig; + contextTestState.loadConfigImpl = params.getRuntimeConfig; contextTestState.discoveredModels = params.discoveredModels ?? []; contextTestState.ensureOpenClawModelsJson.mockClear(); return { ensureOpenClawModelsJson: contextTestState.ensureOpenClawModelsJson }; } function mockContextModuleDeps(loadConfigImpl: () => unknown) { - return mockContextDeps({ loadConfig: loadConfigImpl }); + return mockContextDeps({ getRuntimeConfig: loadConfigImpl }); } // Shared mock setup used by multiple tests. @@ -53,7 +53,7 @@ function mockDiscoveryDeps( configModels?: Record }>, ) { mockContextDeps({ - loadConfig: () => ({ models: configModels ? { providers: configModels } : {} }), + getRuntimeConfig: () => ({ models: configModels ? { providers: configModels } : {} }), discoveredModels: models, }); } diff --git a/src/agents/context.test.ts b/src/agents/context.test.ts index 3be43f89893..ba87aa79f93 100644 --- a/src/agents/context.test.ts +++ b/src/agents/context.test.ts @@ -7,7 +7,7 @@ import { } from "./context.js"; import { createSessionManagerRuntimeRegistry } from "./pi-hooks/session-manager-runtime-registry.js"; -vi.mock("../config/config.js", () => ({ loadConfig: () => ({}) })); +vi.mock("../config/config.js", () => ({ getRuntimeConfig: () => ({}) })); function testModelContextWindow(id: string, contextWindow: number) { return { diff --git a/src/agents/context.ts b/src/agents/context.ts index 4dd6da58e47..09c3b1d2c1d 100644 --- a/src/agents/context.ts +++ b/src/agents/context.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { isHelpOrVersionInvocation } from "../cli/argv.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { computeBackoff, type BackoffPolicy } from "../infra/backoff.js"; import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js"; @@ -193,7 +193,7 @@ function primeConfiguredContextWindows(): OpenClawConfig | undefined { return undefined; } try { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); applyConfiguredContextWindows({ cache: MODEL_CONTEXT_TOKEN_CACHE, modelsConfig: cfg.models as ModelsConfig | undefined, diff --git a/src/agents/live-cache-test-support.ts b/src/agents/live-cache-test-support.ts index dd9dfad933c..3f5b8c59d5d 100644 --- a/src/agents/live-cache-test-support.ts +++ b/src/agents/live-cache-test-support.ts @@ -1,5 +1,5 @@ import { completeSimple, type Api, type AssistantMessage, type Model } from "@mariozechner/pi-ai"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { collectProviderApiKeys } from "./live-auth-keys.js"; @@ -161,7 +161,7 @@ export async function resolveLiveDirectModel(params: { envVar: string; preferredModelIds: readonly string[]; }): Promise { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); const authStorage = discoverAuthStorage(agentDir); diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index b154db0eb6f..743306c6bfa 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.runtime.js"; @@ -111,7 +111,7 @@ export async function loadModelCatalog(params?: { return a.name.localeCompare(b.name); }); try { - const cfg = params?.config ?? loadConfig(); + const cfg = params?.config ?? getRuntimeConfig(); if (!readOnly) { await ensureOpenClawModelsJson(cfg); logStage("models-json-ready"); diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 0b2bbbbfaf4..153be13fe1c 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -1,10 +1,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import { + getRuntimeConfig, getRuntimeConfigSourceSnapshot, projectConfigOntoRuntimeSourceSnapshot, type OpenClawConfig, - loadConfig, } from "../config/config.js"; import { createConfigRuntimeEnv } from "../config/env-vars.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; @@ -95,7 +95,7 @@ function resolveModelsConfigInput(config?: OpenClawConfig): { } { const runtimeSource = getRuntimeConfigSourceSnapshot(); if (!config) { - const loaded = loadConfig(); + const loaded = getRuntimeConfig(); return { config: runtimeSource ?? loaded, sourceConfigForSecrets: runtimeSource ?? loaded, diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index ecaba327051..e9f699c08fb 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -1,7 +1,7 @@ import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { parseLiveCsvFilter } from "../media-generation/live-test-helpers.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; @@ -689,7 +689,7 @@ describeLive("live models (profile keys)", () => { async () => { logProgress("[live-models] loading config"); const cfg = await withLiveStageTimeout( - Promise.resolve().then(() => loadConfig()), + Promise.resolve().then(() => getRuntimeConfig()), "[live-models] load config", ); logProgress("[live-models] preparing models.json"); diff --git a/src/agents/openai-reasoning-compat.live.test.ts b/src/agents/openai-reasoning-compat.live.test.ts index ae8bcf5106c..68e0ff789d7 100644 --- a/src/agents/openai-reasoning-compat.live.test.ts +++ b/src/agents/openai-reasoning-compat.live.test.ts @@ -3,7 +3,7 @@ import { completeSimple, type Api, type Model } from "@mariozechner/pi-ai"; import { SessionManager } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { isLiveProfileKeyModeEnabled, isLiveTestEnabled } from "./live-test-helpers.js"; import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; @@ -124,7 +124,7 @@ describeLive("openai reasoning compat live", () => { "remaps low reasoning for the configured OpenAI mini target", async () => { const { provider, modelId } = resolveTargetModelRef(); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); @@ -178,7 +178,7 @@ describeLive("openai reasoning compat live", () => { "accepts repaired OpenAI Codex parallel tool replay with aborted missing results", async () => { const { provider, modelId } = resolveTargetModelRef(); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); diff --git a/src/agents/openclaw-plugin-tools.ts b/src/agents/openclaw-plugin-tools.ts index a5acc30dc67..962cb5a9682 100644 --- a/src/agents/openclaw-plugin-tools.ts +++ b/src/agents/openclaw-plugin-tools.ts @@ -34,7 +34,6 @@ export function resolveOpenClawPluginToolsForOptions(params: { return []; } - const runtimeSnapshot = getActiveSecretsRuntimeSnapshot(); const deliveryContext = normalizeDeliveryContext({ channel: params.options?.agentChannel, to: params.options?.agentTo, @@ -42,15 +41,20 @@ export function resolveOpenClawPluginToolsForOptions(params: { threadId: params.options?.agentThreadId, }); + const resolveCurrentRuntimeConfig = () => { + const currentRuntimeSnapshot = getActiveSecretsRuntimeSnapshot(); + return selectApplicableRuntimeConfig({ + inputConfig: params.resolvedConfig ?? params.options?.config, + runtimeConfig: currentRuntimeSnapshot?.config, + runtimeSourceConfig: currentRuntimeSnapshot?.sourceConfig, + }); + }; const pluginTools = resolvePluginTools({ ...resolveOpenClawPluginToolInputs({ options: params.options, resolvedConfig: params.resolvedConfig, - runtimeConfig: selectApplicableRuntimeConfig({ - inputConfig: params.resolvedConfig ?? params.options?.config, - runtimeConfig: runtimeSnapshot?.config, - runtimeSourceConfig: runtimeSnapshot?.sourceConfig, - }), + runtimeConfig: resolveCurrentRuntimeConfig(), + getRuntimeConfig: resolveCurrentRuntimeConfig, }), existingToolNames: params.existingToolNames ?? new Set(), toolAllowlist: params.options?.pluginToolAllowlist, diff --git a/src/agents/openclaw-tools.agents.test.ts b/src/agents/openclaw-tools.agents.test.ts index 9285cc0db0a..b32ccf9a7b4 100644 --- a/src/agents/openclaw-tools.agents.test.ts +++ b/src/agents/openclaw-tools.agents.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { createPerSenderSessionConfig } from "./test-helpers/session-config.js"; import { createAgentsListTool } from "./tools/agents-list-tool.js"; -let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = { +let configOverride: ReturnType<(typeof import("../config/config.js"))["getRuntimeConfig"]> = { session: createPerSenderSessionConfig(), }; @@ -10,7 +10,7 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, - loadConfig: () => configOverride, + getRuntimeConfig: () => configOverride, resolveGatewayPort: () => 18789, }; }); diff --git a/src/agents/openclaw-tools.browser-plugin.integration.test.ts b/src/agents/openclaw-tools.browser-plugin.integration.test.ts index 8428a6cb113..083e8c8cef9 100644 --- a/src/agents/openclaw-tools.browser-plugin.integration.test.ts +++ b/src/agents/openclaw-tools.browser-plugin.integration.test.ts @@ -192,4 +192,76 @@ describe("createOpenClawTools browser plugin integration", () => { expect(capturedRuntimeConfig).toBe(resolvedRunConfig); }); + + it("exposes a live runtime config getter to plugin tool factories", () => { + const sourceConfig = { + plugins: { + allow: ["memory-core"], + }, + } as OpenClawConfig; + const firstRuntimeConfig = { + plugins: { + allow: ["memory-core"], + entries: { "memory-core": { enabled: true } }, + }, + } as OpenClawConfig; + const nextRuntimeConfig = { + plugins: { + allow: ["memory-core"], + entries: { "memory-core": { enabled: false } }, + }, + } as OpenClawConfig; + let getRuntimeConfig: (() => OpenClawConfig | undefined) | undefined; + hoisted.resolvePluginTools.mockImplementation((params: unknown) => { + getRuntimeConfig = ( + params as { context?: { getRuntimeConfig?: () => OpenClawConfig | undefined } } + ).context?.getRuntimeConfig; + return []; + }); + activateSecretsRuntimeSnapshot({ + sourceConfig, + config: firstRuntimeConfig, + authStores: [], + warnings: [], + webTools: { + search: { + providerSource: "none", + diagnostics: [], + }, + fetch: { + providerSource: "none", + diagnostics: [], + }, + diagnostics: [], + }, + }); + + resolveOpenClawPluginToolsForOptions({ + options: { config: sourceConfig }, + resolvedConfig: sourceConfig, + }); + + expect(getRuntimeConfig?.()).toStrictEqual(firstRuntimeConfig); + + activateSecretsRuntimeSnapshot({ + sourceConfig, + config: nextRuntimeConfig, + authStores: [], + warnings: [], + webTools: { + search: { + providerSource: "none", + diagnostics: [], + }, + fetch: { + providerSource: "none", + diagnostics: [], + }, + diagnostics: [], + }, + }); + + expect(getRuntimeConfig?.()).toStrictEqual(nextRuntimeConfig); + expect(getRuntimeConfig?.()?.plugins?.entries?.["memory-core"]?.enabled).toBe(false); + }); }); diff --git a/src/agents/openclaw-tools.plugin-context.ts b/src/agents/openclaw-tools.plugin-context.ts index 902ef14e731..18906193512 100644 --- a/src/agents/openclaw-tools.plugin-context.ts +++ b/src/agents/openclaw-tools.plugin-context.ts @@ -28,8 +28,9 @@ export function resolveOpenClawPluginToolInputs(params: { options?: OpenClawPluginToolOptions; resolvedConfig?: OpenClawConfig; runtimeConfig?: OpenClawConfig; + getRuntimeConfig?: () => OpenClawConfig | undefined; }) { - const { options, resolvedConfig, runtimeConfig } = params; + const { options, resolvedConfig, runtimeConfig, getRuntimeConfig } = params; const sessionAgentId = resolveSessionAgentId({ sessionKey: options?.agentSessionKey, config: resolvedConfig, @@ -50,6 +51,7 @@ export function resolveOpenClawPluginToolInputs(params: { context: { config: options?.config, runtimeConfig, + getRuntimeConfig, fsPolicy: options?.fsPolicy, workspaceDir, agentDir: options?.agentDir, diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 5427cdd1dae..b4ca5fd03a5 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -124,7 +124,7 @@ async function createConfigModuleMock() { const actual = await vi.importActual("../config/config.js"); return { ...actual, - loadConfig: () => mockConfig, + getRuntimeConfig: () => mockConfig, }; } diff --git a/src/agents/openclaw-tools.sessions-visibility.test.ts b/src/agents/openclaw-tools.sessions-visibility.test.ts index 69c55f8de07..745043b447b 100644 --- a/src/agents/openclaw-tools.sessions-visibility.test.ts +++ b/src/agents/openclaw-tools.sessions-visibility.test.ts @@ -13,7 +13,7 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, - loadConfig: () => mockConfig, + getRuntimeConfig: () => mockConfig, resolveGatewayPort: () => 18789, }; }); diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index a87fa136225..0013d5d913e 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -12,7 +12,7 @@ vi.mock("../gateway/call.js", () => ({ })); vi.mock("../config/config.js", () => ({ - loadConfig: () => ({ + getRuntimeConfig: () => ({ session: { mainKey: "main", scope: "per-sender", diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts index 83597ed31f6..30e51348fbb 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.allowlist.test.ts @@ -71,7 +71,7 @@ async function spawn(params: { beforeAll(async () => { ({ resetSubagentRegistryForTests, spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({ callGatewayMock: hoisted.callGatewayMock, - loadConfig: () => hoisted.configOverride, + getRuntimeConfig: () => hoisted.configOverride, resolveAgentConfig: (cfg, agentId) => resolveAgentConfigFromList(cfg, agentId), resolveSandboxRuntimeStatus: (params: { cfg?: Record; sessionKey?: string }) => resolveSandboxRuntimeStatusFromConfig(params), diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts index 5aead7bb7bf..70130014d94 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts @@ -2,7 +2,9 @@ import { vi, type Mock } from "vitest"; import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js"; import { resolveRequesterStoreKey } from "./subagent-requester-store-key.js"; -type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; +type SessionsSpawnTestConfig = ReturnType< + (typeof import("../config/config.js"))["getRuntimeConfig"] +>; type SessionsSpawnHookRunner = SubagentLifecycleHookRunner | null; type CaptureSubagentCompletionReply = (typeof import("./subagent-announce.js"))["captureSubagentCompletionReply"]; @@ -197,7 +199,7 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { cachedSubagentSpawnTesting.setDepsForTest({ callGateway: (optsUnknown) => hoisted.callGatewayMock(optsUnknown), getGlobalHookRunner: () => hoisted.state.hookRunnerOverride, - loadConfig: () => hoisted.state.configOverride, + getRuntimeConfig: () => hoisted.state.configOverride, resolveContextEngine: async () => ({ info: { id: "test", name: "Test" }, assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }), @@ -213,7 +215,7 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { }); cachedSubagentRegistryTesting.setDepsForTest({ callGateway: (optsUnknown) => hoisted.callGatewayMock(optsUnknown), - loadConfig: () => hoisted.state.configOverride, + getRuntimeConfig: () => hoisted.state.configOverride, cleanupBrowserSessionsForLifecycleEnd: async () => {}, ensureContextEnginesInitialized: () => {}, ensureRuntimePluginsLoaded: () => {}, @@ -344,7 +346,7 @@ vi.mock("../../gateway/call.js", () => ({ })); vi.mock("../config/config.js", () => ({ - loadConfig: () => hoisted.state.configOverride, + getRuntimeConfig: () => hoisted.state.configOverride, resolveGatewayPort: () => 18789, })); @@ -376,6 +378,6 @@ vi.mock("../tasks/detached-task-runtime.js", () => ({ // Same module, different specifier (used by tools under src/agents/tools/*). vi.mock("../../config/config.js", () => ({ - loadConfig: () => hoisted.state.configOverride, + getRuntimeConfig: () => hoisted.state.configOverride, resolveGatewayPort: () => 18789, })); diff --git a/src/agents/openclaw-tools.subagents.test-harness.ts b/src/agents/openclaw-tools.subagents.test-harness.ts index 845b22889a0..355e1f543e1 100644 --- a/src/agents/openclaw-tools.subagents.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.test-harness.ts @@ -5,7 +5,7 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js"; import { __testing as subagentAnnounceTesting } from "./subagent-announce.js"; import { __testing as subagentControlTesting } from "./subagent-control.js"; -export type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; +export type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["getRuntimeConfig"]>; export const callGatewayMock: MockFn = vi.fn(); @@ -38,7 +38,7 @@ function applySharedSubagentTestDeps() { }); subagentAnnounceTesting.setDepsForTest({ callGateway: callGatewayForTest, - loadConfig: () => configOverride, + getRuntimeConfig: () => configOverride, }); queueCleanupTesting.setDepsForTests({ resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, @@ -55,7 +55,7 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, - loadConfig: () => configOverride, + getRuntimeConfig: () => configOverride, resolveGatewayPort: () => 18789, }; }); diff --git a/src/agents/sandbox/context.ts b/src/agents/sandbox/context.ts index 43a9003e3af..970a0b0fe26 100644 --- a/src/agents/sandbox/context.ts +++ b/src/agents/sandbox/context.ts @@ -184,7 +184,8 @@ export async function resolveSandboxContext(params: { ? await (async () => { // Sandbox browser bridge server runs on a loopback TCP port; always wire up // the same auth that loopback browser clients will send (token/password). - const cfgForAuth = params.config ?? (await import("../../config/config.js")).loadConfig(); + const cfgForAuth = + params.config ?? (await import("../../config/config.js")).getRuntimeConfig(); let browserAuth = resolveBrowserControlAuth(cfgForAuth); try { const ensured = await ensureBrowserControlAuth({ cfg: cfgForAuth }); diff --git a/src/agents/sandbox/manage.test.ts b/src/agents/sandbox/manage.test.ts index b1617661d60..989297cb623 100644 --- a/src/agents/sandbox/manage.test.ts +++ b/src/agents/sandbox/manage.test.ts @@ -4,7 +4,7 @@ let listSandboxBrowsers: typeof import("./manage.js").listSandboxBrowsers; let removeSandboxBrowserContainer: typeof import("./manage.js").removeSandboxBrowserContainer; const configMocks = vi.hoisted(() => ({ - loadConfig: vi.fn(), + getRuntimeConfig: vi.fn(), })); const registryMocks = vi.hoisted(() => ({ @@ -20,7 +20,7 @@ const backendMocks = vi.hoisted(() => ({ })); vi.mock("../../config/config.js", () => ({ - loadConfig: configMocks.loadConfig, + getRuntimeConfig: configMocks.getRuntimeConfig, })); vi.mock("../../plugin-sdk/browser-bridge.js", () => ({ @@ -52,7 +52,7 @@ beforeAll(async () => { describe("listSandboxBrowsers", () => { beforeEach(async () => { - configMocks.loadConfig.mockReset(); + configMocks.getRuntimeConfig.mockReset(); registryMocks.readBrowserRegistry.mockReset(); registryMocks.readRegistry.mockReset(); registryMocks.removeBrowserRegistryEntry.mockReset(); @@ -60,7 +60,7 @@ describe("listSandboxBrowsers", () => { backendMocks.describeRuntime.mockReset(); backendMocks.removeRuntime.mockReset(); - configMocks.loadConfig.mockReturnValue({ + configMocks.getRuntimeConfig.mockReturnValue({ agents: { defaults: { sandbox: { diff --git a/src/agents/sandbox/manage.ts b/src/agents/sandbox/manage.ts index ecca5131914..7fbc85a7739 100644 --- a/src/agents/sandbox/manage.ts +++ b/src/agents/sandbox/manage.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { stopBrowserBridgeServer } from "../../plugin-sdk/browser-bridge.js"; import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; @@ -33,7 +33,7 @@ function toBrowserDockerRuntimeEntry(entry: SandboxBrowserRegistryEntry): Sandbo } export async function listSandboxContainers(): Promise { - const config = loadConfig(); + const config = getRuntimeConfig(); const registry = await readRegistry(); const results: SandboxContainerInfo[] = []; @@ -66,7 +66,7 @@ export async function listSandboxContainers(): Promise { } export async function listSandboxBrowsers(): Promise { - const config = loadConfig(); + const config = getRuntimeConfig(); const registry = await readBrowserRegistry(); const results: SandboxBrowserInfo[] = []; @@ -89,7 +89,7 @@ export async function listSandboxBrowsers(): Promise { } export async function removeSandboxContainer(containerName: string): Promise { - const config = loadConfig(); + const config = getRuntimeConfig(); const registry = await readRegistry(); const entry = registry.entries.find((item) => item.containerName === containerName); if (entry) { @@ -104,7 +104,7 @@ export async function removeSandboxContainer(containerName: string): Promise { - const config = loadConfig(); + const config = getRuntimeConfig(); const registry = await readBrowserRegistry(); const entry = registry.entries.find((item) => item.containerName === containerName); if (entry) { diff --git a/src/agents/sandbox/prune.ts b/src/agents/sandbox/prune.ts index 0dcdb090e79..b9c00f68ddc 100644 --- a/src/agents/sandbox/prune.ts +++ b/src/agents/sandbox/prune.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { stopBrowserBridgeServer } from "../../plugin-sdk/browser-bridge.js"; import { defaultRuntime } from "../../runtime.js"; import { getSandboxBackendManager } from "./backend.js"; @@ -63,7 +63,7 @@ async function pruneSandboxRegistryEntries( } async function pruneSandboxContainers(cfg: SandboxConfig) { - const config = loadConfig(); + const config = getRuntimeConfig(); await pruneSandboxRegistryEntries({ cfg, read: readRegistry, @@ -79,7 +79,7 @@ async function pruneSandboxContainers(cfg: SandboxConfig) { } async function pruneSandboxBrowsers(cfg: SandboxConfig) { - const config = loadConfig(); + const config = getRuntimeConfig(); await pruneSandboxRegistryEntries< SandboxBrowserRegistryEntry & { backendId?: string; diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts index 297c4b0b909..a66ec1f3300 100644 --- a/src/agents/sessions-spawn-hooks.test.ts +++ b/src/agents/sessions-spawn-hooks.test.ts @@ -139,7 +139,7 @@ function expectThreadBindFailureCleanup( beforeAll(async () => { ({ resetSubagentRegistryForTests, spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({ callGatewayMock: hoisted.callGatewayMock, - loadConfig: () => hoisted.configOverride, + getRuntimeConfig: () => hoisted.configOverride, updateSessionStoreMock: hoisted.updateSessionStoreMock, hookRunner: { hasHooks: (hookName: string) => diff --git a/src/agents/subagent-announce-delivery.runtime.ts b/src/agents/subagent-announce-delivery.runtime.ts index 36848420600..11b610638cf 100644 --- a/src/agents/subagent-announce-delivery.runtime.ts +++ b/src/agents/subagent-announce-delivery.runtime.ts @@ -1,4 +1,4 @@ -export { loadConfig } from "../config/config.js"; +export { getRuntimeConfig } from "../config/config.js"; export { loadSessionStore, resolveAgentIdFromSessionKey, diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index f5519e639d6..a595b4b4f02 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -54,7 +54,7 @@ async function deliverSlackThreadAnnouncement(params: { sessionId: params.sessionId, isActive: params.isActive, }), - loadConfig: () => ({}) as never, + getRuntimeConfig: () => ({}) as never, ...(params.queueEmbeddedPiMessage ? { queueEmbeddedPiMessage: params.queueEmbeddedPiMessage } : {}), @@ -94,7 +94,7 @@ async function deliverDiscordDirectMessageCompletion(params: { sessionId: "requester-session-dm", isActive: false, }), - loadConfig: () => ({}) as never, + getRuntimeConfig: () => ({}) as never, ...(params.sendMessage ? { sendMessage: params.sendMessage } : {}), }); @@ -133,7 +133,7 @@ async function deliverTelegramDirectMessageCompletion(params: { sessionId: "requester-session-telegram", isActive: params.isActive === true, }), - loadConfig: () => ({}) as never, + getRuntimeConfig: () => ({}) as never, ...(params.queueEmbeddedPiMessage ? { queueEmbeddedPiMessage: params.queueEmbeddedPiMessage } : {}), @@ -185,7 +185,7 @@ async function deliverSlackChannelAnnouncement(params: { sessionId: params.sessionId, isActive: params.isActive, }), - loadConfig: () => ({}) as never, + getRuntimeConfig: () => ({}) as never, ...(params.queueEmbeddedPiMessage ? { queueEmbeddedPiMessage: params.queueEmbeddedPiMessage } : {}), @@ -287,7 +287,7 @@ describe("deliverSubagentAnnouncement queued delivery", () => { sessionId: "paperclip-session", isActive: activityChecks++ === 0, }), - loadConfig: () => + getRuntimeConfig: () => ({ messages: { queue: { diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index d8fe05e331a..4c969f00ce1 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -23,7 +23,7 @@ import { createBoundDeliveryRouter, getGlobalHookRunner, isEmbeddedPiRunActive, - loadConfig, + getRuntimeConfig, loadSessionStore, queueEmbeddedPiMessage, resolveActiveEmbeddedRunSessionId, @@ -51,7 +51,7 @@ const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000; type SubagentAnnounceDeliveryDeps = { callGateway: typeof callGateway; - loadConfig: typeof loadConfig; + getRuntimeConfig: typeof getRuntimeConfig; getRequesterSessionActivity: (requesterSessionKey: string) => { sessionId?: string; isActive: boolean; @@ -62,7 +62,7 @@ type SubagentAnnounceDeliveryDeps = { const defaultSubagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps = { callGateway, - loadConfig, + getRuntimeConfig, getRequesterSessionActivity: (requesterSessionKey: string) => { const sessionId = resolveActiveEmbeddedRunSessionId(requesterSessionKey) ?? @@ -357,7 +357,7 @@ export async function resolveSubagentCompletionOrigin(params: { } async function sendAnnounce(item: AnnounceQueueItem) { - const cfg = subagentAnnounceDeliveryDeps.loadConfig(); + const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig(); const announceTimeoutMs = resolveSubagentAnnounceTimeoutMs(cfg); const requesterIsSubagent = isInternalAnnounceRequesterSession(item.sessionKey); const origin = item.origin; @@ -402,7 +402,7 @@ async function sendAnnounce(item: AnnounceQueueItem) { } export function loadRequesterSessionEntry(requesterSessionKey: string) { - const cfg = subagentAnnounceDeliveryDeps.loadConfig(); + const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig(); const canonicalKey = resolveRequesterStoreKey(cfg, requesterSessionKey); const agentId = resolveAgentIdFromSessionKey(canonicalKey); const storePath = resolveStorePath(cfg.session?.store, { agentId }); @@ -412,7 +412,7 @@ export function loadRequesterSessionEntry(requesterSessionKey: string) { } export function loadSessionEntryByKey(sessionKey: string) { - const cfg = subagentAnnounceDeliveryDeps.loadConfig(); + const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig(); const agentId = resolveAgentIdFromSessionKey(sessionKey); const storePath = resolveStorePath(cfg.session?.store, { agentId }); const store = loadSessionStore(storePath); @@ -646,7 +646,7 @@ async function sendSubagentAnnounceDirectly(params: { path: "none", }; } - const cfg = subagentAnnounceDeliveryDeps.loadConfig(); + const cfg = subagentAnnounceDeliveryDeps.getRuntimeConfig(); const announceTimeoutMs = resolveSubagentAnnounceTimeoutMs(cfg); const canonicalRequesterSessionKey = resolveRequesterStoreKey( cfg, diff --git a/src/agents/subagent-announce-output.ts b/src/agents/subagent-announce-output.ts index 880aac5788f..9c0ae5a41fd 100644 --- a/src/agents/subagent-announce-output.ts +++ b/src/agents/subagent-announce-output.ts @@ -6,7 +6,7 @@ import { } from "./subagent-announce-capture.js"; import { callGateway, - loadConfig, + getRuntimeConfig, loadSessionStore, resolveAgentIdFromSessionKey, resolveStorePath, @@ -19,13 +19,13 @@ const FAST_TEST_RETRY_INTERVAL_MS = 8; type SubagentAnnounceOutputDeps = { callGateway: typeof callGateway; - loadConfig: typeof loadConfig; + getRuntimeConfig: typeof getRuntimeConfig; readLatestAssistantReply: typeof readLatestAssistantReply; }; const defaultSubagentAnnounceOutputDeps: SubagentAnnounceOutputDeps = { callGateway, - loadConfig, + getRuntimeConfig, readLatestAssistantReply, }; @@ -523,7 +523,7 @@ export async function buildCompactAnnounceStatsLine(params: { startedAt?: number; endedAt?: number; }) { - const cfg = subagentAnnounceOutputDeps.loadConfig(); + const cfg = subagentAnnounceOutputDeps.getRuntimeConfig(); const agentId = resolveAgentIdFromSessionKey(params.sessionKey); const storePath = resolveStorePath(cfg.session?.store, { agentId }); let entry = loadSessionStore(storePath)[params.sessionKey]; diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 616745fa0d3..08c4f50b389 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -275,10 +275,12 @@ describe("subagent announce formatting", () => { }); afterEach(() => { + vi.useRealTimers(); resetAnnounceQueuesForTests(); }); beforeEach(() => { + vi.useRealTimers(); resetAnnounceQueuesForTests(); // OPENCLAW_TEST_FAST is set in beforeAll before module import // to ensure the module-level constant picks it up. @@ -316,7 +318,7 @@ describe("subagent announce formatting", () => { callGateway: async >( req: Parameters[0], ) => (await callGatewaySpy(req)) as T, - loadConfig: () => configOverride, + getRuntimeConfig: () => configOverride, getRequesterSessionActivity: (requesterSessionKey: string) => { const entry = loadSessionStoreFixture()[requesterSessionKey]; const sessionId = entry?.sessionId; @@ -332,7 +334,7 @@ describe("subagent announce formatting", () => { callGateway: async >( req: Parameters[0], ) => (await callGatewaySpy(req)) as T, - loadConfig: () => configOverride, + getRuntimeConfig: () => configOverride, }); loadSessionStoreSpy.mockReset().mockImplementation(() => loadSessionStoreFixture()); resolveAgentIdFromSessionKeySpy.mockReset().mockImplementation(() => "main"); diff --git a/src/agents/subagent-announce.runtime.ts b/src/agents/subagent-announce.runtime.ts index 250147c40bd..bd9eadb1108 100644 --- a/src/agents/subagent-announce.runtime.ts +++ b/src/agents/subagent-announce.runtime.ts @@ -1,4 +1,4 @@ -export { loadConfig } from "../config/config.js"; +export { getRuntimeConfig } from "../config/config.js"; export { loadSessionStore, resolveAgentIdFromSessionKey, diff --git a/src/agents/subagent-announce.test-support.ts b/src/agents/subagent-announce.test-support.ts index a7163f177e2..e6c6686d97f 100644 --- a/src/agents/subagent-announce.test-support.ts +++ b/src/agents/subagent-announce.test-support.ts @@ -3,7 +3,7 @@ import type { callGateway } from "../gateway/call.js"; type DeliveryRuntimeMockOptions = { callGateway: (request: unknown) => Promise; - loadConfig: () => OpenClawConfig; + getRuntimeConfig: () => OpenClawConfig; loadSessionStore: (storePath: string) => unknown; resolveAgentIdFromSessionKey: (sessionKey: string) => string; resolveMainSessionKey: (cfg: unknown) => string; @@ -47,7 +47,7 @@ export function createSubagentAnnounceDeliveryRuntimeMock(options: DeliveryRunti return { callGateway: (async >(request: Parameters[0]) => (await options.callGateway(request)) as T) as typeof callGateway, - loadConfig: options.loadConfig, + getRuntimeConfig: options.getRuntimeConfig, loadSessionStore: options.loadSessionStore, resolveAgentIdFromSessionKey: options.resolveAgentIdFromSessionKey, resolveMainSessionKey: options.resolveMainSessionKey, diff --git a/src/agents/subagent-announce.test.ts b/src/agents/subagent-announce.test.ts index 838cb1eb859..f4413756482 100644 --- a/src/agents/subagent-announce.test.ts +++ b/src/agents/subagent-announce.test.ts @@ -16,7 +16,7 @@ const readLatestAssistantReplyMock = vi.fn(async (_params?: unknown) => "raw sub const isEmbeddedPiRunActiveMock = vi.fn((_sessionId: string) => false); const queueEmbeddedPiMessageMock = vi.fn((_sessionId: string, _text: string) => false); const waitForEmbeddedPiRunEndMock = vi.fn(async (_sessionId: string, _timeoutMs?: number) => true); -let mockConfig: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = { +let mockConfig: ReturnType<(typeof import("../config/config.js"))["getRuntimeConfig"]> = { session: { mainKey: "main", scope: "per-sender", @@ -39,7 +39,7 @@ const { subagentRegistryRuntimeMock } = vi.hoisted(() => ({ vi.mock("./subagent-announce.runtime.js", () => ({ callGateway: (request: unknown) => callGatewayMock(request), isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId), - loadConfig: () => mockConfig, + getRuntimeConfig: () => mockConfig, loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath), queueEmbeddedPiMessage: (sessionId: string, text: string) => queueEmbeddedPiMessageMock(sessionId, text), @@ -58,7 +58,7 @@ vi.mock("./tools/agent-step.js", () => ({ vi.mock("./subagent-announce-delivery.runtime.js", () => createSubagentAnnounceDeliveryRuntimeMock({ callGateway: (request: unknown) => callGatewayMock(request), - loadConfig: () => mockConfig, + getRuntimeConfig: () => mockConfig, loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath), resolveAgentIdFromSessionKey: (sessionKey: string) => resolveAgentIdFromSessionKeyMock(sessionKey), diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts index fef2b46b9f6..633c28b7c3b 100644 --- a/src/agents/subagent-announce.timeout.test.ts +++ b/src/agents/subagent-announce.timeout.test.ts @@ -16,7 +16,7 @@ let callGatewayImpl: (request: GatewayCall) => Promise = async (request return {}; }; let sessionStore: Record> = {}; -let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = { +let configOverride: ReturnType<(typeof import("../config/config.js"))["getRuntimeConfig"]> = { session: { mainKey: "main", scope: "per-sender", @@ -82,7 +82,7 @@ vi.mock("./subagent-announce-delivery.runtime.js", () => } return await callGatewayImpl(typed); }, - loadConfig: () => configOverride, + getRuntimeConfig: () => configOverride, loadSessionStore: () => sessionStore, resolveAgentIdFromSessionKey: () => "main", resolveMainSessionKey: () => "agent:main:main", @@ -170,7 +170,7 @@ vi.mock("./subagent-announce-delivery.js", () => ({ })); vi.mock("./subagent-announce.runtime.js", () => ({ callGateway: createGatewayCallModuleMock().callGateway, - loadConfig: () => configOverride, + getRuntimeConfig: () => configOverride, loadSessionStore: vi.fn(() => sessionStore), resolveAgentIdFromSessionKey: () => "main", resolveStorePath: () => "/tmp/sessions-main.json", diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 4b0c5b82cbd..1345ecffccd 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -39,7 +39,7 @@ import { import { callGateway, isEmbeddedPiRunActive, - loadConfig, + getRuntimeConfig, waitForEmbeddedPiRunEnd, } from "./subagent-announce.runtime.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; @@ -48,13 +48,13 @@ import { isAnnounceSkip } from "./tools/sessions-send-tokens.js"; type SubagentAnnounceDeps = { callGateway: typeof callGateway; - loadConfig: typeof loadConfig; + getRuntimeConfig: typeof getRuntimeConfig; loadSubagentRegistryRuntime: typeof loadSubagentRegistryRuntime; }; const defaultSubagentAnnounceDeps: SubagentAnnounceDeps = { callGateway, - loadConfig, + getRuntimeConfig, loadSubagentRegistryRuntime, }; @@ -172,7 +172,7 @@ async function wakeSubagentRunAfterDescendants(params: { return false; } - const cfg = subagentAnnounceDeps.loadConfig(); + const cfg = subagentAnnounceDeps.getRuntimeConfig(); const announceTimeoutMs = resolveSubagentAnnounceTimeoutMs(cfg); const wakeMessage = buildDescendantWakeMessage({ findings: params.findings, diff --git a/src/agents/subagent-orphan-recovery.test.ts b/src/agents/subagent-orphan-recovery.test.ts index 664d84cbb3f..5d9bb7179c6 100644 --- a/src/agents/subagent-orphan-recovery.test.ts +++ b/src/agents/subagent-orphan-recovery.test.ts @@ -12,7 +12,7 @@ import type { SubagentRunRecord } from "./subagent-registry.types.js"; // Mock dependencies before importing the module under test vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn(() => ({ + getRuntimeConfig: vi.fn(() => ({ session: { store: undefined }, })), })); diff --git a/src/agents/subagent-orphan-recovery.ts b/src/agents/subagent-orphan-recovery.ts index aab66f6e30f..5db92776ed4 100644 --- a/src/agents/subagent-orphan-recovery.ts +++ b/src/agents/subagent-orphan-recovery.ts @@ -10,7 +10,7 @@ */ import crypto from "node:crypto"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { loadSessionStore, resolveAgentIdFromSessionKey, @@ -258,7 +258,7 @@ export async function recoverOrphanedSubagentSessions(params: { return result; } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const storeCache = new Map>(); for (const [runId, runRecord] of activeRuns.entries()) { diff --git a/src/agents/subagent-registry-helpers.ts b/src/agents/subagent-registry-helpers.ts index 7dc643be756..140fa73fc0f 100644 --- a/src/agents/subagent-registry-helpers.ts +++ b/src/agents/subagent-registry-helpers.ts @@ -1,6 +1,6 @@ import { promises as fs } from "node:fs"; import path from "node:path"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { loadSessionStore, resolveAgentIdFromSessionKey, @@ -97,7 +97,7 @@ export async function persistSubagentSessionTiming(entry: SubagentRunRecord) { return; } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const agentId = resolveAgentIdFromSessionKey(childSessionKey); const storePath = resolveStorePath(cfg.session?.store, { agentId }); const startedAt = getSubagentSessionStartedAt(entry); @@ -152,7 +152,7 @@ export function resolveSubagentRunOrphanReason(params: { return "missing-session-entry"; } try { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const agentId = resolveAgentIdFromSessionKey(childSessionKey); const storePath = resolveStorePath(cfg.session?.store, { agentId }); let store = params.storeCache?.get(storePath); @@ -308,7 +308,7 @@ export function reconcileOrphanedRestoredRuns(params: { } export function resolveArchiveAfterMs(cfg?: OpenClawConfig) { - const config = cfg ?? loadConfig(); + const config = cfg ?? getRuntimeConfig(); const minutes = config.agents?.defaults?.subagents?.archiveAfterMinutes ?? 60; if (!Number.isFinite(minutes) || minutes < 0) { return undefined; diff --git a/src/agents/subagent-registry-run-manager.ts b/src/agents/subagent-registry-run-manager.ts index 25fb267f689..c6d1bbaf687 100644 --- a/src/agents/subagent-registry-run-manager.ts +++ b/src/agents/subagent-registry-run-manager.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway } from "../gateway/call.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -62,7 +62,7 @@ export function createSubagentRunManager(params: { endedHookInFlightRunIds: Set; persist(): void; callGateway: typeof callGateway; - loadConfig: typeof loadConfig; + getRuntimeConfig: typeof getRuntimeConfig; ensureRuntimePluginsLoaded: | typeof ensureRuntimePluginsLoadedFn | ((args: { @@ -254,7 +254,7 @@ export function createSubagentRunManager(params: { } const now = Date.now(); - const cfg = params.loadConfig(); + const cfg = params.getRuntimeConfig(); const archiveAfterMs = resolveArchiveAfterMs(cfg); const spawnMode = source.spawnMode === "session" ? "session" : "run"; const archiveAtMs = @@ -320,7 +320,7 @@ export function createSubagentRunManager(params: { return; } const now = Date.now(); - const cfg = params.loadConfig(); + const cfg = params.getRuntimeConfig(); const archiveAfterMs = resolveArchiveAfterMs(cfg); const spawnMode = registerParams.spawnMode === "session" ? "session" : "run"; const archiveAtMs = @@ -499,7 +499,7 @@ export function createSubagentRunManager(params: { }); continue; } - const cfg = params.loadConfig(); + const cfg = params.getRuntimeConfig(); void Promise.resolve( params.ensureRuntimePluginsLoaded({ config: cfg, diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index 4a5c39faf1b..dcc60728d5c 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vi */ const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({ + getRuntimeConfig: vi.fn(() => ({ session: { store: "/tmp/test-store", mainKey: "main" }, agents: {}, })), @@ -27,7 +27,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../config/config.js", () => ({ - loadConfig: mocks.loadConfig, + getRuntimeConfig: mocks.getRuntimeConfig, })); vi.mock("../config/sessions.js", () => ({ @@ -82,7 +82,7 @@ describe("announce loop guard (#18264)", () => { vi.useFakeTimers(); mocks.callGateway.mockClear(); mocks.captureSubagentCompletionReply.mockClear(); - mocks.loadConfig.mockClear(); + mocks.getRuntimeConfig.mockClear(); mocks.loadSubagentRegistryFromDisk.mockReset(); mocks.loadSubagentRegistryFromDisk.mockReturnValue(new Map()); mocks.onAgentEventStop.mockClear(); diff --git a/src/agents/subagent-registry.archive.e2e.test.ts b/src/agents/subagent-registry.archive.e2e.test.ts index 3c6f405bfac..b7dbfe9adb4 100644 --- a/src/agents/subagent-registry.archive.e2e.test.ts +++ b/src/agents/subagent-registry.archive.e2e.test.ts @@ -34,7 +34,7 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, - loadConfig: loadConfigMock, + getRuntimeConfig: loadConfigMock, }; }); diff --git a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts index 9b6c129d4c5..a3a24e233dd 100644 --- a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts +++ b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts @@ -159,13 +159,13 @@ describe("subagent registry lifecycle error grace", () => { ); mod.__testing.setDepsForTest({ callGateway: callGatewayMock as typeof import("../gateway/call.js").callGateway, - loadConfig: loadConfigMock as typeof import("../config/config.js").loadConfig, + getRuntimeConfig: loadConfigMock as typeof import("../config/config.js").getRuntimeConfig, onAgentEvent: onAgentEventMock as unknown as typeof import("../infra/agent-events.js").onAgentEvent, }); subagentAnnounceTesting.setDepsForTest({ callGateway: callGatewayMock as typeof import("../gateway/call.js").callGateway, - loadConfig: loadConfigMock as typeof import("../config/config.js").loadConfig, + getRuntimeConfig: loadConfigMock as typeof import("../config/config.js").getRuntimeConfig, loadSubagentRegistryRuntime: async () => ({ countActiveDescendantRuns: mod.countActiveDescendantRuns, countPendingDescendantRuns: mod.countPendingDescendantRuns, @@ -181,7 +181,7 @@ describe("subagent registry lifecycle error grace", () => { }); subagentAnnounceDeliveryTesting.setDepsForTest({ callGateway: callGatewayMock as typeof import("../gateway/call.js").callGateway, - loadConfig: loadConfigMock as typeof import("../config/config.js").loadConfig, + getRuntimeConfig: loadConfigMock as typeof import("../config/config.js").getRuntimeConfig, getRequesterSessionActivity: (requesterSessionKey: string) => { const entry = sessionStore[requesterSessionKey]; return { @@ -192,7 +192,7 @@ describe("subagent registry lifecycle error grace", () => { }); subagentAnnounceOutputTesting.setDepsForTest({ callGateway: callGatewayMock as typeof import("../gateway/call.js").callGateway, - loadConfig: loadConfigMock as typeof import("../config/config.js").loadConfig, + getRuntimeConfig: loadConfigMock as typeof import("../config/config.js").getRuntimeConfig, }); }); diff --git a/src/agents/subagent-registry.nested.e2e.test.ts b/src/agents/subagent-registry.nested.e2e.test.ts index 68b0b9c983d..c216bf9fcee 100644 --- a/src/agents/subagent-registry.nested.e2e.test.ts +++ b/src/agents/subagent-registry.nested.e2e.test.ts @@ -5,7 +5,7 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, - loadConfig: vi.fn(() => ({ + getRuntimeConfig: vi.fn(() => ({ agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, })), }; diff --git a/src/agents/subagent-registry.persistence.test-support.ts b/src/agents/subagent-registry.persistence.test-support.ts index ec66b1125e1..075507f1a49 100644 --- a/src/agents/subagent-registry.persistence.test-support.ts +++ b/src/agents/subagent-registry.persistence.test-support.ts @@ -65,7 +65,7 @@ export function createSubagentRegistryTestDeps( cleanupBrowserSessionsForLifecycleEnd: vi.fn(async () => {}), ensureContextEnginesInitialized: vi.fn(), ensureRuntimePluginsLoaded: vi.fn(), - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), resolveAgentTimeoutMs: vi.fn(() => 100), resolveContextEngine: vi.fn(async () => ({ info: { id: "test", name: "Test", version: "0.0.1" }, diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index b00acc208a4..4a03517fcb9 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -34,7 +34,7 @@ vi.mock("../infra/agent-events.js", () => ({ })); vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn(() => ({ + getRuntimeConfig: vi.fn(() => ({ agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, })), })); diff --git a/src/agents/subagent-registry.test.ts b/src/agents/subagent-registry.test.ts index 4f83a2458ff..7b5b6c2c70c 100644 --- a/src/agents/subagent-registry.test.ts +++ b/src/agents/subagent-registry.test.ts @@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({ callGateway: vi.fn(), onAgentEvent: vi.fn(() => noop), getAgentRunContext: vi.fn(() => undefined), - loadConfig: vi.fn(() => ({ + getRuntimeConfig: vi.fn(() => ({ agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, session: { mainKey: "main", scope: "per-sender" as const }, })), @@ -51,7 +51,7 @@ vi.mock("../infra/agent-events.js", () => ({ vi.mock("../config/config.js", () => { return { - loadConfig: mocks.loadConfig, + getRuntimeConfig: mocks.getRuntimeConfig, }; }); @@ -118,7 +118,7 @@ describe("subagent registry seam flow", () => { vi.setSystemTime(new Date("2026-03-24T12:00:00Z")); mocks.onAgentEvent.mockReturnValue(noop); mocks.getAgentRunContext.mockReturnValue(undefined); - mocks.loadConfig.mockReturnValue({ + mocks.getRuntimeConfig.mockReturnValue({ agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, session: { mainKey: "main", scope: "per-sender" as const }, }); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index cbd5044a288..8a55fc7c6f9 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -1,5 +1,5 @@ import type { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-cleanup.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { loadSessionStore, resolveAgentIdFromSessionKey, @@ -88,7 +88,7 @@ type SubagentRegistryDeps = { captureSubagentCompletionReply: SubagentAnnounceModule["captureSubagentCompletionReply"]; cleanupBrowserSessionsForLifecycleEnd: typeof cleanupBrowserSessionsForLifecycleEnd; getSubagentRunsSnapshotForRead: typeof getSubagentRunsSnapshotForRead; - loadConfig: typeof loadConfig; + getRuntimeConfig: typeof getRuntimeConfig; onAgentEvent: typeof onAgentEvent; persistSubagentRunsToDisk: typeof persistSubagentRunsToDisk; resolveAgentTimeoutMs: typeof resolveAgentTimeoutMs; @@ -121,7 +121,7 @@ const defaultSubagentRegistryDeps: SubagentRegistryDeps = { cleanupBrowserSessionsForLifecycleEnd: async (params) => (await loadCleanupBrowserSessionsForLifecycleEnd())(params), getSubagentRunsSnapshotForRead, - loadConfig, + getRuntimeConfig, onAgentEvent, persistSubagentRunsToDisk, resolveAgentTimeoutMs, @@ -209,7 +209,7 @@ function loadSubagentSessionEntry( return undefined; } const agentId = resolveAgentIdFromSessionKey(key); - const storePath = resolveStorePath(loadConfig().session?.store, { agentId }); + const storePath = resolveStorePath(getRuntimeConfig().session?.store, { agentId }); let store = storeCache.get(storePath); if (!store) { store = loadSessionStore(storePath); @@ -471,7 +471,7 @@ async function notifyContextEngineSubagentEnded(params: { workspaceDir?: string; }) { try { - const cfg = subagentRegistryDeps.loadConfig(); + const cfg = subagentRegistryDeps.getRuntimeConfig(); await ensureSubagentRegistryPluginRuntimeLoaded({ config: cfg, workspaceDir: params.workspaceDir, @@ -517,7 +517,7 @@ async function emitSubagentEndedHookForRun(params: { if (params.entry.endedHookEmittedAt) { return; } - const cfg = subagentRegistryDeps.loadConfig(); + const cfg = subagentRegistryDeps.getRuntimeConfig(); await ensureSubagentRegistryPluginRuntimeLoaded({ config: cfg, workspaceDir: params.entry.workspaceDir, @@ -653,7 +653,7 @@ function resumeSubagentRun(runId: string) { } // Wait for completion again after restart. - const cfg = subagentRegistryDeps.loadConfig(); + const cfg = subagentRegistryDeps.getRuntimeConfig(); const waitTimeoutMs = resolveSubagentWaitTimeoutMs(cfg, entry.runTimeoutSeconds); void subagentRunManager.waitForSubagentCompletion(runId, waitTimeoutMs, entry); resumedRuns.add(runId); @@ -945,7 +945,7 @@ const subagentRunManager = createSubagentRunManager({ endedHookInFlightRunIds, persist: persistSubagentRuns, callGateway: (request) => subagentRegistryDeps.callGateway(request), - loadConfig: () => subagentRegistryDeps.loadConfig(), + getRuntimeConfig: () => subagentRegistryDeps.getRuntimeConfig(), ensureRuntimePluginsLoaded: (args: { config: OpenClawConfig; workspaceDir?: string; diff --git a/src/agents/subagent-spawn.attachments.test.ts b/src/agents/subagent-spawn.attachments.test.ts index c44003add46..5fa8285cd62 100644 --- a/src/agents/subagent-spawn.attachments.test.ts +++ b/src/agents/subagent-spawn.attachments.test.ts @@ -20,7 +20,7 @@ let subagentSpawnModule: Awaited { subagentSpawnModule = await loadSubagentSpawnModuleForTest({ callGatewayMock, - loadConfig: () => configOverride, + getRuntimeConfig: () => configOverride, updateSessionStoreMock, workspaceDir: workspaceDirOverride || os.tmpdir(), }); diff --git a/src/agents/subagent-spawn.depth-limits.test.ts b/src/agents/subagent-spawn.depth-limits.test.ts index fd18b3c6c05..88fb51bfb5a 100644 --- a/src/agents/subagent-spawn.depth-limits.test.ts +++ b/src/agents/subagent-spawn.depth-limits.test.ts @@ -49,7 +49,7 @@ describe("subagent spawn depth + child limits", () => { beforeAll(async () => { ({ spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({ callGatewayMock: hoisted.callGatewayMock, - loadConfig: () => hoisted.configOverride, + getRuntimeConfig: () => hoisted.configOverride, registerSubagentRunMock: hoisted.registerSubagentRunMock, updateSessionStoreMock: hoisted.updateSessionStoreMock, getSubagentDepthFromSessionStore: (sessionKey) => hoisted.depthBySession.get(sessionKey) ?? 0, diff --git a/src/agents/subagent-spawn.mode-session-diagnostics.test.ts b/src/agents/subagent-spawn.mode-session-diagnostics.test.ts index 3cae57392b5..3e64d2f9cd6 100644 --- a/src/agents/subagent-spawn.mode-session-diagnostics.test.ts +++ b/src/agents/subagent-spawn.mode-session-diagnostics.test.ts @@ -17,7 +17,7 @@ describe('spawnSubagentDirect mode="session" diagnostics (#67400)', () => { callGatewayMock.mockReset(); ({ spawnSubagentDirect, resetSubagentRegistryForTests } = await loadSubagentSpawnModuleForTest({ callGatewayMock, - loadConfig: () => createSubagentSpawnTestConfig(os.tmpdir()), + getRuntimeConfig: () => createSubagentSpawnTestConfig(os.tmpdir()), workspaceDir: os.tmpdir(), })); resetSubagentRegistryForTests(); @@ -74,7 +74,7 @@ describe('spawnSubagentDirect mode="session" with registered thread hooks (#6740 callGatewayMock.mockReset(); ({ spawnSubagentDirect, resetSubagentRegistryForTests } = await loadSubagentSpawnModuleForTest({ callGatewayMock, - loadConfig: () => createSubagentSpawnTestConfig(os.tmpdir()), + getRuntimeConfig: () => createSubagentSpawnTestConfig(os.tmpdir()), workspaceDir: os.tmpdir(), hookRunner: { hasHooks: () => true, diff --git a/src/agents/subagent-spawn.model-session.test.ts b/src/agents/subagent-spawn.model-session.test.ts index b977457fb58..f9b041959df 100644 --- a/src/agents/subagent-spawn.model-session.test.ts +++ b/src/agents/subagent-spawn.model-session.test.ts @@ -19,7 +19,7 @@ describe("spawnSubagentDirect runtime model persistence", () => { beforeAll(async () => { ({ resetSubagentRegistryForTests, spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({ callGatewayMock, - loadConfig: () => createSubagentSpawnTestConfig(os.tmpdir()), + getRuntimeConfig: () => createSubagentSpawnTestConfig(os.tmpdir()), updateSessionStoreMock, pruneLegacyStoreKeysMock, workspaceDir: os.tmpdir(), diff --git a/src/agents/subagent-spawn.runtime.ts b/src/agents/subagent-spawn.runtime.ts index 15e9fa48c65..91b0f89ce33 100644 --- a/src/agents/subagent-spawn.runtime.ts +++ b/src/agents/subagent-spawn.runtime.ts @@ -3,7 +3,7 @@ export { DEFAULT_SUBAGENT_MAX_CHILDREN_PER_AGENT, DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH, } from "../config/agent-limits.js"; -export { loadConfig } from "../config/config.js"; +export { getRuntimeConfig } from "../config/config.js"; export { mergeSessionEntry, updateSessionStore } from "../config/sessions.js"; export { forkSessionFromParent, diff --git a/src/agents/subagent-spawn.test-helpers.ts b/src/agents/subagent-spawn.test-helpers.ts index a10b1325b10..49f45908eb6 100644 --- a/src/agents/subagent-spawn.test-helpers.ts +++ b/src/agents/subagent-spawn.test-helpers.ts @@ -112,7 +112,7 @@ export function expectPersistedRuntimeModel(params: { export async function loadSubagentSpawnModuleForTest(params: { callGatewayMock: MockFn; - loadConfig?: () => Record; + getRuntimeConfig?: () => Record; updateSessionStoreMock?: MockFn; forkSessionFromParentMock?: MockFn; resolveContextEngineMock?: MockFn; @@ -171,8 +171,9 @@ export async function loadSubagentSpawnModuleForTest(params: { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH: 3, ADMIN_SCOPE: "operator.admin", AGENT_LANE_SUBAGENT: "subagent", - loadConfig: () => - params.loadConfig?.() ?? createSubagentSpawnTestConfig(params.workspaceDir ?? os.tmpdir()), + getRuntimeConfig: () => + params.getRuntimeConfig?.() ?? + createSubagentSpawnTestConfig(params.workspaceDir ?? os.tmpdir()), resolveContextEngine: params.resolveContextEngineMock ?? (async () => ({})), resolveParentForkMaxTokens: params.resolveParentForkMaxTokensMock ?? (() => 100_000), mergeSessionEntry: ( diff --git a/src/agents/subagent-spawn.test.ts b/src/agents/subagent-spawn.test.ts index 9ebb4d6006c..169d232e693 100644 --- a/src/agents/subagent-spawn.test.ts +++ b/src/agents/subagent-spawn.test.ts @@ -41,7 +41,7 @@ describe("spawnSubagentDirect seam flow", () => { beforeAll(async () => { ({ resetSubagentRegistryForTests, spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({ callGatewayMock: hoisted.callGatewayMock, - loadConfig: () => hoisted.configOverride, + getRuntimeConfig: () => hoisted.configOverride, updateSessionStoreMock: hoisted.updateSessionStoreMock, pruneLegacyStoreKeysMock: hoisted.pruneLegacyStoreKeysMock, registerSubagentRunMock: hoisted.registerSubagentRunMock, diff --git a/src/agents/subagent-spawn.thread-binding.test.ts b/src/agents/subagent-spawn.thread-binding.test.ts index 6bd96229554..84efe9ef0e1 100644 --- a/src/agents/subagent-spawn.thread-binding.test.ts +++ b/src/agents/subagent-spawn.thread-binding.test.ts @@ -35,7 +35,7 @@ describe("spawnSubagentDirect thread binding delivery", () => { beforeAll(async () => { ({ spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({ callGatewayMock: hoisted.callGatewayMock, - loadConfig: () => currentConfig, + getRuntimeConfig: () => currentConfig, updateSessionStoreMock: hoisted.updateSessionStoreMock, registerSubagentRunMock: hoisted.registerSubagentRunMock, emitSessionLifecycleEventMock: hoisted.emitSessionLifecycleEventMock, diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 6e3e69ce782..060ae817dd8 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -49,7 +49,7 @@ import { emitSessionLifecycleEvent, forkSessionFromParent, getGlobalHookRunner, - loadConfig, + getRuntimeConfig, mergeSessionEntry, mergeDeliveryContext, normalizeDeliveryContext, @@ -91,7 +91,7 @@ type SubagentSpawnDeps = { callGateway: typeof callGateway; forkSessionFromParent: typeof forkSessionFromParent; getGlobalHookRunner: () => SubagentLifecycleHookRunner | null; - loadConfig: typeof loadConfig; + getRuntimeConfig: typeof getRuntimeConfig; resolveContextEngine: typeof resolveContextEngine; resolveParentForkMaxTokens: typeof resolveParentForkMaxTokens; updateSessionStore: typeof updateSessionStore; @@ -101,7 +101,7 @@ const defaultSubagentSpawnDeps: SubagentSpawnDeps = { callGateway, forkSessionFromParent, getGlobalHookRunner, - loadConfig, + getRuntimeConfig, resolveContextEngine, resolveParentForkMaxTokens, updateSessionStore, @@ -251,7 +251,7 @@ function buildDirectChildSessionPatch(patch: Record): Partial { beforeAll(async () => { ({ resetSubagentRegistryForTests, spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({ callGatewayMock: hoisted.callGatewayMock, - loadConfig: () => hoisted.configOverride, + getRuntimeConfig: () => hoisted.configOverride, registerSubagentRunMock: hoisted.registerSubagentRunMock, hookRunner: hoisted.hookRunner, resolveAgentConfig: resolveTestAgentConfig, diff --git a/src/agents/tool-replay-repair.live.test.ts b/src/agents/tool-replay-repair.live.test.ts index ebde652c777..149a66fc71c 100644 --- a/src/agents/tool-replay-repair.live.test.ts +++ b/src/agents/tool-replay-repair.live.test.ts @@ -3,7 +3,7 @@ import { completeSimple, type Api, type Context, type Model } from "@mariozechne import { SessionManager } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { isLiveProfileKeyModeEnabled, isLiveTestEnabled } from "./live-test-helpers.js"; import { getApiKeyForModel, requireApiKey } from "./model-auth.js"; @@ -186,7 +186,7 @@ describeLive("tool replay repair live", () => { it( `accepts repaired displaced and missing tool results with ${target.ref}`, async () => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); @@ -301,7 +301,7 @@ describeLive("tool replay repair live", () => { it( `accepts transport replay after dropping aborted assistant tool calls with ${target.ref}`, async () => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); diff --git a/src/agents/tools/agents-list-tool.test.ts b/src/agents/tools/agents-list-tool.test.ts index e04d946d394..86d1e907c34 100644 --- a/src/agents/tools/agents-list-tool.test.ts +++ b/src/agents/tools/agents-list-tool.test.ts @@ -8,7 +8,7 @@ vi.mock("../../config/config.js", async () => { await vi.importActual("../../config/config.js"); return { ...actual, - loadConfig: () => loadConfigMock(), + getRuntimeConfig: () => loadConfigMock(), }; }); diff --git a/src/agents/tools/agents-list-tool.ts b/src/agents/tools/agents-list-tool.ts index 88d068dc17f..e7cea9e614b 100644 --- a/src/agents/tools/agents-list-tool.ts +++ b/src/agents/tools/agents-list-tool.ts @@ -1,5 +1,5 @@ import { Type } from "typebox"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { DEFAULT_AGENT_ID, normalizeAgentId, @@ -34,7 +34,7 @@ function normalizeRuntimeValue(value: unknown): string | undefined { } function resolveAgentRuntimeMetadata( - cfg: ReturnType, + cfg: ReturnType, agentId: string, ): NonNullable { const envRuntime = normalizeRuntimeValue(process.env.OPENCLAW_AGENT_RUNTIME); @@ -84,7 +84,7 @@ export function createAgentsListTool(opts?: { 'List OpenClaw agent ids you can target with `sessions_spawn` when `runtime="subagent"` (based on subagent allowlists).', parameters: AgentsListToolSchema, execute: async () => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const requesterInternalKey = typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim() diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 8d6d8b1dec1..325a9990506 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -1,5 +1,5 @@ import { Type, type TSchema } from "typebox"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; import type { CronDelivery, CronMessageChannel } from "../../cron/types.js"; import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; @@ -354,7 +354,7 @@ async function buildReminderContextLines(params: { if (!sessionKey) { return []; } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const resolvedKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey }); try { @@ -638,7 +638,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con sessionContext: { sessionKey: opts?.agentSessionKey }, }) ?? params.job; if (job && typeof job === "object") { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const resolvedSessionKey = opts?.agentSessionKey ? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey }) diff --git a/src/agents/tools/embedded-gateway-stub.runtime.ts b/src/agents/tools/embedded-gateway-stub.runtime.ts index 59d65437f60..1878816f496 100644 --- a/src/agents/tools/embedded-gateway-stub.runtime.ts +++ b/src/agents/tools/embedded-gateway-stub.runtime.ts @@ -1,5 +1,5 @@ export { resolveSessionAgentId } from "../../agents/agent-scope.js"; -export { loadConfig } from "../../config/config.js"; +export { getRuntimeConfig } from "../../config/config.js"; export { projectRecentChatDisplayMessages, resolveEffectiveChatHistoryMaxChars, diff --git a/src/agents/tools/embedded-gateway-stub.test.ts b/src/agents/tools/embedded-gateway-stub.test.ts index 91644a3c7f1..7b350f165a5 100644 --- a/src/agents/tools/embedded-gateway-stub.test.ts +++ b/src/agents/tools/embedded-gateway-stub.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createEmbeddedCallGateway } from "./embedded-gateway-stub.js"; const runtime = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({ agents: { list: [{ id: "main", default: true }] } })), + getRuntimeConfig: vi.fn(() => ({ agents: { list: [{ id: "main", default: true }] } })), resolveSessionKeyFromResolveParams: vi.fn(), resolveSessionAgentId: vi.fn(() => "main"), loadSessionEntry: vi.fn(() => ({ @@ -31,7 +31,7 @@ vi.mock("./embedded-gateway-stub.runtime.js", () => runtime); describe("embedded gateway stub", () => { beforeEach(() => { - runtime.loadConfig.mockClear(); + runtime.getRuntimeConfig.mockClear(); runtime.resolveSessionKeyFromResolveParams.mockReset(); runtime.projectRecentChatDisplayMessages.mockClear(); runtime.readSessionMessages.mockClear(); diff --git a/src/agents/tools/embedded-gateway-stub.ts b/src/agents/tools/embedded-gateway-stub.ts index 43314ed5068..060575c29a5 100644 --- a/src/agents/tools/embedded-gateway-stub.ts +++ b/src/agents/tools/embedded-gateway-stub.ts @@ -8,7 +8,7 @@ type EmbeddedCallGateway = >(opts: CallGatewayOption interface EmbeddedGatewayRuntime { resolveSessionAgentId: (opts: { sessionKey: string; config: OpenClawConfig }) => string; - loadConfig: () => OpenClawConfig; + getRuntimeConfig: () => OpenClawConfig; augmentChatHistoryWithCliSessionImports: (opts: { entry: unknown; provider: string | undefined; @@ -68,7 +68,7 @@ async function getRuntime(): Promise { async function handleSessionsList(params: Record) { const rt = await getRuntime(); - const cfg = rt.loadConfig(); + const cfg = rt.getRuntimeConfig(); const { storePath, store } = rt.loadCombinedSessionStoreForGateway(cfg); return rt.listSessionsFromStore({ cfg, @@ -80,7 +80,7 @@ async function handleSessionsList(params: Record) { async function handleSessionsResolve(params: Record) { const rt = await getRuntime(); - const cfg = rt.loadConfig(); + const cfg = rt.getRuntimeConfig(); const resolved = await rt.resolveSessionKeyFromResolveParams({ cfg, p: params as SessionsResolveParams, diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts index 1326f25445f..bfbbf6902e6 100644 --- a/src/agents/tools/gateway.test.ts +++ b/src/agents/tools/gateway.test.ts @@ -5,7 +5,7 @@ const configState = vi.hoisted(() => ({ value: {} as Record, })); vi.mock("../../config/config.js", () => ({ - loadConfig: () => configState.value, + getRuntimeConfig: () => configState.value, resolveGatewayPort: () => 18789, })); vi.mock("../../gateway/call.js", () => ({ diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index 534c528d820..9141db26bbe 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -1,4 +1,4 @@ -import { loadConfig, resolveGatewayPort } from "../../config/config.js"; +import { getRuntimeConfig, resolveGatewayPort } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway } from "../../gateway/call.js"; import { resolveGatewayCredentialsFromConfig, trimToUndefined } from "../../gateway/credentials.js"; @@ -122,7 +122,7 @@ function resolveGatewayOverrideToken(params: { } export function resolveGatewayOptions(opts?: GatewayCallOptions) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const validatedOverride = trimToUndefined(opts?.gatewayUrl) !== undefined ? validateGatewayUrlOverrideForAgentTools({ diff --git a/src/agents/tools/image-generate-tool.ts b/src/agents/tools/image-generate-tool.ts index 4d411fe821a..451256a1089 100644 --- a/src/agents/tools/image-generate-tool.ts +++ b/src/agents/tools/image-generate-tool.ts @@ -1,5 +1,5 @@ import { Type } from "typebox"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { parseImageGenerationModelRef } from "../../image-generation/model-ref.js"; import { @@ -566,7 +566,7 @@ export function createImageGenerateTool(options?: { sandbox?: ImageGenerateSandboxConfig; fsPolicy?: ToolFsPolicy; }): AnyAgentTool | null { - const cfg = options?.config ?? loadConfig(); + const cfg = options?.config ?? getRuntimeConfig(); const imageGenerationModelConfig = resolveImageGenerationModelConfigForTool({ cfg, agentDir: options?.agentDir, diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 8b9c53b7971..b46f052dcdc 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -30,7 +30,7 @@ function createTelegramPollExtraToolSchemas() { const mocks = vi.hoisted(() => ({ runMessageAction: vi.fn(), - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({ resolvedConfig: config, diagnostics: [], @@ -116,7 +116,7 @@ vi.mock("../../config/config.js", async () => { await vi.importActual("../../config/config.js"); return { ...actual, - loadConfig: mocks.loadConfig, + getRuntimeConfig: mocks.getRuntimeConfig, }; }); @@ -159,7 +159,7 @@ beforeAll(async () => { beforeEach(() => { resetPluginRuntimeStateForTest(); mocks.runMessageAction.mockReset(); - mocks.loadConfig.mockReset().mockReturnValue({}); + mocks.getRuntimeConfig.mockReset().mockReturnValue({}); mocks.resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({ resolvedConfig: config, diagnostics: [], @@ -238,7 +238,7 @@ async function executeSend(params: { describe("message tool secret scoping", () => { it("scopes command-time secret resolution to the selected channel/account", async () => { mockSendResult({ channel: "discord", to: "discord:123" }); - mocks.loadConfig.mockReturnValue({ + mocks.getRuntimeConfig.mockReturnValue({ channels: { discord: { token: { source: "env", provider: "default", id: "DISCORD_TOKEN" }, @@ -256,7 +256,7 @@ describe("message tool secret scoping", () => { const tool = createMessageTool({ currentChannelProvider: "discord", agentAccountId: "ops", - loadConfig: mocks.loadConfig as never, + getRuntimeConfig: mocks.getRuntimeConfig as never, getScopedChannelsCommandSecretTargets: mocks.getScopedChannelsCommandSecretTargets as never, resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway as never, runMessageAction: mocks.runMessageAction as never, diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 13b52c8b254..f72d61484cb 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -13,7 +13,7 @@ import type { ChannelMessageActionName } from "../../channels/plugins/types.publ import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; import { getScopedChannelsCommandSecretTargets } from "../../cli/command-secret-targets.js"; import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js"; @@ -425,7 +425,7 @@ type MessageToolOptions = { agentSessionKey?: string; sessionId?: string; config?: OpenClawConfig; - loadConfig?: () => OpenClawConfig; + getRuntimeConfig?: () => OpenClawConfig; getScopedChannelsCommandSecretTargets?: typeof getScopedChannelsCommandSecretTargets; resolveCommandSecretRefsViaGateway?: typeof resolveCommandSecretRefsViaGateway; runMessageAction?: typeof runMessageAction; @@ -650,7 +650,7 @@ function appendMessageToolReadHint( } export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { - const loadConfigForTool = options?.loadConfig ?? loadConfig; + const loadConfigForTool = options?.getRuntimeConfig ?? getRuntimeConfig; const getScopedSecretTargetsForTool = options?.getScopedChannelsCommandSecretTargets ?? getScopedChannelsCommandSecretTargets; const resolveSecretRefsForTool = diff --git a/src/agents/tools/music-generate-tool.test.ts b/src/agents/tools/music-generate-tool.test.ts index 6a683685014..d5142676dd7 100644 --- a/src/agents/tools/music-generate-tool.test.ts +++ b/src/agents/tools/music-generate-tool.test.ts @@ -18,7 +18,7 @@ const taskExecutorMocks = vi.hoisted(() => ({ })); const configMocks = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), })); const mediaStoreMocks = vi.hoisted(() => ({ diff --git a/src/agents/tools/music-generate-tool.ts b/src/agents/tools/music-generate-tool.ts index 001a3ebdab9..df2f25f03f8 100644 --- a/src/agents/tools/music-generate-tool.ts +++ b/src/agents/tools/music-generate-tool.ts @@ -1,5 +1,5 @@ import { Type } from "typebox"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import type { SsrFPolicy } from "../../infra/net/ssrf.js"; @@ -493,7 +493,7 @@ export function createMusicGenerateTool(options?: { fsPolicy?: ToolFsPolicy; scheduleBackgroundWork?: MusicGenerateBackgroundScheduler; }): AnyAgentTool | null { - const cfg: OpenClawConfig = options?.config ?? loadConfig(); + const cfg: OpenClawConfig = options?.config ?? getRuntimeConfig(); const musicGenerationModelConfig = resolveMusicGenerationModelConfigForTool({ cfg, agentDir: options?.agentDir, diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 49c9036d851..da27ddbe962 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -5,7 +5,7 @@ import type { ThinkLevel, VerboseLevel, } from "../../auto-reply/thinking.js"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { loadSessionStore, resolveStorePath, @@ -42,15 +42,15 @@ import { import type { AnyAgentTool } from "./common.js"; import { readStringParam } from "./common.js"; import { - createSessionVisibilityGuard, - shouldResolveSessionIdInput, createAgentToAgentPolicy, + createSessionVisibilityGuard, resolveCurrentSessionClientAlias, resolveEffectiveSessionToolsVisibility, resolveInternalSessionKey, - resolveSessionReference, resolveSandboxedSessionToolContext, + resolveSessionReference, resolveVisibleSessionReference, + shouldResolveSessionIdInput, } from "./sessions-helpers.js"; const SessionStatusToolSchema = Type.Object({ @@ -264,7 +264,7 @@ export function createSessionStatusTool(opts?: { parameters: SessionStatusToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; - const cfg = opts?.config ?? loadConfig(); + const cfg = opts?.config ?? getRuntimeConfig(); const { mainKey, alias, effectiveRequesterKey } = resolveSandboxedSessionToolContext({ cfg, agentSessionKey: opts?.agentSessionKey, diff --git a/src/agents/tools/sessions-helpers.ts b/src/agents/tools/sessions-helpers.ts index 2e486ff5a88..ccda3d43576 100644 --- a/src/agents/tools/sessions-helpers.ts +++ b/src/agents/tools/sessions-helpers.ts @@ -34,7 +34,7 @@ export { sanitizeTextContent, stripToolMessages, } from "./chat-history-text.js"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -98,7 +98,7 @@ export function resolveSessionToolContext(opts?: { sandboxed?: boolean; config?: OpenClawConfig; }) { - const cfg = opts?.config ?? loadConfig(); + const cfg = opts?.config ?? getRuntimeConfig(); return { cfg, ...resolveSandboxedSessionToolContext({ diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 9cf7dacca46..318a008a0ec 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -1,5 +1,5 @@ import { Type } from "typebox"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway } from "../../gateway/call.js"; import { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js"; @@ -191,7 +191,7 @@ export function createSessionsHistoryTool(opts?: { const sessionKeyParam = readStringParam(params, "sessionKey", { required: true, }); - const cfg = opts?.config ?? loadConfig(); + const cfg = opts?.config ?? getRuntimeConfig(); const { mainKey, alias, effectiveRequesterKey, restrictToSpawned } = resolveSandboxedSessionToolContext({ cfg, diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 2b6bb7e4082..d491cef9ecf 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { Type } from "typebox"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, @@ -70,7 +70,7 @@ export function createSessionsListTool(opts?: { parameters: SessionsListToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; - const cfg = opts?.config ?? loadConfig(); + const cfg = opts?.config ?? getRuntimeConfig(); const { mainKey, alias, requesterInternalKey, restrictToSpawned } = resolveSandboxedSessionToolContext({ cfg, diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index ed95ca2d49d..842d3343e30 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -1,6 +1,6 @@ import { Type } from "typebox"; import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway } from "../../gateway/call.js"; import { normalizeDeliveryContext } from "../../utils/delivery-context.shared.js"; @@ -309,7 +309,7 @@ export function createSessionsSpawnTool( Boolean(childRunId) && streamTo !== "parent"; if (shouldTrackViaRegistry && childSessionKey && childRunId) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const trackedSpawnMode = resolveTrackedSpawnMode({ requestedMode: result.mode, threadRequested: thread, diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index b916eac1a18..fa7fb9db2a8 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -28,7 +28,7 @@ vi.mock("../../config/config.js", async () => { await vi.importActual("../../config/config.js"); return { ...actual, - loadConfig: () => loadConfigMock() as never, + getRuntimeConfig: () => loadConfigMock() as never, }; }); vi.mock("./sessions-send-tool.a2a.js", () => ({ diff --git a/src/agents/tools/subagents-tool.ts b/src/agents/tools/subagents-tool.ts index cb50cfdeb95..289c7f4e442 100644 --- a/src/agents/tools/subagents-tool.ts +++ b/src/agents/tools/subagents-tool.ts @@ -1,5 +1,5 @@ import { Type } from "typebox"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { optionalStringEnum } from "../schema/typebox.js"; import { DEFAULT_RECENT_MINUTES, @@ -40,7 +40,7 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge execute: async (_toolCallId, args) => { const params = args as Record; const action = (readStringParam(params, "action") ?? "list") as SubagentAction; - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const controller = resolveSubagentController({ cfg, agentSessionKey: opts?.agentSessionKey, diff --git a/src/agents/tools/tts-tool.ts b/src/agents/tools/tts-tool.ts index 6d96638833e..3b8386bb195 100644 --- a/src/agents/tools/tts-tool.ts +++ b/src/agents/tools/tts-tool.ts @@ -1,6 +1,6 @@ import { Type } from "typebox"; import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { textToSpeech } from "../../tts/tts.js"; import type { GatewayMessageChannel } from "../../utils/message-channel.js"; @@ -71,7 +71,7 @@ export function createTtsTool(opts?: { const text = readStringParam(params, "text", { required: true }); const channel = readStringParam(params, "channel"); const timeoutMs = readTtsTimeoutMs(params); - const cfg = opts?.config ?? loadConfig(); + const cfg = opts?.config ?? getRuntimeConfig(); const result = await textToSpeech({ text, cfg, diff --git a/src/agents/tools/video-generate-tool.ts b/src/agents/tools/video-generate-tool.ts index 44115387d54..b8fc823f820 100644 --- a/src/agents/tools/video-generate-tool.ts +++ b/src/agents/tools/video-generate-tool.ts @@ -1,5 +1,5 @@ import { Type } from "typebox"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import type { SsrFPolicy } from "../../infra/net/ssrf.js"; @@ -800,7 +800,7 @@ export function createVideoGenerateTool(options?: { fsPolicy?: ToolFsPolicy; scheduleBackgroundWork?: VideoGenerateBackgroundScheduler; }): AnyAgentTool | null { - const cfg: OpenClawConfig = options?.config ?? loadConfig(); + const cfg: OpenClawConfig = options?.config ?? getRuntimeConfig(); const videoGenerationModelConfig = resolveVideoGenerationModelConfigForTool({ cfg, agentDir: options?.agentDir, diff --git a/src/auto-reply/reply.block-streaming.test.ts b/src/auto-reply/reply.block-streaming.test.ts index 14cceded419..d7c67b11499 100644 --- a/src/auto-reply/reply.block-streaming.test.ts +++ b/src/auto-reply/reply.block-streaming.test.ts @@ -44,7 +44,7 @@ vi.mock("../channels/model-overrides.js", () => ({ resolveChannelModelOverride: vi.fn(() => undefined), })); vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), })); vi.mock("../runtime.js", () => ({ defaultRuntime: { log: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() }, diff --git a/src/auto-reply/reply/commands-allowlist.test.ts b/src/auto-reply/reply/commands-allowlist.test.ts index cdd59f5da47..eb6e204c032 100644 --- a/src/auto-reply/reply/commands-allowlist.test.ts +++ b/src/auto-reply/reply/commands-allowlist.test.ts @@ -21,7 +21,7 @@ import type { HandleCommandsParams } from "./commands-types.js"; const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn()); -const writeConfigFileMock = vi.hoisted(() => vi.fn()); +const replaceConfigFileMock = vi.hoisted(() => vi.fn()); const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn()); const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); @@ -29,7 +29,7 @@ const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); vi.mock("../../config/config.js", () => ({ readConfigFileSnapshot: readConfigFileSnapshotMock, validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, - writeConfigFile: writeConfigFileMock, + replaceConfigFile: replaceConfigFileMock, })); vi.mock("../../pairing/pairing-store.js", () => ({ @@ -187,10 +187,10 @@ beforeEach(() => { ok: true, config, })); - writeConfigFileMock.mockImplementation(async (config: unknown) => { + replaceConfigFileMock.mockImplementation(async (params: { nextConfig: unknown }) => { const configPath = process.env.OPENCLAW_CONFIG_PATH; if (configPath) { - await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8"); + await fs.writeFile(configPath, JSON.stringify(params.nextConfig, null, 2), "utf-8"); } }); readChannelAllowFromStoreMock.mockResolvedValue([]); @@ -405,7 +405,7 @@ describe("handleAllowlistCommand", () => { }); it("blocks config-targeted edits when the target account disables writes", async () => { - const previousWriteCount = writeConfigFileMock.mock.calls.length; + const previousWriteCount = replaceConfigFileMock.mock.calls.length; const cfg = { commands: { text: true, config: true }, channels: { @@ -431,7 +431,7 @@ describe("handleAllowlistCommand", () => { expect(result?.shouldContinue).toBe(false); expect(result?.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true"); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); + expect(replaceConfigFileMock.mock.calls.length).toBe(previousWriteCount); }); it("honors the configured default account when gating omitted-account config edits", async () => { @@ -453,7 +453,7 @@ describe("handleAllowlistCommand", () => { ]), ); - const previousWriteCount = writeConfigFileMock.mock.calls.length; + const previousWriteCount = replaceConfigFileMock.mock.calls.length; const cfg = { commands: { text: true, config: true }, channels: { @@ -479,7 +479,7 @@ describe("handleAllowlistCommand", () => { expect(result?.shouldContinue).toBe(false); expect(result?.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true"); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); + expect(replaceConfigFileMock.mock.calls.length).toBe(previousWriteCount); }); it("blocks allowlist writes from authorized non-owner senders", async () => { @@ -511,7 +511,7 @@ describe("handleAllowlistCommand", () => { expect(result?.shouldContinue).toBe(false); expect(result?.reply).toBeUndefined(); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); expect(addChannelAllowFromStoreEntryMock).not.toHaveBeenCalled(); }); @@ -534,7 +534,7 @@ describe("handleAllowlistCommand", () => { expect(result?.shouldContinue).toBe(false); expect(result?.reply).toBeUndefined(); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); expect(addChannelAllowFromStoreEntryMock).not.toHaveBeenCalled(); }); @@ -583,7 +583,7 @@ describe("handleAllowlistCommand", () => { expect(result?.shouldContinue).toBe(false); expect(result?.reply?.text).toContain("Invalid account id"); expect((Object.prototype as Record).allowFrom).toBeUndefined(); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); it("removes DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => { diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index 9dfe2162910..bb9e78bdca0 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -3,8 +3,8 @@ import type { ChannelId } from "../../channels/plugins/types.public.js"; import { normalizeChannelId } from "../../channels/registry.js"; import { readConfigFileSnapshot, + replaceConfigFile, validateConfigObjectWithPlugins, - writeConfigFile, } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { @@ -515,7 +515,10 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo reply: { text: `⚠️ Config invalid after update (${issue.path}: ${issue.message}).` }, }; } - await writeConfigFile(validated.config); + await replaceConfigFile({ + nextConfig: validated.config, + afterWrite: { mode: "auto" }, + }); } if (!configChanged && !shouldTouchStore) { diff --git a/src/auto-reply/reply/commands-config.ts b/src/auto-reply/reply/commands-config.ts index 7d37519b096..bcd9541087a 100644 --- a/src/auto-reply/reply/commands-config.ts +++ b/src/auto-reply/reply/commands-config.ts @@ -8,8 +8,8 @@ import { } from "../../config/config-paths.js"; import { readConfigFileSnapshot, + replaceConfigFile, validateConfigObjectWithPlugins, - writeConfigFile, } from "../../config/config.js"; import { getConfigOverrides, @@ -159,7 +159,10 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma }, }; } - await writeConfigFile(validated.config); + await replaceConfigFile({ + nextConfig: validated.config, + afterWrite: { mode: "auto" }, + }); return { shouldContinue: false, reply: { text: `⚙️ Config updated: ${configCommand.path} removed.` }, @@ -178,7 +181,10 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma }, }; } - await writeConfigFile(validated.config); + await replaceConfigFile({ + nextConfig: validated.config, + afterWrite: { mode: "auto" }, + }); const valueLabel = typeof configCommand.value === "string" ? `"${configCommand.value}"` diff --git a/src/auto-reply/reply/commands-gating.test.ts b/src/auto-reply/reply/commands-gating.test.ts index 5878e1bd972..11ec1b09fb5 100644 --- a/src/auto-reply/reply/commands-gating.test.ts +++ b/src/auto-reply/reply/commands-gating.test.ts @@ -17,7 +17,7 @@ const validateConfigObjectWithPluginsMock = vi.hoisted(() => issues: [], })), ); -const writeConfigFileMock = vi.hoisted(() => vi.fn(async () => undefined)); +const replaceConfigFileMock = vi.hoisted(() => vi.fn(async () => undefined)); const getConfigOverridesMock = vi.hoisted(() => vi.fn(() => ({}))); const getConfigValueAtPathMock = vi.hoisted(() => vi.fn()); const parseConfigPathMock = vi.hoisted(() => vi.fn()); @@ -79,7 +79,7 @@ vi.mock("../../config/config-paths.js", () => ({ vi.mock("../../config/config.js", () => ({ readConfigFileSnapshot: readConfigFileSnapshotMock, validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, - writeConfigFile: writeConfigFileMock, + replaceConfigFile: replaceConfigFileMock, })); vi.mock("../../config/runtime-overrides.js", () => ({ @@ -418,11 +418,11 @@ describe("command gating", () => { ] as const; for (const testCase of cases) { - const previousWriteCount = writeConfigFileMock.mock.calls.length; + const previousWriteCount = replaceConfigFileMock.mock.calls.length; const result = await handleConfigCommand(testCase.params, true); expect(result?.shouldContinue).toBe(false); expect(result?.reply?.text).toContain(testCase.expectedText); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); + expect(replaceConfigFileMock.mock.calls.length).toBe(previousWriteCount); } }); @@ -449,12 +449,12 @@ describe("command gating", () => { params.command.surface = "telegram"; params.command.senderIsOwner = true; - const previousWriteCount = writeConfigFileMock.mock.calls.length; + const previousWriteCount = replaceConfigFileMock.mock.calls.length; const result = await handleConfigCommand(params, true); expect(result?.shouldContinue).toBe(false); expect(result?.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true"); - expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount); + expect(replaceConfigFileMock.mock.calls.length).toBe(previousWriteCount); }); it("enforces gateway client permissions for /config commands", async () => { @@ -505,6 +505,6 @@ describe("command gating", () => { const setResult = await handleConfigCommand(setParams, true); expect(setResult?.shouldContinue).toBe(false); expect(setResult?.reply?.text).toContain("Config updated"); - expect(writeConfigFileMock).toHaveBeenCalled(); + expect(replaceConfigFileMock).toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index 819bf37e776..41267e60868 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -5,7 +5,7 @@ import { buildPluginsCommandParams } from "./commands.test-harness.js"; const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn()); -const writeConfigFileMock = vi.hoisted(() => vi.fn(async () => undefined)); +const replaceConfigFileMock = vi.hoisted(() => vi.fn(async () => undefined)); const buildPluginRegistrySnapshotReportMock = vi.hoisted(() => vi.fn()); const buildPluginDiagnosticsReportMock = vi.hoisted(() => vi.fn()); const buildPluginInspectReportMock = vi.hoisted(() => vi.fn()); @@ -35,7 +35,7 @@ vi.mock("../../cli/plugins-registry-refresh.js", () => ({ vi.mock("../../config/config.js", () => ({ readConfigFileSnapshot: readConfigFileSnapshotMock, validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, - writeConfigFile: writeConfigFileMock, + replaceConfigFile: replaceConfigFileMock, })); vi.mock("../../infra/archive.js", () => ({ @@ -212,13 +212,16 @@ describe("handlePluginsCommand", () => { const enableResult = await handlePluginsCommand(enableParams, true); expect(enableResult?.reply?.text).toContain('Plugin "superpowers" enabled'); - expect(writeConfigFileMock).toHaveBeenLastCalledWith( + expect(replaceConfigFileMock).toHaveBeenLastCalledWith( expect.objectContaining({ - plugins: expect.objectContaining({ - entries: expect.objectContaining({ - superpowers: expect.objectContaining({ enabled: true }), + nextConfig: expect.objectContaining({ + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + superpowers: expect.objectContaining({ enabled: true }), + }), }), }), + afterWrite: { mode: "auto" }, }), ); expect(refreshPluginRegistryAfterConfigMutationMock).toHaveBeenLastCalledWith( @@ -239,13 +242,16 @@ describe("handlePluginsCommand", () => { const disableResult = await handlePluginsCommand(disableParams, true); expect(disableResult?.reply?.text).toContain('Plugin "superpowers" disabled'); - expect(writeConfigFileMock).toHaveBeenLastCalledWith( + expect(replaceConfigFileMock).toHaveBeenLastCalledWith( expect.objectContaining({ - plugins: expect.objectContaining({ - entries: expect.objectContaining({ - superpowers: expect.objectContaining({ enabled: false }), + nextConfig: expect.objectContaining({ + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + superpowers: expect.objectContaining({ enabled: false }), + }), }), }), + afterWrite: { mode: "auto" }, }), ); expect(refreshPluginRegistryAfterConfigMutationMock).toHaveBeenLastCalledWith( diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 609562bccaf..c8d3e6633fb 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -11,8 +11,8 @@ import type { ConfigSnapshotForInstallPersist } from "../../cli/plugins-install- import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { readConfigFileSnapshot, + replaceConfigFile, validateConfigObjectWithPlugins, - writeConfigFile, } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../config/types.plugins.js"; @@ -486,7 +486,10 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm }, }; } - await writeConfigFile(validated.config); + await replaceConfigFile({ + nextConfig: validated.config, + afterWrite: { mode: "auto" }, + }); let registryWarning: string | undefined; await refreshPluginRegistryAfterConfigMutation({ config: validated.config, diff --git a/src/auto-reply/reply/commands-subagents.test-mocks.ts b/src/auto-reply/reply/commands-subagents.test-mocks.ts index 917aabcf2e2..6b43279bb94 100644 --- a/src/auto-reply/reply/commands-subagents.test-mocks.ts +++ b/src/auto-reply/reply/commands-subagents.test-mocks.ts @@ -5,7 +5,7 @@ vi.mock("../../config/config.js", async () => { await vi.importActual("../../config/config.js"); return { ...actual, - loadConfig: () => ({}), + getRuntimeConfig: () => ({}), }; }); diff --git a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts index 758facb58dc..102e442b9eb 100644 --- a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts @@ -13,6 +13,7 @@ import { internalHookMocks, mocks, resetPluginTtsAndThreadMocks, + runtimePluginMocks, sessionBindingMocks, sessionStoreMocks, setDiscordTestRegistry, @@ -83,6 +84,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { diagnosticMocks.logMessageQueued.mockReset(); diagnosticMocks.logMessageProcessed.mockReset(); diagnosticMocks.logSessionStateChange.mockReset(); + runtimePluginMocks.ensureRuntimePluginsLoaded.mockReset(); resetPluginTtsAndThreadMocks(); }); @@ -102,6 +104,10 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { replyResolver: async () => ({ text: "model reply" }), }); + expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + config: emptyConfig, + workspaceDir: expect.any(String), + }); expect(hookMocks.runner.runReplyDispatch).toHaveBeenCalledWith( expect.objectContaining({ sessionKey: "agent:test:session", diff --git a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts index 12d7478abbd..9124e5e6093 100644 --- a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts @@ -112,6 +112,9 @@ const replyMediaPathMocks = vi.hoisted(() => ({ (_params?: unknown) => async (payload: ReplyPayload) => payload, ), })); +const runtimePluginMocks = vi.hoisted(() => ({ + ensureRuntimePluginsLoaded: vi.fn(), +})); const threadInfoMocks = vi.hoisted(() => ({ parseSessionThreadInfo: vi.fn< (sessionKey: string | undefined) => { @@ -133,6 +136,7 @@ export { sessionBindingMocks, sessionStoreMocks, replyMediaPathMocks, + runtimePluginMocks, threadInfoMocks, ttsMocks, }; @@ -228,6 +232,41 @@ vi.mock("../../infra/agent-events.js", () => ({ emitAgentEvent: (params: unknown) => agentEventMocks.emitAgentEvent(params), onAgentEvent: (listener: unknown) => agentEventMocks.onAgentEvent(listener), })); +vi.mock("./runtime-plugins.runtime.js", () => ({ + ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, +})); +vi.mock("./conversation-binding-input.js", () => { + const normalize = (value: unknown) => + typeof value === "string" && value.trim() ? value.trim() : undefined; + return { + resolveConversationBindingContextFromMessage: (params: { + ctx: { + OriginatingChannel?: string | null; + Surface?: string | null; + Provider?: string | null; + AccountId?: string | null; + OriginatingTo?: string | null; + To?: string | null; + From?: string | null; + }; + }) => { + const channel = normalize( + params.ctx.OriginatingChannel ?? params.ctx.Surface ?? params.ctx.Provider, + )?.toLowerCase(); + const conversationId = normalize( + params.ctx.OriginatingTo ?? params.ctx.To ?? params.ctx.From, + ); + if (!channel || !conversationId) { + return null; + } + return { + channel, + accountId: normalize(params.ctx.AccountId) ?? "default", + conversationId, + }; + }, + }; +}); vi.mock("../../plugins/conversation-binding.js", () => ({ buildPluginBindingDeclinedText: () => "Plugin binding request was declined.", buildPluginBindingErrorText: () => "Plugin binding request failed.", diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 9645d912024..a6a4418735e 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -443,7 +443,7 @@ vi.mock("./reply-media-paths.runtime.js", () => ({ createReplyMediaPathNormalizer: (params: unknown) => replyMediaPathMocks.createReplyMediaPathNormalizer(params), })); -vi.mock("../../agents/runtime-plugins.js", () => ({ +vi.mock("./runtime-plugins.runtime.js", () => ({ ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, })); vi.mock("./conversation-binding-input.js", () => ({ @@ -3481,7 +3481,7 @@ describe("dispatchReplyFromConfig", () => { expect(replyResolver).toHaveBeenCalledTimes(1); }); - it("passes configOverride to replyResolver when provided", async () => { + it("passes the loaded config plus configOverride patch to replyResolver when provided", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); @@ -3509,10 +3509,12 @@ describe("dispatchReplyFromConfig", () => { configOverride: overrideCfg, }); - expect(receivedCfg).toBe(overrideCfg); + expect(receivedCfg).not.toBe(cfg); + expect(receivedCfg).not.toBe(overrideCfg); + expect(receivedCfg).toEqual(overrideCfg); }); - it("does not pass cfg as implicit configOverride when configOverride is not provided", async () => { + it("passes the already loaded config to replyResolver when configOverride is not provided", async () => { setNoAbort(); const cfg = { agents: { defaults: { userTimezone: "UTC" } } } as OpenClawConfig; const dispatcher = createDispatcher(); @@ -3530,7 +3532,7 @@ describe("dispatchReplyFromConfig", () => { await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); - expect(receivedCfg).toBeUndefined(); + expect(receivedCfg).toBe(cfg); }); it("suppresses isReasoning payloads from final replies (WhatsApp channel)", async () => { diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index ccd05fb9c1f..106f7d3e85d 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -11,6 +11,7 @@ import { } from "../../bindings/records.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js"; +import { applyMergePatch } from "../../config/merge-patch.js"; import { parseSessionThreadInfoFast } from "../../config/sessions/thread-info.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -74,6 +75,7 @@ import type { DispatchFromConfigResult, } from "./dispatch-from-config.types.js"; import { resolveEffectiveReplyRoute } from "./effective-reply-route.js"; +import { withFullRuntimeReplyConfig } from "./get-reply-fast-path.js"; import { claimInboundDedupe, commitInboundDedupe, releaseInboundDedupe } from "./inbound-dedupe.js"; import { resolveReplyRoutingDecision } from "./routing-policy.js"; import { resolveRunTypingPolicy } from "./typing-policy.js"; @@ -84,7 +86,7 @@ let getReplyFromConfigRuntimePromise: Promise< > | null = null; let abortRuntimePromise: Promise | null = null; let ttsRuntimePromise: Promise | null = null; -let runtimePluginsPromise: Promise | null = null; +let runtimePluginsPromise: Promise | null = null; let replyMediaPathsRuntimePromise: Promise | null = null; @@ -109,7 +111,7 @@ function loadTtsRuntime() { } function loadRuntimePlugins() { - runtimePluginsPromise ??= import("../../agents/runtime-plugins.js"); + runtimePluginsPromise ??= import("./runtime-plugins.runtime.js"); return runtimePluginsPromise; } @@ -347,6 +349,11 @@ export async function dispatchReplyFromConfig( const markInboundDedupeReplayUnsafe = () => { inboundDedupeReplayUnsafe = true; }; + const commitInboundDedupeIfClaimed = () => { + if (inboundDedupeClaim.status === "claimed") { + commitInboundDedupe(inboundDedupeClaim.key); + } + }; const initialSessionStoreEntry = resolveSessionStoreLookup(ctx, cfg); const boundAcpDispatchSessionKey = resolveBoundAcpDispatchSessionKey({ ctx, cfg }); @@ -629,6 +636,7 @@ export async function dispatchReplyFromConfig( } markIdle("plugin_binding_dispatch"); recordProcessed("completed", { reason: "plugin-bound-handled" }); + commitInboundDedupeIfClaimed(); return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; } case "missing_plugin": @@ -655,6 +663,7 @@ export async function dispatchReplyFromConfig( ); markIdle("plugin_binding_declined"); recordProcessed("completed", { reason: "plugin-bound-declined" }); + commitInboundDedupeIfClaimed(); return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; } case "error": { @@ -667,6 +676,7 @@ export async function dispatchReplyFromConfig( ); markIdle("plugin_binding_error"); recordProcessed("completed", { reason: "plugin-bound-error" }); + commitInboundDedupeIfClaimed(); return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; } } @@ -739,6 +749,7 @@ export async function dispatchReplyFromConfig( counts.final += routedFinalCount; recordProcessed("completed", { reason: "fast_abort" }); markIdle("message_completed"); + commitInboundDedupeIfClaimed(); return { queuedFinal, counts }; } @@ -817,6 +828,7 @@ export async function dispatchReplyFromConfig( counts.final += routedFinalCount; recordProcessed("completed", { reason: "before_dispatch_handled" }); markIdle("message_completed"); + commitInboundDedupeIfClaimed(); return { queuedFinal, counts }; } } @@ -848,6 +860,7 @@ export async function dispatchReplyFromConfig( }, ); if (replyDispatchResult?.handled) { + commitInboundDedupeIfClaimed(); return { queuedFinal: replyDispatchResult.queuedFinal, counts: replyDispatchResult.counts, @@ -1024,6 +1037,9 @@ export async function dispatchReplyFromConfig( const replyResolver = params.replyResolver ?? (await loadGetReplyFromConfigRuntime()).getReplyFromConfig; + const replyConfig = withFullRuntimeReplyConfig( + params.configOverride ? (applyMergePatch(cfg, params.configOverride) as OpenClawConfig) : cfg, + ); const replyResult = await replyResolver( ctx, { @@ -1187,7 +1203,7 @@ export async function dispatchReplyFromConfig( return run(); }, }, - params.configOverride, + replyConfig, ); if (ctx.AcpDispatchTailAfterReset === true) { @@ -1308,9 +1324,7 @@ export async function dispatchReplyFromConfig( const counts = dispatcher.getQueuedCounts(); counts.final += routedFinalCount; - if (inboundDedupeClaim.status === "claimed") { - commitInboundDedupe(inboundDedupeClaim.key); - } + commitInboundDedupeIfClaimed(); recordProcessed( "completed", pluginFallbackReason ? { reason: pluginFallbackReason } : undefined, diff --git a/src/auto-reply/reply/dispatch-from-config.types.ts b/src/auto-reply/reply/dispatch-from-config.types.ts index b92db430be3..ecab40b4d68 100644 --- a/src/auto-reply/reply/dispatch-from-config.types.ts +++ b/src/auto-reply/reply/dispatch-from-config.types.ts @@ -18,7 +18,7 @@ export type DispatchFromConfigParams = { replyResolver?: GetReplyFromConfig; fastAbortResolver?: TryFastAbortFromMessage; formatAbortReplyTextResolver?: FormatAbortReplyText; - /** Optional config override passed to getReplyFromConfig (e.g. per-sender timezone). */ + /** Optional patch applied to the already loaded config before reply resolution. */ configOverride?: OpenClawConfig; }; diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index e5790d55f54..862c59384e6 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -95,13 +95,13 @@ export function usesFullReplyRuntime(config: unknown): boolean { } export function resolveGetReplyConfig(params: { - loadConfig: () => OpenClawConfig; + getRuntimeConfig: () => OpenClawConfig; isFastTestEnv: boolean; configOverride?: OpenClawConfig; }): OpenClawConfig { const { configOverride } = params; if (configOverride == null) { - return params.loadConfig(); + return params.getRuntimeConfig(); } if (params.isFastTestEnv && !isCompleteReplyConfig(configOverride) && !isSlowReplyTestAllowed()) { throw new Error( @@ -111,7 +111,10 @@ export function resolveGetReplyConfig(params: { if (params.isFastTestEnv && isCompleteReplyConfig(configOverride)) { return configOverride; } - return applyMergePatch(params.loadConfig(), configOverride) as OpenClawConfig; + if (isCompleteReplyConfig(configOverride)) { + return configOverride; + } + return applyMergePatch(params.getRuntimeConfig(), configOverride) as OpenClawConfig; } export function shouldUseReplyFastTestBootstrap(params: { diff --git a/src/auto-reply/reply/get-reply.config-override.test.ts b/src/auto-reply/reply/get-reply.config-override.test.ts index deb54fc2c72..0bfd74c428b 100644 --- a/src/auto-reply/reply/get-reply.config-override.test.ts +++ b/src/auto-reply/reply/get-reply.config-override.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { buildGetReplyCtx, @@ -16,11 +16,11 @@ const mocks = vi.hoisted(() => ({ registerGetReplyRuntimeOverrides(mocks); let getReplyFromConfig: typeof import("./get-reply.js").getReplyFromConfig; -let loadConfigMock: typeof import("../../config/config.js").loadConfig; +let loadConfigMock: typeof import("../../config/config.js").getRuntimeConfig; async function loadGetReplyRuntimeForTest() { ({ getReplyFromConfig } = await loadGetReplyModuleForTest({ cacheKey: import.meta.url })); - ({ loadConfig: loadConfigMock } = await import("../../config/config.js")); + ({ getRuntimeConfig: loadConfigMock } = await import("../../config/config.js")); } describe("getReplyFromConfig configOverride", () => { @@ -40,7 +40,7 @@ describe("getReplyFromConfig configOverride", () => { vi.unstubAllEnvs(); }); - it("merges configOverride over fresh loadConfig()", async () => { + it("merges configOverride over fresh getRuntimeConfig()", async () => { vi.mocked(loadConfigMock).mockReturnValue({ channels: { telegram: { @@ -64,4 +64,31 @@ describe("getReplyFromConfig configOverride", () => { expectResolvedTelegramTimezone(mocks.resolveReplyDirectives); }); + + it("uses complete configOverride without reloading config", async () => { + const { withFullRuntimeReplyConfig } = await import("./get-reply-fast-path.js"); + vi.mocked(loadConfigMock).mockImplementation(() => { + throw new Error("getRuntimeConfig should not be called for complete runtime config"); + }); + + await getReplyFromConfig( + buildGetReplyCtx(), + undefined, + withFullRuntimeReplyConfig({ + channels: { + telegram: { + botToken: "resolved-telegram-token", + }, + }, + agents: { + defaults: { + userTimezone: "America/New_York", + }, + }, + } satisfies OpenClawConfig), + ); + + expect(loadConfigMock).not.toHaveBeenCalled(); + expectResolvedTelegramTimezone(mocks.resolveReplyDirectives); + }); }); 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 c79e918c5ca..069961e2687 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -31,12 +31,12 @@ vi.mock("../../agents/workspace.js", () => ({ registerGetReplyRuntimeOverrides(mocks); let getReplyFromConfig: typeof import("./get-reply.js").getReplyFromConfig; -let loadConfigMock: typeof import("../../config/config.js").loadConfig; +let loadConfigMock: typeof import("../../config/config.js").getRuntimeConfig; let runPreparedReplyMock: typeof import("./get-reply-run.js").runPreparedReply; async function loadGetReplyRuntimeForTest() { ({ getReplyFromConfig } = await loadGetReplyModuleForTest({ cacheKey: import.meta.url })); - ({ loadConfig: loadConfigMock } = await import("../../config/config.js")); + ({ getRuntimeConfig: loadConfigMock } = await import("../../config/config.js")); ({ runPreparedReply: runPreparedReplyMock } = await import("./get-reply-run.js")); } @@ -69,7 +69,7 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(vi.mocked(loadConfigMock)).not.toHaveBeenCalled(); }); - it("skips loadConfig, workspace bootstrap, and session bootstrap for marked test configs", async () => { + it("skips getRuntimeConfig, workspace bootstrap, and session bootstrap for marked test configs", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reply-")); const cfg = markCompleteReplyConfig({ agents: { @@ -96,7 +96,7 @@ describe("getReplyFromConfig fast test bootstrap", () => { ); }); - it("still merges partial config overrides against loadConfig()", async () => { + it("still merges partial config overrides against getRuntimeConfig()", async () => { vi.stubEnv("OPENCLAW_ALLOW_SLOW_REPLY_TESTS", "1"); vi.mocked(loadConfigMock).mockReturnValue({ channels: { diff --git a/src/auto-reply/reply/get-reply.test-mocks.ts b/src/auto-reply/reply/get-reply.test-mocks.ts index 3d542cf5f3e..b79e107ea5a 100644 --- a/src/auto-reply/reply/get-reply.test-mocks.ts +++ b/src/auto-reply/reply/get-reply.test-mocks.ts @@ -38,7 +38,7 @@ vi.mock("../../channels/model-overrides.js", () => ({ })); vi.mock("../../config/config.js", () => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), })); vi.mock("../../runtime.js", () => ({ diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 05bd159ed8f..6f17e3cc654 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -9,7 +9,7 @@ import { resolveModelRefFromString } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js"; import { resolveChannelModelOverride } from "../../channels/model-overrides.js"; -import { type OpenClawConfig, loadConfig } from "../../config/config.js"; +import { type OpenClawConfig, getRuntimeConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; @@ -161,7 +161,7 @@ export async function getReplyFromConfig( ): Promise { const isFastTestEnv = process.env.OPENCLAW_TEST_FAST === "1"; const cfg = resolveGetReplyConfig({ - loadConfig, + getRuntimeConfig, isFastTestEnv, configOverride, }); diff --git a/src/auto-reply/reply/runtime-plugins.runtime.ts b/src/auto-reply/reply/runtime-plugins.runtime.ts new file mode 100644 index 00000000000..9a7872300f9 --- /dev/null +++ b/src/auto-reply/reply/runtime-plugins.runtime.ts @@ -0,0 +1 @@ +export { ensureRuntimePluginsLoaded } from "../../agents/runtime-plugins.js"; diff --git a/src/auto-reply/reply/session-usage.ts b/src/auto-reply/reply/session-usage.ts index 9afede8f6ab..cd937387500 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -4,7 +4,7 @@ import { hasNonzeroUsage, type NormalizedUsage, } from "../../agents/usage.js"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { type SessionSystemPromptReport, type SessionEntry, @@ -96,7 +96,7 @@ export async function persistSessionUsageUpdate(params: { } const label = params.logLabel ? `${params.logLabel} ` : ""; - const cfg = params.cfg ?? loadConfig(); + const cfg = params.cfg ?? getRuntimeConfig(); const hasUsage = hasNonzeroUsage(params.usage); const hasPromptTokens = typeof params.promptTokens === "number" && diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 24cfbb27ad3..059e75639d8 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -127,6 +127,7 @@ vi.mock("../runtime.js", () => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: mocks.loadConfig as typeof import("../config/config.js").getRuntimeConfig, loadConfig: mocks.loadConfig as typeof import("../config/config.js").loadConfig, })); diff --git a/src/cli/capability-cli.ts b/src/cli/capability-cli.ts index a5ba86618b7..1873f49bfa4 100644 --- a/src/cli/capability-cli.ts +++ b/src/cli/capability-cli.ts @@ -13,7 +13,7 @@ import { import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js"; import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; @@ -573,7 +573,7 @@ async function runModelRun(params: { model?: string; transport: CapabilityTransport; }) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const agentId = resolveDefaultAgentId(cfg); if (params.transport === "local") { const result = await agentCommand( @@ -646,7 +646,7 @@ async function runModelRun(params: { } async function buildModelProviders() { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const catalog = await loadModelCatalog({ config: cfg }); const selectedProvider = resolveSelectedProviderFromModelRef( resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model), @@ -700,7 +700,7 @@ async function runModelAuthStatus() { } async function runModelAuthLogout(provider: string) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const agentDir = resolveAgentDir(cfg, resolveDefaultAgentId(cfg)); const store = loadAuthProfileStoreForRuntime(agentDir); const profileIds = listProfilesForProvider(store, provider); @@ -753,7 +753,7 @@ async function runImageGenerate(params: { output?: string; timeoutMs?: number; }) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const agentDir = resolveAgentDir(cfg, resolveDefaultAgentId(cfg)); const inputImages = params.file && params.file.length > 0 @@ -820,7 +820,7 @@ async function runImageDescribe(params: { files: string[]; model?: string; }) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const agentDir = resolveAgentDir(cfg, resolveDefaultAgentId(cfg)); const activeModel = requireProviderModelOverride(params.model); const outputs = await Promise.all( @@ -869,7 +869,7 @@ async function runAudioTranscribe(params: { model?: string; prompt?: string; }) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const activeModel = requireProviderModelOverride(params.model); const result = await transcribeAudioFile({ filePath: path.resolve(params.file), @@ -959,7 +959,7 @@ async function runVideoGenerate(params: { watermark?: boolean; timeoutMs?: number; }) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const agentDir = resolveAgentDir(cfg, resolveDefaultAgentId(cfg)); const result = await generateVideo({ cfg, @@ -1034,7 +1034,7 @@ async function runVideoGenerate(params: { } async function runVideoDescribe(params: { file: string; model?: string }) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const activeModel = requireProviderModelOverride(params.model); const result = await describeVideoFile({ filePath: path.resolve(params.file), @@ -1065,7 +1065,9 @@ async function runTtsConvert(params: { transport: CapabilityTransport; }) { if (params.transport === "gateway") { - const gatewayConnection = buildGatewayConnectionDetailsWithResolvers({ config: loadConfig() }); + const gatewayConnection = buildGatewayConnectionDetailsWithResolvers({ + config: getRuntimeConfig(), + }); const result: { audioPath?: string; provider?: string; @@ -1111,7 +1113,7 @@ async function runTtsConvert(params: { } satisfies CapabilityEnvelope; } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const overrides = resolveExplicitTtsOverrides({ cfg, provider: params.provider, @@ -1157,7 +1159,7 @@ async function runTtsConvert(params: { } async function runTtsProviders(transport: CapabilityTransport) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); if (transport === "gateway") { const payload: { providers?: Array>; @@ -1209,7 +1211,7 @@ async function runTtsPersonas(transport: CapabilityTransport) { timeoutMs: 30_000, }); } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const config = resolveTtsConfig(cfg); const prefsPath = resolveTtsPrefsPath(config); const active = getTtsPersona(config, prefsPath); @@ -1227,7 +1229,7 @@ async function runTtsPersonas(transport: CapabilityTransport) { } async function runTtsVoices(providerRaw?: string) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const config = resolveTtsConfig(cfg); const prefsPath = resolveTtsPrefsPath(config); const provider = normalizeOptionalString(providerRaw) || getTtsProvider(config, prefsPath); @@ -1266,7 +1268,7 @@ async function runTtsStateMutation(params: { return payload; } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const config = resolveTtsConfig(cfg); const prefsPath = resolveTtsPrefsPath(config); if (params.capability === "tts.enable") { @@ -1303,7 +1305,7 @@ async function runTtsStateMutation(params: { } async function runWebSearchCommand(params: { query: string; provider?: string; limit?: number }) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const result = await runWebSearch({ config: cfg, providerId: params.provider, @@ -1324,7 +1326,7 @@ async function runWebSearchCommand(params: { query: string; provider?: string; l } async function runWebFetchCommand(params: { url: string; provider?: string; format?: string }) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolved = resolveWebFetchDefinition({ config: cfg, providerId: params.provider, @@ -1352,7 +1354,7 @@ async function runMemoryEmbeddingCreate(params: { model?: string; }) { ensureMemoryEmbeddingProvidersRegistered(); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const modelRef = resolveModelRefOverride(params.model); const requestedProvider = normalizeOptionalString(params.provider) || modelRef.provider || "auto"; const result = await createEmbeddingProvider({ @@ -1477,7 +1479,7 @@ export function registerCapabilityCli(program: Command) { .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { - const result = await loadModelCatalog({ config: loadConfig() }); + const result = await loadModelCatalog({ config: getRuntimeConfig() }); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, providerSummaryText); }); }); @@ -1490,7 +1492,7 @@ export function registerCapabilityCli(program: Command) { .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { const target = normalizeStringifiedOptionalString(opts.model) ?? ""; - const catalog = await loadModelCatalog({ config: loadConfig() }); + const catalog = await loadModelCatalog({ config: getRuntimeConfig() }); const entry = catalog.find((candidate) => `${candidate.provider}/${candidate.id}` === target) ?? catalog.find((candidate) => candidate.id === target); @@ -1673,7 +1675,7 @@ export function registerCapabilityCli(program: Command) { .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const selectedProvider = resolveSelectedProviderFromModelRef( resolveAgentModelPrimaryValue(cfg.agents?.defaults?.imageGenerationModel), ); @@ -1721,7 +1723,7 @@ export function registerCapabilityCli(program: Command) { .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const providers = [...buildMediaUnderstandingRegistry(undefined, cfg).values()] .filter((provider) => provider.capabilities?.includes("audio")) .map((provider) => ({ @@ -1998,7 +2000,7 @@ export function registerCapabilityCli(program: Command) { .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const selectedGenerationProvider = resolveSelectedProviderFromModelRef( resolveAgentModelPrimaryValue(cfg.agents?.defaults?.videoGenerationModel), ); @@ -2076,7 +2078,7 @@ export function registerCapabilityCli(program: Command) { .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const selectedSearchProvider = typeof cfg.tools?.web?.search?.provider === "string" ? normalizeLowercaseStringOrEmpty(cfg.tools.web.search.provider) @@ -2134,7 +2136,7 @@ export function registerCapabilityCli(program: Command) { .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { ensureMemoryEmbeddingProvidersRegistered(); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const agentId = resolveDefaultAgentId(cfg); const resolvedMemory = resolveMemorySearchConfig(cfg, agentId); const selectedProvider = diff --git a/src/cli/channel-auth.test.ts b/src/cli/channel-auth.test.ts index 1acb60e875c..a4c544779d6 100644 --- a/src/cli/channel-auth.test.ts +++ b/src/cli/channel-auth.test.ts @@ -46,6 +46,7 @@ vi.mock("../channels/plugins/index.js", () => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: mocks.loadConfig, loadConfig: mocks.loadConfig, readConfigFileSnapshot: mocks.readConfigFileSnapshot, replaceConfigFile: mocks.replaceConfigFile, diff --git a/src/cli/channel-auth.ts b/src/cli/channel-auth.ts index 558c18b6cb4..41dedefc580 100644 --- a/src/cli/channel-auth.ts +++ b/src/cli/channel-auth.ts @@ -5,7 +5,7 @@ import { normalizeChannelId, } from "../channels/plugins/index.js"; import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js"; -import { loadConfig, readConfigFileSnapshot, type OpenClawConfig } from "../config/config.js"; +import { getRuntimeConfig, readConfigFileSnapshot, type OpenClawConfig } from "../config/config.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { callGateway } from "../gateway/call.js"; import { setVerbose } from "../globals.js"; @@ -174,7 +174,7 @@ export async function runChannelLogin( ) { const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); const autoEnabled = applyPluginAutoEnable({ - config: loadConfig(), + config: getRuntimeConfig(), env: process.env, }); const loadedCfg = autoEnabled.config; @@ -217,7 +217,7 @@ export async function runChannelLogout( ) { const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); const autoEnabled = applyPluginAutoEnable({ - config: loadConfig(), + config: getRuntimeConfig(), env: process.env, }); const loadedCfg = autoEnabled.config; diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 1099d4c3e66..92e026fd46d 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -7,7 +7,7 @@ const resolveNodeStartupTlsEnvironmentMock = vi.hoisted(() => vi.fn()); const loadConfigMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789)); -const writeConfigFileMock = vi.hoisted(() => vi.fn()); +const replaceConfigFileMock = vi.hoisted(() => vi.fn()); const resolveIsNixModeMock = vi.hoisted(() => vi.fn(() => false)); const resolveSecretInputRefMock = vi.hoisted(() => vi.fn((_value?: unknown): { ref: unknown } => ({ ref: undefined })), @@ -93,7 +93,7 @@ vi.mock("../../commands/gateway-install-token.persist.runtime.js", () => ({ snapshot: await readConfigFileSnapshotMock(), writeOptions: { expectedConfigPath: "/tmp/openclaw.json" }, })), - writeConfigFile: writeConfigFileMock, + replaceConfigFile: replaceConfigFileMock, })); vi.mock("../../config/types.secrets.js", () => ({ @@ -190,7 +190,7 @@ describe("runDaemonInstall", () => { resolveNodeStartupTlsEnvironmentMock.mockReset(); readConfigFileSnapshotMock.mockReset(); resolveGatewayPortMock.mockClear(); - writeConfigFileMock.mockReset(); + replaceConfigFileMock.mockReset(); resolveIsNixModeMock.mockReset(); resolveSecretInputRefMock.mockReset(); resolveGatewayAuthMock.mockReset(); @@ -266,7 +266,7 @@ describe("runDaemonInstall", () => { expect(actionState.failed).toEqual([]); expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); expectFirstInstallPlanCallOmitsToken(); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); expect( actionState.warnings.some((warning) => warning.includes("gateway.auth.token is SecretRef-managed"), @@ -300,11 +300,11 @@ describe("runDaemonInstall", () => { await runDaemonInstall({ json: true }); expect(actionState.failed).toEqual([]); - expect(writeConfigFileMock).toHaveBeenCalledTimes(1); - const writtenConfig = writeConfigFileMock.mock.calls[0]?.[0] as { - gateway?: { auth?: { token?: string } }; + expect(replaceConfigFileMock).toHaveBeenCalledTimes(1); + const writeParams = replaceConfigFileMock.mock.calls[0]?.[0] as { + nextConfig?: { gateway?: { auth?: { token?: string } } }; }; - expect(writtenConfig.gateway?.auth?.token).toBe("minted-token"); + expect(writeParams.nextConfig?.gateway?.auth?.token).toBe("minted-token"); expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith( expect.objectContaining({ port: 18789 }), ); @@ -408,7 +408,7 @@ describe("runDaemonInstall", () => { await runDaemonInstall({ json: true }); expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled(); expect(actionState.emitted.at(-1)).toMatchObject({ result: "already-installed" }); }); diff --git a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts index 609ab259723..f83c7e5714b 100644 --- a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts @@ -11,6 +11,7 @@ const readConfigFileSnapshotMock = vi.fn(); const loadConfig = vi.fn(() => ({})); vi.mock("../../config/config.js", () => ({ + getRuntimeConfig: () => loadConfig(), loadConfig: () => loadConfig(), readConfigFileSnapshot: () => readConfigFileSnapshotMock(), })); diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 3fcdd4f9648..ba132e936b7 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -20,6 +20,7 @@ const writeGatewayRestartIntentSync = vi.fn(); const clearGatewayRestartIntentSync = vi.fn(); vi.mock("../../config/config.js", () => ({ + getRuntimeConfig: () => loadConfig(), loadConfig: () => loadConfig(), readBestEffortConfig: async () => loadConfig(), })); diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index eb37d4d0929..df625839b61 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -54,6 +54,7 @@ const loadConfig = vi.hoisted(() => vi.fn(() => ({}))); const recoverInstalledLaunchAgent = vi.hoisted(() => vi.fn()); vi.mock("../../config/config.js", () => ({ + getRuntimeConfig: () => loadConfig(), loadConfig: () => loadConfig(), readBestEffortConfig: async () => loadConfig(), resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env), diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index f8b404ac425..c64b78b8cd7 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -98,6 +98,7 @@ vi.mock("../../config/config.js", () => ({ }, }; }, + getRuntimeConfig: () => cliLoadedConfig, loadConfig: () => cliLoadedConfig, resolveConfigPath: (env: NodeJS.ProcessEnv, stateDir: string) => resolveConfigPath(env, stateDir), resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env), diff --git a/src/cli/directory-cli.test.ts b/src/cli/directory-cli.test.ts index 35c29ff3617..84ec9b19123 100644 --- a/src/cli/directory-cli.test.ts +++ b/src/cli/directory-cli.test.ts @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: mocks.loadConfig, loadConfig: mocks.loadConfig, readConfigFileSnapshot: mocks.readConfigFileSnapshot, replaceConfigFile: mocks.replaceConfigFile, diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts index 03d60c5e479..b4547e50df7 100644 --- a/src/cli/directory-cli.ts +++ b/src/cli/directory-cli.ts @@ -2,7 +2,7 @@ import type { Command } from "commander"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelPlugin } from "../channels/plugins/index.js"; import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js"; -import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; +import { getRuntimeConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { danger } from "../globals.js"; import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js"; @@ -105,7 +105,7 @@ export function registerDirectoryCli(program: Command) { const resolve = async (opts: { channel?: string; account?: string }) => { const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); const autoEnabled = applyPluginAutoEnable({ - config: loadConfig(), + config: getRuntimeConfig(), env: process.env, }); let cfg = autoEnabled.config; diff --git a/src/cli/dns-cli.ts b/src/cli/dns-cli.ts index 4ae38c5f040..04a6c7bf477 100644 --- a/src/cli/dns-cli.ts +++ b/src/cli/dns-cli.ts @@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import type { Command } from "commander"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; import { getWideAreaZonePath, resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; import { defaultRuntime } from "../runtime.js"; @@ -120,7 +120,7 @@ export function registerDnsCli(program: Command) { false, ) .action(async (opts) => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const tailnetIPv4 = pickPrimaryTailnetIPv4(); const tailnetIPv6 = pickPrimaryTailnetIPv6(); const wideAreaDomain = resolveWideAreaDiscoveryDomain({ diff --git a/src/cli/gateway-cli/dev.ts b/src/cli/gateway-cli/dev.ts index 2c9b40dde26..0f2b3e7cd85 100644 --- a/src/cli/gateway-cli/dev.ts +++ b/src/cli/gateway-cli/dev.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { resolveWorkspaceTemplateDir } from "../../agents/workspace-templates.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { handleReset } from "../../commands/onboard-helpers.js"; -import { createConfigIO, writeConfigFile } from "../../config/config.js"; +import { createConfigIO, replaceConfigFile } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import { resolveUserPath, shortenHomePath } from "../../utils.js"; @@ -101,29 +101,32 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) { return; } - await writeConfigFile({ - gateway: { - mode: "local", - bind: "loopback", - }, - agents: { - defaults: { - workspace, - skipBootstrap: true, + await replaceConfigFile({ + nextConfig: { + gateway: { + mode: "local", + bind: "loopback", }, - list: [ - { - id: "dev", - default: true, + agents: { + defaults: { workspace, - identity: { - name: DEV_IDENTITY_NAME, - theme: DEV_IDENTITY_THEME, - emoji: DEV_IDENTITY_EMOJI, - }, + skipBootstrap: true, }, - ], + list: [ + { + id: "dev", + default: true, + workspace, + identity: { + name: DEV_IDENTITY_NAME, + theme: DEV_IDENTITY_THEME, + emoji: DEV_IDENTITY_EMOJI, + }, + }, + ], + }, }, + afterWrite: { mode: "auto" }, }); await ensureDevWorkspace(workspace); defaultRuntime.log(`Dev config ready: ${shortenHomePath(configPath)}`); diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index fff72a91fd4..17e2e5b545a 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -105,6 +105,7 @@ vi.mock("../../agents/pi-embedded-runner/runs.js", () => ({ })); vi.mock("../../config/config.js", () => ({ + getRuntimeConfig: () => loadConfig(), loadConfig: () => loadConfig(), })); diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index f42e3199043..83b2784b6cb 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -4,7 +4,7 @@ import { getActiveEmbeddedRunCount, waitForActiveEmbeddedRuns, } from "../../agents/pi-embedded-runner/runs.js"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import type { startGatewayServer } from "../../gateway/server.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { acquireGatewayLock } from "../../infra/gateway-lock.js"; @@ -250,7 +250,7 @@ export async function runGatewayLoop(params: { const SHUTDOWN_TIMEOUT_MS = SUPERVISOR_STOP_TIMEOUT_MS - 5_000; const resolveRestartDrainTimeoutMs = (): RestartDrainTimeoutMs => { try { - const timeoutMs = loadConfig().gateway?.reload?.deferralTimeoutMs; + const timeoutMs = getRuntimeConfig().gateway?.reload?.deferralTimeoutMs; return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined; diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 9b0ba6c3210..9864ff91b98 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; +import { getRuntimeConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildWorkspaceHookStatus, @@ -469,7 +469,7 @@ export function registerHooksCli(program: Command): void { .option("-v, --verbose", "Show more details including missing requirements", false) .action(async (opts) => runHooksCliAction(async () => { - const config = loadConfig(); + const config = getRuntimeConfig(); const report = buildHooksReport(config); writeHooksOutput(formatHooksList(report, opts), opts.json); }), @@ -481,7 +481,7 @@ export function registerHooksCli(program: Command): void { .option("--json", "Output as JSON", false) .action(async (name, opts) => runHooksCliAction(async () => { - const config = loadConfig(); + const config = getRuntimeConfig(); const report = buildHooksReport(config); writeHooksOutput(formatHookInfo(report, name, opts), opts.json); }), @@ -493,7 +493,7 @@ export function registerHooksCli(program: Command): void { .option("--json", "Output as JSON", false) .action(async (opts) => runHooksCliAction(async () => { - const config = loadConfig(); + const config = getRuntimeConfig(); const report = buildHooksReport(config); writeHooksOutput(formatHooksCheck(report, opts), opts.json); }), @@ -558,7 +558,7 @@ export function registerHooksCli(program: Command): void { hooks.action(async () => runHooksCliAction(async () => { - const config = loadConfig(); + const config = getRuntimeConfig(); const report = buildHooksReport(config); defaultRuntime.log(formatHooksList(report, {})); }), diff --git a/src/cli/pairing-cli.test.ts b/src/cli/pairing-cli.test.ts index d0ac8095d59..793e2c1b15b 100644 --- a/src/cli/pairing-cli.test.ts +++ b/src/cli/pairing-cli.test.ts @@ -54,6 +54,7 @@ vi.mock("../channels/plugins/index.js", () => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: vi.fn().mockReturnValue({}), loadConfig: vi.fn().mockReturnValue({}), })); diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index d2ff4c2cc4a..6b80ed3f480 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import { normalizeChannelId } from "../channels/plugins/index.js"; import { listPairingChannels, notifyPairingApproved } from "../channels/plugins/pairing.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolvePairingIdLabel } from "../pairing/pairing-labels.js"; import { approveChannelPairingCode, listChannelPairingRequests } from "../pairing/pairing-store.js"; import type { PairingChannel } from "../pairing/pairing-store.types.js"; @@ -38,7 +38,7 @@ function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel } async function notifyApproved(channel: PairingChannel, id: string) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); await notifyPairingApproved({ channelId: channel, id, cfg }); } diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 897364c4568..663ae0fc234 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import type { Mock } from "vitest"; import { vi } from "vitest"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { createEmptyUninstallActions } from "../plugins/uninstall.js"; @@ -117,6 +118,7 @@ vi.mock("../runtime.js", () => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: () => loadConfig(), loadConfig: () => loadConfig(), readConfigFileSnapshot: (( ...args: Parameters<(typeof import("../config/config.js"))["readConfigFileSnapshot"]> @@ -549,7 +551,7 @@ export function resetPluginsCliTestState() { loadConfig.mockReturnValue({} as OpenClawConfig); readConfigFileSnapshot.mockImplementation(async () => { - const config = loadConfig(); + const config = getRuntimeConfig(); return { path: "/tmp/openclaw-config.json5", exists: true, diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 0387ce3f923..76321d30604 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -1,7 +1,7 @@ import os from "node:os"; import path from "node:path"; import type { Command } from "commander"; -import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; +import { getRuntimeConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; @@ -147,7 +147,7 @@ export function registerPluginsCli(program: Command) { .option("--verbose", "Show detailed entries", false) .action(async (opts: PluginsListOptions) => { const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const report = buildPluginRegistrySnapshotReport({ config: cfg, ...(opts.json ? { logger: quietPluginJsonLogger } : {}), @@ -262,7 +262,7 @@ export function registerPluginsCli(program: Command) { } = await import("../plugins/status.js"); const { loadInstalledPluginIndexInstallRecords } = await import("../plugins/installed-plugin-index-records.js"); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const installRecords = await loadInstalledPluginIndexInstallRecords(); const report = buildPluginDiagnosticsReport({ config: cfg, @@ -774,7 +774,7 @@ export function registerPluginsCli(program: Command) { .action(async (opts: PluginRegistryOptions) => { const { inspectPluginRegistry, refreshPluginRegistry } = await import("../plugins/plugin-registry.js"); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); if (opts.refresh) { const index = await refreshPluginRegistry({ diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 0b55d313f64..950d1ddd6b0 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -1,4 +1,4 @@ -import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; +import { getRuntimeConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; import { updateNpmInstalledHookPacks } from "../hooks/update.js"; import { loadInstalledPluginIndexInstallRecords, @@ -22,7 +22,7 @@ export async function runPluginUpdateCommand(params: { opts: { all?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean }; }) { const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); const cfgWithPluginInstallRecords = withPluginInstallRecords(cfg, pluginInstallRecords); const logger = { diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 2d1d4895774..d40adb4ef1e 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -23,7 +23,10 @@ vi.mock("../runtime.js", async () => { runtime, ); }); -vi.mock("../config/config.js", () => ({ loadConfig: mocks.loadConfig })); +vi.mock("../config/config.js", () => ({ + getRuntimeConfig: mocks.loadConfig, + loadConfig: mocks.loadConfig, +})); vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWithTimeout })); vi.mock("../media/qr-terminal.ts", () => ({ renderQrTerminal: mocks.renderTerminal, diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index 1f65ae82987..329cf0cba83 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { trimToUndefined } from "../gateway/credentials.js"; @@ -119,7 +119,7 @@ export function registerQrCli(program: Command) { const password = trimToUndefined(opts.password) ?? ""; const wantsRemote = opts.remote === true; - const loadedRaw = loadConfig(); + const loadedRaw = getRuntimeConfig(); if (wantsRemote && !opts.url && !opts.publicUrl) { const tailscaleMode = loadedRaw.gateway?.tailscale?.mode ?? "off"; const remoteUrl = loadedRaw.gateway?.remote?.url; diff --git a/src/cli/qr-dashboard.integration.test.ts b/src/cli/qr-dashboard.integration.test.ts index 8490bcefa35..77d97c74384 100644 --- a/src/cli/qr-dashboard.integration.test.ts +++ b/src/cli/qr-dashboard.integration.test.ts @@ -19,6 +19,7 @@ vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + getRuntimeConfig: loadConfigMock, loadConfig: loadConfigMock, readConfigFileSnapshot: readConfigFileSnapshotMock, resolveGatewayPort: resolveGatewayPortMock, diff --git a/src/cli/security-cli.test.ts b/src/cli/security-cli.test.ts index c24780a02e8..1e110960d13 100644 --- a/src/cli/security-cli.test.ts +++ b/src/cli/security-cli.test.ts @@ -27,6 +27,7 @@ const { } = mocks; vi.mock("../config/config.js", () => ({ + getRuntimeConfig: () => mocks.loadConfig(), loadConfig: () => mocks.loadConfig(), })); diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts index 18a541c0ddc..3a9d7b9eeee 100644 --- a/src/cli/security-cli.ts +++ b/src/cli/security-cli.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { defaultRuntime } from "../runtime.js"; import { runSecurityAudit } from "../security/audit.js"; import { fixSecurityFootguns } from "../security/fix.js"; @@ -65,7 +65,7 @@ export function registerSecurityCli(program: Command) { const password = normalizeOptionalString(opts.password); const fixResult = opts.fix ? await fixSecurityFootguns().catch((_err) => null) : null; - const sourceConfig = loadConfig(); + const sourceConfig = getRuntimeConfig(); const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = await resolveCommandSecretRefsViaGateway({ config: sourceConfig, diff --git a/src/cli/send-runtime/channel-outbound-send.ts b/src/cli/send-runtime/channel-outbound-send.ts index bd51071e7dd..47d7c0c44fe 100644 --- a/src/cli/send-runtime/channel-outbound-send.ts +++ b/src/cli/send-runtime/channel-outbound-send.ts @@ -1,6 +1,6 @@ import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; import type { ChannelId } from "../../channels/plugins/types.public.js"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { OutboundMediaAccess } from "../../media/load-options.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -42,7 +42,7 @@ export function createChannelOutboundRuntimeSend(params: { const threadId = resolveRuntimeThreadId(opts); const replyToId = resolveRuntimeReplyToId(opts); const buildContext = () => ({ - cfg: opts.cfg ?? loadConfig(), + cfg: opts.cfg ?? getRuntimeConfig(), to, text, mediaUrl: opts.mediaUrl, diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 73f7facb2de..13f2aa54db9 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -105,6 +105,7 @@ vi.mock("../runtime.js", () => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: () => mocks.loadConfigMock(), loadConfig: () => mocks.loadConfigMock(), })); diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index ff7e60f8f91..909efaa2782 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -6,7 +6,7 @@ import { searchSkillsFromClawHub, updateSkillsFromClawHub, } from "../agents/skills-clawhub.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { defaultRuntime } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { formatDocsLink } from "../terminal/links.js"; @@ -25,7 +25,7 @@ type SkillStatusReport = Awaited< >; async function loadSkillsStatusReport(): Promise { - const config = loadConfig(); + const config = getRuntimeConfig(); const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); const { buildWorkspaceSkillStatus } = await import("../agents/skills-status.js"); return buildWorkspaceSkillStatus(workspaceDir, { config }); @@ -42,7 +42,7 @@ async function runSkillsAction(render: (report: SkillStatusReport) => string): P } function resolveActiveWorkspaceDir(): string { - const config = loadConfig(); + const config = getRuntimeConfig(); return resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); } diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts index 80221e59420..58983424abf 100644 --- a/src/commands/agent-via-gateway.test.ts +++ b/src/commands/agent-via-gateway.test.ts @@ -78,7 +78,7 @@ function mockLocalAgentReply(text = "local") { }); } -vi.mock("../config/config.js", () => ({ loadConfig })); +vi.mock("../config/config.js", () => ({ getRuntimeConfig: loadConfig, loadConfig })); vi.mock("../gateway/call.js", () => ({ callGateway, randomIdempotencyKey: () => "idem-1", diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index 3428b1b2b93..336f986ab4f 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -3,7 +3,7 @@ import { listAgentIds } from "../agents/agent-scope.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { CliDeps } from "../cli/deps.types.js"; import { withProgress } from "../cli/progress.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; @@ -102,7 +102,7 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim throw new Error("Pass --to , --session-id, or --agent to choose a session"); } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const agentIdRaw = opts.agent?.trim(); const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined; if (agentId) { diff --git a/src/commands/agent.acp.test.ts b/src/commands/agent.acp.test.ts index 7ae55c32328..2a79bb85d97 100644 --- a/src/commands/agent.acp.test.ts +++ b/src/commands/agent.acp.test.ts @@ -140,7 +140,9 @@ function createAcpEnabledConfig(home: string, storePath: string): OpenClawConfig } function mockConfig(home: string, storePath: string) { - loadConfigSpy.mockReturnValue(createAcpEnabledConfig(home, storePath)); + const cfg = createAcpEnabledConfig(home, storePath); + loadConfigSpy.mockReturnValue(cfg); + configIoModule.setRuntimeConfigSnapshot(cfg, cfg); } function mockConfigWithAcpOverrides( @@ -154,6 +156,7 @@ function mockConfigWithAcpOverrides( ...acpOverrides, }; loadConfigSpy.mockReturnValue(cfg); + configIoModule.setRuntimeConfigSnapshot(cfg, cfg); } function writeAcpSessionStore(storePath: string, agent = "codex") { diff --git a/src/commands/agent.runtime-config.test.ts b/src/commands/agent.runtime-config.test.ts index 68995c26f0d..0872429570f 100644 --- a/src/commands/agent.runtime-config.test.ts +++ b/src/commands/agent.runtime-config.test.ts @@ -24,6 +24,7 @@ const readConfigFileSnapshotForWriteMock = vi.hoisted(() => vi.fn<() => Promise>(), ); vi.mock("../config/io.js", () => ({ + getRuntimeConfig: loadConfigMock, loadConfig: loadConfigMock, readConfigFileSnapshotForWrite: readConfigFileSnapshotForWriteMock, })); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index b0ec26c4cdb..9f33383176a 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -27,6 +27,7 @@ const configIoMocks = vi.hoisted(() => ({ })); vi.mock("../config/io.js", () => ({ + getRuntimeConfig: configIoMocks.loadConfig, loadConfig: configIoMocks.loadConfig, readConfigFileSnapshotForWrite: configIoMocks.readConfigFileSnapshotForWrite, })); diff --git a/src/commands/agents.bind.test-support.ts b/src/commands/agents.bind.test-support.ts index 05e85294e46..67f4ac57ad7 100644 --- a/src/commands/agents.bind.test-support.ts +++ b/src/commands/agents.bind.test-support.ts @@ -19,6 +19,8 @@ export const replaceConfigFileMock: Mock<(...args: unknown[]) => Promise Promise>; diff --git a/src/commands/channels.resolve.test.ts b/src/commands/channels.resolve.test.ts index daad7185398..5eb06bd07f9 100644 --- a/src/commands/channels.resolve.test.ts +++ b/src/commands/channels.resolve.test.ts @@ -23,6 +23,7 @@ vi.mock("../cli/command-secret-targets.js", () => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: mocks.loadConfig, loadConfig: mocks.loadConfig, readConfigFileSnapshot: mocks.readConfigFileSnapshot, replaceConfigFile: mocks.replaceConfigFile, diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index fc09709c37f..d184b1ed3e1 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -7,7 +7,11 @@ import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolu import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; -import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../../config/config.js"; +import { + getRuntimeConfig, + readConfigFileSnapshot, + replaceConfigFile, +} from "../../config/config.js"; import { danger } from "../../globals.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; @@ -115,7 +119,7 @@ function formatResolveResult(result: ResolveResult): string { export async function channelsResolveCommand(opts: ChannelsResolveOptions, runtime: RuntimeEnv) { const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); - const loadedRaw = loadConfig(); + const loadedRaw = getRuntimeConfig(); let { effectiveConfig: cfg } = await resolveCommandConfigWithSecrets({ config: loadedRaw, commandName: "channels resolve", diff --git a/src/commands/cleanup-plan.ts b/src/commands/cleanup-plan.ts index 6c32832ae8f..d43bad651ee 100644 --- a/src/commands/cleanup-plan.ts +++ b/src/commands/cleanup-plan.ts @@ -1,5 +1,5 @@ import { - loadConfig, + getRuntimeConfig, resolveConfigPath, resolveOAuthDir, resolveStateDir, @@ -16,7 +16,7 @@ export function resolveCleanupPlanFromDisk(): { oauthInsideState: boolean; workspaceDirs: string[]; } { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const stateDir = resolveStateDir(); const configPath = resolveConfigPath(); const oauthDir = resolveOAuthDir(); diff --git a/src/commands/configure.daemon.test.ts b/src/commands/configure.daemon.test.ts index d589e0cedda..8c074ac7017 100644 --- a/src/commands/configure.daemon.test.ts +++ b/src/commands/configure.daemon.test.ts @@ -24,6 +24,7 @@ vi.mock("../cli/progress.js", () => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: loadConfig, loadConfig, })); diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index 7f3d4d6e857..73fa212dc39 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -1,5 +1,5 @@ import { withProgress } from "../cli/progress.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js"; import { isNonFatalSystemdInstallProbeError } from "../daemon/systemd.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -99,7 +99,7 @@ export async function maybeInstallDaemon(params: { async (progress) => { progress.setLabel("Preparing Gateway service…"); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const tokenResolution = await resolveGatewayInstallToken({ config: cfg, env: process.env, diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 75d0c658215..c9c232ebb62 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -27,7 +27,7 @@ const mocks = vi.hoisted(() => ({ readCommand: vi.fn(), stage: vi.fn(), install: vi.fn(), - writeConfigFile: vi.fn().mockResolvedValue(undefined), + replaceConfigFile: vi.fn().mockResolvedValue(undefined), auditGatewayServiceConfig: vi.fn(), buildGatewayInstallPlan: vi.fn(), resolveGatewayAuthTokenForService: vi.fn(), @@ -48,7 +48,7 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, - writeConfigFile: mocks.writeConfigFile, + replaceConfigFile: mocks.replaceConfigFile, }; }); @@ -280,7 +280,7 @@ describe("maybeRepairGatewayServiceConfig", () => { }), }), ); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); expect(mocks.stage).not.toHaveBeenCalled(); expect(mocks.install).toHaveBeenCalledTimes(1); }); @@ -386,13 +386,16 @@ describe("maybeRepairGatewayServiceConfig", () => { }), }), ); - expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect(mocks.replaceConfigFile).toHaveBeenCalledWith( expect.objectContaining({ - gateway: expect.objectContaining({ - auth: expect.objectContaining({ - token: "env-token", + nextConfig: expect.objectContaining({ + gateway: expect.objectContaining({ + auth: expect.objectContaining({ + token: "env-token", + }), }), }), + afterWrite: { mode: "auto" }, }), ); expect(mocks.stage).not.toHaveBeenCalled(); @@ -609,13 +612,16 @@ describe("maybeRepairGatewayServiceConfig", () => { expectedGatewayToken: undefined, }), ); - expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect(mocks.replaceConfigFile).toHaveBeenCalledWith( expect.objectContaining({ - gateway: expect.objectContaining({ - auth: expect.objectContaining({ - token: "stale-token", + nextConfig: expect.objectContaining({ + gateway: expect.objectContaining({ + auth: expect.objectContaining({ + token: "stale-token", + }), }), }), + afterWrite: { mode: "auto" }, }), ); expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith( @@ -666,7 +672,7 @@ describe("maybeRepairGatewayServiceConfig", () => { }), ); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); expect(mocks.stage).toHaveBeenCalledTimes(1); expect(mocks.install).not.toHaveBeenCalled(); }, @@ -705,7 +711,7 @@ describe("maybeRepairGatewayServiceConfig", () => { await runRepair(cfg); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith( expect.objectContaining({ config: cfg, @@ -735,7 +741,7 @@ describe("maybeRepairGatewayServiceConfig", () => { EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway service config", ); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); expect(mocks.stage).not.toHaveBeenCalled(); expect(mocks.install).not.toHaveBeenCalled(); }); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 1597cf9e5ba..8fc696a2913 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; -import { writeConfigFile, type OpenClawConfig } from "../config/config.js"; +import { replaceConfigFile, type OpenClawConfig } from "../config/config.js"; import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import { @@ -447,7 +447,10 @@ export async function maybeRepairGatewayServiceConfig( }, }; try { - await writeConfigFile(nextCfg); + await replaceConfigFile({ + nextConfig: nextCfg, + afterWrite: { mode: "auto" }, + }); cfgForServiceInstall = nextCfg; note( expectedGatewayToken diff --git a/src/commands/flows.test.ts b/src/commands/flows.test.ts index a67b68c8d8f..a077dbd9e7c 100644 --- a/src/commands/flows.test.ts +++ b/src/commands/flows.test.ts @@ -13,6 +13,7 @@ import { withTempDir } from "../test-helpers/temp-dir.js"; import { flowsCancelCommand, flowsListCommand, flowsShowCommand } from "./flows.js"; vi.mock("../config/config.js", () => ({ + getRuntimeConfig: vi.fn(() => ({})), loadConfig: vi.fn(() => ({})), })); diff --git a/src/commands/flows.ts b/src/commands/flows.ts index a8e61d742ce..d64d0bad47a 100644 --- a/src/commands/flows.ts +++ b/src/commands/flows.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { info } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -250,7 +250,7 @@ export async function flowsCancelCommand(opts: { lookup: string }, runtime: Runt return; } const result = await cancelFlowById({ - cfg: loadConfig(), + cfg: getRuntimeConfig(), flowId: flow.flowId, }); if (!result.found) { diff --git a/src/commands/gateway-install-token.persist.runtime.ts b/src/commands/gateway-install-token.persist.runtime.ts index e553c2c93c3..4f8a93ffa53 100644 --- a/src/commands/gateway-install-token.persist.runtime.ts +++ b/src/commands/gateway-install-token.persist.runtime.ts @@ -1,5 +1,2 @@ -export { - readConfigFileSnapshot, - readConfigFileSnapshotForWrite, - writeConfigFile, -} from "../config/io.js"; +export { readConfigFileSnapshot, readConfigFileSnapshotForWrite } from "../config/io.js"; +export { replaceConfigFile } from "../config/mutate.js"; diff --git a/src/commands/gateway-install-token.test.ts b/src/commands/gateway-install-token.test.ts index bd544e57069..4de873d93cc 100644 --- a/src/commands/gateway-install-token.test.ts +++ b/src/commands/gateway-install-token.test.ts @@ -4,7 +4,7 @@ import { resolveGatewayInstallToken } from "./gateway-install-token.js"; const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotForWriteMock = vi.hoisted(() => vi.fn()); -const writeConfigFileMock = vi.hoisted(() => vi.fn()); +const replaceConfigFileMock = vi.hoisted(() => vi.fn()); const resolveSecretInputRefMock = vi.hoisted(() => vi.fn((): { ref: unknown } => ({ ref: undefined })), ); @@ -32,7 +32,7 @@ const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token")); vi.mock("./gateway-install-token.persist.runtime.js", () => ({ readConfigFileSnapshot: readConfigFileSnapshotMock, readConfigFileSnapshotForWrite: readConfigFileSnapshotForWriteMock, - writeConfigFile: writeConfigFileMock, + replaceConfigFile: replaceConfigFileMock, })); vi.mock("../config/types.secrets.js", () => ({ @@ -158,7 +158,7 @@ describe("resolveGatewayInstallToken", () => { expect(result.unavailableReason).toContain("gateway.auth.mode is unset"); expect(result.unavailableReason).toContain("openclaw config set gateway.auth.mode token"); expect(result.unavailableReason).toContain("openclaw config set gateway.auth.mode password"); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); expect(resolveSecretRefValuesMock).not.toHaveBeenCalled(); }); @@ -176,7 +176,7 @@ describe("resolveGatewayInstallToken", () => { expect( result.warnings.some((message) => message.includes("without saving to config")), ).toBeTruthy(); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); it("persists auto-generated token when requested", async () => { @@ -190,18 +190,21 @@ describe("resolveGatewayInstallToken", () => { }); expect(result.warnings.some((message) => message.includes("saving to config"))).toBeTruthy(); - expect(writeConfigFileMock).toHaveBeenCalledWith( + expect(replaceConfigFileMock).toHaveBeenCalledWith( expect.objectContaining({ - gateway: { - auth: { - mode: "token", - token: "generated-token", + nextConfig: expect.objectContaining({ + gateway: { + auth: { + mode: "token", + token: "generated-token", + }, }, - }, - }), - expect.objectContaining({ - baseSnapshot: expect.any(Object), - skipRuntimeSnapshotRefresh: true, + }), + writeOptions: expect.objectContaining({ + baseSnapshot: expect.any(Object), + skipRuntimeSnapshotRefresh: true, + }), + afterWrite: { mode: "auto" }, }), ); }); @@ -236,7 +239,7 @@ describe("resolveGatewayInstallToken", () => { expect( result.warnings.some((message) => message.includes("skipping plaintext token persistence")), ).toBeTruthy(); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); it("does not auto-generate when inferred mode has password SecretRef configured", async () => { @@ -263,7 +266,7 @@ describe("resolveGatewayInstallToken", () => { expect(result.token).toBeUndefined(); expect(result.unavailableReason).toBeUndefined(); expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); it("passes the install env through to gateway auth resolution", async () => { @@ -295,7 +298,7 @@ describe("resolveGatewayInstallToken", () => { expect(result.token).toBeUndefined(); expect(result.unavailableReason).toBeUndefined(); expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false); - expect(writeConfigFileMock).not.toHaveBeenCalled(); + expect(replaceConfigFileMock).not.toHaveBeenCalled(); }); it("skips token SecretRef resolution when token auth is not required", async () => { diff --git a/src/commands/gateway-install-token.ts b/src/commands/gateway-install-token.ts index 56fa0acbcf7..b3a42816a01 100644 --- a/src/commands/gateway-install-token.ts +++ b/src/commands/gateway-install-token.ts @@ -10,7 +10,7 @@ import { resolveGatewayAuth } from "../gateway/auth.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { readConfigFileSnapshotForWrite, - writeConfigFile, + replaceConfigFile, } from "./gateway-install-token.persist.runtime.js"; import { randomToken } from "./random-token.js"; @@ -64,8 +64,8 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: { ? undefined : normalizeOptionalString(baseConfig.gateway.auth.token); if (!existingTokenRef && !baseConfigToken) { - await writeConfigFile( - { + await replaceConfigFile({ + nextConfig: { ...baseConfig, gateway: { ...baseConfig.gateway, @@ -76,13 +76,14 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: { }, }, }, - { + writeOptions: { baseSnapshot: snapshot, ...prepared.writeOptions, ...params.configWriteOptions, skipRuntimeSnapshotRefresh: true, }, - ); + afterWrite: { mode: "auto" }, + }); return params.token; } if (baseConfigToken) { diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 0f961619ea2..d9ff11c4aa3 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -27,6 +27,7 @@ type TelegramHealthAccount = { async function loadFreshHealthModulesForTest() { vi.doMock("../config/config.js", () => ({ + getRuntimeConfig: () => testConfig, loadConfig: () => testConfig, })); vi.doMock("../config/sessions.js", () => ({ diff --git a/src/commands/health.ts b/src/commands/health.ts index ea8fae7f849..9ff7b40a4ad 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -5,6 +5,7 @@ import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js"; import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import { withProgress } from "../cli/progress.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveStorePath } from "../config/sessions/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; @@ -256,8 +257,7 @@ export async function getHealthSnapshot(params?: { probe?: boolean; }): Promise { const timeoutMs = params?.timeoutMs; - const { loadConfig } = await loadConfigModule(); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const { defaultAgentId, ordered } = resolveAgentOrder(cfg); const channelBindings = buildChannelAccountBindings(cfg); const sessionCache = new Map(); diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index a975be8bcf8..4a3e5521185 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -12,6 +12,7 @@ type RunMessageActionParams = { let testConfig: Record = {}; const applyPluginAutoEnable = vi.hoisted(() => vi.fn(({ config }) => ({ config, changes: [] }))); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: () => testConfig, loadConfig: () => testConfig, })); diff --git a/src/commands/message.ts b/src/commands/message.ts index 7730879e403..b63e4a8571a 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -6,7 +6,7 @@ import { getScopedChannelsCommandSecretTargets } from "../cli/command-secret-tar import { resolveMessageSecretScope } from "../cli/message-secret-scope.js"; import { createOutboundSendDeps, type CliDeps } from "../cli/outbound-send-deps.js"; import { withProgress } from "../cli/progress.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; import { runMessageAction } from "../infra/outbound/message-action-runner.js"; @@ -31,7 +31,7 @@ export async function messageCommand( deps: CliDeps, runtime: RuntimeEnv, ) { - const loadedRaw = loadConfig(); + const loadedRaw = getRuntimeConfig(); const scope = resolveMessageSecretScope({ channel: opts.channel, target: opts.target, diff --git a/src/commands/migrate.test.ts b/src/commands/migrate.test.ts index 078c22f84ac..0184a54be4c 100644 --- a/src/commands/migrate.test.ts +++ b/src/commands/migrate.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: () => ({}), loadConfig: () => ({}), })); diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index dc104de837f..0588a3444c0 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -1,5 +1,5 @@ import { promptYesNo } from "../cli/prompt.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { redactMigrationPlan } from "../plugin-sdk/migration.js"; import { resolvePluginMigrationProviders } from "../plugins/migration-provider-runtime.js"; import type { MigrationApplyResult, MigrationPlan } from "../plugins/types.js"; @@ -17,11 +17,13 @@ import type { export type { MigrateApplyOptions, MigrateCommonOptions, MigrateDefaultOptions }; export async function migrateListCommand(runtime: RuntimeEnv, opts: { json?: boolean } = {}) { - const providers = resolvePluginMigrationProviders({ cfg: loadConfig() }).map((provider) => ({ - id: provider.id, - label: provider.label, - description: provider.description, - })); + const providers = resolvePluginMigrationProviders({ cfg: getRuntimeConfig() }).map( + (provider) => ({ + id: provider.id, + label: provider.label, + description: provider.description, + }), + ); if (opts.json) { writeRuntimeJson(runtime, { providers }); return; diff --git a/src/commands/migrate/context.ts b/src/commands/migrate/context.ts index b51dca7a3c6..575bcb62d1a 100644 --- a/src/commands/migrate/context.ts +++ b/src/commands/migrate/context.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { resolveStateDir } from "../../config/paths.js"; import type { MigrationProviderContext } from "../../plugins/types.js"; import type { RuntimeEnv } from "../../runtime.js"; @@ -36,7 +36,7 @@ export function buildMigrationContext(params: { reportDir?: string; json?: boolean; }): MigrationProviderContext { - const config = loadConfig(); + const config = getRuntimeConfig(); const stateDir = resolveStateDir(); return { config, diff --git a/src/commands/migrate/providers.ts b/src/commands/migrate/providers.ts index ed2012c62cc..70b0c8f98e2 100644 --- a/src/commands/migrate/providers.ts +++ b/src/commands/migrate/providers.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { resolvePluginMigrationProvider, resolvePluginMigrationProviders, @@ -9,7 +9,7 @@ import { buildMigrationContext } from "./context.js"; import type { MigrateCommonOptions } from "./types.js"; export function resolveMigrationProvider(providerId: string): MigrationProviderPlugin { - const config = loadConfig(); + const config = getRuntimeConfig(); const provider = resolvePluginMigrationProvider({ providerId, cfg: config }); if (!provider) { const available = resolvePluginMigrationProviders({ cfg: config }).map((entry) => entry.id); diff --git a/src/commands/sandbox-explain.test.ts b/src/commands/sandbox-explain.test.ts index 08816c699cb..bdbd753b9b1 100644 --- a/src/commands/sandbox-explain.test.ts +++ b/src/commands/sandbox-explain.test.ts @@ -9,6 +9,7 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, + getRuntimeConfig: vi.fn().mockImplementation(() => mockCfg), loadConfig: vi.fn().mockImplementation(() => mockCfg), }; }); diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index 6e3005bee92..d59633daffd 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -2,7 +2,7 @@ import { resolveAgentConfig } from "../agents/agent-scope.js"; import { resolveSandboxConfigForAgent } from "../agents/sandbox.js"; import { resolveSandboxToolPolicyForAgent } from "../agents/sandbox/tool-policy.js"; import { normalizeAnyChannelId } from "../channels/registry.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { loadSessionStore, resolveAgentMainSessionKey, @@ -137,7 +137,7 @@ export async function sandboxExplainCommand( opts: SandboxExplainOptions, runtime: RuntimeEnv, ): Promise { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const defaultAgentId = resolveAgentIdFromSessionKey(resolveMainSessionKey(cfg)); const resolvedAgentId = normalizeAgentId( diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index 5f593d34b3d..3b98787ce3f 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: mocks.loadConfig, loadConfig: mocks.loadConfig, })); diff --git a/src/commands/sessions-cleanup.ts b/src/commands/sessions-cleanup.ts index a3dad54a889..710efef554d 100644 --- a/src/commands/sessions-cleanup.ts +++ b/src/commands/sessions-cleanup.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { capEntryCount, enforceSessionDiskBudget, @@ -296,7 +296,7 @@ function renderStoreDryRunPlan(params: { } export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runtime: RuntimeEnv) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const displayDefaults = resolveSessionDisplayDefaults(cfg); const mode = opts.enforce ? "enforce" : resolveMaintenanceConfig().mode; const targets = resolveSessionStoreTargetsOrExit({ diff --git a/src/commands/sessions.default-agent-store.test.ts b/src/commands/sessions.default-agent-store.test.ts index 7429d88f07d..e249157d194 100644 --- a/src/commands/sessions.default-agent-store.test.ts +++ b/src/commands/sessions.default-agent-store.test.ts @@ -14,6 +14,7 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, + getRuntimeConfig: loadConfigMock, loadConfig: loadConfigMock, }; }); diff --git a/src/commands/sessions.test-helpers.ts b/src/commands/sessions.test-helpers.ts index 0c1cbd7fd62..65e47390597 100644 --- a/src/commands/sessions.test-helpers.ts +++ b/src/commands/sessions.test-helpers.ts @@ -18,6 +18,7 @@ const sessionsConfigState = vi.hoisted(() => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: () => sessionsConfigState.loadConfig(), loadConfig: () => sessionsConfigState.loadConfig(), })); diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index e812e9d99a3..dfb96a8974b 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -1,5 +1,5 @@ import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { loadSessionStore, resolveSessionTotalTokens } from "../config/sessions.js"; import { info } from "../globals.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; @@ -112,7 +112,7 @@ export async function sessionsCommand( runtime: RuntimeEnv, ) { const aggregateAgents = opts.allAgents === true; - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const displayDefaults = resolveSessionDisplayDefaults(cfg); const configuredContextTokens = cfg.agents?.defaults?.contextTokens; const configContextTokens = diff --git a/src/commands/setup.test.ts b/src/commands/setup.test.ts index 3785e19d74a..eed8d0c2456 100644 --- a/src/commands/setup.test.ts +++ b/src/commands/setup.test.ts @@ -20,9 +20,9 @@ function createSetupDeps(home: string) { ), mkdir: vi.fn(async () => {}), resolveSessionTranscriptsDir: vi.fn(() => path.join(home, ".openclaw", "sessions")), - writeConfigFile: vi.fn(async (config: unknown) => { + replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: unknown }) => { await fs.mkdir(path.dirname(configPath), { recursive: true }); - await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + await fs.writeFile(configPath, JSON.stringify(nextConfig, null, 2)); }), }; } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 3c2e94e0b76..003d9abbd3e 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -29,11 +29,14 @@ type SetupCommandDeps = { ) => void | Promise; mkdir?: (dir: string, options: { recursive: true }) => Promise; resolveSessionTranscriptsDir?: () => string | Promise; - writeConfigFile?: (config: OpenClawConfig) => Promise; + replaceConfigFile?: (params: { + nextConfig: OpenClawConfig; + afterWrite: { mode: "auto" }; + }) => Promise; }; type AgentWorkspaceModule = typeof import("../agents/workspace.js"); -type ConfigIOModule = typeof import("../config/io.js"); +type ConfigIOModule = typeof import("../config/config.js"); type ConfigLoggingModule = typeof import("../config/logging.js"); let agentWorkspaceModulePromise: Promise | undefined; @@ -46,7 +49,7 @@ function loadAgentWorkspaceModule(): Promise { } function loadConfigIOModule(): Promise { - configIOModulePromise ??= import("../config/io.js"); + configIOModulePromise ??= import("../config/config.js"); return configIOModulePromise; } @@ -80,8 +83,11 @@ async function ensureDefaultAgentWorkspace( } async function writeDefaultConfigFile(config: OpenClawConfig): Promise { - const { writeConfigFile } = await loadConfigIOModule(); - await writeConfigFile(config); + const { replaceConfigFile } = await loadConfigIOModule(); + await replaceConfigFile({ + nextConfig: config, + afterWrite: { mode: "auto" }, + }); } async function formatDefaultConfigPath(configPath: string): Promise { @@ -154,7 +160,12 @@ export async function setupCommand( defaults.workspace !== workspace || cfg.gateway?.mode !== next.gateway?.mode ) { - await (deps.writeConfigFile ?? writeDefaultConfigFile)(next); + const replaceConfig = + deps.replaceConfigFile ?? ((params) => writeDefaultConfigFile(params.nextConfig)); + await replaceConfig({ + nextConfig: next, + afterWrite: { mode: "auto" }, + }); if (!existingRaw.exists) { const formatConfigPath = deps.formatConfigPath ?? formatDefaultConfigPath; runtime.log(`Wrote ${await formatConfigPath(configPath)}`); diff --git a/src/commands/status.summary.test.ts b/src/commands/status.summary.test.ts index ed09d643fbf..800c372ce39 100644 --- a/src/commands/status.summary.test.ts +++ b/src/commands/status.summary.test.ts @@ -34,6 +34,10 @@ vi.mock("../config/io.js", () => ({ loadConfig: vi.fn(() => ({})), })); +vi.mock("../config/config.js", () => ({ + getRuntimeConfig: vi.fn(() => ({})), +})); + vi.mock("../gateway/agent-list.js", () => ({ listGatewayAgentsBasic: vi.fn(() => ({ defaultId: "main", diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index dcfb48a3468..89ac19f1321 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -1,4 +1,5 @@ import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions/main-session.js"; import { resolveStorePath } from "../config/sessions/paths.js"; import { readSessionStoreReadOnly } from "../config/sessions/store-read.js"; @@ -16,7 +17,6 @@ import type { HeartbeatStatus, SessionStatus, StatusSummary } from "./status.typ let channelSummaryModulePromise: Promise | undefined; let linkChannelModulePromise: Promise | undefined; -let configIoModulePromise: Promise | undefined; let taskRegistryMaintenanceModulePromise: | Promise | undefined; @@ -36,11 +36,6 @@ const loadStatusSummaryRuntimeModule = createLazyRuntimeSurface( ({ statusSummaryRuntime }) => statusSummaryRuntime, ); -function loadConfigIoModule() { - configIoModulePromise ??= import("../config/io.js"); - return configIoModulePromise; -} - function loadTaskRegistryMaintenanceModule() { taskRegistryMaintenanceModulePromise ??= import("../tasks/task-registry.maintenance.js"); return taskRegistryMaintenanceModulePromise; @@ -118,7 +113,7 @@ export async function getStatusSummary( resolveContextTokensForModel, resolveSessionModelRef, } = await loadStatusSummaryRuntimeModule(); - const cfg = options.config ?? (await loadConfigIoModule()).loadConfig(); + const cfg = options.config ?? getRuntimeConfig(); const channelScopeConfig = options.sourceConfig === undefined ? { config: cfg } diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 61d4a426ecb..f4cb8e0c055 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -689,6 +689,7 @@ vi.mock("../infra/update-check.js", () => ({ compareSemverStrings: vi.fn(() => 0), })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: mocks.loadConfig, loadConfig: mocks.loadConfig, readBestEffortConfig: vi.fn(async () => mocks.loadConfig()), resolveGatewayPort: vi.fn(() => 18789), diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index b8e4be919ed..8152875a500 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveCronStorePath } from "../cron/store.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -47,11 +47,11 @@ const RUN_PAD = 10; const info = theme.info; async function loadTaskCancelConfig() { - return loadConfig(); + return getRuntimeConfig(); } function configureTaskMaintenanceFromConfig(): void { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); configureTaskRegistryMaintenance({ cronStorePath: resolveCronStorePath(cfg.cron?.store), }); diff --git a/src/config/config.multi-agent-agentdir-validation.test.ts b/src/config/config.multi-agent-agentdir-validation.test.ts index 6fb60328920..ed1da0d352d 100644 --- a/src/config/config.multi-agent-agentdir-validation.test.ts +++ b/src/config/config.multi-agent-agentdir-validation.test.ts @@ -1,7 +1,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { loadConfig } from "./config.js"; +import { getRuntimeConfig } from "./config.js"; import { withTempHomeConfig } from "./test-helpers.js"; import { validateConfigObject } from "./validation.js"; @@ -23,7 +23,7 @@ describe("multi-agent agentDir validation", () => { } }); - it("throws on shared agentDir during loadConfig()", async () => { + it("throws on shared agentDir during getRuntimeConfig()", async () => { await withTempHomeConfig( { agents: { @@ -36,7 +36,7 @@ describe("multi-agent agentDir validation", () => { }, async () => { const spy = vi.spyOn(console, "error").mockImplementation(() => {}); - expect(() => loadConfig()).toThrow(/duplicate agentDir/i); + expect(() => getRuntimeConfig()).toThrow(/duplicate agentDir/i); expect(spy.mock.calls.flat().join(" ")).toMatch(/Duplicate agentDir/i); spy.mockRestore(); }, diff --git a/src/config/config.talk-validation.test.ts b/src/config/config.talk-validation.test.ts index c56d23112fb..9a351a15093 100644 --- a/src/config/config.talk-validation.test.ts +++ b/src/config/config.talk-validation.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { clearConfigCache, clearRuntimeConfigSnapshot, loadConfig } from "./config.js"; +import { getRuntimeConfig, clearConfigCache, clearRuntimeConfigSnapshot } from "./config.js"; import { withTempHomeConfig } from "./test-helpers.js"; describe("talk config validation fail-closed behavior", () => { @@ -15,7 +15,7 @@ describe("talk config validation fail-closed behavior", () => { let thrown: unknown; try { - loadConfig(); + getRuntimeConfig(); } catch (error) { thrown = error; } diff --git a/src/config/config.ts b/src/config/config.ts index 56c3a28a8d0..12962ab33ba 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -26,6 +26,8 @@ export { setRuntimeConfigSnapshot, writeConfigFile, } from "./io.js"; +export { resolveConfigWriteAfterWrite, resolveConfigWriteFollowUp } from "./runtime-snapshot.js"; +export type { ConfigWriteAfterWrite, ConfigWriteFollowUp } from "./runtime-snapshot.js"; export type { ConfigWriteNotification } from "./io.js"; export { ConfigMutationConflictError, mutateConfigFile, replaceConfigFile } from "./mutate.js"; export * from "./paths.js"; diff --git a/src/config/io.ts b/src/config/io.ts index 3e9835d2175..840ad337e0c 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -94,6 +94,7 @@ import { setRuntimeConfigSnapshot as setRuntimeConfigSnapshotState, getRuntimeConfigSnapshotRefreshHandler as getRuntimeConfigSnapshotRefreshHandlerState, setRuntimeConfigSnapshotRefreshHandler as setRuntimeConfigSnapshotRefreshHandlerState, + type ConfigWriteAfterWrite, type RuntimeConfigWriteNotification, } from "./runtime-snapshot.js"; import { resolveShellEnvExpectedKeys } from "./shell-env-expected-keys.js"; @@ -208,6 +209,11 @@ export type ConfigWriteOptions = { * Useful when the caller wants machine-readable output only (--json mode). */ skipOutputLogs?: boolean; + /** + * Runtime reload intent for observers that react to committed config writes. + * Omitted means the observer should use its normal reload plan. + */ + afterWrite?: ConfigWriteAfterWrite; }; export type ReadConfigFileSnapshotForWriteResult = { @@ -2371,6 +2377,7 @@ export async function writeConfigFile( runtimeConfig: currentRuntimeConfig, persistedHash: writeResult.persistedHash, writtenAtMs: Date.now(), + afterWrite: options.afterWrite, }); }; // Keep the last-known-good runtime snapshot active until the specialized refresh path diff --git a/src/config/mutate.test.ts b/src/config/mutate.test.ts index 55cb7ff8af0..8a241c95766 100644 --- a/src/config/mutate.test.ts +++ b/src/config/mutate.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { ConfigMutationConflictError, mutateConfigFile, replaceConfigFile } from "./mutate.js"; +import { registerRuntimeConfigWriteListener, resetConfigRuntimeState } from "./runtime-snapshot.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js"; const ioMocks = vi.hoisted(() => ({ @@ -53,6 +54,7 @@ describe("config mutate helpers", () => { beforeEach(() => { vi.clearAllMocks(); + resetConfigRuntimeState(); ioMocks.resolveConfigSnapshotHash.mockImplementation( (snapshot: { hash?: string }) => snapshot.hash ?? null, ); @@ -85,6 +87,8 @@ describe("config mutate helpers", () => { port: 18789, auth: { mode: "token" }, }); + expect(result.afterWrite).toEqual({ mode: "auto" }); + expect(result.followUp).toEqual({ mode: "auto", requiresRestart: false }); expect(ioMocks.writeConfigFile).toHaveBeenCalledWith( { gateway: { @@ -92,7 +96,7 @@ describe("config mutate helpers", () => { auth: { mode: "token" }, }, }, - { expectedConfigPath: snapshot.path }, + { expectedConfigPath: snapshot.path, afterWrite: { mode: "auto" } }, ); }); @@ -133,6 +137,37 @@ describe("config mutate helpers", () => { { baseSnapshot: snapshot, expectedConfigPath: snapshot.path, + afterWrite: { mode: "auto" }, + }, + ); + }); + + it("returns explicit restart follow-up intent for replace writes", async () => { + const snapshot = createSnapshot({ + hash: "hash-restart", + sourceConfig: { gateway: { auth: { mode: "token" } } }, + }); + + const result = await replaceConfigFile({ + baseHash: snapshot.hash, + nextConfig: { gateway: { auth: { mode: "token", token: "minted" } } }, + snapshot, + afterWrite: { mode: "restart", reason: "plugin auth changed" }, + writeOptions: { expectedConfigPath: snapshot.path }, + }); + + expect(result.afterWrite).toEqual({ mode: "restart", reason: "plugin auth changed" }); + expect(result.followUp).toEqual({ + mode: "restart", + reason: "plugin auth changed", + requiresRestart: true, + }); + expect(ioMocks.writeConfigFile).toHaveBeenCalledWith( + { gateway: { auth: { mode: "token", token: "minted" } } }, + { + baseSnapshot: snapshot, + expectedConfigPath: snapshot.path, + afterWrite: { mode: "restart", reason: "plugin auth changed" }, }, ); }); @@ -162,32 +197,81 @@ describe("config mutate helpers", () => { }, }, }); - - await replaceConfigFile({ - baseHash: snapshot.hash, - snapshot, - writeOptions: { - expectedConfigPath: snapshot.path, - unsetPaths: [["plugins", "installs"]], - }, - nextConfig: { + const refreshedSnapshot = createSnapshot({ + hash: "hash-include-refreshed", + path: configPath, + parsed: { plugins: { $include: "./config/plugins.json5" } }, + sourceConfig: { plugins: { entries: { old: { enabled: true }, demo: { enabled: true }, }, - installs: { - demo: { - source: "npm", - spec: "demo", - installPath: "/tmp/demo", - }, - }, }, }, }); + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: refreshedSnapshot, + writeOptions: { expectedConfigPath: configPath }, + }); + const notifications: unknown[] = []; + const unregister = registerRuntimeConfigWriteListener((event) => { + notifications.push(event); + }); + + try { + await replaceConfigFile({ + baseHash: snapshot.hash, + snapshot, + afterWrite: { mode: "restart", reason: "test include refresh" }, + writeOptions: { + expectedConfigPath: snapshot.path, + unsetPaths: [["plugins", "installs"]], + }, + nextConfig: { + plugins: { + entries: { + old: { enabled: true }, + demo: { enabled: true }, + }, + installs: { + demo: { + source: "npm", + spec: "demo", + installPath: "/tmp/demo", + }, + }, + }, + }, + }); + } finally { + unregister(); + } expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); + expect(notifications).toMatchObject([ + { + configPath, + persistedHash: "hash-include-refreshed", + sourceConfig: { + plugins: { + entries: { + old: { enabled: true }, + demo: { enabled: true }, + }, + }, + }, + runtimeConfig: { + plugins: { + entries: { + old: { enabled: true }, + demo: { enabled: true }, + }, + }, + }, + afterWrite: { mode: "restart", reason: "test include refresh" }, + }, + ]); await expect(fs.readFile(configPath, "utf-8")).resolves.toContain( '"$include": "./config/plugins.json5"', ); @@ -228,6 +312,7 @@ describe("config mutate helpers", () => { { baseSnapshot: snapshot, expectedConfigPath: snapshot.path, + afterWrite: { mode: "auto" }, }, ); }); diff --git a/src/config/mutate.ts b/src/config/mutate.ts index e3f3a25c85e..dbb8edaad64 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; +import { formatErrorMessage } from "../infra/errors.js"; import { isPathInside } from "../security/scan-paths.js"; import { isRecord } from "../utils.js"; import { maintainConfigBackups } from "./backup-rotation.js"; @@ -14,6 +15,17 @@ import { type ConfigWriteOptions, } from "./io.js"; import { applyUnsetPathsForWrite, resolveManagedUnsetPathsForWrite } from "./io.write-prepare.js"; +import { + finalizeRuntimeSnapshotWrite, + getRuntimeConfigSnapshot, + getRuntimeConfigSnapshotRefreshHandler, + getRuntimeConfigSourceSnapshot, + notifyRuntimeConfigWriteListeners, + resolveConfigWriteAfterWrite, + resolveConfigWriteFollowUp, + type ConfigWriteAfterWrite, + type ConfigWriteFollowUp, +} from "./runtime-snapshot.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "./types.js"; import { validateConfigObjectWithPlugins } from "./validation.js"; @@ -34,6 +46,13 @@ export type ConfigReplaceResult = { previousHash: string | null; snapshot: ConfigFileSnapshot; nextConfig: OpenClawConfig; + afterWrite: ConfigWriteAfterWrite; + followUp: ConfigWriteFollowUp; +}; + +type ConfigMutationIO = { + readConfigFileSnapshotForWrite: typeof readConfigFileSnapshotForWrite; + writeConfigFile: (cfg: OpenClawConfig, options?: ConfigWriteOptions) => Promise; }; function assertBaseHashMatches(snapshot: ConfigFileSnapshot, expectedHash?: string): string | null { @@ -112,7 +131,9 @@ async function writeJsonFileAtomic(filePath: string, value: unknown): Promise { const nextConfig = applyUnsetPathsForWrite( params.nextConfig, @@ -138,7 +159,63 @@ async function tryWriteSingleTopLevelIncludeMutation(params: { ); } + const runtimeConfigSnapshot = getRuntimeConfigSnapshot(); + const runtimeConfigSourceSnapshot = getRuntimeConfigSourceSnapshot(); + const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot); + const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot); await writeJsonFileAtomic(includePath, nextConfigRecord[key]); + if ( + params.writeOptions?.skipRuntimeSnapshotRefresh && + !hadRuntimeSnapshot && + !getRuntimeConfigSnapshotRefreshHandler() + ) { + return true; + } + + const refreshed = await ( + params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite + )(); + const refreshedSnapshot = refreshed.snapshot; + const persistedHash = resolveConfigSnapshotHash(refreshedSnapshot); + if (!refreshedSnapshot.valid) { + throw createInvalidConfigError( + params.snapshot.path, + formatInvalidConfigDetails(refreshedSnapshot.issues), + ); + } + if (!persistedHash) { + throw new Error( + `Config was written to ${params.snapshot.path}, but no persisted hash was available.`, + ); + } + + const notifyCommittedWrite = () => { + const currentRuntimeConfig = getRuntimeConfigSnapshot(); + if (!currentRuntimeConfig) { + return; + } + notifyRuntimeConfigWriteListeners({ + configPath: params.snapshot.path, + sourceConfig: refreshedSnapshot.sourceConfig, + runtimeConfig: currentRuntimeConfig, + persistedHash, + writtenAtMs: Date.now(), + afterWrite: params.afterWrite ?? params.writeOptions?.afterWrite, + }); + }; + await finalizeRuntimeSnapshotWrite({ + nextSourceConfig: refreshedSnapshot.sourceConfig, + hadRuntimeSnapshot, + hadBothSnapshots, + loadFreshConfig: () => refreshedSnapshot.runtimeConfig, + notifyCommittedWrite, + formatRefreshError: (error) => formatErrorMessage(error), + createRefreshError: (detail, cause) => + new Error( + `Config was written to ${params.snapshot.path}, but runtime snapshot refresh failed: ${detail}`, + { cause }, + ), + }); return true; } @@ -146,24 +223,32 @@ export async function replaceConfigFile(params: { nextConfig: OpenClawConfig; baseHash?: string; snapshot?: ConfigFileSnapshot; + afterWrite?: ConfigWriteOptions["afterWrite"]; writeOptions?: ConfigWriteOptions; + io?: ConfigMutationIO; }): Promise { const prepared = params.snapshot && params.writeOptions ? { snapshot: params.snapshot, writeOptions: params.writeOptions } - : await readConfigFileSnapshotForWrite(); + : await (params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite)(); const { snapshot, writeOptions } = prepared; const previousHash = assertBaseHashMatches(snapshot, params.baseHash); + const afterWrite = resolveConfigWriteAfterWrite( + params.afterWrite ?? params.writeOptions?.afterWrite, + ); const wroteInclude = await tryWriteSingleTopLevelIncludeMutation({ snapshot, nextConfig: params.nextConfig, + afterWrite, writeOptions: params.writeOptions ?? writeOptions, + io: params.io, }); if (!wroteInclude) { - await writeConfigFile(params.nextConfig, { + await (params.io?.writeConfigFile ?? writeConfigFile)(params.nextConfig, { baseSnapshot: snapshot, ...writeOptions, ...params.writeOptions, + afterWrite, }); } return { @@ -171,35 +256,47 @@ export async function replaceConfigFile(params: { previousHash, snapshot, nextConfig: params.nextConfig, + afterWrite, + followUp: resolveConfigWriteFollowUp(afterWrite), }; } export async function mutateConfigFile(params: { base?: ConfigMutationBase; baseHash?: string; + afterWrite?: ConfigWriteOptions["afterWrite"]; writeOptions?: ConfigWriteOptions; + io?: ConfigMutationIO; mutate: ( draft: OpenClawConfig, context: { snapshot: ConfigFileSnapshot; previousHash: string | null }, ) => Promise | T | void; }): Promise { - const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite(); + const { snapshot, writeOptions } = await ( + params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite + )(); const previousHash = assertBaseHashMatches(snapshot, params.baseHash); const baseConfig = params.base === "runtime" ? snapshot.runtimeConfig : snapshot.sourceConfig; const draft = structuredClone(baseConfig) as OpenClawConfig; const result = (await params.mutate(draft, { snapshot, previousHash })) as T | undefined; + const afterWrite = resolveConfigWriteAfterWrite( + params.afterWrite ?? params.writeOptions?.afterWrite, + ); const wroteInclude = await tryWriteSingleTopLevelIncludeMutation({ snapshot, nextConfig: draft, + afterWrite, writeOptions: { ...writeOptions, ...params.writeOptions, }, + io: params.io, }); if (!wroteInclude) { - await writeConfigFile(draft, { + await (params.io?.writeConfigFile ?? writeConfigFile)(draft, { ...writeOptions, ...params.writeOptions, + afterWrite, }); } return { @@ -208,5 +305,7 @@ export async function mutateConfigFile(params: { snapshot, nextConfig: draft, result, + afterWrite, + followUp: resolveConfigWriteFollowUp(afterWrite), }; } diff --git a/src/config/runtime-schema.test.ts b/src/config/runtime-schema.test.ts index c57b4acc092..0d16b136727 100644 --- a/src/config/runtime-schema.test.ts +++ b/src/config/runtime-schema.test.ts @@ -18,6 +18,7 @@ let loadGatewayRuntimeConfigSchema: typeof import("./runtime-schema.js").loadGat vi.mock("./config.js", () => { return { + getRuntimeConfig: () => mockLoadConfig(), loadConfig: () => mockLoadConfig(), readConfigFileSnapshot: () => mockReadConfigFileSnapshot(), }; diff --git a/src/config/runtime-schema.ts b/src/config/runtime-schema.ts index ccf15809c15..37dbec27770 100644 --- a/src/config/runtime-schema.ts +++ b/src/config/runtime-schema.ts @@ -5,7 +5,7 @@ import { collectChannelSchemaMetadata, collectPluginSchemaMetadata, } from "./channel-config-metadata.js"; -import { loadConfig, readConfigFileSnapshot } from "./config.js"; +import { getRuntimeConfig, readConfigFileSnapshot } from "./config.js"; import type { OpenClawConfig } from "./config.js"; import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js"; @@ -22,7 +22,7 @@ function loadManifestRegistry(config: OpenClawConfig, env?: NodeJS.ProcessEnv) { } export function loadGatewayRuntimeConfigSchema(): ConfigSchemaResponse { - const config = loadConfig(); + const config = getRuntimeConfig(); const registry = loadManifestRegistry(config); return buildConfigSchema({ plugins: collectPluginSchemaMetadata(registry), diff --git a/src/config/runtime-snapshot.ts b/src/config/runtime-snapshot.ts index 6ae2275f92d..242d1aad539 100644 --- a/src/config/runtime-snapshot.ts +++ b/src/config/runtime-snapshot.ts @@ -4,6 +4,57 @@ export type RuntimeConfigSnapshotRefreshParams = { sourceConfig: OpenClawConfig; }; +export type ConfigWriteAfterWrite = + | { mode: "auto" } + | { mode: "restart"; reason: string } + | { mode: "none"; reason: string }; + +export type ConfigWriteFollowUp = + | { + mode: "auto"; + requiresRestart: false; + } + | { + mode: "none"; + reason: string; + requiresRestart: false; + } + | { + mode: "restart"; + reason: string; + requiresRestart: true; + }; + +export function resolveConfigWriteAfterWrite( + afterWrite?: ConfigWriteAfterWrite, +): ConfigWriteAfterWrite { + return afterWrite ?? { mode: "auto" }; +} + +export function resolveConfigWriteFollowUp( + afterWrite?: ConfigWriteAfterWrite, +): ConfigWriteFollowUp { + const resolved = resolveConfigWriteAfterWrite(afterWrite); + if (resolved.mode === "restart") { + return { + mode: "restart", + reason: resolved.reason, + requiresRestart: true, + }; + } + if (resolved.mode === "none") { + return { + mode: "none", + reason: resolved.reason, + requiresRestart: false, + }; + } + return { + mode: "auto", + requiresRestart: false, + }; +} + export type RuntimeConfigSnapshotRefreshHandler = { refresh: (params: RuntimeConfigSnapshotRefreshParams) => boolean | Promise; clearOnRefreshFailure?: () => void; @@ -15,6 +66,7 @@ export type RuntimeConfigWriteNotification = { runtimeConfig: OpenClawConfig; persistedHash: string; writtenAtMs: number; + afterWrite?: ConfigWriteAfterWrite; }; let runtimeConfigSnapshot: OpenClawConfig | null = null; diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 21bf9fe7aee..420adb3ec4d 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -4412,7 +4412,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { minLength: 1, title: "Memory Search Input Type", description: - "Use this optional provider-specific `input_type` value when the same label should apply to both query and document embedding requests. For asymmetric providers, prefer queryInputType and documentInputType.", + "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.", }, queryInputType: { type: "string", @@ -26095,7 +26095,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, "agents.defaults.memorySearch.inputType": { label: "Memory Search Input Type", - help: "Use this optional provider-specific `input_type` value when the same label should apply to both query and document embedding requests. For asymmetric providers, prefer queryInputType and documentInputType.", + help: "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.", tags: ["advanced"], }, "agents.defaults.memorySearch.queryInputType": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 55955453ddb..70ac8b3ac9d 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1001,7 +1001,7 @@ export const FIELD_HELP: Record = { "agents.defaults.memorySearch.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": - "Use this optional provider-specific `input_type` value when the same label should apply to both query and document embedding requests. For asymmetric providers, prefer queryInputType and documentInputType.", + "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": "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": diff --git a/src/config/sessions/delivery-info.test.ts b/src/config/sessions/delivery-info.test.ts index 72b18f9e0c5..d3e4f58d64f 100644 --- a/src/config/sessions/delivery-info.test.ts +++ b/src/config/sessions/delivery-info.test.ts @@ -8,7 +8,7 @@ const storeState = vi.hoisted(() => ({ })); vi.mock("../io.js", () => ({ - loadConfig: () => ({}), + getRuntimeConfig: () => ({}), })); vi.mock("./paths.js", () => ({ diff --git a/src/config/sessions/delivery-info.ts b/src/config/sessions/delivery-info.ts index 0b279451a7e..5dfdec05ff6 100644 --- a/src/config/sessions/delivery-info.ts +++ b/src/config/sessions/delivery-info.ts @@ -1,5 +1,5 @@ import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js"; -import { loadConfig } from "../io.js"; +import { getRuntimeConfig } from "../io.js"; import { resolveStorePath } from "./paths.js"; import { loadSessionStore } from "./store.js"; export { parseSessionThreadInfo } from "./thread-info.js"; @@ -31,7 +31,7 @@ export function extractDeliveryInfo(sessionKey: string | undefined): { | { channel?: string; to?: string; accountId?: string; threadId?: string } | undefined; try { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const storePath = resolveStorePath(cfg.session?.store); const store = loadSessionStore(storePath); let entry = store[sessionKey]; diff --git a/src/config/sessions/main-session.runtime.ts b/src/config/sessions/main-session.runtime.ts index 1a06bb360ec..cb4a912a193 100644 --- a/src/config/sessions/main-session.runtime.ts +++ b/src/config/sessions/main-session.runtime.ts @@ -1,6 +1,6 @@ -import { loadConfig } from "../io.js"; +import { getRuntimeConfig } from "../io.js"; import { resolveMainSessionKey } from "./main-session.js"; export function resolveMainSessionKeyFromConfig(): string { - return resolveMainSessionKey(loadConfig()); + return resolveMainSessionKey(getRuntimeConfig()); } diff --git a/src/config/sessions/store-maintenance-runtime.ts b/src/config/sessions/store-maintenance-runtime.ts index af3ed4a7213..0ed677c664b 100644 --- a/src/config/sessions/store-maintenance-runtime.ts +++ b/src/config/sessions/store-maintenance-runtime.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../config.js"; +import { getRuntimeConfig } from "../config.js"; import type { SessionMaintenanceConfig } from "../types.base.js"; import { resolveMaintenanceConfigFromInput, @@ -8,7 +8,7 @@ import { export function resolveMaintenanceConfig(): ResolvedSessionMaintenanceConfig { let maintenance: SessionMaintenanceConfig | undefined; try { - maintenance = loadConfig().session?.maintenance; + maintenance = getRuntimeConfig().session?.maintenance; } catch { // Config may not be available in narrow test/runtime helpers. } diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index 3cee16a963c..3ac0216956e 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -8,10 +8,10 @@ import type { SessionEntry } from "./types.js"; // Keep integration tests deterministic: never read a real openclaw.json. vi.mock("../config.js", async () => ({ ...(await vi.importActual("../config.js")), - loadConfig: vi.fn().mockReturnValue({}), + getRuntimeConfig: vi.fn().mockReturnValue({}), })); -import { loadConfig } from "../config.js"; +import { getRuntimeConfig } from "../config.js"; import { clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } from "./store.js"; let mockLoadConfig: ReturnType; @@ -86,7 +86,7 @@ describe("Integration: saveSessionStore with pruning", () => { }); beforeEach(async () => { - mockLoadConfig = vi.mocked(loadConfig) as ReturnType; + mockLoadConfig = vi.mocked(getRuntimeConfig) as ReturnType; mockLoadConfig.mockReset(); testDir = await createCaseDir("pruning-integ"); storePath = path.join(testDir, "sessions.json"); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index db298b3d4f0..1fc9445e395 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -237,7 +237,7 @@ async function saveSessionStoreUnlocked( normalizeSessionStore(store); if (!opts?.skipMaintenance) { - // Resolve maintenance config once (avoids repeated loadConfig() calls). + // Resolve maintenance config once (avoids repeated getRuntimeConfig() calls). const maintenance = opts?.maintenanceConfig ? { ...opts.maintenanceConfig, ...opts?.maintenanceOverride } : { ...resolveMaintenanceConfig(), ...opts?.maintenanceOverride }; diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index d0dcee02b1d..543c87bdea6 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -472,7 +472,7 @@ async function runGatewayDaemonHealth(ctx: DoctorHealthFlowContext): Promise { const { formatCliCommand } = await import("../cli/command-format.js"); const { applyWizardMetadata } = await import("../commands/onboard-helpers.js"); - const { CONFIG_PATH, writeConfigFile } = await import("../config/config.js"); + const { CONFIG_PATH, replaceConfigFile } = await import("../config/config.js"); const { logConfigUpdated } = await import("../config/logging.js"); const { shortenHomePath } = await import("../utils.js"); const shouldWriteConfig = @@ -483,7 +483,10 @@ async function runWriteConfigHealth(ctx: DoctorHealthFlowContext): Promise command: "doctor", mode: resolveDoctorMode(ctx.cfg), }); - await writeConfigFile(ctx.cfg); + await replaceConfigFile({ + nextConfig: ctx.cfg, + afterWrite: { mode: "auto" }, + }); logConfigUpdated(ctx.runtime); const backupPath = `${CONFIG_PATH}.bak`; if (fs.existsSync(backupPath)) { diff --git a/src/gateway/android-node.capabilities.live.test.ts b/src/gateway/android-node.capabilities.live.test.ts index 480e937ce1d..1c441e93975 100644 --- a/src/gateway/android-node.capabilities.live.test.ts +++ b/src/gateway/android-node.capabilities.live.test.ts @@ -3,7 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { unwrapRemoteConfigSnapshot } from "../../test/helpers/gateway/android-node-capabilities-policy-config.js"; import { shouldFetchRemotePolicyConfig } from "../../test/helpers/gateway/android-node-capabilities-policy-source.js"; import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { parseNodeList, parsePairingList } from "../shared/node-list-parse.js"; @@ -257,7 +257,7 @@ const COMMAND_PROFILES: Record = { }; function resolveGatewayConnection() { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const urlOverride = readString(process.env.OPENCLAW_ANDROID_GATEWAY_URL); const details = buildGatewayConnectionDetails({ config: cfg, @@ -290,7 +290,7 @@ async function resolvePolicyConfigForRun(params: { return unwrapRemoteConfigSnapshot(raw); } - const loadLocalConfig = params.loadLocalConfig ?? loadConfig; + const loadLocalConfig = params.loadLocalConfig ?? getRuntimeConfig; return loadLocalConfig(); } diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 2f6a3680caa..3c5a3150192 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -7,7 +7,7 @@ import type { DeviceIdentity } from "../infra/device-identity.js"; import { captureEnv } from "../test-utils/env.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { - loadConfigMock as loadConfig, + loadConfigMock as getRuntimeConfig, pickPrimaryLanIPv4Mock as pickPrimaryLanIPv4, pickPrimaryTailnetIPv4Mock as pickPrimaryTailnetIPv4, resolveGatewayPortMock as resolveGatewayPort, @@ -134,7 +134,7 @@ class StubGatewayClient { } function resetGatewayCallMocks() { - loadConfig.mockClear(); + getRuntimeConfig.mockClear(); resolveGatewayPort.mockClear(); pickPrimaryTailnetIPv4.mockClear(); pickPrimaryLanIPv4.mockClear(); @@ -144,7 +144,7 @@ function resetGatewayCallMocks() { closeCode = 1006; closeReason = ""; helloMethods = ["health", "secrets.resolve"]; - const loadConfigForTests = loadConfig as unknown as () => OpenClawConfig; + const loadConfigForTests = getRuntimeConfig as unknown as () => OpenClawConfig; const resolveGatewayPortForTests = resolveGatewayPort as unknown as ( cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv, @@ -152,7 +152,7 @@ function resetGatewayCallMocks() { __testing.setDepsForTests({ createGatewayClient: (opts) => new StubGatewayClient(opts as ConstructorParameters[0]) as never, - loadConfig: loadConfigForTests, + getRuntimeConfig: loadConfigForTests, loadOrCreateDeviceIdentity: () => { if (deviceIdentityState.throwOnLoad) { throw new Error("read-only identity dir"); @@ -170,7 +170,7 @@ function setGatewayNetworkDefaults(port = 18789) { } function setLocalLoopbackGatewayConfig(port = 18789) { - loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); setGatewayNetworkDefaults(port); } @@ -220,7 +220,7 @@ describe("callGateway url resolution", () => { tailnetIp: undefined, }, ])("local auto-bind: $label", async ({ tailnetIp }) => { - loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); resolveGatewayPort.mockReturnValue(18800); pickPrimaryTailnetIPv4.mockReturnValue(tailnetIp); @@ -266,7 +266,7 @@ describe("callGateway url resolution", () => { expectedUrl: "ws://127.0.0.1:18800", }, ])("uses loopback for $label", async ({ gateway, tailnetIp, lanIp, expectedUrl }) => { - loadConfig.mockReturnValue({ gateway }); + getRuntimeConfig.mockReturnValue({ gateway }); resolveGatewayPort.mockReturnValue(18800); pickPrimaryTailnetIPv4.mockReturnValue(tailnetIp); pickPrimaryLanIPv4.mockReturnValue(lanIp); @@ -277,7 +277,7 @@ describe("callGateway url resolution", () => { }); it("uses url override in remote mode even when remote url is missing", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, }); resolveGatewayPort.mockReturnValue(18789); @@ -294,8 +294,8 @@ describe("callGateway url resolution", () => { }); it("skips config loading when explicit url and token are provided", async () => { - loadConfig.mockImplementation(() => { - throw new Error("loadConfig should not run"); + getRuntimeConfig.mockImplementation(() => { + throw new Error("getRuntimeConfig should not run"); }); await callGatewayCli({ @@ -304,7 +304,7 @@ describe("callGateway url resolution", () => { token: "test-token", }); - expect(loadConfig).not.toHaveBeenCalled(); + expect(getRuntimeConfig).not.toHaveBeenCalled(); expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800"); expect(lastClientOptions?.token).toBe("test-token"); }); @@ -355,7 +355,7 @@ describe("callGateway url resolution", () => { }); it("keeps backend device identity enabled for remote shared-token auth", async () => { - loadConfig.mockReturnValue(makeRemotePasswordGatewayConfig("remote-password")); + getRuntimeConfig.mockReturnValue(makeRemotePasswordGatewayConfig("remote-password")); setGatewayNetworkDefaults(); await callGateway({ @@ -385,7 +385,7 @@ describe("callGateway url resolution", () => { }); it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, }); resolveGatewayPort.mockReturnValue(18789); @@ -403,7 +403,7 @@ describe("callGateway url resolution", () => { }); it("uses env URL override credentials without resolving local password SecretRefs", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", auth: { @@ -432,7 +432,7 @@ describe("callGateway url resolution", () => { }); it("uses remote tlsFingerprint with env URL override", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", remote: { @@ -454,7 +454,7 @@ describe("callGateway url resolution", () => { }); it("does not apply remote tlsFingerprint for CLI url override", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", remote: { @@ -572,7 +572,7 @@ describe("callGateway url resolution", () => { }, stop() {}, }) as never, - loadConfig: loadConfig as unknown as () => OpenClawConfig, + getRuntimeConfig: getRuntimeConfig as unknown as () => OpenClawConfig, loadOrCreateDeviceIdentity: () => deviceIdentityState.value, resolveGatewayPort: resolveGatewayPort as unknown as ( cfg?: OpenClawConfig, @@ -612,7 +612,7 @@ describe("buildGatewayConnectionDetails", () => { }); it("emits a remote fallback note when remote url is missing", () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, }); resolveGatewayPort.mockReturnValue(18789); @@ -641,7 +641,7 @@ describe("buildGatewayConnectionDetails", () => { expectedUrl: "ws://127.0.0.1:18800", }, ])("uses loopback URL for bind=lan $label", ({ gateway, expectedUrl }) => { - loadConfig.mockReturnValue({ gateway }); + getRuntimeConfig.mockReturnValue({ gateway }); resolveGatewayPort.mockReturnValue(18800); pickPrimaryTailnetIPv4.mockReturnValue(undefined); pickPrimaryLanIPv4.mockReturnValue("10.0.0.5"); @@ -654,7 +654,7 @@ describe("buildGatewayConnectionDetails", () => { }); it("prefers remote url when configured", () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "tailnet", @@ -673,7 +673,7 @@ describe("buildGatewayConnectionDetails", () => { }); it("uses env OPENCLAW_GATEWAY_URL when set", () => { - loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); resolveGatewayPort.mockReturnValue(18800); pickPrimaryTailnetIPv4.mockReturnValue(undefined); const prevUrl = process.env.OPENCLAW_GATEWAY_URL; @@ -699,10 +699,10 @@ describe("buildGatewayConnectionDetails", () => { process.env.OPENCLAW_STATE_DIR = tempStateDir; process.env.OPENCLAW_CONFIG_PATH = path.join(tempStateDir, "missing-config.json"); try { - loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); resolveGatewayPort.mockReturnValue(18800); __testing.setDepsForTests({ - loadConfig: {} as never, + getRuntimeConfig: {} as never, resolveGatewayPort: () => 18789, }); @@ -716,7 +716,7 @@ describe("buildGatewayConnectionDetails", () => { }); it("throws for insecure ws:// remote URLs (CWE-319)", () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", @@ -742,7 +742,7 @@ describe("buildGatewayConnectionDetails", () => { it("allows ws:// private remote URLs only when OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", () => { process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1"; - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", @@ -759,7 +759,7 @@ describe("buildGatewayConnectionDetails", () => { it("allows ws:// hostname remote URLs when OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", () => { process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1"; - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", @@ -906,7 +906,7 @@ describe("callGateway error details", () => { }); }, }) as never, - loadConfig: loadConfig as unknown as () => OpenClawConfig, + getRuntimeConfig: getRuntimeConfig as unknown as () => OpenClawConfig, loadOrCreateDeviceIdentity: () => deviceIdentityState.value, resolveGatewayPort: resolveGatewayPort as unknown as ( cfg?: OpenClawConfig, @@ -964,7 +964,7 @@ describe("callGateway error details", () => { }); }, }) as never, - loadConfig: loadConfig as unknown as () => OpenClawConfig, + getRuntimeConfig: getRuntimeConfig as unknown as () => OpenClawConfig, loadOrCreateDeviceIdentity: () => deviceIdentityState.value, resolveGatewayPort: resolveGatewayPort as unknown as ( cfg?: OpenClawConfig, @@ -986,7 +986,7 @@ describe("callGateway error details", () => { }); it("fails fast when remote mode is missing remote url", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, }); await expect( @@ -1032,7 +1032,7 @@ describe("callGateway url override auth requirements", () => { it("throws when url override is set without explicit credentials", async () => { process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; process.env.OPENCLAW_GATEWAY_PASSWORD = "env-password"; - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", auth: { token: "local-token", password: "local-password" }, @@ -1046,7 +1046,7 @@ describe("callGateway url override auth requirements", () => { it("throws when env URL override is set without env credentials", async () => { process.env.OPENCLAW_GATEWAY_URL = "wss://override.example/ws"; - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", auth: { token: "local-token", password: "local-password" }, @@ -1142,7 +1142,7 @@ describe("callGateway password resolution", () => { if (envPassword !== undefined) { process.env.OPENCLAW_GATEWAY_PASSWORD = envPassword; } - loadConfig.mockReturnValue(config); + getRuntimeConfig.mockReturnValue(config); await callGateway({ method: "health" }); @@ -1151,7 +1151,7 @@ describe("callGateway password resolution", () => { it("resolves gateway.auth.password SecretInput refs for gateway calls", async () => { process.env.LOCAL_REF_PASSWORD = "resolved-local-ref-password"; // pragma: allowlist secret - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback", @@ -1174,7 +1174,7 @@ describe("callGateway password resolution", () => { it("does not resolve local password ref when env password takes precedence", async () => { process.env.OPENCLAW_GATEWAY_PASSWORD = "from-env"; - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback", @@ -1196,7 +1196,7 @@ describe("callGateway password resolution", () => { }); it("does not resolve local password ref when token auth can win", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback", @@ -1220,7 +1220,7 @@ describe("callGateway password resolution", () => { it("resolves local password ref before unresolved local token ref can block auth", async () => { process.env.LOCAL_FALLBACK_PASSWORD = "resolved-local-fallback-password"; // pragma: allowlist secret - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback", @@ -1244,7 +1244,7 @@ describe("callGateway password resolution", () => { it("fails closed when unresolved local token SecretRef would otherwise fall back to remote token", async () => { process.env.LOCAL_REMOTE_FALLBACK_TOKEN = "resolved-local-remote-fallback-token"; - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback", @@ -1269,7 +1269,7 @@ describe("callGateway password resolution", () => { it.each(["none", "trusted-proxy"] as const)( "ignores unresolved local password ref when auth mode is %s", async (mode) => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback", @@ -1293,7 +1293,7 @@ describe("callGateway password resolution", () => { ); it("does not resolve local password ref when remote password is already configured", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", @@ -1320,7 +1320,7 @@ describe("callGateway password resolution", () => { it("resolves gateway.remote.token SecretInput refs when remote token is required", async () => { process.env.REMOTE_REF_TOKEN = "resolved-remote-ref-token"; - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", @@ -1344,7 +1344,7 @@ describe("callGateway password resolution", () => { it("resolves gateway.remote.password SecretInput refs when remote password is required", async () => { process.env.REMOTE_REF_PASSWORD = "resolved-remote-ref-password"; // pragma: allowlist secret - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", @@ -1367,7 +1367,7 @@ describe("callGateway password resolution", () => { }); it("does not resolve remote token ref when remote password already wins", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", @@ -1393,7 +1393,7 @@ describe("callGateway password resolution", () => { it("resolves remote token ref before unresolved remote password ref can block auth", async () => { process.env.REMOTE_REF_TOKEN = "resolved-remote-ref-token"; - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", @@ -1418,7 +1418,7 @@ describe("callGateway password resolution", () => { }); it("does not resolve remote password ref when remote token already wins", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", @@ -1444,7 +1444,7 @@ describe("callGateway password resolution", () => { it("resolves remote token refs on local-mode calls when fallback token can win", async () => { process.env.LOCAL_FALLBACK_REMOTE_TOKEN = "resolved-local-fallback-remote-token"; - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback", @@ -1470,7 +1470,7 @@ describe("callGateway password resolution", () => { it.each(["none", "trusted-proxy"] as const)( "does not resolve remote refs on non-remote gateway calls when auth mode is %s", async (mode) => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback", @@ -1501,7 +1501,7 @@ describe("callGateway password resolution", () => { password?: string; token?: string; }; - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ gateway: { mode: "local", auth, diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 659b12aa909..4dbc458ff17 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { loadConfig } from "../config/io.js"; +import { getRuntimeConfig } from "../config/io.js"; import { resolveConfigPath as resolveConfigPathFromPaths, resolveGatewayPort as resolveGatewayPortFromPaths, @@ -83,7 +83,7 @@ export type CallGatewayOptions = CallGatewayBaseOptions & { const defaultCreateGatewayClient = (opts: GatewayClientOptions) => new GatewayClient(opts); const defaultGatewayCallDeps = { createGatewayClient: defaultCreateGatewayClient, - loadConfig, + getRuntimeConfig, loadOrCreateDeviceIdentity, resolveGatewayPort: resolveGatewayPortFromPaths, resolveConfigPath: resolveConfigPathFromPaths, @@ -117,11 +117,11 @@ function resolveGatewayClientDisplayName(opts: CallGatewayBaseOptions): string | function loadGatewayConfig(): OpenClawConfig { const loadConfigFn = - typeof gatewayCallDeps.loadConfig === "function" - ? gatewayCallDeps.loadConfig - : typeof defaultGatewayCallDeps.loadConfig === "function" - ? defaultGatewayCallDeps.loadConfig - : loadConfig; + typeof gatewayCallDeps.getRuntimeConfig === "function" + ? gatewayCallDeps.getRuntimeConfig + : typeof defaultGatewayCallDeps.getRuntimeConfig === "function" + ? defaultGatewayCallDeps.getRuntimeConfig + : getRuntimeConfig; return loadConfigFn(); } @@ -158,7 +158,7 @@ export function buildGatewayConnectionDetails( } = {}, ): GatewayConnectionDetails { return buildGatewayConnectionDetailsWithResolvers(options, { - loadConfig: () => loadGatewayConfig(), + getRuntimeConfig: () => loadGatewayConfig(), resolveConfigPath: (env) => resolveGatewayConfigPath(env), resolveGatewayPort: (config, env) => resolveGatewayPortValue(config, env), }); @@ -168,7 +168,8 @@ export const __testing = { setDepsForTests(deps: Partial | undefined): void { gatewayCallDeps.createGatewayClient = deps?.createGatewayClient ?? defaultGatewayCallDeps.createGatewayClient; - gatewayCallDeps.loadConfig = deps?.loadConfig ?? defaultGatewayCallDeps.loadConfig; + gatewayCallDeps.getRuntimeConfig = + deps?.getRuntimeConfig ?? defaultGatewayCallDeps.getRuntimeConfig; gatewayCallDeps.loadOrCreateDeviceIdentity = deps?.loadOrCreateDeviceIdentity ?? defaultGatewayCallDeps.loadOrCreateDeviceIdentity; gatewayCallDeps.resolveGatewayPort = @@ -186,7 +187,7 @@ export const __testing = { }, resetDepsForTests(): void { gatewayCallDeps.createGatewayClient = defaultGatewayCallDeps.createGatewayClient; - gatewayCallDeps.loadConfig = defaultGatewayCallDeps.loadConfig; + gatewayCallDeps.getRuntimeConfig = defaultGatewayCallDeps.getRuntimeConfig; gatewayCallDeps.loadOrCreateDeviceIdentity = defaultGatewayCallDeps.loadOrCreateDeviceIdentity; gatewayCallDeps.resolveGatewayPort = defaultGatewayCallDeps.resolveGatewayPort; gatewayCallDeps.resolveConfigPath = defaultGatewayCallDeps.resolveConfigPath; diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 4eefdcb676e..1a26fa164f5 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -930,6 +930,57 @@ describe("startGatewayConfigReloader", () => { await harness.reloader.stop(); }); + it("honors in-process write intent to skip reload", async () => { + const readSnapshot = vi + .fn<() => Promise>() + .mockResolvedValueOnce(makeZeroDebounceHookSnapshot("internal-none")); + const promoteSnapshot = vi.fn(async () => true); + const harness = createReloaderHarness(readSnapshot, { promoteSnapshot }); + + harness.emitWrite({ + ...makeZeroDebounceHookWrite("internal-none"), + afterWrite: { mode: "none", reason: "caller handles follow-up" }, + }); + await vi.runOnlyPendingTimersAsync(); + + expect(harness.onHotReload).not.toHaveBeenCalled(); + expect(harness.onRestart).not.toHaveBeenCalled(); + expect(harness.log.info).toHaveBeenCalledWith( + "config reload skipped by writer intent (caller handles follow-up)", + ); + expect(promoteSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ hash: "internal-none" }), + "in-process-write", + ); + + await harness.reloader.stop(); + }); + + it("honors in-process write intent to force restart", async () => { + const readSnapshot = vi + .fn<() => Promise>() + .mockResolvedValueOnce(makeZeroDebounceHookSnapshot("internal-restart")); + const harness = createReloaderHarness(readSnapshot); + + harness.emitWrite({ + ...makeZeroDebounceHookWrite("internal-restart"), + afterWrite: { mode: "restart", reason: "plugin runtime contract changed" }, + }); + await vi.runOnlyPendingTimersAsync(); + + expect(harness.onHotReload).not.toHaveBeenCalled(); + expect(harness.onRestart).toHaveBeenCalledTimes(1); + expect(harness.onRestart).toHaveBeenCalledWith( + expect.objectContaining({ + restartGateway: true, + restartReasons: expect.arrayContaining(["plugin runtime contract changed"]), + }), + expect.any(Object), + ); + + await harness.reloader.stop(); + }); + it("plans in-process reloads from source config and ignores runtime materialized paths", async () => { const baseInstall = { source: "npm" as const, diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 00dbfb33096..ecb0660028f 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -7,7 +7,10 @@ import type { ConfigWriteNotification, GatewayReloadMode, } from "../config/config.js"; -import { shouldAttemptLastKnownGoodRecovery } from "../config/config.js"; +import { + resolveConfigWriteFollowUp, + shouldAttemptLastKnownGoodRecovery, +} from "../config/config.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; import { isPlainObject } from "../utils.js"; import { @@ -157,6 +160,7 @@ export function startGatewayConfigReloader(opts: { config: OpenClawConfig; compareConfig: OpenClawConfig; persistedHash: string; + afterWrite?: ConfigWriteNotification["afterWrite"]; } | null = null; let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null; @@ -249,7 +253,11 @@ export function startGatewayConfigReloader(opts: { return nextSnapshot; }; - const applySnapshot = async (nextConfig: OpenClawConfig, nextCompareConfig: OpenClawConfig) => { + const applySnapshot = async ( + nextConfig: OpenClawConfig, + nextCompareConfig: OpenClawConfig, + afterWrite?: ConfigWriteNotification["afterWrite"], + ) => { const changedPaths = diffConfigPaths(currentCompareConfig, nextCompareConfig); const pluginInstallTimestampNoopPaths = listPluginInstallTimestampMetadataPaths( currentCompareConfig, @@ -276,18 +284,34 @@ export function startGatewayConfigReloader(opts: { opts.log.info(`skills snapshot invalidated by config change (${skillsChangedPath})`); } + const followUp = resolveConfigWriteFollowUp(afterWrite); opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`); + if (followUp.mode === "none") { + opts.log.info(`config reload skipped by writer intent (${followUp.reason})`); + return; + } const plan = buildGatewayReloadPlan(changedPaths, { noopPaths: pluginInstallTimestampNoopPaths, forceChangedPaths: pluginInstallWholeRecordPaths, }); - if (isNoopReloadPlan(plan)) { + if (isNoopReloadPlan(plan) && !followUp.requiresRestart) { return; } if (settings.mode === "off") { opts.log.info("config reload disabled (gateway.reload.mode=off)"); return; } + if (followUp.requiresRestart) { + queueRestart( + { + ...plan, + restartGateway: true, + restartReasons: [...plan.restartReasons, followUp.reason], + }, + nextConfig, + ); + return; + } if (settings.mode === "restart") { queueRestart(plan, nextConfig); return; @@ -352,7 +376,11 @@ export function startGatewayConfigReloader(opts: { const pendingWrite = pendingInProcessConfig; pendingInProcessConfig = null; missingConfigRetries = 0; - await applySnapshot(pendingWrite.config, pendingWrite.compareConfig); + await applySnapshot( + pendingWrite.config, + pendingWrite.compareConfig, + pendingWrite.afterWrite, + ); await promoteAcceptedInProcessWrite(pendingWrite.persistedHash); return; } @@ -406,6 +434,7 @@ export function startGatewayConfigReloader(opts: { config: event.runtimeConfig, compareConfig: event.sourceConfig, persistedHash: event.persistedHash, + afterWrite: event.afterWrite, }; lastAppliedWriteHash = event.persistedHash; scheduleAfter(0); diff --git a/src/gateway/connection-details.ts b/src/gateway/connection-details.ts index 078a03ca1cd..705e3b97700 100644 --- a/src/gateway/connection-details.ts +++ b/src/gateway/connection-details.ts @@ -12,7 +12,7 @@ export type GatewayConnectionDetails = { }; type GatewayConnectionDetailResolvers = { - loadConfig?: () => OpenClawConfig; + getRuntimeConfig?: () => OpenClawConfig; resolveConfigPath?: (env: NodeJS.ProcessEnv) => string; resolveGatewayPort?: (cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv) => number; }; @@ -26,7 +26,7 @@ export function buildGatewayConnectionDetailsWithResolvers( } = {}, resolvers: GatewayConnectionDetailResolvers = {}, ): GatewayConnectionDetails { - const config = options.config ?? resolvers.loadConfig?.() ?? {}; + const config = options.config ?? resolvers.getRuntimeConfig?.() ?? {}; const configPath = options.configPath ?? resolvers.resolveConfigPath?.(process.env) ?? diff --git a/src/gateway/embeddings-http.ts b/src/gateway/embeddings-http.ts index 010c3b019a0..d3af3746f81 100644 --- a/src/gateway/embeddings-http.ts +++ b/src/gateway/embeddings-http.ts @@ -2,7 +2,7 @@ import { Buffer } from "node:buffer"; import type { IncomingMessage, ServerResponse } from "node:http"; import { resolveAgentDir } from "../agents/agent-scope.js"; import { resolveMemorySearchConfig } from "../agents/memory-search.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { logWarn } from "../logger.js"; @@ -239,7 +239,7 @@ export async function handleOpenAiEmbeddingsHttpRequest( return true; } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); if (requestModel !== OPENCLAW_MODEL_ID && !resolveAgentIdFromModel(requestModel, cfg)) { sendJson(res, 400, { error: { diff --git a/src/gateway/exec-approval-ios-push.test.ts b/src/gateway/exec-approval-ios-push.test.ts index 6e15acb1c4c..f291850426b 100644 --- a/src/gateway/exec-approval-ios-push.test.ts +++ b/src/gateway/exec-approval-ios-push.test.ts @@ -50,7 +50,7 @@ function mockPairedIosOperator(scopes: string[]) { } vi.mock("../config/config.js", () => ({ - loadConfig: () => ({ gateway: {} }), + getRuntimeConfig: () => ({ gateway: {} }), })); vi.mock("../infra/device-pairing.js", async () => { diff --git a/src/gateway/exec-approval-ios-push.ts b/src/gateway/exec-approval-ios-push.ts index 8b78b90bc06..1f925d66d5b 100644 --- a/src/gateway/exec-approval-ios-push.ts +++ b/src/gateway/exec-approval-ios-push.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { hasEffectivePairedDeviceRole, listDevicePairing, @@ -139,7 +139,7 @@ async function resolveDeliveryPlan(params: { let relayConfig: ApnsRelayConfig | undefined; if (needsRelay) { - const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway); + const relay = resolveApnsRelayConfigFromEnv(process.env, getRuntimeConfig().gateway); if (relay.ok) { relayConfig = relay.value; } else { diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts index af33b192430..0f620b05ad0 100644 --- a/src/gateway/gateway-acp-bind.live.test.ts +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -6,7 +6,11 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { getAcpRuntimeBackend } from "../acp/runtime/registry.js"; import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; -import { clearConfigCache, clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js"; +import { + clearConfigCache, + clearRuntimeConfigSnapshot, + getRuntimeConfig, +} from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { clearPluginLoaderCache } from "../plugins/loader.js"; import { @@ -596,7 +600,7 @@ describeLive("gateway live (ACP bind)", () => { await prepareCodexHomeForLiveBindTest(); } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const acpxEntry = cfg.plugins?.entries?.acpx; const existingAgentOverrides: Record = typeof acpxEntry?.config === "object" && diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 65d15fe296a..3df09c7712a 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -32,7 +32,7 @@ import { ensureOpenClawModelsJson } from "../agents/models-config.js"; import { isRateLimitErrorMessage } from "../agents/pi-embedded-helpers/errors.js"; import { discoverAuthStorage, discoverModels } from "../agents/pi-model-discovery.js"; import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js"; -import { clearRuntimeConfigSnapshot, loadConfig } from "../config/io.js"; +import { clearRuntimeConfigSnapshot, getRuntimeConfig } from "../config/io.js"; import type { ModelsConfig, ModelProviderConfig, OpenClawConfig } from "../config/types.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { normalizeGoogleModelId } from "../plugin-sdk/google-model-id.js"; @@ -2131,7 +2131,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { async () => await withSuppressedGatewayLiveWarnings(async () => { clearRuntimeConfigSnapshot(); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); @@ -2281,7 +2281,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { const token = `test-${randomUUID()}`; process.env.OPENCLAW_GATEWAY_TOKEN = token; - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); await ensureOpenClawModelsJson(cfg); const agentDir = resolveOpenClawAgentDir(); diff --git a/src/gateway/gateway.test.ts b/src/gateway/gateway.test.ts index ffb0cfc2348..2f1af51f05c 100644 --- a/src/gateway/gateway.test.ts +++ b/src/gateway/gateway.test.ts @@ -430,7 +430,12 @@ module.exports = { expect(resToken.ok).toBe(true); } finally { await server2.close({ reason: "wizard auth verify" }); - await fs.rm(tempHome, { recursive: true, force: true }); + await fs.rm(tempHome, { + recursive: true, + force: true, + maxRetries: 10, + retryDelay: 50, + }); envSnapshot.restore(); } }, diff --git a/src/gateway/http-auth-utils.ts b/src/gateway/http-auth-utils.ts index 9ad4b513ead..2578f40c32f 100644 --- a/src/gateway/http-auth-utils.ts +++ b/src/gateway/http-auth-utils.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeLowercaseStringOrEmpty, @@ -52,7 +52,7 @@ export type GatewayHttpRequestAuthCheckResult = export function resolveHttpBrowserOriginPolicy( req: IncomingMessage, - cfg = loadConfig(), + cfg = getRuntimeConfig(), ): NonNullable[0]["browserOriginPolicy"]> { return { requestHost: getHeader(req, "host"), @@ -151,7 +151,7 @@ export async function authorizeScopedGatewayHttpRequestOrReply(params: { requestAuth: AuthorizedGatewayHttpRequest, ) => string[]; }): Promise<{ cfg: OpenClawConfig; requestAuth: AuthorizedGatewayHttpRequest } | null> { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const requestAuth = await authorizeGatewayHttpRequestOrReply({ req: params.req, res: params.res, diff --git a/src/gateway/http-utils.authorize-request.test.ts b/src/gateway/http-utils.authorize-request.test.ts index 7e339a72086..06a08b70fdb 100644 --- a/src/gateway/http-utils.authorize-request.test.ts +++ b/src/gateway/http-utils.authorize-request.test.ts @@ -6,7 +6,7 @@ vi.mock("./auth.js", () => ({ })); vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn(() => ({ + getRuntimeConfig: vi.fn(() => ({ gateway: { controlUi: { allowedOrigins: ["https://control.example.com"], diff --git a/src/gateway/http-utils.model-override.test.ts b/src/gateway/http-utils.model-override.test.ts index 48fe2a08820..1e29ffc2595 100644 --- a/src/gateway/http-utils.model-override.test.ts +++ b/src/gateway/http-utils.model-override.test.ts @@ -6,7 +6,7 @@ const loadConfigMock = vi.fn(); const loadGatewayModelCatalogMock = vi.fn(); vi.mock("../config/config.js", () => ({ - loadConfig: () => loadConfigMock(), + getRuntimeConfig: () => loadConfigMock(), })); vi.mock("./server-model-catalog.js", () => ({ diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index 9319cab56b6..0a217a99a2d 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -7,7 +7,7 @@ import { parseModelRef, resolveDefaultModelForAgent, } from "../agents/model-selection.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty, @@ -49,7 +49,7 @@ export function resolveAgentIdFromHeader(req: IncomingMessage): string | undefin export function resolveAgentIdFromModel( model: string | undefined, - cfg = loadConfig(), + cfg = getRuntimeConfig(), ): string | undefined { const raw = model?.trim(); if (!raw) { @@ -87,7 +87,7 @@ export async function resolveOpenAiCompatModelOverride(params: { return {}; } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const defaultModelRef = resolveDefaultModelForAgent({ cfg, agentId: params.agentId }); const defaultProvider = defaultModelRef.provider; const parsed = parseModelRef(raw, defaultProvider); @@ -116,7 +116,7 @@ export function resolveAgentIdForRequest(params: { req: IncomingMessage; model: string | undefined; }): string { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const fromHeader = resolveAgentIdFromHeader(params.req); if (fromHeader) { return fromHeader; diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 28a262084a8..b053f507c24 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -44,7 +44,7 @@ const resolveGatewayScopedToolsMock = vi.hoisted(() => ); vi.mock("../config/config.js", () => ({ - loadConfig: () => ({ session: { mainKey: "main" } }), + getRuntimeConfig: () => ({ session: { mainKey: "main" } }), })); vi.mock("../config/sessions.js", () => ({ diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index 2faff020ddf..22db91df3ef 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -4,7 +4,7 @@ import { type IncomingMessage, type ServerResponse, } from "node:http"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { formatErrorMessage } from "../infra/errors.js"; import { logDebug, logWarn } from "../logger.js"; @@ -105,7 +105,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ try { const body = await readMcpHttpBody(req); const parsed: JsonRpcRequest | JsonRpcRequest[] = JSON.parse(body); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const requestContext = resolveMcpRequestContext(req, cfg, auth); const scopedTools = toolCache.resolve({ cfg, diff --git a/src/gateway/models-http.ts b/src/gateway/models-http.ts index fc774aab681..3478d94f43d 100644 --- a/src/gateway/models-http.ts +++ b/src/gateway/models-http.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import { sendInvalidRequest, sendJson, sendMethodNotAllowed } from "./http-common.js"; @@ -55,7 +55,7 @@ async function authorizeRequest( } function loadAgentModelIds(): string[] { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const defaultAgentId = resolveDefaultAgentId(cfg); const ids = new Set([OPENCLAW_MODEL_ID, OPENCLAW_DEFAULT_MODEL_ID]); ids.add(`openclaw/${defaultAgentId}`); diff --git a/src/gateway/server-channels.approval-bootstrap.test.ts b/src/gateway/server-channels.approval-bootstrap.test.ts index 24615b1c464..d0ffcc672a3 100644 --- a/src/gateway/server-channels.approval-bootstrap.test.ts +++ b/src/gateway/server-channels.approval-bootstrap.test.ts @@ -95,7 +95,7 @@ function createManager( const runtime = runtimeForLogger(log); const channelRuntimeEnvs = { discord: runtime } as unknown as Record; return createChannelManager({ - loadConfig: () => ({}), + getRuntimeConfig: () => ({}), channelLogs, channelRuntimeEnvs, ...(options?.channelRuntime ? { channelRuntime: options.channelRuntime } : {}), diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index 224adfa6acb..236e5feaa87 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -117,7 +117,7 @@ function installTestRegistry(...plugins: ChannelPlugin[]) { function createManager(options?: { channelRuntime?: PluginRuntime["channel"]; resolveChannelRuntime?: () => PluginRuntime["channel"] | Promise; - loadConfig?: () => Record; + getRuntimeConfig?: () => Record; channelIds?: ChannelId[]; }) { const log = createSubsystemLogger("gateway/server-channels-test"); @@ -130,7 +130,7 @@ function createManager(options?: { channelRuntimeEnvs[channelId] ??= runtime; } return createChannelManager({ - loadConfig: () => options?.loadConfig?.() ?? {}, + getRuntimeConfig: () => options?.getRuntimeConfig?.() ?? {}, channelLogs, channelRuntimeEnvs, ...(options?.channelRuntime ? { channelRuntime: options.channelRuntime } : {}), @@ -518,7 +518,7 @@ describe("server-channels auto restart", () => { ); const manager = createManager({ - loadConfig: () => ({ + getRuntimeConfig: () => ({ channels: { discord: { accounts: { @@ -547,7 +547,7 @@ describe("server-channels auto restart", () => { ); const manager = createManager({ - loadConfig: () => ({ + getRuntimeConfig: () => ({ channels: { discord: { healthMonitor: { enabled: false }, @@ -570,7 +570,7 @@ describe("server-channels auto restart", () => { ); const manager = createManager({ - loadConfig: () => ({ + getRuntimeConfig: () => ({ channels: { discord: { accounts: { @@ -611,7 +611,7 @@ describe("server-channels auto restart", () => { ); const manager = createManager({ - loadConfig: () => ({ + getRuntimeConfig: () => ({ channels: { discord: { accounts: { diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 56131c7575b..bf6ad4dd855 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -118,7 +118,7 @@ function applyDescribedAccountFields( } type ChannelManagerOptions = { - loadConfig: () => OpenClawConfig; + getRuntimeConfig: () => OpenClawConfig; channelLogs: Record; channelRuntimeEnvs: Record; /** @@ -141,7 +141,7 @@ type ChannelManagerOptions = { * import { createPluginRuntime } from "../plugins/runtime/index.js"; * * const channelManager = createChannelManager({ - * loadConfig, + * getRuntimeConfig, * channelLogs, * channelRuntimeEnvs, * channelRuntime: createPluginRuntime().channel, @@ -181,8 +181,13 @@ export type ChannelManager = { // Channel docking: lifecycle hooks (`plugin.gateway`) flow through this manager. export function createChannelManager(opts: ChannelManagerOptions): ChannelManager { - const { loadConfig, channelLogs, channelRuntimeEnvs, channelRuntime, resolveChannelRuntime } = - opts; + const { + getRuntimeConfig, + channelLogs, + channelRuntimeEnvs, + channelRuntime, + resolveChannelRuntime, + } = opts; const channelStores = new Map(); // Tracks restart attempts per channel:account. Reset on successful start. @@ -220,7 +225,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage }; const isHealthMonitorEnabled = (channelId: ChannelId, accountId: string): boolean => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const channelConfig = cfg.channels?.[channelId] as ChannelHealthMonitorConfig | undefined; const accountOverride = resolveAccountHealthMonitorOverride(channelConfig, accountId); const channelOverride = channelConfig?.healthMonitor?.enabled; @@ -314,7 +319,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage return; } const { preserveRestartAttempts = false, preserveManualStop = false } = opts; - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); resetDirectoryCache({ channel: channelId, accountId }); const store = getStore(channelId); const accountIds = accountId ? [accountId] : plugin.config.listAccountIds(cfg); @@ -557,7 +562,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage if (!plugin?.gateway?.stopAccount && store.aborts.size === 0 && store.tasks.size === 0) { return; } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const knownIds = new Set([ ...store.aborts.keys(), ...store.starting.keys(), @@ -647,7 +652,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage if (!plugin) { return; } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const resolvedId = accountId ?? resolveChannelDefaultAccountId({ @@ -668,7 +673,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage }; const getRuntimeSnapshot = (): ChannelRuntimeSnapshot => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const channels: ChannelRuntimeSnapshot["channels"] = {}; const channelAccounts: ChannelRuntimeSnapshot["channelAccounts"] = {}; for (const plugin of listChannelPlugins()) { diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 85553e60878..2772e3d6772 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -9,7 +9,7 @@ vi.mock("./server-chat.persist-session-lifecycle.runtime.js", () => ({ })); vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), })); vi.mock("../infra/heartbeat-visibility.js", () => ({ @@ -24,7 +24,7 @@ vi.mock("./server-chat.load-gateway-session-row.runtime.js", () => ({ loadGatewaySessionRow: vi.fn(), })); -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js"; import { createAgentEventHandler, @@ -36,7 +36,7 @@ import { loadGatewaySessionRow } from "./server-chat.load-gateway-session-row.ru describe("agent event handler", () => { beforeEach(() => { - vi.mocked(loadConfig).mockReturnValue({}); + vi.mocked(getRuntimeConfig).mockReturnValue({}); vi.mocked(resolveHeartbeatVisibility).mockReturnValue({ showOk: false, showAlerts: true, @@ -1500,7 +1500,7 @@ describe("agent event handler", () => { }); it("keeps heartbeat alert text in final chat output when remainder exceeds ackMaxChars", () => { - vi.mocked(loadConfig).mockReturnValue({ + vi.mocked(getRuntimeConfig).mockReturnValue({ agents: { defaults: { heartbeat: { ackMaxChars: 10 } } }, }); diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 8bd30a66cdc..f79aa231ad9 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -1,6 +1,6 @@ import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-reply/heartbeat.js"; import { normalizeVerboseLevel } from "../auto-reply/thinking.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js"; import { detectErrorKind, type ErrorKind } from "../infra/errors.js"; import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js"; @@ -19,7 +19,7 @@ import { formatForLog } from "./ws-log.js"; function resolveHeartbeatAckMaxChars(): number { try { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); return Math.max( 0, cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, @@ -53,7 +53,7 @@ function shouldHideHeartbeatChatOutput(runId: string, sourceRunId?: string): boo } try { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" }); return !visibility.showOk; } catch { diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index 10e6c3757de..edc35e1c82a 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -61,7 +61,7 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, - loadConfig: () => loadConfigMock(), + getRuntimeConfig: () => loadConfigMock(), }; }); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 6deefc776a1..a9487c7699f 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -1,7 +1,7 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-cleanup.js"; import type { CliDeps } from "../cli/deps.types.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { canonicalizeMainSessionAlias, resolveAgentIdFromSessionKey, @@ -80,7 +80,7 @@ export function buildGatewayCronService(params: { }; const resolveCronAgent = (requested?: string | null) => { - const runtimeConfig = loadConfig(); + const runtimeConfig = getRuntimeConfig(); const normalized = typeof requested === "string" && requested.trim() ? normalizeAgentId(requested) : undefined; const effectiveConfig = @@ -136,7 +136,7 @@ export function buildGatewayCronService(params: { (opts?.sessionKey ? normalizeAgentId(resolveAgentIdFromSessionKey(opts.sessionKey)) : undefined); - const runtimeConfigBase = loadConfig(); + const runtimeConfigBase = getRuntimeConfig(); const runtimeConfig = derivedAgentId !== undefined ? mergeRuntimeAgentConfig(runtimeConfigBase, derivedAgentId) diff --git a/src/gateway/server-http.probe.test.ts b/src/gateway/server-http.probe.test.ts index 8d6bd165c99..a6457da7261 100644 --- a/src/gateway/server-http.probe.test.ts +++ b/src/gateway/server-http.probe.test.ts @@ -266,14 +266,14 @@ describe("gateway probe endpoints", () => { }); it("serves /healthz before loading gateway config", async () => { - const loadConfig = vi.fn(() => { + const getRuntimeConfig = vi.fn(() => { throw new Error("config load blocked"); }); await withGatewayServer({ prefix: "probe-healthz-before-config", resolvedAuth: AUTH_NONE, - overrides: { loadConfig }, + overrides: { getRuntimeConfig }, run: async (server) => { const req = createRequest({ path: "/healthz" }); const { res, getBody } = createResponse(); @@ -281,7 +281,7 @@ describe("gateway probe endpoints", () => { expect(res.statusCode).toBe(200); expect(getBody()).toBe(JSON.stringify({ ok: true, status: "live" })); - expect(loadConfig).not.toHaveBeenCalled(); + expect(getRuntimeConfig).not.toHaveBeenCalled(); }, }); }); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 970b4b49fbc..26d64999074 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -15,7 +15,7 @@ import { } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; import { resolveBundledChannelGatewayAuthBypassPaths } from "../channels/plugins/gateway-auth-bypass.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createDiagnosticTraceContext, @@ -486,7 +486,7 @@ export function createGatewayHttpServer(opts: { /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; getReadiness?: ReadinessChecker; - loadConfig?: () => OpenClawConfig; + getRuntimeConfig?: () => OpenClawConfig; tlsOptions?: TlsOptions; }): HttpServer { const { @@ -508,7 +508,7 @@ export function createGatewayHttpServer(opts: { getReadiness, } = opts; const getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth); - const loadGatewayConfig = opts.loadConfig ?? loadConfig; + const loadGatewayConfig = opts.getRuntimeConfig ?? getRuntimeConfig; const openAiCompatEnabled = openAiChatCompletionsEnabled || openResponsesEnabled; const httpServer: HttpServer = opts.tlsOptions ? createHttpsServer(opts.tlsOptions, (req, res) => { @@ -829,7 +829,7 @@ export function attachGatewayUpgradeHandler(opts: { const getResolvedAuth = opts.getResolvedAuth ?? (() => resolvedAuth); httpServer.on("upgrade", (req, socket, head) => { void runWithDiagnosticTraceContext(createDiagnosticTraceContext(), async () => { - const configSnapshot = loadConfig(); + const configSnapshot = getRuntimeConfig(); const trustedProxies = configSnapshot.gateway?.trustedProxies ?? []; const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true; const scopedCanvas = normalizeCanvasScopedUrl(req.url ?? "/"); diff --git a/src/gateway/server-methods/agent.create-event.test.ts b/src/gateway/server-methods/agent.create-event.test.ts index 6e17587e44f..d28c5005fbb 100644 --- a/src/gateway/server-methods/agent.create-event.test.ts +++ b/src/gateway/server-methods/agent.create-event.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const configMocks = vi.hoisted(() => ({ storePath: "", workspaceDir: "", - loadConfig: vi.fn(() => ({ + getRuntimeConfig: vi.fn(() => ({ agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" }, @@ -25,7 +25,7 @@ const agentIngressMocks = vi.hoisted(() => ({ })); vi.mock("../../config/config.js", () => ({ - loadConfig: configMocks.loadConfig, + getRuntimeConfig: configMocks.getRuntimeConfig, })); vi.mock("../../commands/agent.js", () => ({ @@ -51,7 +51,7 @@ describe("agent handler session create events", () => { storePath = path.join(tempDir, "sessions.json"); configMocks.storePath = storePath; configMocks.workspaceDir = tempDir; - configMocks.loadConfig.mockClear(); + configMocks.getRuntimeConfig.mockClear(); agentIngressMocks.agentCommandFromIngress.mockClear(); agentIngressMocks.agentCommandFromIngress.mockResolvedValue({ ok: true }); await fs.writeFile(storePath, "{}\n", "utf8"); @@ -80,6 +80,7 @@ describe("agent handler session create events", () => { chatAbortControllers: new Map(), addChatRun: vi.fn(), registerToolEventRecipient: vi.fn(), + getRuntimeConfig: configMocks.getRuntimeConfig, getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), broadcastToConnIds, } as never, diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 47f71416ee8..4faa4e15d3c 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -77,7 +77,7 @@ vi.mock("../../config/config.js", async () => { await vi.importActual("../../config/config.js"); return { ...actual, - loadConfig: () => mocks.loadConfigReturn, + getRuntimeConfig: () => mocks.loadConfigReturn, }; }); @@ -154,6 +154,7 @@ const makeContext = (): GatewayRequestContext => logGateway: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, broadcastToConnIds: vi.fn(), getSessionEventSubscriberConnIds: () => new Set(), + getRuntimeConfig: () => mocks.loadConfigReturn, }) as unknown as GatewayRequestContext; type AgentHandlerArgs = Parameters[0]; @@ -673,6 +674,7 @@ describe("gateway agent handler", () => { logGateway: { info: vi.fn(), error: vi.fn() }, broadcastToConnIds, getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), + getRuntimeConfig: () => mocks.loadConfigReturn, } as unknown as GatewayRequestContext, }, ); @@ -754,6 +756,7 @@ describe("gateway agent handler", () => { logGateway: { info: vi.fn(), error: vi.fn() }, broadcastToConnIds, getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), + getRuntimeConfig: () => mocks.loadConfigReturn, } as unknown as GatewayRequestContext, }, ); @@ -895,6 +898,7 @@ describe("gateway agent handler", () => { logGateway: { info: logInfo, error: vi.fn() }, broadcastToConnIds: vi.fn(), getSessionEventSubscriberConnIds: () => new Set(), + getRuntimeConfig: () => mocks.loadConfigReturn, } as unknown as GatewayRequestContext, }, ); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index e5047cf2283..5f9f98a37ac 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -24,7 +24,6 @@ import { shouldApplyStartupContext, } from "../../auto-reply/reply/startup-context.js"; import { agentCommandFromIngress } from "../../commands/agent.js"; -import { loadConfig } from "../../config/config.js"; import { evaluateSessionFreshness, mergeSessionEntry, @@ -456,7 +455,7 @@ export const agentHandlers: GatewayRequestHandlers = { } const providerOverride = allowModelOverride ? request.provider : undefined; const modelOverride = allowModelOverride ? request.model : undefined; - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const idem = request.idempotencyKey; const normalizedSpawned = normalizeSpawnedRunMetadata({ groupId: request.groupId, @@ -1206,7 +1205,7 @@ export const agentHandlers: GatewayRequestHandlers = { } } }, - "agent.identity.get": ({ params, respond }) => { + "agent.identity.get": ({ params, respond, context }) => { if (!validateAgentIdentityParams(params)) { respond( false, @@ -1250,7 +1249,7 @@ export const agentHandlers: GatewayRequestHandlers = { } agentId = resolved; } - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const identity = resolveAssistantIdentity({ cfg, agentId }); const avatarValue = resolveAssistantAvatarUrl({ diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 8abedfe301e..844b319e862 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ findAgentEntryIndex: vi.fn((_list?: unknown, _agentId?: string) => -1), applyAgentConfig: vi.fn((_cfg: unknown, _opts: unknown) => ({})), pruneAgentConfig: vi.fn(() => ({ config: {}, removedBindings: 0 })), - writeConfigFile: vi.fn(async () => {}), + writeConfigFile: vi.fn(async (_nextConfig?: unknown) => {}), ensureAgentWorkspace: vi.fn( async (params?: { dir?: string }): Promise<{ dir: string; identityPathCreated: boolean }> => ({ dir: params?.dir @@ -47,8 +47,10 @@ vi.mock("../../config/config.js", async () => { await vi.importActual("../../config/config.js"); return { ...actual, - loadConfig: () => mocks.loadConfigReturn, + getRuntimeConfig: () => mocks.loadConfigReturn, writeConfigFile: mocks.writeConfigFile, + replaceConfigFile: async (params: { nextConfig: unknown }) => + await mocks.writeConfigFile(params.nextConfig), }; }); @@ -160,7 +162,7 @@ function makeCall(method: keyof typeof agentsHandlers, params: Record mocks.loadConfigReturn } as never, req: { type: "req" as const, id: "1", method }, client: null, isWebchatConnect: () => false, diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index a87dc2f82e3..0f93665f447 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -26,7 +26,7 @@ import { listAgentEntries, pruneAgentConfig, } from "../../commands/agents.config.js"; -import { loadConfig, writeConfigFile } from "../../config/config.js"; +import { replaceConfigFile } from "../../config/config.js"; import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js"; import type { IdentityConfig } from "../../config/types.base.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -101,13 +101,13 @@ const ALLOWED_FILE_NAMES = new Set([...BOOTSTRAP_FILE_NAMES, ...MEMORY_F function resolveAgentWorkspaceFileOrRespondError( params: Record, respond: RespondFn, + cfg: OpenClawConfig, ): { cfg: OpenClawConfig; agentId: string; workspaceDir: string; name: string; } | null { - const cfg = loadConfig(); const rawAgentId = params.agentId; const agentId = resolveAgentIdOrError( typeof rawAgentId === "string" || typeof rawAgentId === "number" ? String(rawAgentId) : "", @@ -413,7 +413,7 @@ async function buildIdentityMarkdownOrRespondUnsafe(params: { } export const agentsHandlers: GatewayRequestHandlers = { - "agents.list": ({ params, respond }) => { + "agents.list": ({ params, respond, context }) => { if (!validateAgentsListParams(params)) { respond( false, @@ -426,11 +426,11 @@ export const agentsHandlers: GatewayRequestHandlers = { return; } - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const result = listAgentsForGateway(cfg); respond(true, result, undefined); }, - "agents.create": async ({ params, respond }) => { + "agents.create": async ({ params, respond, context }) => { if (!validateAgentsCreateParams(params)) { respond( false, @@ -445,7 +445,7 @@ export const agentsHandlers: GatewayRequestHandlers = { return; } - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const rawName = params.name.trim(); const agentId = normalizeAgentId(rawName); if (agentId === DEFAULT_AGENT_ID) { @@ -518,17 +518,20 @@ export const agentsHandlers: GatewayRequestHandlers = { return; } } - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + afterWrite: { mode: "auto" }, + }); respond(true, { ok: true, agentId, name: safeName, workspace: workspaceDir, model }, undefined); }, - "agents.update": async ({ params, respond }) => { + "agents.update": async ({ params, respond, context }) => { if (!validateAgentsUpdateParams(params)) { respondInvalidMethodParams(respond, "agents.update", validateAgentsUpdateParams.errors); return; } - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const agentId = normalizeAgentId(params.agentId); if (!isConfiguredAgent(cfg, agentId)) { respondAgentNotFound(respond, agentId); @@ -606,17 +609,20 @@ export const agentsHandlers: GatewayRequestHandlers = { } } - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + afterWrite: { mode: "auto" }, + }); respond(true, { ok: true, agentId }, undefined); }, - "agents.delete": async ({ params, respond }) => { + "agents.delete": async ({ params, respond, context }) => { if (!validateAgentsDeleteParams(params)) { respondInvalidMethodParams(respond, "agents.delete", validateAgentsDeleteParams.errors); return; } - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const agentId = normalizeAgentId(params.agentId); if (agentId === DEFAULT_AGENT_ID) { respond( @@ -637,7 +643,10 @@ export const agentsHandlers: GatewayRequestHandlers = { const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); const result = pruneAgentConfig(cfg, agentId); - await writeConfigFile(result.config); + await replaceConfigFile({ + nextConfig: result.config, + afterWrite: { mode: "auto" }, + }); // Purge session store entries so orphaned sessions cannot be targeted (#65524). await purgeAgentSessionStoreEntries(cfg, agentId); @@ -652,7 +661,7 @@ export const agentsHandlers: GatewayRequestHandlers = { respond(true, { ok: true, agentId, removedBindings: result.removedBindings }, undefined); }, - "agents.files.list": async ({ params, respond }) => { + "agents.files.list": async ({ params, respond, context }) => { if (!validateAgentsFilesListParams(params)) { respond( false, @@ -666,7 +675,7 @@ export const agentsHandlers: GatewayRequestHandlers = { ); return; } - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const agentId = resolveAgentIdOrError(params.agentId, cfg); if (!agentId) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown agent id")); @@ -682,12 +691,16 @@ export const agentsHandlers: GatewayRequestHandlers = { const files = await listAgentFiles(workspaceDir, { hideBootstrap }); respond(true, { agentId, workspace: workspaceDir, files }, undefined); }, - "agents.files.get": async ({ params, respond }) => { + "agents.files.get": async ({ params, respond, context }) => { if (!validateAgentsFilesGetParams(params)) { respondInvalidMethodParams(respond, "agents.files.get", validateAgentsFilesGetParams.errors); return; } - const resolved = resolveAgentWorkspaceFileOrRespondError(params, respond); + const resolved = resolveAgentWorkspaceFileOrRespondError( + params, + respond, + context.getRuntimeConfig(), + ); if (!resolved) { return; } @@ -729,12 +742,16 @@ export const agentsHandlers: GatewayRequestHandlers = { undefined, ); }, - "agents.files.set": async ({ params, respond }) => { + "agents.files.set": async ({ params, respond, context }) => { if (!validateAgentsFilesSetParams(params)) { respondInvalidMethodParams(respond, "agents.files.set", validateAgentsFilesSetParams.errors); return; } - const resolved = resolveAgentWorkspaceFileOrRespondError(params, respond); + const resolved = resolveAgentWorkspaceFileOrRespondError( + params, + respond, + context.getRuntimeConfig(), + ); if (!resolved) { return; } diff --git a/src/gateway/server-methods/channels.start.test.ts b/src/gateway/server-methods/channels.start.test.ts index 7f10b21aa5f..8240ab2ab88 100644 --- a/src/gateway/server-methods/channels.start.test.ts +++ b/src/gateway/server-methods/channels.start.test.ts @@ -3,13 +3,13 @@ import type { ChannelRuntimeSnapshot } from "../server-channel-runtime.types.js" import type { GatewayRequestHandlerOptions } from "./types.js"; const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), applyPluginAutoEnable: vi.fn(), getChannelPlugin: vi.fn(), })); vi.mock("../../config/config.js", () => ({ - loadConfig: mocks.loadConfig, + getRuntimeConfig: mocks.getRuntimeConfig, readConfigFileSnapshot: vi.fn(), })); @@ -36,6 +36,7 @@ function createOptions( isWebchatConnect: () => false, respond: vi.fn(), context: { + getRuntimeConfig: mocks.getRuntimeConfig, startChannel: vi.fn(), getRuntimeSnapshot: vi.fn( (): ChannelRuntimeSnapshot => ({ @@ -63,7 +64,7 @@ function createOptions( describe("channelsHandlers channels.start", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.loadConfig.mockReturnValue({}); + mocks.getRuntimeConfig.mockReturnValue({}); mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] })); mocks.getChannelPlugin.mockReturnValue({ id: "whatsapp", @@ -86,6 +87,7 @@ describe("channelsHandlers channels.start", () => { { respond, context: { + getRuntimeConfig: mocks.getRuntimeConfig, startChannel, getRuntimeSnapshot: vi.fn( (): ChannelRuntimeSnapshot => ({ @@ -136,6 +138,7 @@ describe("channelsHandlers channels.start", () => { { respond, context: { + getRuntimeConfig: mocks.getRuntimeConfig, startChannel, getRuntimeSnapshot: vi.fn( (): ChannelRuntimeSnapshot => ({ diff --git a/src/gateway/server-methods/channels.status.test.ts b/src/gateway/server-methods/channels.status.test.ts index 8235a55f7aa..66e29f4e325 100644 --- a/src/gateway/server-methods/channels.status.test.ts +++ b/src/gateway/server-methods/channels.status.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayRequestHandlerOptions } from "./types.js"; const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), applyPluginAutoEnable: vi.fn(), listChannelPlugins: vi.fn(), buildChannelUiCatalog: vi.fn(), @@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../../config/config.js", () => ({ - loadConfig: mocks.loadConfig, + getRuntimeConfig: mocks.getRuntimeConfig, readConfigFileSnapshot: vi.fn(async () => ({ config: {}, path: "openclaw.config.json", @@ -55,6 +55,7 @@ function createOptions( isWebchatConnect: () => false, respond: vi.fn(), context: { + getRuntimeConfig: mocks.getRuntimeConfig, getRuntimeSnapshot: () => ({ channels: {}, channelAccounts: {}, @@ -67,7 +68,7 @@ function createOptions( describe("channelsHandlers channels.status", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.loadConfig.mockReturnValue({}); + mocks.getRuntimeConfig.mockReturnValue({}); mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] })); mocks.buildChannelUiCatalog.mockReturnValue({ order: ["whatsapp"], diff --git a/src/gateway/server-methods/channels.ts b/src/gateway/server-methods/channels.ts index b8daa362314..be9653b2cdd 100644 --- a/src/gateway/server-methods/channels.ts +++ b/src/gateway/server-methods/channels.ts @@ -9,7 +9,7 @@ import { import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js"; -import { loadConfig, readConfigFileSnapshot } from "../../config/config.js"; +import { readConfigFileSnapshot } from "../../config/config.js"; import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getChannelActivity } from "../../infra/channel-activity.js"; @@ -155,7 +155,7 @@ export const channelsHandlers: GatewayRequestHandlers = { const timeoutMsRaw = (params as { timeoutMs?: unknown }).timeoutMs; const timeoutMs = resolveChannelsStatusTimeoutMs({ probe, timeoutMsRaw }); const cfg = applyPluginAutoEnable({ - config: loadConfig(), + config: context.getRuntimeConfig(), env: process.env, }).config; const runtime = context.getRuntimeSnapshot(); @@ -376,7 +376,7 @@ export const channelsHandlers: GatewayRequestHandlers = { } try { const cfg = applyPluginAutoEnable({ - config: loadConfig(), + config: context.getRuntimeConfig(), env: process.env, }).config; const payload = await startChannelAccount({ diff --git a/src/gateway/server-methods/commands.test.ts b/src/gateway/server-methods/commands.test.ts index 37c087e2a97..cc29dd46fa3 100644 --- a/src/gateway/server-methods/commands.test.ts +++ b/src/gateway/server-methods/commands.test.ts @@ -101,7 +101,7 @@ vi.mock("../../plugins/commands.js", () => ({ ]), })); vi.mock("../../config/config.js", () => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), })); vi.mock("../../agents/agent-scope.js", () => ({ listAgentIds: vi.fn(() => ["main", "dev"]), @@ -174,7 +174,7 @@ function callHandler(params: Record = {}) { req: {} as never, client: null, isWebchatConnect: () => false, - context: {} as never, + context: { getRuntimeConfig: () => ({}) } as never, }); return result!; } diff --git a/src/gateway/server-methods/commands.ts b/src/gateway/server-methods/commands.ts index 5d63cefd146..a34d533b564 100644 --- a/src/gateway/server-methods/commands.ts +++ b/src/gateway/server-methods/commands.ts @@ -7,7 +7,6 @@ import type { } from "../../auto-reply/commands-registry.types.js"; import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; -import { loadConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getPluginCommandSpecs } from "../../plugins/command-specs.js"; import { listPluginCommands } from "../../plugins/commands.js"; @@ -52,8 +51,11 @@ function clampDescription(value: string | undefined): string { return clampString(value ?? "", COMMAND_DESCRIPTION_MAX_LENGTH); } -function resolveAgentIdOrRespondError(rawAgentId: unknown, respond: RespondFn) { - const cfg = loadConfig(); +function resolveAgentIdOrRespondError( + rawAgentId: unknown, + respond: RespondFn, + cfg: OpenClawConfig, +) { const knownAgents = listAgentIds(cfg); const requestedAgentId = typeof rawAgentId === "string" ? rawAgentId.trim() : ""; const agentId = requestedAgentId || resolveDefaultAgentId(cfg); @@ -240,7 +242,7 @@ export function buildCommandsListResult(params: { } export const commandsHandlers: GatewayRequestHandlers = { - "commands.list": ({ params, respond }) => { + "commands.list": ({ params, respond, context }) => { if (!validateCommandsListParams(params)) { respond( false, @@ -252,7 +254,11 @@ export const commandsHandlers: GatewayRequestHandlers = { ); return; } - const resolved = resolveAgentIdOrRespondError(params.agentId, respond); + const resolved = resolveAgentIdOrRespondError( + params.agentId, + respond, + context.getRuntimeConfig(), + ); if (!resolved) { return; } diff --git a/src/gateway/server-methods/config.shared-auth.test.ts b/src/gateway/server-methods/config.shared-auth.test.ts index 10fda1f8f65..f8e5924cab5 100644 --- a/src/gateway/server-methods/config.shared-auth.test.ts +++ b/src/gateway/server-methods/config.shared-auth.test.ts @@ -31,6 +31,8 @@ vi.mock("../../config/config.js", async () => { readConfigFileSnapshotForWrite: readConfigFileSnapshotForWriteMock, validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, writeConfigFile: writeConfigFileMock, + replaceConfigFile: async (params: { nextConfig: unknown; writeOptions?: unknown }) => + await writeConfigFileMock(params.nextConfig, params.writeOptions), }; }); diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 009b9c8d380..4370a77e026 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -5,9 +5,9 @@ import { parseConfigJson5, readConfigFileSnapshot, readConfigFileSnapshotForWrite, + replaceConfigFile, resolveConfigSnapshotHash, validateConfigObjectWithPlugins, - writeConfigFile, } from "../../config/config.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; import { applyMergePatch } from "../../config/merge-patch.js"; @@ -387,7 +387,7 @@ async function tryWriteRestartSentinelPayload( function loadSchemaWithPlugins(): ConfigSchemaResponse { // Note: We can't easily cache this, as there are no callback that can invalidate - // our cache. However, loadConfig() and loadOpenClawPlugins() (called inside + // our cache. However, getRuntimeConfig() and loadOpenClawPlugins() (called inside // loadGatewayRuntimeConfigSchema) already cache their results, and buildConfigSchema() // is just a cheap transformation. return loadGatewayRuntimeConfigSchema(); @@ -456,7 +456,11 @@ export const configHandlers: GatewayRequestHandlers = { if (!(await ensureResolvableSecretRefsOrRespond({ config: parsed.config, respond }))) { return; } - await writeConfigFile(parsed.config, writeOptions); + await replaceConfigFile({ + nextConfig: parsed.config, + writeOptions, + afterWrite: { mode: "auto" }, + }); respond( true, { @@ -576,7 +580,11 @@ export const configHandlers: GatewayRequestHandlers = { snapshot.config, validated.config, ); - await writeConfigFile(validated.config, writeOptions); + await replaceConfigFile({ + nextConfig: validated.config, + writeOptions, + afterWrite: { mode: "auto" }, + }); const { sessionKey, note, restartDelayMs, deliveryContext, threadId } = resolveConfigRestartRequest(params); @@ -649,7 +657,11 @@ export const configHandlers: GatewayRequestHandlers = { // Compare before the write so we invalidate clients authenticated against the // previous shared secret immediately after the config update succeeds. const disconnectSharedAuthClients = didSharedGatewayAuthChange(snapshot.config, parsed.config); - await writeConfigFile(parsed.config, writeOptions); + await replaceConfigFile({ + nextConfig: parsed.config, + writeOptions, + afterWrite: { mode: "auto" }, + }); const { sessionKey, note, restartDelayMs, deliveryContext, threadId } = resolveConfigRestartRequest(params); diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index cd3fc392f44..5ea9166d5fb 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -1,4 +1,3 @@ -import { loadConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveCronDeliveryPreviews } from "../../cron/delivery-preview.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; @@ -165,7 +164,7 @@ export const cronHandlers: GatewayRequestHandlers = { sortDir: p.sortDir, }); const deliveryPreviews = await resolveCronDeliveryPreviews({ - cfg: loadConfig(), + cfg: context.getRuntimeConfig(), defaultAgentId: context.cron.getDefaultAgentId(), jobs: page.jobs, }); @@ -220,7 +219,7 @@ export const cronHandlers: GatewayRequestHandlers = { return; } const jobCreate = normalized as unknown as CronJobCreate; - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const timestampValidation = validateScheduleTimestamp(jobCreate.schedule); if (!timestampValidation.ok) { respond( @@ -292,7 +291,7 @@ export const cronHandlers: GatewayRequestHandlers = { return; } const patch = p.patch as unknown as CronJobPatch; - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); if (patch.schedule) { const timestampValidation = validateScheduleTimestamp(patch.schedule); if (!timestampValidation.ok) { diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index e1058be6a88..ea379fefd84 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -2,14 +2,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { CronJob } from "../../cron/types.js"; -const loadConfig = vi.hoisted(() => vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig)); +const getRuntimeConfig = vi.hoisted(() => + vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig), +); vi.mock("../../config/config.js", async () => { const actual = await vi.importActual("../../config/config.js"); return { ...actual, - loadConfig, + getRuntimeConfig, }; }); @@ -26,6 +28,7 @@ function createCronContext(currentJob?: CronJob) { logGateway: { info: vi.fn(), }, + getRuntimeConfig: () => getRuntimeConfig(), }; } @@ -76,11 +79,11 @@ function createCronJob(overrides: Partial = {}): CronJob { describe("cron method validation", () => { beforeEach(() => { - loadConfig.mockReset().mockReturnValue({} as OpenClawConfig); + getRuntimeConfig.mockReset().mockReturnValue({} as OpenClawConfig); }); it("rejects ambiguous announce delivery on add when multiple channels are configured", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ session: { mainKey: "main", }, @@ -122,7 +125,7 @@ describe("cron method validation", () => { }); it("rejects ambiguous announce delivery on update when multiple channels are configured", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ session: { mainKey: "main", }, @@ -164,7 +167,7 @@ describe("cron method validation", () => { }); it("rejects target ids mistakenly supplied as delivery.channel providers", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ session: { mainKey: "main", }, diff --git a/src/gateway/server-methods/doctor.test.ts b/src/gateway/server-methods/doctor.test.ts index 574db5a900f..4f257211cf5 100644 --- a/src/gateway/server-methods/doctor.test.ts +++ b/src/gateway/server-methods/doctor.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -const loadConfig = vi.hoisted(() => vi.fn(() => ({}) as OpenClawConfig)); +const getRuntimeConfig = vi.hoisted(() => vi.fn(() => ({}) as OpenClawConfig)); const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "main")); const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn((_cfg: OpenClawConfig, _agentId: string) => "/tmp/openclaw"), @@ -23,7 +23,7 @@ const removeGroundedShortTermCandidates = vi.hoisted(() => vi.fn()); const repairDreamingArtifacts = vi.hoisted(() => vi.fn()); vi.mock("../../config/config.js", () => ({ - loadConfig, + getRuntimeConfig, })); vi.mock("../../agents/agent-scope.js", () => ({ @@ -50,6 +50,8 @@ vi.mock("./doctor.memory-core-runtime.js", () => ({ import { doctorHandlers } from "./doctor.js"; +const makeRuntimeContext = () => ({ getRuntimeConfig: () => getRuntimeConfig() }); + const invokeDoctorMemoryStatus = async ( respond: ReturnType, context?: { cron?: { list?: ReturnType } }, @@ -64,6 +66,7 @@ const invokeDoctorMemoryStatus = async ( params: {} as never, respond: respond as never, context: { + ...makeRuntimeContext(), cron: { list: cronList, }, @@ -78,7 +81,7 @@ const invokeDoctorMemoryDreamDiary = async (respond: ReturnType) = req: {} as never, params: {} as never, respond: respond as never, - context: {} as never, + context: makeRuntimeContext() as never, client: null, isWebchatConnect: () => false, }); @@ -89,7 +92,7 @@ const invokeDoctorMemoryBackfillDreamDiary = async (respond: ReturnType false, }); @@ -100,7 +103,7 @@ const invokeDoctorMemoryResetDreamDiary = async (respond: ReturnType false, }); @@ -111,7 +114,7 @@ const invokeDoctorMemoryResetGroundedShortTerm = async (respond: ReturnType false, }); @@ -122,7 +125,7 @@ const invokeDoctorMemoryRepairDreamingArtifacts = async (respond: ReturnType false, }); @@ -133,7 +136,7 @@ const invokeDoctorMemoryDedupeDreamDiary = async (respond: ReturnType false, }); @@ -155,7 +158,7 @@ const expectEmbeddingErrorResponse = (respond: ReturnType, error: describe("doctor.memory.status", () => { beforeEach(() => { - loadConfig.mockClear(); + getRuntimeConfig.mockClear(); resolveDefaultAgentId.mockClear(); resolveAgentWorkspaceDir.mockReset().mockReturnValue("/tmp/openclaw"); resolveMemorySearchConfig.mockReset().mockReturnValue({ enabled: true }); @@ -388,7 +391,7 @@ describe("doctor.memory.status", () => { "utf-8", ); - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ agents: { defaults: { userTimezone: "America/Los_Angeles", @@ -544,7 +547,7 @@ describe("doctor.memory.status", () => { "utf-8", ); resolveMemorySearchConfig.mockReturnValue(null); - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ plugins: { entries: { "memory-core": { @@ -589,7 +592,7 @@ describe("doctor.memory.status", () => { }); it("reads dreaming config from the selected memory slot plugin", async () => { - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ plugins: { slots: { memory: "memos-local-openclaw-plugin", @@ -669,7 +672,7 @@ describe("doctor.memory.status", () => { ); await fs.mkdir(path.join(mainWorkspaceDir, "memory", ".dreams"), { recursive: true }); - loadConfig.mockReturnValue({ + getRuntimeConfig.mockReturnValue({ agents: { defaults: { memorySearch: { @@ -837,7 +840,7 @@ describe("doctor.memory dream actions", () => { describe("doctor.memory.dreamDiary", () => { beforeEach(() => { - loadConfig.mockClear(); + getRuntimeConfig.mockClear(); resolveDefaultAgentId.mockClear(); resolveAgentWorkspaceDir.mockReset().mockReturnValue("/tmp/openclaw"); previewGroundedRemMarkdown.mockReset(); diff --git a/src/gateway/server-methods/doctor.ts b/src/gateway/server-methods/doctor.ts index 0cce7c3de47..a787ceab714 100644 --- a/src/gateway/server-methods/doctor.ts +++ b/src/gateway/server-methods/doctor.ts @@ -1,7 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { loadConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { isSameMemoryDreamingDay, @@ -783,7 +782,7 @@ async function readDreamDiary( export const doctorHandlers: GatewayRequestHandlers = { "doctor.memory.status": async ({ respond, context }) => { - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const agentId = resolveDefaultAgentId(cfg); const { manager, error } = await getActiveMemorySearchManager({ cfg, @@ -875,8 +874,8 @@ export const doctorHandlers: GatewayRequestHandlers = { await manager.close?.().catch(() => {}); } }, - "doctor.memory.dreamDiary": async ({ respond }) => { - const cfg = loadConfig(); + "doctor.memory.dreamDiary": async ({ respond, context }) => { + const cfg = context.getRuntimeConfig(); const agentId = resolveDefaultAgentId(cfg); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const dreamDiary = await readDreamDiary(workspaceDir); @@ -886,8 +885,8 @@ export const doctorHandlers: GatewayRequestHandlers = { }; respond(true, payload, undefined); }, - "doctor.memory.backfillDreamDiary": async ({ respond }) => { - const cfg = loadConfig(); + "doctor.memory.backfillDreamDiary": async ({ respond, context }) => { + const cfg = context.getRuntimeConfig(); const agentId = resolveDefaultAgentId(cfg); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const memoryDir = path.join(workspaceDir, "memory"); @@ -944,8 +943,8 @@ export const doctorHandlers: GatewayRequestHandlers = { }; respond(true, payload, undefined); }, - "doctor.memory.resetDreamDiary": async ({ respond }) => { - const cfg = loadConfig(); + "doctor.memory.resetDreamDiary": async ({ respond, context }) => { + const cfg = context.getRuntimeConfig(); const agentId = resolveDefaultAgentId(cfg); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const removed = await removeBackfillDiaryEntries({ workspaceDir }); @@ -959,8 +958,8 @@ export const doctorHandlers: GatewayRequestHandlers = { }; respond(true, payload, undefined); }, - "doctor.memory.resetGroundedShortTerm": async ({ respond }) => { - const cfg = loadConfig(); + "doctor.memory.resetGroundedShortTerm": async ({ respond, context }) => { + const cfg = context.getRuntimeConfig(); const agentId = resolveDefaultAgentId(cfg); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const removed = await removeGroundedShortTermCandidates({ workspaceDir }); @@ -971,8 +970,8 @@ export const doctorHandlers: GatewayRequestHandlers = { }; respond(true, payload, undefined); }, - "doctor.memory.repairDreamingArtifacts": async ({ respond }) => { - const cfg = loadConfig(); + "doctor.memory.repairDreamingArtifacts": async ({ respond, context }) => { + const cfg = context.getRuntimeConfig(); const agentId = resolveDefaultAgentId(cfg); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const repair = await repairDreamingArtifacts({ workspaceDir }); @@ -988,8 +987,8 @@ export const doctorHandlers: GatewayRequestHandlers = { }; respond(true, payload, undefined); }, - "doctor.memory.dedupeDreamDiary": async ({ respond }) => { - const cfg = loadConfig(); + "doctor.memory.dedupeDreamDiary": async ({ respond, context }) => { + const cfg = context.getRuntimeConfig(); const agentId = resolveDefaultAgentId(cfg); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const dedupe = await dedupeDreamDiaryEntries({ workspaceDir }); diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts index e2310cdf0fb..30298704812 100644 --- a/src/gateway/server-methods/models-auth-status.test.ts +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -3,7 +3,7 @@ import type { AuthHealthSummary } from "../../agents/auth-health.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent"), ensureAuthProfileStore: vi.fn(() => ({ profiles: {} })), buildAuthHealthSummary: vi.fn( @@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../../config/config.js", () => ({ - loadConfig: mocks.loadConfig, + getRuntimeConfig: mocks.getRuntimeConfig, })); vi.mock("../../agents/agent-paths.js", () => ({ @@ -55,7 +55,7 @@ function createOptions( client: null, isWebchatConnect: () => false, respond, - context: {} as unknown, + context: { getRuntimeConfig: mocks.getRuntimeConfig } as unknown, } as unknown as GatewayRequestHandlerOptions & { respond: ReturnType }; } @@ -92,7 +92,7 @@ describe("models.authStatus", () => { beforeEach(() => { vi.clearAllMocks(); invalidateModelAuthStatusCache(); - mocks.loadConfig.mockReturnValue({}); + mocks.getRuntimeConfig.mockReturnValue({}); mocks.ensureAuthProfileStore.mockReturnValue({ profiles: {} }); mocks.buildAuthHealthSummary.mockReturnValue({ now: 0, @@ -258,7 +258,7 @@ describe("models.authStatus", () => { // auth already satisfies it, so forwarding to buildAuthHealthSummary // would flag it as missing and cry wolf. Inline string is the simplest // "available" SecretInput for testing. - mocks.loadConfig.mockReturnValue({ + mocks.getRuntimeConfig.mockReturnValue({ models: { providers: { "openai-codex": { auth: "oauth", apiKey: "sk-xxxxx" }, @@ -278,7 +278,7 @@ describe("models.authStatus", () => { // through to the normal missing synthesis so the dashboard surfaces // the broken config instead of masking it. delete process.env.MODELS_AUTH_STATUS_TEST_MISSING_KEY; - mocks.loadConfig.mockReturnValue({ + mocks.getRuntimeConfig.mockReturnValue({ models: { providers: { "openai-codex": { @@ -301,7 +301,7 @@ describe("models.authStatus", () => { it("env SecretRef pointing at a set env var is treated as env-backed", async () => { process.env.MODELS_AUTH_STATUS_TEST_SET_KEY = "sk-real-value"; - mocks.loadConfig.mockReturnValue({ + mocks.getRuntimeConfig.mockReturnValue({ models: { providers: { "openai-codex": { @@ -331,7 +331,7 @@ describe("models.authStatus", () => { // models.providers loop — otherwise a provider with resolvable apiKey // plus a matching auth.profiles entry re-adds itself and triggers the // false-missing alert we just fixed. - mocks.loadConfig.mockReturnValue({ + mocks.getRuntimeConfig.mockReturnValue({ models: { providers: { "openai-codex": { auth: "oauth", apiKey: "sk-xxxxx" }, @@ -355,7 +355,7 @@ describe("models.authStatus", () => { // Without normalization, expectsOAuth.has(prov.provider) fires on the // raw `z.ai` key but prov.provider is `zai`, so the "configured oauth // but no oauth profile" signal silently skipped the alias path. - mocks.loadConfig.mockReturnValue({ + mocks.getRuntimeConfig.mockReturnValue({ models: { providers: { "z.ai": { auth: "oauth" } } }, }); mocks.buildAuthHealthSummary.mockReturnValue({ @@ -389,7 +389,7 @@ describe("models.authStatus", () => { it("flags provider configured auth:oauth but with only api_key profile as missing", async () => { // Config says provider should use OAuth; store has only an api_key // credential (e.g. operator switched modes but forgot to login). - mocks.loadConfig.mockReturnValue({ + mocks.getRuntimeConfig.mockReturnValue({ models: { providers: { anthropic: { auth: "oauth" } } }, }); mocks.buildAuthHealthSummary.mockReturnValue({ diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index b2e7fe2378b..bd166e2012c 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -9,7 +9,7 @@ import { } from "../../agents/auth-health.js"; import { ensureAuthProfileStore } from "../../agents/auth-profiles.js"; import { normalizeProviderId } from "../../agents/provider-id.js"; -import { loadConfig, type OpenClawConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/config.js"; import { isSecretRef } from "../../config/types.secrets.js"; import { loadProviderUsageSummary } from "../../infra/provider-usage.load.js"; import { PROVIDER_LABELS, resolveUsageProviderId } from "../../infra/provider-usage.shared.js"; @@ -282,7 +282,7 @@ function resolveConfiguredProviders(cfg: OpenClawConfig): { } export const modelsAuthStatusHandlers: GatewayRequestHandlers = { - "models.authStatus": async ({ params, respond }) => { + "models.authStatus": async ({ params, respond, context }) => { const now = Date.now(); const bypassCache = Boolean((params as { refresh?: boolean } | undefined)?.refresh); if (!bypassCache && cached && now - cached.ts < CACHE_TTL_MS) { @@ -290,7 +290,7 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { return; } try { - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const agentDir = resolveOpenClawAgentDir(); const store = ensureAuthProfileStore(agentDir); const configured = resolveConfiguredProviders(cfg); diff --git a/src/gateway/server-methods/models.ts b/src/gateway/server-methods/models.ts index 087ee7495f2..39722fc268b 100644 --- a/src/gateway/server-methods/models.ts +++ b/src/gateway/server-methods/models.ts @@ -1,6 +1,5 @@ import { DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { buildAllowedModelSet } from "../../agents/model-selection.js"; -import { loadConfig } from "../../config/config.js"; import { ErrorCodes, errorShape, @@ -24,7 +23,7 @@ export const modelsHandlers: GatewayRequestHandlers = { } try { const catalog = await context.loadGatewayModelCatalog(); - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const { allowedCatalog } = buildAllowedModelSet({ cfg, catalog, diff --git a/src/gateway/server-methods/nodes-pending.test.ts b/src/gateway/server-methods/nodes-pending.test.ts index 110ef8711e4..436e54981b3 100644 --- a/src/gateway/server-methods/nodes-pending.test.ts +++ b/src/gateway/server-methods/nodes-pending.test.ts @@ -41,6 +41,7 @@ function makeContext(overrides?: Partial>) { info: vi.fn(), warn: vi.fn(), }, + getRuntimeConfig: () => ({}), ...overrides, }; } @@ -157,6 +158,7 @@ describe("node.pending handlers", () => { }); expect(mocks.maybeWakeNodeWithApns).toHaveBeenCalledWith("ios-node-2", { wakeReason: "node.pending", + cfg: {}, }); expect(mocks.waitForNodeReconnect).toHaveBeenCalledWith({ nodeId: "ios-node-2", diff --git a/src/gateway/server-methods/nodes-pending.ts b/src/gateway/server-methods/nodes-pending.ts index 8c46951b072..28bd26ce57b 100644 --- a/src/gateway/server-methods/nodes-pending.ts +++ b/src/gateway/server-methods/nodes-pending.ts @@ -86,7 +86,11 @@ export const nodePendingHandlers: GatewayRequestHandlers = { context.logGateway.info( `node pending wake start node=${p.nodeId} req=${wakeReqId} type=${queued.item.type}`, ); - const wake = await maybeWakeNodeWithApns(p.nodeId, { wakeReason: "node.pending" }); + const cfg = context.getRuntimeConfig(); + const wake = await maybeWakeNodeWithApns(p.nodeId, { + wakeReason: "node.pending", + cfg, + }); context.logGateway.info( `node pending wake stage=wake1 node=${p.nodeId} req=${wakeReqId} ` + `available=${wake.available} throttled=${wake.throttled} ` + @@ -109,6 +113,7 @@ export const nodePendingHandlers: GatewayRequestHandlers = { const retryWake = await maybeWakeNodeWithApns(p.nodeId, { force: true, wakeReason: "node.pending", + cfg, }); context.logGateway.info( `node pending wake stage=wake2 node=${p.nodeId} req=${wakeReqId} force=true ` + @@ -129,7 +134,7 @@ export const nodePendingHandlers: GatewayRequestHandlers = { } } if (!context.nodeRegistry.get(p.nodeId)) { - const nudge = await maybeSendNodeWakeNudge(p.nodeId); + const nudge = await maybeSendNodeWakeNudge(p.nodeId, { cfg }); context.logGateway.info( `node pending wake nudge node=${p.nodeId} req=${wakeReqId} sent=${nudge.sent} ` + `throttled=${nudge.throttled} reason=${nudge.reason} durationMs=${nudge.durationMs} ` + diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index c066fc11902..d14c2a7f506 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -14,7 +14,7 @@ type MockNodeCommandPolicyParams = { }; const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), resolveNodeCommandAllowlist: vi.fn<() => Set>(() => new Set()), isNodeCommandAllowed: vi.fn< (params: MockNodeCommandPolicyParams) => { ok: true } | { ok: false; reason: string } @@ -33,7 +33,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../../config/config.js", () => ({ - loadConfig: mocks.loadConfig, + getRuntimeConfig: mocks.getRuntimeConfig, })); vi.mock("../node-command-policy.js", () => ({ @@ -134,7 +134,7 @@ function mockDirectWakeConfig(nodeId: string, overrides: WakeResultOverrides = { } function mockRelayWakeConfig(nodeId: string, overrides: WakeResultOverrides = {}) { - mocks.loadConfig.mockReturnValue({ + mocks.getRuntimeConfig.mockReturnValue({ gateway: { push: { apns: { @@ -200,6 +200,7 @@ async function invokeNode(params: { nodeRegistry: params.nodeRegistry, execApprovalManager: undefined, logGateway, + getRuntimeConfig: () => mocks.getRuntimeConfig(), } as never, client: null, req: { type: "req", id: "req-node-invoke", method: "node.invoke" }, @@ -229,7 +230,7 @@ async function pullPending(nodeId: string, commands?: string[]) { await nodeHandlers["node.pending.pull"]({ params: {}, respond: respond as never, - context: {} as never, + context: { getRuntimeConfig: () => mocks.getRuntimeConfig() } as never, client: createNodeClient(nodeId, commands) as never, req: { type: "req", id: "req-node-pending", method: "node.pending.pull" }, isWebchatConnect: () => false, @@ -242,7 +243,7 @@ async function ackPending(nodeId: string, ids: string[], commands?: string[]) { await nodeHandlers["node.pending.ack"]({ params: { ids }, respond: respond as never, - context: {} as never, + context: { getRuntimeConfig: () => mocks.getRuntimeConfig() } as never, client: createNodeClient(nodeId, commands) as never, req: { type: "req", id: "req-node-pending-ack", method: "node.pending.ack" }, isWebchatConnect: () => false, @@ -252,8 +253,8 @@ async function ackPending(nodeId: string, ids: string[], commands?: string[]) { describe("node.invoke APNs wake path", () => { beforeEach(() => { - mocks.loadConfig.mockClear(); - mocks.loadConfig.mockReturnValue({}); + mocks.getRuntimeConfig.mockClear(); + mocks.getRuntimeConfig.mockReturnValue({}); mocks.resolveNodeCommandAllowlist.mockClear(); mocks.resolveNodeCommandAllowlist.mockReturnValue(new Set()); mocks.isNodeCommandAllowed.mockClear(); diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 3b677a82661..9d26664ddde 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { listDevicePairing } from "../../infra/device-pairing.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { @@ -136,8 +137,8 @@ async function resolveDirectNodePushConfig() { : { ok: false as const, error: auth.error }; } -function resolveRelayNodePushConfig() { - const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway); +function resolveRelayNodePushConfig(cfg: OpenClawConfig) { + const relay = resolveApnsRelayConfigFromEnv(process.env, cfg.gateway); return relay.ok ? { ok: true as const, relayConfig: relay.value } : { ok: false as const, error: relay.error }; @@ -245,6 +246,7 @@ function listPendingNodeActions(nodeId: string): PendingNodeAction[] { function resolveAllowedPendingNodeActions(params: { nodeId: string; client: { connect?: ConnectParams | null } | null; + cfg: OpenClawConfig; }): PendingNodeAction[] { const pending = listPendingNodeActions(params.nodeId); if (pending.length === 0) { @@ -252,7 +254,7 @@ function resolveAllowedPendingNodeActions(params: { } const connect = params.client?.connect; const declaredCommands = Array.isArray(connect?.commands) ? connect.commands : []; - const allowlist = resolveNodeCommandAllowlist(loadConfig(), { + const allowlist = resolveNodeCommandAllowlist(params.cfg, { platform: connect?.client?.platform, deviceFamily: connect?.client?.deviceFamily, }); @@ -302,7 +304,7 @@ function toPendingParamsJSON(params: unknown): string | undefined { export async function maybeWakeNodeWithApns( nodeId: string, - opts?: { force?: boolean; wakeReason?: string }, + opts?: { force?: boolean; wakeReason?: string; cfg?: OpenClawConfig }, ): Promise { const state = nodeWakeById.get(nodeId) ?? { lastWakeAtMs: 0 }; nodeWakeById.set(nodeId, state); @@ -332,7 +334,7 @@ export async function maybeWakeNodeWithApns( let wakeResult; if (registration.transport === "relay") { - const relay = resolveRelayNodePushConfig(); + const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig()); if (!relay.ok) { return withDuration({ available: false, @@ -410,7 +412,10 @@ export async function maybeWakeNodeWithApns( } } -export async function maybeSendNodeWakeNudge(nodeId: string): Promise { +export async function maybeSendNodeWakeNudge( + nodeId: string, + opts?: { cfg?: OpenClawConfig }, +): Promise { const startedAtMs = Date.now(); const withDuration = ( attempt: Omit, @@ -431,7 +436,7 @@ export async function maybeSendNodeWakeNudge(nodeId: string): Promise { + "node.pending.pull": async ({ params, respond, client, context }) => { if (!validateNodeListParams(params)) { respondInvalidParams({ respond, @@ -797,7 +802,11 @@ export const nodeHandlers: GatewayRequestHandlers = { return; } - const pending = resolveAllowedPendingNodeActions({ nodeId: trimmedNodeId, client }); + const pending = resolveAllowedPendingNodeActions({ + nodeId: trimmedNodeId, + client, + cfg: context.getRuntimeConfig(), + }); respond( true, { @@ -895,6 +904,7 @@ export const nodeHandlers: GatewayRequestHandlers = { } await respondUnavailableOnThrow(respond, async () => { + const cfg = context.getRuntimeConfig(); let nodeSession = context.nodeRegistry.get(nodeId); if (!nodeSession) { const wakeReqId = req.id; @@ -903,7 +913,7 @@ export const nodeHandlers: GatewayRequestHandlers = { `node wake start node=${nodeId} req=${wakeReqId} command=${command}`, ); - const wake = await maybeWakeNodeWithApns(nodeId); + const wake = await maybeWakeNodeWithApns(nodeId, { cfg }); context.logGateway.info( `node wake stage=wake1 node=${nodeId} req=${wakeReqId} ` + `available=${wake.available} throttled=${wake.throttled} ` + @@ -926,7 +936,7 @@ export const nodeHandlers: GatewayRequestHandlers = { } nodeSession = context.nodeRegistry.get(nodeId); if (!nodeSession && wake.available) { - const retryWake = await maybeWakeNodeWithApns(nodeId, { force: true }); + const retryWake = await maybeWakeNodeWithApns(nodeId, { force: true, cfg }); context.logGateway.info( `node wake stage=wake2 node=${nodeId} req=${wakeReqId} force=true ` + `available=${retryWake.available} throttled=${retryWake.throttled} ` + @@ -951,7 +961,7 @@ export const nodeHandlers: GatewayRequestHandlers = { } if (!nodeSession) { const totalDurationMs = Math.max(0, Date.now() - wakeFlowStartedAtMs); - const nudge = await maybeSendNodeWakeNudge(nodeId); + const nudge = await maybeSendNodeWakeNudge(nodeId, { cfg }); context.logGateway.info( `node wake nudge node=${nodeId} req=${wakeReqId} sent=${nudge.sent} ` + `throttled=${nudge.throttled} reason=${nudge.reason} durationMs=${nudge.durationMs} ` + @@ -976,7 +986,6 @@ export const nodeHandlers: GatewayRequestHandlers = { `node wake done node=${nodeId} req=${wakeReqId} connected=true totalMs=${totalDurationMs}`, ); } - const cfg = loadConfig(); const allowlist = resolveNodeCommandAllowlist(cfg, nodeSession); const allowed = isNodeCommandAllowed({ command, @@ -1033,7 +1042,7 @@ export const nodeHandlers: GatewayRequestHandlers = { paramsJSON, idempotencyKey: p.idempotencyKey, }); - const wake = await maybeWakeNodeWithApns(nodeId); + const wake = await maybeWakeNodeWithApns(nodeId, { cfg }); context.logGateway.info( `node pending queued node=${nodeId} req=${req.id} command=${command} ` + `queuedId=${queued.id} wakePath=${wake.path} wakeAvailable=${wake.available}`, diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts index fc56e0e25d0..776130d6587 100644 --- a/src/gateway/server-methods/push.test.ts +++ b/src/gateway/server-methods/push.test.ts @@ -3,11 +3,11 @@ import { ErrorCodes } from "../protocol/index.js"; import { pushHandlers } from "./push.js"; const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), })); vi.mock("../../config/config.js", () => ({ - loadConfig: mocks.loadConfig, + getRuntimeConfig: mocks.getRuntimeConfig, })); vi.mock("../../infra/push-apns.js", () => ({ @@ -99,7 +99,7 @@ function createInvokeParams(params: Record) { await pushHandlers["push.test"]({ params, respond: respond as never, - context: {} as never, + context: { getRuntimeConfig: () => mocks.getRuntimeConfig() } as never, client: null, req: { type: "req", id: "req-1", method: "push.test" }, isWebchatConnect: () => false, @@ -119,8 +119,8 @@ function expectInvalidRequestResponse( describe("push.test handler", () => { beforeEach(() => { - mocks.loadConfig.mockClear(); - mocks.loadConfig.mockReturnValue({}); + mocks.getRuntimeConfig.mockClear(); + mocks.getRuntimeConfig.mockReturnValue({}); vi.mocked(loadApnsRegistration).mockClear(); vi.mocked(normalizeApnsEnvironment).mockClear(); vi.mocked(resolveApnsAuthConfigFromEnv).mockClear(); @@ -163,7 +163,7 @@ describe("push.test handler", () => { }); it("sends push test through relay registrations", async () => { - mocks.loadConfig.mockReturnValue({ + mocks.getRuntimeConfig.mockReturnValue({ gateway: { push: { apns: { diff --git a/src/gateway/server-methods/push.ts b/src/gateway/server-methods/push.ts index fe114b119f1..5455d934e32 100644 --- a/src/gateway/server-methods/push.ts +++ b/src/gateway/server-methods/push.ts @@ -1,4 +1,3 @@ -import { loadConfig } from "../../config/config.js"; import { clearApnsRegistrationIfCurrent, loadApnsRegistration, @@ -29,7 +28,7 @@ import { normalizeTrimmedString } from "./record-shared.js"; import type { GatewayRequestHandlers } from "./types.js"; export const pushHandlers: GatewayRequestHandlers = { - "push.test": async ({ params, respond }) => { + "push.test": async ({ params, respond, context }) => { if (!validatePushTestParams(params)) { respondInvalidParams({ respond, @@ -83,7 +82,10 @@ export const pushHandlers: GatewayRequestHandlers = { }); })() : await (async () => { - const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway); + const relay = resolveApnsRelayConfigFromEnv( + process.env, + context.getRuntimeConfig().gateway, + ); if (!relay.ok) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, relay.error)); return null; diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 68329abf28b..30a79c7a3a7 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -34,7 +34,7 @@ vi.mock("../../config/config.js", async () => { await vi.importActual("../../config/config.js"); return { ...actual, - loadConfig: () => ({}), + getRuntimeConfig: () => ({}), }; }); @@ -119,6 +119,7 @@ async function loadSendHandlersForTest() { const makeContext = (): GatewayRequestContext => ({ dedupe: new Map(), + getRuntimeConfig: () => ({}), }) as unknown as GatewayRequestContext; async function runSend(params: Record) { diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 558ca7e3718..2700cfb4005 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -2,7 +2,6 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { normalizeChannelId } from "../../channels/plugins/index.js"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; import { createOutboundSendDeps } from "../../cli/deps.js"; -import { loadConfig } from "../../config/config.js"; import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveOutboundChannelPlugin } from "../../infra/outbound/channel-resolution.js"; @@ -101,6 +100,7 @@ async function runGatewayInflightWork(params: { async function resolveRequestedChannel(params: { requestChannel: unknown; unsupportedMessage: (input: string) => string; + context: GatewayRequestContext; rejectWebchatAsInternalOnly?: boolean; }): Promise< | { @@ -128,7 +128,7 @@ async function resolveRequestedChannel(params: { }; } const cfg = applyPluginAutoEnable({ - config: loadConfig(), + config: params.context.getRuntimeConfig(), env: process.env, }).config; let channel = normalizedChannel; @@ -318,6 +318,7 @@ export const sendHandlers: GatewayRequestHandlers = { const resolvedChannel = await resolveRequestedChannel({ requestChannel: request.channel, unsupportedMessage: (input) => `unsupported channel: ${input}`, + context, rejectWebchatAsInternalOnly: true, }); if ("error" in resolvedChannel) { @@ -423,6 +424,7 @@ export const sendHandlers: GatewayRequestHandlers = { const resolvedChannel = await resolveRequestedChannel({ requestChannel: request.channel, unsupportedMessage: (input) => `unsupported channel: ${input}`, + context, rejectWebchatAsInternalOnly: true, }); if ("error" in resolvedChannel) { @@ -603,6 +605,7 @@ export const sendHandlers: GatewayRequestHandlers = { const resolvedChannel = await resolveRequestedChannel({ requestChannel: request.channel, unsupportedMessage: (input) => `unsupported poll channel: ${input}`, + context, }); if ("error" in resolvedChannel) { respond(false, undefined, resolvedChannel.error); diff --git a/src/gateway/server-methods/sessions.send-deleted-agent.test.ts b/src/gateway/server-methods/sessions.send-deleted-agent.test.ts index 58f3f2880dd..532c3e2dcf5 100644 --- a/src/gateway/server-methods/sessions.send-deleted-agent.test.ts +++ b/src/gateway/server-methods/sessions.send-deleted-agent.test.ts @@ -21,6 +21,7 @@ describe("sessions.send / sessions.steer deleted-agent guard", () => { chatAbortControllers: new Map(), broadcastToConnIds: vi.fn(), getSessionEventSubscriberConnIds: () => new Set(), + getRuntimeConfig: () => ({}), } as unknown as GatewayRequestContext; await sessionsHandlers[method]({ diff --git a/src/gateway/server-methods/sessions.send-followup-status.test.ts b/src/gateway/server-methods/sessions.send-followup-status.test.ts index b9a94d6f814..5e6294fb4b1 100644 --- a/src/gateway/server-methods/sessions.send-followup-status.test.ts +++ b/src/gateway/server-methods/sessions.send-followup-status.test.ts @@ -93,6 +93,7 @@ describe("sessions.send completed subagent follow-up status", () => { chatAbortControllers: new Map(), broadcastToConnIds, getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), + getRuntimeConfig: () => ({}), } as unknown as GatewayRequestContext; await sessionsHandlers["sessions.send"]({ diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index fd9d2c33945..d5d015fc8ae 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -11,7 +11,6 @@ import { import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js"; import { normalizeReasoningLevel, normalizeThinkLevel } from "../../auto-reply/thinking.js"; -import { loadConfig } from "../../config/config.js"; import { loadSessionStore, resolveMainSessionKey, @@ -20,6 +19,7 @@ import { type SessionEntry, updateSessionStore, } from "../../config/sessions.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { hasInternalHookListeners, triggerInternalHook, @@ -144,8 +144,7 @@ function rejectPluginRuntimeDeleteMismatch(params: { return true; } -function resolveGatewaySessionTargetFromKey(key: string) { - const cfg = loadConfig(); +function resolveGatewaySessionTargetFromKey(key: string, cfg: OpenClawConfig) { const target = resolveGatewaySessionStoreTarget({ cfg, key }); return { cfg, target, storePath: target.storePath }; } @@ -600,12 +599,12 @@ async function handleSessionSend(params: { } } export const sessionsHandlers: GatewayRequestHandlers = { - "sessions.list": ({ params, respond }) => { + "sessions.list": ({ params, respond, context }) => { if (!assertValidParams(params, validateSessionsListParams, "sessions.list", respond)) { return; } const p = params; - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); const result = listSessionsFromStore({ cfg, @@ -675,7 +674,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { } respond(true, { subscribed: false, key: canonicalKey }, undefined); }, - "sessions.preview": ({ params, respond }) => { + "sessions.preview": ({ params, respond, context }) => { if (!assertValidParams(params, validateSessionsPreviewParams, "sessions.preview", respond)) { return; } @@ -697,7 +696,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const storeCache = new Map>(); const previews: SessionsPreviewEntry[] = []; @@ -737,12 +736,12 @@ export const sessionsHandlers: GatewayRequestHandlers = { respond(true, { ts: Date.now(), previews } satisfies SessionsPreviewResult, undefined); }, - "sessions.resolve": async ({ params, respond }) => { + "sessions.resolve": async ({ params, respond, context }) => { if (!assertValidParams(params, validateSessionsResolveParams, "sessions.resolve", respond)) { return; } const p = params; - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const resolved = await resolveSessionKeyFromResolveParams({ cfg, p }); if (!resolved.ok) { @@ -823,7 +822,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } const p = params; - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const requestedKey = normalizeOptionalString(p.key); const agentId = normalizeAgentId( normalizeOptionalString(p.agentId) ?? resolveDefaultAgentId(cfg), @@ -1318,7 +1317,10 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } - const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key); + const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey( + key, + context.getRuntimeConfig(), + ); const applied = await updateSessionStore(storePath, async (store) => { const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key, store }); return await applySessionsPatchToStore({ @@ -1410,7 +1412,10 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } - const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key); + const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey( + key, + context.getRuntimeConfig(), + ); const mainKey = resolveMainSessionKey(cfg); if (target.canonicalKey === mainKey) { respond( @@ -1494,7 +1499,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { }); } }, - "sessions.get": ({ params, respond }) => { + "sessions.get": ({ params, respond, context }) => { const p = params; const key = requireSessionKey(p.key ?? p.sessionKey, respond); if (!key) { @@ -1505,7 +1510,10 @@ export const sessionsHandlers: GatewayRequestHandlers = { ? Math.max(1, Math.floor(p.limit)) : 200; - const { target, storePath } = resolveGatewaySessionTargetFromKey(key); + const { target, storePath } = resolveGatewaySessionTargetFromKey( + key, + context.getRuntimeConfig(), + ); const store = loadSessionStore(storePath); const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys); if (!entry?.sessionId) { @@ -1534,7 +1542,10 @@ export const sessionsHandlers: GatewayRequestHandlers = { ? Math.max(1, Math.floor(p.maxLines)) : undefined; - const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key); + const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey( + key, + context.getRuntimeConfig(), + ); // Lock + read in a short critical section; transcript work happens outside. const compactTarget = await updateSessionStore(storePath, (store) => { const { entry, primaryKey } = migrateAndPruneGatewaySessionStoreKey({ cfg, key, store }); diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 32b9b05876b..2196d6c47e5 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -41,6 +41,7 @@ export type GatewayRequestContext = { deps: CliDeps; cron: CronServiceContract; cronStorePath: string; + getRuntimeConfig: () => OpenClawConfig; execApprovalManager?: ExecApprovalManager; pluginApprovalManager?: ExecApprovalManager; loadGatewayModelCatalog: () => Promise; diff --git a/src/gateway/server-methods/skills.clawhub.test.ts b/src/gateway/server-methods/skills.clawhub.test.ts index 5d18f7364c1..dda8a584d88 100644 --- a/src/gateway/server-methods/skills.clawhub.test.ts +++ b/src/gateway/server-methods/skills.clawhub.test.ts @@ -8,7 +8,7 @@ const installSkillMock = vi.fn(); const updateSkillsFromClawHubMock = vi.fn(); vi.mock("../../config/config.js", () => ({ - loadConfig: () => loadConfigMock(), + getRuntimeConfig: () => loadConfigMock(), writeConfigFile: vi.fn(), })); @@ -29,6 +29,8 @@ vi.mock("../../agents/skills-install.js", () => ({ const { skillsHandlers } = await import("./skills.js"); +const makeContext = () => ({ getRuntimeConfig: () => ({}) }); + describe("skills gateway handlers (clawhub)", () => { beforeEach(() => { loadConfigMock.mockReset(); @@ -63,7 +65,7 @@ describe("skills gateway handlers (clawhub)", () => { req: {} as never, client: null as never, isWebchatConnect: () => false, - context: {} as never, + context: makeContext() as never, respond: (success, result, err) => { ok = success; response = result; @@ -109,7 +111,7 @@ describe("skills gateway handlers (clawhub)", () => { req: {} as never, client: null as never, isWebchatConnect: () => false, - context: {} as never, + context: makeContext() as never, respond: (success, result, err) => { ok = success; response = result; @@ -156,7 +158,7 @@ describe("skills gateway handlers (clawhub)", () => { req: {} as never, client: null as never, isWebchatConnect: () => false, - context: {} as never, + context: makeContext() as never, respond: (success, result, err) => { ok = success; response = result; @@ -196,7 +198,7 @@ describe("skills gateway handlers (clawhub)", () => { req: {} as never, client: null as never, isWebchatConnect: () => false, - context: {} as never, + context: makeContext() as never, respond: (success, _result, err) => { ok = success; error = err as { code?: string; message?: string } | undefined; diff --git a/src/gateway/server-methods/skills.search-detail.test.ts b/src/gateway/server-methods/skills.search-detail.test.ts index c81f223fb91..42faaf6858e 100644 --- a/src/gateway/server-methods/skills.search-detail.test.ts +++ b/src/gateway/server-methods/skills.search-detail.test.ts @@ -4,7 +4,7 @@ const searchSkillsFromClawHubMock = vi.fn(); const fetchClawHubSkillDetailMock = vi.fn(); vi.mock("../../config/config.js", () => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), writeConfigFile: vi.fn(), })); diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index 0f5af972960..78d89acb75f 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -13,7 +13,7 @@ import { installSkill } from "../../agents/skills-install.js"; import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js"; import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js"; import { listAgentWorkspaceDirs } from "../../agents/workspace-dirs.js"; -import { loadConfig, writeConfigFile } from "../../config/config.js"; +import { replaceConfigFile } from "../../config/config.js"; import { redactConfigObject, REDACTED_SENTINEL } from "../../config/redact-snapshot.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { fetchClawHubSkillDetail } from "../../infra/clawhub.js"; @@ -67,7 +67,7 @@ function collectSkillBins(entries: SkillEntry[]): string[] { } export const skillsHandlers: GatewayRequestHandlers = { - "skills.status": ({ params, respond }) => { + "skills.status": ({ params, respond, context }) => { if (!validateSkillsStatusParams(params)) { respond( false, @@ -79,7 +79,7 @@ export const skillsHandlers: GatewayRequestHandlers = { ); return; } - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const agentIdRaw = normalizeOptionalString(params?.agentId) ?? ""; const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : resolveDefaultAgentId(cfg); if (agentIdRaw) { @@ -107,7 +107,7 @@ export const skillsHandlers: GatewayRequestHandlers = { }); respond(true, report, undefined); }, - "skills.bins": ({ params, respond }) => { + "skills.bins": ({ params, respond, context }) => { if (!validateSkillsBinsParams(params)) { respond( false, @@ -119,7 +119,7 @@ export const skillsHandlers: GatewayRequestHandlers = { ); return; } - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const workspaceDirs = listAgentWorkspaceDirs(cfg); const bins = new Set(); for (const workspaceDir of workspaceDirs) { @@ -173,7 +173,7 @@ export const skillsHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(err))); } }, - "skills.install": async ({ params, respond }) => { + "skills.install": async ({ params, respond, context }) => { if (!validateSkillsInstallParams(params)) { respond( false, @@ -185,7 +185,7 @@ export const skillsHandlers: GatewayRequestHandlers = { ); return; } - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); if (params && typeof params === "object" && "source" in params && params.source === "clawhub") { const p = params as { @@ -238,7 +238,7 @@ export const skillsHandlers: GatewayRequestHandlers = { result.ok ? undefined : errorShape(ErrorCodes.UNAVAILABLE, result.message), ); }, - "skills.update": async ({ params, respond }) => { + "skills.update": async ({ params, respond, context }) => { if (!validateSkillsUpdateParams(params)) { respond( false, @@ -275,7 +275,7 @@ export const skillsHandlers: GatewayRequestHandlers = { ); return; } - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); const results = await updateSkillsFromClawHub({ workspaceDir, @@ -304,7 +304,7 @@ export const skillsHandlers: GatewayRequestHandlers = { apiKey?: string; env?: Record; }; - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const skills = cfg.skills ? { ...cfg.skills } : {}; const entries = skills.entries ? { ...skills.entries } : {}; const current = entries[p.skillKey] ? { ...entries[p.skillKey] } : {}; @@ -346,7 +346,10 @@ export const skillsHandlers: GatewayRequestHandlers = { ...cfg, skills, }; - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + afterWrite: { mode: "auto" }, + }); respond( true, { ok: true, skillKey: p.skillKey, config: redactConfigObject(current) }, diff --git a/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts b/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts index 1dc80a801da..e7364fcbe67 100644 --- a/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts +++ b/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts @@ -11,9 +11,13 @@ let loadedConfig: unknown = { vi.mock("../../config/config.js", () => { return { loadConfig: () => loadedConfig, + getRuntimeConfig: () => loadedConfig, writeConfigFile: async (cfg: unknown) => { writtenConfig = cfg; }, + replaceConfigFile: async ({ nextConfig }: { nextConfig: unknown }) => { + writtenConfig = nextConfig; + }, }; }); @@ -38,7 +42,7 @@ describe("skills.update", () => { req: {} as never, client: null as never, isWebchatConnect: () => false, - context: {} as never, + context: { getRuntimeConfig: () => ({ skills: { entries: {} } }) } as never, respond: (success, _result, err) => { ok = success; error = err; @@ -79,7 +83,7 @@ describe("skills.update", () => { req: {} as never, client: null as never, isWebchatConnect: () => false, - context: {} as never, + context: { getRuntimeConfig: () => loadedConfig } as never, respond: (_success, result, _err) => { responseResult = result; }, @@ -137,7 +141,7 @@ describe("skills.update", () => { req: {} as never, client: null as never, isWebchatConnect: () => false, - context: {} as never, + context: { getRuntimeConfig: () => loadedConfig } as never, respond: () => {}, }); diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index f96b7c15bf6..d30f5a017d3 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { talkHandlers } from "./talk.js"; const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn<() => OpenClawConfig>(), + getRuntimeConfig: vi.fn<() => OpenClawConfig>(), readConfigFileSnapshot: vi.fn(), canonicalizeSpeechProviderId: vi.fn((providerId: string | undefined) => providerId), getSpeechProvider: vi.fn(), @@ -11,7 +11,6 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../../config/config.js", () => ({ - loadConfig: mocks.loadConfig, readConfigFileSnapshot: mocks.readConfigFileSnapshot, })); @@ -51,7 +50,7 @@ describe("talk.speak handler", () => { id: "ACME_SPEECH_API_KEY", }); - mocks.loadConfig.mockReturnValue(runtimeConfig); + mocks.getRuntimeConfig.mockReturnValue(runtimeConfig); mocks.readConfigFileSnapshot.mockResolvedValue({ path: "/tmp/openclaw.json", hash: "test-hash", @@ -89,10 +88,10 @@ describe("talk.speak handler", () => { client: null, isWebchatConnect: () => false, respond: respond as never, - context: {} as never, + context: { getRuntimeConfig: () => runtimeConfig } as never, }); - expect(mocks.loadConfig).toHaveBeenCalledTimes(1); + expect(mocks.getRuntimeConfig).not.toHaveBeenCalled(); expect(mocks.readConfigFileSnapshot).not.toHaveBeenCalled(); expect(mocks.synthesizeSpeech).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 61d37b50a48..08039d957e3 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -1,4 +1,4 @@ -import { loadConfig, readConfigFileSnapshot } from "../../config/config.js"; +import { readConfigFileSnapshot } from "../../config/config.js"; import { redactConfigObject } from "../../config/redact-snapshot.js"; import { buildTalkConfigResponse, @@ -349,7 +349,7 @@ function resolveTalkResponseFromConfig(params: { } export const talkHandlers: GatewayRequestHandlers = { - "talk.config": async ({ params, respond, client }) => { + "talk.config": async ({ params, respond, client, context }) => { if (!validateTalkConfigParams(params)) { respond( false, @@ -373,7 +373,7 @@ export const talkHandlers: GatewayRequestHandlers = { } const snapshot = await readConfigFileSnapshot(); - const runtimeConfig = loadConfig(); + const runtimeConfig = context.getRuntimeConfig(); const configPayload: Record = {}; const talk = resolveTalkResponseFromConfig({ @@ -397,7 +397,7 @@ export const talkHandlers: GatewayRequestHandlers = { respond(true, { config: configPayload }, undefined); }, - "talk.realtime.session": async ({ params, respond }) => { + "talk.realtime.session": async ({ params, respond, context }) => { if (!validateTalkRealtimeSessionParams(params)) { respond( false, @@ -415,7 +415,7 @@ export const talkHandlers: GatewayRequestHandlers = { voice?: string; }; try { - const runtimeConfig = loadConfig(); + const runtimeConfig = context.getRuntimeConfig(); const realtimeConfig = buildTalkRealtimeConfig(runtimeConfig, typedParams.provider); const resolution = resolveConfiguredRealtimeVoiceProvider({ configuredProviderId: realtimeConfig.provider, @@ -457,7 +457,7 @@ export const talkHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, - "talk.speak": async ({ params, respond }) => { + "talk.speak": async ({ params, respond, context }) => { if (!validateTalkSpeakParams(params)) { respond( false, @@ -494,7 +494,7 @@ export const talkHandlers: GatewayRequestHandlers = { } try { - const runtimeConfig = loadConfig(); + const runtimeConfig = context.getRuntimeConfig(); const setup = buildTalkTtsConfig(runtimeConfig); if ("error" in setup) { respond(false, undefined, talkSpeakError(setup.reason, setup.error)); diff --git a/src/gateway/server-methods/tools-catalog.test.ts b/src/gateway/server-methods/tools-catalog.test.ts index 79a60929760..a99b67fc3e8 100644 --- a/src/gateway/server-methods/tools-catalog.test.ts +++ b/src/gateway/server-methods/tools-catalog.test.ts @@ -4,7 +4,7 @@ import { ErrorCodes } from "../protocol/index.js"; import { toolsCatalogHandlers } from "./tools-catalog.js"; vi.mock("../../config/config.js", () => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), })); vi.mock("../../agents/agent-scope.js", () => ({ @@ -39,7 +39,7 @@ function createInvokeParams(params: Record) { await toolsCatalogHandlers["tools.catalog"]({ params, respond: respond as never, - context: {} as never, + context: { getRuntimeConfig: () => ({}) } as never, client: null, req: { type: "req", id: "req-1", method: "tools.catalog" }, isWebchatConnect: () => false, diff --git a/src/gateway/server-methods/tools-catalog.ts b/src/gateway/server-methods/tools-catalog.ts index 49d9d595c1b..c74c8ccd875 100644 --- a/src/gateway/server-methods/tools-catalog.ts +++ b/src/gateway/server-methods/tools-catalog.ts @@ -10,7 +10,6 @@ import { resolveCoreToolProfiles, } from "../../agents/tool-catalog.js"; import { summarizeToolDescriptionText } from "../../agents/tool-description-summary.js"; -import { loadConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getPluginToolMeta, resolvePluginTools } from "../../plugins/tools.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -41,8 +40,11 @@ type ToolCatalogGroup = { tools: ToolCatalogEntry[]; }; -function resolveAgentIdOrRespondError(rawAgentId: unknown, respond: RespondFn) { - const cfg = loadConfig(); +function resolveAgentIdOrRespondError( + rawAgentId: unknown, + respond: RespondFn, + cfg: OpenClawConfig, +) { const knownAgents = listAgentIds(cfg); const requestedAgentId = normalizeOptionalString(rawAgentId) ?? ""; const agentId = requestedAgentId || resolveDefaultAgentId(cfg); @@ -154,7 +156,7 @@ export function buildToolsCatalogResult(params: { } export const toolsCatalogHandlers: GatewayRequestHandlers = { - "tools.catalog": ({ params, respond }) => { + "tools.catalog": ({ params, respond, context }) => { if (!validateToolsCatalogParams(params)) { respond( false, @@ -166,7 +168,11 @@ export const toolsCatalogHandlers: GatewayRequestHandlers = { ); return; } - const resolved = resolveAgentIdOrRespondError(params.agentId, respond); + const resolved = resolveAgentIdOrRespondError( + params.agentId, + respond, + context.getRuntimeConfig(), + ); if (!resolved) { return; } diff --git a/src/gateway/server-methods/tools-effective.runtime.ts b/src/gateway/server-methods/tools-effective.runtime.ts index c6ea74da692..6e56dbda4bf 100644 --- a/src/gateway/server-methods/tools-effective.runtime.ts +++ b/src/gateway/server-methods/tools-effective.runtime.ts @@ -1,7 +1,6 @@ export { listAgentIds, resolveSessionAgentId } from "../../agents/agent-scope.js"; export { resolveEffectiveToolInventory } from "../../agents/tools-effective-inventory.js"; export { resolveReplyToMode } from "../../auto-reply/reply/reply-threading.js"; -export { loadConfig } from "../../config/config.js"; export { getActivePluginChannelRegistryVersion, getActivePluginRegistryVersion, diff --git a/src/gateway/server-methods/tools-effective.test.ts b/src/gateway/server-methods/tools-effective.test.ts index 19272f38dfe..07be737f87f 100644 --- a/src/gateway/server-methods/tools-effective.test.ts +++ b/src/gateway/server-methods/tools-effective.test.ts @@ -10,7 +10,7 @@ const runtimeMocks = vi.hoisted(() => ({ threadId: "thread-2", })), listAgentIds: vi.fn(() => ["main"]), - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), loadSessionEntry: vi.fn(() => ({ cfg: {}, canonicalKey: "main:abc", @@ -68,7 +68,7 @@ function createInvokeParams(params: Record) { await toolsEffectiveHandlers["tools.effective"]({ params, respond: respond as never, - context: {} as never, + context: { getRuntimeConfig: () => ({}) } as never, client: null, req: { type: "req", id: "req-1", method: "tools.effective" }, isWebchatConnect: () => false, @@ -318,7 +318,7 @@ describe("tools.effective handler", () => { await toolsEffectiveHandlers["tools.effective"]({ params: { sessionKey: "main:abc" }, respond: respond as never, - context: {} as never, + context: { getRuntimeConfig: () => ({}) } as never, client: { connect: { scopes: ["operator.admin"] }, } as never, diff --git a/src/gateway/server-methods/tools-effective.ts b/src/gateway/server-methods/tools-effective.ts index 0d0c471296e..2bdf3e7b3ae 100644 --- a/src/gateway/server-methods/tools-effective.ts +++ b/src/gateway/server-methods/tools-effective.ts @@ -14,7 +14,6 @@ import { getActivePluginChannelRegistryVersion, getActivePluginRegistryVersion, listAgentIds, - loadConfig, loadSessionEntry, resolveEffectiveToolInventory, resolveReplyToMode, @@ -290,7 +289,7 @@ function resolveTrustedToolsEffectiveContext(params: { } export const toolsEffectiveHandlers: GatewayRequestHandlers = { - "tools.effective": async ({ params, respond, client }) => { + "tools.effective": async ({ params, respond, client, context }) => { if (!validateToolsEffectiveParams(params)) { respond( false, @@ -302,7 +301,7 @@ export const toolsEffectiveHandlers: GatewayRequestHandlers = { ); return; } - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const requestedAgentId = resolveRequestedAgentIdOrRespondError({ rawAgentId: params.agentId, cfg, diff --git a/src/gateway/server-methods/tts.test.ts b/src/gateway/server-methods/tts.test.ts index 04fb44dbdfd..c24547b7313 100644 --- a/src/gateway/server-methods/tts.test.ts +++ b/src/gateway/server-methods/tts.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes } from "../protocol/index.js"; const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), resolveExplicitTtsOverrides: vi.fn(() => ({})), textToSpeech: vi.fn(async () => ({ success: true, @@ -14,7 +14,8 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../../config/config.js", () => ({ - loadConfig: mocks.loadConfig as typeof import("../../config/config.js").loadConfig, + getRuntimeConfig: + mocks.getRuntimeConfig as typeof import("../../config/config.js").getRuntimeConfig, })); vi.mock("../../tts/provider-registry.js", () => ({ @@ -44,8 +45,8 @@ vi.mock("../../tts/tts.js", () => ({ describe("ttsHandlers", () => { beforeEach(() => { - mocks.loadConfig.mockReset(); - mocks.loadConfig.mockReturnValue({}); + mocks.getRuntimeConfig.mockReset(); + mocks.getRuntimeConfig.mockReturnValue({}); mocks.resolveExplicitTtsOverrides.mockReset(); mocks.resolveExplicitTtsOverrides.mockReturnValue({}); mocks.textToSpeech.mockReset(); @@ -72,6 +73,7 @@ describe("ttsHandlers", () => { provider: "bad", }, respond, + context: { getRuntimeConfig: mocks.getRuntimeConfig }, } as never); expect(respond).toHaveBeenCalledWith( diff --git a/src/gateway/server-methods/tts.ts b/src/gateway/server-methods/tts.ts index 8eb2d47ea4a..4aada1074b7 100644 --- a/src/gateway/server-methods/tts.ts +++ b/src/gateway/server-methods/tts.ts @@ -1,4 +1,3 @@ -import { loadConfig } from "../../config/config.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { canonicalizeSpeechProviderId, @@ -27,9 +26,9 @@ import { formatForLog } from "../ws-log.js"; import type { GatewayRequestHandlers } from "./types.js"; export const ttsHandlers: GatewayRequestHandlers = { - "tts.status": async ({ respond }) => { + "tts.status": async ({ respond, context }) => { try { - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const config = resolveTtsConfig(cfg); const prefsPath = resolveTtsPrefsPath(config); const provider = getTtsProvider(config, prefsPath); @@ -67,9 +66,9 @@ export const ttsHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, - "tts.enable": async ({ respond }) => { + "tts.enable": async ({ respond, context }) => { try { - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const config = resolveTtsConfig(cfg); const prefsPath = resolveTtsPrefsPath(config); setTtsEnabled(prefsPath, true); @@ -78,9 +77,9 @@ export const ttsHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, - "tts.disable": async ({ respond }) => { + "tts.disable": async ({ respond, context }) => { try { - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const config = resolveTtsConfig(cfg); const prefsPath = resolveTtsPrefsPath(config); setTtsEnabled(prefsPath, false); @@ -89,7 +88,7 @@ export const ttsHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, - "tts.convert": async ({ params, respond }) => { + "tts.convert": async ({ params, respond, context }) => { const text = normalizeOptionalString(params.text) ?? ""; if (!text) { respond( @@ -100,7 +99,7 @@ export const ttsHandlers: GatewayRequestHandlers = { return; } try { - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const channel = normalizeOptionalString(params.channel); const providerRaw = normalizeOptionalString(params.provider); const modelId = normalizeOptionalString(params.modelId); @@ -142,8 +141,8 @@ export const ttsHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, - "tts.setProvider": async ({ params, respond }) => { - const cfg = loadConfig(); + "tts.setProvider": async ({ params, respond, context }) => { + const cfg = context.getRuntimeConfig(); const provider = canonicalizeSpeechProviderId( normalizeOptionalString(params.provider) ?? "", cfg, @@ -168,9 +167,9 @@ export const ttsHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, - "tts.personas": async ({ respond }) => { + "tts.personas": async ({ respond, context }) => { try { - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const config = resolveTtsConfig(cfg); const prefsPath = resolveTtsPrefsPath(config); const active = getTtsPersona(config, prefsPath); @@ -189,8 +188,8 @@ export const ttsHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, - "tts.setPersona": async ({ params, respond }) => { - const cfg = loadConfig(); + "tts.setPersona": async ({ params, respond, context }) => { + const cfg = context.getRuntimeConfig(); const rawPersona = normalizeOptionalString(params.persona); try { const config = resolveTtsConfig(cfg); @@ -220,9 +219,9 @@ export const ttsHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err))); } }, - "tts.providers": async ({ respond }) => { + "tts.providers": async ({ respond, context }) => { try { - const cfg = loadConfig(); + const cfg = context.getRuntimeConfig(); const config = resolveTtsConfig(cfg); const prefsPath = resolveTtsPrefsPath(config); respond(true, { diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index 5139bc2fa1b..eacac005bdb 100644 --- a/src/gateway/server-methods/update.test.ts +++ b/src/gateway/server-methods/update.test.ts @@ -20,7 +20,7 @@ const detectRespawnSupervisorMock = vi.fn(() => null); const scheduleGatewaySigusr1RestartMock = vi.fn(() => ({ scheduled: true })); vi.mock("../../config/config.js", () => ({ - loadConfig: () => ({ update: {} }), + getRuntimeConfig: () => ({ update: {} }), })); vi.mock("../../config/commands.flags.js", () => ({ @@ -147,6 +147,7 @@ async function invokeUpdateRun( await updateHandlers["update.run"]({ params, respond: onRespond as never, + context: { getRuntimeConfig: () => ({ update: {} }) }, } as never); } diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index 338e93625ed..7c134206f94 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -1,5 +1,4 @@ import { isRestartEnabled } from "../../config/commands.flags.js"; -import { loadConfig } from "../../config/config.js"; import { extractDeliveryInfo } from "../../config/sessions.js"; import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js"; import { readPackageVersion } from "../../infra/package-json.js"; @@ -55,7 +54,7 @@ export const updateHandlers: GatewayRequestHandlers = { let result: Awaited>; try { - const config = loadConfig(); + const config = context.getRuntimeConfig(); const configChannel = normalizeUpdateChannel(config.update?.channel); const root = (await resolveOpenClawPackageRoot({ diff --git a/src/gateway/server-methods/usage.sessions-usage.test.ts b/src/gateway/server-methods/usage.sessions-usage.test.ts index db25201b481..926544a13cd 100644 --- a/src/gateway/server-methods/usage.sessions-usage.test.ts +++ b/src/gateway/server-methods/usage.sessions-usage.test.ts @@ -6,7 +6,7 @@ import { withEnvAsync } from "../../test-utils/env.js"; vi.mock("../../config/config.js", () => { return { - loadConfig: vi.fn(() => ({ + getRuntimeConfig: vi.fn(() => ({ agents: { list: [{ id: "main" }, { id: "opus" }], }, @@ -82,11 +82,19 @@ import { import { loadCombinedSessionStoreForGateway } from "../session-utils.js"; import { usageHandlers } from "./usage.js"; +const TEST_RUNTIME_CONFIG = { + agents: { + list: [{ id: "main" }, { id: "opus" }], + }, + session: {}, +}; + async function runSessionsUsage(params: Record) { const respond = vi.fn(); await usageHandlers["sessions.usage"]({ respond, params, + context: { getRuntimeConfig: () => TEST_RUNTIME_CONFIG }, } as unknown as Parameters<(typeof usageHandlers)["sessions.usage"]>[0]); return respond; } @@ -96,6 +104,7 @@ async function runSessionsUsageTimeseries(params: Record) { await usageHandlers["sessions.usage.timeseries"]({ respond, params, + context: { getRuntimeConfig: () => TEST_RUNTIME_CONFIG }, } as unknown as Parameters<(typeof usageHandlers)["sessions.usage.timeseries"]>[0]); return respond; } @@ -105,6 +114,7 @@ async function runSessionsUsageLogs(params: Record) { await usageHandlers["sessions.usage.logs"]({ respond, params, + context: { getRuntimeConfig: () => TEST_RUNTIME_CONFIG }, } as unknown as Parameters<(typeof usageHandlers)["sessions.usage.logs"]>[0]); return respond; } diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 8a373c46669..bf4d5c6e442 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import { loadConfig } from "../../config/config.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, @@ -89,6 +88,7 @@ function setCostUsageCache(cacheKey: string, entry: CostUsageCacheEntry): void { function resolveSessionUsageFileOrRespond( key: string, respond: RespondFn, + config: OpenClawConfig, ): { config: OpenClawConfig; entry: SessionEntry | undefined; @@ -96,7 +96,6 @@ function resolveSessionUsageFileOrRespond( sessionId: string; sessionFile: string; } | null { - const config = loadConfig(); const { entry, storePath } = loadSessionEntry(key); // For discovered sessions (not in store), try using key as sessionId directly @@ -394,8 +393,8 @@ export const usageHandlers: GatewayRequestHandlers = { const summary = await loadProviderUsageSummary(); respond(true, summary, undefined); }, - "usage.cost": async ({ respond, params }) => { - const config = loadConfig(); + "usage.cost": async ({ respond, params, context }) => { + const config = context.getRuntimeConfig(); const { startMs, endMs } = parseDateRange({ startDate: params?.startDate, endDate: params?.endDate, @@ -406,7 +405,7 @@ export const usageHandlers: GatewayRequestHandlers = { const summary = await loadCostUsageSummaryCached({ startMs, endMs, config }); respond(true, summary, undefined); }, - "sessions.usage": async ({ respond, params }) => { + "sessions.usage": async ({ respond, params, context }) => { if (!validateSessionsUsageParams(params)) { respond( false, @@ -420,7 +419,7 @@ export const usageHandlers: GatewayRequestHandlers = { } const p = params; - const config = loadConfig(); + const config = context.getRuntimeConfig(); const { startMs, endMs } = parseDateRange({ startDate: p.startDate, endDate: p.endDate, @@ -848,7 +847,7 @@ export const usageHandlers: GatewayRequestHandlers = { respond(true, result, undefined); }, - "sessions.usage.timeseries": async ({ respond, params }) => { + "sessions.usage.timeseries": async ({ respond, params, context }) => { const key = normalizeOptionalString(params?.key) ?? null; if (!key) { respond( @@ -859,7 +858,7 @@ export const usageHandlers: GatewayRequestHandlers = { return; } - const resolved = resolveSessionUsageFileOrRespond(key, respond); + const resolved = resolveSessionUsageFileOrRespond(key, respond, context.getRuntimeConfig()); if (!resolved) { return; } @@ -885,7 +884,7 @@ export const usageHandlers: GatewayRequestHandlers = { respond(true, timeseries, undefined); }, - "sessions.usage.logs": async ({ respond, params }) => { + "sessions.usage.logs": async ({ respond, params, context }) => { const key = normalizeOptionalString(params?.key) ?? null; if (!key) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key is required for logs")); @@ -897,7 +896,7 @@ export const usageHandlers: GatewayRequestHandlers = { ? Math.min(params.limit, 1000) : 200; - const resolved = resolveSessionUsageFileOrRespond(key, respond); + const resolved = resolveSessionUsageFileOrRespond(key, respond, context.getRuntimeConfig()); if (!resolved) { return; } diff --git a/src/gateway/server-node-events.runtime.ts b/src/gateway/server-node-events.runtime.ts index 8ba881dace9..cb0c49204c8 100644 --- a/src/gateway/server-node-events.runtime.ts +++ b/src/gateway/server-node-events.runtime.ts @@ -3,7 +3,7 @@ export { sanitizeInboundSystemTags } from "../auto-reply/reply/inbound-text.js"; export { normalizeChannelId } from "../channels/plugins/index.js"; export { createOutboundSendDeps } from "../cli/outbound-send-deps.js"; export { agentCommandFromIngress } from "../commands/agent.js"; -export { loadConfig } from "../config/config.js"; +export { getRuntimeConfig } from "../config/config.js"; export { updateSessionStore } from "../config/sessions.js"; export { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; export { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index e96ed7d0db5..d4095b62a14 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -74,7 +74,7 @@ const runtimeMocks = vi.hoisted(() => ({ deliverOutboundPayloads: vi.fn(async () => {}), enqueueSystemEvent: vi.fn(), formatForLog: vi.fn((err: unknown) => (err instanceof Error ? err.message : String(err))), - loadConfig: vi.fn(() => ({ session: { mainKey: "agent:main:main" } })), + getRuntimeConfig: vi.fn(() => ({ session: { mainKey: "agent:main:main" } })), loadOrCreateDeviceIdentity: loadOrCreateDeviceIdentityMock, loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)), migrateAndPruneGatewaySessionStoreKey: vi.fn( @@ -139,7 +139,7 @@ import { handleNodeEvent, resetNodeEventDeduplicationForTests } from "./server-n const enqueueSystemEventMock = runtimeMocks.enqueueSystemEvent; const requestHeartbeatNowMock = runtimeMocks.requestHeartbeatNow; -const loadConfigMock = runtimeMocks.loadConfig; +const loadConfigMock = runtimeMocks.getRuntimeConfig; const agentCommandMock = runtimeMocks.agentCommandFromIngress; const updateSessionStoreMock = runtimeMocks.updateSessionStore; const loadSessionEntryMock = runtimeMocks.loadSessionEntry; diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 51f2c098c5a..e763af3a746 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -16,7 +16,7 @@ import { deliverOutboundPayloads, enqueueSystemEvent, formatForLog, - loadConfig, + getRuntimeConfig, loadOrCreateDeviceIdentity, loadSessionEntry, migrateAndPruneGatewaySessionStoreKey, @@ -324,7 +324,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt return; } const sessionKeyRaw = normalizeOptionalString(obj.sessionKey) ?? ""; - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const rawMainKey = normalizeMainKey(cfg.session?.mainKey); const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : rawMainKey; const { storePath, entry, canonicalKey } = loadSessionEntry(sessionKey); @@ -409,7 +409,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt const sessionKeyRaw = (link?.sessionKey ?? "").trim(); const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`; - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const { storePath, entry, canonicalKey } = loadSessionEntry(sessionKey); let message = (link?.message ?? "").trim(); @@ -631,7 +631,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt // Respect tools.exec.notifyOnExit setting (default: true) // When false, skip system event notifications for node exec events. - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const notifyOnExit = cfg.tools?.exec?.notifyOnExit !== false; if (!notifyOnExit) { return; diff --git a/src/gateway/server-request-context.test.ts b/src/gateway/server-request-context.test.ts index f2e91b9698e..736b7d3b61f 100644 --- a/src/gateway/server-request-context.test.ts +++ b/src/gateway/server-request-context.test.ts @@ -57,6 +57,7 @@ describe("createGatewayRequestContext", () => { findRunningWizard: vi.fn(() => null), purgeWizardSession: vi.fn(), getRuntimeSnapshot: vi.fn(() => ({}) as never), + getRuntimeConfig: vi.fn(() => ({}) as never), startChannel: vi.fn(async () => undefined), stopChannel: vi.fn(async () => undefined), markChannelLoggedOut: vi.fn(), diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index a799aae6798..87c343fdda4 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -11,6 +11,7 @@ type GatewayRequestContextClient = GatewayClient & { export type GatewayRequestContextParams = { deps: GatewayRequestContext["deps"]; runtimeState: Pick; + getRuntimeConfig: GatewayRequestContext["getRuntimeConfig"]; execApprovalManager: GatewayRequestContext["execApprovalManager"]; pluginApprovalManager: GatewayRequestContext["pluginApprovalManager"]; loadGatewayModelCatalog: GatewayRequestContext["loadGatewayModelCatalog"]; @@ -73,6 +74,7 @@ export function createGatewayRequestContext( get cronStorePath() { return params.runtimeState.cronState.storePath; }, + getRuntimeConfig: params.getRuntimeConfig, execApprovalManager: params.execApprovalManager, pluginApprovalManager: params.pluginApprovalManager, loadGatewayModelCatalog: params.loadGatewayModelCatalog, diff --git a/src/gateway/server-session-key.test.ts b/src/gateway/server-session-key.test.ts index b779921ae62..81423fcd4ee 100644 --- a/src/gateway/server-session-key.test.ts +++ b/src/gateway/server-session-key.test.ts @@ -8,7 +8,7 @@ const hoisted = vi.hoisted(() => ({ })); vi.mock("../config/config.js", () => ({ - loadConfig: () => hoisted.loadConfigMock(), + getRuntimeConfig: () => hoisted.loadConfigMock(), })); vi.mock("./session-utils.js", async () => { diff --git a/src/gateway/server-session-key.ts b/src/gateway/server-session-key.ts index 858a37edf13..d30d651da22 100644 --- a/src/gateway/server-session-key.ts +++ b/src/gateway/server-session-key.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js"; import { toAgentRequestSessionKey } from "../routing/session-key.js"; @@ -49,7 +49,7 @@ export function resolveSessionKeyForRun(runId: string) { } resolvedSessionKeyByRunId.delete(runId); } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const { store } = loadCombinedSessionStoreForGateway(cfg); const matches = Object.entries(store).filter( (entry): entry is [string, SessionEntry] => entry[1]?.sessionId === runId, diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index 4fd3c5dc544..ec2ff1545ec 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -9,9 +9,9 @@ import { readConfigFileSnapshot, recoverConfigFromLastKnownGood, recoverConfigFromJsonRootSuffix, + replaceConfigFile, shouldAttemptLastKnownGoodRecovery, validateConfigObjectWithPlugins, - writeConfigFile, } from "../config/config.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; import { asResolvedSourceConfig, materializeRuntimeConfig } from "../config/materialize.js"; @@ -226,7 +226,10 @@ export async function loadGatewayStartupConfigSnapshot(params: { } try { - await writeConfigFile(autoEnable.config); + await replaceConfigFile({ + nextConfig: autoEnable.config, + afterWrite: { mode: "auto" }, + }); wroteConfig = true; configSnapshot = await readConfigFileSnapshot(); assertValidGatewayStartupConfigSnapshot(configSnapshot); diff --git a/src/gateway/server-startup-early.test.ts b/src/gateway/server-startup-early.test.ts index f1793a4376b..06aa7624e46 100644 --- a/src/gateway/server-startup-early.test.ts +++ b/src/gateway/server-startup-early.test.ts @@ -36,7 +36,7 @@ describe("startGatewayEarlyRuntime", () => { skillsRefreshDelayMs: 30_000, getSkillsRefreshTimer: () => null, setSkillsRefreshTimer: () => {}, - loadConfig: () => ({}) as never, + getRuntimeConfig: () => ({}) as never, }); expect(earlyRuntime).not.toHaveProperty("mcpServer"); diff --git a/src/gateway/server-startup-early.ts b/src/gateway/server-startup-early.ts index 0d9cb54ef88..41d7f7f52da 100644 --- a/src/gateway/server-startup-early.ts +++ b/src/gateway/server-startup-early.ts @@ -57,7 +57,7 @@ export async function startGatewayEarlyRuntime(params: { skillsRefreshDelayMs: number; getSkillsRefreshTimer: () => ReturnType | null; setSkillsRefreshTimer: (timer: ReturnType | null) => void; - loadConfig: () => OpenClawConfig; + getRuntimeConfig: () => OpenClawConfig; }) { let bonjourStop: (() => Promise) | null = null; if (!params.minimalTestGateway) { @@ -100,7 +100,7 @@ export async function startGatewayEarlyRuntime(params: { } const nextTimer = setTimeout(() => { params.setSkillsRefreshTimer(null); - void refreshRemoteBinsForConnectedNodes(params.loadConfig()); + void refreshRemoteBinsForConnectedNodes(params.getRuntimeConfig()); }, params.skillsRefreshDelayMs); params.setSkillsRefreshTimer(nextTimer); }); diff --git a/src/gateway/server.auth.modes.suite.ts b/src/gateway/server.auth.modes.suite.ts index f0efddc455e..c7582acaaad 100644 --- a/src/gateway/server.auth.modes.suite.ts +++ b/src/gateway/server.auth.modes.suite.ts @@ -150,12 +150,15 @@ export function registerAuthModesSuite(): void { beforeAll(async () => { testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true }; testState.gatewayControlUi = { allowedOrigins: [tailscaleOrigin] }; - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ - gateway: { - auth: testState.gatewayAuth, - controlUi: testState.gatewayControlUi, + const { replaceConfigFile } = await import("../config/config.js"); + await replaceConfigFile({ + nextConfig: { + gateway: { + auth: testState.gatewayAuth, + controlUi: testState.gatewayControlUi, + }, }, + afterWrite: { mode: "auto" }, }); port = await getFreePort(); server = await startGatewayServer(port); diff --git a/src/gateway/server.auth.shared.ts b/src/gateway/server.auth.shared.ts index aadfa1053ce..e1341b2956c 100644 --- a/src/gateway/server.auth.shared.ts +++ b/src/gateway/server.auth.shared.ts @@ -221,32 +221,35 @@ async function approvePendingPairingIfNeeded() { } async function configureTrustedProxyControlUiAuth() { - const { writeConfigFile } = await import("../config/config.js"); + const { replaceConfigFile } = await import("../config/config.js"); testState.gatewayAuth = undefined; testState.gatewayControlUi = { ...testState.gatewayControlUi, allowedOrigins: ["https://localhost"], }; - await writeConfigFile({ - gateway: { - auth: { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", - requiredHeaders: ["x-forwarded-proto"], + await replaceConfigFile({ + nextConfig: { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + }, + }, + trustedProxies: ["127.0.0.1"], + controlUi: { + allowedOrigins: ["https://localhost"], }, }, - trustedProxies: ["127.0.0.1"], - controlUi: { - allowedOrigins: ["https://localhost"], - }, }, + afterWrite: { mode: "auto" }, }); } async function writeTrustedProxyControlUiConfig(params?: { allowInsecureAuth?: boolean }) { - const { writeConfigFile } = await import("../config/config.js"); - const nextConfig: Parameters[0] = { + const { replaceConfigFile } = await import("../config/config.js"); + const nextConfig = { gateway: { trustedProxies: ["127.0.0.1"], controlUi: { @@ -255,7 +258,10 @@ async function writeTrustedProxyControlUiConfig(params?: { allowInsecureAuth?: b }, }, }; - await writeConfigFile(nextConfig); + await replaceConfigFile({ + nextConfig, + afterWrite: { mode: "auto" }, + }); } function isConnectResMessage(id: string) { diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 013634744d8..80b9a9f5199 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -158,18 +158,22 @@ async function setupCronTestRun(params: { type DirectCronState = { cron: { stop: () => void }; storePath: string; + getRuntimeConfig: () => import("../config/types.openclaw.js").OpenClawConfig; }; async function createDirectCronState(): Promise { - const [{ loadConfig }, { buildGatewayCronService }] = await Promise.all([ + const [{ getRuntimeConfig }, { buildGatewayCronService }] = await Promise.all([ import("../config/config.js"), import("./server-cron.js"), ]); - return buildGatewayCronService({ - cfg: loadConfig(), - deps: {} as never, - broadcast: vi.fn(), - }); + return { + ...buildGatewayCronService({ + cfg: getRuntimeConfig(), + deps: {} as never, + broadcast: vi.fn(), + }), + getRuntimeConfig: getRuntimeConfig, + }; } async function directCronReq( @@ -199,6 +203,7 @@ async function directCronReq( warn: vi.fn(), error: vi.fn(), }, + getRuntimeConfig: cronState.getRuntimeConfig, } as never, client: null, isWebchatConnect: () => false, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 401f0e4e1df..6673dbb5181 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -10,12 +10,11 @@ import { applyConfigOverrides, getRuntimeConfig, isNixMode, - loadConfig, promoteConfigSnapshotToLastKnownGood, readConfigFileSnapshot, recoverConfigFromLastKnownGood, registerConfigWriteListener, - writeConfigFile, + replaceConfigFile, } from "../config/config.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { resolveMainSessionKey } from "../config/sessions.js"; @@ -379,7 +378,12 @@ export async function startGatewayServer( : await startupTrace.measure("control-ui.seed", () => maybeSeedControlUiAllowedOriginsAtStartup({ config: cfgAtStart, - writeConfig: writeConfigFile, + writeConfig: async (nextConfig) => { + await replaceConfigFile({ + nextConfig, + afterWrite: { mode: "auto" }, + }); + }, log, runtimeBind: opts.bind, runtimePort: port, @@ -536,9 +540,9 @@ export async function startGatewayServer( const serverStartedAt = Date.now(); let startupSidecarsReady = minimalTestGateway; const channelManager = createChannelManager({ - loadConfig: () => + getRuntimeConfig: () => applyPluginAutoEnable({ - config: loadConfig(), + config: getRuntimeConfig(), env: process.env, }).config, channelLogs, @@ -725,7 +729,7 @@ export async function startGatewayServer( setSkillsRefreshTimer: (timer) => { runtimeState.skillsRefreshTimer = timer; }, - loadConfig, + getRuntimeConfig, }), ); runtimeState.bonjourStop = earlyRuntime.bonjourStop; @@ -784,6 +788,7 @@ export async function startGatewayServer( const gatewayRequestContext = createGatewayRequestContext({ deps, runtimeState, + getRuntimeConfig, execApprovalManager, pluginApprovalManager, loadGatewayModelCatalog, diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 310570df846..8a087d171b6 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -383,6 +383,7 @@ async function directSessionReq( }, ): Promise<{ ok: boolean; payload?: TPayload; error?: { code?: string; message?: string } }> { const sessionsHandlers = await getSessionsHandlers(); + const { getRuntimeConfig } = await getGatewayConfigModule(); let result: | { ok: boolean; payload?: TPayload; error?: { code?: string; message?: string } } | undefined; @@ -405,6 +406,7 @@ async function directSessionReq( broadcastToConnIds: vi.fn(), getSessionEventSubscriberConnIds: () => new Set(), loadGatewayModelCatalog: async () => piSdkMock.models, + getRuntimeConfig: getRuntimeConfig, ...opts?.context, } as never, client: opts?.client ?? null, @@ -815,6 +817,7 @@ describe("gateway server sessions", () => { const broadcastToConnIds = vi.fn(); const respond = vi.fn(); const sessionsHandlers = await getSessionsHandlers(); + const { getRuntimeConfig } = await getGatewayConfigModule(); await sessionsHandlers["sessions.patch"]({ req: {} as never, params: { @@ -826,6 +829,7 @@ describe("gateway server sessions", () => { broadcastToConnIds, getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), loadGatewayModelCatalog: async () => ({ providers: [] }), + getRuntimeConfig: getRuntimeConfig, } as never, client: null, isWebchatConnect: () => false, @@ -874,6 +878,7 @@ describe("gateway server sessions", () => { const broadcastToConnIds = vi.fn(); const respond = vi.fn(); const sessionsHandlers = await getSessionsHandlers(); + const { getRuntimeConfig } = await getGatewayConfigModule(); await sessionsHandlers["sessions.patch"]({ req: {} as never, params: { @@ -885,6 +890,7 @@ describe("gateway server sessions", () => { broadcastToConnIds, getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), loadGatewayModelCatalog: async () => ({ providers: [] }), + getRuntimeConfig: getRuntimeConfig, } as never, client: null, isWebchatConnect: () => false, @@ -928,6 +934,7 @@ describe("gateway server sessions", () => { const broadcastToConnIds = vi.fn(); const respond = vi.fn(); const sessionsHandlers = await getSessionsHandlers(); + const { getRuntimeConfig } = await getGatewayConfigModule(); await sessionsHandlers["sessions.patch"]({ req: {} as never, params: { @@ -939,6 +946,7 @@ describe("gateway server sessions", () => { broadcastToConnIds, getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), loadGatewayModelCatalog: async () => ({ providers: [] }), + getRuntimeConfig: getRuntimeConfig, } as never, client: null, isWebchatConnect: () => false, @@ -981,6 +989,7 @@ describe("gateway server sessions", () => { const broadcastToConnIds = vi.fn(); const respond = vi.fn(); const sessionsHandlers = await getSessionsHandlers(); + const { getRuntimeConfig } = await getGatewayConfigModule(); await sessionsHandlers["sessions.patch"]({ req: {} as never, params: { @@ -992,6 +1001,7 @@ describe("gateway server sessions", () => { broadcastToConnIds, getSessionEventSubscriberConnIds: () => new Set(["conn-1"]), loadGatewayModelCatalog: async () => ({ providers: [] }), + getRuntimeConfig: getRuntimeConfig, } as never, client: null, isWebchatConnect: () => false, @@ -1083,10 +1093,12 @@ describe("gateway server sessions", () => { ]), ); const sessionsHandlers = await getSessionsHandlers(); + const { getRuntimeConfig } = await getGatewayConfigModule(); const directContext = { broadcastToConnIds: vi.fn(), getSessionEventSubscriberConnIds: () => new Set(), loadGatewayModelCatalog: async () => piSdkMock.models, + getRuntimeConfig: getRuntimeConfig, } as never; async function directSessionReq( method: keyof typeof sessionsHandlers, @@ -3268,14 +3280,17 @@ describe("gateway server sessions", () => { }); beforeResetHookState.hasBeforeResetHook = true; - const [{ loadConfig }, { resolveGatewaySessionStoreTarget }, { withSessionStoreLockForTest }] = - await Promise.all([ - import("../config/config.js"), - import("./session-utils.js"), - import("../config/sessions/store.js"), - ]); + const [ + { getRuntimeConfig }, + { resolveGatewaySessionStoreTarget }, + { withSessionStoreLockForTest }, + ] = await Promise.all([ + import("../config/config.js"), + import("./session-utils.js"), + import("../config/sessions/store.js"), + ]); const gatewayStorePath = resolveGatewaySessionStoreTarget({ - cfg: loadConfig(), + cfg: getRuntimeConfig(), key: "main", }).storePath; diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index 47491f94199..76cc977fb1e 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -1,6 +1,6 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { getHealthSnapshot, type HealthSummary } from "../../commands/health.js"; -import { STATE_DIR, createConfigIO, loadConfig } from "../../config/config.js"; +import { getRuntimeConfig, STATE_DIR, createConfigIO } from "../../config/config.js"; import { resolveMainSessionKey } from "../../config/sessions.js"; import { listSystemPresence } from "../../infra/system-presence.js"; import { getUpdateAvailable } from "../../infra/update-startup.js"; @@ -15,7 +15,7 @@ let healthRefresh: Promise | null = null; let broadcastHealthUpdate: ((snap: HealthSummary) => void) | null = null; export function buildGatewaySnapshot(opts?: { includeSensitive?: boolean }): Snapshot { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const defaultAgentId = resolveDefaultAgentId(cfg); const mainKey = normalizeMainKey(cfg.session?.mainKey); const mainSessionKey = resolveMainSessionKey(cfg); diff --git a/src/gateway/server/hooks.agent-trust.test.ts b/src/gateway/server/hooks.agent-trust.test.ts index f17209499bc..0048dadf413 100644 --- a/src/gateway/server/hooks.agent-trust.test.ts +++ b/src/gateway/server/hooks.agent-trust.test.ts @@ -19,7 +19,7 @@ vi.mock("../../config/sessions.js", () => ({ resolveMainSessionKeyFromConfig: resolveMainSessionKeyMock, })); vi.mock("../../config/config.js", () => ({ - loadConfig: loadConfigMock, + getRuntimeConfig: loadConfigMock, })); let capturedDispatchAgentHook: ((...args: unknown[]) => unknown) | undefined; diff --git a/src/gateway/server/hooks.ts b/src/gateway/server/hooks.ts index abfa7e93fe7..9d9767a2f24 100644 --- a/src/gateway/server/hooks.ts +++ b/src/gateway/server/hooks.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { sanitizeInboundSystemTags } from "../../auto-reply/reply/inbound-text.js"; import type { CliDeps } from "../../cli/deps.types.js"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js"; import type { CronJob } from "../../cron/types.js"; import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js"; @@ -70,7 +70,7 @@ export function createGatewayHooksRequestHandler(params: { const runId = randomUUID(); void (async () => { try { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const { runCronIsolatedAgentTurn } = await import("../../cron/isolated-agent.js"); const result = await runCronIsolatedAgentTurn({ cfg, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 634627bede4..f34ceecd329 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1,7 +1,7 @@ import type { IncomingMessage } from "node:http"; import os from "node:os"; import type { RawData, WebSocket } from "ws"; -import { loadConfig } from "../../../config/config.js"; +import { getRuntimeConfig } from "../../../config/config.js"; import { getBoundDeviceBootstrapProfile, getDeviceBootstrapTokenProfile, @@ -260,7 +260,7 @@ export function attachGatewayWsMessageHandler(params: { }); }); - const configSnapshot = loadConfig(); + const configSnapshot = getRuntimeConfig(); const trustedProxies = configSnapshot.gateway?.trustedProxies ?? []; const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true; const clientIp = resolveClientIp({ @@ -1246,7 +1246,7 @@ export function attachGatewayWsMessageHandler(params: { if (role === "node") { const reconciliation = await reconcileNodePairingOnConnect({ - cfg: loadConfig(), + cfg: getRuntimeConfig(), connectParams, pairedNode: await getPairedNode(connectParams.device?.id ?? connectParams.client.id), reportedClientIp, @@ -1400,7 +1400,7 @@ export function attachGatewayWsMessageHandler(params: { platform: nodeSession.platform, deviceFamily: nodeSession.deviceFamily, commands: nodeSession.commands, - cfg: loadConfig(), + cfg: getRuntimeConfig(), }).catch((err) => logGateway.warn( `remote bin probe failed for ${nodeSession.nodeId}: ${formatForLog(err)}`, diff --git a/src/gateway/session-kill-http.test.ts b/src/gateway/session-kill-http.test.ts index b49dd841cfb..18f7236d521 100644 --- a/src/gateway/session-kill-http.test.ts +++ b/src/gateway/session-kill-http.test.ts @@ -15,7 +15,7 @@ const killControlledSubagentRunMock = vi.fn(); const killSubagentRunAdminMock = vi.fn(); vi.mock("../config/config.js", () => ({ - loadConfig: () => cfg, + getRuntimeConfig: () => cfg, })); vi.mock("./auth.js", () => ({ diff --git a/src/gateway/session-kill-http.ts b/src/gateway/session-kill-http.ts index 0a746393956..737d9a891f7 100644 --- a/src/gateway/session-kill-http.ts +++ b/src/gateway/session-kill-http.ts @@ -5,7 +5,7 @@ import { resolveSubagentController, } from "../agents/subagent-control.js"; import { getLatestSubagentRunByChildSessionKey } from "../agents/subagent-registry.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import { isLocalDirectRequest, type ResolvedGatewayAuth } from "./auth.js"; @@ -42,7 +42,7 @@ export async function handleSessionKillHttpRequest( rateLimiter?: AuthRateLimiter; }, ): Promise { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); const sessionKey = resolveSessionKeyFromPath(url.pathname); if (!sessionKey) { diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index f2b00966389..cc89b1a9a78 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -15,7 +15,7 @@ import { buildSessionStartHookPayload, } from "../auto-reply/reply/session-hooks.js"; import { clearSessionResetRuntimeState } from "../auto-reply/reply/session-reset-cleanup.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { snapshotSessionOrigin, type SessionEntry, @@ -477,7 +477,7 @@ export async function performGatewaySessionReset(params: { | { ok: false; error: ReturnType } > { const { cfg, target, storePath } = (() => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const target = resolveGatewaySessionStoreTarget({ cfg, key: params.key }); return { cfg, target, storePath: target.storePath }; })(); diff --git a/src/gateway/session-transcript-key.test.ts b/src/gateway/session-transcript-key.test.ts index c2e40e41025..abd26123f6c 100644 --- a/src/gateway/session-transcript-key.test.ts +++ b/src/gateway/session-transcript-key.test.ts @@ -14,7 +14,7 @@ const { })); vi.mock("../config/config.js", () => ({ - loadConfig: loadConfigMock, + getRuntimeConfig: loadConfigMock, })); vi.mock("./session-utils.js", () => ({ diff --git a/src/gateway/session-transcript-key.ts b/src/gateway/session-transcript-key.ts index 85fb0df94b7..a0476fa5873 100644 --- a/src/gateway/session-transcript-key.ts +++ b/src/gateway/session-transcript-key.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; @@ -62,7 +62,7 @@ export function resolveSessionKeyForTranscriptFile(sessionFile: string): string if (!targetPath) { return undefined; } - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const { store } = loadCombinedSessionStoreForGateway(cfg); const cachedKey = TRANSCRIPT_SESSION_KEY_CACHE.get(targetPath); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 6cc3839c730..0a588fd2b79 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -34,7 +34,7 @@ import { shouldKeepSubagentRunChildLink, } from "../agents/subagent-run-liveness.js"; import { listThinkingLevelOptions } from "../auto-reply/thinking.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveAgentModelFallbackValues } from "../config/model-input.js"; import { resolveStateDir } from "../config/paths.js"; import { @@ -472,7 +472,7 @@ export function resolveDeletedAgentIdFromSessionKey( } export function loadSessionEntry(sessionKey: string) { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const key = normalizeOptionalString(sessionKey) ?? ""; const target = resolveGatewaySessionStoreTarget({ cfg, diff --git a/src/gateway/sessions-history-http.revocation.test.ts b/src/gateway/sessions-history-http.revocation.test.ts index e01be4a1eb3..21b37f4e7ed 100644 --- a/src/gateway/sessions-history-http.revocation.test.ts +++ b/src/gateway/sessions-history-http.revocation.test.ts @@ -18,7 +18,7 @@ let gatewayConfig: { let authCheckCalls = 0; vi.mock("../config/config.js", () => ({ - loadConfig: () => ({ + getRuntimeConfig: () => ({ gateway: gatewayConfig, }), })); diff --git a/src/gateway/sessions-history-http.ts b/src/gateway/sessions-history-http.ts index 02e7dabe653..17251a8394a 100644 --- a/src/gateway/sessions-history-http.ts +++ b/src/gateway/sessions-history-http.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { loadSessionStore } from "../config/sessions.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js"; @@ -243,7 +243,7 @@ export async function handleSessionHistoryHttpRequest( }) .catch((error) => { // Surface the underlying error so operators can distinguish transient - // infrastructure failures (for example a `loadConfig()` read error + // infrastructure failures (for example a `getRuntimeConfig()` read error // inside the reauth path) from deliberate revocation, then fail closed. log.warn("session history SSE stream work failed; closing stream", { error }); closeStream(); @@ -251,7 +251,7 @@ export async function handleSessionHistoryHttpRequest( }; const isStreamStillAuthorized = async (): Promise => { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const currentRequestAuth = await checkGatewayHttpRequestAuth({ req, auth: opts.getResolvedAuth?.() ?? opts.auth, diff --git a/src/gateway/talk.test-helpers.ts b/src/gateway/talk.test-helpers.ts index 3a42fcd4413..ebfbf0e1d36 100644 --- a/src/gateway/talk.test-helpers.ts +++ b/src/gateway/talk.test-helpers.ts @@ -11,6 +11,7 @@ export type TalkSpeakTestPayload = { export async function invokeTalkSpeakDirect(params: Record) { const { talkHandlers } = await import("./server-methods/talk.js"); + const { getRuntimeConfig } = await import("../config/config.js"); let response: | { ok: boolean; @@ -26,7 +27,7 @@ export async function invokeTalkSpeakDirect(params: Record) { respond: (ok, payload, error) => { response = { ok, payload, error }; }, - context: {} as never, + context: { getRuntimeConfig: getRuntimeConfig } as never, }); return response; } diff --git a/src/gateway/test-helpers.config-runtime.ts b/src/gateway/test-helpers.config-runtime.ts index d29a380e9d8..36ffa5c94a4 100644 --- a/src/gateway/test-helpers.config-runtime.ts +++ b/src/gateway/test-helpers.config-runtime.ts @@ -263,7 +263,6 @@ export function createGatewayConfigModuleMock(actual: GatewayConfigModule): Gate }, applyConfigOverrides: (cfg: OpenClawConfig) => composeTestConfig(cfg as Record), - loadConfig: loadRuntimeAwareTestConfig, getRuntimeConfig: loadRuntimeAwareTestConfig, parseConfigJson5: (raw: string) => { try { diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 792cc96dfc2..b8638123149 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -211,7 +211,7 @@ vi.mock("../config/io.js", async () => { const configMock = createGatewayConfigModuleMock(configActual); const createConfigIO = vi.fn(() => ({ ...actual.createConfigIO(), - loadConfig: configMock.loadConfig, + getRuntimeConfig: configMock.getRuntimeConfig, readConfigFileSnapshot: configMock.readConfigFileSnapshot, readConfigFileSnapshotForWrite: configMock.readConfigFileSnapshotForWrite, writeConfigFile: configMock.writeConfigFile, @@ -220,7 +220,6 @@ vi.mock("../config/io.js", async () => { ...actual, createConfigIO, getRuntimeConfig: configMock.getRuntimeConfig, - loadConfig: configMock.loadConfig, readConfigFileSnapshot: configMock.readConfigFileSnapshot, readConfigFileSnapshotForWrite: configMock.readConfigFileSnapshotForWrite, writeConfigFile: configMock.writeConfigFile, diff --git a/src/gateway/tools-invoke-http.cron-regression.test.ts b/src/gateway/tools-invoke-http.cron-regression.test.ts index 1c902eee1a4..c2e490b35c6 100644 --- a/src/gateway/tools-invoke-http.cron-regression.test.ts +++ b/src/gateway/tools-invoke-http.cron-regression.test.ts @@ -16,7 +16,7 @@ const noPluginToolMeta = () => undefined; const noWarnLog = () => {}; vi.mock("../config/config.js", () => ({ - loadConfig: () => cfg, + getRuntimeConfig: () => cfg, })); vi.mock("../config/sessions.js", () => ({ diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index 45e6caf757c..21cfe141a05 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -22,7 +22,7 @@ let lastCreateOpenClawToolsContext: Record | undefined; // Perf: keep this suite pure unit. Mock heavyweight config/session modules. vi.mock("../config/config.js", () => ({ - loadConfig: () => cfg, + getRuntimeConfig: () => cfg, })); vi.mock("../config/sessions.js", () => ({ diff --git a/src/hooks/gmail-ops.ts b/src/hooks/gmail-ops.ts index ae0700bfcd6..673e1e945e7 100644 --- a/src/hooks/gmail-ops.ts +++ b/src/hooks/gmail-ops.ts @@ -1,13 +1,13 @@ import { spawn } from "node:child_process"; import { formatCliCommand } from "../cli/command-format.js"; import { + getRuntimeConfig, type OpenClawConfig, CONFIG_PATH, - loadConfig, readConfigFileSnapshot, + replaceConfigFile, resolveGatewayPort, validateConfigObjectWithPlugins, - writeConfigFile, } from "../config/config.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime } from "../runtime.js"; @@ -237,7 +237,10 @@ export async function runGmailSetup(opts: GmailSetupOptions) { if (!validated.ok) { throw new Error(`Config validation failed: ${validated.issues[0]?.message ?? "invalid"}`); } - await writeConfigFile(validated.config); + await replaceConfigFile({ + nextConfig: validated.config, + afterWrite: { mode: "auto" }, + }); const summary = { projectId, @@ -271,7 +274,7 @@ export async function runGmailSetup(opts: GmailSetupOptions) { export async function runGmailService(opts: GmailRunOptions) { await ensureDependency("gog", ["gogcli"]); - const config = loadConfig(); + const config = getRuntimeConfig(); const overrides: GmailHookOverrides = { account: opts.account, diff --git a/src/hooks/loader.ts b/src/hooks/loader.ts index bf7384c35b9..1d069dfbbaa 100644 --- a/src/hooks/loader.ts +++ b/src/hooks/loader.ts @@ -71,7 +71,7 @@ function resetLoadedInternalHooks(): void { * * @example * ```ts - * const config = await loadConfig(); + * const config = await getRuntimeConfig(); * const workspaceDir = resolveAgentWorkspaceDir(config, agentId); * const count = await loadInternalHooks(config, workspaceDir); * console.log(`Loaded ${count} hook handlers`); diff --git a/src/infra/approval-turn-source.test.ts b/src/infra/approval-turn-source.test.ts index 4a114f8822e..6174aaf2185 100644 --- a/src/infra/approval-turn-source.test.ts +++ b/src/infra/approval-turn-source.test.ts @@ -4,7 +4,7 @@ const loadConfigMock = vi.hoisted(() => vi.fn()); const resolveExecApprovalInitiatingSurfaceStateMock = vi.hoisted(() => vi.fn()); vi.mock("../config/config.js", () => ({ - loadConfig: () => loadConfigMock(), + getRuntimeConfig: () => loadConfigMock(), })); vi.mock("./exec-approval-surface.js", () => ({ diff --git a/src/infra/approval-turn-source.ts b/src/infra/approval-turn-source.ts index 284ce6fe128..3467c6ce6c9 100644 --- a/src/infra/approval-turn-source.ts +++ b/src/infra/approval-turn-source.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { resolveExecApprovalInitiatingSurfaceState } from "./exec-approval-surface.js"; export function hasApprovalTurnSourceRoute(params: { @@ -12,7 +12,7 @@ export function hasApprovalTurnSourceRoute(params: { resolveExecApprovalInitiatingSurfaceState({ channel: params.turnSourceChannel, accountId: params.turnSourceAccountId, - cfg: loadConfig(), + cfg: getRuntimeConfig(), }).kind === "enabled" ); } diff --git a/src/infra/channel-summary.ts b/src/infra/channel-summary.ts index 73b663d8e6d..e1cf9a073c5 100644 --- a/src/infra/channel-summary.ts +++ b/src/infra/channel-summary.ts @@ -45,8 +45,8 @@ const accountLine = (label: string, details: string[]) => ` - ${label}${details.length ? ` (${details.join(", ")})` : ""}`; async function loadChannelSummaryConfig(): Promise { - const { loadConfig } = await import("../config/config.js"); - return loadConfig(); + const { getRuntimeConfig } = await import("../config/config.js"); + return getRuntimeConfig(); } async function listChannelSummaryPlugins(params: { diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 56211baf431..97f4068614f 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -3,7 +3,7 @@ import { getLoadedChannelPlugin, resolveChannelApprovalAdapter, } from "../channels/plugins/index.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { ExecApprovalForwardingConfig, ExecApprovalForwardTarget, @@ -747,7 +747,7 @@ const pluginApprovalStrategy = createApprovalStrategy< export function createExecApprovalForwarder( deps: ExecApprovalForwarderDeps = {}, ): ExecApprovalForwarder { - const getConfig = deps.getConfig ?? loadConfig; + const getConfig = deps.getConfig ?? getRuntimeConfig; const deliver = deps.deliver ?? (async (params) => { diff --git a/src/infra/exec-approval-surface.test.ts b/src/infra/exec-approval-surface.test.ts index 6d74f9fa69f..7f203b6a0a1 100644 --- a/src/infra/exec-approval-surface.test.ts +++ b/src/infra/exec-approval-surface.test.ts @@ -10,7 +10,7 @@ vi.mock("../config/config.js", async () => { const actual = await vi.importActual("../config/config.js"); return { ...actual, - loadConfig: (...args: unknown[]) => loadConfigMock(...args), + getRuntimeConfig: (...args: unknown[]) => loadConfigMock(...args), }; }); diff --git a/src/infra/exec-approval-surface.ts b/src/infra/exec-approval-surface.ts index 52a8589ad53..e55724fa9e2 100644 --- a/src/infra/exec-approval-surface.ts +++ b/src/infra/exec-approval-surface.ts @@ -3,7 +3,7 @@ import { listChannelPlugins, resolveChannelApprovalCapability, } from "../channels/plugins/index.js"; -import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { INTERNAL_MESSAGE_CHANNEL, @@ -49,7 +49,7 @@ export function resolveExecApprovalInitiatingSurfaceState(params: { return { kind: "enabled", channel, channelLabel, accountId }; } - const cfg = params.cfg ?? loadConfig(); + const cfg = params.cfg ?? getRuntimeConfig(); const capability = resolveChannelApprovalCapability(getChannelPlugin(channel)); const state = capability?.getExecInitiatingSurfaceState?.({ diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 71c52baec37..7a7a7b9eb72 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -32,7 +32,7 @@ import type { ChannelId, ChannelPlugin, } from "../channels/plugins/types.public.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { canonicalizeMainSessionAlias, resolveAgentMainSessionKey, @@ -732,7 +732,7 @@ export async function runHeartbeatOnce(opts: { reason?: string; deps?: HeartbeatDeps; }): Promise { - const cfg = opts.cfg ?? loadConfig(); + const cfg = opts.cfg ?? getRuntimeConfig(); const explicitAgentId = typeof opts.agentId === "string" ? opts.agentId.trim() : ""; const forcedSessionAgentId = explicitAgentId.length > 0 ? undefined : parseAgentSessionKey(opts.sessionKey)?.agentId; @@ -1323,7 +1323,7 @@ export function startHeartbeatRunner(opts: { const runtime = opts.runtime ?? defaultRuntime; const runOnce = opts.runOnce ?? runHeartbeatOnce; const state = { - cfg: opts.cfg ?? loadConfig(), + cfg: opts.cfg ?? getRuntimeConfig(), runtime, schedulerSeed: resolveHeartbeatSchedulerSeed(opts.stableSchedulerSeed), agents: new Map(), diff --git a/src/infra/outbound/cfg-threading.guard.test.ts b/src/infra/outbound/cfg-threading.guard.test.ts index 2f8a33604bb..9c6ad4caadf 100644 --- a/src/infra/outbound/cfg-threading.guard.test.ts +++ b/src/infra/outbound/cfg-threading.guard.test.ts @@ -7,7 +7,7 @@ import { bundledPluginFile } from "../../../test/helpers/bundled-plugin-paths.js const thisFilePath = fileURLToPath(import.meta.url); const thisDir = path.dirname(thisFilePath); const repoRoot = path.resolve(thisDir, "../../.."); -const loadConfigPattern = /\b(?:loadConfig|config\.loadConfig)\s*\(/; +const loadConfigPattern = /\b(?:getRuntimeConfig|config\.getRuntimeConfig)\s*\(/; function toPosix(relativePath: string): string { return relativePath.split(path.sep).join("/"); @@ -163,35 +163,35 @@ function extractOutboundBlock(source: string, file: string): string { } describe("outbound cfg-threading guard", () => { - it("keeps outbound adapter entrypoints free of loadConfig calls", () => { + it("keeps outbound adapter entrypoints free of getRuntimeConfig calls", () => { const coreAdapterFiles = listCoreOutboundEntryFiles(); const extensionAdapterFiles = listExtensionFiles().adapterEntrypoints; const adapterFiles = [...coreAdapterFiles, ...extensionAdapterFiles]; for (const file of adapterFiles) { const source = readRepoFile(file); - expect(source, `${file} must not call loadConfig in outbound entrypoint`).not.toMatch( + expect(source, `${file} must not call getRuntimeConfig in outbound entrypoint`).not.toMatch( loadConfigPattern, ); } }); - it("keeps inline channel outbound blocks free of loadConfig calls", () => { + it("keeps inline channel outbound blocks free of getRuntimeConfig calls", () => { const inlineFiles = listExtensionFiles().inlineChannelEntrypoints; for (const file of inlineFiles) { const source = readRepoFile(file); const outboundBlock = extractOutboundBlock(source, file); - expect(outboundBlock, `${file} outbound block must not call loadConfig`).not.toMatch( + expect(outboundBlock, `${file} outbound block must not call getRuntimeConfig`).not.toMatch( loadConfigPattern, ); } }); - it("keeps high-risk runtime delivery paths free of loadConfig calls", () => { + it("keeps high-risk runtime delivery paths free of getRuntimeConfig calls", () => { const runtimeFiles = listHighRiskRuntimeCfgFiles(); for (const file of runtimeFiles) { const source = readRepoFile(file); - expect(source, `${file} must not call loadConfig`).not.toMatch(loadConfigPattern); + expect(source, `${file} must not call getRuntimeConfig`).not.toMatch(loadConfigPattern); } }); }); diff --git a/src/infra/outbound/message.config.runtime.ts b/src/infra/outbound/message.config.runtime.ts index 08f2114655e..6cac39b0d6f 100644 --- a/src/infra/outbound/message.config.runtime.ts +++ b/src/infra/outbound/message.config.runtime.ts @@ -1 +1 @@ -export { loadConfig } from "../../config/io.js"; +export { getRuntimeConfig } from "../../config/io.js"; diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 5937ab2581e..107066f2c17 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -220,8 +220,8 @@ async function resolveMessageConfig(cfg?: OpenClawConfig): Promise { diff --git a/src/infra/provider-usage-plugin-runtime.test-mocks.ts b/src/infra/provider-usage-plugin-runtime.test-mocks.ts index a68aba431da..f1c3bdae56b 100644 --- a/src/infra/provider-usage-plugin-runtime.test-mocks.ts +++ b/src/infra/provider-usage-plugin-runtime.test-mocks.ts @@ -7,7 +7,7 @@ const resolveProviderUsageSnapshotWithPluginMock = vi.hoisted(() => ); vi.mock("../config/config.js", () => ({ - loadConfig: () => ({}), + getRuntimeConfig: () => ({}), })); vi.mock("../plugins/provider-runtime.js", async () => { diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index f3c2a872487..215aaf137a1 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -11,7 +11,7 @@ import { resolveEnvApiKey } from "../agents/model-auth-env.js"; import { isNonSecretApiKeyMarker } from "../agents/model-auth-markers.js"; import { resolveUsableCustomProviderApiKey } from "../agents/model-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; -import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { isActivatedManifestOwner, @@ -228,7 +228,7 @@ async function resolveOAuthToken(params: { try { const resolved = await resolveApiKeyForProfile({ // Reuse the already-resolved config snapshot for token/ref resolution so - // usage snapshots don't trigger a second ambient loadConfig() call. + // usage snapshots don't trigger a second ambient getRuntimeConfig() call. cfg: params.state.cfg, store, profileId, @@ -364,7 +364,7 @@ export async function resolveProviderAuths(params: { } const stateBase = { - cfg: params.config ?? loadConfig(), + cfg: params.config ?? getRuntimeConfig(), env: params.env ?? process.env, agentDir: params.agentDir, }; diff --git a/src/infra/provider-usage.load.plugin.test.ts b/src/infra/provider-usage.load.plugin.test.ts index 87e892474ed..d50a5126764 100644 --- a/src/infra/provider-usage.load.plugin.test.ts +++ b/src/infra/provider-usage.load.plugin.test.ts @@ -4,7 +4,7 @@ import { createProviderUsageFetch } from "../test-utils/provider-usage-fetch.js" const resolveProviderUsageSnapshotWithPluginMock = vi.fn(); vi.mock("../config/config.js", () => ({ - loadConfig: () => ({}), + getRuntimeConfig: () => ({}), })); vi.mock("../plugins/provider-runtime.js", async () => { diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index 567aa9e1282..6c6dd29140b 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -1,4 +1,4 @@ -import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; import { resolveProviderUsageSnapshotWithPlugin } from "../plugins/provider-runtime.js"; import { resolveFetch } from "./fetch.js"; import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js"; @@ -84,7 +84,7 @@ export async function loadProviderUsageSummary( ): Promise { const now = opts.now ?? Date.now(); const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const config = opts.config ?? loadConfig(); + const config = opts.config ?? getRuntimeConfig(); const env = opts.env ?? process.env; const fetchFn = resolveFetch(opts.fetch); if (!fetchFn) { diff --git a/src/logging/redact.test.ts b/src/logging/redact.test.ts index 30ba0da0a8c..d47109bfd7b 100644 --- a/src/logging/redact.test.ts +++ b/src/logging/redact.test.ts @@ -79,6 +79,15 @@ describe("redactSensitiveText", () => { expect(output).toBe("cdp=https://browserless.example.com/?token=***"); }); + it("masks standalone lowercase token assignments in diagnostic output", () => { + const input = "matrix access_token=abcdef1234567890ghij next"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe("matrix access_token=abcdef…ghij next"); + }); + it("masks JSON fields", () => { const input = '{"token":"abcdef1234567890ghij"}'; const output = redactSensitiveText(input, { @@ -97,6 +106,15 @@ describe("redactSensitiveText", () => { expect(output).toBe("Authorization: Bearer abcdef…ghij"); }); + it("masks URL query tokens", () => { + const input = "GET /_matrix/client/v3/sync?access_token=abcdef1234567890ghij"; + const output = redactSensitiveText(input, { + mode: "tools", + patterns: defaults, + }); + expect(output).toBe("GET /_matrix/client/v3/sync?access_token=abcdef…ghij"); + }); + it("masks bot-style tokens", () => { const input = "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef"; const output = redactSensitiveText(input, { diff --git a/src/logging/redact.ts b/src/logging/redact.ts index 230cb2c83ef..377224ffbfd 100644 --- a/src/logging/redact.ts +++ b/src/logging/redact.ts @@ -24,6 +24,9 @@ const DEFAULT_REDACT_PATTERNS: string[] = [ // Authorization headers. String.raw`Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)`, String.raw`\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b`, + // Standalone token assignments in CLI or HTTP diagnostics. URL query params + // are handled above so non-secret params survive and long values stay hinted. + String.raw`(^|[\s,;])(?:access_token|refresh_token|api[-_]?key|token|secret|password|passwd)=([^\s&#]+)`, // PEM blocks. String.raw`-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----`, // Common token prefixes. diff --git a/src/mcp/channel-server.shutdown-unhandled-rejection.test.ts b/src/mcp/channel-server.shutdown-unhandled-rejection.test.ts index ac712bb2f91..648051ef1a3 100644 --- a/src/mcp/channel-server.shutdown-unhandled-rejection.test.ts +++ b/src/mcp/channel-server.shutdown-unhandled-rejection.test.ts @@ -43,7 +43,7 @@ vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({ })); vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn(() => ({})), + getRuntimeConfig: vi.fn(() => ({})), })); vi.mock("../version.js", () => ({ diff --git a/src/mcp/channel-server.ts b/src/mcp/channel-server.ts index 9dccb16fa59..49798ec4fc1 100644 --- a/src/mcp/channel-server.ts +++ b/src/mcp/channel-server.ts @@ -1,6 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; import { VERSION } from "../version.js"; import { OpenClawChannelBridge } from "./channel-bridge.js"; import { ClaudePermissionRequestSchema, type ClaudeChannelMode } from "./channel-shared.js"; @@ -23,7 +23,7 @@ export async function createOpenClawChannelMcpServer(opts: OpenClawMcpServeOptio start: () => Promise; close: () => Promise; }> { - const cfg = opts.config ?? loadConfig(); + const cfg = opts.config ?? getRuntimeConfig(); const claudeChannelMode = opts.claudeChannelMode ?? "auto"; const capabilities = getChannelMcpCapabilities(claudeChannelMode); const server = new McpServer( diff --git a/src/mcp/plugin-tools-serve.ts b/src/mcp/plugin-tools-serve.ts index f3e108b7e8f..0610616f702 100644 --- a/src/mcp/plugin-tools-serve.ts +++ b/src/mcp/plugin-tools-serve.ts @@ -9,7 +9,7 @@ import { pathToFileURL } from "node:url"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { resolvePluginTools } from "../plugins/tools.js"; @@ -28,13 +28,13 @@ export function createPluginToolsMcpServer( tools?: AnyAgentTool[]; } = {}, ): Server { - const cfg = params.config ?? loadConfig(); + const cfg = params.config ?? getRuntimeConfig(); const tools = params.tools ?? resolveTools(cfg); return createToolsMcpServer({ name: "openclaw-plugin-tools", tools }); } export async function servePluginToolsMcp(): Promise { - const config = loadConfig(); + const config = getRuntimeConfig(); const tools = resolveTools(config); const server = createPluginToolsMcpServer({ config, tools }); if (tools.length === 0) { diff --git a/src/memory-host-sdk/runtime-core.ts b/src/memory-host-sdk/runtime-core.ts index c9e1b09fe11..8f2765d96b8 100644 --- a/src/memory-host-sdk/runtime-core.ts +++ b/src/memory-host-sdk/runtime-core.ts @@ -13,7 +13,11 @@ export { } from "../agents/tools/common.js"; export { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; export { parseNonNegativeByteSize } from "../config/byte-size.js"; -export { loadConfig } from "../config/config.js"; +export { + getRuntimeConfig, + /** @deprecated Use getRuntimeConfig(), or pass the already loaded config through the call path. */ + loadConfig, +} from "../config/config.js"; export { resolveStateDir } from "../config/paths.js"; export { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 5d5c89c9bda..114da3d9eb6 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -477,7 +477,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { sendInvokeResult, sendExecFinishedEvent, preferMacAppExecHost: params.preferMacAppExecHost, - loadConfig: () => getRuntimeConfigSnapshot() ?? {}, + getRuntimeConfig: () => getRuntimeConfigSnapshot() ?? {}, }); return { diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index ef1e969093c..bf3445f5cc4 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { GatewayClient } from "../gateway/client.js"; import { @@ -189,15 +190,14 @@ export type HandleSystemRunInvokeOptions = { sendInvokeResult: (result: SystemRunInvokeResult) => Promise; sendExecFinishedEvent: (params: ExecFinishedEventParams) => Promise; preferMacAppExecHost: boolean; - loadConfig?: () => OpenClawConfig; + getRuntimeConfig?: () => OpenClawConfig; }; async function loadSystemRunConfig(opts: HandleSystemRunInvokeOptions): Promise { - if (opts.loadConfig) { - return opts.loadConfig(); + if (opts.getRuntimeConfig) { + return opts.getRuntimeConfig(); } - const { loadConfig } = await import("../config/config.js"); - return loadConfig(); + return getRuntimeConfig(); } async function sendSystemRunDenied( diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 82ee7624f2b..b73f5823252 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -1,4 +1,4 @@ -import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; import { GatewayClient, type GatewayReconnectPausedInfo } from "../gateway/client.js"; import { resolveGatewayConnectionAuth } from "../gateway/connection-auth.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js"; @@ -198,13 +198,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { const gateway: NodeHostGatewayConfig = { host: opts.gatewayHost, port: opts.gatewayPort, - tls: opts.gatewayTls ?? loadConfig().gateway?.tls?.enabled ?? false, + tls: opts.gatewayTls ?? getRuntimeConfig().gateway?.tls?.enabled ?? false, tlsFingerprint: opts.gatewayTlsFingerprint, }; config.gateway = gateway; await saveNodeHostConfig(config); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); await ensureNodeHostPluginRegistry({ config: cfg, env: process.env }); const pluginNodeHost = listRegisteredNodeHostCapsAndCommands(); const { token, password } = await resolveNodeHostGatewayCredentials({ diff --git a/src/plugin-sdk/browser-config-runtime.ts b/src/plugin-sdk/browser-config-runtime.ts index 3f2d5f02817..87cd6414d85 100644 --- a/src/plugin-sdk/browser-config-runtime.ts +++ b/src/plugin-sdk/browser-config-runtime.ts @@ -1,12 +1,32 @@ export { + /** + * @deprecated Use getRuntimeConfig(), runtime.config.current(), or pass the + * already loaded config through the call path. Runtime code must not reload + * config on demand. Bundled plugins and repo code are blocked from using + * this by the deprecated-internal-config-api architecture guard. + */ createConfigIO, + getRuntimeConfig, getRuntimeConfigSnapshot, + /** + * @deprecated Use getRuntimeConfig(), runtime.config.current(), or pass the + * already loaded config through the call path. Runtime code must not reload + * config on demand. Bundled plugins and repo code are blocked from using + * this by the deprecated-internal-config-api architecture guard. + */ loadConfig, + /** + * @deprecated Use mutateConfigFile() or replaceConfigFile() with an explicit + * afterWrite intent so restart behavior stays under host control. Bundled + * plugins and repo code are blocked from using this by the + * deprecated-internal-config-api architecture guard. + */ writeConfigFile, type BrowserConfig, type BrowserProfileConfig, type OpenClawConfig, } from "../config/config.js"; +export { mutateConfigFile, replaceConfigFile } from "../config/mutate.js"; export { resolveConfigPath, resolveGatewayPort } from "../config/paths.js"; export { DEFAULT_BROWSER_CONTROL_PORT, diff --git a/src/plugin-sdk/config-runtime.ts b/src/plugin-sdk/config-runtime.ts index cb192333fbd..c31a274e22a 100644 --- a/src/plugin-sdk/config-runtime.ts +++ b/src/plugin-sdk/config-runtime.ts @@ -50,11 +50,26 @@ export { clearRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, getRuntimeConfigSnapshot, + getRuntimeConfig, + /** + * @deprecated Use getRuntimeConfig(), runtime.config.current(), or pass the + * already loaded config through the call path. Runtime code must not reload + * config on demand. Bundled plugins and repo code are blocked from using + * this by the deprecated-internal-config-api architecture guard. + */ loadConfig, readConfigFileSnapshotForWrite, setRuntimeConfigSnapshot, + /** + * @deprecated Use mutateConfigFile() or replaceConfigFile() with an explicit + * afterWrite intent so restart behavior stays under host control. Bundled + * plugins and repo code are blocked from using this by the + * deprecated-internal-config-api architecture guard. + */ writeConfigFile, } from "../config/io.js"; +export { mutateConfigFile, replaceConfigFile } from "../config/mutate.js"; +export type { ConfigWriteAfterWrite } from "../config/runtime-snapshot.js"; export { logConfigUpdated } from "../config/logging.js"; export { updateConfig } from "../commands/models/shared.js"; export { resolveChannelModelOverride } from "../channels/model-overrides.js"; diff --git a/src/plugin-sdk/memory-core.ts b/src/plugin-sdk/memory-core.ts index 452563d328c..728a8d66449 100644 --- a/src/plugin-sdk/memory-core.ts +++ b/src/plugin-sdk/memory-core.ts @@ -6,6 +6,12 @@ export { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR, emptyPluginConfigSchema, jsonResult, + /** + * @deprecated Use getRuntimeConfig(), runtime.config.current(), or pass the + * already loaded config through the call path. Runtime code must not reload + * config on demand. Bundled plugins and repo code are blocked from using + * this by the deprecated-internal-config-api architecture guard. + */ loadConfig, parseAgentSessionKey, parseNonNegativeByteSize, diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index e90d359b143..3de5e77608d 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -30,6 +30,7 @@ vi.mock("../config/plugin-auto-enable.js", () => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: (...args: unknown[]) => mocks.loadConfig(...args), loadConfig: (...args: unknown[]) => mocks.loadConfig(...args), readConfigFileSnapshot: (...args: unknown[]) => mocks.readConfigFileSnapshot(...args), })); diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index ca75f7cd58f..27ea2595921 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { loadConfig, readConfigFileSnapshot } from "../config/config.js"; +import { getRuntimeConfig, readConfigFileSnapshot } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createPluginCliLogger, @@ -25,7 +25,7 @@ export const loadValidatedConfigForPluginRegistration = if (!snapshot.valid) { return null; } - return loadConfig(); + return getRuntimeConfig(); }; export async function getPluginCliCommandDescriptors( diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index 7f4195527fc..c6f7ddf8c46 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -54,19 +54,19 @@ const BUNDLED_LIVE_CONFIG_HOOK_GUARDS = { "extensions/diffs/src/plugin.ts": [ "resolveLivePluginConfigObject(", '"diffs"', - "api.runtime.config?.loadConfig?.() ?? api.config", + "api.runtime.config?.current?.() ?? api.config", ], "extensions/memory-core/src/dreaming.ts": [ 'params.reason === "runtime"', "resolveMemoryCorePluginConfig(startupCfg)", - "api.runtime.config?.loadConfig?.() ?? api.config", + "api.runtime.config?.current?.() ?? api.config", ], "extensions/memory-lancedb/index.ts": ["resolveLivePluginConfigObject(", '"memory-lancedb"'], "extensions/skill-workshop/index.ts": ["resolveLivePluginConfigObject(", '"skill-workshop"'], "extensions/thread-ownership/index.ts": [ "resolveLivePluginConfigObject(", '"thread-ownership"', - "api.runtime.config?.loadConfig?.() ?? api.config", + "api.runtime.config?.current?.() ?? api.config", ], } as const satisfies Record; const BUNDLED_LIVE_CONFIG_PROVIDER_GUARDS = { diff --git a/src/plugins/contracts/deprecated-internal-config-api.test.ts b/src/plugins/contracts/deprecated-internal-config-api.test.ts new file mode 100644 index 00000000000..574f82b2096 --- /dev/null +++ b/src/plugins/contracts/deprecated-internal-config-api.test.ts @@ -0,0 +1,355 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const REPO_ROOT = resolve(SRC_ROOT, ".."); +const EXTENSIONS_ROOT = resolve(REPO_ROOT, "extensions"); +const REPO_CODE_ROOTS = ["src", "extensions", "packages", "test", "scripts"].map((entry) => + resolve(REPO_ROOT, entry), +); +const GATEWAY_SERVER_METHODS_ROOT = resolve(SRC_ROOT, "gateway/server-methods"); +const AMBIENT_RUNTIME_CONFIG_ROOTS = [ + "src/gateway", + "src/auto-reply", + "src/agents", + "src/infra", + "src/mcp", + "src/plugins/runtime", + "src/config/sessions", +].map((entry) => resolve(REPO_ROOT, entry)); + +const COMPAT_CONFIG_API_FILES = new Set([ + "src/config/config.ts", + "src/config/io.ts", + "src/config/mutate.ts", + "src/memory-host-sdk/runtime-core.ts", + "src/plugin-sdk/browser-config-runtime.ts", + "src/plugin-sdk/config-runtime.ts", + "src/plugin-sdk/memory-core.ts", + "src/plugin-sdk/memory-core-host-runtime-core.ts", + "src/plugins/contracts/deprecated-internal-config-api.test.ts", + "src/plugins/runtime/runtime-config.test.ts", + "src/plugins/runtime/runtime-config.ts", + "src/plugins/runtime/types-core.ts", +]); +const AMBIENT_RUNTIME_LOAD_CONFIG_COMPAT_FILES = new Set([ + "src/plugins/runtime/load-context.ts", + "src/plugins/runtime/runtime-config.ts", + "src/plugins/runtime/runtime-plugin-boundary.ts", +]); +const PROCESS_BOUNDARY_DIRECT_CONFIG_LOAD_FILES = new Set([ + "src/cli/banner-config-lite.ts", + "src/cli/daemon-cli/status.gather.ts", +]); + +function collectTypeScriptFiles(dir: string): string[] { + const entries = readdirSync(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const fullPath = resolve(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "dist" || entry.name === "node_modules") { + continue; + } + files.push(...collectTypeScriptFiles(fullPath)); + continue; + } + if (entry.isFile() && entry.name.endsWith(".ts")) { + files.push(fullPath); + } + } + return files; +} + +function repoRelative(filePath: string): string { + return relative(REPO_ROOT, filePath).split(sep).join("/"); +} + +function isProductionExtensionFile(relPath: string): boolean { + if ( + relPath.includes("/test-support/") || + relPath.includes(".test.") || + relPath.includes(".live.test.") || + relPath.includes(".test-d.") || + relPath.includes(".test-harness.") || + relPath.includes(".test-shared.") || + relPath.endsWith("-test-helpers.ts") || + relPath.endsWith("-test-support.ts") + ) { + return false; + } + return true; +} + +function isTestOrHarnessFile(relPath: string): boolean { + return ( + relPath.includes("test-support") || + relPath.includes("/test-support/") || + relPath.includes("/test-helpers/") || + relPath.includes(".test.") || + relPath.includes(".live.test.") || + relPath.includes(".test-d.") || + relPath.includes(".test-harness.") || + relPath.includes(".test-shared.") || + relPath.endsWith(".test-helpers.ts") || + relPath.endsWith(".test-support.ts") || + relPath.endsWith("-test-helpers.ts") || + relPath.endsWith("-test-support.ts") + ); +} + +function isCompatConfigApiFile(relPath: string): boolean { + return COMPAT_CONFIG_API_FILES.has(relPath); +} + +function isAmbientRuntimeConfigCompatFile(relPath: string): boolean { + return AMBIENT_RUNTIME_LOAD_CONFIG_COMPAT_FILES.has(relPath); +} + +function findLineNumbers(source: string, pattern: RegExp): number[] { + const lines = source.split(/\r?\n/); + return lines.flatMap((line, index) => (pattern.test(line) ? [index + 1] : [])); +} + +function findMatchLineNumbers(source: string, pattern: RegExp): number[] { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + const regex = new RegExp(pattern.source, flags); + const lines: number[] = []; + for (let match = regex.exec(source); match; match = regex.exec(source)) { + lines.push(source.slice(0, match.index).split(/\r?\n/).length); + } + return lines; +} + +function findNonCommentLineNumbers(source: string, pattern: RegExp): number[] { + return source.split(/\r?\n/).flatMap((line, index) => { + const trimmed = line.trimStart(); + if (trimmed.startsWith("//") || trimmed.startsWith("*")) { + return []; + } + return pattern.test(line) ? [index + 1] : []; + }); +} + +describe("deprecated internal config API guardrails", () => { + it("keeps bundled plugin production code off direct runtime config load/write APIs", () => { + const violations: string[] = []; + const files = collectTypeScriptFiles(EXTENSIONS_ROOT) + .map((filePath) => ({ filePath, relPath: repoRelative(filePath) })) + .filter(({ relPath }) => isProductionExtensionFile(relPath)); + + for (const { filePath, relPath } of files) { + const source = readFileSync(filePath, "utf8"); + const guards = [ + { + pattern: + /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.loadConfig\b/, + replacement: "use runtime.config.current() or pass the already loaded config", + }, + { + pattern: + /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.writeConfigFile\b/, + replacement: + "use runtime.config.mutateConfigFile(...) or replaceConfigFile(...) with afterWrite", + }, + { + pattern: + /\b(?:import|export)\s+(?:type\s+)?\{[^}]*\bloadConfig\b[^}]*\}\s+from\s+["']openclaw\/plugin-sdk\/(?:browser-config-runtime|config-runtime|memory-core-host-runtime-core)["']/, + replacement: + "use getRuntimeConfig(), runtime.config.current(), or pass the already loaded config", + }, + { + pattern: /(? { + const violations: string[] = []; + const files = REPO_CODE_ROOTS.flatMap(collectTypeScriptFiles) + .map((filePath) => ({ filePath, relPath: repoRelative(filePath) })) + .filter(({ relPath }) => !isCompatConfigApiFile(relPath)); + + const guards = [ + { + pattern: + /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.loadConfig\b/, + replacement: "use runtime.config.current() or pass the already loaded config", + }, + { + pattern: + /(?:api\.runtime\.config|core\.config|runtime\.config|get[A-Za-z0-9]+Runtime\(\)\.config|rt\.config|configApi)\??\.writeConfigFile\b/, + replacement: + "use runtime.config.mutateConfigFile(...) or replaceConfigFile(...) with afterWrite", + }, + { + pattern: + /\b(?:import|export)\s+(?:type\s+)?\{[\s\S]*?\b(?:loadConfig|writeConfigFile)\b[\s\S]*?\}\s+from\s+["']openclaw\/plugin-sdk\/(?:browser-config-runtime|config-runtime|memory-core-host-runtime-core|memory-core)["']/, + replacement: + "use getRuntimeConfig(), runtime.config.current(), or mutation helpers with afterWrite", + }, + { + pattern: + /ReturnType/, + replacement: "use OpenClawConfig or the explicit mutation helper type", + }, + ]; + + for (const { filePath, relPath } of files) { + const source = readFileSync(filePath, "utf8"); + for (const guard of guards) { + for (const line of findMatchLineNumbers(source, guard.pattern)) { + violations.push(`${relPath}:${line} ${guard.replacement}`); + } + } + } + + expect(violations).toEqual([]); + }); + + it("keeps production config writes on mutation helpers", () => { + const violations: string[] = []; + const files = REPO_CODE_ROOTS.flatMap(collectTypeScriptFiles) + .map((filePath) => ({ filePath, relPath: repoRelative(filePath) })) + .filter( + ({ relPath }) => + !isTestOrHarnessFile(relPath) && + !isCompatConfigApiFile(relPath) && + !relPath.startsWith("test/"), + ); + + const importPattern = + /\bimport\s+\{[\s\S]*?\bwriteConfigFile\b[\s\S]*?\}\s+from\s+["'][^"']*(?:config\/config|config\/io)\.js["']/; + const dynamicImportPattern = + /\bconst\s+\{[\s\S]*?\bwriteConfigFile\b[\s\S]*?\}\s*=\s*await\s+import\(["'][^"']*(?:config\/config|config\/io)\.js["']\)/; + const directMethodPattern = /\.\s*writeConfigFile\s*\(/; + + for (const { filePath, relPath } of files) { + const source = readFileSync(filePath, "utf8"); + for (const pattern of [importPattern, dynamicImportPattern]) { + for (const line of findMatchLineNumbers(source, pattern)) { + violations.push( + `${relPath}:${line} use replaceConfigFile(...) or mutateConfigFile(...) with afterWrite`, + ); + } + } + for (const line of findNonCommentLineNumbers(source, directMethodPattern)) { + violations.push( + `${relPath}:${line} use replaceConfigFile(...) or mutateConfigFile(...) with afterWrite`, + ); + } + } + + expect(violations).toEqual([]); + }); + + it("keeps production code off direct config loads outside explicit process boundaries", () => { + const violations: string[] = []; + const files = REPO_CODE_ROOTS.flatMap(collectTypeScriptFiles) + .map((filePath) => ({ filePath, relPath: repoRelative(filePath) })) + .filter( + ({ relPath }) => + !isTestOrHarnessFile(relPath) && + !isCompatConfigApiFile(relPath) && + !PROCESS_BOUNDARY_DIRECT_CONFIG_LOAD_FILES.has(relPath) && + !relPath.startsWith("test/"), + ); + + const directCallPattern = /(? { + const violations: string[] = []; + const files = collectTypeScriptFiles(GATEWAY_SERVER_METHODS_ROOT) + .map((filePath) => ({ filePath, relPath: repoRelative(filePath) })) + .filter(({ relPath }) => !isTestOrHarnessFile(relPath)); + + const guards = [ + { + pattern: + /\bimport\s+\{[\s\S]*?\bloadConfig\b[\s\S]*?\}\s+from\s+["'][^"']*(?:config\/config|config\/io)\.js["']/, + replacement: "use context.getRuntimeConfig() in gateway request handlers", + }, + { + pattern: /(? { + const violations: string[] = []; + const files = AMBIENT_RUNTIME_CONFIG_ROOTS.flatMap(collectTypeScriptFiles) + .map((filePath) => ({ filePath, relPath: repoRelative(filePath) })) + .filter( + ({ relPath }) => + !isTestOrHarnessFile(relPath) && + !isCompatConfigApiFile(relPath) && + !isAmbientRuntimeConfigCompatFile(relPath), + ); + + for (const { filePath, relPath } of files) { + const source = readFileSync(filePath, "utf8"); + const loadConfigLines = findNonCommentLineNumbers(source, /(? ({ + getRuntimeConfig: loadConfigMock, loadConfig: loadConfigMock, })); diff --git a/src/plugins/runtime/load-context.ts b/src/plugins/runtime/load-context.ts index 5117de8b33f..0c970682b8f 100644 --- a/src/plugins/runtime/load-context.ts +++ b/src/plugins/runtime/load-context.ts @@ -1,5 +1,5 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createSubsystemLogger } from "../../logging.js"; @@ -45,7 +45,7 @@ export function resolvePluginRuntimeLoadContext( options?: PluginRuntimeLoadContextOptions, ): PluginRuntimeLoadContext { const env = options?.env ?? process.env; - const rawConfig = options?.config ?? loadConfig(); + const rawConfig = options?.config ?? getRuntimeConfig(); const activationSourceConfig = resolvePluginActivationSourceConfig({ config: rawConfig, activationSourceConfig: options?.activationSourceConfig, diff --git a/src/plugins/runtime/metadata-registry-loader.test.ts b/src/plugins/runtime/metadata-registry-loader.test.ts index 873d668a592..2f8c60e4bd9 100644 --- a/src/plugins/runtime/metadata-registry-loader.test.ts +++ b/src/plugins/runtime/metadata-registry-loader.test.ts @@ -7,6 +7,7 @@ const loadOpenClawPluginsMock = vi.fn(); let loadPluginMetadataRegistrySnapshot: typeof import("./metadata-registry-loader.js").loadPluginMetadataRegistrySnapshot; vi.mock("../../config/config.js", () => ({ + getRuntimeConfig: () => loadConfigMock(), loadConfig: () => loadConfigMock(), })); diff --git a/src/plugins/runtime/runtime-config.test.ts b/src/plugins/runtime/runtime-config.test.ts new file mode 100644 index 00000000000..afc7d9bd203 --- /dev/null +++ b/src/plugins/runtime/runtime-config.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; + +const getRuntimeConfigMock = vi.fn(); +const mutateConfigFileMock = vi.fn(); +const replaceConfigFileMock = vi.fn(); + +vi.mock("../../config/config.js", () => ({ + getRuntimeConfig: () => getRuntimeConfigMock(), +})); + +vi.mock("../../config/mutate.js", () => ({ + mutateConfigFile: (...args: unknown[]) => mutateConfigFileMock(...args), + replaceConfigFile: (...args: unknown[]) => replaceConfigFileMock(...args), +})); + +const { createRuntimeConfig } = await import("./runtime-config.js"); + +describe("createRuntimeConfig", () => { + beforeEach(() => { + getRuntimeConfigMock.mockReset(); + mutateConfigFileMock.mockReset(); + replaceConfigFileMock.mockReset(); + getRuntimeConfigMock.mockReturnValue({ plugins: {} }); + mutateConfigFileMock.mockResolvedValue({ previousHash: null, nextHash: "next" }); + replaceConfigFileMock.mockResolvedValue({ previousHash: null, nextHash: "next" }); + }); + + it("reads config from the runtime snapshot for current and deprecated loadConfig", () => { + const runtimeConfig = { plugins: { entries: {} } }; + getRuntimeConfigMock.mockReturnValue(runtimeConfig); + const configApi = createRuntimeConfig(); + + expect(configApi.current()).toBe(runtimeConfig); + expect(configApi.loadConfig()).toBe(runtimeConfig); + expect(getRuntimeConfigMock).toHaveBeenCalledTimes(2); + }); + + it("routes deprecated writeConfigFile through replaceConfigFile with afterWrite", async () => { + const configApi = createRuntimeConfig(); + const nextConfig = { plugins: { entries: {} } } as OpenClawConfig; + + await configApi.writeConfigFile(nextConfig); + + expect(replaceConfigFileMock).toHaveBeenCalledWith({ + nextConfig, + afterWrite: { mode: "auto" }, + writeOptions: undefined, + }); + }); + + it("preserves explicit afterWrite intent for deprecated writeConfigFile", async () => { + const configApi = createRuntimeConfig(); + const nextConfig = { plugins: { entries: {} } } as OpenClawConfig; + + await configApi.writeConfigFile(nextConfig, { + afterWrite: { mode: "none", reason: "test-controlled" }, + }); + + expect(replaceConfigFileMock).toHaveBeenCalledWith({ + nextConfig, + afterWrite: { mode: "none", reason: "test-controlled" }, + writeOptions: { afterWrite: { mode: "none", reason: "test-controlled" } }, + }); + }); +}); diff --git a/src/plugins/runtime/runtime-config.ts b/src/plugins/runtime/runtime-config.ts index c25646f830d..5fba1326297 100644 --- a/src/plugins/runtime/runtime-config.ts +++ b/src/plugins/runtime/runtime-config.ts @@ -1,9 +1,30 @@ -import { loadConfig, writeConfigFile } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; +import { + mutateConfigFile as mutateConfigFileInternal, + replaceConfigFile as replaceConfigFileInternal, +} from "../../config/mutate.js"; import type { PluginRuntime } from "./types.js"; export function createRuntimeConfig(): PluginRuntime["config"] { return { - loadConfig, - writeConfigFile, + current: getRuntimeConfig, + mutateConfigFile: async (params) => + await mutateConfigFileInternal({ + ...params, + writeOptions: params.writeOptions, + }), + replaceConfigFile: async (params) => + await replaceConfigFileInternal({ + ...params, + writeOptions: params.writeOptions, + }), + loadConfig: getRuntimeConfig, + writeConfigFile: async (cfg, options) => { + await replaceConfigFileInternal({ + nextConfig: cfg, + afterWrite: options?.afterWrite ?? { mode: "auto" }, + writeOptions: options, + }); + }, }; } diff --git a/src/plugins/runtime/runtime-plugin-boundary.ts b/src/plugins/runtime/runtime-plugin-boundary.ts index aa925ad1f89..56e0cbfcede 100644 --- a/src/plugins/runtime/runtime-plugin-boundary.ts +++ b/src/plugins/runtime/runtime-plugin-boundary.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { loadConfig } from "../../config/config.js"; +import { getRuntimeConfig } from "../../config/config.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "../jiti-loader-cache.js"; import { loadPluginManifestRegistry } from "../manifest-registry.js"; import { shouldPreferNativeJiti } from "../sdk-alias.js"; @@ -20,7 +20,7 @@ type CachedPluginBoundaryLoaderParams = { export function readPluginBoundaryConfigSafely() { try { - return loadConfig(); + return getRuntimeConfig(); } catch { return {}; } diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index e493e84b625..5e087414ead 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -16,6 +16,38 @@ type RuntimeWriteConfigOptions = { unsetPaths?: string[][]; }; +export type DeepReadonly = T extends (...args: never[]) => unknown + ? T + : T extends readonly (infer U)[] + ? ReadonlyArray> + : T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : T; + +type RuntimeConfigAfterWrite = import("../../config/config.js").ConfigWriteAfterWrite; +type RuntimeConfigReplaceResult = import("../../config/mutate.js").ConfigReplaceResult; +type RuntimeConfigMutationBase = import("../../config/mutate.js").ConfigMutationBase; +type RuntimeConfigMutationContext = { + snapshot: import("../../config/types.openclaw.js").ConfigFileSnapshot; + previousHash: string | null; +}; +type RuntimeMutateConfigFileParams = { + base?: RuntimeConfigMutationBase; + baseHash?: string; + afterWrite: RuntimeConfigAfterWrite; + writeOptions?: RuntimeWriteConfigOptions; + mutate: ( + draft: import("../../config/types.openclaw.js").OpenClawConfig, + context: RuntimeConfigMutationContext, + ) => Promise | T | void; +}; +type RuntimeReplaceConfigFileParams = { + nextConfig: import("../../config/types.openclaw.js").OpenClawConfig; + baseHash?: string; + afterWrite: RuntimeConfigAfterWrite; + writeOptions?: RuntimeWriteConfigOptions; +}; + /** Structured logger surface injected into runtime-backed plugin helpers. */ export type RuntimeLogger = { debug?: (message: string, meta?: Record) => void; @@ -36,10 +68,38 @@ export type RunHeartbeatOnceOptions = { export type PluginRuntimeCore = { version: string; config: { + /** Current process runtime config snapshot. Prefer config passed into the active call path. */ + current: () => DeepReadonly; + /** + * Persist a focused config mutation. Callers must choose the post-write + * behavior explicitly so the gateway can hot-reload, restart, or defer. + */ + mutateConfigFile: ( + params: RuntimeMutateConfigFileParams, + ) => Promise; + /** + * Persist a full config replacement. Callers must choose the post-write + * behavior explicitly so the gateway can hot-reload, restart, or defer. + */ + replaceConfigFile: ( + params: RuntimeReplaceConfigFileParams, + ) => Promise; + /** + * @deprecated Use current(), or pass the already loaded config through the + * call path. Runtime code must not reload config on demand. Bundled + * plugins and repo code are blocked from using this by the + * deprecated-internal-config-api architecture guard. + */ loadConfig: () => import("../../config/types.openclaw.js").OpenClawConfig; + /** + * @deprecated Use mutateConfigFile() or replaceConfigFile() with an + * explicit afterWrite intent so restart behavior stays under host control. + * Bundled plugins and repo code are blocked from using this by the + * deprecated-internal-config-api architecture guard. + */ writeConfigFile: ( cfg: import("../../config/types.openclaw.js").OpenClawConfig, - options?: RuntimeWriteConfigOptions, + options?: RuntimeWriteConfigOptions & { afterWrite?: RuntimeConfigAfterWrite }, ) => Promise; }; agent: { diff --git a/src/plugins/status.test.ts b/src/plugins/status.test.ts index 1f825fffa04..989c7d904d7 100644 --- a/src/plugins/status.test.ts +++ b/src/plugins/status.test.ts @@ -28,6 +28,7 @@ let formatPluginCompatibilityNotice: typeof import("./status.js").formatPluginCo let summarizePluginCompatibility: typeof import("./status.js").summarizePluginCompatibility; vi.mock("../config/config.js", () => ({ + getRuntimeConfig: () => loadConfigMock(), loadConfig: () => loadConfigMock(), })); diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 74214837364..02a1067d68c 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -1,5 +1,5 @@ import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOpenClawVersionBase } from "../config/version.js"; import { listImportedBundledPluginFacadeIds } from "../plugin-sdk/facade-runtime.js"; @@ -210,7 +210,7 @@ function buildPluginRecordFromInstalledIndex( export function buildPluginRegistrySnapshotReport( params?: PluginReportParams, ): PluginRegistryStatusReport { - const config = params?.config ?? loadConfig(); + const config = params?.config ?? getRuntimeConfig(); const result = loadPluginRegistrySnapshotWithMetadata({ config, env: params?.env, @@ -241,7 +241,7 @@ function buildPluginReport( loadModules: boolean, ): PluginStatusReport { const baseContext = resolvePluginRuntimeLoadContext({ - config: params?.config ?? loadConfig(), + config: params?.config ?? getRuntimeConfig(), env: params?.env, logger: params?.logger, workspaceDir: params?.workspaceDir, @@ -345,7 +345,7 @@ export function buildPluginInspectReport(params: { logger?: PluginLogger; report?: PluginStatusReport; }): PluginInspectReport | null { - const rawConfig = params.config ?? loadConfig(); + const rawConfig = params.config ?? getRuntimeConfig(); const config = resolvePluginRuntimeLoadContext({ config: rawConfig, env: params.env, @@ -475,7 +475,7 @@ export function buildAllPluginInspectReports(params?: { logger?: PluginLogger; report?: PluginStatusReport; }): PluginInspectReport[] { - const rawConfig = params?.config ?? loadConfig(); + const rawConfig = params?.config ?? getRuntimeConfig(); const report = params?.report ?? buildPluginDiagnosticsReport({ diff --git a/src/plugins/tool-types.ts b/src/plugins/tool-types.ts index d8d3df294a4..62119b00ffe 100644 --- a/src/plugins/tool-types.ts +++ b/src/plugins/tool-types.ts @@ -9,6 +9,8 @@ export type OpenClawPluginToolContext = { config?: OpenClawConfig; /** Active runtime-resolved config snapshot when one is available. */ runtimeConfig?: OpenClawConfig; + /** Returns the latest runtime-resolved config snapshot for long-lived tool definitions. */ + getRuntimeConfig?: () => OpenClawConfig | undefined; /** Effective filesystem policy for the active tool run. */ fsPolicy?: ToolFsPolicy; workspaceDir?: string; diff --git a/src/secrets/apply.ts b/src/secrets/apply.ts index 9fa3152a16b..29b28157f22 100644 --- a/src/secrets/apply.ts +++ b/src/secrets/apply.ts @@ -8,7 +8,12 @@ import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js"; import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; import { coercePersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js"; import { normalizeProviderId } from "../agents/model-selection.js"; -import { resolveStateDir, type OpenClawConfig } from "../config/config.js"; +import { + replaceConfigFile, + resolveStateDir, + type ConfigFileSnapshot, + type OpenClawConfig, +} from "../config/config.js"; import type { ConfigWriteOptions } from "../config/io.js"; import type { SecretProviderConfig } from "../config/types.secrets.js"; import { normalizeAgentId } from "../routing/session-key.js"; @@ -49,6 +54,7 @@ type ApplyWrite = { type ProjectedState = { nextConfig: OpenClawConfig; + configSnapshot: ConfigFileSnapshot; configPath: string; configWriteOptions: ConfigWriteOptions; authStoreByPath: Map>; @@ -270,6 +276,7 @@ async function projectPlanState(params: { return { nextConfig, + configSnapshot: snapshot, configPath, configWriteOptions: writeOptions, authStoreByPath, @@ -823,7 +830,13 @@ export async function runSecretsApply(params: { } try { - await io.writeConfigFile(projected.nextConfig, projected.configWriteOptions); + await replaceConfigFile({ + nextConfig: projected.nextConfig, + snapshot: projected.configSnapshot, + writeOptions: projected.configWriteOptions, + io, + afterWrite: { mode: "auto" }, + }); for (const write of writes) { writeTextFileAtomic(write.path, write.content, write.mode); } diff --git a/src/secrets/runtime-core-snapshots.test.ts b/src/secrets/runtime-core-snapshots.test.ts index dd3f7ede5a7..e9825908ecd 100644 --- a/src/secrets/runtime-core-snapshots.test.ts +++ b/src/secrets/runtime-core-snapshots.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; -import { clearConfigCache, clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js"; +import { + getRuntimeConfig, + clearConfigCache, + clearRuntimeConfigSnapshot, +} from "../config/config.js"; import { createEmptyPluginRegistry } from "../plugins/registry.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { captureEnv, withEnvAsync } from "../test-utils/env.js"; @@ -277,7 +281,7 @@ describe("secrets runtime snapshot core lanes", () => { const prepared = await prepareOpenAiRuntimeSnapshot({ includeAuthStoreRefs: false }); activateSecretsRuntimeSnapshot(prepared); - expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-runtime"); + expect(getRuntimeConfig().models?.providers?.openai?.apiKey).toBe("sk-runtime"); }); it("activates runtime snapshots for ensureAuthProfileStore", async () => { diff --git a/src/secrets/runtime-openai-file-fixture.test-helper.ts b/src/secrets/runtime-openai-file-fixture.test-helper.ts index a696dd89b71..431a6a5c01c 100644 --- a/src/secrets/runtime-openai-file-fixture.test-helper.ts +++ b/src/secrets/runtime-openai-file-fixture.test-helper.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { expect } from "vitest"; import type { AuthProfileStore } from "../agents/auth-profiles.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; import type { captureEnv } from "../test-utils/env.js"; @@ -102,7 +102,7 @@ export function createOpenAIFileRuntimeConfig(secretFile: string): OpenClawConfi } export function expectResolvedOpenAIRuntime(agentDir: string) { - expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime"); + expect(getRuntimeConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime"); const activeAuthStore = getActiveSecretsRuntimeSnapshot()?.authStores.find( (entry) => entry.agentDir === agentDir, )?.store; diff --git a/src/secrets/runtime.gateway-auth.integration.test.ts b/src/secrets/runtime.gateway-auth.integration.test.ts index 13366511c98..7151dc9f54b 100644 --- a/src/secrets/runtime.gateway-auth.integration.test.ts +++ b/src/secrets/runtime.gateway-auth.integration.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { loadConfig, writeConfigFile } from "../config/config.js"; +import { getRuntimeConfig, writeConfigFile } from "../config/config.js"; import { withTempHome } from "../config/home-env.test-harness.js"; import { withEnvAsync } from "../test-utils/env.js"; import { @@ -98,11 +98,11 @@ describe("secrets runtime snapshot gateway-auth integration", () => { }); activateSecretsRuntimeSnapshot(prepared); - expect(loadConfig().gateway?.auth?.token).toBe("gateway-runtime-token"); + expect(getRuntimeConfig().gateway?.auth?.token).toBe("gateway-runtime-token"); await expect( writeConfigFile({ - ...loadConfig(), + ...getRuntimeConfig(), gateway: { auth: { mode: "token", @@ -114,7 +114,7 @@ describe("secrets runtime snapshot gateway-auth integration", () => { const activeAfterFailure = getActiveSecretsRuntimeSnapshot(); expect(activeAfterFailure).not.toBeNull(); - expect(loadConfig().gateway?.auth?.token).toBe("gateway-runtime-token"); + expect(getRuntimeConfig().gateway?.auth?.token).toBe("gateway-runtime-token"); expect(activeAfterFailure?.sourceConfig.gateway?.auth?.token).toEqual(initialTokenRef); const persistedConfig = JSON.parse( diff --git a/src/security/fix.ts b/src/security/fix.ts index 21c46b0323b..40ef1a73813 100644 --- a/src/security/fix.ts +++ b/src/security/fix.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; -import { createConfigIO } from "../config/config.js"; +import { createConfigIO, replaceConfigFile } from "../config/config.js"; import { collectIncludePathsRecursive } from "../config/includes-scan.js"; import { resolveConfigPath, resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -404,7 +404,7 @@ export async function fixSecurityFootguns(opts?: { const errors: string[] = []; const io = createConfigIO({ env, configPath }); - const snap = await io.readConfigFileSnapshot(); + const { snapshot: snap, writeOptions } = await io.readConfigFileSnapshotForWrite(); if (!snap.valid) { errors.push(...snap.issues.map((i) => `${i.path}: ${i.message}`)); } @@ -421,10 +421,16 @@ export async function fixSecurityFootguns(opts?: { if (changes.length > 0) { try { - await io.writeConfigFile(fixed.cfg); + await replaceConfigFile({ + nextConfig: fixed.cfg, + snapshot: snap, + writeOptions, + io, + afterWrite: { mode: "auto" }, + }); configWritten = true; } catch (err) { - errors.push(`writeConfigFile failed: ${String(err)}`); + errors.push(`replaceConfigFile failed: ${String(err)}`); } } } diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index cb3eaca91fb..cb2b01a71ca 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -44,6 +44,7 @@ vi.mock("../agents/model-selection.js", () => ({ })); vi.mock("../config/config.js", () => ({ + getRuntimeConfig: () => ({}), loadConfig: () => ({}), })); diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index 85a359fc403..6da7e67df96 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -4,7 +4,7 @@ import { resolveSessionAgentId } from "../agents/agent-scope.js"; import { DEFAULT_PROVIDER } from "../agents/defaults.js"; import { buildAllowedModelSet, resolveThinkingDefault } from "../agents/model-selection.js"; import { createDefaultDeps } from "../cli/deps.js"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { updateSessionStore } from "../config/sessions.js"; import { projectRecentChatDisplayMessages, @@ -243,7 +243,7 @@ export class EmbeddedTuiBackend implements TuiBackend { } async listSessions(opts?: Parameters[0]): Promise { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const { storePath, store } = loadCombinedSessionStoreForGateway(cfg); return listSessionsFromStore({ cfg, @@ -254,13 +254,13 @@ export class EmbeddedTuiBackend implements TuiBackend { } async listAgents(): Promise { - return listAgentsForGateway(loadConfig()) as TuiAgentsList; + return listAgentsForGateway(getRuntimeConfig()) as TuiAgentsList; } async patchSession( opts: Parameters[0], ): Promise { - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const target = resolveGatewaySessionStoreTarget({ cfg, key: opts.key }); const applied = await updateSessionStore(target.storePath, async (store) => { const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({ @@ -315,7 +315,7 @@ export class EmbeddedTuiBackend implements TuiBackend { async listModels(): Promise { const catalog = await loadGatewayModelCatalog(); - const cfg = loadConfig(); + const cfg = getRuntimeConfig(); const { allowedCatalog } = buildAllowedModelSet({ cfg, catalog, diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index e6c12c426b0..d6a0975b9f9 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -13,6 +13,7 @@ import { captureEnv, withEnvAsync } from "../test-utils/env.js"; vi.mock("../config/config.js", async () => { const mocks = await import("../gateway/gateway-connection.test-mocks.js"); return { + getRuntimeConfig: mocks.loadConfigMock, loadConfig: mocks.loadConfigMock, resolveConfigPath: mocks.resolveConfigPathMock, resolveGatewayPort: mocks.resolveGatewayPortMock, diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 61ea951824d..7cefc298ef1 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { loadConfig } from "../config/config.js"; +import { getRuntimeConfig } from "../config/config.js"; import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; import { resolveGatewayInteractiveSurfaceAuth } from "../gateway/auth-surface-resolution.js"; import { @@ -249,7 +249,7 @@ export class GatewayChatClient implements TuiBackend { export async function resolveGatewayConnection( opts: GatewayConnectionOptions, ): Promise { - const config = loadConfig(); + const config = getRuntimeConfig(); const env = process.env; const gatewayAuthMode = config.gateway?.auth?.mode; const isRemoteMode = config.gateway?.mode === "remote"; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index ab5fc65a53b..a36a78bcd45 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -13,7 +13,7 @@ import { TUI, } from "@mariozechner/pi-tui"; import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { loadConfig, type OpenClawConfig } from "../config/config.js"; +import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; import { setConsoleSubsystemFilter } from "../logging/console.js"; import { loggingState } from "../logging/state.js"; import { @@ -292,7 +292,7 @@ export function resolveCtrlCAction(params: { export async function runTui(opts: RunTuiOptions): Promise { const isLocalMode = opts.local === true || opts.backend !== undefined; - const config = opts.config ?? loadConfig(); + const config = opts.config ?? getRuntimeConfig(); const initialSessionInput = (opts.session ?? "").trim(); let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope; let sessionMainKey = normalizeMainKey(config.session?.mainKey); diff --git a/src/types/modelcontextprotocol-sdk-subpaths.d.ts b/src/types/modelcontextprotocol-sdk-subpaths.d.ts new file mode 100644 index 00000000000..db3c0d6f812 --- /dev/null +++ b/src/types/modelcontextprotocol-sdk-subpaths.d.ts @@ -0,0 +1,20 @@ +declare module "@modelcontextprotocol/sdk/server/streamableHttp.js" { + import type { IncomingMessage, ServerResponse } from "node:http"; + + export type StreamableHTTPServerTransportOptions = { + sessionIdGenerator?: (() => string) | undefined; + }; + + export class StreamableHTTPServerTransport { + constructor(options?: StreamableHTTPServerTransportOptions); + get sessionId(): string | undefined; + start(): Promise; + close(): Promise; + send(message: unknown, options?: { relatedRequestId?: string | number }): Promise; + handleRequest( + req: IncomingMessage & { auth?: unknown }, + res: ServerResponse, + parsedBody?: unknown, + ): Promise; + } +} diff --git a/src/wizard/setup.migration-import.ts b/src/wizard/setup.migration-import.ts index 150cce46da3..f0ea06730ba 100644 --- a/src/wizard/setup.migration-import.ts +++ b/src/wizard/setup.migration-import.ts @@ -198,7 +198,7 @@ export async function runSetupMigrationImport(params: { detections: readonly SetupMigrationDetection[]; prompter: WizardPrompter; runtime: RuntimeEnv; - writeConfigFile: (config: OpenClawConfig) => Promise; + commitConfigFile: (config: OpenClawConfig) => Promise; }): Promise { const [ { applyLocalSetupWorkspaceConfig, applySkipBootstrapConfig }, @@ -285,7 +285,7 @@ export async function runSetupMigrationImport(params: { command: "onboard", mode: "local", }); - targetConfig = await params.writeConfigFile(targetConfig); + targetConfig = await params.commitConfigFile(targetConfig); const applyCtx = { ...ctx, config: targetConfig, diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 65b9c58c26f..0f0096d9b33 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -106,7 +106,7 @@ function providerPluginStub( } const healthCommand = vi.hoisted(() => vi.fn(async () => {})); const ensureWorkspaceAndSessions = vi.hoisted(() => vi.fn(async () => {})); -const writeConfigFile = vi.hoisted(() => vi.fn(async () => {})); +const replaceConfigFile = vi.hoisted(() => vi.fn(async () => ({ config: {} }))); const resolveGatewayPort = vi.hoisted(() => vi.fn((_cfg?: unknown, env?: NodeJS.ProcessEnv) => { const raw = env?.OPENCLAW_GATEWAY_PORT ?? process.env.OPENCLAW_GATEWAY_PORT; @@ -210,7 +210,7 @@ vi.mock("../config/config.js", () => ({ DEFAULT_GATEWAY_PORT: 18789, createConfigIO, resolveGatewayPort, - writeConfigFile, + replaceConfigFile, })); vi.mock("../commands/onboard-helpers.js", () => ({ @@ -458,7 +458,7 @@ describe("runSetupWizard", () => { it("persists skipBootstrap and skips workspace bootstrap creation when requested", async () => { ensureWorkspaceAndSessions.mockClear(); - writeConfigFile.mockClear(); + replaceConfigFile.mockClear(); const workspaceDir = await makeCaseDir("skip-bootstrap-"); const prompter = buildWizardPrompter({}); @@ -482,12 +482,14 @@ describe("runSetupWizard", () => { prompter, ); - expect(writeConfigFile).toHaveBeenCalledWith( + expect(replaceConfigFile).toHaveBeenCalledWith( expect.objectContaining({ - agents: expect.objectContaining({ - defaults: expect.objectContaining({ - skipBootstrap: true, - workspace: workspaceDir, + nextConfig: expect.objectContaining({ + agents: expect.objectContaining({ + defaults: expect.objectContaining({ + skipBootstrap: true, + workspace: workspaceDir, + }), }), }), }), diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index eb8288e500d..7061ef89723 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -8,7 +8,7 @@ import type { OnboardOptions, ResetScope, } from "../commands/onboard-types.js"; -import { createConfigIO, resolveGatewayPort, writeConfigFile } from "../config/config.js"; +import { createConfigIO, replaceConfigFile, resolveGatewayPort } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -57,10 +57,13 @@ function loadModelPickerModule(): Promise { async function writeWizardConfigFile(config: OpenClawConfig): Promise { const committed = await commitConfigWriteWithPendingPluginInstalls({ nextConfig: config, - commit: async (nextConfig, writeOptions) => - writeOptions - ? await writeConfigFile(nextConfig, writeOptions) - : await writeConfigFile(nextConfig), + commit: async (nextConfig, writeOptions) => { + await replaceConfigFile({ + nextConfig, + ...(writeOptions ? { writeOptions } : {}), + afterWrite: { mode: "auto" }, + }); + }, }); return committed.config; } @@ -323,7 +326,7 @@ export async function runSetupWizard( detections: migrationDetections, prompter, runtime, - writeConfigFile: writeWizardConfigFile, + commitConfigFile: writeWizardConfigFile, }); return; } diff --git a/test/gateway.multi.e2e.test.ts b/test/gateway.multi.e2e.test.ts index a2bc48a41d1..01e8eea84ec 100644 --- a/test/gateway.multi.e2e.test.ts +++ b/test/gateway.multi.e2e.test.ts @@ -105,7 +105,7 @@ describe("gateway multi-instance e2e", () => { const idempotencyKey = `idem-${randomUUID()}`; const sendRes = await chatClient.request("chat.send", { sessionKey, - message: "/context list", + message: "/whoami", idempotencyKey, }); expect(sendRes.status).toBe("started"); diff --git a/test/helpers/plugins/plugin-runtime-mock.ts b/test/helpers/plugins/plugin-runtime-mock.ts index aea9d3ac08a..ba0607e8330 100644 --- a/test/helpers/plugins/plugin-runtime-mock.ts +++ b/test/helpers/plugins/plugin-runtime-mock.ts @@ -75,6 +75,24 @@ export function createPluginRuntimeMock(overrides: DeepPartial = const base: PluginRuntime = { version: "1.0.0-test", config: { + current: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["current"], + mutateConfigFile: vi.fn(async () => ({ + path: "/tmp/openclaw.json", + previousHash: null, + snapshot: {} as never, + nextConfig: {}, + afterWrite: { mode: "auto" }, + followUp: { mode: "auto", requiresRestart: false }, + result: undefined, + })) as unknown as PluginRuntime["config"]["mutateConfigFile"], + replaceConfigFile: vi.fn(async ({ nextConfig }) => ({ + path: "/tmp/openclaw.json", + previousHash: null, + snapshot: {} as never, + nextConfig, + afterWrite: { mode: "auto" }, + followUp: { mode: "auto", requiresRestart: false }, + })) as unknown as PluginRuntime["config"]["replaceConfigFile"], loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"], writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"], },